@yss123/cage-admin 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +62 -3
  2. package/dist/bootstrap.d.ts +27 -1
  3. package/dist/bootstrap.js +52 -16
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/catalog.d.ts +48 -0
  6. package/dist/catalog.js +96 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/diagnostics.d.ts +14 -0
  9. package/dist/diagnostics.js +163 -0
  10. package/dist/diagnostics.js.map +1 -0
  11. package/dist/domain/constraints.d.ts +23 -0
  12. package/dist/domain/constraints.js +27 -0
  13. package/dist/domain/constraints.js.map +1 -1
  14. package/dist/domain/index.d.ts +5 -0
  15. package/dist/domain/index.js +5 -0
  16. package/dist/domain/index.js.map +1 -1
  17. package/dist/domain/org.d.ts +230 -149
  18. package/dist/domain/org.js +98 -4
  19. package/dist/domain/org.js.map +1 -1
  20. package/dist/http.d.ts +13 -1
  21. package/dist/http.js +28 -13
  22. package/dist/http.js.map +1 -1
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +6 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +9 -5
  27. package/ui/dist/app/shell.js +135 -0
  28. package/ui/dist/app.js +34 -0
  29. package/ui/dist/modules/audit/audit-page.js +107 -0
  30. package/ui/dist/modules/audit/index.js +5 -0
  31. package/ui/dist/modules/auth/index.js +5 -0
  32. package/ui/dist/modules/auth/login-page.js +38 -0
  33. package/ui/dist/modules/models/index.js +5 -0
  34. package/ui/dist/modules/models/models-page.js +60 -0
  35. package/ui/dist/modules/operations/index.js +6 -0
  36. package/ui/dist/modules/operations/operations-page.js +110 -0
  37. package/ui/dist/modules/operations/sync-page.js +60 -0
  38. package/ui/dist/modules/org/index.js +5 -0
  39. package/ui/dist/modules/org/org-page.js +243 -0
  40. package/ui/dist/modules/personalities/index.js +5 -0
  41. package/ui/dist/modules/personalities/personalities-page.js +161 -0
  42. package/ui/dist/modules/roles/index.js +5 -0
  43. package/ui/dist/modules/roles/roles-page.js +348 -0
  44. package/ui/dist/modules/tasks/index.js +5 -0
  45. package/ui/dist/modules/tasks/tasks-page.js +68 -0
  46. package/ui/dist/shared/api.js +166 -0
  47. package/ui/dist/shared/dom.js +139 -0
  48. package/ui/dist/shared/labels.js +84 -0
  49. package/ui/dist/shared/navigation.js +14 -0
  50. package/ui/dist/shared/types.js +1 -0
  51. package/ui/index.html +12 -10
  52. package/ui/style.css +1657 -41
  53. package/ui/app.js +0 -339
