create-lumal-admin 0.0.4-beta → 0.0.6-beta

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.
Files changed (185) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +98 -12
  3. package/dist/cli.d.ts +18 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +308 -28
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +11 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +98 -2
  10. package/dist/create.js.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/presets.d.ts +41 -0
  16. package/dist/presets.d.ts.map +1 -0
  17. package/dist/presets.js +58 -0
  18. package/dist/presets.js.map +1 -0
  19. package/dist/project-cli.d.ts +3 -0
  20. package/dist/project-cli.d.ts.map +1 -0
  21. package/dist/project-cli.js +13 -0
  22. package/dist/project-cli.js.map +1 -0
  23. package/dist/projects.d.ts +21 -0
  24. package/dist/projects.d.ts.map +1 -0
  25. package/dist/projects.js +124 -0
  26. package/dist/projects.js.map +1 -0
  27. package/dist/scaffold.d.ts +2 -11
  28. package/dist/scaffold.d.ts.map +1 -1
  29. package/dist/scaffold.js +2 -1602
  30. package/dist/scaffold.js.map +1 -1
  31. package/dist/templates.d.ts +7 -0
  32. package/dist/templates.d.ts.map +1 -0
  33. package/dist/templates.js +91 -0
  34. package/dist/templates.js.map +1 -0
  35. package/dist/types.d.ts +64 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/package.json +13 -4
  40. package/templates/base/.env.example +4 -0
  41. package/templates/base/API-CONTRACT.md +84 -0
  42. package/templates/base/README.md +59 -0
  43. package/templates/base/index.html +12 -0
  44. package/templates/base/package.json +38 -0
  45. package/templates/base/src/App.vue +206 -0
  46. package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
  47. package/templates/base/src/app/components/AppSettingsDrawer.vue +37 -0
  48. package/templates/base/src/app/menu-policy.ts +163 -0
  49. package/templates/base/src/app/module.ts +46 -0
  50. package/templates/base/src/app/router/index.ts +107 -0
  51. package/templates/base/src/app/router/views.ts +12 -0
  52. package/templates/base/src/app/services/access.ts +2 -0
  53. package/templates/base/src/app/services/contracts.ts +54 -0
  54. package/templates/base/src/app/services/local-contracts.ts +18 -0
  55. package/templates/base/src/app/services/preferences.ts +41 -0
  56. package/templates/base/src/app/services/session-runtime.ts +186 -0
  57. package/templates/base/src/app/services/session.ts +14 -0
  58. package/templates/base/src/app/stored-menus.ts +72 -0
  59. package/templates/base/src/app/views/SafeIframeView.vue +34 -0
  60. package/templates/base/src/app/views/auth/LoginView.vue +75 -0
  61. package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
  62. package/templates/base/src/app/views/error/NotFoundView.vue +16 -0
  63. package/templates/base/src/main.ts +40 -0
  64. package/templates/base/src/modules/business/README.md +33 -0
  65. package/templates/base/src/modules/dashboard/DashboardView.vue +27 -0
  66. package/templates/base/src/modules/dashboard/module.ts +14 -0
  67. package/templates/base/src/shared/api.ts +53 -0
  68. package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
  69. package/templates/base/src/shared/composables/use-async-data.ts +35 -0
  70. package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
  71. package/templates/base/src/shared/status.ts +4 -0
  72. package/templates/base/src/shared/tree.ts +41 -0
  73. package/templates/base/src/styles.scss +173 -0
  74. package/templates/base/tests/menu-policy.test.ts.template +89 -0
  75. package/templates/base/tests/session.test.ts.template +45 -0
  76. package/templates/base/vite.config.ts +54 -0
  77. package/templates/blank/README.md +6 -0
  78. package/templates/blank/src/App.vue +11 -0
  79. package/templates/common/AGENTS.md +13 -0
  80. package/templates/common/_editorconfig +15 -0
  81. package/templates/common/_gitattributes +6 -0
  82. package/templates/common/_gitignore +7 -0
  83. package/templates/common/_prettierignore +6 -0
  84. package/templates/common/docs/DEVELOPMENT.md +26 -0
  85. package/templates/common/eslint.config.mjs +60 -0
  86. package/templates/common/package.json +39 -0
  87. package/templates/common/prettier.config.mjs +8 -0
  88. package/templates/common/tsconfig.json +19 -0
  89. package/templates/common/vitest.config.ts +3 -0
  90. package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
  91. package/templates/features/examples/src/modules/examples/FormExampleView.vue +32 -0
  92. package/templates/features/examples/src/modules/examples/module.ts +27 -0
  93. package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
  94. package/templates/features/system/src/modules/system/config/api.ts +38 -0
  95. package/templates/features/system/src/modules/system/config/routes.ts +21 -0
  96. package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
  97. package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
  98. package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
  99. package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
  100. package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
  101. package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
  102. package/templates/features/system/src/modules/system/local.ts +348 -0
  103. package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
  104. package/templates/features/system/src/modules/system/menu/api.ts +73 -0
  105. package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
  106. package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
  107. package/templates/features/system/src/modules/system/menu/types.ts +18 -0
  108. package/templates/features/system/src/modules/system/module.ts +22 -0
  109. package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
  110. package/templates/features/system/src/modules/system/organization/api.ts +38 -0
  111. package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
  112. package/templates/features/system/src/modules/system/organization/types.ts +17 -0
  113. package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
  114. package/templates/features/system/src/modules/system/role/api.ts +40 -0
  115. package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
  116. package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
  117. package/templates/features/system/src/modules/system/role/routes.ts +24 -0
  118. package/templates/features/system/src/modules/system/role/types.ts +12 -0
  119. package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
  120. package/templates/features/system/src/modules/system/user/api.ts +45 -0
  121. package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
  122. package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
  123. package/templates/features/system/src/modules/system/user/routes.ts +27 -0
  124. package/templates/features/system/src/modules/system/user/types.ts +28 -0
  125. package/templates/features/system/tests/system.test.ts.template +266 -0
  126. package/templates/modes/http/src/app/services/data-source.ts +124 -0
  127. package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
  128. package/templates/modes/http/tests/http.test.ts.template +218 -0
  129. package/templates/modes/local/src/app/services/data-source.ts +113 -0
  130. package/templates/modes/local/src/app/services/local-database.ts +67 -0
  131. package/templates/modes/local/tests/local.test.ts.template +132 -0
  132. package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
  133. package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
  134. package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
  135. package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
  136. package/templates/presets/standard/src/modules/profile/api.ts +49 -0
  137. package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
  138. package/templates/presets/standard/src/modules/profile/local.ts +40 -0
  139. package/templates/presets/standard/src/modules/profile/module.ts +11 -0
  140. package/templates/presets/standard/tests/profile.test.ts.template +72 -0
  141. package/templates/screen/README.md +6 -0
  142. package/templates/screen/src/App.vue +7 -0
  143. package/templates/screen/src/screen/ScreenEntry.vue +18 -0
  144. package/templates/screen/src/screen/ScreenView.vue +48 -0
  145. package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
  146. package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
  147. package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
  148. package/templates/screen-auth/src/services/session.ts +61 -0
  149. package/templates/screen-auth/tests/session.test.ts.template +54 -0
  150. package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
  151. package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
  152. package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
  153. package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
  154. package/templates/screen-data/src/screen/ScreenView.vue +69 -0
  155. package/templates/screen-data/src/services/metrics-types.ts +4 -0
  156. package/templates/screen-data-http/src/services/metrics.ts +20 -0
  157. package/templates/screen-data-http/src/services/request.ts +3 -0
  158. package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
  159. package/templates/screen-data-local/src/services/metrics.ts +11 -0
  160. package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
  161. package/templates/screen-http/API-CONTRACT.md +20 -0
  162. package/templates/screen-http/src/services/http.ts +17 -0
  163. package/templates/screen-request-auth/src/services/request.ts +15 -0
  164. package/templates/screen-router/src/App.vue +7 -0
  165. package/templates/screen-router/src/main.ts +13 -0
  166. package/templates/screen-router/src/router.ts +7 -0
  167. package/templates/screen-router/tests/app.test.ts.template +20 -0
  168. package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
  169. package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
  170. package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
  171. package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
  172. package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
  173. package/templates/vben-compatible/MIGRATION.md +30 -0
  174. package/templates/vben-compatible/src/modules/vben-migration/VbenFormView.vue +11 -0
  175. package/templates/vben-compatible/src/modules/vben-migration/VbenUsersView.vue +12 -0
  176. package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
  177. package/templates/vben-compatible/src/modules/vben-migration/components/VbenUsers.vue +144 -0
  178. package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
  179. package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
  180. package/templates/vue/index.html +12 -0
  181. package/templates/vue/src/main.ts +5 -0
  182. package/templates/vue/src/styles.css +19 -0
  183. package/templates/vue/tests/app.test.ts.template +10 -0
  184. package/templates/vue/vite.config.ts +9 -0
  185. package/templates/vue/vitest.config.ts +4 -0
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import type { PermissionNode } from '../../../../app/module';
3
+ import type { Role } from '../types';
4
+ import { ElAlert, ElButton, ElDialog, ElTree } from 'element-plus';
5
+ import { nextTick, shallowRef, useTemplateRef, watch } from 'vue';
6
+ import { collectPermissions } from '../../../../app/module';
7
+ import { application, service } from '../../../../app/services/session';
8
+ import { createRoleService } from '../api';
9
+
10
+ /***********************授权树加载与回显*********************/
11
+ const props = defineProps<{ role: Role }>();
12
+ const visible = defineModel<boolean>({ required: true });
13
+ const api = createRoleService(service);
14
+ const tree = useTemplateRef<InstanceType<typeof ElTree>>('tree');
15
+ const nodes = shallowRef<PermissionNode[]>([]);
16
+ const loading = shallowRef(false);
17
+ const saving = shallowRef(false);
18
+ const saved = shallowRef(false);
19
+ const failure = shallowRef('');
20
+ let generation = 0;
21
+ function flatten(items: PermissionNode[]): PermissionNode[] {
22
+ return items.flatMap((item) => [item, ...flatten(item.children ?? [])]);
23
+ }
24
+ async function load(): Promise<void> {
25
+ const version = ++generation;
26
+ loading.value = true;
27
+ failure.value = '';
28
+ saved.value = false;
29
+ try {
30
+ const [next, granted] = await Promise.all([api.tree(), api.permissions(props.role.code)]);
31
+ if (version !== generation || !visible.value) return;
32
+ nodes.value = next;
33
+ await nextTick();
34
+ tree.value?.setCheckedKeys(
35
+ flatten(next)
36
+ .filter((node) => node.permission && granted.includes(node.permission))
37
+ .map((node) => node.id),
38
+ );
39
+ } catch (reason) {
40
+ if (version === generation) failure.value = reason instanceof Error ? reason.message : '授权加载失败';
41
+ } finally {
42
+ if (version === generation) loading.value = false;
43
+ }
44
+ }
45
+ watch(
46
+ visible,
47
+ (open) => {
48
+ if (open) void load();
49
+ else generation += 1;
50
+ },
51
+ { immediate: true },
52
+ );
53
+
54
+ /***********************保存与部分成功恢复*********************/
55
+ async function save(): Promise<void> {
56
+ if (saving.value || loading.value || !tree.value) return;
57
+ saving.value = true;
58
+ failure.value = '';
59
+ try {
60
+ if (!saved.value) {
61
+ const keys = new Set([...tree.value.getCheckedKeys(false), ...tree.value.getHalfCheckedKeys()].map(String));
62
+ const known = new Set(collectPermissions(nodes.value));
63
+ const permissions = flatten(nodes.value)
64
+ .filter((node) => keys.has(node.id) && node.permission && known.has(node.permission))
65
+ .map((node) => node.permission!);
66
+ await api.authorize(props.role.code, permissions);
67
+ saved.value = true;
68
+ }
69
+ // 授权写入和当前账号权限重读不是事务,失败时只重试重读。
70
+ await application.refreshAccess();
71
+ visible.value = false;
72
+ } catch (reason) {
73
+ failure.value = `${saved.value ? '授权已保存,但会话刷新失败。可重试刷新:' : ''}${reason instanceof Error ? reason.message : '授权保存失败'}`;
74
+ } finally {
75
+ saving.value = false;
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <ElDialog
82
+ v-model="visible"
83
+ :title="`角色授权:${role.name}`"
84
+ width="min(600px, 94vw)"
85
+ append-to-body
86
+ :close-on-click-modal="!saving"
87
+ :close-on-press-escape="!saving"
88
+ >
89
+ <ElAlert v-if="failure" :title="failure" type="error" :closable="false" role="alert" />
90
+ <ElButton :disabled="loading || saving" @click="load"> 重新读取 </ElButton>
91
+ <ElTree
92
+ ref="tree"
93
+ :data="nodes"
94
+ node-key="id"
95
+ show-checkbox
96
+ default-expand-all
97
+ :props="{
98
+ label: 'label',
99
+ children: 'children',
100
+ disabled: () => loading || saving || saved || role.code === 'admin',
101
+ }"
102
+ />
103
+ <template #footer>
104
+ <ElButton :disabled="saving" @click="visible = false"> 关闭 </ElButton>
105
+ <ElButton type="primary" :loading="saving" :disabled="loading || !nodes.length" @click="save">
106
+ {{ saved ? '重试刷新' : '保存授权' }}
107
+ </ElButton>
108
+ </template>
109
+ </ElDialog>
110
+ </template>
@@ -0,0 +1,31 @@
1
+ import type { PermissionNode } from '../../../app/module';
2
+ import { object, text } from '../../../shared/api';
3
+
4
+ /** 将非叶节点的访问权限拆成独立叶子,避免回显父权限时误授予全部按钮权限。 */
5
+ export function parsePermissionTree(value: unknown): PermissionNode[] {
6
+ const ids = new Set<string>();
7
+ const seen = new WeakSet<object>();
8
+ let count = 0;
9
+ function visit(value: unknown, depth: number): PermissionNode[] {
10
+ if (!Array.isArray(value) || depth > 30) throw new Error('权限树必须是有限层级的数组');
11
+ return value.map((item) => {
12
+ const raw = object(item);
13
+ const id = text(raw.id, '权限节点 ID', true);
14
+ if (++count > 1000 || seen.has(raw) || ids.has(id)) throw new Error('权限树存在循环、重复节点或超过数量限制');
15
+ ids.add(id);
16
+ seen.add(raw);
17
+ const label = text(raw.label, '权限标题', true);
18
+ const permission = raw.permission === undefined ? undefined : text(raw.permission, '权限码', true);
19
+ const children = raw.children === undefined ? [] : visit(raw.children, depth + 1);
20
+ if (!children.length) return { id: `permission:${id}`, label, permission };
21
+ return {
22
+ id: `group:${id}`,
23
+ label,
24
+ children: permission
25
+ ? [{ id: `permission:${id}`, label: `${label}(访问)`, permission }, ...children]
26
+ : children,
27
+ };
28
+ });
29
+ }
30
+ return visit(value, 0);
31
+ }
@@ -0,0 +1,24 @@
1
+ import type { AppModule } from '../../../app/module';
2
+
3
+ export const menus: AppModule['menus'] = [
4
+ {
5
+ path: '/system/roles',
6
+ name: 'SystemRoles',
7
+ component: 'system/roles',
8
+ meta: { title: '角色管理', authority: ['system:role:list'], keepAlive: true, order: 2 },
9
+ },
10
+ ];
11
+ export const views: AppModule['views'] = { 'system/roles': () => import('./RoleView.vue') };
12
+ export const permissions: AppModule['permissions'] = [
13
+ {
14
+ id: 'system-role',
15
+ label: '角色管理',
16
+ children: [
17
+ { id: 'system:role:list', label: '查询角色', permission: 'system:role:list' },
18
+ { id: 'system:role:create', label: '新增角色', permission: 'system:role:create' },
19
+ { id: 'system:role:update', label: '修改角色', permission: 'system:role:update' },
20
+ { id: 'system:role:delete', label: '删除角色', permission: 'system:role:delete' },
21
+ { id: 'system:role:authorize', label: '角色授权', permission: 'system:role:authorize' },
22
+ ],
23
+ },
24
+ ];
@@ -0,0 +1,12 @@
1
+ import type { Status } from '../../../shared/api';
2
+
3
+ export interface RoleInput {
4
+ code: string;
5
+ name: string;
6
+ description: string;
7
+ status: Status;
8
+ }
9
+ export interface Role extends RoleInput {
10
+ id: string;
11
+ builtIn: boolean;
12
+ }
@@ -0,0 +1,297 @@
1
+ <script setup lang="ts">
2
+ import type { CrudDataSource, SchemaFormItem, SchemaTableColumn, SchemaTableRow } from '@lumal/core/components';
3
+ import type { Option } from '../../../shared/api';
4
+ import type { User, UserQuery } from './types';
5
+ import { LumalCrudTable, LumalPage } from '@lumal/core/components';
6
+ import { ElAlert, ElButton, ElMessage, ElMessageBox } from 'element-plus';
7
+ import { computed, shallowRef, useTemplateRef, watch } from 'vue';
8
+ import { permissionStore, service } from '../../../app/services/session';
9
+ import { options } from '../../../shared/api';
10
+ import { useAsyncData } from '../../../shared/composables/use-async-data';
11
+ import { statusOptions } from '../../../shared/status';
12
+ import { createUserService, parseUser, userInput } from './api';
13
+ import UserOrganizationFilter from './components/UserOrganizationFilter.vue';
14
+ import UserRoleDialog from './components/UserRoleDialog.vue';
15
+
16
+ /***********************用户列表与引用选项*********************/
17
+ const api = createUserService(service);
18
+ const table = useTemplateRef<InstanceType<typeof LumalCrudTable>>('table');
19
+ const organizationId = shallowRef('');
20
+ const selected = shallowRef<SchemaTableRow[]>([]);
21
+ const actionError = shallowRef('');
22
+ let listError: unknown;
23
+ const acting = shallowRef(false);
24
+ const roleDialog = shallowRef(false);
25
+ const assigning = shallowRef<User[]>([]);
26
+ const batchAssignment = shallowRef(false);
27
+ const can = (action: string) => permissionStore.hasPermission(`system:user:${action}`);
28
+ const references = useAsyncData(
29
+ async () => {
30
+ const [roles, organizations] = await Promise.all([
31
+ service.request('GET', '/system/roles/options'),
32
+ service.request('GET', '/system/organizations/options'),
33
+ ]);
34
+ return { roles: options(roles), organizations: options(organizations) };
35
+ },
36
+ { roles: [] as Option[], organizations: [] as Option[] },
37
+ );
38
+ function flatten(options: Option[]): Option[] {
39
+ return options.flatMap((item) => [item, ...flatten(item.children ?? [])]);
40
+ }
41
+ const orgLabels = computed(
42
+ () => new Map(flatten(references.data.value.organizations).map((item) => [item.value, item.label])),
43
+ );
44
+ watch(organizationId, () => {
45
+ void table.value?.reload();
46
+ });
47
+ const dataSource: CrudDataSource = {
48
+ async fetch(input) {
49
+ listError = undefined;
50
+ try {
51
+ return await api.list({ ...input, query: { ...input.query, organizationId: organizationId.value } as UserQuery });
52
+ } catch (reason) {
53
+ listError = reason;
54
+ throw reason;
55
+ }
56
+ },
57
+ create: (model) => api.create(userInput(model)),
58
+ update: (row, model) => api.update(parseUser(row).id, userInput(model)),
59
+ remove: (row) => api.remove(parseUser(row).id),
60
+ };
61
+
62
+ /***********************用户独立表单与查询契约*********************/
63
+ const columns = computed<SchemaTableColumn[]>(() => [
64
+ { field: 'username', label: '用户名', minWidth: 130 },
65
+ { field: 'nickname', label: '昵称', minWidth: 120 },
66
+ {
67
+ field: 'organizationId',
68
+ label: '机构',
69
+ minWidth: 140,
70
+ formatter: (value) => orgLabels.value.get(String(value)) ?? '-',
71
+ },
72
+ {
73
+ field: 'roles',
74
+ label: '角色',
75
+ minWidth: 150,
76
+ formatter: (value) =>
77
+ Array.isArray(value)
78
+ ? value
79
+ .map((code) => references.data.value.roles.find((option) => option.value === code)?.label ?? code)
80
+ .join('、')
81
+ : '',
82
+ },
83
+ { field: 'status', label: '状态', options: statusOptions, width: 90 },
84
+ { field: 'phone', label: '手机号', minWidth: 130 },
85
+ ]);
86
+ const schemas = computed<SchemaFormItem[]>(() => [
87
+ { field: 'username', label: '用户名', component: 'input', required: true, editDisabled: true },
88
+ {
89
+ field: 'nickname',
90
+ label: '昵称',
91
+ component: 'input',
92
+ required: true,
93
+ rules: [{ max: 32, message: '昵称最多 32 个字符' }],
94
+ },
95
+ {
96
+ field: 'organizationId',
97
+ label: '所属机构',
98
+ component: 'tree-select',
99
+ options: references.data.value.organizations,
100
+ componentProps: {
101
+ data: references.data.value.organizations,
102
+ checkStrictly: true,
103
+ defaultExpandAll: true,
104
+ props: { children: 'children', label: 'label', value: 'value', disabled: 'disabled' },
105
+ },
106
+ defaultValue: references.data.value.organizations[0]?.value ?? '',
107
+ },
108
+ {
109
+ field: 'roles',
110
+ label: '角色',
111
+ component: 'select',
112
+ required: true,
113
+ options: references.data.value.roles,
114
+ defaultValue: ['guest'],
115
+ componentProps: { multiple: true },
116
+ disabled: ({ mode }) => mode === 'edit' && !can('assign-roles'),
117
+ },
118
+ {
119
+ field: 'status',
120
+ label: '状态',
121
+ component: 'select',
122
+ options: statusOptions,
123
+ defaultValue: 'enabled',
124
+ disabled: ({ mode, model }) => model.username === 'admin' || (mode === 'edit' && !can('status')),
125
+ },
126
+ {
127
+ field: 'phone',
128
+ label: '手机号',
129
+ component: 'input',
130
+ rules: [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }],
131
+ },
132
+ ]);
133
+ const querySchemas = computed<SchemaFormItem[]>(() => [
134
+ { field: 'keyword', label: '关键词', component: 'input' },
135
+ { field: 'roles', label: '角色', component: 'select', options: references.data.value.roles },
136
+ { field: 'status', label: '状态', component: 'select', options: statusOptions },
137
+ ]);
138
+
139
+ /***********************状态、批量操作与密码重置*********************/
140
+ async function run(action: () => Promise<unknown>): Promise<void> {
141
+ if (acting.value) return;
142
+ acting.value = true;
143
+ actionError.value = '';
144
+ let persisted = false;
145
+ try {
146
+ await action();
147
+ persisted = true;
148
+ await table.value?.reload();
149
+ if (listError) throw listError;
150
+ table.value?.clearSelection();
151
+ selected.value = [];
152
+ ElMessage.success('操作成功');
153
+ } catch (reason) {
154
+ actionError.value = `${persisted ? '操作已保存,但刷新失败:' : ''}${reason instanceof Error ? reason.message : '操作失败'}`;
155
+ } finally {
156
+ acting.value = false;
157
+ }
158
+ }
159
+ function assign(users: User[], batch = false): void {
160
+ assigning.value = users;
161
+ batchAssignment.value = batch;
162
+ roleDialog.value = true;
163
+ }
164
+ async function resetPassword(row: SchemaTableRow): Promise<void> {
165
+ try {
166
+ await ElMessageBox.confirm(`重置 ${parseUser(row).nickname} 的密码?`, '密码重置', {
167
+ confirmButtonText: '重置',
168
+ cancelButtonText: '取消',
169
+ });
170
+ } catch {
171
+ return;
172
+ }
173
+ await run(async () => {
174
+ const result = await api.resetPassword(parseUser(row).id);
175
+ await ElMessageBox.alert(`临时密码:${result.temporaryPassword}`, '密码已重置');
176
+ });
177
+ }
178
+ async function rolesSaved(): Promise<void> {
179
+ table.value?.clearSelection();
180
+ selected.value = [];
181
+ await table.value?.reload();
182
+ if (listError) actionError.value = '角色分配已保存,但列表读取失败,请重新加载';
183
+ }
184
+ </script>
185
+
186
+ <template>
187
+ <LumalPage class="lumal-admin-page" title="用户管理" no-padding>
188
+ <ElAlert v-if="references.error.value" :title="references.error.value" type="error" :closable="false">
189
+ <ElButton link @click="references.reload"> 重试选项加载 </ElButton>
190
+ </ElAlert>
191
+ <ElAlert v-if="actionError" :title="actionError" type="error" role="alert" closable @close="actionError = ''" />
192
+ <div class="user-layout">
193
+ <UserOrganizationFilter v-model="organizationId" :options="references.data.value.organizations" />
194
+ <LumalCrudTable
195
+ ref="table"
196
+ :data-source="dataSource"
197
+ :form-schemas="schemas"
198
+ :table="{ columns, rowKey: 'id', selection: true, showColumnSettings: true, actionWidth: 430 }"
199
+ :query="{ schemas: querySchemas, columns: 3, collapsible: true, mobileDefaultVisible: false }"
200
+ :toolbar="{ create: can('create') && !references.error.value, batchDelete: false, refresh: true }"
201
+ :actions="{ view: false, edit: false, remove: false }"
202
+ :editor="{ columns: 2 }"
203
+ @selection-change="selected = $event"
204
+ @operation-error="actionError = $event instanceof Error ? $event.message : String($event)"
205
+ >
206
+ <template #toolbar-actions="{ selectedRows }">
207
+ <ElButton
208
+ v-if="can('status')"
209
+ :disabled="!selectedRows.length || acting"
210
+ @click="
211
+ run(() =>
212
+ api.batchStatus(
213
+ selectedRows.map((row) => parseUser(row).id),
214
+ 'enabled',
215
+ ),
216
+ )
217
+ "
218
+ >
219
+ 批量启用
220
+ </ElButton>
221
+ <ElButton
222
+ v-if="can('status')"
223
+ :disabled="!selectedRows.length || acting"
224
+ @click="
225
+ run(() =>
226
+ api.batchStatus(
227
+ selectedRows.map((row) => parseUser(row).id),
228
+ 'disabled',
229
+ ),
230
+ )
231
+ "
232
+ >
233
+ 批量停用
234
+ </ElButton>
235
+ <ElButton
236
+ v-if="can('assign-roles')"
237
+ :disabled="!selectedRows.length || !!references.error.value"
238
+ @click="assign(selectedRows.map(parseUser), true)"
239
+ >
240
+ 批量分配角色
241
+ </ElButton>
242
+ </template>
243
+ <template #row-actions="{ row }">
244
+ <ElButton link @click="table?.openView(row)"> 查看 </ElButton>
245
+ <ElButton v-if="can('update')" link @click="table?.openEdit(row)"> 编辑 </ElButton>
246
+ <ElButton
247
+ v-if="can('status')"
248
+ link
249
+ :disabled="row.username === 'admin' || acting"
250
+ @click="run(() => api.setStatus(parseUser(row).id, row.status === 'enabled' ? 'disabled' : 'enabled'))"
251
+ >
252
+ {{ row.status === 'enabled' ? '停用' : '启用' }}
253
+ </ElButton>
254
+ <ElButton
255
+ v-if="can('assign-roles')"
256
+ link
257
+ :disabled="!!references.error.value"
258
+ @click="assign([parseUser(row)])"
259
+ >
260
+ 分配角色
261
+ </ElButton>
262
+ <ElButton v-if="can('reset-password')" link @click="resetPassword(row)"> 重置密码 </ElButton>
263
+ <ElButton
264
+ v-if="can('delete')"
265
+ link
266
+ type="danger"
267
+ :disabled="row.username === 'admin'"
268
+ @click="table?.removeRow(row)"
269
+ >
270
+ 删除
271
+ </ElButton>
272
+ </template>
273
+ </LumalCrudTable>
274
+ </div>
275
+ <UserRoleDialog
276
+ v-model="roleDialog"
277
+ :users="assigning"
278
+ :options="references.data.value.roles"
279
+ :batch="batchAssignment"
280
+ @saved="rolesSaved"
281
+ />
282
+ </LumalPage>
283
+ </template>
284
+
285
+ <style scoped>
286
+ .user-layout {
287
+ display: grid;
288
+ grid-template-columns: 220px minmax(0, 1fr);
289
+ gap: 16px;
290
+ min-width: 0;
291
+ }
292
+ @media (max-width: 768px) {
293
+ .user-layout {
294
+ grid-template-columns: minmax(0, 1fr);
295
+ }
296
+ }
297
+ </style>
@@ -0,0 +1,45 @@
1
+ import type { SchemaFormModel } from '@lumal/core/components';
2
+ import type { ApplicationService } from '../../../app/services/contracts';
3
+ import type { PageInput } from '../../../shared/api';
4
+ import type { User, UserBatchResult, UserInput, UserQuery } from './types';
5
+ import { formStatus, object, stringArray, text } from '../../../shared/api';
6
+
7
+ export function userInput(model: SchemaFormModel): UserInput {
8
+ return {
9
+ username: text(model.username, '用户名', true),
10
+ nickname: text(model.nickname, '昵称', true),
11
+ organizationId: text(model.organizationId, '所属机构'),
12
+ roles: stringArray(model.roles),
13
+ phone: text(model.phone, '手机号'),
14
+ status: formStatus(model),
15
+ };
16
+ }
17
+ export function parseUser(value: unknown): User {
18
+ const raw = object(value);
19
+ return { ...userInput(raw), id: text(raw.id, '用户 ID', true), createdAt: text(raw.createdAt, '创建时间') };
20
+ }
21
+ export function createUserService(transport: Pick<ApplicationService, 'list' | 'request'>) {
22
+ const path = (id: string) => `/system/users/${encodeURIComponent(id)}`;
23
+ return {
24
+ async list(input: PageInput<UserQuery>) {
25
+ const result = await transport.list('users', { ...input, query: { ...input.query } });
26
+ return { items: result.items.map(parseUser), total: result.total };
27
+ },
28
+ create: async (input: UserInput) => parseUser(await transport.request('POST', '/system/users', { body: input })),
29
+ update: async (id: string, input: UserInput) =>
30
+ parseUser(await transport.request('PUT', path(id), { body: input })),
31
+ remove: (id: string) => transport.request<void>('DELETE', path(id)),
32
+ setStatus: (id: string, status: User['status']) =>
33
+ transport.request<User>('PATCH', `${path(id)}/status`, { body: { status } }),
34
+ assignRoles: (id: string, roles: string[]) =>
35
+ transport.request<User>('PUT', `${path(id)}/roles`, { body: { roles } }),
36
+ batchStatus: (ids: string[], status: User['status']) =>
37
+ transport.request<UserBatchResult>('PATCH', '/system/users/batch/status', { body: { ids, status } }),
38
+ batchRoles: (ids: string[], roles: string[], mode: 'append' | 'replace') =>
39
+ transport.request<UserBatchResult>('PUT', '/system/users/batch/roles', { body: { ids, roles, mode } }),
40
+ async resetPassword(id: string) {
41
+ const result = object(await transport.request('POST', `${path(id)}/reset-password`));
42
+ return { temporaryPassword: text(result.temporaryPassword, '临时密码', true) };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import type { Option } from '../../../../shared/api';
3
+ import { ElInput, ElTree } from 'element-plus';
4
+ import { shallowRef, useTemplateRef, watch } from 'vue';
5
+
6
+ /***********************机构筛选契约*********************/
7
+ defineProps<{ options: Option[] }>();
8
+ const value = defineModel<string>({ required: true });
9
+ const keyword = shallowRef('');
10
+ const tree = useTemplateRef<InstanceType<typeof ElTree>>('tree');
11
+ watch(keyword, (text) => tree.value?.filter(text));
12
+ function filter(text: string, data: Record<string, unknown>): boolean {
13
+ return !text || String(data.label).includes(text);
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <details class="user-organizations" open>
19
+ <summary>机构筛选</summary>
20
+ <ElInput v-model="keyword" aria-label="搜索机构" placeholder="搜索机构" clearable />
21
+ <ElTree
22
+ ref="tree"
23
+ :data="[{ label: '全部机构', value: '', children: options }]"
24
+ node-key="value"
25
+ default-expand-all
26
+ highlight-current
27
+ :current-node-key="value"
28
+ :expand-on-click-node="false"
29
+ :filter-node-method="filter"
30
+ :props="{ label: 'label' }"
31
+ @node-click="(node: Option) => (value = node.value)"
32
+ />
33
+ </details>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .user-organizations {
38
+ min-width: 0;
39
+ border-right: 1px solid var(--el-border-color-light);
40
+ padding-right: 16px;
41
+ }
42
+ .user-organizations summary {
43
+ font-weight: 600;
44
+ margin-bottom: 12px;
45
+ cursor: pointer;
46
+ }
47
+ .user-organizations :deep(.el-tree) {
48
+ margin-top: 12px;
49
+ max-height: 60dvh;
50
+ overflow: auto;
51
+ }
52
+ @media (max-width: 768px) {
53
+ .user-organizations {
54
+ border: 0;
55
+ padding: 0;
56
+ }
57
+ .user-organizations :deep(.el-tree) {
58
+ max-height: 160px;
59
+ }
60
+ }
61
+ </style>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
3
+ import type { Option } from '../../../../shared/api';
4
+ import type { User } from '../types';
5
+ import { LumalSchemaForm } from '@lumal/core/components';
6
+ import { ElAlert, ElDialog, ElMessage } from 'element-plus';
7
+ import { computed, shallowRef, watch } from 'vue';
8
+ import { service } from '../../../../app/services/session';
9
+ import { stringArray } from '../../../../shared/api';
10
+ import { createUserService } from '../api';
11
+
12
+ /***********************角色分配契约*********************/
13
+ const props = defineProps<{ users: User[]; options: Option[]; batch: boolean }>();
14
+ const emit = defineEmits<{ saved: [] }>();
15
+ const visible = defineModel<boolean>({ required: true });
16
+ const api = createUserService(service);
17
+ const model = shallowRef<SchemaFormModel>({});
18
+ const saving = shallowRef(false);
19
+ const error = shallowRef('');
20
+ watch(visible, (open) => {
21
+ if (open) {
22
+ model.value = { roles: props.batch ? [] : (props.users[0]?.roles ?? []), mode: 'append' };
23
+ error.value = '';
24
+ }
25
+ });
26
+ const schemas = computed<SchemaFormItem[]>(() => [
27
+ ...(props.batch
28
+ ? [
29
+ {
30
+ field: 'mode',
31
+ label: '分配方式',
32
+ component: 'radio' as const,
33
+ options: [
34
+ { label: '追加', value: 'append' },
35
+ { label: '替换', value: 'replace' },
36
+ ],
37
+ },
38
+ ]
39
+ : []),
40
+ {
41
+ field: 'roles',
42
+ label: '角色',
43
+ component: 'select',
44
+ componentProps: { multiple: true },
45
+ options: props.options,
46
+ required: true,
47
+ },
48
+ ]);
49
+
50
+ /***********************授权请求与失败恢复*********************/
51
+ async function save(values: SchemaFormModel): Promise<void> {
52
+ if (saving.value || !props.users.length) return;
53
+ saving.value = true;
54
+ error.value = '';
55
+ try {
56
+ const roles = stringArray(values.roles);
57
+ if (props.batch)
58
+ await api.batchRoles(
59
+ props.users.map((user) => user.id),
60
+ roles,
61
+ values.mode === 'replace' ? 'replace' : 'append',
62
+ );
63
+ else await api.assignRoles(props.users[0]!.id, roles);
64
+ visible.value = false;
65
+ emit('saved');
66
+ ElMessage.success('角色分配已保存');
67
+ } catch (reason) {
68
+ error.value = reason instanceof Error ? reason.message : '角色分配失败';
69
+ } finally {
70
+ saving.value = false;
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <ElDialog
77
+ v-model="visible"
78
+ :title="batch ? '批量分配角色' : '分配角色'"
79
+ width="min(520px, 94vw)"
80
+ append-to-body
81
+ :close-on-click-modal="!saving"
82
+ >
83
+ <ElAlert v-if="error" :title="error" type="error" :closable="false" role="alert" />
84
+ <LumalSchemaForm
85
+ v-model="model"
86
+ :schemas="schemas"
87
+ :submit-loading="saving"
88
+ show-actions
89
+ submit-text="保存角色"
90
+ @submit="save"
91
+ />
92
+ </ElDialog>
93
+ </template>