From a870f748756a851508fcd59c4c700ef8d76c0ac0 Mon Sep 17 00:00:00 2001 From: wangheng Date: Mon, 2 Nov 2026 18:20:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E7=8A=B6=E6=80=81=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=88=B0=E5=AE=BF=E4=B8=BB=E4=BE=A7=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=9A=8F=E6=9C=BA=E7=AB=AF=E5=8F=A3=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E4=B8=A2=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/dsh.plugin.json | 2 +- 01_content/lib/client.js | 163 +++++++++++++++++++---- 01_content/lib/index.js | 199 ++++++++++++++++++----------- 01_content/package-lock.json | 8 +- 01_content/package.json | 2 +- 01_content/src/client/hostState.ts | 92 +++++++++++++ 01_content/src/client/index.tsx | 80 ++++++++++-- 01_content/src/index.ts | 43 +++++++ PATCH.md | 47 +++++++ 9 files changed, 523 insertions(+), 113 deletions(-) create mode 100644 01_content/src/client/hostState.ts create mode 100644 PATCH.md diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index aeb3b16..57e40e4 100644 --- a/01_content/dsh.plugin.json +++ b/01_content/dsh.plugin.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目", - "version": "0.5.1", + "version": "0.5.2-local.1", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 9ff5fb9..3328f86 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -9160,7 +9160,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.5.1 \u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`; // src/client/updateCheck.ts -var LOCAL_VERSION = false ? "dev" : "0.5.1"; +var LOCAL_VERSION = false ? "dev" : "0.5.2-local.1"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; @@ -9263,14 +9263,14 @@ async function checkUpdate(force = false) { let d = null; for (let attempt = 0; attempt < 3 && !d; attempt++) { const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 8e3); + const timer2 = setTimeout(() => ctrl.abort(), 8e3); try { const r = await fetch("https://api.github.com/repos/" + UPDATE_REPO + "/releases/latest", { headers: { Accept: "application/vnd.github+json" }, cache: "no-store", signal: ctrl.signal }); if (r.ok) d = await r.json(); else if (r.status === 403 || r.status === 404) break; } catch { } finally { - clearTimeout(timer); + clearTimeout(timer2); } } if (!d) { @@ -10702,11 +10702,11 @@ function map(array, callback) { return result; } function mapDomain(domain, callback) { - const parts = domain.split("@"); + const parts2 = domain.split("@"); let result = ""; - if (parts.length > 1) { - result = parts[0] + "@"; - domain = parts[1]; + if (parts2.length > 1) { + result = parts2[0] + "@"; + domain = parts2[1]; } domain = domain.replace(regexSeparators, "."); const labels = domain.split("."); @@ -17108,8 +17108,8 @@ function tabTitleOf(content) { } } function basenameOf2(p) { - const parts = String(p).replace(/[\\/]+$/, "").split(/[\\/]/); - return parts[parts.length - 1] || String(p); + const parts2 = String(p).replace(/[\\/]+$/, "").split(/[\\/]/); + return parts2[parts2.length - 1] || String(p); } function sameContent(a, b) { if (a.kind === "iframe" && b.kind === "iframe") return a.url === b.url; @@ -19318,8 +19318,8 @@ function GitPane() { function JobsPane() { const [, setTick] = (0, import_react2.useState)(0); (0, import_react2.useEffect)(() => { - const timer = window.setInterval(() => setTick((t) => t + 1), 2e3); - return () => window.clearInterval(timer); + const timer2 = window.setInterval(() => setTick((t) => t + 1), 2e3); + return () => window.clearInterval(timer2); }, []); const jobs = splitEnv?.getJobs?.() ?? []; const subagents = splitEnv?.getSubagents?.() ?? []; @@ -20733,6 +20733,69 @@ var DEFAULT_BG_SVG = ' { + timer = null; + void flush(); + }, 400); +} +async function flush() { + if (!hydrated || inflight) { + if (inflight) dirty = true; + return; + } + if (!parts.projects && !parts.view) return; + inflight = true; + try { + await fetch(ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: { ...parts, updatedAt: Date.now() } }), + keepalive: true + }); + } catch { + } finally { + inflight = false; + if (dirty) { + dirty = false; + schedule(); + } + } +} +function queueHostSave(patch) { + parts = { ...parts, ...patch }; + if (!hydrated) return; + schedule(); +} +function markHydrated() { + hydrated = true; +} +function flushHostSave() { + if (timer) { + clearTimeout(timer); + timer = null; + } + void flush(); +} + // src/client/index.tsx var import_jsx_runtime3 = require("react/jsx-runtime"); var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable"; @@ -20759,6 +20822,7 @@ async function copyText(text2) { } var PERSIST_KEY2 = "dsh.worktable.view.v1"; var PROJECTS_KEY = "dsh.worktable.projects.v1"; +var SAVED_AT_KEY = "dsh.worktable.savedAt.v1"; var MIN_TOP = 56; var SNAP_PX = 32; var LEGACY_BUMP_COOLDOWN = 15e3; @@ -20930,11 +20994,10 @@ function normHsl(v) { l: Math.min(Math.max(Math.round(v.l), 0), 200) }; } -function loadView() { +function normalizeView(raw) { try { - const raw = localStorage.getItem(PERSIST_KEY2); - if (!raw) return { ...DEFAULT_VIEW }; - const p = JSON.parse(raw); + if (!raw || typeof raw !== "object") return { ...DEFAULT_VIEW }; + const p = raw; const orderBy = p.sortMigratedV2 === true ? p.orderBy === "recent" ? "recent" : "manual" : "manual"; return { query: typeof p.query === "string" ? p.query : "", @@ -20972,11 +21035,19 @@ function loadView() { return { ...DEFAULT_VIEW }; } } -function loadProjects() { +function loadView() { try { - const raw = localStorage.getItem(PROJECTS_KEY); - if (!raw) return { ...DEFAULT_PROJECTS }; - const p = JSON.parse(raw); + const raw = localStorage.getItem(PERSIST_KEY2); + if (!raw) return { ...DEFAULT_VIEW }; + return normalizeView(JSON.parse(raw)); + } catch { + return { ...DEFAULT_VIEW }; + } +} +function normalizeProjects(raw) { + try { + if (!raw || typeof raw !== "object") return { ...DEFAULT_PROJECTS }; + const p = raw; return { order: Array.isArray(p.order) ? p.order.filter((x) => typeof x === "string") : [], lastUsed: p.lastUsed && typeof p.lastUsed === "object" ? p.lastUsed : {}, @@ -20994,6 +21065,21 @@ function loadProjects() { return { ...DEFAULT_PROJECTS }; } } +function loadProjects() { + try { + const raw = localStorage.getItem(PROJECTS_KEY); + if (!raw) return { ...DEFAULT_PROJECTS }; + return normalizeProjects(JSON.parse(raw)); + } catch { + return { ...DEFAULT_PROJECTS }; + } +} +function markSaved() { + try { + localStorage.setItem(SAVED_AT_KEY, String(Date.now())); + } catch { + } +} var clamp2 = (v, lo, hi) => Math.min(Math.max(v, lo), hi); function findSidebar(start) { let el = start; @@ -21632,14 +21718,14 @@ function WorktableSection(props) { let d = null; for (let attempt = 0; attempt < 3 && !d && updateAliveRef.current; attempt++) { const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 8e3); + const timer2 = setTimeout(() => ctrl.abort(), 8e3); try { const r = await fetch("https://api.github.com/repos/" + UPDATE_REPO2 + "/releases/latest", { headers: { Accept: "application/vnd.github+json" }, cache: "no-store", signal: ctrl.signal }); if (r.ok) d = await r.json(); else if (r.status === 403 || r.status === 404) break; } catch { } finally { - clearTimeout(timer); + clearTimeout(timer2); } } if (!updateAliveRef.current) return; @@ -21991,6 +22077,8 @@ function WorktableSection(props) { localStorage.setItem(PERSIST_KEY2, JSON.stringify({ ...next, sortMigratedV2: true })); } catch { } + markSaved(); + queueHostSave({ view: next }); return next; }); }; @@ -22001,9 +22089,38 @@ function WorktableSection(props) { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)); } catch { } + markSaved(); + queueHostSave({ projects: next }); return next; }); }; + (0, import_react3.useEffect)(() => { + let cancelled = false; + void (async () => { + const host = await loadHostState(); + if (cancelled) return; + let localAt = 0; + try { + localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? "0") || 0; + } catch { + } + const hostAt = typeof host?.updatedAt === "number" ? host.updatedAt : 0; + if (host && hostAt > localAt) { + if (host.projects) persistProjects(normalizeProjects(host.projects)); + if (host.view) persistView(normalizeView(host.view)); + } + markHydrated(); + if (hostAt <= localAt) flushHostSave(); + })(); + const flush2 = () => flushHostSave(); + window.addEventListener("pagehide", flush2); + window.addEventListener("beforeunload", flush2); + return () => { + cancelled = true; + window.removeEventListener("pagehide", flush2); + window.removeEventListener("beforeunload", flush2); + }; + }, []); (0, import_react3.useEffect)(() => { const saved = projects.views[CONSOLE_ID]; if (!specHasConsoleTab(saved)) { @@ -23020,10 +23137,10 @@ function WorktableSection(props) { if (!isFloat) measureBottomOverlay(); }, [isFloat, wide, measureBottomOverlay]); (0, import_react3.useEffect)(() => { - const timer = window.setInterval(() => { + const timer2 = window.setInterval(() => { if (!floatRef.current) measureBottomOverlay(); }, 2e3); - return () => window.clearInterval(timer); + return () => window.clearInterval(timer2); }, [measureBottomOverlay]); const dockedStyle = !isFloat && bottomInset > 0 ? { marginBottom: bottomInset } : void 0; const sectionTop = rootRef.current?.getBoundingClientRect().top ?? 100; diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 065d09c..d834593 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -118,84 +118,84 @@ function createPreviewFiles(now = () => Date.now()) { } // template/dshell.css -var dshell_default = `/* dsh-worktable \u539F\u751F\u76AE\u80A4 \xB7 DSH \u8BBE\u8BA1\u7CFB\u7EDF\u7EC4\u4EF6\u5E93 - \u7528\u6CD5\uFF1A - \u6240\u6709\u989C\u8272\u8D70 DSH \u4E3B\u9898\u53D8\u91CF\uFF08--dsw-alias-*\uFF09\uFF0C\u81EA\u52A8\u9002\u914D\u660E\u6697\u4E3B\u9898\u3002 */ -:root { color-scheme: dark; } -* { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } -body { - background: var(--dsw-alias-bg-base, #0b0e14); - color: var(--dsw-alias-label-primary, #e6e8eb); - font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; - font-size: 13px; - line-height: 1.6; -} -.dshell { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; min-height: 100%; } -/* \u6587\u5B57\u5C42\u7EA7 */ -.dshell-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e8eb); } -.dshell-sub { margin: 0; font-size: 12px; color: var(--dsw-alias-label-secondary, #9aa4b2); } -.dshell-muted { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11.5px; } -/* \u5361\u7247 */ -.dshell-card { border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 10px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); padding: 12px 14px; } -.dshell-card + .dshell-card { margin-top: 10px; } -/* \u6309\u94AE\uFF08\u7EFF\u8272\u4E3B\u6309\u94AE / \u5E7D\u7075\u6309\u94AE / \u5371\u9669\uFF09 */ -.dshell-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 999px; border: 1px solid transparent; background: #3fb950; color: #0b0e14; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; } -.dshell-btn:hover { filter: brightness(1.08); } -.dshell-btnGhost { background: transparent; border-color: var(--dsw-alias-border-l1, #262b36); color: var(--dsw-alias-label-secondary, #9aa4b2); } -.dshell-btnGhost:hover { color: var(--dsw-alias-label-primary, #e6e8eb); border-color: var(--dsw-alias-border-l2, #3a4150); } -.dshell-btnDanger { background: transparent; border-color: #f85149; color: #f85149; } -/* \u72B6\u6001\u5FBD\u6807\uFF08\u5706\u70B9 + \u6587\u5B57\uFF1B\u7EFF=\u5DF2\u5B8C\u6210 \u9EC4=\u5F85\u529E/\u5F85\u53D1\u5E03 \u7070=\u672A\u5F00\u59CB\uFF09 */ -.dshell-badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1, #262b36); font-size: 11.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); background: var(--dsw-alias-fill-l1, rgba(255,255,255,.03)); } -.dshell-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-label-tertiary, #6b7280); } -.dshell-badgeDone { color: #3fb950; border-color: rgba(63,185,80,.4); } -.dshell-badgeDone::before { background: #3fb950; box-shadow: 0 0 5px #3fb950; } -.dshell-badgeWait { color: #d29922; border-color: rgba(210,153,34,.4); } -.dshell-badgeWait::before { background: #d29922; box-shadow: 0 0 5px #d29922; } -.dshell-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--dsw-alias-label-tertiary, #6b7280); } -.dshell-dotDone { background: #3fb950; box-shadow: 0 0 5px #3fb950; } -.dshell-dotWait { background: #d29922; box-shadow: 0 0 5px #d29922; } -/* \u6807\u7B7E\u9875 */ -.dshell-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l1, #262b36); } -.dshell-tab { padding: 7px 12px; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); cursor: pointer; border: none; background: none; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; } -.dshell-tabOn { color: var(--dsw-alias-label-primary, #e6e8eb); border-bottom-color: var(--dsw-alias-state-accent-primary, #4f8ef7); } -/* \u5217\u8868 */ -.dshell-list { display: flex; flex-direction: column; gap: 6px; } -.dshell-listItem { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 8px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); cursor: pointer; } -.dshell-listItem:hover { border-color: var(--dsw-alias-border-l2, #3a4150); background: var(--dsw-alias-fill-l1, rgba(255,255,255,.05)); } -.dshell-listItemTitle { font-size: 12.5px; color: var(--dsw-alias-label-primary, #e6e8eb); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dshell-listItemMeta { flex: none; font-size: 11px; color: var(--dsw-alias-label-tertiary, #6b7280); } -/* \u7F51\u683C / \u7EDF\u8BA1 */ -.dshell-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; } -.dshell-stat { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 10px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); } -.dshell-statLabel { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa4b2); } -.dshell-statValue { font-size: 20px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e8eb); } -.dshell-statDelta { font-size: 11px; color: #3fb950; } -/* \u8FDB\u5EA6\u6761 */ -.dshell-progress { height: 6px; border-radius: 3px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.06)); overflow: hidden; } -.dshell-progressBar { height: 100%; border-radius: 3px; background: #3fb950; } -/* \u8F93\u5165 */ -.dshell-input, .dshell-textarea { width: 100%; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 8px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.03)); color: var(--dsw-alias-label-primary, #e6e8eb); font: inherit; font-size: 12.5px; outline: none; } -.dshell-input:focus, .dshell-textarea:focus { border-color: var(--dsw-alias-state-accent-primary, #4f8ef7); } -/* \u8868\u683C */ -.dshell-table { width: 100%; border-collapse: collapse; font-size: 12px; } -.dshell-table th, .dshell-table td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1, #262b36); } -.dshell-table th { color: var(--dsw-alias-label-secondary, #9aa4b2); font-weight: 500; } -/* \u952E\u503C\u5BF9 */ -.dshell-kv { display: flex; flex-direction: column; gap: 6px; } -.dshell-kvRow { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; } -.dshell-kvKey { color: var(--dsw-alias-label-secondary, #9aa4b2); } -.dshell-kvValue { color: var(--dsw-alias-label-primary, #e6e8eb); text-align: right; } -/* \u5206\u5272\u7EBF */ -.dshell-divider { height: 1px; background: var(--dsw-alias-border-l1, #262b36); margin: 6px 0; } -/* \u6EDA\u52A8\u6761 */ -::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-thumb { background: rgba(255,255,255,.14); border-radius: 5px; } -::-webkit-scrollbar-track { background: transparent; } +var dshell_default = `/* dsh-worktable \u539F\u751F\u76AE\u80A4 \xB7 DSH \u8BBE\u8BA1\u7CFB\u7EDF\u7EC4\u4EF6\u5E93\r + \u7528\u6CD5\uFF1A\r + \u6240\u6709\u989C\u8272\u8D70 DSH \u4E3B\u9898\u53D8\u91CF\uFF08--dsw-alias-*\uFF09\uFF0C\u81EA\u52A8\u9002\u914D\u660E\u6697\u4E3B\u9898\u3002 */\r +:root { color-scheme: dark; }\r +* { box-sizing: border-box; }\r +html, body { margin: 0; padding: 0; }\r +body {\r + background: var(--dsw-alias-bg-base, #0b0e14);\r + color: var(--dsw-alias-label-primary, #e6e8eb);\r + font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;\r + font-size: 13px;\r + line-height: 1.6;\r +}\r +.dshell { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; min-height: 100%; }\r +/* \u6587\u5B57\u5C42\u7EA7 */\r +.dshell-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e8eb); }\r +.dshell-sub { margin: 0; font-size: 12px; color: var(--dsw-alias-label-secondary, #9aa4b2); }\r +.dshell-muted { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11.5px; }\r +/* \u5361\u7247 */\r +.dshell-card { border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 10px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); padding: 12px 14px; }\r +.dshell-card + .dshell-card { margin-top: 10px; }\r +/* \u6309\u94AE\uFF08\u7EFF\u8272\u4E3B\u6309\u94AE / \u5E7D\u7075\u6309\u94AE / \u5371\u9669\uFF09 */\r +.dshell-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 999px; border: 1px solid transparent; background: #3fb950; color: #0b0e14; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; }\r +.dshell-btn:hover { filter: brightness(1.08); }\r +.dshell-btnGhost { background: transparent; border-color: var(--dsw-alias-border-l1, #262b36); color: var(--dsw-alias-label-secondary, #9aa4b2); }\r +.dshell-btnGhost:hover { color: var(--dsw-alias-label-primary, #e6e8eb); border-color: var(--dsw-alias-border-l2, #3a4150); }\r +.dshell-btnDanger { background: transparent; border-color: #f85149; color: #f85149; }\r +/* \u72B6\u6001\u5FBD\u6807\uFF08\u5706\u70B9 + \u6587\u5B57\uFF1B\u7EFF=\u5DF2\u5B8C\u6210 \u9EC4=\u5F85\u529E/\u5F85\u53D1\u5E03 \u7070=\u672A\u5F00\u59CB\uFF09 */\r +.dshell-badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1, #262b36); font-size: 11.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); background: var(--dsw-alias-fill-l1, rgba(255,255,255,.03)); }\r +.dshell-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-label-tertiary, #6b7280); }\r +.dshell-badgeDone { color: #3fb950; border-color: rgba(63,185,80,.4); }\r +.dshell-badgeDone::before { background: #3fb950; box-shadow: 0 0 5px #3fb950; }\r +.dshell-badgeWait { color: #d29922; border-color: rgba(210,153,34,.4); }\r +.dshell-badgeWait::before { background: #d29922; box-shadow: 0 0 5px #d29922; }\r +.dshell-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--dsw-alias-label-tertiary, #6b7280); }\r +.dshell-dotDone { background: #3fb950; box-shadow: 0 0 5px #3fb950; }\r +.dshell-dotWait { background: #d29922; box-shadow: 0 0 5px #d29922; }\r +/* \u6807\u7B7E\u9875 */\r +.dshell-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l1, #262b36); }\r +.dshell-tab { padding: 7px 12px; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); cursor: pointer; border: none; background: none; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; }\r +.dshell-tabOn { color: var(--dsw-alias-label-primary, #e6e8eb); border-bottom-color: var(--dsw-alias-state-accent-primary, #4f8ef7); }\r +/* \u5217\u8868 */\r +.dshell-list { display: flex; flex-direction: column; gap: 6px; }\r +.dshell-listItem { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 8px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); cursor: pointer; }\r +.dshell-listItem:hover { border-color: var(--dsw-alias-border-l2, #3a4150); background: var(--dsw-alias-fill-l1, rgba(255,255,255,.05)); }\r +.dshell-listItemTitle { font-size: 12.5px; color: var(--dsw-alias-label-primary, #e6e8eb); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\r +.dshell-listItemMeta { flex: none; font-size: 11px; color: var(--dsw-alias-label-tertiary, #6b7280); }\r +/* \u7F51\u683C / \u7EDF\u8BA1 */\r +.dshell-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }\r +.dshell-stat { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 10px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.02)); }\r +.dshell-statLabel { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa4b2); }\r +.dshell-statValue { font-size: 20px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e8eb); }\r +.dshell-statDelta { font-size: 11px; color: #3fb950; }\r +/* \u8FDB\u5EA6\u6761 */\r +.dshell-progress { height: 6px; border-radius: 3px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.06)); overflow: hidden; }\r +.dshell-progressBar { height: 100%; border-radius: 3px; background: #3fb950; }\r +/* \u8F93\u5165 */\r +.dshell-input, .dshell-textarea { width: 100%; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l1, #262b36); border-radius: 8px; background: var(--dsw-alias-fill-l1, rgba(255,255,255,.03)); color: var(--dsw-alias-label-primary, #e6e8eb); font: inherit; font-size: 12.5px; outline: none; }\r +.dshell-input:focus, .dshell-textarea:focus { border-color: var(--dsw-alias-state-accent-primary, #4f8ef7); }\r +/* \u8868\u683C */\r +.dshell-table { width: 100%; border-collapse: collapse; font-size: 12px; }\r +.dshell-table th, .dshell-table td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1, #262b36); }\r +.dshell-table th { color: var(--dsw-alias-label-secondary, #9aa4b2); font-weight: 500; }\r +/* \u952E\u503C\u5BF9 */\r +.dshell-kv { display: flex; flex-direction: column; gap: 6px; }\r +.dshell-kvRow { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }\r +.dshell-kvKey { color: var(--dsw-alias-label-secondary, #9aa4b2); }\r +.dshell-kvValue { color: var(--dsw-alias-label-primary, #e6e8eb); text-align: right; }\r +/* \u5206\u5272\u7EBF */\r +.dshell-divider { height: 1px; background: var(--dsw-alias-border-l1, #262b36); margin: 6px 0; }\r +/* \u6EDA\u52A8\u6761 */\r +::-webkit-scrollbar { width: 10px; height: 10px; }\r +::-webkit-scrollbar-thumb { background: rgba(255,255,255,.14); border-radius: 5px; }\r +::-webkit-scrollbar-track { background: transparent; }\r `; // template/dshell.html -var dshell_default2 = '\n\n\n\n \n \n \u6211\u7684\u7A97\u53E3\n \n\n\n
\n \n

