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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-project-deck
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/dsh-project-deck.svg)](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
- ./install.sh # 装到 ~/.project-deck,macOS 下注册 launchd
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 github:peng456/dsh-project-deck
36
- # 或 Desktop → 插件 → 安装 github:peng456/dsh-project-deck
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
  节点只读上报,不传文件内容:
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
- if not os.path.exists(WXSEND):
856
- return 3, 'wxsend 不存在'
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 r.returncode == 2:
862
- mark_channel(False, msg.splitlines()[0] if msg else 'queued')
863
- return r.returncode, msg
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
- print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
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':
@@ -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
- try:
52
- r = subprocess.run([P.WXSEND, '--status'], capture_output=True, text=True, timeout=10)
53
- ok = r.returncode == 0
54
- return {'ok': ok, 'raw': r.stdout.strip(), 'code': r.returncode}
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;left:16px;bottom:18px;z-index:120;display:flex;align-items:center;justify-content:center;
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 drag = React.useRef(null)
136
- const moved = React.useRef(false)
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
- const onPointerDown = (e) => {
144
- drag.current = { dx: e.clientX - (pos ? pos.x : 16), dy: e.clientY - (pos ? pos.y : window.innerHeight - 64) }
145
- moved.current = false
146
- e.currentTarget.setPointerCapture(e.pointerId)
147
- }
148
- const onPointerMove = (e) => {
149
- if (!drag.current) return
150
- const nx = e.clientX - drag.current.dx
151
- const ny = e.clientY - drag.current.dy
152
- if (Math.abs(nx - (pos ? pos.x : 16)) > 4 || !pos) moved.current = true
153
- setPos(clamp(nx, ny))
154
- }
155
- const onPointerUp = (e) => {
156
- if (!drag.current) return
157
- drag.current = null
158
- try { e.currentTarget.releasePointerCapture(e.pointerId) } catch {}
159
- setPos((cur) => {
160
- if (cur) { try { localStorage.setItem('deck-fab-pos', JSON.stringify(cur)) } catch {} }
161
- return cur
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
- // 面板(iframe,127.0.0.1:9199)点「最近会话」时 postMessage 过来,在这里跳到对应会话
207
+ // 面板里也能改图标位置(拖拽失效时的兜底)
169
208
  React.useEffect(() => {
170
- const onMessage = (e) => {
209
+ const onPos = (e) => {
171
210
  if (e.origin !== PANEL) return
172
- const data = e.data || {}
173
- if (data.type === 'dsh-project-deck:open-project') {
174
- openProjectWith(appCtx, { root: String(data.root || ''), sessionId: data.sessionId ? String(data.sessionId) : null })
175
- .then((ok) => {
176
- console.log('[dsh-project-deck] open-project', data.project, 'ok=', ok)
177
- try {
178
- const frame = document.querySelector('iframe.deck-frame')
179
- if (frame && frame.contentWindow) {
180
- frame.contentWindow.postMessage({ type: 'dsh-project-deck:open-result', ok, project: data.project }, PANEL)
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
- if (data.type !== 'dsh-project-deck:open-session' || !data.sessionId) return
188
- const ok = openSessionWith(appCtx, String(data.sessionId))
189
- console.log('[dsh-project-deck] open-session request', data.sessionId, 'ok=', ok)
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', onMessage)
199
- return () => window.removeEventListener('message', onMessage)
200
- }, [appCtx])
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
- title: '项目甲板 Project Deck(项目 / 播报 / 节点)',
215
- onClick: openPanel,
216
- }, '\u{1F4CB}'),
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.0",
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",