dsh-plugin-deck-studio 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/workbench.html CHANGED
@@ -67,6 +67,20 @@
67
67
  .manual-page figure.doc-figure { margin: 16px 0 20px; }
68
68
  .manual-page figure.doc-figure img { max-width: 100%; min-height: 160px; background: #f4f2ea; border: 1px solid var(--line, #e3e1d8); border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.05); }
69
69
  .manual-page figure.doc-figure figcaption { font-size: 12px; color: var(--ink-3, #8a8a80); margin-top: 6px; }
70
+ /* P0:数据目录黄条(非模态、不挡操作;事实来自 /api/deck-studio.data-dir) */
71
+ .data-dir-banner{margin:0;padding:10px 18px;border-bottom:1px solid #e0b96a;background:#fdf7e8;color:#6b4b18;font-size:12.5px}
72
+ .data-dir-banner[hidden]{display:none}
73
+ .data-dir-banner-body{display:flex;gap:16px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
74
+ .data-dir-banner-main{min-width:0;flex:1 1 520px}
75
+ .data-dir-banner-main b{color:#8a5a00;font-size:13px}
76
+ .data-dir-line{display:block;margin-top:2px;color:#6b6353}
77
+ .data-dir-line code{font-size:12px;background:#fff8ea;border:1px solid #eadcbe;border-radius:4px;padding:1px 5px;word-break:break-all}
78
+ .data-dir-candidate{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
79
+ .data-dir-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#3b4a4d;word-break:break-all}
80
+ .data-dir-count{color:#8a7a58;font-size:11px}
81
+ .data-dir-candidate input{flex:1 1 280px;min-width:200px;padding:4px 8px;border:1px solid #d8c9a8;border-radius:6px;font-size:12px}
82
+ .data-dir-banner-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
83
+ .data-dir-readonly{font-size:11px;color:#8a5a00}
70
84
  </style>
71
85
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230f6975'/%3E%3Crect x='15' y='26' width='34' height='21' rx='3.5' fill='%23faf8f2' opacity='.55' transform='rotate(-5 32 36.5)'/%3E%3Crect x='17' y='17' width='34' height='22' rx='3.5' fill='%23faf8f2'/%3E%3Crect x='22' y='23' width='19' height='2.6' rx='1.3' fill='%230f6975'/%3E%3Crect x='22' y='29' width='12' height='2.2' rx='1.1' fill='%231496a8'/%3E%3Cpath d='M48.5 9.5l2.2 5.4 5.4 2.2-5.4 2.2-2.2 5.4-2.2-5.4-5.4-2.2 5.4-2.2z' fill='%23c5882f'/%3E%3C/svg%3E">
72
86
  </head>
@@ -108,6 +122,9 @@
108
122
  <button id="shellSidebarBtn" title="显示 / 收起 DSH 侧栏(会话 · 插件 · 设置)" hidden>☰ DSH</button>
109
123
  </header>
110
124
 
125
+ <!-- P0:数据目录黄条(非模态;空时不渲染,见 renderDataDirBanner) -->
126
+ <div id="dataDirBanner" class="data-dir-banner" hidden></div>
127
+
111
128
  <section id="homeView" class="home">
112
129
  <aside class="home-nav">
113
130
  <button class="btn rail-new home-new-deck" id="newDeckBtn">+ 新建 Deck</button>
@@ -215,6 +232,35 @@
215
232
  </div>
216
233
  </div>
217
234
 
235
+ <!-- P1:首次引导(真·首次才弹:decks 空 && 无 render-settings.json && 无 location.json —— 判定在服务端) -->
236
+ <div id="firstRunModal" class="modal" hidden>
237
+ <div class="modal-card" style="width:min(620px,94vw)">
238
+ <div class="modal-head">
239
+ <div><div class="eyebrow">Welcome</div><h2>先把数据放在哪定下来</h2></div>
240
+ <button class="modal-close" id="frClose" title="稍后再说">✕</button>
241
+ </div>
242
+ <form class="modal-body" id="firstRunForm">
243
+ <p class="engine-note">换端口不改数据,换 dataRoot 才改数据。这里一次定两件事,之后随时能在设置里改。</p>
244
+ <label class="field"><span>① 数据放哪(工作副本 / 出图 / 事件)</span>
245
+ <span class="dir-picker-field"><input id="frDataRoot" autocomplete="off" placeholder="绝对路径,例:/Users/你/.dsh/deckline"><button type="button" class="btn ghost" id="frPickData">选择…</button></span>
246
+ </label>
247
+ <label class="field"><span>② 新建 deck 的源放哪(projectsRoot,可留空)</span>
248
+ <span class="dir-picker-field"><input id="frProjectsRoot" autocomplete="off" placeholder="留空 = 数据目录下的 projects"><button type="button" class="btn ghost" id="frPickProjects">选择…</button></span>
249
+ </label>
250
+ <div class="diag-card">
251
+ <div class="diag-root">数据目录:<code id="frEchoData">—</code></div>
252
+ <div class="diag-root">源目录:<code id="frEchoProjects">—</code></div>
253
+ <p class="diag-note">备份就是复制这两个目录。</p>
254
+ </div>
255
+ <p class="small" id="frState"></p>
256
+ <div class="nd-foot"><span class="spacer"></span>
257
+ <button type="button" class="btn alt" id="frLater">稍后再说</button>
258
+ <button type="submit" class="btn" id="frSubmit">就用这个位置</button>
259
+ </div>
260
+ </form>
261
+ </div>
262
+ </div>
263
+
218
264
  <button id="embedBack" class="embed-back" hidden>← 回到对话</button>
219
265
 
220
266
  <div id="workspaceView" class="layout">
@@ -423,6 +469,85 @@
423
469
  </div>
424
470
 
425
471
 
472
+ <!-- P2:数据目录自检面板(独立模态;首页 ⚙ / 无 deck / mismatch 时进这里) -->
473
+ <style id="diag-style">
474
+ .diag-card{border:1px solid #e3e1d8;border-radius:10px;padding:12px 14px;margin-bottom:12px;background:#fff}
475
+ .diag-card.is-env{border-color:#e0b96a;background:#fdf7e8}
476
+ .diag-root{font-size:13px;line-height:1.5;word-break:break-all}
477
+ .diag-root code{font-size:12px}
478
+ .diag-src{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:11px;border:1px solid #cdd6d4;color:#4a5c5a;background:#f4f7f6;white-space:nowrap}
479
+ .diag-src.is-env{border-color:#d8a54a;color:#8a5a00;background:#fdf3dd}
480
+ .diag-note{margin:8px 0 0;font-size:12px;color:#5f6b6d}
481
+ .diag-grid{display:grid;grid-template-columns:132px minmax(0,1fr);gap:4px 10px;font-size:12px;margin:8px 0 0}
482
+ .diag-grid dt{color:#728086}
483
+ .diag-grid dd{margin:0;word-break:break-all;color:#18343b}
484
+ .diag-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px}
485
+ .diag-table{width:100%;border-collapse:collapse;font-size:11.5px;margin-top:8px}
486
+ .diag-table th{text-align:left;color:#728086;font-weight:600;border-bottom:1px solid #e3e1d8;padding:3px 6px;white-space:nowrap}
487
+ .diag-table td{padding:3px 6px;border-bottom:1px dashed #eee7d9;word-break:break-all}
488
+ .diag-bad{color:#b3261e}
489
+ .diag-warn{color:#b3591e}
490
+ .diag-ok{color:#1c7c46}
491
+ .diag-root-chip{margin-left:10px;font-size:11.5px;color:#6b7b7a;cursor:default}
492
+ .diag-root-chip b{color:#0f6975;font-weight:600}
493
+ #diagModal .modal-card{width:min(760px,94vw)}
494
+ #diagModal .modal-body{max-height:72vh;overflow:auto}
495
+ /* P2.5:数据目录选择器(只用 .dir-picker-* 前缀,不碰已有类名) */
496
+ .dir-picker-path{font-size:12.5px;line-height:1.55;word-break:break-all;color:#18343b}
497
+ .dir-picker-path code{font-size:12px}
498
+ .dir-picker-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0 4px}
499
+ .dir-picker-chips{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 12px}
500
+ .dir-picker-chip{border:1px solid #cdd6d4;background:#f4f7f6;border-radius:999px;padding:4px 10px;font-size:12px;color:#3b4a4d;cursor:pointer;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
501
+ .dir-picker-chip:hover{border-color:#0f6975;color:#0f6975}
502
+ .dir-picker-chip.is-current{border-color:#0f6975;background:#e9f4f3}
503
+ .dir-picker-tag{font-size:10.5px;color:#0f6975}
504
+ .dir-picker-count{font-size:10.5px;color:#8a7a58}
505
+ .dir-picker-list{border:1px solid #e3e1d8;border-radius:10px;max-height:42vh;overflow:auto;background:#fff}
506
+ .dir-picker-row{display:flex;gap:10px;align-items:center;width:100%;padding:8px 12px;border:0;border-bottom:1px solid #f2efe6;background:transparent;text-align:left;font-size:12.5px;color:#18343b;cursor:pointer}
507
+ .dir-picker-row:last-child{border-bottom:0}
508
+ .dir-picker-row:hover{background:#f6f9f8}
509
+ .dir-picker-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
510
+ .dir-picker-meta{flex:none;color:#728086;font-size:11px}
511
+ .dir-picker-empty{padding:14px 12px;color:#8a9599;font-size:12px}
512
+ .dir-picker-toggle{display:flex;gap:6px;align-items:center;margin:10px 0 0;font-size:12px;color:#5f6b6d;cursor:pointer}
513
+ .dir-picker-error{margin:10px 0 0;font-size:12px;color:#b3261e;word-break:break-all}
514
+ .dir-picker-field{display:flex;gap:8px;align-items:center}
515
+ .dir-picker-field input{flex:1 1 auto;min-width:0}
516
+ #dirPickerModal .modal-card{width:min(680px,94vw)}
517
+ #dirPickerModal .modal-body{max-height:72vh;overflow:auto}
518
+ </style>
519
+
520
+ <div id="diagModal" class="modal" hidden>
521
+ <div class="modal-card">
522
+ <div class="modal-head">
523
+ <div><div class="eyebrow">Data Directory</div><h2>当前数据目录与自检</h2></div>
524
+ <button class="modal-close" id="diagClose" title="关闭">✕</button>
525
+ </div>
526
+ <div class="modal-body" id="diagBody"></div>
527
+ <div class="modal-foot">
528
+ <button class="btn" id="diagCopy" type="button">复制诊断信息</button>
529
+ <button class="btn alt" id="diagRefresh" type="button">重新检测</button>
530
+ <span class="small" id="diagState"></span>
531
+ </div>
532
+ </div>
533
+ </div>
534
+
535
+ <!-- P2.5:数据目录选择器(点目录走,不用手打路径;Esc 关,Enter = 用当前目录) -->
536
+ <div id="dirPickerModal" class="modal" hidden>
537
+ <div class="modal-card">
538
+ <div class="modal-head">
539
+ <div><div class="eyebrow">Pick a folder</div><h2>选择数据目录</h2></div>
540
+ <button class="modal-close" id="dirPickerClose" title="关闭">✕</button>
541
+ </div>
542
+ <div class="modal-body" id="dirPickerBody"></div>
543
+ <div class="modal-foot">
544
+ <button class="btn alt" id="dirPickerCancel" type="button">取消</button>
545
+ <button class="btn" id="dirPickerUse" type="button">用这个目录</button>
546
+ <span class="small" id="dirPickerState"></span>
547
+ </div>
548
+ </div>
549
+ </div>
550
+
426
551
  <!-- F-102:全屏演示层(整页图一页一屏,方向键 / 空格 / PageUp-Down / 滚轮翻页) -->
427
552
  <div id="presentOverlay" class="present-overlay" role="dialog" aria-modal="true" aria-label="全屏演示" tabindex="-1" hidden>
428
553
  <div class="present-stage" id="presentStage" title="点一下 = 下一页"></div>
@@ -638,6 +763,526 @@ function demoData() {
638
763
  };
639
764
  }
640
765
 
766
+ /* ---------------- 数据目录:自检面板 + 首次引导(P2 / 规格 §3 §5) ----------------
767
+ * 契约:.deck-assistant-scratch/data-dir-api.md §1 §3 §4
768
+ * 纪律:根路径 / 命中来源 / 进程 cwd / env 覆盖 / firstRun 全由后端解析,前端只展示、不重算;
769
+ * 字段缺失时渲染「未知(后端未返回)」,不空白、不编默认值;
770
+ * diagnosticsText 由后端生成脱敏文本,前端只复制,不做第二套脱敏。 */
771
+ const DATA_SOURCE_LABEL = { env: "环境变量", pointer: "指针文件", profile: "profile 配置", default: "默认值" };
772
+ const DECK_STATUS_LABEL = {
773
+ ok: { text: "✓ 有", cls: "diag-ok" },
774
+ partial: { text: "图片在、正文丢了", cls: "diag-warn" },
775
+ mismatch: { text: "正文和图片都不在当前目录", cls: "diag-bad" },
776
+ absent: { text: "还没初始化", cls: "diag-warn" },
777
+ };
778
+ const DIAG_UNKNOWN = "未知(后端未返回)";
779
+ const ENV_READONLY_TEXT = "由环境变量 DSH_DECK_DATA_ROOT 指定(只读)";
780
+ const FIRST_RUN_FLAG = "deckline.firstRunDismissed";
781
+ let FIRST_RUN_MODE = "first-run";
782
+ let DIAG_DATA = null;
783
+
784
+ /** 归一化后端载荷:兼容 GET self-check(扁平)与 workbench.dataDir(包一层)两种形状。 */
785
+ function readDataDir(data, selfCheck) {
786
+ const sc = selfCheck && typeof selfCheck === "object" ? selfCheck : null;
787
+ const wrapped = data && data.dataDir && typeof data.dataDir === "object" ? data.dataDir : null;
788
+ const src = sc && sc.dataRoot ? sc : (wrapped || {});
789
+ const config = (data && data.config) || {};
790
+ const pick = (entry, fallback) => {
791
+ if (entry && typeof entry === "object") {
792
+ return {
793
+ value: String(entry.value ?? ""),
794
+ effective: String(entry.effective || entry.value || ""),
795
+ source: String(entry.source || "unknown"),
796
+ sourceLabel: String(entry.sourceLabel || ""),
797
+ envOverridden: entry.envOverridden === true,
798
+ writable: typeof entry.writable === "boolean" ? entry.writable : null,
799
+ exists: typeof entry.exists === "boolean" ? entry.exists : null,
800
+ };
801
+ }
802
+ return {
803
+ value: String(fallback ?? ""),
804
+ effective: String(fallback ?? ""),
805
+ source: "unknown",
806
+ sourceLabel: "",
807
+ envOverridden: false,
808
+ writable: null,
809
+ exists: null,
810
+ };
811
+ };
812
+ const env = src.envOverride || (wrapped && wrapped.envOverride) || null;
813
+ return {
814
+ dataRoot: pick(src.dataRoot, config.dataRoot),
815
+ projectsRoot: pick(src.projectsRoot, config.projectsRoot),
816
+ workspacePath: pick(src.workspacePath, data && data.workspacePath),
817
+ dshHome: pick(src.dshHome, ""),
818
+ processCwd: String(src.processCwd || ""),
819
+ pointerFile: src.pointerFile || (wrapped && wrapped.pointerFile) || null,
820
+ envOverride: { active: env ? env.active === true : false, name: String((env && env.name) || "DSH_DECK_DATA_ROOT") },
821
+ firstRun: src.firstRun === true || Boolean(wrapped && wrapped.firstRun),
822
+ sourceLabel: String(src.sourceLabel || ""),
823
+ versions: (sc && sc.versions) || {},
824
+ render: (sc && sc.render) || null,
825
+ decks: (sc && sc.decks) || src.decks || null,
826
+ diagnosticsText: String((sc && sc.diagnosticsText) || ""),
827
+ };
828
+ }
829
+
830
+ function diagSourceChip(entry) {
831
+ const label = entry.sourceLabel
832
+ || (entry.source === "unknown" ? DIAG_UNKNOWN : (DATA_SOURCE_LABEL[entry.source] || entry.source));
833
+ return `<span class="diag-src${entry.source === "env" ? " is-env" : ""}">${esc(label)}</span>`;
834
+ }
835
+
836
+ function shortPath(value, max = 44) {
837
+ const text = String(value ?? "");
838
+ if (!text) return "—";
839
+ if (text.length <= max) return text;
840
+ const head = Math.ceil((max - 1) / 2);
841
+ return text.slice(0, head) + "…" + text.slice(text.length - (max - 1 - head));
842
+ }
843
+
844
+ function diagPathCell(entry) {
845
+ if (!entry.effective) return `<span class="diag-bad">${esc(DIAG_UNKNOWN)}</span>`;
846
+ const flags = [];
847
+ if (entry.envOverridden) flags.push('<span class="diag-src is-env">只读</span>');
848
+ if (entry.writable === false) flags.push('<span class="diag-src is-env">不可写</span>');
849
+ if (entry.exists === false) flags.push('<span class="diag-src">还没创建</span>');
850
+ // 未单独配置(projectsRoot 常见):正文说「沿用默认」,紧跟一行写清真实落盘路径。
851
+ // 面板只展示后端给的 effective,不在这里拼 <dataRoot>/projects/<deckId>/source。
852
+ const shown = entry.value
853
+ ? `<code>${esc(entry.value)}</code>`
854
+ : `<span class="diag-warn">沿用默认(未单独配置)</span>`;
855
+ const extra = entry.effective && entry.effective !== entry.value
856
+ ? `<div class="diag-note">实际落盘:<code>${esc(entry.effective)}</code></div>`
857
+ : "";
858
+ return `${shown}${diagSourceChip(entry)}${flags.join("")}${extra}`;
859
+ }
860
+
861
+ function diagDeckTable(dd, data) {
862
+ // self-check 给 {registeredCount, items[]};workbench 的 dataDir.decks 是数组(每项带 status/statusLabel)。
863
+ const block = dd.decks;
864
+ let items = null;
865
+ if (Array.isArray(block)) items = block;
866
+ else if (block && Array.isArray(block.items)) items = block.items;
867
+ if (!items || !items.length) {
868
+ items = ((data && data.decks) || []).map((deck) => ({ deckId: deck.deckId, title: deck.title || deck.label, pageCount: deck.pageCount }));
869
+ }
870
+ if (!items.length) return "";
871
+ const rows = items.map((deck) => {
872
+ const known = DECK_STATUS_LABEL[deck.status];
873
+ const text = deck.statusLabel || (known ? known.text : (deck.status || DIAG_UNKNOWN));
874
+ const cls = known ? known.cls : "diag-warn";
875
+ const rendered = Number.isFinite(Number(deck.renderedPages))
876
+ ? `<span class="${Number(deck.renderedPages) > 0 ? "diag-ok" : ""}">${Number(deck.renderedPages)}</span>`
877
+ : `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`;
878
+ return `<tr><td><code>${esc(deck.deckId || "")}</code></td><td>${esc(deck.title || deck.label || "")}</td><td>${esc(deck.pageCount ?? "—")}</td><td>${rendered}</td><td class="${cls}">${esc(text)}</td></tr>`;
879
+ }).join("");
880
+ return `<table class="diag-table"><thead><tr><th>id</th><th>标题</th><th>页数</th><th>已出图</th><th>script/current.md</th></tr></thead><tbody>${rows}</tbody></table>`;
881
+ }
882
+
883
+ function renderDiagHtml(data, selfCheck) {
884
+ const dd = readDataDir(data, selfCheck);
885
+ const countBlock = dd.decks && !Array.isArray(dd.decks) ? dd.decks : null;
886
+ const counts = countBlock && Number.isFinite(Number(countBlock.registeredCount))
887
+ ? `注册 ${esc(countBlock.registeredCount)} · 磁盘 ${esc(countBlock.onDiskCount ?? "—")}`
888
+ + (Array.isArray(countBlock.onlyOnDisk) && countBlock.onlyOnDisk.length ? ` <span class="diag-warn">磁盘多出 ${esc(countBlock.onlyOnDisk.join("、"))}</span>` : "")
889
+ + (Array.isArray(countBlock.onlyRegistered) && countBlock.onlyRegistered.length ? ` <span class="diag-warn">注册多出 ${esc(countBlock.onlyRegistered.join("、"))}</span>` : "")
890
+ : (Array.isArray(dd.decks)
891
+ ? `注册 ${dd.decks.length} · 磁盘 <span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`
892
+ : `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`);
893
+ const renderText = dd.render && dd.render.mode
894
+ ? `${esc(dd.render.mode)}${dd.render.nodeName ? ` · ${esc(dd.render.nodeName)}` : ""}${dd.render.model ? ` / ${esc(dd.render.model)}` : ""}`
895
+ : `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`;
896
+ const envLine = dd.envOverride.active
897
+ ? `<p class="diag-note diag-warn"><b>${esc(ENV_READONLY_TEXT)}</b>要改请改这个环境变量后重启 DSH。</p>`
898
+ : "";
899
+ const pointerLine = dd.pointerFile && dd.pointerFile.path
900
+ ? `<dt>指针文件</dt><dd><code>${esc(dd.pointerFile.path)}</code> <span class="diag-src">${dd.pointerFile.exists ? "存在" : "尚未创建"}</span>${dd.pointerFile.error ? ` <span class="diag-bad">${esc(dd.pointerFile.error)}</span>` : ""}</dd>`
901
+ : "";
902
+ const rows = [
903
+ `<dt>进程 cwd</dt><dd>${dd.processCwd ? `<code>${esc(dd.processCwd)}</code>` : `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`}</dd>`,
904
+ `<dt>dataRoot</dt><dd>${diagPathCell(dd.dataRoot)}</dd>`,
905
+ `<dt>projectsRoot</dt><dd>${diagPathCell(dd.projectsRoot)}</dd>`,
906
+ `<dt>workspacePath</dt><dd>${diagPathCell(dd.workspacePath)}</dd>`,
907
+ `<dt>DSH_HOME</dt><dd>${diagPathCell(dd.dshHome)}</dd>`,
908
+ pointerLine,
909
+ `<dt>deck:注册 / 磁盘</dt><dd>${counts}</dd>`,
910
+ `<dt>出图</dt><dd>${renderText}</dd>`,
911
+ `<dt>版本</dt><dd>插件 ${esc(dd.versions.plugin || (data && data.version) || "—")} · DSH ${esc(dd.versions.dsh || "—")} · Node ${esc(dd.versions.node || "—")}</dd>`,
912
+ ].filter(Boolean).join("");
913
+ return `
914
+ <div class="diag-card${dd.envOverride.active ? " is-env" : ""}">
915
+ <div class="diag-root"><b>当前数据目录</b> <code>${esc(dd.dataRoot.value || dd.dataRoot.effective || "—")}</code>${diagSourceChip(dd.dataRoot)}</div>
916
+ <p class="diag-note">换端口不改数据,换 dataRoot 才改数据。</p>
917
+ ${envLine}
918
+ <div class="diag-actions">
919
+ <button class="btn alt" type="button" id="diagChange"${dd.envOverride.active ? " disabled" : ""}>改数据目录</button>
920
+ <span class="small">${dd.envOverride.active ? "env 覆盖时不可改(只读)" : "写 location.json,不用重启"}</span>
921
+ </div>
922
+ </div>
923
+ <div class="diag-card"><dl class="diag-grid">${rows}</dl></div>
924
+ <div class="diag-card"><div class="diag-root">每本 deck</div>${diagDeckTable(dd, data) || '<p class="diag-note">还没有 deck。</p>'}</div>`;
925
+ }
926
+
927
+ async function refreshDiagnostics() {
928
+ const stateEl = getEl("diagState");
929
+ if (stateEl) stateEl.textContent = "正在检测…";
930
+ let selfCheck = null;
931
+ try {
932
+ const fresh = await route("self-check");
933
+ if (fresh && fresh.ok !== false) selfCheck = fresh;
934
+ } catch {
935
+ /* 后端 self-check 未就绪:退回 workbench payload,缺的字段渲染「未知」 */
936
+ }
937
+ DIAG_DATA = selfCheck;
938
+ const box = getEl("diagBody");
939
+ if (box) box.innerHTML = renderDiagHtml(STATE.data, selfCheck);
940
+ const text = String((selfCheck && selfCheck.diagnosticsText) || "");
941
+ const copyBtn = getEl("diagCopy");
942
+ if (copyBtn) copyBtn.disabled = !text;
943
+ if (stateEl) stateEl.textContent = text ? "" : "后端还没提供脱敏诊断文本(GET self-check 未就绪),暂无内容可复制";
944
+ }
945
+
946
+ async function openDiagnostics() {
947
+ const modal = getEl("diagModal");
948
+ if (!modal) return;
949
+ modal.hidden = false;
950
+ await refreshDiagnostics();
951
+ }
952
+
953
+ function closeDiagnostics() {
954
+ const modal = getEl("diagModal");
955
+ if (modal) modal.hidden = true;
956
+ }
957
+
958
+ async function copyDiagnostics() {
959
+ const text = String((DIAG_DATA && DIAG_DATA.diagnosticsText) || "");
960
+ const stateEl = getEl("diagState");
961
+ if (!text) { if (stateEl) stateEl.textContent = "没有可复制的诊断文本"; return; }
962
+ try {
963
+ await navigator.clipboard.writeText(text);
964
+ if (stateEl) stateEl.textContent = "已复制(已脱敏,可直接粘贴给他人)";
965
+ return;
966
+ } catch {
967
+ /* 退回落选方案 */
968
+ }
969
+ const holder = document.createElement("textarea");
970
+ holder.value = text;
971
+ holder.style.position = "fixed";
972
+ holder.style.opacity = "0";
973
+ document.body.appendChild(holder);
974
+ holder.select();
975
+ try {
976
+ document.execCommand("copy");
977
+ if (stateEl) stateEl.textContent = "已复制(已脱敏)";
978
+ } catch {
979
+ if (stateEl) stateEl.textContent = "复制失败:浏览器不允许写剪贴板";
980
+ }
981
+ holder.remove();
982
+ }
983
+
984
+ /** 首次引导 / 自检面板「改数据目录」共用同一个表单(markup 见 task-3.3)。 */
985
+ function openDataDirForm(settingsMode = false) {
986
+ const modal = getEl("firstRunModal");
987
+ if (!modal) return;
988
+ const dd = readDataDir(DIAG_DATA || STATE.data);
989
+ if (dd.envOverride.active) {
990
+ flash(ENV_READONLY_TEXT, "error");
991
+ return;
992
+ }
993
+ FIRST_RUN_MODE = settingsMode ? "settings" : "first-run";
994
+ const dataInput = getEl("frDataRoot");
995
+ const projectsInput = getEl("frProjectsRoot");
996
+ if (dataInput) dataInput.value = dd.dataRoot.value || dd.dataRoot.effective || "";
997
+ if (projectsInput) projectsInput.value = dd.projectsRoot.value || "";
998
+ const submit = getEl("frSubmit");
999
+ if (submit) submit.textContent = settingsMode ? "保存并重新检测" : "就用这个位置";
1000
+ const stateEl = getEl("frState");
1001
+ if (stateEl) stateEl.textContent = "";
1002
+ syncFirstRunEcho();
1003
+ modal.hidden = false;
1004
+ }
1005
+
1006
+ function syncFirstRunEcho() {
1007
+ const dataRoot = (getEl("frDataRoot") && getEl("frDataRoot").value.trim()) || "";
1008
+ const projectsRoot = (getEl("frProjectsRoot") && getEl("frProjectsRoot").value.trim()) || "";
1009
+ if (getEl("frEchoData")) getEl("frEchoData").textContent = dataRoot || "—";
1010
+ if (getEl("frEchoProjects")) getEl("frEchoProjects").textContent = projectsRoot || (dataRoot ? dataRoot + "/projects" : "—");
1011
+ }
1012
+
1013
+ /** 只在真·首次弹:firstRun 由后端判定(decks 空 + 无 render-settings.json + 无 location.json)。 */
1014
+ function maybeFirstRun(data) {
1015
+ const dd = readDataDir(data);
1016
+ if (dd.envOverride.active || !dd.firstRun) return;
1017
+ const modal = getEl("firstRunModal");
1018
+ if (!modal || modal.hidden === false) return;
1019
+ try {
1020
+ if (sessionStorage.getItem(FIRST_RUN_FLAG) === "1") return;
1021
+ } catch {
1022
+ /* 隐私模式:当作没标记过 */
1023
+ }
1024
+ openDataDirForm(false);
1025
+ }
1026
+
1027
+ function dismissFirstRun() {
1028
+ const modal = getEl("firstRunModal");
1029
+ if (modal) modal.hidden = true;
1030
+ try { sessionStorage.setItem(FIRST_RUN_FLAG, "1"); } catch { /* ignore */ }
1031
+ }
1032
+
1033
+ /** POST data-dir:错误按 code 分支(env-override 409 / not-writable 400 / deck-not-there 409)。 */
1034
+ async function submitDataDir(event) {
1035
+ event.preventDefault();
1036
+ const stateEl = getEl("frState");
1037
+ const submit = getEl("frSubmit");
1038
+ const dataRoot = (getEl("frDataRoot") && getEl("frDataRoot").value.trim()) || "";
1039
+ const projectsRoot = (getEl("frProjectsRoot") && getEl("frProjectsRoot").value.trim()) || "";
1040
+ if (!dataRoot) {
1041
+ if (stateEl) stateEl.textContent = "先填数据目录的绝对路径(浏览器拿不到磁盘路径,需要粘贴)";
1042
+ return;
1043
+ }
1044
+ if (submit) submit.disabled = true;
1045
+ if (stateEl) stateEl.textContent = "正在校验并写入 location.json…";
1046
+ try {
1047
+ const res = await fetch("/api/deck-studio.data-dir", {
1048
+ method: "POST",
1049
+ headers: { "content-type": "application/json" },
1050
+ body: JSON.stringify({ dataRoot, projectsRoot, setBy: FIRST_RUN_MODE }),
1051
+ });
1052
+ const result = await res.json().catch(() => ({}));
1053
+ if (!res.ok || result.ok === false) {
1054
+ const code = (result.error && result.error.code) || "";
1055
+ if (code === "env-override") throw new Error(ENV_READONLY_TEXT + ",改环境变量后重启 DSH");
1056
+ if (code === "not-writable") throw new Error("这个目录不可写(没有权限或不在可写卷上):" + dataRoot);
1057
+ if (code === "bad-path") throw new Error("必须是绝对路径(浏览器拿不到磁盘路径,请粘贴完整路径):" + dataRoot);
1058
+ if (code === "not-found") throw new Error("这个目录不存在:" + dataRoot);
1059
+ if (code === "switching") throw new Error("数据目录切换中,请稍等一秒再点(上一次切换还没落完)");
1060
+ if (code === "deck-not-there") throw new Error("这个目录里没有这本 deck 的数据:" + dataRoot);
1061
+ throw new Error((result.error && result.error.message) || `HTTP ${res.status}`);
1062
+ }
1063
+ getEl("firstRunModal").hidden = true;
1064
+ try { sessionStorage.removeItem(FIRST_RUN_FLAG); } catch { /* ignore */ }
1065
+ STATE.docCache = {};
1066
+ STATE.sig = "";
1067
+ await loadWorkbench(STATE.deckId);
1068
+ if (getEl("diagModal") && getEl("diagModal").hidden === false) await refreshDiagnostics();
1069
+ flash("数据目录已设为 " + ((result.switched && result.switched.to) || dataRoot));
1070
+ } catch (error) {
1071
+ /* 就地报错:不回退默认值、不关闭弹层、保留用户输入(规格 §3) */
1072
+ if (stateEl) stateEl.textContent = "保存失败:" + error.message;
1073
+ } finally {
1074
+ if (submit) submit.disabled = false;
1075
+ }
1076
+ }
1077
+
1078
+ function bindDataDirUi() {
1079
+ document.addEventListener("click", (event) => {
1080
+ const target = event.target;
1081
+ if (!target || !target.closest) return;
1082
+ if (target.closest("#stOpenDiag")) { openDiagnostics(); return; }
1083
+ if (target.closest("#diagClose")) { closeDiagnostics(); return; }
1084
+ if (target.closest("#diagCopy")) { copyDiagnostics(); return; }
1085
+ if (target.closest("#diagRefresh")) { refreshDiagnostics(); return; }
1086
+ if (target.closest("#diagChange")) { openDirPicker({ mode: "switch" }); return; }
1087
+ if (target.closest("#frLater") || target.closest("#frClose")) { dismissFirstRun(); return; }
1088
+ });
1089
+ const form = getEl("firstRunForm");
1090
+ if (form) form.addEventListener("submit", submitDataDir);
1091
+ ["frDataRoot", "frProjectsRoot"].forEach((id) => {
1092
+ const input = getEl(id);
1093
+ if (input) input.addEventListener("input", syncFirstRunEcho);
1094
+ });
1095
+ const firstRun = getEl("firstRunModal");
1096
+ if (firstRun) firstRun.addEventListener("click", (event) => { if (event.target === firstRun) dismissFirstRun(); });
1097
+ const diag = getEl("diagModal");
1098
+ if (diag) diag.addEventListener("click", (event) => { if (event.target === diag) closeDiagnostics(); });
1099
+ }
1100
+
1101
+ /* ---------------- 数据目录选择器(P2.5:别再让人手打绝对路径) ----------------
1102
+ * 接口:GET /api/deck-studio.dir-browse?path=<绝对路径> → { ok, dir:{path,parent,isRoot,truncated,dirs:[{name,path,hidden,hasScript,deckCount,isDataRoot}]} }
1103
+ * 错误:400 bad-path / not-found / not-a-dir、409 switching、500 browse-failed —— 按 error.code 分支,message 只做兜底。
1104
+ * 状态:只活在一次弹窗打开期间(DIR_PICKER),关闭即丢弃;每次打开都重新拉接口,不写 STATE、不缓存目录。 */
1105
+ // setBy 用既有值 "settings"(后端已认这个枚举);想在看板里区分「选择器切换」再换成 "dir-picker",需先确认后端不校验枚举。
1106
+ const DIR_PICKER_SWITCH_SETBY = "settings";
1107
+ let DIR_PICKER = null;
1108
+
1109
+ function dirPickerErrText(code, message, path) {
1110
+ if (code === "bad-path") return "要填绝对路径,例如 /Users/你/.dsh/deckline";
1111
+ if (code === "not-found") return "这个目录不存在:" + (path || "");
1112
+ if (code === "not-a-dir") return "这是一个文件,不是目录:" + (path || "");
1113
+ if (code === "switching") return "数据目录切换中,等一秒再试";
1114
+ if (code === "browse-failed") return "读目录失败:" + (message || "服务端报错,稍后再试");
1115
+ return message || "读取目录失败";
1116
+ }
1117
+
1118
+ /** 打开选择器:mode=fill 把选中的路径填回某个输入框;mode=switch 直接在线切换。 */
1119
+ function openDirPicker(options = {}) {
1120
+ const modal = getEl("dirPickerModal");
1121
+ if (!modal) return;
1122
+ const dd = readDataDir(DIAG_DATA || STATE.data);
1123
+ if (dd.envOverride.active) {
1124
+ // env 覆盖时目录是只读的,选也没用——直接说清楚,不弹一个点不动的界面。
1125
+ flash(ENV_READONLY_TEXT + ",改环境变量后重启 DSH", "error");
1126
+ return;
1127
+ }
1128
+ DIR_PICKER = {
1129
+ mode: options.mode === "switch" ? "switch" : "fill",
1130
+ targetId: options.targetId || "frDataRoot",
1131
+ path: options.path || dd.dataRoot.value || dd.dataRoot.effective || "",
1132
+ data: null,
1133
+ loading: true,
1134
+ error: "",
1135
+ showHidden: false,
1136
+ };
1137
+ modal.hidden = false;
1138
+ renderDirPicker();
1139
+ loadDirPicker(DIR_PICKER.path);
1140
+ }
1141
+
1142
+ function closeDirPicker() {
1143
+ const modal = getEl("dirPickerModal");
1144
+ if (modal) modal.hidden = true;
1145
+ DIR_PICKER = null;
1146
+ }
1147
+
1148
+ /** 每次进目录都重新拉接口(不缓存、不猜父子关系,parent 由后端给)。 */
1149
+ async function loadDirPicker(path) {
1150
+ if (!DIR_PICKER) return;
1151
+ DIR_PICKER.loading = true;
1152
+ DIR_PICKER.error = "";
1153
+ DIR_PICKER.path = path || DIR_PICKER.path;
1154
+ renderDirPicker();
1155
+ try {
1156
+ const query = DIR_PICKER.path ? "?path=" + encodeURIComponent(DIR_PICKER.path) : "";
1157
+ const res = await fetch("/api/deck-studio.dir-browse" + query, { headers: { accept: "application/json" } });
1158
+ let payload = {};
1159
+ try { payload = await res.json(); } catch { /* 非 JSON 响应走下面兜底 */ }
1160
+ if (!res.ok || !payload?.ok) {
1161
+ const failure = new Error(payload?.error?.message || "HTTP " + res.status);
1162
+ failure.code = payload?.error?.code || "";
1163
+ throw failure;
1164
+ }
1165
+ DIR_PICKER.data = payload.dir || null;
1166
+ DIR_PICKER.path = payload.dir?.path || DIR_PICKER.path;
1167
+ } catch (error) {
1168
+ DIR_PICKER.error = dirPickerErrText(error?.code, error?.message, DIR_PICKER.path);
1169
+ DIR_PICKER.data = null;
1170
+ } finally {
1171
+ DIR_PICKER.loading = false;
1172
+ renderDirPicker();
1173
+ }
1174
+ }
1175
+
1176
+ /** 渲染弹窗内容(每次都从 DIR_PICKER + 接口返回值算,不保留上一步的 DOM)。 */
1177
+ function renderDirPicker() {
1178
+ const body = getEl("dirPickerBody");
1179
+ if (!body || !DIR_PICKER) return;
1180
+ const st = DIR_PICKER;
1181
+ const dir = st.data;
1182
+ const dirs = Array.isArray(dir?.dirs) ? dir.dirs : [];
1183
+ // 有 deck 的目录排前面(最可能是用户要找的那个),当前数据目录额外标「当前」。
1184
+ const chips = dirs
1185
+ .filter((item) => Number(item.deckCount) > 0 || item.isDataRoot)
1186
+ .sort((a, b) => (Number(b.deckCount) || 0) - (Number(a.deckCount) || 0));
1187
+ const chipHtml = chips.length
1188
+ ? `<div class="dir-picker-chips">${chips.map((item) => `<button type="button" class="dir-picker-chip${item.isDataRoot ? " is-current" : ""}" data-dir-path="${esc(item.path)}" title="${esc(item.path)}">📁 ${esc(item.name)}${item.isDataRoot ? ' <span class="dir-picker-tag">当前</span>' : ""}${Number(item.deckCount) > 0 ? ` <span class="dir-picker-count">${Number(item.deckCount)} 本 deck</span>` : ""}</button>`).join("")}</div>`
1189
+ : "";
1190
+ const visible = dirs.filter((item) => st.showHidden || !item.hidden);
1191
+ const rowsHtml = visible.length
1192
+ ? visible.map((item) => `<button type="button" class="dir-picker-row" data-dir-path="${esc(item.path)}" title="${esc(item.path)}"><span class="dir-picker-name">📁 ${esc(item.name)}</span>${Number(item.deckCount) > 0 ? `<span class="dir-picker-meta">${Number(item.deckCount)} 本 deck</span>` : ""}${item.isDataRoot ? '<span class="dir-picker-meta dir-picker-tag">当前数据目录</span>' : ""}</button>`).join("")
1193
+ : `<div class="dir-picker-empty">${dir ? "这个目录下没有子目录。" : "还没读到目录。"}</div>`;
1194
+ const hiddenCount = dirs.filter((item) => item.hidden).length;
1195
+ body.innerHTML = `
1196
+ <div class="dir-picker-path">当前:<code>${esc(dir?.path || st.path || "—")}</code></div>
1197
+ <div class="dir-picker-bar">
1198
+ <button class="btn ghost" type="button" id="dirPickerUp"${dir?.parent ? "" : " disabled"}>↑ 上一级</button>
1199
+ <span class="small">${dir?.truncated ? "子目录太多,只显示了一部分" : ""}</span>
1200
+ </div>
1201
+ ${chipHtml}
1202
+ <div class="dir-picker-list">${rowsHtml}</div>
1203
+ ${hiddenCount > 0 ? `<label class="dir-picker-toggle"><input type="checkbox" id="dirPickerHidden"${st.showHidden ? " checked" : ""}> 显示隐藏目录(${hiddenCount} 个以 . 开头)</label>` : ""}
1204
+ ${st.error ? `<p class="dir-picker-error">${esc(st.error)}</p>` : ""}`;
1205
+ const useBtn = getEl("dirPickerUse");
1206
+ if (useBtn) useBtn.disabled = st.loading || !dir;
1207
+ const stateEl = getEl("dirPickerState");
1208
+ if (stateEl) {
1209
+ stateEl.textContent = st.loading
1210
+ ? "正在读目录…"
1211
+ : st.mode === "switch"
1212
+ ? "选中后立刻切换(不用重启)"
1213
+ : "选中后只填回表单,不会立刻切换";
1214
+ }
1215
+ }
1216
+
1217
+ /** 点「用这个目录」:fill → 填回输入框;switch → 复用 switchDataDirTo()。 */
1218
+ async function confirmDirPicker() {
1219
+ const st = DIR_PICKER;
1220
+ if (!st || st.loading || !st.data) return;
1221
+ const path = st.data.path;
1222
+ if (st.mode === "fill") {
1223
+ const targetId = st.targetId;
1224
+ closeDirPicker();
1225
+ const wizard = getEl("firstRunModal");
1226
+ const wizardOpen = wizard && wizard.hidden === false;
1227
+ if (!wizardOpen) openDataDirForm(true); // 从设置页进来:借首次引导那张表单承接,保存流程完全不变
1228
+ const input = getEl(targetId);
1229
+ if (input) input.value = path;
1230
+ syncFirstRunEcho();
1231
+ const stateEl = getEl("frState");
1232
+ if (stateEl) stateEl.textContent = "已选好目录,点「保存并重新检测」生效。";
1233
+ return;
1234
+ }
1235
+ const useBtn = getEl("dirPickerUse");
1236
+ if (useBtn) useBtn.disabled = true;
1237
+ const stateEl = getEl("dirPickerState");
1238
+ if (stateEl) stateEl.textContent = "正在切换…";
1239
+ const ok = await switchDataDirTo(path, {
1240
+ setBy: DIR_PICKER_SWITCH_SETBY,
1241
+ onError: (error) => {
1242
+ // 失败停在弹窗里(不 flash 掉就没了),按 code 给人话;用户还能换个目录重试。
1243
+ if (!DIR_PICKER) return;
1244
+ DIR_PICKER.error = dirPickerErrText(error?.code, error?.message, path);
1245
+ renderDirPicker();
1246
+ },
1247
+ });
1248
+ if (useBtn) useBtn.disabled = false;
1249
+ if (ok) {
1250
+ closeDirPicker();
1251
+ // 切换后自检面板还开着的话(从 ⚙ →「改数据目录」进来),把表里的路径与 deck 状态一起刷新。
1252
+ const diagOpen = getEl("diagModal");
1253
+ if (diagOpen && diagOpen.hidden === false) await refreshDiagnostics();
1254
+ }
1255
+ }
1256
+
1257
+ function bindDirPickerUi() {
1258
+ document.addEventListener("click", (event) => {
1259
+ const target = event.target;
1260
+ if (!target || !target.closest) return;
1261
+ if (target.closest("#stPickDir") || target.closest("#frPickData")) { openDirPicker({ mode: "fill", targetId: "frDataRoot" }); return; }
1262
+ if (target.closest("#frPickProjects")) { openDirPicker({ mode: "fill", targetId: "frProjectsRoot" }); return; }
1263
+ if (target.closest("#dirPickerClose") || target.closest("#dirPickerCancel")) { closeDirPicker(); return; }
1264
+ if (target.closest("#dirPickerUse")) { confirmDirPicker(); return; }
1265
+ if (target.closest("#dirPickerUp")) { if (DIR_PICKER?.data?.parent) loadDirPicker(DIR_PICKER.data.parent); return; }
1266
+ const row = target.closest("[data-dir-path]");
1267
+ if (row && row.closest("#dirPickerModal")) { loadDirPicker(row.dataset.dirPath); return; }
1268
+ });
1269
+ document.addEventListener("change", (event) => {
1270
+ const target = event.target;
1271
+ if (target && target.id === "dirPickerHidden" && DIR_PICKER) {
1272
+ DIR_PICKER.showHidden = target.checked === true;
1273
+ renderDirPicker();
1274
+ }
1275
+ });
1276
+ document.addEventListener("keydown", (event) => {
1277
+ const modal = getEl("dirPickerModal");
1278
+ if (!modal || modal.hidden) return;
1279
+ if (event.key === "Escape") { event.preventDefault(); closeDirPicker(); return; }
1280
+ if (event.key === "Enter") { event.preventDefault(); confirmDirPicker(); }
1281
+ });
1282
+ const modal = getEl("dirPickerModal");
1283
+ if (modal) modal.addEventListener("click", (event) => { if (event.target === modal) closeDirPicker(); });
1284
+ }
1285
+
641
1286
  /* ---------------- 渲染 ---------------- */
642
1287
  /** 切右栏标签(Library / Context / Inspector / 设置)。F-131:进 deck、点页面默认给 Inspector。 */
643
1288
  function activateRightTab(name) {
@@ -717,6 +1362,89 @@ function renderChrome() {
717
1362
  // 首页右栏给 Library(deck 列表 / 文档);只有 deck 工作区才默认 Inspector,且不覆盖用户手动选择
718
1363
  if (STATE.view === "home" && !STATE.rightTab) activateRightTab("library");
719
1364
  if (STATE.view === "deck" && !STATE.selected && (active.slides || []).length) selectSlide(active.slides[0].id);
1365
+ renderDataDirBanner(data);
1366
+ }
1367
+
1368
+ /* P0:数据目录黄条(事实由后端 /api/deck-studio.data-dir 给,文案按规格 §2 渲染,非模态不挡操作)。 */
1369
+ function renderDataDirBanner(data) {
1370
+ const box = getEl("dataDirBanner");
1371
+ if (!box) return;
1372
+ const dd = data?.dataDir;
1373
+ const banner = dd?.banner;
1374
+ if (!banner?.show) {
1375
+ box.hidden = true;
1376
+ box.innerHTML = "";
1377
+ return;
1378
+ }
1379
+ const envOverride = dd?.envOverride?.active === true;
1380
+ const title = banner.scope === "home" && (banner.affectedDecks ?? 0) > 1
1381
+ ? `⚠ 有 ${banner.affectedDecks} 本 deck 的正文和图片不在当前数据目录`
1382
+ : banner.kind === "partial"
1383
+ ? "⚠ 这本 deck 的图片在,正文(script)丢了"
1384
+ : "⚠ 这本 deck 的正文和图片不在当前数据目录";
1385
+ const currentRoot = dd?.dataRoot?.value || banner.dataRoot || "—";
1386
+ const sourceNote = dd?.sourceLabel ? `(${esc(dd.sourceLabel)})` : "";
1387
+ const candidates = banner.candidates || [];
1388
+ const rows = candidates.length
1389
+ ? candidates.map((candidate) => `<div class="data-dir-candidate"><span class="data-dir-path">已发现:${esc(candidate.dataRoot)}</span><span class="data-dir-count">(${Number(candidate.deckCount) || 0} 本 deck · ${Number(candidate.imageCount) || 0} 张图)</span><button class="btn ghost" data-data-root="${esc(candidate.dataRoot)}"${envOverride ? " disabled" : ""}>切到这个目录</button></div>`).join("")
1390
+ : `<div class="data-dir-candidate data-dir-empty"><span>没找到这本 deck 的历史数据。如果你在另一个 DSH 实例里出过图,请在那个实例的『设置』里看它显示的数据目录,然后把它填到下面。</span><input id="dataDirManualInput" placeholder="/绝对路径"${envOverride ? " disabled" : ""}><button class="btn ghost" id="dataDirManualSwitch"${envOverride ? " disabled" : ""}>用这个目录</button></div>`;
1391
+ box.innerHTML = `<div class="data-dir-banner-body"><div class="data-dir-banner-main"><b>${title}</b><span class="data-dir-line" title="换端口不改数据,换 dataRoot 才改数据。">当前:<code>${esc(currentRoot)}</code>${sourceNote}</span>${rows}</div><div class="data-dir-banner-actions"><button class="btn ghost" id="dataDirOpenSettings">打开设置</button><button class="btn ghost" id="dataDirCopyDiag">复制诊断信息</button>${envOverride ? `<span class="data-dir-readonly">由环境变量 ${esc(dd?.envOverride?.name || "DSH_DECK_DATA_ROOT")} 指定(只读)</span>` : ""}</div></div>`;
1392
+ box.hidden = false;
1393
+ box.querySelectorAll("[data-data-root]").forEach((node) => node.addEventListener("click", () => switchDataDirTo(node.dataset.dataRoot)));
1394
+ box.querySelector("#dataDirOpenSettings")?.addEventListener("click", () => openSettingsPanel());
1395
+ box.querySelector("#dataDirCopyDiag")?.addEventListener("click", copyDataDirDiagnostics);
1396
+ box.querySelector("#dataDirManualSwitch")?.addEventListener("click", () => {
1397
+ const value = (getEl("dataDirManualInput")?.value || "").trim();
1398
+ if (!value) {
1399
+ flash("先填一个绝对路径", "error");
1400
+ return;
1401
+ }
1402
+ switchDataDirTo(value);
1403
+ });
1404
+ }
1405
+
1406
+ /** 点「切到这个目录」:写指针 → 后端重建服务 → 重拉工作台(不整页刷新、不清会话)。 */
1407
+ async function switchDataDirTo(dataRoot, options = {}) {
1408
+ const deckId = STATE.data?.active?.deckId ?? null;
1409
+ const setBy = options.setBy || "mismatch-banner";
1410
+ try {
1411
+ const res = await fetch("/api/deck-studio.data-dir", {
1412
+ method: "POST",
1413
+ headers: { "content-type": "application/json" },
1414
+ body: JSON.stringify({ dataRoot, setBy, deckId }),
1415
+ });
1416
+ let payload = {};
1417
+ try { payload = await res.json(); } catch { /* 非 JSON 响应走下面兜底 */ }
1418
+ if (!res.ok || !payload?.ok) {
1419
+ const failure = new Error(payload?.error?.message || "HTTP " + res.status);
1420
+ failure.code = payload?.error?.code || "";
1421
+ throw failure;
1422
+ }
1423
+ STATE.docCache = {};
1424
+ STATE.sig = "";
1425
+ flash("已切到 " + (payload?.dataDir?.dataRoot?.value || dataRoot));
1426
+ await loadWorkbench(STATE.deckId);
1427
+ if (STATE.view === "home") renderHome();
1428
+ return true;
1429
+ } catch (error) {
1430
+ // 选择器要把 code 翻成人话后留在弹窗里,所以它传 onError 接管提示;其它调用方照旧 flash。
1431
+ if (typeof options.onError === "function") { options.onError(error); return false; }
1432
+ flash("切换失败:" + (error?.message ?? error), "error");
1433
+ return false;
1434
+ }
1435
+ }
1436
+
1437
+ /** 复制诊断信息:文本由后端 /self-check 生成(脱敏口径只有一份),前端不重拼。 */
1438
+ async function copyDataDirDiagnostics() {
1439
+ try {
1440
+ const res = await fetch("/api/deck-studio.self-check", { headers: { accept: "application/json" } });
1441
+ const payload = await res.json();
1442
+ if (!payload?.ok) throw new Error(payload?.error?.message || "自检失败");
1443
+ await navigator.clipboard.writeText(payload.diagnosticsText || "");
1444
+ flash("诊断信息已复制");
1445
+ } catch (error) {
1446
+ flash("复制失败:" + (error?.message ?? error), "error");
1447
+ }
720
1448
  }
721
1449
 
722
1450
  /* ---------------- 首页(左侧导航 + 右侧分区 blocks) ---------------- */
@@ -861,8 +1589,10 @@ function renderHome() {
861
1589
  }
862
1590
 
863
1591
  function renderHomeDecks(decks, active, gov) {
864
- if (!decks.length) return `<div class="home-empty">还没有 deck。点左下「+ 新建 Deck」开始。</div>`;
865
- return `<div class="section-head"><h2>Decks</h2><span class="small">${decks.length} 个</span></div>
1592
+ const dd = readDataDir(STATE.data);
1593
+ const rootChip = `<span class="diag-root-chip" title="${esc(dd.dataRoot.value || dd.dataRoot.effective || "数据目录未知")}">当前数据目录 <b>${esc(shortPath(dd.dataRoot.value || dd.dataRoot.effective))}</b>${dd.envOverride.active ? " · env" : ""}</span>`;
1594
+ if (!decks.length) return `<div class="section-head"><h2>Decks</h2><span class="small">0 个</span>${rootChip}</div><div class="home-empty">还没有 deck。点左下「+ 新建 Deck」开始。</div>`;
1595
+ return `<div class="section-head"><h2>Decks</h2><span class="small">${decks.length} 个</span>${rootChip}</div>
866
1596
  <div class="home-blocks">${decks.map((deck) => {
867
1597
  const isActive = deck.deckId === active.deckId;
868
1598
  // F-52:每张卡用自己的文档状态(后端 decks[].documents / missing / score),
@@ -1169,7 +1899,8 @@ async function checkPluginUpdate() {
1169
1899
  async function openSettingsPanel() {
1170
1900
  const deckId = STATE.data?.active?.deckId;
1171
1901
  if (!deckId) {
1172
- dlToast("先在 Decks 里打开一本 deck,右栏就有设置(出图节点 / 模型 / 尺寸)", 6000);
1902
+ // 0 deck / 真·首次 / mismatch:设置内容就是数据目录自检面板,别再 toast 早退。
1903
+ await openDiagnostics();
1173
1904
  return;
1174
1905
  }
1175
1906
  if (STATE.view === "home") await showDeck(deckId);
@@ -2635,7 +3366,25 @@ async function fillInspectorBody(pageId) {
2635
3366
  getEl("insBody").value = view.body || slide.body || "";
2636
3367
  getEl("insPrompt").value = view.prompt || slide.visualBrief || "";
2637
3368
  } catch (error) {
2638
- if (STATE.selected === pageId) getEl("inspectorStatus").textContent = "读取正文失败:" + error.message;
3369
+ if (STATE.selected !== pageId) return;
3370
+ // 实例指到了不同的 dataRoot 时,这里只会拿到 page not found:
3371
+ // 只说「读取正文失败」会被当成页面 bug,所以带上 dataRoot / deckId 与诊断入口。
3372
+ const insDataDirRoot = STATE.data?.dataDir?.dataRoot;
3373
+ const insDataRoot = (typeof insDataDirRoot === "string" ? insDataDirRoot : insDataDirRoot?.value) || STATE.data?.config?.dataRoot || "(未知,见设置页)";
3374
+ const pageMissing = /page not found/i.test(String(error?.message || ""));
3375
+ getEl("inspectorStatus").innerHTML = pageMissing
3376
+ ? `读取正文失败:当前数据目录没有这本 deck 的 script(dataRoot=${esc(insDataRoot)},deckId=${esc(deckId)},pageId=${esc(pageId)})`
3377
+ + `<br>换端口不改数据,换 dataRoot 才改数据:先核对 ⚙ 里的「当前数据目录」,是不是出图时用的那个。`
3378
+ + `<div style="margin-top:8px"><button type="button" class="btn alt" id="insOpenDiagnostics">打开诊断</button></div>`
3379
+ : `读取正文失败:${esc(String(error?.message || error))}<br>dataRoot=${esc(insDataRoot)},deckId=${esc(deckId)},pageId=${esc(pageId)}`;
3380
+ const insDiagBtn = document.getElementById("insOpenDiagnostics");
3381
+ if (insDiagBtn) {
3382
+ insDiagBtn.addEventListener("click", () => {
3383
+ // openDiagnostics() 无 deck 也能开;尚未就绪时退回设置面板
3384
+ if (typeof openDiagnostics === "function") openDiagnostics();
3385
+ else openSettingsPanel();
3386
+ });
3387
+ }
2639
3388
  }
2640
3389
  }
2641
3390
 
@@ -2736,6 +3485,7 @@ function renderSettings(data) {
2736
3485
  const focused = document.activeElement;
2737
3486
  if (SETTINGS_FORM_DIRTY || (focused && focused.closest && focused.closest(".node-editor"))) return;
2738
3487
  const config = data.config || {};
3488
+ const dd = readDataDir(data);
2739
3489
  const renderNodes = data.active?.renderNodes || { nodes: [], active: null };
2740
3490
  const settings = data.renderSettings || {};
2741
3491
  // 设置页里正在编辑的节点列表(以服务端 payload 为种子;保存时整表提交)
@@ -2825,7 +3575,12 @@ function renderSettings(data) {
2825
3575
  仓库和 profile 里都不会出现你的 key 或节点地址。</p>
2826
3576
  </div>
2827
3577
  <h3 class="settings-title">其它</h3>
2828
- <div class="field"><label>Data root(.deck)</label><input value="${esc(config.dataRoot)}" readonly></div>`;
3578
+ <div class="diag-card${dd.envOverride.active ? " is-env" : ""}">
3579
+ <div class="diag-root">当前数据目录:<code>${esc(shortPath(dd.dataRoot.value || dd.dataRoot.effective, 62))}</code>${diagSourceChip(dd.dataRoot)}</div>
3580
+ <p class="diag-note">换端口不改数据,换 dataRoot 才改数据。</p>
3581
+ ${dd.envOverride.active ? `<p class="diag-note diag-warn">${esc(ENV_READONLY_TEXT)}</p>` : ""}
3582
+ <div class="diag-actions"><button class="btn alt" type="button" id="stOpenDiag">打开自检面板</button><button class="btn" type="button" id="stPickDir">选择…</button></div>
3583
+ </div>`;
2829
3584
  getEl("rsActive")?.setAttribute("title", "当前 deck 生效的节点 / 模型(切换后下一次出图生效)");
2830
3585
  fillSettingsNodeForm();
2831
3586
  bindRenderSettingsForm();
@@ -4859,6 +5614,9 @@ function backToManual(box) {
4859
5614
  getEl("embedBack").addEventListener("click", () => window.parent?.postMessage({ type: "dsh-deck-studio:close" }, "*"));
4860
5615
  await loadWorkbench();
4861
5616
  absorbUpdate(STATE.data);
5617
+ bindDataDirUi();
5618
+ bindDirPickerUi();
5619
+ maybeFirstRun(STATE.data);
4862
5620
  getEl("settingsBtn").addEventListener("click", openSettingsPanel);
4863
5621
  getEl("updateBtn").addEventListener("click", checkPluginUpdate);
4864
5622
  getEl("repoBtn").addEventListener("click", openRepo);