@yss123/cage-admin 0.1.0 → 0.2.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/ui/app.js DELETED
@@ -1,339 +0,0 @@
1
- // /admin 系统管理前端:独立子应用,不依赖业务前端用 React 还是 Vue。
2
- // 这些页面自己也走内核:改组织和授权就是调用生成的命令路由,同样被鉴权、写审计。
3
- // 所有内容都用 textContent 写入,不拼接 HTML。
4
-
5
- const state = { me: null, catalog: null, csrf: null };
6
-
7
- // ---------------- 小工具 ----------------
8
-
9
- function h(tag, attrs = {}, ...children) {
10
- const el = document.createElement(tag);
11
- for (const [k, v] of Object.entries(attrs || {})) {
12
- if (v === undefined || v === null || v === false) continue;
13
- if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
14
- else if (k === 'class') el.className = v;
15
- else if (k === 'value') el.value = v;
16
- else if (k === 'checked') el.checked = !!v;
17
- else el.setAttribute(k, v === true ? '' : String(v));
18
- }
19
- for (const c of children.flat()) {
20
- if (c === null || c === undefined || c === false) continue;
21
- el.append(c instanceof Node ? c : document.createTextNode(String(c)));
22
- }
23
- return el;
24
- }
25
-
26
- function toast(msg, bad = false) {
27
- const t = h('div', { class: `toast ${bad ? 'bad' : 'ok'}` }, msg);
28
- document.body.append(t);
29
- setTimeout(() => t.remove(), 4000);
30
- }
31
-
32
- const uuid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random());
33
-
34
- async function csrf(refresh = false) {
35
- if (state.csrf && !refresh) return state.csrf;
36
- const r = await fetch('/auth/csrf', { credentials: 'include' });
37
- state.csrf = (await r.json()).token;
38
- return state.csrf;
39
- }
40
-
41
- async function api(method, url, body) {
42
- const headers = { accept: 'application/json' };
43
- if (method !== 'GET') {
44
- headers['content-type'] = 'application/json';
45
- headers['x-csrf-token'] = await csrf();
46
- headers['idempotency-key'] = uuid();
47
- }
48
- const res = await fetch(url, { method, credentials: 'include', headers, body: method === 'GET' ? undefined : JSON.stringify(body ?? {}) });
49
- const text = await res.text();
50
- let data = null;
51
- try { data = text ? JSON.parse(text) : null; } catch { data = text; }
52
- if (!res.ok) {
53
- if (res.status === 401 && url !== '/auth/login') { state.me = null; render(); }
54
- const msg = errorText(data);
55
- const err = new Error(msg);
56
- err.data = data;
57
- throw err;
58
- }
59
- return data;
60
- }
61
-
62
- const ERR = {
63
- UNAUTHENTICATED: '未登录或会话已过期', FORBIDDEN: '没有权限', NOT_FOUND: '不存在或不在你的数据范围内',
64
- INVALID_INPUT: '输入不正确', INVALID_TRANSITION: '当前状态不允许这个操作', CONSTRAINT_VIOLATED: '不满足规则',
65
- INVARIANT_VIOLATED: '数据不满足不变量', CONFLICT: '数据已被修改,请刷新', DOMAIN_ERROR: '业务错误', RATE_LIMITED: '操作太频繁',
66
- };
67
- function errorText(d) {
68
- if (!d || typeof d !== 'object') return String(d ?? '请求失败');
69
- const base = ERR[d.code] ?? d.code;
70
- const extra = d.domainCode ?? d.detail?.message ?? (typeof d.detail === 'string' ? d.detail : '');
71
- return extra ? `${base}:${extra}` : base;
72
- }
73
-
74
- async function run(fn, okMsg) {
75
- try {
76
- const r = await fn();
77
- if (okMsg) toast(okMsg);
78
- return r;
79
- } catch (e) {
80
- toast(e.message, true);
81
- return null;
82
- }
83
- }
84
-
85
- const can = (cap) => !!state.me?.capabilities?.includes(cap);
86
- const fmt = (t) => (t ? new Date(t).toLocaleString() : '');
87
- const table = (cols, rows) =>
88
- h('table', {}, h('thead', {}, h('tr', {}, cols.map((c) => h('th', {}, c[0])))),
89
- h('tbody', {}, rows.length ? rows.map((r) => h('tr', {}, cols.map((c) => h('td', {}, c[1](r))))) : h('tr', {}, h('td', { colspan: cols.length, class: 'muted' }, '暂无数据'))));
90
-
91
- // ---------------- 页面 ----------------
92
-
93
- const pages = {
94
- tasks: { title: '我的待办', render: renderTasks },
95
- org: { title: '用户与部门', render: renderOrg, need: ['User.read', 'Dept.read'] },
96
- roles: { title: '角色与授权', render: renderRoles, need: ['Role.read'] },
97
- audit: { title: '审计查询', render: renderAudit, need: ['Sys.audit.read'] },
98
- ops: { title: '异步任务', render: renderOps, need: ['Sys.ops.read'] },
99
- sync: { title: '第三方同步', render: renderSync, need: ['Sys.ops.read'] },
100
- };
101
-
102
- async function renderTasks(main) {
103
- const sec = h('section', {}, h('h2', {}, '我的待办'));
104
- main.append(sec);
105
- let tasks = [];
106
- try { tasks = await api('GET', '/api/approval/tasks'); } catch (e) { sec.append(h('p', { class: 'muted' }, '没有启用代码内审批路由,或暂无待办。')); return; }
107
- sec.append(table([
108
- ['流程', (t) => t.process], ['步骤', (t) => t.title], ['单据', (t) => h('code', {}, `${t.aggregate} ${t.businessKey.slice(0, 8)}`)],
109
- ['流程变量', (t) => h('pre', {}, JSON.stringify(t.vars))], ['时间', (t) => fmt(t.createdAt)],
110
- ['操作', (t) => {
111
- const comment = h('input', { placeholder: '意见(可选)' });
112
- return h('div', { class: 'row' }, comment,
113
- h('button', { class: 'primary', onclick: async () => { if (await run(() => api('POST', `/api/approval/tasks/${t.id}/approve`, { comment: comment.value }), '已同意')) go('tasks'); } }, '同意'),
114
- h('button', { class: 'danger', onclick: async () => { if (await run(() => api('POST', `/api/approval/tasks/${t.id}/reject`, { comment: comment.value }), '已驳回')) go('tasks'); } }, '驳回'));
115
- }],
116
- ], tasks));
117
- }
118
-
119
- async function renderOrg(main) {
120
- const [depts, users, roles] = await Promise.all([
121
- api('GET', '/api/dept?pageSize=200&sort=createdAt'),
122
- api('GET', '/api/user?pageSize=200&status=active,disabled'),
123
- can('Role.read') ? api('GET', '/api/role?pageSize=200') : Promise.resolve({ items: [] }),
124
- ]);
125
- const deptName = new Map(depts.items.map((d) => [d.id, d.name]));
126
- const userName = new Map(users.items.map((u) => [u.id, `${u.name}(${u.username})`]));
127
- const depth = (d) => Math.max(0, String(d.ancestors).split('/').filter(Boolean).length - 1);
128
- const deptSel = (val) => h('select', {}, depts.items.map((d) => h('option', { value: d.id, selected: d.id === val }, `${' '.repeat(depth(d))}${d.name}`)));
129
-
130
- // 部门
131
- const ds = h('section', {}, h('h2', {}, '部门'));
132
- if (can('Dept.create')) {
133
- const name = h('input', { placeholder: '部门名称', required: true });
134
- const parent = deptSel();
135
- ds.append(h('form', { class: 'row', onsubmit: async (e) => { e.preventDefault(); if (await run(() => api('POST', '/api/dept/create', { name: name.value, parentId: parent.value }), '已新建')) go('org'); } },
136
- name, h('span', { class: 'muted' }, '上级'), parent, h('button', { class: 'primary' }, '新建部门')));
137
- }
138
- ds.append(table([
139
- ['名称', (d) => `${' '.repeat(depth(d))}${d.name}`], ['编码', (d) => d.code ?? ''],
140
- ['负责人', (d) => {
141
- if (!can('Dept.update')) return userName.get(d.leaderId) ?? '';
142
- const sel = h('select', { onchange: () => run(() => api('POST', `/api/dept/${d.id}/update`, { leaderId: sel.value || null }), '已保存') },
143
- h('option', { value: '' }, '(无)'), users.items.map((u) => h('option', { value: u.id, selected: u.id === d.leaderId }, userName.get(u.id))));
144
- return sel;
145
- }],
146
- ['状态', (d) => d.status],
147
- ], depts.items));
148
- main.append(ds);
149
-
150
- // 用户
151
- const us = h('section', {}, h('h2', {}, '用户'));
152
- if (can('User.create')) {
153
- const username = h('input', { placeholder: '用户名', required: true });
154
- const name = h('input', { placeholder: '姓名', required: true });
155
- const dept = deptSel();
156
- us.append(h('form', { class: 'row', onsubmit: async (e) => { e.preventDefault(); if (await run(() => api('POST', '/api/user/create', { username: username.value, name: name.value, deptId: dept.value }), '已新建,默认没有任何角色')) go('org'); } },
157
- username, name, dept, h('button', { class: 'primary' }, '新建用户')));
158
- }
159
- const roleName = new Map(roles.items.map((r) => [r.id, r.name]));
160
- us.append(table([
161
- ['用户名', (u) => u.username], ['姓名', (u) => u.name], ['部门', (u) => deptName.get(u.deptId) ?? u.deptId],
162
- ['角色', (u) => {
163
- if (!can('User.assignRoles') || u.status !== 'active') return u.roleIds.map((r) => h('span', { class: 'tag' }, roleName.get(r) ?? r.slice(0, 8)));
164
- const sel = h('select', { multiple: true, size: Math.min(4, Math.max(2, roles.items.length)) },
165
- roles.items.filter((r) => r.status === 'active').map((r) => h('option', { value: r.id, selected: u.roleIds.includes(r.id) }, r.name)));
166
- return h('div', { class: 'row' }, sel, h('button', { onclick: () => run(() => api('POST', `/api/user/${u.id}/assign-roles`, { roleIds: [...sel.selectedOptions].map((o) => o.value) }), '角色已更新') }, '保存'));
167
- }],
168
- ['状态', (u) => h('span', { class: u.status === 'active' ? 'ok' : 'bad' }, u.status)],
169
- ['操作', (u) => h('div', { class: 'row' },
170
- can('User.setPassword') && u.status === 'active' ? h('button', { onclick: () => { const p = prompt(`为 ${u.username} 设置新密码(至少 8 位)`); if (p) run(() => api('POST', `/api/user/${u.id}/set-password`, { password: p }), '密码已重置'); } }, '重置密码') : null,
171
- u.status === 'active' && can('User.disable') ? h('button', { class: 'danger', onclick: async () => { if (await run(() => api('POST', `/api/user/${u.id}/disable`), '已停用')) go('org'); } }, '停用') : null,
172
- u.status === 'disabled' && can('User.enable') ? h('button', { onclick: async () => { if (await run(() => api('POST', `/api/user/${u.id}/enable`), '已启用')) go('org'); } }, '启用') : null)],
173
- ], users.items));
174
- main.append(us);
175
- }
176
-
177
- let selectedRole = null;
178
- async function renderRoles(main) {
179
- state.catalog ??= await api('GET', '/api/sys/catalog');
180
- const roles = (await api('GET', '/api/role?pageSize=200')).items;
181
- const wrap = h('div', { class: 'split' });
182
- const left = h('section', {}, h('h2', {}, '角色'));
183
- if (can('Role.create')) {
184
- const code = h('input', { placeholder: '编码,例如 purchaser', required: true, pattern: '[a-z][a-z0-9_]*' });
185
- const name = h('input', { placeholder: '名称', required: true });
186
- left.append(h('form', { class: 'row', onsubmit: async (e) => { e.preventDefault(); const r = await run(() => api('POST', '/api/role/create', { code: code.value, name: name.value }), '已新建,默认没有任何能力'); if (r) { selectedRole = r.id; go('roles'); } } }, code, name, h('button', { class: 'primary' }, '新建')));
187
- }
188
- for (const r of roles) {
189
- left.append(h('div', { class: `list-item ${r.id === selectedRole ? 'on' : ''}`, onclick: () => { selectedRole = r.id; go('roles'); } }, r.name, ' ', h('span', { class: 'muted' }, r.code), r.status !== 'active' ? h('span', { class: 'tag' }, r.status) : null));
190
- }
191
- wrap.append(left);
192
- const role = roles.find((r) => r.id === selectedRole);
193
- const right = h('section', {});
194
- wrap.append(right);
195
- main.append(wrap);
196
- if (!role) { right.append(h('p', { class: 'muted' }, '选择一个角色,勾选能力点、设置数据范围。能力目录由代码派生,这里只能在目录里勾选。')); return; }
197
- right.append(h('h2', {}, `${role.name}(${role.code})`));
198
- const editable = role.status === 'active' && can('Role.grantCapability');
199
- const has = new Map(role.capabilities.map((c) => [c.capability, c.params]));
200
-
201
- // 数据范围
202
- right.append(h('h3', {}, '数据范围(未配置的聚合按「仅本人」)'));
203
- const scopeOf = new Map(role.scopes.map((s) => [s.aggregate, s]));
204
- const combName = { self: '仅本人', dept: '本部门', deptSubtree: '本部门及下级', deptSet: '指定部门', all: '全部' };
205
- right.append(table([
206
- ['聚合', (a) => a.name === '*' ? '所有聚合(*)' : `${a.description ?? a.name}(${a.name})`],
207
- ['范围', (a) => {
208
- const cur = scopeOf.get(a.name);
209
- if (!can('Role.setScope') || role.status !== 'active') return cur ? combName[cur.combinator] : h('span', { class: 'muted' }, '未配置');
210
- const usable = state.catalog.combinators.filter((c) => c === 'all' || (c === 'self' ? a.scope?.ownerField : a.scope?.deptField) || a.name === '*');
211
- return h('select', { onchange: (e) => run(() => api('POST', `/api/role/${role.id}/set-scope`, { aggregate: a.name, combinator: e.target.value || null }), '范围已更新') },
212
- h('option', { value: '' }, '(未配置)'), usable.filter((c) => c !== 'deptSet').map((c) => h('option', { value: c, selected: cur?.combinator === c }, combName[c])));
213
- }],
214
- ], [{ name: '*', scope: {} }, ...state.catalog.aggregates]));
215
-
216
- // 能力点
217
- right.append(h('h3', {}, '能力点(不能授出自己没有的能力点)'));
218
- const groups = new Map();
219
- for (const c of state.catalog.capabilities) {
220
- const g = c.aggregate ?? '系统';
221
- if (!groups.has(g)) groups.set(g, []);
222
- groups.get(g).push(c);
223
- }
224
- for (const [g, caps] of groups) {
225
- right.append(h('h3', {}, g));
226
- const box = h('div', { class: 'caps' });
227
- for (const c of caps) {
228
- const checked = has.has(c.capability);
229
- const mine = can(c.capability);
230
- const param = c.capability.endsWith('.approve')
231
- ? h('input', { type: 'number', placeholder: '额度', style: 'width:110px', value: has.get(c.capability)?.ceiling ?? '', disabled: !editable || !checked,
232
- onchange: (e) => run(() => api('POST', `/api/role/${role.id}/grant-capability`, { capability: c.capability, params: { ceiling: Number(e.target.value) } }), '额度已更新') })
233
- : null;
234
- box.append(h('label', { title: c.description ?? '' },
235
- h('input', { type: 'checkbox', checked, disabled: !editable || (!checked && !mine),
236
- onchange: async (e) => {
237
- const url = e.target.checked ? `/api/role/${role.id}/grant-capability` : `/api/role/${role.id}/revoke-capability`;
238
- if (!(await run(() => api('POST', url, { capability: c.capability }), e.target.checked ? '已授予' : '已收回'))) e.target.checked = !e.target.checked;
239
- else go('roles');
240
- } }),
241
- ' ', c.capability, c.invokedBy && !c.invokedBy.includes('user') ? h('span', { class: 'tag' }, c.invokedBy.join('/')) : null, ' ', param));
242
- }
243
- right.append(box);
244
- }
245
- }
246
-
247
- async function renderAudit(main) {
248
- const f = { actor: h('input', { placeholder: '操作人 id' }), aggregate: h('input', { placeholder: '聚合,例如 PurchaseOrder' }), aggregateId: h('input', { placeholder: '对象 id' }), command: h('input', { placeholder: '命令,例如 PurchaseOrder.approve' }),
249
- kind: h('select', {}, ['', 'write', 'read', 'denied', 'ops'].map((k) => h('option', { value: k }, k || '全部类型'))) };
250
- const out = h('div');
251
- const load = async () => {
252
- const qs = new URLSearchParams(Object.entries(f).filter(([, el]) => el.value).map(([k, el]) => [k, el.value]));
253
- const r = await run(() => api('GET', `/api/sys/audit?${qs}`));
254
- out.replaceChildren(table([
255
- ['时间', (a) => fmt(a.at)], ['类型', (a) => a.kind], ['操作人', (a) => h('code', {}, `${a.actor_kind}:${String(a.actor).slice(0, 18)}`)],
256
- ['代表', (a) => a.on_behalf_of ? h('code', {}, a.on_behalf_of.slice(0, 8)) : ''], ['来源', (a) => a.source], ['命令', (a) => a.command ?? a.capability ?? ''],
257
- ['对象', (a) => a.aggregate_id ? h('code', {}, `${a.aggregate} ${a.aggregate_id.slice(0, 8)}`) : a.aggregate ?? ''],
258
- ['结果', (a) => h('span', { class: a.result === 'ok' ? 'ok' : 'bad' }, a.result)], ['差异', (a) => a.diff ? h('pre', {}, JSON.stringify(a.diff, null, 1)) : ''],
259
- ], r?.items ?? []));
260
- };
261
- main.append(h('section', {}, h('h2', {}, '审计查询'), h('form', { class: 'row', onsubmit: (e) => { e.preventDefault(); load(); } }, Object.values(f), h('button', { class: 'primary' }, '查询')), out));
262
- load();
263
- }
264
-
265
- async function renderOps(main) {
266
- const [sum, dead, jobs] = await Promise.all([api('GET', '/api/sys/ops/summary'), api('GET', '/api/sys/ops/dead-letters'), api('GET', '/api/sys/ops/jobs')]);
267
- main.append(h('section', {}, h('h2', {}, 'outbox 积压'),
268
- table([['状态', (r) => r.status], ['数量', (r) => r.n], ['最早', (r) => fmt(r.oldest)]], sum.outbox),
269
- h('p', { class: sum.deadLetters ? 'bad' : 'muted' }, `未处理死信 ${sum.deadLetters} 条。死信会挡住同一单据的后续事件(为了保证顺序),直到有人处理。`)));
270
- const manage = can('Sys.ops.manage');
271
- main.append(h('section', {}, h('h2', {}, '死信'), table([
272
- ['事件', (d) => d.event], ['对象', (d) => h('code', {}, d.aggregate_id.slice(0, 8))], ['次数', (d) => d.attempts], ['错误', (d) => h('pre', {}, d.error ?? '')], ['时间', (d) => fmt(d.created_at)],
273
- ['操作', (d) => manage ? h('div', { class: 'row' },
274
- h('button', { class: 'primary', onclick: async () => { if (await run(() => api('POST', `/api/sys/ops/dead-letters/${d.id}/replay`), '已重放,仍走内核流水线')) go('ops'); } }, '重放'),
275
- h('button', { class: 'danger', onclick: async () => { if (confirm('丢弃后这个事件不会再处理,确定?') && (await run(() => api('POST', `/api/sys/ops/dead-letters/${d.id}/discard`), '已丢弃'))) go('ops'); } }, '丢弃')) : ''],
276
- ], dead)));
277
- main.append(h('section', {}, h('h2', {}, '定时任务'), table([
278
- ['任务', (j) => j.name], ['调度', (j) => h('code', {}, j.cron)], ['调用命令', (j) => j.calls.map((c) => h('span', { class: 'tag' }, c))],
279
- ['上次', (j) => j.lastRunAt ? `${fmt(j.lastRunAt)} ${j.lastStatus ?? ''}` : ''], ['下次', (j) => fmt(j.nextRunAt)],
280
- ['状态', (j) => manage ? h('button', { onclick: async () => { if (await run(() => api('POST', `/api/sys/ops/jobs/${j.name}/${j.enabled ? 'disable' : 'enable'}`), '已更新')) go('ops'); } }, j.enabled ? '停用' : '启用') : (j.enabled ? '启用' : '停用')],
281
- ], jobs)));
282
- }
283
-
284
- async function renderSync(main) {
285
- const rows = await api('GET', '/api/sys/ops/sync-records');
286
- main.append(h('section', {}, h('h2', {}, '第三方同步记录'), table([
287
- ['连接器', (r) => `${r.connector}.${r.operation}`], ['单号', (r) => h('code', {}, r.ref_id ?? '')], ['状态', (r) => h('span', { class: r.status === 'ok' ? 'ok' : 'bad' }, r.status)],
288
- ['HTTP', (r) => r.http_status ?? ''], ['次数', (r) => r.attempts], ['错误', (r) => h('pre', {}, r.error ?? '')], ['更新', (r) => fmt(r.updated_at)],
289
- ], rows)));
290
- }
291
-
292
- // ---------------- 外壳 ----------------
293
-
294
- function renderLogin() {
295
- const u = h('input', { placeholder: '用户名', autocomplete: 'username', required: true });
296
- const p = h('input', { placeholder: '密码', type: 'password', autocomplete: 'current-password', required: true });
297
- return h('section', { class: 'login' }, h('h2', {}, '登录系统管理'),
298
- h('form', { onsubmit: async (e) => { e.preventDefault(); if (await run(() => api('POST', '/auth/login', { username: u.value, password: p.value }))) { state.csrf = null; await boot(); } } },
299
- u, p, h('button', { class: 'primary', style: 'width:100%' }, '登录')),
300
- h('p', { class: 'muted' }, h('a', { href: '/auth/sso' }, '使用公司 SSO 登录')));
301
- }
302
-
303
- function current() {
304
- const k = location.hash.replace(/^#\/?/, '') || 'tasks';
305
- return pages[k] ? k : 'tasks';
306
- }
307
-
308
- function go(k) {
309
- if (location.hash !== `#/${k}`) location.hash = `#/${k}`;
310
- else render();
311
- }
312
-
313
- async function render() {
314
- const app = document.getElementById('app');
315
- app.replaceChildren();
316
- if (!state.me) { app.append(renderLogin()); return; }
317
- const k = current();
318
- const visible = Object.entries(pages).filter(([, p]) => !p.need || p.need.some(can));
319
- app.append(h('header', {}, h('strong', {}, '系统管理'),
320
- h('nav', {}, visible.map(([key, p]) => h('a', { href: `#/${key}`, class: key === k ? 'on' : '' }, p.title))),
321
- h('span', { class: 'me' }, state.me.user?.name ?? '', h('button', { onclick: async () => { await run(() => api('POST', '/auth/logout')); state.me = null; state.csrf = null; render(); } }, '退出'))));
322
- const main = h('main');
323
- app.append(main);
324
- const page = pages[k];
325
- if (page.need && !page.need.some(can)) { main.append(h('section', {}, h('p', { class: 'muted' }, '没有权限访问这个页面。'))); return; }
326
- try {
327
- await page.render(main);
328
- } catch (e) {
329
- main.append(h('section', {}, h('p', { class: 'bad' }, e.message)));
330
- }
331
- }
332
-
333
- async function boot() {
334
- try { state.me = await api('GET', '/api/me'); } catch { state.me = null; }
335
- render();
336
- }
337
-
338
- window.addEventListener('hashchange', render);
339
- boot();