dsh-project-deck 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +111 -2
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +1765 -0
- package/backend/deck-node +188 -0
- package/backend/deck-node.js +152 -0
- package/backend/deck-node.ps1 +106 -0
- package/backend/panel.html +855 -0
- package/backend/panel.py +953 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +366 -0
- package/lib/index.js +98 -0
- package/package.json +56 -4
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>项目甲板 · 控制台</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#0b0f14; --panel:#121821; --panel2:#0f151d; --line:#1f2a37;
|
|
10
|
+
--text:#e6edf3; --muted:#8b98a5; --accent:#35d0ba; --accent-dim:#1f6f66;
|
|
11
|
+
--warn:#f0b429; --danger:#ef5b5b; --ok:#3ddc97; --radius:14px;
|
|
12
|
+
}
|
|
13
|
+
*{box-sizing:border-box}
|
|
14
|
+
body{margin:0;background:radial-gradient(1200px 600px at 15% -10%,#12303a 0%,transparent 55%),var(--bg);
|
|
15
|
+
color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif}
|
|
16
|
+
a{color:var(--accent)}
|
|
17
|
+
.wrap{max-width:1180px;margin:0 auto;padding:26px 22px 70px}
|
|
18
|
+
header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:22px}
|
|
19
|
+
h1{font-size:20px;margin:0;letter-spacing:.5px}
|
|
20
|
+
h1 span{color:var(--accent)}
|
|
21
|
+
.sub{color:var(--muted);font-size:12px;margin-top:4px}
|
|
22
|
+
.pills{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
23
|
+
.pill{display:inline-flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line);
|
|
24
|
+
border-radius:999px;padding:6px 12px;font-size:12px;color:var(--muted)}
|
|
25
|
+
.pill b{color:var(--text);font-weight:600}
|
|
26
|
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
|
27
|
+
.dot.ok{background:var(--ok);box-shadow:0 0 10px rgba(61,220,151,.7)}
|
|
28
|
+
.dot.bad{background:var(--danger);box-shadow:0 0 10px rgba(239,91,91,.6)}
|
|
29
|
+
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px}
|
|
30
|
+
.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px}
|
|
31
|
+
.stat .n{font-size:24px;font-weight:700;font-variant-numeric:tabular-nums}
|
|
32
|
+
.stat .l{color:var(--muted);font-size:12px;margin-top:2px}
|
|
33
|
+
.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;background:var(--panel2);
|
|
34
|
+
border:1px solid var(--line);border-radius:var(--radius);padding:12px 14px;margin-bottom:20px}
|
|
35
|
+
.controls label{color:var(--muted);font-size:12px}
|
|
36
|
+
input,select,textarea{background:#0a0f15;border:1px solid var(--line);color:var(--text);
|
|
37
|
+
border-radius:9px;padding:7px 10px;font:inherit;outline:none}
|
|
38
|
+
input:focus,select:focus,textarea:focus{border-color:var(--accent-dim)}
|
|
39
|
+
input[type=number]{width:88px}
|
|
40
|
+
button{background:#17222d;border:1px solid var(--line);color:var(--text);border-radius:9px;
|
|
41
|
+
padding:7px 13px;font:inherit;cursor:pointer;transition:.15s}
|
|
42
|
+
button:hover{border-color:var(--accent-dim);color:#fff}
|
|
43
|
+
button.primary{background:linear-gradient(180deg,#2bb8a5,#1f8f81);border-color:#2bb8a5;color:#04110f;font-weight:600}
|
|
44
|
+
button.primary:hover{filter:brightness(1.08)}
|
|
45
|
+
button.ghost{background:transparent}
|
|
46
|
+
button.danger{color:#ffb3b3;border-color:#4a2226}
|
|
47
|
+
button.danger:hover{background:#2a1518}
|
|
48
|
+
.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
|
|
49
|
+
.switch input{display:none}
|
|
50
|
+
.track{width:38px;height:21px;border-radius:999px;background:#24303d;position:relative;transition:.2s}
|
|
51
|
+
.track::after{content:"";position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;
|
|
52
|
+
background:#9fb0c0;transition:.2s}
|
|
53
|
+
.switch input:checked + .track{background:var(--accent-dim)}
|
|
54
|
+
.switch input:checked + .track::after{left:19px;background:var(--accent)}
|
|
55
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:14px}
|
|
56
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;
|
|
57
|
+
display:flex;flex-direction:column;gap:10px;position:relative;overflow:hidden}
|
|
58
|
+
.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--line)}
|
|
59
|
+
.card.active::before{background:var(--accent)}
|
|
60
|
+
.card.blocked::before{background:var(--danger)}
|
|
61
|
+
.card.paused::before{background:var(--warn)}
|
|
62
|
+
.card.archived::before{background:#3a4653}
|
|
63
|
+
.card h3{margin:0;font-size:15px;display:flex;align-items:center;gap:8px;min-width:0}
|
|
64
|
+
.cardhead{display:flex;align-items:center;gap:8px}
|
|
65
|
+
.cardhead h3{flex:1;min-width:0}
|
|
66
|
+
.cardhead button.enter{flex:none;font-size:11.5px;padding:4px 9px;color:#7ce7da;border-color:#1f4d48;background:#0f1e1d;font-weight:600;border-radius:8px;cursor:pointer}
|
|
67
|
+
.cardhead button.enter:hover{background:#123230;color:#a7f3e9}
|
|
68
|
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
69
|
+
.chip{font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:1px 7px;background:#0d131a}
|
|
70
|
+
.chip.teal{color:#7ce7da;border-color:#1f4d48;background:#0f1e1d}
|
|
71
|
+
.chip.warn{color:#ffd27a;border-color:#4d3a17;background:#1d1710}
|
|
72
|
+
.chip.bad{color:#ffa3a3;border-color:#4a2226;background:#1c1214}
|
|
73
|
+
.row{display:flex;gap:8px;font-size:12.5px;color:var(--muted);align-items:baseline}
|
|
74
|
+
.row .k{min-width:52px;color:#6f7d8a}
|
|
75
|
+
.row .v{color:#cfd9e3;word-break:break-all}
|
|
76
|
+
.next{background:#0d131a;border:1px dashed var(--line);border-radius:9px;padding:8px 10px;font-size:12.5px;color:#cfd9e3}
|
|
77
|
+
.card footer{display:flex;gap:8px;margin-top:auto;padding-top:4px;flex-wrap:wrap}
|
|
78
|
+
.card footer button{font-size:12px;padding:5px 10px}
|
|
79
|
+
.card footer button.enter{color:#7ce7da;border-color:#1f4d48;background:#0f1e1d;font-weight:600}
|
|
80
|
+
.card footer button.enter:hover{background:#123230;color:#a7f3e9}
|
|
81
|
+
.editbox{display:flex;flex-direction:column;gap:8px;background:#0d131a;border:1px solid var(--line);
|
|
82
|
+
border-radius:10px;padding:10px}
|
|
83
|
+
.editbox input,.editbox textarea{width:100%}
|
|
84
|
+
details.add{background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius);margin-top:20px;padding:0 14px}
|
|
85
|
+
details.add summary{cursor:pointer;padding:13px 0;color:var(--accent);font-size:13px}
|
|
86
|
+
.formgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding-bottom:14px}
|
|
87
|
+
.formgrid input,.formgrid select{width:100%}
|
|
88
|
+
.hint{color:var(--muted);font-size:11.5px}
|
|
89
|
+
h2{font-size:14px;color:var(--muted);font-weight:600;letter-spacing:.4px;margin:28px 0 12px}
|
|
90
|
+
.history{display:flex;flex-direction:column;gap:10px}
|
|
91
|
+
.hist{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
|
92
|
+
.hist .top{display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--muted)}
|
|
93
|
+
.hist pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
94
|
+
color:#c6d1dc;max-height:120px;overflow:auto}
|
|
95
|
+
.scanrow .top{align-items:center}
|
|
96
|
+
.scanrow .top button{padding:3px 10px;font-size:12px}
|
|
97
|
+
.toast{position:fixed;right:20px;bottom:20px;background:#12211f;border:1px solid #1f4d48;color:#9ff0e4;
|
|
98
|
+
padding:11px 15px;border-radius:10px;font-size:13px;opacity:0;transform:translateY(8px);
|
|
99
|
+
transition:.2s;pointer-events:none;max-width:420px;z-index:9}
|
|
100
|
+
.toast.show{opacity:1;transform:none}
|
|
101
|
+
.toast.err{background:#211416;border-color:#4a2226;color:#ffb3b3}
|
|
102
|
+
.modal{position:fixed;inset:0;background:rgba(4,8,12,.72);display:none;align-items:flex-start;
|
|
103
|
+
justify-content:center;padding:48px 18px;z-index:20;overflow:auto}
|
|
104
|
+
.modal.show{display:flex}
|
|
105
|
+
.modalbox{background:var(--panel);border:1px solid var(--line);border-radius:16px;max-width:760px;width:100%;padding:20px 22px}
|
|
106
|
+
.modalbox h3{margin:0 0 4px;font-size:17px}
|
|
107
|
+
.mhead{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px}
|
|
108
|
+
.msec{margin-top:16px}
|
|
109
|
+
.msec h4{margin:0 0 8px;font-size:12px;color:var(--muted);letter-spacing:.5px;font-weight:600}
|
|
110
|
+
.commit{display:flex;gap:10px;font-size:12.5px;color:#c6d1dc;padding:4px 0;border-bottom:1px dashed #1b2530}
|
|
111
|
+
.commit .sha{color:#7ce7da;font-family:ui-monospace,Menlo,monospace}
|
|
112
|
+
.commit .when{color:#6f7d8a;margin-left:auto;white-space:nowrap}
|
|
113
|
+
.goalrow{display:flex;gap:8px;font-size:12.5px;padding:3px 0;color:#cfd9e3}
|
|
114
|
+
.imgthumbs{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
|
115
|
+
.imgthumbs img{width:100%;aspect-ratio:3/4;object-fit:cover;object-position:top;border-radius:10px;border:1px solid var(--line);display:block}
|
|
116
|
+
.empty{color:var(--muted);padding:26px;text-align:center;border:1px dashed var(--line);border-radius:var(--radius)}
|
|
117
|
+
@media(max-width:820px){.stats{grid-template-columns:repeat(2,1fr)}.formgrid{grid-template-columns:1fr}}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="wrap">
|
|
122
|
+
<div id="hintbar" style="display:none;background:#10201f;border:1px solid #1f4d48;color:#7ce7da;
|
|
123
|
+
padding:9px 14px;border-radius:10px;margin-bottom:14px;font-size:12.5px">
|
|
124
|
+
你正在浏览器里单独打开面板:点每个项目卡片的「💬 进入会话」会在新标签打开 DSH 深链;在 DSH 内嵌面板(左下角 📋)里点则直接切换。没有会话的项目会自动新建。
|
|
125
|
+
</div>
|
|
126
|
+
<div id="wxBanner" style="display:none;background:#2a1518;border:1px solid #4a2226;color:#ffb3b3;
|
|
127
|
+
padding:9px 14px;border-radius:10px;margin-bottom:14px;font-size:12.5px"></div>
|
|
128
|
+
<div id="errbar" style="display:none;background:#2a1518;border:1px solid #4a2226;color:#ffb3b3;
|
|
129
|
+
padding:10px 14px;border-radius:10px;margin-bottom:14px;font-size:12.5px"></div>
|
|
130
|
+
<header>
|
|
131
|
+
<div>
|
|
132
|
+
<h1>项目甲板 <span>·</span> 控制台</h1>
|
|
133
|
+
<div class="sub">项目注册表 · 周期播报 · 微信通道 <span id="clock"></span></div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="pills">
|
|
136
|
+
<span class="pill"><i class="dot" id="dotDaemon"></i>守护进程 <b id="daemonText">…</b></span>
|
|
137
|
+
<span class="pill"><i class="dot" id="dotWx"></i>微信 <b id="wxText">…</b></span>
|
|
138
|
+
<span class="pill">下次播报 <b id="nextText">…</b></span>
|
|
139
|
+
<button class="ghost" onclick="refresh()">刷新</button>
|
|
140
|
+
</div>
|
|
141
|
+
</header>
|
|
142
|
+
|
|
143
|
+
<div class="stats">
|
|
144
|
+
<div class="stat"><div class="n" id="sTotal">-</div><div class="l">项目</div></div>
|
|
145
|
+
<div class="stat"><div class="n" id="sActive" style="color:var(--accent)">-</div><div class="l">活跃</div></div>
|
|
146
|
+
<div class="stat"><div class="n" id="sBlocked" style="color:var(--danger)">-</div><div class="l">阻塞</div></div>
|
|
147
|
+
<div class="stat"><div class="n" id="sRecent" style="color:var(--warn)">-</div><div class="l">近 6 小时有更新</div></div>
|
|
148
|
+
<div class="stat"><div class="n" id="sNoMem" style="color:var(--muted)">-</div><div class="l">未落盘</div></div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="controls">
|
|
152
|
+
<label class="switch" title="周期播报开关">
|
|
153
|
+
<input type="checkbox" id="enabled" onchange="setConfig({enabled:this.checked})">
|
|
154
|
+
<span class="track"></span>
|
|
155
|
+
</label>
|
|
156
|
+
<label>每 <input type="number" id="interval" min="0.1" max="720" step="0.5" style="width:78px"> 小时播报一次</label>
|
|
157
|
+
<button onclick="applyInterval()">应用</button>
|
|
158
|
+
<label style="margin-left:6px">图标位置
|
|
159
|
+
<select onchange="setFabPos(this.value)" style="padding:5px 8px">
|
|
160
|
+
<option value="">默认</option><option value="right-bottom">右下</option>
|
|
161
|
+
<option value="left-bottom">左下</option><option value="right-top">右上</option>
|
|
162
|
+
<option value="center-right">右侧居中</option>
|
|
163
|
+
</select>
|
|
164
|
+
</label>
|
|
165
|
+
<label style="margin-left:6px">版式
|
|
166
|
+
<select id="format" onchange="setConfig({format:this.value})" style="padding:5px 8px">
|
|
167
|
+
<option value="markdown">Markdown</option>
|
|
168
|
+
<option value="plain">纯文本</option>
|
|
169
|
+
</select>
|
|
170
|
+
</label>
|
|
171
|
+
<label class="switch" title="周期播报时附一张看板图">
|
|
172
|
+
<input type="checkbox" id="imageOn" onchange="setConfig({image:this.checked})">
|
|
173
|
+
<span class="track"></span>
|
|
174
|
+
</label>
|
|
175
|
+
<label>播报附图</label>
|
|
176
|
+
<span style="flex:1"></span>
|
|
177
|
+
<span class="hint" id="cfgHint"></span>
|
|
178
|
+
<button onclick="sendImage()" id="btnImage">图片播报</button>
|
|
179
|
+
<button class="primary" id="btnReport" onclick="sendReport()">立即播报</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div id="imgBox" style="display:none;margin:0 0 18px">
|
|
183
|
+
<img id="lastImg" style="width:100%;max-width:560px;border-radius:12px;border:1px solid var(--line);display:block">
|
|
184
|
+
<div class="hint" id="imgHint" style="margin-top:6px"></div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="controls" style="padding:9px 12px">
|
|
188
|
+
<input id="q" placeholder="搜索项目(名称 / id / 路径 / 说明)" style="flex:1;min-width:180px" oninput="render()">
|
|
189
|
+
<select id="fStatus" onchange="render()">
|
|
190
|
+
<option value="">全部状态</option><option value="active">活跃</option>
|
|
191
|
+
<option value="paused">暂停</option><option value="blocked">阻塞</option><option value="archived">归档</option>
|
|
192
|
+
</select>
|
|
193
|
+
<span class="hint" id="filterHint"></span>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div id="approvalsBox" style="display:none">
|
|
197
|
+
<h2>等你拍板 <span class="hint" id="apCount"></span></h2>
|
|
198
|
+
<div class="history" id="approvalsList"></div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="grid" id="grid"></div>
|
|
202
|
+
|
|
203
|
+
<details class="add">
|
|
204
|
+
<summary>⚙ 扫描与播报设置</summary>
|
|
205
|
+
<div class="formgrid">
|
|
206
|
+
<label class="hint" style="grid-column:1/-1">扫描根目录(每行一个,支持 ~)<br>
|
|
207
|
+
<textarea id="sRoots" rows="4" style="width:100%"></textarea></label>
|
|
208
|
+
<label class="hint">扫描深度<br><input id="sDepth" type="number" min="1" max="3" style="width:100%"></label>
|
|
209
|
+
<label class="hint">自动登记阈值<br><input id="sThreshold" type="number" min="0" max="100" style="width:100%"></label>
|
|
210
|
+
<label class="hint">扫描间隔(小时)<br><input id="sInterval" type="number" min="1" max="720" step="1" style="width:100%"></label>
|
|
211
|
+
<label class="hint">自动登记新项目<br><input id="sAutoAdd" type="checkbox"></label>
|
|
212
|
+
<label class="hint">开启周期扫描<br><input id="sEnabled" type="checkbox"></label>
|
|
213
|
+
</div>
|
|
214
|
+
<div style="padding:0 0 14px;display:flex;gap:8px">
|
|
215
|
+
<button class="primary" onclick="saveScan()">保存扫描设置</button>
|
|
216
|
+
<button class="ghost" onclick="loadConfigIntoForm()">重置</button>
|
|
217
|
+
</div>
|
|
218
|
+
</details>
|
|
219
|
+
|
|
220
|
+
<h2>节点 <span class="hint" id="nodeHint"></span></h2>
|
|
221
|
+
<div class="grid" id="nodeGrid"></div>
|
|
222
|
+
|
|
223
|
+
<h2>播报图片 <span class="hint" id="imgCount"></span></h2>
|
|
224
|
+
<div class="imgthumbs" id="imgGallery"></div>
|
|
225
|
+
|
|
226
|
+
<h2>自动发现 <span class="hint" id="scanHint"></span></h2>
|
|
227
|
+
<div class="controls">
|
|
228
|
+
<button onclick="loadScan()">重新扫描</button>
|
|
229
|
+
<button class="primary" onclick="runScan()">扫描并登记高置信度</button>
|
|
230
|
+
<span class="hint">信号:DSH 工作区 / git 仓库 / 项目标记;≥70 分自动登记,其余列在这里等你确认</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="history" id="scanList"></div>
|
|
233
|
+
|
|
234
|
+
<details class="add">
|
|
235
|
+
<summary>+ 登记新项目</summary>
|
|
236
|
+
<div class="formgrid">
|
|
237
|
+
<input id="aId" placeholder="id(kebab-case,如 diet-project)">
|
|
238
|
+
<input id="aName" placeholder="显示名称">
|
|
239
|
+
<input id="aRoot" placeholder="根目录(绝对路径)">
|
|
240
|
+
<select id="aKind">
|
|
241
|
+
<option value="code">代码</option><option value="content">内容</option>
|
|
242
|
+
<option value="ops">运维</option><option value="life">生活</option><option value="research">研究</option>
|
|
243
|
+
</select>
|
|
244
|
+
<input id="aPriority" type="number" value="50" min="0" max="100" placeholder="优先级 0-100">
|
|
245
|
+
<input id="aNote" placeholder="一句话说明(播报里的「下一步」兜底)">
|
|
246
|
+
</div>
|
|
247
|
+
<div style="padding-bottom:14px"><button class="primary" onclick="addProject()">登记</button></div>
|
|
248
|
+
</details>
|
|
249
|
+
|
|
250
|
+
<h2>播报历史</h2>
|
|
251
|
+
<div class="history" id="history"></div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="modal" id="pickerModal" onclick="if(event.target===this)closePicker()">
|
|
254
|
+
<div class="modalbox" style="max-width:860px">
|
|
255
|
+
<div class="mhead"><div><h3 id="pkTitle">整理会话</h3><div class="hint" id="pkSub">这里能看到所有工作区的会话;绑定后它会出现在该项目的「进入会话」里</div></div>
|
|
256
|
+
<button onclick="closePicker()">关闭</button></div>
|
|
257
|
+
<input id="pkQ" placeholder="搜索会话内容 / 路径…" style="width:100%;margin-bottom:10px" oninput="filterPicker()">
|
|
258
|
+
<div id="pkList" style="max-height:56vh;overflow:auto"></div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="modal" id="modal" onclick="if(event.target===this)closeModal()">
|
|
262
|
+
<div class="modalbox">
|
|
263
|
+
<div class="mhead"><div><h3 id="mTitle">—</h3><div class="hint" id="mSub"></div></div>
|
|
264
|
+
<button onclick="closeModal()">关闭</button></div>
|
|
265
|
+
<div id="mBody"></div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="toast" id="toast"></div>
|
|
269
|
+
|
|
270
|
+
<script>
|
|
271
|
+
window.onerror = function(msg, src, line, col){
|
|
272
|
+
const b = document.getElementById('errbar');
|
|
273
|
+
b.style.display = 'block';
|
|
274
|
+
b.textContent = '页面脚本错误:' + msg + '(' + line + ':' + col + ')';
|
|
275
|
+
};
|
|
276
|
+
window.addEventListener('unhandledrejection', function(e){
|
|
277
|
+
const b = document.getElementById('errbar');
|
|
278
|
+
b.style.display = 'block';
|
|
279
|
+
b.textContent = '请求失败:' + (e.reason && e.reason.message ? e.reason.message : e.reason);
|
|
280
|
+
});
|
|
281
|
+
</script>
|
|
282
|
+
<script>
|
|
283
|
+
let S = null, offset = 0, editId = null;
|
|
284
|
+
|
|
285
|
+
const $ = id => document.getElementById(id);
|
|
286
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
287
|
+
const fmt = ts => ts ? new Date(ts*1000).toLocaleString('zh-CN',{hour12:false}) : '—';
|
|
288
|
+
const shortTime = ts => ts ? new Date(ts*1000).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}) : '—';
|
|
289
|
+
|
|
290
|
+
function toast(msg, err){
|
|
291
|
+
const t = $('toast'); t.textContent = msg; t.className = 'toast show' + (err ? ' err' : '');
|
|
292
|
+
clearTimeout(t._h); t._h = setTimeout(()=>t.className='toast', 4200);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function api(path, body){
|
|
296
|
+
const r = await fetch(path, {method: body ? 'POST':'GET', headers:{'Content-Type':'application/json'},
|
|
297
|
+
body: body ? JSON.stringify(body) : undefined});
|
|
298
|
+
const j = await r.json();
|
|
299
|
+
if (!r.ok || j.ok === false) throw new Error(j.error || ('HTTP ' + r.status));
|
|
300
|
+
return j;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function tick(){
|
|
304
|
+
if (!S) return;
|
|
305
|
+
const now = Date.now()/1000 + offset;
|
|
306
|
+
const left = (S.daemon.nextRun || 0) - now;
|
|
307
|
+
$('nextText').textContent = left > 0 ? fmtDur(left) : (S.config.enabled ? '即将播报' : '已暂停');
|
|
308
|
+
$('clock').textContent = new Date().toLocaleTimeString('zh-CN',{hour12:false});
|
|
309
|
+
}
|
|
310
|
+
function fmtDur(sec){
|
|
311
|
+
sec = Math.max(0, Math.round(sec));
|
|
312
|
+
const h = Math.floor(sec/3600), m = Math.floor(sec%3600/60), s = sec%60;
|
|
313
|
+
return (h? h+'h ':'') + (h||m? m+'m ':'') + s+'s';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function render(){
|
|
317
|
+
if (!S) return;
|
|
318
|
+
const c = S.counts;
|
|
319
|
+
$('sTotal').textContent = c.total; $('sActive').textContent = c.active;
|
|
320
|
+
$('sBlocked').textContent = c.blocked; $('sRecent').textContent = c.recent;
|
|
321
|
+
$('sNoMem').textContent = c.noMemory;
|
|
322
|
+
$('enabled').checked = !!S.config.enabled;
|
|
323
|
+
if (document.activeElement !== $('interval')) $('interval').value = S.config.intervalHours;
|
|
324
|
+
$('format').value = S.config.format || 'markdown';
|
|
325
|
+
$('imageOn').checked = !!S.config.image;
|
|
326
|
+
$('cfgHint').textContent = '守护进程 ' + (S.daemon.running ? '运行中 pid ' + S.daemon.pid : '未运行');
|
|
327
|
+
$('daemonText').textContent = S.daemon.running ? '运行中' : '未运行';
|
|
328
|
+
$('dotDaemon').className = 'dot ' + (S.daemon.running ? 'ok':'bad');
|
|
329
|
+
const wxStale = !!S.wechat.stale;
|
|
330
|
+
$('wxText').textContent = wxStale ? '通道过期' : (S.wechat.ok ? '已接通' : '未接通');
|
|
331
|
+
$('dotWx').className = 'dot ' + (wxStale ? 'bad' : (S.wechat.ok ? 'ok':'bad'));
|
|
332
|
+
const banner = $('wxBanner');
|
|
333
|
+
if (banner) {
|
|
334
|
+
banner.style.display = wxStale ? 'block' : 'none';
|
|
335
|
+
if (wxStale) banner.innerHTML = '⚠️ 微信通道 context_token 已过期:这段时间的播报<b>不会排队堆积</b>。在微信里给 bot 发任意一句话,下一轮播报自动恢复。';
|
|
336
|
+
}
|
|
337
|
+
$('nextText').textContent = S.daemon.nextRun ? shortTime(S.daemon.nextRun) : '—';
|
|
338
|
+
|
|
339
|
+
const grid = $('grid');
|
|
340
|
+
const q = ($('q') && $('q').value || '').trim().toLowerCase();
|
|
341
|
+
const fs = $('fStatus') ? $('fStatus').value : '';
|
|
342
|
+
const list = S.projects.filter(p => {
|
|
343
|
+
const hitQ = !q || [p.name, p.id, p.root, p.note, p.next].join(' ').toLowerCase().includes(q);
|
|
344
|
+
const hitS = !fs || p.status === fs;
|
|
345
|
+
return hitQ && hitS;
|
|
346
|
+
});
|
|
347
|
+
if ($('filterHint')) $('filterHint').textContent = `显示 ${list.length} / ${S.projects.length}`;
|
|
348
|
+
if (!list.length){
|
|
349
|
+
grid.innerHTML = '<div class="empty">没有匹配的项目。</div>';
|
|
350
|
+
} else {
|
|
351
|
+
grid.innerHTML = list.map(cardHtml).join('');
|
|
352
|
+
}
|
|
353
|
+
if (!scanFormLoaded && S.config.scan) { loadConfigIntoForm(); }
|
|
354
|
+
renderHistory();
|
|
355
|
+
renderNodes();
|
|
356
|
+
renderApprovals();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function renderApprovals(){
|
|
360
|
+
const box = $('approvalsBox');
|
|
361
|
+
if (!box) return;
|
|
362
|
+
const items = S.approvals || [];
|
|
363
|
+
box.style.display = items.length ? 'block' : 'none';
|
|
364
|
+
if (!items.length) return;
|
|
365
|
+
$('apCount').textContent = items.length + ' 项';
|
|
366
|
+
$('approvalsList').innerHTML = items.map(x => `
|
|
367
|
+
<div class="hist">
|
|
368
|
+
<div class="top">
|
|
369
|
+
<span><span class="chip teal">${esc(x.project)}</span> ${esc(relText(x.createdAt))}</span>
|
|
370
|
+
<span style="display:flex;gap:6px">
|
|
371
|
+
<button style="font-size:11px;padding:2px 9px" onclick="resolveAp('${esc(x.id)}','approve','${esc(x.recommend || '')}')">采纳${x.recommend ? '推荐 ' + esc(x.recommend) : ''}</button>
|
|
372
|
+
<button class="danger" style="font-size:11px;padding:2px 9px" onclick="resolveAp('${esc(x.id)}','reject','')">拒绝</button>
|
|
373
|
+
</span>
|
|
374
|
+
</div>
|
|
375
|
+
<div style="margin-top:6px;color:#cfd9e3;font-size:12.5px">${esc(x.question)}</div>
|
|
376
|
+
${x.options ? `<div class="hint" style="margin-top:4px">选项:${esc(x.options)}${x.recommend ? ' · 推荐 ' + esc(x.recommend) : ''}</div>` : ''}
|
|
377
|
+
</div>`).join('');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function setAutonomy(id, mode){
|
|
381
|
+
try{
|
|
382
|
+
const r = await api('/api/autonomy', {project: id, mode});
|
|
383
|
+
S = r.state; render();
|
|
384
|
+
toast('自主模式 → ' + mode);
|
|
385
|
+
}catch(e){ toast('设置失败:' + e.message, true); }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function resolveAp(id, action, choice){
|
|
389
|
+
try{
|
|
390
|
+
const r = await api('/api/approval', {id, action, choice});
|
|
391
|
+
S = r.state; render();
|
|
392
|
+
toast(action === 'approve' ? '已采纳' : '已拒绝');
|
|
393
|
+
}catch(e){ toast('处理失败:' + e.message, true); }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderNodes(){
|
|
397
|
+
const box = $('nodeGrid');
|
|
398
|
+
if (!box) return;
|
|
399
|
+
const nodes = S.nodes || [];
|
|
400
|
+
$('nodeHint').textContent = nodes.length
|
|
401
|
+
? `${nodes.filter(n => n.online).length}/${nodes.length} 在线`
|
|
402
|
+
: '还没有登记节点(deck nodes add …)';
|
|
403
|
+
if (!nodes.length){
|
|
404
|
+
box.innerHTML = '<div class="empty">还没有登记节点。用 <code>deck nodes add --id hos --name "HOS 手机" --platform android</code> 添加。</div>';
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
box.innerHTML = nodes.map(n => `
|
|
408
|
+
<div class="card ${n.online ? 'active' : 'paused'}">
|
|
409
|
+
<h3>${esc(n.name)} <span class="chip">${esc(n.id)}</span></h3>
|
|
410
|
+
<div class="chips"><span class="chip">${esc(n.platform || '未上报')}</span>
|
|
411
|
+
<span class="chip ${n.online ? 'teal' : 'warn'}">${n.online ? '在线' : '离线'}</span></div>
|
|
412
|
+
<div class="row"><span class="k">地址</span><span class="v">${esc(n.address || '—')}</span></div>
|
|
413
|
+
${n.runtimes ? `<div class="row"><span class="k">运行时</span><span class="v">${esc(n.runtimes)}</span></div>` : ''}
|
|
414
|
+
<div class="row"><span class="k">最近上报</span><span class="v">${esc(n.scannedAt ? relText(n.scannedAt) : '从未')}</span></div>
|
|
415
|
+
<div class="row"><span class="k">项目数</span><span class="v">${esc(n.projects)}</span></div>
|
|
416
|
+
</div>`).join('');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function relText(ts){
|
|
420
|
+
const d = Date.now()/1000 - ts;
|
|
421
|
+
if (d < 90) return '刚刚';
|
|
422
|
+
if (d < 3600) return Math.floor(d/60) + ' 分钟前';
|
|
423
|
+
if (d < 86400) return Math.floor(d/3600) + ' 小时前';
|
|
424
|
+
return Math.floor(d/86400) + ' 天前';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function cardHtml(p){
|
|
428
|
+
const st = esc(p.status);
|
|
429
|
+
const kind = `<span class="chip teal">${esc(p.kindLabel)}</span>`;
|
|
430
|
+
const mem = p.memory ? '<span class="chip">已落盘</span>' : '<span class="chip warn">未落盘</span>';
|
|
431
|
+
const board = p.board && p.board.total ? `<span class="chip">看板 ${p.board.total}</span>` : '<span class="chip">无看板</span>';
|
|
432
|
+
const git = p.git ? `<span class="chip">git ${esc(p.git.sha)}${p.git.dirty ? ' <b style="color:var(--warn)">+' + p.git.dirty + '</b>' : ''}</span>` : '';
|
|
433
|
+
const missing = p.exists ? '' : '<span class="chip bad">目录缺失</span>';
|
|
434
|
+
const aut = (p.autonomy && p.autonomy.mode) || 'off';
|
|
435
|
+
const autChip = `<span class="chip ${aut === 'auto' ? 'teal' : (aut === 'assist' ? 'warn' : '')}">🤖 ${aut}</span>`;
|
|
436
|
+
const next = p.next || '<span style="color:var(--muted)">(未登记进展)</span>';
|
|
437
|
+
return `<div class="card ${st}" id="card-${esc(p.id)}">
|
|
438
|
+
<div class="cardhead">
|
|
439
|
+
<h3 style="cursor:pointer" onclick="enterProject('${esc(p.id)}')"
|
|
440
|
+
title="${p.sessions && p.sessions.length ? '进入最近会话' : '该项目还没有会话,点击新建'}">${esc(p.name)} <span class="chip">${esc(p.id)}</span></h3>
|
|
441
|
+
<button class="enter" onclick="enterProject('${esc(p.id)}')"
|
|
442
|
+
title="${p.sessions && p.sessions.length ? '进入该项目最近一次会话' : '该项目还没有会话,点击新建一个'}">💬 进入会话</button>
|
|
443
|
+
</div>
|
|
444
|
+
<div class="chips">${kind}${statusChip(p)}${autChip}${mem}${board}${git}${missing}</div>
|
|
445
|
+
<div class="next">下一步:${esc(p.next || '(未登记进展)')}</div>
|
|
446
|
+
<div class="row"><span class="k">最近活动</span><span class="v">${esc(p.activityText)}</span></div>
|
|
447
|
+
<div class="row"><span class="k">根目录</span><span class="v">${esc(p.root)}</span></div>
|
|
448
|
+
${p.note ? `<div class="row"><span class="k">说明</span><span class="v">${esc(p.note)}</span></div>` : ''}
|
|
449
|
+
<footer>
|
|
450
|
+
<button onclick="openDetail('${esc(p.id)}')" title="项目详情(会话列表 / git / 看板 / STATE)">详情</button>
|
|
451
|
+
<select onchange="setAutonomy('${esc(p.id)}', this.value)" title="自主推进模式" style="font-size:12px;padding:5px 8px">
|
|
452
|
+
${['off','assist','auto'].map(m => `<option value="${m}" ${aut===m?'selected':''}>🤖 ${m}</option>`).join('')}
|
|
453
|
+
</select>
|
|
454
|
+
<select onchange="setStatus('${esc(p.id)}', this.value)" style="font-size:12px;padding:5px 8px">
|
|
455
|
+
${['active','paused','blocked','archived'].map(s => `<option value="${s}" ${p.status===s?'selected':''}>${statusName(s)}</option>`).join('')}
|
|
456
|
+
</select>
|
|
457
|
+
<button onclick="toggleEdit('${esc(p.id)}')">编辑</button>
|
|
458
|
+
<button onclick="copyPath('${esc(p.root)}')">复制路径</button>
|
|
459
|
+
<button class="danger" onclick="removeProject('${esc(p.id)}')">移除</button>
|
|
460
|
+
</footer>
|
|
461
|
+
<div id="edit-${esc(p.id)}"></div>
|
|
462
|
+
</div>`;
|
|
463
|
+
}
|
|
464
|
+
function statusName(s){ return {active:'活跃',paused:'暂停',blocked:'阻塞',archived:'归档'}[s] || s; }
|
|
465
|
+
function statusChip(p){
|
|
466
|
+
const cls = {active:'teal',paused:'warn',blocked:'bad',archived:''}[p.status] || '';
|
|
467
|
+
return `<span class="chip ${cls}">${statusName(p.status)}</span>`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function renderHistory(){
|
|
471
|
+
const box = $('history');
|
|
472
|
+
if (!S.history.length){ box.innerHTML = '<div class="empty">还没有播报记录</div>'; return; }
|
|
473
|
+
box.innerHTML = S.history.map(h => `
|
|
474
|
+
<div class="hist">
|
|
475
|
+
<div class="top"><span>${esc(fmt(h.at))}</span><span>${h.exit === 0 ? '✅ 已送达' : '⚠️ 未送达(exit ' + esc(h.exit) + ')'}</span></div>
|
|
476
|
+
<pre>${esc(h.text)}</pre>
|
|
477
|
+
</div>`).join('');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function toggleEdit(id){
|
|
481
|
+
if (editId === id){ editId = null; $('edit-'+id).innerHTML=''; return; }
|
|
482
|
+
editId = id;
|
|
483
|
+
const p = S.projects.find(x => x.id === id);
|
|
484
|
+
$('edit-'+id).innerHTML = `<div class="editbox">
|
|
485
|
+
<input id="e-name-${id}" value="${esc(p.name)}" placeholder="名称">
|
|
486
|
+
<div style="display:flex;gap:8px">
|
|
487
|
+
<input id="e-pri-${id}" type="number" value="${esc(p.priority)}" min="0" max="100" placeholder="优先级" style="width:110px">
|
|
488
|
+
<select id="e-kind-${id}" style="flex:1">
|
|
489
|
+
${['code','content','ops','life','research'].map(k => `<option value="${k}" ${p.kind===k?'selected':''}>${{code:'代码',content:'内容',ops:'运维',life:'生活',research:'研究'}[k]}</option>`).join('')}
|
|
490
|
+
</select>
|
|
491
|
+
</div>
|
|
492
|
+
<input id="e-alias-${id}" value="${esc((p.aliases||[]).join(', '))}" placeholder="别名(逗号分隔)">
|
|
493
|
+
<input id="e-note-${id}" value="${esc(p.note || '')}" placeholder="一句话说明">
|
|
494
|
+
<div style="display:flex;gap:8px">
|
|
495
|
+
<button onclick="saveEdit('${id}')" class="primary">保存</button>
|
|
496
|
+
<button onclick="toggleEdit('${id}')" class="ghost">取消</button>
|
|
497
|
+
</div>
|
|
498
|
+
</div>`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function saveEdit(id){
|
|
502
|
+
try{
|
|
503
|
+
const body = {action:'update', id, name: $('e-name-'+id).value,
|
|
504
|
+
priority: parseInt($('e-pri-'+id).value || '50', 10), note: $('e-note-'+id).value,
|
|
505
|
+
kind: $('e-kind-'+id).value,
|
|
506
|
+
aliases: $('e-alias-'+id).value.split(',').map(x => x.trim()).filter(Boolean)};
|
|
507
|
+
const r = await api('/api/project', body);
|
|
508
|
+
S = r.state; render(); toast('已保存');
|
|
509
|
+
}catch(e){ toast('保存失败:' + e.message, true); }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function setStatus(id, status){
|
|
513
|
+
try{ S = (await api('/api/project', {action:'update', id, status})).state; render(); }
|
|
514
|
+
catch(e){ toast('修改失败:' + e.message, true); }
|
|
515
|
+
}
|
|
516
|
+
async function removeProject(id){
|
|
517
|
+
if (!confirm('移除项目 ' + id + '?\n(只从注册表移除,目录和 ~/.ai 记忆都不动)')) return;
|
|
518
|
+
try{ S = (await api('/api/project', {action:'remove', id})).state; render(); toast('已移除 ' + id); }
|
|
519
|
+
catch(e){ toast('移除失败:' + e.message, true); }
|
|
520
|
+
}
|
|
521
|
+
async function addProject(){
|
|
522
|
+
const body = {action:'add', id: $('aId').value.trim(), name: $('aName').value.trim(),
|
|
523
|
+
root: $('aRoot').value.trim(), kind: $('aKind').value,
|
|
524
|
+
priority: parseInt($('aPriority').value || '50', 10), note: $('aNote').value.trim()};
|
|
525
|
+
if (!body.id || !body.root) return toast('id 与根目录必填', true);
|
|
526
|
+
try{
|
|
527
|
+
S = (await api('/api/project', body)).state; render();
|
|
528
|
+
['aId','aName','aRoot','aNote'].forEach(k => $(k).value = ''); toast('已登记 ' + body.id);
|
|
529
|
+
}catch(e){ toast('登记失败:' + e.message, true); }
|
|
530
|
+
}
|
|
531
|
+
async function setConfig(patch){
|
|
532
|
+
try{ S = (await api('/api/config', patch)).state; render(); toast('配置已更新'); }
|
|
533
|
+
catch(e){ toast('配置失败:' + e.message, true); refresh(); }
|
|
534
|
+
}
|
|
535
|
+
function applyInterval(){
|
|
536
|
+
const v = parseFloat($('interval').value);
|
|
537
|
+
if (!(v > 0)) return toast('间隔必须大于 0', true);
|
|
538
|
+
setConfig({intervalHours: v});
|
|
539
|
+
}
|
|
540
|
+
async function sendReport(){
|
|
541
|
+
const b = $('btnReport'); b.disabled = true; b.textContent = '发送中…';
|
|
542
|
+
try{
|
|
543
|
+
const r = await api('/api/report', {});
|
|
544
|
+
S = r.state; render();
|
|
545
|
+
if (r.code === 0) toast('已发送到微信');
|
|
546
|
+
else if (r.code === 2 && (r.message || '').includes('SKIP_CHANNEL_STALE'))
|
|
547
|
+
toast('微信通道过期,本次已跳过(不排队):在微信里给 bot 发一句话即可恢复', true);
|
|
548
|
+
else if (r.code === 2) toast('已入队但未送达:通道恢复后自动补发', true);
|
|
549
|
+
else toast('发送失败(exit ' + r.code + '):' + (r.message || ''), true);
|
|
550
|
+
}catch(e){ toast('发送失败:' + e.message, true); }
|
|
551
|
+
finally{ b.disabled = false; b.textContent = '立即播报'; }
|
|
552
|
+
}
|
|
553
|
+
let pendingReq = null;
|
|
554
|
+
|
|
555
|
+
window.addEventListener('message', (e) => {
|
|
556
|
+
const d = e.data || {};
|
|
557
|
+
if (d.type !== 'dsh-project-deck:open-result' || !pendingReq) return;
|
|
558
|
+
if (d.project && d.project !== pendingReq.project) return;
|
|
559
|
+
if (d.sessionId && pendingReq.sessionId && d.sessionId !== pendingReq.sessionId) return;
|
|
560
|
+
clearTimeout(pendingReq.timer);
|
|
561
|
+
const ok = !!d.ok;
|
|
562
|
+
const label = pendingReq.sessionId ? pendingReq.sessionId.replace(/^(session|mesh)-/, '').slice(0, 8) : pendingReq.project;
|
|
563
|
+
toast(ok ? '已进入 ' + label + ' 的会话' : 'DSH 拒绝跳转:插件里没有可用的会话/工作区服务', !ok);
|
|
564
|
+
pendingReq = null;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
function inPluginFrame(){ return window.parent && window.parent !== window; }
|
|
568
|
+
|
|
569
|
+
function sendToPlugin(payload){
|
|
570
|
+
window.parent.postMessage(payload, '*');
|
|
571
|
+
if (pendingReq) clearTimeout(pendingReq.timer);
|
|
572
|
+
pendingReq = {
|
|
573
|
+
project: payload.project,
|
|
574
|
+
sessionId: payload.sessionId || null,
|
|
575
|
+
timer: setTimeout(() => {
|
|
576
|
+
if (pendingReq && pendingReq.project === payload.project) {
|
|
577
|
+
pendingReq = null;
|
|
578
|
+
toast('DSH 插件没有响应:确认 dsh-project-deck 插件已加载(左下角 📋 按钮)', true);
|
|
579
|
+
}
|
|
580
|
+
}, 2500),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function setFabPos(pos){
|
|
585
|
+
if (!inPluginFrame()) { toast('请在 DSH 内嵌面板里使用(浏览器打开时无效)', true); return; }
|
|
586
|
+
window.parent.postMessage({ type: 'deck:set-fab-pos', pos }, '*');
|
|
587
|
+
if (!pos) { toast('已重置为默认位置'); return; }
|
|
588
|
+
toast('图标位置已切换');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function openDeepLink(hash){
|
|
592
|
+
const base = (S && S.config && S.config.web) || 'http://127.0.0.1:43120';
|
|
593
|
+
const url = base.replace(/\/+$/, '') + '/#' + hash;
|
|
594
|
+
const w = window.open(url, '_blank');
|
|
595
|
+
navigator.clipboard?.writeText(url).catch(() => {});
|
|
596
|
+
if (w) toast('已在新标签打开 DSH;若 401/空白,把链接粘到已登录的 DSH 标签页');
|
|
597
|
+
else toast('弹窗被拦截,链接已复制:粘到 DSH 标签页地址栏回车');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 进入项目:有会话→最近一次;没有→让插件找/建 workspace 再开会话
|
|
601
|
+
function enterProject(projectId){
|
|
602
|
+
const p = (S && S.projects || []).find(x => x.id === projectId);
|
|
603
|
+
if (!p) return toast('项目不存在', true);
|
|
604
|
+
const sid = p.sessions && p.sessions.length ? p.sessions[0].id : null;
|
|
605
|
+
if (inPluginFrame()) {
|
|
606
|
+
sendToPlugin({ type: 'dsh-project-deck:open-project', project: p.id, root: p.root, sessionId: sid });
|
|
607
|
+
toast(sid ? '正在打开最近会话…' : '该项目还没有会话,正在新建…');
|
|
608
|
+
} else if (sid) {
|
|
609
|
+
openDeepLink('deck-session=' + encodeURIComponent(sid));
|
|
610
|
+
} else {
|
|
611
|
+
openDeepLink('deck-project=' + encodeURIComponent(p.root));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 详情里指定某个会话打开
|
|
616
|
+
function openSession(projectId, sessionId){
|
|
617
|
+
if (!sessionId) return toast('这个项目还没有会话', true);
|
|
618
|
+
if (inPluginFrame()) {
|
|
619
|
+
sendToPlugin({ type: 'dsh-project-deck:open-session', project: projectId, sessionId });
|
|
620
|
+
toast('正在打开会话 ' + sessionId.replace(/^(session|mesh)-/, '').slice(0, 8) + '…');
|
|
621
|
+
} else {
|
|
622
|
+
openDeepLink('deck-session=' + encodeURIComponent(sessionId));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function copyPath(p){ navigator.clipboard?.writeText(p).then(()=>toast('已复制路径'), ()=>toast('复制失败', true)); }
|
|
627
|
+
|
|
628
|
+
let SCAN = {candidates: [], lastRun: 0};
|
|
629
|
+
|
|
630
|
+
async function loadScan(){
|
|
631
|
+
try{
|
|
632
|
+
const r = await fetch('/api/scan'); const j = await r.json();
|
|
633
|
+
if (j.ok === false) throw new Error(j.error);
|
|
634
|
+
SCAN = j; renderScan();
|
|
635
|
+
}catch(e){ $('scanHint').textContent = '扫描失败:' + e.message; }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function renderScan(){
|
|
639
|
+
const box = $('scanList');
|
|
640
|
+
$('scanHint').textContent = SCAN.lastRun
|
|
641
|
+
? '上次扫描 ' + shortTime(SCAN.lastRun) + ' · ' + SCAN.candidates.length + ' 个候选'
|
|
642
|
+
: '尚未扫描';
|
|
643
|
+
if (!SCAN.candidates.length){
|
|
644
|
+
box.innerHTML = '<div class="empty">没有待确认的候选项目。新项目出现时会自动扫描到。</div>';
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
box.innerHTML = SCAN.candidates.map(c => `
|
|
648
|
+
<div class="hist scanrow">
|
|
649
|
+
<div class="top">
|
|
650
|
+
<span><b>${esc(c.name)}</b> <span class="chip">${esc(c.id)}</span>
|
|
651
|
+
<span class="chip teal">${esc(c.score)} 分</span>
|
|
652
|
+
${c.node ? `<span class="chip warn">节点 ${esc(c.node)}</span>` : `<span class="chip">${esc(c.sessions || 0)} 会话</span>`}</span>
|
|
653
|
+
<span style="display:flex;gap:6px">
|
|
654
|
+
<button onclick="addCand('${esc(c.path)}')">登记</button>
|
|
655
|
+
<button style="font-size:11px;padding:2px 8px" onclick="ignoreCand('${esc(c.path)}',7)">忽略7天</button>
|
|
656
|
+
<button style="font-size:11px;padding:2px 8px" onclick="ignoreCand('${esc(c.path)}',0)">永不</button>
|
|
657
|
+
</span>
|
|
658
|
+
</div>
|
|
659
|
+
<div class="hint" style="margin-top:6px">${esc(c.path)}</div>
|
|
660
|
+
<div class="hint">${esc((c.reasons || []).join(';') || '—')} · 最近活动 ${esc(c.last ? shortTime(c.last) : '—')}</div>
|
|
661
|
+
</div>`).join('');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function ignoreCand(path, days){
|
|
665
|
+
try{
|
|
666
|
+
const r = await api('/api/scan/ignore', {path, days});
|
|
667
|
+
S = r.state;
|
|
668
|
+
SCAN.candidates = r.candidates;
|
|
669
|
+
render(); renderScan();
|
|
670
|
+
toast(days ? ('已忽略 ' + days + ' 天') : '已永久忽略(可在 scan-ignore.json 撤销)');
|
|
671
|
+
}catch(e){ toast('操作失败:' + e.message, true); }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function addCand(path){
|
|
675
|
+
try{
|
|
676
|
+
const r = await api('/api/scan', {action:'add', paths:[path]});
|
|
677
|
+
S = r.state; SCAN = {candidates: r.candidates, lastRun: SCAN.lastRun};
|
|
678
|
+
render(); renderScan(); toast('已登记 ' + (r.added[0] || ''));
|
|
679
|
+
}catch(e){ toast('登记失败:' + e.message, true); }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function runScan(){
|
|
683
|
+
try{
|
|
684
|
+
toast('正在扫描…');
|
|
685
|
+
const r = await api('/api/scan', {action:'run'});
|
|
686
|
+
S = r.state; SCAN = {candidates: r.candidates, lastRun: Date.now()/1000};
|
|
687
|
+
render(); renderScan();
|
|
688
|
+
toast(r.added.length ? '已自动登记 ' + r.added.length + ' 个:' + r.added.map(a => a.name).join('、')
|
|
689
|
+
: '扫描完成,未发现达到阈值的新项目');
|
|
690
|
+
}catch(e){ toast('扫描失败:' + e.message, true); }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function sendImage(){
|
|
694
|
+
const b = $('btnImage'); b.disabled = true; b.textContent = '渲染中…';
|
|
695
|
+
try{
|
|
696
|
+
const r = await api('/api/image', {});
|
|
697
|
+
$('imgBox').style.display = 'block';
|
|
698
|
+
$('lastImg').src = r.url;
|
|
699
|
+
$('lastImg').onload = () => { S && (S.config.image = S.config.image); };
|
|
700
|
+
$('imgHint').textContent = (r.code === 0 ? '已送达微信' : '未送达(exit ' + r.code + '):' + r.message)
|
|
701
|
+
+ ' · ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
|
|
702
|
+
toast(r.code === 0 ? '图片已发送到微信' : r.message, r.code !== 0);
|
|
703
|
+
}catch(e){ toast('图片播报失败:' + e.message, true); }
|
|
704
|
+
finally{ b.disabled = false; b.textContent = '图片播报'; }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
let scanFormLoaded = false;
|
|
708
|
+
|
|
709
|
+
function renderDetail(d){
|
|
710
|
+
const p = d.project;
|
|
711
|
+
const gitRows = d.gitLog.length
|
|
712
|
+
? d.gitLog.map(g => `<div class="commit"><span class="sha">${esc(g.sha)}</span><span>${esc(g.subject)}</span><span class="when">${esc(g.when)}</span></div>`).join('')
|
|
713
|
+
: '<div class="hint">没有 git 提交记录</div>';
|
|
714
|
+
const goalRows = d.goals.length
|
|
715
|
+
? d.goals.map(g => `<div class="goalrow"><span class="chip">${esc(g.status)}</span><span>${esc(g.title)}</span></div>`).join('')
|
|
716
|
+
: '<div class="hint">看板为空(还没有目标)</div>';
|
|
717
|
+
const stateBox = d.state
|
|
718
|
+
? `<div class="hint" style="margin-bottom:6px">${esc(d.state.path)} · ${d.state.size} 字节</div>
|
|
719
|
+
<pre style="white-space:pre-wrap;font:12px/1.5 ui-monospace,Menlo,monospace;color:#c6d1dc;max-height:220px;overflow:auto;margin:0">${esc(d.state.excerpt)}</pre>`
|
|
720
|
+
: '<div class="hint">这个项目还没有落盘 STATE(在会话里说「落盘项目」即可创建)</div>';
|
|
721
|
+
$('mTitle').textContent = p.name + ' · ' + p.id;
|
|
722
|
+
$('mSub').textContent = `${p.kindLabel} · ${p.statusLabel} · 最近活动 ${p.activityText} · 优先级 ${p.priority}`;
|
|
723
|
+
$('mBody').innerHTML = `
|
|
724
|
+
<div class="row"><span class="k">根目录</span><span class="v">${esc(p.root)}</span></div>
|
|
725
|
+
<div class="row" style="margin-top:4px"><span class="k">下一步</span><span class="v">${esc(p.next || '(未登记)')}</span></div>
|
|
726
|
+
<div class="msec"><h4>最近会话(${(p.sessions || []).length})<button style="margin-left:10px;font-size:11px;padding:2px 8px" onclick="openPicker('${esc(p.id)}')">整理 / 绑定其它会话</button></h4>${(p.sessions || []).length
|
|
727
|
+
? p.sessions.map(s => `<div class="goalrow"><span class="chip">${esc(shortTime(s.at))}</span>
|
|
728
|
+
<span title="${esc(s.id)}" style="font-family:ui-monospace,Menlo,monospace">${esc(s.title || s.id)}</span>
|
|
729
|
+
<button style="margin-left:auto;font-size:11px;padding:2px 8px" onclick="openSession('${esc(p.id)}','${esc(s.id)}')">打开</button></div>`).join('')
|
|
730
|
+
: '<div class="hint">还没有这个项目的会话</div>'}</div>
|
|
731
|
+
<div class="msec"><h4>GIT 最近提交</h4>${gitRows}</div>
|
|
732
|
+
<div class="msec"><h4>看板目标(${d.goals.length})</h4>${goalRows}</div>
|
|
733
|
+
<div class="msec"><h4>落盘 STATE</h4>${stateBox}</div>`;
|
|
734
|
+
$('modal').classList.add('show');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function openDetail(id){
|
|
738
|
+
location.hash = 'p=' + id;
|
|
739
|
+
try{
|
|
740
|
+
const r = await fetch('/api/project?id=' + encodeURIComponent(id));
|
|
741
|
+
const j = await r.json();
|
|
742
|
+
if (j.ok === false) throw new Error(j.error);
|
|
743
|
+
renderDetail(j.detail);
|
|
744
|
+
}catch(e){ toast('加载详情失败:' + e.message, true); }
|
|
745
|
+
}
|
|
746
|
+
function closeModal(){ $('modal').classList.remove('show'); if (location.hash.startsWith('#p=')) history.replaceState(null,'',location.pathname); }
|
|
747
|
+
|
|
748
|
+
function loadConfigIntoForm(){
|
|
749
|
+
const sc = (S && S.config && S.config.scan) || {};
|
|
750
|
+
$('sRoots').value = (sc.roots || []).join('\n');
|
|
751
|
+
$('sDepth').value = sc.depth || 1;
|
|
752
|
+
$('sThreshold').value = sc.threshold ?? 70;
|
|
753
|
+
$('sInterval').value = sc.intervalHours || 24;
|
|
754
|
+
$('sAutoAdd').checked = !!sc.autoAdd;
|
|
755
|
+
$('sEnabled').checked = sc.enabled !== false;
|
|
756
|
+
scanFormLoaded = true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function saveScan(){
|
|
760
|
+
try{
|
|
761
|
+
const r = await api('/api/config', {scan: {
|
|
762
|
+
roots: $('sRoots').value.split('\n').map(x => x.trim()).filter(Boolean),
|
|
763
|
+
depth: parseInt($('sDepth').value || '1', 10),
|
|
764
|
+
threshold: parseInt($('sThreshold').value || '70', 10),
|
|
765
|
+
intervalHours: parseFloat($('sInterval').value || '24'),
|
|
766
|
+
autoAdd: $('sAutoAdd').checked,
|
|
767
|
+
enabled: $('sEnabled').checked}});
|
|
768
|
+
S = r.state; render(); toast('扫描设置已保存');
|
|
769
|
+
}catch(e){ toast('保存失败:' + e.message, true); }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let PICKER = { project: null, rows: [], q: '' };
|
|
773
|
+
|
|
774
|
+
async function openPicker(projectId){
|
|
775
|
+
PICKER = { project: projectId, rows: [], q: '' };
|
|
776
|
+
$('pkQ').value = '';
|
|
777
|
+
$('pkTitle').textContent = '整理会话 · ' + projectId;
|
|
778
|
+
$('pkList').innerHTML = '<div class="empty">加载会话索引…(首次可能要几秒)</div>';
|
|
779
|
+
$('pickerModal').classList.add('show');
|
|
780
|
+
try{
|
|
781
|
+
const r = await fetch('/api/all-sessions?limit=400');
|
|
782
|
+
const j = await r.json();
|
|
783
|
+
if (j.ok === false) throw new Error(j.error);
|
|
784
|
+
PICKER.rows = j.sessions || [];
|
|
785
|
+
renderPicker();
|
|
786
|
+
}catch(e){ $('pkList').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>'; }
|
|
787
|
+
}
|
|
788
|
+
function closePicker(){ $('pickerModal').classList.remove('show'); }
|
|
789
|
+
function filterPicker(){ PICKER.q = ($('pkQ').value || '').trim().toLowerCase(); renderPicker(); }
|
|
790
|
+
function renderPicker(){
|
|
791
|
+
const p = (S.projects || []).find(x => x.id === PICKER.project);
|
|
792
|
+
const bound = new Set((p && p.boundSessions) || []);
|
|
793
|
+
const own = new Set(((p && p.sessions) || []).map(s => s.id));
|
|
794
|
+
const q = PICKER.q;
|
|
795
|
+
const rows = PICKER.rows.filter(r => !q || (r.title || '').toLowerCase().includes(q) || (r.prompt || r.first || '').toLowerCase().includes(q) || (r.cwd || '').toLowerCase().includes(q));
|
|
796
|
+
if (!rows.length){ $('pkList').innerHTML = '<div class="empty">没有匹配的会话</div>'; return; }
|
|
797
|
+
$('pkList').innerHTML = rows.map(r => {
|
|
798
|
+
const isBound = bound.has(r.id);
|
|
799
|
+
const isOwn = own.has(r.id);
|
|
800
|
+
const badge = isBound ? '<span class="chip teal">已绑定</span>' : (isOwn ? '<span class="chip">同项目</span>' : '');
|
|
801
|
+
return `<div class="hist" style="margin-bottom:8px">
|
|
802
|
+
<div class="top"><span>${esc(shortTime(r.at))} · ${esc(r.cwd || '未知路径')}${r.title ? ' · ' + esc(r.title) : ''} ${badge}</span>
|
|
803
|
+
<button style="font-size:11px;padding:2px 9px" onclick="bindSession('${esc(PICKER.project)}','${esc(r.id)}', ${isBound ? 'false' : 'true'})">${isBound ? '解除绑定' : '绑定'}</button></div>
|
|
804
|
+
<div class="hint" style="margin-top:4px">${esc((r.first || '(无文字消息)').slice(0, 120))}</div>
|
|
805
|
+
</div>`;
|
|
806
|
+
}).join('');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function bindSession(projectId, sessionId, bind){
|
|
810
|
+
try{
|
|
811
|
+
const r = await api('/api/bind', {project: projectId, sessionId, bind});
|
|
812
|
+
S = r.state;
|
|
813
|
+
const p = (S.projects || []).find(x => x.id === projectId);
|
|
814
|
+
PICKER.rows && renderPicker();
|
|
815
|
+
if (p) {
|
|
816
|
+
const b = new Set(p.boundSessions || []);
|
|
817
|
+
if (bind) b.add(sessionId); else b.delete(sessionId);
|
|
818
|
+
p.boundSessions = [...b];
|
|
819
|
+
render();
|
|
820
|
+
}
|
|
821
|
+
toast(bind ? '已绑定会话' : '已解除绑定');
|
|
822
|
+
}catch(e){ toast('操作失败:' + e.message, true); }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function loadImages(){
|
|
826
|
+
try{
|
|
827
|
+
const r = await fetch('/api/image/history'); const j = await r.json();
|
|
828
|
+
const box = $('imgGallery');
|
|
829
|
+
if (!j.images || !j.images.length){ box.innerHTML = '<div class="empty">还没有生成过图片</div>'; $('imgCount').textContent = ''; return; }
|
|
830
|
+
$('imgCount').textContent = '最近 ' + j.images.length + ' 张';
|
|
831
|
+
box.innerHTML = j.images.map(im => `
|
|
832
|
+
<div><a href="${esc(im.url)}" target="_blank"><img src="${esc(im.url)}" alt="播报图"></a>
|
|
833
|
+
<div class="hint" style="margin-top:5px">${esc(shortTime(im.at))}</div></div>`).join('');
|
|
834
|
+
}catch(e){ /* 忽略 */ }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function refresh(){
|
|
838
|
+
try{
|
|
839
|
+
const r = await fetch('/api/state'); S = await r.json();
|
|
840
|
+
offset = S.now - Date.now()/1000;
|
|
841
|
+
render(); tick();
|
|
842
|
+
}catch(e){ toast('面板数据加载失败:' + e.message, true); }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
refresh().then(() => {
|
|
846
|
+
const h = location.hash;
|
|
847
|
+
if (h.startsWith('#p=')) openDetail(decodeURIComponent(h.slice(3)));
|
|
848
|
+
});
|
|
849
|
+
loadScan();
|
|
850
|
+
loadImages();
|
|
851
|
+
setInterval(tick, 1000);
|
|
852
|
+
setInterval(refresh, 15000);
|
|
853
|
+
</script>
|
|
854
|
+
</body>
|
|
855
|
+
</html>
|