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.
- package/CHANGELOG.md +25 -0
- package/README.md +98 -12
- package/dist/cli.d.ts +18 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +308 -28
- package/dist/cli.js.map +1 -1
- package/dist/create.d.ts +11 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +98 -2
- package/dist/create.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/presets.d.ts +41 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +58 -0
- package/dist/presets.js.map +1 -0
- package/dist/project-cli.d.ts +3 -0
- package/dist/project-cli.d.ts.map +1 -0
- package/dist/project-cli.js +13 -0
- package/dist/project-cli.js.map +1 -0
- package/dist/projects.d.ts +21 -0
- package/dist/projects.d.ts.map +1 -0
- package/dist/projects.js +124 -0
- package/dist/projects.js.map +1 -0
- package/dist/scaffold.d.ts +2 -11
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +2 -1602
- package/dist/scaffold.js.map +1 -1
- package/dist/templates.d.ts +7 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +91 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +13 -4
- package/templates/base/.env.example +4 -0
- package/templates/base/API-CONTRACT.md +84 -0
- package/templates/base/README.md +59 -0
- package/templates/base/index.html +12 -0
- package/templates/base/package.json +38 -0
- package/templates/base/src/App.vue +206 -0
- package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
- package/templates/base/src/app/components/AppSettingsDrawer.vue +37 -0
- package/templates/base/src/app/menu-policy.ts +163 -0
- package/templates/base/src/app/module.ts +46 -0
- package/templates/base/src/app/router/index.ts +107 -0
- package/templates/base/src/app/router/views.ts +12 -0
- package/templates/base/src/app/services/access.ts +2 -0
- package/templates/base/src/app/services/contracts.ts +54 -0
- package/templates/base/src/app/services/local-contracts.ts +18 -0
- package/templates/base/src/app/services/preferences.ts +41 -0
- package/templates/base/src/app/services/session-runtime.ts +186 -0
- package/templates/base/src/app/services/session.ts +14 -0
- package/templates/base/src/app/stored-menus.ts +72 -0
- package/templates/base/src/app/views/SafeIframeView.vue +34 -0
- package/templates/base/src/app/views/auth/LoginView.vue +75 -0
- package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
- package/templates/base/src/app/views/error/NotFoundView.vue +16 -0
- package/templates/base/src/main.ts +40 -0
- package/templates/base/src/modules/business/README.md +33 -0
- package/templates/base/src/modules/dashboard/DashboardView.vue +27 -0
- package/templates/base/src/modules/dashboard/module.ts +14 -0
- package/templates/base/src/shared/api.ts +53 -0
- package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
- package/templates/base/src/shared/composables/use-async-data.ts +35 -0
- package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
- package/templates/base/src/shared/status.ts +4 -0
- package/templates/base/src/shared/tree.ts +41 -0
- package/templates/base/src/styles.scss +173 -0
- package/templates/base/tests/menu-policy.test.ts.template +89 -0
- package/templates/base/tests/session.test.ts.template +45 -0
- package/templates/base/vite.config.ts +54 -0
- package/templates/blank/README.md +6 -0
- package/templates/blank/src/App.vue +11 -0
- package/templates/common/AGENTS.md +13 -0
- package/templates/common/_editorconfig +15 -0
- package/templates/common/_gitattributes +6 -0
- package/templates/common/_gitignore +7 -0
- package/templates/common/_prettierignore +6 -0
- package/templates/common/docs/DEVELOPMENT.md +26 -0
- package/templates/common/eslint.config.mjs +60 -0
- package/templates/common/package.json +39 -0
- package/templates/common/prettier.config.mjs +8 -0
- package/templates/common/tsconfig.json +19 -0
- package/templates/common/vitest.config.ts +3 -0
- package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
- package/templates/features/examples/src/modules/examples/FormExampleView.vue +32 -0
- package/templates/features/examples/src/modules/examples/module.ts +27 -0
- package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
- package/templates/features/system/src/modules/system/config/api.ts +38 -0
- package/templates/features/system/src/modules/system/config/routes.ts +21 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
- package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
- package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
- package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
- package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
- package/templates/features/system/src/modules/system/local.ts +348 -0
- package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
- package/templates/features/system/src/modules/system/menu/api.ts +73 -0
- package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
- package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
- package/templates/features/system/src/modules/system/menu/types.ts +18 -0
- package/templates/features/system/src/modules/system/module.ts +22 -0
- package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
- package/templates/features/system/src/modules/system/organization/api.ts +38 -0
- package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
- package/templates/features/system/src/modules/system/organization/types.ts +17 -0
- package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
- package/templates/features/system/src/modules/system/role/api.ts +40 -0
- package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
- package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
- package/templates/features/system/src/modules/system/role/routes.ts +24 -0
- package/templates/features/system/src/modules/system/role/types.ts +12 -0
- package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
- package/templates/features/system/src/modules/system/user/api.ts +45 -0
- package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
- package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
- package/templates/features/system/src/modules/system/user/routes.ts +27 -0
- package/templates/features/system/src/modules/system/user/types.ts +28 -0
- package/templates/features/system/tests/system.test.ts.template +266 -0
- package/templates/modes/http/src/app/services/data-source.ts +124 -0
- package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
- package/templates/modes/http/tests/http.test.ts.template +218 -0
- package/templates/modes/local/src/app/services/data-source.ts +113 -0
- package/templates/modes/local/src/app/services/local-database.ts +67 -0
- package/templates/modes/local/tests/local.test.ts.template +132 -0
- package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
- package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
- package/templates/presets/standard/src/modules/profile/api.ts +49 -0
- package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
- package/templates/presets/standard/src/modules/profile/local.ts +40 -0
- package/templates/presets/standard/src/modules/profile/module.ts +11 -0
- package/templates/presets/standard/tests/profile.test.ts.template +72 -0
- package/templates/screen/README.md +6 -0
- package/templates/screen/src/App.vue +7 -0
- package/templates/screen/src/screen/ScreenEntry.vue +18 -0
- package/templates/screen/src/screen/ScreenView.vue +48 -0
- package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
- package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
- package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
- package/templates/screen-auth/src/services/session.ts +61 -0
- package/templates/screen-auth/tests/session.test.ts.template +54 -0
- package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
- package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
- package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
- package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
- package/templates/screen-data/src/screen/ScreenView.vue +69 -0
- package/templates/screen-data/src/services/metrics-types.ts +4 -0
- package/templates/screen-data-http/src/services/metrics.ts +20 -0
- package/templates/screen-data-http/src/services/request.ts +3 -0
- package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
- package/templates/screen-data-local/src/services/metrics.ts +11 -0
- package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
- package/templates/screen-http/API-CONTRACT.md +20 -0
- package/templates/screen-http/src/services/http.ts +17 -0
- package/templates/screen-request-auth/src/services/request.ts +15 -0
- package/templates/screen-router/src/App.vue +7 -0
- package/templates/screen-router/src/main.ts +13 -0
- package/templates/screen-router/src/router.ts +7 -0
- package/templates/screen-router/tests/app.test.ts.template +20 -0
- package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
- package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
- package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
- package/templates/vben-compatible/MIGRATION.md +30 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenFormView.vue +11 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenUsersView.vue +12 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenUsers.vue +144 -0
- package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
- package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
- package/templates/vue/index.html +12 -0
- package/templates/vue/src/main.ts +5 -0
- package/templates/vue/src/styles.css +19 -0
- package/templates/vue/tests/app.test.ts.template +10 -0
- package/templates/vue/vite.config.ts +9 -0
- package/templates/vue/vitest.config.ts +4 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { createHttpBackend } from '../src/app/services/data-source';
|
|
7
|
+
import { createApplicationSession } from '../src/app/services/session-runtime';
|
|
8
|
+
|
|
9
|
+
const tokens = new Map<string, string>();
|
|
10
|
+
const refreshTokens = new Map<string, string>();
|
|
11
|
+
const expired = new Set<string>();
|
|
12
|
+
const calls = new Map<string, number>();
|
|
13
|
+
let failRefresh = false;
|
|
14
|
+
let failLogout = false;
|
|
15
|
+
let failAccess = false;
|
|
16
|
+
let menuPayload: unknown;
|
|
17
|
+
let baseURL = '';
|
|
18
|
+
const server = createServer(async (request, response) => {
|
|
19
|
+
const path = new URL(request.url!, 'http://localhost').pathname;
|
|
20
|
+
calls.set(path, (calls.get(path) ?? 0) + 1);
|
|
21
|
+
let body = '';
|
|
22
|
+
for await (const part of request) body += String(part);
|
|
23
|
+
const input = body ? (JSON.parse(body) as Record<string, string>) : {};
|
|
24
|
+
const token = request.headers.authorization?.replace('Bearer ', '') ?? '';
|
|
25
|
+
function send(data: unknown, status = 200) {
|
|
26
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
27
|
+
response.end(JSON.stringify(data));
|
|
28
|
+
}
|
|
29
|
+
function issue(username: string) {
|
|
30
|
+
const accessToken = crypto.randomUUID();
|
|
31
|
+
const refreshToken = crypto.randomUUID();
|
|
32
|
+
tokens.set(accessToken, username);
|
|
33
|
+
refreshTokens.set(refreshToken, username);
|
|
34
|
+
return { accessToken, refreshToken };
|
|
35
|
+
}
|
|
36
|
+
if (path === '/auth/login') {
|
|
37
|
+
if (input.password !== 'valid') return send({ message: '登录失败' }, 401);
|
|
38
|
+
return send(issue(input.username!));
|
|
39
|
+
}
|
|
40
|
+
if (path === '/auth/refresh') {
|
|
41
|
+
await delay(40);
|
|
42
|
+
const username = refreshTokens.get(input.refreshToken!);
|
|
43
|
+
return !failRefresh && username ? send(issue(username)) : send({ message: '刷新失败' }, 401);
|
|
44
|
+
}
|
|
45
|
+
const username = tokens.get(token);
|
|
46
|
+
if (!username || expired.has(token)) return send({ message: '已过期' }, 401);
|
|
47
|
+
if (path === '/session/me') return send({ id: username, name: username, username });
|
|
48
|
+
if (path === '/session/access')
|
|
49
|
+
return failAccess
|
|
50
|
+
? send({ message: '权限读取失败' }, 503)
|
|
51
|
+
: send({ roles: [username], permissions: [`${username}:read`] });
|
|
52
|
+
if (path === '/menus')
|
|
53
|
+
return send(
|
|
54
|
+
menuPayload ?? [{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }],
|
|
55
|
+
);
|
|
56
|
+
if (path === '/auth/logout') {
|
|
57
|
+
tokens.delete(token);
|
|
58
|
+
return send({}, failLogout ? 503 : 200);
|
|
59
|
+
}
|
|
60
|
+
if (path.startsWith('/system/')) return send({ items: [{ id: '1', owner: username }], total: 1 });
|
|
61
|
+
send({}, 404);
|
|
62
|
+
});
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
|
|
65
|
+
baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
66
|
+
});
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
calls.clear();
|
|
69
|
+
tokens.clear();
|
|
70
|
+
refreshTokens.clear();
|
|
71
|
+
expired.clear();
|
|
72
|
+
failRefresh = false;
|
|
73
|
+
failLogout = false;
|
|
74
|
+
failAccess = false;
|
|
75
|
+
menuPayload = undefined;
|
|
76
|
+
});
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
server.closeAllConnections();
|
|
79
|
+
await new Promise<void>((done, reject) => server.close((error) => (error ? reject(error) : done())));
|
|
80
|
+
});
|
|
81
|
+
function memoryStorage(): Storage {
|
|
82
|
+
const values = new Map<string, string>();
|
|
83
|
+
return {
|
|
84
|
+
getItem: (key) => values.get(key) ?? null,
|
|
85
|
+
setItem: (key, value) => {
|
|
86
|
+
values.set(key, value);
|
|
87
|
+
},
|
|
88
|
+
removeItem: (key) => {
|
|
89
|
+
values.delete(key);
|
|
90
|
+
},
|
|
91
|
+
clear: () => values.clear(),
|
|
92
|
+
key: (index) => [...values.keys()][index] ?? null,
|
|
93
|
+
get length() {
|
|
94
|
+
return values.size;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function application(url = baseURL) {
|
|
99
|
+
const storage = memoryStorage();
|
|
100
|
+
return createApplicationSession((context) => createHttpBackend(context, url), storage, storage);
|
|
101
|
+
}
|
|
102
|
+
describe('真实 HTTP 应用服务', () => {
|
|
103
|
+
it('权限重读失败清理旧授权,保留凭据以供显式重试且并发刷新单飞', async () => {
|
|
104
|
+
const app = application();
|
|
105
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
106
|
+
const token = app.session.getToken();
|
|
107
|
+
failAccess = true;
|
|
108
|
+
const failed = app.refreshAccess();
|
|
109
|
+
expect(app.refreshAccess()).toBe(failed);
|
|
110
|
+
await expect(failed).rejects.toThrow('权限读取失败');
|
|
111
|
+
expect(app.permissionStore.permissions).toEqual([]);
|
|
112
|
+
expect(app.menus.value).toEqual([]);
|
|
113
|
+
expect(app.ready.value).toBe(false);
|
|
114
|
+
expect(app.session.getToken()).toBe(token);
|
|
115
|
+
failAccess = false;
|
|
116
|
+
await app.refreshAccess();
|
|
117
|
+
expect(app.ready.value).toBe(true);
|
|
118
|
+
expect(app.permissionStore.permissions).toEqual(['alice:read']);
|
|
119
|
+
});
|
|
120
|
+
it.each([
|
|
121
|
+
{ component: 'constructor' },
|
|
122
|
+
{ component: '__proto__' },
|
|
123
|
+
{ component: 'not-registered' },
|
|
124
|
+
{ meta: { externalLink: 'javascript:alert(1)' } },
|
|
125
|
+
{ meta: { externalLink: '//evil.invalid' } },
|
|
126
|
+
{ meta: { authority: [7] } },
|
|
127
|
+
])('真实 HTTP 下发的恶意子菜单拒绝初始化 %j', async (invalid) => {
|
|
128
|
+
menuPayload = [
|
|
129
|
+
{
|
|
130
|
+
path: '/remote',
|
|
131
|
+
name: 'Remote',
|
|
132
|
+
children: [{ path: '/remote/page', name: 'RemotePage', component: 'dashboard/index', ...invalid }],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
const app = application();
|
|
136
|
+
await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow();
|
|
137
|
+
expect(app.ready.value).toBe(false);
|
|
138
|
+
expect(app.menus.value).toEqual([]);
|
|
139
|
+
expect(app.isAuthenticated()).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
it('配置缺失明确失败,不产生演示会话', async () => {
|
|
142
|
+
const app = application('');
|
|
143
|
+
await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow('VITE_API_BASE_URL');
|
|
144
|
+
expect(app.isAuthenticated()).toBe(false);
|
|
145
|
+
expect(calls.size).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
it('登录失败、成功、用户权限菜单与真实 CRUD 请求', async () => {
|
|
148
|
+
const app = application();
|
|
149
|
+
await expect(app.login({ username: 'alice', password: 'wrong' })).rejects.toThrow('登录失败');
|
|
150
|
+
expect(app.currentUser.value).toBeNull();
|
|
151
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
152
|
+
expect(app.ready.value).toBe(true);
|
|
153
|
+
expect(app.currentUser.value?.username).toBe('alice');
|
|
154
|
+
expect(app.permissionStore.permissions).toEqual(['alice:read']);
|
|
155
|
+
expect(app.permissionStore.roles).toEqual(['alice']);
|
|
156
|
+
expect(app.menus.value[0]?.path).toBe('/deep');
|
|
157
|
+
expect(await app.service.list('users', { page: 1, pageSize: 10, query: {} })).toEqual({
|
|
158
|
+
items: [{ id: '1', owner: 'alice' }],
|
|
159
|
+
total: 1,
|
|
160
|
+
});
|
|
161
|
+
await Promise.all([app.initialize(), app.initialize()]);
|
|
162
|
+
expect(calls.get('/session/me')).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
it('401 并发请求只刷新一次,重放使用新凭据', async () => {
|
|
165
|
+
const app = application();
|
|
166
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
167
|
+
expired.add(app.session.getToken()!);
|
|
168
|
+
const signal = new AbortController().signal;
|
|
169
|
+
const [user, access, menus] = await Promise.all([
|
|
170
|
+
app.service.getUser(signal),
|
|
171
|
+
app.service.getAccess(signal),
|
|
172
|
+
app.service.getMenus(signal),
|
|
173
|
+
]);
|
|
174
|
+
expect(user.username).toBe('alice');
|
|
175
|
+
expect(access.permissions).toEqual(['alice:read']);
|
|
176
|
+
expect(menus[0]?.path).toBe('/deep');
|
|
177
|
+
expect(calls.get('/auth/refresh')).toBe(1);
|
|
178
|
+
expect(calls.get('/session/me')).toBe(3);
|
|
179
|
+
});
|
|
180
|
+
it('刷新失败清理凭据、权限、菜单、标签和动态状态回调', async () => {
|
|
181
|
+
const app = application();
|
|
182
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
183
|
+
let resets = 0;
|
|
184
|
+
app.onReset(() => {
|
|
185
|
+
resets += 1;
|
|
186
|
+
});
|
|
187
|
+
expired.add(app.session.getToken()!);
|
|
188
|
+
failRefresh = true;
|
|
189
|
+
await expect(app.service.getUser(new AbortController().signal)).rejects.toThrow();
|
|
190
|
+
expect(app.isAuthenticated()).toBe(false);
|
|
191
|
+
expect(app.currentUser.value).toBeNull();
|
|
192
|
+
expect(app.menus.value).toEqual([]);
|
|
193
|
+
expect(app.permissionStore.permissions).toEqual([]);
|
|
194
|
+
expect(resets).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
it('退出接口失败不保留本地身份', async () => {
|
|
197
|
+
const app = application();
|
|
198
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
199
|
+
failLogout = true;
|
|
200
|
+
await expect(app.logout()).rejects.toThrow();
|
|
201
|
+
expect(app.isAuthenticated()).toBe(false);
|
|
202
|
+
expect(app.permissionStore.roles).toEqual([]);
|
|
203
|
+
expect(calls.get('/auth/logout')).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
it('刷新在途时切换账号,旧响应不能污染新用户', async () => {
|
|
206
|
+
const app = application();
|
|
207
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
208
|
+
expired.add(app.session.getToken()!);
|
|
209
|
+
const old = app.service.getUser(new AbortController().signal).catch((error) => error);
|
|
210
|
+
while (!calls.get('/auth/refresh')) await delay(1);
|
|
211
|
+
await app.login({ username: 'bob', password: 'valid' });
|
|
212
|
+
await old;
|
|
213
|
+
await delay(50);
|
|
214
|
+
expect(app.currentUser.value?.username).toBe('bob');
|
|
215
|
+
expect(app.permissionStore.permissions).toEqual(['bob:read']);
|
|
216
|
+
expect(app.ready.value).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessSnapshot,
|
|
3
|
+
ApplicationService,
|
|
4
|
+
BusinessRequest,
|
|
5
|
+
CurrentUser,
|
|
6
|
+
DataRow,
|
|
7
|
+
HttpMethod,
|
|
8
|
+
ServiceContext,
|
|
9
|
+
} from './contracts';
|
|
10
|
+
import type { LocalModule } from './local-contracts';
|
|
11
|
+
import { object, stringArray } from '../menu-policy';
|
|
12
|
+
import { permissionCodes, viewComponents } from '../router/views';
|
|
13
|
+
import { buildStoredMenus } from '../stored-menus';
|
|
14
|
+
import { createLocalDatabase } from './local-database';
|
|
15
|
+
|
|
16
|
+
export const demoAccount = import.meta.env.DEV ? { username: 'admin', password: 'lumal123' } : null;
|
|
17
|
+
const localModules = import.meta.glob<LocalModule>(['../../modules/*/local.ts', '../../modules/business/*/local.ts']);
|
|
18
|
+
|
|
19
|
+
export function createBackend(context: ServiceContext): ApplicationService {
|
|
20
|
+
const db = createLocalDatabase();
|
|
21
|
+
let ownedToken = '';
|
|
22
|
+
let handlers: Promise<ReturnType<LocalModule['createLocalHandler']>[]> | undefined;
|
|
23
|
+
function assertDemo(): void {
|
|
24
|
+
if (import.meta.env.PROD && import.meta.env.VITE_ENABLE_LOCAL_DEMO !== 'true')
|
|
25
|
+
throw new Error('生产环境已禁用本地演示,请使用 HTTP 模式或显式启用演示');
|
|
26
|
+
context.getSignal().throwIfAborted();
|
|
27
|
+
}
|
|
28
|
+
function account(): DataRow {
|
|
29
|
+
assertDemo();
|
|
30
|
+
const token = context.getSession()?.accessToken ?? '';
|
|
31
|
+
const raw = sessionStorage.getItem(`demo:${token}`);
|
|
32
|
+
if (!raw) throw new Error('本地演示会话已失效');
|
|
33
|
+
const identity = object(JSON.parse(raw));
|
|
34
|
+
const user = db.read('users').find((row) => row.id === identity.id);
|
|
35
|
+
if (!user || user.status !== 'enabled') throw new Error('本地账号已停用或删除,请重新登录');
|
|
36
|
+
ownedToken = token;
|
|
37
|
+
return user;
|
|
38
|
+
}
|
|
39
|
+
function user(): CurrentUser {
|
|
40
|
+
const current = account();
|
|
41
|
+
return { id: String(current.id), username: String(current.username), name: String(current.nickname) };
|
|
42
|
+
}
|
|
43
|
+
function access(): AccessSnapshot {
|
|
44
|
+
const current = account();
|
|
45
|
+
const assigned = stringArray(current.roles);
|
|
46
|
+
const roles = db.read('roles').filter((row) => assigned.includes(String(row.code)) && row.status === 'enabled');
|
|
47
|
+
return {
|
|
48
|
+
roles: roles.map((row) => String(row.code)),
|
|
49
|
+
permissions: [...new Set(roles.flatMap((row) => stringArray(row.permissions)))].filter((code) =>
|
|
50
|
+
permissionCodes.includes(code),
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function assertPermission(code: string): void {
|
|
55
|
+
if (!access().permissions.includes(code)) throw new Error(`无操作权限:${code}`);
|
|
56
|
+
}
|
|
57
|
+
async function businessRequest<T>(method: HttpMethod, path: string, input: BusinessRequest = {}): Promise<T> {
|
|
58
|
+
user();
|
|
59
|
+
const signal = context.getSignal();
|
|
60
|
+
handlers ??= Promise.all(
|
|
61
|
+
Object.values(localModules).map(async (load) =>
|
|
62
|
+
(await load()).createLocalHandler({ db, user, assertPermission }),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
const handler = (await handlers).find((item) => item.handles(path));
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
if (handler) return (await handler.request(method, path, input)) as T;
|
|
68
|
+
throw new Error(`本地数据源不支持此业务接口:${method} ${path}`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
request: businessRequest,
|
|
72
|
+
async login(input, signal) {
|
|
73
|
+
assertDemo();
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
const found = db.read('users').find((row) => row.username === input.username);
|
|
76
|
+
if (!found || found.status !== 'enabled' || input.password !== found.password)
|
|
77
|
+
throw new Error('账号或密码不正确');
|
|
78
|
+
ownedToken = crypto.randomUUID();
|
|
79
|
+
sessionStorage.setItem(`demo:${ownedToken}`, JSON.stringify({ id: found.id }));
|
|
80
|
+
return { accessToken: ownedToken };
|
|
81
|
+
},
|
|
82
|
+
getUser: async (signal) => {
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
return user();
|
|
85
|
+
},
|
|
86
|
+
getAccess: async (signal) => {
|
|
87
|
+
signal.throwIfAborted();
|
|
88
|
+
return access();
|
|
89
|
+
},
|
|
90
|
+
getMenus: async (signal) => {
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
user();
|
|
93
|
+
return buildStoredMenus(db.read('menus'), viewComponents);
|
|
94
|
+
},
|
|
95
|
+
logout: async (token) => {
|
|
96
|
+
sessionStorage.removeItem(`demo:${token}`);
|
|
97
|
+
},
|
|
98
|
+
clearCache() {
|
|
99
|
+
if (ownedToken) sessionStorage.removeItem(`demo:${ownedToken}`);
|
|
100
|
+
ownedToken = '';
|
|
101
|
+
handlers = undefined;
|
|
102
|
+
},
|
|
103
|
+
list: (resource, input) =>
|
|
104
|
+
businessRequest('GET', `/system/${resource}`, {
|
|
105
|
+
query: { page: input.page, pageSize: input.pageSize, ...input.query } as BusinessRequest['query'],
|
|
106
|
+
}),
|
|
107
|
+
save: (resource, model, id) =>
|
|
108
|
+
businessRequest(id ? 'PUT' : 'POST', `/system/${resource}${id ? `/${encodeURIComponent(id)}` : ''}`, {
|
|
109
|
+
body: model,
|
|
110
|
+
}),
|
|
111
|
+
remove: (resource, id) => businessRequest('DELETE', `/system/${resource}/${encodeURIComponent(id)}`),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { DataRow } from './contracts';
|
|
2
|
+
import type { LocalDatabase } from './local-contracts';
|
|
3
|
+
import { localMenus, permissionCodes } from '../router/views';
|
|
4
|
+
import { seedMenuRows } from '../stored-menus';
|
|
5
|
+
|
|
6
|
+
// 仅开发用途。存储和校验不能替代生产后端的认证、授权与密码散列。
|
|
7
|
+
export function createLocalDatabase(): LocalDatabase {
|
|
8
|
+
const initial: Record<string, DataRow[]> = {
|
|
9
|
+
users: [
|
|
10
|
+
{
|
|
11
|
+
id: 'admin',
|
|
12
|
+
username: 'admin',
|
|
13
|
+
nickname: '管理员',
|
|
14
|
+
organizationId: 'root',
|
|
15
|
+
roles: ['admin'],
|
|
16
|
+
status: 'enabled',
|
|
17
|
+
phone: '',
|
|
18
|
+
createdAt: '',
|
|
19
|
+
password: 'lumal123',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'guest',
|
|
23
|
+
username: 'guest',
|
|
24
|
+
nickname: '访客',
|
|
25
|
+
organizationId: 'root',
|
|
26
|
+
roles: ['guest'],
|
|
27
|
+
status: 'enabled',
|
|
28
|
+
phone: '',
|
|
29
|
+
createdAt: '',
|
|
30
|
+
password: 'lumal123',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
roles: [
|
|
34
|
+
{
|
|
35
|
+
id: 'admin',
|
|
36
|
+
code: 'admin',
|
|
37
|
+
name: '管理员',
|
|
38
|
+
description: '内置管理员',
|
|
39
|
+
builtIn: true,
|
|
40
|
+
status: 'enabled',
|
|
41
|
+
permissions: permissionCodes,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'guest',
|
|
45
|
+
code: 'guest',
|
|
46
|
+
name: '访客',
|
|
47
|
+
description: '默认访客',
|
|
48
|
+
builtIn: true,
|
|
49
|
+
status: 'enabled',
|
|
50
|
+
permissions: [],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
menus: seedMenuRows(localMenus),
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
read<T extends DataRow>(resource: string, fallback?: () => T[]): T[] {
|
|
57
|
+
const raw = localStorage.getItem(`demo:system:${resource}`);
|
|
58
|
+
const value: unknown = raw ? JSON.parse(raw) : structuredClone(initial[resource] ?? fallback?.() ?? []);
|
|
59
|
+
if (!Array.isArray(value) || value.some((row) => !row || typeof row !== 'object' || Array.isArray(row)))
|
|
60
|
+
throw new Error(`本地演示数据损坏:${resource}`);
|
|
61
|
+
return value as T[];
|
|
62
|
+
},
|
|
63
|
+
write(resource, rows) {
|
|
64
|
+
localStorage.setItem(`demo:system:${resource}`, JSON.stringify(rows));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
2
|
+
// @vitest-environment node
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { localMenus } from '../src/app/router/views';
|
|
5
|
+
import { createBackend } from '../src/app/services/data-source';
|
|
6
|
+
import { createApplicationSession } from '../src/app/services/session-runtime';
|
|
7
|
+
|
|
8
|
+
function storage(): Storage {
|
|
9
|
+
const values = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key) => values.get(key) ?? null,
|
|
12
|
+
setItem: (key, value) => {
|
|
13
|
+
values.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key) => {
|
|
16
|
+
values.delete(key);
|
|
17
|
+
},
|
|
18
|
+
clear: () => values.clear(),
|
|
19
|
+
key: (index) => [...values.keys()][index] ?? null,
|
|
20
|
+
get length() {
|
|
21
|
+
return values.size;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
vi.stubGlobal('localStorage', storage());
|
|
28
|
+
vi.stubGlobal('sessionStorage', storage());
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
vi.unstubAllEnvs();
|
|
33
|
+
});
|
|
34
|
+
function application() {
|
|
35
|
+
return createApplicationSession(createBackend, localStorage, sessionStorage);
|
|
36
|
+
}
|
|
37
|
+
describe('本地应用服务', () => {
|
|
38
|
+
it('认证、动态菜单和重新初始化使用同一服务契约', async () => {
|
|
39
|
+
const app = application();
|
|
40
|
+
await expect(app.login({ username: 'admin', password: 'wrong' })).rejects.toThrow('账号或密码');
|
|
41
|
+
await app.login({ username: 'admin', password: 'lumal123' });
|
|
42
|
+
expect(app.currentUser.value?.username).toBe('admin');
|
|
43
|
+
expect(app.menus.value.some((menu) => menu.path === '/dashboard')).toBe(true);
|
|
44
|
+
const token = app.session.getToken();
|
|
45
|
+
const restored = application();
|
|
46
|
+
await restored.initialize();
|
|
47
|
+
expect(restored.session.getToken()).toBe(token);
|
|
48
|
+
expect(restored.ready.value).toBe(true);
|
|
49
|
+
await restored.logout();
|
|
50
|
+
expect(sessionStorage.getItem(`demo:${token}`)).toBeNull();
|
|
51
|
+
expect(restored.permissionStore.roles).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
it('生产默认拒绝演示账号,显式启用仅用于演示站点', async () => {
|
|
54
|
+
vi.stubEnv('PROD', true);
|
|
55
|
+
vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', '');
|
|
56
|
+
await expect(application().login({ username: 'admin', password: 'lumal123' })).rejects.toThrow('生产环境');
|
|
57
|
+
vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', 'true');
|
|
58
|
+
await expect(application().login({ username: 'admin', password: 'lumal123' })).resolves.toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it('管理能力按预设启用,访客无写权限', async () => {
|
|
61
|
+
const app = application();
|
|
62
|
+
await app.login({ username: 'admin', password: 'lumal123' });
|
|
63
|
+
const flatten = (menus: LumalMenuRecord[]): LumalMenuRecord[] =>
|
|
64
|
+
menus.flatMap((menu) => [menu, ...flatten(menu.children ?? [])]);
|
|
65
|
+
if (flatten(localMenus).some((menu) => menu.component === 'system/users')) {
|
|
66
|
+
await app.service.save('users', { username: 'new-user', nickname: '新增', roles: ['guest'], status: 'enabled' });
|
|
67
|
+
const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'new-user' } });
|
|
68
|
+
expect(result.total).toBe(1);
|
|
69
|
+
await app.service.remove('users', String(result.items[0]?.id));
|
|
70
|
+
await expect(app.service.remove('users', 'admin')).rejects.toThrow('内置管理员');
|
|
71
|
+
await expect(
|
|
72
|
+
app.service.save('users', { username: 'admin', nickname: '重复', roles: ['guest'] }),
|
|
73
|
+
).rejects.toThrow('重复');
|
|
74
|
+
await expect(
|
|
75
|
+
app.service.save('roles', { code: 'admin', name: '管理员', status: 'disabled' }, 'admin'),
|
|
76
|
+
).rejects.toThrow('内置角色');
|
|
77
|
+
await app.service.save('roles', { code: 'reader', name: '只读角色', status: 'enabled' });
|
|
78
|
+
await app.service.request('PUT', '/system/roles/reader/permissions', {
|
|
79
|
+
body: { permissions: ['system:user:list'] },
|
|
80
|
+
});
|
|
81
|
+
await app.service.save('users', {
|
|
82
|
+
username: 'reader',
|
|
83
|
+
nickname: '只读用户',
|
|
84
|
+
roles: ['reader'],
|
|
85
|
+
status: 'enabled',
|
|
86
|
+
});
|
|
87
|
+
await app.service.save('dictionaries/items', { typeCode: 'status', label: '待审核', value: 'pending' });
|
|
88
|
+
expect(await app.service.request('GET', '/system/dictionaries/items', { query: { typeCode: 'other' } })).toEqual(
|
|
89
|
+
[],
|
|
90
|
+
);
|
|
91
|
+
await app.service.save('menus', { title: '目录', path: '/group', name: 'Group', type: 'directory' });
|
|
92
|
+
const group = (await app.service.request<Record<string, unknown>[]>('GET', '/system/menus')).find(
|
|
93
|
+
(row) => row.name === 'Group',
|
|
94
|
+
)!;
|
|
95
|
+
await expect(app.service.save('menus', { ...group, parentId: group.id }, String(group.id))).rejects.toThrow(
|
|
96
|
+
'循环',
|
|
97
|
+
);
|
|
98
|
+
await expect(
|
|
99
|
+
app.service.save('menus', {
|
|
100
|
+
title: '恶意',
|
|
101
|
+
path: '/bad',
|
|
102
|
+
name: 'Bad',
|
|
103
|
+
type: 'menu',
|
|
104
|
+
component: 'https://evil.invalid/a.js',
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toThrow('白名单');
|
|
107
|
+
await app.login({ username: 'reader', password: 'lumal123' });
|
|
108
|
+
expect(app.permissionStore.permissions).toEqual(['system:user:list']);
|
|
109
|
+
await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).resolves.toHaveProperty('total');
|
|
110
|
+
await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限');
|
|
111
|
+
} else if (flatten(localMenus).some((menu) => menu.component === 'migration/users')) {
|
|
112
|
+
expect(app.permissionStore.permissions).toContain('system:user:create');
|
|
113
|
+
await app.service.save('users', {
|
|
114
|
+
username: 'migration-only',
|
|
115
|
+
nickname: '迁移用户',
|
|
116
|
+
roles: ['guest'],
|
|
117
|
+
status: 'enabled',
|
|
118
|
+
});
|
|
119
|
+
const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } });
|
|
120
|
+
expect(result.total).toBe(1);
|
|
121
|
+
await app.service.remove('users', String(result.items[0]?.id));
|
|
122
|
+
expect(
|
|
123
|
+
(await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })).total,
|
|
124
|
+
).toBe(0);
|
|
125
|
+
} else {
|
|
126
|
+
await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).rejects.toThrow('不支持');
|
|
127
|
+
}
|
|
128
|
+
await app.login({ username: 'guest', password: 'lumal123' });
|
|
129
|
+
expect(app.permissionStore.permissions).toEqual([]);
|
|
130
|
+
await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LumalPage } from '@lumal/core/components';
|
|
3
|
+
import { ElEmpty } from 'element-plus';
|
|
4
|
+
import { currentUser } from '../../app/services/session';
|
|
5
|
+
import DashboardSessionStatus from './components/DashboardSessionStatus.vue';
|
|
6
|
+
import DashboardShortcuts from './components/DashboardShortcuts.vue';
|
|
7
|
+
|
|
8
|
+
/***********************工作台装配*********************/
|
|
9
|
+
const today = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(new Date());
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<LumalPage class="lumal-admin-page" title="工作台">
|
|
14
|
+
<header class="dashboard-welcome">
|
|
15
|
+
<div>
|
|
16
|
+
<h2>你好,{{ currentUser?.name }}</h2>
|
|
17
|
+
<p>{{ today }}</p>
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
20
|
+
<DashboardShortcuts />
|
|
21
|
+
<DashboardSessionStatus />
|
|
22
|
+
<section class="dashboard-section">
|
|
23
|
+
<h3>业务动态</h3>
|
|
24
|
+
<ElEmpty description="暂无业务动态" :image-size="72" />
|
|
25
|
+
</section>
|
|
26
|
+
</LumalPage>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style scoped>
|
|
30
|
+
.dashboard-welcome {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: space-between;
|
|
34
|
+
padding: 8px 0 24px;
|
|
35
|
+
}
|
|
36
|
+
.dashboard-welcome h2 {
|
|
37
|
+
margin: 0 0 8px;
|
|
38
|
+
font-size: 22px;
|
|
39
|
+
}
|
|
40
|
+
.dashboard-welcome p {
|
|
41
|
+
margin: 0;
|
|
42
|
+
color: var(--el-text-color-secondary);
|
|
43
|
+
}
|
|
44
|
+
.dashboard-section {
|
|
45
|
+
padding-top: 24px;
|
|
46
|
+
border-top: 1px solid var(--el-border-color-lighter);
|
|
47
|
+
}
|
|
48
|
+
.dashboard-section h3 {
|
|
49
|
+
margin: 0;
|
|
50
|
+
font-size: 16px;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus';
|
|
3
|
+
import { appConfig } from '../../../app.config';
|
|
4
|
+
import { permissionStore } from '../../../app/services/session';
|
|
5
|
+
|
|
6
|
+
/***********************真实应用与会话状态*********************/
|
|
7
|
+
const source = appConfig.apiMode === 'local' ? '本地开发演示' : 'HTTP API';
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<section class="session-status">
|
|
12
|
+
<h3>应用状态</h3>
|
|
13
|
+
<ElDescriptions :column="1" border>
|
|
14
|
+
<ElDescriptionsItem label="数据源">
|
|
15
|
+
<ElTag :type="appConfig.apiMode === 'local' ? 'warning' : 'success'">
|
|
16
|
+
{{ source }}
|
|
17
|
+
</ElTag>
|
|
18
|
+
</ElDescriptionsItem>
|
|
19
|
+
<ElDescriptionsItem label="当前角色">
|
|
20
|
+
{{ permissionStore.roles.join('、') || '无' }}
|
|
21
|
+
</ElDescriptionsItem>
|
|
22
|
+
<ElDescriptionsItem label="可用操作权限">
|
|
23
|
+
{{ permissionStore.permissions.length }}
|
|
24
|
+
</ElDescriptionsItem>
|
|
25
|
+
<ElDescriptionsItem label="启用模块">
|
|
26
|
+
{{ appConfig.features.join('、') || '无可选模块' }}
|
|
27
|
+
</ElDescriptionsItem>
|
|
28
|
+
</ElDescriptions>
|
|
29
|
+
</section>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
.session-status {
|
|
34
|
+
padding: 24px 0;
|
|
35
|
+
border-top: 1px solid var(--el-border-color-lighter);
|
|
36
|
+
}
|
|
37
|
+
.session-status h3 {
|
|
38
|
+
margin: 0 0 16px;
|
|
39
|
+
font-size: 16px;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SidebarMenuItem } from '@lumal/core/router';
|
|
3
|
+
import { LumalIcon } from '@lumal/icons-vue';
|
|
4
|
+
import { ElButton, ElEmpty } from 'element-plus';
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
import { useRouter } from 'vue-router';
|
|
7
|
+
import { createAdminSidebarMenus } from '../../../app/router';
|
|
8
|
+
|
|
9
|
+
/***********************当前权限下的快捷入口*********************/
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
function flatten(items: SidebarMenuItem[]): SidebarMenuItem[] {
|
|
12
|
+
return items.flatMap((item) => (item.children?.length ? flatten(item.children) : [item]));
|
|
13
|
+
}
|
|
14
|
+
const shortcuts = computed(() =>
|
|
15
|
+
flatten(createAdminSidebarMenus())
|
|
16
|
+
.filter((item) => item.path !== '/dashboard' && !item.externalLink)
|
|
17
|
+
.slice(0, 6),
|
|
18
|
+
);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<section class="dashboard-shortcuts">
|
|
23
|
+
<h3>常用入口</h3>
|
|
24
|
+
<div v-if="shortcuts.length" class="shortcut-list">
|
|
25
|
+
<ElButton v-for="item in shortcuts" :key="item.path" @click="router.push(item.path)">
|
|
26
|
+
<LumalIcon :name="item.icon || 'lumal:grid'" />{{ item.title }}
|
|
27
|
+
</ElButton>
|
|
28
|
+
</div>
|
|
29
|
+
<ElEmpty v-else description="暂无可用入口" :image-size="48" />
|
|
30
|
+
</section>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<style scoped>
|
|
34
|
+
.dashboard-shortcuts {
|
|
35
|
+
padding-bottom: 24px;
|
|
36
|
+
}
|
|
37
|
+
.dashboard-shortcuts h3 {
|
|
38
|
+
margin: 0 0 16px;
|
|
39
|
+
font-size: 16px;
|
|
40
|
+
}
|
|
41
|
+
.shortcut-list {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-wrap: wrap;
|
|
44
|
+
gap: 12px;
|
|
45
|
+
}
|
|
46
|
+
.shortcut-list :deep(.el-button) {
|
|
47
|
+
min-height: 40px;
|
|
48
|
+
margin: 0;
|
|
49
|
+
gap: 8px;
|
|
50
|
+
}
|
|
51
|
+
</style>
|