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,86 @@
|
|
|
1
|
+
import type { LumalLayoutTabItem } from '@lumal/core/layout'
|
|
2
|
+
import type { LumalMenuInputRecord, 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 { application, permissionStore } from '../services/session'
|
|
10
|
+
import { viewComponents } from './views'
|
|
11
|
+
|
|
12
|
+
/***********************路由初始化*********************/
|
|
13
|
+
export const routeError = shallowRef('')
|
|
14
|
+
export function createAdminRouter(history?: RouterHistory) {
|
|
15
|
+
const targetRouter = createRouter({
|
|
16
|
+
history: history ?? (appConfig.routerMode === 'history' ? createWebHistory(import.meta.env.BASE_URL) : createWebHashHistory(import.meta.env.BASE_URL)),
|
|
17
|
+
routes: [
|
|
18
|
+
{ path: '/login', name: 'Login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'public', requireLogin: false, title: '登录' } },
|
|
19
|
+
{ path: '/', redirect: '/dashboard' },
|
|
20
|
+
{ path: '/403', component: () => import('../views/error/ForbiddenView.vue'), meta: { title: '无权限' } },
|
|
21
|
+
{ path: '/404', component: () => import('../views/error/NotFoundView.vue'), meta: { title: '页面不存在' } },
|
|
22
|
+
{ path: '/:pathMatch(.*)*', name: 'CatchAll', component: () => import('../views/error/NotFoundView.vue') },
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
const runtime = createMenuRouteRuntime({
|
|
26
|
+
router: {
|
|
27
|
+
addRoute: route => targetRouter.addRoute(route as RouteRecordRaw),
|
|
28
|
+
getRoutes: () => targetRouter.getRoutes(),
|
|
29
|
+
hasRoute: name => targetRouter.hasRoute(name),
|
|
30
|
+
removeRoute: name => targetRouter.removeRoute(name),
|
|
31
|
+
},
|
|
32
|
+
componentResolver: name => viewComponents[name],
|
|
33
|
+
hasPermission: values => permissionStore.hasPermission(values, 'every'),
|
|
34
|
+
hasRole: values => permissionStore.hasRole(values, 'some'),
|
|
35
|
+
loadRemoteMenus: async () => {
|
|
36
|
+
await application.initialize()
|
|
37
|
+
return application.menus.value as unknown as LumalMenuInputRecord[]
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
application.onReset((expired) => {
|
|
41
|
+
runtime.resetRemote()
|
|
42
|
+
if (expired && targetRouter.currentRoute.value.path !== '/login')
|
|
43
|
+
void targetRouter.replace({ path: '/login', query: { redirect: targetRouter.currentRoute.value.fullPath } })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
targetRouter.beforeEach(async (to) => {
|
|
47
|
+
if (!application.isAuthenticated())
|
|
48
|
+
return to.path === '/login' ? true : { path: '/login', query: { redirect: to.fullPath } }
|
|
49
|
+
const rematch = !runtime.remoteLoaded
|
|
50
|
+
try {
|
|
51
|
+
await runtime.loadRemote()
|
|
52
|
+
routeError.value = ''
|
|
53
|
+
}
|
|
54
|
+
catch (reason) {
|
|
55
|
+
routeError.value = reason instanceof Error ? reason.message : '菜单加载失败'
|
|
56
|
+
application.reset()
|
|
57
|
+
return { path: '/login', query: { redirect: to.fullPath } }
|
|
58
|
+
}
|
|
59
|
+
if (to.path === '/login' || to.path === '/')
|
|
60
|
+
return runtime.firstAccessiblePath || '/403'
|
|
61
|
+
if (rematch)
|
|
62
|
+
return to.fullPath
|
|
63
|
+
return to.name === 'CatchAll' ? '/404' : true
|
|
64
|
+
})
|
|
65
|
+
setupPermissionGuard(targetRouter, permissionStore, {
|
|
66
|
+
isAuthenticated: application.isAuthenticated,
|
|
67
|
+
mode: 'every',
|
|
68
|
+
roleMode: 'some',
|
|
69
|
+
requireLoginByDefault: true,
|
|
70
|
+
noAccessRedirect: '/403',
|
|
71
|
+
})
|
|
72
|
+
return { router: targetRouter, runtime }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { router, runtime } = createAdminRouter()
|
|
76
|
+
export { router }
|
|
77
|
+
export function createAdminSidebarMenus(): SidebarMenuItem[] {
|
|
78
|
+
// ready 使非响应式菜单运行时在退出和初始化完成后重新计算。
|
|
79
|
+
return application.ready.value ? runtime.sidebarMenus : []
|
|
80
|
+
}
|
|
81
|
+
export function createAdminTabs(): LumalLayoutTabItem[] {
|
|
82
|
+
return runtime.routes.filter(route => route.meta.affixTab === true
|
|
83
|
+
&& permissionStore.hasPermission(route.meta.permissions, 'every')
|
|
84
|
+
&& permissionStore.hasRole(route.meta.roles, 'some'))
|
|
85
|
+
.map(route => ({ path: route.path, title: route.meta.title, closable: false }))
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LumalMenuRecord } from '@lumal/core/router'
|
|
2
|
+
|
|
3
|
+
export interface FeatureModule {
|
|
4
|
+
menus: LumalMenuRecord[]
|
|
5
|
+
views: Record<string, () => Promise<unknown>>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const modules = import.meta.glob<FeatureModule>('../modules/*.ts', { eager: true })
|
|
9
|
+
export const localMenus: LumalMenuRecord[] = [
|
|
10
|
+
{ path: '/dashboard', name: 'Dashboard', component: 'dashboard/index', meta: { title: '工作台', icon: 'app:dashboard', affixTab: true, keepAlive: true } },
|
|
11
|
+
...Object.values(modules).flatMap(module => module.menus),
|
|
12
|
+
]
|
|
13
|
+
export const viewComponents: Record<string, () => Promise<unknown>> = {
|
|
14
|
+
'dashboard/index': () => import('../views/dashboard/DashboardView.vue'),
|
|
15
|
+
...Object.assign({}, ...Object.values(modules).map(module => module.views)),
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AuthSessionData } from '@lumal/core/auth'
|
|
2
|
+
import type { LumalMenuRecord } from '@lumal/core/router'
|
|
3
|
+
|
|
4
|
+
export interface LoginInput { username: string, password: string }
|
|
5
|
+
export interface CurrentUser { id: string, name: string, username: string }
|
|
6
|
+
export interface AccessSnapshot { permissions: string[], roles: string[] }
|
|
7
|
+
export type DataRow = Record<string, unknown>
|
|
8
|
+
export interface ListInput { page: number, pageSize: number, query: DataRow }
|
|
9
|
+
export interface PageResult { items: DataRow[], total: number }
|
|
10
|
+
|
|
11
|
+
// 应用协议由 Local/HTTP 共同实现,后端包装和字段转换不进入 Core。
|
|
12
|
+
export interface ApplicationService {
|
|
13
|
+
login: (input: LoginInput, signal: AbortSignal) => Promise<AuthSessionData>
|
|
14
|
+
getUser: (signal: AbortSignal) => Promise<CurrentUser>
|
|
15
|
+
getAccess: (signal: AbortSignal) => Promise<AccessSnapshot>
|
|
16
|
+
getMenus: (signal: AbortSignal) => Promise<LumalMenuRecord[]>
|
|
17
|
+
logout: (accessToken: string) => Promise<void>
|
|
18
|
+
clearCache: () => void
|
|
19
|
+
list: (resource: string, input: ListInput) => Promise<PageResult>
|
|
20
|
+
save: (resource: string, model: DataRow, id?: string) => Promise<unknown>
|
|
21
|
+
remove: (resource: string, id: string) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ServiceContext {
|
|
25
|
+
getSession: () => AuthSessionData | null
|
|
26
|
+
getSignal: () => AbortSignal
|
|
27
|
+
setSession: (value: AuthSessionData) => void
|
|
28
|
+
expire: () => Promise<void>
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LumalPreferencesDefaults } from '@lumal/core/theme'
|
|
2
|
+
import { createPreferencesStore } from '@lumal/core/theme'
|
|
3
|
+
import { appConfig } from '../app.config'
|
|
4
|
+
|
|
5
|
+
export const adminPreferenceDefaults = {
|
|
6
|
+
app: { dynamicTitle: true, layout: 'sidebar-nav' },
|
|
7
|
+
breadcrumb: { enable: true, hideOnlyOne: false, showHome: true, showIcon: true },
|
|
8
|
+
header: { globalSearch: true },
|
|
9
|
+
shortcutKeys: { globalSearch: true },
|
|
10
|
+
sidebar: { width: 220 },
|
|
11
|
+
tabbar: {
|
|
12
|
+
cache: appConfig.preset !== 'minimal',
|
|
13
|
+
draggable: true,
|
|
14
|
+
enable: appConfig.preset !== 'minimal',
|
|
15
|
+
maxCount: 0,
|
|
16
|
+
middleClickToClose: true,
|
|
17
|
+
persist: true,
|
|
18
|
+
showIcon: true,
|
|
19
|
+
showMaximize: true,
|
|
20
|
+
showMore: true,
|
|
21
|
+
showRefresh: true,
|
|
22
|
+
styleType: 'chrome',
|
|
23
|
+
visitHistory: true,
|
|
24
|
+
wheelable: true,
|
|
25
|
+
},
|
|
26
|
+
theme: { fontSize: 14 },
|
|
27
|
+
transition: { enable: true, name: 'fade-side' },
|
|
28
|
+
} satisfies LumalPreferencesDefaults
|
|
29
|
+
|
|
30
|
+
const preferencesStore = createPreferencesStore({
|
|
31
|
+
defaults: adminPreferenceDefaults,
|
|
32
|
+
storage: localStorage,
|
|
33
|
+
storageKey: 'lumal-admin:preferences',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const adminPreferences = preferencesStore.state
|
|
37
|
+
export const adminResolvedThemeMode = preferencesStore.resolvedThemeMode
|
|
38
|
+
|
|
39
|
+
export function patchAdminPreferences(preferences: Parameters<typeof preferencesStore.patch>[0]): void {
|
|
40
|
+
preferencesStore.patch(preferences)
|
|
41
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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 error = shallowRef('')
|
|
17
|
+
const resetHandlers = new Set<(expired: boolean) => void>()
|
|
18
|
+
const tabStorageKey = 'lumal-admin:tabs'
|
|
19
|
+
let generation = 0
|
|
20
|
+
let controller = new AbortController()
|
|
21
|
+
let initializing: Promise<void> | undefined
|
|
22
|
+
const session = createAuthSession({ storage, tokenKey: 'lumal-admin:session' })
|
|
23
|
+
let service: ApplicationService
|
|
24
|
+
|
|
25
|
+
function reset(expired = false): void {
|
|
26
|
+
generation += 1
|
|
27
|
+
controller.abort()
|
|
28
|
+
controller = new AbortController()
|
|
29
|
+
initializing = undefined
|
|
30
|
+
ready.value = false
|
|
31
|
+
user.value = null
|
|
32
|
+
menus.value = []
|
|
33
|
+
permissionStore.clear()
|
|
34
|
+
session.clearToken()
|
|
35
|
+
service.clearCache()
|
|
36
|
+
tabStorage?.removeItem(tabStorageKey)
|
|
37
|
+
resetHandlers.forEach(handler => handler(expired))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
service = createService({
|
|
41
|
+
getSession: session.getSession,
|
|
42
|
+
getSignal: () => controller.signal,
|
|
43
|
+
setSession: session.setSession,
|
|
44
|
+
expire: async () => reset(true),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/***********************会话初始化与隔离*********************/
|
|
48
|
+
function initialize(): Promise<void> {
|
|
49
|
+
if (ready.value || !session.isAuthenticated())
|
|
50
|
+
return Promise.resolve()
|
|
51
|
+
if (initializing)
|
|
52
|
+
return initializing
|
|
53
|
+
const version = generation
|
|
54
|
+
const signal = controller.signal
|
|
55
|
+
const pending = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const [nextUser, access, nextMenus] = await Promise.all([
|
|
58
|
+
service.getUser(signal),
|
|
59
|
+
service.getAccess(signal),
|
|
60
|
+
service.getMenus(signal),
|
|
61
|
+
])
|
|
62
|
+
signal.throwIfAborted()
|
|
63
|
+
if (version !== generation)
|
|
64
|
+
throw new Error('会话已切换')
|
|
65
|
+
user.value = nextUser
|
|
66
|
+
permissionStore.setPermissions(access.permissions)
|
|
67
|
+
permissionStore.setRoles(access.roles)
|
|
68
|
+
menus.value = nextMenus
|
|
69
|
+
ready.value = true
|
|
70
|
+
error.value = ''
|
|
71
|
+
}
|
|
72
|
+
catch (reason) {
|
|
73
|
+
if (version === generation) {
|
|
74
|
+
reset()
|
|
75
|
+
error.value = reason instanceof Error ? reason.message : '会话初始化失败'
|
|
76
|
+
}
|
|
77
|
+
throw reason
|
|
78
|
+
}
|
|
79
|
+
})()
|
|
80
|
+
initializing = pending
|
|
81
|
+
void pending.finally(() => {
|
|
82
|
+
if (initializing === pending)
|
|
83
|
+
initializing = undefined
|
|
84
|
+
}).catch(() => {}) // 原始 Promise 仍向调用方传播异常,只消费 finally 分支。
|
|
85
|
+
return pending
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function login(input: LoginInput): Promise<void> {
|
|
89
|
+
reset()
|
|
90
|
+
const version = generation
|
|
91
|
+
const signal = controller.signal
|
|
92
|
+
error.value = ''
|
|
93
|
+
try {
|
|
94
|
+
const result = await service.login(input, signal)
|
|
95
|
+
signal.throwIfAborted()
|
|
96
|
+
if (version !== generation)
|
|
97
|
+
throw new Error('会话已切换')
|
|
98
|
+
session.setSession(result)
|
|
99
|
+
await initialize()
|
|
100
|
+
}
|
|
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)
|
|
115
|
+
await service.logout(token)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
currentUser: readonly(user),
|
|
120
|
+
error: readonly(error),
|
|
121
|
+
menus: readonly(menus),
|
|
122
|
+
ready: readonly(ready),
|
|
123
|
+
permissionStore,
|
|
124
|
+
session,
|
|
125
|
+
service,
|
|
126
|
+
tabStorageKey,
|
|
127
|
+
initialize,
|
|
128
|
+
isAuthenticated: session.isAuthenticated,
|
|
129
|
+
login,
|
|
130
|
+
logout,
|
|
131
|
+
reset,
|
|
132
|
+
onReset(handler: (expired: boolean) => void) {
|
|
133
|
+
resetHandlers.add(handler)
|
|
134
|
+
return () => resetHandlers.delete(handler)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createBackend } from './data-source'
|
|
2
|
+
import { createApplicationSession } from './session-runtime'
|
|
3
|
+
|
|
4
|
+
export const application = createApplicationSession(createBackend, localStorage, sessionStorage)
|
|
5
|
+
export const {
|
|
6
|
+
currentUser,
|
|
7
|
+
initialize,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
login,
|
|
10
|
+
logout,
|
|
11
|
+
permissionStore,
|
|
12
|
+
service,
|
|
13
|
+
tabStorageKey: adminTabSnapshotStorageKey,
|
|
14
|
+
} = application
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: var(--el-text-color-primary);
|
|
3
|
+
background: var(--el-bg-color-page);
|
|
4
|
+
font-family:
|
|
5
|
+
Inter,
|
|
6
|
+
"Segoe UI",
|
|
7
|
+
"Microsoft YaHei",
|
|
8
|
+
Arial,
|
|
9
|
+
sans-serif;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
min-width: 320px;
|
|
15
|
+
overflow-x: hidden;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.lumal-admin-home {
|
|
19
|
+
display: grid;
|
|
20
|
+
gap: var(--lumal-page-gutter, 20px);
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
padding: var(--lumal-page-gutter, 20px);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.lumal-admin-home__metrics {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
28
|
+
gap: 16px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.lumal-admin-home__metric {
|
|
32
|
+
display: grid;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
gap: 8px;
|
|
35
|
+
padding: 18px;
|
|
36
|
+
border: 1px solid var(--el-border-color-lighter);
|
|
37
|
+
border-radius: var(--lumal-radius-base, 8px);
|
|
38
|
+
background: var(--el-bg-color);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.lumal-admin-home__metric strong {
|
|
42
|
+
color: var(--el-color-primary);
|
|
43
|
+
font-size: 28px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.lumal-admin-home__metric small {
|
|
47
|
+
color: var(--el-text-color-secondary);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.lumal-admin-page {
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
padding: var(--lumal-page-gutter, 20px);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.lumal-admin-page__list {
|
|
56
|
+
display: grid;
|
|
57
|
+
gap: 10px;
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding-left: 18px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.lumal-admin-page__item {
|
|
63
|
+
color: var(--el-text-color-regular);
|
|
64
|
+
font-size: 14px;
|
|
65
|
+
line-height: 1.6;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.lumal-admin-header-actions {
|
|
69
|
+
display: flex;
|
|
70
|
+
min-width: 0;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 4px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.lumal-admin-header-actions .el-button {
|
|
76
|
+
min-width: 44px;
|
|
77
|
+
min-height: 44px;
|
|
78
|
+
margin: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.lumal-admin-header-actions__user {
|
|
82
|
+
max-width: 120px;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
color: var(--el-text-color-regular);
|
|
85
|
+
font-size: 13px;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.lumal-admin-login {
|
|
91
|
+
display: grid;
|
|
92
|
+
min-height: 100dvh;
|
|
93
|
+
place-items: center;
|
|
94
|
+
padding: 24px;
|
|
95
|
+
background: var(--el-bg-color-page);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.lumal-admin-login__panel {
|
|
99
|
+
box-sizing: border-box;
|
|
100
|
+
width: min(400px, 100%);
|
|
101
|
+
padding: 32px;
|
|
102
|
+
border: 1px solid var(--el-border-color-lighter);
|
|
103
|
+
border-radius: var(--lumal-radius-large, 12px);
|
|
104
|
+
background: var(--el-bg-color);
|
|
105
|
+
box-shadow: var(--lumal-shadow-dialog);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.lumal-admin-login__brand {
|
|
109
|
+
display: grid;
|
|
110
|
+
width: 48px;
|
|
111
|
+
height: 48px;
|
|
112
|
+
place-items: center;
|
|
113
|
+
border-radius: var(--lumal-radius-base, 8px);
|
|
114
|
+
color: var(--el-color-white);
|
|
115
|
+
background: var(--el-color-primary);
|
|
116
|
+
font-size: 24px;
|
|
117
|
+
font-weight: 800;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.lumal-admin-login h1 {
|
|
121
|
+
margin: 20px 0 6px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.lumal-admin-login p,
|
|
125
|
+
.lumal-admin-login small {
|
|
126
|
+
color: var(--el-text-color-secondary);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.lumal-admin-login .el-form {
|
|
130
|
+
margin: 24px 0 16px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.lumal-admin-login .el-button {
|
|
134
|
+
width: 100%;
|
|
135
|
+
min-height: 44px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.lumal-admin-login__error {
|
|
139
|
+
color: var(--el-color-danger) !important;
|
|
140
|
+
font-size: 13px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@media (max-width: 900px) {
|
|
144
|
+
.lumal-admin-home__metrics {
|
|
145
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@media (max-width: 640px) {
|
|
150
|
+
.lumal-admin-home,
|
|
151
|
+
.lumal-admin-page {
|
|
152
|
+
padding: 12px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.lumal-admin-home__metrics {
|
|
156
|
+
grid-template-columns: minmax(0, 1fr);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.lumal-admin-header-actions__user {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.lumal-admin-login {
|
|
164
|
+
padding: 16px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.lumal-admin-login__panel {
|
|
168
|
+
padding: 24px 20px;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
*,
|
|
174
|
+
*::before,
|
|
175
|
+
*::after {
|
|
176
|
+
scroll-behavior: auto !important;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FormInstance } from 'element-plus'
|
|
3
|
+
import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus'
|
|
4
|
+
import { reactive, shallowRef, useTemplateRef } from 'vue'
|
|
5
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
6
|
+
import { routeError } from '../../router'
|
|
7
|
+
import { demoAccount } from '../../services/data-source'
|
|
8
|
+
import { application, login } from '../../services/session'
|
|
9
|
+
|
|
10
|
+
/***********************登录状态*********************/
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const formRef = useTemplateRef<FormInstance>('formRef')
|
|
14
|
+
const loading = shallowRef(false)
|
|
15
|
+
const errorMessage = shallowRef('')
|
|
16
|
+
const form = reactive({ username: demoAccount?.username ?? '', password: demoAccount?.password ?? '' })
|
|
17
|
+
|
|
18
|
+
/***********************登录提交*********************/
|
|
19
|
+
function resolveRedirect(): string {
|
|
20
|
+
const redirect = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect
|
|
21
|
+
return redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/login') ? redirect : '/dashboard'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function handleSubmit(): Promise<void> {
|
|
25
|
+
if (loading.value) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
loading.value = true
|
|
30
|
+
errorMessage.value = ''
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const valid = await formRef.value?.validate()
|
|
34
|
+
if (valid === false) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await login(form)
|
|
39
|
+
await router.replace(resolveRedirect())
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
errorMessage.value = error instanceof Error ? error.message : '登录失败'
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
loading.value = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<main class="lumal-admin-login">
|
|
52
|
+
<section class="lumal-admin-login__panel">
|
|
53
|
+
<div class="lumal-admin-login__brand" aria-hidden="true">
|
|
54
|
+
L
|
|
55
|
+
</div>
|
|
56
|
+
<h1>Lumal Admin</h1>
|
|
57
|
+
<p>轻量后台基座</p>
|
|
58
|
+
|
|
59
|
+
<ElForm ref="formRef" :model="form" label-position="top" @submit.prevent="handleSubmit">
|
|
60
|
+
<ElFormItem label="用户名" prop="username" :rules="[{ required: true, message: '请输入用户名' }]">
|
|
61
|
+
<ElInput v-model="form.username" autocomplete="username" />
|
|
62
|
+
</ElFormItem>
|
|
63
|
+
<ElFormItem label="密码" prop="password" :rules="[{ required: true, message: '请输入密码' }]">
|
|
64
|
+
<ElInput v-model="form.password" type="password" show-password autocomplete="current-password" />
|
|
65
|
+
</ElFormItem>
|
|
66
|
+
<p v-if="errorMessage" class="lumal-admin-login__error" role="alert">
|
|
67
|
+
{{ errorMessage }}
|
|
68
|
+
</p>
|
|
69
|
+
<ElButton native-type="submit" type="primary" :loading="loading">
|
|
70
|
+
登录
|
|
71
|
+
</ElButton>
|
|
72
|
+
</ElForm>
|
|
73
|
+
|
|
74
|
+
<p v-if="routeError || application.error.value" role="alert">
|
|
75
|
+
{{ routeError || application.error.value }}
|
|
76
|
+
</p>
|
|
77
|
+
<small v-if="demoAccount">本地演示账号:{{ demoAccount.username }} / {{ demoAccount.password }}</small>
|
|
78
|
+
</section>
|
|
79
|
+
</main>
|
|
80
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElDescriptions, ElDescriptionsItem } from 'element-plus'
|
|
3
|
+
import { currentUser, permissionStore } from '../../services/session'
|
|
4
|
+
|
|
5
|
+
/***********************会话摘要*********************/
|
|
6
|
+
const startedAt = new Date().toLocaleDateString()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<main class="lumal-admin-page">
|
|
11
|
+
<h1>工作台</h1>
|
|
12
|
+
<ElDescriptions :column="1" border>
|
|
13
|
+
<ElDescriptionsItem label="当前用户">
|
|
14
|
+
{{ currentUser?.name }}
|
|
15
|
+
</ElDescriptionsItem>
|
|
16
|
+
<ElDescriptionsItem label="角色">
|
|
17
|
+
{{ permissionStore.roles.join('、') }}
|
|
18
|
+
</ElDescriptionsItem>
|
|
19
|
+
<ElDescriptionsItem label="权限数">
|
|
20
|
+
{{ permissionStore.permissions.length }}
|
|
21
|
+
</ElDescriptionsItem>
|
|
22
|
+
<ElDescriptionsItem label="日期">
|
|
23
|
+
{{ startedAt }}
|
|
24
|
+
</ElDescriptionsItem>
|
|
25
|
+
</ElDescriptions>
|
|
26
|
+
</main>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LumalPage } from '@lumal/core/components'
|
|
3
|
+
import { ElButton } from 'element-plus'
|
|
4
|
+
import { useRouter } from 'vue-router'
|
|
5
|
+
|
|
6
|
+
/***********************路由操作*********************/
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
|
|
9
|
+
function handleBackDashboardClick(): void {
|
|
10
|
+
void router.push('/dashboard')
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<main class="lumal-admin-page">
|
|
16
|
+
<LumalPage
|
|
17
|
+
title="无权限"
|
|
18
|
+
description="当前账号没有访问该页面所需的权限。"
|
|
19
|
+
>
|
|
20
|
+
<ElButton type="primary" @click="handleBackDashboardClick">
|
|
21
|
+
返回工作台
|
|
22
|
+
</ElButton>
|
|
23
|
+
</LumalPage>
|
|
24
|
+
</main>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LumalPage } from '@lumal/core/components'
|
|
3
|
+
import { ElButton } from 'element-plus'
|
|
4
|
+
import { useRouter } from 'vue-router'
|
|
5
|
+
|
|
6
|
+
/***********************异常页导航*********************/
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<main class="lumal-admin-page">
|
|
12
|
+
<LumalPage title="页面不存在" description="请检查地址,或返回工作台继续操作。">
|
|
13
|
+
<ElButton type="primary" @click="router.push('/dashboard')">
|
|
14
|
+
返回工作台
|
|
15
|
+
</ElButton>
|
|
16
|
+
</LumalPage>
|
|
17
|
+
</main>
|
|
18
|
+
</template>
|