dsh-project-deck 0.1.0 → 0.1.3
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/README.md +46 -4
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +317 -9
- package/backend/panel.html +63 -1
- package/backend/panel.py +51 -6
- package/lib/client.js +129 -61
- package/package.json +2 -2
- package/backend/__pycache__/deckcpython-312.pyc +0 -0
- package/backend/__pycache__/panel.cpython-312.pyc +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-project-deck
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-project-deck)
|
|
4
|
+
|
|
3
5
|
**把你的仓库、目录和半途项目,变成一块看得见进度的甲板。**
|
|
4
6
|
A project deck for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):项目注册表 + 可视化面板 + 定时微信播报 + 自动发现 + 多机扫描。
|
|
5
7
|
|
|
@@ -29,11 +31,12 @@ python3 backend/deck demo # 生成 4 个假项目,起面板
|
|
|
29
31
|
|
|
30
32
|
```bash
|
|
31
33
|
# 1. 后端(CLI + 面板 + 定时器)
|
|
32
|
-
|
|
34
|
+
git clone https://github.com/peng456/dsh-project-deck && cd dsh-project-deck && ./install.sh
|
|
33
35
|
|
|
34
|
-
# 2. DSH
|
|
35
|
-
dsh plugin --profile desktop add
|
|
36
|
-
#
|
|
36
|
+
# 2. DSH 插件(npm 或 GitHub 二选一)
|
|
37
|
+
dsh plugin --profile desktop add dsh-project-deck
|
|
38
|
+
# dsh plugin --profile desktop add github:peng456/dsh-project-deck
|
|
39
|
+
# 或 Desktop → 插件 → 搜索/安装 dsh-project-deck
|
|
37
40
|
```
|
|
38
41
|
|
|
39
42
|
重启 DSH 后左下角出现 📋,**可以拖到页面任意位置**,位置会被记住;点开就是面板。
|
|
@@ -64,6 +67,33 @@ deck demo # 演示环境
|
|
|
64
67
|
无变化:官网改版、基础设施、…
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
## 通知渠道(可选,默认 wxpush)
|
|
71
|
+
|
|
72
|
+
播报通过 `report.delivery` 选择发送器,**不带任何内置账号**:
|
|
73
|
+
|
|
74
|
+
```jsonc
|
|
75
|
+
"report": {
|
|
76
|
+
"enabled": true,
|
|
77
|
+
"image": false, // 附图仅 wxpush 通道支持
|
|
78
|
+
"delivery": {
|
|
79
|
+
"type": "wxpush", // wxpush | serverchan | webhook | command
|
|
80
|
+
"url": "http://127.0.0.1:9098", // wxpush
|
|
81
|
+
"key": "", // ServerChan SendKey
|
|
82
|
+
"webhook": "", // 任意 webhook(企业微信机器人等)
|
|
83
|
+
"command": "" // 自写脚本:stdin 收文本,退出码 0 = 成功
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| 类型 | 依赖 | 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `wxpush` | 本机 wxpush 服务 | 支持文字 + 看板图;token 过期时会入队等恢复 |
|
|
91
|
+
| `serverchan` | ServerChan SendKey | 纯 HTTP,无 token 过期问题 |
|
|
92
|
+
| `webhook` | 任意 URL | 发 `{"msgtype":"text","text":{"content":...}}`,兼容企业微信机器人 |
|
|
93
|
+
| `command` | 你的脚本 | 最灵活,退出码判定 |
|
|
94
|
+
|
|
95
|
+
`deck status` 会显示当前通道与连通状态;通道不通时播报不会堆积(附图除外,会有限重试)。
|
|
96
|
+
|
|
67
97
|
## 配置(`~/.project-deck/config.json`)
|
|
68
98
|
|
|
69
99
|
| key | 默认 | 说明 |
|
|
@@ -75,6 +105,18 @@ deck demo # 演示环境
|
|
|
75
105
|
| `schedule.quiet` | 00:30–06:30 | 安静时段,不播报不扫描 |
|
|
76
106
|
| `panel.allowLanRead` | true | 允许手机通过局域网看面板(只读) |
|
|
77
107
|
|
|
108
|
+
## 自主推进(可选)
|
|
109
|
+
|
|
110
|
+
每个项目可设 `off` / `assist` / `auto`:`assist` 只给选项和推荐,`auto` 会自己查事实、自己选方案、做可逆的事,把 push/删除/发布这类不可逆动作放进审批队列等你一句话。
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
deck autonomy my-app auto
|
|
114
|
+
deck approvals # 等你拍板
|
|
115
|
+
deck decisions # 我替你做过的决定
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
规则写在 [docs/AUTONOMY.md](docs/AUTONOMY.md)。
|
|
119
|
+
|
|
78
120
|
## 多机扫描
|
|
79
121
|
|
|
80
122
|
节点只读上报,不传文件内容:
|
|
Binary file
|
package/backend/deck
CHANGED
|
@@ -174,6 +174,190 @@ def session_activity(root):
|
|
|
174
174
|
return latest
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
def autonomy_of(p):
|
|
178
|
+
a = p.get('autonomy') or {}
|
|
179
|
+
mode = a.get('mode', 'off')
|
|
180
|
+
if mode not in AUTONOMY_MODES:
|
|
181
|
+
mode = 'off'
|
|
182
|
+
return {'mode': mode, 'maxPerDay': int(a.get('maxPerDay', 3)),
|
|
183
|
+
'day': a.get('day', ''), 'actionsToday': int(a.get('actionsToday', 0))}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _today():
|
|
187
|
+
return datetime.now().strftime('%Y-%m-%d')
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def can_act(p):
|
|
191
|
+
a = autonomy_of(p)
|
|
192
|
+
if a['mode'] == 'off':
|
|
193
|
+
return False, 'off'
|
|
194
|
+
if a['day'] != _today():
|
|
195
|
+
return a['mode'] != 'off', 'new-day'
|
|
196
|
+
if a['actionsToday'] >= a['maxPerDay']:
|
|
197
|
+
return False, f"预算用尽({a['actionsToday']}/{a['maxPerDay']})"
|
|
198
|
+
return True, 'ok'
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def bump_actions(pid):
|
|
202
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
203
|
+
for p in reg['projects']:
|
|
204
|
+
if p['id'] == pid:
|
|
205
|
+
a = autonomy_of(p)
|
|
206
|
+
if a['day'] != _today():
|
|
207
|
+
a['day'] = _today(); a['actionsToday'] = 0
|
|
208
|
+
a['actionsToday'] += 1
|
|
209
|
+
p['autonomy'] = a
|
|
210
|
+
save_json(REGISTRY, reg)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def record_decision(pid, question, options, choice, why, reversible=True, mode=None):
|
|
214
|
+
entry = {'at': time.time(), 'project': pid, 'question': question,
|
|
215
|
+
'options': options, 'choice': choice, 'why': why,
|
|
216
|
+
'reversible': bool(reversible), 'mode': mode or ''}
|
|
217
|
+
with open(DECISIONS, 'a') as f:
|
|
218
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
219
|
+
log(f'[autonomy] decision {pid}: {choice} ({question[:40]})')
|
|
220
|
+
return entry
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_approvals():
|
|
224
|
+
return load_json(APPROVALS, {'items': []})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def add_approval(pid, question, options, recommend='', irreversible=True):
|
|
228
|
+
d = load_approvals()
|
|
229
|
+
item = {'id': 'ap-' + secrets.token_hex(4), 'project': pid, 'question': question,
|
|
230
|
+
'options': options, 'recommend': recommend, 'irreversible': bool(irreversible),
|
|
231
|
+
'createdAt': time.time(), 'status': 'pending'}
|
|
232
|
+
d.setdefault('items', []).append(item)
|
|
233
|
+
save_json(APPROVALS, d)
|
|
234
|
+
log(f'[autonomy] approval {item["id"]} {pid}: {question[:50]}')
|
|
235
|
+
return item
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def resolve_approval(aid, decision, choice='', why=''):
|
|
239
|
+
d = load_approvals()
|
|
240
|
+
for it in d.get('items', []):
|
|
241
|
+
if it['id'] == aid and it['status'] == 'pending':
|
|
242
|
+
it['status'] = 'approved' if decision == 'approve' else 'rejected'
|
|
243
|
+
it['resolvedAt'] = time.time()
|
|
244
|
+
it['humanChoice'] = choice or it.get('recommend', '')
|
|
245
|
+
if why:
|
|
246
|
+
it['note'] = why
|
|
247
|
+
save_json(APPROVALS, d)
|
|
248
|
+
record_decision(it['project'], it['question'], it.get('options', []),
|
|
249
|
+
it['humanChoice'] if decision == 'approve' else 'rejected',
|
|
250
|
+
'human: ' + (why or decision), True, 'human')
|
|
251
|
+
return it
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_autonomy(argv):
|
|
256
|
+
if not argv:
|
|
257
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
258
|
+
rows = [(p['id'], autonomy_of(p)) for p in reg['projects']]
|
|
259
|
+
for pid, a in rows:
|
|
260
|
+
print(f"{pid:22} {a['mode']:7} 今日 {a['actionsToday']}/{a['maxPerDay']}")
|
|
261
|
+
return 0
|
|
262
|
+
pid = argv[0]
|
|
263
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
264
|
+
proj = next((p for p in reg['projects'] if p['id'] == pid), None)
|
|
265
|
+
if not proj:
|
|
266
|
+
print('项目不存在'); return 1
|
|
267
|
+
if len(argv) < 2:
|
|
268
|
+
a = autonomy_of(proj); print(f"{pid}: {a['mode']} 今日 {a['actionsToday']}/{a['maxPerDay']}"); return 0
|
|
269
|
+
mode = argv[1]
|
|
270
|
+
if mode not in AUTONOMY_MODES:
|
|
271
|
+
print('模式只能是 off / assist / auto'); return 1
|
|
272
|
+
a = autonomy_of(proj)
|
|
273
|
+
a['mode'] = mode
|
|
274
|
+
if '--max' in argv:
|
|
275
|
+
try: a['maxPerDay'] = int(argv[argv.index('--max') + 1])
|
|
276
|
+
except (IndexError, ValueError): pass
|
|
277
|
+
proj['autonomy'] = a
|
|
278
|
+
save_json(REGISTRY, reg)
|
|
279
|
+
print(f"{pid}: 自主模式 → {mode}(每日上限 {a['maxPerDay']})")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_decide(argv):
|
|
284
|
+
if not argv:
|
|
285
|
+
print('用法: deck decide <id> --question Q --options A|B|C --choice B --why "..." [--irreversible]')
|
|
286
|
+
return 1
|
|
287
|
+
pid = argv[0]
|
|
288
|
+
opts = {}
|
|
289
|
+
i = 1
|
|
290
|
+
while i < len(argv):
|
|
291
|
+
if argv[i].startswith('--') and i + 1 < len(argv):
|
|
292
|
+
opts[argv[i][2:]] = argv[i + 1]; i += 2
|
|
293
|
+
else: i += 1
|
|
294
|
+
q = opts.get('question', ''); choice = opts.get('choice', '')
|
|
295
|
+
if not q or not choice:
|
|
296
|
+
print('缺少 --question / --choice'); return 1
|
|
297
|
+
record_decision(pid, q, opts.get('options', ''), choice, opts.get('why', ''),
|
|
298
|
+
not ('irreversible' in opts or '--irreversible' in argv))
|
|
299
|
+
bump_actions(pid)
|
|
300
|
+
print('已记录决策')
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_ask(argv):
|
|
305
|
+
if not argv:
|
|
306
|
+
print('用法: deck ask <id> --question Q --options A,B [--recommend A]')
|
|
307
|
+
return 1
|
|
308
|
+
pid = argv[0]; opts = {}
|
|
309
|
+
i = 1
|
|
310
|
+
while i < len(argv):
|
|
311
|
+
if argv[i].startswith('--') and i + 1 < len(argv):
|
|
312
|
+
opts[argv[i][2:]] = argv[i + 1]; i += 2
|
|
313
|
+
else: i += 1
|
|
314
|
+
q = opts.get('question', '')
|
|
315
|
+
if not q: print('缺少 --question'); return 1
|
|
316
|
+
it = add_approval(pid, q, opts.get('options', ''), opts.get('recommend', ''))
|
|
317
|
+
print('已加入审批队列:', it['id'])
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_approvals(argv):
|
|
322
|
+
d = load_approvals()
|
|
323
|
+
pend = [x for x in d.get('items', []) if x.get('status') == 'pending']
|
|
324
|
+
if not argv:
|
|
325
|
+
if not pend: print('没有待批事项'); return 0
|
|
326
|
+
for x in pend:
|
|
327
|
+
age = rel_time(x['createdAt'])
|
|
328
|
+
print(f"{x['id']} {x['project']:18} {age:8} {x['question'][:60]}")
|
|
329
|
+
if x.get('options'): print(' 选项:', x['options'], '| 推荐:', x.get('recommend') or '-')
|
|
330
|
+
return 0
|
|
331
|
+
if argv[0] in ('--approve', '--reject') and len(argv) > 1:
|
|
332
|
+
aid = argv[1]
|
|
333
|
+
choice = ''
|
|
334
|
+
if '--choice' in argv: choice = argv[argv.index('--choice') + 1]
|
|
335
|
+
why = ''
|
|
336
|
+
if '--why' in argv: why = argv[argv.index('--why') + 1]
|
|
337
|
+
it = resolve_approval(aid, 'approve' if argv[0] == '--approve' else 'reject', choice, why)
|
|
338
|
+
print('已处理' if it else '找不到该待批项')
|
|
339
|
+
return 0
|
|
340
|
+
print('用法: deck approvals [--approve ID [--choice X] | --reject ID [--why "..."]]')
|
|
341
|
+
return 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_decisions(argv):
|
|
345
|
+
limit = 20
|
|
346
|
+
if '--limit' in argv:
|
|
347
|
+
try: limit = int(argv[argv.index('--limit') + 1])
|
|
348
|
+
except (IndexError, ValueError): pass
|
|
349
|
+
if not os.path.exists(DECISIONS):
|
|
350
|
+
print('还没有决策记录'); return 0
|
|
351
|
+
rows = []
|
|
352
|
+
for line in open(DECISIONS):
|
|
353
|
+
try: rows.append(json.loads(line))
|
|
354
|
+
except json.JSONDecodeError: pass
|
|
355
|
+
for r in rows[-limit:]:
|
|
356
|
+
flag = '' if r.get('reversible', True) else ' [不可逆]'
|
|
357
|
+
print(f"{datetime.fromtimestamp(r['at']):%m-%d %H:%M} {r['project']:18} {r['choice']}{flag} ← {r['question'][:50]}")
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
177
361
|
def in_quiet(cfg, ts=None):
|
|
178
362
|
q = (cfg.get('schedule') or {}).get('quiet') or {}
|
|
179
363
|
start, end = q.get('from'), q.get('to')
|
|
@@ -412,6 +596,21 @@ def build_report(cfg):
|
|
|
412
596
|
if added or cands:
|
|
413
597
|
lines.append('')
|
|
414
598
|
|
|
599
|
+
# 等你拍板 + 自主推进概览
|
|
600
|
+
pend = [x for x in load_approvals().get('items', []) if x.get('status') == 'pending']
|
|
601
|
+
if pend:
|
|
602
|
+
lines.append(f'**等你拍板**:{len(pend)} 项')
|
|
603
|
+
for x in pend[:3]:
|
|
604
|
+
opts = f"({x['options']},推荐 {x.get('recommend') or '-'})" if x.get('options') else ''
|
|
605
|
+
lines.append(f"- {x['project']}:{x['question'][:50]}{opts}")
|
|
606
|
+
lines.append('')
|
|
607
|
+
modes = {'auto': 0, 'assist': 0, 'off': 0}
|
|
608
|
+
for p in projects:
|
|
609
|
+
modes[autonomy_of(p)['mode']] = modes.get(autonomy_of(p)['mode'], 0) + 1
|
|
610
|
+
if modes['auto'] or modes['assist']:
|
|
611
|
+
lines.append(f"自主推进:auto {modes['auto']} · assist {modes['assist']} · off {modes['off']}")
|
|
612
|
+
lines.append('')
|
|
613
|
+
|
|
415
614
|
# 节点离线提醒(超过 3 天没上报)
|
|
416
615
|
try:
|
|
417
616
|
off = []
|
|
@@ -455,6 +654,9 @@ def build_report(cfg):
|
|
|
455
654
|
WS_STORE = os.path.join(HOME, '.dsh', 'storages', 'workspace.json')
|
|
456
655
|
SCAN_STATE = os.path.join(BASE, 'scan-state.json')
|
|
457
656
|
SCAN_IGNORE = os.path.join(BASE, 'scan-ignore.json')
|
|
657
|
+
DECISIONS = os.path.join(BASE, 'decisions.jsonl')
|
|
658
|
+
APPROVALS = os.path.join(BASE, 'approvals.json')
|
|
659
|
+
AUTONOMY_MODES = ('off', 'assist', 'auto')
|
|
458
660
|
NODES = os.path.join(BASE, 'nodes.json')
|
|
459
661
|
NODE_SNAPS = os.path.join(BASE, 'nodes')
|
|
460
662
|
|
|
@@ -815,6 +1017,97 @@ def cmd_scan(argv):
|
|
|
815
1017
|
|
|
816
1018
|
|
|
817
1019
|
# ── 发送 ────────────────────────────────────────────────────
|
|
1020
|
+
def delivery_cfg(cfg=None):
|
|
1021
|
+
cfg = cfg or load_json(CONFIG, {})
|
|
1022
|
+
d = (cfg.get('report') or {}).get('delivery') or {}
|
|
1023
|
+
return {
|
|
1024
|
+
'type': (d.get('type') or 'wxpush').lower(),
|
|
1025
|
+
'url': (d.get('url') or 'http://127.0.0.1:9098').rstrip('/'),
|
|
1026
|
+
'key': d.get('key') or '',
|
|
1027
|
+
'webhook': d.get('webhook') or '',
|
|
1028
|
+
'command': d.get('command') or '',
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _http_json(url, payload, timeout=20, headers=None):
|
|
1033
|
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
1034
|
+
req = urllib.request.Request(url, data=body,
|
|
1035
|
+
headers={'Content-Type': 'application/json', **(headers or {})})
|
|
1036
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1037
|
+
return r.status, json.loads(r.read().decode() or '{}')
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def delivery_send(text):
|
|
1041
|
+
"""按 report.delivery.type 发送一条文本。返回 (0|2|3, 说明)。"""
|
|
1042
|
+
d = delivery_cfg()
|
|
1043
|
+
t = d['type']
|
|
1044
|
+
try:
|
|
1045
|
+
if t == 'wxpush':
|
|
1046
|
+
if not d['url']:
|
|
1047
|
+
return 3, 'wxpush url 未配置'
|
|
1048
|
+
code, body = _http_json(d['url'] + '/send', {'message': text})
|
|
1049
|
+
if code == 200:
|
|
1050
|
+
return 0, '已送达(wxpush)'
|
|
1051
|
+
if code == 202:
|
|
1052
|
+
return 2, '已入队(wxpush 未接通)'
|
|
1053
|
+
return 3, f'wxpush HTTP {code}'
|
|
1054
|
+
if t == 'serverchan':
|
|
1055
|
+
if not d['key']:
|
|
1056
|
+
return 2, 'ServerChan key 未配置'
|
|
1057
|
+
url = f"https://sctapi.ftqq.com/{d['key']}.send"
|
|
1058
|
+
import urllib.parse
|
|
1059
|
+
data = urllib.parse.urlencode({'title': '项目播报', 'desp': text}).encode()
|
|
1060
|
+
req = urllib.request.Request(url, data=data)
|
|
1061
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
1062
|
+
j = json.loads(r.read().decode() or '{}')
|
|
1063
|
+
if j.get('code') in (0, '0'):
|
|
1064
|
+
return 0, '已送达(ServerChan)'
|
|
1065
|
+
return 3, f"ServerChan 返回 {str(j)[:120]}"
|
|
1066
|
+
if t == 'webhook':
|
|
1067
|
+
if not d['webhook']:
|
|
1068
|
+
return 2, 'webhook 未配置'
|
|
1069
|
+
code, body = _http_json(d['webhook'], {
|
|
1070
|
+
'msgtype': 'text', 'text': {'content': text}, 'message': text})
|
|
1071
|
+
return (0, '已送达(webhook)') if code in (200, 204) else (3, f'webhook HTTP {code}')
|
|
1072
|
+
if t == 'command':
|
|
1073
|
+
if not d['command']:
|
|
1074
|
+
return 2, 'command 未配置'
|
|
1075
|
+
r = subprocess.run(d['command'], shell=True, input=text, capture_output=True, text=True, timeout=60)
|
|
1076
|
+
if r.returncode == 0:
|
|
1077
|
+
return 0, '已送达(command)'
|
|
1078
|
+
return 3, f"command 退出码 {r.returncode}: {(r.stderr or r.stdout)[:120]}"
|
|
1079
|
+
return 3, f'未知 delivery.type: {t}'
|
|
1080
|
+
except Exception as e:
|
|
1081
|
+
return 3, f'{t} 发送失败: {e}'
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def delivery_status():
|
|
1085
|
+
"""给面板/status 用:返回 {ok, stale, detail, pending}。"""
|
|
1086
|
+
d = delivery_cfg()
|
|
1087
|
+
out = {'type': d['type'], 'ok': False, 'detail': '', 'pending': 0}
|
|
1088
|
+
try:
|
|
1089
|
+
if d['type'] == 'wxpush':
|
|
1090
|
+
with urllib.request.urlopen(d['url'] + '/health', timeout=5) as r:
|
|
1091
|
+
j = json.loads(r.read().decode() or '{}')
|
|
1092
|
+
out['ok'] = bool(j.get('ready') or j.get('authorized'))
|
|
1093
|
+
out['pending'] = int((j.get('outbox') or {}).get('pending') or 0)
|
|
1094
|
+
out['detail'] = 'wxpush 已接通' if out['ok'] else ('wxpush 未接通:需要扫码授权或给 bot 发消息刷新')
|
|
1095
|
+
if j.get('token_dead'):
|
|
1096
|
+
out['detail'] = 'wxpush 登录态已失效:需要重新扫码'
|
|
1097
|
+
elif d['type'] == 'serverchan':
|
|
1098
|
+
out['ok'] = bool(d['key'])
|
|
1099
|
+
out['detail'] = 'ServerChan 已配置' if out['ok'] else 'ServerChan key 未配置'
|
|
1100
|
+
elif d['type'] == 'webhook':
|
|
1101
|
+
out['ok'] = bool(d['webhook'])
|
|
1102
|
+
out['detail'] = 'webhook 已配置' if out['ok'] else 'webhook 未配置'
|
|
1103
|
+
elif d['type'] == 'command':
|
|
1104
|
+
out['ok'] = bool(d['command'])
|
|
1105
|
+
out['detail'] = f"command: {d['command']}" if out['ok'] else 'command 未配置'
|
|
1106
|
+
except Exception as e:
|
|
1107
|
+
out['detail'] = f'{d["type"]} 不可达: {e}'
|
|
1108
|
+
return out
|
|
1109
|
+
|
|
1110
|
+
|
|
818
1111
|
def channel_state():
|
|
819
1112
|
return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
|
|
820
1113
|
|
|
@@ -852,15 +1145,12 @@ def mark_channel(ok, err=''):
|
|
|
852
1145
|
|
|
853
1146
|
|
|
854
1147
|
def send_wechat(text):
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
r = subprocess.run([WXSEND], input=text, capture_output=True, text=True, timeout=30)
|
|
858
|
-
msg = (r.stdout or r.stderr).strip()
|
|
859
|
-
if r.returncode == 0:
|
|
1148
|
+
code, msg = delivery_send(text)
|
|
1149
|
+
if code == 0:
|
|
860
1150
|
mark_channel(True)
|
|
861
|
-
elif
|
|
862
|
-
mark_channel(False, msg
|
|
863
|
-
return
|
|
1151
|
+
elif code == 2:
|
|
1152
|
+
mark_channel(False, msg[:120])
|
|
1153
|
+
return code, msg
|
|
864
1154
|
|
|
865
1155
|
|
|
866
1156
|
def refresh_local_node(cfg=None):
|
|
@@ -930,6 +1220,8 @@ def do_report_with_image(cfg, quiet=False, force=None):
|
|
|
930
1220
|
"""播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
|
|
931
1221
|
code, text, msg = do_report(cfg, quiet)
|
|
932
1222
|
want = cfg.get('report', {}).get('image') if force is None else force
|
|
1223
|
+
if delivery_cfg(cfg)['type'] != 'wxpush':
|
|
1224
|
+
want = False
|
|
933
1225
|
if code == 0 and want:
|
|
934
1226
|
try:
|
|
935
1227
|
png = render_image(cfg)
|
|
@@ -1001,6 +1293,8 @@ def flush_pending_image():
|
|
|
1001
1293
|
p = load_json(PENDING_IMG, None)
|
|
1002
1294
|
if not p:
|
|
1003
1295
|
return False
|
|
1296
|
+
if delivery_cfg()['type'] != 'wxpush':
|
|
1297
|
+
return False
|
|
1004
1298
|
if channel_stale():
|
|
1005
1299
|
return False
|
|
1006
1300
|
# 失败后 2 分钟内不重复尝试,避免空转
|
|
@@ -1376,6 +1670,9 @@ def cmd_status():
|
|
|
1376
1670
|
print(f"周期播报: {'开启' if r.get('enabled', True) else '关闭'},每 {r.get('intervalHours', 4)} 小时,"
|
|
1377
1671
|
f"版式 {r.get('format', 'markdown')},{'附图' if r.get('image') else '不附图'}")
|
|
1378
1672
|
print(f"守护进程: {'运行中 pid ' + str(pid) if pid else '未运行'}")
|
|
1673
|
+
pend_count = len([x for x in load_approvals().get('items', []) if x.get('status') == 'pending'])
|
|
1674
|
+
if pend_count:
|
|
1675
|
+
print(f"待批事项: {pend_count} 个")
|
|
1379
1676
|
sc = load_json(SCAN_STATE, {})
|
|
1380
1677
|
if sc.get('lastRun'):
|
|
1381
1678
|
print(f"自动发现: 上次扫描 {datetime.fromtimestamp(sc['lastRun']):%m-%d %H:%M}"
|
|
@@ -1392,7 +1689,8 @@ def cmd_status():
|
|
|
1392
1689
|
nxt += 1800
|
|
1393
1690
|
print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
|
|
1394
1691
|
wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
|
|
1395
|
-
|
|
1692
|
+
st = delivery_status()
|
|
1693
|
+
print(f"通知渠道: {st['type']} · " + ('✅ ' if st['ok'] else '⚠️ ') + st['detail'])
|
|
1396
1694
|
return 0 if pid else 1
|
|
1397
1695
|
|
|
1398
1696
|
|
|
@@ -1529,6 +1827,16 @@ def main():
|
|
|
1529
1827
|
st['last_run'] = time.time()
|
|
1530
1828
|
save_json(STATE, st)
|
|
1531
1829
|
return code
|
|
1830
|
+
if cmd == 'autonomy':
|
|
1831
|
+
return cmd_autonomy(rest)
|
|
1832
|
+
if cmd == 'decide':
|
|
1833
|
+
return cmd_decide(rest)
|
|
1834
|
+
if cmd == 'ask':
|
|
1835
|
+
return cmd_ask(rest)
|
|
1836
|
+
if cmd == 'approvals':
|
|
1837
|
+
return cmd_approvals(rest)
|
|
1838
|
+
if cmd == 'decisions':
|
|
1839
|
+
return cmd_decisions(rest)
|
|
1532
1840
|
if cmd == 'demo':
|
|
1533
1841
|
return cmd_demo(rest)
|
|
1534
1842
|
if cmd == 'panel':
|
package/backend/panel.html
CHANGED
|
@@ -155,6 +155,13 @@
|
|
|
155
155
|
</label>
|
|
156
156
|
<label>每 <input type="number" id="interval" min="0.1" max="720" step="0.5" style="width:78px"> 小时播报一次</label>
|
|
157
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>
|
|
158
165
|
<label style="margin-left:6px">版式
|
|
159
166
|
<select id="format" onchange="setConfig({format:this.value})" style="padding:5px 8px">
|
|
160
167
|
<option value="markdown">Markdown</option>
|
|
@@ -186,6 +193,11 @@
|
|
|
186
193
|
<span class="hint" id="filterHint"></span>
|
|
187
194
|
</div>
|
|
188
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
|
+
|
|
189
201
|
<div class="grid" id="grid"></div>
|
|
190
202
|
|
|
191
203
|
<details class="add">
|
|
@@ -341,6 +353,44 @@ function render(){
|
|
|
341
353
|
if (!scanFormLoaded && S.config.scan) { loadConfigIntoForm(); }
|
|
342
354
|
renderHistory();
|
|
343
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); }
|
|
344
394
|
}
|
|
345
395
|
|
|
346
396
|
function renderNodes(){
|
|
@@ -381,6 +431,8 @@ function cardHtml(p){
|
|
|
381
431
|
const board = p.board && p.board.total ? `<span class="chip">看板 ${p.board.total}</span>` : '<span class="chip">无看板</span>';
|
|
382
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>` : '';
|
|
383
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>`;
|
|
384
436
|
const next = p.next || '<span style="color:var(--muted)">(未登记进展)</span>';
|
|
385
437
|
return `<div class="card ${st}" id="card-${esc(p.id)}">
|
|
386
438
|
<div class="cardhead">
|
|
@@ -389,13 +441,16 @@ function cardHtml(p){
|
|
|
389
441
|
<button class="enter" onclick="enterProject('${esc(p.id)}')"
|
|
390
442
|
title="${p.sessions && p.sessions.length ? '进入该项目最近一次会话' : '该项目还没有会话,点击新建一个'}">💬 进入会话</button>
|
|
391
443
|
</div>
|
|
392
|
-
<div class="chips">${kind}${statusChip(p)}${mem}${board}${git}${missing}</div>
|
|
444
|
+
<div class="chips">${kind}${statusChip(p)}${autChip}${mem}${board}${git}${missing}</div>
|
|
393
445
|
<div class="next">下一步:${esc(p.next || '(未登记进展)')}</div>
|
|
394
446
|
<div class="row"><span class="k">最近活动</span><span class="v">${esc(p.activityText)}</span></div>
|
|
395
447
|
<div class="row"><span class="k">根目录</span><span class="v">${esc(p.root)}</span></div>
|
|
396
448
|
${p.note ? `<div class="row"><span class="k">说明</span><span class="v">${esc(p.note)}</span></div>` : ''}
|
|
397
449
|
<footer>
|
|
398
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>
|
|
399
454
|
<select onchange="setStatus('${esc(p.id)}', this.value)" style="font-size:12px;padding:5px 8px">
|
|
400
455
|
${['active','paused','blocked','archived'].map(s => `<option value="${s}" ${p.status===s?'selected':''}>${statusName(s)}</option>`).join('')}
|
|
401
456
|
</select>
|
|
@@ -526,6 +581,13 @@ function sendToPlugin(payload){
|
|
|
526
581
|
};
|
|
527
582
|
}
|
|
528
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
|
+
|
|
529
591
|
function openDeepLink(hash){
|
|
530
592
|
const base = (S && S.config && S.config.web) || 'http://127.0.0.1:43120';
|
|
531
593
|
const url = base.replace(/\/+$/, '') + '/#' + hash;
|
package/backend/panel.py
CHANGED
|
@@ -48,12 +48,10 @@ P = load_deck()
|
|
|
48
48
|
|
|
49
49
|
# ── 数据汇总 ────────────────────────────────────────────────
|
|
50
50
|
def wechat_state():
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
except (OSError, subprocess.SubprocessError):
|
|
56
|
-
return {'ok': False, 'raw': 'wxsend 不可用', 'code': 3}
|
|
51
|
+
st = P.delivery_status()
|
|
52
|
+
return {'ok': bool(st.get('ok')), 'raw': st.get('detail', ''),
|
|
53
|
+
'code': 0 if st.get('ok') else 2, 'type': st.get('type'),
|
|
54
|
+
'pending': st.get('pending', 0)}
|
|
57
55
|
|
|
58
56
|
|
|
59
57
|
def project_view(p):
|
|
@@ -68,6 +66,7 @@ def project_view(p):
|
|
|
68
66
|
'exists': info['exists'], 'memory': info['memory'],
|
|
69
67
|
'activity': info['activity'], 'activityText': P.rel_time(info['activity']),
|
|
70
68
|
'git': info['git'], 'board': info['board'],
|
|
69
|
+
'autonomy': P.autonomy_of(p),
|
|
71
70
|
'sessions': project_sessions(p.get('root', ''), p.get('sessions') or []),
|
|
72
71
|
'boundSessions': p.get('sessions') or [],
|
|
73
72
|
}
|
|
@@ -120,6 +119,8 @@ def snapshot():
|
|
|
120
119
|
'projects': projects,
|
|
121
120
|
'history': history,
|
|
122
121
|
'nodes': nodes_view(),
|
|
122
|
+
'approvals': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending'],
|
|
123
|
+
'decisions': decisions_view(10),
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
|
|
@@ -616,6 +617,41 @@ def bind_session(payload):
|
|
|
616
617
|
return snapshot()
|
|
617
618
|
|
|
618
619
|
|
|
620
|
+
def set_autonomy(project, mode, max_per_day=None):
|
|
621
|
+
if mode not in ('off', 'assist', 'auto'):
|
|
622
|
+
raise ValueError('mode 只能是 off / assist / auto')
|
|
623
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
624
|
+
proj = next((x for x in reg['projects'] if x['id'] == project), None)
|
|
625
|
+
if not proj:
|
|
626
|
+
raise ValueError('项目不存在')
|
|
627
|
+
a = P.autonomy_of(proj)
|
|
628
|
+
a['mode'] = mode
|
|
629
|
+
if max_per_day is not None:
|
|
630
|
+
a['maxPerDay'] = int(max_per_day)
|
|
631
|
+
proj['autonomy'] = a
|
|
632
|
+
P.save_json(P.REGISTRY, reg)
|
|
633
|
+
P.log(f'[panel] autonomy {project} -> {mode}')
|
|
634
|
+
return snapshot()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def resolve_approval(approval_id, action, choice=''):
|
|
638
|
+
it = P.resolve_approval(approval_id, action, choice)
|
|
639
|
+
if not it:
|
|
640
|
+
raise ValueError('待批项不存在或已处理')
|
|
641
|
+
return {'item': it, 'state': snapshot()}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def decisions_view(limit=20):
|
|
645
|
+
rows = []
|
|
646
|
+
if os.path.exists(P.DECISIONS):
|
|
647
|
+
for line in open(P.DECISIONS):
|
|
648
|
+
try:
|
|
649
|
+
rows.append(json.loads(line))
|
|
650
|
+
except json.JSONDecodeError:
|
|
651
|
+
continue
|
|
652
|
+
return rows[-limit:][::-1]
|
|
653
|
+
|
|
654
|
+
|
|
619
655
|
def ignore_candidate(path, days):
|
|
620
656
|
if not path:
|
|
621
657
|
raise ValueError('缺少 path')
|
|
@@ -721,6 +757,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
721
757
|
self.send_response(status)
|
|
722
758
|
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
|
723
759
|
self.send_header('Content-Length', str(len(body)))
|
|
760
|
+
self.send_header('Access-Control-Allow-Origin', '*')
|
|
724
761
|
self.end_headers()
|
|
725
762
|
self.wfile.write(body)
|
|
726
763
|
|
|
@@ -764,6 +801,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
764
801
|
if not proj:
|
|
765
802
|
return self._json({'ok': False, 'error': '项目不存在'}, 404)
|
|
766
803
|
return self._json({'ok': True, 'id': pid, 'sessions': project_sessions(proj.get('root', ''), 20)})
|
|
804
|
+
if path == '/api/decisions':
|
|
805
|
+
return self._json({'ok': True, 'decisions': decisions_view(30)})
|
|
806
|
+
if path == '/api/approvals':
|
|
807
|
+
return self._json({'ok': True, 'items': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending']})
|
|
767
808
|
if path == '/api/nodes':
|
|
768
809
|
return self._json({'ok': True, 'nodes': nodes_view()})
|
|
769
810
|
if path == '/api/node/scanner.ps1':
|
|
@@ -850,6 +891,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
850
891
|
return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
|
|
851
892
|
|
|
852
893
|
try:
|
|
894
|
+
if path == '/api/autonomy':
|
|
895
|
+
return self._json({'ok': True, 'state': set_autonomy(payload.get('project'), payload.get('mode'), payload.get('maxPerDay'))})
|
|
896
|
+
if path == '/api/approval':
|
|
897
|
+
return self._json({'ok': True, **resolve_approval(payload.get('id'), payload.get('action'), payload.get('choice') or '')})
|
|
853
898
|
if path == '/api/scan/ignore':
|
|
854
899
|
return self._json({'ok': True, **ignore_candidate(payload.get('path'), payload.get('days', 7))})
|
|
855
900
|
if path == '/api/bind':
|
package/lib/client.js
CHANGED
|
@@ -94,14 +94,19 @@ window.__ModuleLoader__.load({
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const css = `
|
|
97
|
-
.deck-fab{position:fixed;
|
|
97
|
+
.deck-fab{position:fixed;right:18px;bottom:112px;left:auto;z-index:120;position:fixed;pointer-events:auto;touch-action:none;cursor:grab;user-select:none;display:flex;align-items:center;justify-content:center;
|
|
98
98
|
width:46px;height:46px;border-radius:50%;border:1px solid rgba(53,208,186,.45);cursor:pointer;
|
|
99
99
|
background:linear-gradient(180deg,#12303a,#0f1e1d);color:#7ce7da;font-size:20px;line-height:1;
|
|
100
100
|
box-shadow:0 10px 24px rgba(0,0,0,.35),0 2px 6px rgba(0,0,0,.2);transition:filter .12s,transform .12s;
|
|
101
101
|
font-family:system-ui,-apple-system,"PingFang SC",sans-serif;padding:0}
|
|
102
102
|
.deck-fab:hover{filter:brightness(1.12);transform:translateY(-1px)}
|
|
103
|
+
.deck-fab:active{cursor:grabbing}
|
|
104
|
+
.deck-fab svg{display:block}
|
|
105
|
+
.deck-fab .deck-badge{position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;
|
|
106
|
+
background:#ef5b5b;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;box-shadow:0 0 0 2px #0b0f14}
|
|
107
|
+
.deck-fab.ai-on{box-shadow:0 0 0 2px rgba(53,208,186,.55),0 10px 24px rgba(0,0,0,.35)}
|
|
103
108
|
.deck-fab:active{transform:translateY(0)}
|
|
104
|
-
.deck-mask{position:fixed;inset:0;z-index:130;background:rgba(4,8,12,.62);display:flex;align-items:center;justify-content:center;padding:24px}
|
|
109
|
+
.deck-mask{position:fixed;inset:0;z-index:130;background:rgba(4,8,12,.62);display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto}
|
|
105
110
|
.deck-box{width:min(1180px,94vw);height:min(860px,88vh);background:#0b0f14;border:1px solid #1f2a37;border-radius:14px;
|
|
106
111
|
display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.45)}
|
|
107
112
|
.deck-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #1f2a37;
|
|
@@ -132,72 +137,94 @@ window.__ModuleLoader__.load({
|
|
|
132
137
|
const [pos, setPos] = React.useState(() => {
|
|
133
138
|
try { const v = JSON.parse(localStorage.getItem('deck-fab-pos') || 'null'); return v && typeof v.x === 'number' ? v : null } catch { return null }
|
|
134
139
|
})
|
|
135
|
-
const
|
|
136
|
-
|
|
140
|
+
const [summary, setSummary] = React.useState({ auto: 0, assist: 0, pending: 0 })
|
|
141
|
+
React.useEffect(() => {
|
|
142
|
+
let alive = true
|
|
143
|
+
const load = () => {
|
|
144
|
+
fetch('http://127.0.0.1:9199/api/state', { cache: 'no-store' })
|
|
145
|
+
.then((r) => r.json())
|
|
146
|
+
.then((d) => {
|
|
147
|
+
if (!alive) return
|
|
148
|
+
const ps = d.projects || []
|
|
149
|
+
setSummary({
|
|
150
|
+
auto: ps.filter((p) => p.autonomy && p.autonomy.mode === 'auto').length,
|
|
151
|
+
assist: ps.filter((p) => p.autonomy && p.autonomy.mode === 'assist').length,
|
|
152
|
+
pending: (d.approvals || []).length,
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
.catch(() => {})
|
|
156
|
+
}
|
|
157
|
+
load()
|
|
158
|
+
const t = setInterval(load, 60000)
|
|
159
|
+
return () => { alive = false; clearInterval(t) }
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
const dragState = React.useRef(null)
|
|
163
|
+
const movedRef = React.useRef(false)
|
|
137
164
|
|
|
138
165
|
const clamp = (x, y) => ({
|
|
139
166
|
x: Math.max(6, Math.min(x, window.innerWidth - 54)),
|
|
140
167
|
y: Math.max(6, Math.min(y, window.innerHeight - 54)),
|
|
141
168
|
})
|
|
142
169
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
// 用 window 级监听,避免指针移出按钮就丢事件(之前只能按住按钮原地挪)
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
const onMove = (e) => {
|
|
173
|
+
const d = dragState.current
|
|
174
|
+
if (!d) return
|
|
175
|
+
if (!d.moved && (Math.abs(e.clientX - d.sx) > 3 || Math.abs(e.clientY - d.sy) > 3)) d.moved = true
|
|
176
|
+
setPos(clamp(e.clientX - d.dx, e.clientY - d.dy))
|
|
177
|
+
}
|
|
178
|
+
const onUp = () => {
|
|
179
|
+
const d = dragState.current
|
|
180
|
+
if (!d) return
|
|
181
|
+
dragState.current = null
|
|
182
|
+
movedRef.current = d.moved
|
|
183
|
+
setPos((cur) => { if (cur) { try { localStorage.setItem('deck-fab-pos', JSON.stringify(cur)) } catch {} } return cur })
|
|
184
|
+
}
|
|
185
|
+
window.addEventListener('pointermove', onMove, true)
|
|
186
|
+
window.addEventListener('pointerup', onUp, true)
|
|
187
|
+
window.addEventListener('mousemove', onMove, true)
|
|
188
|
+
window.addEventListener('mouseup', onUp, true)
|
|
189
|
+
return () => {
|
|
190
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
191
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
192
|
+
window.removeEventListener('mousemove', onMove, true)
|
|
193
|
+
window.removeEventListener('mouseup', onUp, true)
|
|
194
|
+
}
|
|
195
|
+
}, [])
|
|
196
|
+
|
|
197
|
+
const fabStyle = pos ? { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' } : undefined
|
|
198
|
+
|
|
199
|
+
const onDragStart = (e) => {
|
|
200
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
201
|
+
dragState.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, sx: e.clientX, sy: e.clientY, moved: false }
|
|
202
|
+
movedRef.current = false
|
|
203
|
+
if (!pos) setPos({ x: r.left, y: r.top })
|
|
204
|
+
if (e.preventDefault) e.preventDefault()
|
|
163
205
|
}
|
|
164
|
-
const fabStyle = pos
|
|
165
|
-
? { left: pos.x + 'px', top: pos.y + 'px', bottom: 'auto', right: 'auto' }
|
|
166
|
-
: undefined
|
|
167
206
|
|
|
168
|
-
//
|
|
207
|
+
// 面板里也能改图标位置(拖拽失效时的兜底)
|
|
169
208
|
React.useEffect(() => {
|
|
170
|
-
const
|
|
209
|
+
const onPos = (e) => {
|
|
171
210
|
if (e.origin !== PANEL) return
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
} catch (err) { /* 忽略 */ }
|
|
183
|
-
if (ok) setOpen(false)
|
|
184
|
-
})
|
|
185
|
-
return
|
|
211
|
+
const d = e.data || {}
|
|
212
|
+
if (d.type !== 'deck:set-fab-pos') return
|
|
213
|
+
const W = window.innerWidth, H = window.innerHeight
|
|
214
|
+
const spots = {
|
|
215
|
+
'right-bottom': { x: W - 60, y: H - 160 },
|
|
216
|
+
'left-bottom': { x: 16, y: H - 160 },
|
|
217
|
+
'right-top': { x: W - 60, y: 84 },
|
|
218
|
+
'center-right': { x: W - 60, y: Math.round(H / 2) },
|
|
219
|
+
default: { x: W - 60, y: H - 160 },
|
|
186
220
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const frame = document.querySelector('iframe.deck-frame')
|
|
192
|
-
if (frame && frame.contentWindow) {
|
|
193
|
-
frame.contentWindow.postMessage({ type: 'dsh-project-deck:open-result', ok, sessionId: data.sessionId }, PANEL)
|
|
194
|
-
}
|
|
195
|
-
} catch (e) { console.warn('[dsh-project-deck] 回传结果失败', e) }
|
|
196
|
-
if (ok) setOpen(false)
|
|
221
|
+
const next = spots[d.pos] || spots.default
|
|
222
|
+
setPos(clamp(next.x, next.y))
|
|
223
|
+
try { localStorage.setItem('deck-fab-pos', JSON.stringify(clamp(next.x, next.y))) } catch {}
|
|
197
224
|
}
|
|
198
|
-
window.addEventListener('message',
|
|
199
|
-
return () => window.removeEventListener('message',
|
|
200
|
-
}, [
|
|
225
|
+
window.addEventListener('message', onPos)
|
|
226
|
+
return () => window.removeEventListener('message', onPos)
|
|
227
|
+
}, [])
|
|
201
228
|
|
|
202
229
|
React.useEffect(() => {
|
|
203
230
|
if (!open) return undefined
|
|
@@ -210,10 +237,33 @@ window.__ModuleLoader__.load({
|
|
|
210
237
|
|
|
211
238
|
return h(React.Fragment, null,
|
|
212
239
|
h('button', {
|
|
213
|
-
className: 'deck-fab',
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
240
|
+
className: 'deck-fab' + (summary.auto > 0 ? ' ai-on' : ''),
|
|
241
|
+
style: fabStyle,
|
|
242
|
+
title: '项目甲板 · AI 项目管理与自动推进(可拖动 v4)'
|
|
243
|
+
+ (summary.auto ? `|auto ${summary.auto}` : '')
|
|
244
|
+
+ (summary.pending ? `|待你拍板 ${summary.pending}` : ''),
|
|
245
|
+
onPointerDown: onDragStart,
|
|
246
|
+
onMouseDown: onDragStart,
|
|
247
|
+
onClick: () => { if (!movedRef.current) openPanel(); movedRef.current = false },
|
|
248
|
+
onDragStart: (e) => e.preventDefault(),
|
|
249
|
+
},
|
|
250
|
+
h('svg', { width: 26, height: 26, viewBox: '0 0 24 24', fill: 'none', stroke: '#7ce7da', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
251
|
+
// 左:AI 机器人
|
|
252
|
+
h('rect', { x: 1.6, y: 8.2, width: 8.2, height: 7.2, rx: 2.2 }),
|
|
253
|
+
h('path', { d: 'M5.7 8.2V6.2' }),
|
|
254
|
+
h('circle', { cx: 5.7, cy: 5, r: 1.05, fill: '#7ce7da', stroke: 'none' }),
|
|
255
|
+
h('circle', { cx: 4.1, cy: 11.5, r: 0.9, fill: '#7ce7da', stroke: 'none' }),
|
|
256
|
+
h('circle', { cx: 7.3, cy: 11.5, r: 0.9, fill: '#7ce7da', stroke: 'none' }),
|
|
257
|
+
h('path', { d: 'M4.5 13.7h2.4' }),
|
|
258
|
+
// 右:待办清单(两条已完成)
|
|
259
|
+
h('rect', { x: 11.8, y: 4.6, width: 10.4, height: 14.8, rx: 2.6 }),
|
|
260
|
+
h('path', { d: 'M13.9 9.4l1.1 1.1 1.9-2.1' }),
|
|
261
|
+
h('path', { d: 'M18.1 9.9h2.3' }),
|
|
262
|
+
h('path', { d: 'M13.9 14l1.1 1.1 1.9-2.1' }),
|
|
263
|
+
h('path', { d: 'M18.1 14.5h2.3' }),
|
|
264
|
+
),
|
|
265
|
+
summary.pending > 0 ? h('span', { className: 'deck-badge' }, String(summary.pending)) : null,
|
|
266
|
+
),
|
|
217
267
|
open
|
|
218
268
|
? h('div', { className: 'deck-mask', onClick: (e) => { if (e.target === e.currentTarget) setOpen(false) } },
|
|
219
269
|
h('div', { className: 'deck-box' },
|
|
@@ -242,6 +292,24 @@ window.__ModuleLoader__.load({
|
|
|
242
292
|
)
|
|
243
293
|
}
|
|
244
294
|
|
|
295
|
+
// 崩溃兜底:任何渲染错误都不让图标凭空消失
|
|
296
|
+
class Boundary extends React.Component {
|
|
297
|
+
constructor(props) { super(props); this.state = { err: null } }
|
|
298
|
+
static getDerivedStateFromError(err) { return { err } }
|
|
299
|
+
render() {
|
|
300
|
+
if (this.state.err) {
|
|
301
|
+
return h('div', {
|
|
302
|
+
title: String(this.state.err && this.state.err.message || this.state.err),
|
|
303
|
+
style: { position: 'fixed', right: 12, bottom: 100, zIndex: 121, background: '#2a1518',
|
|
304
|
+
color: '#ffb3b3', padding: '5px 9px', borderRadius: 8, fontSize: 11,
|
|
305
|
+
border: '1px solid #4a2226', cursor: 'pointer', pointerEvents: 'auto' },
|
|
306
|
+
onClick: () => this.setState({ err: null }),
|
|
307
|
+
}, '项目甲板加载失败(点我重试)')
|
|
308
|
+
}
|
|
309
|
+
return this.props.children
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
245
313
|
exports.name = 'dsh-project-deck'
|
|
246
314
|
exports.inject = ['slots']
|
|
247
315
|
exports.apply = function apply(ctx) {
|
|
@@ -289,7 +357,7 @@ window.__ModuleLoader__.load({
|
|
|
289
357
|
|
|
290
358
|
slots.inject('shell.overlay', () => slots.register(
|
|
291
359
|
{ name: 'shell.overlay', id: 'deck-panel', order: 6, label: '项目甲板' },
|
|
292
|
-
(props) => h(Panel, Object.assign({}, props, { appCtx: ctx })),
|
|
360
|
+
(props) => h(Boundary, null, h(Panel, Object.assign({}, props, { appCtx: ctx }))),
|
|
293
361
|
))
|
|
294
362
|
console.log('[dsh-project-deck] client ready')
|
|
295
363
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-project-deck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: a project deck for your repos — registry, dashboard, scheduled WeChat reports, auto-discovery, multi-node scanning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
|
47
|
-
"url": "https://github.com/peng456/dsh-project-deck"
|
|
47
|
+
"url": "git+https://github.com/peng456/dsh-project-deck.git"
|
|
48
48
|
},
|
|
49
49
|
"author": "peng456",
|
|
50
50
|
"homepage": "https://github.com/peng456/dsh-project-deck#readme",
|
|
Binary file
|
|
Binary file
|