create-lumal-admin 0.0.4-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.
- package/CHANGELOG.md +12 -0
- package/README.md +67 -10
- package/dist/cli.d.ts +12 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +91 -28
- package/dist/cli.js.map +1 -1
- package/dist/create.d.ts +2 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +85 -2
- package/dist/create.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/presets.d.ts +15 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +30 -0
- package/dist/presets.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 +5 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +62 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +25 -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 +4 -3
- package/templates/base/.env.example +4 -0
- package/templates/base/API-CONTRACT.md +37 -0
- package/templates/base/README.md +42 -0
- package/templates/base/_gitignore +6 -0
- package/templates/base/eslint.config.mjs +8 -0
- package/templates/base/index.html +12 -0
- package/templates/base/package.json +38 -0
- package/templates/base/src/App.vue +214 -0
- package/templates/base/src/components/app/AppHeaderActions.vue +34 -0
- package/templates/base/src/components/app/AppSettingsDrawer.vue +37 -0
- package/templates/base/src/main.ts +41 -0
- package/templates/base/src/router/index.ts +86 -0
- package/templates/base/src/router/views.ts +16 -0
- package/templates/base/src/services/access.ts +2 -0
- package/templates/base/src/services/contracts.ts +29 -0
- package/templates/base/src/services/preferences.ts +41 -0
- package/templates/base/src/services/session-runtime.ts +137 -0
- package/templates/base/src/services/session.ts +14 -0
- package/templates/base/src/styles.scss +178 -0
- package/templates/base/src/views/auth/LoginView.vue +80 -0
- package/templates/base/src/views/dashboard/DashboardView.vue +27 -0
- package/templates/base/src/views/error/ForbiddenView.vue +25 -0
- package/templates/base/src/views/error/NotFoundView.vue +18 -0
- package/templates/base/tests/session.test.ts.template +34 -0
- package/templates/base/tsconfig.json +22 -0
- package/templates/base/vite.config.ts +54 -0
- package/templates/base/vitest.config.ts +3 -0
- package/templates/features/examples/src/modules/examples.ts +10 -0
- package/templates/features/examples/src/views/examples/CrudExampleView.vue +30 -0
- package/templates/features/examples/src/views/examples/FormExampleView.vue +25 -0
- package/templates/features/system/src/business/system/entities.ts +93 -0
- package/templates/features/system/src/modules/system.ts +28 -0
- package/templates/features/system/src/views/system/SystemEntityView.vue +85 -0
- package/templates/modes/http/src/services/data-source.ts +120 -0
- package/templates/modes/http/src/services/http-adapter.ts +124 -0
- package/templates/modes/http/tests/http.test.ts.template +165 -0
- package/templates/modes/local/src/services/data-source.ts +176 -0
- package/templates/modes/local/tests/local.test.ts.template +92 -0
- package/templates/vben-compatible/MIGRATION.md +28 -0
- package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +34 -0
- package/templates/vben-compatible/src/components/migration/VbenUsers.vue +135 -0
- package/templates/vben-compatible/src/modules/vben.ts +17 -0
- package/templates/vben-compatible/src/views/migration/VbenFormView.vue +10 -0
- package/templates/vben-compatible/src/views/migration/VbenUsersView.vue +11 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import type { AddressInfo } from 'node:net'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { createHttpBackend } from '../src/services/data-source'
|
|
7
|
+
import { createApplicationSession } from '../src/services/session-runtime'
|
|
8
|
+
|
|
9
|
+
const tokens = new Map<string, string>()
|
|
10
|
+
const refreshTokens = new Map<string, string>()
|
|
11
|
+
const expired = new Set<string>()
|
|
12
|
+
const calls = new Map<string, number>()
|
|
13
|
+
let failRefresh = false
|
|
14
|
+
let failLogout = false
|
|
15
|
+
let baseURL = ''
|
|
16
|
+
const server = createServer(async (request, response) => {
|
|
17
|
+
const path = new URL(request.url!, 'http://localhost').pathname
|
|
18
|
+
calls.set(path, (calls.get(path) ?? 0) + 1)
|
|
19
|
+
let body = ''
|
|
20
|
+
for await (const part of request)
|
|
21
|
+
body += String(part)
|
|
22
|
+
const input = body ? JSON.parse(body) as Record<string, string> : {}
|
|
23
|
+
const token = request.headers.authorization?.replace('Bearer ', '') ?? ''
|
|
24
|
+
function send(data: unknown, status = 200) {
|
|
25
|
+
response.writeHead(status, { 'content-type': 'application/json' })
|
|
26
|
+
response.end(JSON.stringify(data))
|
|
27
|
+
}
|
|
28
|
+
function issue(username: string) {
|
|
29
|
+
const accessToken = crypto.randomUUID()
|
|
30
|
+
const refreshToken = crypto.randomUUID()
|
|
31
|
+
tokens.set(accessToken, username)
|
|
32
|
+
refreshTokens.set(refreshToken, username)
|
|
33
|
+
return { accessToken, refreshToken }
|
|
34
|
+
}
|
|
35
|
+
if (path === '/auth/login') {
|
|
36
|
+
if (input.password !== 'valid')
|
|
37
|
+
return send({ message: '登录失败' }, 401)
|
|
38
|
+
return send(issue(input.username!))
|
|
39
|
+
}
|
|
40
|
+
if (path === '/auth/refresh') {
|
|
41
|
+
await delay(40)
|
|
42
|
+
const username = refreshTokens.get(input.refreshToken!)
|
|
43
|
+
return !failRefresh && username ? send(issue(username)) : send({ message: '刷新失败' }, 401)
|
|
44
|
+
}
|
|
45
|
+
const username = tokens.get(token)
|
|
46
|
+
if (!username || expired.has(token))
|
|
47
|
+
return send({ message: '已过期' }, 401)
|
|
48
|
+
if (path === '/session/me')
|
|
49
|
+
return send({ id: username, name: username, username })
|
|
50
|
+
if (path === '/session/access')
|
|
51
|
+
return send({ roles: [username], permissions: [`${username}:read`] })
|
|
52
|
+
if (path === '/menus')
|
|
53
|
+
return send([{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }])
|
|
54
|
+
if (path === '/auth/logout') {
|
|
55
|
+
tokens.delete(token)
|
|
56
|
+
return send({}, failLogout ? 503 : 200)
|
|
57
|
+
}
|
|
58
|
+
if (path.startsWith('/system/'))
|
|
59
|
+
return send({ items: [{ id: '1', owner: username }], total: 1 })
|
|
60
|
+
send({}, 404)
|
|
61
|
+
})
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
|
64
|
+
baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
|
65
|
+
})
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
calls.clear()
|
|
68
|
+
tokens.clear()
|
|
69
|
+
refreshTokens.clear()
|
|
70
|
+
expired.clear()
|
|
71
|
+
failRefresh = false
|
|
72
|
+
failLogout = false
|
|
73
|
+
})
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
server.closeAllConnections()
|
|
76
|
+
await new Promise<void>((done, reject) => server.close(error => error ? reject(error) : done()))
|
|
77
|
+
})
|
|
78
|
+
function memoryStorage(): Storage {
|
|
79
|
+
const values = new Map<string, string>()
|
|
80
|
+
return {
|
|
81
|
+
getItem: key => values.get(key) ?? null,
|
|
82
|
+
setItem: (key, value) => { values.set(key, value) },
|
|
83
|
+
removeItem: (key) => { values.delete(key) },
|
|
84
|
+
clear: () => values.clear(),
|
|
85
|
+
key: index => [...values.keys()][index] ?? null,
|
|
86
|
+
get length() { return values.size },
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function application(url = baseURL) {
|
|
90
|
+
const storage = memoryStorage()
|
|
91
|
+
return createApplicationSession(context => createHttpBackend(context, url), storage, storage)
|
|
92
|
+
}
|
|
93
|
+
describe('真实 HTTP 应用服务', () => {
|
|
94
|
+
it('配置缺失明确失败,不产生演示会话', async () => {
|
|
95
|
+
const app = application('')
|
|
96
|
+
await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow('VITE_API_BASE_URL')
|
|
97
|
+
expect(app.isAuthenticated()).toBe(false)
|
|
98
|
+
expect(calls.size).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
it('登录失败、成功、用户权限菜单与真实 CRUD 请求', async () => {
|
|
101
|
+
const app = application()
|
|
102
|
+
await expect(app.login({ username: 'alice', password: 'wrong' })).rejects.toThrow()
|
|
103
|
+
expect(app.currentUser.value).toBeNull()
|
|
104
|
+
await app.login({ username: 'alice', password: 'valid' })
|
|
105
|
+
expect(app.ready.value).toBe(true)
|
|
106
|
+
expect(app.currentUser.value?.username).toBe('alice')
|
|
107
|
+
expect(app.permissionStore.permissions).toEqual(['alice:read'])
|
|
108
|
+
expect(app.permissionStore.roles).toEqual(['alice'])
|
|
109
|
+
expect(app.menus.value[0]?.path).toBe('/deep')
|
|
110
|
+
expect(await app.service.list('users', { page: 1, pageSize: 10, query: {} })).toEqual({ items: [{ id: '1', owner: 'alice' }], total: 1 })
|
|
111
|
+
await Promise.all([app.initialize(), app.initialize()])
|
|
112
|
+
expect(calls.get('/session/me')).toBe(1)
|
|
113
|
+
})
|
|
114
|
+
it('401 并发请求只刷新一次,重放使用新凭据', async () => {
|
|
115
|
+
const app = application()
|
|
116
|
+
await app.login({ username: 'alice', password: 'valid' })
|
|
117
|
+
expired.add(app.session.getToken()!)
|
|
118
|
+
const signal = new AbortController().signal
|
|
119
|
+
const [user, access, menus] = await Promise.all([app.service.getUser(signal), app.service.getAccess(signal), app.service.getMenus(signal)])
|
|
120
|
+
expect(user.username).toBe('alice')
|
|
121
|
+
expect(access.permissions).toEqual(['alice:read'])
|
|
122
|
+
expect(menus[0]?.path).toBe('/deep')
|
|
123
|
+
expect(calls.get('/auth/refresh')).toBe(1)
|
|
124
|
+
expect(calls.get('/session/me')).toBe(3)
|
|
125
|
+
})
|
|
126
|
+
it('刷新失败清理凭据、权限、菜单、标签和动态状态回调', async () => {
|
|
127
|
+
const app = application()
|
|
128
|
+
await app.login({ username: 'alice', password: 'valid' })
|
|
129
|
+
let resets = 0
|
|
130
|
+
app.onReset(() => {
|
|
131
|
+
resets += 1
|
|
132
|
+
})
|
|
133
|
+
expired.add(app.session.getToken()!)
|
|
134
|
+
failRefresh = true
|
|
135
|
+
await expect(app.service.getUser(new AbortController().signal)).rejects.toThrow()
|
|
136
|
+
expect(app.isAuthenticated()).toBe(false)
|
|
137
|
+
expect(app.currentUser.value).toBeNull()
|
|
138
|
+
expect(app.menus.value).toEqual([])
|
|
139
|
+
expect(app.permissionStore.permissions).toEqual([])
|
|
140
|
+
expect(resets).toBe(1)
|
|
141
|
+
})
|
|
142
|
+
it('退出接口失败不保留本地身份', async () => {
|
|
143
|
+
const app = application()
|
|
144
|
+
await app.login({ username: 'alice', password: 'valid' })
|
|
145
|
+
failLogout = true
|
|
146
|
+
await expect(app.logout()).rejects.toThrow()
|
|
147
|
+
expect(app.isAuthenticated()).toBe(false)
|
|
148
|
+
expect(app.permissionStore.roles).toEqual([])
|
|
149
|
+
expect(calls.get('/auth/logout')).toBe(1)
|
|
150
|
+
})
|
|
151
|
+
it('刷新在途时切换账号,旧响应不能污染新用户', async () => {
|
|
152
|
+
const app = application()
|
|
153
|
+
await app.login({ username: 'alice', password: 'valid' })
|
|
154
|
+
expired.add(app.session.getToken()!)
|
|
155
|
+
const old = app.service.getUser(new AbortController().signal).catch(error => error)
|
|
156
|
+
while (!calls.get('/auth/refresh'))
|
|
157
|
+
await delay(1)
|
|
158
|
+
await app.login({ username: 'bob', password: 'valid' })
|
|
159
|
+
await old
|
|
160
|
+
await delay(50)
|
|
161
|
+
expect(app.currentUser.value?.username).toBe('bob')
|
|
162
|
+
expect(app.permissionStore.permissions).toEqual(['bob:read'])
|
|
163
|
+
expect(app.ready.value).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router'
|
|
2
|
+
import type { AccessSnapshot, ApplicationService, CurrentUser, DataRow, ServiceContext } from './contracts'
|
|
3
|
+
import { localMenus } from '../router/views'
|
|
4
|
+
|
|
5
|
+
export const demoAccount = import.meta.env.DEV ? { username: 'admin', password: 'lumal123' } : null
|
|
6
|
+
|
|
7
|
+
// 本地演示数据独立于 HTTP 模板,生产构建默认拒绝演示认证。
|
|
8
|
+
export function createBackend(context: ServiceContext): ApplicationService {
|
|
9
|
+
const permissions = [...new Set(localMenus.flatMap((menu) => {
|
|
10
|
+
const prefix = String(menu.meta?.permissionPrefix ?? '')
|
|
11
|
+
return prefix ? ['list', 'view', 'create', 'update', 'delete', 'authorize'].map(action => `${prefix}:${action}`) : []
|
|
12
|
+
}))]
|
|
13
|
+
const initial: Record<string, DataRow[]> = {
|
|
14
|
+
users: [
|
|
15
|
+
{ id: 'admin', username: 'admin', nickname: '管理员', roles: ['admin'], status: 'enabled' },
|
|
16
|
+
{ id: 'guest', username: 'guest', nickname: '访客', roles: ['guest'], status: 'enabled' },
|
|
17
|
+
],
|
|
18
|
+
roles: [
|
|
19
|
+
{ id: 'admin', code: 'admin', name: '管理员', status: 'enabled', permissions },
|
|
20
|
+
{ id: 'guest', code: 'guest', name: '访客', status: 'enabled', permissions: [] },
|
|
21
|
+
],
|
|
22
|
+
menus: localMenus.map(menu => ({
|
|
23
|
+
id: menu.name ?? menu.path,
|
|
24
|
+
title: menu.meta?.title,
|
|
25
|
+
path: menu.path,
|
|
26
|
+
name: menu.name,
|
|
27
|
+
component: menu.component,
|
|
28
|
+
permission: menu.meta?.authority,
|
|
29
|
+
type: 'menu',
|
|
30
|
+
hidden: false,
|
|
31
|
+
})),
|
|
32
|
+
config: [{ id: 'appName', key: 'appName', value: 'Lumal Admin' }, { id: 'pageSize', key: 'pageSize', value: '10' }],
|
|
33
|
+
}
|
|
34
|
+
function readRows(resource: string): DataRow[] {
|
|
35
|
+
const value = localStorage.getItem(`demo:system:${resource}`)
|
|
36
|
+
return value ? JSON.parse(value) as DataRow[] : structuredClone(initial[resource] ?? [])
|
|
37
|
+
}
|
|
38
|
+
function assertDemo(): void {
|
|
39
|
+
if (import.meta.env.PROD && import.meta.env.VITE_ENABLE_LOCAL_DEMO !== 'true')
|
|
40
|
+
throw new Error('生产环境已禁用本地演示,请使用 HTTP 模式或显式启用演示')
|
|
41
|
+
}
|
|
42
|
+
function getUser(): CurrentUser {
|
|
43
|
+
assertDemo()
|
|
44
|
+
const raw = sessionStorage.getItem(`demo:${context.getSession()?.accessToken ?? ''}`)
|
|
45
|
+
if (!raw)
|
|
46
|
+
throw new Error('本地演示会话已失效')
|
|
47
|
+
return JSON.parse(raw) as CurrentUser
|
|
48
|
+
}
|
|
49
|
+
function getAccess(): AccessSnapshot {
|
|
50
|
+
const user = getUser()
|
|
51
|
+
const record = readRows('users').find(row => row.username === user.username)
|
|
52
|
+
if (!record || record.status === 'disabled')
|
|
53
|
+
throw new Error('本地账号已停用或删除,请重新登录')
|
|
54
|
+
const roles = Array.isArray(record.roles) ? record.roles.map(String) : []
|
|
55
|
+
return {
|
|
56
|
+
roles,
|
|
57
|
+
permissions: [...new Set(readRows('roles').filter(role => roles.includes(String(role.code)) && role.status !== 'disabled').flatMap(role => Array.isArray(role.permissions) ? role.permissions.map(String) : []))],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeRows(resource: string, rows: DataRow[]): void {
|
|
61
|
+
localStorage.setItem(`demo:system:${resource}`, JSON.stringify(rows))
|
|
62
|
+
}
|
|
63
|
+
function assertWrite(resource: string, action: string): void {
|
|
64
|
+
const menu = localMenus.find(menu => menu.meta?.resource === resource)
|
|
65
|
+
const prefix = String(menu?.meta?.permissionPrefix ?? '')
|
|
66
|
+
if (!prefix || !getAccess().permissions.includes(`${prefix}:${action}`))
|
|
67
|
+
throw new Error('无操作权限')
|
|
68
|
+
}
|
|
69
|
+
function buildMenus(rows: DataRow[]): LumalMenuRecord[] {
|
|
70
|
+
const active = new Set<string>()
|
|
71
|
+
const identities = new Set(rows.map(row => String(row.id)))
|
|
72
|
+
const visit = (parentId = ''): LumalMenuRecord[] => rows
|
|
73
|
+
.filter(row => String(row.parentId ?? '') === parentId && row.type !== 'button')
|
|
74
|
+
.sort((left, right) => Number(left.order ?? 0) - Number(right.order ?? 0))
|
|
75
|
+
.map((row) => {
|
|
76
|
+
const id = String(row.id)
|
|
77
|
+
if (active.has(id))
|
|
78
|
+
throw new Error('菜单存在循环父节点')
|
|
79
|
+
active.add(id)
|
|
80
|
+
const original = localMenus.find(menu => menu.component === row.component)
|
|
81
|
+
if (row.type !== 'directory' && !original)
|
|
82
|
+
throw new Error(`菜单组件不在预设白名单中:${String(row.component)}`)
|
|
83
|
+
const children = visit(id)
|
|
84
|
+
active.delete(id)
|
|
85
|
+
return {
|
|
86
|
+
path: String(row.path),
|
|
87
|
+
name: String(row.name),
|
|
88
|
+
component: row.type === 'directory' ? undefined : original?.component,
|
|
89
|
+
children,
|
|
90
|
+
meta: { ...original?.meta, title: String(row.title), hideInMenu: Boolean(row.hidden), authority: row.permission as string | string[], order: Number(row.order ?? 0) },
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const parents = new Set([String(row.id)])
|
|
95
|
+
let parent = String(row.parentId ?? '')
|
|
96
|
+
while (parent) {
|
|
97
|
+
if (parents.has(parent) || !identities.has(parent))
|
|
98
|
+
throw new Error('菜单父节点不存在或形成循环')
|
|
99
|
+
parents.add(parent)
|
|
100
|
+
parent = String(rows.find(item => String(item.id) === parent)?.parentId ?? '')
|
|
101
|
+
}
|
|
102
|
+
if (typeof row.path !== 'string' || !row.path.startsWith('/') || !row.name)
|
|
103
|
+
throw new Error('菜单必须配置绝对路径与唯一名称')
|
|
104
|
+
}
|
|
105
|
+
return visit()
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
async login(input, signal) {
|
|
109
|
+
assertDemo()
|
|
110
|
+
signal.throwIfAborted()
|
|
111
|
+
const account = readRows('users').find(row => row.username === input.username)
|
|
112
|
+
if (!account || account.status === 'disabled' || input.password !== 'lumal123')
|
|
113
|
+
throw new Error('账号或密码不正确')
|
|
114
|
+
const accessToken = crypto.randomUUID()
|
|
115
|
+
sessionStorage.setItem(`demo:${accessToken}`, JSON.stringify({ id: input.username, name: input.username, username: input.username }))
|
|
116
|
+
return { accessToken }
|
|
117
|
+
},
|
|
118
|
+
getUser: async (signal) => {
|
|
119
|
+
signal.throwIfAborted()
|
|
120
|
+
return getUser()
|
|
121
|
+
},
|
|
122
|
+
getAccess: async (signal) => {
|
|
123
|
+
signal.throwIfAborted()
|
|
124
|
+
return getAccess()
|
|
125
|
+
},
|
|
126
|
+
getMenus: async (signal) => {
|
|
127
|
+
signal.throwIfAborted()
|
|
128
|
+
getUser()
|
|
129
|
+
// 只解析本预设组件白名单;菜单可编辑层级,不接受任意远程 import。
|
|
130
|
+
return buildMenus(readRows('menus'))
|
|
131
|
+
},
|
|
132
|
+
logout: async (token) => { sessionStorage.removeItem(`demo:${token}`) },
|
|
133
|
+
clearCache() {},
|
|
134
|
+
async list(resource, input) {
|
|
135
|
+
assertWrite(resource, resource === 'config' ? 'view' : 'list')
|
|
136
|
+
const keyword = String(input.query.keyword ?? '').trim()
|
|
137
|
+
const rows = readRows(resource).filter(row => (!keyword || Object.values(row).some(value => String(value).includes(keyword)))
|
|
138
|
+
&& (!input.query.typeCode || row.typeCode === input.query.typeCode))
|
|
139
|
+
if (typeof input.query.sortField === 'string' && input.query.sortOrder) {
|
|
140
|
+
const field = input.query.sortField
|
|
141
|
+
rows.sort((left, right) => String(left[field] ?? '').localeCompare(String(right[field] ?? ''), 'zh-CN', { numeric: true }) * (input.query.sortOrder === 'desc' ? -1 : 1))
|
|
142
|
+
}
|
|
143
|
+
return { items: rows.slice((input.page - 1) * input.pageSize, input.page * input.pageSize), total: rows.length }
|
|
144
|
+
},
|
|
145
|
+
async save(resource, model, id) {
|
|
146
|
+
assertWrite(resource, id ? 'update' : 'create')
|
|
147
|
+
if (resource === 'roles' && Array.isArray(model.permissions))
|
|
148
|
+
assertWrite(resource, 'authorize')
|
|
149
|
+
const rows = readRows(resource)
|
|
150
|
+
if (id && !rows.some(row => row.id === id))
|
|
151
|
+
throw new Error('记录不存在')
|
|
152
|
+
const record: DataRow = { ...model, id: id ?? crypto.randomUUID() }
|
|
153
|
+
const uniqueField = resource === 'users' ? 'username' : resource === 'roles' ? 'code' : undefined
|
|
154
|
+
if (uniqueField && rows.some(row => row.id !== id && row[uniqueField] === record[uniqueField]))
|
|
155
|
+
throw new Error(`${uniqueField} 重复`)
|
|
156
|
+
if (resource === 'users' && id === 'admin' && (record.status === 'disabled' || !Array.isArray(record.roles) || !record.roles.includes('admin')))
|
|
157
|
+
throw new Error('不能停用或移除内置管理员角色')
|
|
158
|
+
const assignedPermissions = record.permissions
|
|
159
|
+
if (resource === 'roles' && id === 'admin' && (record.code !== 'admin' || record.status === 'disabled' || !Array.isArray(assignedPermissions) || !permissions.every(code => assignedPermissions.includes(code))))
|
|
160
|
+
throw new Error('不能停用或移除内置管理员权限')
|
|
161
|
+
const next = id ? rows.map(row => row.id === id ? record : row) : [...rows, record]
|
|
162
|
+
if (resource === 'menus')
|
|
163
|
+
buildMenus(next)
|
|
164
|
+
writeRows(resource, next)
|
|
165
|
+
return record
|
|
166
|
+
},
|
|
167
|
+
async remove(resource, id) {
|
|
168
|
+
assertWrite(resource, 'delete')
|
|
169
|
+
if (['users', 'roles'].includes(resource) && id === 'admin')
|
|
170
|
+
throw new Error('不能删除内置管理员')
|
|
171
|
+
if (resource === 'menus' && readRows(resource).some(row => row.parentId === id))
|
|
172
|
+
throw new Error('请先删除子菜单')
|
|
173
|
+
writeRows(resource, readRows(resource).filter(row => row.id !== id))
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { localMenus } from '../src/router/views'
|
|
4
|
+
import { createBackend } from '../src/services/data-source'
|
|
5
|
+
import { createApplicationSession } from '../src/services/session-runtime'
|
|
6
|
+
|
|
7
|
+
function storage(): Storage {
|
|
8
|
+
const values = new Map<string, string>()
|
|
9
|
+
return {
|
|
10
|
+
getItem: key => values.get(key) ?? null,
|
|
11
|
+
setItem: (key, value) => { values.set(key, value) },
|
|
12
|
+
removeItem: (key) => { values.delete(key) },
|
|
13
|
+
clear: () => values.clear(),
|
|
14
|
+
key: index => [...values.keys()][index] ?? null,
|
|
15
|
+
get length() { return values.size },
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.unstubAllEnvs()
|
|
20
|
+
vi.stubGlobal('localStorage', storage())
|
|
21
|
+
vi.stubGlobal('sessionStorage', storage())
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals()
|
|
25
|
+
vi.unstubAllEnvs()
|
|
26
|
+
})
|
|
27
|
+
function application() {
|
|
28
|
+
return createApplicationSession(createBackend, localStorage, sessionStorage)
|
|
29
|
+
}
|
|
30
|
+
describe('本地应用服务', () => {
|
|
31
|
+
it('认证、动态菜单和重新初始化使用同一服务契约', async () => {
|
|
32
|
+
const app = application()
|
|
33
|
+
await expect(app.login({ username: 'admin', password: 'wrong' })).rejects.toThrow('账号或密码')
|
|
34
|
+
await app.login({ username: 'admin', password: 'lumal123' })
|
|
35
|
+
expect(app.currentUser.value?.username).toBe('admin')
|
|
36
|
+
expect(app.menus.value.some(menu => menu.path === '/dashboard')).toBe(true)
|
|
37
|
+
const token = app.session.getToken()
|
|
38
|
+
const restored = application()
|
|
39
|
+
await restored.initialize()
|
|
40
|
+
expect(restored.session.getToken()).toBe(token)
|
|
41
|
+
expect(restored.ready.value).toBe(true)
|
|
42
|
+
await restored.logout()
|
|
43
|
+
expect(sessionStorage.getItem(`demo:${token}`)).toBeNull()
|
|
44
|
+
expect(restored.permissionStore.roles).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
it('生产默认拒绝演示账号,显式启用仅用于演示站点', async () => {
|
|
47
|
+
vi.stubEnv('PROD', true)
|
|
48
|
+
vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', '')
|
|
49
|
+
await expect(application().login({ username: 'admin', password: 'lumal123' })).rejects.toThrow('生产环境')
|
|
50
|
+
vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', 'true')
|
|
51
|
+
await expect(application().login({ username: 'admin', password: 'lumal123' })).resolves.toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
it('管理能力按预设启用,访客无写权限', async () => {
|
|
54
|
+
const app = application()
|
|
55
|
+
await app.login({ username: 'admin', password: 'lumal123' })
|
|
56
|
+
if (localMenus.some(menu => menu.component === 'system/users')) {
|
|
57
|
+
await app.service.save('users', { username: 'new-user', nickname: '新增', roles: ['guest'], status: 'enabled' })
|
|
58
|
+
const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'new-user' } })
|
|
59
|
+
expect(result.total).toBe(1)
|
|
60
|
+
await app.service.remove('users', String(result.items[0]?.id))
|
|
61
|
+
await expect(app.service.remove('users', 'admin')).rejects.toThrow('内置管理员')
|
|
62
|
+
await expect(app.service.save('users', { username: 'admin' })).rejects.toThrow('重复')
|
|
63
|
+
await expect(app.service.save('roles', { code: 'admin', status: 'disabled', permissions: [] }, 'admin')).rejects.toThrow('内置管理员')
|
|
64
|
+
await app.service.save('roles', { code: 'reader', name: '只读角色', status: 'enabled', permissions: ['system:user:list'] })
|
|
65
|
+
await app.service.save('users', { username: 'reader', roles: ['reader'], status: 'enabled' })
|
|
66
|
+
await app.service.save('dictionaries/items', { typeCode: 'status', label: '启用', value: 'enabled' })
|
|
67
|
+
expect((await app.service.list('dictionaries/items', { page: 1, pageSize: 10, query: { typeCode: 'other' } })).total).toBe(0)
|
|
68
|
+
await app.service.save('menus', { title: '目录', path: '/group', name: 'Group', type: 'directory' })
|
|
69
|
+
const group = (await app.service.list('menus', { page: 1, pageSize: 10, query: { keyword: 'Group' } })).items[0]!
|
|
70
|
+
await expect(app.service.save('menus', { ...group, parentId: group.id }, String(group.id))).rejects.toThrow('循环')
|
|
71
|
+
await expect(app.service.save('menus', { title: '恶意', path: '/bad', name: 'Bad', type: 'menu', component: 'https://evil.invalid/a.js' })).rejects.toThrow('白名单')
|
|
72
|
+
await app.login({ username: 'reader', password: 'lumal123' })
|
|
73
|
+
expect(app.permissionStore.permissions).toEqual(['system:user:list'])
|
|
74
|
+
await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).resolves.toHaveProperty('total')
|
|
75
|
+
await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限')
|
|
76
|
+
}
|
|
77
|
+
else if (localMenus.some(menu => menu.component === 'migration/users')) {
|
|
78
|
+
expect(app.permissionStore.permissions).toContain('system:user:create')
|
|
79
|
+
await app.service.save('users', { username: 'migration-only', nickname: '迁移用户', roles: ['guest'], status: 'enabled' })
|
|
80
|
+
const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })
|
|
81
|
+
expect(result.total).toBe(1)
|
|
82
|
+
await app.service.remove('users', String(result.items[0]?.id))
|
|
83
|
+
expect((await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })).total).toBe(0)
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).rejects.toThrow('无操作权限')
|
|
87
|
+
}
|
|
88
|
+
await app.login({ username: 'guest', password: 'lumal123' })
|
|
89
|
+
expect(app.permissionStore.permissions).toEqual([])
|
|
90
|
+
await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Vben 5 Element Plus 迁移基准
|
|
2
|
+
|
|
3
|
+
基准 v5.7.0,commit `63a38dce49ba109f61607994e21ba921d8e970e9`。
|
|
4
|
+
本项目不加载 VXE Table / vee-validate / Vben Store。
|
|
5
|
+
|
|
6
|
+
1. `#/adapter/form` 和 `#/adapter/vxe-table` 改为 `@lumal/vben-compat/v5`。
|
|
7
|
+
2. 保留 `[Form, formApi]`、单数 `schema`、`[Grid, gridApi]`、顶层 `formOptions`。
|
|
8
|
+
3. 业务 API 接到本项目 ApplicationService。query 仍接收
|
|
9
|
+
`{ page: { currentPage, pageSize }, sort }` 和第二参数 formValues。
|
|
10
|
+
4. 默认结果为 `{ items, total }`,不同响应在 `proxyConfig.response` 明确配置。
|
|
11
|
+
5. Element Plus 控件属性沿用;Tailwind 布局需转成项目 CSS,
|
|
12
|
+
`wrapperClass` 的基础 grid-cols-N 映射为原生响应式列数。
|
|
13
|
+
6. 原 Vben Drawer/Modal 改为 Element Plus Dialog 或 Lumal CRUD 编辑器。
|
|
14
|
+
7. `@vben/access` 的高频用法可改为本入口的 AccessControl / useAccess;
|
|
15
|
+
路由数据先经 adaptVbenMenus,authority 按角色匹配。
|
|
16
|
+
|
|
17
|
+
本模板 `migration/form` 对应 web-ele/demos/form/basic 与共享 rules/dynamic
|
|
18
|
+
场景;`migration/users` 对应共享 vxe-table/form 和 system/user 的
|
|
19
|
+
查询、分页、列配置、插槽操作、弹窗校验、增删改与按钮权限。
|
|
20
|
+
不是声称原始页面仅换 import 就可以完整运行。
|
|
21
|
+
|
|
22
|
+
不支持:任意自定义表单组件注册、VXE renderer、虚拟滚动、高级编辑、
|
|
23
|
+
表单嵌套路径、fieldMappingTime、Vben 全局偏好 Store/Drawer/Modal。
|
|
24
|
+
配置不在受支持类型内时会明确报错,不静默忽略。
|
|
25
|
+
Zod 校验器可由应用自行安装后传入 rules;兼容包不内置或导出 z。
|
|
26
|
+
Lumal Form 泛型约束模型字段,不是 Vben 的控件名称泛型;显式泛型需调整。
|
|
27
|
+
配置通过 hook/setState 更新,不接受 schema/gridOptions 组件 props。
|
|
28
|
+
Zod transform 需在应用提交边界处理;Grid 的命令式 query/reload 会向调用方传播错误。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useVbenForm } from '@lumal/vben-compat/v5'
|
|
3
|
+
import { shallowRef } from 'vue'
|
|
4
|
+
|
|
5
|
+
/***********************5.7.0 基础控件与动态依赖迁移*********************/
|
|
6
|
+
const submitted = shallowRef('')
|
|
7
|
+
const [Form, formApi] = useVbenForm({
|
|
8
|
+
layout: 'horizontal',
|
|
9
|
+
wrapperClass: 'grid-cols-2',
|
|
10
|
+
handleSubmit(values) { submitted.value = JSON.stringify(values) },
|
|
11
|
+
schema: [
|
|
12
|
+
{ component: 'Input', fieldName: 'name', label: '姓名', rules: 'required' },
|
|
13
|
+
{ component: 'Select', fieldName: 'role', label: '角色', rules: 'selectRequired', componentProps: { options: [{ label: '管理员', value: 'admin' }, { label: '访客', value: 'guest' }] } },
|
|
14
|
+
{ component: 'DatePicker', fieldName: 'date', label: '日期', rules: 'selectRequired', componentProps: { valueFormat: 'YYYY-MM-DD' } },
|
|
15
|
+
{ component: 'Switch', fieldName: 'enabled', label: '启用扩展', defaultValue: true },
|
|
16
|
+
{
|
|
17
|
+
component: 'Input',
|
|
18
|
+
fieldName: 'remark',
|
|
19
|
+
label: '备注',
|
|
20
|
+
dependencies: { triggerFields: ['enabled', 'role'], if: values => Boolean(values.enabled), disabled: values => values.role === 'guest', required: values => values.role === 'admin' },
|
|
21
|
+
},
|
|
22
|
+
{ component: 'Input', fieldName: 'cssHidden', label: '保留值字段', defaultValue: 'retained', dependencies: { triggerFields: ['enabled'], show: values => Boolean(values.enabled) } },
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
defineExpose({ formApi })
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<section>
|
|
30
|
+
<h2>Vben 5 表单</h2>
|
|
31
|
+
<Form />
|
|
32
|
+
<output aria-label="提交结果">{{ submitted }}</output>
|
|
33
|
+
</section>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ApplicationService, DataRow } from '../../services/contracts'
|
|
3
|
+
import { AccessControl, useAccess, useVbenForm, useVbenVxeGrid } from '@lumal/vben-compat/v5'
|
|
4
|
+
import { ElButton, ElDialog, ElMessageBox } from 'element-plus'
|
|
5
|
+
import { nextTick, shallowRef } from 'vue'
|
|
6
|
+
|
|
7
|
+
/***********************业务服务与权限边界*********************/
|
|
8
|
+
const props = defineProps<{ service: Pick<ApplicationService, 'list' | 'save' | 'remove'> }>()
|
|
9
|
+
const { hasAccessByCodes } = useAccess()
|
|
10
|
+
const visible = shallowRef(false)
|
|
11
|
+
const editingId = shallowRef<string>()
|
|
12
|
+
const failure = shallowRef('')
|
|
13
|
+
function rowId(row: DataRow): string {
|
|
14
|
+
if (typeof row.id !== 'string' && typeof row.id !== 'number')
|
|
15
|
+
throw new Error('用户缺少 id')
|
|
16
|
+
return String(row.id)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/***********************5.7.0 查询分页与列插槽迁移*********************/
|
|
20
|
+
const [Grid, gridApi] = useVbenVxeGrid({
|
|
21
|
+
tableTitle: '用户迁移示例',
|
|
22
|
+
formOptions: { schema: [{ component: 'Input', fieldName: 'keyword', label: '关键词' }], submitOnEnter: true },
|
|
23
|
+
gridOptions: {
|
|
24
|
+
columns: [
|
|
25
|
+
{ type: 'checkbox', width: 48 },
|
|
26
|
+
{ type: 'seq', title: '序号', width: 64 },
|
|
27
|
+
{ field: 'username', title: '用户名', sortable: true },
|
|
28
|
+
{ field: 'nickname', title: '昵称' },
|
|
29
|
+
{ field: 'status', title: '状态' },
|
|
30
|
+
{ field: 'id', title: '操作', slots: { default: 'operation' } },
|
|
31
|
+
],
|
|
32
|
+
rowConfig: { keyField: 'id' },
|
|
33
|
+
pagerConfig: { pageSize: 10 },
|
|
34
|
+
proxyConfig: {
|
|
35
|
+
ajax: {
|
|
36
|
+
query: ({ page, sort }, formValues) => props.service.list('users', {
|
|
37
|
+
page: page.currentPage,
|
|
38
|
+
pageSize: page.pageSize,
|
|
39
|
+
query: { ...formValues, sortField: sort.field, sortOrder: sort.order },
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
toolbarConfig: { custom: true, refresh: true, search: true },
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/***********************新增编辑弹窗与失败恢复*********************/
|
|
48
|
+
const [Form, formApi] = useVbenForm({
|
|
49
|
+
showDefaultActions: false,
|
|
50
|
+
schema: [
|
|
51
|
+
{ component: 'Input', fieldName: 'username', label: '用户名', rules: 'required' },
|
|
52
|
+
{ component: 'Input', fieldName: 'nickname', label: '昵称', rules: 'required' },
|
|
53
|
+
{ component: 'Select', fieldName: 'roles', label: '角色', defaultValue: ['guest'], componentProps: { multiple: true, options: [{ label: '访客', value: 'guest' }, { label: '管理员', value: 'admin' }] } },
|
|
54
|
+
{ component: 'Select', fieldName: 'status', label: '状态', defaultValue: 'enabled', componentProps: { options: [{ label: '启用', value: 'enabled' }, { label: '停用', value: 'disabled' }] } },
|
|
55
|
+
],
|
|
56
|
+
})
|
|
57
|
+
const saving = shallowRef(false)
|
|
58
|
+
async function open(row?: DataRow): Promise<void> {
|
|
59
|
+
editingId.value = row ? rowId(row) : undefined
|
|
60
|
+
failure.value = ''
|
|
61
|
+
visible.value = true
|
|
62
|
+
await nextTick()
|
|
63
|
+
await formApi.resetForm()
|
|
64
|
+
if (row)
|
|
65
|
+
await formApi.setValues(row)
|
|
66
|
+
}
|
|
67
|
+
async function save(): Promise<void> {
|
|
68
|
+
if (saving.value || !(await formApi.validate()).valid)
|
|
69
|
+
return
|
|
70
|
+
saving.value = true
|
|
71
|
+
try {
|
|
72
|
+
await props.service.save('users', await formApi.getValues(), editingId.value)
|
|
73
|
+
visible.value = false
|
|
74
|
+
await gridApi.query()
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
failure.value = error instanceof Error ? error.message : String(error)
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
saving.value = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function remove(row: DataRow): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
await ElMessageBox.confirm(`删除 ${String(row.username)}?`, '删除确认')
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return // Element Plus 的取消和关闭都表示不执行删除。
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await props.service.remove('users', rowId(row))
|
|
92
|
+
await gridApi.query()
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
failure.value = error instanceof Error ? error.message : String(error)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
defineExpose({ gridApi })
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<template>
|
|
102
|
+
<section>
|
|
103
|
+
<p v-if="failure" role="alert">
|
|
104
|
+
{{ failure }}
|
|
105
|
+
</p>
|
|
106
|
+
<Grid>
|
|
107
|
+
<template #toolbar-tools>
|
|
108
|
+
<AccessControl type="code" :codes="['system:user:create']">
|
|
109
|
+
<ElButton type="primary" @click="open()">
|
|
110
|
+
新增用户
|
|
111
|
+
</ElButton>
|
|
112
|
+
</AccessControl>
|
|
113
|
+
</template>
|
|
114
|
+
<template #operation="{ row }">
|
|
115
|
+
<ElButton v-if="hasAccessByCodes(['system:user:update'])" link @click="open(row)">
|
|
116
|
+
编辑
|
|
117
|
+
</ElButton>
|
|
118
|
+
<ElButton v-if="hasAccessByCodes(['system:user:delete'])" link type="danger" @click="remove(row)">
|
|
119
|
+
删除
|
|
120
|
+
</ElButton>
|
|
121
|
+
</template>
|
|
122
|
+
</Grid>
|
|
123
|
+
<ElDialog v-model="visible" :title="editingId ? '编辑用户' : '新增用户'" destroy-on-close width="min(640px, 92vw)">
|
|
124
|
+
<Form />
|
|
125
|
+
<template #footer>
|
|
126
|
+
<ElButton @click="visible = false">
|
|
127
|
+
取消
|
|
128
|
+
</ElButton>
|
|
129
|
+
<ElButton type="primary" :loading="saving" @click="save">
|
|
130
|
+
保存
|
|
131
|
+
</ElButton>
|
|
132
|
+
</template>
|
|
133
|
+
</ElDialog>
|
|
134
|
+
</section>
|
|
135
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { adaptVbenMenus } from '@lumal/vben-compat/v5/router'
|
|
2
|
+
import '@lumal/vben-compat/style.css'
|
|
3
|
+
|
|
4
|
+
// Vben authority 是角色;按钮权限继续由应用 PermissionStore 管理。
|
|
5
|
+
export const menus = adaptVbenMenus([
|
|
6
|
+
{ path: '/migration/form', name: 'VbenForm', component: 'migration/form', meta: { title: 'Vben 表单迁移', keepAlive: true } },
|
|
7
|
+
{ path: '/migration/users', name: 'VbenUsers', component: 'migration/users', meta: { title: 'Vben 业务迁移', authority: ['admin'], keepAlive: true } },
|
|
8
|
+
]).map((menu) => {
|
|
9
|
+
if (menu.component !== 'migration/users')
|
|
10
|
+
return menu
|
|
11
|
+
// 迁移页也可单独生成,资源权限不能依赖可选的 system 模块。
|
|
12
|
+
return { ...menu, meta: { ...menu.meta, permissionPrefix: 'system:user', resource: 'users' } }
|
|
13
|
+
})
|
|
14
|
+
export const views = {
|
|
15
|
+
'migration/form': () => import('../views/migration/VbenFormView.vue'),
|
|
16
|
+
'migration/users': () => import('../views/migration/VbenUsersView.vue'),
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/***********************迁移表单场景装配*********************/
|
|
3
|
+
import VbenFormExample from '../../components/migration/VbenFormExample.vue'
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<main class="lumal-admin-page">
|
|
8
|
+
<VbenFormExample />
|
|
9
|
+
</main>
|
|
10
|
+
</template>
|