@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/README.md +31 -0
- package/dist/catalog.d.ts +43 -0
- package/dist/catalog.js +91 -0
- package/dist/catalog.js.map +1 -0
- package/dist/domain/org.d.ts +148 -148
- package/dist/http.d.ts +2 -1
- package/dist/http.js +4 -13
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
- package/ui/dist/app/shell.js +110 -0
- package/ui/dist/app.js +24 -0
- package/ui/dist/modules/audit/audit-page.js +95 -0
- package/ui/dist/modules/audit/index.js +1 -0
- package/ui/dist/modules/auth/index.js +1 -0
- package/ui/dist/modules/auth/login-page.js +30 -0
- package/ui/dist/modules/operations/index.js +2 -0
- package/ui/dist/modules/operations/operations-page.js +95 -0
- package/ui/dist/modules/operations/sync-page.js +48 -0
- package/ui/dist/modules/org/index.js +1 -0
- package/ui/dist/modules/org/org-page.js +213 -0
- package/ui/dist/modules/roles/index.js +1 -0
- package/ui/dist/modules/roles/roles-page.js +308 -0
- package/ui/dist/modules/tasks/index.js +1 -0
- package/ui/dist/modules/tasks/tasks-page.js +60 -0
- package/ui/dist/shared/api.js +84 -0
- package/ui/dist/shared/dom.js +103 -0
- package/ui/dist/shared/labels.js +55 -0
- package/ui/dist/shared/navigation.js +7 -0
- package/ui/dist/shared/types.js +1 -0
- package/ui/index.html +11 -10
- package/ui/style.css +1576 -41
- package/ui/app.js +0 -339
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yss123/cage-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "组织模型声明与 /admin 系统管理",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
-
"ui"
|
|
20
|
+
"ui/index.html",
|
|
21
|
+
"ui/style.css",
|
|
22
|
+
"ui/dist"
|
|
21
23
|
],
|
|
22
24
|
"dependencies": {
|
|
23
25
|
"fastify": "^5.6.0",
|
|
24
26
|
"zod": "^4.1.5",
|
|
25
27
|
"kysely": "^0.28.5",
|
|
26
|
-
"@yss123/cage-kernel": "0.
|
|
28
|
+
"@yss123/cage-kernel": "0.2.0"
|
|
27
29
|
},
|
|
28
30
|
"license": "MIT",
|
|
29
31
|
"repository": {
|
|
@@ -43,7 +45,8 @@
|
|
|
43
45
|
"node": ">=24"
|
|
44
46
|
},
|
|
45
47
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json",
|
|
47
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
|
+
"build": "tsc -p tsconfig.json && tsc -p ui/tsconfig.json",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p ui/tsconfig.json --noEmit",
|
|
50
|
+
"test": "vitest run"
|
|
48
51
|
}
|
|
49
52
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { api, can, run, state } from '../shared/api.js';
|
|
2
|
+
import { emptyState, h, icon, panel } from '../shared/dom.js';
|
|
3
|
+
import { navigate } from '../shared/navigation.js';
|
|
4
|
+
import { renderTasks } from '../modules/tasks/index.js';
|
|
5
|
+
import { renderOrg } from '../modules/org/index.js';
|
|
6
|
+
import { renderRoles } from '../modules/roles/index.js';
|
|
7
|
+
import { renderAudit } from '../modules/audit/index.js';
|
|
8
|
+
import { renderOperations, renderSync } from '../modules/operations/index.js';
|
|
9
|
+
import { loginPage } from '../modules/auth/index.js';
|
|
10
|
+
const pages = {
|
|
11
|
+
tasks: {
|
|
12
|
+
title: '我的待办',
|
|
13
|
+
description: '集中处理分配给你的审批任务,让每一步协作及时推进。',
|
|
14
|
+
glyph: 'inbox',
|
|
15
|
+
category: '团队与权限',
|
|
16
|
+
render: renderTasks,
|
|
17
|
+
need: [],
|
|
18
|
+
},
|
|
19
|
+
org: {
|
|
20
|
+
title: '用户与部门',
|
|
21
|
+
description: '管理团队成员、组织关系与部门负责人。',
|
|
22
|
+
glyph: 'users',
|
|
23
|
+
category: '团队与权限',
|
|
24
|
+
render: renderOrg,
|
|
25
|
+
need: ['User.read', 'Dept.read'],
|
|
26
|
+
},
|
|
27
|
+
roles: {
|
|
28
|
+
title: '角色与授权',
|
|
29
|
+
description: '用清晰的职责边界,配置每个角色可以做什么、可以看什么。',
|
|
30
|
+
glyph: 'shield',
|
|
31
|
+
category: '团队与权限',
|
|
32
|
+
render: renderRoles,
|
|
33
|
+
need: ['Role.read'],
|
|
34
|
+
},
|
|
35
|
+
audit: {
|
|
36
|
+
title: '操作审计',
|
|
37
|
+
description: '业务操作有据可查,重要变更全程留痕。',
|
|
38
|
+
glyph: 'file',
|
|
39
|
+
category: '运行与记录',
|
|
40
|
+
render: renderAudit,
|
|
41
|
+
need: ['Sys.audit.read'],
|
|
42
|
+
},
|
|
43
|
+
ops: {
|
|
44
|
+
title: '异步任务',
|
|
45
|
+
description: '查看事件投递、处理失败事件,管理业务定时任务。',
|
|
46
|
+
glyph: 'clock',
|
|
47
|
+
category: '运行与记录',
|
|
48
|
+
render: renderOperations,
|
|
49
|
+
need: ['Sys.ops.read'],
|
|
50
|
+
},
|
|
51
|
+
sync: {
|
|
52
|
+
title: '第三方同步',
|
|
53
|
+
description: '追踪业务与外部系统之间的每一次同步。',
|
|
54
|
+
glyph: 'link',
|
|
55
|
+
category: '运行与记录',
|
|
56
|
+
render: renderSync,
|
|
57
|
+
need: ['Sys.ops.read'],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
/** 应用组合层只负责导航、会话和页面加载;各管理模块维护自己的表单和请求。 */
|
|
61
|
+
export async function render(boot) {
|
|
62
|
+
const root = document.getElementById('app');
|
|
63
|
+
if (!state.me) {
|
|
64
|
+
root.replaceChildren(loginPage(boot));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const hash = location.hash.replace(/^#\/?/, '') || 'tasks';
|
|
68
|
+
const key = (hash in pages ? hash : 'tasks');
|
|
69
|
+
const page = pages[key];
|
|
70
|
+
const visible = Object.entries(pages).filter(([, item]) => !item.need.length || item.need.some(can));
|
|
71
|
+
const sidebar = h('aside', { class: 'app-sidebar' }, h('a', { class: 'brand', href: '#/tasks', 'aria-label': 'Cage 系统管理首页' }, h('span', { class: 'brand-mark' }, icon('grid')), h('span', {}, 'Cage', h('small', {}, '系统管理'))), ...['团队与权限', '运行与记录'].map((category) => h('div', { class: 'nav-group' }, h('p', { class: 'nav-caption' }, category), h('nav', { 'aria-label': category }, visible
|
|
72
|
+
.filter(([, item]) => item.category === category)
|
|
73
|
+
.map(([name, item]) => h('a', {
|
|
74
|
+
href: `#/${name}`,
|
|
75
|
+
class: `nav-item ${key === name ? 'active' : ''}`,
|
|
76
|
+
'aria-current': key === name ? 'page' : undefined,
|
|
77
|
+
}, icon(item.glyph), h('span', {}, item.title), key === name && h('i', { class: 'nav-active-dot' })))))), h('div', { class: 'sidebar-bottom' }, h('div', { class: 'workspace-label' }, h('span', { class: 'connection-dot' }), '团队管理工作空间'), h('a', { href: '/', class: 'back-to-business' }, icon('arrow'), '返回业务首页')));
|
|
78
|
+
const logout = h('button', {
|
|
79
|
+
class: 'icon-button',
|
|
80
|
+
'aria-label': '退出登录',
|
|
81
|
+
title: '退出登录',
|
|
82
|
+
onclick: async () => {
|
|
83
|
+
if (await run(() => api('POST', '/auth/logout'), undefined, logout)) {
|
|
84
|
+
state.me = null;
|
|
85
|
+
state.catalog = null;
|
|
86
|
+
state.csrf = null;
|
|
87
|
+
void render(boot);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}, icon('logout'));
|
|
91
|
+
const content = h('div', { class: 'page-content' });
|
|
92
|
+
const loading = h('div', { class: 'loading-panel', role: 'status' }, h('span', { class: 'spinner' }), '正在读取管理数据…');
|
|
93
|
+
content.append(loading);
|
|
94
|
+
const main = h('main', { class: 'app-main' }, h('header', { class: 'app-topbar' }, h('div', { class: 'breadcrumb' }, h('span', {}, '工作空间'), icon('chevron'), h('span', {}, '系统管理'), icon('chevron'), h('strong', {}, page.title)), h('div', { class: 'account' }, h('span', { class: 'avatar' }, state.me.user.name.slice(0, 1)), h('span', { class: 'account-copy' }, h('strong', {}, state.me.user.name), h('small', {}, '当前登录')), logout)), h('div', { class: 'page-container' }, h('div', { class: 'page-heading' }, h('div', {}, h('span', { class: 'eyebrow' }, '管理工作台'), h('h1', {}, page.title), h('p', {}, page.description)), h('button', { class: 'refresh-button', onclick: () => navigate(key) }, icon('refresh'), '刷新数据')), content, h('footer', { class: 'page-footer' }, h('span', {}, 'Cage · 让业务有序发生'), h('span', {}, icon('shield'), '操作受角色权限与数据范围约束'))));
|
|
95
|
+
root.replaceChildren(h('div', { class: 'admin-shell' }, sidebar, main));
|
|
96
|
+
if (page.need.length && !page.need.some(can)) {
|
|
97
|
+
loading.remove();
|
|
98
|
+
content.append(panel('访问受限', undefined, emptyState('你没有查看此页面的权限', '请联系管理员,按职责配置所需角色。', 'lock')));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await page.render(content);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
content.append(h('div', { class: 'error-panel', role: 'alert' }, icon('info'), h('div', {}, h('strong', {}, '暂时无法读取数据'), h('p', {}, error instanceof TypeError ? '请检查网络与 API 服务后重新尝试。' : error.message)), h('button', { onclick: () => navigate(key) }, '重新加载')));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
loading.remove();
|
|
109
|
+
}
|
|
110
|
+
}
|
package/ui/dist/app.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { api, ApiError, state } from './shared/api.js';
|
|
2
|
+
import { h, icon } from './shared/dom.js';
|
|
3
|
+
import { render } from './app/shell.js';
|
|
4
|
+
/** 浏览器入口恢复真实会话与展示目录,不包含具体页面、表单或授权逻辑。 */
|
|
5
|
+
async function boot() {
|
|
6
|
+
try {
|
|
7
|
+
state.me = await api('GET', '/api/me');
|
|
8
|
+
state.catalog = await api('GET', '/api/sys/catalog');
|
|
9
|
+
await render(boot);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error instanceof ApiError && error.code === 'UNAUTHENTICATED') {
|
|
13
|
+
await render(boot);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
document
|
|
17
|
+
.getElementById('app')
|
|
18
|
+
.replaceChildren(h('main', { class: 'connection-state' }, h('span', { class: 'brand-mark' }, icon('grid')), h('h1', {}, '暂时无法连接工作空间'), h('p', {}, '请检查网络与 API 服务,然后重新尝试。'), h('button', { class: 'primary', onclick: boot }, '重新连接')));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
window.addEventListener('hashchange', () => void render(boot));
|
|
22
|
+
window.addEventListener('cage:refresh', () => void render(boot));
|
|
23
|
+
window.addEventListener('cage:session-expired', () => void render(boot));
|
|
24
|
+
void boot();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { api, run, state } from '../../shared/api.js';
|
|
2
|
+
import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
|
|
3
|
+
import { capabilityName, objectName } from '../../shared/labels.js';
|
|
4
|
+
const kinds = {
|
|
5
|
+
write: '业务写入',
|
|
6
|
+
read: '数据读取',
|
|
7
|
+
denied: '拒绝访问',
|
|
8
|
+
ops: '运维操作',
|
|
9
|
+
};
|
|
10
|
+
const actors = {
|
|
11
|
+
user: '用户',
|
|
12
|
+
process: '审批流程',
|
|
13
|
+
external: '外部系统',
|
|
14
|
+
job: '定时任务',
|
|
15
|
+
system: '系统',
|
|
16
|
+
};
|
|
17
|
+
export async function renderAudit(main) {
|
|
18
|
+
const actor = h('input', { placeholder: '操作人的用户 ID' });
|
|
19
|
+
const aggregate = h('select', {}, h('option', { value: '' }, '全部业务对象'), state.catalog.aggregates.map((item) => h('option', { value: item.name }, item.label)));
|
|
20
|
+
const aggregateId = h('input', { placeholder: '完整单据 ID' });
|
|
21
|
+
const command = h('input', { placeholder: '例如 PurchaseOrder.approve' });
|
|
22
|
+
const kind = h('select', {}, h('option', { value: '' }, '全部操作类型'), Object.entries(kinds).map(([value, label]) => h('option', { value }, label)));
|
|
23
|
+
const fields = { actor, aggregate, aggregateId, command, kind };
|
|
24
|
+
const out = h('div');
|
|
25
|
+
const search = h('button', { type: 'submit', class: 'primary' }, '查询记录');
|
|
26
|
+
const previous = h('button', {
|
|
27
|
+
class: 'small',
|
|
28
|
+
onclick: () => {
|
|
29
|
+
page--;
|
|
30
|
+
void load();
|
|
31
|
+
},
|
|
32
|
+
}, '上一页');
|
|
33
|
+
const next = h('button', {
|
|
34
|
+
class: 'small',
|
|
35
|
+
onclick: () => {
|
|
36
|
+
page++;
|
|
37
|
+
void load();
|
|
38
|
+
},
|
|
39
|
+
}, '下一页');
|
|
40
|
+
const pageText = h('span', { class: 'muted' });
|
|
41
|
+
let page = 1;
|
|
42
|
+
main.append(panel('审计记录', '查询谁在什么时间操作了哪些数据,以及操作是否成功。', h('form', {
|
|
43
|
+
class: 'audit-filters',
|
|
44
|
+
onsubmit: (event) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
page = 1;
|
|
47
|
+
void load();
|
|
48
|
+
},
|
|
49
|
+
}, field('业务对象', aggregate), field('操作类型', kind), field('操作人', actor), field('单据编号', aggregateId), field('操作标识', command), search), out, h('div', { class: 'pagination' }, pageText, h('div', { class: 'row-actions' }, previous, next))));
|
|
50
|
+
async function load() {
|
|
51
|
+
const params = new URLSearchParams(Object.entries(fields)
|
|
52
|
+
.filter(([, input]) => input.value)
|
|
53
|
+
.map(([key, input]) => [key, input.value]));
|
|
54
|
+
params.set('page', String(page));
|
|
55
|
+
params.set('pageSize', '50');
|
|
56
|
+
const result = await run(() => api('GET', `/api/sys/audit?${params}`), undefined, search);
|
|
57
|
+
if (!result)
|
|
58
|
+
return;
|
|
59
|
+
out.replaceChildren(table([
|
|
60
|
+
['发生时间', (item) => formatTime(item.at)],
|
|
61
|
+
[
|
|
62
|
+
'操作类型',
|
|
63
|
+
(item) => badge(kinds[item.kind] || '系统记录', item.kind === 'denied' ? 'red' : 'neutral'),
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'操作主体',
|
|
67
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, actors[item.actor_kind] || '系统主体'), h('code', { title: item.actor }, item.actor.slice(0, 12)), item.on_behalf_of && h('small', {}, `代表用户 ${item.on_behalf_of.slice(0, 8)}`)),
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
'执行操作',
|
|
71
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, capabilityName(item.command || item.capability || '')), h('code', {}, item.command || item.capability || item.source)),
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'关联对象',
|
|
75
|
+
(item) => item.aggregate
|
|
76
|
+
? h('div', { class: 'stack-cell' }, h('span', {}, objectName(item.aggregate)), h('code', { title: item.aggregate_id }, item.aggregate_id?.slice(0, 12) || '—'))
|
|
77
|
+
: '—',
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'结果',
|
|
81
|
+
(item) => badge(item.result === 'ok' ? '成功' : '未成功', item.result === 'ok' ? 'green' : 'red'),
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
'变更详情',
|
|
85
|
+
(item) => item.diff
|
|
86
|
+
? h('details', {}, h('summary', {}, '查看变更'), h('pre', {}, JSON.stringify(item.diff, null, 2)))
|
|
87
|
+
: h('span', { class: 'muted' }, '无字段变更'),
|
|
88
|
+
],
|
|
89
|
+
], result.items));
|
|
90
|
+
pageText.textContent = `第 ${page} 页 · 本页 ${result.items.length} 条,每页最多 50 条`;
|
|
91
|
+
previous.disabled = page === 1;
|
|
92
|
+
next.disabled = result.items.length < 50;
|
|
93
|
+
}
|
|
94
|
+
await load();
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderAudit } from './audit-page.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { loginPage } from './login-page.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { api, run, state } from '../../shared/api.js';
|
|
2
|
+
import { field, h, icon } from '../../shared/dom.js';
|
|
3
|
+
export function loginPage(onLogin) {
|
|
4
|
+
const username = h('input', {
|
|
5
|
+
name: 'username',
|
|
6
|
+
placeholder: '请输入登录账号',
|
|
7
|
+
autocomplete: 'username',
|
|
8
|
+
required: true,
|
|
9
|
+
});
|
|
10
|
+
const password = h('input', {
|
|
11
|
+
name: 'password',
|
|
12
|
+
type: 'password',
|
|
13
|
+
placeholder: '请输入密码',
|
|
14
|
+
autocomplete: 'current-password',
|
|
15
|
+
required: true,
|
|
16
|
+
});
|
|
17
|
+
const submit = h('button', { class: 'primary', type: 'submit' }, '登录系统管理', icon('arrow'));
|
|
18
|
+
const form = h('form', {
|
|
19
|
+
class: 'stack-form',
|
|
20
|
+
onsubmit: async (event) => {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
const result = await run(() => api('POST', '/auth/login', { username: username.value, password: password.value }), undefined, submit);
|
|
23
|
+
if (result) {
|
|
24
|
+
state.csrf = null;
|
|
25
|
+
await onLogin();
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}, field('登录账号', username), field('密码', password), submit);
|
|
29
|
+
return h('main', { class: 'login-layout' }, h('section', { class: 'login-story' }, h('div', { class: 'login-brand' }, h('span', { class: 'brand-mark' }, icon('grid')), 'Cage'), h('div', {}, h('span', { class: 'eyebrow' }, '系统管理工作台'), h('h1', {}, '让协作有序,', h('br'), '让权限清晰。'), h('p', {}, '从团队与角色,到流程与运行记录。', h('br'), '在一个工作空间里,管理业务的基础。')), h('small', {}, '团队协作 · 权限管理 · 运行追踪')), h('section', { class: 'login-panel' }, h('span', { class: 'eyebrow' }, '欢迎回来'), h('h2', {}, '登录你的工作空间'), h('p', { class: 'muted' }, '使用管理员为你配置的账号继续。'), form, h('a', { class: 'sso-link', href: '/auth/sso' }, '使用企业单点登录', icon('arrow')), h('p', { class: 'login-footnote' }, icon('lock'), '使用安全会话连接,操作受权限与数据范围约束。')));
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { api, can, run } from '../../shared/api.js';
|
|
2
|
+
import { badge, field, formatTime, h, icon, modal, panel, table } from '../../shared/dom.js';
|
|
3
|
+
import { capabilityName, eventName, jobName, statusName } from '../../shared/labels.js';
|
|
4
|
+
import { navigate } from '../../shared/navigation.js';
|
|
5
|
+
export async function renderOperations(main) {
|
|
6
|
+
const [summary, dead, jobs] = await Promise.all([
|
|
7
|
+
api('GET', '/api/sys/ops/summary'),
|
|
8
|
+
api('GET', '/api/sys/ops/dead-letters'),
|
|
9
|
+
api('GET', '/api/sys/ops/jobs'),
|
|
10
|
+
]);
|
|
11
|
+
const manageable = can('Sys.ops.manage');
|
|
12
|
+
main.append(h('div', { class: 'stats-row' }, stat('待投递事件', summary.outbox.reduce((sum, item) => sum + item.n, 0), 'inbox'), stat('待处理死信', summary.deadLetters, 'info', summary.deadLetters > 0), stat('运行中定时任务', jobs.filter((job) => job.enabled).length, 'clock')));
|
|
13
|
+
main.append(panel('事件投递队列', '事件由后台 worker 按顺序投递,失败后自动退避重试。', table([
|
|
14
|
+
[
|
|
15
|
+
'处理状态',
|
|
16
|
+
(item) => badge(statusName(item.status), item.status === 'pending' ? 'amber' : 'neutral'),
|
|
17
|
+
],
|
|
18
|
+
['事件数量', (item) => h('strong', {}, item.n)],
|
|
19
|
+
['最早进入时间', (item) => formatTime(item.oldest)],
|
|
20
|
+
], summary.outbox, '投递队列已清空,没有积压事件')));
|
|
21
|
+
main.append(panel('需要处理的死信', '请先排查失败原因再重放。同一单据的后续事件会等待这条事件处理完成。', table([
|
|
22
|
+
[
|
|
23
|
+
'业务事件',
|
|
24
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, eventName(item.event)), h('code', {}, item.event)),
|
|
25
|
+
],
|
|
26
|
+
['关联单据', (item) => h('code', { title: item.aggregate_id }, item.aggregate_id.slice(0, 12))],
|
|
27
|
+
['尝试次数', (item) => badge(`${item.attempts} 次`, 'amber')],
|
|
28
|
+
[
|
|
29
|
+
'失败原因',
|
|
30
|
+
(item) => h('details', {}, h('summary', { class: 'failure-summary' }, (item.error || '无错误说明').slice(0, 65)), h('pre', {}, item.error || '无错误说明')),
|
|
31
|
+
],
|
|
32
|
+
['进入时间', (item) => formatTime(item.created_at)],
|
|
33
|
+
[
|
|
34
|
+
'处理',
|
|
35
|
+
(item) => manageable
|
|
36
|
+
? h('div', { class: 'row-actions' }, h('button', {
|
|
37
|
+
class: 'small primary',
|
|
38
|
+
onclick: async (event) => {
|
|
39
|
+
if (await run(() => api('POST', `/api/sys/ops/dead-letters/${item.id}/replay`), '事件已重新进入投递队列', event.currentTarget))
|
|
40
|
+
navigate('ops');
|
|
41
|
+
},
|
|
42
|
+
}, '重新投递'), h('button', { class: 'small danger', onclick: () => discard(item) }, '丢弃'))
|
|
43
|
+
: h('span', { class: 'muted' }, '仅查看'),
|
|
44
|
+
],
|
|
45
|
+
], dead, '当前没有待处理的死信')));
|
|
46
|
+
main.append(panel('定时任务', '按业务声明的时间执行;停用后不会继续触发新的调度。', table([
|
|
47
|
+
[
|
|
48
|
+
'任务名称',
|
|
49
|
+
(job) => h('div', { class: 'stack-cell' }, h('strong', {}, jobName(job.name)), h('code', {}, job.name)),
|
|
50
|
+
],
|
|
51
|
+
['执行计划', (job) => h('code', {}, job.cron)],
|
|
52
|
+
[
|
|
53
|
+
'业务操作',
|
|
54
|
+
(job) => h('div', { class: 'tag-list' }, job.calls.map((name) => badge(capabilityName(name)))),
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
'上次运行',
|
|
58
|
+
(job) => h('div', { class: 'stack-cell' }, formatTime(job.lastRunAt), job.lastStatus &&
|
|
59
|
+
badge(statusName(job.lastStatus), job.lastStatus === 'ok' ? 'green' : 'amber')),
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'下次计划',
|
|
63
|
+
(job) => (job.enabled ? formatTime(job.nextRunAt) : h('span', { class: 'muted' }, '已暂停调度')),
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'状态与操作',
|
|
67
|
+
(job) => h('div', { class: 'row-actions' }, badge(job.enabled ? '运行中' : '已停用', job.enabled ? 'green' : 'neutral'), manageable &&
|
|
68
|
+
h('button', {
|
|
69
|
+
class: 'small',
|
|
70
|
+
onclick: async (event) => {
|
|
71
|
+
if (await run(() => api('POST', `/api/sys/ops/jobs/${encodeURIComponent(job.name)}/${job.enabled ? 'disable' : 'enable'}`), '定时任务状态已更新', event.currentTarget))
|
|
72
|
+
navigate('ops');
|
|
73
|
+
},
|
|
74
|
+
}, job.enabled ? '停用' : '启用')),
|
|
75
|
+
],
|
|
76
|
+
], jobs, '当前业务没有声明定时任务')));
|
|
77
|
+
function discard(item) {
|
|
78
|
+
const confirm = h('input', { type: 'checkbox', required: true });
|
|
79
|
+
const save = h('button', { class: 'danger', type: 'submit' }, '确认丢弃事件');
|
|
80
|
+
const form = h('form', {
|
|
81
|
+
class: 'stack-form',
|
|
82
|
+
onsubmit: async (event) => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
if (await run(() => api('POST', `/api/sys/ops/dead-letters/${item.id}/discard`), '事件已丢弃', save)) {
|
|
85
|
+
dialog.close();
|
|
86
|
+
navigate('ops');
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}, h('p', {}, '丢弃后,这条事件将不再重试。请确认业务补偿或人工处理已经完成。'), field('我已了解此操作会停止该事件的后续处理', confirm), h('div', { class: 'form-actions' }, save));
|
|
90
|
+
const dialog = modal('丢弃死信事件', form);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function stat(label, value, glyph, warning = false) {
|
|
94
|
+
return h('div', { class: `stat-card ${warning ? 'attention' : ''}` }, h('span', { class: 'stat-icon' }, icon(glyph)), h('div', {}, h('span', {}, label), h('strong', {}, value)));
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { api, run, state } from '../../shared/api.js';
|
|
2
|
+
import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
|
|
3
|
+
export async function renderSync(main) {
|
|
4
|
+
const connector = h('input', { placeholder: '连接器标识,留空查看全部' });
|
|
5
|
+
const status = h('select', {}, h('option', { value: '' }, '全部结果'), h('option', { value: 'ok' }, '同步成功'), h('option', { value: 'failed' }, '同步失败'));
|
|
6
|
+
const query = h('button', { class: 'primary', type: 'submit' }, '查询记录');
|
|
7
|
+
const out = h('div');
|
|
8
|
+
main.append(panel('外部系统同步记录', '查看出站请求的结果、重试次数与失败原因,辅助第三方联调与排障。', h('form', {
|
|
9
|
+
class: 'sync-filters',
|
|
10
|
+
onsubmit: (event) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
void load();
|
|
13
|
+
},
|
|
14
|
+
}, field('连接器', connector), field('同步结果', status), query), out));
|
|
15
|
+
async function load() {
|
|
16
|
+
const params = new URLSearchParams();
|
|
17
|
+
if (connector.value)
|
|
18
|
+
params.set('connector', connector.value);
|
|
19
|
+
if (status.value)
|
|
20
|
+
params.set('status', status.value);
|
|
21
|
+
const result = await run(() => api('GET', `/api/sys/ops/sync-records?${params}`), undefined, query);
|
|
22
|
+
if (!result)
|
|
23
|
+
return;
|
|
24
|
+
out.replaceChildren(table([
|
|
25
|
+
[
|
|
26
|
+
'连接器 / 操作',
|
|
27
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, state.catalog.labels[`connector:connector:${item.connector}`] ||
|
|
28
|
+
state.catalog.labels[`connector:${item.connector}`] ||
|
|
29
|
+
'外部系统连接器'), h('code', {}, `${item.connector}.${item.operation}`)),
|
|
30
|
+
],
|
|
31
|
+
['业务单据', (item) => h('code', {}, item.ref_id || '—')],
|
|
32
|
+
[
|
|
33
|
+
'同步结果',
|
|
34
|
+
(item) => badge(item.status === 'ok' ? '同步成功' : '同步失败', item.status === 'ok' ? 'green' : 'red'),
|
|
35
|
+
],
|
|
36
|
+
['响应状态', (item) => item.http_status ?? '未收到响应'],
|
|
37
|
+
['尝试次数', (item) => `${item.attempts} 次`],
|
|
38
|
+
[
|
|
39
|
+
'错误详情',
|
|
40
|
+
(item) => item.error
|
|
41
|
+
? h('details', {}, h('summary', {}, '查看原因'), h('pre', {}, item.error))
|
|
42
|
+
: h('span', { class: 'muted' }, '—'),
|
|
43
|
+
],
|
|
44
|
+
['最近更新', (item) => formatTime(item.updated_at)],
|
|
45
|
+
], result, '暂无符合条件的同步记录'));
|
|
46
|
+
}
|
|
47
|
+
await load();
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderOrg } from './org-page.js';
|