dsh-hams 0.0.0-stage → 0.1.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -2
  4. package/client/index.js +3122 -0
  5. package/cordis.patch.yml +16 -0
  6. package/package.json +74 -4
  7. package/src/domain/check.ts +193 -0
  8. package/src/domain/errors.ts +59 -0
  9. package/src/domain/evidence.ts +134 -0
  10. package/src/domain/ids.ts +31 -0
  11. package/src/host/chat-runtime.ts +489 -0
  12. package/src/host/dsh-adapter.ts +867 -0
  13. package/src/host/session-adapter.ts +276 -0
  14. package/src/http/handler.ts +1132 -0
  15. package/src/index.js +53 -0
  16. package/src/service/audit.ts +92 -0
  17. package/src/service/chat.ts +1100 -0
  18. package/src/service/contracts.ts +193 -0
  19. package/src/service/defaults.ts +182 -0
  20. package/src/service/deliverables.ts +240 -0
  21. package/src/service/hams.ts +3103 -0
  22. package/src/service/knowledge.ts +940 -0
  23. package/src/service/member-config.ts +39 -0
  24. package/src/service/members.ts +506 -0
  25. package/src/service/memory.ts +375 -0
  26. package/src/service/permissions.ts +118 -0
  27. package/src/service/project-lifecycle.ts +233 -0
  28. package/src/service/projects.ts +840 -0
  29. package/src/service/receipts.ts +63 -0
  30. package/src/service/templates.ts +140 -0
  31. package/src/service/tick.ts +751 -0
  32. package/src/service/tools.ts +711 -0
  33. package/src/service/types.ts +219 -0
  34. package/src/service/work-item-create.ts +159 -0
  35. package/src/service/workspace-profile.ts +443 -0
  36. package/src/store/backup.ts +239 -0
  37. package/src/store/bootstrap.ts +225 -0
  38. package/src/store/db.ts +179 -0
  39. package/src/store/lock.ts +194 -0
  40. package/src/store/migrations.ts +211 -0
  41. package/src/store/schema/001_p1.sql +846 -0
  42. package/src/store/schema/001_p1.sql.ts +865 -0
  43. package/src/store/schema/002_knowledge.sql +54 -0
  44. package/src/store/schema/002_knowledge.sql.ts +68 -0
  45. package/src/store/schema/003_projects.sql.ts +3 -0
  46. package/src/store/schema/004_team.sql.ts +50 -0
  47. package/src/store/schema/005_target_check.sql.ts +22 -0
  48. package/src/store/schema/006_target_flow.sql.ts +19 -0
  49. package/src/store/schema/007_project_delete.sql.ts +17 -0
  50. package/src/store/schema/008_memory.sql.ts +47 -0
  51. package/src/store/schema/generate-projects.mjs +16 -0
  52. package/src/store/seed.ts +114 -0
