create-lumal-admin 0.0.5-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 +13 -0
- package/README.md +39 -10
- package/dist/cli.d.ts +9 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +236 -19
- package/dist/cli.js.map +1 -1
- package/dist/create.d.ts +10 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +18 -5
- 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 +29 -3
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +38 -10
- package/dist/presets.js.map +1 -1
- 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/templates.d.ts +2 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +42 -13
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -3
- package/templates/base/API-CONTRACT.md +67 -20
- package/templates/base/README.md +20 -3
- package/templates/base/src/App.vue +71 -79
- package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
- package/templates/base/src/{components/app → app/components}/AppSettingsDrawer.vue +15 -15
- 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/{services → app/services}/access.ts +1 -1
- 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/{services → app/services}/preferences.ts +8 -8
- package/templates/base/src/app/services/session-runtime.ts +186 -0
- package/templates/base/src/{services → app/services}/session.ts +4 -4
- package/templates/base/src/app/stored-menus.ts +72 -0
- package/templates/base/src/app/views/SafeIframeView.vue +34 -0
- package/templates/base/src/{views → app/views}/auth/LoginView.vue +29 -34
- package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
- package/templates/base/src/{views → app/views}/error/NotFoundView.vue +5 -7
- package/templates/base/src/main.ts +21 -22
- package/templates/base/src/modules/business/README.md +33 -0
- package/templates/base/src/{views → modules}/dashboard/DashboardView.vue +3 -3
- 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 +1 -6
- package/templates/base/tests/menu-policy.test.ts.template +89 -0
- package/templates/base/tests/session.test.ts.template +38 -27
- package/templates/base/vite.config.ts +4 -4
- 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/{base → common}/_gitignore +1 -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/{base → common}/tsconfig.json +2 -5
- package/templates/common/vitest.config.ts +3 -0
- package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
- package/templates/features/examples/src/{views → modules}/examples/FormExampleView.vue +15 -8
- 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 +186 -133
- 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 +117 -77
- 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 +2 -0
- package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenFormView.vue +2 -1
- package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenUsersView.vue +3 -2
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
- package/templates/vben-compatible/src/{components/migration → modules/vben-migration/components}/VbenUsers.vue +73 -64
- 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
- package/templates/base/eslint.config.mjs +0 -8
- package/templates/base/src/components/app/AppHeaderActions.vue +0 -34
- package/templates/base/src/router/index.ts +0 -86
- package/templates/base/src/router/views.ts +0 -16
- package/templates/base/src/services/contracts.ts +0 -29
- package/templates/base/src/services/session-runtime.ts +0 -137
- package/templates/base/src/views/error/ForbiddenView.vue +0 -25
- package/templates/base/vitest.config.ts +0 -3
- package/templates/features/examples/src/modules/examples.ts +0 -10
- package/templates/features/examples/src/views/examples/CrudExampleView.vue +0 -30
- package/templates/features/system/src/business/system/entities.ts +0 -93
- package/templates/features/system/src/modules/system.ts +0 -28
- package/templates/features/system/src/views/system/SystemEntityView.vue +0 -85
- package/templates/modes/http/src/services/data-source.ts +0 -120
- package/templates/modes/http/src/services/http-adapter.ts +0 -124
- package/templates/modes/local/src/services/data-source.ts +0 -176
- package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +0 -34
- package/templates/vben-compatible/src/modules/vben.ts +0 -17
|
@@ -1,165 +1,218 @@
|
|
|
1
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/services/data-source'
|
|
7
|
-
import { createApplicationSession } from '../src/services/session-runtime'
|
|
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
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
|
|
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 = '';
|
|
16
18
|
const server = createServer(async (request, response) => {
|
|
17
|
-
const path = new URL(request.url!, 'http://localhost').pathname
|
|
18
|
-
calls.set(path, (calls.get(path) ?? 0) + 1)
|
|
19
|
-
let body = ''
|
|
20
|
-
for await (const part of request)
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const token = request.headers.authorization?.replace('Bearer ', '') ?? ''
|
|
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 ', '') ?? '';
|
|
24
25
|
function send(data: unknown, status = 200) {
|
|
25
|
-
response.writeHead(status, { 'content-type': 'application/json' })
|
|
26
|
-
response.end(JSON.stringify(data))
|
|
26
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
27
|
+
response.end(JSON.stringify(data));
|
|
27
28
|
}
|
|
28
29
|
function issue(username: string) {
|
|
29
|
-
const accessToken = crypto.randomUUID()
|
|
30
|
-
const refreshToken = crypto.randomUUID()
|
|
31
|
-
tokens.set(accessToken, username)
|
|
32
|
-
refreshTokens.set(refreshToken, username)
|
|
33
|
-
return { accessToken, refreshToken }
|
|
30
|
+
const accessToken = crypto.randomUUID();
|
|
31
|
+
const refreshToken = crypto.randomUUID();
|
|
32
|
+
tokens.set(accessToken, username);
|
|
33
|
+
refreshTokens.set(refreshToken, username);
|
|
34
|
+
return { accessToken, refreshToken };
|
|
34
35
|
}
|
|
35
36
|
if (path === '/auth/login') {
|
|
36
|
-
if (input.password !== 'valid')
|
|
37
|
-
|
|
38
|
-
return send(issue(input.username!))
|
|
37
|
+
if (input.password !== 'valid') return send({ message: '登录失败' }, 401);
|
|
38
|
+
return send(issue(input.username!));
|
|
39
39
|
}
|
|
40
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)
|
|
41
|
+
await delay(40);
|
|
42
|
+
const username = refreshTokens.get(input.refreshToken!);
|
|
43
|
+
return !failRefresh && username ? send(issue(username)) : send({ message: '刷新失败' }, 401);
|
|
44
44
|
}
|
|
45
|
-
const username = tokens.get(token)
|
|
46
|
-
if (!username || expired.has(token))
|
|
47
|
-
|
|
48
|
-
if (path === '/session/me')
|
|
49
|
-
return send({ id: username, name: username, username })
|
|
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 });
|
|
50
48
|
if (path === '/session/access')
|
|
51
|
-
return
|
|
49
|
+
return failAccess
|
|
50
|
+
? send({ message: '权限读取失败' }, 503)
|
|
51
|
+
: send({ roles: [username], permissions: [`${username}:read`] });
|
|
52
52
|
if (path === '/menus')
|
|
53
|
-
return send(
|
|
53
|
+
return send(
|
|
54
|
+
menuPayload ?? [{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }],
|
|
55
|
+
);
|
|
54
56
|
if (path === '/auth/logout') {
|
|
55
|
-
tokens.delete(token)
|
|
56
|
-
return send({}, failLogout ? 503 : 200)
|
|
57
|
+
tokens.delete(token);
|
|
58
|
+
return send({}, failLogout ? 503 : 200);
|
|
57
59
|
}
|
|
58
|
-
if (path.startsWith('/system/'))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
60
|
+
if (path.startsWith('/system/')) return send({ items: [{ id: '1', owner: username }], total: 1 });
|
|
61
|
+
send({}, 404);
|
|
62
|
+
});
|
|
62
63
|
beforeAll(async () => {
|
|
63
|
-
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
|
64
|
-
baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}
|
|
65
|
-
})
|
|
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
|
+
});
|
|
66
67
|
beforeEach(() => {
|
|
67
|
-
calls.clear()
|
|
68
|
-
tokens.clear()
|
|
69
|
-
refreshTokens.clear()
|
|
70
|
-
expired.clear()
|
|
71
|
-
failRefresh = false
|
|
72
|
-
failLogout = false
|
|
73
|
-
|
|
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
|
+
});
|
|
74
77
|
afterAll(async () => {
|
|
75
|
-
server.closeAllConnections()
|
|
76
|
-
await new Promise<void>((done, reject) => server.close(error => error ? reject(error) : done()))
|
|
77
|
-
})
|
|
78
|
+
server.closeAllConnections();
|
|
79
|
+
await new Promise<void>((done, reject) => server.close((error) => (error ? reject(error) : done())));
|
|
80
|
+
});
|
|
78
81
|
function memoryStorage(): Storage {
|
|
79
|
-
const values = new Map<string, string>()
|
|
82
|
+
const values = new Map<string, string>();
|
|
80
83
|
return {
|
|
81
|
-
getItem: key => values.get(key) ?? null,
|
|
82
|
-
setItem: (key, value) => {
|
|
83
|
-
|
|
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
|
+
},
|
|
84
91
|
clear: () => values.clear(),
|
|
85
|
-
key: index => [...values.keys()][index] ?? null,
|
|
86
|
-
get length() {
|
|
87
|
-
|
|
92
|
+
key: (index) => [...values.keys()][index] ?? null,
|
|
93
|
+
get length() {
|
|
94
|
+
return values.size;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
88
97
|
}
|
|
89
98
|
function application(url = baseURL) {
|
|
90
|
-
const storage = memoryStorage()
|
|
91
|
-
return createApplicationSession(context => createHttpBackend(context, url), storage, storage)
|
|
99
|
+
const storage = memoryStorage();
|
|
100
|
+
return createApplicationSession((context) => createHttpBackend(context, url), storage, storage);
|
|
92
101
|
}
|
|
93
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
|
+
});
|
|
94
141
|
it('配置缺失明确失败,不产生演示会话', async () => {
|
|
95
|
-
const app = application('')
|
|
96
|
-
await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow('VITE_API_BASE_URL')
|
|
97
|
-
expect(app.isAuthenticated()).toBe(false)
|
|
98
|
-
expect(calls.size).toBe(0)
|
|
99
|
-
})
|
|
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
|
+
});
|
|
100
147
|
it('登录失败、成功、用户权限菜单与真实 CRUD 请求', async () => {
|
|
101
|
-
const app = application()
|
|
102
|
-
await expect(app.login({ username: 'alice', password: 'wrong' })).rejects.toThrow()
|
|
103
|
-
expect(app.currentUser.value).toBeNull()
|
|
104
|
-
await app.login({ username: 'alice', password: 'valid' })
|
|
105
|
-
expect(app.ready.value).toBe(true)
|
|
106
|
-
expect(app.currentUser.value?.username).toBe('alice')
|
|
107
|
-
expect(app.permissionStore.permissions).toEqual(['alice:read'])
|
|
108
|
-
expect(app.permissionStore.roles).toEqual(['alice'])
|
|
109
|
-
expect(app.menus.value[0]?.path).toBe('/deep')
|
|
110
|
-
expect(await app.service.list('users', { page: 1, pageSize: 10, query: {} })).toEqual({
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
});
|
|
114
164
|
it('401 并发请求只刷新一次,重放使用新凭据', async () => {
|
|
115
|
-
const app = application()
|
|
116
|
-
await app.login({ username: 'alice', password: 'valid' })
|
|
117
|
-
expired.add(app.session.getToken()!)
|
|
118
|
-
const signal = new AbortController().signal
|
|
119
|
-
const [user, access, menus] = await Promise.all([
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
expect(
|
|
125
|
-
|
|
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
|
+
});
|
|
126
180
|
it('刷新失败清理凭据、权限、菜单、标签和动态状态回调', async () => {
|
|
127
|
-
const app = application()
|
|
128
|
-
await app.login({ username: 'alice', password: 'valid' })
|
|
129
|
-
let resets = 0
|
|
181
|
+
const app = application();
|
|
182
|
+
await app.login({ username: 'alice', password: 'valid' });
|
|
183
|
+
let resets = 0;
|
|
130
184
|
app.onReset(() => {
|
|
131
|
-
resets += 1
|
|
132
|
-
})
|
|
133
|
-
expired.add(app.session.getToken()!)
|
|
134
|
-
failRefresh = true
|
|
135
|
-
await expect(app.service.getUser(new AbortController().signal)).rejects.toThrow()
|
|
136
|
-
expect(app.isAuthenticated()).toBe(false)
|
|
137
|
-
expect(app.currentUser.value).toBeNull()
|
|
138
|
-
expect(app.menus.value).toEqual([])
|
|
139
|
-
expect(app.permissionStore.permissions).toEqual([])
|
|
140
|
-
expect(resets).toBe(1)
|
|
141
|
-
})
|
|
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
|
+
});
|
|
142
196
|
it('退出接口失败不保留本地身份', async () => {
|
|
143
|
-
const app = application()
|
|
144
|
-
await app.login({ username: 'alice', password: 'valid' })
|
|
145
|
-
failLogout = true
|
|
146
|
-
await expect(app.logout()).rejects.toThrow()
|
|
147
|
-
expect(app.isAuthenticated()).toBe(false)
|
|
148
|
-
expect(app.permissionStore.roles).toEqual([])
|
|
149
|
-
expect(calls.get('/auth/logout')).toBe(1)
|
|
150
|
-
})
|
|
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
|
+
});
|
|
151
205
|
it('刷新在途时切换账号,旧响应不能污染新用户', async () => {
|
|
152
|
-
const app = application()
|
|
153
|
-
await app.login({ username: 'alice', password: 'valid' })
|
|
154
|
-
expired.add(app.session.getToken()!)
|
|
155
|
-
const old = app.service.getUser(new AbortController().signal).catch(error => error)
|
|
156
|
-
while (!calls.get('/auth/refresh'))
|
|
157
|
-
|
|
158
|
-
await
|
|
159
|
-
await
|
|
160
|
-
|
|
161
|
-
expect(app.
|
|
162
|
-
expect(app.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
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
|
+
}
|