@yss123/cage-admin 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +31 -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 +6 -0
  6. package/dist/catalog.js +7 -0
  7. package/dist/catalog.js.map +1 -1
  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 +82 -1
  18. package/dist/domain/org.js +98 -4
  19. package/dist/domain/org.js.map +1 -1
  20. package/dist/http.d.ts +11 -0
  21. package/dist/http.js +24 -0
  22. package/dist/http.js.map +1 -1
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +3 -2
  27. package/ui/dist/app/shell.js +39 -5
  28. package/ui/dist/app.js +10 -0
  29. package/ui/dist/modules/audit/audit-page.js +12 -0
  30. package/ui/dist/modules/audit/index.js +4 -0
  31. package/ui/dist/modules/auth/index.js +4 -0
  32. package/ui/dist/modules/auth/login-page.js +8 -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 +4 -0
  36. package/ui/dist/modules/operations/operations-page.js +15 -0
  37. package/ui/dist/modules/operations/sync-page.js +12 -0
  38. package/ui/dist/modules/org/index.js +4 -0
  39. package/ui/dist/modules/org/org-page.js +30 -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 +4 -0
  43. package/ui/dist/modules/roles/roles-page.js +40 -0
  44. package/ui/dist/modules/tasks/index.js +5 -0
  45. package/ui/dist/modules/tasks/operations-pages.js +107 -0
  46. package/ui/dist/modules/tasks/tasks-page.js +44 -12
  47. package/ui/dist/shared/api.js +84 -2
  48. package/ui/dist/shared/dom.js +36 -0
  49. package/ui/dist/shared/labels.js +29 -0
  50. package/ui/dist/shared/navigation.js +7 -0
  51. package/ui/index.html +1 -0
  52. package/ui/style.css +118 -0
@@ -1,3 +1,8 @@
1
+ /**
2
+ * 角色与授权管理界面,区分能力授予、能力参数和对象数据范围。
3
+ * 页面只按当前登录能力限制可操作项;实际防提权、状态与范围检查由服务端命令执行。
4
+ * 能力切换即时提交,范围编辑先保留草稿再保存,只有成功响应才更新已保存角色快照。
5
+ */
1
6
  import { api, can, run, state } from '../../shared/api.js';
2
7
  import { badge, emptyState, field, h, icon, modal, searchInput } from '../../shared/dom.js';
3
8
  import { invocation, objectName, scopes } from '../../shared/labels.js';
@@ -6,6 +11,10 @@ let selectedRole = null;
6
11
  let tab = 'permissions';
7
12
  let query = '';
8
13
  let filter = 'all';
