@yss123/cage-admin 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +49 -2
- package/dist/bootstrap.d.ts +17 -0
- package/dist/bootstrap.js +43 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/catalog.d.ts +43 -0
- package/dist/catalog.js +91 -0
- package/dist/catalog.js.map +1 -0
- package/dist/domain/constraints.d.ts +9 -0
- package/dist/domain/constraints.js +61 -0
- package/dist/domain/constraints.js.map +1 -0
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.js +5 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/org.d.ts +387 -0
- package/dist/domain/org.js +238 -0
- package/dist/domain/org.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.js +150 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -3
- 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 +14 -0
- package/ui/style.css +1577 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { api, can, run, state } from '../../shared/api.js';
|
|
2
|
+
import { badge, emptyState, field, h, icon, modal, searchInput } from '../../shared/dom.js';
|
|
3
|
+
import { invocation, objectName, scopes } from '../../shared/labels.js';
|
|
4
|
+
// 只保存页面交互,角色和权限仍以命令响应为准;搜索不会改动授权数据。
|
|
5
|
+
let selectedRole = null;
|
|
6
|
+
let tab = 'permissions';
|
|
7
|
+
let query = '';
|
|
8
|
+
let filter = 'all';
|
|
9
|
+
export async function renderRoles(main) {
|
|
10
|
+
const [result, departmentResult] = await Promise.all([
|
|
11
|
+
api('GET', '/api/role?pageSize=200'),
|
|
12
|
+
can('Dept.read')
|
|
13
|
+
? api('GET', '/api/dept?pageSize=200')
|
|
14
|
+
: Promise.resolve({ items: [] }),
|
|
15
|
+
]);
|
|
16
|
+
let roles = result.items;
|
|
17
|
+
if (!selectedRole || !roles.some((role) => role.id === selectedRole))
|
|
18
|
+
selectedRole = roles[0]?.id ?? null;
|
|
19
|
+
const list = h('div', { class: 'role-list', 'aria-label': '角色列表' });
|
|
20
|
+
const details = h('div', { class: 'role-details' });
|
|
21
|
+
let roleQuery = '';
|
|
22
|
+
const count = badge(String(roles.length), 'neutral');
|
|
23
|
+
const sidebar = h('aside', { class: 'role-sidebar' }, h('div', { class: 'role-list-heading' }, h('h2', {}, '团队角色'), count), h('p', { class: 'muted sidebar-description' }, '选择角色,配置可以做什么、可以看哪些数据。'), searchInput('搜索角色名称或编码', (value) => {
|
|
24
|
+
roleQuery = value.toLowerCase();
|
|
25
|
+
renderList();
|
|
26
|
+
}), list);
|
|
27
|
+
if (can('Role.create'))
|
|
28
|
+
sidebar.append(h('button', { class: 'button primary create-role', onclick: createRole }, icon('plus'), '新建角色'));
|
|
29
|
+
main.append(h('div', { class: 'role-workspace' }, sidebar, details));
|
|
30
|
+
function renderList() {
|
|
31
|
+
const visible = roles.filter((role) => `${role.name} ${role.code}`.toLowerCase().includes(roleQuery));
|
|
32
|
+
list.replaceChildren(...visible.map((role) => h('button', {
|
|
33
|
+
class: `role-item ${role.id === selectedRole ? 'selected' : ''}`,
|
|
34
|
+
'aria-pressed': role.id === selectedRole,
|
|
35
|
+
onclick: () => {
|
|
36
|
+
selectedRole = role.id;
|
|
37
|
+
renderList();
|
|
38
|
+
renderDetail();
|
|
39
|
+
},
|
|
40
|
+
}, h('span', { class: 'role-avatar' }, icon('shield')), h('span', { class: 'role-item-copy' }, h('strong', {}, role.name), h('small', {}, role.code)), role.status !== 'active'
|
|
41
|
+
? badge('停用')
|
|
42
|
+
: h('span', { class: 'role-item-count' }, role.capabilities.length))));
|
|
43
|
+
if (!visible.length)
|
|
44
|
+
list.append(h('p', { class: 'list-empty' }, '没有匹配的角色'));
|
|
45
|
+
}
|
|
46
|
+
function createRole() {
|
|
47
|
+
const name = h('input', { placeholder: '例如:采购经理', required: true, maxlength: 100 });
|
|
48
|
+
const code = h('input', {
|
|
49
|
+
placeholder: '例如:purchase_manager',
|
|
50
|
+
required: true,
|
|
51
|
+
pattern: '[a-z][a-z0-9_]*',
|
|
52
|
+
});
|
|
53
|
+
const save = h('button', { class: 'primary', type: 'submit' }, '创建角色');
|
|
54
|
+
const form = h('form', {
|
|
55
|
+
class: 'stack-form',
|
|
56
|
+
onsubmit: async (event) => {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
const created = await run(() => api('POST', '/api/role/create', { name: name.value, code: code.value }), '角色已创建,请继续配置权限', save);
|
|
59
|
+
if (created) {
|
|
60
|
+
roles = [...roles, created];
|
|
61
|
+
selectedRole = created.id;
|
|
62
|
+
count.textContent = String(roles.length);
|
|
63
|
+
dialog.close();
|
|
64
|
+
renderList();
|
|
65
|
+
renderDetail();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}, field('角色名称', name), field('角色编码', code, '用于业务识别,使用小写字母、数字和下划线。'), h('p', { class: 'inline-info' }, icon('info'), '新角色默认没有操作权限,不会自动获得已有业务能力。'), h('div', { class: 'form-actions' }, save));
|
|
69
|
+
const dialog = modal('新建团队角色', form);
|
|
70
|
+
name.focus();
|
|
71
|
+
}
|
|
72
|
+
function renderDetail() {
|
|
73
|
+
let role = roles.find((item) => item.id === selectedRole);
|
|
74
|
+
if (!role) {
|
|
75
|
+
details.replaceChildren(emptyState('从一个角色开始', '选择左侧角色,或创建一个新的团队角色。', 'shield'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const updateRole = (updated) => {
|
|
79
|
+
role = updated;
|
|
80
|
+
roles = roles.map((item) => (item.id === updated.id ? updated : item));
|
|
81
|
+
renderList();
|
|
82
|
+
};
|
|
83
|
+
const countText = h('strong', {}, String(role.capabilities.length));
|
|
84
|
+
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', {}, '项已授权')));
|
|
85
|
+
const body = h('div', { class: 'role-tab-body' });
|
|
86
|
+
const buttons = ['permissions', 'scopes'].map((key) => h('button', {
|
|
87
|
+
role: 'tab',
|
|
88
|
+
'aria-selected': tab === key,
|
|
89
|
+
class: tab === key ? 'active' : '',
|
|
90
|
+
onclick: () => {
|
|
91
|
+
tab = key;
|
|
92
|
+
renderDetail();
|
|
93
|
+
},
|
|
94
|
+
}, icon(key === 'permissions' ? 'shield' : 'grid'), key === 'permissions' ? '操作权限' : '数据范围'));
|
|
95
|
+
details.replaceChildren(summary, h('div', { class: 'role-tabs', role: 'tablist', 'aria-label': '角色配置' }, buttons), body);
|
|
96
|
+
if (role.status !== 'active')
|
|
97
|
+
body.append(h('div', { class: 'inline-info' }, icon('lock'), '此角色已停用,当前配置仅供查看。'));
|
|
98
|
+
if (tab === 'permissions') {
|
|
99
|
+
const grid = h('div', { class: 'permission-grid' });
|
|
100
|
+
const filteredCount = h('span', { class: 'muted result-count' });
|
|
101
|
+
const filters = h('div', { class: 'segmented', 'aria-label': '筛选操作权限' }, ['all', 'granted', 'ungranted'].map((key) => h('button', {
|
|
102
|
+
class: key === filter ? 'active' : '',
|
|
103
|
+
'aria-pressed': key === filter,
|
|
104
|
+
onclick: (event) => {
|
|
105
|
+
filter = key;
|
|
106
|
+
for (const button of filters.querySelectorAll('button')) {
|
|
107
|
+
button.classList.remove('active');
|
|
108
|
+
button.setAttribute('aria-pressed', 'false');
|
|
109
|
+
}
|
|
110
|
+
const button = event.currentTarget;
|
|
111
|
+
button.classList.add('active');
|
|
112
|
+
button.setAttribute('aria-pressed', 'true');
|
|
113
|
+
renderGroups();
|
|
114
|
+
},
|
|
115
|
+
}, { all: '全部', granted: '已授权', ungranted: '未授权' }[key])));
|
|
116
|
+
body.append(h('div', { class: 'permission-toolbar' }, searchInput('搜索业务、操作名称或权限标识', (value) => {
|
|
117
|
+
query = value;
|
|
118
|
+
renderGroups();
|
|
119
|
+
}, query), filters), h('div', { class: 'permission-help' }, h('span', {}, icon('info'), '勾选后即时保存。只能授予你已拥有的权限。'), filteredCount), grid);
|
|
120
|
+
function renderGroups() {
|
|
121
|
+
const granted = new Map(role.capabilities.map((item) => [item.capability, item.params]));
|
|
122
|
+
const search = query.trim().toLowerCase();
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
const visible = state.catalog.capabilities.filter((item) => {
|
|
125
|
+
const has = granted.has(item.capability);
|
|
126
|
+
return ((filter === 'all' || (filter === 'granted' ? has : !has)) &&
|
|
127
|
+
`${item.label} ${item.description ?? ''} ${item.capability} ${item.aggregate ? objectName(item.aggregate) : '系统管理'}`
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.includes(search));
|
|
130
|
+
});
|
|
131
|
+
for (const capability of visible) {
|
|
132
|
+
const key = capability.aggregate ?? 'system';
|
|
133
|
+
if (!groups.has(key))
|
|
134
|
+
groups.set(key, []);
|
|
135
|
+
groups.get(key).push(capability);
|
|
136
|
+
}
|
|
137
|
+
grid.replaceChildren();
|
|
138
|
+
filteredCount.textContent = `显示 ${visible.length} 项权限`;
|
|
139
|
+
countText.textContent = String(role.capabilities.length);
|
|
140
|
+
// 客户业务排在前面,框架自带的组织与运维权限放在后面,先回答业务人员最关心的问题。
|
|
141
|
+
const builtIn = new Set(['Dept', 'User', 'Role', 'system']);
|
|
142
|
+
const ordered = [...groups].sort(([a], [b]) => Number(builtIn.has(a)) - Number(builtIn.has(b)));
|
|
143
|
+
for (const [name, capabilities] of ordered) {
|
|
144
|
+
const isSystem = name === 'system';
|
|
145
|
+
const grantedCount = capabilities.filter((item) => granted.has(item.capability)).length;
|
|
146
|
+
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}`)));
|
|
147
|
+
for (const capability of capabilities)
|
|
148
|
+
card.append(permissionRow(capability, granted));
|
|
149
|
+
grid.append(card);
|
|
150
|
+
}
|
|
151
|
+
if (!visible.length)
|
|
152
|
+
grid.append(emptyState('没有找到匹配的权限', '换一个关键词,或调整上方授权筛选。', 'search'));
|
|
153
|
+
}
|
|
154
|
+
function permissionRow(capability, granted) {
|
|
155
|
+
const checked = granted.has(capability.capability);
|
|
156
|
+
const authorized = role.status === 'active' &&
|
|
157
|
+
can(checked ? 'Role.revokeCapability' : 'Role.grantCapability') &&
|
|
158
|
+
(checked || can(capability.capability));
|
|
159
|
+
const mode = invocation(capability);
|
|
160
|
+
const toggle = h('input', {
|
|
161
|
+
type: 'checkbox',
|
|
162
|
+
checked,
|
|
163
|
+
disabled: !authorized,
|
|
164
|
+
'aria-label': `${checked ? '收回' : '授予'} ${capability.label}`,
|
|
165
|
+
onchange: async () => {
|
|
166
|
+
toggle.disabled = true;
|
|
167
|
+
const updated = await run(() => api('POST', `/api/role/${role.id}/${toggle.checked ? 'grant-capability' : 'revoke-capability'}`, { capability: capability.capability }), toggle.checked ? '操作权限已授予' : '操作权限已收回');
|
|
168
|
+
if (updated) {
|
|
169
|
+
updateRole(updated);
|
|
170
|
+
renderGroups();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
toggle.checked = checked;
|
|
174
|
+
toggle.disabled = !authorized;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const title = capability.aggregate
|
|
179
|
+
? capability.label.replaceAll(capability.aggregate, objectName(capability.aggregate))
|
|
180
|
+
: capability.label;
|
|
181
|
+
const row = h('div', { class: `permission-row ${checked ? 'granted' : ''}` }, h('label', {
|
|
182
|
+
class: 'permission-toggle',
|
|
183
|
+
title: !authorized && !checked
|
|
184
|
+
? '你没有该权限,无法授予其他角色'
|
|
185
|
+
: capability.kind === 'command'
|
|
186
|
+
? capability.description || title
|
|
187
|
+
: title,
|
|
188
|
+
}, toggle, h('span', { class: 'checkbox-visual' }, icon('check')), h('span', { class: 'permission-copy' }, h('span', { class: 'permission-name' }, title), h('code', {}, capability.capability))), mode &&
|
|
189
|
+
h('span', { class: 'invocation-badge', title: '配置此权限后,仍需通过对应流程或外部接口调用' }, mode));
|
|
190
|
+
if (checked &&
|
|
191
|
+
(capability.capability.endsWith('.approve') ||
|
|
192
|
+
granted.get(capability.capability)?.ceiling !== undefined)) {
|
|
193
|
+
const ceiling = h('input', {
|
|
194
|
+
type: 'number',
|
|
195
|
+
min: 0,
|
|
196
|
+
step: 'any',
|
|
197
|
+
placeholder: '请设置额度',
|
|
198
|
+
value: String(granted.get(capability.capability)?.ceiling ?? ''),
|
|
199
|
+
required: true,
|
|
200
|
+
disabled: !can('Role.grantCapability') || !can(capability.capability) || role.status !== 'active',
|
|
201
|
+
'aria-label': `${title}额度上限`,
|
|
202
|
+
});
|
|
203
|
+
const save = h('button', { type: 'submit', class: 'small', disabled: ceiling.disabled }, '保存额度');
|
|
204
|
+
row.append(h('form', {
|
|
205
|
+
class: 'limit-form',
|
|
206
|
+
onsubmit: async (event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
const updated = await run(() => api('POST', `/api/role/${role.id}/grant-capability`, {
|
|
209
|
+
capability: capability.capability,
|
|
210
|
+
params: { ...granted.get(capability.capability), ceiling: Number(ceiling.value) },
|
|
211
|
+
}), '额度已保存', save);
|
|
212
|
+
if (updated) {
|
|
213
|
+
updateRole(updated);
|
|
214
|
+
renderGroups();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}, h('span', {}, '额度上限'), ceiling, save));
|
|
218
|
+
}
|
|
219
|
+
return row;
|
|
220
|
+
}
|
|
221
|
+
renderGroups();
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
body.append(h('div', { class: 'inline-info' }, icon('info'), '操作权限决定“可以做什么”,数据范围决定“可以看哪些记录”。未单独配置的对象沿用默认范围。'));
|
|
225
|
+
const scopeGrid = h('div', { class: 'scope-grid' });
|
|
226
|
+
for (const aggregate of [
|
|
227
|
+
{ name: '*', label: '默认数据范围', scope: { ownerField: null, deptField: null } },
|
|
228
|
+
...state.catalog.aggregates,
|
|
229
|
+
])
|
|
230
|
+
scopeGrid.append(scopeCard(aggregate));
|
|
231
|
+
body.append(scopeGrid);
|
|
232
|
+
}
|
|
233
|
+
function scopeCard(aggregate) {
|
|
234
|
+
const current = role.scopes.find((scope) => scope.aggregate === aggregate.name);
|
|
235
|
+
const inherited = role.scopes.find((scope) => scope.aggregate === '*');
|
|
236
|
+
const editable = role.status === 'active' && can('Role.setScope');
|
|
237
|
+
const usable = state.catalog.combinators.filter((kind) => aggregate.name === '*' ||
|
|
238
|
+
kind === 'all' ||
|
|
239
|
+
(kind === 'self' ? aggregate.scope.ownerField : aggregate.scope.deptField));
|
|
240
|
+
const selected = new Set(current?.deptIds ?? []);
|
|
241
|
+
const departmentBox = h('div', { class: 'department-options' }, departmentResult.items.map((dept) => h('label', {}, h('input', {
|
|
242
|
+
type: 'checkbox',
|
|
243
|
+
checked: selected.has(dept.id),
|
|
244
|
+
disabled: !editable,
|
|
245
|
+
onchange: (event) => {
|
|
246
|
+
event.currentTarget.checked
|
|
247
|
+
? selected.add(dept.id)
|
|
248
|
+
: selected.delete(dept.id);
|
|
249
|
+
dirty();
|
|
250
|
+
},
|
|
251
|
+
}), dept.name)));
|
|
252
|
+
if (!departmentResult.items.length)
|
|
253
|
+
departmentBox.append(h('p', { class: 'muted' }, can('Dept.read') ? '当前没有可选择的部门' : '需要查看部门权限才能调整部门清单,已有选择会保留。'));
|
|
254
|
+
const status = h('span', { class: 'save-state' }, '已保存');
|
|
255
|
+
const save = h('button', { class: 'small', type: 'submit', disabled: true }, '保存范围');
|
|
256
|
+
const description = h('p', { class: 'scope-description' });
|
|
257
|
+
const select = h('select', {
|
|
258
|
+
value: current?.combinator ?? '',
|
|
259
|
+
disabled: !editable,
|
|
260
|
+
'aria-label': `${aggregate.label}的数据范围`,
|
|
261
|
+
onchange: () => {
|
|
262
|
+
updateDescription();
|
|
263
|
+
dirty();
|
|
264
|
+
},
|
|
265
|
+
}, h('option', { value: '' }, aggregate.name === '*'
|
|
266
|
+
? '未配置(按仅本人处理)'
|
|
267
|
+
: `沿用默认:${scopes[inherited?.combinator ?? 'self'].title}`), usable.map((kind) => h('option', { value: kind }, scopes[kind].title)));
|
|
268
|
+
function dirty() {
|
|
269
|
+
status.textContent = '尚未保存';
|
|
270
|
+
status.classList.add('pending');
|
|
271
|
+
save.disabled = !editable;
|
|
272
|
+
}
|
|
273
|
+
function updateDescription() {
|
|
274
|
+
const kind = select.value;
|
|
275
|
+
description.textContent = kind ? scopes[kind].hint : '由默认数据范围决定;默认未配置时按仅本人处理。';
|
|
276
|
+
const effective = kind || inherited?.combinator || 'self';
|
|
277
|
+
// 继承允许保留统一默认值,但不能把不适用的范围解释成“可查看本部门”。
|
|
278
|
+
// 这里只说明真实效果,不修改授权,也不替用户扩大到全部数据。
|
|
279
|
+
if (aggregate.name !== '*' && effective === 'self' && !aggregate.scope.ownerField)
|
|
280
|
+
description.textContent += ' 此对象未设置本人归属,当前范围无法匹配记录。';
|
|
281
|
+
else if (aggregate.name !== '*' &&
|
|
282
|
+
['dept', 'deptSubtree', 'deptSet'].includes(effective) &&
|
|
283
|
+
!aggregate.scope.deptField)
|
|
284
|
+
description.textContent += ' 此对象未设置部门归属,当前范围无法匹配记录。';
|
|
285
|
+
departmentBox.hidden = kind !== 'deptSet';
|
|
286
|
+
}
|
|
287
|
+
updateDescription();
|
|
288
|
+
const form = h('form', {
|
|
289
|
+
class: `scope-card ${aggregate.name === '*' ? 'default-scope' : ''}`,
|
|
290
|
+
onsubmit: async (event) => {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
const updated = await run(() => api('POST', `/api/role/${role.id}/set-scope`, {
|
|
293
|
+
aggregate: aggregate.name,
|
|
294
|
+
combinator: select.value || null,
|
|
295
|
+
...(select.value === 'deptSet' ? { deptIds: [...selected] } : {}),
|
|
296
|
+
}), '数据范围已保存', save);
|
|
297
|
+
if (updated) {
|
|
298
|
+
updateRole(updated);
|
|
299
|
+
renderDetail();
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
}, 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));
|
|
303
|
+
return form;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
renderList();
|
|
307
|
+
renderDetail();
|
|
308
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderTasks } from './tasks-page.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { api, run, state } from '../../shared/api.js';
|
|
2
|
+
import { badge, emptyState, formatTime, h, panel, table } from '../../shared/dom.js';
|
|
3
|
+
import { objectName } from '../../shared/labels.js';
|
|
4
|
+
import { navigate } from '../../shared/navigation.js';
|
|
5
|
+
export async function renderTasks(main) {
|
|
6
|
+
const hasApprovals = state.catalog.approvals.length > 0;
|
|
7
|
+
if (!hasApprovals) {
|
|
8
|
+
main.append(panel('我的审批待办', undefined, emptyState('当前没有启用审批流程', '业务启用审批后,分配给你的任务会显示在这里。')));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const tasks = await api('GET', '/api/approval/tasks');
|
|
12
|
+
if (!tasks.length) {
|
|
13
|
+
main.append(panel('我的审批待办', undefined, emptyState('待办已处理完毕', '新的审批任务分配给你后,会显示在这里。')));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
main.append(panel('等待你处理', `共 ${tasks.length} 项待办。审批结论会继续执行业务权限与规则校验。`, table([
|
|
17
|
+
[
|
|
18
|
+
'审批事项',
|
|
19
|
+
(task) => h('div', { class: 'stack-cell' }, h('strong', {}, task.title), h('small', {}, state.catalog.approvals.find((item) => item.process === task.process)?.label || '业务审批')),
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
'关联单据',
|
|
23
|
+
(task) => h('div', { class: 'stack-cell' }, badge(objectName(task.aggregate)), h('code', {}, task.businessKey.slice(0, 8))),
|
|
24
|
+
],
|
|
25
|
+
['提交时间', (task) => formatTime(task.createdAt)],
|
|
26
|
+
[
|
|
27
|
+
'流程信息',
|
|
28
|
+
(task) => h('details', {}, h('summary', {}, '查看详情'), h('pre', {}, JSON.stringify(task.vars, null, 2))),
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
'处理意见与操作',
|
|
32
|
+
(task) => {
|
|
33
|
+
const comment = h('input', {
|
|
34
|
+
placeholder: '填写审批意见(可选)',
|
|
35
|
+
'aria-label': `${task.title}审批意见`,
|
|
36
|
+
});
|
|
37
|
+
const actions = h('div', { class: 'row-actions' });
|
|
38
|
+
const submit = async (action, button) => {
|
|
39
|
+
// 一项待办的两个结论互斥,发送期间同时禁用,避免界面重复提交相反结论。
|
|
40
|
+
for (const item of actions.querySelectorAll('button'))
|
|
41
|
+
item.disabled = true;
|
|
42
|
+
const result = await run(() => api('POST', `/api/approval/tasks/${task.id}/${action}`, { comment: comment.value }), action === 'approve' ? '已通过审批' : '已驳回', button);
|
|
43
|
+
if (result)
|
|
44
|
+
navigate('tasks');
|
|
45
|
+
else
|
|
46
|
+
for (const item of actions.querySelectorAll('button'))
|
|
47
|
+
item.disabled = false;
|
|
48
|
+
};
|
|
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
|
+
}, '通过审批'));
|
|
56
|
+
return h('div', { class: 'task-actions' }, comment, actions);
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
], tasks)));
|
|
60
|
+
}
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
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
|
+
<div id="notifications" aria-live="polite"></div>
|
|
12
|
+
<script type="module" src="./dist/app.js"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|