Files
rclone_gui/webgui/web/assets/js/state.js
T
ci eb32fd5e22 init: 初始化 rclone-webgui 项目结构
外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。

- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
  - web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
  - webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
  - rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
    rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
  COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
  (/config/、/operations/、/sync/、/job/ 等) 与文件下载
  (/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
  --rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
  持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
  Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
2026-06-19 12:42:35 +08:00

217 lines
5.1 KiB
JavaScript

// state.js — minimal pub/sub store + hash router + localStorage-backed
// job metadata cache. Vanilla, no framework.
const JOB_META_KEY = "webgui:jobMeta";
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const listeners = new Set();
const state = {
remotes: [], // [{ name, type }]
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
providers: null, // cached /config/providers response
// jobMeta is persisted to localStorage so the jobs view can show
// src/dst for jobs after a page reload. Keyed by jobid (number).
jobMeta: loadJobMeta(),
};
function loadJobMeta() {
try {
const raw = localStorage.getItem(JOB_META_KEY);
if (!raw) return new Map();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Map();
const now = Date.now();
const map = new Map();
for (const [id, meta] of arr) {
if (meta && typeof meta.submittedAt === "number") {
if (now - meta.submittedAt < JOB_META_TTL_MS) {
map.set(Number(id), meta);
}
}
}
return map;
} catch {
return new Map();
}
}
function saveJobMeta() {
try {
const arr = Array.from(state.jobMeta.entries());
localStorage.setItem(JOB_META_KEY, JSON.stringify(arr));
} catch {
// localStorage might be unavailable (private mode, quota); ignore.
}
}
export function getState() {
return state;
}
export function setState(patch) {
Object.assign(state, patch);
for (const l of listeners) {
try {
l(state);
} catch (e) {
console.error("state listener error", e);
}
}
}
export function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// --- Job metadata API ---
export function rememberJob(jobid, { action, src, dst }) {
state.jobMeta.set(Number(jobid), {
action,
src,
dst,
submittedAt: Date.now(),
});
saveJobMeta();
}
export function getJobMeta(jobid) {
return state.jobMeta.get(Number(jobid));
}
export function forgetJob(jobid) {
state.jobMeta.delete(Number(jobid));
saveJobMeta();
}
// --- Router ---
// Hash-based routes:
// #/remotes
// #/browse/<remote>/<path...>
// #/jobs
// #/jobs/new
// #/configure/new
// #/configure/new/<providerName>
// #/configure/edit/<remoteName>
const routeListeners = new Set();
export function onRoute(fn) {
routeListeners.add(fn);
return () => routeListeners.delete(fn);
}
function parseHash(hash) {
const raw = hash.replace(/^#\/?/, "");
const parts = raw.split("/").filter(Boolean);
if (parts.length === 0) {
return { name: "remotes", params: {} };
}
switch (parts[0]) {
case "remotes":
return { name: "remotes", params: {} };
case "browse":
return {
name: "browse",
params: {
remote: decodeURIComponent(parts[1] || ""),
path: parts.slice(2).map(decodeURIComponent).join("/"),
},
};
case "jobs":
if (parts[1] === "new") {
return { name: "jobs-new", params: {} };
}
return { name: "jobs", params: {} };
case "configure":
if (parts[1] === "new") {
return {
name: "configure-new",
params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" },
};
}
if (parts[1] === "edit") {
return {
name: "configure-edit",
params: { remote: decodeURIComponent(parts[2] || "") },
};
}
return { name: "configure-new", params: { provider: "" } };
default:
return { name: "remotes", params: {} };
}
}
export function navigate(hash) {
if (location.hash !== hash) {
location.hash = hash;
} else {
dispatch();
}
}
function dispatch() {
const route = parseHash(location.hash);
for (const l of routeListeners) {
try {
l(route);
} catch (e) {
console.error("route listener error", e);
}
}
}
window.addEventListener("hashchange", dispatch);
window.addEventListener("load", dispatch);
// --- Toast ---
export function toast(message, kind = "default", ttl = 4000) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
const el = document.createElement("div");
el.className = `toast toast-${kind}`;
el.textContent = message;
stack.appendChild(el);
setTimeout(() => el.remove(), ttl);
}
// --- Format helpers ---
export function formatBytes(n) {
if (n == null || isNaN(n)) return "—";
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
export function formatSpeed(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s";
}
export function formatDuration(seconds) {
if (seconds == null || !isFinite(seconds)) return "—";
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function formatTime(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}