@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { api, can, run } from '../../shared/api.js';
|
|
2
|
+
import { badge, field, h, icon, modal, panel, searchInput, table } from '../../shared/dom.js';
|
|
3
|
+
import { navigate } from '../../shared/navigation.js';
|
|
4
|
+
/** 组织编辑继续走生成命令;负责人和角色是两份配置,在界面上明确区分。 */
|
|
5
|
+
export async function renderOrg(main) {
|
|
6
|
+
const [departments, people, roleResult] = await Promise.all([
|
|
7
|
+
can('Dept.read')
|
|
8
|
+
? api('GET', '/api/dept?pageSize=200&sort=createdAt')
|
|
9
|
+
: Promise.resolve({ items: [], total: 0 }),
|
|
10
|
+
can('User.read')
|
|
11
|
+
? api('GET', '/api/user?pageSize=200&status=active,disabled')
|
|
12
|
+
: Promise.resolve({ items: [], total: 0 }),
|
|
13
|
+
can('Role.read')
|
|
14
|
+
? api('GET', '/api/role?pageSize=200')
|
|
15
|
+
: Promise.resolve({ items: [] }),
|
|
16
|
+
]);
|
|
17
|
+
const depts = departments.items, users = people.items, roles = roleResult.items;
|
|
18
|
+
const departmentName = new Map(depts.map((dept) => [dept.id, dept.name]));
|
|
19
|
+
const userName = new Map(users.map((user) => [user.id, `${user.name}(${user.username})`]));
|
|
20
|
+
const roleName = new Map(roles.map((role) => [role.id, role.name]));
|
|
21
|
+
const depth = (dept) => Math.max(0, dept.ancestors.split('/').filter(Boolean).length - 1);
|
|
22
|
+
const selectDepartment = (value) => h('select', { ...(value ? { value } : {}) }, depts.map((dept) => h('option', { value: dept.id }, `${' '.repeat(depth(dept))}${dept.name}`)));
|
|
23
|
+
main.append(h('div', { class: 'stats-row' }, stat('部门', departments.total, 'grid'), stat('用户', people.total, 'users'), stat('当前列表已启用', users.filter((user) => user.status === 'active').length, 'check')));
|
|
24
|
+
const deptPanel = panel('部门与负责人', '部门负责人用于审批选人;给用户分配经理角色后,仍需在这里指定负责人。');
|
|
25
|
+
const departmentActions = h('div', { class: 'section-actions' });
|
|
26
|
+
if (can('Dept.create') && depts.length)
|
|
27
|
+
departmentActions.append(h('button', { class: 'small', onclick: createDepartment }, icon('plus'), '新建部门'));
|
|
28
|
+
deptPanel.querySelector('.panel-heading').append(departmentActions);
|
|
29
|
+
deptPanel.append(table([
|
|
30
|
+
[
|
|
31
|
+
'部门名称',
|
|
32
|
+
(dept) => h('div', { class: 'entity-cell', style: `padding-left:${depth(dept) * 20}px` }, h('span', { class: 'entity-icon' }, icon('grid')), h('strong', {}, dept.name)),
|
|
33
|
+
],
|
|
34
|
+
['部门编码', (dept) => h('code', { class: 'muted' }, dept.code || '—')],
|
|
35
|
+
[
|
|
36
|
+
'部门负责人',
|
|
37
|
+
(dept) => {
|
|
38
|
+
if (!can('Dept.update') || !can('User.read'))
|
|
39
|
+
return userName.get(dept.leaderId ?? '') || '未设置';
|
|
40
|
+
const select = h('select', {
|
|
41
|
+
value: dept.leaderId ?? '',
|
|
42
|
+
'aria-label': `${dept.name}负责人`,
|
|
43
|
+
onchange: async () => {
|
|
44
|
+
select.disabled = true;
|
|
45
|
+
const updated = await run(() => api('POST', `/api/dept/${dept.id}/update`, { leaderId: select.value || null }), '部门负责人已保存');
|
|
46
|
+
if (updated)
|
|
47
|
+
dept.leaderId = updated.leaderId;
|
|
48
|
+
else
|
|
49
|
+
select.value = dept.leaderId ?? '';
|
|
50
|
+
select.disabled = false;
|
|
51
|
+
},
|
|
52
|
+
}, h('option', { value: '' }, '请选择负责人'), users
|
|
53
|
+
.filter((user) => user.status === 'active')
|
|
54
|
+
.map((user) => h('option', { value: user.id }, userName.get(user.id))));
|
|
55
|
+
return select;
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
'状态',
|
|
60
|
+
(dept) => badge(dept.status === 'active' ? '已启用' : '已停用', dept.status === 'active' ? 'green' : 'neutral'),
|
|
61
|
+
],
|
|
62
|
+
], depts));
|
|
63
|
+
if (can('Dept.read'))
|
|
64
|
+
main.append(deptPanel);
|
|
65
|
+
if (can('User.read')) {
|
|
66
|
+
const out = h('div');
|
|
67
|
+
let query = '';
|
|
68
|
+
const toolbar = h('div', { class: 'table-toolbar' }, searchInput('搜索姓名、账号或部门', (value) => {
|
|
69
|
+
query = value.toLowerCase();
|
|
70
|
+
renderUsers();
|
|
71
|
+
}));
|
|
72
|
+
if (can('User.create'))
|
|
73
|
+
toolbar.append(h('button', { class: 'primary', disabled: !depts.length, onclick: createUser }, icon('plus'), '新建用户'));
|
|
74
|
+
const userPanel = panel('用户与角色', '通过角色分配操作权限;账号停用后将无法继续访问系统。', toolbar, out);
|
|
75
|
+
main.append(userPanel);
|
|
76
|
+
function renderUsers() {
|
|
77
|
+
const visible = users.filter((user) => `${user.name} ${user.username} ${departmentName.get(user.deptId) ?? ''}`
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.includes(query));
|
|
80
|
+
out.replaceChildren(table([
|
|
81
|
+
[
|
|
82
|
+
'用户',
|
|
83
|
+
(user) => h('div', { class: 'person-cell' }, h('span', { class: 'person-avatar' }, user.name.slice(0, 1)), h('div', {}, h('strong', {}, user.name), h('small', {}, user.username))),
|
|
84
|
+
],
|
|
85
|
+
['所属部门', (user) => departmentName.get(user.deptId) || '未获取部门名称'],
|
|
86
|
+
[
|
|
87
|
+
'拥有角色',
|
|
88
|
+
(user) => h('div', { class: 'tag-list' }, user.roleIds.length
|
|
89
|
+
? user.roleIds.map((id) => badge(roleName.get(id) || '未获取角色名称'))
|
|
90
|
+
: h('span', { class: 'muted' }, '尚未分配'), can('User.assignRoles') && can('Role.read') && user.status === 'active'
|
|
91
|
+
? h('button', { class: 'text-button', onclick: () => assignRoles(user) }, '配置')
|
|
92
|
+
: null),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'状态',
|
|
96
|
+
(user) => badge(user.status === 'active' ? '已启用' : '已停用', user.status === 'active' ? 'green' : 'neutral'),
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'账号操作',
|
|
100
|
+
(user) => h('div', { class: 'row-actions' }, can('User.setPassword') && user.status === 'active'
|
|
101
|
+
? h('button', { class: 'text-button', onclick: () => resetPassword(user) }, '重置密码')
|
|
102
|
+
: null, user.status === 'active' && can('User.disable')
|
|
103
|
+
? h('button', {
|
|
104
|
+
class: 'text-button danger',
|
|
105
|
+
onclick: async (event) => {
|
|
106
|
+
if (await run(() => api('POST', `/api/user/${user.id}/disable`), '账号已停用', event.currentTarget))
|
|
107
|
+
navigate('org');
|
|
108
|
+
},
|
|
109
|
+
}, '停用')
|
|
110
|
+
: null, user.status === 'disabled' && can('User.enable')
|
|
111
|
+
? h('button', {
|
|
112
|
+
class: 'text-button',
|
|
113
|
+
onclick: async (event) => {
|
|
114
|
+
if (await run(() => api('POST', `/api/user/${user.id}/enable`), '账号已启用', event.currentTarget))
|
|
115
|
+
navigate('org');
|
|
116
|
+
},
|
|
117
|
+
}, '启用')
|
|
118
|
+
: null),
|
|
119
|
+
],
|
|
120
|
+
], visible));
|
|
121
|
+
}
|
|
122
|
+
renderUsers();
|
|
123
|
+
}
|
|
124
|
+
if (people.total > users.length || departments.total > depts.length)
|
|
125
|
+
main.append(h('p', { class: 'muted' }, '当前仅展示前 200 条可见组织记录;超出部分请通过业务组织接口查询。'));
|
|
126
|
+
function createDepartment() {
|
|
127
|
+
const name = h('input', { required: true, maxlength: 100, placeholder: '例如:采购部' });
|
|
128
|
+
const parent = selectDepartment();
|
|
129
|
+
const save = h('button', { type: 'submit', class: 'primary' }, '创建部门');
|
|
130
|
+
const form = h('form', {
|
|
131
|
+
class: 'stack-form',
|
|
132
|
+
onsubmit: async (event) => {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
if (await run(() => api('POST', '/api/dept/create', { name: name.value, parentId: parent.value }), '部门已创建', save)) {
|
|
135
|
+
dialog.close();
|
|
136
|
+
navigate('org');
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}, field('部门名称', name), field('上级部门', parent), h('div', { class: 'form-actions' }, save));
|
|
140
|
+
const dialog = modal('新建部门', form);
|
|
141
|
+
name.focus();
|
|
142
|
+
}
|
|
143
|
+
function createUser() {
|
|
144
|
+
const username = h('input', { required: true, placeholder: '用于登录的账号', autocomplete: 'off' });
|
|
145
|
+
const name = h('input', { required: true, placeholder: '用户真实姓名' });
|
|
146
|
+
const department = selectDepartment();
|
|
147
|
+
const save = h('button', { type: 'submit', class: 'primary' }, '创建用户');
|
|
148
|
+
const form = h('form', {
|
|
149
|
+
class: 'stack-form',
|
|
150
|
+
onsubmit: async (event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
if (await run(() => api('POST', '/api/user/create', {
|
|
153
|
+
username: username.value,
|
|
154
|
+
name: name.value,
|
|
155
|
+
deptId: department.value,
|
|
156
|
+
}), '用户已创建,请设置密码并分配角色', save)) {
|
|
157
|
+
dialog.close();
|
|
158
|
+
navigate('org');
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}, field('登录账号', username), field('用户姓名', name), field('所属部门', department), h('p', { class: 'inline-info' }, icon('info'), '新用户默认没有角色。创建后请设置登录密码,并按职责配置角色。'), h('div', { class: 'form-actions' }, save));
|
|
162
|
+
const dialog = modal('新建用户', form);
|
|
163
|
+
username.focus();
|
|
164
|
+
}
|
|
165
|
+
function assignRoles(user) {
|
|
166
|
+
const selected = new Set(user.roleIds);
|
|
167
|
+
const save = h('button', { class: 'primary', type: 'submit' }, '保存角色');
|
|
168
|
+
const form = h('form', {
|
|
169
|
+
class: 'stack-form',
|
|
170
|
+
onsubmit: async (event) => {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
if (await run(() => api('POST', `/api/user/${user.id}/assign-roles`, { roleIds: [...selected] }), '用户角色已保存', save)) {
|
|
173
|
+
dialog.close();
|
|
174
|
+
navigate('org');
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}, h('p', { class: 'muted' }, `为 ${user.name} 选择角色,多个角色的有效权限会共同生效。`), h('div', { class: 'choice-list' }, roles
|
|
178
|
+
.filter((role) => role.status === 'active')
|
|
179
|
+
.map((role) => h('label', {}, h('input', {
|
|
180
|
+
type: 'checkbox',
|
|
181
|
+
checked: selected.has(role.id),
|
|
182
|
+
onchange: (event) => {
|
|
183
|
+
event.currentTarget.checked
|
|
184
|
+
? selected.add(role.id)
|
|
185
|
+
: selected.delete(role.id);
|
|
186
|
+
},
|
|
187
|
+
}), h('span', {}, h('strong', {}, role.name), h('small', {}, role.code))))), h('div', { class: 'form-actions' }, save));
|
|
188
|
+
const dialog = modal('配置用户角色', form);
|
|
189
|
+
}
|
|
190
|
+
function resetPassword(user) {
|
|
191
|
+
const password = h('input', {
|
|
192
|
+
type: 'password',
|
|
193
|
+
minlength: 8,
|
|
194
|
+
required: true,
|
|
195
|
+
autocomplete: 'new-password',
|
|
196
|
+
placeholder: '至少 8 位',
|
|
197
|
+
});
|
|
198
|
+
const save = h('button', { type: 'submit', class: 'primary' }, '保存新密码');
|
|
199
|
+
const form = h('form', {
|
|
200
|
+
class: 'stack-form',
|
|
201
|
+
onsubmit: async (event) => {
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
if (await run(() => api('POST', `/api/user/${user.id}/set-password`, { password: password.value }), '密码已重置', save))
|
|
204
|
+
dialog.close();
|
|
205
|
+
},
|
|
206
|
+
}, h('p', { class: 'muted' }, `正在为 ${user.name}(${user.username})设置新密码。`), field('新密码', password), h('div', { class: 'form-actions' }, save));
|
|
207
|
+
const dialog = modal('重置登录密码', form);
|
|
208
|
+
password.focus();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function stat(label, value, glyph) {
|
|
212
|
+
return h('div', { class: 'stat-card' }, h('span', { class: 'stat-icon' }, icon(glyph)), h('div', {}, h('span', {}, label), h('strong', {}, value)));
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderRoles } from './roles-page.js';
|
|
@@ -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
|
+
}
|