create-lumal-admin 0.0.3-beta → 0.0.5-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +67 -10
  3. package/dist/cli.d.ts +12 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +91 -28
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +2 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +85 -2
  10. package/dist/create.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/presets.d.ts +15 -0
  14. package/dist/presets.d.ts.map +1 -0
  15. package/dist/presets.js +30 -0
  16. package/dist/presets.js.map +1 -0
  17. package/dist/scaffold.d.ts +2 -11
  18. package/dist/scaffold.d.ts.map +1 -1
  19. package/dist/scaffold.js +2 -1602
  20. package/dist/scaffold.js.map +1 -1
  21. package/dist/templates.d.ts +5 -0
  22. package/dist/templates.d.ts.map +1 -0
  23. package/dist/templates.js +62 -0
  24. package/dist/templates.js.map +1 -0
  25. package/dist/types.d.ts +25 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +2 -0
  28. package/dist/types.js.map +1 -0
  29. package/package.json +4 -3
  30. package/templates/base/.env.example +4 -0
  31. package/templates/base/API-CONTRACT.md +37 -0
  32. package/templates/base/README.md +42 -0
  33. package/templates/base/_gitignore +6 -0
  34. package/templates/base/eslint.config.mjs +8 -0
  35. package/templates/base/index.html +12 -0
  36. package/templates/base/package.json +38 -0
  37. package/templates/base/src/App.vue +214 -0
  38. package/templates/base/src/components/app/AppHeaderActions.vue +34 -0
  39. package/templates/base/src/components/app/AppSettingsDrawer.vue +37 -0
  40. package/templates/base/src/main.ts +41 -0
  41. package/templates/base/src/router/index.ts +86 -0
  42. package/templates/base/src/router/views.ts +16 -0
  43. package/templates/base/src/services/access.ts +2 -0
  44. package/templates/base/src/services/contracts.ts +29 -0
  45. package/templates/base/src/services/preferences.ts +41 -0
  46. package/templates/base/src/services/session-runtime.ts +137 -0
  47. package/templates/base/src/services/session.ts +14 -0
  48. package/templates/base/src/styles.scss +178 -0
  49. package/templates/base/src/views/auth/LoginView.vue +80 -0
  50. package/templates/base/src/views/dashboard/DashboardView.vue +27 -0
  51. package/templates/base/src/views/error/ForbiddenView.vue +25 -0
  52. package/templates/base/src/views/error/NotFoundView.vue +18 -0
  53. package/templates/base/tests/session.test.ts.template +34 -0
  54. package/templates/base/tsconfig.json +22 -0
  55. package/templates/base/vite.config.ts +54 -0
  56. package/templates/base/vitest.config.ts +3 -0
  57. package/templates/features/examples/src/modules/examples.ts +10 -0
  58. package/templates/features/examples/src/views/examples/CrudExampleView.vue +30 -0
  59. package/templates/features/examples/src/views/examples/FormExampleView.vue +25 -0
  60. package/templates/features/system/src/business/system/entities.ts +93 -0
  61. package/templates/features/system/src/modules/system.ts +28 -0
  62. package/templates/features/system/src/views/system/SystemEntityView.vue +85 -0
  63. package/templates/modes/http/src/services/data-source.ts +120 -0
  64. package/templates/modes/http/src/services/http-adapter.ts +124 -0
  65. package/templates/modes/http/tests/http.test.ts.template +165 -0
  66. package/templates/modes/local/src/services/data-source.ts +176 -0
  67. package/templates/modes/local/tests/local.test.ts.template +92 -0
  68. package/templates/vben-compatible/MIGRATION.md +28 -0
  69. package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +34 -0
  70. package/templates/vben-compatible/src/components/migration/VbenUsers.vue +135 -0
  71. package/templates/vben-compatible/src/modules/vben.ts +17 -0
  72. package/templates/vben-compatible/src/views/migration/VbenFormView.vue +10 -0
  73. package/templates/vben-compatible/src/views/migration/VbenUsersView.vue +11 -0
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApplicationSession } from '../src/services/session-runtime'
3
+
4
+ describe('应用会话', () => {
5
+ it('统一清理认证、权限、菜单与标签', () => {
6
+ const values = new Map<string, string>()
7
+ const storage = {
8
+ getItem: (key: string) => values.get(key) ?? null,
9
+ setItem: (key: string, value: string) => { values.set(key, value) },
10
+ removeItem: (key: string) => { values.delete(key) },
11
+ clear: () => values.clear(),
12
+ key: (index: number) => [...values.keys()][index] ?? null,
13
+ get length() { return values.size },
14
+ }
15
+ const app = createApplicationSession(() => ({
16
+ login: async () => ({ accessToken: 'test-only' }),
17
+ getUser: async () => ({ id: '1', username: 'test', name: 'Test' }),
18
+ getAccess: async () => ({ roles: ['admin'], permissions: ['read'] }),
19
+ getMenus: async () => [],
20
+ logout: async () => {},
21
+ clearCache: () => {},
22
+ list: async () => ({ items: [], total: 0 }),
23
+ save: async () => {},
24
+ remove: async () => {},
25
+ }), storage, storage)
26
+ app.session.setSession({ accessToken: 'test-only' })
27
+ app.permissionStore.setPermissions(['read'])
28
+ storage.setItem(app.tabStorageKey, 'old-tabs')
29
+ app.reset()
30
+ expect(app.isAuthenticated()).toBe(false)
31
+ expect(app.permissionStore.permissions).toEqual([])
32
+ expect(storage.getItem(app.tabStorageKey)).toBeNull()
33
+ })
34
+ })
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "jsx": "preserve",
5
+ "useDefineForClassFields": true,
6
+ "baseUrl": ".",
7
+ "module": "ESNext",
8
+ "moduleResolution": "Bundler",
9
+ "paths": { "@/*": ["src/*"] },
10
+ "resolveJsonModule": true,
11
+ "ignoreDeprecations": "6.0",
12
+ "types": ["vite/client", "node"],
13
+ "strict": true,
14
+ "isolatedModules": true,
15
+ "skipLibCheck": true
16
+ },
17
+ "include": [
18
+ "src/**/*.ts",
19
+ "src/**/*.vue",
20
+ "vite.config.ts"
21
+ ]
22
+ }
@@ -0,0 +1,54 @@
1
+ import { fileURLToPath, URL } from 'node:url'
2
+ import vue from '@vitejs/plugin-vue'
3
+ import { defineConfig } from 'vite'
4
+
5
+ /***********************应用开发配置*********************/
6
+ export default defineConfig({
7
+ plugins: [vue()],
8
+ // manifest 用于核对路由懒加载和初始资源体积。
9
+ build: {
10
+ manifest: true,
11
+ chunkSizeWarningLimit: 500,
12
+ rolldownOptions: {
13
+ output: {
14
+ codeSplitting: {
15
+ groups: [
16
+ {
17
+ entriesAware: true,
18
+ maxSize: 420 * 1024,
19
+ name: 'vendor-element-plus',
20
+ priority: 40,
21
+ test: /node_modules[\\/]element-plus[\\/]/,
22
+ },
23
+ {
24
+ entriesAware: true,
25
+ maxSize: 420 * 1024,
26
+ name: 'vendor-vue',
27
+ priority: 30,
28
+ test: /node_modules[\\/](?:@vue|vue|vue-router)[\\/]/,
29
+ },
30
+ {
31
+ entriesAware: true,
32
+ maxSize: 420 * 1024,
33
+ name: 'vendor-lumal',
34
+ priority: 20,
35
+ test: /node_modules[\\/]@lumal[\\/]/,
36
+ },
37
+ {
38
+ entriesAware: true,
39
+ maxSize: 420 * 1024,
40
+ name: 'vendor',
41
+ priority: 10,
42
+ test: /node_modules[\\/]/,
43
+ },
44
+ ],
45
+ },
46
+ },
47
+ },
48
+ },
49
+ resolve: {
50
+ alias: {
51
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
52
+ },
53
+ },
54
+ })
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({ test: { environment: 'node' } })
@@ -0,0 +1,10 @@
1
+ import type { FeatureModule } from '../router/views'
2
+
3
+ export const menus: FeatureModule['menus'] = [
4
+ { path: '/examples/form', name: 'SchemaFormExample', component: 'examples/form', meta: { title: 'Schema 表单', keepAlive: true } },
5
+ { path: '/examples/crud', name: 'CrudExample', component: 'examples/crud', meta: { title: 'CRUD 示例', keepAlive: true } },
6
+ ]
7
+ export const views: FeatureModule['views'] = {
8
+ 'examples/form': () => import('../views/examples/FormExampleView.vue'),
9
+ 'examples/crud': () => import('../views/examples/CrudExampleView.vue'),
10
+ }
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import type { CrudDataSource, SchemaTableRow } from '@lumal/core/components'
3
+ import { LumalCrudTable } from '@lumal/core/components'
4
+
5
+ /***********************隔离的 CRUD 示例数据*********************/
6
+ let rows: SchemaTableRow[] = [{ id: 'example', name: '示例记录', status: 'enabled' }]
7
+ const columns = [{ field: 'name', label: '名称' }, { field: 'status', label: '状态' }]
8
+ const dataSource: CrudDataSource = {
9
+ fetch: async ({ page, pageSize, query }) => {
10
+ const filtered = rows.filter(row => String(row.name).includes(String(query.keyword ?? '')))
11
+ return { items: filtered.slice((page - 1) * pageSize, page * pageSize), total: filtered.length }
12
+ },
13
+ create: async (model) => { rows = [...rows, { ...model, id: crypto.randomUUID() }] },
14
+ update: async (row, model) => { rows = rows.map(item => item.id === row.id ? { ...model, id: row.id } : item) },
15
+ remove: async (row) => { rows = rows.filter(item => item.id !== row.id) },
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <main class="lumal-admin-page">
21
+ <LumalCrudTable
22
+ title="CRUD 示例"
23
+ :data-source="dataSource"
24
+ :table="{ columns, rowKey: 'id', showColumnSettings: true, selection: true }"
25
+ :query="{ schemas: [{ field: 'keyword', label: '关键词', component: 'input' }] }"
26
+ :form-schemas="[{ field: 'name', label: '名称', component: 'input', required: true }, { field: 'status', label: '状态', component: 'select', options: [{ label: '启用', value: 'enabled' }, { label: '停用', value: 'disabled' }], defaultValue: 'enabled' }]"
27
+ :toolbar="{ export: true, refresh: true }"
28
+ />
29
+ </main>
30
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components'
3
+ import { LumalSchemaForm } from '@lumal/core/components'
4
+ import { ElMessage } from 'element-plus'
5
+ import { shallowRef } from 'vue'
6
+
7
+ /***********************Schema 与联动*********************/
8
+ const model = shallowRef<SchemaFormModel>({})
9
+ const schemas: SchemaFormItem[] = [
10
+ { field: 'name', label: '名称', required: true, component: 'input' },
11
+ { field: 'date', label: '日期', component: 'date', componentProps: { valueFormat: 'YYYY-MM-DD' } },
12
+ { field: 'notify', label: '邮件通知', component: 'switch' },
13
+ { field: 'email', label: '邮箱', component: 'input', hidden: ({ model }) => !model.notify, required: ({ model }) => Boolean(model.notify), rules: [{ type: 'email', message: '请输入有效邮箱' }] },
14
+ ]
15
+ function submit(): void {
16
+ ElMessage.success('保存成功')
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <main class="lumal-admin-page">
22
+ <h1>Schema 表单</h1>
23
+ <LumalSchemaForm v-model="model" :schemas="schemas" :columns="2" show-actions show-reset @submit="submit" />
24
+ </main>
25
+ </template>
@@ -0,0 +1,93 @@
1
+ import type { SchemaFormItem, SchemaTableColumn } from '@lumal/core/components'
2
+ import { localMenus } from '../../router/views'
3
+
4
+ export interface EntityDefinition {
5
+ title: string
6
+ resource: string
7
+ permission: string
8
+ schemas: SchemaFormItem[]
9
+ columns: SchemaTableColumn[]
10
+ create?: boolean
11
+ remove?: boolean
12
+ }
13
+ const status: SchemaFormItem = {
14
+ field: 'status',
15
+ label: '状态',
16
+ component: 'select',
17
+ defaultValue: 'enabled',
18
+ options: [{ label: '启用', value: 'enabled' }, { label: '停用', value: 'disabled' }],
19
+ }
20
+ function input(field: string, label: string, required = true): SchemaFormItem {
21
+ return { field, label, required, component: 'input' }
22
+ }
23
+ function define(resource: string, permission: string, title: string, schemas: SchemaFormItem[]): EntityDefinition {
24
+ return {
25
+ title,
26
+ resource,
27
+ permission,
28
+ schemas,
29
+ columns: schemas.map(schema => ({
30
+ field: schema.field,
31
+ label: schema.label,
32
+ options: schema.component === 'select' && Array.isArray(schema.options) ? schema.options : undefined,
33
+ })),
34
+ }
35
+ }
36
+
37
+ // 保持应用侧字段契约;页面与 HTTP adapter 都不依赖演示应用源码。
38
+ export const entities: EntityDefinition[] = [
39
+ define('users', 'system:user', '用户管理', [
40
+ { ...input('username', '用户名'), editDisabled: true },
41
+ input('nickname', '昵称'),
42
+ input('phone', '手机号', false),
43
+ input('organizationId', '机构 ID', false),
44
+ { field: 'roles', label: '角色', component: 'select', componentProps: { multiple: true }, defaultValue: ['guest'], options: [{ label: '管理员', value: 'admin' }, { label: '运营', value: 'operator' }, { label: '访客', value: 'guest' }] },
45
+ status,
46
+ ]),
47
+ define('roles', 'system:role', '角色管理', [
48
+ input('name', '角色名称'),
49
+ { ...input('code', '角色编码'), editDisabled: true },
50
+ input('description', '说明', false),
51
+ status,
52
+ {
53
+ field: 'permissions',
54
+ label: '授权权限',
55
+ component: 'checkbox',
56
+ defaultValue: [],
57
+ options: [...new Set(localMenus.flatMap((menu) => {
58
+ const prefix = String(menu.meta?.permissionPrefix ?? '')
59
+ return prefix ? ['list', 'view', 'create', 'update', 'delete', 'authorize'].map(action => `${prefix}:${action}`) : []
60
+ }))].map(value => ({ label: value, value })),
61
+ },
62
+ ]),
63
+ define('menus', 'system:menu', '菜单管理', [
64
+ input('title', '标题'),
65
+ input('path', '路径'),
66
+ input('name', '路由名'),
67
+ { ...input('component', '组件白名单键'), required: ({ model }) => model.type === 'menu', hidden: ({ model }) => model.type !== 'menu' },
68
+ input('parentId', '父节点 ID', false),
69
+ input('permission', '权限标识', false),
70
+ { field: 'type', label: '类型', component: 'select', defaultValue: 'menu', options: [{ label: '目录', value: 'directory' }, { label: '页面', value: 'menu' }, { label: '按钮', value: 'button' }] },
71
+ { field: 'order', label: '排序', component: 'number', defaultValue: 0 },
72
+ { field: 'hidden', label: '隐藏菜单', component: 'switch', defaultValue: false },
73
+ ]),
74
+ define('organizations', 'system:organization', '机构管理', [input('name', '机构名称'), input('code', '机构编码'), input('parentId', '父机构 ID', false), input('leader', '负责人', false), status]),
75
+ define('dictionaries/types', 'system:dict', '字典管理', [input('name', '字典名称'), input('code', '字典编码'), input('description', '说明', false), status]),
76
+ define('dictionaries/items', 'system:dict', '字典项管理', [
77
+ input('typeCode', '字典编码'),
78
+ input('label', '显示名称'),
79
+ input('value', '值'),
80
+ input('color', '颜色', false),
81
+ { field: 'order', label: '排序', component: 'number', defaultValue: 0 },
82
+ status,
83
+ ]),
84
+ {
85
+ ...define('config', 'system:config', '系统配置', [
86
+ { ...input('key', '配置键'), disabled: true },
87
+ input('value', '配置值'),
88
+ input('description', '说明', false),
89
+ ]),
90
+ create: false,
91
+ remove: false,
92
+ },
93
+ ]
@@ -0,0 +1,28 @@
1
+ import type { LumalMenuRecord } from '@lumal/core/router'
2
+ import type { FeatureModule } from '../router/views'
3
+
4
+ const definitions = [
5
+ ['users', 'user', '用户管理'],
6
+ ['roles', 'role', '角色管理'],
7
+ ['menus', 'menu', '菜单管理'],
8
+ ['organizations', 'organization', '机构管理'],
9
+ ['dictionaries/types', 'dict', '字典管理'],
10
+ ['dictionaries/items', 'dict', '字典项管理'],
11
+ ['config', 'config', '系统配置'],
12
+ ]
13
+ export const menus: LumalMenuRecord[] = definitions.map(([resource, code, title]) => ({
14
+ component: `system/${resource}`,
15
+ name: `System-${resource!.replaceAll('/', '-')}`,
16
+ path: `/system/${resource}`,
17
+ meta: {
18
+ title,
19
+ resource,
20
+ permissionPrefix: `system:${code}`,
21
+ authority: [`system:${code}:${code === 'config' ? 'view' : 'list'}`],
22
+ keepAlive: true,
23
+ },
24
+ }))
25
+ export const views: FeatureModule['views'] = Object.fromEntries(definitions.map(([resource]) => [
26
+ `system/${resource}`,
27
+ () => import('../views/system/SystemEntityView.vue'),
28
+ ]))
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import type { CrudDataSource, SchemaFormItem, SchemaFormOption, SchemaTableRow } from '@lumal/core/components'
3
+ import { LumalCrudTable } from '@lumal/core/components'
4
+ import { ElAlert } from 'element-plus'
5
+ import { computed, shallowRef, useTemplateRef, watch } from 'vue'
6
+ import { useRoute } from 'vue-router'
7
+ import { entities } from '../../business/system/entities'
8
+ import { permissionStore, service } from '../../services/session'
9
+
10
+ /***********************业务模块配置*********************/
11
+ const route = useRoute()
12
+ // RouterView 按路径缓存实例;离开页面后不能读取新路由并切换本实例的数据源。
13
+ const entity = entities.find(item => `/system/${item.resource}` === route.path)
14
+ if (!entity)
15
+ throw new Error('未注册的系统模块')
16
+ const tableRef = useTemplateRef<InstanceType<typeof LumalCrudTable>>('tableRef')
17
+ const can = (action: string) => permissionStore.hasPermission(`${entity.permission}:${action}`)
18
+ const table = computed(() => ({ columns: entity.columns, showColumnSettings: true, rowKey: 'id', selection: true }))
19
+ const actions = computed(() => ({ view: true, edit: can('update'), remove: entity.remove !== false && can('delete') }))
20
+ const toolbar = computed(() => ({ create: entity.create !== false && can('create'), batchDelete: false, refresh: true, export: true }))
21
+ const querySchemas = computed(() => [
22
+ { field: 'keyword', label: '关键词', component: 'input' as const },
23
+ ...(entity.resource === 'dictionaries/items' ? [{ field: 'typeCode', label: '字典编码', component: 'input' as const }] : []),
24
+ ])
25
+ const operationError = shallowRef('')
26
+ function handleOperationError(error: unknown): void {
27
+ operationError.value = error instanceof Error ? error.message : String(error)
28
+ }
29
+
30
+ /***********************业务引用数据*********************/
31
+ const roleOptions = shallowRef<SchemaFormOption[]>()
32
+ const schemas = computed<SchemaFormItem[]>(() => entity.schemas.map(schema =>
33
+ schema.field === 'roles' && roleOptions.value ? { ...schema, options: roleOptions.value } : schema))
34
+ watch(() => entity.resource, async (resource, _previous, onCleanup) => {
35
+ let active = true
36
+ onCleanup(() => {
37
+ active = false
38
+ })
39
+ if (resource !== 'users' || !permissionStore.hasPermission('system:role:list'))
40
+ return
41
+ try {
42
+ const result = await service.list('roles', { page: 1, pageSize: 1000, query: {} })
43
+ if (active)
44
+ roleOptions.value = result.items.filter(row => row.status !== 'disabled').map(row => ({ label: String(row.name ?? row.code), value: String(row.code) }))
45
+ }
46
+ catch (error) {
47
+ // 引用数据失败应进入应用错误边界,不能把加载失败伪装成空角色。
48
+ if (active)
49
+ throw error
50
+ }
51
+ }, { immediate: true })
52
+
53
+ /***********************真实应用数据源*********************/
54
+ function id(row: SchemaTableRow): string {
55
+ if (typeof row.id !== 'string' && typeof row.id !== 'number')
56
+ throw new Error('业务记录缺少主键 id')
57
+ return String(row.id)
58
+ }
59
+ const dataSource: CrudDataSource = {
60
+ fetch: input => service.list(entity.resource, input),
61
+ create: model => service.save(entity.resource, model),
62
+ update: (row, model) => service.save(entity.resource, model, id(row)),
63
+ remove: row => service.remove(entity.resource, id(row)),
64
+ }
65
+ defineExpose({ reload: () => tableRef.value?.reload() })
66
+ </script>
67
+
68
+ <template>
69
+ <main class="lumal-admin-page">
70
+ <ElAlert v-if="operationError" :title="operationError" type="error" role="alert" closable @close="operationError = ''" />
71
+ <LumalCrudTable
72
+ ref="tableRef"
73
+ :key="entity.resource"
74
+ :title="entity.title"
75
+ :table="table"
76
+ :form-schemas="schemas"
77
+ :data-source="dataSource"
78
+ :query="{ schemas: querySchemas, columns: 3 }"
79
+ :actions="actions"
80
+ :toolbar="toolbar"
81
+ :editor="{ type: 'dialog', columns: 2 }"
82
+ @operation-error="handleOperationError"
83
+ />
84
+ </main>
85
+ </template>
@@ -0,0 +1,120 @@
1
+ import type { ApplicationService, ServiceContext } from './contracts'
2
+ import { createRequestClient, RequestError } from '@lumal/core/request'
3
+ import { createHttpAdapter } from './http-adapter'
4
+
5
+ export const demoAccount: { username: string, password: string } | null = null
6
+
7
+ export function createHttpBackend(context: ServiceContext, baseURL: string, adapterName = 'standard'): ApplicationService {
8
+ const adapter = createHttpAdapter(adapterName)
9
+ const publicRequest = createRequestClient({
10
+ baseURL,
11
+ onRequest() {
12
+ if (!baseURL.trim())
13
+ throw new Error('未配置 VITE_API_BASE_URL,请先接入真实后端')
14
+ },
15
+ onResponse: <T>({ data, init }: { data: unknown, init: RequestInit }) => {
16
+ init.signal?.throwIfAborted()
17
+ return adapter.parseResponse(data) as T
18
+ },
19
+ })
20
+ const request = createRequestClient({
21
+ baseURL,
22
+ duplicateSubmit: true,
23
+ getToken: () => context.getSession()?.accessToken,
24
+ onRequest({ init }) {
25
+ if (!baseURL.trim())
26
+ throw new Error('未配置 VITE_API_BASE_URL,请先接入真实后端')
27
+ init.signal = init.signal ? AbortSignal.any([init.signal, context.getSignal()]) : context.getSignal()
28
+ const token = context.getSession()?.accessToken
29
+ if (!token || new Headers(init.headers).get('Authorization') !== `Bearer ${token}`)
30
+ throw new RequestError('会话已切换', { kind: 'cancelled' })
31
+ },
32
+ onResponse: <T>({ data, init }: { data: unknown, init: RequestInit }) => {
33
+ init.signal?.throwIfAborted()
34
+ return adapter.parseResponse(data) as T
35
+ },
36
+ authRefresh: {
37
+ async refresh({ init }) {
38
+ const refreshToken = context.getSession()?.refreshToken
39
+ if (!refreshToken)
40
+ throw new Error('缺少刷新凭据')
41
+ const result = await publicRequest.post('/auth/refresh', { body: { refreshToken }, signal: init.signal ?? undefined })
42
+ init.signal?.throwIfAborted()
43
+ if (context.getSession()?.refreshToken !== refreshToken)
44
+ throw new DOMException('会话已切换', 'AbortError')
45
+ context.setSession(adapter.parseSession(result))
46
+ },
47
+ },
48
+ onSessionExpired: context.expire,
49
+ })
50
+ return {
51
+ login: async (input, signal) => adapter.parseSession(await publicRequest.post('/auth/login', { body: { ...input }, signal })),
52
+ getUser: async signal => adapter.parseUser(await request.get('/session/me', { signal })),
53
+ getAccess: async signal => adapter.parseAccess(await request.get('/session/access', { signal })),
54
+ getMenus: async signal => adapter.parseMenus(await request.get(adapter.menusPath, { signal })),
55
+ logout: async (accessToken) => {
56
+ await publicRequest.post('/auth/logout', { headers: { Authorization: `Bearer ${accessToken}` } })
57
+ },
58
+ clearCache() {
59
+ publicRequest.clearCache()
60
+ request.clearCache()
61
+ },
62
+ async list(resource, input) {
63
+ const result = await request.get(`/system/${resource}`, {
64
+ query: { page: input.page, pageSize: input.pageSize, ...input.query } as Record<string, string | number>,
65
+ })
66
+ if (resource === 'config' && adapter.configMode === 'object') {
67
+ if (!result || typeof result !== 'object' || Array.isArray(result))
68
+ throw new Error('系统配置响应必须是对象')
69
+ const items = Object.entries(result).map(([key, value]) => ({ id: key, key, value: String(value) }))
70
+ return { items, total: items.length }
71
+ }
72
+ // Mock 的树形 / 字典接口不分页,显式在应用 adapter 边界转换。
73
+ if (adapterName === 'lumal-mock' && Array.isArray(result)) {
74
+ const flatten = (rows: Record<string, unknown>[]): Record<string, unknown>[] => rows.flatMap(row => [
75
+ { ...row, children: undefined },
76
+ ...(Array.isArray(row.children) ? flatten(row.children) : []),
77
+ ])
78
+ const items = flatten(result)
79
+ const keyword = String(input.query.keyword ?? '').trim()
80
+ const filtered = items.filter(row => !keyword || Object.values(row).some(value => String(value).includes(keyword)))
81
+ return { items: filtered.slice((input.page - 1) * input.pageSize, input.page * input.pageSize), total: filtered.length }
82
+ }
83
+ const page = adapter.parsePage(result)
84
+ if (resource === 'roles' && adapterName === 'lumal-mock') {
85
+ page.items = await Promise.all(page.items.map(async row => ({
86
+ ...row,
87
+ permissions: await request.get(`/system/roles/${encodeURIComponent(String(row.code))}/permissions`),
88
+ })))
89
+ }
90
+ return page
91
+ },
92
+ async save(resource, model, id) {
93
+ if (resource === 'config' && adapter.configMode === 'object') {
94
+ if (!id)
95
+ throw new Error('系统配置只允许更新已存在的配置键')
96
+ const current = await request.get<Record<string, unknown>>('/system/config')
97
+ if (!(id in current))
98
+ throw new Error('配置键不存在')
99
+ const original = current[id]
100
+ const value = typeof original === 'boolean'
101
+ ? String(model.value) === 'true'
102
+ : typeof original === 'number' ? Number(model.value) : String(model.value ?? '')
103
+ return request.put('/system/config', { body: { [id]: value } })
104
+ }
105
+ const saved = id
106
+ ? await request.put(`/system/${resource}/${encodeURIComponent(id)}`, { body: model })
107
+ : await request.post(`/system/${resource}`, { body: model })
108
+ if (resource === 'roles' && adapterName === 'lumal-mock' && Array.isArray(model.permissions))
109
+ await request.put(`/system/roles/${encodeURIComponent(String(model.code))}/permissions`, { body: { permissions: model.permissions } })
110
+ return saved
111
+ },
112
+ remove: async (resource, id) => {
113
+ await request.delete(`/system/${resource}/${encodeURIComponent(id)}`)
114
+ },
115
+ }
116
+ }
117
+
118
+ export function createBackend(context: ServiceContext): ApplicationService {
119
+ return createHttpBackend(context, import.meta.env.VITE_API_BASE_URL ?? '', import.meta.env.VITE_API_ADAPTER || 'standard')
120
+ }
@@ -0,0 +1,124 @@
1
+ import type { AuthSessionData } from '@lumal/core/auth'
2
+ import type { LumalMenuRecord } from '@lumal/core/router'
3
+ import type { AccessSnapshot, CurrentUser, PageResult } from './contracts'
4
+ import { parseAuthSession } from '@lumal/core/auth'
5
+ import { createStandardResponseParser, parsePageResult } from '@lumal/core/request'
6
+ import { localMenus, viewComponents } from '../router/views'
7
+
8
+ export interface HttpAdapter {
9
+ parseResponse: (data: unknown) => unknown
10
+ parseSession: (data: unknown) => AuthSessionData
11
+ parseUser: (data: unknown) => CurrentUser
12
+ parseAccess: (data: unknown) => AccessSnapshot
13
+ parseMenus: (data: unknown) => LumalMenuRecord[]
14
+ parsePage: (data: unknown) => PageResult
15
+ menusPath: string
16
+ configMode?: 'object'
17
+ }
18
+
19
+ function record(value: unknown): Record<string, unknown> {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value))
21
+ throw new Error('后端返回了无效对象')
22
+ return value as Record<string, unknown>
23
+ }
24
+ function strings(value: unknown): string[] {
25
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string'))
26
+ throw new Error('角色和权限必须是字符串数组')
27
+ return value
28
+ }
29
+ function parseUser(value: unknown): CurrentUser {
30
+ const raw = record(value)
31
+ if (!['id', 'name', 'username'].every(key => typeof raw[key] === 'string' && raw[key]))
32
+ throw new Error('当前用户缺少 id/name/username')
33
+ return { id: String(raw.id), name: String(raw.name), username: String(raw.username) }
34
+ }
35
+ function parseAccess(value: unknown): AccessSnapshot {
36
+ const raw = record(value)
37
+ return { roles: strings(raw.roles), permissions: strings(raw.permissions) }
38
+ }
39
+ function parseMenus(value: unknown): LumalMenuRecord[] {
40
+ if (!Array.isArray(value))
41
+ throw new Error('菜单响应必须是数组')
42
+ const visit = (items: unknown[]): LumalMenuRecord[] => items.map((item) => {
43
+ const raw = record(item)
44
+ if (typeof raw.path !== 'string' || !raw.path.startsWith('/'))
45
+ throw new Error('菜单 path 必须是站内绝对路径')
46
+ if (raw.component && (typeof raw.component !== 'string' || !viewComponents[raw.component]))
47
+ throw new Error(`菜单组件不在白名单中:${String(raw.component)}`)
48
+ return {
49
+ path: raw.path,
50
+ name: typeof raw.name === 'string' ? raw.name : undefined,
51
+ component: typeof raw.component === 'string' ? raw.component : undefined,
52
+ redirect: typeof raw.redirect === 'string' ? raw.redirect : undefined,
53
+ meta: raw.meta ? record(raw.meta) : undefined,
54
+ children: Array.isArray(raw.children) ? visit(raw.children) : undefined,
55
+ }
56
+ })
57
+ return visit(value)
58
+ }
59
+
60
+ // 默认直接消费标准 JSON。特定后端包装仅在显式选择的应用 adapter 中处理。
61
+ export function createHttpAdapter(name = 'standard'): HttpAdapter {
62
+ const standard: HttpAdapter = {
63
+ parseResponse: value => value,
64
+ parseSession: parseAuthSession,
65
+ parseUser,
66
+ parseAccess,
67
+ parseMenus,
68
+ parsePage: value => Array.isArray(value) ? { items: value, total: value.length } : parsePageResult(value),
69
+ menusPath: '/menus',
70
+ }
71
+ if (name === 'standard')
72
+ return standard
73
+ if (name !== 'lumal-mock')
74
+ throw new Error(`未知 HTTP adapter:${name}`)
75
+ const unwrap = createStandardResponseParser({
76
+ fieldNames: { code: 'statusCode', data: 'result', message: 'resultMsg' },
77
+ successCodes: ['0000'],
78
+ sessionExpiredCodes: ['AUTH_EXPIRED'],
79
+ })
80
+ return {
81
+ ...standard,
82
+ // Lumal Mock 是显式接入示例,不能影响标准模式的协议。
83
+ parseResponse: data => unwrap({ data } as Parameters<typeof unwrap>[0]),
84
+ parseSession: value => parseAuthSession(value, { fieldNames: { accessToken: 'access_token', refreshToken: 'refresh_token', expiresAt: 'expire_time' } }),
85
+ parsePage: value => Array.isArray(value) ? { items: value, total: value.length } : parsePageResult(value, { fieldNames: { items: 'records', total: 'totalNum' } }),
86
+ menusPath: '/menu',
87
+ configMode: 'object',
88
+ parseAccess(value) {
89
+ const access = parseAccess(value)
90
+ // 此后端以 view 权限管理配置更新;只在显式 adapter 中映射应用权限。
91
+ return {
92
+ ...access,
93
+ permissions: access.permissions.includes('system:config:view')
94
+ ? [...new Set([...access.permissions, 'system:config:update'])]
95
+ : access.permissions,
96
+ }
97
+ },
98
+ parseMenus(value) {
99
+ // 参考 Mock 包含图表等额外演示页面,只映射当前预设声明的页面。
100
+ if (!Array.isArray(value))
101
+ throw new Error('Mock 菜单响应必须是数组')
102
+ const supported = new Map(localMenus.map(menu => [menu.component, menu]))
103
+ const visit = (items: unknown[]): LumalMenuRecord[] => items.flatMap((item) => {
104
+ const raw = record(item)
105
+ const component = raw.viewPath === 'system/dict' ? 'system/dictionaries/types' : String(raw.viewPath ?? '')
106
+ const selected = supported.get(component)
107
+ return [
108
+ ...(selected
109
+ ? [{
110
+ ...selected,
111
+ meta: {
112
+ ...selected.meta,
113
+ title: String(raw.menuName ?? selected.meta?.title),
114
+ authority: strings(raw.authCode ?? []),
115
+ },
116
+ }]
117
+ : []),
118
+ ...(Array.isArray(raw.nodes) ? visit(raw.nodes) : []),
119
+ ]
120
+ })
121
+ return visit(value)
122
+ },
123
+ }
124
+ }