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,18 @@
|
|
|
1
|
+
import type { BusinessRequest, CurrentUser, DataRow, HttpMethod } from './contracts';
|
|
2
|
+
|
|
3
|
+
export interface LocalDatabase {
|
|
4
|
+
read: <T extends DataRow = DataRow>(resource: string, initial?: () => T[]) => T[];
|
|
5
|
+
write: <T extends DataRow>(resource: string, rows: T[]) => void;
|
|
6
|
+
}
|
|
7
|
+
export interface LocalBusinessContext {
|
|
8
|
+
db: LocalDatabase;
|
|
9
|
+
user: () => CurrentUser;
|
|
10
|
+
assertPermission: (code: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface LocalHandler {
|
|
13
|
+
handles: (path: string) => boolean;
|
|
14
|
+
request: (method: HttpMethod, path: string, input: BusinessRequest) => Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface LocalModule {
|
|
17
|
+
createLocalHandler: (context: LocalBusinessContext) => LocalHandler;
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LumalPreferencesDefaults } from '@lumal/core/theme';
|
|
2
|
+
import { createPreferencesStore } from '@lumal/core/theme';
|
|
3
|
+
import { appConfig } from '../../app.config';
|
|
4
|
+
|
|
5
|
+
export const adminPreferenceDefaults = {
|
|
6
|
+
app: { dynamicTitle: true, layout: 'sidebar-nav' },
|
|
7
|
+
breadcrumb: { enable: true, hideOnlyOne: false, showHome: true, showIcon: true },
|
|
8
|
+
header: { globalSearch: true },
|
|
9
|
+
shortcutKeys: { globalSearch: true },
|
|
10
|
+
sidebar: { width: 220 },
|
|
11
|
+
tabbar: {
|
|
12
|
+
cache: appConfig.preset !== 'minimal',
|
|
13
|
+
draggable: true,
|
|
14
|
+
enable: appConfig.preset !== 'minimal',
|
|
15
|
+
maxCount: 0,
|
|
16
|
+
middleClickToClose: true,
|
|
17
|
+
persist: true,
|
|
18
|
+
showIcon: true,
|
|
19
|
+
showMaximize: true,
|
|
20
|
+
showMore: true,
|
|
21
|
+
showRefresh: true,
|
|
22
|
+
styleType: 'chrome',
|
|
23
|
+
visitHistory: true,
|
|
24
|
+
wheelable: true,
|
|
25
|
+
},
|
|
26
|
+
theme: { fontSize: 14 },
|
|
27
|
+
transition: { enable: true, name: 'fade-side' },
|
|
28
|
+
} satisfies LumalPreferencesDefaults;
|
|
29
|
+
|
|
30
|
+
const preferencesStore = createPreferencesStore({
|
|
31
|
+
defaults: adminPreferenceDefaults,
|
|
32
|
+
storage: localStorage,
|
|
33
|
+
storageKey: 'lumal-admin:preferences',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const adminPreferences = preferencesStore.state;
|
|
37
|
+
export const adminResolvedThemeMode = preferencesStore.resolvedThemeMode;
|
|
38
|
+
|
|
39
|
+
export function patchAdminPreferences(preferences: Parameters<typeof preferencesStore.patch>[0]): void {
|
|
40
|
+
preferencesStore.patch(preferences);
|
|
41
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
2
|
+
import type { ApplicationService, CurrentUser, LoginInput, ServiceContext } from './contracts';
|
|
3
|
+
import { createAuthSession } from '@lumal/core/auth';
|
|
4
|
+
import { createPermissionStore } from '@lumal/core/permission';
|
|
5
|
+
import { readonly, shallowRef } from 'vue';
|
|
6
|
+
|
|
7
|
+
export function createApplicationSession(
|
|
8
|
+
createService: (context: ServiceContext) => ApplicationService,
|
|
9
|
+
storage: Storage,
|
|
10
|
+
tabStorage?: Storage,
|
|
11
|
+
) {
|
|
12
|
+
const permissionStore = createPermissionStore();
|
|
13
|
+
const user = shallowRef<CurrentUser | null>(null);
|
|
14
|
+
const menus = shallowRef<LumalMenuRecord[]>([]);
|
|
15
|
+
const ready = shallowRef(false);
|
|
16
|
+
const accessVersion = shallowRef(0);
|
|
17
|
+
const error = shallowRef('');
|
|
18
|
+
const resetHandlers = new Set<(expired: boolean) => void>();
|
|
19
|
+
const refreshHandlers = new Set<() => void | Promise<void>>();
|
|
20
|
+
const tabStorageKey = 'lumal-admin:tabs';
|
|
21
|
+
let generation = 0;
|
|
22
|
+
let controller = new AbortController();
|
|
23
|
+
let initializing: Promise<void> | undefined;
|
|
24
|
+
let refreshing: Promise<void> | undefined;
|
|
25
|
+
const session = createAuthSession({ storage, tokenKey: 'lumal-admin:session' });
|
|
26
|
+
let service: ApplicationService;
|
|
27
|
+
|
|
28
|
+
function reset(expired = false): void {
|
|
29
|
+
generation += 1;
|
|
30
|
+
controller.abort();
|
|
31
|
+
controller = new AbortController();
|
|
32
|
+
initializing = undefined;
|
|
33
|
+
refreshing = undefined;
|
|
34
|
+
accessVersion.value += 1;
|
|
35
|
+
ready.value = false;
|
|
36
|
+
user.value = null;
|
|
37
|
+
menus.value = [];
|
|
38
|
+
permissionStore.clear();
|
|
39
|
+
session.clearToken();
|
|
40
|
+
service.clearCache();
|
|
41
|
+
tabStorage?.removeItem(tabStorageKey);
|
|
42
|
+
resetHandlers.forEach((handler) => handler(expired));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
service = createService({
|
|
46
|
+
getSession: session.getSession,
|
|
47
|
+
getSignal: () => controller.signal,
|
|
48
|
+
setSession: session.setSession,
|
|
49
|
+
expire: async () => reset(true),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/***********************会话初始化与隔离*********************/
|
|
53
|
+
function initialize(): Promise<void> {
|
|
54
|
+
if (ready.value || !session.isAuthenticated()) return Promise.resolve();
|
|
55
|
+
if (initializing) return initializing;
|
|
56
|
+
const version = generation;
|
|
57
|
+
const signal = controller.signal;
|
|
58
|
+
const pending = (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const [nextUser, access, nextMenus] = await Promise.all([
|
|
61
|
+
service.getUser(signal),
|
|
62
|
+
service.getAccess(signal),
|
|
63
|
+
service.getMenus(signal),
|
|
64
|
+
]);
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
if (version !== generation) throw new Error('会话已切换');
|
|
67
|
+
user.value = nextUser;
|
|
68
|
+
permissionStore.setPermissions(access.permissions);
|
|
69
|
+
permissionStore.setRoles(access.roles);
|
|
70
|
+
menus.value = nextMenus;
|
|
71
|
+
ready.value = true;
|
|
72
|
+
error.value = '';
|
|
73
|
+
} catch (reason) {
|
|
74
|
+
if (version === generation) {
|
|
75
|
+
reset();
|
|
76
|
+
error.value = reason instanceof Error ? reason.message : '会话初始化失败';
|
|
77
|
+
}
|
|
78
|
+
throw reason;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
initializing = pending;
|
|
82
|
+
void pending
|
|
83
|
+
.finally(() => {
|
|
84
|
+
if (initializing === pending) initializing = undefined;
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {}); // 原始 Promise 仍向调用方传播异常,只消费 finally 分支。
|
|
87
|
+
return pending;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function login(input: LoginInput): Promise<void> {
|
|
91
|
+
reset();
|
|
92
|
+
const version = generation;
|
|
93
|
+
const signal = controller.signal;
|
|
94
|
+
error.value = '';
|
|
95
|
+
try {
|
|
96
|
+
const result = await service.login(input, signal);
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
if (version !== generation) throw new Error('会话已切换');
|
|
99
|
+
session.setSession(result);
|
|
100
|
+
await initialize();
|
|
101
|
+
} catch (reason) {
|
|
102
|
+
if (version === generation) {
|
|
103
|
+
reset();
|
|
104
|
+
error.value = reason instanceof Error ? reason.message : '登录失败';
|
|
105
|
+
}
|
|
106
|
+
throw reason;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function logout(): Promise<void> {
|
|
111
|
+
const token = session.getToken();
|
|
112
|
+
reset();
|
|
113
|
+
// 即使远端撤销失败,本地状态也已清理;错误交由界面反馈。
|
|
114
|
+
if (token) await service.logout(token);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function refreshAccess(): Promise<void> {
|
|
118
|
+
if (refreshing) return refreshing;
|
|
119
|
+
if (!session.isAuthenticated()) return Promise.reject(new Error('会话已失效,请重新登录'));
|
|
120
|
+
const version = generation;
|
|
121
|
+
const refreshController = new AbortController();
|
|
122
|
+
const signal = AbortSignal.any([controller.signal, refreshController.signal]);
|
|
123
|
+
const pending = (async () => {
|
|
124
|
+
const reads = [service.getUser(signal), service.getAccess(signal), service.getMenus(signal)] as const;
|
|
125
|
+
try {
|
|
126
|
+
const [nextUser, access, nextMenus] = await Promise.all(reads);
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (version !== generation) throw new Error('会话已切换');
|
|
129
|
+
user.value = nextUser;
|
|
130
|
+
permissionStore.setPermissions(access.permissions);
|
|
131
|
+
permissionStore.setRoles(access.roles);
|
|
132
|
+
menus.value = nextMenus;
|
|
133
|
+
ready.value = true;
|
|
134
|
+
await Promise.all([...refreshHandlers].map((handler) => handler()));
|
|
135
|
+
accessVersion.value += 1;
|
|
136
|
+
tabStorage?.removeItem(tabStorageKey);
|
|
137
|
+
} catch (reason) {
|
|
138
|
+
// 一个读取失败后取消其余请求并等待释放去重占位,立即重试不会撞上旧请求。
|
|
139
|
+
refreshController.abort(reason);
|
|
140
|
+
await Promise.allSettled(reads);
|
|
141
|
+
if (version === generation) {
|
|
142
|
+
// 重读失败时拒绝继续使用旧授权;保留 Token 以便显式重试。
|
|
143
|
+
permissionStore.clear();
|
|
144
|
+
menus.value = [];
|
|
145
|
+
ready.value = false;
|
|
146
|
+
tabStorage?.removeItem(tabStorageKey);
|
|
147
|
+
resetHandlers.forEach((handler) => handler(false));
|
|
148
|
+
}
|
|
149
|
+
throw reason;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
refreshing = pending;
|
|
153
|
+
void pending
|
|
154
|
+
.finally(() => {
|
|
155
|
+
if (refreshing === pending) refreshing = undefined;
|
|
156
|
+
})
|
|
157
|
+
.catch(() => {});
|
|
158
|
+
return pending;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
currentUser: readonly(user),
|
|
163
|
+
error: readonly(error),
|
|
164
|
+
menus: readonly(menus),
|
|
165
|
+
ready: readonly(ready),
|
|
166
|
+
accessVersion: readonly(accessVersion),
|
|
167
|
+
permissionStore,
|
|
168
|
+
session,
|
|
169
|
+
service,
|
|
170
|
+
tabStorageKey,
|
|
171
|
+
initialize,
|
|
172
|
+
isAuthenticated: session.isAuthenticated,
|
|
173
|
+
login,
|
|
174
|
+
logout,
|
|
175
|
+
reset,
|
|
176
|
+
refreshAccess,
|
|
177
|
+
onRefresh(handler: () => void | Promise<void>) {
|
|
178
|
+
refreshHandlers.add(handler);
|
|
179
|
+
return () => refreshHandlers.delete(handler);
|
|
180
|
+
},
|
|
181
|
+
onReset(handler: (expired: boolean) => void) {
|
|
182
|
+
resetHandlers.add(handler);
|
|
183
|
+
return () => resetHandlers.delete(handler);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createBackend } from './data-source';
|
|
2
|
+
import { createApplicationSession } from './session-runtime';
|
|
3
|
+
|
|
4
|
+
export const application = createApplicationSession(createBackend, localStorage, sessionStorage);
|
|
5
|
+
export const {
|
|
6
|
+
currentUser,
|
|
7
|
+
initialize,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
login,
|
|
10
|
+
logout,
|
|
11
|
+
permissionStore,
|
|
12
|
+
service,
|
|
13
|
+
tabStorageKey: adminTabSnapshotStorageKey,
|
|
14
|
+
} = application;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
2
|
+
import type { DataRow } from './services/contracts';
|
|
3
|
+
import { assertHierarchy } from '../shared/tree';
|
|
4
|
+
import { safeExternalUrl, stringArray, validateMenus } from './menu-policy';
|
|
5
|
+
|
|
6
|
+
export function seedMenuRows(menus: LumalMenuRecord[], parentId = ''): DataRow[] {
|
|
7
|
+
return menus.flatMap((menu) => [
|
|
8
|
+
{
|
|
9
|
+
id: menu.name ?? menu.path,
|
|
10
|
+
parentId,
|
|
11
|
+
path: menu.path,
|
|
12
|
+
name: menu.name ?? menu.path,
|
|
13
|
+
type: menu.component ? 'menu' : 'directory',
|
|
14
|
+
title: String(menu.meta?.title ?? menu.name),
|
|
15
|
+
component: menu.component ?? '',
|
|
16
|
+
icon: menu.meta?.icon ?? '',
|
|
17
|
+
order: menu.meta?.order ?? 0,
|
|
18
|
+
hidden: menu.meta?.hideInMenu === true,
|
|
19
|
+
permissions: typeof menu.meta?.authority === 'string' ? [menu.meta.authority] : (menu.meta?.authority ?? []),
|
|
20
|
+
roles: typeof menu.meta?.roles === 'string' ? [menu.meta.roles] : (menu.meta?.roles ?? []),
|
|
21
|
+
keepAlive: menu.meta?.keepAlive !== false,
|
|
22
|
+
hideInTab: menu.meta?.hideInTab === true,
|
|
23
|
+
externalLink: '',
|
|
24
|
+
},
|
|
25
|
+
...seedMenuRows(menu.children ?? [], menu.name ?? menu.path),
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
export function buildStoredMenus(rows: DataRow[], views: Record<string, () => Promise<unknown>>): LumalMenuRecord[] {
|
|
29
|
+
const items = rows.map((row): DataRow & { id: string; parentId: string } => ({
|
|
30
|
+
...row,
|
|
31
|
+
id: String(row.id),
|
|
32
|
+
parentId: String(row.parentId ?? ''),
|
|
33
|
+
}));
|
|
34
|
+
assertHierarchy(items);
|
|
35
|
+
for (const row of items) {
|
|
36
|
+
const parent = items.find((item) => item.id === row.parentId);
|
|
37
|
+
if (parent && !['directory', 'menu'].includes(String(parent.type))) throw new Error('只有目录和菜单可以包含子节点');
|
|
38
|
+
}
|
|
39
|
+
function visit(parentId = ''): LumalMenuRecord[] {
|
|
40
|
+
return items
|
|
41
|
+
.filter((row) => row.parentId === parentId && row.type !== 'button')
|
|
42
|
+
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
|
|
43
|
+
.map((row) => ({
|
|
44
|
+
path: String(row.path),
|
|
45
|
+
name: String(row.name),
|
|
46
|
+
component: ['directory', 'external'].includes(String(row.type)) ? undefined : String(row.component),
|
|
47
|
+
meta: {
|
|
48
|
+
title: String(row.title),
|
|
49
|
+
order: Number(row.order ?? 0),
|
|
50
|
+
icon: String(row.icon ?? ''),
|
|
51
|
+
hideInMenu: row.hidden === true,
|
|
52
|
+
keepAlive: row.keepAlive !== false,
|
|
53
|
+
hideInTab: row.hideInTab === true,
|
|
54
|
+
affixTab: row.path === '/dashboard',
|
|
55
|
+
authority: stringArray(row.permissions ?? []),
|
|
56
|
+
roles: stringArray(row.roles ?? []),
|
|
57
|
+
...(row.type === 'external'
|
|
58
|
+
? { externalLink: safeExternalUrl(row.externalLink), externalTarget: '_blank' }
|
|
59
|
+
: {}),
|
|
60
|
+
...(row.type === 'embedded' ? { iframeSrc: safeExternalUrl(row.externalLink) } : {}),
|
|
61
|
+
},
|
|
62
|
+
children: visit(row.id),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
// 空目录允许先保存;隐藏该目录,直到存在可访问的页面。
|
|
66
|
+
const prune = (menus: LumalMenuRecord[]): LumalMenuRecord[] =>
|
|
67
|
+
menus.flatMap((menu) => {
|
|
68
|
+
const children = prune(menu.children ?? []);
|
|
69
|
+
return !menu.component && !menu.meta?.externalLink && !children.length ? [] : [{ ...menu, children }];
|
|
70
|
+
});
|
|
71
|
+
return validateMenus(prune(visit()), views);
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { useRoute } from 'vue-router';
|
|
4
|
+
import { safeExternalUrl } from '../menu-policy';
|
|
5
|
+
|
|
6
|
+
/***********************独立 iframe 来源校验*********************/
|
|
7
|
+
const route = useRoute();
|
|
8
|
+
const src = computed(() => {
|
|
9
|
+
const value = safeExternalUrl(route.meta.iframeSrc);
|
|
10
|
+
const allowed = String(import.meta.env.VITE_IFRAME_ORIGINS ?? '')
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((item) => item.trim());
|
|
13
|
+
if (!allowed.includes(new URL(value).origin)) throw new Error('iframe 来源未获授权');
|
|
14
|
+
return value;
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<iframe
|
|
20
|
+
class="app-iframe"
|
|
21
|
+
:src="src"
|
|
22
|
+
:title="String(route.meta.title ?? '内嵌页面')"
|
|
23
|
+
sandbox="allow-scripts allow-forms"
|
|
24
|
+
referrerpolicy="no-referrer"
|
|
25
|
+
/>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style scoped>
|
|
29
|
+
.app-iframe {
|
|
30
|
+
width: 100%;
|
|
31
|
+
height: calc(100dvh - 160px);
|
|
32
|
+
border: 0;
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FormInstance } from 'element-plus';
|
|
3
|
+
import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus';
|
|
4
|
+
import { reactive, shallowRef, useTemplateRef } from 'vue';
|
|
5
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
6
|
+
import { isInternalLocation } from '../../menu-policy';
|
|
7
|
+
import { routeError } from '../../router';
|
|
8
|
+
import { demoAccount } from '../../services/data-source';
|
|
9
|
+
import { application, login } from '../../services/session';
|
|
10
|
+
|
|
11
|
+
/***********************登录状态*********************/
|
|
12
|
+
const route = useRoute();
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const formRef = useTemplateRef<FormInstance>('formRef');
|
|
15
|
+
const loading = shallowRef(false);
|
|
16
|
+
const errorMessage = shallowRef('');
|
|
17
|
+
const form = reactive({ username: demoAccount?.username ?? '', password: demoAccount?.password ?? '' });
|
|
18
|
+
|
|
19
|
+
/***********************登录提交*********************/
|
|
20
|
+
function resolveRedirect(): string {
|
|
21
|
+
const redirect = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect;
|
|
22
|
+
return redirect && isInternalLocation(redirect) && !redirect.startsWith('/login') ? redirect : '/dashboard';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleSubmit(): Promise<void> {
|
|
26
|
+
if (loading.value) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
loading.value = true;
|
|
31
|
+
errorMessage.value = '';
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const valid = await formRef.value?.validate();
|
|
35
|
+
if (valid === false) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await login(form);
|
|
40
|
+
await router.replace(resolveRedirect());
|
|
41
|
+
} catch (error) {
|
|
42
|
+
errorMessage.value = error instanceof Error ? error.message : '登录失败';
|
|
43
|
+
} finally {
|
|
44
|
+
loading.value = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<main class="lumal-admin-login">
|
|
51
|
+
<section class="lumal-admin-login__panel">
|
|
52
|
+
<div class="lumal-admin-login__brand" aria-hidden="true">L</div>
|
|
53
|
+
<h1>Lumal Admin</h1>
|
|
54
|
+
<p>轻量后台基座</p>
|
|
55
|
+
|
|
56
|
+
<ElForm ref="formRef" :model="form" label-position="top" @submit.prevent="handleSubmit">
|
|
57
|
+
<ElFormItem label="用户名" prop="username" :rules="[{ required: true, message: '请输入用户名' }]">
|
|
58
|
+
<ElInput v-model="form.username" autocomplete="username" />
|
|
59
|
+
</ElFormItem>
|
|
60
|
+
<ElFormItem label="密码" prop="password" :rules="[{ required: true, message: '请输入密码' }]">
|
|
61
|
+
<ElInput v-model="form.password" type="password" show-password autocomplete="current-password" />
|
|
62
|
+
</ElFormItem>
|
|
63
|
+
<p v-if="errorMessage" class="lumal-admin-login__error" role="alert">
|
|
64
|
+
{{ errorMessage }}
|
|
65
|
+
</p>
|
|
66
|
+
<ElButton native-type="submit" type="primary" :loading="loading"> 登录 </ElButton>
|
|
67
|
+
</ElForm>
|
|
68
|
+
|
|
69
|
+
<p v-if="routeError || application.error.value" role="alert">
|
|
70
|
+
{{ routeError || application.error.value }}
|
|
71
|
+
</p>
|
|
72
|
+
<small v-if="demoAccount">本地演示账号:{{ demoAccount.username }} / {{ demoAccount.password }}</small>
|
|
73
|
+
</section>
|
|
74
|
+
</main>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LumalPage } from '@lumal/core/components';
|
|
3
|
+
import { ElButton } from 'element-plus';
|
|
4
|
+
import { useRouter } from 'vue-router';
|
|
5
|
+
|
|
6
|
+
/***********************路由操作*********************/
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
|
|
9
|
+
function handleBackDashboardClick(): void {
|
|
10
|
+
void router.push('/dashboard');
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<main class="lumal-admin-page">
|
|
16
|
+
<LumalPage title="无权限" description="当前账号没有访问该页面所需的权限。">
|
|
17
|
+
<ElButton type="primary" @click="handleBackDashboardClick"> 返回工作台 </ElButton>
|
|
18
|
+
</LumalPage>
|
|
19
|
+
</main>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LumalPage } from '@lumal/core/components';
|
|
3
|
+
import { ElButton } from 'element-plus';
|
|
4
|
+
import { useRouter } from 'vue-router';
|
|
5
|
+
|
|
6
|
+
/***********************异常页导航*********************/
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<main class="lumal-admin-page">
|
|
12
|
+
<LumalPage title="页面不存在" description="请检查地址,或返回工作台继续操作。">
|
|
13
|
+
<ElButton type="primary" @click="router.push('/dashboard')"> 返回工作台 </ElButton>
|
|
14
|
+
</LumalPage>
|
|
15
|
+
</main>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IconDefinition } from '@lumal/icons';
|
|
2
|
+
import { registerAuthorityDirectives } from '@lumal/core/directives';
|
|
3
|
+
import { createLumalAdmin } from '@lumal/core/minimal';
|
|
4
|
+
import App from './App.vue';
|
|
5
|
+
import { router } from './app/router';
|
|
6
|
+
import { permissionStore } from './app/services/access';
|
|
7
|
+
import '@lumal/core/theme-chalk/index.scss';
|
|
8
|
+
import '@lumal/core/style.css';
|
|
9
|
+
import '@lumal/icons-vue/style.css';
|
|
10
|
+
import 'element-plus/theme-chalk/el-alert.css';
|
|
11
|
+
import 'element-plus/theme-chalk/dark/css-vars.css';
|
|
12
|
+
import './styles.scss';
|
|
13
|
+
|
|
14
|
+
/***********************本地图标定义*********************/
|
|
15
|
+
const localIcons: IconDefinition[] = [
|
|
16
|
+
{
|
|
17
|
+
key: 'app:dashboard',
|
|
18
|
+
label: '控制台',
|
|
19
|
+
source: 'local-svg',
|
|
20
|
+
svgText:
|
|
21
|
+
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/***********************应用启动*********************/
|
|
26
|
+
const framework = createLumalAdmin({
|
|
27
|
+
rootComponent: App,
|
|
28
|
+
router,
|
|
29
|
+
permissionStore,
|
|
30
|
+
icons: {
|
|
31
|
+
localSvg: localIcons,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
registerAuthorityDirectives(framework.app, permissionStore);
|
|
36
|
+
void framework.mount('#app').catch((error: unknown) => {
|
|
37
|
+
console.error('应用启动失败', error);
|
|
38
|
+
const element = document.getElementById('app');
|
|
39
|
+
if (element) element.textContent = '应用启动失败,请检查配置与控制台。';
|
|
40
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 业务模块
|
|
2
|
+
|
|
3
|
+
在此目录新增 `orders/module.ts`,导出 `menus`、`views` 和可选 `permissions`。
|
|
4
|
+
应用仅自动加载模块元数据,页面使用 `() => import('./OrderView.vue')`。
|
|
5
|
+
业务类型与 API 放在同一模块内,不要向系统管理或 Core 添加业务依赖。
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import type { AppModule } from '../../../app/module';
|
|
9
|
+
|
|
10
|
+
export const menus: AppModule['menus'] = [
|
|
11
|
+
{
|
|
12
|
+
path: '/orders',
|
|
13
|
+
name: 'Orders',
|
|
14
|
+
component: 'business/orders',
|
|
15
|
+
meta: { title: '订单', authority: ['orders:list'] },
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
export const views: AppModule['views'] = {
|
|
19
|
+
'business/orders': () => import('./OrderView.vue'),
|
|
20
|
+
};
|
|
21
|
+
export const permissions: AppModule['permissions'] = [
|
|
22
|
+
{ id: 'orders:list', label: '订单列表', permission: 'orders:list' },
|
|
23
|
+
];
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
后端 `/menus` 必须返回此组件白名单键;HTTP 不会把本地路由悄悄融合到远端菜单中。
|
|
27
|
+
本地开发的权限来自显式注册表,生产权限始终由后端检查。
|
|
28
|
+
|
|
29
|
+
业务 API、输入/响应类型与页面放在 `orders/` 内,通过 `BusinessTransport` 发送请求。
|
|
30
|
+
Local 模式可在同目录提供 `local.ts`,导出满足 `LocalModule` 的 `createLocalHandler`:
|
|
31
|
+
`handles(path)` 仅匹配本模块端点,`request(method, path, input)` 实现开发行为。
|
|
32
|
+
处理器通过上下文的 `db/user/assertPermission` 读写演示数据、确认身份和检查权限。
|
|
33
|
+
路由元数据和 Local 处理器均自动发现,无需改写中心注册文件;HTTP 仍需真实后端实现同一端点。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElDescriptions, ElDescriptionsItem } from 'element-plus';
|
|
3
|
+
import { currentUser, permissionStore } from '../../app/services/session';
|
|
4
|
+
|
|
5
|
+
/***********************会话摘要*********************/
|
|
6
|
+
const startedAt = new Date().toLocaleDateString();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<main class="lumal-admin-page">
|
|
11
|
+
<h1>工作台</h1>
|
|
12
|
+
<ElDescriptions :column="1" border>
|
|
13
|
+
<ElDescriptionsItem label="当前用户">
|
|
14
|
+
{{ currentUser?.name }}
|
|
15
|
+
</ElDescriptionsItem>
|
|
16
|
+
<ElDescriptionsItem label="角色">
|
|
17
|
+
{{ permissionStore.roles.join('、') }}
|
|
18
|
+
</ElDescriptionsItem>
|
|
19
|
+
<ElDescriptionsItem label="权限数">
|
|
20
|
+
{{ permissionStore.permissions.length }}
|
|
21
|
+
</ElDescriptionsItem>
|
|
22
|
+
<ElDescriptionsItem label="日期">
|
|
23
|
+
{{ startedAt }}
|
|
24
|
+
</ElDescriptionsItem>
|
|
25
|
+
</ElDescriptions>
|
|
26
|
+
</main>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppModule } from '../../app/module';
|
|
2
|
+
|
|
3
|
+
export const menus: AppModule['menus'] = [
|
|
4
|
+
{
|
|
5
|
+
path: '/dashboard',
|
|
6
|
+
name: 'Dashboard',
|
|
7
|
+
component: 'dashboard/index',
|
|
8
|
+
meta: { title: '工作台', icon: 'app:dashboard', affixTab: true, keepAlive: true, order: 0 },
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
export const views: AppModule['views'] = {
|
|
12
|
+
'dashboard/index': () => import('./DashboardView.vue'),
|
|
13
|
+
'app/iframe': () => import('../../app/views/SafeIframeView.vue'),
|
|
14
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SchemaFormModel } from '@lumal/core/components';
|
|
2
|
+
import { object, stringArray } from '../app/menu-policy';
|
|
3
|
+
|
|
4
|
+
export type Status = 'enabled' | 'disabled';
|
|
5
|
+
export interface Option {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
children?: Option[];
|
|
10
|
+
}
|
|
11
|
+
export interface Query {
|
|
12
|
+
keyword?: string;
|
|
13
|
+
status?: Status | '';
|
|
14
|
+
}
|
|
15
|
+
export interface PageInput<Q = Query> {
|
|
16
|
+
page: number;
|
|
17
|
+
pageSize: number;
|
|
18
|
+
query: Q;
|
|
19
|
+
}
|
|
20
|
+
export { object, stringArray };
|
|
21
|
+
|
|
22
|
+
export function text(value: unknown, label: string, required = false): string {
|
|
23
|
+
if (value == null && !required) return '';
|
|
24
|
+
if (typeof value !== 'string' || (required && !value.trim()))
|
|
25
|
+
throw new Error(`${label}必须是${required ? '非空' : ''}字符串`);
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
export function status(value: unknown): Status {
|
|
29
|
+
if (value !== 'enabled' && value !== 'disabled') throw new Error('状态仅支持 enabled / disabled');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export function integer(value: unknown): number {
|
|
33
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error('排序必须是非负整数');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
export function rows<T>(value: unknown, parse: (item: unknown) => T): T[] {
|
|
37
|
+
if (!Array.isArray(value)) throw new Error('列表响应必须是数组');
|
|
38
|
+
return value.map(parse);
|
|
39
|
+
}
|
|
40
|
+
export function options(value: unknown): Option[] {
|
|
41
|
+
return rows(value, (item) => {
|
|
42
|
+
const raw = object(item);
|
|
43
|
+
return {
|
|
44
|
+
label: text(raw.label, '选项名称', true),
|
|
45
|
+
value: text(raw.value, '选项值', true),
|
|
46
|
+
disabled: raw.disabled === true,
|
|
47
|
+
children: raw.children === undefined ? undefined : options(raw.children),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function formStatus(model: SchemaFormModel): Status {
|
|
52
|
+
return status(model.status ?? 'enabled');
|
|
53
|
+
}
|