dsh-plugin-deck-studio 0.2.10 → 0.2.13
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/assets/icon.svg +13 -0
- package/client.js +39 -15
- package/index.js +538 -20
- package/lib/config.js +16 -12
- package/lib/data-dir-detect.js +381 -0
- package/lib/data-dir.js +288 -0
- package/package.json +3 -1
- package/seeds/docs/assets/screenshots/f192-01-plugin-icon.png +0 -0
- package/seeds/docs/assets/screenshots/f192-02-icon-sizes.png +0 -0
- package/seeds/docs/assets/screenshots/f197-01-deckline-entry.png +0 -0
- package/seeds/docs/assets/screenshots/f197-02-deckline-icon.png +0 -0
- package/seeds/docs/install.en.md +63 -5
- package/seeds/docs/install.md +66 -6
- package/ui/workbench.css +38 -0
- package/ui/workbench.html +913 -15
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>
|
|
@@ -117,6 +134,11 @@
|
|
|
117
134
|
<button class="home-nav-item" data-section="resources"><span class="hn-icon">◇</span><span class="hn-label">资源库</span><em id="navCountResources">0</em></button>
|
|
118
135
|
<button class="home-nav-item" data-section="conversations"><span class="hn-icon">☰</span><span class="hn-label">Conversations</span><em id="navCountConvs">0</em></button>
|
|
119
136
|
<button class="home-nav-item" data-section="manual"><span class="hn-icon">?</span><span class="hn-label">使用手册</span></button>
|
|
137
|
+
<!-- F-193/F-194:关于只留一页入口;版本 chip + 有新版小圆点仍常驻,升级入口不藏在顶栏图标里 -->
|
|
138
|
+
<div class="home-nav-sep" aria-hidden="true"></div>
|
|
139
|
+
<button class="home-nav-item" data-section="about" id="aboutNavBtn" title="关于 Deckline:版本 / 公众号 / 开源地址 / 其他项目">
|
|
140
|
+
<span class="hn-icon">☺</span><span class="hn-label">关于</span><em id="navUpdateVer">—</em><i class="hn-dot" id="navUpdateDot" hidden></i>
|
|
141
|
+
</button>
|
|
120
142
|
</nav>
|
|
121
143
|
</aside>
|
|
122
144
|
<main class="home-main">
|
|
@@ -215,6 +237,35 @@
|
|
|
215
237
|
</div>
|
|
216
238
|
</div>
|
|
217
239
|
|
|
240
|
+
<!-- P1:首次引导(真·首次才弹:decks 空 && 无 render-settings.json && 无 location.json —— 判定在服务端) -->
|
|
241
|
+
<div id="firstRunModal" class="modal" hidden>
|
|
242
|
+
<div class="modal-card" style="width:min(620px,94vw)">
|
|
243
|
+
<div class="modal-head">
|
|
244
|
+
<div><div class="eyebrow">Welcome</div><h2>先把数据放在哪定下来</h2></div>
|
|
245
|
+
<button class="modal-close" id="frClose" title="稍后再说">✕</button>
|
|
246
|
+
</div>
|
|
247
|
+
<form class="modal-body" id="firstRunForm">
|
|
248
|
+
<p class="engine-note">换端口不改数据,换 dataRoot 才改数据。这里一次定两件事,之后随时能在设置里改。</p>
|
|
249
|
+
<label class="field"><span>① 数据放哪(工作副本 / 出图 / 事件)</span>
|
|
250
|
+
<span class="dir-picker-field"><input id="frDataRoot" autocomplete="off" placeholder="绝对路径,例:/Users/你/.dsh/deckline"><button type="button" class="btn ghost" id="frPickData">选择…</button></span>
|
|
251
|
+
</label>
|
|
252
|
+
<label class="field"><span>② 新建 deck 的源放哪(projectsRoot,可留空)</span>
|
|
253
|
+
<span class="dir-picker-field"><input id="frProjectsRoot" autocomplete="off" placeholder="留空 = 数据目录下的 projects"><button type="button" class="btn ghost" id="frPickProjects">选择…</button></span>
|
|
254
|
+
</label>
|
|
255
|
+
<div class="diag-card">
|
|
256
|
+
<div class="diag-root">数据目录:<code id="frEchoData">—</code></div>
|
|
257
|
+
<div class="diag-root">源目录:<code id="frEchoProjects">—</code></div>
|
|
258
|
+
<p class="diag-note">备份就是复制这两个目录。</p>
|
|
259
|
+
</div>
|
|
260
|
+
<p class="small" id="frState"></p>
|
|
261
|
+
<div class="nd-foot"><span class="spacer"></span>
|
|
262
|
+
<button type="button" class="btn alt" id="frLater">稍后再说</button>
|
|
263
|
+
<button type="submit" class="btn" id="frSubmit">就用这个位置</button>
|
|
264
|
+
</div>
|
|
265
|
+
</form>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
218
269
|
<button id="embedBack" class="embed-back" hidden>← 回到对话</button>
|
|
219
270
|
|
|
220
271
|
<div id="workspaceView" class="layout">
|
|
@@ -423,6 +474,85 @@
|
|
|
423
474
|
</div>
|
|
424
475
|
|
|
425
476
|
|
|
477
|
+
<!-- P2:数据目录自检面板(独立模态;首页 ⚙ / 无 deck / mismatch 时进这里) -->
|
|
478
|
+
<style id="diag-style">
|
|
479
|
+
.diag-card{border:1px solid #e3e1d8;border-radius:10px;padding:12px 14px;margin-bottom:12px;background:#fff}
|
|
480
|
+
.diag-card.is-env{border-color:#e0b96a;background:#fdf7e8}
|
|
481
|
+
.diag-root{font-size:13px;line-height:1.5;word-break:break-all}
|
|
482
|
+
.diag-root code{font-size:12px}
|
|
483
|
+
.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}
|
|
484
|
+
.diag-src.is-env{border-color:#d8a54a;color:#8a5a00;background:#fdf3dd}
|
|
485
|
+
.diag-note{margin:8px 0 0;font-size:12px;color:#5f6b6d}
|
|
486
|
+
.diag-grid{display:grid;grid-template-columns:132px minmax(0,1fr);gap:4px 10px;font-size:12px;margin:8px 0 0}
|
|
487
|
+
.diag-grid dt{color:#728086}
|
|
488
|
+
.diag-grid dd{margin:0;word-break:break-all;color:#18343b}
|
|
489
|
+
.diag-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px}
|
|
490
|
+
.diag-table{width:100%;border-collapse:collapse;font-size:11.5px;margin-top:8px}
|
|
491
|
+
.diag-table th{text-align:left;color:#728086;font-weight:600;border-bottom:1px solid #e3e1d8;padding:3px 6px;white-space:nowrap}
|
|
492
|
+
.diag-table td{padding:3px 6px;border-bottom:1px dashed #eee7d9;word-break:break-all}
|
|
493
|
+
.diag-bad{color:#b3261e}
|
|
494
|
+
.diag-warn{color:#b3591e}
|
|
495
|
+
.diag-ok{color:#1c7c46}
|
|
496
|
+
.diag-root-chip{margin-left:10px;font-size:11.5px;color:#6b7b7a;cursor:default}
|
|
497
|
+
.diag-root-chip b{color:#0f6975;font-weight:600}
|
|
498
|
+
#diagModal .modal-card{width:min(760px,94vw)}
|
|
499
|
+
#diagModal .modal-body{max-height:72vh;overflow:auto}
|
|
500
|
+
/* P2.5:数据目录选择器(只用 .dir-picker-* 前缀,不碰已有类名) */
|
|
501
|
+
.dir-picker-path{font-size:12.5px;line-height:1.55;word-break:break-all;color:#18343b}
|
|
502
|
+
.dir-picker-path code{font-size:12px}
|
|
503
|
+
.dir-picker-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0 4px}
|
|
504
|
+
.dir-picker-chips{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 12px}
|
|
505
|
+
.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}
|
|
506
|
+
.dir-picker-chip:hover{border-color:#0f6975;color:#0f6975}
|
|
507
|
+
.dir-picker-chip.is-current{border-color:#0f6975;background:#e9f4f3}
|
|
508
|
+
.dir-picker-tag{font-size:10.5px;color:#0f6975}
|
|
509
|
+
.dir-picker-count{font-size:10.5px;color:#8a7a58}
|
|
510
|
+
.dir-picker-list{border:1px solid #e3e1d8;border-radius:10px;max-height:42vh;overflow:auto;background:#fff}
|
|
511
|
+
.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}
|
|
512
|
+
.dir-picker-row:last-child{border-bottom:0}
|
|
513
|
+
.dir-picker-row:hover{background:#f6f9f8}
|
|
514
|
+
.dir-picker-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
515
|
+
.dir-picker-meta{flex:none;color:#728086;font-size:11px}
|
|
516
|
+
.dir-picker-empty{padding:14px 12px;color:#8a9599;font-size:12px}
|
|
517
|
+
.dir-picker-toggle{display:flex;gap:6px;align-items:center;margin:10px 0 0;font-size:12px;color:#5f6b6d;cursor:pointer}
|
|
518
|
+
.dir-picker-error{margin:10px 0 0;font-size:12px;color:#b3261e;word-break:break-all}
|
|
519
|
+
.dir-picker-field{display:flex;gap:8px;align-items:center}
|
|
520
|
+
.dir-picker-field input{flex:1 1 auto;min-width:0}
|
|
521
|
+
#dirPickerModal .modal-card{width:min(680px,94vw)}
|
|
522
|
+
#dirPickerModal .modal-body{max-height:72vh;overflow:auto}
|
|
523
|
+
</style>
|
|
524
|
+
|
|
525
|
+
<div id="diagModal" class="modal" hidden>
|
|
526
|
+
<div class="modal-card">
|
|
527
|
+
<div class="modal-head">
|
|
528
|
+
<div><div class="eyebrow">Data Directory</div><h2>当前数据目录与自检</h2></div>
|
|
529
|
+
<button class="modal-close" id="diagClose" title="关闭">✕</button>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="modal-body" id="diagBody"></div>
|
|
532
|
+
<div class="modal-foot">
|
|
533
|
+
<button class="btn" id="diagCopy" type="button">复制诊断信息</button>
|
|
534
|
+
<button class="btn alt" id="diagRefresh" type="button">重新检测</button>
|
|
535
|
+
<span class="small" id="diagState"></span>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
|
|
540
|
+
<!-- P2.5:数据目录选择器(点目录走,不用手打路径;Esc 关,Enter = 用当前目录) -->
|
|
541
|
+
<div id="dirPickerModal" class="modal" hidden>
|
|
542
|
+
<div class="modal-card">
|
|
543
|
+
<div class="modal-head">
|
|
544
|
+
<div><div class="eyebrow">Pick a folder</div><h2>选择数据目录</h2></div>
|
|
545
|
+
<button class="modal-close" id="dirPickerClose" title="关闭">✕</button>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="modal-body" id="dirPickerBody"></div>
|
|
548
|
+
<div class="modal-foot">
|
|
549
|
+
<button class="btn alt" id="dirPickerCancel" type="button">取消</button>
|
|
550
|
+
<button class="btn" id="dirPickerUse" type="button">用这个目录</button>
|
|
551
|
+
<span class="small" id="dirPickerState"></span>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
|
|
426
556
|
<!-- F-102:全屏演示层(整页图一页一屏,方向键 / 空格 / PageUp-Down / 滚轮翻页) -->
|
|
427
557
|
<div id="presentOverlay" class="present-overlay" role="dialog" aria-modal="true" aria-label="全屏演示" tabindex="-1" hidden>
|
|
428
558
|
<div class="present-stage" id="presentStage" title="点一下 = 下一页"></div>
|
|
@@ -638,6 +768,526 @@ function demoData() {
|
|
|
638
768
|
};
|
|
639
769
|
}
|
|
640
770
|
|
|
771
|
+
/* ---------------- 数据目录:自检面板 + 首次引导(P2 / 规格 §3 §5) ----------------
|
|
772
|
+
* 契约:.deck-assistant-scratch/data-dir-api.md §1 §3 §4
|
|
773
|
+
* 纪律:根路径 / 命中来源 / 进程 cwd / env 覆盖 / firstRun 全由后端解析,前端只展示、不重算;
|
|
774
|
+
* 字段缺失时渲染「未知(后端未返回)」,不空白、不编默认值;
|
|
775
|
+
* diagnosticsText 由后端生成脱敏文本,前端只复制,不做第二套脱敏。 */
|
|
776
|
+
const DATA_SOURCE_LABEL = { env: "环境变量", pointer: "指针文件", profile: "profile 配置", default: "默认值" };
|
|
777
|
+
const DECK_STATUS_LABEL = {
|
|
778
|
+
ok: { text: "✓ 有", cls: "diag-ok" },
|
|
779
|
+
partial: { text: "图片在、正文丢了", cls: "diag-warn" },
|
|
780
|
+
mismatch: { text: "正文和图片都不在当前目录", cls: "diag-bad" },
|
|
781
|
+
absent: { text: "还没初始化", cls: "diag-warn" },
|
|
782
|
+
};
|
|
783
|
+
const DIAG_UNKNOWN = "未知(后端未返回)";
|
|
784
|
+
const ENV_READONLY_TEXT = "由环境变量 DSH_DECK_DATA_ROOT 指定(只读)";
|
|
785
|
+
const FIRST_RUN_FLAG = "deckline.firstRunDismissed";
|
|
786
|
+
let FIRST_RUN_MODE = "first-run";
|
|
787
|
+
let DIAG_DATA = null;
|
|
788
|
+
|
|
789
|
+
/** 归一化后端载荷:兼容 GET self-check(扁平)与 workbench.dataDir(包一层)两种形状。 */
|
|
790
|
+
function readDataDir(data, selfCheck) {
|
|
791
|
+
const sc = selfCheck && typeof selfCheck === "object" ? selfCheck : null;
|
|
792
|
+
const wrapped = data && data.dataDir && typeof data.dataDir === "object" ? data.dataDir : null;
|
|
793
|
+
const src = sc && sc.dataRoot ? sc : (wrapped || {});
|
|
794
|
+
const config = (data && data.config) || {};
|
|
795
|
+
const pick = (entry, fallback) => {
|
|
796
|
+
if (entry && typeof entry === "object") {
|
|
797
|
+
return {
|
|
798
|
+
value: String(entry.value ?? ""),
|
|
799
|
+
effective: String(entry.effective || entry.value || ""),
|
|
800
|
+
source: String(entry.source || "unknown"),
|
|
801
|
+
sourceLabel: String(entry.sourceLabel || ""),
|
|
802
|
+
envOverridden: entry.envOverridden === true,
|
|
803
|
+
writable: typeof entry.writable === "boolean" ? entry.writable : null,
|
|
804
|
+
exists: typeof entry.exists === "boolean" ? entry.exists : null,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
value: String(fallback ?? ""),
|
|
809
|
+
effective: String(fallback ?? ""),
|
|
810
|
+
source: "unknown",
|
|
811
|
+
sourceLabel: "",
|
|
812
|
+
envOverridden: false,
|
|
813
|
+
writable: null,
|
|
814
|
+
exists: null,
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
const env = src.envOverride || (wrapped && wrapped.envOverride) || null;
|
|
818
|
+
return {
|
|
819
|
+
dataRoot: pick(src.dataRoot, config.dataRoot),
|
|
820
|
+
projectsRoot: pick(src.projectsRoot, config.projectsRoot),
|
|
821
|
+
workspacePath: pick(src.workspacePath, data && data.workspacePath),
|
|
822
|
+
dshHome: pick(src.dshHome, ""),
|
|
823
|
+
processCwd: String(src.processCwd || ""),
|
|
824
|
+
pointerFile: src.pointerFile || (wrapped && wrapped.pointerFile) || null,
|
|
825
|
+
envOverride: { active: env ? env.active === true : false, name: String((env && env.name) || "DSH_DECK_DATA_ROOT") },
|
|
826
|
+
firstRun: src.firstRun === true || Boolean(wrapped && wrapped.firstRun),
|
|
827
|
+
sourceLabel: String(src.sourceLabel || ""),
|
|
828
|
+
versions: (sc && sc.versions) || {},
|
|
829
|
+
render: (sc && sc.render) || null,
|
|
830
|
+
decks: (sc && sc.decks) || src.decks || null,
|
|
831
|
+
diagnosticsText: String((sc && sc.diagnosticsText) || ""),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function diagSourceChip(entry) {
|
|
836
|
+
const label = entry.sourceLabel
|
|
837
|
+
|| (entry.source === "unknown" ? DIAG_UNKNOWN : (DATA_SOURCE_LABEL[entry.source] || entry.source));
|
|
838
|
+
return `<span class="diag-src${entry.source === "env" ? " is-env" : ""}">${esc(label)}</span>`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function shortPath(value, max = 44) {
|
|
842
|
+
const text = String(value ?? "");
|
|
843
|
+
if (!text) return "—";
|
|
844
|
+
if (text.length <= max) return text;
|
|
845
|
+
const head = Math.ceil((max - 1) / 2);
|
|
846
|
+
return text.slice(0, head) + "…" + text.slice(text.length - (max - 1 - head));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function diagPathCell(entry) {
|
|
850
|
+
if (!entry.effective) return `<span class="diag-bad">${esc(DIAG_UNKNOWN)}</span>`;
|
|
851
|
+
const flags = [];
|
|
852
|
+
if (entry.envOverridden) flags.push('<span class="diag-src is-env">只读</span>');
|
|
853
|
+
if (entry.writable === false) flags.push('<span class="diag-src is-env">不可写</span>');
|
|
854
|
+
if (entry.exists === false) flags.push('<span class="diag-src">还没创建</span>');
|
|
855
|
+
// 未单独配置(projectsRoot 常见):正文说「沿用默认」,紧跟一行写清真实落盘路径。
|
|
856
|
+
// 面板只展示后端给的 effective,不在这里拼 <dataRoot>/projects/<deckId>/source。
|
|
857
|
+
const shown = entry.value
|
|
858
|
+
? `<code>${esc(entry.value)}</code>`
|
|
859
|
+
: `<span class="diag-warn">沿用默认(未单独配置)</span>`;
|
|
860
|
+
const extra = entry.effective && entry.effective !== entry.value
|
|
861
|
+
? `<div class="diag-note">实际落盘:<code>${esc(entry.effective)}</code></div>`
|
|
862
|
+
: "";
|
|
863
|
+
return `${shown}${diagSourceChip(entry)}${flags.join("")}${extra}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function diagDeckTable(dd, data) {
|
|
867
|
+
// self-check 给 {registeredCount, items[]};workbench 的 dataDir.decks 是数组(每项带 status/statusLabel)。
|
|
868
|
+
const block = dd.decks;
|
|
869
|
+
let items = null;
|
|
870
|
+
if (Array.isArray(block)) items = block;
|
|
871
|
+
else if (block && Array.isArray(block.items)) items = block.items;
|
|
872
|
+
if (!items || !items.length) {
|
|
873
|
+
items = ((data && data.decks) || []).map((deck) => ({ deckId: deck.deckId, title: deck.title || deck.label, pageCount: deck.pageCount }));
|
|
874
|
+
}
|
|
875
|
+
if (!items.length) return "";
|
|
876
|
+
const rows = items.map((deck) => {
|
|
877
|
+
const known = DECK_STATUS_LABEL[deck.status];
|
|
878
|
+
const text = deck.statusLabel || (known ? known.text : (deck.status || DIAG_UNKNOWN));
|
|
879
|
+
const cls = known ? known.cls : "diag-warn";
|
|
880
|
+
const rendered = Number.isFinite(Number(deck.renderedPages))
|
|
881
|
+
? `<span class="${Number(deck.renderedPages) > 0 ? "diag-ok" : ""}">${Number(deck.renderedPages)}</span>`
|
|
882
|
+
: `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`;
|
|
883
|
+
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>`;
|
|
884
|
+
}).join("");
|
|
885
|
+
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>`;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function renderDiagHtml(data, selfCheck) {
|
|
889
|
+
const dd = readDataDir(data, selfCheck);
|
|
890
|
+
const countBlock = dd.decks && !Array.isArray(dd.decks) ? dd.decks : null;
|
|
891
|
+
const counts = countBlock && Number.isFinite(Number(countBlock.registeredCount))
|
|
892
|
+
? `注册 ${esc(countBlock.registeredCount)} · 磁盘 ${esc(countBlock.onDiskCount ?? "—")}`
|
|
893
|
+
+ (Array.isArray(countBlock.onlyOnDisk) && countBlock.onlyOnDisk.length ? ` <span class="diag-warn">磁盘多出 ${esc(countBlock.onlyOnDisk.join("、"))}</span>` : "")
|
|
894
|
+
+ (Array.isArray(countBlock.onlyRegistered) && countBlock.onlyRegistered.length ? ` <span class="diag-warn">注册多出 ${esc(countBlock.onlyRegistered.join("、"))}</span>` : "")
|
|
895
|
+
: (Array.isArray(dd.decks)
|
|
896
|
+
? `注册 ${dd.decks.length} · 磁盘 <span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`
|
|
897
|
+
: `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`);
|
|
898
|
+
const renderText = dd.render && dd.render.mode
|
|
899
|
+
? `${esc(dd.render.mode)}${dd.render.nodeName ? ` · ${esc(dd.render.nodeName)}` : ""}${dd.render.model ? ` / ${esc(dd.render.model)}` : ""}`
|
|
900
|
+
: `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`;
|
|
901
|
+
const envLine = dd.envOverride.active
|
|
902
|
+
? `<p class="diag-note diag-warn"><b>${esc(ENV_READONLY_TEXT)}</b>要改请改这个环境变量后重启 DSH。</p>`
|
|
903
|
+
: "";
|
|
904
|
+
const pointerLine = dd.pointerFile && dd.pointerFile.path
|
|
905
|
+
? `<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>`
|
|
906
|
+
: "";
|
|
907
|
+
const rows = [
|
|
908
|
+
`<dt>进程 cwd</dt><dd>${dd.processCwd ? `<code>${esc(dd.processCwd)}</code>` : `<span class="diag-warn">${esc(DIAG_UNKNOWN)}</span>`}</dd>`,
|
|
909
|
+
`<dt>dataRoot</dt><dd>${diagPathCell(dd.dataRoot)}</dd>`,
|
|
910
|
+
`<dt>projectsRoot</dt><dd>${diagPathCell(dd.projectsRoot)}</dd>`,
|
|
911
|
+
`<dt>workspacePath</dt><dd>${diagPathCell(dd.workspacePath)}</dd>`,
|
|
912
|
+
`<dt>DSH_HOME</dt><dd>${diagPathCell(dd.dshHome)}</dd>`,
|
|
913
|
+
pointerLine,
|
|
914
|
+
`<dt>deck:注册 / 磁盘</dt><dd>${counts}</dd>`,
|
|
915
|
+
`<dt>出图</dt><dd>${renderText}</dd>`,
|
|
916
|
+
`<dt>版本</dt><dd>插件 ${esc(dd.versions.plugin || (data && data.version) || "—")} · DSH ${esc(dd.versions.dsh || "—")} · Node ${esc(dd.versions.node || "—")}</dd>`,
|
|
917
|
+
].filter(Boolean).join("");
|
|
918
|
+
return `
|
|
919
|
+
<div class="diag-card${dd.envOverride.active ? " is-env" : ""}">
|
|
920
|
+
<div class="diag-root"><b>当前数据目录</b> <code>${esc(dd.dataRoot.value || dd.dataRoot.effective || "—")}</code>${diagSourceChip(dd.dataRoot)}</div>
|
|
921
|
+
<p class="diag-note">换端口不改数据,换 dataRoot 才改数据。</p>
|
|
922
|
+
${envLine}
|
|
923
|
+
<div class="diag-actions">
|
|
924
|
+
<button class="btn alt" type="button" id="diagChange"${dd.envOverride.active ? " disabled" : ""}>改数据目录</button>
|
|
925
|
+
<span class="small">${dd.envOverride.active ? "env 覆盖时不可改(只读)" : "写 location.json,不用重启"}</span>
|
|
926
|
+
</div>
|
|
927
|
+
</div>
|
|
928
|
+
<div class="diag-card"><dl class="diag-grid">${rows}</dl></div>
|
|
929
|
+
<div class="diag-card"><div class="diag-root">每本 deck</div>${diagDeckTable(dd, data) || '<p class="diag-note">还没有 deck。</p>'}</div>`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function refreshDiagnostics() {
|
|
933
|
+
const stateEl = getEl("diagState");
|
|
934
|
+
if (stateEl) stateEl.textContent = "正在检测…";
|
|
935
|
+
let selfCheck = null;
|
|
936
|
+
try {
|
|
937
|
+
const fresh = await route("self-check");
|
|
938
|
+
if (fresh && fresh.ok !== false) selfCheck = fresh;
|
|
939
|
+
} catch {
|
|
940
|
+
/* 后端 self-check 未就绪:退回 workbench payload,缺的字段渲染「未知」 */
|
|
941
|
+
}
|
|
942
|
+
DIAG_DATA = selfCheck;
|
|
943
|
+
const box = getEl("diagBody");
|
|
944
|
+
if (box) box.innerHTML = renderDiagHtml(STATE.data, selfCheck);
|
|
945
|
+
const text = String((selfCheck && selfCheck.diagnosticsText) || "");
|
|
946
|
+
const copyBtn = getEl("diagCopy");
|
|
947
|
+
if (copyBtn) copyBtn.disabled = !text;
|
|
948
|
+
if (stateEl) stateEl.textContent = text ? "" : "后端还没提供脱敏诊断文本(GET self-check 未就绪),暂无内容可复制";
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function openDiagnostics() {
|
|
952
|
+
const modal = getEl("diagModal");
|
|
953
|
+
if (!modal) return;
|
|
954
|
+
modal.hidden = false;
|
|
955
|
+
await refreshDiagnostics();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function closeDiagnostics() {
|
|
959
|
+
const modal = getEl("diagModal");
|
|
960
|
+
if (modal) modal.hidden = true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function copyDiagnostics() {
|
|
964
|
+
const text = String((DIAG_DATA && DIAG_DATA.diagnosticsText) || "");
|
|
965
|
+
const stateEl = getEl("diagState");
|
|
966
|
+
if (!text) { if (stateEl) stateEl.textContent = "没有可复制的诊断文本"; return; }
|
|
967
|
+
try {
|
|
968
|
+
await navigator.clipboard.writeText(text);
|
|
969
|
+
if (stateEl) stateEl.textContent = "已复制(已脱敏,可直接粘贴给他人)";
|
|
970
|
+
return;
|
|
971
|
+
} catch {
|
|
972
|
+
/* 退回落选方案 */
|
|
973
|
+
}
|
|
974
|
+
const holder = document.createElement("textarea");
|
|
975
|
+
holder.value = text;
|
|
976
|
+
holder.style.position = "fixed";
|
|
977
|
+
holder.style.opacity = "0";
|
|
978
|
+
document.body.appendChild(holder);
|
|
979
|
+
holder.select();
|
|
980
|
+
try {
|
|
981
|
+
document.execCommand("copy");
|
|
982
|
+
if (stateEl) stateEl.textContent = "已复制(已脱敏)";
|
|
983
|
+
} catch {
|
|
984
|
+
if (stateEl) stateEl.textContent = "复制失败:浏览器不允许写剪贴板";
|
|
985
|
+
}
|
|
986
|
+
holder.remove();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** 首次引导 / 自检面板「改数据目录」共用同一个表单(markup 见 task-3.3)。 */
|
|
990
|
+
function openDataDirForm(settingsMode = false) {
|
|
991
|
+
const modal = getEl("firstRunModal");
|
|
992
|
+
if (!modal) return;
|
|
993
|
+
const dd = readDataDir(DIAG_DATA || STATE.data);
|
|
994
|
+
if (dd.envOverride.active) {
|
|
995
|
+
flash(ENV_READONLY_TEXT, "error");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
FIRST_RUN_MODE = settingsMode ? "settings" : "first-run";
|
|
999
|
+
const dataInput = getEl("frDataRoot");
|
|
1000
|
+
const projectsInput = getEl("frProjectsRoot");
|
|
1001
|
+
if (dataInput) dataInput.value = dd.dataRoot.value || dd.dataRoot.effective || "";
|
|
1002
|
+
if (projectsInput) projectsInput.value = dd.projectsRoot.value || "";
|
|
1003
|
+
const submit = getEl("frSubmit");
|
|
1004
|
+
if (submit) submit.textContent = settingsMode ? "保存并重新检测" : "就用这个位置";
|
|
1005
|
+
const stateEl = getEl("frState");
|
|
1006
|
+
if (stateEl) stateEl.textContent = "";
|
|
1007
|
+
syncFirstRunEcho();
|
|
1008
|
+
modal.hidden = false;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function syncFirstRunEcho() {
|
|
1012
|
+
const dataRoot = (getEl("frDataRoot") && getEl("frDataRoot").value.trim()) || "";
|
|
1013
|
+
const projectsRoot = (getEl("frProjectsRoot") && getEl("frProjectsRoot").value.trim()) || "";
|
|
1014
|
+
if (getEl("frEchoData")) getEl("frEchoData").textContent = dataRoot || "—";
|
|
1015
|
+
if (getEl("frEchoProjects")) getEl("frEchoProjects").textContent = projectsRoot || (dataRoot ? dataRoot + "/projects" : "—");
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** 只在真·首次弹:firstRun 由后端判定(decks 空 + 无 render-settings.json + 无 location.json)。 */
|
|
1019
|
+
function maybeFirstRun(data) {
|
|
1020
|
+
const dd = readDataDir(data);
|
|
1021
|
+
if (dd.envOverride.active || !dd.firstRun) return;
|
|
1022
|
+
const modal = getEl("firstRunModal");
|
|
1023
|
+
if (!modal || modal.hidden === false) return;
|
|
1024
|
+
try {
|
|
1025
|
+
if (sessionStorage.getItem(FIRST_RUN_FLAG) === "1") return;
|
|
1026
|
+
} catch {
|
|
1027
|
+
/* 隐私模式:当作没标记过 */
|
|
1028
|
+
}
|
|
1029
|
+
openDataDirForm(false);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function dismissFirstRun() {
|
|
1033
|
+
const modal = getEl("firstRunModal");
|
|
1034
|
+
if (modal) modal.hidden = true;
|
|
1035
|
+
try { sessionStorage.setItem(FIRST_RUN_FLAG, "1"); } catch { /* ignore */ }
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** POST data-dir:错误按 code 分支(env-override 409 / not-writable 400 / deck-not-there 409)。 */
|
|
1039
|
+
async function submitDataDir(event) {
|
|
1040
|
+
event.preventDefault();
|
|
1041
|
+
const stateEl = getEl("frState");
|
|
1042
|
+
const submit = getEl("frSubmit");
|
|
1043
|
+
const dataRoot = (getEl("frDataRoot") && getEl("frDataRoot").value.trim()) || "";
|
|
1044
|
+
const projectsRoot = (getEl("frProjectsRoot") && getEl("frProjectsRoot").value.trim()) || "";
|
|
1045
|
+
if (!dataRoot) {
|
|
1046
|
+
if (stateEl) stateEl.textContent = "先填数据目录的绝对路径(浏览器拿不到磁盘路径,需要粘贴)";
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (submit) submit.disabled = true;
|
|
1050
|
+
if (stateEl) stateEl.textContent = "正在校验并写入 location.json…";
|
|
1051
|
+
try {
|
|
1052
|
+
const res = await fetch("/api/deck-studio.data-dir", {
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
headers: { "content-type": "application/json" },
|
|
1055
|
+
body: JSON.stringify({ dataRoot, projectsRoot, setBy: FIRST_RUN_MODE }),
|
|
1056
|
+
});
|
|
1057
|
+
const result = await res.json().catch(() => ({}));
|
|
1058
|
+
if (!res.ok || result.ok === false) {
|
|
1059
|
+
const code = (result.error && result.error.code) || "";
|
|
1060
|
+
if (code === "env-override") throw new Error(ENV_READONLY_TEXT + ",改环境变量后重启 DSH");
|
|
1061
|
+
if (code === "not-writable") throw new Error("这个目录不可写(没有权限或不在可写卷上):" + dataRoot);
|
|
1062
|
+
if (code === "bad-path") throw new Error("必须是绝对路径(浏览器拿不到磁盘路径,请粘贴完整路径):" + dataRoot);
|
|
1063
|
+
if (code === "not-found") throw new Error("这个目录不存在:" + dataRoot);
|
|
1064
|
+
if (code === "switching") throw new Error("数据目录切换中,请稍等一秒再点(上一次切换还没落完)");
|
|
1065
|
+
if (code === "deck-not-there") throw new Error("这个目录里没有这本 deck 的数据:" + dataRoot);
|
|
1066
|
+
throw new Error((result.error && result.error.message) || `HTTP ${res.status}`);
|
|
1067
|
+
}
|
|
1068
|
+
getEl("firstRunModal").hidden = true;
|
|
1069
|
+
try { sessionStorage.removeItem(FIRST_RUN_FLAG); } catch { /* ignore */ }
|
|
1070
|
+
STATE.docCache = {};
|
|
1071
|
+
STATE.sig = "";
|
|
1072
|
+
await loadWorkbench(STATE.deckId);
|
|
1073
|
+
if (getEl("diagModal") && getEl("diagModal").hidden === false) await refreshDiagnostics();
|
|
1074
|
+
flash("数据目录已设为 " + ((result.switched && result.switched.to) || dataRoot));
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
/* 就地报错:不回退默认值、不关闭弹层、保留用户输入(规格 §3) */
|
|
1077
|
+
if (stateEl) stateEl.textContent = "保存失败:" + error.message;
|
|
1078
|
+
} finally {
|
|
1079
|
+
if (submit) submit.disabled = false;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function bindDataDirUi() {
|
|
1084
|
+
document.addEventListener("click", (event) => {
|
|
1085
|
+
const target = event.target;
|
|
1086
|
+
if (!target || !target.closest) return;
|
|
1087
|
+
if (target.closest("#stOpenDiag")) { openDiagnostics(); return; }
|
|
1088
|
+
if (target.closest("#diagClose")) { closeDiagnostics(); return; }
|
|
1089
|
+
if (target.closest("#diagCopy")) { copyDiagnostics(); return; }
|
|
1090
|
+
if (target.closest("#diagRefresh")) { refreshDiagnostics(); return; }
|
|
1091
|
+
if (target.closest("#diagChange")) { openDirPicker({ mode: "switch" }); return; }
|
|
1092
|
+
if (target.closest("#frLater") || target.closest("#frClose")) { dismissFirstRun(); return; }
|
|
1093
|
+
});
|
|
1094
|
+
const form = getEl("firstRunForm");
|
|
1095
|
+
if (form) form.addEventListener("submit", submitDataDir);
|
|
1096
|
+
["frDataRoot", "frProjectsRoot"].forEach((id) => {
|
|
1097
|
+
const input = getEl(id);
|
|
1098
|
+
if (input) input.addEventListener("input", syncFirstRunEcho);
|
|
1099
|
+
});
|
|
1100
|
+
const firstRun = getEl("firstRunModal");
|
|
1101
|
+
if (firstRun) firstRun.addEventListener("click", (event) => { if (event.target === firstRun) dismissFirstRun(); });
|
|
1102
|
+
const diag = getEl("diagModal");
|
|
1103
|
+
if (diag) diag.addEventListener("click", (event) => { if (event.target === diag) closeDiagnostics(); });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/* ---------------- 数据目录选择器(P2.5:别再让人手打绝对路径) ----------------
|
|
1107
|
+
* 接口:GET /api/deck-studio.dir-browse?path=<绝对路径> → { ok, dir:{path,parent,isRoot,truncated,dirs:[{name,path,hidden,hasScript,deckCount,isDataRoot}]} }
|
|
1108
|
+
* 错误:400 bad-path / not-found / not-a-dir、409 switching、500 browse-failed —— 按 error.code 分支,message 只做兜底。
|
|
1109
|
+
* 状态:只活在一次弹窗打开期间(DIR_PICKER),关闭即丢弃;每次打开都重新拉接口,不写 STATE、不缓存目录。 */
|
|
1110
|
+
// setBy 用既有值 "settings"(后端已认这个枚举);想在看板里区分「选择器切换」再换成 "dir-picker",需先确认后端不校验枚举。
|
|
1111
|
+
const DIR_PICKER_SWITCH_SETBY = "settings";
|
|
1112
|
+
let DIR_PICKER = null;
|
|
1113
|
+
|
|
1114
|
+
function dirPickerErrText(code, message, path) {
|
|
1115
|
+
if (code === "bad-path") return "要填绝对路径,例如 /Users/你/.dsh/deckline";
|
|
1116
|
+
if (code === "not-found") return "这个目录不存在:" + (path || "");
|
|
1117
|
+
if (code === "not-a-dir") return "这是一个文件,不是目录:" + (path || "");
|
|
1118
|
+
if (code === "switching") return "数据目录切换中,等一秒再试";
|
|
1119
|
+
if (code === "browse-failed") return "读目录失败:" + (message || "服务端报错,稍后再试");
|
|
1120
|
+
return message || "读取目录失败";
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** 打开选择器:mode=fill 把选中的路径填回某个输入框;mode=switch 直接在线切换。 */
|
|
1124
|
+
function openDirPicker(options = {}) {
|
|
1125
|
+
const modal = getEl("dirPickerModal");
|
|
1126
|
+
if (!modal) return;
|
|
1127
|
+
const dd = readDataDir(DIAG_DATA || STATE.data);
|
|
1128
|
+
if (dd.envOverride.active) {
|
|
1129
|
+
// env 覆盖时目录是只读的,选也没用——直接说清楚,不弹一个点不动的界面。
|
|
1130
|
+
flash(ENV_READONLY_TEXT + ",改环境变量后重启 DSH", "error");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
DIR_PICKER = {
|
|
1134
|
+
mode: options.mode === "switch" ? "switch" : "fill",
|
|
1135
|
+
targetId: options.targetId || "frDataRoot",
|
|
1136
|
+
path: options.path || dd.dataRoot.value || dd.dataRoot.effective || "",
|
|
1137
|
+
data: null,
|
|
1138
|
+
loading: true,
|
|
1139
|
+
error: "",
|
|
1140
|
+
showHidden: false,
|
|
1141
|
+
};
|
|
1142
|
+
modal.hidden = false;
|
|
1143
|
+
renderDirPicker();
|
|
1144
|
+
loadDirPicker(DIR_PICKER.path);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function closeDirPicker() {
|
|
1148
|
+
const modal = getEl("dirPickerModal");
|
|
1149
|
+
if (modal) modal.hidden = true;
|
|
1150
|
+
DIR_PICKER = null;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** 每次进目录都重新拉接口(不缓存、不猜父子关系,parent 由后端给)。 */
|
|
1154
|
+
async function loadDirPicker(path) {
|
|
1155
|
+
if (!DIR_PICKER) return;
|
|
1156
|
+
DIR_PICKER.loading = true;
|
|
1157
|
+
DIR_PICKER.error = "";
|
|
1158
|
+
DIR_PICKER.path = path || DIR_PICKER.path;
|
|
1159
|
+
renderDirPicker();
|
|
1160
|
+
try {
|
|
1161
|
+
const query = DIR_PICKER.path ? "?path=" + encodeURIComponent(DIR_PICKER.path) : "";
|
|
1162
|
+
const res = await fetch("/api/deck-studio.dir-browse" + query, { headers: { accept: "application/json" } });
|
|
1163
|
+
let payload = {};
|
|
1164
|
+
try { payload = await res.json(); } catch { /* 非 JSON 响应走下面兜底 */ }
|
|
1165
|
+
if (!res.ok || !payload?.ok) {
|
|
1166
|
+
const failure = new Error(payload?.error?.message || "HTTP " + res.status);
|
|
1167
|
+
failure.code = payload?.error?.code || "";
|
|
1168
|
+
throw failure;
|
|
1169
|
+
}
|
|
1170
|
+
DIR_PICKER.data = payload.dir || null;
|
|
1171
|
+
DIR_PICKER.path = payload.dir?.path || DIR_PICKER.path;
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
DIR_PICKER.error = dirPickerErrText(error?.code, error?.message, DIR_PICKER.path);
|
|
1174
|
+
DIR_PICKER.data = null;
|
|
1175
|
+
} finally {
|
|
1176
|
+
DIR_PICKER.loading = false;
|
|
1177
|
+
renderDirPicker();
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** 渲染弹窗内容(每次都从 DIR_PICKER + 接口返回值算,不保留上一步的 DOM)。 */
|
|
1182
|
+
function renderDirPicker() {
|
|
1183
|
+
const body = getEl("dirPickerBody");
|
|
1184
|
+
if (!body || !DIR_PICKER) return;
|
|
1185
|
+
const st = DIR_PICKER;
|
|
1186
|
+
const dir = st.data;
|
|
1187
|
+
const dirs = Array.isArray(dir?.dirs) ? dir.dirs : [];
|
|
1188
|
+
// 有 deck 的目录排前面(最可能是用户要找的那个),当前数据目录额外标「当前」。
|
|
1189
|
+
const chips = dirs
|
|
1190
|
+
.filter((item) => Number(item.deckCount) > 0 || item.isDataRoot)
|
|
1191
|
+
.sort((a, b) => (Number(b.deckCount) || 0) - (Number(a.deckCount) || 0));
|
|
1192
|
+
const chipHtml = chips.length
|
|
1193
|
+
? `<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>`
|
|
1194
|
+
: "";
|
|
1195
|
+
const visible = dirs.filter((item) => st.showHidden || !item.hidden);
|
|
1196
|
+
const rowsHtml = visible.length
|
|
1197
|
+
? 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("")
|
|
1198
|
+
: `<div class="dir-picker-empty">${dir ? "这个目录下没有子目录。" : "还没读到目录。"}</div>`;
|
|
1199
|
+
const hiddenCount = dirs.filter((item) => item.hidden).length;
|
|
1200
|
+
body.innerHTML = `
|
|
1201
|
+
<div class="dir-picker-path">当前:<code>${esc(dir?.path || st.path || "—")}</code></div>
|
|
1202
|
+
<div class="dir-picker-bar">
|
|
1203
|
+
<button class="btn ghost" type="button" id="dirPickerUp"${dir?.parent ? "" : " disabled"}>↑ 上一级</button>
|
|
1204
|
+
<span class="small">${dir?.truncated ? "子目录太多,只显示了一部分" : ""}</span>
|
|
1205
|
+
</div>
|
|
1206
|
+
${chipHtml}
|
|
1207
|
+
<div class="dir-picker-list">${rowsHtml}</div>
|
|
1208
|
+
${hiddenCount > 0 ? `<label class="dir-picker-toggle"><input type="checkbox" id="dirPickerHidden"${st.showHidden ? " checked" : ""}> 显示隐藏目录(${hiddenCount} 个以 . 开头)</label>` : ""}
|
|
1209
|
+
${st.error ? `<p class="dir-picker-error">${esc(st.error)}</p>` : ""}`;
|
|
1210
|
+
const useBtn = getEl("dirPickerUse");
|
|
1211
|
+
if (useBtn) useBtn.disabled = st.loading || !dir;
|
|
1212
|
+
const stateEl = getEl("dirPickerState");
|
|
1213
|
+
if (stateEl) {
|
|
1214
|
+
stateEl.textContent = st.loading
|
|
1215
|
+
? "正在读目录…"
|
|
1216
|
+
: st.mode === "switch"
|
|
1217
|
+
? "选中后立刻切换(不用重启)"
|
|
1218
|
+
: "选中后只填回表单,不会立刻切换";
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** 点「用这个目录」:fill → 填回输入框;switch → 复用 switchDataDirTo()。 */
|
|
1223
|
+
async function confirmDirPicker() {
|
|
1224
|
+
const st = DIR_PICKER;
|
|
1225
|
+
if (!st || st.loading || !st.data) return;
|
|
1226
|
+
const path = st.data.path;
|
|
1227
|
+
if (st.mode === "fill") {
|
|
1228
|
+
const targetId = st.targetId;
|
|
1229
|
+
closeDirPicker();
|
|
1230
|
+
const wizard = getEl("firstRunModal");
|
|
1231
|
+
const wizardOpen = wizard && wizard.hidden === false;
|
|
1232
|
+
if (!wizardOpen) openDataDirForm(true); // 从设置页进来:借首次引导那张表单承接,保存流程完全不变
|
|
1233
|
+
const input = getEl(targetId);
|
|
1234
|
+
if (input) input.value = path;
|
|
1235
|
+
syncFirstRunEcho();
|
|
1236
|
+
const stateEl = getEl("frState");
|
|
1237
|
+
if (stateEl) stateEl.textContent = "已选好目录,点「保存并重新检测」生效。";
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const useBtn = getEl("dirPickerUse");
|
|
1241
|
+
if (useBtn) useBtn.disabled = true;
|
|
1242
|
+
const stateEl = getEl("dirPickerState");
|
|
1243
|
+
if (stateEl) stateEl.textContent = "正在切换…";
|
|
1244
|
+
const ok = await switchDataDirTo(path, {
|
|
1245
|
+
setBy: DIR_PICKER_SWITCH_SETBY,
|
|
1246
|
+
onError: (error) => {
|
|
1247
|
+
// 失败停在弹窗里(不 flash 掉就没了),按 code 给人话;用户还能换个目录重试。
|
|
1248
|
+
if (!DIR_PICKER) return;
|
|
1249
|
+
DIR_PICKER.error = dirPickerErrText(error?.code, error?.message, path);
|
|
1250
|
+
renderDirPicker();
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
if (useBtn) useBtn.disabled = false;
|
|
1254
|
+
if (ok) {
|
|
1255
|
+
closeDirPicker();
|
|
1256
|
+
// 切换后自检面板还开着的话(从 ⚙ →「改数据目录」进来),把表里的路径与 deck 状态一起刷新。
|
|
1257
|
+
const diagOpen = getEl("diagModal");
|
|
1258
|
+
if (diagOpen && diagOpen.hidden === false) await refreshDiagnostics();
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function bindDirPickerUi() {
|
|
1263
|
+
document.addEventListener("click", (event) => {
|
|
1264
|
+
const target = event.target;
|
|
1265
|
+
if (!target || !target.closest) return;
|
|
1266
|
+
if (target.closest("#stPickDir") || target.closest("#frPickData")) { openDirPicker({ mode: "fill", targetId: "frDataRoot" }); return; }
|
|
1267
|
+
if (target.closest("#frPickProjects")) { openDirPicker({ mode: "fill", targetId: "frProjectsRoot" }); return; }
|
|
1268
|
+
if (target.closest("#dirPickerClose") || target.closest("#dirPickerCancel")) { closeDirPicker(); return; }
|
|
1269
|
+
if (target.closest("#dirPickerUse")) { confirmDirPicker(); return; }
|
|
1270
|
+
if (target.closest("#dirPickerUp")) { if (DIR_PICKER?.data?.parent) loadDirPicker(DIR_PICKER.data.parent); return; }
|
|
1271
|
+
const row = target.closest("[data-dir-path]");
|
|
1272
|
+
if (row && row.closest("#dirPickerModal")) { loadDirPicker(row.dataset.dirPath); return; }
|
|
1273
|
+
});
|
|
1274
|
+
document.addEventListener("change", (event) => {
|
|
1275
|
+
const target = event.target;
|
|
1276
|
+
if (target && target.id === "dirPickerHidden" && DIR_PICKER) {
|
|
1277
|
+
DIR_PICKER.showHidden = target.checked === true;
|
|
1278
|
+
renderDirPicker();
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
document.addEventListener("keydown", (event) => {
|
|
1282
|
+
const modal = getEl("dirPickerModal");
|
|
1283
|
+
if (!modal || modal.hidden) return;
|
|
1284
|
+
if (event.key === "Escape") { event.preventDefault(); closeDirPicker(); return; }
|
|
1285
|
+
if (event.key === "Enter") { event.preventDefault(); confirmDirPicker(); }
|
|
1286
|
+
});
|
|
1287
|
+
const modal = getEl("dirPickerModal");
|
|
1288
|
+
if (modal) modal.addEventListener("click", (event) => { if (event.target === modal) closeDirPicker(); });
|
|
1289
|
+
}
|
|
1290
|
+
|
|
641
1291
|
/* ---------------- 渲染 ---------------- */
|
|
642
1292
|
/** 切右栏标签(Library / Context / Inspector / 设置)。F-131:进 deck、点页面默认给 Inspector。 */
|
|
643
1293
|
function activateRightTab(name) {
|
|
@@ -717,12 +1367,95 @@ function renderChrome() {
|
|
|
717
1367
|
// 首页右栏给 Library(deck 列表 / 文档);只有 deck 工作区才默认 Inspector,且不覆盖用户手动选择
|
|
718
1368
|
if (STATE.view === "home" && !STATE.rightTab) activateRightTab("library");
|
|
719
1369
|
if (STATE.view === "deck" && !STATE.selected && (active.slides || []).length) selectSlide(active.slides[0].id);
|
|
1370
|
+
renderDataDirBanner(data);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/* P0:数据目录黄条(事实由后端 /api/deck-studio.data-dir 给,文案按规格 §2 渲染,非模态不挡操作)。 */
|
|
1374
|
+
function renderDataDirBanner(data) {
|
|
1375
|
+
const box = getEl("dataDirBanner");
|
|
1376
|
+
if (!box) return;
|
|
1377
|
+
const dd = data?.dataDir;
|
|
1378
|
+
const banner = dd?.banner;
|
|
1379
|
+
if (!banner?.show) {
|
|
1380
|
+
box.hidden = true;
|
|
1381
|
+
box.innerHTML = "";
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const envOverride = dd?.envOverride?.active === true;
|
|
1385
|
+
const title = banner.scope === "home" && (banner.affectedDecks ?? 0) > 1
|
|
1386
|
+
? `⚠ 有 ${banner.affectedDecks} 本 deck 的正文和图片不在当前数据目录`
|
|
1387
|
+
: banner.kind === "partial"
|
|
1388
|
+
? "⚠ 这本 deck 的图片在,正文(script)丢了"
|
|
1389
|
+
: "⚠ 这本 deck 的正文和图片不在当前数据目录";
|
|
1390
|
+
const currentRoot = dd?.dataRoot?.value || banner.dataRoot || "—";
|
|
1391
|
+
const sourceNote = dd?.sourceLabel ? `(${esc(dd.sourceLabel)})` : "";
|
|
1392
|
+
const candidates = banner.candidates || [];
|
|
1393
|
+
const rows = candidates.length
|
|
1394
|
+
? 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("")
|
|
1395
|
+
: `<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>`;
|
|
1396
|
+
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>`;
|
|
1397
|
+
box.hidden = false;
|
|
1398
|
+
box.querySelectorAll("[data-data-root]").forEach((node) => node.addEventListener("click", () => switchDataDirTo(node.dataset.dataRoot)));
|
|
1399
|
+
box.querySelector("#dataDirOpenSettings")?.addEventListener("click", () => openSettingsPanel());
|
|
1400
|
+
box.querySelector("#dataDirCopyDiag")?.addEventListener("click", copyDataDirDiagnostics);
|
|
1401
|
+
box.querySelector("#dataDirManualSwitch")?.addEventListener("click", () => {
|
|
1402
|
+
const value = (getEl("dataDirManualInput")?.value || "").trim();
|
|
1403
|
+
if (!value) {
|
|
1404
|
+
flash("先填一个绝对路径", "error");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
switchDataDirTo(value);
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** 点「切到这个目录」:写指针 → 后端重建服务 → 重拉工作台(不整页刷新、不清会话)。 */
|
|
1412
|
+
async function switchDataDirTo(dataRoot, options = {}) {
|
|
1413
|
+
const deckId = STATE.data?.active?.deckId ?? null;
|
|
1414
|
+
const setBy = options.setBy || "mismatch-banner";
|
|
1415
|
+
try {
|
|
1416
|
+
const res = await fetch("/api/deck-studio.data-dir", {
|
|
1417
|
+
method: "POST",
|
|
1418
|
+
headers: { "content-type": "application/json" },
|
|
1419
|
+
body: JSON.stringify({ dataRoot, setBy, deckId }),
|
|
1420
|
+
});
|
|
1421
|
+
let payload = {};
|
|
1422
|
+
try { payload = await res.json(); } catch { /* 非 JSON 响应走下面兜底 */ }
|
|
1423
|
+
if (!res.ok || !payload?.ok) {
|
|
1424
|
+
const failure = new Error(payload?.error?.message || "HTTP " + res.status);
|
|
1425
|
+
failure.code = payload?.error?.code || "";
|
|
1426
|
+
throw failure;
|
|
1427
|
+
}
|
|
1428
|
+
STATE.docCache = {};
|
|
1429
|
+
STATE.sig = "";
|
|
1430
|
+
flash("已切到 " + (payload?.dataDir?.dataRoot?.value || dataRoot));
|
|
1431
|
+
await loadWorkbench(STATE.deckId);
|
|
1432
|
+
if (STATE.view === "home") renderHome();
|
|
1433
|
+
return true;
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
// 选择器要把 code 翻成人话后留在弹窗里,所以它传 onError 接管提示;其它调用方照旧 flash。
|
|
1436
|
+
if (typeof options.onError === "function") { options.onError(error); return false; }
|
|
1437
|
+
flash("切换失败:" + (error?.message ?? error), "error");
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** 复制诊断信息:文本由后端 /self-check 生成(脱敏口径只有一份),前端不重拼。 */
|
|
1443
|
+
async function copyDataDirDiagnostics() {
|
|
1444
|
+
try {
|
|
1445
|
+
const res = await fetch("/api/deck-studio.self-check", { headers: { accept: "application/json" } });
|
|
1446
|
+
const payload = await res.json();
|
|
1447
|
+
if (!payload?.ok) throw new Error(payload?.error?.message || "自检失败");
|
|
1448
|
+
await navigator.clipboard.writeText(payload.diagnosticsText || "");
|
|
1449
|
+
flash("诊断信息已复制");
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
flash("复制失败:" + (error?.message ?? error), "error");
|
|
1452
|
+
}
|
|
720
1453
|
}
|
|
721
1454
|
|
|
722
1455
|
/* ---------------- 首页(左侧导航 + 右侧分区 blocks) ---------------- */
|
|
723
1456
|
|
|
724
|
-
const HOME_SECTIONS = ["decks", "designs", "resources", "conversations", "manual"];
|
|
725
|
-
const HOME_TITLES = { decks: "Decks", designs: "Designs", resources: "资源库", conversations: "Conversations" };
|
|
1457
|
+
const HOME_SECTIONS = ["decks", "designs", "resources", "conversations", "manual", "about"];
|
|
1458
|
+
const HOME_TITLES = { decks: "Decks", designs: "Designs", resources: "资源库", conversations: "Conversations", manual: "使用手册", about: "关于" };
|
|
726
1459
|
|
|
727
1460
|
function homeDateText() {
|
|
728
1461
|
const now = new Date();
|
|
@@ -803,6 +1536,7 @@ function renderHome() {
|
|
|
803
1536
|
resources: `${resources.length} 个资源(logo / 素材);点开预览。`,
|
|
804
1537
|
conversations: `${sessions.length} 个历史会话;点一个继续聊。`,
|
|
805
1538
|
manual: "从新建到导出:截图 + 步骤;找不到就直接在 Chat 里问。",
|
|
1539
|
+
about: "作者 / 公众号 / 版本更新 / 开源地址 / 其他项目,都在这一页。",
|
|
806
1540
|
}[section];
|
|
807
1541
|
getEl("homeGreetSub").textContent = greet;
|
|
808
1542
|
|
|
@@ -822,8 +1556,22 @@ function renderHome() {
|
|
|
822
1556
|
if (STATE.manualDoc) openManualDoc(box, STATE.manualDoc);
|
|
823
1557
|
else renderManualHome(box);
|
|
824
1558
|
}
|
|
1559
|
+
else if (section === "about") {
|
|
1560
|
+
box.innerHTML = renderHomeAbout(resources);
|
|
1561
|
+
paintUpdate(); // 版本 chip / 更新状态在关于页也要跟着顶栏走(F-193)
|
|
1562
|
+
}
|
|
825
1563
|
else box.innerHTML = renderHomeConversations(sessions, bindings);
|
|
826
1564
|
|
|
1565
|
+
// F-193:「关于」分区里的按钮(检查更新 / 外链)——外链直接开新标签。
|
|
1566
|
+
box.querySelectorAll("[data-about-check]").forEach((node) => node.addEventListener("click", () => checkPluginUpdate()));
|
|
1567
|
+
box.querySelectorAll("[data-about-external]").forEach((node) => node.addEventListener("click", () => {
|
|
1568
|
+
const links = aboutLinks();
|
|
1569
|
+
const kind = node.dataset.aboutExternal;
|
|
1570
|
+
if (kind === "repo") openExternalUrl(links.repo, "还没拿到仓库地址");
|
|
1571
|
+
else if (kind === "issues") openExternalUrl(links.issues, "还没拿到提建议地址");
|
|
1572
|
+
else if (kind === "profile") openExternalUrl(links.profile, "还没拿到 GitHub 主页地址");
|
|
1573
|
+
}));
|
|
1574
|
+
|
|
827
1575
|
box.querySelectorAll("[data-home-deck]").forEach((node) => node.addEventListener("click", async () => {
|
|
828
1576
|
const deckId = node.dataset.homeDeck;
|
|
829
1577
|
// 先等 deck 切完(iframe 里的 Chat 也要加载完),再选默认会话——否则 postMessage 发早了会落到旧会话。
|
|
@@ -861,8 +1609,10 @@ function renderHome() {
|
|
|
861
1609
|
}
|
|
862
1610
|
|
|
863
1611
|
function renderHomeDecks(decks, active, gov) {
|
|
864
|
-
|
|
865
|
-
|
|
1612
|
+
const dd = readDataDir(STATE.data);
|
|
1613
|
+
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>`;
|
|
1614
|
+
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>`;
|
|
1615
|
+
return `<div class="section-head"><h2>Decks</h2><span class="small">${decks.length} 个</span>${rootChip}</div>
|
|
866
1616
|
<div class="home-blocks">${decks.map((deck) => {
|
|
867
1617
|
const isActive = deck.deckId === active.deckId;
|
|
868
1618
|
// F-52:每张卡用自己的文档状态(后端 decks[].documents / missing / score),
|
|
@@ -981,6 +1731,90 @@ function renderHomeResources(resources) {
|
|
|
981
1731
|
</button>`).join("")}</div>`;
|
|
982
1732
|
}
|
|
983
1733
|
|
|
1734
|
+
/* ---------------- F-193 / F-194:首页「关于」单页(作者 → 公众号 → 版本 → 开源 → 其他项目) ---------------- */
|
|
1735
|
+
|
|
1736
|
+
/** 公众号二维码从资源库现取(个人二维码不进仓库);拿不到就降级成说明,不显示破图。 */
|
|
1737
|
+
function aboutWechatQr(resources = []) {
|
|
1738
|
+
return resources.find((resource) => /二维码|qr|微信|wechat/i.test(`${resource?.name || ""} ${resource?.description || ""} ${resource?.url || ""}`)) || null;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function renderHomeAbout(resources = []) {
|
|
1742
|
+
const links = aboutLinks();
|
|
1743
|
+
const qr = aboutWechatQr(resources);
|
|
1744
|
+
const versionLine = UPDATE.current ? `v${UPDATE.current}` : "读取中…";
|
|
1745
|
+
const installNote = UPDATE.installMode === "linked"
|
|
1746
|
+
? "本机是开发链接安装(link:/file:):更新走仓库,界面不执行 pnpm。"
|
|
1747
|
+
: UPDATE.installMode === "npm"
|
|
1748
|
+
? "npm 安装:有新版可以在这里直接更新,重启 DSH 生效。"
|
|
1749
|
+
: "没识别出安装方式时,可手动 dsh plugin add。";
|
|
1750
|
+
const repoShort = links.repo.replace(/^https?:\/\//, "");
|
|
1751
|
+
const projectRows = [
|
|
1752
|
+
{ name: "Deckline · dsh-deck-studio", badge: "本插件", external: "repo", desc: "DSH 的 image deck / PPT 工作台:故事线、逐页 prompt、AI 出图与版本留存、风格库、导出 PDF/PPTX。" },
|
|
1753
|
+
];
|
|
1754
|
+
if (links.profile) {
|
|
1755
|
+
projectRows.push({
|
|
1756
|
+
name: `GitHub @${links.profile.split("/").pop()}`,
|
|
1757
|
+
badge: "全部项目",
|
|
1758
|
+
external: "profile",
|
|
1759
|
+
desc: "我的开源主页:插件、实验和工具会陆续挂在这里。",
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
return `<div class="section-head"><h2>关于</h2><span class="small">作者 · 公众号 · 版本更新 · 开源</span></div>
|
|
1763
|
+
<div class="about-stack">
|
|
1764
|
+
<section class="about-card about-hero">
|
|
1765
|
+
<div class="about-profile">
|
|
1766
|
+
<div class="about-avatar" aria-hidden="true">Y</div>
|
|
1767
|
+
<div class="about-profile-body">
|
|
1768
|
+
<h3>${esc(ABOUT.author)}<span class="about-role">${esc(ABOUT.role)}</span></h3>
|
|
1769
|
+
<p class="about-desc">${esc(ABOUT.intro)}</p>
|
|
1770
|
+
</div>
|
|
1771
|
+
</div>
|
|
1772
|
+
<div class="about-wechat">
|
|
1773
|
+
<div class="about-wechat-text">
|
|
1774
|
+
<div class="eyebrow">微信公众号</div>
|
|
1775
|
+
<b>${esc(ABOUT.account)}</b>
|
|
1776
|
+
<p class="about-hint">${qr ? "扫码关注 / 加我微信" : `把二维码图片放进数据目录的 <code>resources/</code>(资源库),这里会自动显示。`}</p>
|
|
1777
|
+
</div>
|
|
1778
|
+
${qr
|
|
1779
|
+
? `<img class="about-qr" src="${esc(qr.url)}" alt="${esc(qr.name || "公众号二维码")}" loading="lazy">`
|
|
1780
|
+
: `<div class="about-qr about-qr-empty">二维码未收录</div>`}
|
|
1781
|
+
</div>
|
|
1782
|
+
</section>
|
|
1783
|
+
|
|
1784
|
+
<section class="about-card">
|
|
1785
|
+
<h3>版本与更新</h3>
|
|
1786
|
+
<p class="about-line">当前版本 <b>${esc(versionLine)}</b></p>
|
|
1787
|
+
<p class="about-desc">${esc(installNote)}</p>
|
|
1788
|
+
<div class="about-actions">
|
|
1789
|
+
<button class="btn" data-about-check>检查新版本</button>
|
|
1790
|
+
</div>
|
|
1791
|
+
<p class="small about-state" data-about-state></p>
|
|
1792
|
+
</section>
|
|
1793
|
+
<section class="about-card">
|
|
1794
|
+
<h3>开源地址与反馈</h3>
|
|
1795
|
+
<p class="about-line"><code>${esc(repoShort)}</code></p>
|
|
1796
|
+
<p class="about-desc">用着不对、想要什么功能,直接提 issue;仓库里能看到版本记录与路线。</p>
|
|
1797
|
+
<div class="about-actions">
|
|
1798
|
+
<button class="btn ghost" data-about-external="repo">仓库地址</button>
|
|
1799
|
+
<button class="btn alt" data-about-external="issues">提交建议 / 问题</button>
|
|
1800
|
+
</div>
|
|
1801
|
+
</section>
|
|
1802
|
+
<section class="about-card">
|
|
1803
|
+
<h3>其他开源项目</h3>
|
|
1804
|
+
<ul class="about-project-list">${projectRows.map((row) => `
|
|
1805
|
+
<li class="about-project-row">
|
|
1806
|
+
<div class="about-project-main">
|
|
1807
|
+
<div class="about-project-head"><b>${esc(row.name)}</b><span class="about-badge">${esc(row.badge)}</span></div>
|
|
1808
|
+
<p class="about-desc">${esc(row.desc)}</p>
|
|
1809
|
+
</div>
|
|
1810
|
+
<button class="btn ghost" data-about-external="${esc(row.external)}">打开</button>
|
|
1811
|
+
</li>`).join("")}
|
|
1812
|
+
</ul>
|
|
1813
|
+
<p class="about-hint">最新列表以 GitHub 主页为准;想第一时间看到更新,关注公众号「${esc(ABOUT.account)}」。</p>
|
|
1814
|
+
</section>
|
|
1815
|
+
</div>`;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
984
1818
|
function renderHomeConversations(sessions, bindings = []) {
|
|
985
1819
|
const bound = new Map((bindings || []).map((binding) => [binding.sessionId, binding]));
|
|
986
1820
|
const targetLabel = (binding) => {
|
|
@@ -1083,19 +1917,55 @@ function dlToast(text, ms = 4200) {
|
|
|
1083
1917
|
/** F-188:更新状态(版本 / 安装方式 / 仓库链接)由 workbench payload 带下来。 */
|
|
1084
1918
|
const UPDATE = { current: "", latest: "", hasUpdate: false, installMode: "", repo: "", suggest: "", note: "" };
|
|
1085
1919
|
|
|
1920
|
+
/** F-193:左栏「关于」分组用的作者 / 公众号信息;仓库链接优先用包里的 repository,兜底写死公开地址。 */
|
|
1921
|
+
const ABOUT = {
|
|
1922
|
+
author: "Yuna",
|
|
1923
|
+
role: "Deckline 作者",
|
|
1924
|
+
account: "Yuna 的 AI 修炼手册",
|
|
1925
|
+
intro: "把 AI 落到工作现场:用对话把分享、培训、汇报做成整页图片 deck。",
|
|
1926
|
+
fallbackRepo: "https://github.com/yuna78/dsh-deck-studio",
|
|
1927
|
+
};
|
|
1928
|
+
|
|
1929
|
+
function aboutLinks() {
|
|
1930
|
+
const repo = UPDATE.repo || ABOUT.fallbackRepo;
|
|
1931
|
+
const issues = UPDATE.suggest || UPDATE.issues || `${repo.replace(/\/+$/, "")}/issues/new/choose`;
|
|
1932
|
+
const profile = repo.replace(/^https?:\/\/(?:www\.)?github\.com\/([^/]+).*$/i, "https://github.com/$1");
|
|
1933
|
+
return { repo, issues, profile: profile === repo ? "" : profile };
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** 外链统一走这里:拿不到地址时给 toast,不让按钮静默没反应。 */
|
|
1937
|
+
function openExternalUrl(url, missingMessage) {
|
|
1938
|
+
if (!url) {
|
|
1939
|
+
dlToast(missingMessage || "链接还没拿到:插件包或运行环境里没有配置地址", 5000);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
window.open(url, "_blank", "noopener");
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1086
1945
|
function paintUpdate() {
|
|
1087
1946
|
const ver = getEl("updateVer");
|
|
1088
1947
|
if (ver) ver.textContent = UPDATE.current ? `v${UPDATE.current}` : "—";
|
|
1089
1948
|
const dot = getEl("updateDot");
|
|
1090
1949
|
if (dot) dot.hidden = !UPDATE.hasUpdate;
|
|
1950
|
+
const title = UPDATE.hasUpdate
|
|
1951
|
+
? `有新版 v${UPDATE.latest}(当前 v${UPDATE.current})`
|
|
1952
|
+
: UPDATE.installMode === "linked"
|
|
1953
|
+
? "本机是开发链接安装:更新走 git pull"
|
|
1954
|
+
: "检查更新";
|
|
1091
1955
|
const btn = getEl("updateBtn");
|
|
1092
|
-
if (btn)
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1956
|
+
if (btn) btn.title = title;
|
|
1957
|
+
// F-193:左栏「检查新版本」与顶栏 ↻ 共用同一份状态(版本 chip + 有新版小圆点)
|
|
1958
|
+
const navVer = getEl("navUpdateVer");
|
|
1959
|
+
if (navVer) navVer.textContent = UPDATE.current ? `v${UPDATE.current}` : "—";
|
|
1960
|
+
const navDot = getEl("navUpdateDot");
|
|
1961
|
+
if (navDot) navDot.hidden = !UPDATE.hasUpdate;
|
|
1962
|
+
const aboutNavBtn = getEl("aboutNavBtn");
|
|
1963
|
+
if (aboutNavBtn) aboutNavBtn.title = UPDATE.hasUpdate ? `关于 Deckline · ${title}` : "关于 Deckline:版本 / 公众号 / 开源地址 / 其他项目";
|
|
1964
|
+
const aboutState = document.querySelectorAll("[data-about-state]");
|
|
1965
|
+
aboutState.forEach((node) => {
|
|
1966
|
+
// 只在「有新版」时占用这行;「当前版本」上面已经写过了,别重复。
|
|
1967
|
+
node.textContent = UPDATE.hasUpdate ? `发现新版 v${UPDATE.latest}(当前 v${UPDATE.current})` : "";
|
|
1968
|
+
});
|
|
1099
1969
|
}
|
|
1100
1970
|
|
|
1101
1971
|
function absorbUpdate(payload) {
|
|
@@ -1169,7 +2039,8 @@ async function checkPluginUpdate() {
|
|
|
1169
2039
|
async function openSettingsPanel() {
|
|
1170
2040
|
const deckId = STATE.data?.active?.deckId;
|
|
1171
2041
|
if (!deckId) {
|
|
1172
|
-
|
|
2042
|
+
// 0 deck / 真·首次 / mismatch:设置内容就是数据目录自检面板,别再 toast 早退。
|
|
2043
|
+
await openDiagnostics();
|
|
1173
2044
|
return;
|
|
1174
2045
|
}
|
|
1175
2046
|
if (STATE.view === "home") await showDeck(deckId);
|
|
@@ -2635,7 +3506,25 @@ async function fillInspectorBody(pageId) {
|
|
|
2635
3506
|
getEl("insBody").value = view.body || slide.body || "";
|
|
2636
3507
|
getEl("insPrompt").value = view.prompt || slide.visualBrief || "";
|
|
2637
3508
|
} catch (error) {
|
|
2638
|
-
if (STATE.selected
|
|
3509
|
+
if (STATE.selected !== pageId) return;
|
|
3510
|
+
// 实例指到了不同的 dataRoot 时,这里只会拿到 page not found:
|
|
3511
|
+
// 只说「读取正文失败」会被当成页面 bug,所以带上 dataRoot / deckId 与诊断入口。
|
|
3512
|
+
const insDataDirRoot = STATE.data?.dataDir?.dataRoot;
|
|
3513
|
+
const insDataRoot = (typeof insDataDirRoot === "string" ? insDataDirRoot : insDataDirRoot?.value) || STATE.data?.config?.dataRoot || "(未知,见设置页)";
|
|
3514
|
+
const pageMissing = /page not found/i.test(String(error?.message || ""));
|
|
3515
|
+
getEl("inspectorStatus").innerHTML = pageMissing
|
|
3516
|
+
? `读取正文失败:当前数据目录没有这本 deck 的 script(dataRoot=${esc(insDataRoot)},deckId=${esc(deckId)},pageId=${esc(pageId)})`
|
|
3517
|
+
+ `<br>换端口不改数据,换 dataRoot 才改数据:先核对 ⚙ 里的「当前数据目录」,是不是出图时用的那个。`
|
|
3518
|
+
+ `<div style="margin-top:8px"><button type="button" class="btn alt" id="insOpenDiagnostics">打开诊断</button></div>`
|
|
3519
|
+
: `读取正文失败:${esc(String(error?.message || error))}<br>dataRoot=${esc(insDataRoot)},deckId=${esc(deckId)},pageId=${esc(pageId)}`;
|
|
3520
|
+
const insDiagBtn = document.getElementById("insOpenDiagnostics");
|
|
3521
|
+
if (insDiagBtn) {
|
|
3522
|
+
insDiagBtn.addEventListener("click", () => {
|
|
3523
|
+
// openDiagnostics() 无 deck 也能开;尚未就绪时退回设置面板
|
|
3524
|
+
if (typeof openDiagnostics === "function") openDiagnostics();
|
|
3525
|
+
else openSettingsPanel();
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
2639
3528
|
}
|
|
2640
3529
|
}
|
|
2641
3530
|
|
|
@@ -2736,6 +3625,7 @@ function renderSettings(data) {
|
|
|
2736
3625
|
const focused = document.activeElement;
|
|
2737
3626
|
if (SETTINGS_FORM_DIRTY || (focused && focused.closest && focused.closest(".node-editor"))) return;
|
|
2738
3627
|
const config = data.config || {};
|
|
3628
|
+
const dd = readDataDir(data);
|
|
2739
3629
|
const renderNodes = data.active?.renderNodes || { nodes: [], active: null };
|
|
2740
3630
|
const settings = data.renderSettings || {};
|
|
2741
3631
|
// 设置页里正在编辑的节点列表(以服务端 payload 为种子;保存时整表提交)
|
|
@@ -2825,7 +3715,12 @@ function renderSettings(data) {
|
|
|
2825
3715
|
仓库和 profile 里都不会出现你的 key 或节点地址。</p>
|
|
2826
3716
|
</div>
|
|
2827
3717
|
<h3 class="settings-title">其它</h3>
|
|
2828
|
-
<div class="
|
|
3718
|
+
<div class="diag-card${dd.envOverride.active ? " is-env" : ""}">
|
|
3719
|
+
<div class="diag-root">当前数据目录:<code>${esc(shortPath(dd.dataRoot.value || dd.dataRoot.effective, 62))}</code>${diagSourceChip(dd.dataRoot)}</div>
|
|
3720
|
+
<p class="diag-note">换端口不改数据,换 dataRoot 才改数据。</p>
|
|
3721
|
+
${dd.envOverride.active ? `<p class="diag-note diag-warn">${esc(ENV_READONLY_TEXT)}</p>` : ""}
|
|
3722
|
+
<div class="diag-actions"><button class="btn alt" type="button" id="stOpenDiag">打开自检面板</button><button class="btn" type="button" id="stPickDir">选择…</button></div>
|
|
3723
|
+
</div>`;
|
|
2829
3724
|
getEl("rsActive")?.setAttribute("title", "当前 deck 生效的节点 / 模型(切换后下一次出图生效)");
|
|
2830
3725
|
fillSettingsNodeForm();
|
|
2831
3726
|
bindRenderSettingsForm();
|
|
@@ -4859,6 +5754,9 @@ function backToManual(box) {
|
|
|
4859
5754
|
getEl("embedBack").addEventListener("click", () => window.parent?.postMessage({ type: "dsh-deck-studio:close" }, "*"));
|
|
4860
5755
|
await loadWorkbench();
|
|
4861
5756
|
absorbUpdate(STATE.data);
|
|
5757
|
+
bindDataDirUi();
|
|
5758
|
+
bindDirPickerUi();
|
|
5759
|
+
maybeFirstRun(STATE.data);
|
|
4862
5760
|
getEl("settingsBtn").addEventListener("click", openSettingsPanel);
|
|
4863
5761
|
getEl("updateBtn").addEventListener("click", checkPluginUpdate);
|
|
4864
5762
|
getEl("repoBtn").addEventListener("click", openRepo);
|
|
@@ -4878,7 +5776,7 @@ function backToManual(box) {
|
|
|
4878
5776
|
getEl("workspaceView").style.display = "grid";
|
|
4879
5777
|
switchChatToBoundSession().catch(() => {});
|
|
4880
5778
|
} else {
|
|
4881
|
-
const match = location.hash.match(/^#\/home\/(decks|designs|resources|conversations|manual)/);
|
|
5779
|
+
const match = location.hash.match(/^#\/home\/(decks|designs|resources|conversations|manual|about)/);
|
|
4882
5780
|
STATE.homeSection = match ? match[1] : "decks";
|
|
4883
5781
|
showHome();
|
|
4884
5782
|
}
|