@@ -0,0 +1,348 @@
1
+ /**
2
+ * 角色与授权管理界面,区分能力授予、能力参数和对象数据范围。
3
+ * 页面只按当前登录能力限制可操作项;实际防提权、状态与范围检查由服务端命令执行。
4
+ * 能力切换即时提交,范围编辑先保留草稿再保存,只有成功响应才更新已保存角色快照。
5
+ */
6
+ import { api, can, run, state } from '../../shared/api.js';
7
+ import { badge, emptyState, field, h, icon, modal, searchInput } from '../../shared/dom.js';
8
+ import { invocation, objectName, scopes } from '../../shared/labels.js';
9
+ // 只保存页面交互,角色和权限仍以命令响应为准;搜索不会改动授权数据。
10
+ let selectedRole = null;
11
+ let tab = 'permissions';
12
+ let query = '';
13
+ let filter = 'all';
14
+ /**
15
+ * 读取角色与可访问部门,构建角色列表和权限/范围详情。
16
+ * 角色、筛选词及当前选项卡为页面内状态;列表数据来自有界查询,目录来自启动阶段。
17
+ */
18
+ export async function renderRoles(main) {
19
+ const [result, departmentResult] = await Promise.all([
20
+ api('GET', '/api/role?pageSize=200'),
21
+ can('Dept.read')
22
+ ? api('GET', '/api/dept?pageSize=200')
23
+ : Promise.resolve({ items: [] }),
24
+ ]);
25
+ let roles = result.items;
26
+ if (!selectedRole || !roles.some((role) => role.id === selectedRole))
27
+ selectedRole = roles[0]?.id ?? null;
28
+ const list = h('div', { class: 'role-list', 'aria-label': '角色列表' });
29
+ const details = h('div', { class: 'role-details' });
30
+ let roleQuery = '';
31
+ const count = badge(String(roles.length), 'neutral');
32
+ const sidebar = h('aside', { class: 'role-sidebar' }, h('div', { class: 'role-list-heading' }, h('h2', {}, '团队角色'), count), h('p', { class: 'muted sidebar-description' }, '选择角色,配置可以做什么、可以看哪些数据。'), searchInput('搜索角色名称或编码', (value) => {
33
+ roleQuery = value.toLowerCase();
34
+ renderList();
35
+ }), list);
36
+ if (can('Role.create'))
37
+ sidebar.append(h('button', { class: 'button primary create-role', onclick: createRole }, icon('plus'), '新建角色'));
38
+ main.append(h('div', { class: 'role-workspace' }, sidebar, details));
39
+ /**
40
+ * 按角色名称或 code 过滤当前加载集合,选择后同步重画详情,不改角色状态或授权。
41
+ */
42
+ function renderList() {
43
+ const visible = roles.filter((role) => `${role.name} ${role.code}`.toLowerCase().includes(roleQuery));
44
+ list.replaceChildren(...visible.map((role) => h('button', {
45
+ class: `role-item ${role.id === selectedRole ? 'selected' : ''}`,
46
+ 'aria-pressed': role.id === selectedRole,
47
+ onclick: () => {
48
+ selectedRole = role.id;
49
+ renderList();
50
+ renderDetail();
51
+ },
52
+ }, h('span', { class: 'role-avatar' }, icon('shield')), h('span', { class: 'role-item-copy' }, h('strong', {}, role.name), h('small', {}, role.code)), role.status !== 'active'
53
+ ? badge('停用')
54
+ : h('span', { class: 'role-item-count' }, role.capabilities.length))));
55
+ if (!visible.length)
56
+ list.append(h('p', { class: 'list-empty' }, '没有匹配的角色'));
57
+ }
58
+ /**
59
+ * 提交新角色名称和 code,成功后把真实返回角色加入列表并选中。
60
+ * 新角色没有默认业务能力,后续需要显式授权。
61
+ */
62
+ function createRole() {
63
+ const name = h('input', { placeholder: '例如:采购经理', required: true, maxlength: 100 });
64
+ const code = h('input', {
65
+ placeholder: '例如:purchase_manager',
66
+ required: true,
67
+ pattern: '[a-z][a-z0-9_]*',
68
+ });
69
+ const save = h('button', { class: 'primary', type: 'submit' }, '创建角色');
70
+ const form = h('form', {
71
+ class: 'stack-form',
72
+ onsubmit: async (event) => {
73
+ event.preventDefault();
74
+ const created = await run(() => api('POST', '/api/role/create', { name: name.value, code: code.value }), '角色已创建,请继续配置权限', save);
75
+ if (created) {
76
+ roles = [...roles, created];
77
+ selectedRole = created.id;
78
+ count.textContent = String(roles.length);
79
+ dialog.close();
80
+ renderList();
81
+ renderDetail();
82
+ }
83
+ },
84
+ }, field('角色名称', name), field('角色编码', code, '用于业务识别,使用小写字母、数字和下划线。'), h('p', { class: 'inline-info' }, icon('info'), '新角色默认没有操作权限,不会自动获得已有业务能力。'), h('div', { class: 'form-actions' }, save));
85
+ const dialog = modal('新建团队角色', form);
86
+ name.focus();
87
+ }
88
+ /**
89
+ * 为选中角色重建摘要与选项卡;已停用角色只展示配置。
90
+ * 局部 updateRole 仅接受服务端成功快照,避免界面把失败请求的临时选择当成已保存授权。
91
+ */
92
+ function renderDetail() {
93
+ let role = roles.find((item) => item.id === selectedRole);
94
+ if (!role) {
95
+ details.replaceChildren(emptyState('从一个角色开始', '选择左侧角色,或创建一个新的团队角色。', 'shield'));
96
+ return;
97
+ }
98
+ const updateRole = (updated) => {
99
+ role = updated;
100
+ roles = roles.map((item) => (item.id === updated.id ? updated : item));
101
+ renderList();
102
+ };
103
+ const countText = h('strong', {}, String(role.capabilities.length));
104
+ const summary = h('div', { class: 'role-summary' }, h('div', { class: 'role-title' }, h('span', { class: 'role-emblem' }, icon('shield')), h('div', {}, h('div', { class: 'title-line' }, h('h2', {}, role.name), badge(role.status === 'active' ? '使用中' : '已停用', role.status === 'active' ? 'green' : 'neutral')), h('p', {}, h('code', {}, role.code), h('span', {}, '· 按角色统一管理访问权限')))), h('div', { class: 'permission-total' }, countText, h('span', {}, '项已授权')));
105
+ const body = h('div', { class: 'role-tab-body' });
106
+ const buttons = ['permissions', 'scopes'].map((key) => h('button', {
107
+ role: 'tab',
108
+ 'aria-selected': tab === key,
109
+ class: tab === key ? 'active' : '',
110
+ onclick: () => {
111
+ tab = key;
112
+ renderDetail();
113
+ },
114
+ }, icon(key === 'permissions' ? 'shield' : 'grid'), key === 'permissions' ? '操作权限' : '数据范围'));
115
+ details.replaceChildren(summary, h('div', { class: 'role-tabs', role: 'tablist', 'aria-label': '角色配置' }, buttons), body);
116
+ if (role.status !== 'active')
117
+ body.append(h('div', { class: 'inline-info' }, icon('lock'), '此角色已停用,当前配置仅供查看。'));
118
+ if (tab === 'permissions') {
119
+ const grid = h('div', { class: 'permission-grid' });
120
+ const filteredCount = h('span', { class: 'muted result-count' });
121
+ const filters = h('div', { class: 'segmented', 'aria-label': '筛选操作权限' }, ['all', 'granted', 'ungranted'].map((key) => h('button', {
122
+ class: key === filter ? 'active' : '',
123
+ 'aria-pressed': key === filter,
124
+ onclick: (event) => {
125
+ filter = key;
126
+ for (const button of filters.querySelectorAll('button')) {
127
+ button.classList.remove('active');
128
+ button.setAttribute('aria-pressed', 'false');
129
+ }
130
+ const button = event.currentTarget;
131
+ button.classList.add('active');
132
+ button.setAttribute('aria-pressed', 'true');
133
+ renderGroups();
134
+ },
135
+ }, { all: '全部', granted: '已授权', ungranted: '未授权' }[key])));
136
+ body.append(h('div', { class: 'permission-toolbar' }, searchInput('搜索业务、操作名称或权限标识', (value) => {
137
+ query = value;
138
+ renderGroups();
139
+ }, query), filters), h('div', { class: 'permission-help' }, h('span', {}, icon('info'), '勾选后即时保存。只能授予你已拥有的权限。'), filteredCount), grid);
140
+ /**
141
+ * 根据搜索词和已授权筛选生成能力分组,业务对象排在系统对象之前。
142
+ * 授权计数来自 role.capabilities,当前显示计数来自筛选集合,两者分别表达不同含义。
143
+ */
144
+ function renderGroups() {
145
+ const granted = new Map(role.capabilities.map((item) => [item.capability, item.params]));
146
+ const search = query.trim().toLowerCase();
147
+ const groups = new Map();
148
+ const visible = state.catalog.capabilities.filter((item) => {
149
+ const has = granted.has(item.capability);
150
+ return ((filter === 'all' || (filter === 'granted' ? has : !has)) &&
151
+ `${item.label} ${item.description ?? ''} ${item.capability} ${item.aggregate ? objectName(item.aggregate) : '系统管理'}`
152
+ .toLowerCase()
153
+ .includes(search));
154
+ });
155
+ for (const capability of visible) {
156
+ const key = capability.aggregate ?? 'system';
157
+ if (!groups.has(key))
158
+ groups.set(key, []);
159
+ groups.get(key).push(capability);
160
+ }
161
+ grid.replaceChildren();
162
+ filteredCount.textContent = `显示 ${visible.length} 项权限`;
163
+ countText.textContent = String(role.capabilities.length);
164
+ // 客户业务排在前面,框架自带的组织与运维权限放在后面,先回答业务人员最关心的问题。
165
+ const builtIn = new Set(['Dept', 'User', 'Role', 'system']);
166
+ const ordered = [...groups].sort(([a], [b]) => Number(builtIn.has(a)) - Number(builtIn.has(b)));
167
+ for (const [name, capabilities] of ordered) {
168
+ const isSystem = name === 'system';
169
+ const grantedCount = capabilities.filter((item) => granted.has(item.capability)).length;
170
+ const card = h('section', { class: 'permission-card' }, h('div', { class: 'permission-card-heading' }, h('span', { class: `group-icon ${isSystem ? 'system' : ''}` }, icon(isSystem ? 'lock' : 'grid')), h('div', { class: 'group-title' }, h('h3', {}, isSystem ? '系统管理' : objectName(name)), h('code', {}, isSystem ? 'SYSTEM' : name)), h('span', { class: 'group-progress' }, `${grantedCount} / ${capabilities.length}`)));
171
+ for (const capability of capabilities)
172
+ card.append(permissionRow(capability, granted));
173
+ grid.append(card);
174
+ }
175
+ if (!visible.length)
176
+ grid.append(emptyState('没有找到匹配的权限', '换一个关键词,或调整上方授权筛选。', 'search'));
177
+ }
178
+ /**
179
+ * 构建能力开关及可选额度编辑器,按钮依据角色状态和当前操作者能力启用。
180
+ * 切换失败恢复旧勾选值,成功更新角色快照;额度保存保留已有参数并替换 ceiling。
181
+ */
182
+ function permissionRow(capability, granted) {
183
+ const checked = granted.has(capability.capability);
184
+ const authorized = role.status === 'active' &&
185
+ can(checked ? 'Role.revokeCapability' : 'Role.grantCapability') &&
186
+ (checked || can(capability.capability));
187
+ const mode = invocation(capability);
188
+ const toggle = h('input', {
189
+ type: 'checkbox',
190
+ checked,
191
+ disabled: !authorized,
192
+ 'aria-label': `${checked ? '收回' : '授予'} ${capability.label}`,
193
+ onchange: async () => {
194
+ toggle.disabled = true;
195
+ const updated = await run(() => api('POST', `/api/role/${role.id}/${toggle.checked ? 'grant-capability' : 'revoke-capability'}`, { capability: capability.capability }), toggle.checked ? '操作权限已授予' : '操作权限已收回');
196
+ if (updated) {
197
+ updateRole(updated);
198
+ renderGroups();
199
+ }
200
+ else {
201
+ toggle.checked = checked;
202
+ toggle.disabled = !authorized;
203
+ }
204
+ },
205
+ });
206
+ const title = capability.aggregate
207
+ ? capability.label.replaceAll(capability.aggregate, objectName(capability.aggregate))
208
+ : capability.label;
209
+ const row = h('div', { class: `permission-row ${checked ? 'granted' : ''}` }, h('label', {
210
+ class: 'permission-toggle',
211
+ title: !authorized && !checked
212
+ ? '你没有该权限,无法授予其他角色'
213
+ : capability.kind === 'command'
214
+ ? capability.description || title
215
+ : title,
216
+ }, toggle, h('span', { class: 'checkbox-visual' }, icon('check')), h('span', { class: 'permission-copy' }, h('span', { class: 'permission-name' }, title), h('code', {}, capability.capability))), mode &&
217
+ h('span', { class: 'invocation-badge', title: '配置此权限后,仍需通过对应流程或外部接口调用' }, mode));
218
+ if (checked &&
219
+ (capability.capability.endsWith('.approve') ||
220
+ granted.get(capability.capability)?.ceiling !== undefined)) {
221
+ const ceiling = h('input', {
222
+ type: 'number',
223
+ min: 0,
224
+ step: 'any',
225
+ placeholder: '请设置额度',
226
+ value: String(granted.get(capability.capability)?.ceiling ?? ''),
227
+ required: true,
228
+ disabled: !can('Role.grantCapability') || !can(capability.capability) || role.status !== 'active',
229
+ 'aria-label': `${title}额度上限`,
230
+ });
231
+ const save = h('button', { type: 'submit', class: 'small', disabled: ceiling.disabled }, '保存额度');
232
+ row.append(h('form', {
233
+ class: 'limit-form',
234
+ onsubmit: async (event) => {
235
+ event.preventDefault();
236
+ const updated = await run(() => api('POST', `/api/role/${role.id}/grant-capability`, {
237
+ capability: capability.capability,
238
+ params: { ...granted.get(capability.capability), ceiling: Number(ceiling.value) },
239
+ }), '额度已保存', save);
240
+ if (updated) {
241
+ updateRole(updated);
242
+ renderGroups();
243
+ }
244
+ },
245
+ }, h('span', {}, '额度上限'), ceiling, save));
246
+ }
247
+ return row;
248
+ }
249
+ renderGroups();
250
+ }
251
+ else {
252
+ body.append(h('div', { class: 'inline-info' }, icon('info'), '操作权限决定“可以做什么”,数据范围决定“可以看哪些记录”。未单独配置的对象沿用默认范围。'));
253
+ const scopeGrid = h('div', { class: 'scope-grid' });
254
+ for (const aggregate of [
255
+ { name: '*', label: '默认数据范围', scope: { ownerField: null, deptField: null } },
256
+ ...state.catalog.aggregates,
257
+ ])
258
+ scopeGrid.append(scopeCard(aggregate));
259
+ body.append(scopeGrid);
260
+ }
261
+ /**
262
+ * 构建某聚合的范围编辑卡,区分显式范围、通配继承与未配置时 self。
263
+ * 可选组合子受聚合 owner/dept 字段约束;deptSet 保留已选 ID,即使当前部门列表无法读取。
264
+ * 提交 null 删除显式配置,成功后重画详情以反映真实服务端结果。
265
+ */
266
+ function scopeCard(aggregate) {
267
+ const current = role.scopes.find((scope) => scope.aggregate === aggregate.name);
268
+ const inherited = role.scopes.find((scope) => scope.aggregate === '*');
269
+ const editable = role.status === 'active' && can('Role.setScope');
270
+ const usable = state.catalog.combinators.filter((kind) => aggregate.name === '*' ||
271
+ kind === 'all' ||
272
+ (kind === 'self' ? aggregate.scope.ownerField : aggregate.scope.deptField));
273
+ const selected = new Set(current?.deptIds ?? []);
274
+ const departmentBox = h('div', { class: 'department-options' }, departmentResult.items.map((dept) => h('label', {}, h('input', {
275
+ type: 'checkbox',
276
+ checked: selected.has(dept.id),
277
+ disabled: !editable,
278
+ onchange: (event) => {
279
+ event.currentTarget.checked
280
+ ? selected.add(dept.id)
281
+ : selected.delete(dept.id);
282
+ dirty();
283
+ },
284
+ }), dept.name)));
285
+ if (!departmentResult.items.length)
286
+ departmentBox.append(h('p', { class: 'muted' }, can('Dept.read') ? '当前没有可选择的部门' : '需要查看部门权限才能调整部门清单,已有选择会保留。'));
287
+ const status = h('span', { class: 'save-state' }, '已保存');
288
+ const save = h('button', { class: 'small', type: 'submit', disabled: true }, '保存范围');
289
+ const description = h('p', { class: 'scope-description' });
290
+ const select = h('select', {
291
+ value: current?.combinator ?? '',
292
+ disabled: !editable,
293
+ 'aria-label': `${aggregate.label}的数据范围`,
294
+ onchange: () => {
295
+ updateDescription();
296
+ dirty();
297
+ },
298
+ }, h('option', { value: '' }, aggregate.name === '*'
299
+ ? '未配置(按仅本人处理)'
300
+ : `沿用默认:${scopes[inherited?.combinator ?? 'self'].title}`), usable.map((kind) => h('option', { value: kind }, scopes[kind].title)));
301
+ /**
302
+ * 只标记当前范围表单有未保存变化并启用保存按钮,不立即修改角色快照。
303
+ */
304
+ function dirty() {
305
+ status.textContent = '尚未保存';
306
+ status.classList.add('pending');
307
+ save.disabled = !editable;
308
+ }
309
+ /**
310
+ * 解释当前选择或继承后的范围效果;缺所需归属字段时明确无法匹配记录。
311
+ * 该提示不自动扩大授权,指定部门选择框仅在 deptSet 下显示。
312
+ */
313
+ function updateDescription() {
314
+ const kind = select.value;
315
+ description.textContent = kind ? scopes[kind].hint : '由默认数据范围决定;默认未配置时按仅本人处理。';
316
+ const effective = kind || inherited?.combinator || 'self';
317
+ // 继承允许保留统一默认值,但不能把不适用的范围解释成“可查看本部门”。
318
+ // 这里只说明真实效果,不修改授权,也不替用户扩大到全部数据。
319
+ if (aggregate.name !== '*' && effective === 'self' && !aggregate.scope.ownerField)
320
+ description.textContent += ' 此对象未设置本人归属,当前范围无法匹配记录。';
321
+ else if (aggregate.name !== '*' &&
322
+ ['dept', 'deptSubtree', 'deptSet'].includes(effective) &&
323
+ !aggregate.scope.deptField)
324
+ description.textContent += ' 此对象未设置部门归属,当前范围无法匹配记录。';
325
+ departmentBox.hidden = kind !== 'deptSet';
326
+ }
327
+ updateDescription();
328
+ const form = h('form', {
329
+ class: `scope-card ${aggregate.name === '*' ? 'default-scope' : ''}`,
330
+ onsubmit: async (event) => {
331
+ event.preventDefault();
332
+ const updated = await run(() => api('POST', `/api/role/${role.id}/set-scope`, {
333
+ aggregate: aggregate.name,
334
+ combinator: select.value || null,
335
+ ...(select.value === 'deptSet' ? { deptIds: [...selected] } : {}),
336
+ }), '数据范围已保存', save);
337
+ if (updated) {
338
+ updateRole(updated);
339
+ renderDetail();
340
+ }
341
+ },
342
+ }, h('div', { class: 'scope-heading' }, h('div', {}, h('h3', {}, aggregate.name === '*' ? aggregate.label : objectName(aggregate.name)), h('code', {}, aggregate.name === '*' ? '适用于未单独配置的业务对象' : aggregate.name)), aggregate.name === '*' && badge('默认', 'green')), select, description, departmentBox, h('div', { class: 'scope-footer' }, status, save));
343
+ return form;
344
+ }
345
+ }
346
+ renderList();
347
+ renderDetail();
348
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 个人审批待办模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
5
+ export { renderTasks } from './tasks-page.js';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 当前用户审批待办页面,读取真实任务并通过批准/拒绝接口提交决定。
3
+ * 待办分配只说明选人,最终动作仍受业务结论命令的能力、范围和硬约束;页面不直接改变单据状态。
4
+ */
5
+ import { api, run, state } from '../../shared/api.js';
6
+ import { badge, emptyState, formatTime, h, panel, table } from '../../shared/dom.js';
7
+ import { objectName } from '../../shared/labels.js';
8
+ import { navigate } from '../../shared/navigation.js';
9
+ /**
10
+ * 加载本人待办与页面需要的对象资料,展示任务、步骤和可用操作。
11
+ * 提交期间禁止重复动作,成功后刷新待办,失败保留可理解原因;空列表表示当前请求没有可见待办。
12
+ */
13
+ export async function renderTasks(main) {
14
+ const hasApprovals = state.catalog.approvals.length > 0;
15
+ if (!hasApprovals) {
16
+ main.append(panel('我的审批待办', undefined, emptyState('当前没有启用审批流程', '业务启用审批后,分配给你的任务会显示在这里。')));
17
+ return;
18
+ }
19
+ const tasks = await api('GET', '/api/approval/tasks');
20
+ if (!tasks.length) {
21
+ main.append(panel('我的审批待办', undefined, emptyState('待办已处理完毕', '新的审批任务分配给你后,会显示在这里。')));
22
+ return;
23
+ }
24
+ main.append(panel('等待你处理', `共 ${tasks.length} 项待办。审批结论会继续执行业务权限与规则校验。`, table([
25
+ [
26
+ '审批事项',
27
+ (task) => h('div', { class: 'stack-cell' }, h('strong', {}, task.title), h('small', {}, state.catalog.approvals.find((item) => item.process === task.process)?.label || '业务审批')),
28
+ ],
29
+ [
30
+ '关联单据',
31
+ (task) => h('div', { class: 'stack-cell' }, badge(objectName(task.aggregate)), h('code', {}, task.businessKey.slice(0, 8))),
32
+ ],
33
+ ['提交时间', (task) => formatTime(task.createdAt)],
34
+ [
35
+ '流程信息',
36
+ (task) => h('details', {}, h('summary', {}, '查看详情'), h('pre', {}, JSON.stringify(task.vars, null, 2))),
37
+ ],
38
+ [
39
+ '处理意见与操作',
40
+ (task) => {
41
+ const comment = h('input', {
42
+ placeholder: '填写审批意见(可选)',
43
+ 'aria-label': `${task.title}审批意见`,
44
+ });
45
+ const actions = h('div', { class: 'row-actions' });
46
+ const submit = async (action, button) => {
47
+ // 一项待办的两个结论互斥,发送期间同时禁用,避免界面重复提交相反结论。
48
+ for (const item of actions.querySelectorAll('button'))
49
+ item.disabled = true;
50
+ const result = await run(() => api('POST', `/api/approval/tasks/${task.id}/${action}`, { comment: comment.value }), action === 'approve' ? '已通过审批' : '已驳回', button);
51
+ if (result)
52
+ navigate('tasks');
53
+ else
54
+ for (const item of actions.querySelectorAll('button'))
55
+ item.disabled = false;
56
+ };
57
+ actions.append(h('button', {
58
+ class: 'small',
59
+ onclick: (event) => submit('reject', event.currentTarget),
60
+ }, '驳回'), h('button', {
61
+ class: 'small primary',
62
+ onclick: (event) => submit('approve', event.currentTarget),
63
+ }, '通过审批'));
64
+ return h('div', { class: 'task-actions' }, comment, actions);
65
+ },
66
+ ],
67
+ ], tasks)));
68
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 系统管理浏览器的统一 HTTP、会话状态和操作反馈层。
3
+ * GET 读取 JSON,POST 自动获取 CSRF 并附幂等键;401 清空会话并通知应用壳,其他失败保留业务原因。
4
+ * 本模块只消费协议,不导入服务端领域实现;SSE 试聊独立按事件流读取,DOM 输出由调用方负责安全渲染。
5
+ */
6
+ import { h, icon } from './dom.js';
7
+ /**
8
+ * 当前页面进程持有的身份、展示目录与 CSRF 令牌缓存,null 表示尚未取得或已清除。
9
+ * 只用于页面展示与请求头,不作为后端权限判断的事实来源;退出和非登录请求 401 时会清空。
10
+ */
11
+ export const state = {
12
+ me: null,
13
+ catalog: null,
14
+ csrf: null,
15
+ };
16
+ const errors = {
17
+ UNAUTHENTICATED: '登录已过期,请重新登录',
18
+ FORBIDDEN: '你没有执行此操作的权限',
19
+ NOT_FOUND: '记录不存在或不在你的数据范围内',
20
+ INVALID_INPUT: '请检查填写内容',
21
+ INVALID_TRANSITION: '当前状态不允许此操作',
22
+ CONSTRAINT_VIOLATED: '未满足操作规则',
23
+ INVARIANT_VIOLATED: '数据未满足业务规则',
24
+ CONFLICT: '数据已更新,请刷新后重试',
25
+ DOMAIN_ERROR: '业务规则未满足',
26
+ RATE_LIMITED: '操作过于频繁,请稍后再试',
27
+ };
28
+ /**
29
+ * 包含协议 code 的 HTTP 失败异常,message 为页面可显示的中文原因。
30
+ * 由 checkResponse 创建;网络错误与 JSON 解析错误会以原异常传播,调用方不能假定都是 ApiError。
31
+ */
32
+ export class ApiError extends Error {
33
+ code;
34
+ constructor(code, message) {
35
+ super(message);
36
+ this.code = code;
37
+ }
38
+ }
39
+ /** 系统管理同样使用会话、CSRF 和幂等键,不拥有绕过领域命令的写入通道。 */
40
+ /**
41
+ * 发送管理接口 JSON 请求,成功返回响应体并按 T 提供静态类型。
42
+ * POST 在缺少 CSRF 时先取令牌,每次调用生成新的幂等键;这里不自动重试,也不运行响应 schema 校验。
43
+ * 请求携带同源会话,失败交由 checkResponse 分类;T 只是调用方的类型约定,不能替代服务端验证。
44
+ */
45
+ export async function api(method, url, body) {
46
+ const headers = { accept: 'application/json' };
47
+ if (method === 'POST') {
48
+ if (!state.csrf) {
49
+ const response = await fetch('/auth/csrf', { credentials: 'include' });
50
+ if (!response.ok)
51
+ throw new Error('无法获取安全令牌,请刷新后重试');
52
+ state.csrf = (await response.json()).token;
53
+ }
54
+ headers['content-type'] = 'application/json';
55
+ headers['x-csrf-token'] = state.csrf;
56
+ headers['idempotency-key'] = crypto.randomUUID();
57
+ }
58
+ const response = await fetch(url, {
59
+ method,
60
+ credentials: 'include',
61
+ headers,
62
+ body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
63
+ });
64
+ const data = await response.json();
65
+ checkResponse(response, data, url);
66
+ return data;
67
+ }
68
+ /**
69
+ * 把非成功 HTTP 响应转换为 ApiError,优先保留业务码、字段级问题或字符串详情。
70
+ * 非登录接口 401 清空全部会话缓存并广播过期事件;登录失败保留登录页处理流程。
71
+ * 成功响应不修改状态;服务端未提供已知错误码时使用通用提示。
72
+ */
73
+ function checkResponse(response, data, url) {
74
+ if (!response.ok) {
75
+ if (response.status === 401 && url !== '/auth/login') {
76
+ state.me = null;
77
+ state.catalog = null;
78
+ state.csrf = null;
79
+ window.dispatchEvent(new Event('cage:session-expired'));
80
+ }
81
+ // 参数校验使用 issues 数组。丢弃它会让所有字段错误都退化成“请检查填写内容”。
82
+ const fields = { baseUrl: 'API 基地址', model: '模型名称', apiKey: 'API Key', apiFormat: '接口协议', contextWindowTokens: '上下文窗口', responsesCompaction: '服务端自动压缩', timeoutMs: '调用超时', label: '显示名称', version: '配置版本', description: '性格描述', avatar: '头像', address: '称呼', examples: '说话示例', defaultKey: '默认性格' };
83
+ const detail = data.domainCode || data.detail?.message || (Array.isArray(data.detail)
84
+ ? data.detail.map((issue) => `${fields[issue.path ?? ''] ?? issue.path ?? '参数'}:${issue.message ?? '格式无效'}`).join(';')
85
+ : typeof data.detail === 'string' ? data.detail : '');
86
+ const message = `${errors[data.code] ?? '服务暂时无法处理请求'}${detail ? `:${detail}` : ''}`;
87
+ throw new ApiError(data.code, message);
88
+ }
89
+ }
90
+ /** 管理预览逐段更新,沿用登录、CSRF 和错误提示;不将模型文本作为 HTML 插入。 */
91
+ /**
92
+ * 以 POST 发起可取消的 SSE 管理试聊,将完整 data 事件解析为 T 交给 onEvent。
93
+ * 只在空行分隔处解析 JSON,跨网络分片保留缓冲,未完成缓冲超过上限时拒绝继续积累。
94
+ * HTTP 错误复用统一处理;缺少事件流响应直接失败。finally 取消 reader 并释放锁,signal 中断不表示已撤销远端处理。
95
+ */
96
+ export async function postEvents(url, body, onEvent, signal) {
97
+ if (!state.csrf)
98
+ state.csrf = (await api('GET', '/auth/csrf')).token;
99
+ const response = await fetch(url, { method: 'POST', credentials: 'include', signal,
100
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'x-csrf-token': state.csrf, 'idempotency-key': crypto.randomUUID() }, body: JSON.stringify(body) });
101
+ if (!response.ok)
102
+ checkResponse(response, await response.json(), url);
103
+ if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream'))
104
+ throw new Error('试聊连接未建立,请重试');
105
+ const reader = response.body.getReader(), decoder = new TextDecoder();
106
+ let buffer = '';
107
+ try {
108
+ for (;;) {
109
+ const part = await reader.read();
110
+ if (part.done)
111
+ break;
112
+ buffer += decoder.decode(part.value, { stream: true });
113
+ if (buffer.length > 1_000_000)
114
+ throw new Error('试聊返回内容过大');
115
+ let boundary;
116
+ while ((boundary = /\r?\n\r?\n/.exec(buffer))) {
117
+ const block = buffer.slice(0, boundary.index);
118
+ buffer = buffer.slice(boundary.index + boundary[0].length);
119
+ const value = block.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
120
+ if (value)
121
+ onEvent(JSON.parse(value));
122
+ }
123
+ }
124
+ }
125
+ finally {
126
+ await reader.cancel().catch(() => { });
127
+ reader.releaseLock();
128
+ }
129
+ }
130
+ /**
131
+ * 读取当前会话能力列表以控制界面可见性;不考虑对象范围,也不授予后端执行权限。
132
+ */
133
+ export const can = (capability) => Boolean(state.me?.capabilities.includes(capability));
134
+ /**
135
+ * 向 notifications 容器追加纯文本操作反馈,5 秒后移除。
136
+ * failed 决定错误样式与 alert 语义,消息通过 DOM 辅助函数渲染,不解释为 HTML。
137
+ */
138
+ export function toast(message, failed = false) {
139
+ const item = h('div', { class: `toast ${failed ? 'error' : 'success'}`, role: failed ? 'alert' : 'status' }, icon(failed ? 'info' : 'check'), message);
140
+ document.getElementById('notifications').append(item);
141
+ setTimeout(() => item.remove(), 5000);
142
+ }
143
+ /** 失败会明确显示原因;按钮等待返回,避免界面把未保存的授权显示成成功。 */
144
+ /**
145
+ * 包装一次页面操作:等待期间禁用按钮,成功可提示文案,失败显示原因并返回 null。
146
+ * finally 恢复按钮状态;返回值只有实际 action 成功才可用于替换界面数据。
147
+ * 这里捕获异常供交互反馈,不代表事务补偿或请求重试,调用方应检查返回值后再更新 UI。
148
+ */
149
+ export async function run(action, success, button) {
150
+ if (button)
151
+ button.disabled = true;
152
+ try {
153
+ const result = await action();
154
+ if (success)
155
+ toast(success);
156
+ return result;
157
+ }
158
+ catch (error) {
159
+ toast(error instanceof TypeError ? '连接失败,请检查网络与 API 服务' : error.message, true);
160
+ return null;
161
+ }
162
+ finally {
163
+ if (button)
164
+ button.disabled = false;
165
+ }
166
+ }