@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.
@@ -0,0 +1,84 @@
1
+ import { h, icon } from './dom.js';
2
+ export const state = {
3
+ me: null,
4
+ catalog: null,
5
+ csrf: null,
6
+ };
7
+ const errors = {
8
+ UNAUTHENTICATED: '登录已过期,请重新登录',
9
+ FORBIDDEN: '你没有执行此操作的权限',
10
+ NOT_FOUND: '记录不存在或不在你的数据范围内',
11
+ INVALID_INPUT: '请检查填写内容',
12
+ INVALID_TRANSITION: '当前状态不允许此操作',
13
+ CONSTRAINT_VIOLATED: '未满足操作规则',
14
+ INVARIANT_VIOLATED: '数据未满足业务规则',
15
+ CONFLICT: '数据已更新,请刷新后重试',
16
+ DOMAIN_ERROR: '业务规则未满足',
17
+ RATE_LIMITED: '操作过于频繁,请稍后再试',
18
+ };
19
+ export class ApiError extends Error {
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
26
+ /** 系统管理同样使用会话、CSRF 和幂等键,不拥有绕过领域命令的写入通道。 */
27
+ export async function api(method, url, body) {
28
+ const headers = { accept: 'application/json' };
29
+ if (method === 'POST') {
30
+ if (!state.csrf) {
31
+ const response = await fetch('/auth/csrf', { credentials: 'include' });
32
+ if (!response.ok)
33
+ throw new Error('无法获取安全令牌,请刷新后重试');
34
+ state.csrf = (await response.json()).token;
35
+ }
36
+ headers['content-type'] = 'application/json';
37
+ headers['x-csrf-token'] = state.csrf;
38
+ headers['idempotency-key'] = crypto.randomUUID();
39
+ }
40
+ const response = await fetch(url, {
41
+ method,
42
+ credentials: 'include',
43
+ headers,
44
+ body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
45
+ });
46
+ const data = await response.json();
47
+ if (!response.ok) {
48
+ if (response.status === 401 && url !== '/auth/login') {
49
+ state.me = null;
50
+ state.catalog = null;
51
+ state.csrf = null;
52
+ window.dispatchEvent(new Event('cage:session-expired'));
53
+ }
54
+ const detail = data.domainCode || data.detail?.message || (typeof data.detail === 'string' ? data.detail : '');
55
+ const message = `${errors[data.code] ?? '服务暂时无法处理请求'}${detail ? `:${detail}` : ''}`;
56
+ throw new ApiError(data.code, message);
57
+ }
58
+ return data;
59
+ }
60
+ export const can = (capability) => Boolean(state.me?.capabilities.includes(capability));
61
+ export function toast(message, failed = false) {
62
+ const item = h('div', { class: `toast ${failed ? 'error' : 'success'}`, role: failed ? 'alert' : 'status' }, icon(failed ? 'info' : 'check'), message);
63
+ document.getElementById('notifications').append(item);
64
+ setTimeout(() => item.remove(), 5000);
65
+ }
66
+ /** 失败会明确显示原因;按钮等待返回,避免界面把未保存的授权显示成成功。 */
67
+ export async function run(action, success, button) {
68
+ if (button)
69
+ button.disabled = true;
70
+ try {
71
+ const result = await action();
72
+ if (success)
73
+ toast(success);
74
+ return result;
75
+ }
76
+ catch (error) {
77
+ toast(error instanceof TypeError ? '连接失败,请检查网络与 API 服务' : error.message, true);
78
+ return null;
79
+ }
80
+ finally {
81
+ if (button)
82
+ button.disabled = false;
83
+ }
84
+ }
@@ -0,0 +1,103 @@
1
+ /** 统一用 DOM/textContent 构建界面;业务名称、错误、审计差异不会被当作 HTML 执行。 */
2
+ export function h(tag, attrs = {}, ...children) {
3
+ const element = document.createElement(tag);
4
+ const append = (child) => {
5
+ if (Array.isArray(child))
6
+ child.forEach(append);
7
+ else if (child !== null && child !== undefined && child !== false)
8
+ element.append(child instanceof Node ? child : document.createTextNode(String(child)));
9
+ };
10
+ children.forEach(append);
11
+ // select 的 value 必须在 option 加入后赋值,否则浏览器会丢失当前选择。
12
+ for (const [key, value] of Object.entries(attrs)) {
13
+ if (value === null || value === undefined)
14
+ continue;
15
+ if (key.startsWith('on'))
16
+ element.addEventListener(key.slice(2), value);
17
+ else if (key === 'class')
18
+ element.className = String(value);
19
+ else if (key === 'value')
20
+ element.value = String(value);
21
+ else if (key === 'checked')
22
+ element.checked = Boolean(value);
23
+ else if (key.startsWith('aria-'))
24
+ element.setAttribute(key, String(value));
25
+ else if (value !== false)
26
+ element.setAttribute(key, value === true ? '' : String(value));
27
+ }
28
+ return element;
29
+ }
30
+ const paths = {
31
+ grid: ['M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z'],
32
+ shield: ['M12 3 3 7v5c0 5 9 9 9 9s9-4 9-9V7z', 'm8 12 3 3 5-6'],
33
+ users: [
34
+ 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2',
35
+ 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8',
36
+ 'M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
37
+ ],
38
+ inbox: ['M4 4h16l2 12v4H2v-4z', 'M2 16h6l2 3h4l2-3h6'],
39
+ search: ['M21 21l-5-5', 'M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16'],
40
+ clock: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20', 'M12 6v6l4 2'],
41
+ link: ['M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2', 'M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2'],
42
+ file: ['M14 2H4v20h16V8z', 'M14 2v6h6M8 13h8M8 17h5'],
43
+ arrow: ['M7 17 17 7M7 7h10v10'],
44
+ plus: ['M12 5v14M5 12h14'],
45
+ check: ['m5 12 4 4L19 6'],
46
+ chevron: ['m9 5 7 7-7 7'],
47
+ logout: ['M9 21H3V3h6M9 12h12m-4-4 4 4-4 4'],
48
+ refresh: ['M20 7a9 9 0 1 0 1 10M20 3v5h-5'],
49
+ info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20', 'M12 11v6M12 7h.01'],
50
+ lock: ['M5 10h14v11H5zM8 10V7a4 4 0 0 1 8 0v3M12 14v3'],
51
+ };
52
+ /** 线条图标是本地 SVG,无字体、CDN 或图片下载依赖。 */
53
+ export function icon(name) {
54
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
55
+ for (const [key, value] of Object.entries({
56
+ viewBox: '0 0 24 24',
57
+ fill: 'none',
58
+ stroke: 'currentColor',
59
+ 'stroke-width': '1.7',
60
+ 'stroke-linecap': 'round',
61
+ 'stroke-linejoin': 'round',
62
+ 'aria-hidden': 'true',
63
+ class: 'icon',
64
+ }))
65
+ svg.setAttribute(key, value);
66
+ for (const d of paths[name]) {
67
+ const path = document.createElementNS(svg.namespaceURI, 'path');
68
+ path.setAttribute('d', d);
69
+ svg.append(path);
70
+ }
71
+ return svg;
72
+ }
73
+ export const field = (label, control, hint) => h('label', { class: 'field' }, h('span', {}, label), control, hint && h('small', {}, hint));
74
+ export const formatTime = (time) => time ? new Date(time).toLocaleString('zh-CN', { hour12: false }) : '—';
75
+ export const badge = (text, tone = 'neutral') => h('span', { class: `badge ${tone}` }, text);
76
+ export function table(columns, rows, empty = '暂无记录') {
77
+ 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
+ ? rows.map((row) => h('tr', {}, columns.map(([, cell]) => h('td', {}, cell(row)))))
79
+ : h('tr', {}, h('td', { colspan: columns.length, class: 'table-empty' }, empty)))));
80
+ }
81
+ export function emptyState(title, message, glyph = 'inbox') {
82
+ return h('div', { class: 'empty-state' }, h('span', { class: 'empty-icon' }, icon(glyph)), h('h3', {}, title), h('p', {}, message));
83
+ }
84
+ export function panel(title, description, ...children) {
85
+ return h('section', { class: 'panel' }, h('div', { class: 'panel-heading' }, h('div', {}, h('h2', {}, title), description && h('p', {}, description))), children);
86
+ }
87
+ export function searchInput(placeholder, onInput, value = '') {
88
+ const input = h('input', {
89
+ type: 'search',
90
+ placeholder,
91
+ 'aria-label': placeholder,
92
+ value,
93
+ oninput: () => onInput(input.value),
94
+ });
95
+ return h('div', { class: 'search-field' }, icon('search'), input);
96
+ }
97
+ export function modal(title, content) {
98
+ 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
+ dialog.addEventListener('close', () => dialog.remove(), { once: true });
100
+ document.body.append(dialog);
101
+ dialog.showModal();
102
+ return dialog;
103
+ }
@@ -0,0 +1,55 @@
1
+ import { state } from './api.js';
2
+ export const scopes = {
3
+ self: { title: '仅本人', hint: '仅查看归属于自己的记录' },
4
+ dept: { title: '本部门', hint: '查看本人所在部门的记录' },
5
+ deptSubtree: { title: '本部门及下级', hint: '查看本人部门及下属部门的记录' },
6
+ deptSet: { title: '指定部门', hint: '仅查看所选部门的记录' },
7
+ all: { title: '全部数据', hint: '查看当前租户内的全部记录' },
8
+ };
9
+ export function objectName(name) {
10
+ const object = state.catalog.aggregates.find((item) => item.name === name);
11
+ return object?.label && object.label !== name ? object.label : object?.description || '业务对象';
12
+ }
13
+ export function capabilityName(capability) {
14
+ const item = state.catalog.capabilities.find((item) => item.capability === capability);
15
+ if (item)
16
+ return item.aggregate ? item.label.replaceAll(item.aggregate, objectName(item.aggregate)) : item.label;
17
+ // 这些审计操作不属于领域能力目录,单独命名,避免登录和运维都显示成“业务操作”。
18
+ const operations = {
19
+ 'auth.login': '账号登录',
20
+ 'deadLetter.replay': '重新投递事件',
21
+ 'deadLetter.discard': '丢弃死信事件',
22
+ 'job.enable': '启用定时任务',
23
+ 'job.disable': '停用定时任务',
24
+ };
25
+ return operations[capability] || '业务操作';
26
+ }
27
+ export function invocation(capability) {
28
+ if (!capability.invokedBy || capability.invokedBy.includes('user'))
29
+ return '';
30
+ const names = {
31
+ process: '审批流程',
32
+ external: '外部系统',
33
+ job: '定时任务',
34
+ system: '系统内部',
35
+ reaction: '自动反应',
36
+ };
37
+ return capability.invokedBy.map((item) => names[item] || '自动流程').join(' / ') + '调用';
38
+ }
39
+ export const statusNames = {
40
+ active: '已启用',
41
+ disabled: '已停用',
42
+ archived: '已停用',
43
+ pending: '等待处理',
44
+ processing: '处理中',
45
+ running: '执行中',
46
+ done: '已完成',
47
+ ok: '成功',
48
+ failed: '失败',
49
+ error: '失败',
50
+ dead: '已进入死信',
51
+ retry: '等待重试',
52
+ };
53
+ export const statusName = (status) => statusNames[status] || '待确认';
54
+ export const eventName = (name) => state.catalog.labels[`event:${name}`] || '业务事件';
55
+ export const jobName = (name) => state.catalog.jobs.find((item) => item.name === name)?.label || '定时任务';
@@ -0,0 +1,7 @@
1
+ // 事件让模块只发出导航意图,不反向导入应用壳。
2
+ export function navigate(page) {
3
+ if (location.hash !== `#/${page}`)
4
+ location.hash = `#/${page}`;
5
+ else
6
+ window.dispatchEvent(new Event('cage:refresh'));
7
+ }
@@ -0,0 +1 @@
1
+ export {};
package/ui/index.html CHANGED
@@ -1,13 +1,14 @@
1
1
  <!doctype html>
2
2
  <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>系统管理</title>
7
- <link rel="stylesheet" href="./style.css" />
8
- </head>
9
- <body>
10
- <div id="app"></div>
11
- <script type="module" src="./app.js"></script>
12
- </body>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>系统管理</title>
7
+ <link rel="stylesheet" href="./style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <div id="notifications" aria-live="polite"></div>
12
+ <script type="module" src="./dist/app.js"></script>
13
+ </body>
13
14
  </html>