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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
2
|
+
|
|
3
|
+
export interface PermissionNode {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
permission?: string;
|
|
7
|
+
children?: PermissionNode[];
|
|
8
|
+
}
|
|
9
|
+
export interface AppModule {
|
|
10
|
+
menus: LumalMenuRecord[];
|
|
11
|
+
views: Record<string, () => Promise<unknown>>;
|
|
12
|
+
permissions?: PermissionNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function collectPermissions(nodes: readonly PermissionNode[]): string[] {
|
|
16
|
+
return [
|
|
17
|
+
...new Set(
|
|
18
|
+
nodes.flatMap((node) => [
|
|
19
|
+
...(node.permission ? [node.permission] : []),
|
|
20
|
+
...collectPermissions(node.children ?? []),
|
|
21
|
+
]),
|
|
22
|
+
),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assembleModules(modules: AppModule[]) {
|
|
27
|
+
const views: Record<string, () => Promise<unknown>> = Object.create(null);
|
|
28
|
+
const permissions = modules.flatMap((module) => module.permissions ?? []);
|
|
29
|
+
for (const module of modules) {
|
|
30
|
+
for (const [name, loader] of Object.entries(module.views)) {
|
|
31
|
+
if (
|
|
32
|
+
['constructor', '__proto__', 'prototype'].includes(name) ||
|
|
33
|
+
Object.hasOwn(views, name) ||
|
|
34
|
+
typeof loader !== 'function'
|
|
35
|
+
)
|
|
36
|
+
throw new Error(`模块组件名称重复或无效:${name}`);
|
|
37
|
+
views[name] = loader;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
views,
|
|
42
|
+
menus: modules.flatMap((module) => module.menus),
|
|
43
|
+
permissions,
|
|
44
|
+
permissionCodes: collectPermissions(permissions),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { LumalLayoutTabItem } from '@lumal/core/layout';
|
|
2
|
+
import type { SidebarMenuItem } from '@lumal/core/router';
|
|
3
|
+
import type { RouteRecordRaw, RouterHistory } from 'vue-router';
|
|
4
|
+
import { setupPermissionGuard } from '@lumal/core/permission';
|
|
5
|
+
import { createMenuRouteRuntime } from '@lumal/core/router';
|
|
6
|
+
import { shallowRef } from 'vue';
|
|
7
|
+
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
|
|
8
|
+
import { appConfig } from '../../app.config';
|
|
9
|
+
import { resolveView, validateMenus, withAccessibleRedirects } from '../menu-policy';
|
|
10
|
+
import { application, permissionStore } from '../services/session';
|
|
11
|
+
import { viewComponents } from './views';
|
|
12
|
+
|
|
13
|
+
/***********************路由初始化*********************/
|
|
14
|
+
export const routeError = shallowRef('');
|
|
15
|
+
export function createAdminRouter(history?: RouterHistory) {
|
|
16
|
+
const targetRouter = createRouter({
|
|
17
|
+
history:
|
|
18
|
+
history ??
|
|
19
|
+
(appConfig.routerMode === 'history'
|
|
20
|
+
? createWebHistory(import.meta.env.BASE_URL)
|
|
21
|
+
: createWebHashHistory(import.meta.env.BASE_URL)),
|
|
22
|
+
routes: [
|
|
23
|
+
{
|
|
24
|
+
path: '/login',
|
|
25
|
+
name: 'Login',
|
|
26
|
+
component: () => import('../views/auth/LoginView.vue'),
|
|
27
|
+
meta: { layout: 'public', requireLogin: false, title: '登录' },
|
|
28
|
+
},
|
|
29
|
+
{ path: '/', redirect: '/dashboard' },
|
|
30
|
+
{ path: '/403', component: () => import('../views/error/ForbiddenView.vue'), meta: { title: '无权限' } },
|
|
31
|
+
{ path: '/404', component: () => import('../views/error/NotFoundView.vue'), meta: { title: '页面不存在' } },
|
|
32
|
+
{ path: '/:pathMatch(.*)*', name: 'CatchAll', component: () => import('../views/error/NotFoundView.vue') },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
const runtime = createMenuRouteRuntime({
|
|
36
|
+
router: {
|
|
37
|
+
addRoute: (route) => targetRouter.addRoute(route as RouteRecordRaw),
|
|
38
|
+
getRoutes: () => targetRouter.getRoutes(),
|
|
39
|
+
hasRoute: (name) => targetRouter.hasRoute(name),
|
|
40
|
+
removeRoute: (name) => targetRouter.removeRoute(name),
|
|
41
|
+
},
|
|
42
|
+
componentResolver: (name) => resolveView(viewComponents, name),
|
|
43
|
+
hasPermission: (values) => permissionStore.hasPermission(values, 'every'),
|
|
44
|
+
hasRole: (values) => permissionStore.hasRole(values, 'some'),
|
|
45
|
+
loadRemoteMenus: async () => {
|
|
46
|
+
await application.initialize();
|
|
47
|
+
return withAccessibleRedirects(validateMenus(application.menus.value, viewComponents), {
|
|
48
|
+
permissions: permissionStore.permissions,
|
|
49
|
+
roles: permissionStore.roles,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
application.onReset((expired) => {
|
|
54
|
+
runtime.resetRemote();
|
|
55
|
+
if (expired && targetRouter.currentRoute.value.path !== '/login')
|
|
56
|
+
void targetRouter.replace({ path: '/login', query: { redirect: targetRouter.currentRoute.value.fullPath } });
|
|
57
|
+
});
|
|
58
|
+
const navigationVersion = shallowRef(0);
|
|
59
|
+
application.onRefresh(async () => {
|
|
60
|
+
await runtime.reload();
|
|
61
|
+
navigationVersion.value += 1;
|
|
62
|
+
await targetRouter.replace(targetRouter.currentRoute.value.fullPath);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
targetRouter.beforeEach(async (to) => {
|
|
66
|
+
if (!application.isAuthenticated())
|
|
67
|
+
return to.path === '/login' ? true : { path: '/login', query: { redirect: to.fullPath } };
|
|
68
|
+
const rematch = !runtime.remoteLoaded;
|
|
69
|
+
try {
|
|
70
|
+
await runtime.loadRemote();
|
|
71
|
+
routeError.value = '';
|
|
72
|
+
} catch (reason) {
|
|
73
|
+
routeError.value = reason instanceof Error ? reason.message : '菜单加载失败';
|
|
74
|
+
application.reset();
|
|
75
|
+
return { path: '/login', query: { redirect: to.fullPath } };
|
|
76
|
+
}
|
|
77
|
+
if (to.path === '/login' || to.path === '/') return runtime.firstAccessiblePath || '/403';
|
|
78
|
+
if (rematch) return to.fullPath;
|
|
79
|
+
return to.name === 'CatchAll' ? '/404' : true;
|
|
80
|
+
});
|
|
81
|
+
setupPermissionGuard(targetRouter, permissionStore, {
|
|
82
|
+
isAuthenticated: application.isAuthenticated,
|
|
83
|
+
mode: 'every',
|
|
84
|
+
roleMode: 'some',
|
|
85
|
+
requireLoginByDefault: true,
|
|
86
|
+
noAccessRedirect: '/403',
|
|
87
|
+
});
|
|
88
|
+
return { router: targetRouter, runtime, navigationVersion };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { router, runtime, navigationVersion } = createAdminRouter();
|
|
92
|
+
export { router };
|
|
93
|
+
export function createAdminSidebarMenus(): SidebarMenuItem[] {
|
|
94
|
+
void navigationVersion.value;
|
|
95
|
+
// ready 使非响应式菜单运行时在退出和初始化完成后重新计算。
|
|
96
|
+
return application.ready.value ? runtime.sidebarMenus : [];
|
|
97
|
+
}
|
|
98
|
+
export function createAdminTabs(): LumalLayoutTabItem[] {
|
|
99
|
+
return runtime.routes
|
|
100
|
+
.filter(
|
|
101
|
+
(route) =>
|
|
102
|
+
route.meta.affixTab === true &&
|
|
103
|
+
permissionStore.hasPermission(route.meta.permissions, 'every') &&
|
|
104
|
+
permissionStore.hasRole(route.meta.roles, 'some'),
|
|
105
|
+
)
|
|
106
|
+
.map((route) => ({ path: route.path, title: route.meta.title, closable: false }));
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AppModule } from '../module';
|
|
2
|
+
import { assembleModules } from '../module';
|
|
3
|
+
|
|
4
|
+
// 只加载元数据,页面仍以动态 import 分包;业务子模块无需修改中心注册文件。
|
|
5
|
+
const modules = import.meta.glob<AppModule>(['../../modules/*/module.ts', '../../modules/business/*/module.ts'], {
|
|
6
|
+
eager: true,
|
|
7
|
+
});
|
|
8
|
+
const assembled = assembleModules(Object.values(modules));
|
|
9
|
+
export const localMenus = assembled.menus;
|
|
10
|
+
export const viewComponents = { ...assembled.views, 'app/iframe': () => import('../views/SafeIframeView.vue') };
|
|
11
|
+
export const permissionTree = assembled.permissions;
|
|
12
|
+
export const permissionCodes = assembled.permissionCodes;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 单一权限 Store,由实际会话响应填充,不能持久化演示权限。
|
|
2
|
-
export { permissionStore } from './session'
|
|
2
|
+
export { permissionStore } from './session';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuthSessionData } from '@lumal/core/auth';
|
|
2
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
3
|
+
|
|
4
|
+
export interface LoginInput {
|
|
5
|
+
username: string;
|
|
6
|
+
password: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CurrentUser {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
username: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AccessSnapshot {
|
|
14
|
+
permissions: string[];
|
|
15
|
+
roles: string[];
|
|
16
|
+
}
|
|
17
|
+
export type DataRow = Record<string, unknown>;
|
|
18
|
+
export interface ListInput {
|
|
19
|
+
page: number;
|
|
20
|
+
pageSize: number;
|
|
21
|
+
query: DataRow;
|
|
22
|
+
}
|
|
23
|
+
export interface PageResult<T = DataRow> {
|
|
24
|
+
items: T[];
|
|
25
|
+
total: number;
|
|
26
|
+
}
|
|
27
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
28
|
+
export interface BusinessRequest {
|
|
29
|
+
body?: object;
|
|
30
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
31
|
+
}
|
|
32
|
+
export interface BusinessTransport {
|
|
33
|
+
request: <T>(method: HttpMethod, path: string, input?: BusinessRequest) => Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 应用协议由 Local/HTTP 共同实现,后端包装和字段转换不进入 Core。
|
|
37
|
+
export interface ApplicationService extends BusinessTransport {
|
|
38
|
+
login: (input: LoginInput, signal: AbortSignal) => Promise<AuthSessionData>;
|
|
39
|
+
getUser: (signal: AbortSignal) => Promise<CurrentUser>;
|
|
40
|
+
getAccess: (signal: AbortSignal) => Promise<AccessSnapshot>;
|
|
41
|
+
getMenus: (signal: AbortSignal) => Promise<LumalMenuRecord[]>;
|
|
42
|
+
logout: (accessToken: string) => Promise<void>;
|
|
43
|
+
clearCache: () => void;
|
|
44
|
+
list: (resource: string, input: ListInput) => Promise<PageResult>;
|
|
45
|
+
save: (resource: string, model: DataRow, id?: string) => Promise<unknown>;
|
|
46
|
+
remove: (resource: string, id: string) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ServiceContext {
|
|
50
|
+
getSession: () => AuthSessionData | null;
|
|
51
|
+
getSignal: () => AbortSignal;
|
|
52
|
+
setSession: (value: AuthSessionData) => void;
|
|
53
|
+
expire: () => Promise<void>;
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { LumalPreferencesDefaults } from '@lumal/core/theme'
|
|
2
|
-
import { createPreferencesStore } from '@lumal/core/theme'
|
|
3
|
-
import { appConfig } from '
|
|
1
|
+
import type { LumalPreferencesDefaults } from '@lumal/core/theme';
|
|
2
|
+
import { createPreferencesStore } from '@lumal/core/theme';
|
|
3
|
+
import { appConfig } from '../../app.config';
|
|
4
4
|
|
|
5
5
|
export const adminPreferenceDefaults = {
|
|
6
6
|
app: { dynamicTitle: true, layout: 'sidebar-nav' },
|
|
@@ -25,17 +25,17 @@ export const adminPreferenceDefaults = {
|
|
|
25
25
|
},
|
|
26
26
|
theme: { fontSize: 14 },
|
|
27
27
|
transition: { enable: true, name: 'fade-side' },
|
|
28
|
-
} satisfies LumalPreferencesDefaults
|
|
28
|
+
} satisfies LumalPreferencesDefaults;
|
|
29
29
|
|
|
30
30
|
const preferencesStore = createPreferencesStore({
|
|
31
31
|
defaults: adminPreferenceDefaults,
|
|
32
32
|
storage: localStorage,
|
|
33
33
|
storageKey: 'lumal-admin:preferences',
|
|
34
|
-
})
|
|
34
|
+
});
|
|
35
35
|
|
|
36
|
-
export const adminPreferences = preferencesStore.state
|
|
37
|
-
export const adminResolvedThemeMode = preferencesStore.resolvedThemeMode
|
|
36
|
+
export const adminPreferences = preferencesStore.state;
|
|
37
|
+
export const adminResolvedThemeMode = preferencesStore.resolvedThemeMode;
|
|
38
38
|
|
|
39
39
|
export function patchAdminPreferences(preferences: Parameters<typeof preferencesStore.patch>[0]): void {
|
|
40
|
-
preferencesStore.patch(preferences)
|
|
40
|
+
preferencesStore.patch(preferences);
|
|
41
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createBackend } from './data-source'
|
|
2
|
-
import { createApplicationSession } from './session-runtime'
|
|
1
|
+
import { createBackend } from './data-source';
|
|
2
|
+
import { createApplicationSession } from './session-runtime';
|
|
3
3
|
|
|
4
|
-
export const application = createApplicationSession(createBackend, localStorage, sessionStorage)
|
|
4
|
+
export const application = createApplicationSession(createBackend, localStorage, sessionStorage);
|
|
5
5
|
export const {
|
|
6
6
|
currentUser,
|
|
7
7
|
initialize,
|
|
@@ -11,4 +11,4 @@ export const {
|
|
|
11
11
|
permissionStore,
|
|
12
12
|
service,
|
|
13
13
|
tabStorageKey: adminTabSnapshotStorageKey,
|
|
14
|
-
} = application
|
|
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>
|