@@ -0,0 +1,3122 @@
1
+ /**
2
+ * dsh-hams 客户端模块(由宿主 client loader 加载,不需要打包器)。
3
+ *
4
+ * 约定与本地已验证插件一致:文件包一层 `window.__ModuleLoader__.load({id, factory})`,
5
+ * React 由宿主提供(`require('react')`),`apply(ctx)` 里用 `ctx.slots.inject/register`
6
+ * 注册左侧入口与主面板。没有 JSX:用 `React.createElement`。
7
+ *
8
+ * 首版四屏(只读 + 少量动作):总览 / 追踪单 / 待我把关 / 异常。
9
+ * 数据接口:`overview/get`、`workItem/list`、`workItem/get`、`approval/decide`、
10
+ * `workItem/pause|resume|cancel`。失败一律显示错误码 + nextStep,不弹 alert。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-hams',
14
+ factory: (require) => {
15
+ var module = { exports: {} };
16
+ const React = require('react');
17
+ const h = React.createElement;
18
+
19
+ const name = 'hams-client';
20
+ const inject = ['slots', 'layout'];
21
+ const PANEL_ID = 'hams';
22
+ const API = '/hams/api/v1/';
23
+
24
+ /**
25
+ * 左侧栏白名单:宿主补丁只做「按 window.__hamsRows 过滤」,具体留哪几行由这里决定。
26
+ * 这样「应用」页的固定/取消固定才能真正增删左侧行;插件没加载时宿主不过滤(原行为)。
27
+ */
28
+ const SHELL_ROWS = ['conversations', 'schedules', 'hams', 'apps'];
29
+ const PIN_STORE_KEY = 'hams.pinnedApps';
30
+ const readPinnedApps = () => {
31
+ try {
32
+ const raw = window.localStorage.getItem(PIN_STORE_KEY);
33
+ const list = raw ? JSON.parse(raw) : [];
34
+ return Array.isArray(list) ? list.filter((id) => typeof id === 'string' && id && !SHELL_ROWS.includes(id)) : [];
35
+ } catch (err) { return []; }
36
+ };
37
+ const writePinnedApps = (ids) => {
38
+ try { window.localStorage.setItem(PIN_STORE_KEY, JSON.stringify(ids)); } catch (err) { /* 隐私模式写不了就只在内存里生效 */ }
39
+ };
40
+ const syncSidebarRows = () => {
41
+ const pins = readPinnedApps();
42
+ try { window.__hamsRows = [...SHELL_ROWS, ...pins]; } catch (err) { /* 忽略 */ }
43
+ return pins;
44
+ };
45
+ syncSidebarRows();
46
+
47
+ // 设计口径:靠排版和色块分层,不用矩形框线。
48
+ // 卡片/面板 = 淡蓝底;选中 = 深一点的蓝底;分隔线只在万不得已时用最浅的一档。
49
+ const C = {
50
+ line: 'rgba(127,127,127,.25)',
51
+ hair: 'rgba(127,127,127,.12)',
52
+ soft: 'rgba(127,127,127,.08)',
53
+ tint: 'rgba(47,91,216,.035)',
54
+ tintHover: 'rgba(47,91,216,.055)',
55
+ tintStrong: 'rgba(47,91,216,.08)',
56
+ muted: 'rgba(127,127,127,.9)',
57
+ accent: '#2F5BD8',
58
+ ok: '#2f9e44',
59
+ bad: '#e03131',
60
+ warn: '#e8590c',
61
+ };
62
+ /** 把 #rrggbb / rgb() / rgba() 统一变成带 alpha 的 rgba;用于徽标底色。 */
63
+ const withAlpha = (color, alpha) => {
64
+ const value = String(color || '').trim();
65
+ if (value.startsWith('#')) {
66
+ const hex = value.length === 4 ? value.slice(1).split('').map((ch) => ch + ch).join('') : value.slice(1, 7);
67
+ const num = parseInt(hex, 16);
68
+ return `rgba(${(num >> 16) & 255},${(num >> 8) & 255},${num & 255},${alpha})`;
69
+ }
70
+ const rgba = /^rgba?\(([^)]+)\)$/.exec(value);
71
+ if (rgba) {
72
+ const parts = rgba[1].split(',').map((part) => part.trim());
73
+ return `rgba(${parts[0]},${parts[1]},${parts[2]},${alpha})`;
74
+ }
75
+ return value;
76
+ };
77
+
78
+ let seq = 0;
79
+ function newKey(prefix) {
80
+ seq += 1;
81
+ return `${prefix}-${Date.now().toString(36)}-${seq}`;
82
+ }
83
+
84
+ async function call(command, options = {}) {
85
+ const body = {
86
+ commandId: newKey('ui'),
87
+ idempotencyKey: options.idempotencyKey || newKey('ui-idem'),
88
+ objectId: options.objectId,
89
+ expectedRevision: options.expectedRevision,
90
+ payload: options.payload,
91
+ };
92
+ const response = await fetch(API + command, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify(body),
96
+ });
97
+ if (response.status === 401) {
98
+ // 宿主没发会话 cookie:这一页不是从带 ?token= 的地址打开的(或 cookie 过期)。
99
+ throw {
100
+ code: 'POLICY_DENIED',
101
+ message: '浏览器会话未认证',
102
+ nextStep: '用 dsh 打印的带 ?token= 的地址重新打开页面(或刷新)后再操作',
103
+ };
104
+ }
105
+ if (response.status === 403) {
106
+ throw {
107
+ code: 'POLICY_DENIED',
108
+ message: '面板还没有绑定本机操作者,或请求来源不被信任',
109
+ nextStep: '从本机 127.0.0.1/localhost 打开面板;若仍失败,检查 hams_user(auth_source=local_host) 或插件配置 localPrincipalId',
110
+ };
111
+ }
112
+ let json;
113
+ try {
114
+ json = await response.json();
115
+ } catch {
116
+ if (response.status === 404 || response.status === 405) {
117
+ // 页面刚刷新、插件服务端还是旧版本时就是这个签名:新界面调用不到新接口。
118
+ throw {
119
+ code: 'HOST_CAPABILITY_MISSING',
120
+ message: `插件服务端没有这个接口(HTTP ${response.status}:${command})`,
121
+ nextStep: '重启 DeepSeek Harness(刷新页面不够,服务端代码要重启才生效);重启后再试',
122
+ };
123
+ }
124
+ throw { code: 'STATE_INVALID', message: `响应不是 JSON(HTTP ${response.status})`, nextStep: '刷新后重试' };
125
+ }
126
+ if (json.ok === false) throw json.error || { code: 'STATE_INVALID', message: '未知失败', nextStep: '刷新后重试' };
127
+ return json.data !== undefined ? json.data : json;
128
+ }
129
+
130
+ const styles = {
131
+ rail: { width: 188, flex: '0 0 188px', padding: '14px 10px', display: 'flex', flexDirection: 'column', gap: 4, boxSizing: 'border-box' },
132
+ railBrand: { fontSize: 12, fontWeight: 600, letterSpacing: .2, color: C.muted, padding: '0 8px 10px' },
133
+ railItem: (active) => ({
134
+ textAlign: 'left', padding: '7px 10px', borderRadius: 8, fontSize: 13, cursor: 'pointer',
135
+ border: 'none',
136
+ background: active ? C.tintStrong : 'transparent',
137
+ color: active ? C.accent : 'inherit',
138
+ }),
139
+ // 页面标题固定在壳里,只有 contentBody 滚动:长列表/详情滚到底,标题也不会被带走。
140
+ content: { flex: 1, minWidth: 0, minHeight: 0, padding: '20px 0 0', display: 'flex', flexDirection: 'column', gap: 14, fontSize: 13, color: 'inherit', overflow: 'hidden' },
141
+ contentBody: { flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', gap: 14, overflowY: 'auto', overflowX: 'hidden', padding: '0 24px 20px' },
142
+ pageHeader: { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap', flex: '0 0 auto', padding: '0 24px 10px' },
143
+ header: { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' },
144
+ title: { fontSize: 18, fontWeight: 600, margin: 0 },
145
+ muted: { color: C.muted, fontSize: 12 },
146
+ tabs: { display: 'flex', gap: 6, paddingBottom: 4, flexWrap: 'wrap' },
147
+ tabStrip: { display: 'flex', gap: 6, paddingBottom: 6, flexWrap: 'wrap', borderBottom: `1px solid ${C.hair}` },
148
+ tab: (active) => ({
149
+ padding: '5px 10px',
150
+ borderRadius: 8,
151
+ border: 'none',
152
+ background: active ? C.tintStrong : 'transparent',
153
+ color: active ? C.accent : 'inherit',
154
+ cursor: 'pointer',
155
+ fontSize: 13,
156
+ }),
157
+ page: { display: 'flex', flexDirection: 'column', gap: 18, minWidth: 0 },
158
+ card: { border: 'none', padding: '2px 0', display: 'flex', flexDirection: 'column', gap: 8 },
159
+ row: { display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' },
160
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 },
161
+ stat: { border: 'none', borderRadius: 10, padding: '8px 10px', background: 'transparent' },
162
+ num: { fontSize: 22, fontWeight: 600 },
163
+ list: { display: 'flex', flexDirection: 'column', gap: 8, margin: 0, padding: 0, listStyle: 'none' },
164
+ item: { border: 'none', borderRadius: 10, padding: '10px 12px', background: 'transparent', display: 'flex', flexDirection: 'column', gap: 6 },
165
+ badge: (color) => ({ display: 'inline-block', padding: '1px 8px', borderRadius: 999, fontSize: 11, color, border: 'none', background: withAlpha(color, .12), whiteSpace: 'nowrap' }),
166
+ button: (primary) => ({
167
+ padding: '4px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
168
+ border: 'none',
169
+ background: primary ? C.accent : C.tintHover,
170
+ color: primary ? '#fff' : 'inherit',
171
+ }),
172
+ err: { border: 'none', borderRadius: 10, padding: '10px 12px', color: C.bad, background: 'rgba(224,49,49,.08)' },
173
+ empty: { color: C.muted, padding: '24px 12px', textAlign: 'center' },
174
+ notice: { padding: '10px 12px', borderRadius: 8, background: C.tint, fontSize: 12, lineHeight: 1.6 },
175
+ pre: { margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, maxHeight: 320, overflow: 'auto' },
176
+ input: {
177
+ padding: '6px 9px', borderRadius: 8, fontSize: 12, color: 'inherit',
178
+ border: 'none', background: C.tint, minWidth: 0,
179
+ },
180
+ };
181
+
182
+ const STATUS_COLOR = {
183
+ ready: C.accent, active: C.ok, waiting_check: C.warn, waiting_review: C.warn,
184
+ blocked: C.bad, paused: C.muted, completed: C.ok, failed: C.bad, cancelled: C.muted,
185
+ };
186
+
187
+ /** 动作名 → 中文按钮文案(服务端给的是语义名,界面不直接展示英文)。 */
188
+ const LABELS = {
189
+ accept: '接单', claim: '认领', addEvidence: '加证据', requestClosure: '申请关单',
190
+ pause: '暂停', resume: '继续', cancel: '取消', reopen: '重开', retry: '重试',
191
+ reportBlocker: '报阻塞', unblock: '解除阻塞', decideApproval: '去总览审批',
192
+ };
193
+
194
+ /**
195
+ * 界面已经接上的动作 → 命令。表里没有的动作仍然展示但禁用,
196
+ * 并在 title 写清「用工具代替」,避免点了才报 POLICY_DENIED。
197
+ */
198
+ const ACTIONS = {
199
+ accept: { command: 'workItem/accept' },
200
+ pause: { command: 'workItem/pause' },
201
+ resume: { command: 'workItem/resume' },
202
+ cancel: { command: 'workItem/cancel' },
203
+ reopen: { command: 'workItem/reopen' },
204
+ retry: { command: 'workItem/retry' },
205
+ requestClosure: { command: 'workItem/requestClosure', reason: true },
206
+ assign: { command: 'workItem/assign' },
207
+ reportBlocker: { command: 'workItem/reportBlocker', reason: true },
208
+ unblock: { command: 'workItem/unblock' },
209
+ addEvidence: { command: 'workItem/addEvidence', form: true },
210
+ };
211
+
212
+ const badge = (text, color) => h('span', { style: styles.badge(color || C.muted) }, text);
213
+ const fmtTime = (ms) => (ms ? new Date(ms).toLocaleString() : '—');
214
+ /** 表格里的日期列只放日期,完整时间进 title:一行放得下,也不会把列撑开。 */
215
+ const fmtDay = (ms) => (ms ? new Date(ms).toLocaleDateString('zh-CN') : '—');
216
+ /** 头像只用姓名首字:本机没有头像文件,也不该为几个字去拉网络图片。 */
217
+ const initials = (name) => String(name || '?').trim().slice(0, 1).toUpperCase();
218
+ const avatar = (name, size, kind) => h('span', {
219
+ 'aria-hidden': 'true',
220
+ style: {
221
+ width: size, height: size, flex: 'none', borderRadius: '50%', display: 'inline-flex',
222
+ alignItems: 'center', justifyContent: 'center', boxSizing: 'border-box',
223
+ background: kind === 'bot' ? C.tintStrong : withAlpha(C.ok, .14), border: 'none',
224
+ color: kind === 'bot' ? C.accent : C.ok, fontSize: Math.round(size * 0.42), fontWeight: 600,
225
+ },
226
+ }, initials(name));
227
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
228
+
229
+ function ErrorBox({ error, onDismiss }) {
230
+ if (!error) return null;
231
+ return h('div', { style: styles.err }, [
232
+ h('div', { key: 'h', style: { fontWeight: 600 } }, `${error.code || 'CONNECTION_LOST'}:${error.message || '连接中断,请稍后刷新'}`),
233
+ h('div', { key: 'n', style: { fontSize: 12, marginTop: 4 } }, `下一步:${error.nextStep || '刷新后重试'}`),
234
+ onDismiss ? h('button', { key: 'd', style: { ...styles.button(false), marginTop: 6 }, onClick: onDismiss }, '知道了') : null,
235
+ ]);
236
+ }
237
+
238
+ function Stat({ label, value }) {
239
+ return h('div', { style: styles.stat }, [
240
+ h('div', { key: 'l', style: styles.muted }, label),
241
+ h('div', { key: 'v', style: styles.num }, String(value)),
242
+ ]);
243
+ }
244
+
245
+ function approvalValidity(approval, item, now = Date.now()) {
246
+ if (approval.status !== 'pending') return approval.invalidated_reason ? `本申请已失效:${approval.invalidated_reason}` : '本申请已结束,请查看审批记录';
247
+ if (approval.expires_at <= now) return `本申请已于 ${fmtTime(approval.expires_at)} 过期,未批准`;
248
+ if (!item) return '正在载入申请材料';
249
+ if (item.evidence_set_hash !== approval.evidence_set_hash) return '申请后证据有变化,请按新材料重新申请';
250
+ if (item.revision !== approval.subject_revision) return '对象修订号有变化,请重新申请';
251
+ if (item.execution_epoch !== approval.execution_epoch) return '执行代次已变化,请在当前代次重新处置';
252
+ if (item.template_version_id !== approval.criteria_ref) return '判据版本已变化,请重新申请';
253
+ if (item.status !== 'waiting_review') return '工作项当前不在待验收状态';
254
+ return null;
255
+ }
256
+ function ApprovalDrawer({ approval, item, busy, onClose, onApprove, onOpenTask, me, canApprove }) {
257
+ const [note,setNote] = useDraft(`approval:${approval.id}`, '');
258
+ const invalid = approval.subject_kind !== 'work_item_close' ? '此类审批尚未接入材料核对,请使用对应业务页面' : approvalValidity(approval,item);
259
+ const allowed = !invalid && canApprove && !!me && approval.approver_id === me;
260
+ const template = item && item.templateVersion;
261
+ return h('div',{className:'hams-drawer-backdrop',onClick:onClose},h('section',{className:'hams-drawer',role:'dialog','aria-modal':true,'aria-label':'审批材料核对',onClick:e=>e.stopPropagation()},[
262
+ h('div',{key:'head',style:styles.row},[h('h2',{key:'title',style:{margin:0,fontSize:17}},'待我把关 · 审批材料'),rowButton('关闭',onClose,{key:'close'})]),
263
+ h('strong',{key:'subject'},item ? item.title : approval.reason_text),
264
+ h('div',{key:'binding',style:{...styles.notice,wordBreak:'break-all'}},[
265
+ h('div',{key:'v'},`对象修订 v${approval.subject_revision} · 执行代次 ${approval.execution_epoch}`),
266
+ h('div',{key:'h'},`申请证据哈希 ${approval.evidence_set_hash || '无'}`),
267
+ h('div',{key:'c'},`判据版本 ${approval.criteria_ref || '未指定'} · 有效期至 ${fmtTime(approval.expires_at)}`),
268
+ ]),
269
+ invalid ? h('div',{key:'invalid',style:styles.err},[invalid,item && item.evidence_set_hash!==approval.evidence_set_hash ? h('div',{key:'new',style:{wordBreak:'break-all'}},`当前证据哈希 ${item.evidence_set_hash}`):null,rowButton('查看当前工作项',()=>onOpenTask(approval.subject_id),{key:'go'})]) : null,
270
+ h('div',{key:'materials',className:'hams-approval-columns'},[
271
+ h('div',{key:'criteria',style:styles.list},[
272
+ h('strong',{key:'h'},'判据与证据'),
273
+ h('pre',{key:'c',style:styles.pre},JSON.stringify(safeJSON(approval.criteria_json,[]),null,2)),
274
+ ...((item && item.evidence) || []).map(row=>h('div',{key:row.id,style:styles.item},[badge(row.kind,C.accent),h('div',{key:'ref',style:{wordBreak:'break-all'}},row.ref),h('div',{key:'at',style:styles.muted},fmtTime(row.observed_at))])),
275
+ item && !(item.evidence || []).length ? h('div',{key:'empty',style:styles.empty},'暂无证据') : null,
276
+ ]),
277
+ h('div',{key:'decision',style:styles.list},[
278
+ h('strong',{key:'h'},'申请理由'),h('p',{key:'reason',style:{margin:0,lineHeight:1.7}},approval.reason_text || '未填写'),
279
+ h('label',{key:'note',style:styles.list},[h('span',{key:'l'},'决策说明(要求补充或退回处理必填)'),h('textarea',{key:'text',style:{...styles.input,minHeight:120},value:note,onChange:e=>setNote(e.target.value),'aria-label':'决策说明'})]),
280
+ h('div',{key:'tip',style:styles.notice},'要求补充:转为缺证据阻塞。退回处理:回到执行中重新处置。批准关闭:本次审批与关单同时完成。'),
281
+ ]),
282
+ ]),
283
+ h('div',{key:'actions',style:{...styles.row,justifyContent:'flex-end'}},[
284
+ rowButton('要求补充',()=>onApprove(approval,'return',note),{key:'return',disabled:busy || !allowed || !note.trim()}),
285
+ rowButton('退回处理',()=>onApprove(approval,'reject',note),{key:'reject',disabled:busy || !allowed || !note.trim()}),
286
+ rowButton('批准关闭',()=>onApprove(approval,'approve',note),{key:'approve',style:styles.button(true),disabled:busy || !allowed,title:invalid || undefined}),
287
+ ]),
288
+ ]));
289
+ }
290
+ function ProposalDecision({ proposal, busy, me, templates, onDecide }) {
291
+ const [note,setNote]=useDraft(`proposal:${proposal.id}`, '');
292
+ const [template,setTemplate]=React.useState((templates.find(t=>t.published_version_id)||{}).published_version_id || '');
293
+ const body=proposal.body || ''; const rationale=safeJSON(proposal.rationale_json);
294
+ return h('article',{style:styles.item},[
295
+ h('div',{key:'h',style:styles.row},[h('strong',{key:'t'},proposal.title),badge('提议待决定',C.warn)]),
296
+ h('div',{key:'subject',style:styles.muted},`${proposal.kind} · ${proposal.subject_key || ''}`),
297
+ h('p',{key:'body',style:{margin:0,lineHeight:1.7}},typeof body==='string'?body:JSON.stringify(body)),
298
+ h('pre',{key:'rationale',style:styles.pre},JSON.stringify(rationale,null,2)),
299
+ h(Field,{key:'note',label:'决定理由',value:note,onChange:setNote}),
300
+ proposal.kind==='create_task' ? h('select',{key:'template',style:styles.input,value:template,onChange:e=>setTemplate(e.target.value),'aria-label':'采纳提议的判据模板'},[h('option',{key:'none',value:''},'选择判据模板'),...templates.filter(t=>t.published_version_id).map(t=>h('option',{key:t.id,value:t.published_version_id},t.name))]) : null,
301
+ h('div',{key:'actions',style:{...styles.row,justifyContent:'flex-start'}},[
302
+ rowButton(proposal.kind==='create_task'?'采纳并建任务':'采纳记录,相关变更另行执行',()=>onDecide(proposal,'accept',note,template),{key:'yes',style:styles.button(true),disabled:busy || !note.trim() || (proposal.kind==='create_task' && !template) || proposal.decider_id!==me}),
303
+ rowButton('不采纳',()=>onDecide(proposal,'reject',note),{key:'no',disabled:busy || !note.trim() || proposal.decider_id!==me}),
304
+ ]),
305
+ ]);
306
+ }
307
+ function OverviewTab({ overview, onOpenApproval, onOpenTask, onProposal, onReview, busy, projects=[], templates=[], me }) {
308
+ const [mode,setMode]=React.useState('all');
309
+ if (!overview) return h('div',{style:styles.empty},'正在读取工作台…');
310
+ const pending=overview.approvals.length+overview.proposals.length+(overview.reviews || []).length;
311
+ return h('div',{style:styles.list},[
312
+ h('div',{key:'stats',style:styles.grid},[
313
+ h(Stat,{key:'pending',label:'待我把关',value:pending}),h(Stat,{key:'due',label:'到期复查',value:overview.dueChecks.length}),h(Stat,{key:'open',label:'我的未结',value:overview.counts.myOpen}),h(Stat,{key:'runs',label:'进行中运行',value:overview.counts.activeRuns}),
314
+ ]),
315
+ h('section',{key:'pending',style:styles.card},[
316
+ h('div',{key:'h',style:styles.row},[h('strong',{key:'t'},`待我把关 · ${pending}`),h('span',{key:'note',style:styles.muted},'超时失效后可重新申请,不会自动通过')]),
317
+ h('div',{key:'filter',style:styles.tabStrip},[['all','全部'],['approval','审批'],['proposal','提议'],['review','复盘']].map(([key,label])=>rowButton(label,()=>setMode(key),{key,style:styles.tab(mode===key)}))),
318
+ ...(['all','approval'].includes(mode)?overview.approvals.map(ap=>h('article',{key:ap.id,style:styles.item},[
319
+ h('div',{key:'h',style:styles.row},[h('strong',{key:'t'},ap.reason_text || '工作项关单申请'),badge(ap.expires_at<=Date.now()?'已过期':'待审批',C.warn)]),
320
+ h('div',{key:'binding',style:styles.muted},`对象 v${ap.subject_revision} · 判据 ${ap.criteria_ref || '—'} · 到期 ${fmtTime(ap.expires_at)}`),
321
+ rowButton('核对材料',()=>onOpenApproval(ap),{key:'open',disabled:busy}),
322
+ ])):[]),
323
+ ...(['all','proposal'].includes(mode)?overview.proposals.map(p=>h(ProposalDecision,{key:p.id,proposal:p,busy,me,templates,onDecide:onProposal})):[]),
324
+ ...(['all','review'].includes(mode)?(overview.reviews || []).map(review=>h('article',{key:review.id,style:styles.item},[
325
+ h('strong',{key:'title'},`${review.projectName || review.project_name || '项目'} · 待确认复盘`),h('div',{key:'period',style:styles.muted},`${fmtTime(review.period_start)} — ${fmtTime(review.period_end)}`),
326
+ rowButton('查看冻结快照',()=>onReview(review),{key:'open'}),
327
+ ])):[]),
328
+ !pending?h('div',{key:'empty',style:styles.empty},'暂时没有等你决定的事项'):null,
329
+ ]),
330
+ h('div',{key:'bottom',className:'hams-overview-grid'},[
331
+ h('section',{key:'due',style:styles.card},[h('strong',{key:'h'},'到期复查'),...overview.dueChecks.map(check=>rowButton(`${check.title} · ${fmtTime(check.planned_due_at)}`,()=>onOpenTask(check.work_item_id),{key:check.id,style:{...styles.item,textAlign:'left',background:'transparent',color:'inherit',cursor:'pointer'}})),!overview.dueChecks.length?h('div',{key:'empty',style:styles.empty},'暂无到期复查'):null]),
332
+ h('section',{key:'goals',style:styles.card},[h('strong',{key:'h'},'项目目标与推进'),...projects.filter(p=>p.status==='active').map(p=>h('div',{key:p.id,style:styles.item},[h('strong',{key:'n'},p.name),h('div',{key:'g',style:styles.muted},p.goal_text),h('div',{key:'w',style:styles.muted},`未结 ${p.work_items.open} / ${p.work_items.total} · 目标 v${p.current_target_version}`)])),!projects.length?h('div',{key:'empty',style:styles.empty},'尚无项目,先建立目标与指标'):null]),
333
+ h('section',{key:'runs',style:styles.card},[h('strong',{key:'h'},'执行与异常'),...overview.executions.map(run=>rowButton(`${run.title} · ${run.status}`,()=>onOpenTask(run.workItemId),{key:run.runId})),...overview.attention.map(item=>h('div',{key:item.id,style:styles.notice},[h('strong',{key:'reason'},item.reason),h('div',{key:'detail'},item.detail),item.workItemId?rowButton('查看工作项',()=>onOpenTask(item.workItemId),{key:'open'}):null])),!overview.executions.length && !overview.attention.length?h('div',{key:'empty',style:styles.empty},'暂无执行或异常'):null]),
334
+ ]),
335
+ ]);
336
+ }
337
+
338
+ const STATUS_LABEL = { ready: '待处理', active: '执行中', waiting_check: '待复查', waiting_review: '待验收', blocked: '阻塞', paused: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' };
339
+ const BLOCK_LABEL = { needs_evidence: '缺证据', external: '等外部', dependency: '等依赖', permission: '缺权限', human_input: '等人输入', data_source: '缺数据源' };
340
+ const statusBadge = (status) => badge(STATUS_LABEL[status] || status, STATUS_COLOR[status]);
341
+ const rowButton = (label, onClick, props = {}) => h('button', { type: 'button', style: styles.button(false), onClick, ...props }, label);
342
+ const drafts = new Map();
343
+ function useDraft(key, initial) {
344
+ const [value, setValue] = React.useState(() => drafts.has(key) ? drafts.get(key) : initial);
345
+ const update = next => setValue(old => { const value = typeof next === 'function' ? next(old) : next; drafts.set(key, value); return value; });
346
+ return [value, update];
347
+ }
348
+ function clearDraft(prefix) { for (const key of drafts.keys()) if (key.startsWith(prefix)) drafts.delete(key); }
349
+ function safeJSON(value, fallback = {}) { if (value && typeof value === 'object') return value; try { return JSON.parse(value || '{}'); } catch { return fallback; } }
350
+ function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
351
+ function boardColumn(item) { return item.status === 'paused' ? (item.resume_state || 'active') : item.status; }
352
+ function taskFilter(items, filters) {
353
+ const q = (filters.q || '').trim().toLowerCase();
354
+ return items.filter((item) => (!q || `${item.title} ${item.id} ${item.asset_id || ''}`.toLowerCase().includes(q))
355
+ && (!filters.status || item.status === filters.status) && (!filters.domain || item.domain === filters.domain)
356
+ && (!filters.project || item.project_id === filters.project)
357
+ && (!filters.mine || item.owner_human_id === filters.me || item.assignee_id === filters.me))
358
+ .sort((a,b) => ((a.status === 'waiting_check' ? 0 : 1) - (b.status === 'waiting_check' ? 0 : 1))
359
+ || ((a.next_action_at || a.due_at || Infinity) - (b.next_action_at || b.due_at || Infinity)) || (a.priority - b.priority));
360
+ }
361
+ function BlockerForm({ busy, onSubmit }) {
362
+ const [category, setCategory] = React.useState('needs_evidence');
363
+ const [note, setNote] = React.useState('');
364
+ return h('div', { style: styles.card }, [h('strong', { key:'h' }, '报告阻塞'),
365
+ h('select', { key:'c', style:styles.input, value:category, onChange:e=>setCategory(e.target.value), 'aria-label':'阻塞类别' }, Object.entries(BLOCK_LABEL).map(([key,label])=>h('option',{key,value:key},label))),
366
+ h(Field,{key:'n',label:'阻塞说明',value:note,onChange:setNote}),
367
+ rowButton('提交阻塞',()=>onSubmit({category,note}),{key:'s',disabled:busy || !note.trim()})]);
368
+ }
369
+ function TaskDetail({ selected, onClose, onAct, busy, canAct, evidenceForm, actionForm, onCheck, members = [] }) {
370
+ const [tab,setTab] = React.useState('summary');
371
+ const [assignee, setAssignee] = React.useState('');
372
+ if (!selected) return null;
373
+ const tabs = [['summary','概览'],['events','处理记录'],['evidence','证据'],['runs','执行'],['approvals','审批']];
374
+ const template = selected.templateVersion || {};
375
+ const required = safeJSON(template.required_evidence_json, []);
376
+ const events = selected.events || [];
377
+ const evidence = selected.evidence || [];
378
+ const checks = selected.checks || [];
379
+ const actions = selected.allowedActions || [];
380
+ return h('section', { className:'hams-detail', style:styles.page, 'aria-label':'工作项详情' }, [
381
+ // 操作与标题同一行(原来在页尾,长页面要滚到底才看得见)。窄屏时按钮换行、标题先占满整行。
382
+ h('div',{key:'head',style:{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap',minWidth:0}},[
383
+ statusBadge(selected.status),
384
+ h('strong',{key:'t',style:{fontSize:16,flex:'1 1 220px',minWidth:0}},selected.title),
385
+ h('span',{key:'v',style:{...styles.muted,flex:'none'}},`修订 v${selected.revision} · 执行代次 ${selected.execution_epoch}`),
386
+ actions.length ? h('div',{key:'a',style:{display:'flex',gap:6,flexWrap:'wrap',justifyContent:'flex-end'}},actions.map(action=>rowButton(LABELS[action] || action,()=>onAct(action,selected),{key:action,disabled:busy || !canAct || (!ACTIONS[action] && action!=='decideApproval'),title:!ACTIONS[action] && action!=='decideApproval' ? '由业务智能体执行' : undefined}))) : null,
387
+ onClose ? rowButton('← 返回任务列表',onClose,{key:'close'}) : null,
388
+ ]),
389
+ selected.status==='paused' ? h('div',{key:'pause',style:styles.notice},`追踪单已暂停,暂停前为${STATUS_LABEL[boardColumn(selected)] || '执行中'}。恢复后到期复查会重新入队。`) : null,
390
+ selected.blocked_reason ? h('div',{key:'block',style:styles.notice},`现在还不能关单:${BLOCK_LABEL[selected.blocked_reason] || selected.blocked_reason}`) : null,
391
+ h('div',{key:'tabs',style:styles.tabStrip,role:'tablist'},tabs.map(([id,label])=>rowButton(label,()=>setTab(id),{key:id,role:'tab','aria-selected':tab===id,style:styles.tab(tab===id)}))),
392
+ tab==='summary' ? h('div',{key:'summary',style:styles.list},[
393
+ h('div',{key:'meta',style:styles.grid},[h(Stat,{key:'p',label:'优先级',value:`P${selected.priority || 3}`}),h(Stat,{key:'due',label:'下一步时间',value:fmtTime(selected.next_action_at)}),h(Stat,{key:'d',label:'任务截止',value:fmtTime(selected.due_at)})]),
394
+ h('div',{key:'owner',style:styles.muted},`业务域 ${selected.domain} · 负责人 ${selected.owner_name || selected.owner_human_id} · 执行人 ${selected.assignee_name || selected.assignee_id || '未分配'}`),
395
+ selected.status === 'ready' && members.filter((m) => m.kind === 'bot' && m.status === 'active').length
396
+ ? h('div', { key: 'assign', style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, [
397
+ h('span', { key: 'l', style: styles.muted }, '指派给'),
398
+ h('select', {
399
+ key: 's', 'aria-label': '指派给哪个智能体', value: assignee,
400
+ style: styles.input, onChange: (event) => setAssignee(event.target.value),
401
+ }, [h('option', { key: '', value: '' }, '选择 bot'), ...members.filter((m) => m.kind === 'bot' && m.status === 'active').map((m) => h('option', { key: m.id, value: m.id }, m.name))]),
402
+ h(PMButton, { key: 'go', primary: true, disabled: busy || !canAct || !assignee, onClick: () => onAct('assign', selected, { memberId: assignee }) }, '指派并开跑'),
403
+ h('span', { key: 'hint', style: styles.muted }, '指派后由 bot 在自己的会话里认领并执行这一轮。'),
404
+ ])
405
+ : null,
406
+ h('strong',{key:'criteria'},'关单判据'),
407
+ h('div',{key:'template'},selected.template_version_id ? `判据版本 ${selected.template_version_id}` : '未绑定判据模板,需绑定后才能申请关单'),
408
+ h('pre',{key:'required',style:styles.pre},JSON.stringify(required,null,2)),
409
+ h('div',{key:'hash',style:{...styles.muted,wordBreak:'break-all'}},`当前证据哈希 ${selected.evidence_set_hash || '无证据'}`),
410
+ h('strong',{key:'ch'},'复查计划'),
411
+ checks.length ? checks.map(check=>h('div',{key:check.id,style:styles.item},[
412
+ h('div',{key:'d'},`${fmtTime(check.planned_due_at)} · ${check.status} · ${safeJSON(check.result_json).verdict || '尚未判定'}`),
413
+ ['scheduled','running'].includes(check.status) && onCheck ? rowButton('核验证据并复查',()=>onCheck(check.id),{key:'go',disabled:busy || !canAct || !(selected.canEvaluateCheck)}) : null,
414
+ ])) : h('div',{key:'empty',style:styles.empty},'暂无复查计划'),
415
+ ]) : null,
416
+ tab==='events' ? h('div',{key:'events',style:styles.list},events.length ? events.map(event=>h('article',{key:event.id,style:styles.item},[
417
+ h('div',{key:'h',style:styles.row},[h('strong',{key:'k'},event.kind),h('time',{key:'at',style:styles.muted},fmtTime(event.created_at))]),
418
+ h('div',{key:'who',style:styles.muted},`操作人 ${event.actor_name || event.actor_id || '系统'} · 修订 ${event.revision || '—'}`),
419
+ h('pre',{key:'p',style:styles.pre},JSON.stringify(safeJSON(event.payload_json),null,2)),
420
+ ])) : h('div',{style:styles.empty},'暂无处理记录')) : null,
421
+ tab==='evidence' ? h('div',{key:'evidence',style:styles.list},evidence.length ? evidence.map(row=>h('article',{key:row.id,style:styles.item},[
422
+ h('div',{key:'h',style:styles.row},[badge(row.kind,C.accent),h('time',{key:'at',style:styles.muted},fmtTime(row.observed_at))]),
423
+ h('div',{key:'ref',style:{wordBreak:'break-all'}},row.ref),
424
+ h('div',{key:'source',style:styles.muted},`来源 ${row.source || '未注明'} · ${row.supersedes_id ? `替代 ${row.supersedes_id}` : '原始记录'}${row.purged_at ? ' · 附件已到期清理,留存哈希' : ''}`),
425
+ ])) : h('div',{style:styles.empty},'尚未提交证据,可用下方「加证据」追加')) : null,
426
+ tab==='runs' ? h('div',{key:'runs',style:styles.list},[
427
+ h('div',{key:'thread',style:styles.notice},selected.current_thread ? `第 ${selected.current_thread.generation} 代 · 会话 ${selected.current_thread.session_id || '尚未关联宿主会话'}` : '尚未关联宿主会话'),
428
+ ...(selected.runs || []).map(run=>h('article',{key:run.id,style:styles.item},[
429
+ h('strong',{key:'h'},`${run.status} · ${run.trigger || '运行'}`),h('div',{key:'at',style:styles.muted},`${fmtTime(run.started_at || run.created_at)} → ${fmtTime(run.finished_at)}`),
430
+ h('div',{key:'id',style:styles.muted},`运行 ${run.id} · 成员 ${run.member_id}`),
431
+ ])),
432
+ !(selected.runs || []).length ? h('div',{key:'empty',style:styles.empty},'暂无执行记录') : null,
433
+ ]) : null,
434
+ tab==='approvals' ? h('div',{key:'approvals',style:styles.list},(selected.approvals || []).length ? selected.approvals.map(ap=>h('article',{key:ap.id,style:styles.item},[
435
+ h('strong',{key:'state'},`${ap.status === 'pending' && ap.expires_at<=Date.now() ? '已过期,未批准' : ap.status} · ${fmtTime(ap.created_at)}`),
436
+ h('div',{key:'binding',style:{...styles.muted,wordBreak:'break-all'}},`对象 v${ap.subject_revision} · 证据 ${ap.evidence_set_hash} · 判据 ${ap.criteria_ref || '—'} · 执行代次 ${ap.execution_epoch}`),
437
+ h('div',{key:'reason'},ap.reason_text || ''),h('div',{key:'note',style:styles.muted},ap.decision_note || `有效期至 ${fmtTime(ap.expires_at)}`),
438
+ ])) : h('div',{style:styles.empty},'尚无审批申请')) : null,
439
+ evidenceForm && evidenceForm.workItemId===selected.id ? h(EvidenceForm,{key:'ef',form:evidenceForm,busy:busy || !canAct,onSubmit:input=>onAct('addEvidence',selected,input)}) : null,
440
+ actionForm && actionForm.workItemId===selected.id ? actionForm.action==='reportBlocker'
441
+ ? h(BlockerForm,{key:'bf',busy:busy || !canAct,onSubmit:input=>onAct('reportBlocker',selected,input)})
442
+ : h(ReasonForm,{key:actionForm.action,draftKey:`action:${selected.id}:${actionForm.action}`,busy:busy || !canAct,required:true,label:actionForm.action==='requestClosure' ? '申请关单理由' : '操作原因',onSubmit:reason=>onAct(actionForm.action,selected,{reason})}) : null,
443
+ ]);
444
+ }
445
+ function TaskCalendar({ items, checks, onSelect }) {
446
+ const [month,setMonth] = React.useState(()=>{const d=new Date();d.setDate(1);d.setHours(0,0,0,0);return d.getTime();});
447
+ const current=new Date(month); const start=new Date(month);start.setDate(1-((start.getDay()+6)%7));
448
+ const events=[];
449
+ items.forEach(item=>{if(item.due_at)events.push({id:`due-${item.id}`,itemId:item.id,title:item.title,at:item.due_at,kind:'截止'});});
450
+ (checks || []).forEach(check=>{ const item=items.find(row=>row.id===check.work_item_id); if(item && ['scheduled','running'].includes(check.status))events.push({id:check.id,itemId:item.id,title:item.title,at:check.planned_due_at,kind:'复查'}); });
451
+ const move=delta=>{const d=new Date(month);d.setMonth(d.getMonth()+delta);setMonth(d.getTime());};
452
+ return h('div',{style:styles.list},[
453
+ h('div',{key:'head',style:styles.row},[rowButton('上个月',()=>move(-1),{key:'prev'}),h('strong',{key:'month'},`${current.getFullYear()} 年 ${current.getMonth()+1} 月`),rowButton('下个月',()=>move(1),{key:'next'})]),
454
+ h('div',{key:'week',style:{display:'grid',gridTemplateColumns:'repeat(7,minmax(0,1fr))'}},['一','二','三','四','五','六','日'].map(d=>h('div',{key:d,style:{padding:8,color:C.muted}},d))),
455
+ h('div',{key:'grid',className:'hams-calendar'},Array.from({length:42},(_,index)=>{const d=new Date(start);d.setDate(start.getDate()+index);const key=dayKey(d);return h('div',{key,style:{minHeight:100,padding:7,border:'none',borderRadius:8,background:d.getMonth()===current.getMonth()?'transparent':C.tint}},[
456
+ h('div',{key:'day',style:{fontSize:12,color:key===dayKey(Date.now())?C.accent:C.muted,fontWeight:600}},d.getDate()),
457
+ ...events.filter(ev=>dayKey(ev.at)===key).map(ev=>rowButton(`${ev.kind} · ${ev.title}`,()=>onSelect(ev.itemId),{key:ev.id,style:{...styles.button(false),display:'block',width:'100%',fontSize:11,marginTop:5,textAlign:'left',whiteSpace:'normal',background:ev.kind==='复查'?withAlpha(C.warn,.14):C.soft},title:fmtTime(ev.at)})),
458
+ ]);})),
459
+ h('div',{key:'note',style:styles.muted},'日历以任务截止和复查计划原定时间为准。未安排日期的任务仍可在列表查看。'),
460
+ ]);
461
+ }
462
+ function ItemsTab({ items, selected, onSelect, onAct, busy, onCreate, canAct, canCreate=true, evidenceForm, actionForm, templates, onCreateOpen, createOpen, onCreateCancel, projects=[], members=[], projectFilter='', onProjectFilter, checks=[], me, onClose, onCheck }) {
463
+ const [view,setView]=React.useState('list'); const [q,setQ]=React.useState('');const [status,setStatus]=React.useState('');const [domain,setDomain]=React.useState('');const [mine,setMine]=React.useState(false);
464
+ const visible=taskFilter(items,{q,status,domain,mine,project:projectFilter,me});
465
+ const memberName=id=>(members.find(m=>m.id===id)||{}).name || id || '未分配';
466
+ const card=item=>h('button',{key:item.id,type:'button',onClick:()=>onSelect(item.id),style:{...styles.item,background:selected && selected.id===item.id?C.tintStrong:'transparent',color:'inherit',textAlign:'left',width:'100%',cursor:'pointer',borderColor:'transparent'}},[
467
+ h('div',{key:'title',style:styles.row},[h('strong',{key:'t'},item.title),statusBadge(item.status)]),
468
+ h('div',{key:'meta',style:styles.muted},`${item.domain} · ${memberName(item.assignee_id || item.owner_human_id)} · P${item.priority || 3}`),
469
+ item.status==='paused' ? h('div',{key:'paused',style:styles.muted},`最后更新 ${fmtTime(item.updated_at)} · 恢复后继续原流程`) : null,
470
+ h('div',{key:'due',style:styles.muted},`下一步 ${fmtTime(item.next_action_at || item.due_at)}`),
471
+ ]);
472
+ /**
473
+ * 列表视图:一行一条,五列固定宽度,行与行对齐(看板仍用上面的卡片)。
474
+ * 列宽按最长内容预留:状态标签 84 / 标题弹性 / 处理人 140–168 / 优先级 56 / 截止 112–132。
475
+ */
476
+ const LIST_COLS='84px minmax(0,1fr) minmax(140px,168px) 56px minmax(112px,132px)';
477
+ const who2=item=>{
478
+ const id=item.assignee_id || item.owner_human_id;
479
+ const member=members.find(m=>m.id===id);
480
+ return {name:(member && member.name) || memberName(id),kind:member && member.kind};
481
+ };
482
+ const listHead=h('div',{key:'list-head',style:{display:'grid',gridTemplateColumns:LIST_COLS,gap:12,alignItems:'center',padding:'0 13px',color:C.muted,fontSize:11}},[
483
+ h('span',{key:'s'},'状态'),h('span',{key:'t'},'任务'),h('span',{key:'o'},'处理人'),h('span',{key:'p'},'优先级'),h('span',{key:'d'},'截止'),
484
+ ]);
485
+ const listRow=item=>{
486
+ const who=who2(item);
487
+ const due=item.due_at || item.next_action_at;
488
+ return h('button',{key:item.id,type:'button',onClick:()=>onSelect(item.id),style:{
489
+ display:'grid',gridTemplateColumns:LIST_COLS,gap:12,alignItems:'center',width:'100%',boxSizing:'border-box',
490
+ padding:'9px 10px',borderRadius:10,fontSize:12.5,minWidth:0,
491
+ border:'none',
492
+ background: selected && selected.id===item.id?C.tintStrong:'transparent',
493
+ background:'transparent',color:'inherit',textAlign:'left',cursor:'pointer',
494
+ }},[
495
+ h('span',{key:'s',style:{justifySelf:'start',minWidth:0}},statusBadge(item.status)),
496
+ h('span',{key:'t',title:item.title,style:{minWidth:0,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',fontWeight:500}},item.title),
497
+ h('span',{key:'o',style:{display:'flex',alignItems:'center',gap:6,minWidth:0}},[
498
+ avatar(who.name,22,who.kind),
499
+ h('span',{key:'n',style:{minWidth:0,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}},who.name),
500
+ ]),
501
+ h('span',{key:'p',style:{fontVariantNumeric:'tabular-nums'}},`P${item.priority || 3}`),
502
+ h('span',{key:'d',title:fmtTime(due),style:{color:C.muted,fontVariantNumeric:'tabular-nums',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}},fmtDay(due)),
503
+ ]);
504
+ };
505
+ const select=(label,value,change,rows)=>h('select',{'aria-label':label,style:styles.input,value,onChange:e=>change(e.target.value)},[h('option',{key:'all',value:''},label),...rows.map(([id,text])=>h('option',{key:id,value:id},text))]);
506
+ // 新建工作项独立成页:提交或取消都回到列表
507
+ if (createOpen) {
508
+ return h(PMPage,{key:'task-create',backLabel:'← 返回任务列表',onBack:onCreateCancel || (()=>{}),crumb:'任务 / 新建工作项'},
509
+ h(CreateForm,{key:'create',busy:busy || !canCreate,onSubmit:onCreate,onCancel:onCreateCancel,templates,projects,projectId:projectFilter}));
510
+ }
511
+ // 选中任务时,详情页占满整个页面(列表→详情→返回列表)
512
+ if (selected) {
513
+ return h(PMPage,{key:`task-${selected.id}`,backLabel:'← 返回任务列表',onBack:onClose || (()=>{}),crumb:`任务 / ${selected.title}`},
514
+ h(TaskDetail,{key:selected.id,selected,onClose:null,onAct,busy,canAct,evidenceForm,actionForm,onCheck,members}));
515
+ }
516
+ return h('div',{style:styles.list},[
517
+ h('div',{key:'toolbar',style:styles.row},[h('div',{key:'tabs',style:styles.tabs},[['list','列表'],['board','看板'],['calendar','日历']].map(([key,label])=>rowButton(label,()=>setView(key),{key,style:styles.tab(view===key),'aria-pressed':view===key}))),rowButton('新建工作项',onCreateOpen,{key:'new',disabled:busy || !canCreate,style:styles.button(true)})]),
518
+ h('div',{key:'filters',style:{...styles.row,justifyContent:'flex-start'}},[
519
+ h('input',{key:'search',style:styles.input,type:'search',value:q,onChange:e=>setQ(e.target.value),placeholder:'搜索标题、编号、资产','aria-label':'搜索任务'}),
520
+ select('全部状态',status,setStatus,Object.entries(STATUS_LABEL)), select('全部业务域',domain,setDomain,[...new Set(items.map(i=>i.domain))].map(d=>[d,d])),
521
+ select('全部项目',projectFilter,onProjectFilter || (()=>{}),projects.map(p=>[p.id,p.name])),
522
+ h('label',{key:'mine',style:{display:'flex',gap:5}},[h('input',{key:'i',type:'checkbox',checked:mine,onChange:e=>setMine(e.target.checked)}),'只看我的']),h('span',{key:'count',style:styles.muted},`${visible.length} 条`),
523
+ ]),
524
+ view==='list' ? h('div',{key:'list',style:styles.list},visible.length?[listHead,...visible.map(listRow)]:h('div',{style:styles.empty},'没有符合条件的工作项')) : null,
525
+ view==='board' ? h('div',{key:'board',className:'hams-board'},['ready','active','waiting_check','waiting_review','blocked'].map(column=>{const rows=visible.filter(item=>boardColumn(item)===column);return h('section',{key:column,style:{background:C.tint,borderRadius:10,padding:10,display:'flex',flexDirection:'column',gap:8,minWidth:185}},[
526
+ h('strong',{key:'title',style:{fontSize:12,padding:4}},`${STATUS_LABEL[column]} · ${rows.length}`),...rows.map(card),!rows.length?h('div',{key:'empty',style:styles.empty},'暂无工作项'):null,
527
+ ]);})) : null,
528
+ view==='board' ? h('div',{key:'note',style:styles.muted},'已暂停卡保留在暂停前的列。已完成、失败与已取消可切到列表筛选查看。') : null,
529
+ view==='calendar' ? h(TaskCalendar,{key:'calendar',items:visible,checks,onSelect}) : null,
530
+ ]);
531
+ }
532
+
533
+ /** 阻塞原因按列取;没有就是 null。 */
534
+ function blockedReason(item) {
535
+ return item && item.blocked_reason ? item.blocked_reason : null;
536
+ }
537
+
538
+ function Field({ label, value, onChange, placeholder, type }) {
539
+ return h('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } }, [
540
+ h('span', { key: 'l', style: styles.muted }, label),
541
+ h('input', {
542
+ key: 'i',
543
+ type: type || 'text',
544
+ value,
545
+ placeholder,
546
+ style: styles.input,
547
+ onChange: (event) => onChange(event.target.value),
548
+ }),
549
+ ]);
550
+ }
551
+
552
+ /** 需要一句话理由的动作:面板内填,不用 window.prompt(宿主 webview 不支持 prompt)。 */
553
+ function ReasonForm({ label, busy, required, onSubmit, draftKey }) {
554
+ const [reason, setReason] = useDraft(draftKey || 'reason:general', '');
555
+ return h('div', { style: { display: 'flex', gap: 6, alignItems: 'flex-end', flexWrap: 'wrap' } }, [
556
+ h(Field, { key: 'r', label, value: reason, onChange: setReason, placeholder: '用来说明这一动作的原因' }),
557
+ h('button', {
558
+ key: 's', style: styles.button(true), disabled: busy || (required && !reason.trim()),
559
+ onClick: () => onSubmit(reason.trim()),
560
+ }, busy ? '提交中…' : '提交'),
561
+ ]);
562
+ }
563
+
564
+ /** 人工建单:只要服务层要求的最小字段;绑一个已发布模板,单才关得掉。 */
565
+ function CreateForm({ busy, onSubmit, onCancel, templates, projects = [], projectId: initialProjectId = '' }) {
566
+ const [projectId, setProjectId] = useDraft(`task-create:${initialProjectId}:project`, initialProjectId);
567
+ const [dueAt, setDueAt] = useDraft(`task-create:${initialProjectId}:due`, '');
568
+ const pickable = (templates || []).filter((t) => t.published_version_id);
569
+ const [domain, setDomain] = useDraft(`task-create:${initialProjectId}:domain`, '研究');
570
+ const [category, setCategory] = useDraft(`task-create:${initialProjectId}:category`, '研究推进');
571
+ const [title, setTitle] = useDraft(`task-create:${initialProjectId}:title`, '');
572
+ const [priority, setPriority] = useDraft(`task-create:${initialProjectId}:priority`, '3');
573
+ const [templateVersionId, setTemplateVersionId] = React.useState(pickable.length ? pickable[0].published_version_id : '');
574
+ return h('div', { style: styles.card }, [
575
+ h('strong', { key: 't' }, '新建工作项'),
576
+ h('div', { key: 'g', style: styles.grid }, [
577
+ h(Field, { key: 'd', label: '业务域', value: domain, onChange: setDomain }),
578
+ h(Field, { key: 'c', label: '类别', value: category, onChange: setCategory }),
579
+ h(Field, { key: 'p', label: '优先级(1 高–5 低)', value: priority, onChange: setPriority, type: 'number' }),
580
+ ]),
581
+ h('label', { key: 'project', style: styles.list }, [
582
+ h('span', { key: 'l', style: styles.muted }, '所属项目'),
583
+ h('select', { key: 's', style: styles.input, value: projectId, onChange: e => setProjectId(e.target.value), 'aria-label': '所属项目' }, [h('option', {key:'none',value:''}, '不关联项目'), ...projects.map(p => h('option', {key:p.id,value:p.id},p.name))]),
584
+ ]),
585
+ h(Field, {key:'due',label:'截止时间(可选)',type:'datetime-local',value:dueAt,onChange:setDueAt}),
586
+ h(Field, { key: 'ti', label: '标题', value: title, onChange: setTitle, placeholder: '例如:给政策信号追踪补失败告警' }),
587
+ h('label', { key: 'tp', style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } }, [
588
+ h('span', { key: 'l', style: styles.muted }, '判据模板(决定要哪些证据、怎么复查、谁审批)'),
589
+ h('select', {
590
+ key: 's',
591
+ style: styles.input,
592
+ value: templateVersionId,
593
+ onChange: (event) => setTemplateVersionId(event.target.value),
594
+ }, [
595
+ h('option', { key: '', value: '' }, pickable.length ? '(不绑定模板,将无法申请关单)' : '(还没有已发布模板)'),
596
+ ...pickable.map((t) => h('option', { key: t.published_version_id, value: t.published_version_id },
597
+ `${t.name} v${t.version} · ${t.domain}/${t.category}`)),
598
+ ]),
599
+ ]),
600
+ h('div', { key: 'a', style: { display: 'flex', gap: 7 } }, [
601
+ h('button', {
602
+ key: 's', style: styles.button(true), disabled: busy || !title.trim(),
603
+ onClick: () => onSubmit({
604
+ domain, category, title: title.trim(), projectId: projectId || undefined, dueAt: dueAt ? new Date(dueAt).getTime() : undefined,
605
+ priority: Number(priority) || undefined,
606
+ templateVersionId: templateVersionId || undefined,
607
+ }),
608
+ }, busy ? '提交中…' : '创建'),
609
+ h('button', { key: 'c', style: styles.button(false), disabled: busy, onClick: onCancel }, '取消'),
610
+ ]),
611
+ h('div', { key: 'h', style: styles.muted },
612
+ pickable.length
613
+ ? '创建后为待处理,接单后开始执行。'
614
+ : '尚无已发布的判据模板,创建后暂时无法申请关单。'),
615
+ ]);
616
+ }
617
+
618
+ /** 加证据:类型 + 引用 + 说明,时间取现在。 */
619
+ function EvidenceForm({ form, busy, onSubmit }) {
620
+ const [kind, setKind] = useDraft(`evidence:${form.workItemId}:kind`, 'reading');
621
+ const [ref, setRef] = useDraft(`evidence:${form.workItemId}:ref`, '');
622
+ return h('div', { key: 'ef', style: { ...styles.card } }, [
623
+ h('strong', { key: 't' }, '追加证据'),
624
+ h('div', { key: 'g', style: styles.grid }, [
625
+ h('label', { key: 'k', style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } }, [
626
+ h('span', { key: 'l', style: styles.muted }, '类型'),
627
+ h('select', {
628
+ key: 's', value: kind, style: styles.input,
629
+ onChange: (event) => setKind(event.target.value),
630
+ }, ['reading', 'photo', 'receipt', 'log', 'doc'].map((k) => h('option', { key: k, value: k }, k))),
631
+ ]),
632
+ h(Field, { key: 'r', label: '引用(路径 / URL / 编号)', value: ref, onChange: setRef, placeholder: 'evidence/2026-11-14/bms.png' }),
633
+ ]),
634
+ h('button', {
635
+ key: 's', style: styles.button(true), disabled: busy || !ref.trim(),
636
+ onClick: () => onSubmit({ kind, ref: ref.trim(), observedAt: Date.now(), source: form && form.source }),
637
+ }, busy ? '提交中…' : '提交证据'),
638
+ h('div', { key: 'h', style: styles.muted }, '证据只追加;填错了要另起一条并注明替代关系,不能改旧行。'),
639
+ ]);
640
+ }
641
+
642
+ function AttentionTab({ overview }) {
643
+ if (!overview) return h('div', { style: styles.empty }, '加载中…');
644
+ if (!overview.attention.length) return h('div', { style: styles.empty }, '没有需要人关注的事项');
645
+ return h('ul', { style: styles.list }, overview.attention.map((item) =>
646
+ h('li', { key: item.id, style: styles.item }, [
647
+ h('div', { style: styles.row }, [h('span', { key: 'r' }, item.reason), badge('未解决', C.bad)]),
648
+ h('div', { key: 'd', style: styles.muted }, item.detail),
649
+ h('div', { key: 't', style: styles.muted }, fmtTime(item.createdAt)),
650
+ ])));
651
+ }
652
+
653
+ const NAV = [
654
+ ['overview', '总览'],
655
+ ['projects', '项目'],
656
+ ['tasks', '任务'],
657
+ ['agents', '智能体'],
658
+ ['knowledge', '知识'],
659
+ ];
660
+
661
+ // ── M6:项目 / 智能体 / 知识三屏 ────────────────────────────────────────
662
+ // 三屏都只读后端真实数据:project/*、member/*、knowledge/*。
663
+ // 空态不造假数据,直接给「下一步做什么」。
664
+
665
+ const PROJECT_STATUS_LABEL = { draft: '草稿', active: '进行中', paused: '已暂停', archived: '已归档' };
666
+ const MEMBER_KIND_LABEL = { human: '人', bot: 'bot' };
667
+ const MEMBER_STATUS_LABEL = { active: '在岗', paused: '暂停', retired: '已退休' };
668
+ const KNOWLEDGE_KIND_LABEL = { doc: '文档', skill: '技能', table: '数据表' };
669
+ const KNOWLEDGE_STATUS_LABEL = { draft: '草稿', published: '已发布', archived: '已归档', retired: '已退役' };
670
+ const BOT_CAPABILITIES = [
671
+ ['work_item.claim', '认领工作项'],
672
+ ['work_item.execute', '执行'],
673
+ ['work_item.request_closure', '申请关单'],
674
+ ['evidence.append', '加证据'],
675
+ ['check.evaluate', '复查判定'],
676
+ ['proposal.create', '提议'],
677
+ ['connector.ingest', '入站告警'],
678
+ ['metric.record', '记指标读数'],
679
+ ];
680
+
681
+ function Chips({ items, color, empty }) {
682
+ if (!items || !items.length) return h('span', { style: styles.muted }, empty || '—');
683
+ return h('span', { style: { display: 'inline-flex', gap: 4, flexWrap: 'wrap' } },
684
+ items.map((item) => h('span', { key: item, style: styles.badge(color || C.muted) }, item)));
685
+ }
686
+
687
+ /* Plain JS fragment for the existing client factory. Replace ProjectCreateForm,
688
+ * ProjectScreen, MemberScreen and MemberCreateForm; insert all PM* helpers once.
689
+ * Shared React/h/styles/C/badge/fmtTime/ErrorBox/Chips/Stat/MetricRow are reused.
690
+ * onCommand(command,payload): Promise<data>; reject on failure, refresh selected object
691
+ * after writes; keep tryMetric read-only. Parent sets objectId + expectedRevision.
692
+ */
693
+ const PM_CALCULATORS = [['manual_latest', '最近一条人工读数', 'manual'], ['work_item_count', '工作项数量', 'work_item'], ['work_item_completion_rate', '工作项完成率', 'work_item']];
694
+ const PM_PROJECT_TABS = [['goals', '目标与阶段'], ['work', '工作项'], ['memory', '记忆'], ['deliverables', '交付物'], ['reviews', '复盘历史']];
695
+ const PM_MEMORY_KINDS = [['fact', '事实'], ['decision', '决策'], ['preference', '偏好'], ['lesson', '教训'], ['capability', '能力']];
696
+ const PM_MEMORY_KIND_COLOR = { fact: C.muted, decision: C.accent, preference: C.warn, lesson: C.ok, capability: C.accent };
697
+ const PM_MEMORY_ORIGIN = { auto: '自动发现', manual: '手工记录', tool: 'bot 记录' };
698
+ function PMMemoryKindLabel(kind) { return (PM_MEMORY_KINDS.find(([key]) => key === kind) || [null, kind || '未知'])[1]; }
699
+ const PM_MEMBER_TABS = [['config', '配置'], ['skills', '技能'], ['memory', '记忆'], ['permissions', '权限'], ['versions', '版本历史'], ['runs', '运行历史']];
700
+ function PMAllowed(object, action) {
701
+ return !!object && Array.isArray(object.allowedActions) && [action, `project/${action}`, `member/${action}`].some((key) => object.allowedActions.includes(key));
702
+ }
703
+ function PMParse(value, fallback) { if (typeof value !== 'string') return value == null ? fallback : value; try { return JSON.parse(value); } catch { return fallback; } }
704
+ function PMMetricVerdict(metric) {
705
+ if (!metric.calculator_key || !metric.source_kind) return 'unknown';
706
+ const value = metric.latest ? metric.latest.value : metric.actual_value !== undefined ? metric.actual_value : metric.value;
707
+ const target = metric.target_value !== undefined ? metric.target_value : metric.targetValue;
708
+ const max = metric.target_max !== undefined ? metric.target_max : metric.targetMax;
709
+ if (value == null || target == null || !Number.isFinite(Number(value)) || !Number.isFinite(Number(target))) return 'unknown';
710
+ if (metric.direction === 'range' && (max == null || !Number.isFinite(Number(max)))) return 'unknown';
711
+ return (metric.direction === 'lower' ? Number(value) <= Number(target) : metric.direction === 'range' ? Number(value) >= Number(target) && Number(value) <= Number(max) : Number(value) >= Number(target)) ? 'met' : 'missed';
712
+ }
713
+ function PMFilterProjects(projects, filters) {
714
+ const query = (filters.query || '').trim().toLocaleLowerCase();
715
+ return (projects || []).filter((p) => (!query || [p.name, p.goal_text, p.owner_name, p.coordinator_name].some((v) => String(v || '').toLocaleLowerCase().includes(query))) && (!filters.type || p.type === filters.type) && (!filters.owner || p.owner_human_id === filters.owner) && (!filters.mine || p.needs_my_action === true || Number(p.pending_my_count) > 0));
716
+ }
717
+ function PMActivityDays(activity) {
718
+ if (!activity || !Array.isArray(activity.days) || !activity.to) return [];
719
+ const parsed = new Date(activity.to);
720
+ if (!Number.isFinite(parsed.getTime())) return [];
721
+ const end = Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate());
722
+ const map = new Map(activity.days.map((day) => [String(day.day).slice(0, 10), day]));
723
+ const startDate = activity.from ? new Date(activity.from) : null;
724
+ const start = startDate && Number.isFinite(startDate.getTime()) ? startDate.toISOString().slice(0, 10) : null;
725
+ return Array.from({ length: 84 }, (_, i) => {
726
+ const day = new Date(end - (83 - i) * 86400000).toISOString().slice(0, 10);
727
+ const raw = map.get(day);
728
+ return { day, count: raw && Number.isFinite(Number(raw.count)) && Number(raw.count) >= 0 ? Number(raw.count) : start && day >= start ? 0 : null };
729
+ });
730
+ }
731
+ function PMInput({ label, value, onChange, type, multiline, disabled, placeholder }) {
732
+ return h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 12 } }, [h('span', { key: 'l', style: styles.muted }, label), h(multiline ? 'textarea' : 'input', { key: 'i', 'aria-label': label, type: type || 'text', value: value == null ? '' : value, disabled, placeholder, style: { ...styles.input, ...(multiline ? { minHeight: 72, resize: 'vertical' } : {}) }, onChange: (e) => onChange(e.target.value) })]);
733
+ }
734
+ function PMSelect({ label, value, onChange, options, disabled }) {
735
+ return h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 12 } }, [h('span', { key: 'l', style: styles.muted }, label), h('select', { key: 's', 'aria-label': label, value: value || '', disabled, style: styles.input, onChange: (e) => onChange(e.target.value) }, options.map(([key, text]) => h('option', { key, value: key }, text)))]);
736
+ }
737
+ function PMButton({ children, onClick, disabled, primary, title, style }) {
738
+ return h('button', { type: 'button', style: { ...styles.button(primary), ...(disabled ? { opacity: .45, cursor: 'not-allowed' } : {}), ...(style || {}) }, disabled, title: title || (disabled ? '当前状态、权限或必填信息不允许此操作' : undefined), onClick }, children);
739
+ }
740
+ function PMTabs({ tabs, value, onChange, label }) {
741
+ return h('div', { role: 'tablist', 'aria-label': label, style: styles.tabStrip }, tabs.map(([key, text]) => h('button', { key, type: 'button', role: 'tab', 'aria-selected': value === key, style: styles.tab(value === key), onClick: () => onChange(key) }, text)));
742
+ }
743
+ function PMEmpty({ children }) { return h('div', { style: styles.empty }, children); }
744
+ // 整页外壳:顶部固定返回入口,下面是被查看/编辑的对象。列表 → 详情/新建 → 返回列表,四屏一致。
745
+ function PMPage({ backLabel, onBack, crumb, children }) {
746
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 } }, [
747
+ h('div', { key: 'back', style: { ...styles.row, justifyContent: 'flex-start', gap: 10 } }, [
748
+ h(PMButton, { key: 'b', onClick: onBack || (() => {}) }, backLabel),
749
+ crumb ? h('span', { key: 'c', style: styles.muted }, crumb) : null,
750
+ ]),
751
+ ...(Array.isArray(children) ? children : [children]),
752
+ ]);
753
+ }
754
+ function PMJson({ value }) { return h('pre', { style: { ...styles.muted, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 } }, typeof value === 'string' ? value : JSON.stringify(value, null, 2)); }
755
+ function PMVerdict({ verdict }) { return badge({ met: '达标', missed: '未达标', unknown: '未知' }[verdict] || '未知', { met: C.ok, missed: C.warn, unknown: C.muted }[verdict] || C.muted); }
756
+ function usePMCommand(onCommand) {
757
+ const [error, setError] = React.useState(null);
758
+ const [pending, setPending] = React.useState(false);
759
+ const guard = React.useRef(false);
760
+ const execute = async (command, payload, after) => {
761
+ if (!onCommand || guard.current) return;
762
+ guard.current = true; setPending(true); setError(null);
763
+ try { const result = await onCommand(command, payload); if (result && result.ok === false) throw result.error; if (after) after(result); return result; }
764
+ catch (err) { setError({ code: err.code || 'COMMAND_FAILED', message: err.message || String(err), nextStep: err.nextStep || '保留填写内容,刷新对象后重试。' }); }
765
+ finally { guard.current = false; setPending(false); }
766
+ };
767
+ return { error, pending, execute, dismiss: () => setError(null) };
768
+ }
769
+ function usePMDraft(key, initial) { return typeof useDraft === 'function' ? useDraft(key, initial) : React.useState(initial); }
770
+ function PMClearDraft(prefix) { if (typeof clearDraft === 'function') clearDraft(prefix); }
771
+ function PMBlankMetric() { return { name: '', direction: 'higher', targetValue: '', targetMax: '', unit: '', windowKind: 'week', calculatorKey: '', sourceRef: '', trial: null }; }
772
+ function PMMetricValid(row) { return !!row.name.trim() && (row.targetValue === '' || Number.isFinite(Number(row.targetValue))) && (row.direction !== 'range' || (row.targetValue !== '' && row.targetMax !== '' && Number.isFinite(Number(row.targetMax)) && Number(row.targetMax) >= Number(row.targetValue))); }
773
+ function PMMetricPayload(row) {
774
+ return { name: row.name.trim(), direction: row.direction, targetValue: row.targetValue === '' ? null : Number(row.targetValue), targetMax: row.direction === 'range' && row.targetMax !== '' ? Number(row.targetMax) : null, unit: row.unit.trim() || null, windowKind: row.windowKind, calculatorKey: row.calculatorKey || undefined, calculatorParams: row.calculatorParams || {}, sourceKind: (PM_CALCULATORS.find(([key]) => key === row.calculatorKey) || [null, null, 'manual'])[2], sourceRef: row.sourceRef.trim() || undefined };
775
+ }
776
+ function PMMetricsEditor({ metrics, onChange, busy, onCommand, projectId, canTry }) {
777
+ const state = usePMCommand(onCommand);
778
+ const patch = (index, key, value) => onChange((rows) => rows.map((row, i) => i === index ? { ...row, [key]: value, trial: null } : row));
779
+ const locked = busy || state.pending;
780
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, [
781
+ h('div', { key: 'head', style: styles.row }, [h('strong', { key: 't' }, `目标与证据来源(${metrics.length}/3)`), h(PMButton, { key: 'add', disabled: locked || metrics.length >= 3, onClick: () => onChange((rows) => [...rows, PMBlankMetric()]) }, metrics.length >= 3 ? '已达上限 3 个' : '添加目标')]),
782
+ h(ErrorBox, { key: 'err', error: state.error, onDismiss: state.dismiss }),
783
+ ...metrics.map((row, index) => h('section', { key: index, style: styles.card }, [
784
+ h('div', { key: 'basic', style: styles.grid }, [h(PMInput, { key: 'n', label: '目标名称', value: row.name, onChange: (v) => patch(index, 'name', v), disabled: locked }), h(PMSelect, { key: 'd', label: '方向', value: row.direction, options: [['higher', '越高越好'], ['lower', '越低越好'], ['range', '区间']], onChange: (v) => patch(index, 'direction', v), disabled: locked }), h(PMInput, { key: 'v', label: row.direction === 'range' ? '目标下界' : '目标值', type: 'number', value: row.targetValue, onChange: (v) => patch(index, 'targetValue', v), disabled: locked }), row.direction === 'range' ? h(PMInput, { key: 'max', label: '目标上界', type: 'number', value: row.targetMax, onChange: (v) => patch(index, 'targetMax', v), disabled: locked }) : null, h(PMInput, { key: 'u', label: '单位', value: row.unit, onChange: (v) => patch(index, 'unit', v), disabled: locked })]),
785
+ h('div', { key: 'source', style: styles.grid }, [h(PMSelect, { key: 'c', label: '预置计算方式', value: row.calculatorKey, options: [['', '未定义(未知)'], ...PM_CALCULATORS.map(([key, text]) => [key, text])], onChange: (v) => patch(index, 'calculatorKey', v), disabled: locked }), h(PMSelect, { key: 'w', label: '计算窗口', value: row.windowKind, options: [['day', '近 1 天'], ['week', '近 7 天'], ['month', '近 30 天'], ['snapshot', '时点快照']], onChange: (v) => patch(index, 'windowKind', v), disabled: locked }), row.calculatorKey === 'manual_latest' ? h(PMInput, { key: 'r', label: '人工读数来源引用', value: row.sourceRef, onChange: (v) => patch(index, 'sourceRef', v), disabled: locked }) : h('div', { key: 'r', style: styles.muted }, row.calculatorKey ? '数据来源:本项目真实工作项' : '尚未选择数据来源')]),
786
+ h('div', { key: 'trial', role: 'status', style: styles.muted }, row.trial ? row.trial.complete && row.trial.value != null ? `试算值 ${row.trial.value}${row.unit} · ${row.trial.verdict === 'met' ? '达标' : row.trial.verdict === 'missed' ? '未达标' : '判定未知'} · ${fmtTime(row.trial.windowStart)} — ${fmtTime(row.trial.windowEnd)}` : `未知 · ${row.trial.reason || '缺少可计算数据,不参与达标率分母'}` : '尚未试算:缺数或口径未定义时保持未知,不参与达标率分母。'),
787
+ h('div', { key: 'actions', style: { display: 'flex', gap: 8 } }, [h(PMButton, { key: 'try', disabled: locked || !onCommand || !canTry || !PMMetricValid(row) || !row.calculatorKey || (row.calculatorKey === 'manual_latest' && !row.sourceRef.trim()), onClick: () => { const signature = JSON.stringify(PMMetricPayload(row)); state.execute('project/tryMetric', { projectId, metric: PMMetricPayload(row) }, (trial) => onChange((rows) => rows.map((r, i) => i === index && JSON.stringify(PMMetricPayload(r)) === signature ? { ...r, trial: trial || { complete: false, reason: '接口未返回试算结果' } } : r))); } }, state.pending ? '试算中…' : '试算最近一期'), h(PMButton, { key: 'rm', disabled: locked, onClick: () => onChange((rows) => rows.filter((_, i) => i !== index)) }, '移除')]),
788
+ ])),
789
+ metrics.length ? null : h(PMEmpty, { key: 'empty' }, '尚未定义指标。可先保存草稿,之后发布新目标口径。'),
790
+ ]);
791
+ }
792
+ function PMDraftMetricRow(metric) {
793
+ return {
794
+ name: metric.name || '', direction: metric.direction || 'higher',
795
+ targetValue: metric.targetValue == null ? '' : String(metric.targetValue),
796
+ targetMax: metric.targetMax == null ? '' : String(metric.targetMax),
797
+ unit: metric.unit || '', windowKind: metric.windowKind || 'week',
798
+ calculatorKey: metric.calculatorKey || '', calculatorParams: metric.calculatorParams || {},
799
+ sourceKind: metric.sourceKind || 'manual', sourceRef: metric.sourceRef || '', trial: null,
800
+ };
801
+ }
802
+ /** 新建向导:任何一步都能存草稿;草稿项目出现在项目列表里,点开继续填。 */
803
+ function ProjectCreateForm({ bots = [], draft = null, busy, onSubmit, onCancel, onCommand, onSaveDraft, onPublish, allowedActions = [] }) {
804
+ const projectBots = bots.filter((b) => b.status === 'active');
805
+ const defaultCoordinator = draft && draft.coordinatorId
806
+ ? draft.coordinatorId
807
+ : ((projectBots.find((b) => b.name === '项目管理') || {}).id || '');
808
+ const prefix = 'pm/project-create/';
809
+ const [step, setStep] = usePMDraft(prefix + 'step', draft && Number.isFinite(Number(draft.step)) ? Number(draft.step) : 0);
810
+ const [name, setName] = usePMDraft(prefix + 'name', draft ? draft.name || '' : '');
811
+ const [type, setType] = usePMDraft(prefix + 'type', draft ? draft.type || 'continuous' : 'continuous');
812
+ const [goal, setGoal] = usePMDraft(prefix + 'goal', draft ? draft.goalText || '' : '');
813
+ const [coordinatorId, setCoordinatorId] = usePMDraft(prefix + 'coordinatorId', defaultCoordinator);
814
+ const [coordinatorTouched, setCoordinatorTouched] = React.useState(Boolean(draft && draft.coordinatorId));
815
+ const [metrics, setMetrics] = usePMDraft(prefix + 'metrics', ((draft && draft.metrics) || []).map(PMDraftMetricRow));
816
+ const [draftId, setDraftId] = React.useState((draft && draft.projectId) || null);
817
+ const [savedAt, setSavedAt] = React.useState((draft && draft.savedAt) || null);
818
+ const [saving, setSaving] = React.useState(false);
819
+ const [error, setError] = React.useState(null);
820
+ const locked = busy || saving;
821
+ const canCreate = PMAllowed({ allowedActions }, 'create');
822
+ const steps = ['名称与类型', '目标与范围', '指标与证据', '验收人与把关点', '复盘节奏'];
823
+ const valid = metrics.every(PMMetricValid);
824
+ const payload = () => ({
825
+ projectId: draftId || undefined, name: name.trim(), type, goalText: goal.trim(),
826
+ // 没动过选择时把「用默认推进 bot」交给服务端;动过且清空才是显式「不指定」。
827
+ coordinatorId: coordinatorId ? coordinatorId : coordinatorTouched ? null : undefined,
828
+ metrics: metrics.map(PMMetricPayload), step,
829
+ });
830
+ const saveDraft = async () => {
831
+ if (locked || !canCreate || !name.trim()) return;
832
+ setSaving(true); setError(null);
833
+ try {
834
+ if (onSaveDraft) {
835
+ const receipt = await onSaveDraft(payload());
836
+ if (receipt && receipt.objectId) setDraftId(receipt.objectId);
837
+ } else if (onCommand) {
838
+ const receipt = await onCommand('project/saveDraft', payload());
839
+ if (receipt && receipt.objectId) setDraftId(receipt.objectId);
840
+ } else if (onSubmit) {
841
+ onSubmit({ ...payload(), publish: false });
842
+ }
843
+ setSavedAt(Date.now());
844
+ } catch (err) { setError(err); } finally { setSaving(false); }
845
+ };
846
+ const publish = async () => {
847
+ if (locked || !canCreate || !name.trim() || !goal.trim() || !valid) return;
848
+ setSaving(true); setError(null);
849
+ try {
850
+ if (onPublish) await onPublish(payload());
851
+ else if (onCommand) await onCommand('project/create', { ...payload(), publish: true });
852
+ else if (onSubmit) onSubmit({ ...payload(), publish: true });
853
+ } catch (err) { setError(err); } finally { setSaving(false); }
854
+ };
855
+ const savedLabel = savedAt ? `草稿已保存 ${new Date(savedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` : '可随时保存草稿,下次从项目列表继续';
856
+ return h('section', { style: styles.card }, [
857
+ h('div', { key: 'h', style: styles.row }, [h('h2', { key: 't', style: styles.title }, draft ? '继续完善草稿' : '新建项目'), h('span', { key: 's', style: styles.muted }, `第 ${step + 1} 步,共 5 步 · ${savedLabel}`)]),
858
+ h('ol', { key: 'steps', style: { ...styles.tabs, listStyle: 'none', paddingLeft: 0 } }, steps.map((label, i) => h('li', { key: label, style: { color: i === step ? C.accent : C.muted, fontWeight: i === step ? 600 : 400 } }, `${i < step ? '✓' : i + 1} ${label}`))),
859
+ h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }),
860
+ step === 0 ? h('div', { key: 's0', style: styles.grid }, [
861
+ h(PMInput, { key: 'n', label: '项目名称', value: name, onChange: setName, disabled: locked }),
862
+ h(PMSelect, { key: 'ty', label: '项目类型', value: type, onChange: setType, disabled: locked, options: [['continuous', '持续运营'], ['one_off', '一次性项目']] }),
863
+ h(PMSelect, { key: 'co', label: '推进 bot', value: coordinatorId, onChange: (value) => { setCoordinatorTouched(true); setCoordinatorId(value); }, disabled: locked, options: [['', '不指定(由我推进)'], ...projectBots.map((b) => [b.id, b.name])] }),
864
+ ]) : null,
865
+ step === 0 ? h('span', { key: 'cohint', style: styles.muted }, '默认用「项目管理」bot 推进;改选别的 bot 或「不指定」都可以。') : null,
866
+ step === 1 ? h(PMInput, { key: 's1', label: '项目目标与业务范围(从多少到多少、什么时候、靠什么)', multiline: true, value: goal, onChange: setGoal, disabled: locked }) : null,
867
+ step === 2 ? h(PMMetricsEditor, { key: 's2', metrics, onChange: setMetrics, busy: locked, onCommand, canTry: canCreate }) : null,
868
+ step === 3 ? h('div', { key: 's3', style: styles.card }, [h('strong', { key: 'h' }, '验收人与把关点'), h('p', { key: 'n', style: { margin: 0, lineHeight: 1.8 } }, '创建后在「阶段目标」中填写验收标准,指定阶段验收人;到期不会自动通过。每次发起复盘时单独指定确认人,未指定时使用项目负责人。')]) : null,
869
+ step === 4 ? h('div', { key: 's4', style: styles.card }, [h('strong', { key: 'n' }, name || '未命名项目'), h('div', { key: 'g' }, goal || '尚未填写目标'), h('div', { key: 'm', style: styles.muted }, `${type === 'continuous' ? '持续运营' : '一次性项目'} · ${metrics.length} 个指标`), h('p', { key: 'r', style: { margin: 0, lineHeight: 1.8 } }, '创建后可手动发起周度、月度、季度或阶段复盘;每次冻结当期快照并等待确认。')]) : null,
870
+ h('div', { key: 'footer', style: { ...styles.row, justifyContent: 'flex-start' } }, [
871
+ h(PMButton, { key: 'prev', disabled: locked || step === 0, onClick: () => setStep(step - 1) }, '上一步'),
872
+ h(PMButton, { key: 'draft', disabled: locked || !canCreate || !name.trim(), onClick: saveDraft }, saving ? '保存中…' : '保存草稿'),
873
+ step < 4
874
+ ? h(PMButton, { key: 'next', primary: true, disabled: locked || (step === 0 && !name.trim()) || (step === 1 && !goal.trim()) || (step === 2 && !valid), onClick: () => setStep(step + 1) }, `下一步:${steps[step + 1]}`)
875
+ : h(PMButton, { key: 'publish', primary: true, disabled: locked || !canCreate || !name.trim() || !goal.trim() || !valid, onClick: publish }, saving ? '提交中…' : '创建并发布 v1'),
876
+ h(PMButton, { key: 'cancel', disabled: locked, onClick: onCancel }, '取消'),
877
+ ]),
878
+ draftId ? h('span', { key: 'draftid', style: styles.muted }, '草稿项目已建立;返回项目列表可以从「草稿」继续。') : null,
879
+ !canCreate ? h('span', { key: 'readonly', style: styles.muted }, '当前没有创建项目权限。') : null,
880
+ ]);
881
+ }
882
+ function PMPhaseForm({ detail, humans, busy, execute, onCancel }) {
883
+ const [name, setName] = usePMDraft(`pm/phase/${detail.id}/` + 'name', ''); const [start, setStart] = usePMDraft(`pm/phase/${detail.id}/` + 'start', ''); const [end, setEnd] = usePMDraft(`pm/phase/${detail.id}/` + 'end', '');
884
+ const [description, setDescription] = usePMDraft(`pm/phase/${detail.id}/` + 'description', ''); const [verifier, setVerifier] = usePMDraft(`pm/phase/${detail.id}/` + 'verifier', ''); const [checks, setChecks] = usePMDraft(`pm/phase/${detail.id}/` + 'checks', '');
885
+ return h('section', { style: styles.card }, [h('strong', { key: 'h' }, '新增阶段目标'),
886
+ h('div', { key: 'g', style: styles.grid }, [h(PMInput, { key: 'n', label: '阶段名称', value: name, onChange: setName, disabled: busy }), h(PMInput, { key: 's', label: '开始时间', type: 'datetime-local', value: start, onChange: setStart, disabled: busy }), h(PMInput, { key: 'e', label: '结束时间', type: 'datetime-local', value: end, onChange: setEnd, disabled: busy }), h(PMSelect, { key: 'v', label: '验收人', value: verifier, onChange: setVerifier, disabled: busy, options: [['', '请选择'], ...humans.map((p) => [p.id, p.name])] })]),
887
+ h(PMInput, { key: 't', label: '阶段目标与验收标准', multiline: true, value: description, onChange: setDescription, disabled: busy }), h(PMInput, { key: 'c', label: '检查项(每行一项)', multiline: true, value: checks, onChange: setChecks, disabled: busy }),
888
+ h('div', { key: 'a', style: styles.row }, [h(PMButton, { key: 's', primary: true, disabled: busy || !PMAllowed(detail, 'createPhase') || !name.trim() || !description.trim() || !verifier || !start || !end || new Date(end) < new Date(start), onClick: () => execute('project/createPhase', { projectId: detail.id, name: name.trim(), startsAt: new Date(start).getTime(), endsAt: new Date(end).getTime(), confirmerId: verifier, targets: [{ description: description.trim(), verifierHumanId: verifier }], checks: checks.split('\n').map((v) => v.trim()).filter(Boolean) }, () => { PMClearDraft(`pm/phase/${detail.id}/`); onCancel(); }) }, '创建阶段'), h(PMButton, { key: 'c', disabled: busy, onClick: onCancel }, '取消')])]);
889
+ }
890
+ function PMReviewForm({ detail, humans, busy, execute, onCancel }) {
891
+ const [kind, setKind] = usePMDraft(`pm/review/${detail.id}/` + 'kind', 'monthly'); const [start, setStart] = usePMDraft(`pm/review/${detail.id}/` + 'start', ''); const [end, setEnd] = usePMDraft(`pm/review/${detail.id}/` + 'end', '');
892
+ const [conclusion, setConclusion] = usePMDraft(`pm/review/${detail.id}/` + 'conclusion', ''); const [confirmer, setConfirmer] = usePMDraft(`pm/review/${detail.id}/` + 'confirmer', '');
893
+ return h('section', { style: styles.card }, [h('strong', { key: 'h' }, '发起复盘 · 冻结当期快照'),
894
+ h('div', { key: 'g', style: styles.grid }, [h(PMSelect, { key: 'k', label: '复盘类型', value: kind, onChange: setKind, disabled: busy, options: [['weekly', '周度'], ['monthly', '月度'], ['quarterly', '季度'], ['phase', '阶段']] }), h(PMInput, { key: 's', label: '周期开始', value: start, type: 'datetime-local', onChange: setStart, disabled: busy }), h(PMInput, { key: 'e', label: '周期结束', value: end, type: 'datetime-local', onChange: setEnd, disabled: busy }), h(PMSelect, { key: 'c', label: '确认人', value: confirmer, onChange: setConfirmer, disabled: busy, options: [['', '当前项目负责人'], ...humans.map((p) => [p.id, p.name])] })]),
895
+ h(PMInput, { key: 't', label: '复盘结论', multiline: true, value: conclusion, onChange: setConclusion, disabled: busy }),
896
+ end && new Date(end).getTime() > Date.now() ? h('span', { key: 'future', style: { color: C.warn } }, '复盘结束时间不能晚于现在,请选择已发生的周期。') : null,
897
+ h('div', { key: 'a', style: styles.row }, [h(PMButton, { key: 's', primary: true, disabled: busy || !PMAllowed(detail, 'createReview') || !start || !end || new Date(end) < new Date(start) || new Date(end).getTime() > Date.now(), onClick: () => execute('project/createReview', { projectId: detail.id, kind, periodStart: new Date(start).getTime(), periodEnd: new Date(end).getTime(), conclusionText: conclusion.trim(), confirmerId: confirmer || undefined }, () => { PMClearDraft(`pm/review/${detail.id}/`); onCancel(); }) }, '冻结快照并待确认'), h(PMButton, { key: 'c', disabled: busy, onClick: onCancel }, '取消')])]);
898
+ }
899
+ function PMTargetForm({ detail, busy, onCommand, execute, onCancel }) {
900
+ const [goal, setGoal] = usePMDraft(`pm/target/${detail.id}/` + 'goal', detail.goal_text || '');
901
+ const [metrics, setMetrics] = usePMDraft(`pm/target/${detail.id}/` + 'metrics', (detail.metrics || []).map((m) => ({ name: m.name, direction: m.direction, targetValue: m.target_value == null ? '' : String(m.target_value), targetMax: m.target_max == null ? '' : String(m.target_max), unit: m.unit || '', windowKind: m.window_kind || 'week', calculatorKey: m.calculator_key || '', calculatorParams: PMParse(m.calculator_params_json, {}), sourceRef: m.source_ref || '', trial: null })));
902
+ return h('section', { style: styles.card }, [h('strong', { key: 'h' }, '发布新目标版本'), h(PMInput, { key: 'g', label: '目标说明(立项项目书的第一段)', value: goal, onChange: setGoal, multiline: true, disabled: busy }), h(PMMetricsEditor, { key: 'm', metrics, onChange: setMetrics, busy, onCommand, projectId: detail.id, canTry: PMAllowed(detail, 'publishTarget') }), h('div', { key: 'a', style: styles.row }, [h(PMButton, { key: 's', primary: true, disabled: busy || !PMAllowed(detail, 'publishTarget') || !goal.trim() || !metrics.every(PMMetricValid), onClick: () => execute('project/publishTarget', { projectId: detail.id, goalText: goal.trim(), metrics: metrics.map(PMMetricPayload) }, () => { PMClearDraft(`pm/target/${detail.id}/`); onCancel(); }) }, '发布新版本'), h(PMButton, { key: 'c', disabled: busy, onClick: onCancel }, '取消')]), h('span', { key: 'n', style: styles.muted }, '当前版本和既有复盘保留原口径。')]);
903
+ }
904
+ function PMWorkList({ items = [], onOpen }) {
905
+ const labels = { ready: '待处理', active: '执行中', waiting_check: '待复查', waiting_review: '待验收', blocked: '阻塞', paused: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' };
906
+ if (!items.length) return h(PMEmpty, null, '还没有关联工作项');
907
+ // 一行一条,列宽固定,和任务列表同一套读法:状态 / 名称 / 更新时间 / 下一步 / 截止。
908
+ const cols = '84px minmax(0,1fr) 116px 116px 108px';
909
+ const cell = (key, text, extra) => h('span', { key, title: extra && extra.title, style: { color: C.muted, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', ...(extra && extra.style) } }, text);
910
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } }, [
911
+ h('div', { key: 'hdr', style: { display: 'grid', gridTemplateColumns: cols, gap: 10, padding: '0 11px', color: C.muted, fontSize: 11 } }, [
912
+ h('span', { key: 's' }, '状态'), h('span', { key: 't' }, '任务名称'), h('span', { key: 'u' }, '更新时间'), h('span', { key: 'n' }, '下一步工作'), h('span', { key: 'd' }, '截止时间'),
913
+ ]),
914
+ ...items.map((item) => h('div', {
915
+ key: item.id,
916
+ style: { display: 'grid', gridTemplateColumns: cols, gap: 10, alignItems: 'center', padding: '8px 10px', border: 'none', borderRadius: 10, background: C.tint, fontSize: 12.5, minWidth: 0 },
917
+ }, [
918
+ h('span', { key: 's', style: { justifySelf: 'start' } }, badge(labels[item.status] || item.status, STATUS_COLOR[item.status])),
919
+ onOpen ? h('button', {
920
+ key: 't', type: 'button', title: item.title, onClick: () => onOpen(item.id),
921
+ style: { textAlign: 'left', padding: 0, border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500, font: 'inherit' },
922
+ }, item.title) : h('strong', { key: 't', style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.title),
923
+ cell('u', fmtDay(item.updated_at)),
924
+ cell('n', item.next_action_at ? fmtDay(item.next_action_at) : '—'),
925
+ cell('d', item.due_at ? fmtDay(item.due_at) : '—'),
926
+ ])),
927
+ ]);
928
+ }
929
+ /** 宿主能力目录:agent preset 与模型。拿不到就退回手填,不阻塞配置。 */
930
+ function useHostCatalog() {
931
+ const [catalog, setCatalog] = React.useState({ available: false, presets: [], models: [], reason: '' });
932
+ React.useEffect(() => {
933
+ let alive = true;
934
+ call('host/catalog', { payload: {} })
935
+ .then((data) => { if (alive) setCatalog(data || { available: false, presets: [], models: [] }); })
936
+ .catch((err) => { if (alive) setCatalog({ available: false, presets: [], models: [], reason: err.message || '读不到宿主目录' }); });
937
+ return () => { alive = false; };
938
+ }, []);
939
+ return catalog;
940
+ }
941
+
942
+ /** 模型/宿主 preset:有目录就下拉,没有就退回手填(老实例没有接 agents 服务时也能编辑配置)。 */
943
+ function ModelSelect({ catalog, value, onChange, disabled, labelKeys }) {
944
+ const models = (catalog && catalog.models) || [];
945
+ const curated = models.map((row) => [row.provider ? `${row.provider}/${row.model}` : row.model, row.label || row.model]);
946
+ if (!curated.length) return h(PMInput, { label: labelKeys.model, value: value || '', onChange, disabled, placeholder: '留空 = 跟随宿主默认模型' });
947
+ // 当前配置可能写的是裸模型名(宿主允许),目录里对不上时也要如实显示,而不是悄悄回落成默认。
948
+ const options = value && !curated.some(([option]) => option === value) ? [[value, `${value}(当前配置)`], ...curated] : curated;
949
+ return h(PMSelect, { label: labelKeys.model, value: value || '', onChange, disabled, options: [['', '跟随宿主默认模型'], ...options] });
950
+ }
951
+ function PresetSelect({ catalog, value, onChange, disabled }) {
952
+ const presets = ((catalog && catalog.presets) || []).filter((row) => row && row.id);
953
+ if (!presets.length) return h(PMInput, { label: 'Agent preset', value: value || '', onChange, disabled, placeholder: '留空 = hams-bot' });
954
+ const known = presets.some((row) => row.id === value);
955
+ return h(PMSelect, {
956
+ label: 'Agent preset',
957
+ value: known ? value : '',
958
+ onChange,
959
+ disabled,
960
+ options: [['', '默认(hams-bot)'], ...presets.map((row) => [row.id, `${row.name || row.id}${row.broken ? '(不可用)' : ''}`])],
961
+ });
962
+ }
963
+
964
+ /** 行内 Markdown:`code` / **粗** / *斜* / [文字](https 链接)。其余原样保留。 */
965
+ function mdInline(text, keyBase) {
966
+ const source = String(text == null ? '' : text);
967
+ const nodes = [];
968
+ const pattern = /(`[^`\n]+`|\*\*[^*\n]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g;
969
+ let last = 0; let match; let index = 0;
970
+ while ((match = pattern.exec(source))) {
971
+ if (match.index > last) nodes.push(source.slice(last, match.index));
972
+ const token = match[0]; const key = `${keyBase}-i${index++}`;
973
+ if (token.startsWith('`')) nodes.push(h('code', { key, style: { padding: '1px 4px', borderRadius: 4, background: C.soft, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11.5 } }, token.slice(1, -1)));
974
+ else if (token.startsWith('**')) nodes.push(h('strong', { key }, token.slice(2, -2)));
975
+ else if (token.startsWith('*')) nodes.push(h('em', { key }, token.slice(1, -1)));
976
+ else {
977
+ const link = /^\[([^\]]+)\]\((https?:[^)]+)\)$/.exec(token);
978
+ nodes.push(h('a', { key, href: link[2], target: '_blank', rel: 'noreferrer noopener', style: { color: C.accent, textDecoration: 'underline' } }, link[1]));
979
+ }
980
+ last = match.index + token.length;
981
+ }
982
+ if (last < source.length) nodes.push(source.slice(last));
983
+ return nodes;
984
+ }
985
+
986
+ /**
987
+ * 极简 Markdown 渲染:代码块、标题、有序/无序列表、引用、分隔线 + 行内样式。
988
+ * 不引入外部库(客户端是手写模块),也不拼接 HTML(消息来自模型,避免 XSS)。
989
+ */
990
+ function Markdown({ text }) {
991
+ const source = String(text == null ? '' : text);
992
+ const lines = source.split('\n');
993
+ const blocks = [];
994
+ let buffer = [];
995
+ let i = 0;
996
+ const key = (suffix) => `md-${blocks.length}-${suffix}`;
997
+ const flush = () => {
998
+ if (!buffer.length) return;
999
+ blocks.push(h('div', { key: key('p'), style: { whiteSpace: 'pre-wrap' } }, mdInline(buffer.join('\n'), key('p'))));
1000
+ buffer = [];
1001
+ };
1002
+ while (i < lines.length) {
1003
+ const line = lines[i];
1004
+ const fence = /^```\s*([\w+#.-]*)\s*$/.exec(line);
1005
+ if (fence) {
1006
+ flush();
1007
+ const code = [];
1008
+ i += 1;
1009
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
1010
+ i += 1;
1011
+ blocks.push(h('pre', { key: key('c'), style: { margin: 0, padding: '8px 10px', background: C.soft, border: 'none', borderRadius: 8, overflowX: 'auto', fontSize: 11.5, lineHeight: 1.6 } },
1012
+ h('code', null, code.join('\n'))));
1013
+ continue;
1014
+ }
1015
+ if (/^\s*$/.test(line)) { flush(); i += 1; continue }
1016
+ if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[i + 1])) {
1017
+ flush();
1018
+ const splitRow = (row) => row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
1019
+ const header = splitRow(line);
1020
+ const aligns = splitRow(lines[i + 1]).map((cell) => {
1021
+ const left = cell.startsWith(':');
1022
+ const right = cell.endsWith(':');
1023
+ return left && right ? 'center' : right ? 'right' : 'left';
1024
+ });
1025
+ i += 2;
1026
+ const body = [];
1027
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { body.push(splitRow(lines[i])); i += 1 }
1028
+ const cellStyle = { border: 'none', padding: '6px 9px', verticalAlign: 'top', textAlign: 'left' };
1029
+ blocks.push(h('div', { key: key('tbl'), style: { overflowX: 'auto', minWidth: 0 } },
1030
+ h('table', { style: { borderCollapse: 'collapse', width: '100%', minWidth: 360, fontSize: 11.5, lineHeight: 1.6 } }, [
1031
+ h('thead', { key: 'th' }, h('tr', null, header.map((cell, index) => h('th', {
1032
+ key: `th-${index}`,
1033
+ style: { ...cellStyle, textAlign: aligns[index] || 'left', background: C.tintStrong, fontWeight: 600, whiteSpace: 'nowrap', borderRadius: 6 },
1034
+ }, mdInline(cell, `th-${index}`))))),
1035
+ h('tbody', { key: 'tb' }, body.map((cells, rowIndex) => h('tr', { key: `tr-${rowIndex}` },
1036
+ cells.map((cell, index) => h('td', { key: `td-${rowIndex}-${index}`, style: { ...cellStyle, textAlign: aligns[index] || 'left', background: rowIndex % 2 ? C.tint : 'transparent' } }, mdInline(cell, `td-${rowIndex}-${index}`)))))),
1037
+ ])));
1038
+ continue;
1039
+ }
1040
+ if (/^#{1,4}\s+/.test(line)) {
1041
+ flush();
1042
+ const depth = line.match(/^#+/)[0].length;
1043
+ blocks.push(h(depth <= 2 ? 'h3' : 'h4', { key: key('h'), style: { margin: 0, fontSize: depth <= 2 ? 13.5 : 12.5, fontWeight: 650 } }, mdInline(line.replace(/^#+\s+/, ''), key('h'))));
1044
+ i += 1; continue;
1045
+ }
1046
+ if (/^\s*[-*]\s+/.test(line)) {
1047
+ flush();
1048
+ const items = [];
1049
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s+/, '')); i += 1 }
1050
+ blocks.push(h('ul', { key: key('ul'), style: { margin: 0, paddingLeft: 18, display: 'flex', flexDirection: 'column', gap: 2 } },
1051
+ items.map((item, index) => h('li', { key: `${key('ul')}-${index}` }, mdInline(item, `${key('ul')}-${index}`)))));
1052
+ continue;
1053
+ }
1054
+ if (/^\s*\d+[.)]\s+/.test(line)) {
1055
+ flush();
1056
+ const items = [];
1057
+ while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i += 1 }
1058
+ blocks.push(h('ol', { key: key('ol'), style: { margin: 0, paddingLeft: 20, display: 'flex', flexDirection: 'column', gap: 2 } },
1059
+ items.map((item, index) => h('li', { key: `${key('ol')}-${index}` }, mdInline(item, `${key('ol')}-${index}`)))));
1060
+ continue;
1061
+ }
1062
+ if (/^>\s?/.test(line)) {
1063
+ flush();
1064
+ const quote = [];
1065
+ while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, '')); i += 1 }
1066
+ blocks.push(h('blockquote', { key: key('q'), style: { margin: 0, padding: '6px 10px', border: 'none', borderRadius: 6, background: C.tint, color: C.muted } }, mdInline(quote.join('\n'), key('q'))));
1067
+ continue;
1068
+ }
1069
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
1070
+ flush();
1071
+ blocks.push(h('div', { key: key('hr'), style: { height: 8 } }));
1072
+ i += 1; continue;
1073
+ }
1074
+ buffer.push(line); i += 1;
1075
+ }
1076
+ flush();
1077
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, wordBreak: 'break-word', fontSize: 12.5, lineHeight: 1.75 } }, blocks);
1078
+ }
1079
+
1080
+ /** 聊天里的一条消息:靠左、无气泡;名字 + 时间在上,正文按 Markdown 渲染。 */
1081
+ function ChatRow({ name, at, note, text }) {
1082
+ const time = at ? new Date(at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
1083
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, padding: '12px 0' } }, [
1084
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 } }, [
1085
+ h('strong', { key: 'n', style: { fontSize: 12, fontWeight: 600 } }, name),
1086
+ time ? h('span', { key: 't', style: { ...styles.muted, fontSize: 10.5 } }, time) : null,
1087
+ note ? h('span', { key: 'x', style: { ...styles.muted, fontSize: 11, color: C.accent } }, note) : null,
1088
+ ]),
1089
+ h('div', { key: 'b', style: { minWidth: 0, paddingLeft: 14 } }, h(Markdown, { text })),
1090
+ ]);
1091
+ }
1092
+
1093
+ /**
1094
+ * 对话面板:单聊(kind=member)与项目群聊(kind=project)。
1095
+ * 消息正文在宿主会话里,这里轮询读;HAMS 只存会话指针。
1096
+ */
1097
+ function ChatPane({ kind, targetId, title }) {
1098
+ const [data, setData] = React.useState(null);
1099
+ const [text, setText] = React.useState('');
1100
+ const [error, setError] = React.useState(null);
1101
+ const [sending, setSending] = React.useState(false);
1102
+ const alive = React.useRef(true);
1103
+ const scroller = React.useRef(null);
1104
+
1105
+ const poll = React.useCallback(async () => {
1106
+ try {
1107
+ const next = await call('chat/messages', { payload: { kind, targetId } });
1108
+ if (alive.current) { setData(next); setError(null); }
1109
+ } catch (err) {
1110
+ if (alive.current) setError(err);
1111
+ }
1112
+ }, [kind, targetId]);
1113
+
1114
+ React.useEffect(() => {
1115
+ alive.current = true;
1116
+ setData(null); setError(null); setText('');
1117
+ let timer = null;
1118
+ const start = async () => {
1119
+ try { await call('chat/open', { payload: { kind, targetId } }); }
1120
+ catch (err) { if (alive.current) setError(err); }
1121
+ if (!alive.current) return;
1122
+ await poll();
1123
+ if (alive.current) timer = setInterval(poll, 1500);
1124
+ };
1125
+ start();
1126
+ return () => { alive.current = false; if (timer) clearInterval(timer); };
1127
+ }, [kind, targetId]);
1128
+
1129
+ // 只有本来就在底部时才跟随新消息;往上翻看历史时不能被拽回去。
1130
+ const stick = React.useRef(true);
1131
+ const onListScroll = () => {
1132
+ const el = scroller.current;
1133
+ if (!el) return;
1134
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64;
1135
+ };
1136
+ React.useEffect(() => {
1137
+ const el = scroller.current;
1138
+ if (el && stick.current) el.scrollTop = el.scrollHeight;
1139
+ }, [data]);
1140
+
1141
+ const send = async () => {
1142
+ const value = text.trim();
1143
+ if (!value || sending) return;
1144
+ setSending(true); setError(null);
1145
+ try {
1146
+ await call('chat/send', { payload: { kind, targetId, text: value } });
1147
+ setText('');
1148
+ await poll();
1149
+ } catch (err) {
1150
+ setError(err);
1151
+ } finally {
1152
+ setSending(false);
1153
+ }
1154
+ };
1155
+ const stop = async () => {
1156
+ setError(null);
1157
+ try { await call('chat/stop', { payload: { kind, targetId } }); await poll(); }
1158
+ catch (err) { setError(err); }
1159
+ };
1160
+
1161
+ const messages = (data && data.messages) || [];
1162
+ const running = Boolean(data && data.running);
1163
+ const available = !data || data.available !== false;
1164
+ const thread = data && data.thread;
1165
+
1166
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0, padding: '2px 0' } }, [
1167
+ h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', minWidth: 0 } }, [
1168
+ h('strong', { key: 't', style: { fontSize: 13 } }, kind === 'project' ? '项目群聊' : '单聊'),
1169
+ h('span', { key: 'w', style: { ...styles.muted, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1170
+ thread ? (kind === 'project' ? `回答:${thread.memberName} · 会话 ${thread.sessionId.slice(0, 18)}` : `会话 ${thread.sessionId.slice(0, 18)}`) : '建立会话…'),
1171
+ running ? h('span', { key: 'run', style: { ...styles.muted, color: C.warn } }, '运行中…') : null,
1172
+ running ? h(PMButton, { key: 'stop', onClick: stop }, '停止') : null,
1173
+ ]),
1174
+ error ? h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }) : null,
1175
+ !available ? h('div', { key: 'na', style: styles.notice }, data && data.reason ? data.reason : '宿主没有提供 agent 会话服务') : null,
1176
+ data && data.error ? h('div', { key: 'turnError', style: { ...styles.notice, color: '#B42318', background: 'rgba(224,49,49,.08)' } }, `这一轮没跑起来:${data.error}`) : null,
1177
+ h('div', { key: 'list', ref: scroller, onScroll: onListScroll, style: { display: 'flex', flexDirection: 'column', gap: 2, minHeight: 260, maxHeight: 'calc(100vh - 380px)', overflowY: 'auto', padding: '8px 16px', border: 'none', borderRadius: 12, background: 'transparent' } },
1178
+ messages.length ? messages.map((message) => h(ChatRow, {
1179
+ key: message.id,
1180
+ name: message.role === 'user' ? '你' : ((thread && thread.memberName) || 'bot'),
1181
+ at: message.at,
1182
+ text: message.text,
1183
+ })) : h('div', { key: 'empty', style: { ...styles.muted, padding: '12px 4px' } }, '还没有消息。交代一件事,这个 bot 会在自己的工作目录里推进。')),
1184
+ h('div', { key: 'compose', style: { display: 'flex', gap: 8, alignItems: 'flex-end' } }, [
1185
+ h('textarea', {
1186
+ key: 'i',
1187
+ value: text,
1188
+ 'aria-label': kind === 'project' ? '给项目群聊发消息' : '给智能体发消息',
1189
+ placeholder: available ? (kind === 'project' ? '对这个项目说点什么…' : '对这个 bot 说点什么…') : '宿主会话服务不可用,发不出去',
1190
+ disabled: !available || sending,
1191
+ rows: 2,
1192
+ style: { ...styles.input, flex: '1 1 auto', minWidth: 0, resize: 'vertical', minHeight: 44, lineHeight: 1.6 },
1193
+ onChange: (event) => setText(event.target.value),
1194
+ onKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send(); } },
1195
+ }),
1196
+ h(PMButton, { key: 'send', primary: true, disabled: !available || sending || !text.trim(), onClick: send }, sending ? '发送中…' : '发送'),
1197
+ ]),
1198
+ ]);
1199
+ }
1200
+
1201
+ /**
1202
+ * 项目群聊:多人多会话。推进 bot 是指挥,其他成员被 @ 后在自己的会话里回答;
1203
+ * 所有发言都合并进 HAMS 的 project_message,界面只读这一份记录。
1204
+ */
1205
+ function GroupChatPane({ projectId, title, bots = [] }) {
1206
+ const [data, setData] = React.useState(null);
1207
+ const [text, setText] = React.useState('');
1208
+ const [error, setError] = React.useState(null);
1209
+ const [sending, setSending] = React.useState(false);
1210
+ const scroller = React.useRef(null);
1211
+
1212
+ const poll = React.useCallback(async () => {
1213
+ try {
1214
+ const next = await call('chat/group/messages', { payload: { projectId } });
1215
+ setData(next); setError(null);
1216
+ } catch (err) { setError(err); }
1217
+ }, [projectId]);
1218
+
1219
+ React.useEffect(() => {
1220
+ let alive = true; let timer = null;
1221
+ const start = async () => {
1222
+ await poll();
1223
+ timer = setInterval(() => { if (alive) poll(); }, 2000);
1224
+ };
1225
+ start();
1226
+ return () => { alive = false; if (timer) clearInterval(timer); };
1227
+ }, [projectId]);
1228
+
1229
+ // 只有本来就在底部时才跟随新消息;往上翻看历史时不能被拽回去。
1230
+ const stick = React.useRef(true);
1231
+ const onListScroll = () => {
1232
+ const el = scroller.current;
1233
+ if (!el) return;
1234
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64;
1235
+ };
1236
+ React.useEffect(() => {
1237
+ const el = scroller.current;
1238
+ if (el && stick.current) el.scrollTop = el.scrollHeight;
1239
+ }, [data]);
1240
+
1241
+ const send = async () => {
1242
+ const value = text.trim();
1243
+ if (!value || sending) return;
1244
+ setSending(true); setError(null);
1245
+ try {
1246
+ await call('chat/group/send', { payload: { projectId, text: value } });
1247
+ setText('');
1248
+ await poll();
1249
+ } catch (err) { setError(err); } finally { setSending(false); }
1250
+ };
1251
+
1252
+ const messages = (data && data.messages) || [];
1253
+ const coordinator = data && data.coordinator;
1254
+ const members = (data && data.members) || [];
1255
+ const running = Boolean(data && data.running);
1256
+ const available = !data || data.available !== false;
1257
+ const authorColor = (message) => message.authorKind === 'human' ? C.accent : '#fff';
1258
+ const authorText = (message) => message.authorKind === 'human' ? '#fff' : 'inherit';
1259
+ const mentionables = [coordinator, ...members].filter((row) => row && row.status === 'active');
1260
+ const addMention = (name) => setText((current) => `${current}${current && !current.endsWith(' ') ? ' ' : ''}@${name} `);
1261
+
1262
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0, padding: '2px 0' } }, [
1263
+ h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', minWidth: 0 } }, [
1264
+ h('strong', { key: 't', style: { fontSize: 13 } }, '项目群聊'),
1265
+ h('span', { key: 'w', style: { ...styles.muted, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1266
+ data === null ? (error ? '读取失败:见下方原因' : '加载中…') : coordinator ? `推进:${coordinator.name} · 团队 ${members.length + 1} 人` : '这个项目还没有推进 bot'),
1267
+ running ? h('span', { key: 'run', style: { ...styles.muted, color: C.warn } }, '运行中…') : null,
1268
+ ]),
1269
+ error ? h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }) : null,
1270
+ data && data.error ? h('div', { key: 'turnError', style: { ...styles.notice, color: '#B42318', background: 'rgba(224,49,49,.08)' } }, `这一轮没跑起来:${data.error}`) : null,
1271
+ !available ? h('div', { key: 'na', style: styles.notice }, data && data.reason ? data.reason : '宿主没有提供 agent 会话服务') : null,
1272
+ h('div', { key: 'list', ref: scroller, onScroll: onListScroll, style: { display: 'flex', flexDirection: 'column', gap: 2, minHeight: 280, maxHeight: 'calc(100vh - 400px)', overflowY: 'auto', padding: '8px 16px', border: 'none', borderRadius: 12, background: 'transparent' } },
1273
+ messages.length ? messages.map((message) => {
1274
+ const target = message.toMemberId ? ([...(members || []), coordinator].find((m) => m && m.memberId === message.toMemberId) || {}).name : null;
1275
+ // 正文里已经写了 @名字 时不再重复标一次;只有旧数据依赖 toMemberId 时才补标记。
1276
+ const note = message.kind === 'mention' && !(target && String(message.text).startsWith(`@${target}`)) ? `@${target || '成员'}` : null;
1277
+ return h(ChatRow, {
1278
+ key: message.id,
1279
+ name: message.authorName,
1280
+ at: message.at,
1281
+ note,
1282
+ text: message.text,
1283
+ });
1284
+ }) : h('div', { key: 'empty', style: { ...styles.muted, padding: '12px 4px' } },
1285
+ '还没有消息。说点什么,推进 bot 会拆任务、@ 团队成员、把进展带回来。')),
1286
+ mentionables.length ? h('div', { key: 'mentions', style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } }, [
1287
+ h('span', { key: 'l', style: { ...styles.muted, fontSize: 11 } }, '点名排查:'),
1288
+ ...mentionables.map((row) => h(PMButton, { key: row.memberId, disabled: sending, onClick: () => addMention(row.name), title: `在消息里 @${row.name}` }, `@${row.name}`)),
1289
+ ]) : null,
1290
+ h('div', { key: 'compose', style: { display: 'flex', gap: 8, alignItems: 'flex-end' } }, [
1291
+ h('textarea', {
1292
+ key: 'i', value: text, 'aria-label': '给项目群聊发消息',
1293
+ placeholder: available ? '对项目说点什么;@某个 bot 可以直接点名它去排查…' : '宿主会话服务不可用,发不出去',
1294
+ disabled: !available || sending, rows: 2,
1295
+ style: { ...styles.input, flex: '1 1 auto', minWidth: 0, resize: 'vertical', minHeight: 44, lineHeight: 1.6 },
1296
+ onChange: (event) => setText(event.target.value),
1297
+ onKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send(); } },
1298
+ }),
1299
+ h(PMButton, { key: 'send', primary: true, disabled: !available || sending || !text.trim(), onClick: send }, sending ? '发送中…' : '发送'),
1300
+ ]),
1301
+ ]);
1302
+ }
1303
+
1304
+ /** 团队页签:推进 bot + 手动加入的常驻 bot + 项目内临时 bot。三列对齐:成员 / 职责 / 操作。 */
1305
+ function PMTeamTab({ project, bots = [] }) {
1306
+ const [team, setTeam] = React.useState(null);
1307
+ const [error, setError] = React.useState(null);
1308
+ const [busy, setBusy] = React.useState(false);
1309
+ const [addOpen, setAddOpen] = React.useState(false);
1310
+ const [pick, setPick] = React.useState('');
1311
+ const [name, setName] = React.useState('');
1312
+ const [brief, setBrief] = React.useState('');
1313
+
1314
+ const load = React.useCallback(async () => {
1315
+ try { setTeam(await call('chat/team', { payload: { projectId: project.id } })); setError(null); }
1316
+ catch (err) { setError(err); }
1317
+ }, [project.id]);
1318
+ React.useEffect(() => { load(); }, [load]);
1319
+
1320
+ const run = async (fn) => {
1321
+ setBusy(true); setError(null);
1322
+ try { await fn(); await load(); return true; } catch (err) { setError(err); return false; } finally { setBusy(false); }
1323
+ };
1324
+
1325
+ const coordinator = (team && team.coordinator) || null;
1326
+ const members = (team && team.members) || [];
1327
+ const joined = new Set([...(coordinator ? [coordinator.memberId] : []), ...members.map((row) => row.memberId)]);
1328
+ const candidates = (bots || []).filter((bot) => bot.kind === 'bot' && bot.status === 'active' && !joined.has(bot.id));
1329
+ const columns = 'minmax(210px, 1.1fr) minmax(0, 2fr) 88px';
1330
+
1331
+ const memberCell = (member) => h('div', { key: 'm', style: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, whiteSpace: 'nowrap' } }, [
1332
+ badge(member.kind === 'coordinator' ? '推进' : member.kind === 'temp' ? '临时' : '成员', member.kind === 'coordinator' ? C.accent : member.kind === 'temp' ? C.warn : C.muted),
1333
+ h('strong', { key: 'n', style: { fontSize: 12.5, whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' } }, member.name),
1334
+ member.status !== 'active' ? h('span', { key: 's', style: { ...styles.muted, fontSize: 11 } }, member.status) : null,
1335
+ member.running ? h('span', { key: 'r', style: { ...styles.muted, color: C.warn, fontSize: 11 } }, '运行中') : null,
1336
+ ]);
1337
+
1338
+ const row = (member) => h('div', {
1339
+ key: member.memberId,
1340
+ style: { display: 'grid', gridTemplateColumns: columns, alignItems: 'center', gap: 12, padding: '10px 2px', border: 'none', borderRadius: 10, background: 'transparent', minWidth: 0 },
1341
+ }, [
1342
+ memberCell(member),
1343
+ h('span', { key: 'd', title: member.brief || '', style: { ...styles.muted, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, member.brief || '—'),
1344
+ h('div', { key: 'a', style: { display: 'flex', justifyContent: 'flex-end' } },
1345
+ member.kind === 'coordinator' ? null : h(PMButton, { key: 'x', disabled: busy, onClick: () => run(() => call('chat/team/remove', { payload: { projectId: project.id, memberId: member.memberId } })) }, '移出')),
1346
+ ]);
1347
+
1348
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 } }, [
1349
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
1350
+ h('strong', { key: 't', style: { fontSize: 13 } }, '团队'),
1351
+ h('span', { key: 'd', style: { ...styles.muted, minWidth: 0 } }, '推进 bot 指挥;成员被 @ 时在自己的会话里回答;临时 bot 只属于这个项目。'),
1352
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1353
+ h(PMButton, { key: 'add', primary: true, disabled: busy, onClick: () => setAddOpen((open) => !open) }, addOpen ? '收起' : '+ 成员'),
1354
+ ]),
1355
+ error ? h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }) : null,
1356
+ addOpen ? h('div', { key: 'addPanel', style: { display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 0', border: 'none', borderRadius: 10, background: 'transparent' } }, [
1357
+ h('div', { key: 'pull', style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } }, [
1358
+ h('span', { key: 'l', style: { fontSize: 12 } }, '拉一个在岗 bot 进群'),
1359
+ h('select', {
1360
+ key: 's', 'aria-label': '选择要加入群聊的 bot', value: pick, disabled: busy,
1361
+ style: { ...styles.input, maxWidth: 260 }, onChange: (event) => setPick(event.target.value),
1362
+ }, [h('option', { key: '', value: '' }, '选择 bot'), ...candidates.map((bot) => h('option', { key: bot.id, value: bot.id }, bot.name))]),
1363
+ h(PMButton, { key: 'go', disabled: busy || !pick, onClick: async () => { if (await run(() => call('chat/team/add', { payload: { projectId: project.id, memberId: pick } }))) { setPick(''); setAddOpen(false); } } }, '加入'),
1364
+ ]),
1365
+ h('div', { key: 'spawn', style: { display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' } }, [
1366
+ h('div', { key: 'n', style: { maxWidth: 200 } }, h(PMInput, { label: '临时 bot 名字', value: name, onChange: setName, placeholder: '如:因子核验员' })),
1367
+ h('div', { key: 'b', style: { maxWidth: 360 } }, h(PMInput, { label: '职责(一句话)', value: brief, onChange: setBrief, placeholder: '只查这一周的因子' })),
1368
+ h(PMButton, { key: 'go', disabled: busy || !name.trim(), onClick: async () => { if (await run(() => call('chat/team/spawn', { payload: { projectId: project.id, name: name.trim(), brief: brief.trim() } }))) { setName(''); setBrief(''); setAddOpen(false); } } }, '创建临时 bot'),
1369
+ ]),
1370
+ h('span', { key: 'hint', style: styles.muted }, '临时 bot 由你创建、只属于本项目;推进 bot 自己也可以建(hams_group_spawn_bot),建完会出现在下面。'),
1371
+ ]) : null,
1372
+ h('div', { key: 'cols', style: { display: 'grid', gridTemplateColumns: columns, gap: 12, padding: '0 12px', ...styles.muted, fontSize: 11 } }, [
1373
+ h('span', { key: 'c1' }, '成员'), h('span', { key: 'c2' }, '职责'), h('span', { key: 'c3', style: { textAlign: 'right' } }, '操作'),
1374
+ ]),
1375
+ h('div', { key: 'rows', style: { display: 'flex', flexDirection: 'column', gap: 6 } }, [
1376
+ coordinator ? row(coordinator) : h('div', { key: 'noc', style: styles.notice }, '这个项目还没有推进 bot,群聊没有指挥者。'),
1377
+ ...members.map(row),
1378
+ ]),
1379
+ ]);
1380
+ }
1381
+
1382
+ /**
1383
+ * 交付物:项目推进过程中 bot 在工作项目录里产出的文件。
1384
+ * 只读 HAMS 数据目录下的 bots/<成员>/work/<工作项>/,文本可预览(.md 按 Markdown 渲染)。
1385
+ */
1386
+ function PMDeliverablesTab({ project }) {
1387
+ const [data, setData] = React.useState(null);
1388
+ const [error, setError] = React.useState(null);
1389
+ const [busy, setBusy] = React.useState(false);
1390
+ const [selected, setSelected] = React.useState(null);
1391
+ const [fileError, setFileError] = React.useState(null);
1392
+
1393
+ const load = React.useCallback(async () => {
1394
+ setBusy(true);
1395
+ try {
1396
+ setData(await call('project/deliverables', { payload: { projectId: project.id } }));
1397
+ setError(null);
1398
+ } catch (err) { setError(err); } finally { setBusy(false); }
1399
+ }, [project.id]);
1400
+ React.useEffect(() => { load(); }, [load]);
1401
+
1402
+ const open = async (item) => {
1403
+ setFileError(null);
1404
+ try {
1405
+ const file = await call('project/deliverable', { payload: { projectId: project.id, path: item.relPath } });
1406
+ setSelected({ ...file, label: item.label, source: item.source, memberName: item.memberName });
1407
+ } catch (err) { setFileError(err); }
1408
+ };
1409
+
1410
+ const items = (data && data.items) || [];
1411
+ const fmtBytes = (size) => size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`;
1412
+ const isMarkdown = selected && /\.(md|markdown)$/i.test(selected.name || '');
1413
+
1414
+ return h('div', { role: 'tabpanel', style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 } }, [
1415
+ h('div', { key: 'bar', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' } }, [
1416
+ h('strong', { key: 't', style: { fontSize: 13 } }, '交付物'),
1417
+ h('span', { key: 'c', style: styles.muted }, data
1418
+ ? `${items.length} 个文件 · 工作项 ${items.filter((row) => row.source === 'work').length} · 群聊 ${items.filter((row) => row.source === 'chat').length}${data.truncated ? '(已截断)' : ''}`
1419
+ : '加载中…'),
1420
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1421
+ h(PMButton, { key: 'r', disabled: busy, onClick: load }, busy ? '刷新中…' : '刷新'),
1422
+ ]),
1423
+ error ? h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }) : null,
1424
+ fileError ? h(ErrorBox, { key: 'ferr', error: fileError, onDismiss: () => setFileError(null) }) : null,
1425
+ data && !items.length
1426
+ ? h('div', { key: 'empty', style: styles.notice }, '还没有交付物。bot 在工作项里产出的文件会出现在这里(工作区 bots/<成员>/work/<工作项>)。')
1427
+ : null,
1428
+ items.length ? h('div', { key: 'two', style: { display: 'grid', gridTemplateColumns: 'minmax(320px,1fr) minmax(0,1.4fr)', gap: 14, alignItems: 'stretch', height: 'calc(100vh - 300px)', minHeight: 300 } }, [
1429
+ h('div', { key: 'list', style: { minWidth: 0, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 } },
1430
+ items.map((item) => h('div', {
1431
+ key: item.relPath,
1432
+ role: 'button',
1433
+ title: item.relPath,
1434
+ onClick: () => open(item),
1435
+ style: {
1436
+ display: 'flex', flexDirection: 'column', gap: 2, padding: '8px 10px', borderRadius: 8, cursor: 'pointer', minWidth: 0,
1437
+ background: selected && selected.relPath === item.relPath ? C.tintStrong : 'transparent',
1438
+ },
1439
+ }, [
1440
+ h('span', { key: 'n', style: { fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.name),
1441
+ h('span', { key: 'm', style: { ...styles.muted, fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1442
+ `${item.memberName} · ${fmtBytes(item.size)} · ${fmtTime(item.mtime)} · ${item.label}`),
1443
+ h('span', { key: 's', style: { display: 'none' } }, item.source),
1444
+ ]))),
1445
+ selected
1446
+ ? h('div', { key: 'preview', style: { minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 6 } }, [
1447
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' } }, [
1448
+ h('strong', { key: 'n', style: { fontSize: 12.5, wordBreak: 'break-all' } }, selected.name),
1449
+ h('span', { key: 's', style: styles.muted }, `${fmtBytes(selected.size)} · ${fmtTime(selected.mtime)} · ${selected.memberName}`),
1450
+ ]),
1451
+ h('span', { key: 'p', style: { ...styles.muted, wordBreak: 'break-all', fontSize: 11 } }, selected.relPath),
1452
+ h('div', { key: 'b', style: { flex: '1 1 auto', minHeight: 0, overflow: 'auto' } },
1453
+ selected.binary
1454
+ ? h('div', { key: 'bin', style: styles.notice }, '二进制文件(图片 / 压缩包 / 数据文件等)不能在这里预览,去工作区用对应工具打开。')
1455
+ : h(React.Fragment, null, [
1456
+ isMarkdown
1457
+ ? h('div', { key: 'md', style: { fontSize: 12.5 } }, renderMarkdown(selected.content, `dl-${selected.relPath}`))
1458
+ : h('pre', { key: 'raw', style: { margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11.5, lineHeight: 1.6 } }, selected.content),
1459
+ selected.truncated ? h('div', { key: 'tr', style: { ...styles.muted, paddingTop: 6 } }, '只显示前 256KB。') : null,
1460
+ ])),
1461
+ ])
1462
+ : h('div', { key: 'hint', style: { ...styles.muted, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' } }, '左侧选一个文件看内容'),
1463
+ ]) : null,
1464
+ ]);
1465
+ }
1466
+
1467
+ /**
1468
+ * 立项项目书:当前目标口径的只读全文(说明 + 每个目标的定义与目标值 + 最新读数/达标情况 + 发布信息),
1469
+ * 以及历史版本。就是「这个项目当初立项时定了什么」的那份文档。
1470
+ */
1471
+ function PMProjectCharter({ detail, metrics = [], versions = [], onClose }) {
1472
+ const fmtDirection = (direction) => ({ higher: '越高越好', lower: '越低越好', range: '区间' })[direction] || direction || '方向未定';
1473
+ const fmtTarget = (metric) => metric.target_value == null ? '目标未设' : `${metric.target_value}${metric.unit || ''}${metric.target_max == null ? '' : `–${metric.target_max}${metric.unit || ''}`}`;
1474
+ return h('div', { role: 'tabpanel', style: { display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 } }, [
1475
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' } }, [
1476
+ h(PMButton, { key: 'back', onClick: onClose }, '← 返回目标与阶段'),
1477
+ h('strong', { key: 't', style: { fontSize: 14 } }, '立项项目书'),
1478
+ h('span', { key: 's', style: styles.muted }, detail.current_target ? `口径 v${detail.current_target.version} · ${fmtTime(detail.current_target.published_at)} 冻结` : '尚未发布口径'),
1479
+ ]),
1480
+ h('div', { key: 'goal', style: { display: 'flex', flexDirection: 'column', gap: 6 } }, [
1481
+ h('strong', { key: 't', style: { fontSize: 13 } }, '目标说明'),
1482
+ h('p', { key: 'g', style: { margin: 0, lineHeight: 1.8 } }, detail.goal_text || '尚未填写目标说明'),
1483
+ ]),
1484
+ h('div', { key: 'targets', style: { display: 'flex', flexDirection: 'column', gap: 10 } }, [
1485
+ h('strong', { key: 't', style: { fontSize: 13 } }, `目标(${metrics.length})`),
1486
+ ...metrics.map((metric, index) => h('div', { key: metric.id || index, style: { display: 'flex', flexDirection: 'column', gap: 3, paddingLeft: 2 } }, [
1487
+ h('div', { key: 'r', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' } }, [
1488
+ h('strong', { key: 'n', style: { fontSize: 12.5 } }, `目标 ${index + 1}:${metric.name}`),
1489
+ h(PMVerdict, { key: 'v', verdict: PMMetricVerdict(metric) }),
1490
+ ]),
1491
+ h('span', { key: 'd', style: styles.muted },
1492
+ `${fmtDirection(metric.direction)} · ${fmtTarget(metric)} · 窗口 ${({ day: '近 1 天', week: '近 7 天', month: '近 30 天', snapshot: '时点快照' })[metric.window_kind] || metric.window_kind || '未设'} · 来源 ${metric.calculator_key || metric.source_kind || '未定义'}`),
1493
+ metric.definition_text ? h('span', { key: 'def', style: { ...styles.muted, lineHeight: 1.7 } }, `定义:${metric.definition_text}`) : null,
1494
+ h('span', { key: 'l', style: styles.muted },
1495
+ metric.latest && metric.latest.value != null ? `最新读数 ${metric.latest.value}${metric.unit || ''}(${fmtTime(metric.latest.observed_at)})` : '还没有读数:缺数时保持未知,不参与达标率分母'),
1496
+ ])),
1497
+ metrics.length ? null : h(PMEmpty, { key: 'e' }, '这份口径还没有定义目标'),
1498
+ ]),
1499
+ h('div', { key: 'versions', style: { display: 'flex', flexDirection: 'column', gap: 10 } }, [
1500
+ h('strong', { key: 't', style: { fontSize: 13 } }, `历史版本(${versions.length})`),
1501
+ ...versions.map((version) => h('div', { key: version.id || version.version, style: { display: 'flex', flexDirection: 'column', gap: 3 } }, [
1502
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' } }, [
1503
+ h('strong', { key: 'v', style: { fontSize: 12.5 } }, `v${version.version}`),
1504
+ version.version === detail.current_target_version ? badge('当前', C.accent) : badge('历史', C.muted),
1505
+ h('span', { key: 't', style: styles.muted }, `${fmtTime(version.published_at)} · 发布人 ${version.published_by_name || version.published_by || '未记录'} · ${(version.metrics || []).length} 个目标`),
1506
+ ]),
1507
+ h('span', { key: 'g', style: { ...styles.muted, lineHeight: 1.7 } }, version.goal_text || '未写目标'),
1508
+ ])),
1509
+ ]),
1510
+ ]);
1511
+ }
1512
+
1513
+ /**
1514
+ * 「目标与阶段」:项目 → 阶段 → 目标 → 检验记录。
1515
+ *
1516
+ * 提交检验 = 申请审批:项目经理提交(验证摘要 + 报告链接),指定审批人批准后目标才算达成;
1517
+ * 驳回会留下原因。每个项目至少有一个「默认阶段 + 首个目标」(服务端建项目时补齐)。
1518
+ */
1519
+ function PMGoalsTab({ detail, metrics, phases, humans = [], me, busy, locked, can, state, onPublishTarget, onRecord, form, onForm }) {
1520
+ const [showVersions, setShowVersions] = React.useState(false);
1521
+ const [openTarget, setOpenTarget] = React.useState('');
1522
+ const [charterOpen, setCharterOpen] = React.useState(false);
1523
+ const [rejecting, setRejecting] = React.useState('');
1524
+ const [rejectNote, setRejectNote] = React.useState('');
1525
+ const [editing, setEditing] = React.useState('');
1526
+ const [editDraft, setEditDraft] = React.useState({});
1527
+ const [adding, setAdding] = React.useState('');
1528
+ const [addDraft, setAddDraft] = React.useState({});
1529
+ const [draft, setDraft] = React.useState({});
1530
+ const versions = detail.target_versions || [];
1531
+ const valid = metrics.filter((m) => PMMetricVerdict(m) !== 'unknown');
1532
+ const met = valid.filter((m) => PMMetricVerdict(m) === 'met');
1533
+ const PHASE_STATUS = { planned: '待开始', active: '进行中', awaiting_confirm: '待确认', confirmed: '已确认', done: '已确认' };
1534
+ const VERDICT = { approved: '已通过', rejected: '已驳回', pending: '待审批' };
1535
+ const canManage = can('createPhase');
1536
+ const humanOptions = (humans || []).map((human) => [human.id, human.name || human.id]);
1537
+ const metricOptions = [['', '不关联指标'], ...metrics.map((m) => [m.id, m.name])];
1538
+ const draftOf = (id) => draft[id] || { summary: '', reportUrl: '', note: '', approverId: '' };
1539
+ const patchDraft = (id, key, value) => setDraft((prev) => ({ ...prev, [id]: { ...draftOf(id), [key]: value } }));
1540
+ const addDraftOf = () => addDraft;
1541
+ const patchAdd = (key, value) => setAddDraft((prev) => ({ ...prev, [key]: value }));
1542
+ const targetStatus = (target) => target.achieved ? '已达成' : target.pending ? '待审批' : target.rejected ? '已驳回' : '推进中';
1543
+ const targetColor = (target) => target.achieved ? C.ok : target.pending ? C.warn : target.rejected ? C.bad : C.muted;
1544
+ const fmtLimit = (endsAt) => (endsAt && endsAt > 253000000000000 ? '无限制' : fmtDay(endsAt));
1545
+ // 项目目标状态:全部达标=已达成;有读数未达标=推进中;没有可判定目标=未量化
1546
+ const goalLabel = !valid.length ? '未量化' : met.length === valid.length ? '已达成' : '推进中';
1547
+ const goalColor = !valid.length ? C.muted : met.length === valid.length ? C.ok : C.warn;
1548
+
1549
+ if (charterOpen) {
1550
+ return h(PMProjectCharter, { key: 'charter', detail, metrics, versions, onClose: () => setCharterOpen(false) });
1551
+ }
1552
+
1553
+ return h('div', { role: 'tabpanel', style: { display: 'flex', flexDirection: 'column', gap: 18, minWidth: 0 } }, [
1554
+ // ── 项目目标:状态徽标 + 一行摘要 + 说明(≤2 行)+ 最多 3 个目标(浅色块)──
1555
+ h('div', { key: 'goal', style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
1556
+ h('div', { key: 'r', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
1557
+ badge(goalLabel, goalColor),
1558
+ h('strong', { key: 'n', style: { fontSize: 13.5 } }, '项目目标'),
1559
+ h('span', { key: 'v', style: styles.muted }, detail.current_target ? `口径 v${detail.current_target.version}` : '口径未发布'),
1560
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1561
+ h(PMButton, { key: 'edit', primary: true, disabled: busy || !can('publishTarget'), onClick: () => onPublishTarget && onPublishTarget() }, '编辑'),
1562
+ h(PMButton, { key: 'charter', onClick: () => setCharterOpen(true) }, '查看立项项目书'),
1563
+ h(PMButton, { key: 'hist', disabled: busy, onClick: () => setShowVersions((open) => !open) }, showVersions ? '收起版本历史' : `版本历史(${versions.length})`),
1564
+ ]),
1565
+ h('div', {
1566
+ key: 'desc',
1567
+ title: detail.goal_text || '',
1568
+ style: {
1569
+ ...styles.muted, lineHeight: 1.6,
1570
+ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
1571
+ },
1572
+ }, detail.goal_text || '尚未填写目标说明'),
1573
+ metrics.length
1574
+ ? h('div', { key: 'targets', style: { display: 'flex', flexDirection: 'column', gap: 6, paddingTop: 2 } },
1575
+ metrics.slice(0, 3).map((metric, index) => {
1576
+ const verdict = PMMetricVerdict(metric);
1577
+ const latest = metric.latest && metric.latest.value != null ? `${metric.latest.value}${metric.unit || ''}` : '没有读数';
1578
+ const targetText = metric.target_value == null ? '目标未设' : `目标 ${metric.target_value}${metric.unit || ''}${metric.target_max == null ? '' : `–${metric.target_max}${metric.unit || ''}`}`;
1579
+ return h('div', {
1580
+ key: metric.id,
1581
+ style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: C.tintStrong, borderRadius: 10, padding: '9px 12px' },
1582
+ }, [
1583
+ badge(({ met: '达标', missed: '未达标', unknown: '未知' })[verdict], verdict === 'met' ? C.ok : verdict === 'missed' ? C.bad : C.muted),
1584
+ h('span', { key: 'd', style: { fontWeight: 500 } }, `目标 ${index + 1}:${metric.name}`),
1585
+ h('span', { key: 'l', style: styles.muted }, `最新 ${latest} · ${targetText}`),
1586
+ ]);
1587
+ }))
1588
+ : h('span', { key: 'none', style: styles.muted }, '还没有目标:点「编辑」,在目标口径里写 1–3 条可衡量的目标'),
1589
+ metrics.length > 3 ? h('span', { key: 'cap', style: styles.muted }, `当前口径有 ${metrics.length} 个目标,这里只显示前 3 个;请到「编辑」里收敛到最多 3 个。`) : null,
1590
+ metrics.length && metrics.length < 3 ? h('span', { key: 'add', style: styles.muted }, '还可以再加:点「编辑」新增目标(最多 3 个)。') : null,
1591
+ showVersions ? h('div', { key: 'versions', style: { display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 6 } },
1592
+ versions.map((v) => h('div', { key: v.id || v.version, style: { display: 'flex', flexDirection: 'column', gap: 4 } }, [
1593
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' } }, [
1594
+ h('strong', { key: 'v', style: { fontSize: 12.5 } }, `v${v.version}`),
1595
+ v.version === detail.current_target_version ? badge('当前', C.accent) : null,
1596
+ h('span', { key: 't', style: styles.muted }, `${fmtTime(v.published_at)} · 发布人 ${v.published_by_name || v.published_by || '未记录'}`),
1597
+ ]),
1598
+ h('span', { key: 'g', style: { lineHeight: 1.7 } }, v.goal_text || '未写目标'),
1599
+ ...(v.metrics || []).map((m, i) => h('div', { key: m.id || i, style: { display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' } }, [
1600
+ h('span', { key: 'n', style: styles.muted }, m.name || m.metric_id || `目标 ${i + 1}`),
1601
+ h('span', { key: 'd', style: styles.muted }, `目标 ${m.target_value == null ? '未设' : `${m.target_value}${m.unit || ''}`}`),
1602
+ ])),
1603
+ ]))) : null,
1604
+ ]),
1605
+ // ── 阶段(缩进块)──
1606
+ ...phases.map((phase) => {
1607
+ const targets = phase.targets || [];
1608
+ const achieved = targets.filter((target) => target.achieved).length;
1609
+ const phaseTone = phase.status === 'active' ? C.ok : phase.status === 'awaiting_confirm' ? C.warn : C.muted;
1610
+ return h('div', { key: phase.id, style: { display: 'flex', flexDirection: 'column', gap: 10, borderLeft: `2px solid ${C.hair}`, paddingLeft: 14 } }, [
1611
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
1612
+ badge(PHASE_STATUS[phase.status] || phase.status, phaseTone),
1613
+ h('strong', { key: 'n', style: { fontSize: 13 } }, `阶段 ${phase.seq}:${phase.name}`),
1614
+ h('span', { key: 'd', style: styles.muted }, `截止 ${fmtLimit(phase.ends_at)}`),
1615
+ h('span', { key: 'a', style: styles.muted }, `已达成 ${achieved}/${targets.length}`),
1616
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1617
+ h('div', { key: 'a', style: { display: 'flex', gap: 6, flexShrink: 0, flexWrap: 'wrap' } }, [
1618
+ !['confirmed', 'done'].includes(phase.status) ? h(PMButton, {
1619
+ key: 'advance', disabled: locked || !can('updatePhase') || phase.status === 'awaiting_confirm',
1620
+ onClick: () => state.execute('project/updatePhase', { projectId: detail.id, phaseId: phase.id, status: phase.status === 'active' ? 'awaiting_confirm' : 'active' }),
1621
+ }, phase.status === 'active' ? '提交阶段验收' : '开始阶段') : null,
1622
+ phase.status === 'awaiting_confirm' ? h(PMButton, {
1623
+ key: 'confirm', primary: true, disabled: locked || !can('confirmPhase'),
1624
+ onClick: () => state.execute('project/confirmPhase', { projectId: detail.id, phaseId: phase.id }),
1625
+ }, '确认阶段通过') : null,
1626
+ h(PMButton, { key: 'add', disabled: locked || !canManage, onClick: () => { setAdding(adding === phase.id ? '' : phase.id); setAddDraft({ description: '', detail: '', targetValue: '', verifierHumanId: (humanOptions[0] || [''])[0], metricId: '' }); } }, adding === phase.id ? '收起' : '+目标'),
1627
+ ]),
1628
+ ]),
1629
+ adding === phase.id ? h('div', { key: 'addForm', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '6px 0 2px 12px', borderLeft: `2px solid ${C.hair}` } }, [
1630
+ h('div', { key: 'd', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
1631
+ h('div', { key: 'desc', style: { flex: '1 1 360px', minWidth: 220 } }, h(PMInput, { label: '目标(一句话)', value: addDraftOf().description || '', onChange: (value) => patchAdd('description', value), disabled: busy })),
1632
+ h('div', { key: 'val', style: { width: 140 } }, h(PMInput, { label: '目标值(可空)', type: 'number', value: addDraftOf().targetValue || '', onChange: (value) => patchAdd('targetValue', value), disabled: busy })),
1633
+ ]),
1634
+ h('div', { key: 'det', style: { flex: '1 1 auto' } }, h(PMInput, { label: '目标详情:计算方式与验收要求', multiline: true, value: addDraftOf().detail || '', onChange: (value) => patchAdd('detail', value), disabled: busy })),
1635
+ h('div', { key: 'a', style: { display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' } }, [
1636
+ humanOptions.length ? h('div', { key: 'w', style: { width: 200 } }, h(PMSelect, { label: '验收人', value: addDraftOf().verifierHumanId || '', options: humanOptions, onChange: (value) => patchAdd('verifierHumanId', value), disabled: busy })) : null,
1637
+ h('div', { key: 'm', style: { width: 200 } }, h(PMSelect, { label: '关联指标(可空)', value: addDraftOf().metricId || '', options: metricOptions, onChange: (value) => patchAdd('metricId', value), disabled: busy })),
1638
+ h(PMButton, {
1639
+ key: 'go', primary: true, disabled: busy || !(addDraftOf().description || '').trim(),
1640
+ onClick: () => state.execute('project/addTarget', {
1641
+ projectId: detail.id, phaseId: phase.id, description: (addDraftOf().description || '').trim(),
1642
+ detail: (addDraftOf().detail || '').trim(),
1643
+ targetValue: (addDraftOf().targetValue || '').trim() === '' ? null : Number(addDraftOf().targetValue),
1644
+ ...(addDraftOf().verifierHumanId ? { verifierHumanId: addDraftOf().verifierHumanId } : {}),
1645
+ ...(addDraftOf().metricId ? { metricId: addDraftOf().metricId } : {}),
1646
+ }, () => { setAdding(''); setAddDraft({}); }),
1647
+ }, '添加目标'),
1648
+ ]),
1649
+ ]) : null,
1650
+ ...targets.map((target, index) => {
1651
+ const open = openTarget === target.id;
1652
+ const checks = target.checks || [];
1653
+ const d = draftOf(target.id);
1654
+ const linkedMetric = target.metric_id ? metrics.find((m) => m.id === target.metric_id) : null;
1655
+ const targetTone = targetColor(target);
1656
+ return h('div', { key: target.id || index, style: { display: 'flex', flexDirection: 'column', gap: 6, borderLeft: `2px solid ${C.hair}`, paddingLeft: 12 } }, [
1657
+ h('div', { key: 'r', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
1658
+ badge(targetStatus(target), targetTone),
1659
+ h('span', { key: 'd', style: { fontWeight: 500 } }, `目标 ${index + 1}:${target.description}`),
1660
+ target.target_value != null ? h('span', { key: 'v', style: styles.muted }, `目标值 ${target.target_value}`) : null,
1661
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1662
+ h('div', { key: 'a', style: { display: 'flex', gap: 6, flexShrink: 0 } }, [
1663
+ h(PMButton, { key: 'detail', onClick: () => setOpenTarget(open ? '' : target.id) }, open ? '收起' : '目标详情'),
1664
+ canManage ? h(PMButton, { key: 'edit', disabled: busy, onClick: () => { setOpenTarget(target.id); setEditing(target.id); setEditDraft({ description: target.description || '', detail: target.detail || '', targetValue: target.target_value == null ? '' : String(target.target_value), verifierHumanId: target.verifier_human_id || '' }); } }, '编辑') : null,
1665
+ canManage ? h(PMButton, { key: 'submit', primary: true, disabled: busy, onClick: () => setOpenTarget(target.id) }, '申请验证') : null,
1666
+ ]),
1667
+ ]),
1668
+ h('div', { key: 'dd', style: { ...styles.muted, lineHeight: 1.7 } }, `目标详情:${target.detail || '(尚未填写计算方式与验收要求)'}${linkedMetric ? ` · 关联指标:${linkedMetric.name}` : ''}`),
1669
+ (target.checks || []).length ? h('div', { key: 'checks', style: { display: 'flex', flexDirection: 'column', gap: 6, paddingLeft: 10, borderLeft: `1px solid ${C.hair}` } }, checks.map((check) => h('div', { key: check.id, style: { display: 'flex', flexDirection: 'column', gap: 3 } }, [
1670
+ h('div', { key: 'r', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' } }, [
1671
+ badge(VERDICT[check.status] || check.status, check.status === 'approved' ? C.ok : check.status === 'rejected' ? C.bad : C.warn),
1672
+ h('span', { key: 't', style: { ...styles.muted, fontSize: 11.5 } }, fmtTime(check.submitted_at)),
1673
+ h('span', { key: 'm', style: { fontSize: 12 } }, check.summary),
1674
+ check.report_url ? h('a', { key: 'u', href: check.report_url, target: '_blank', rel: 'noreferrer noopener', style: { color: C.accent, fontSize: 12 } }, '验证报告') : null,
1675
+ check.status === 'pending' ? h('span', { key: 'w', style: { ...styles.muted, fontSize: 11.5 } }, `待 ${check.approver_name || '审批人'} 审批`) : null,
1676
+ check.decision_note ? h('span', { key: 'n', style: { ...styles.muted, fontSize: 11.5 } }, `审批意见:${check.decision_note}`) : null,
1677
+ ]),
1678
+ check.status === 'pending' && check.approval_id && me && check.approver_id === me
1679
+ ? h('div', { key: 'act', style: { display: 'flex', gap: 6, alignItems: 'flex-end', flexWrap: 'wrap', paddingTop: 2 } }, [
1680
+ h(PMButton, { key: 'ok', primary: true, disabled: busy, onClick: () => state.execute('approval/decide', { approvalId: check.approval_id, decision: 'approve', note: '' }) }, '通过验证'),
1681
+ h(PMButton, { key: 'no', disabled: busy || rejecting === check.id, onClick: () => { setRejecting(check.id); setRejectNote(''); } }, '驳回'),
1682
+ rejecting === check.id ? h(React.Fragment, null, [
1683
+ h('div', { key: 'n', style: { flex: '1 1 240px', minWidth: 160 } }, h(PMInput, { label: '驳回原因(必填)', value: rejectNote, onChange: setRejectNote, disabled: busy })),
1684
+ h(PMButton, { key: 'go', disabled: busy || !rejectNote.trim(), onClick: () => state.execute('approval/decide', { approvalId: check.approval_id, decision: 'reject', note: rejectNote.trim() }, () => { setRejecting(''); setRejectNote(''); }) }, '确认驳回'),
1685
+ h(PMButton, { key: 'c', disabled: busy, onClick: () => setRejecting('') }, '取消'),
1686
+ ]) : null,
1687
+ ])
1688
+ : null,
1689
+ ]))) : null,
1690
+ open ? h('div', { key: 'panel', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0 2px 10px', borderLeft: `1px solid ${C.hair}` } }, [
1691
+ canManage && editing === target.id ? h('div', { key: 'editForm', style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
1692
+ h('div', { key: 'd', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
1693
+ h('div', { key: 'desc', style: { flex: '1 1 340px', minWidth: 200 } }, h(PMInput, { label: '目标(一句话)', value: editDraft.description || '', onChange: (value) => setEditDraft((prev) => ({ ...prev, description: value })), disabled: busy })),
1694
+ h('div', { key: 'val', style: { width: 140 } }, h(PMInput, { label: '目标值(可空)', type: 'number', value: editDraft.targetValue || '', onChange: (value) => setEditDraft((prev) => ({ ...prev, targetValue: value })), disabled: busy })),
1695
+ ]),
1696
+ h('div', { key: 'det' }, h(PMInput, { label: '目标详情:计算方式与验收要求', multiline: true, value: editDraft.detail || '', onChange: (value) => setEditDraft((prev) => ({ ...prev, detail: value })), disabled: busy })),
1697
+ h('div', { key: 'a', style: { display: 'flex', gap: 8, alignItems: 'flex-end' } }, [
1698
+ humanOptions.length ? h('div', { key: 'w', style: { width: 200 } }, h(PMSelect, { label: '验收人', value: editDraft.verifierHumanId || '', options: humanOptions, onChange: (value) => setEditDraft((prev) => ({ ...prev, verifierHumanId: value })), disabled: busy })) : null,
1699
+ h(PMButton, { key: 'save', primary: true, disabled: busy || !(editDraft.description || '').trim(), onClick: () => state.execute('project/updateTarget', {
1700
+ projectId: detail.id, targetId: target.id,
1701
+ description: (editDraft.description || '').trim(), detail: (editDraft.detail || '').trim(),
1702
+ targetValue: (editDraft.targetValue || '').trim() === '' ? null : Number(editDraft.targetValue),
1703
+ ...(editDraft.verifierHumanId ? { verifierHumanId: editDraft.verifierHumanId } : {}),
1704
+ }, () => setEditing('')) }, '保存'),
1705
+ h(PMButton, { key: 'cancel', disabled: busy, onClick: () => setEditing('') }, '取消'),
1706
+ ]),
1707
+ ]) : null,
1708
+ canManage ? h('div', { key: 'submitForm', style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
1709
+ h('span', { key: 't', style: styles.muted }, '申请验证 = 发起一次审批:审批人通过后这个目标才算达成。'),
1710
+ h(PMInput, { label: '验证摘要(审批人看到的就是这句)', value: d.summary, onChange: (value) => patchDraft(target.id, 'summary', value), disabled: busy }),
1711
+ h('div', { key: 'a', style: { display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' } }, [
1712
+ h('div', { key: 'u', style: { flex: '1 1 280px', minWidth: 180 } }, h(PMInput, { label: '验证报告链接(http/https,可空)', value: d.reportUrl, onChange: (value) => patchDraft(target.id, 'reportUrl', value), disabled: busy, placeholder: 'https://…' })),
1713
+ humanOptions.length ? h('div', { key: 'p', style: { width: 200 } }, h(PMSelect, { label: '指定审批人', value: d.approverId || '', options: [['', '选择审批人'], ...humanOptions], onChange: (value) => patchDraft(target.id, 'approverId', value), disabled: busy })) : null,
1714
+ h(PMButton, {
1715
+ key: 'go', primary: true,
1716
+ disabled: busy || !d.approverId || !d.summary.trim() || (!!d.reportUrl.trim() && !/^https?:\/\//i.test(d.reportUrl.trim())),
1717
+ onClick: () => state.execute('project/submitTargetCheck', {
1718
+ projectId: detail.id, targetId: target.id, approverId: d.approverId,
1719
+ summary: d.summary.trim(), reportUrl: d.reportUrl.trim(), note: d.note.trim(),
1720
+ }, () => setDraft((prev) => ({ ...prev, [target.id]: { summary: '', reportUrl: '', note: '', approverId: '' } }))),
1721
+ }, '发起验证审批'),
1722
+ ]),
1723
+ h(PMInput, { label: '说明(可空)', value: d.note, onChange: (value) => patchDraft(target.id, 'note', value), disabled: busy }),
1724
+ ]) : h('div', { key: 'ro', style: styles.muted }, '申请验证需要项目经理权限'),
1725
+ ]) : null,
1726
+ ]);
1727
+ }),
1728
+ (phase.checks || []).length ? h('div', { key: 'chk', style: { display: 'flex', flexDirection: 'column', gap: 4, paddingLeft: 12, borderLeft: `1px solid ${C.hair}` } }, (phase.checks || []).map((check) => h('label', { key: check.id, style: { display: 'flex', alignItems: 'center', gap: 6 } }, [
1729
+ h('input', { key: 'i', type: 'checkbox', checked: !!check.checked, disabled: locked || !can('updatePhase') || ['confirmed', 'done'].includes(phase.status), onChange: (event) => state.execute('project/updatePhase', { projectId: detail.id, phaseId: phase.id, checkId: check.id, checked: event.target.checked }) }),
1730
+ h('span', { key: 't' }, check.label),
1731
+ ]))) : null,
1732
+ ]);
1733
+ }),
1734
+ h('div', { key: 'addPhase', style: { display: 'flex', gap: 10, alignItems: 'baseline' } }, [
1735
+ h('span', { key: 'c', style: styles.muted }, `${phases.length} 个阶段 · 日期到了不会自动通过`),
1736
+ h('div', { key: 'sp', style: { flex: '1 1 auto' } }),
1737
+ h(PMButton, { key: 'new', disabled: locked || !can('createPhase'), onClick: () => onForm('phase') }, '新增阶段'),
1738
+ ]),
1739
+ form === 'phase' ? h(PMPhaseForm, { key: 'form', detail, humans, busy: locked, execute: state.execute, onCancel: () => onForm('') }) : null,
1740
+ phases.length ? null : h(PMEmpty, { key: 'e' }, '还没有阶段——项目建好后会自动补一个默认阶段,如果没有,点下面「新增阶段」。'),
1741
+ ]);
1742
+ }
1743
+
1744
+ /** 项目「记忆」页签:群聊自动发现 + 手工补充;能力类记忆会同步给 bot。 */
1745
+ function PMMemoryTab({ project, bots = [], canManage, onRefresh }) {
1746
+ const [items, setItems] = React.useState(null);
1747
+ const [status, setStatus] = React.useState('active');
1748
+ const [kind, setKind] = React.useState('');
1749
+ const [error, setError] = React.useState(null);
1750
+ const [busy, setBusy] = React.useState(false);
1751
+ const [formOpen, setFormOpen] = React.useState(false);
1752
+ const prefix = `pm/memory/${project.id}/`;
1753
+ const [title, setTitle] = usePMDraft(prefix + 'title', '');
1754
+ const [body, setBody] = usePMDraft(prefix + 'body', '');
1755
+ const [memKind, setMemKind] = usePMDraft(prefix + 'kind', 'fact');
1756
+ const [botId, setBotId] = usePMDraft(prefix + 'botId', project.coordinator_id || '');
1757
+ const activeBots = bots.filter((bot) => bot.status === 'active');
1758
+
1759
+ const load = React.useCallback(async () => {
1760
+ try {
1761
+ const next = await call('project/memory/list', { payload: { projectId: project.id, status, kind: kind || undefined } });
1762
+ setItems(Array.isArray(next) ? next : []);
1763
+ setError(null);
1764
+ } catch (err) { setError(err); }
1765
+ }, [project.id, status, kind]);
1766
+ React.useEffect(() => { load(); }, [load]);
1767
+
1768
+ const submit = async () => {
1769
+ if (!canManage || busy || !title.trim() || !body.trim()) return;
1770
+ setBusy(true); setError(null);
1771
+ try {
1772
+ await call('project/memory/create', {
1773
+ objectId: project.id,
1774
+ payload: {
1775
+ projectId: project.id, kind: memKind, title: title.trim(), body: body.trim(),
1776
+ memberId: memKind === 'capability' ? (botId || null) : null,
1777
+ },
1778
+ });
1779
+ PMClearDraft(prefix); setTitle(''); setBody(''); setFormOpen(false);
1780
+ await load(); if (onRefresh) onRefresh();
1781
+ } catch (err) { setError(err); } finally { setBusy(false); }
1782
+ };
1783
+ const setItemStatus = async (item, next) => {
1784
+ if (!canManage || busy) return;
1785
+ setBusy(true); setError(null);
1786
+ try {
1787
+ await call('project/memory/update', { objectId: item.id, payload: { memoryId: item.id, status: next } });
1788
+ await load(); if (onRefresh) onRefresh();
1789
+ } catch (err) { setError(err); } finally { setBusy(false); }
1790
+ };
1791
+
1792
+ const rows = items || [];
1793
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 } }, [
1794
+ h('div', { key: 'h', style: styles.row }, [
1795
+ h('div', { key: 't', style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', minWidth: 0 } }, [
1796
+ h('strong', { key: 'n', style: { fontSize: 13 } }, '项目记忆'),
1797
+ h('span', { key: 'd', style: styles.muted }, '群聊里自动发现决定/偏好/教训/能力;能力类会同步一份给对应 bot。'),
1798
+ ]),
1799
+ h('div', { key: 'a', style: { display: 'flex', gap: 6 } }, [
1800
+ h(PMButton, { key: 'r', disabled: busy, onClick: load }, '刷新'),
1801
+ canManage ? h(PMButton, { key: 'new', primary: true, disabled: busy, onClick: () => setFormOpen((open) => !open) }, formOpen ? '收起' : '记一条') : null,
1802
+ ]),
1803
+ ]),
1804
+ h(ErrorBox, { key: 'err', error, onDismiss: () => setError(null) }),
1805
+ h('div', { key: 'filters', style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' } }, [
1806
+ h('div', { key: 's', style: { width: 150 } }, h(PMSelect, { label: '状态', value: status, onChange: setStatus, options: [['active', '有效'], ['archived', '已归档'], ['', '全部']] })),
1807
+ h('div', { key: 'k', style: { width: 150 } }, h(PMSelect, { label: '类型', value: kind, onChange: setKind, options: [['', '全部类型'], ...PM_MEMORY_KINDS] })),
1808
+ h('span', { key: 'c', style: { marginLeft: 'auto', ...styles.muted } }, `${rows.length} 条${canManage ? '' : ' · 只读'}`),
1809
+ ]),
1810
+ formOpen && canManage ? h('section', { key: 'form', style: styles.card }, [
1811
+ h('div', { key: 'g', style: styles.grid }, [
1812
+ h(PMInput, { key: 't', label: '标题(一句话)', value: title, onChange: setTitle, disabled: busy }),
1813
+ h(PMSelect, { key: 'k', label: '类型', value: memKind, onChange: setMemKind, disabled: busy, options: PM_MEMORY_KINDS }),
1814
+ ]),
1815
+ memKind === 'capability' ? h(PMSelect, { key: 'bot', label: '同步给哪个 bot', value: botId, onChange: setBotId, disabled: busy, options: [['', '不同步给 bot'], ...activeBots.map((bot) => [bot.id, bot.name])] }) : null,
1816
+ h(PMInput, { key: 'b', label: '内容(保留事实、原因、以后怎么做)', value: body, onChange: setBody, multiline: true, disabled: busy }),
1817
+ h('div', { key: 'a', style: styles.row }, [
1818
+ h(PMButton, { key: 's', primary: true, disabled: busy || !title.trim() || !body.trim(), onClick: submit }, busy ? '保存中…' : '保存记忆'),
1819
+ h(PMButton, { key: 'c', disabled: busy, onClick: () => setFormOpen(false) }, '取消'),
1820
+ ]),
1821
+ ]) : null,
1822
+ items === null
1823
+ ? h('div', { key: 'loading', style: styles.muted }, '读取记忆…')
1824
+ : rows.length
1825
+ ? h('div', { key: 'list', style: styles.list }, rows.map((item) => h('article', { key: item.id, style: styles.card }, [
1826
+ h('div', { key: 'h', style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', minWidth: 0 } }, [
1827
+ badge(PMMemoryKindLabel(item.kind), PM_MEMORY_KIND_COLOR[item.kind] || C.muted),
1828
+ h('strong', { key: 't', style: { fontSize: 13, minWidth: 0 } }, item.title),
1829
+ badge(PM_MEMORY_ORIGIN[item.origin] || item.origin || '记忆', C.muted),
1830
+ item.kind === 'capability' && item.member_name ? badge(`同步给 ${item.member_name}`, C.ok) : null,
1831
+ canManage ? h(PMButton, { key: 'x', style: { marginLeft: 'auto' }, disabled: busy, onClick: () => setItemStatus(item, item.status === 'active' ? 'archived' : 'active') }, item.status === 'active' ? '归档' : '恢复') : null,
1832
+ ]),
1833
+ h('div', { key: 'b', style: { lineHeight: 1.8, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, item.body),
1834
+ h('div', { key: 'm', style: styles.muted }, `${fmtTime(item.created_at)} · ${item.created_by_name || item.created_by || '未知'}${item.source_ref && String(item.source_ref).startsWith('message:') ? ' · 来自群聊' : item.origin === 'tool' ? ' · 来自 bot' : ''}`),
1835
+ ])))
1836
+ : h(PMEmpty, { key: 'e' }, status === 'archived' ? '还没有归档的记忆。' : '还没有记忆。群聊里出现决定、偏好、教训或能力经验时会自动收到这里;也可以点「记一条」手动补。'),
1837
+ ]);
1838
+ }
1839
+ function PMProjectDetail({ detail, humans = [], bots = [], me, busy, onCommand, onStatus, onRecord, onGotoTasks, onOpenWorkItem, onClose, initialTab, onPublishTarget, onContinueDraft, onDelete, onRefreshDetail }) {
1840
+ const [tab, setTab] = React.useState(initialTab || 'chat'); const [form, setForm] = React.useState(''); const [progressFilter, setProgressFilter] = React.useState('all'); const [confirmDelete, setConfirmDelete] = React.useState(false);
1841
+ const state = usePMCommand(onCommand); const locked = busy || state.pending;
1842
+ const metrics = detail.metrics || []; const phases = detail.phases || []; const reviews = detail.reviews || []; const progress = detail.progress_records || {};
1843
+ const items = detail.workItems || detail.projectWorkItems || (Array.isArray(detail.work_items) ? detail.work_items : detail.recent_items || []);
1844
+ const fullWorkItems = !!(detail.workItems || detail.projectWorkItems || Array.isArray(detail.work_items));
1845
+ const can = (action) => PMAllowed(detail, action);
1846
+ const valid = metrics.filter((m) => PMMetricVerdict(m) !== 'unknown'); const met = valid.filter((m) => PMMetricVerdict(m) === 'met');
1847
+ const status = (action) => onCommand ? state.execute('project/status', { projectId: detail.id, status: { publish: 'active', pause: 'paused', resume: 'active', archive: 'archived' }[action] }) : onStatus && onStatus(action);
1848
+ return h('section', { style: styles.page }, [
1849
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', minWidth: 0 } }, [
1850
+ badge(PROJECT_STATUS_LABEL[detail.status] || detail.status, detail.status === 'active' ? C.ok : C.muted),
1851
+ h('h2', { key: 'n', style: { ...styles.title, margin: 0, flex: '1 1 240px', minWidth: 0 } }, detail.name),
1852
+ h('div', { key: 'a', style: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' } }, [
1853
+ can('continueDraft') ? h(PMButton, { key: 'continue', primary: true, disabled: locked || !onContinueDraft, onClick: () => onContinueDraft && onContinueDraft(detail) }, '继续完善草稿') : null,
1854
+ ...['publish', 'pause', 'resume', 'archive'].filter(can).map((action) => h(PMButton, { key: action, disabled: locked || (!onCommand && !onStatus), onClick: () => status(action) }, { publish: '发布目标', pause: '暂停推进', resume: '继续推进', archive: '归档项目' }[action])),
1855
+ can('delete') ? h(PMButton, { key: 'delete', disabled: locked, onClick: () => setConfirmDelete(true) }, '删除项目') : null,
1856
+ h(PMButton, { key: 'review', primary: !can('continueDraft'), disabled: locked || !onCommand || !can('createReview'), onClick: () => { setTab('reviews'); setForm('review'); } }, '发起复盘'),
1857
+ ]),
1858
+ onClose ? h(PMButton, { key: 'close', onClick: onClose }, '✕ 关闭') : null,
1859
+ ]),
1860
+ h('div', { key: 'meta', style: styles.muted }, `${detail.type === 'continuous' ? '持续运营' : '一次性'} · 负责人 ${detail.owner_name || detail.owner_human_id || '未指定'} · 推进 ${detail.coordinator_name || '未指定'} · 修订 ${detail.revision}`),
1861
+ detail.status === 'draft' ? h('div', { key: 'draftNote', style: styles.notice }, '这是草稿:目标口径还没发布。点「继续完善草稿」回到向导,填完目标与指标后创建并发布 v1;也可以继续保存草稿。') : null,
1862
+ confirmDelete ? h('div', { key: 'deleteConfirm', style: { ...styles.notice, background: 'rgba(224,49,49,.08)', color: '#B42318', display: 'flex', flexDirection: 'column', gap: 8 } }, [
1863
+ h('strong', { key: 'h' }, `确认删除《${detail.name}》?`),
1864
+ h('span', { key: 'n' }, '删除后项目从项目列表与群聊消失,项目下的工作项转入「无项目」继续保留;证据与审计不会被删除。'),
1865
+ h('div', { key: 'b', style: { display: 'flex', gap: 8 } }, [
1866
+ h(PMButton, { key: 'yes', primary: true, disabled: locked, onClick: () => state.execute('project/delete', { projectId: detail.id, reason: '界面删除归档项目' }, () => { if (onDelete) onDelete(detail.id); }) }, '确认删除'),
1867
+ h(PMButton, { key: 'no', disabled: locked, onClick: () => setConfirmDelete(false) }, '取消'),
1868
+ ]),
1869
+ ]) : null,
1870
+ h(ErrorBox, { key: 'err', error: state.error, onDismiss: state.dismiss }),
1871
+ h(PMTabs, { key: 'tabs', tabs: [['chat', '群聊'], ['team', '团队'], ...PM_PROJECT_TABS.map(([key, label]) => key === 'memory' && Number(detail.memory_count) > 0 ? [key, `${label} ${detail.memory_count}`] : [key, label])], value: tab, onChange: setTab, label: '项目页签' }),
1872
+ tab === 'chat' ? h(GroupChatPane, { key: 'chat', projectId: detail.id, title: detail.name, bots }) : null,
1873
+ tab === 'team' ? h(PMTeamTab, { key: 'team', project: detail, bots }) : null,
1874
+ tab === 'deliverables' ? h(PMDeliverablesTab, { key: 'deliverables', project: detail }) : null,
1875
+ tab === 'goals' ? h(PMGoalsTab, { key: 'goals', detail, metrics, phases, humans, me, busy: locked, locked, can, state, onPublishTarget, onRecord, form, onForm: setForm }) : null,
1876
+ tab === 'work' ? h('div', { key: 'work', role: 'tabpanel', style: styles.list }, [h('div', { key: 'bar', style: styles.row }, [h('span', { key: 'n', style: styles.muted }, `${items.length} 条${fullWorkItems ? '关联' : '近期'}工作项`), h(PMButton, { key: 'new', primary: true, disabled: locked || !can('createWorkItem') || !onGotoTasks, onClick: () => onGotoTasks(detail.id) }, '新建工作项')]), ...Array.from(new Set(items.map((i) => i.domain || '未分域'))).map((domain) => h('section', { key: domain, style: styles.card }, [h('strong', { key: 'h' }, domain), h(PMWorkList, { key: 'list', items: items.filter((i) => (i.domain || '未分域') === domain), onOpen: onOpenWorkItem })])), !items.length ? h(PMEmpty, { key: 'e' }, '还没有关联工作项') : null, h('details', { key: 'progress', style: { paddingTop: 4 } }, [h('summary', { key: 's' }, '推进记录(提议 / 决定 / 项目变更)'), h('div', { key: 'p', style: { display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 8 } }, [h(PMSelect, { key: 'filter', label: '推进记录筛选', value: progressFilter, onChange: setProgressFilter, options: [['all', '全部'], ['open', '待决定'], ['accepted', '已采纳'], ['rejected', '已驳回']] }), ...(progress.proposals || []).filter((p) => progressFilter === 'all' || p.status === progressFilter).map((p) => { const ds = (progress.decisions || []).filter((d) => d.proposal_id === p.id); return h('article', { key: p.id, style: styles.card }, [h('strong', { key: 't' }, p.title), h('span', { key: 'p' }, `提议:${p.body || '未附说明'}`), h('span', { key: 'd', style: styles.muted }, `决定:${ds.length ? ds.map((d) => `${d.decision || d.status} · ${d.reason_text || d.note_text || d.note || ''} · ${fmtTime(d.created_at)}`).join(';') : p.status === 'open' ? '等待决定' : p.status || '未知'}`), h('span', { key: 'r', style: styles.muted }, `结果:${p.result_text || p.result_work_item_id || p.work_item_id || '尚无结果记录'}`)]); }), ...(progress.events || []).map((e, i) => h('div', { key: e.id || i, style: styles.item }, [h('span', { key: 't' }, e.summary || e.action || e.event_type || '项目变更'), h('span', { key: 'd', style: styles.muted }, fmtTime(e.created_at))])), !(progress.proposals || []).length && !(progress.events || []).length ? h(PMEmpty, { key: 'e' }, '还没有推进记录。提议决定汇总在总览「待我把关」。') : null])])]) : null,
1877
+ tab === 'memory' ? h(PMMemoryTab, { key: 'memory', project: detail, bots, canManage: detail.status !== 'archived' && can('manageMemory'), onRefresh: onRefreshDetail }) : null,
1878
+ tab === 'reviews' ? h('div', { key: 'reviews', role: 'tabpanel', style: styles.list }, [
1879
+ form === 'review' ? h(PMReviewForm, { key: 'form', detail, humans, busy: locked, execute: state.execute, onCancel: () => setForm('') }) : null,
1880
+ ...reviews.map((r) => h('article', { key: r.id, style: styles.card }, [h('div', { key: 'h', style: styles.row }, [h('strong', { key: 't' }, `${({ weekly: '周度', monthly: '月度', quarterly: '季度', phase: '阶段' })[r.kind] || r.kind || '周期'}复盘`), badge(r.status === 'awaiting_confirm' ? '待确认' : r.status === 'confirmed' ? '已确认' : r.status)]), h('span', { key: 'p', style: styles.muted }, `${fmtTime(r.period_start)} — ${fmtTime(r.period_end)} · 目标版本 ${r.target_version || r.target_version_id || '未知'} · 冻结 ${fmtTime(r.created_at)}`), h('div', { key: 'c' }, r.conclusion_text || '尚未填写结论'), ...(r.metrics || []).map((m, i) => h('div', { key: m.id || m.metric_id || i, style: styles.row }, [h('span', { key: 'n' }, m.name || m.metric_id), h('span', { key: 'v' }, `实际 ${m.actual_value == null ? '未知' : m.actual_value}${m.unit || ''} · 目标 ${m.target_value == null ? '未定义' : m.target_value}`), h(PMVerdict, { key: 'verdict', verdict: m.verdict || PMMetricVerdict(m) }), m.evidence_ref ? h('span', { key: 'e', style: styles.muted }, `证据 ${m.evidence_ref}`) : null])), r.status === 'awaiting_confirm' ? h(PMButton, { key: 'confirm', primary: true, disabled: locked || !onCommand || !can('confirmReview') || (r.allowedActions && !PMAllowed(r, 'confirmReview')), onClick: () => state.execute('project/confirmReview', { projectId: detail.id, reviewId: r.id }) }, '确认本期复盘') : null])),
1881
+ reviews.length ? null : h(PMEmpty, { key: 'e' }, '暂无复盘。发起后保留当期指标快照与目标口径。'),
1882
+ ]) : null,
1883
+ ]);
1884
+ }
1885
+ function ProjectScreen({ projects = [], detail, busy, formOpen, draft = null, bots = [], humans = [], me, onCreate, onOpen, onFormOpen, onStatus, onRecord, onGotoTasks, onCommand, onSaveDraft, onPublish, onContinueDraft, onDelete, allowedActions = [], onOpenWorkItem, onClose, initialTab, onRefreshDetail }) {
1886
+ const [query, setQuery] = React.useState(''); const [type, setType] = React.useState(''); const [owner, setOwner] = React.useState(''); const [mine, setMine] = React.useState(false); const [view, setView] = React.useState('list');
1887
+ // 发布新目标口径是独立页:取消/发布后回到「目标与阶段」页签,不再嵌在页签里。
1888
+ const [targetOpen, setTargetOpen] = React.useState(false); const [returnTab, setReturnTab] = React.useState('chat');
1889
+ const state = usePMCommand(onCommand);
1890
+ const openTarget = () => { setReturnTab('goals'); setTargetOpen(true); };
1891
+ const visible = PMFilterProjects(projects, { query, type, owner, mine });
1892
+ const owners = Array.from(new Map(projects.filter((p) => p.owner_human_id).map((p) => [p.owner_human_id, p.owner_name || p.owner_human_id])).entries());
1893
+
1894
+ // 新建项目独立成页:保存或取消都回到列表
1895
+ if (formOpen) {
1896
+ return h(PMPage, { key: 'project-create', backLabel: '← 返回项目列表', onBack: onFormOpen, crumb: '项目 / 新建项目' },
1897
+ h(ProjectCreateForm, { key: 'form', bots, draft, busy, onSubmit: onCreate, onCancel: onFormOpen, onCommand, onSaveDraft, onPublish, allowedActions }));
1898
+ }
1899
+
1900
+ // 发布新版本独立成整页:返回按钮回到版本列表
1901
+ if (detail && targetOpen) {
1902
+ return h(PMPage, { key: 'project-target', backLabel: '← 返回目标与阶段', onBack: () => setTargetOpen(false), crumb: `项目 / ${detail.name} / 发布新目标口径` },
1903
+ h(PMTargetForm, { key: 'target', detail, busy: busy || state.pending, onCommand, execute: state.execute, onCancel: () => setTargetOpen(false) }));
1904
+ }
1905
+
1906
+ // 详情独立成页:整页只显示详情,顶部给返回入口
1907
+ if (detail) {
1908
+ return h(PMPage, { key: `project-${detail.id}`, backLabel: '← 返回项目列表', onBack: () => { setTargetOpen(false); if (onClose) onClose(); }, crumb: `项目 / ${detail.name}` },
1909
+ h(PMProjectDetail, { key: detail.id, detail, busy, humans, bots, me, onCommand, onStatus, onRecord, onGotoTasks, onOpenWorkItem, initialTab: initialTab || returnTab, onPublishTarget: openTarget, onContinueDraft, onDelete, onRefreshDetail }));
1910
+ }
1911
+
1912
+ // 没有详情时显示列表
1913
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } }, [
1914
+ h('div', { key: 'h', style: styles.row }, [h('h1', { key: 't', style: styles.title }, '项目'), h(PMButton, { key: 'new', primary: true, disabled: busy || (!PMAllowed({ allowedActions }, 'create') && !formOpen), onClick: onFormOpen }, '新建项目')]),
1915
+ h('div', { key: 'filters', style: { ...styles.grid, alignItems: 'end' } }, [h(PMInput, { key: 'q', label: '搜索项目', value: query, onChange: setQuery, placeholder: '名称、目标或负责人' }), h(PMSelect, { key: 'type', label: '类型', value: type, onChange: setType, options: [['', '全部类型'], ['continuous', '持续运营'], ['one_off', '一次性项目']] }), h(PMSelect, { key: 'owner', label: '负责人', value: owner, onChange: setOwner, options: [['', '全部负责人'], ...owners] }), h('label', { key: 'mine', style: { display: 'flex', gap: 6, alignItems: 'center', minHeight: 28 } }, [h('input', { key: 'i', type: 'checkbox', checked: mine, onChange: (e) => setMine(e.target.checked) }), '待我处理']), h('div', { key: 'view', style: { display: 'flex', gap: 4 } }, ['list', 'cards'].map((key) => h(PMButton, { key, primary: view === key, onClick: () => setView(key) }, key === 'list' ? '列表' : '卡片')))]),
1916
+ h('span', { key: 'count', style: styles.muted }, `${visible.length} / ${projects.length} 个项目`),
1917
+ visible.length ? h('div', { key: 'list', style: view === 'cards' ? { display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 10 } : styles.list }, visible.map((p) => h('article', { key: p.id, style: styles.item }, [h('div', { key: 'r', style: styles.row }, [h(PMButton, { key: 'open', onClick: () => onOpen(p.id) }, p.name), badge(PROJECT_STATUS_LABEL[p.status] || p.status, p.status === 'active' ? C.ok : C.muted)]), h('span', { key: 'd', style: styles.muted }, `${p.type === 'continuous' ? '持续运营' : '一次性'} · ${p.owner_name || p.owner_human_id || '未指定负责人'} · 指标 ${p.metric_count == null ? '未知' : p.metric_count} · 未结工作 ${p.work_items ? `${p.work_items.open}/${p.work_items.total}` : '未知'}`), h('div', { key: 'g' }, p.goal_text || '尚未定义目标')]))) : h(PMEmpty, { key: 'empty' }, projects.length ? '没有符合筛选条件的项目' : '还没有项目,创建后定义目标与指标。'),
1918
+ ]);
1919
+ }
1920
+ /** 热力图下面那行说明:日期范围、总量、峰值。 */
1921
+ function PMHeatLegend(activity) {
1922
+ const days = PMActivityDays(activity);
1923
+ if (!days.length) return '';
1924
+ const max = Math.max(0, ...days.map((d) => d.count || 0));
1925
+ const total = days.reduce((sum, d) => sum + (d.count || 0), 0);
1926
+ return `${days[0].day} — ${days[83].day}(${activity.timeZone || '服务端日期'}) · 共 ${total} 次活动 · 峰值 ${max} 次/天 · 虚线格为未知`;
1927
+ }
1928
+ function PMHeatmap({ activity, showLegend = true }) {
1929
+ const days = PMActivityDays(activity); const max = Math.max(0, ...days.map((d) => d.count || 0)); const total = days.reduce((sum, d) => sum + (d.count || 0), 0);
1930
+ // 格子固定 13px:12 周 × 7 天只占两百来像素宽;说明文字默认跟区块底部走,不挤在 1/4 栏里折行。
1931
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } }, [
1932
+ h('strong', { key: 'h', style: { fontSize: 12 } }, '最近 12 周活动'),
1933
+ days.length
1934
+ ? h('div', { key: 'grid', role: 'img', 'aria-label': `最近84天共${total}次活动,按服务端日期`, style: { display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'repeat(7,13px)', gridTemplateColumns: 'repeat(12,13px)', gap: 2, alignSelf: 'flex-start' } }, days.map((day) => h('span', { key: day.day, title: `${day.day} · ${day.count == null ? '数据未知' : `${day.count} 次活动`}`, style: { display: 'block', width: 13, height: 13, borderRadius: 3, border: 'none', background: day.count == null ? 'rgba(127,127,127,.06)' : day.count === 0 ? C.soft : C.accent, opacity: !day.count ? 1 : .2 + .8 * day.count / Math.max(max, 1) } })))
1935
+ : h('span', { key: 'e', style: styles.muted }, '尚无完整活动数据'),
1936
+ showLegend && days.length ? h('span', { key: 'legend', style: styles.muted }, PMHeatLegend(activity)) : null,
1937
+ ]);
1938
+ }
1939
+ function MemberCreateForm({ positions = [], humans = [], busy, onSubmit, onCancel }) {
1940
+ const catalog = useHostCatalog();
1941
+ const [kind, setKind] = usePMDraft('pm/member-create/' + 'kind', 'human'); const [name, setName] = usePMDraft('pm/member-create/' + 'name', ''); const [role, setRole] = usePMDraft('pm/member-create/' + 'role', 'executor');
1942
+ const [domain, setDomain] = usePMDraft('pm/member-create/' + 'domain', ''); const [responsibility, setResponsibility] = usePMDraft('pm/member-create/' + 'responsibility', ''); const [positionId, setPositionId] = usePMDraft('pm/member-create/' + 'positionId', '');
1943
+ const [ownerHumanId, setOwnerHumanId] = usePMDraft('pm/member-create/' + 'ownerHumanId', ''); const [preset, setPreset] = usePMDraft('pm/member-create/' + 'preset', ''); const [model, setModel] = usePMDraft('pm/member-create/' + 'model', ''); const [capabilities, setCapabilities] = usePMDraft('pm/member-create/' + 'capabilities', []);
1944
+ const toggle = (key) => setCapabilities((rows) => rows.includes(key) ? rows.filter((r) => r !== key) : [...rows, key]);
1945
+ const activePositions = positions.filter((p) => !p.status || p.status === 'active'); const activeHumans = humans.filter((p) => !p.status || p.status === 'active');
1946
+ const valid = name.trim() && responsibility.trim() && (kind === 'human' ? positionId : ownerHumanId && domain.trim());
1947
+ return h('section', { style: styles.card }, [h('strong', { key: 'h' }, '新建成员'), h('div', { key: 'kind', role: 'group', 'aria-label': '成员类型', style: { display: 'flex', gap: 6 } }, [['human', '人'], ['bot', 'bot']].map(([key, label]) => h(PMButton, { key, primary: kind === key, disabled: busy, onClick: () => setKind(key) }, label))),
1948
+ h('div', { key: 'basic', style: styles.grid }, [h(PMInput, { key: 'n', label: '姓名 / 名称', value: name, onChange: setName, disabled: busy }), h(PMSelect, { key: 'r', label: '角色', value: role, onChange: setRole, disabled: busy, options: [['coordinator', '协调'], ['executor', '执行'], ['observer', '观察']] }), h(PMInput, { key: 'd', label: kind === 'bot' ? '业务域(必填,限定授权范围)' : '业务域', value: domain, onChange: setDomain, disabled: busy })]),
1949
+ h(PMInput, { key: 'resp', label: '职责', value: responsibility, onChange: setResponsibility, multiline: true, disabled: busy }),
1950
+ kind === 'human' ? h('div', { key: 'human', style: styles.list }, [h(PMSelect, { key: 'p', label: '在岗岗位', value: positionId, onChange: setPositionId, disabled: busy, options: [['', '请选择'], ...activePositions.map((p) => [p.id, p.name])] }), h('span', { key: 'hint', style: styles.muted }, '岗位从创建时开始在岗,暂不设截止时间。登录身份需另行绑定;本次创建不接入账号。人的能力来自在岗岗位。'), !activePositions.length ? h(PMEmpty, { key: 'e' }, '没有可选的在岗岗位,需先配置岗位。') : null]) : h('div', { key: 'bot', style: styles.list }, [
1951
+ h(PMSelect, { key: 'owner', label: '责任人(人)', value: ownerHumanId, onChange: setOwnerHumanId, disabled: busy, options: [['', '请选择'], ...activeHumans.map((p) => [p.id, p.name])] }),
1952
+ h('div', { key: 'config', style: styles.grid }, [
1953
+ h(ModelSelect, { key: 'm', catalog, value: model, onChange: setModel, disabled: busy, labelKeys: { model: '模型' } }),
1954
+ h(PresetSelect, { key: 'p', catalog, value: preset, onChange: setPreset, disabled: busy }),
1955
+ ]),
1956
+ h('strong', { key: 'caphead' }, '工具能力白名单'), h('div', { key: 'caps', style: { display: 'flex', flexWrap: 'wrap', gap: 12 } }, BOT_CAPABILITIES.map(([key, label]) => h('label', { key, style: { display: 'inline-flex', gap: 5, alignItems: 'center' } }, [h('input', { key: 'i', type: 'checkbox', checked: capabilities.includes(key), disabled: busy, onChange: () => toggle(key) }), label]))),
1957
+ h('span', { key: 'limits', style: styles.muted }, '审批决策:只有人能在界面批准。业务 bot 不提供通用 shell 或文件写入;授权限定在所填业务域,不跨域。'),
1958
+ ]),
1959
+ h('div', { key: 'actions', style: styles.row }, [h(PMButton, { key: 's', primary: true, disabled: busy || !valid || !onSubmit, onClick: () => onSubmit({ kind, name: name.trim(), role, domain: domain.trim() || undefined, responsibility: responsibility.trim(), positionId: kind === 'human' ? positionId : undefined, ownerHumanId: kind === 'bot' ? ownerHumanId : undefined, agentPreset: kind === 'bot' ? preset.trim() || undefined : undefined, model: kind === 'bot' ? model.trim() || undefined : undefined, capabilities: kind === 'bot' ? capabilities : undefined, scope: kind === 'bot' ? `domain:${domain.trim()}` : undefined }) }, busy ? '提交中…' : kind === 'bot' ? '创建并发布 v1' : '创建成员'), h(PMButton, { key: 'cancel', disabled: busy, onClick: onCancel }, '取消')]),
1960
+ ]);
1961
+ }
1962
+ function PMMemberEditor({ detail, busy, onCommand }) {
1963
+ const catalog = useHostCatalog();
1964
+ const config = detail.config || detail.current_config || (detail.bot_versions || []).find((v) => v.version === detail.current_version) || {};
1965
+ const prefix = `pm/member-config/${detail.id}/${detail.current_version}/`;
1966
+ const initialMemory = JSON.stringify(config.memory_scope || PMParse(config.memory_scope_json, {}) || {}, null, 2);
1967
+ const [responsibility, setResponsibility] = usePMDraft(prefix + 'responsibility', detail.responsibility || config.responsibility || '');
1968
+ const [model, setModel] = usePMDraft(prefix + 'model', config.model || detail.model || '');
1969
+ const [preset, setPreset] = usePMDraft(prefix + 'preset', config.agent_preset || detail.agent_preset || '');
1970
+ const [memory, setMemory] = usePMDraft(prefix + 'memory', initialMemory);
1971
+ const [changeClass, setChangeClass] = usePMDraft(prefix + 'changeClass', 'minor');
1972
+ const state = usePMCommand(onCommand); const canPublish = PMAllowed(detail, 'publishConfig');
1973
+ // 默认只读:点「编辑」才进入可改状态,避免误改线上配置;取消回到原值。
1974
+ const [editing, setEditing] = React.useState(false);
1975
+ const locked = busy || state.pending || !canPublish || !editing;
1976
+ const parsedMemory = PMParse(memory, null); const validMemory = parsedMemory && typeof parsedMemory === 'object' && !Array.isArray(parsedMemory);
1977
+ const needsMajor = model !== (config.model || detail.model || '') || preset !== (config.agent_preset || detail.agent_preset || '') || JSON.stringify(parsedMemory) !== JSON.stringify(config.memory_scope || PMParse(config.memory_scope_json, {}));
1978
+ const reset = () => {
1979
+ setResponsibility(detail.responsibility || config.responsibility || '');
1980
+ setModel(config.model || detail.model || '');
1981
+ setPreset(config.agent_preset || detail.agent_preset || '');
1982
+ setMemory(initialMemory);
1983
+ setChangeClass('minor');
1984
+ PMClearDraft(prefix);
1985
+ setEditing(false);
1986
+ };
1987
+ const publish = () => state.execute('member/publishConfig', { memberId: detail.id, changeClass, responsibility: responsibility.trim(), model: model.trim() || null, agentPreset: preset.trim() || null, memoryScope: parsedMemory }, () => { PMClearDraft(prefix); setEditing(false); });
1988
+ return h('section', { style: styles.card }, [
1989
+ h('div', { key: 'h', style: styles.row }, [
1990
+ h('strong', { key: 't' }, `配置 · 当前 v${detail.current_version}`),
1991
+ !canPublish
1992
+ ? h('span', { key: 'ro', style: styles.muted }, '只读:当前成员配置需要成员管理权限')
1993
+ : editing
1994
+ ? h('div', { key: 'acts', style: { display: 'flex', gap: 6 } }, [
1995
+ h(PMButton, { key: 'cancel', disabled: busy || state.pending, onClick: reset }, '取消'),
1996
+ h(PMButton, { key: 'publish', primary: true, disabled: busy || state.pending || !onCommand || (needsMajor && changeClass !== 'major') || !validMemory || !responsibility.trim(), onClick: publish }, state.pending ? '发布中…' : '保存并发布新版本'),
1997
+ ])
1998
+ : h(PMButton, { key: 'edit', disabled: busy, onClick: () => setEditing(true) }, '编辑'),
1999
+ ]),
2000
+ h(ErrorBox, { key: 'err', error: state.error, onDismiss: state.dismiss }),
2001
+ h(PMInput, { key: 'resp', label: '职责', value: responsibility, onChange: setResponsibility, multiline: true, disabled: locked }),
2002
+ h('div', { key: 'g', style: styles.grid }, [
2003
+ h(ModelSelect, { key: 'model', catalog, value: model, onChange: setModel, disabled: locked, labelKeys: { model: '模型' } }),
2004
+ h(PresetSelect, { key: 'preset', catalog, value: preset, onChange: setPreset, disabled: locked }),
2005
+ ]),
2006
+ h(PMInput, { key: 'memory', label: '记忆范围(JSON 对象)', value: memory, onChange: setMemory, multiline: true, disabled: locked }),
2007
+ !validMemory && editing ? h('span', { key: 'invalid', style: { color: C.warn } }, '记忆范围必须是有效的 JSON 对象。') : null,
2008
+ h('fieldset', { key: 'class', disabled: locked, style: { border: 'none', borderRadius: 8, background: C.tint, padding: '8px 12px', display: 'flex', gap: 12 } }, [h('legend', { key: 'l', style: styles.muted }, '变更级别'), ...[['minor', '小改'], ['major', '大改']].map(([value, label]) => h('label', { key: value }, [h('input', { key: 'i', type: 'radio', name: `change-${detail.id}`, checked: changeClass === value, disabled: locked, onChange: () => setChangeClass(value) }), label]))]),
2009
+ h('div', { key: 'impact', style: { ...styles.muted, lineHeight: 1.8 } }, changeClass === 'major' ? '大改:相关工作项下次运行前自动换新会话,旧会话只读保留。正在运行的执行继续使用冻结版本。' : '小改:已有会话继续使用原配置,可在工作项详情手动换新会话。正在运行的执行继续使用冻结版本。'),
2010
+ needsMajor && changeClass === 'minor' && editing ? h('span', { key: 'major', style: { color: C.warn } }, '模型、preset 或记忆范围已变化,请选择大改。重写职责、换技能版本也应选择大改。') : null,
2011
+ !editing && canPublish ? h('span', { key: 'hint', style: styles.muted }, '点右上「编辑」修改;保存会发布一个新版本,正在运行的执行继续使用冻结版本。') : null,
2012
+ ]);
2013
+ }
2014
+ function PMMemberDetail({ detail, busy, onCommand, onStatus, onOpenWorkItem, onClose, initialTab }) {
2015
+ const [tab, setTab] = React.useState(initialTab || (detail.kind === 'bot' ? 'chat' : 'config')); const [compare, setCompare] = React.useState(''); const state = usePMCommand(onCommand);
2016
+ const versions = detail.bot_versions || []; const config = detail.config || detail.current_config || versions.find((v) => v.version === detail.current_version) || {};
2017
+ const previous = versions.find((v) => String(v.version) === compare); const latest = versions.find((v) => v.version === detail.current_version) || config;
2018
+ const skills = config.skills || (config.skills_json ? PMParse(config.skills_json, []) : detail.skills) || [];
2019
+ return h('section', { style: styles.page }, [
2020
+ // 名称、标识与操作同一行:状态动作贴右上,不再单独占一行把简介挤下去。
2021
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
2022
+ h('h2', { key: 'n', style: { ...styles.title, margin: 0 } }, detail.name),
2023
+ h('span', { key: 'b', style: { display: 'inline-flex', gap: 6 } }, [badge(detail.kind === 'bot' ? 'bot' : '人', detail.kind === 'bot' ? C.accent : C.ok), badge(({ active: '在岗', paused: '暂停', retired: '已退休' })[detail.status] || detail.status)]),
2024
+ h('div', { key: 'a', style: { marginLeft: 'auto', display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
2025
+ ...['pause', 'resume', 'retire'].filter((a) => PMAllowed(detail, a)).map((a) => h(PMButton, { key: a, disabled: busy || state.pending || (!onCommand && !onStatus), onClick: () => onCommand ? state.execute('member/status', { memberId: detail.id, status: { pause: 'paused', resume: 'active', retire: 'retired' }[a] }) : onStatus(a) }, { pause: '暂停分派', resume: '恢复在岗', retire: '退休' }[a])),
2026
+ onClose ? h(PMButton, { key: 'close', onClick: onClose }, '✕ 关闭') : null,
2027
+ ]),
2028
+ ]),
2029
+ h(ErrorBox, { key: 'err', error: state.error, onDismiss: state.dismiss }),
2030
+ // 指标横排 + 热力图占 1/4 宽,同一个区块里左右对齐;简介挪到区块最下面。
2031
+ h('section', { key: 'pulse', style: { ...styles.card, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(200px,25%)', gap: 12, alignItems: 'center' } }, [
2032
+ h('div', { key: 'stats', style: { display: 'grid', gridTemplateColumns: 'repeat(3,minmax(0,1fr))', gap: 10 } }, [
2033
+ h(Stat, { key: 'o', label: '未结工作', value: detail.work ? detail.work.open : '未知' }),
2034
+ h(Stat, { key: 't', label: '累计工作', value: detail.work ? detail.work.total : '未知' }),
2035
+ h(Stat, { key: 'r', label: '执行记录', value: detail.runs ? detail.runs.total : '未知' }),
2036
+ ]),
2037
+ h(PMHeatmap, { key: 'heatmap', activity: detail.activity, showLegend: false }),
2038
+ h('div', { key: 'intro', style: { gridColumn: '1 / -1', paddingTop: 10, display: 'flex', alignItems: 'baseline', gap: 16, flexWrap: 'wrap', color: C.muted, fontSize: 12, lineHeight: 1.7 } }, [
2039
+ h('span', { key: 't', style: { flex: '1 1 320px', minWidth: 0 } }, [
2040
+ h('span', { key: 'l', style: { fontWeight: 600, marginRight: 8, color: 'inherit' } }, '简介'),
2041
+ `${detail.domain || '未分域'} · ${detail.role || '未分配角色'} · ${detail.responsibility || '未填职责'}`,
2042
+ ]),
2043
+ h('span', { key: 'legend', style: { flex: '0 1 auto', fontSize: 11 } }, PMHeatLegend(detail.activity)),
2044
+ ]),
2045
+ ]),
2046
+ h(PMTabs, { key: 'tabs', tabs: detail.kind === 'bot' ? [['chat', '对话'], ...PM_MEMBER_TABS] : [['config', '基本信息'], ['permissions', '岗位与权限'], ['runs', '工作记录']], value: tab, onChange: setTab, label: '成员页签' }),
2047
+ tab === 'chat' && detail.kind === 'bot' ? h(ChatPane, { key: 'chat', kind: 'member', targetId: detail.id, title: detail.name }) : null,
2048
+ tab === 'config' ? detail.kind === 'bot' ? h(PMMemberEditor, { key: `edit-${detail.id}-${detail.current_version}`, detail, busy, onCommand }) : h('div', { key: 'human', style: styles.list }, [h('span', { key: 'login', style: styles.muted }, detail.login ? `登录身份 ${detail.login.login} · ${detail.login.auth_source}` : '尚未绑定登录身份'), h(PMWorkList, { key: 'work', items: detail.recent_work || [], onOpen: onOpenWorkItem })]) : null,
2049
+ tab === 'skills' ? h('div', { key: 'skills', style: styles.list }, [skills.length ? h(PMJson, { key: 'v', value: skills }) : h(PMEmpty, { key: 'e' }, '当前配置没有绑定技能'), h('span', { key: 'n', style: styles.muted }, '技能版本随配置冻结。')]) : null,
2050
+ tab === 'memory' ? h('div', { key: 'memory', style: styles.list }, [
2051
+ h('section', { key: 'scope', style: styles.card }, [
2052
+ h('strong', { key: 'h' }, '配置里的记忆范围'),
2053
+ h('span', { key: 'd', style: styles.muted }, '随配置版本发布;改它要走大改并换新会话。'),
2054
+ h(PMJson, { key: 'v', value: config.memory_scope || PMParse(config.memory_scope_json, {}) }),
2055
+ ]),
2056
+ h('section', { key: 'synced', style: styles.card }, [
2057
+ h('strong', { key: 'h' }, `从项目同步的能力记忆(${(detail.memories || []).length})`),
2058
+ h('span', { key: 'd', style: styles.muted }, '项目「记忆」里标记为能力、且归属这个 bot 的条目,会镜像到这里并进入它的会话上下文。'),
2059
+ ...(detail.memories || []).map((memory) => h('article', { key: memory.id, style: styles.item }, [
2060
+ h('div', { key: 'r', style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' } }, [
2061
+ badge(PMMemoryKindLabel(memory.kind), PM_MEMORY_KIND_COLOR[memory.kind] || C.muted),
2062
+ h('strong', { key: 't' }, memory.title),
2063
+ memory.project_name ? h('span', { key: 'p', style: styles.muted }, memory.project_name) : null,
2064
+ ]),
2065
+ h('div', { key: 'b', style: { lineHeight: 1.8, whiteSpace: 'pre-wrap' } }, memory.body),
2066
+ h('div', { key: 'm', style: styles.muted }, fmtTime(memory.created_at)),
2067
+ ])),
2068
+ (detail.memories || []).length ? null : h(PMEmpty, { key: 'e' }, '还没有从项目同步能力记忆。'),
2069
+ ]),
2070
+ ]) : null,
2071
+ tab === 'permissions' ? h('div', { key: 'permissions', style: styles.list }, [h('strong', { key: 'h' }, detail.kind === 'bot' ? '工具能力白名单' : '在岗岗位与历史'), ...(detail.position_history || []).map((p, i) => h('div', { key: `${p.position_id}-${i}`, style: styles.item }, [h('strong', { key: 'n' }, p.position_name), h('span', { key: 'dates', style: styles.muted }, `${fmtTime(p.starts_at)} — ${p.ends_at ? fmtTime(p.ends_at) : '无截止'} · ${p.active ? '在岗' : '不在岗'}`)])), h(Chips, { key: 'caps', items: (detail.capabilities || []).map((key) => (BOT_CAPABILITIES.find(([value]) => value === key) || [key, key])[1]), empty: detail.kind === 'human' ? '没有在岗岗位,只能查看' : '尚未授权能力' }), ...(detail.permissions || []).map((p, i) => h('span', { key: `scope-${i}`, style: styles.muted }, `${p.capability} · 授权范围 ${p.scope}`)), detail.kind === 'bot' ? h('span', { key: 'limits', style: styles.muted }, '审批决策:只有人能在界面批准。业务 bot 不提供通用 shell 或文件写入,不跨业务域、不改自己的权限。') : null]) : null,
2072
+ tab === 'versions' ? h('div', { key: 'versions', style: styles.list }, [h(PMSelect, { key: 'select', label: '与当前版本对比', value: compare, onChange: setCompare, options: [['', '选择历史版本'], ...versions.map((v) => [String(v.version), `v${v.version} · ${v.change_class === 'major' ? '大改' : '小改'}`])] }), previous ? h('div', { key: 'compare', style: styles.grid }, [h('div', { key: 'old', style: styles.card }, [h('strong', { key: 't' }, `v${previous.version}`), h(PMJson, { key: 'v', value: previous })]), h('div', { key: 'new', style: styles.card }, [h('strong', { key: 't' }, `当前 v${detail.current_version}`), h(PMJson, { key: 'v', value: latest })])]) : null, ...versions.map((v) => h('details', { key: v.id || v.version, style: styles.item }, [h('summary', { key: 's' }, `v${v.version} · ${v.change_class === 'major' ? '大改' : '小改'} · ${fmtTime(v.published_at || v.created_at)}`), h(PMJson, { key: 'config', value: v })])), !versions.length ? h(PMEmpty, { key: 'e' }, '还没有配置版本') : null]) : null,
2073
+ tab === 'runs' ? h('div', { key: 'runs', style: styles.list }, [h(PMWorkList, { key: 'w', items: detail.recent_work || [], onOpen: onOpenWorkItem }), ...(detail.recent_runs || []).map((run) => h('div', { key: run.id, style: styles.item }, [h('span', { key: 's' }, `执行 ${run.status} · ${fmtTime(run.created_at)}`), h('span', { key: 'v', style: styles.muted }, `配置 ${run.bot_version || run.bot_version_id || '未知'} · 工作项 ${run.work_item_id || '未知'}`)]))]) : null,
2074
+ ]);
2075
+ }
2076
+ function MemberScreen({ members = [], detail, positions = [], busy, formOpen, filter = 'all', onFilter, onCreate, onOpen, onFormOpen, onStatus, onCommand, allowedActions = [], onOpenWorkItem, onClose, initialTab }) {
2077
+ const [query, setQuery] = React.useState(''); const [localFilter, setLocalFilter] = React.useState(filter); const state = usePMCommand(onCommand);
2078
+ const selectedFilter = onFilter ? filter : localFilter; const humans = members.filter((m) => m.kind === 'human'); const canCreate = PMAllowed({ allowedActions }, 'create');
2079
+ const visible = members.filter((m) => (selectedFilter === 'all' || m.kind === selectedFilter) && [m.name, m.domain, m.responsibility].some((v) => String(v || '').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())));
2080
+
2081
+ // 新建成员独立成页:保存或取消都回到列表
2082
+ if (formOpen) {
2083
+ return h(PMPage, { key: 'member-create', backLabel: '← 返回成员列表', onBack: onFormOpen, crumb: '人机成员 / 新建成员' },
2084
+ h(MemberCreateForm, {
2085
+ key: 'form', positions, humans, busy: busy || state.pending || !canCreate,
2086
+ onSubmit: (input) => { if (!canCreate) return; if (onCommand) state.execute('member/create', input, () => PMClearDraft('pm/member-create/')); else if (onCreate) onCreate(input); },
2087
+ onCancel: onFormOpen,
2088
+ }));
2089
+ }
2090
+
2091
+ // 成员详情独立成页:与其他屏交互一致
2092
+ if (detail) {
2093
+ return h(PMPage, { key: `member-${detail.id}`, backLabel: '← 返回成员列表', onBack: onClose, crumb: `${detail.kind === 'bot' ? 'bot' : '人'} / ${detail.name}` },
2094
+ h(PMMemberDetail, { key: detail.id, detail, busy, onCommand, onStatus, onOpenWorkItem, initialTab }));
2095
+ }
2096
+
2097
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } }, [
2098
+ h('div', { key: 'h', style: styles.row }, [h('h1', { key: 't', style: styles.title }, '人机成员'), h('span', { key: 'n', style: styles.muted }, `${members.length} 个成员:人 ${humans.length} · bot ${members.length - humans.length}`), h(PMButton, { key: 'new', primary: true, disabled: busy || !canCreate, onClick: onFormOpen }, '新建成员')]),
2099
+ h(ErrorBox, { key: 'err', error: state.error, onDismiss: state.dismiss }),
2100
+ h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, [
2101
+ h('input', {
2102
+ key: 'q', type: 'search', value: query, 'aria-label': '搜索成员', placeholder: '搜索成员:名称、业务域或职责',
2103
+ onChange: (e) => setQuery(e.target.value),
2104
+ style: { ...styles.input, flex: '1 1 220px', maxWidth: 360, height: 30, padding: '0 10px', boxSizing: 'border-box' },
2105
+ }),
2106
+ ...([['all', '全部'], ['human', '人'], ['bot', 'bot']]).map(([value, text]) => h(PMButton, { key: value, primary: selectedFilter === value, style: { height: 30, padding: '0 12px' }, onClick: () => onFilter ? onFilter(value) : setLocalFilter(value) }, text)),
2107
+ h('span', { key: 'n', style: { marginLeft: 'auto', ...styles.muted } }, `${visible.length} / ${members.length} 个`),
2108
+ ]),
2109
+ visible.length
2110
+ ? h('div', { key: 'list', style: styles.list }, visible.map((m) => {
2111
+ // 一张卡两行、头像纵向跨两行:第一行身份与在岗,第二行当前工作量与配置。
2112
+ const statusText = ({ active: '在岗', paused: '暂停', retired: '退休' })[m.status] || m.status;
2113
+ const active = m.status === 'active';
2114
+ const open = m.work && Number.isFinite(m.work.open) ? m.work.open : null;
2115
+ return h('article', {
2116
+ key: m.id,
2117
+ style: { ...styles.item, display: 'grid', gridTemplateColumns: '44px minmax(0,1fr)', columnGap: 12, rowGap: 3, alignItems: 'start', padding: '12px 14px' },
2118
+ }, [
2119
+ h('div', { key: 'av', style: { gridRow: '1 / span 2', display: 'flex', alignItems: 'center' } }, avatar(m.name, 44, m.kind)),
2120
+ h('div', { key: 'r1', style: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 } }, [
2121
+ h(PMButton, { key: 'n', onClick: () => onOpen(m.id), style: { flex: 'none', padding: 0, border: 0, background: 'transparent', color: 'inherit', fontSize: 13.5, fontWeight: 600 } }, m.name),
2122
+ badge(m.kind === 'bot' ? 'bot' : '人', m.kind === 'bot' ? C.accent : C.ok),
2123
+ h('span', { key: 'd', title: m.responsibility || m.role || '', style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: C.muted, fontSize: 12 } }, m.responsibility || m.role || '未写职责'),
2124
+ h('span', { key: 's', style: { marginLeft: 'auto', flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11.5, color: active ? C.ok : C.muted } }, [
2125
+ h('span', { key: 'dot', 'aria-hidden': 'true', style: { width: 6, height: 6, borderRadius: 3, background: active ? C.ok : C.muted } }),
2126
+ statusText,
2127
+ ]),
2128
+ ]),
2129
+ h('div', { key: 'r2', style: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap', fontSize: 11.5, color: C.muted } }, [
2130
+ h('span', { key: 'w' }, `当前未结 ${open === null ? '未知' : `${open} 项`}`),
2131
+ h('span', { key: 'sep1', 'aria-hidden': 'true' }, '·'),
2132
+ h('span', { key: 'dom' }, m.domain || '未分域'),
2133
+ h('span', { key: 'sep2', 'aria-hidden': 'true' }, '·'),
2134
+ h('span', { key: 'role' }, ({ coordinator: '协调', executor: '执行', observer: '观察' })[m.role] || m.role || '未分配角色'),
2135
+ h('span', { key: 'sep3', 'aria-hidden': 'true' }, '·'),
2136
+ h('span', { key: 'ver' }, `配置 ${m.current_version ? `v${m.current_version}` : '未发布'}`),
2137
+ m.positions && m.positions.length ? h('span', { key: 'pos' }, `· 岗位 ${m.positions.map((p) => p.name).join('、')}`) : null,
2138
+ ]),
2139
+ ]);
2140
+ }))
2141
+ : h(PMEmpty, { key: 'e' }, members.length ? '没有符合条件的成员' : '还没有成员,先新建一个 bot 或人。'),
2142
+ ]);
2143
+ }
2144
+
2145
+ function metricVerdict(metric) {
2146
+ const value = metric.latest && metric.latest.value;
2147
+ if (value === null || value === undefined || !Number.isFinite(value) || metric.target_value === null || metric.target_value === undefined || !metric.calculator_key || !metric.source_kind) return 'unknown';
2148
+ if (metric.direction === 'range') return metric.target_max === null || metric.target_max === undefined ? 'unknown' : value >= metric.target_value && value <= metric.target_max ? 'met' : 'miss';
2149
+ return (metric.direction === 'lower' ? value <= metric.target_value : value >= metric.target_value) ? 'met' : 'miss';
2150
+ }
2151
+
2152
+ function MetricRow({ metric, busy, onRecord }) {
2153
+ const [value, setValue] = React.useState('');
2154
+ const unit = metric.unit || '';
2155
+ const latest = !metric.latest
2156
+ ? '没有读数'
2157
+ : metric.latest.value === null
2158
+ ? '未知(缺数)'
2159
+ : `${metric.latest.value}${unit}`;
2160
+ const target = metric.target_value === null || metric.target_value === undefined
2161
+ ? '未设目标'
2162
+ : `${metric.target_value}${metric.direction === 'range' ? `–${metric.target_max ?? '未知'}` : ''}${unit}`;
2163
+ const result = metricVerdict(metric);
2164
+ const color = result === 'met' ? C.ok : result === 'miss' ? C.warn : C.muted;
2165
+ return h('div', { style: styles.item }, [
2166
+ h('div', { key: 'r', style: styles.row }, [
2167
+ h('span', { key: 'v', style: styles.badge(color) }, `最新 ${latest} · ${{met:'已达标',miss:'未达标',unknown:'未知'}[result]}`),
2168
+ ]),
2169
+ h('div', { key: 'd', style: styles.muted },
2170
+ `目标 ${target} · ${metric.direction === 'lower' ? '越低越好' : metric.direction === 'range' ? '区间' : '越高越好'} · ${metric.window_kind} · 来源 ${metric.source_kind || '未指定'}`),
2171
+ h('div', { key: 'a', style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'flex-end' } }, [
2172
+ h(Field, { key: 'i', label: '本次读数', value, onChange: setValue, type: 'number' }),
2173
+ h('button', {
2174
+ key: 's', style: styles.button(true), disabled: busy || value === '',
2175
+ onClick: () => onRecord(metric.id, Number(value)),
2176
+ }, '记录'),
2177
+ h('button', { key: 'x', style: styles.button(false), disabled: busy, onClick: () => onRecord(metric.id, null) }, '记缺数'),
2178
+ ]),
2179
+ metric.series && metric.series.length > 1
2180
+ ? h('div', { key: 's', style: styles.muted }, `最近 ${metric.series.length} 次:${metric.series.map((point) => point.value === null ? '未知' : point.value).join(' / ')}`)
2181
+ : null,
2182
+ ]);
2183
+ }
2184
+
2185
+ function DocCreateForm({ busy, onSubmit, onCancel }) {
2186
+ const [title, setTitle] = React.useState('');
2187
+ const [body, setBody] = React.useState('');
2188
+ const [projectId, setProjectId] = React.useState('');
2189
+ const [dueAt, setDueAt] = React.useState('');
2190
+ const projects = [];
2191
+ return h('div', { style: styles.card }, [
2192
+ h('strong', { key: 't' }, '新建知识文档'),
2193
+ h('label', { key: 'project', style: styles.list }, [
2194
+ h('span', { key: 'l', style: styles.muted }, '所属项目'),
2195
+ h('select', { key: 's', style: styles.input, value: projectId, onChange: e => setProjectId(e.target.value), 'aria-label': '所属项目' }, [h('option', {key:'none',value:''}, '不关联项目'), ...projects.map(p => h('option', {key:p.id,value:p.id},p.name))]),
2196
+ ]),
2197
+ h(Field, {key:'due',label:'截止时间(可选)',type:'datetime-local',value:dueAt,onChange:setDueAt}),
2198
+ h(Field, { key: 'ti', label: '标题', value: title, onChange: setTitle }),
2199
+ h('label', { key: 'b', style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } }, [
2200
+ h('span', { key: 'l', style: styles.muted }, '正文(Markdown;落成真实文件,改稿留旧版本)'),
2201
+ h('textarea', {
2202
+ key: 't', value: body, style: { ...styles.input, minHeight: 120, resize: 'vertical' },
2203
+ onChange: (e) => setBody(e.target.value),
2204
+ }),
2205
+ ]),
2206
+ h('div', { key: 'a', style: { display: 'flex', gap: 7 } }, [
2207
+ h('button', { key: 's', style: styles.button(true), disabled: busy || !title.trim() || !body.trim(), onClick: () => onSubmit({ title: title.trim(), body }) }, '保存草稿'),
2208
+ h('button', { key: 'c', style: styles.button(false), disabled: busy, onClick: onCancel }, '取消'),
2209
+ ]),
2210
+ ]);
2211
+ }
2212
+
2213
+ function DocEditForm({ detail, busy, onSubmit, onCancel }) {
2214
+ const [title, setTitle] = useDraft(`doc:${detail.id}:title`, detail.title);
2215
+ const [body, setBody] = useDraft(`doc:${detail.id}:body`, detail.content);
2216
+ const [projectId, setProjectId] = React.useState('');
2217
+ const [dueAt, setDueAt] = React.useState('');
2218
+ const projects = [];
2219
+ return h('div', { key: 'ef', style: { ...styles.card } }, [
2220
+ h('strong', { key: 't' }, `编辑文档(基于 v${detail.revision})`),
2221
+ h('label', { key: 'project', style: styles.list }, [
2222
+ h('span', { key: 'l', style: styles.muted }, '所属项目'),
2223
+ h('select', { key: 's', style: styles.input, value: projectId, onChange: e => setProjectId(e.target.value), 'aria-label': '所属项目' }, [h('option', {key:'none',value:''}, '不关联项目'), ...projects.map(p => h('option', {key:p.id,value:p.id},p.name))]),
2224
+ ]),
2225
+ h(Field, {key:'due',label:'截止时间(可选)',type:'datetime-local',value:dueAt,onChange:setDueAt}),
2226
+ h(Field, { key: 'ti', label: '标题', value: title, onChange: setTitle }),
2227
+ h('textarea', {
2228
+ key: 'b', value: body, style: { ...styles.input, minHeight: 150, resize: 'vertical' },
2229
+ onChange: (event) => setBody(event.target.value),
2230
+ }),
2231
+ h('div', { key: 'a', style: { display: 'flex', gap: 7 } }, [
2232
+ h('button', {
2233
+ key: 's', style: styles.button(true), disabled: busy || !body.trim(),
2234
+ onClick: () => onSubmit({ docId: detail.id, title: title.trim(), body, expectedRevision: detail.revision }),
2235
+ }, busy ? '提交中…' : '保存新版本'),
2236
+ h('button', { key: 'c', style: styles.button(false), disabled: busy, onClick: onCancel }, '取消'),
2237
+ ]),
2238
+ h('div', { key: 'h', style: styles.muted }, '保存前会把当前版本复制为 .v<修订号>.md;期望修订号不匹配时后端拒绝,不覆盖他人修改。'),
2239
+ ]);
2240
+ }
2241
+
2242
+ function ImportForm({ busy, onSubmit, onCancel }) {
2243
+ const [dir, setDir] = React.useState('');
2244
+ const [recursive, setRecursive] = React.useState(true);
2245
+ return h('div', { style: styles.card }, [
2246
+ h('strong', { key: 't' }, '导入本机 Markdown'),
2247
+ h(Field, { key: 'd', label: '目录(绝对路径或 ~/...)', value: dir, onChange: setDir, placeholder: '~/notes' }),
2248
+ h('label', { key: 'r', style: { display: 'flex', gap: 6, alignItems: 'center', fontSize: 12 } }, [
2249
+ h('input', { key: 'i', type: 'checkbox', checked: recursive, onChange: (e) => setRecursive(e.target.checked) }),
2250
+ h('span', { key: 't' }, '递归子目录(最多 4 层,单文件 ≤ 2MB,最多 500 个)'),
2251
+ ]),
2252
+ h('div', { key: 'a', style: { display: 'flex', gap: 7 } }, [
2253
+ h('button', { key: 's', style: styles.button(true), disabled: busy || !dir.trim(), onClick: () => onSubmit({ dir: dir.trim(), recursive }) }, busy ? '扫描中…' : '开始导入'),
2254
+ h('button', { key: 'c', style: styles.button(false), disabled: busy, onClick: onCancel }, '取消'),
2255
+ ]),
2256
+ h('div', { key: 'h', style: styles.muted }, '只登记路径与内容哈希,不复制文件;源文件改动后重新导入会升版。跳过隐藏目录、node_modules、_build、.git。'),
2257
+ ]);
2258
+ }
2259
+
2260
+ // 极简 Markdown 渲染:覆盖文档预览常用语法。客户端只能 require('react'),不引第三方 md 库。
2261
+ function mdInline(text, keyPrefix) {
2262
+ const src = String(text == null ? '' : text); const out = []; let last = 0; let n = 0;
2263
+ const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]*\]\([^)\s]+\)|\*[^*\n]+\*)/g;
2264
+ let match;
2265
+ while ((match = re.exec(src))) {
2266
+ if (match.index > last) out.push(src.slice(last, match.index));
2267
+ const token = match[0]; const key = `${keyPrefix}-${n++}`;
2268
+ if (token.startsWith('**')) out.push(h('strong', { key }, token.slice(2, -2)));
2269
+ else if (token.startsWith('`')) out.push(h('code', { key, style: { background: C.soft, padding: '0 4px', borderRadius: 3, fontSize: 11.5, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace' } }, token.slice(1, -1)));
2270
+ else if (token.startsWith('[')) {
2271
+ const link = /^\[([^\]]*)\]\(([^)\s]+)\)$/.exec(token);
2272
+ out.push(link ? h('a', { key, href: link[2], target: '_blank', rel: 'noreferrer' }, link[1]) : token);
2273
+ } else out.push(h('em', { key }, token.slice(1, -1)));
2274
+ last = match.index + token.length;
2275
+ }
2276
+ if (last < src.length) out.push(src.slice(last));
2277
+ return out.length ? out : [''];
2278
+ }
2279
+
2280
+ function renderMarkdown(text, keyPrefix) {
2281
+ const lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
2282
+ const prefix = keyPrefix || 'md'; const blocks = []; let i = 0; let n = 0;
2283
+ const nextKey = () => `${prefix}-b${n++}`;
2284
+ const cell = (row) => row.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((part) => part.trim());
2285
+ while (i < lines.length) {
2286
+ const line = lines[i];
2287
+ if (!line.trim()) { i++; continue; }
2288
+ if (/^\s*```/.test(line)) {
2289
+ const body = []; i++;
2290
+ while (i < lines.length && !/^\s*```/.test(lines[i])) { body.push(lines[i]); i++; }
2291
+ i++;
2292
+ blocks.push(h('pre', { key: nextKey(), style: { margin: '8px 0', padding: '10px 12px', background: C.soft, borderRadius: 6, overflowX: 'auto', fontSize: 11.5, lineHeight: 1.6 } }, h('code', null, body.join('\n'))));
2293
+ continue;
2294
+ }
2295
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
2296
+ if (heading) {
2297
+ const level = heading[1].length; const size = [19, 17, 15, 14, 13, 12][level - 1];
2298
+ blocks.push(h('div', { key: nextKey(), style: { fontSize: size, fontWeight: 600, margin: '14px 0 6px', lineHeight: 1.4 } }, mdInline(heading[2], 'h')));
2299
+ i++; continue;
2300
+ }
2301
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
2302
+ blocks.push(h('div', { key: nextKey(), style: { height: 12 } }));
2303
+ i++; continue;
2304
+ }
2305
+ if (/^>\s?/.test(line)) {
2306
+ const quote = [];
2307
+ while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, '')); i++; }
2308
+ blocks.push(h('div', { key: nextKey(), style: { padding: '6px 10px', border: 'none', borderRadius: 6, background: C.tint, color: C.muted, margin: '8px 0', lineHeight: 1.7 } }, mdInline(quote.join(' '), 'q')));
2309
+ continue;
2310
+ }
2311
+ if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*$/.test(lines[i + 1])) {
2312
+ const head = cell(line); i += 2; const rows = [];
2313
+ while (i < lines.length && lines[i].trim() && /\|/.test(lines[i])) { rows.push(cell(lines[i])); i++; }
2314
+ blocks.push(h('table', { key: nextKey(), style: { borderCollapse: 'collapse', fontSize: 12, margin: '8px 0' } }, [
2315
+ h('thead', { key: 'h' }, h('tr', { key: 'r' }, head.map((part, ci) => h('th', { key: ci, style: { textAlign: 'left', padding: '6px 10px', border: 'none', background: C.tintStrong, fontWeight: 600 } }, mdInline(part, `th${ci}`))))),
2316
+ h('tbody', { key: 'b' }, rows.map((row, ri) => h('tr', { key: ri }, row.map((part, ci) => h('td', { key: ci, style: { padding: '6px 10px', border: 'none', background: ri % 2 ? C.tint : 'transparent' } }, mdInline(part, `td${ri}-${ci}`)))))),
2317
+ ]));
2318
+ continue;
2319
+ }
2320
+ if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
2321
+ const ordered = /^\s*\d+\./.test(line); const items = [];
2322
+ while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, '')); i++; }
2323
+ blocks.push(h(ordered ? 'ol' : 'ul', { key: nextKey(), style: { margin: '6px 0', paddingLeft: 22, lineHeight: 1.8 } }, items.map((item, ii) => h('li', { key: ii }, mdInline(item, `li${ii}`)))));
2324
+ continue;
2325
+ }
2326
+ const para = [];
2327
+ while (i < lines.length && lines[i].trim()
2328
+ && !/^\s*(#{1,6}\s|```|>|[-*+]\s|\d+\.\s||---+|\*\*\*+|___+)\s*$/.test(lines[i])
2329
+ && !/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i])) { para.push(lines[i]); i++; }
2330
+ if (para.length) blocks.push(h('p', { key: nextKey(), style: { margin: '6px 0', lineHeight: 1.75 } }, mdInline(para.join('\n'), 'p')));
2331
+ else { blocks.push(h('p', { key: nextKey(), style: { margin: '6px 0', lineHeight: 1.75 } }, lines[i++])); }
2332
+ }
2333
+ return blocks.length ? blocks : [h('span', { key: `${prefix}-empty`, style: styles.muted }, '(空文档)')];
2334
+ }
2335
+
2336
+ function KnowledgeScreen({ data, detail, busy, form, report, importReport, onForm, onOpen, onSync, onCreateDoc, onImport, onPublish, onEdit, editing, onCancelEdit, capabilities }) {
2337
+ const writable = !capabilities || capabilities.includes('knowledge.write');
2338
+ const syncable = !capabilities || capabilities.includes('knowledge.sync');
2339
+ const [kind, setKind] = React.useState('all');
2340
+ const [q, setQ] = React.useState('');
2341
+ // 放大:只隐藏左侧文件列表,预览占满整行;再点「还原」回来。
2342
+ const [expanded, setExpanded] = React.useState(false);
2343
+ const items = (data && data.items) || [];
2344
+ const visible = items.filter((item) =>
2345
+ (kind === 'all' || item.kind === kind)
2346
+ && (!q.trim() || `${item.name || ''} ${item.title || ''} ${item.subtitle || ''}`.toLowerCase().indexOf(q.trim().toLowerCase()) >= 0));
2347
+ const detailCard = detail
2348
+ ? (function () {
2349
+ const head = [
2350
+ h('div', { key: 'h', style: styles.row }, [
2351
+ h('strong', { key: 't' }, detail.kind === 'doc' ? detail.title : detail.name),
2352
+ h('div', { key: 'acts', style: { display: 'flex', alignItems: 'center', gap: 8, flex: 'none' } }, [
2353
+ badge(KNOWLEDGE_KIND_LABEL[detail.kind] || detail.kind, C.accent),
2354
+ h('button', {
2355
+ key: 'zoom', type: 'button', 'aria-pressed': expanded,
2356
+ title: expanded ? '还原列表与预览两栏' : '放大预览(隐藏左侧文件列表)',
2357
+ onClick: () => setExpanded((value) => !value),
2358
+ style: { ...styles.button(false), height: 24, padding: '0 8px', fontSize: 11, flex: 'none' },
2359
+ }, expanded ? '还原' : '放大'),
2360
+ ]),
2361
+ ]),
2362
+ // 文档的 subtitle 往往就是源文件路径:和下面那行重复时只留一次。
2363
+ detail.subtitle && detail.subtitle !== detail.path ? h('div', { key: 'm', style: styles.muted }, detail.subtitle) : null,
2364
+ ];
2365
+ // 卡片占满整栏高度:头部固定,正文区自己滚动;操作按钮跟着正文末尾,不会被顶到卡片最底下。
2366
+ const cardStyle = { ...styles.card, minWidth: 0, minHeight: 0, height: '100%', overflow: 'hidden' };
2367
+ const body = (children) => h('div', { key: 'body', style: { flex: '1 1 auto', minHeight: 0, overflow: 'auto', paddingRight: 4, display: 'flex', flexDirection: 'column', gap: 8 } }, children);
2368
+ if (detail.kind === 'doc') {
2369
+ return h('div', { key: 'd', style: cardStyle }, [
2370
+ ...head,
2371
+ detail.changed ? h('div', { key: 'ch', style: styles.err }, '磁盘上的源文件已改动,与登记哈希不一致;导入的文档请重新导入。') : null,
2372
+ h('div', { key: 's', style: styles.muted },
2373
+ `${KNOWLEDGE_STATUS_LABEL[detail.status] || detail.status} · v${detail.revision} · ${detail.bytes} 字节 · 作者 ${detail.owner_name || detail.owner_id} · ${fmtTime(detail.updated_at)}`),
2374
+ h('div', { key: 'p', style: { ...styles.muted, wordBreak: 'break-all' } }, detail.path),
2375
+ body([
2376
+ editing && detail.allowedActions.indexOf('edit') >= 0
2377
+ ? h(DocEditForm, { key: 'ef', detail, busy, onSubmit: onEdit, onCancel: onCancelEdit })
2378
+ : h('div', { key: 'md', style: { fontSize: 12.5 } }, renderMarkdown(detail.content, `doc-${detail.id}`)),
2379
+ detail.truncated
2380
+ ? h('div', { key: 'tr', style: styles.muted }, '正文超过 16000 字的预览上限:面板内编辑会截断,请用外部编辑器改源文件。')
2381
+ : null,
2382
+ h('div', { key: 'a', style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
2383
+ (detail.allowedActions || []).filter((action) => action !== 'edit' || !detail.truncated).map((action) => h('button', {
2384
+ key: action, style: styles.button(action === 'publish'), disabled: busy || !writable,
2385
+ onClick: () => action === 'publish' ? onPublish(detail.id) : onForm('edit'),
2386
+ }, action === 'publish' ? '发布' : '编辑正文'))),
2387
+ ]),
2388
+ ]);
2389
+ }
2390
+ if (detail.kind === 'skill') {
2391
+ return h('div', { key: 'd', style: cardStyle }, [
2392
+ ...head,
2393
+ h('div', { key: 's', style: styles.muted },
2394
+ `版本 ${detail.version || '—'} · 哈希 ${(detail.content_hash || '').slice(0, 12)} · 已发布 ${fmtTime(detail.published_at)} · 缺失源文件 ${detail.present ? '否' : '是'}`),
2395
+ h('div', { key: 'p', style: { ...styles.muted, wordBreak: 'break-all' } }, detail.path || '(无路径)'),
2396
+ detail.description ? h('div', { key: 'desc' }, detail.description) : null,
2397
+ detail.when_to_use ? h('div', { key: 'wu', style: styles.muted }, `何时使用:${detail.when_to_use}`) : null,
2398
+ body([
2399
+ detail.content
2400
+ ? h('div', { key: 'md', style: { fontSize: 12.5 } }, renderMarkdown(detail.content, `skill-${detail.id}`))
2401
+ : h('div', { key: 'noc', style: styles.muted }, '这个技能没有可预览的正文:源文件可能已从磁盘移除。点下面的「重新同步技能」再试。'),
2402
+ h('div', { key: 'v', style: styles.muted },
2403
+ `历史版本:${(detail.versions || []).map((version) => `${version.version}(${version.status})`).join(' · ')}`),
2404
+ h('div', { key: 'a', style: { display: 'flex', gap: 6 } },
2405
+ (detail.allowedActions || []).map((action) => h('button', {
2406
+ key: action, style: styles.button(true), disabled: busy || !syncable, onClick: onSync,
2407
+ }, '重新同步技能'))),
2408
+ ]),
2409
+ ]);
2410
+ }
2411
+ return h('div', { key: 'd', style: cardStyle }, [
2412
+ ...head,
2413
+ h('div', { key: 's', style: styles.muted },
2414
+ `实时行数 ${detail.row_count === null ? '不可读' : detail.row_count} · 列 ${detail.columns_detail.length} · ${detail.live ? '本机库实时读取' : '外部表只登记结构'}`),
2415
+ body([
2416
+ h('div', { key: 'tw', style: { overflowX: 'auto' } }, h('table', { key: 't', style: { borderCollapse: 'collapse', fontSize: 12 } }, [
2417
+ h('thead', { key: 'h' }, h('tr', { key: 'r' }, [
2418
+ h('th', { key: 'n', style: { textAlign: 'left', padding: '4px 8px', border: 'none', background: C.tintStrong } }, '列'),
2419
+ h('th', { key: 't', style: { textAlign: 'left', padding: '4px 8px', border: 'none', background: C.tintStrong } }, '类型'),
2420
+ h('th', { key: 'p', style: { textAlign: 'left', padding: '4px 8px', border: 'none', background: C.tintStrong } }, '约束'),
2421
+ ])),
2422
+ h('tbody', { key: 'b' }, detail.columns_detail.map((column) =>
2423
+ h('tr', { key: column.name }, [
2424
+ h('td', { key: 'n', style: { padding: '3px 8px' } }, column.name),
2425
+ h('td', { key: 't', style: { padding: '3px 8px' } }, column.type),
2426
+ h('td', { key: 'c', style: { padding: '3px 8px' } }, [column.pk ? '主键 ' : '', column.notnull ? '非空' : ''].join('')),
2427
+ ]))),
2428
+ ])),
2429
+ h('div', { key: 'note', style: styles.muted }, '只登记列结构与实时行数,不展示行内容;数据仍留在原表。'),
2430
+ ]),
2431
+ ]);
2432
+ })()
2433
+ : h('div', { key: 'e', style: { ...styles.card, minWidth: 0, height: '100%', justifyContent: 'center' } }, '左侧选一项看详情');
2434
+
2435
+ // 新建文档 / 导入目录也独立成页:保存或取消都回到知识列表
2436
+ if (form === 'doc') {
2437
+ return h(PMPage, { key: 'knowledge-create', backLabel: '← 返回知识库', onBack: () => onForm(null), crumb: '知识 / 新建文档' },
2438
+ h(DocCreateForm, { key: 'dc', busy, onSubmit: onCreateDoc, onCancel: () => onForm(null) }));
2439
+ }
2440
+ if (form === 'import') {
2441
+ return h(PMPage, { key: 'knowledge-import', backLabel: '← 返回知识库', onBack: () => onForm(null), crumb: '知识 / 导入目录' },
2442
+ h(ImportForm, { key: 'im', busy, onSubmit: onImport, onCancel: () => onForm(null) }));
2443
+ }
2444
+
2445
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } }, [
2446
+ h('div', { key: 'title', style: styles.row }, [
2447
+ h('strong', { key: 't', style: { fontSize: 15 } }, '知识资产'),
2448
+ ]),
2449
+ h('div', { key: 'bar', style: { ...styles.row, justifyContent: 'flex-start' } }, [
2450
+ h('button', { key: 'sync', style: styles.button(true), disabled: busy || !syncable, onClick: onSync }, busy ? '同步中…' : '同步真实数据'),
2451
+ h('button', { key: 'new', style: styles.button(false), disabled: busy || !writable, onClick: () => onForm('doc') }, '新建文档'),
2452
+ h('button', { key: 'imp', style: styles.button(false), disabled: busy || !writable, onClick: () => onForm('import') }, '导入目录'),
2453
+ h('span', { key: 'c', style: styles.muted },
2454
+ data ? `文档 ${data.counts.docs} · 技能 ${data.counts.skills} · 数据表 ${data.counts.tables}` : '加载中'),
2455
+ ]),
2456
+ data && data.error
2457
+ ? h('div', { key: 'err', style: styles.err }, `知识数据源不可用:${data.error.message || data.error.code}。${data.error.nextStep || ''}`)
2458
+ : null,
2459
+ report
2460
+ ? h('div', { key: 'rep', style: { fontSize: 12, color: C.ok } },
2461
+ `同步结果:技能 新增 ${report.skills.created} / 更新 ${report.skills.updated} / 未变 ${report.skills.unchanged} / 缺失 ${report.skills.missing};数据表 新增 ${report.tables.created} / 更新 ${report.tables.updated}`)
2462
+ : null,
2463
+ importReport
2464
+ ? h('div', { key: 'irep', style: { fontSize: 12, color: C.ok } },
2465
+ `导入结果:扫描 ${importReport.scanned} / 新增 ${importReport.imported} / 更新 ${importReport.updated} / 跳过 ${importReport.skipped} / 错误 ${importReport.errors.length}`)
2466
+ : null,
2467
+ h('div', { key: 'filter', style: { ...styles.row, justifyContent: 'flex-start', flexWrap: 'wrap', gap: 8 } }, [
2468
+ h('input', {
2469
+ key: 'q', style: { ...styles.input, flex: '1 1 200px', minWidth: 160 }, value: q, placeholder: '搜索标题 / 说明',
2470
+ onChange: (e) => setQ(e.target.value),
2471
+ }),
2472
+ ...['all', 'doc', 'skill', 'table'].map((key) => h('button', {
2473
+ key, style: styles.tab(kind === key), onClick: () => setKind(key),
2474
+ }, { all: '全部', doc: '文档', skill: '技能', table: '数据表' }[key])),
2475
+ ]),
2476
+ visible.length
2477
+ /**
2478
+ * 两栏固定:左列表、右详情。原来用 flex + wrap,文档里宽表格/长路径一撑就把详情挤到下一行(跑到列表底下),
2479
+ * 所以改成 grid,两栏都 minWidth:0,宽度只受分栏约束、不受内容最小宽度影响。
2480
+ */
2481
+ ? h('div', { key: 'l', style: { display: 'grid', gridTemplateColumns: expanded ? 'minmax(0,1fr)' : 'minmax(320px,1fr) minmax(0,1.45fr)', gap: 14, alignItems: 'stretch', height: 'calc(100vh - 230px)', minHeight: 320 } }, [
2482
+ expanded ? null : h('div', { key: 'col', style: { minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 4 } }, [
2483
+ h('div', { key: 'hdr', style: { display: 'flex', alignItems: 'baseline', gap: 10, padding: '0 11px 2px', color: C.muted, fontSize: 11 } }, [
2484
+ h('span', { key: 'n', style: { flex: '1 1 auto', minWidth: 0 } }, '文件名'),
2485
+ h('span', { key: 'v', style: { flex: 'none', width: 96, textAlign: 'right' } }, '版本'),
2486
+ h('span', { key: 'u', style: { flex: 'none', width: 118, textAlign: 'right' } }, '更新时间'),
2487
+ ]),
2488
+ h('ul', { key: 'ul', style: { ...styles.list, flex: '1 1 auto', minHeight: 0, overflowY: 'auto', overscrollBehavior: 'contain', paddingRight: 4 } }, visible.map((item) => {
2489
+ const name = item.kind === 'doc' ? item.title : item.name;
2490
+ const version = item.kind === 'doc'
2491
+ ? `v${item.revision}`
2492
+ : item.kind === 'skill'
2493
+ ? (item.version || '—')
2494
+ : `列 ${item.columns} · 行 ${item.row_count === null ? '不可读' : item.row_count}`;
2495
+ const updated = fmtTime(item.updated_at || item.published_at);
2496
+ const active = detail && detail.id === item.id && detail.kind === item.kind;
2497
+ return h('li', { key: `${item.kind}-${item.id}`, onClick: () => onOpen(item.kind, item.id), style: { ...styles.item, background: active ? C.tintStrong : 'transparent', padding: '8px 10px', cursor: 'pointer' } }, [
2498
+ h('div', { key: 'r', style: { display: 'flex', alignItems: 'baseline', gap: 10, minWidth: 0 } }, [
2499
+ h('span', {
2500
+ key: 't',
2501
+ title: name,
2502
+ style: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5 },
2503
+ }, name),
2504
+ h('span', { key: 'v', style: { flex: 'none', width: 96, textAlign: 'right', color: C.muted, fontSize: 11.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, version),
2505
+ h('span', { key: 'u', style: { flex: 'none', width: 118, textAlign: 'right', color: C.muted, fontSize: 11.5, whiteSpace: 'nowrap' } }, updated),
2506
+ ]),
2507
+ ]);
2508
+ })),
2509
+ ]),
2510
+ detailCard,
2511
+ ])
2512
+ : null,
2513
+ ]);
2514
+ }
2515
+
2516
+ function initialRoute() {
2517
+ try { const q=new URLSearchParams(window.location.search);return {module:NAV.some(([key])=>key===q.get('hamsView'))?q.get('hamsView'):'overview',item:q.get('hamsItem'),project:q.get('hamsProject')}; } catch { return {module:'overview'}; }
2518
+ }
2519
+ function HamsPanel() {
2520
+ const [module,setModuleState]=React.useState(()=>initialRoute().module);
2521
+ const [overview,setOverview]=React.useState(null);const [items,setItems]=React.useState([]);const [selected,setSelected]=React.useState(null);
2522
+ const [error,setError]=React.useState(null);const [busy,setBusy]=React.useState(false);const [loading,setLoading]=React.useState(true);const [lastRefresh,setLastRefresh]=React.useState(null);
2523
+ const [createOpen,setCreateOpen]=React.useState(false);const [evidenceForm,setEvidenceForm]=React.useState(null);const [actionForm,setActionForm]=React.useState(null);
2524
+ const [whoami,setWhoami]=React.useState(null);const [templates,setTemplates]=React.useState([]);const [checks,setChecks]=React.useState([]);
2525
+ const [projects,setProjects]=React.useState([]);const [projectSel,setProjectSel]=React.useState(null);const [projectForm,setProjectForm]=React.useState(false);const [projectDraft,setProjectDraft]=React.useState(null);const [taskProject,setTaskProject]=React.useState('');
2526
+ const [members,setMembers]=React.useState([]);const [memberSel,setMemberSel]=React.useState(null);const [positions,setPositions]=React.useState([]);const [memberForm,setMemberForm]=React.useState(false);const [memberFilter,setMemberFilter]=React.useState('all');
2527
+ const [knowledge,setKnowledge]=React.useState(null);const [knowledgeSel,setKnowledgeSel]=React.useState(null);const [knowledgeForm,setKnowledgeForm]=React.useState(null);const [knowledgeReport,setKnowledgeReport]=React.useState(null);const [importReport,setImportReport]=React.useState(null);const [editingDoc,setEditingDoc]=React.useState(false);
2528
+ const [approval,setApproval]=React.useState(null);const [approvalItem,setApprovalItem]=React.useState(null);
2529
+ const [health,setHealth]=React.useState(null);const [search,setSearch]=React.useState('');
2530
+ const live=React.useRef({item:null,project:null,member:null,knowledge:null,approval:null,view:'overview'});
2531
+ const readSeq=React.useRef({item:0,project:0,member:0,knowledge:0,approval:0,refresh:0});const mounted=React.useRef(true);const locked=React.useRef(false);
2532
+ const caps=(whoami && whoami.capabilities) || [];const me=whoami && whoami.principal && whoami.principal.memberId;
2533
+ const readOnly=!!(health && health.readOnly);
2534
+ const can=cap=>caps.includes(cap);
2535
+ const setModule=key=>{live.current.view=key;setModuleState(key);try{const url=new URL(window.location.href);url.searchParams.set('hamsView',key);if(key!=='tasks')url.searchParams.delete('hamsItem');if(key!=='projects')url.searchParams.delete('hamsProject');window.history.replaceState(null,'',url);}catch{}};
2536
+ const setDeepLink=(key,id)=>{try{const url=new URL(window.location.href);url.searchParams.set(key,id);window.history.replaceState(null,'',url);}catch{}};
2537
+ const refresh=async()=>{
2538
+ const epoch=++readSeq.current.refresh;
2539
+ const commands=['session/whoami','overview/get','workItem/list','template/list','project/list','member/list','position/list','knowledge/list','ops/health'];
2540
+ const results=await Promise.allSettled(commands.map(command=>call(command,{payload:command==='workItem/list'?{limit:200}:{}})));
2541
+ if(!mounted.current || epoch!==readSeq.current.refresh)return;
2542
+ const setters=[setWhoami,setOverview,setItems,setTemplates,setProjects,setMembers,setPositions,setKnowledge,setHealth];
2543
+ let failed=null;results.forEach((result,index)=>{if(result.status==='fulfilled')setters[index](result.value);else if(index===7)setKnowledge({items:[],counts:{docs:0,skills:0,tables:0},sources:{},error:result.reason});else failed=failed || result.reason;});
2544
+ if(failed)setError(failed);else{setLastRefresh(Date.now());setError(current=>current && ['VERSION_CONFLICT','EVIDENCE_CHANGED','EPOCH_STALE'].includes(current.code)?current:null);}
2545
+ const active=live.current;
2546
+ if(active.item && active.view==='tasks')loadDetail('item','workItem/get',active.item,setSelected);
2547
+ if(active.project && active.view==='projects')loadDetail('project','project/get',active.project,setProjectSel);
2548
+ if(active.member && active.view==='agents')loadDetail('member','member/get',active.member,setMemberSel);
2549
+ if(active.approval && active.approval.subject_kind==='work_item_close')loadDetail('approval','workItem/get',active.approval.subject_id,setApprovalItem);
2550
+ setLoading(false);
2551
+ };
2552
+ const loadDetail=async(kind,command,id,setter,payload)=>{
2553
+ if(['item','project','member'].includes(kind) && live.current[kind]!==id)return null;
2554
+ const epoch=++readSeq.current[kind];
2555
+ try{const value=await call(command,{objectId:id,payload});if(mounted.current && epoch===readSeq.current[kind]){
2556
+ if(['item','project','member'].includes(kind) && live.current[kind]!==id)return null;
2557
+ if(kind==='approval'){
2558
+ if(!live.current.approval || live.current.approval.subject_id!==id)return null;
2559
+ const fresh=(value.approvals || []).find(ap=>ap.id===live.current.approval.id);
2560
+ if(fresh){live.current.approval=fresh;setApproval(fresh);}
2561
+ }
2562
+ setter(value);
2563
+ }return value;}
2564
+ catch(err){if(mounted.current && epoch===readSeq.current[kind])setError(err);return null;}
2565
+ };
2566
+ const openTask=id=>{live.current.item=id;setModule('tasks');setDeepLink('hamsItem',id);setSelected(null);return loadDetail('item','workItem/get',id,setSelected);};
2567
+ const openProject=id=>{live.current.project=id;setModule('projects');setDeepLink('hamsProject',id);setProjectDraft(null);setProjectSel(null);return loadDetail('project','project/get',id,setProjectSel);};
2568
+ const openMember=id=>{live.current.member=id;setModule('agents');setMemberSel(null);return loadDetail('member','member/get',id,setMemberSel);};
2569
+ const openKnowledge=(kind,id)=>{setEditingDoc(false);setKnowledgeSel(null);return loadDetail('knowledge','knowledge/get',null,setKnowledgeSel,{kind,id});};
2570
+ React.useEffect(()=>{
2571
+ mounted.current=true;refresh();const route=initialRoute();if(route.module==='tasks' && route.item)openTask(route.item);else if(route.module==='projects' && route.project)openProject(route.project);
2572
+ const timer=setInterval(()=>{if(!locked.current)refresh();},15000);
2573
+ return ()=>{mounted.current=false;clearInterval(timer);};
2574
+ },[]);
2575
+ React.useEffect(()=>{let alive=true;if(module==='tasks')Promise.all(items.map(item=>call('check/list',{payload:{workItemId:item.id}}))).then(rows=>{if(alive)setChecks(rows.flat());}).catch(err=>{if(alive)setError(err);});return()=>{alive=false;};},[module,items]);
2576
+ const run=async(fn,propagate=false)=>{
2577
+ if(locked.current)return;locked.current=true;setBusy(true);setError(null);
2578
+ try{const value=await fn();await refresh();return value===undefined?true:value;}catch(err){setError(err);if(propagate)throw err;return false;}finally{locked.current=false;setBusy(false);}
2579
+ };
2580
+ const onAct=(action,item,extra)=>{
2581
+ if(action==='decideApproval'){setModule('overview');return;}
2582
+ const spec=ACTIONS[action];if(!spec)return;
2583
+ if(spec.form && !extra){setEvidenceForm({workItemId:item.id,source:'panel'});return;}
2584
+ if(spec.reason && !extra){setActionForm({action,workItemId:item.id});return;}
2585
+ return run(async()=>{await call(spec.command,{objectId:item.id,expectedRevision:item.revision,payload:extra || {}});setActionForm(null);setEvidenceForm(null);clearDraft(`action:${item.id}:`);clearDraft(`evidence:${item.id}:`);await loadDetail('item','workItem/get',item.id,setSelected);});
2586
+ };
2587
+ const onCreate=input=>run(async()=>{const receipt=await call('workItem/create',{payload:input});setCreateOpen(false);clearDraft('task-create:');if(receipt.objectId)await openTask(receipt.objectId);});
2588
+ const onCheck=id=>run(async()=>{await call('check/evaluate',{objectId:id});if(selected)await loadDetail('item','workItem/get',selected.id,setSelected);});
2589
+ const onOpenApproval=async ap=>{live.current.approval=ap;setApproval(ap);setApprovalItem(null);if(ap.subject_kind!=='work_item_close')return;await loadDetail('approval','workItem/get',ap.subject_id,setApprovalItem);};
2590
+ const onApprove=(ap,decision,note)=>run(async()=>{await call('approval/decide',{objectId:ap.id,payload:{decision,note:note || ''}});live.current.approval=null;setApproval(null);clearDraft(`approval:${ap.id}`);if(selected)await loadDetail('item','workItem/get',selected.id,setSelected);});
2591
+ const onProposal=(proposal,decision,note,templateVersionId)=>run(async()=>{
2592
+ const body=safeJSON(proposal.body);const projectId=String(proposal.subject_key || '').startsWith('project:')?proposal.subject_key.slice(8):undefined;
2593
+ await call('proposal/decide',{objectId:proposal.id,payload:{decision,note,workItem:decision==='accept' && proposal.kind==='create_task'?{domain:body.domain || '研究',category:body.category || '研究推进',title:proposal.title,ownerHumanId:me,projectId,templateVersionId}:undefined}});
2594
+ });
2595
+ const projectCommand=(command,payload)=>{
2596
+ if(command==='project/tryMetric')return call(command,{objectId:payload.projectId,payload});
2597
+ // 目标验证的审批:objectId 必须是审批 id(不能拿项目 id 顶替),处理完刷新项目详情。
2598
+ if(command==='approval/decide')return run(async()=>{
2599
+ await call('approval/decide',{objectId:payload.approvalId,payload:{decision:payload.decision,note:payload.note || ''}});
2600
+ if(projectSel)await loadDetail('project','project/get',projectSel.id,setProjectSel);
2601
+ },true);
2602
+ return run(async()=>{
2603
+ if(command==='project/create'){
2604
+ const receipt=await call(command,{payload});setProjectForm(false);clearDraft('pm/project-create/');if(receipt.objectId)await openProject(receipt.objectId);return receipt;
2605
+ }
2606
+ if(command==='project/delete'){
2607
+ const projectId=payload.projectId || (projectSel && projectSel.id);
2608
+ const receipt=await call(command,{objectId:projectId,payload:{...payload,projectId}});
2609
+ setProjectForm(false);setProjectDraft(null);setProjectSel(null);live.current.project=null;
2610
+ try{const url=new URL(window.location.href);url.searchParams.delete('hamsProject');window.history.replaceState(null,'',url);}catch{}
2611
+ return receipt;
2612
+ }
2613
+ if(!projectSel)return;
2614
+ const receipt=await call(command,{objectId:projectSel.id,expectedRevision:projectSel.revision,payload});await loadDetail('project','project/get',projectSel.id,setProjectSel);return receipt;
2615
+ },true);
2616
+ };
2617
+ const onCreateProject=input=>run(async()=>{const receipt=await call('project/create',{payload:input});setProjectForm(false);if(receipt.objectId)await openProject(receipt.objectId);});
2618
+ // 向导草稿:保存不关闭表单;发布时才切到项目详情。
2619
+ const onSaveProjectDraft=async payload=>{const receipt=await call('project/saveDraft',{payload});await refresh();return receipt;};
2620
+ const onPublishProjectDraft=async payload=>{
2621
+ if(payload.projectId){
2622
+ await call('project/saveDraft',{payload});
2623
+ await call('project/publishTarget',{objectId:payload.projectId,payload:{projectId:payload.projectId,goalText:payload.goalText,metrics:payload.metrics}});
2624
+ clearDraft('pm/project-create/');setProjectForm(false);setProjectDraft(null);await openProject(payload.projectId);
2625
+ return;
2626
+ }
2627
+ const receipt=await call('project/create',{payload:{...payload,publish:true}});
2628
+ clearDraft('pm/project-create/');setProjectForm(false);setProjectDraft(null);
2629
+ if(receipt.objectId)await openProject(receipt.objectId);
2630
+ };
2631
+ const onContinueProjectDraft=detail=>{
2632
+ clearDraft('pm/project-create/');
2633
+ const draft=(detail && detail.draft) || {};
2634
+ setProjectDraft({
2635
+ projectId:detail.id, name:detail.name, type:detail.type, goalText:detail.goal_text || '',
2636
+ coordinatorId:detail.coordinator_id || '', step:Number(draft.step) || 0,
2637
+ metrics:Array.isArray(draft.metrics) ? draft.metrics : [], savedAt:draft.savedAt || null,
2638
+ });
2639
+ setProjectForm(true);
2640
+ };
2641
+ const onProjectStatus=action=>{const status={publish:'active',pause:'paused',resume:'active',archive:'archived'}[action];if(status)return projectCommand('project/status',{status});};
2642
+ const onRecordMetric=(metricId,value)=>projectCommand('project/recordMetric',{projectId:projectSel.id,metricId,value,sourceRef:'panel:手动录入'});
2643
+ const memberCommand=(command,payload)=>run(async()=>{
2644
+ if(command==='member/create'){
2645
+ const receipt=await call(command,{payload});setMemberForm(false);clearDraft('pm/member-create/');if(receipt.objectId)await openMember(receipt.objectId);return receipt;
2646
+ }
2647
+ if(!memberSel)return;
2648
+ const receipt=await call(command,{objectId:memberSel.id,expectedRevision:memberSel.current_version,payload});await loadDetail('member','member/get',memberSel.id,setMemberSel);return receipt;
2649
+ },true);
2650
+ const onCreateMember=input=>run(async()=>{const receipt=await call('member/create',{payload:input});setMemberForm(false);clearDraft('pm/member-create/');if(receipt.objectId)await openMember(receipt.objectId);});
2651
+ const onMemberStatus=action=>{const status={pause:'paused',resume:'active',retire:'retired'}[action];if(status)return memberCommand('member/status',{status});};
2652
+ const onSyncKnowledge=()=>run(async()=>{const receipt=await call('knowledge/sync',{payload:{}});setKnowledgeReport((receipt.detail || {}).report || null);if(knowledgeSel)await openKnowledge(knowledgeSel.kind,knowledgeSel.id);});
2653
+ const onCreateDoc=input=>run(async()=>{const receipt=await call('knowledge/createDoc',{payload:input});setKnowledgeForm(null);if(receipt.objectId)await openKnowledge('doc',receipt.objectId);});
2654
+ const onImportDocs=input=>run(async()=>{const receipt=await call('knowledge/importDocs',{payload:input});setKnowledgeForm(null);setImportReport((receipt.detail || {}).report || null);});
2655
+ const onPublishDoc=id=>run(async()=>{await call('knowledge/publishDoc',{objectId:id,payload:{docId:id},expectedRevision:knowledgeSel && knowledgeSel.revision});await openKnowledge('doc',id);});
2656
+ const onEditDoc=input=>run(async()=>{await call('knowledge/updateDoc',{objectId:input.docId,payload:input,expectedRevision:knowledgeSel && knowledgeSel.revision});setEditingDoc(false);await openKnowledge('doc',input.docId);});
2657
+ const count=overview?overview.approvals.length+overview.proposals.length+(overview.reviews || []).length:0;
2658
+ const bots=members.filter(member=>member.kind==='bot');const filteredProjects=search?projects.filter(p=>`${p.name} ${p.goal_text}`.toLowerCase().includes(search.toLowerCase())):projects;
2659
+ return h('div',{className:'hams-root',style:{display:'flex',height:'100%',overflow:'hidden'}},[
2660
+ h('style',{key:'css'},`.hams-root{color:inherit}.hams-root button:disabled{opacity:.45;cursor:not-allowed}.hams-root button:focus-visible,.hams-root input:focus-visible,.hams-root select:focus-visible,.hams-root textarea:focus-visible{outline:2px solid #2F5BD8;outline-offset:2px}.hams-board{display:grid;grid-template-columns:repeat(5,minmax(185px,1fr));gap:12px;overflow-x:auto}.hams-calendar{display:grid;grid-template-columns:repeat(7,minmax(80px,1fr));overflow:auto}.hams-overview-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;align-items:start}.hams-drawer-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.2);display:flex;justify-content:flex-end;z-index:1000}.hams-drawer{background:var(--background,#fff);color:var(--foreground,#16181D);padding:24px;width:min(900px,90vw);overflow:auto;display:flex;flex-direction:column;gap:18px;box-shadow:-8px 0 30px #0002}.hams-approval-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}@media(prefers-color-scheme:dark){.hams-drawer{background:var(--background,#202124);color:var(--foreground,#e8eaed)}}@media(max-width:1000px){.hams-overview-grid{grid-template-columns:1fr}.hams-approval-columns{grid-template-columns:1fr}}@media(max-width:650px){.hams-root>nav{width:100px!important;flex-basis:100px!important}.hams-root>section{padding:14px!important}}`),
2661
+ h('nav',{key:'nav',style:styles.rail,'aria-label':'HAMS 导航'},[
2662
+ h('div',{key:'brand',style:styles.railBrand},'HAMS'),
2663
+ ...NAV.map(([key,label])=>h('button',{key,style:styles.railItem(module===key),onClick:()=>{setSearch('');setModule(key);},'aria-current':module===key?'page':undefined},[label,key==='overview' && count?badge(count,C.bad):null])),
2664
+ h('div',{key:'spacer',style:{flex:1}}),
2665
+ ]),
2666
+ h('section',{key:'main',style:styles.content},[
2667
+ h('header',{key:'header',style:styles.pageHeader},[
2668
+ h('h1',{key:'title',style:styles.title},(NAV.find(([key])=>key===module)||[])[1] || '工作台'),
2669
+ h('span',{key:'status',style:styles.muted},loading?'正在加载…':`${count} 项等你决定 · 更新于 ${lastRefresh?new Date(lastRefresh).toLocaleTimeString():'—'}`),
2670
+ rowButton(busy?'处理中…':'刷新',()=>run(refresh),{key:'refresh',disabled:busy}),
2671
+ module==='projects'?h('input',{key:'search',type:'search',style:{...styles.input,marginLeft:'auto'},value:search,onChange:e=>setSearch(e.target.value),placeholder:'搜索项目','aria-label':'搜索项目'}):null,
2672
+ ]),
2673
+ h('div',{key:'body',style:styles.contentBody},[
2674
+ h(ErrorBox,{key:'error',error,onDismiss:()=>setError(null)}),
2675
+ health && health.readOnly?h('div',{key:'readonly',style:styles.notice},'当前实例为只读,恢复单写者连接后可继续操作。'):null,
2676
+ !loading && projects.length===0 && items.length===0 && module==='overview'?h('section',{key:'onboarding',style:styles.card},[
2677
+ h('strong',{key:'h'},'开始你的第一个工作闭环'),h('p',{key:'text'},'先建项目和目标,再创建任务、收集证据,最后复查与验收。业务数据保存在本机 HAMS 数据目录中。'),
2678
+ rowButton('1 · 新建项目',()=>{setModule('projects');setProjectForm(true);},{key:'project',disabled:!can('project.create')}),rowButton('2 · 添加人机成员',()=>{setModule('agents');setMemberForm(true);},{key:'member',disabled:!can('member.manage')}),rowButton('3 · 整理知识',()=>setModule('knowledge'),{key:'knowledge'}),
2679
+ ]):null,
2680
+ module==='overview'?h(OverviewTab,{key:'overview',overview,projects,templates,me,busy:busy || readOnly,onOpenApproval,onOpenTask:openTask,onProposal,onReview:review=>openProject(review.projectId || review.project_id)}):null,
2681
+ module==='tasks'?h(ItemsTab,{key:'tasks',items,selected,onSelect:openTask,onAct,busy,onCreate,createOpen,onCreateOpen:()=>setCreateOpen(!createOpen),onCreateCancel:()=>setCreateOpen(false),canAct:!readOnly,canCreate:!readOnly && can('work_item.create'),evidenceForm,actionForm,templates,projects,members,checks,me,projectFilter:taskProject,onProjectFilter:setTaskProject,onClose:()=>{readSeq.current.item++;live.current.item=null;setSelected(null);try{const url=new URL(window.location.href);url.searchParams.delete('hamsItem');window.history.replaceState(null,'',url);}catch{}},onCheck}):null,
2682
+ module==='projects'?h(ProjectScreen,{key:'projects',projects:filteredProjects,detail:readOnly && projectSel?{...projectSel,allowedActions:[]}:projectSel,busy:busy || readOnly,formOpen:projectForm,draft:projectDraft,bots,members,humans:members.filter(m=>m.kind==='human'),me,allowedActions:!readOnly && can('project.create')?['create']:[],onCreate:onCreateProject,onOpen:openProject,onFormOpen:()=>{clearDraft('pm/project-create/');setProjectDraft(null);setProjectForm(!projectForm);},onStatus:onProjectStatus,onRecord:onRecordMetric,onCommand:projectCommand,onSaveDraft:onSaveProjectDraft,onPublish:onPublishProjectDraft,onContinueDraft:onContinueProjectDraft,onDelete:()=>{setProjectSel(null);},onRefreshDetail:()=>{if(live.current.project)loadDetail('project','project/get',live.current.project,setProjectSel);},onGotoTasks:id=>{setTaskProject(id || (projectSel && projectSel.id) || '');setModule('tasks');setCreateOpen(true);},onOpenTask:openTask,onOpenWorkItem:openTask,onClose:()=>{setProjectDraft(null);setProjectForm(false);setProjectSel(null);live.current.project=null;try{const url=new URL(window.location.href);url.searchParams.delete('hamsProject');window.history.replaceState(null,'',url);}catch{}}}):null,
2683
+ module==='agents'?h(MemberScreen,{key:'members',members,detail:readOnly && memberSel?{...memberSel,allowedActions:[]}:memberSel,positions,onOpenWorkItem:openTask,busy:busy || readOnly,allowedActions:!readOnly && can('member.manage')?['create']:[],formOpen:memberForm,filter:memberFilter,onFilter:setMemberFilter,onCreate:onCreateMember,onOpen:openMember,onFormOpen:()=>setMemberForm(!memberForm),onStatus:onMemberStatus,onCommand:memberCommand,onClose:()=>{setMemberSel(null);live.current.member=null;}}):null,
2684
+ module==='knowledge'?h(KnowledgeScreen,{key:'knowledge',data:knowledge,detail:knowledgeSel,busy,form:knowledgeForm,report:knowledgeReport,importReport,onForm:next=>{setKnowledgeForm(next);setEditingDoc(next==='edit');},onOpen:openKnowledge,onSync:onSyncKnowledge,onCreateDoc,onImport:onImportDocs,onPublish:onPublishDoc,onEdit:onEditDoc,editing:editingDoc,onCancelEdit:()=>setEditingDoc(false),capabilities:readOnly?[]:caps}):null,
2685
+ ]),
2686
+ ]),
2687
+ approval?h(ApprovalDrawer,{key:approval.id,approval,item:approvalItem,busy,onClose:()=>{live.current.approval=null;readSeq.current.approval++;setApproval(null);},canApprove:!readOnly && can('approval.decide'),onApprove,onOpenTask:id=>{live.current.approval=null;setApproval(null);openTask(id);},me}):null,
2688
+ ]);
2689
+ }
2690
+
2691
+
2692
+ // ── 应用页(设计 §18.12 / 画板 ⑳ 的插件侧落地) ─────────────────────────
2693
+ //
2694
+ // 宿主只提供 sidebar.panellist 插槽,没有「应用」页;这里自己注册一个主面板,
2695
+ // 读宿主侧栏条目(ctx.slots.entries)并按分组展示,点一下就切过去。
2696
+ // 不硬编码条目清单:装了什么插件就显示什么,id 只用于分组与说明文案。
2697
+ const APPS_ID = 'apps';
2698
+ const CONVERSATIONS_ID = 'conversations';
2699
+
2700
+ const APP_GROUPS = [
2701
+ ['pinned', '常驻左侧'],
2702
+ ['create', '生成'],
2703
+ ['tool', '工具'],
2704
+ ['other', '其他入口'],
2705
+ ];
2706
+
2707
+ /** 已知条目的分组与一句话说明;未知条目落进「其他入口」,不猜。 */
2708
+ const APP_META = {
2709
+ hams: { group: 'pinned', desc: '项目、任务、人机成员、知识;关单与发布要人批准', tag: '本项目插件' },
2710
+ schedules: { group: 'pinned', desc: '定时唤醒会话;与 HAMS 的复查计划相互独立', tag: '内置' },
2711
+ 'baoyu-design.artifacts': { group: 'create', desc: '设计画布、幻灯片、仪表盘、白板、动效的作品页', tag: '第三方' },
2712
+ plugins: { group: 'other', desc: '安装、启用、停用,查看版本与兼容性', tag: '内置' },
2713
+ 'agents-anywhere-next': { group: 'tool', desc: '通过 Agents Anywhere 从手机或其他设备连接', tag: '第三方' },
2714
+ 'dsh-market': { group: 'other', desc: '浏览与安装社区插件', tag: '第三方' },
2715
+ };
2716
+
2717
+ function appMetaOf(id, label) {
2718
+ if (APP_META[id]) return APP_META[id];
2719
+ const text = `${label} ${id}`;
2720
+ if (/远程|remote|bridge|live/i.test(text)) return { group: 'tool', desc: '从另一台设备操作当前工作区', tag: '第三方' };
2721
+ if (/作品|设计|design|artifact|slide|dashboard|whiteboard|motion/i.test(text)) {
2722
+ return { group: 'create', desc: '创作产物的入口', tag: '第三方' };
2723
+ }
2724
+ return { group: 'other', desc: '', tag: '第三方' };
2725
+ }
2726
+
2727
+ /** 读一个插槽注册表里的条目(id / 文案 / 顺序);读不到就返回空数组。 */
2728
+ function slotEntries(hole) {
2729
+ const ctx = window.__hams && window.__hams.ctx;
2730
+ if (!ctx || !ctx.slots || typeof ctx.slots.entries !== 'function') return [];
2731
+ return (ctx.slots.entries(hole) || []).map((entry) => {
2732
+ const options = (entry && entry.options) || {};
2733
+ let label = '';
2734
+ try {
2735
+ label = typeof options.label === 'function' ? options.label() : (options.label || '');
2736
+ } catch (err) {
2737
+ label = options.id || '';
2738
+ }
2739
+ return { id: String(options.id || ''), label: String(label || options.id || ''), order: Number(options.order) || 0 };
2740
+ }).filter((entry) => entry.id);
2741
+ }
2742
+
2743
+ /**
2744
+ * 「应用」里要列的东西 = 左侧栏的面板行 + 底部动作行。
2745
+ * 远程控制这类入口注册的是 `sidebar.footer.action`(不在面板行里),
2746
+ * 只读面板行会漏掉它——用户已经点名要它出现在应用里。
2747
+ */
2748
+ function hostEntries() {
2749
+ const panels = slotEntries('sidebar.panellist')
2750
+ .filter((entry) => entry.id !== APPS_ID && entry.id !== CONVERSATIONS_ID)
2751
+ .map((entry) => ({ ...entry, kind: 'panel' }));
2752
+ const footers = slotEntries('sidebar.footer.action')
2753
+ .map((entry) => ({ ...entry, kind: 'footer' }));
2754
+ return [...panels, ...footers].sort((a, b) => (a.order - b.order) || a.label.localeCompare(b.label));
2755
+ }
2756
+
2757
+ /**
2758
+ * 底部动作没有「选中面板」这回事:它就是个按钮。找不到就明说去底部栏点,
2759
+ * 不做静默失败。
2760
+ */
2761
+ function clickFooterAction(label) {
2762
+ const buttons = [...document.querySelectorAll('button[aria-label]')];
2763
+ const byLabel = buttons.reverse().find((node) => node.getAttribute('aria-label') === label);
2764
+ if (byLabel) {
2765
+ byLabel.click();
2766
+ return true;
2767
+ }
2768
+ const area = document.querySelector('[class*="footerActions"]');
2769
+ if (area) {
2770
+ const candidates = [...area.querySelectorAll('button')];
2771
+ const byText = candidates.find((node) => (node.textContent || '').trim() === label);
2772
+ if (byText) {
2773
+ byText.click();
2774
+ return true;
2775
+ }
2776
+ if (candidates.length === 1) {
2777
+ candidates[0].click();
2778
+ return true;
2779
+ }
2780
+ }
2781
+ return false;
2782
+ }
2783
+
2784
+ function hostActivePanel() {
2785
+ const ctx = window.__hams && window.__hams.ctx;
2786
+ try {
2787
+ return ctx.layout.panelInfo.getSnapshot().activePanelId;
2788
+ } catch (err) {
2789
+ return null;
2790
+ }
2791
+ }
2792
+
2793
+ function openHostPanel(id) {
2794
+ const ctx = window.__hams && window.__hams.ctx;
2795
+ if (!ctx || !ctx.layout) throw { code: 'HOST_CAPABILITY_MISSING', message: '宿主没有 layout 服务,无法切换面板', nextStep: '升级宿主后重试' };
2796
+ ctx.layout.selectPanel(id);
2797
+ }
2798
+
2799
+ /**
2800
+ * 宿主侧栏只在自身状态变化时重渲染:用一个不可见的 actions 占位注册当触发器
2801
+ * (More 菜单已被宿主补丁关掉,所以不会多出一行)。
2802
+ */
2803
+ let sidebarRefreshOff = null;
2804
+ function refreshHostSidebar() {
2805
+ const ctx = window.__hams && window.__hams.ctx;
2806
+ if (!ctx || !ctx.slots || typeof ctx.slots.register !== 'function') return;
2807
+ try {
2808
+ if (typeof sidebarRefreshOff === 'function') { sidebarRefreshOff(); sidebarRefreshOff = null; }
2809
+ sidebarRefreshOff = ctx.slots.register({
2810
+ name: 'sidebar.panellist.actions',
2811
+ id: 'hams-refresh',
2812
+ order: 999,
2813
+ label: () => 'HAMS',
2814
+ }, () => null);
2815
+ } catch (err) { /* 宿主不认这个插槽时忽略:固定状态仍会写进白名单,下次重渲染生效 */ }
2816
+ }
2817
+
2818
+ function AppsPanel() {
2819
+ const [entries, setEntries] = React.useState(() => hostEntries());
2820
+ const [query, setQuery] = React.useState('');
2821
+ const [active, setActive] = React.useState(() => hostActivePanel());
2822
+ const [pins, setPins] = React.useState(() => readPinnedApps());
2823
+ const [error, setError] = React.useState(null);
2824
+
2825
+ React.useEffect(() => {
2826
+ const ctx = window.__hams && window.__hams.ctx;
2827
+ const sync = () => {
2828
+ setEntries(hostEntries());
2829
+ setActive(hostActivePanel());
2830
+ };
2831
+ sync();
2832
+ const offs = [];
2833
+ try { offs.push(ctx.slots.subscribe('sidebar.panellist', sync)); } catch (err) { /* 订阅失败不影响一次性读取 */ }
2834
+ try { offs.push(ctx.layout.panelInfo.subscribe(sync)); } catch (err) { /* 同上 */ }
2835
+ const timer = window.setInterval(sync, 3000);
2836
+ return () => {
2837
+ window.clearInterval(timer);
2838
+ offs.forEach((off) => { try { if (typeof off === 'function') off(); } catch (err) { /* 忽略 */ } });
2839
+ };
2840
+ }, []);
2841
+
2842
+ const open = (entry) => {
2843
+ const id = typeof entry === 'string' ? entry : entry.id;
2844
+ const kind = typeof entry === 'string' ? 'panel' : entry.kind;
2845
+ try {
2846
+ setError(null);
2847
+ if (kind === 'footer') {
2848
+ if (!clickFooterAction(typeof entry === 'string' ? id : entry.label)) {
2849
+ throw { code: 'HOST_CAPABILITY_MISSING', message: '这个入口是左栏底部的按钮,页面里找不到它', nextStep: '点左侧栏底部对应图标打开(它可能被收在底部区域)' };
2850
+ }
2851
+ return;
2852
+ }
2853
+ if (id === CONVERSATIONS_ID) openHostPanel(null);
2854
+ else openHostPanel(id);
2855
+ setActive(hostActivePanel());
2856
+ } catch (err) {
2857
+ setError(err && err.code ? err : { code: 'HOST_CAPABILITY_MISSING', message: String(err && err.message ? err.message : err), nextStep: '这个入口没有注册主面板,先看宿主日志' });
2858
+ }
2859
+ };
2860
+
2861
+ const isPinned = (entry) => SHELL_ROWS.includes(entry.id) || pins.includes(entry.id);
2862
+ const togglePin = (entry) => {
2863
+ const current = readPinnedApps();
2864
+ const next = current.includes(entry.id) ? current.filter((id) => id !== entry.id) : [...current, entry.id];
2865
+ writePinnedApps(next);
2866
+ syncSidebarRows();
2867
+ setPins(next);
2868
+ refreshHostSidebar();
2869
+ };
2870
+ const filtered = entries.filter((entry) => !query.trim() || entry.label.toLowerCase().includes(query.trim().toLowerCase()));
2871
+ const groups = APP_GROUPS.map(([key, title]) => [key, title, filtered.filter((entry) => appMetaOf(entry.id, entry.label).group === key)])
2872
+ .filter(([, , rows]) => rows.length > 0);
2873
+ const thirdParty = entries.filter((entry) => appMetaOf(entry.id, entry.label).tag === '第三方').length;
2874
+
2875
+ return h('div', { style: { ...styles.content, height: '100%', boxSizing: 'border-box' } }, [
2876
+ h('div', { key: 'h', style: styles.pageHeader }, [
2877
+ h('h1', { key: 't', style: styles.title }, '应用'),
2878
+ h('span', { key: 's', style: styles.muted }, `${entries.length} 个入口 · ${thirdParty} 个来自第三方插件`),
2879
+ ]),
2880
+ h('div', { key: 'body', style: styles.contentBody }, [
2881
+ h('div', { key: 'bar', style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } }, [
2882
+ h('input', {
2883
+ key: 'q',
2884
+ value: query,
2885
+ placeholder: '搜索应用',
2886
+ style: { ...styles.input, minWidth: 180, flex: '1 1 180px' },
2887
+ onChange: (event) => setQuery(event.target.value),
2888
+ }),
2889
+ h('button', { key: 'm', style: styles.button(false), onClick: () => open('plugins') }, '管理插件'),
2890
+ h('button', { key: 'c', style: styles.button(false), onClick: () => open(CONVERSATIONS_ID) }, '回到对话'),
2891
+ ]),
2892
+ h(ErrorBox, { key: 'e', error, onDismiss: () => setError(null) }),
2893
+ groups.length === 0
2894
+ ? h('div', { key: 'empty', style: styles.empty }, query.trim() ? `没有匹配「${query.trim()}」的应用` : '宿主还没有注册任何侧栏入口')
2895
+ : groups.map(([key, title, rows]) => h('div', { key, style: styles.card }, [
2896
+ h('div', { key: 'h', style: styles.row }, [
2897
+ h('strong', { key: 't' }, title),
2898
+ h('span', { key: 'c', style: styles.muted }, `${rows.length} 个`),
2899
+ ]),
2900
+ h('ul', { key: 'l', style: styles.list }, rows.map((entry) => {
2901
+ const meta = appMetaOf(entry.id, entry.label);
2902
+ const isActive = active === entry.id;
2903
+ const pinned = isPinned(entry);
2904
+ const shellRow = SHELL_ROWS.includes(entry.id);
2905
+ const pinnable = !shellRow && entry.kind !== 'footer';
2906
+ return h('li', { key: entry.id, style: styles.item }, [
2907
+ h('div', { key: 'm', style: styles.row }, [
2908
+ h('span', { key: 'n', style: { fontWeight: isActive ? 600 : 400 } }, entry.label),
2909
+ h('span', { key: 'b', style: styles.muted }, [
2910
+ entry.kind === 'footer' ? badge('底部栏', C.warn) : null,
2911
+ badge(meta.tag, meta.tag === '第三方' ? C.muted : C.accent),
2912
+ pinned ? badge(shellRow ? '常驻左侧' : '已固定', C.ok) : null,
2913
+ isActive ? badge('当前', C.ok) : null,
2914
+ ]),
2915
+ ]),
2916
+ meta.desc ? h('div', { key: 'd', style: styles.muted }, meta.desc) : null,
2917
+ h('div', { key: 'a', style: { display: 'flex', gap: 6, flexWrap: 'wrap' } }, [
2918
+ h('button', { key: 'o', style: styles.button(true), disabled: isActive, onClick: () => open(entry) }, isActive ? '已打开' : (entry.kind === 'footer' ? '底部栏打开' : '打开')),
2919
+ pinnable
2920
+ ? h('button', {
2921
+ key: 'p',
2922
+ style: styles.button(false),
2923
+ title: pinned ? '从左侧栏移除这一行(刷新后仍有效)' : '把这一行加到左侧栏',
2924
+ onClick: () => togglePin(entry),
2925
+ }, pinned ? '取消固定' : '固定到左侧栏')
2926
+ : null,
2927
+ ]),
2928
+ ]);
2929
+ })),
2930
+ ])),
2931
+ h('div', { key: 'n', style: styles.muted }, '「固定」把入口加到左侧栏,「取消固定」把它移走;刷新后仍保留。插件没加载时宿主不做过滤,左侧栏恢复宿主原样。'),
2932
+ ]),
2933
+ ]);
2934
+ }
2935
+
2936
+ /** 左侧栏的 2x2 点阵(应用)与对话气泡,用宿主自带的图标 sprite。 */
2937
+ function AppsIcon() {
2938
+ return h('svg', { width: 18, height: 18, viewBox: '0 0 18 18', 'aria-hidden': 'true', style: { display: 'block' } },
2939
+ [[3, 3], [10, 3], [3, 10], [10, 10]].map(([x, y], i) =>
2940
+ h('rect', { key: i, x, y, width: 5, height: 5, rx: 1.5, fill: 'currentColor', opacity: 0.85 })));
2941
+ }
2942
+
2943
+ function ConversationsIcon() {
2944
+ // 自己画气泡:宿主 sprite 里只有「新建对话」(带 +)的图标,拿来当「对话」会误导。
2945
+ return h('svg', { width: 18, height: 18, viewBox: '0 0 18 18', 'aria-hidden': 'true', style: { display: 'block' } },
2946
+ h('path', {
2947
+ d: 'M2.4 5.2a1.8 1.8 0 011.8-1.8h9.6a1.8 1.8 0 011.8 1.8v6.2a1.8 1.8 0 01-1.8 1.8H8.6l-3.4 2.8v-2.8H4.2a1.8 1.8 0 01-1.8-1.8z',
2948
+ fill: 'none',
2949
+ stroke: 'currentColor',
2950
+ strokeWidth: 1.4,
2951
+ strokeLinejoin: 'round',
2952
+ }));
2953
+ }
2954
+
2955
+ /**
2956
+ * 「对话」行:宿主把会话视图当成 activePanelId === null。
2957
+ * 点击这一行 = 把主面板切回 null,等于回到对话;不需要另造一个对话页。
2958
+ */
2959
+ function ConversationsRedirect() {
2960
+ React.useEffect(() => {
2961
+ try { openHostPanel(null); } catch (err) { /* 切换失败就停在原面板 */ }
2962
+ }, []);
2963
+ return h('div', { style: styles.empty }, '正在回到对话…');
2964
+ }
2965
+
2966
+ /**
2967
+ * 进入全局面板(工作台 / 应用 / 插件…)时把左侧的会话浏览区收起来,
2968
+ * 回到对话(activePanelId === null)时再放出来。设计 §18.12 要的
2969
+ * 「左侧只留常驻行」就是这样落地的:不改宿主结构,只加一条样式。
2970
+ */
2971
+ function installShellCompact(ctx) {
2972
+ const marker = 'hams-shell-compact';
2973
+ if (!document.getElementById(marker)) {
2974
+ const style = document.createElement('style');
2975
+ style.id = marker;
2976
+ style.textContent = [
2977
+ 'html[data-hams-panel="global"] [class*="regionArea"] { display: none !important; }',
2978
+ // 新会话行搬到会话浏览区上方:宿主那一行留着(点击仍走它的处理器),只是藏起来。
2979
+ 'html[data-hams-newsession="1"] [class*="newSessionRow"] { display: none !important; }',
2980
+ '.hams-newsession { display: none; padding: 0 0 8px; }',
2981
+ 'html[data-hams-newsession="1"] .hams-newsession { display: block; }',
2982
+ '.hams-newsession > button { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; height: 34px; border: none; border-radius: 8px; background: rgba(47,91,216,.07); font: inherit; font-size: 13px; font-weight: 500; color: #16181D; cursor: pointer; }',
2983
+ '.hams-newsession > button:hover { background: rgba(127,127,127,.08); }',
2984
+ ].join('\n');
2985
+ document.head.appendChild(style);
2986
+ }
2987
+
2988
+ const NEW_SESSION_ID = 'hams-newsession';
2989
+ /** 把「新会话」按钮放进 regionArea 顶部(工作区/会话记录上方),离开对话视图时收起。 */
2990
+ const syncNewSession = () => {
2991
+ const root = document.documentElement;
2992
+ const hostRow = document.querySelector('[class*="newSessionRow"]');
2993
+ const wide = !!(hostRow && hostRow.querySelector('[class*="newSessionLabel"]'));
2994
+ const region = document.querySelector('[class*="regionArea"]');
2995
+ const existing = document.getElementById(NEW_SESSION_ID);
2996
+ if (!hostRow || !wide || !region) {
2997
+ root.removeAttribute('data-hams-newsession');
2998
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
2999
+ return;
3000
+ }
3001
+ root.setAttribute('data-hams-newsession', '1');
3002
+ let host = existing;
3003
+ if (!host) {
3004
+ host = document.createElement('div');
3005
+ host.id = NEW_SESSION_ID;
3006
+ host.className = 'hams-newsession';
3007
+ const button = document.createElement('button');
3008
+ button.type = 'button';
3009
+ button.setAttribute('aria-label', '新建会话');
3010
+ button.textContent = '+ 新会话';
3011
+ // 不自己造会话:转发给宿主那一行的真实按钮,快捷键/工作区逻辑都还在它身上。
3012
+ button.addEventListener('click', () => {
3013
+ const target = document.querySelector('[class*="newSessionRow"] button');
3014
+ if (target) target.click();
3015
+ });
3016
+ host.appendChild(button);
3017
+ }
3018
+ if (region.firstChild !== host) region.insertBefore(host, region.firstChild);
3019
+ };
3020
+
3021
+ const apply = () => {
3022
+ let active = null;
3023
+ try { active = ctx.layout.panelInfo.getSnapshot().activePanelId; } catch (err) { active = null; }
3024
+ document.documentElement.dataset.hamsPanel = active === null ? 'conversation' : 'global';
3025
+ syncNewSession();
3026
+ };
3027
+ apply();
3028
+ try { ctx.layout.panelInfo.subscribe(apply); } catch (err) { /* 旧宿主不支持订阅 */ }
3029
+ // 宿主重渲染会换掉 regionArea 的子节点,定期把按钮放回去(幂等,不新增 DOM)。
3030
+ try { window.setInterval(syncNewSession, 1500); } catch (err) { /* 忽略 */ }
3031
+ }
3032
+
3033
+ function HamsIcon() {
3034
+ return h('span', {
3035
+ style: {
3036
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
3037
+ width: 18, height: 18, borderRadius: 5, border: 'none', background: C.tintStrong, fontSize: 10, fontWeight: 700,
3038
+ },
3039
+ }, 'H');
3040
+ }
3041
+
3042
+ function apply(ctx) {
3043
+ try {
3044
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
3045
+ name: 'sidebar.panellist',
3046
+ id: PANEL_ID,
3047
+ order: 12,
3048
+ label: () => 'HAMS',
3049
+ }, HamsIcon));
3050
+ } catch (err) {
3051
+ console.error('[hams] 左侧入口注册失败:', err && err.message ? err.message : err);
3052
+ }
3053
+ try {
3054
+ ctx.slots.inject('main', () => ctx.slots.register({
3055
+ name: 'main',
3056
+ key: PANEL_ID,
3057
+ inject: () => ({}),
3058
+ }, HamsPanel));
3059
+ } catch (err) {
3060
+ console.error('[hams] 主面板注册失败:', err && err.message ? err.message : err);
3061
+ }
3062
+ // 「对话」行:点一下就回到会话视图(宿主用 activePanelId === null 表示对话)。
3063
+ try {
3064
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
3065
+ name: 'sidebar.panellist',
3066
+ id: CONVERSATIONS_ID,
3067
+ order: 5,
3068
+ label: () => '对话',
3069
+ }, ConversationsIcon));
3070
+ } catch (err) {
3071
+ console.error('[hams] 对话入口注册失败:', err && err.message ? err.message : err);
3072
+ }
3073
+ try {
3074
+ ctx.slots.inject('main', () => ctx.slots.register({
3075
+ name: 'main',
3076
+ key: CONVERSATIONS_ID,
3077
+ inject: () => ({}),
3078
+ }, ConversationsRedirect));
3079
+ } catch (err) {
3080
+ console.error('[hams] 对话占位面板注册失败:', err && err.message ? err.message : err);
3081
+ }
3082
+ // 「应用」行与页面:列宿主注册过的全部侧栏入口,点一下就切过去。
3083
+ try {
3084
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
3085
+ name: 'sidebar.panellist',
3086
+ id: APPS_ID,
3087
+ order: 90,
3088
+ label: () => '应用',
3089
+ }, AppsIcon));
3090
+ } catch (err) {
3091
+ console.error('[hams] 应用入口注册失败:', err && err.message ? err.message : err);
3092
+ }
3093
+ try {
3094
+ ctx.slots.inject('main', () => ctx.slots.register({
3095
+ name: 'main',
3096
+ key: APPS_ID,
3097
+ inject: () => ({}),
3098
+ }, AppsPanel));
3099
+ } catch (err) {
3100
+ console.error('[hams] 应用页注册失败:', err && err.message ? err.message : err);
3101
+ }
3102
+ try {
3103
+ // 调试入口:面板、注册组件、以及宿主 ctx(排查插槽/布局 API 时才用)。
3104
+ window.__hams = { panel: PANEL_ID, at: new Date().toISOString(), components: { HamsPanel, HamsIcon, AppsPanel, AppsIcon, ProjectScreen, MemberScreen, KnowledgeScreen, ItemsTab, TaskDetail, TaskCalendar, OverviewTab, ApprovalDrawer }, helpers: { boardColumn, taskFilter, metricVerdict, approvalValidity, renderMarkdown, mdInline, readPinnedApps, writePinnedApps, syncSidebarRows }, ctx };
3105
+ } catch { /* 忽略 */ }
3106
+ try {
3107
+ installShellCompact(ctx);
3108
+ } catch (err) {
3109
+ console.error('[hams] 收起会话浏览区失败:', err && err.message ? err.message : err);
3110
+ }
3111
+ try {
3112
+ // 白名单已写好,触发一次宿主侧栏重渲染,让固定的行立刻对齐。
3113
+ refreshHostSidebar();
3114
+ } catch (err) {
3115
+ console.error('[hams] 侧栏白名单同步失败:', err && err.message ? err.message : err);
3116
+ }
3117
+ }
3118
+
3119
+ module.exports = { name, inject, apply };
3120
+ return module.exports;
3121
+ },
3122
+ });