\u7A97\u53E3\u6807\u9898

\n

\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002

\n\n \n
\n \u5DF2\u5B8C\u6210\n \u8FDB\u884C\u4E2D\n \u672A\u5F00\u59CB\n
\n\n \n
\n \n \n \n
\n\n \n
\n
\n
\u603B\u6570
\n
128
\n
+12.4%
\n
\n
\n
\u8FDB\u884C\u4E2D
\n
7
\n
\n
\n
\u5DF2\u5B8C\u6210
\n
121
\n
\n
\n\n \n
\n
\n \u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u6628\u5929\n
\n
\n \u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u5DF2\u53D1\u5E03\n
\n
\n\n \n
\n

\u8BE6\u60C5

\n
\n
\u5B57\u6BB5 A\u503C A
\n
\u5B57\u6BB5 B\u503C B
\n
\n
\n
\n
\n\n \n
\n \n \n
\n
\n\n\n'; +var dshell_default2 = '\r\n\r\n\r\n\r\n \r\n \r\n \u6211\u7684\u7A97\u53E3\r\n \r\n\r\n\r\n
\r\n \r\n

\u7A97\u53E3\u6807\u9898

\r\n

\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002

\r\n\r\n \r\n
\r\n \u5DF2\u5B8C\u6210\r\n \u8FDB\u884C\u4E2D\r\n \u672A\u5F00\u59CB\r\n
\r\n\r\n \r\n
\r\n \r\n \r\n \r\n
\r\n\r\n \r\n
\r\n
\r\n
\u603B\u6570
\r\n
128
\r\n
+12.4%
\r\n
\r\n
\r\n
\u8FDB\u884C\u4E2D
\r\n
7
\r\n
\r\n
\r\n
\u5DF2\u5B8C\u6210
\r\n
121
\r\n
\r\n
\r\n\r\n \r\n
\r\n
\r\n \u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\r\n \u6628\u5929\r\n
\r\n
\r\n \u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\r\n \u5DF2\u53D1\u5E03\r\n
\r\n
\r\n\r\n \r\n
\r\n