14
+ /**
15
+ * 读取角色与可访问部门,构建角色列表和权限/范围详情。
16
+ * 角色、筛选词及当前选项卡为页面内状态;列表数据来自有界查询,目录来自启动阶段。
17
+ */
9
18
  export async function renderRoles(main) {
10
19
  const [result, departmentResult] = await Promise.all([
11
20
  api('GET', '/api/role?pageSize=200'),
@@ -27,6 +36,9 @@ export async function renderRoles(main) {
27
36
  if (can('Role.create'))
28
37
  sidebar.append(h('button', { class: 'button primary create-role', onclick: createRole }, icon('plus'), '新建角色'));
29
38
  main.append(h('div', { class: 'role-workspace' }, sidebar, details));
39
+ /**
40
+ * 按角色名称或 code 过滤当前加载集合,选择后同步重画详情,不改角色状态或授权。
41
+ */
30
42
  function renderList() {
31
43
  const visible = roles.filter((role) => `${role.name} ${role.code}`.toLowerCase().includes(roleQuery));
32
44
  list.replaceChildren(...visible.map((role) => h('button', {
@@ -43,6 +55,10 @@ export async function renderRoles(main) {
43
55
  if (!visible.length)
44
56
  list.append(h('p', { class: 'list-empty' }, '没有匹配的角色'));
45
57
  }
58
+ /**
59
+ * 提交新角色名称和 code,成功后把真实返回角色加入列表并选中。
60
+ * 新角色没有默认业务能力,后续需要显式授权。
61
+ */
46
62
  function createRole() {
47
63
  const name = h('input', { placeholder: '例如:采购经理', required: true, maxlength: 100 });
48
64
  const code = h('input', {
@@ -69,6 +85,10 @@ export async function renderRoles(main) {
69
85
  const dialog = modal('新建团队角色', form);
70
86
  name.focus();
71
87
  }
88
+ /**
89
+ * 为选中角色重建摘要与选项卡;已停用角色只展示配置。
90
+ * 局部 updateRole 仅接受服务端成功快照,避免界面把失败请求的临时选择当成已保存授权。
91
+ */
72
92
  function renderDetail() {
73
93
  let role = roles.find((item) => item.id === selectedRole);
74
94
  if (!role) {
@@ -117,6 +137,10 @@ export async function renderRoles(main) {
117
137
  query = value;
118
138
  renderGroups();
119
139
  }, query), filters), h('div', { class: 'permission-help' }, h('span', {}, icon('info'), '勾选后即时保存。只能授予你已拥有的权限。'), filteredCount), grid);
140
+ /**
141
+ * 根据搜索词和已授权筛选生成能力分组,业务对象排在系统对象之前。
142
+ * 授权计数来自 role.capabilities,当前显示计数来自筛选集合,两者分别表达不同含义。
143
+ */
120
144
  function renderGroups() {
121
145
  const granted = new Map(role.capabilities.map((item) => [item.capability, item.params]));
122
146
  const search = query.trim().toLowerCase();
@@ -151,6 +175,10 @@ export async function renderRoles(main) {
151
175
  if (!visible.length)
152
176
  grid.append(emptyState('没有找到匹配的权限', '换一个关键词,或调整上方授权筛选。', 'search'));
153
177
  }
178
+ /**
179
+ * 构建能力开关及可选额度编辑器,按钮依据角色状态和当前操作者能力启用。
180
+ * 切换失败恢复旧勾选值,成功更新角色快照;额度保存保留已有参数并替换 ceiling。
181
+ */
154
182
  function permissionRow(capability, granted) {
155
183
  const checked = granted.has(capability.capability);
156
184
  const authorized = role.status === 'active' &&
@@ -230,6 +258,11 @@ export async function renderRoles(main) {
230
258
  scopeGrid.append(scopeCard(aggregate));
231
259
  body.append(scopeGrid);
232
260
  }
261
+ /**
262
+ * 构建某聚合的范围编辑卡,区分显式范围、通配继承与未配置时 self。
263
+ * 可选组合子受聚合 owner/dept 字段约束;deptSet 保留已选 ID,即使当前部门列表无法读取。
264
+ * 提交 null 删除显式配置,成功后重画详情以反映真实服务端结果。
265
+ */
233
266
  function scopeCard(aggregate) {
234
267
  const current = role.scopes.find((scope) => scope.aggregate === aggregate.name);
235
268
  const inherited = role.scopes.find((scope) => scope.aggregate === '*');
@@ -265,11 +298,18 @@ export async function renderRoles(main) {
265
298
  }, h('option', { value: '' }, aggregate.name === '*'
266
299
  ? '未配置(按仅本人处理)'
267
300
  : `沿用默认:${scopes[inherited?.combinator ?? 'self'].title}`), usable.map((kind) => h('option', { value: kind }, scopes[kind].title)));
301
+ /**
302
+ * 只标记当前范围表单有未保存变化并启用保存按钮,不立即修改角色快照。
303
+ */
268
304
  function dirty() {
269
305
  status.textContent = '尚未保存';
270
306
  status.classList.add('pending');
271
307
  save.disabled = !editable;
272
308
  }
309
+ /**
310
+ * 解释当前选择或继承后的范围效果;缺所需归属字段时明确无法匹配记录。
311
+ * 该提示不自动扩大授权,指定部门选择框仅在 deptSet 下显示。
312
+ */
273
313
  function updateDescription() {
274
314
  const kind = select.value;
275
315
  description.textContent = kind ? scopes[kind].hint : '由默认数据范围决定;默认未配置时按仅本人处理。';
@@ -1 +1,6 @@
1
+ /**
2
+ * 个人审批待办模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { renderTasks } from './tasks-page.js';
6
+ export { renderApprovalRequests, renderApprovalNotices } from './operations-pages.js';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 框架自带的申请催办和站内提醒页面,任何启用审批运营的应用都可从 /admin 直接使用。
3
+ * 页面仅消费通用审批 HTTP 协议,不依赖业务领域、采购字段或数据库;状态以服务端返回为准。
4
+ * 过滤保存在 hash 查询参数,写操作完成后刷新当前页;纯文本 DOM 渲染避免把催办原因解释成 HTML。
5
+ */
6
+ import { api, run, state, toast } from '../../shared/api.js';
7
+ import { badge, emptyState, formatTime, h, panel, table } from '../../shared/dom.js';
8
+ import { objectName } from '../../shared/labels.js';
9
+ import { navigate } from '../../shared/navigation.js';
10
+ /** 显示节点总截止时间和浏览器当前时刻的提示;后端会独立判定,不把显示时差作为决定条件。 */
11
+ export function deadlineCell(dueAt) {
12
+ if (!dueAt)
13
+ return h('small', {}, '未设时限');
14
+ const late = Date.parse(dueAt) <= Date.now();
15
+ return h('div', { class: 'stack-cell' }, badge(late ? '已超时' : '办理中', late ? 'red' : 'neutral'), h('small', {}, formatTime(dueAt)));
16
+ }
17
+ /** 目录显式声明未启用时提供说明并停止加载,不将不存在的接口报错伪装为无提醒。 */
18
+ function enabled(main) {
19
+ if (state.catalog?.approvalOperations)
20
+ return true;
21
+ main.append(panel('流程协作', undefined, emptyState('当前应用未启用流程提醒', '启用后可在此查看站内提醒及本人申请。')));
22
+ return false;
23
+ }
24
+ /** 使用聚合中文名与完整单据标识,跨业务通用;不猜测应用自定义的详情 URL。 */
25
+ function subject(item) {
26
+ return h('div', { class: 'stack-cell' }, badge(objectName(item.aggregate)), h('code', { title: item.businessKey }, item.businessKey));
27
+ }
28
+ /** 刷新只重读当前页面;没有后台定时器,离开页面后不会遗留轮询或自动重放催办。 */
29
+ function refresh(page) {
30
+ return h('button', { class: 'small', onclick: () => navigate(page) }, '刷新');
31
+ }
32
+ /**
33
+ * 展示本人运行中的可催办申请。提交绑定实例 UUID 和当前步骤,原因必填,防止旧页面催办新轮次。
34
+ * 冷却期间明确给出下次时间;按钮只是提示,后端仍锁定复核。成功后显示真实接收人数并重新读取。
35
+ */
36
+ export async function renderApprovalRequests(main) {
37
+ main.classList.add('approval-workspace');
38
+ if (!enabled(main))
39
+ return;
40
+ const items = await api('GET', '/api/approval/requests');
41
+ main.append(panel('我的申请', '查看正在审批的申请,向当前办理人发送站内催办。节点时限包含领取、委派和加签所用时间。', h('div', { class: 'table-toolbar' }, h('span', {}, `当前显示 ${items.length} 项`), refresh('requests')), table([
42
+ ['申请事项', item => subject(item)],
43
+ ['当前环节', item => h('div', { class: 'stack-cell' }, ...(item.activeSteps?.map(node => h('div', { class: 'stack-cell' }, h('strong', {}, node.title), h('small', {}, node.branchPath.map(part => `${part.parallel} · ${part.branch}`).join(' / ') || '主线审批')))
44
+ ?? [h('strong', {}, item.title)]))],
45
+ ['最近截止', item => deadlineCell(item.dueAt)],
46
+ ['催办', item => {
47
+ const cooling = !!item.nextUrgeAt && Date.parse(item.nextUrgeAt) > Date.now();
48
+ const reason = h('input', { 'aria-label': `${item.title}催办原因`, placeholder: '请说明需要加快处理的原因', maxlength: 1000, required: true });
49
+ const button = h('button', { class: 'small primary', disabled: cooling, onclick: async (event) => {
50
+ reason.setCustomValidity(reason.value.trim() ? '' : '请填写催办原因');
51
+ if (!reason.reportValidity())
52
+ return;
53
+ const result = await run(() => api('POST', `/api/approval/instances/${item.id}/urge`, { expectedStep: item.currentStep, ...(item.currentSteps ? { expectedSteps: item.currentSteps } : {}), reason: reason.value.trim() }), undefined, event.currentTarget);
54
+ if (result) {
55
+ toast(`已发送 ${result.recipientCount} 条站内催办提醒`);
56
+ navigate('requests');
57
+ }
58
+ } }, cooling ? '冷却中' : '发送催办');
59
+ return h('div', { class: 'task-actions' }, reason, h('div', { class: 'row-actions' }, button), item.nextUrgeAt ? h('small', {}, `可再次催办:${formatTime(item.nextUrgeAt)}${cooling ? ',到时请刷新' : ''}`) : null);
60
+ }],
61
+ ], items, '当前没有可催办的运行申请')));
62
+ }
63
+ /**
64
+ * 分页展示当前仍与本人有关的流程提醒;URL view=unread 固定筛选,可深链接和浏览器前进后退。
65
+ * 已读写入成功才刷新;翻页沿服务端游标,即使权限过滤形成空页也保留“继续加载”。
66
+ * 列表读取不自动标已读,离开页面后忽略正在返回的追加页,避免旧页更新当前画面。
67
+ */
68
+ export async function renderApprovalNotices(main) {
69
+ main.classList.add('approval-workspace');
70
+ if (!enabled(main))
71
+ return;
72
+ const unread = new URLSearchParams(location.hash.split('?')[1] ?? '').get('view') === 'unread';
73
+ const page = `notices?view=${unread ? 'unread' : 'all'}`;
74
+ const query = `/api/approval/notices?limit=50&unreadOnly=${unread}`;
75
+ const initial = await api('GET', query);
76
+ const rows = [...initial.items];
77
+ let next = initial.nextCursor;
78
+ const content = h('div');
79
+ const more = h('button', { class: 'small', onclick: async (event) => {
80
+ if (!next)
81
+ return;
82
+ const result = await run(() => api('GET', `${query}&after=${encodeURIComponent(next)}`), undefined, event.currentTarget);
83
+ if (!result || !content.isConnected)
84
+ return;
85
+ rows.push(...result.items);
86
+ next = result.nextCursor;
87
+ draw();
88
+ } }, '继续加载');
89
+ /** 仅由已完成的读请求重建表格;按钮内容与已读时间始终来自持久状态。 */
90
+ function draw() {
91
+ content.replaceChildren(table([
92
+ ['提醒事项', item => h('div', { class: 'stack-cell' }, badge(item.kind === 'overdue' ? '已超时' : item.kind === 'due_soon' ? '即将到期' : '申请人催办', item.kind === 'overdue' ? 'red' : 'amber'), h('strong', {}, item.title), item.reason ? h('span', {}, item.reason) : h('small', {}, '请及时查看并处理当前审批任务'))],
93
+ ['关联单据', item => subject(item)],
94
+ ['节点截止', item => item.dueAt ? formatTime(item.dueAt) : '未设时限'],
95
+ ['提醒时间', item => formatTime(item.createdAt)],
96
+ ['阅读状态', item => item.readAt ? h('div', { class: 'stack-cell' }, badge('已读', 'green'), h('small', {}, formatTime(item.readAt))) :
97
+ h('button', { class: 'small', onclick: async (event) => {
98
+ const result = await run(() => api('POST', `/api/approval/notices/${item.id}/read`), '已标记为已读', event.currentTarget);
99
+ if (result)
100
+ navigate(page);
101
+ } }, '标记已读')],
102
+ ], rows, unread ? '当前没有可见的未读提醒' : '当前没有可见的流程提醒'));
103
+ more.hidden = !next;
104
+ }
105
+ draw();
106
+ main.append(panel('流程提醒', '临期、超时与申请人催办集中在这里。提醒只提示办理,不会自动作出审批决定。', h('div', { class: 'table-toolbar' }, h('div', { class: 'row-actions' }, h('a', { class: `approval-filter ${unread ? 'selected' : ''}`, href: '#/notices?view=unread', 'aria-current': unread ? 'page' : undefined }, '未读'), h('a', { class: `approval-filter ${!unread ? 'selected' : ''}`, href: '#/notices?view=all', 'aria-current': !unread ? 'page' : undefined }, '全部'), h('a', { class: 'approval-filter', href: '#/tasks' }, '前往待办')), refresh(page)), content, more));
107
+ }
@@ -1,28 +1,45 @@
1
+ /**
2
+ * 当前用户审批收件箱,合并本人、可领取和等待加签任务;管理动作与决定均通过宿主会话接口提交。
3
+ * 待办分配只说明选人,最终动作仍受业务结论命令的能力、范围和硬约束;页面不直接改变单据状态。
4
+ */
1
5
  import { api, run, state } from '../../shared/api.js';
2
6
  import { badge, emptyState, formatTime, h, panel, table } from '../../shared/dom.js';
3
7
  import { objectName } from '../../shared/labels.js';
4
8
  import { navigate } from '../../shared/navigation.js';
9
+ import { deadlineCell } from './operations-pages.js';
10
+ /**
11
+ * 加载本人待办与页面需要的对象资料,展示任务、步骤和可用操作。
12
+ * 提交期间禁止重复动作,成功后刷新待办,失败保留可理解原因;空列表表示当前请求没有可见待办。
13
+ */
5
14
  export async function renderTasks(main) {
15
+ main.classList.add('approval-workspace');
6
16
  const hasApprovals = state.catalog.approvals.length > 0;
7
17
  if (!hasApprovals) {
8
18
  main.append(panel('我的审批待办', undefined, emptyState('当前没有启用审批流程', '业务启用审批后,分配给你的任务会显示在这里。')));
9
19
  return;
10
20
  }
11
- const tasks = await api('GET', '/api/approval/tasks');
21
+ const lists = await Promise.all([
22
+ api('GET', '/api/approval/tasks'), api('GET', '/api/approval/candidate-tasks'),
23
+ api('GET', '/api/approval/tasks?status=waiting'),
24
+ ]);
25
+ const tasks = lists.flat();
12
26
  if (!tasks.length) {
13
27
  main.append(panel('我的审批待办', undefined, emptyState('待办已处理完毕', '新的审批任务分配给你后,会显示在这里。')));
14
28
  return;
15
29
  }
16
- main.append(panel('等待你处理', `共 ${tasks.length} 项待办。审批结论会继续执行业务权限与规则校验。`, table([
30
+ main.append(panel('审批收件箱', `待处理 ${lists[0].length} 项 · 可领取 ${lists[1].length} 项 · 等待加签 ${lists[2].length} 项`, table([
17
31
  [
18
32
  '审批事项',
19
- (task) => h('div', { class: 'stack-cell' }, h('strong', {}, task.title), h('small', {}, state.catalog.approvals.find((item) => item.process === task.process)?.label || '业务审批')),
33
+ (task) => h('div', { class: 'stack-cell' }, h('strong', {}, task.title), task.branchPath?.length ? h('small', {}, task.branchPath.map(part => `${part.parallel} · ${part.branch}`).join(' / ')) : null, h('small', {}, state.catalog.approvals.find((item) => item.process === task.process)?.label || '业务审批')),
20
34
  ],
21
35
  [
22
36
  '关联单据',
23
37
  (task) => h('div', { class: 'stack-cell' }, badge(objectName(task.aggregate)), h('code', {}, task.businessKey.slice(0, 8))),
24
38
  ],
25
39
  ['提交时间', (task) => formatTime(task.createdAt)],
40
+ ['办理截止', task => deadlineCell(task.dueAt)],
41
+ ['处理阶段', task => badge(task.status === 'waiting' ? '等待加签' : task.assigneeId === null ? '可领取' : task.delegation === 'pending' ? '受托处理中' :
42
+ task.signPosition === 'before' ? '前签' : task.signPosition === 'after' ? '后签' : task.signPosition === 'parallel' ? '并行加签' : '待处理')],
26
43
  [
27
44
  '流程信息',
28
45
  (task) => h('details', {}, h('summary', {}, '查看详情'), h('pre', {}, JSON.stringify(task.vars, null, 2))),
@@ -30,29 +47,44 @@ export async function renderTasks(main) {
30
47
  [
31
48
  '处理意见与操作',
32
49
  (task) => {
50
+ if (task.status === 'waiting')
51
+ return h('small', {}, '前置签署完成后恢复处理');
33
52
  const comment = h('input', {
34
- placeholder: '填写审批意见(可选)',
53
+ placeholder: '审批意见;领取、释放或归还时必填',
35
54
  'aria-label': `${task.title}审批意见`,
55
+ maxlength: 1000,
36
56
  });
37
57
  const actions = h('div', { class: 'row-actions' });
38
58
  const submit = async (action, button) => {
59
+ const management = ['claim', 'unclaim', 'resolve'].includes(action);
60
+ comment.setCustomValidity(management && !comment.value.trim() ? '请填写操作原因' : '');
61
+ if (!comment.reportValidity())
62
+ return;
39
63
  // 一项待办的两个结论互斥,发送期间同时禁用,避免界面重复提交相反结论。
40
64
  for (const item of actions.querySelectorAll('button'))
41
65
  item.disabled = true;
42
- const result = await run(() => api('POST', `/api/approval/tasks/${task.id}/${action}`, { comment: comment.value }), action === 'approve' ? '已通过审批' : '已驳回', button);
66
+ const result = await run(() => api('POST', `/api/approval/tasks/${task.id}/${action}`, { ...(management ? { reason: comment.value } : { comment: comment.value }), expectedRevision: task.revision }), { approve: '已通过审批', reject: '已驳回', claim: '已领取任务', unclaim: '已释放到候选池', resolve: '已归还原审批人' }[action], button);
43
67
  if (result)
44
68
  navigate('tasks');
45
69
  else
46
70
  for (const item of actions.querySelectorAll('button'))
47
71
  item.disabled = false;
48
72
  };
49
- actions.append(h('button', {
50
- class: 'small',
51
- onclick: (event) => submit('reject', event.currentTarget),
52
- }, '驳回'), h('button', {
53
- class: 'small primary',
54
- onclick: (event) => submit('approve', event.currentTarget),
55
- }, '通过审批'));
73
+ if (task.assigneeId === null || task.delegation === 'pending') {
74
+ const action = task.assigneeId === null ? 'claim' : 'resolve';
75
+ actions.append(h('button', { class: 'small primary', onclick: event => submit(action, event.currentTarget) }, action === 'claim' ? '领取任务' : '归还审批人'));
76
+ }
77
+ else {
78
+ if (task.assignment === 'candidate')
79
+ actions.append(h('button', { class: 'small', onclick: event => submit('unclaim', event.currentTarget) }, '释放任务'));
80
+ actions.append(h('button', {
81
+ class: 'small',
82
+ onclick: (event) => submit('reject', event.currentTarget),
83
+ }, '驳回'), h('button', {
84
+ class: 'small primary',
85
+ onclick: (event) => submit('approve', event.currentTarget),
86
+ }, '通过审批'));
87
+ }
56
88
  return h('div', { class: 'task-actions' }, comment, actions);
57
89
  },
58
90
  ],
@@ -1,4 +1,13 @@
1
+ /**
2
+ * 系统管理浏览器的统一 HTTP、会话状态和操作反馈层。
3
+ * GET 读取 JSON,POST 自动获取 CSRF 并附幂等键;401 清空会话并通知应用壳,其他失败保留业务原因。
4
+ * 本模块只消费协议,不导入服务端领域实现;SSE 试聊独立按事件流读取,DOM 输出由调用方负责安全渲染。
5
+ */
1
6
  import { h, icon } from './dom.js';
7
+ /**
8
+ * 当前页面进程持有的身份、展示目录与 CSRF 令牌缓存,null 表示尚未取得或已清除。
9
+ * 只用于页面展示与请求头,不作为后端权限判断的事实来源;退出和非登录请求 401 时会清空。
10
+ */
2
11
  export const state = {
3
12
  me: null,
4
13
  catalog: null,
@@ -16,6 +25,10 @@ const errors = {
16
25
  DOMAIN_ERROR: '业务规则未满足',
17
26
  RATE_LIMITED: '操作过于频繁,请稍后再试',
18
27
  };
28
+ /**
29
+ * 包含协议 code 的 HTTP 失败异常,message 为页面可显示的中文原因。
30
+ * 由 checkResponse 创建;网络错误与 JSON 解析错误会以原异常传播,调用方不能假定都是 ApiError。
31
+ */
19
32
  export class ApiError extends Error {
20
33
  code;
21
34
  constructor(code, message) {
@@ -24,6 +37,11 @@ export class ApiError extends Error {
24
37
  }
25
38
  }
26
39
  /** 系统管理同样使用会话、CSRF 和幂等键,不拥有绕过领域命令的写入通道。 */
40
+ /**
41
+ * 发送管理接口 JSON 请求,成功返回响应体并按 T 提供静态类型。
42
+ * POST 在缺少 CSRF 时先取令牌,每次调用生成新的幂等键;这里不自动重试,也不运行响应 schema 校验。
43
+ * 请求携带同源会话,失败交由 checkResponse 分类;T 只是调用方的类型约定,不能替代服务端验证。
44
+ */
27
45
  export async function api(method, url, body) {
28
46
  const headers = { accept: 'application/json' };
29
47
  if (method === 'POST') {
@@ -44,6 +62,15 @@ export async function api(method, url, body) {
44
62
  body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
45
63
  });
46
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) {
47
74
  if (!response.ok) {
48
75
  if (response.status === 401 && url !== '/auth/login') {
49
76
  state.me = null;
@@ -51,19 +78,74 @@ export async function api(method, url, body) {
51
78
  state.csrf = null;
52
79
  window.dispatchEvent(new Event('cage:session-expired'));
53
80
  }
54
- const detail = data.domainCode || data.detail?.message || (typeof data.detail === 'string' ? data.detail : '');
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 : '');
55
86
  const message = `${errors[data.code] ?? '服务暂时无法处理请求'}${detail ? `:${detail}` : ''}`;
56
87
  throw new ApiError(data.code, message);
57
88
  }
58
- return data;
59
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
+ */
60
133
  export const can = (capability) => Boolean(state.me?.capabilities.includes(capability));
134
+ /**
135
+ * 向 notifications 容器追加纯文本操作反馈,5 秒后移除。
136
+ * failed 决定错误样式与 alert 语义,消息通过 DOM 辅助函数渲染,不解释为 HTML。
137
+ */
61
138
  export function toast(message, failed = false) {
62
139
  const item = h('div', { class: `toast ${failed ? 'error' : 'success'}`, role: failed ? 'alert' : 'status' }, icon(failed ? 'info' : 'check'), message);
63
140
  document.getElementById('notifications').append(item);
64
141
  setTimeout(() => item.remove(), 5000);
65
142
  }
66
143
  /** 失败会明确显示原因;按钮等待返回,避免界面把未保存的授权显示成成功。 */
144
+ /**
145
+ * 包装一次页面操作:等待期间禁用按钮,成功可提示文案,失败显示原因并返回 null。
146
+ * finally 恢复按钮状态;返回值只有实际 action 成功才可用于替换界面数据。
147
+ * 这里捕获异常供交互反馈,不代表事务补偿或请求重试,调用方应检查返回值后再更新 UI。
148
+ */
67
149
  export async function run(action, success, button) {
68
150
  if (button)
69
151
  button.disabled = true;
@@ -1,4 +1,9 @@
1
1
  /** 统一用 DOM/textContent 构建界面;业务名称、错误、审计差异不会被当作 HTML 执行。 */
2
+ /**
3
+ * 创建指定标签并递归追加 children,最后赋属性以保证 select 能匹配已插入的 option。
4
+ * value/checked 使用 DOM 属性,aria 属性保留字符串语义;事件键去除 on 前缀后注册监听。
5
+ * 返回对应标签类型的未挂载节点,不做输入业务校验或权限判断。
6
+ */
2
7
  export function h(tag, attrs = {}, ...children) {
3
8
  const element = document.createElement(tag);
4
9
  const append = (child) => {
@@ -50,6 +55,10 @@ const paths = {
50
55
  lock: ['M5 10h14v11H5zM8 10V7a4 4 0 0 1 8 0v3M12 14v3'],
51
56
  };
52
57
  /** 线条图标是本地 SVG,无字体、CDN 或图片下载依赖。 */
58
+ /**
59
+ * 从本地 paths 表生成装饰性 SVG,继承 currentColor 并设置 aria-hidden。
60
+ * name 必须来自已定义图标键;此函数不访问网络或加载外部 SVG。
61
+ */
53
62
  export function icon(name) {
54
63
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
55
64
  for (const [key, value] of Object.entries({
@@ -70,20 +79,43 @@ export function icon(name) {
70
79
  }
71
80
  return svg;
72
81
  }
82
+ /**
83
+ * 把表单标签、控件和可选提示组合为一个 label,控件状态仍由调用页面维护。
84
+ */
73
85
  export const field = (label, control, hint) => h('label', { class: 'field' }, h('span', {}, label), control, hint && h('small', {}, hint));
86
+ /**
87
+ * 将存在的时间字符串按浏览器本地时区显示为中文日期时间,null 显示占位符。
88
+ */
74
89
  export const formatTime = (time) => time ? new Date(time).toLocaleString('zh-CN', { hour12: false }) : '—';
90
+ /**
91
+ * 生成状态徽标,tone 选择既有 CSS 色调;text 作为文本插入,徽标不判断业务终态。
92
+ */
75
93
  export const badge = (text, tone = 'neutral') => h('span', { class: `badge ${tone}` }, text);
94
+ /**
95
+ * 以列标题与单元格渲染函数构造表格;空行集显示跨列提示。
96
+ * rows 是调用方已取得的数据,不在这里分页、过滤或请求服务器;单元格仍通过安全 DOM 子项追加。
97
+ */
76
98
  export function table(columns, rows, empty = '暂无记录') {
77
99
  return h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, columns.map(([name]) => h('th', { scope: 'col' }, name)))), h('tbody', {}, rows.length
78
100
  ? rows.map((row) => h('tr', {}, columns.map(([, cell]) => h('td', {}, cell(row)))))
79
101
  : h('tr', {}, h('td', { colspan: columns.length, class: 'table-empty' }, empty)))));
80
102
  }
103
+ /**
104
+ * 构造含图标、标题和说明的空态块;由调用方区分无记录、无权限和未配置等原因。
105
+ */
81
106
  export function emptyState(title, message, glyph = 'inbox') {
82
107
  return h('div', { class: 'empty-state' }, h('span', { class: 'empty-icon' }, icon(glyph)), h('h3', {}, title), h('p', {}, message));
83
108
  }
109
+ /**
110
+ * 组合带标题、可选说明和内容的 section,返回未挂载面板供页面自由布局。
111
+ */
84
112
  export function panel(title, description, ...children) {
85
113
  return h('section', { class: 'panel' }, h('div', { class: 'panel-heading' }, h('div', {}, h('h2', {}, title), description && h('p', {}, description))), children);
86
114
  }
115
+ /**
116
+ * 创建搜索输入并将每次 input 的当前值交给 onInput。
117
+ * 默认不防抖、不请求服务器,调用者决定本地过滤或发起查询。
118
+ */
87
119
  export function searchInput(placeholder, onInput, value = '') {
88
120
  const input = h('input', {
89
121
  type: 'search',
@@ -94,6 +126,10 @@ export function searchInput(placeholder, onInput, value = '') {
94
126
  });
95
127
  return h('div', { class: 'search-field' }, icon('search'), input);
96
128
  }
129
+ /**
130
+ * 把原生 dialog 挂到 document.body 并以模态方式打开,返回实例便于提交后关闭。
131
+ * close 事件只监听一次并移除节点,避免已关闭表单长期留在 DOM;不会替表单提交或保存草稿。
132
+ */
97
133
  export function modal(title, content) {
98
134
  const dialog = h('dialog', { class: 'modal' }, h('div', { class: 'modal-heading' }, h('h2', {}, title), h('button', { type: 'button', class: 'icon-button', 'aria-label': '关闭窗口', onclick: () => dialog.close() }, '×')), content);
99
135
  dialog.addEventListener('close', () => dialog.remove(), { once: true });
@@ -1,4 +1,11 @@
1
+ /**
2
+ * 把目录技术标识映射为系统管理页面的中文展示文字。
3
+ * 目录由应用启动阶段加载,函数按现有 state.catalog 查找;默认文案只影响显示,不改能力标识、状态或授权规则。
4
+ */
1
5
  import { state } from './api.js';
6
+ /**
7
+ * 范围组合子的固定名称和说明,供角色范围编辑器复用。实际可见记录仍由聚合字段与服务端授权决定。
8
+ */
2
9
  export const scopes = {
3
10
  self: { title: '仅本人', hint: '仅查看归属于自己的记录' },
4
11
  dept: { title: '本部门', hint: '查看本人所在部门的记录' },
@@ -6,10 +13,17 @@ export const scopes = {
6
13
  deptSet: { title: '指定部门', hint: '仅查看所选部门的记录' },
7
14
  all: { title: '全部数据', hint: '查看当前租户内的全部记录' },
8
15
  };
16
+ /**
17
+ * 按聚合标识查找中文 label/description,没有独立中文名时返回通用对象名。
18
+ */
9
19
  export function objectName(name) {
10
20
  const object = state.catalog.aggregates.find((item) => item.name === name);
11
21
  return object?.label && object.label !== name ? object.label : object?.description || '业务对象';
12
22
  }
23
+ /**
24
+ * 先使用能力目录解释操作名,再处理不属于目录的登录与运维审计动作。
25
+ * 找不到映射时返回通用名称,原始 capability 仍应保留供诊断。
26
+ */
13
27
  export function capabilityName(capability) {
14
28
  const item = state.catalog.capabilities.find((item) => item.capability === capability);
15
29
  if (item)
@@ -24,6 +38,9 @@ export function capabilityName(capability) {
24
38
  };
25
39
  return operations[capability] || '业务操作';
26
40
  }
41
+ /**
42
+ * 为不能由 user 直接调用的命令生成流程来源提示;含 user 或没有调用身份限制时返回空串。
43
+ */
27
44
  export function invocation(capability) {
28
45
  if (!capability.invokedBy || capability.invokedBy.includes('user'))
29
46
  return '';
@@ -36,6 +53,9 @@ export function invocation(capability) {
36
53
  };
37
54
  return capability.invokedBy.map((item) => names[item] || '自动流程').join(' / ') + '调用';
38
55
  }
56
+ /**
57
+ * 组织与运行记录的常见状态文案表;展示映射不推断事件已完成或远端调用成功。
58
+ */
39
59
  export const statusNames = {
40
60
  active: '已启用',
41
61
  disabled: '已停用',
@@ -50,6 +70,15 @@ export const statusNames = {
50
70
  dead: '已进入死信',
51
71
  retry: '等待重试',
52
72
  };
73
+ /**
74
+ * 返回常见状态的中文说明,未知值显示待确认,避免将新状态误写为成功。
75
+ */
53
76
  export const statusName = (status) => statusNames[status] || '待确认';
77
+ /**
78
+ * 从项目图谱 labels 查事件中文名,缺配置时只给通用说明。
79
+ */
54
80
  export const eventName = (name) => state.catalog.labels[`event:${name}`] || '业务事件';
81
+ /**
82
+ * 从当前目录查定时任务展示名;不读取调度执行状态或任务结果。
83
+ */
55
84
  export const jobName = (name) => state.catalog.jobs.find((item) => item.name === name)?.label || '定时任务';
@@ -1,4 +1,11 @@
1
+ /**
2
+ * 管理模块共享的导航意图出口,避免页面反向依赖应用壳。
3
+ * 改变 hash 触发壳的 hashchange;当前页面再次导航则派发刷新事件重新读取数据。
4
+ */
1
5
  // 事件让模块只发出导航意图,不反向导入应用壳。
6
+ /**
7
+ * 进入指定管理页面或刷新同一页面。page 是应用壳识别的页面键,不是任意外部 URL。
8
+ */
2
9
  export function navigate(page) {
3
10
  if (location.hash !== `#/${page}`)
4
11
  location.hash = `#/${page}`;
package/ui/index.html CHANGED
@@ -1,4 +1,5 @@
1
1
  <!doctype html>
2
+ <!-- 系统管理浏览器文档入口。应用脚本挂载到 app,通知层由共享反馈工具管理;页面数据通过认证后的接口加载。 -->
2
3
  <html lang="zh-CN">
3
4
  <head>
4
5
  <meta charset="utf-8" />