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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/client/index.js +3122 -0
- package/cordis.patch.yml +16 -0
- package/package.json +74 -4
- package/src/domain/check.ts +193 -0
- package/src/domain/errors.ts +59 -0
- package/src/domain/evidence.ts +134 -0
- package/src/domain/ids.ts +31 -0
- package/src/host/chat-runtime.ts +489 -0
- package/src/host/dsh-adapter.ts +867 -0
- package/src/host/session-adapter.ts +276 -0
- package/src/http/handler.ts +1132 -0
- package/src/index.js +53 -0
- package/src/service/audit.ts +92 -0
- package/src/service/chat.ts +1100 -0
- package/src/service/contracts.ts +193 -0
- package/src/service/defaults.ts +182 -0
- package/src/service/deliverables.ts +240 -0
- package/src/service/hams.ts +3103 -0
- package/src/service/knowledge.ts +940 -0
- package/src/service/member-config.ts +39 -0
- package/src/service/members.ts +506 -0
- package/src/service/memory.ts +375 -0
- package/src/service/permissions.ts +118 -0
- package/src/service/project-lifecycle.ts +233 -0
- package/src/service/projects.ts +840 -0
- package/src/service/receipts.ts +63 -0
- package/src/service/templates.ts +140 -0
- package/src/service/tick.ts +751 -0
- package/src/service/tools.ts +711 -0
- package/src/service/types.ts +219 -0
- package/src/service/work-item-create.ts +159 -0
- package/src/service/workspace-profile.ts +443 -0
- package/src/store/backup.ts +239 -0
- package/src/store/bootstrap.ts +225 -0
- package/src/store/db.ts +179 -0
- package/src/store/lock.ts +194 -0
- package/src/store/migrations.ts +211 -0
- package/src/store/schema/001_p1.sql +846 -0
- package/src/store/schema/001_p1.sql.ts +865 -0
- package/src/store/schema/002_knowledge.sql +54 -0
- package/src/store/schema/002_knowledge.sql.ts +68 -0
- package/src/store/schema/003_projects.sql.ts +3 -0
- package/src/store/schema/004_team.sql.ts +50 -0
- package/src/store/schema/005_target_check.sql.ts +22 -0
- package/src/store/schema/006_target_flow.sql.ts +19 -0
- package/src/store/schema/007_project_delete.sql.ts +17 -0
- package/src/store/schema/008_memory.sql.ts +47 -0
- package/src/store/schema/generate-projects.mjs +16 -0
- package/src/store/seed.ts +114 -0
package/client/index.js
ADDED
|
@@ -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
|
+
});
|