\u8BE6\u60C5

\r\n
\r\n
\u5B57\u6BB5 A\u503C A
\r\n
\u5B57\u6BB5 B\u503C B
\r\n
\r\n
\r\n
\r\n
\r\n\r\n \r\n
\r\n \r\n \r\n
\r\n
\r\n\r\n\r\n'; // src/index.ts function baseDshHome() { @@ -225,7 +225,7 @@ function resolveDshHomeSafe() { cachedDshHome = baseDshHome(); return cachedDshHome; } -var PLUGIN_VERSION = false ? "dev" : "0.5.1"; +var PLUGIN_VERSION = false ? "dev" : "0.5.2-local.1"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; @@ -831,6 +831,53 @@ function apply(ctx) { } } }); + const statePath = () => pathResolve(resolveDshHomeSafe(), "worktable-state.json"); + register({ + kind: "exact", + path: "/api/worktable/state", + handler: async (req, res) => { + const file = statePath(); + if (req.method === "GET" || req.method === "HEAD") { + try { + const raw = await readFile(file, "utf8"); + const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw; + json(res, 200, { ok: true, path: file, state: JSON.parse(text) }); + } catch (err) { + if (err?.code === "ENOENT") { + json(res, 200, { ok: true, path: file, state: null }); + return; + } + json(res, 500, { error: String(err) }); + } + return; + } + if (req.method !== "POST") { + json(res, 405, { error: "method not allowed" }); + return; + } + try { + const body = await readJsonBody(req); + const state = body && typeof body.state === "object" && body.state !== null ? body.state : null; + if (!state) { + json(res, 400, { error: "missing state" }); + return; + } + const text = JSON.stringify(state); + if (text.length > 20 * 1024 * 1024) { + json(res, 413, { error: "state too large" }); + return; + } + const fsx = await import("node:fs/promises"); + await fsx.mkdir(dirname(file), { recursive: true }); + const tmp = file + ".tmp-" + process.pid + "-" + Date.now(); + await fsx.writeFile(tmp, text, "utf8"); + await fsx.rename(tmp, file); + json(res, 200, { ok: true, path: file }); + } catch (err) { + json(res, err?.status ?? 500, { error: String(err) }); + } + } + }); setupTerminal(webServer, ctx); } export { diff --git a/01_content/package-lock.json b/01_content/package-lock.json index 7da5b4b..14c8243 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -8,16 +8,16 @@ "name": "dsh-worktable", "version": "0.5.1", "license": "MIT", - "engines": { - "node": ">=22", - "dsh": "0.2.0-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5" - }, "devDependencies": { "esbuild": "^0.25.0", "highlight.js": "^11.12.0", "markdown-it": "^15.0.0", "xterm": "^5.3.0" }, + "engines": { + "dsh": "0.2.0-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5", + "node": ">=22" + }, "peerDependencies": { "@deepseek-ai/cordis": "*", "@deepseek-ai/dsh-client-locale": "*", diff --git a/01_content/package.json b/01_content/package.json index 8915fa0..bc2cbcc 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)", - "version": "0.5.1", + "version": "0.5.2-local.1", "repository": { "type": "git", "url": "https://github.com/Aisland-SJL/dsh-worktable.git", diff --git a/01_content/src/client/hostState.ts b/01_content/src/client/hostState.ts new file mode 100644 index 0000000..821ef14 --- /dev/null +++ b/01_content/src/client/hostState.ts @@ -0,0 +1,92 @@ +/** + * 宿主侧状态镜像(本地补丁)。 + * + * 背景:桌面端宿主每次启动都带 `--port 0`(随机端口),页面来源随之变化 + * (如 127.0.0.1:2080 → 127.0.0.1:8494)。浏览器按来源隔离 localStorage / + * IndexedDB,换端口就等于换一个空库,项目/布局/外观重启后全丢。 + * + * 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state), + * 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快), + * 宿主文件是跨重启的兜底。 + */ + +const ENDPOINT = '/api/worktable/state' + +export type HostStateParts = { projects?: unknown; view?: unknown } + +/** 宿主镜像里可能带的写入时间戳(毫秒) */ +export type HostState = HostStateParts & { updatedAt?: number } + +/** 拉取宿主侧镜像;没有/不可用都返回 null(不抛错,不影响工作台本身) */ +export async function loadHostState(): Promise { + try { + const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } }) + if (!r.ok) return null + const j: any = await r.json() + return j && typeof j === 'object' && j.state && typeof j.state === 'object' ? (j.state as HostState) : null + } catch { + return null + } +} + +/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */ +let parts: HostStateParts = {} +let timer: any = null +let inflight = false +let dirty = false +/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */ +let hydrated = false + +function schedule() { + if (timer) return + timer = setTimeout(() => { + timer = null + void flush() + }, 400) +} + +async function flush(): Promise { + if (!hydrated || inflight) { + if (inflight) dirty = true + return + } + if (!parts.projects && !parts.view) return + inflight = true + try { + await fetch(ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ state: { ...parts, updatedAt: Date.now() } }), + keepalive: true, + }) + } catch { + // 宿主不可用时静默:localStorage 仍是本次会话的真值 + } finally { + inflight = false + if (dirty) { + dirty = false + schedule() + } + } +} + +/** 记下要镜像的部分;400ms 内多次调用合并成一次 POST */ +export function queueHostSave(patch: HostStateParts): void { + parts = { ...parts, ...patch } + if (!hydrated) return + schedule() +} + +/** 首次恢复判定结束:之后再放行宿主写入 */ +export function markHydrated(): void { + hydrated = true +} + +/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */ +export function flushHostSave(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + void flush() +} diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index e6c585c..441035c 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -12,6 +12,7 @@ import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, m import { photoStore, kindOf } from './photoStore' import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg' import { WAVE_BG_B64 } from './waveBg' +import { loadHostState, queueHostSave, markHydrated, flushHostSave } from './hostState' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 @@ -124,6 +125,8 @@ type ProjectsState = { const PERSIST_KEY = 'dsh.worktable.view.v1' const PROJECTS_KEY = 'dsh.worktable.projects.v1' +/** 本地最后写入时间(毫秒):与宿主镜像的 updatedAt 比较,决定启动时以哪边为准 */ +const SAVED_AT_KEY = 'dsh.worktable.savedAt.v1' const MIN_TOP = 56 const SNAP_PX = 32 /** 插件市场外链(GitHub 仓库,已核实可访问;PRD 提及的 dshfind.com 未验证,不用死链)。 */ @@ -308,11 +311,11 @@ function normHsl(v: any): { h: number; s: number; l: number } | undefined { } } -function loadView(): ViewState { +/** 视图状态归一化(本地存档与宿主镜像共用同一套字段校验) */ +function normalizeView(raw: any): ViewState { try { - const raw = localStorage.getItem(PERSIST_KEY) - if (!raw) return { ...DEFAULT_VIEW } - const p = JSON.parse(raw) + if (!raw || typeof raw !== 'object') return { ...DEFAULT_VIEW } + const p = raw // 显式挑字段:旧版遗留的 groupBy 等未知字段直接忽略。 // 一次性迁移:v2 起默认「手动」排序(最近排序点击即置顶、体验差),旧存「最近」回落为「手动」; // 之后用户手动选择「最近」会写入 sortMigratedV2 标记并被尊重。 @@ -356,11 +359,21 @@ function loadView(): ViewState { } } -function loadProjects(): ProjectsState { +function loadView(): ViewState { try { - const raw = localStorage.getItem(PROJECTS_KEY) - if (!raw) return { ...DEFAULT_PROJECTS } - const p = JSON.parse(raw) + const raw = localStorage.getItem(PERSIST_KEY) + if (!raw) return { ...DEFAULT_VIEW } + return normalizeView(JSON.parse(raw)) + } catch { + return { ...DEFAULT_VIEW } + } +} + +/** 项目状态归一化(本地存档与宿主镜像共用同一套字段校验) */ +function normalizeProjects(raw: any): ProjectsState { + try { + if (!raw || typeof raw !== 'object') return { ...DEFAULT_PROJECTS } + const p = raw return { order: Array.isArray(p.order) ? p.order.filter((x: unknown): x is string => typeof x === 'string') : [], lastUsed: p.lastUsed && typeof p.lastUsed === 'object' ? p.lastUsed : {}, @@ -383,6 +396,21 @@ function loadProjects(): ProjectsState { } } +function loadProjects(): ProjectsState { + try { + const raw = localStorage.getItem(PROJECTS_KEY) + if (!raw) return { ...DEFAULT_PROJECTS } + return normalizeProjects(JSON.parse(raw)) + } catch { + return { ...DEFAULT_PROJECTS } + } +} + +/** 记下本地写入时间,供启动时与宿主镜像比较新鲜度 */ +function markSaved(): void { + try { localStorage.setItem(SAVED_AT_KEY, String(Date.now())) } catch {} +} + const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi) type FloatRect = { top: number } @@ -1380,6 +1408,8 @@ function WorktableSection(props: any) { setView((prev) => { const next = { ...prev, ...patch } try { localStorage.setItem(PERSIST_KEY, JSON.stringify({ ...next, sortMigratedV2: true })) } catch {} + markSaved() + queueHostSave({ view: next }) return next }) } @@ -1388,10 +1418,44 @@ function WorktableSection(props: any) { setProjects((prev) => { const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch } try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)) } catch {} + markSaved() + queueHostSave({ projects: next }) return next }) } + /** + * 宿主侧状态恢复(本地补丁):桌面端每次启动端口随机,localStorage 按来源隔离, + * 重启后本地必然是空的。这里比对「本地最后写入时间」与「宿主镜像 updatedAt」, + * 只在宿主更新时回填,避免用旧镜像盖掉当前页面刚改的内容。 + * 恢复判定结束前不写宿主(markHydrated),防止首屏默认值先把镜像冲掉。 + */ + useEffect(() => { + let cancelled = false + void (async () => { + const host = await loadHostState() + if (cancelled) return + let localAt = 0 + try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {} + const hostAt = typeof host?.updatedAt === 'number' ? host.updatedAt : 0 + if (host && hostAt > localAt) { + if (host.projects) persistProjects(normalizeProjects(host.projects)) + if (host.view) persistView(normalizeView(host.view)) + } + markHydrated() + if (hostAt <= localAt) flushHostSave() + })() + const flush = () => flushHostSave() + window.addEventListener('pagehide', flush) + window.addEventListener('beforeunload', flush) + return () => { + cancelled = true + window.removeEventListener('pagehide', flush) + window.removeEventListener('beforeunload', flush) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // 自愈:启动时若控制室存档是坏布局(旧版关掉控制室标签造成窗格退化成选择器),立即重建默认面板 useEffect(() => { const saved = projects.views[CONSOLE_ID] diff --git a/01_content/src/index.ts b/01_content/src/index.ts index c8ea751..90f4f8d 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -544,5 +544,48 @@ export function apply(ctx: Context) { }, }) + /** + * 工作台状态宿主侧镜像(本地补丁)。 + * + * 桌面端宿主以 `--port 0` 启动,端口每次随机,页面来源跟着变;而 localStorage 按来源隔离, + * 于是「新建的项目重启就没了」。这里把项目/视图这份 JSON 落到 DSH_HOME 下的固定文件: + * 跨重启、跨端口稳定。客户端启动时比对时间戳决定是否回填(见 client/hostState.ts)。 + */ + const statePath = () => pathResolve(resolveDshHomeSafe(), 'worktable-state.json') + register({ + kind: 'exact', + path: '/api/worktable/state', + handler: async (req: any, res: any) => { + const file = statePath() + if (req.method === 'GET' || req.method === 'HEAD') { + try { + const raw = await readFile(file, 'utf8') + const text = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw + json(res, 200, { ok: true, path: file, state: JSON.parse(text) }) + } catch (err: any) { + if (err?.code === 'ENOENT') { json(res, 200, { ok: true, path: file, state: null }); return } + json(res, 500, { error: String(err) }) + } + return + } + if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return } + try { + const body = await readJsonBody(req) + const state = body && typeof body.state === 'object' && body.state !== null ? body.state : null + if (!state) { json(res, 400, { error: 'missing state' }); return } + const text = JSON.stringify(state) + if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: 'state too large' }); return } + const fsx = await import('node:fs/promises') + await fsx.mkdir(dirname(file), { recursive: true }) + const tmp = file + '.tmp-' + process.pid + '-' + Date.now() + await fsx.writeFile(tmp, text, 'utf8') + await fsx.rename(tmp, file) + json(res, 200, { ok: true, path: file }) + } catch (err: any) { + json(res, err?.status ?? 500, { error: String(err) }) + } + }, + }) + setupTerminal(webServer, ctx) } diff --git a/PATCH.md b/PATCH.md new file mode 100644 index 0000000..50ba92c --- /dev/null +++ b/PATCH.md @@ -0,0 +1,47 @@ +# 本地补丁:工作台状态镜像到宿主侧 + +基于上游 `Aisland-SJL/dsh-worktable` **v0.5.1**,本地版本号改成 `0.5.2-local.1`。 + +## 为什么打这个补丁 + +桌面端宿主启动参数写死 `--port 0`(`@deepseek-ai/dsh-slark-desktop-host` 的 +`windows-startup.js`:`--profile web --no-open --host 127.0.0.1 --port 0`),每次启动端口随机。 +而工作台把项目/布局/绑定存在 localStorage(`dsh.worktable.projects.v1`、`dsh.worktable.view.v1`), +媒体存 IndexedDB —— 浏览器按「来源」隔离存储,端口一变就是另一个空库。 +现象:新建的工作台,重启后消失。 + +## 改了什么 + +1. `01_content/src/index.ts`:新增路由 `GET/POST /api/worktable/state` + - 文件位置:`/worktable-state.json`(走)`resolveDshHomeSafe()`) + - GET 读回,POST 原子写入(先写 `.tmp--` 再 rename) + - 走宿主既有鉴权(`requestRejection`),和 `/api/worktable/file` 等私有路由一致 +2. `01_content/src/client/hostState.ts`(新增):宿主镜像的读/写与 400ms 防抖合并 +3. `01_content/src/client/index.tsx`: + - `loadView` / `loadProjects` 抽出 `normalizeView` / `normalizeProjects`,本地存档与宿主镜像共用校验 + - `persistView` / `persistProjects` 额外写 `dsh.worktable.savedAt.v1` 并把状态排进宿主镜像 + - 挂载时拉宿主镜像,只有 `updatedAt > 本地 savedAt` 才回填,避免旧镜像盖掉刚改的内容 + - `markHydrated()` 之前的写入一律不发,防止首屏默认值把镜像冲掉 + - `pagehide` / `beforeunload` 立即冲刷 + +## 覆盖范围 + +- 覆盖:项目、布局、绑定、文件夹、视图/外观设置 +- **不覆盖**:图库媒体(照片)仍在 IndexedDB,换端口照样丢 + +## 构建 / 安装 + +```powershell +cd 01_content +npm install +npm run build # lib/index.js + lib/client.js +npm pack # 产出 dsh-worktable-0.5.2-local.1.tgz(7 文件,无 .map) +dsh plugin --profile web add "file:./dsh-plugins/dsh-worktable-0.5.2-local.1.tgz" +``` + +装完必须**完全退出宿主再重开**。 + +## 升级官方版时的注意 + +上游出 0.5.2 及以上时,本地补丁会被覆盖,需要把上面的三处改动重新合并到新版本再打包。 +如果上游自己解决了「桌面端随机端口 + localStorage」,这个补丁就可以直接丢掉。