create-lumal-admin 0.0.1-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.
@@ -0,0 +1,1577 @@
1
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ /***********************项目名称*********************/
4
+ function normalizePackageName(name) {
5
+ const normalizedName = name
6
+ .trim()
7
+ .toLowerCase()
8
+ .replaceAll('\\', '/')
9
+ .split('/')
10
+ .filter(Boolean)
11
+ .at(-1)
12
+ ?.replace(/[^a-z0-9-]/g, '-')
13
+ .replace(/-+/g, '-')
14
+ .replace(/^-|-$/g, '');
15
+ return normalizedName || 'lumal-admin';
16
+ }
17
+ function resolveProjectName(options) {
18
+ return normalizePackageName(options.name || basename(options.targetDir));
19
+ }
20
+ /***********************模板内容*********************/
21
+ function createPackageJson(projectName) {
22
+ return `${JSON.stringify({
23
+ name: projectName,
24
+ type: 'module',
25
+ version: '0.0.0',
26
+ private: true,
27
+ scripts: {
28
+ dev: 'vite --host 127.0.0.1',
29
+ build: 'vue-tsc --noEmit -p tsconfig.json && vite build',
30
+ preview: 'vite preview --host 127.0.0.1',
31
+ },
32
+ dependencies: {
33
+ '@lumal/core': '^0.0.0',
34
+ '@lumal/icons': '^0.0.0',
35
+ '@lumal/icons-vue': '^0.0.0',
36
+ 'element-plus': '^2.11.0',
37
+ 'vue': '^3.5.0',
38
+ 'vue-router': '^4.5.0',
39
+ },
40
+ devDependencies: {
41
+ '@vitejs/plugin-vue': '^6.0.1',
42
+ 'typescript': '^6.0.0',
43
+ 'vite': '^8.0.0',
44
+ 'vue-tsc': '^3.1.1',
45
+ },
46
+ }, null, 2)}\n`;
47
+ }
48
+ function createIndexHtml() {
49
+ return `<!doctype html>
50
+ <html lang="zh-CN">
51
+ <head>
52
+ <meta charset="UTF-8" />
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
54
+ <title>Lumal Admin</title>
55
+ </head>
56
+ <body>
57
+ <div id="app"></div>
58
+ <script type="module" src="/src/main.ts"></script>
59
+ </body>
60
+ </html>
61
+ `;
62
+ }
63
+ function createTsConfig() {
64
+ return `{
65
+ "compilerOptions": {
66
+ "target": "ES2022",
67
+ "module": "ESNext",
68
+ "moduleResolution": "Bundler",
69
+ "strict": true,
70
+ "jsx": "preserve",
71
+ "useDefineForClassFields": true,
72
+ "resolveJsonModule": true,
73
+ "types": ["vite/client"],
74
+ "isolatedModules": true,
75
+ "skipLibCheck": true
76
+ },
77
+ "include": [
78
+ "src/**/*.ts",
79
+ "src/**/*.vue",
80
+ "vite.config.ts"
81
+ ]
82
+ }
83
+ `;
84
+ }
85
+ function createViteConfig() {
86
+ return `import { fileURLToPath, URL } from 'node:url'
87
+ import vue from '@vitejs/plugin-vue'
88
+ import { defineConfig } from 'vite'
89
+
90
+ /***********************应用开发配置*********************/
91
+ export default defineConfig({
92
+ plugins: [vue()],
93
+ build: {
94
+ chunkSizeWarningLimit: 500,
95
+ rolldownOptions: {
96
+ output: {
97
+ codeSplitting: {
98
+ groups: [
99
+ {
100
+ maxSize: 420 * 1024,
101
+ name: 'vendor-element-plus',
102
+ priority: 40,
103
+ test: /node_modules[\\\\/]element-plus[\\\\/]/,
104
+ },
105
+ {
106
+ maxSize: 420 * 1024,
107
+ name: 'vendor-vue',
108
+ priority: 30,
109
+ test: /node_modules[\\\\/](?:@vue|vue|vue-router)[\\\\/]/,
110
+ },
111
+ {
112
+ maxSize: 420 * 1024,
113
+ name: 'vendor-lumal',
114
+ priority: 20,
115
+ test: /[\\\\/]packages[\\\\/](?:core|icons)[\\\\/](?:dist|src)[\\\\/]/,
116
+ },
117
+ {
118
+ entriesAware: true,
119
+ maxSize: 420 * 1024,
120
+ name: 'vendor',
121
+ priority: 10,
122
+ test: /node_modules[\\\\/]/,
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ },
128
+ },
129
+ resolve: {
130
+ alias: {
131
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
132
+ },
133
+ },
134
+ })
135
+ `;
136
+ }
137
+ function createMainTs() {
138
+ return `import type { IconDefinition } from '@lumal/icons'
139
+ import { createLumalAdmin } from '@lumal/core'
140
+ import ElementPlus from 'element-plus'
141
+ import zhCn from 'element-plus/es/locale/lang/zh-cn'
142
+ import App from './App.vue'
143
+ import { router } from './router'
144
+ import { permissionStore } from './services/access'
145
+ import '@lumal/core/theme-chalk/index.scss'
146
+ import '@lumal/core/style.css'
147
+ import '@lumal/icons-vue/style.css'
148
+ import 'element-plus/dist/index.css'
149
+ import './styles.scss'
150
+
151
+ /***********************本地图标定义*********************/
152
+ const localIcons: IconDefinition[] = [
153
+ {
154
+ key: 'app:dashboard',
155
+ label: '控制台',
156
+ source: 'local-svg',
157
+ svgText: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>',
158
+ },
159
+ ]
160
+
161
+ /***********************应用启动*********************/
162
+ const framework = createLumalAdmin({
163
+ elementPlus: {
164
+ plugin: ElementPlus,
165
+ options: { locale: zhCn },
166
+ },
167
+ rootComponent: App,
168
+ router,
169
+ permissionStore,
170
+ icons: {
171
+ localSvg: localIcons,
172
+ },
173
+ })
174
+
175
+ await framework.mount('#app')
176
+ `;
177
+ }
178
+ function createHttpRequestTs() {
179
+ return `import { createRequestClient } from '@lumal/core/request'
180
+
181
+ const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
182
+
183
+ export const publicRequest = createRequestClient({ baseURL })
184
+
185
+ export function createAuthenticatedRequest(getToken: () => string | undefined) {
186
+ return createRequestClient({
187
+ baseURL,
188
+ getToken,
189
+ })
190
+ }
191
+ `;
192
+ }
193
+ function createHttpAuthApiTs() {
194
+ return `import { publicRequest } from './request'
195
+
196
+ export interface LoginInput {
197
+ password: string
198
+ username: string
199
+ }
200
+
201
+ export function login(input: LoginInput) {
202
+ return publicRequest.post('/auth/login', { body: { ...input } })
203
+ }
204
+
205
+ export function refreshSession(refreshToken: string) {
206
+ return publicRequest.post('/auth/refresh', { body: { refreshToken } })
207
+ }
208
+ `;
209
+ }
210
+ function createApiContractMd() {
211
+ return `# HTTP API 接入
212
+
213
+ 项目通过 \`VITE_API_BASE_URL\` 配置接口地址。业务项目应在应用层定义响应字段、状态码、登录字段和菜单字段映射,不要把业务协议写入 \`@lumal/core\`。
214
+
215
+ 建议后端至少提供:
216
+
217
+ - \`POST /auth/login\`
218
+ - \`POST /auth/refresh\`
219
+ - 当前用户和权限接口
220
+ - 动态菜单接口
221
+
222
+ 生产环境推荐使用同域 \`/api\` 反向代理;跨域部署时填写完整 HTTPS 地址。
223
+ `;
224
+ }
225
+ function createAppVue() {
226
+ return `<script setup lang="ts">
227
+ import type { LumalLayoutTabItem } from '@lumal/core/layout'
228
+ import type { LumalPreferences } from '@lumal/core/theme'
229
+ import type { RouteLocationNormalizedLoaded } from 'vue-router'
230
+ import {
231
+ findMenuItemByPath,
232
+ LumalLayout,
233
+ LumalRouterView,
234
+ } from '@lumal/core/layout'
235
+ import { computed, shallowRef, watch } from 'vue'
236
+ import { useRoute, useRouter } from 'vue-router'
237
+ import AppHeaderActions from './components/app/AppHeaderActions.vue'
238
+ import AppSettingsDrawer from './components/app/AppSettingsDrawer.vue'
239
+ import { createAdminSidebarMenus, createAdminTabs } from './router'
240
+ import {
241
+ adminPreferenceDefaults,
242
+ adminPreferences,
243
+ adminResolvedThemeMode,
244
+ patchAdminPreferences,
245
+ } from './services/preferences'
246
+ import { adminTabSnapshotStorageKey, currentUser, logout } from './services/session'
247
+
248
+ /***********************页面状态*********************/
249
+ const title = 'Lumal Admin'
250
+ const settingsVisible = shallowRef(false)
251
+ const routeRefreshKey = shallowRef(0)
252
+
253
+ /***********************路由状态*********************/
254
+ const route = useRoute()
255
+ const router = useRouter()
256
+ const isPublicLayout = computed(() => route.meta.layout === 'public')
257
+ const allMenus = computed(() => createAdminSidebarMenus())
258
+ const fixedTabs = computed<LumalLayoutTabItem[]>(() => createAdminTabs())
259
+ const routeViewKey = computed(() => \`\${route.fullPath}:\${routeRefreshKey.value}\`)
260
+ const routeViewCache = computed(() => adminPreferences.value.tabbar.enable && adminPreferences.value.tabbar.cache)
261
+ const activePath = computed({
262
+ get: () => route.path,
263
+ set: (path: string) => {
264
+ if (path !== route.path) {
265
+ void router.push(path)
266
+ }
267
+ },
268
+ })
269
+
270
+ watch([
271
+ () => route.meta.title,
272
+ () => adminPreferences.value.app.dynamicTitle,
273
+ ], () => {
274
+ const routeTitle = typeof route.meta.title === 'string' ? route.meta.title.trim() : ''
275
+ document.title = adminPreferences.value.app.dynamicTitle && routeTitle
276
+ ? \`\${routeTitle} - \${title}\`
277
+ : title
278
+ }, { immediate: true })
279
+
280
+ /***********************导航事件*********************/
281
+ function handleMenuSelect(path: string): void {
282
+ const item = findMenuItemByPath(allMenus.value, path)
283
+
284
+ if (item?.externalLink && item.externalTarget !== '_self') {
285
+ window.open(item.externalLink, item.externalTarget ?? '_blank', 'noopener,noreferrer')
286
+ return
287
+ }
288
+
289
+ activePath.value = path
290
+ }
291
+
292
+ function handleToggleSidebar(): void {
293
+ patchAdminPreferences({
294
+ sidebar: { collapsed: !adminPreferences.value.sidebar.collapsed },
295
+ })
296
+ }
297
+
298
+ function handleTabChange(path: string): void {
299
+ activePath.value = path
300
+ }
301
+
302
+ function resolveAdminRouteTab(currentRoute: RouteLocationNormalizedLoaded): LumalLayoutTabItem | undefined {
303
+ if (currentRoute.meta.layout === 'public' || currentRoute.meta.hideInTab === true) {
304
+ return undefined
305
+ }
306
+
307
+ const menu = findMenuItemByPath(allMenus.value, currentRoute.path)
308
+ const tabTitle = menu?.title ?? (typeof currentRoute.meta.title === 'string' ? currentRoute.meta.title : undefined)
309
+
310
+ if (!tabTitle) {
311
+ return undefined
312
+ }
313
+
314
+ return {
315
+ closable: !['/403', '/404'].includes(currentRoute.path),
316
+ icon: menu?.icon ?? (typeof currentRoute.meta.icon === 'string' ? currentRoute.meta.icon : undefined),
317
+ path: currentRoute.path,
318
+ title: tabTitle,
319
+ }
320
+ }
321
+
322
+ async function handleTabRefresh(path: string): Promise<void> {
323
+ if (path !== route.path) {
324
+ await router.push(path)
325
+ }
326
+
327
+ routeRefreshKey.value += 1
328
+ }
329
+
330
+ function handlePreferencesChange(preferences: LumalPreferences): void {
331
+ patchAdminPreferences(preferences)
332
+ }
333
+
334
+ function toggleTheme(): void {
335
+ patchAdminPreferences({
336
+ theme: {
337
+ mode: adminResolvedThemeMode.value === 'dark' ? 'light' : 'dark',
338
+ },
339
+ })
340
+ }
341
+
342
+ async function handleLogout(): Promise<void> {
343
+ await logout()
344
+ await router.replace('/login')
345
+ }
346
+ </script>
347
+
348
+ <template>
349
+ <LumalRouterView
350
+ v-if="isPublicLayout"
351
+ :view-key="routeViewKey"
352
+ transition
353
+ transition-name="fade"
354
+ />
355
+
356
+ <LumalLayout
357
+ v-else
358
+ v-model:active-tab-path="activePath"
359
+ :title="title"
360
+ :menus="allMenus"
361
+ :preferences="adminPreferences"
362
+ route-driven
363
+ :fixed-tabs="fixedTabs"
364
+ :route-tab-resolver="resolveAdminRouteTab"
365
+ tab-fallback-path="/dashboard"
366
+ :tab-storage-key="adminTabSnapshotStorageKey"
367
+ :active-menu-path="activePath"
368
+ @menu-select="handleMenuSelect"
369
+ @toggle-sidebar="handleToggleSidebar"
370
+ @tab-change="handleTabChange"
371
+ @tab-refresh="handleTabRefresh"
372
+ >
373
+ <template #headerActions>
374
+ <AppHeaderActions
375
+ :resolved-theme-mode="adminResolvedThemeMode"
376
+ :user-name="currentUser?.name"
377
+ @logout="handleLogout"
378
+ @open-settings="settingsVisible = true"
379
+ @toggle-theme="toggleTheme"
380
+ />
381
+ </template>
382
+
383
+ <LumalRouterView
384
+ :view-key="routeViewKey"
385
+ :progress="true"
386
+ :loading="true"
387
+ :cache="routeViewCache"
388
+ :cache-max="adminPreferences.tabbar.maxCount"
389
+ :transition="adminPreferences.transition.enable"
390
+ :transition-name="adminPreferences.transition.name"
391
+ />
392
+ </LumalLayout>
393
+
394
+ <AppSettingsDrawer
395
+ v-model:visible="settingsVisible"
396
+ :defaults="adminPreferenceDefaults"
397
+ :preferences="adminPreferences"
398
+ @change="handlePreferencesChange"
399
+ @reset="handlePreferencesChange"
400
+ @update:preferences="handlePreferencesChange"
401
+ />
402
+ </template>
403
+ `;
404
+ }
405
+ function createRouterTs() {
406
+ return `import type { LumalLayoutTabItem } from '@lumal/core/layout'
407
+ import type { LumalStaticMenuRecord, MenuRouteRuntime, RouteRegistryRouterLike, SidebarMenuItem } from '@lumal/core/router'
408
+ import type { Router, RouteRecordRaw, RouterHistory } from 'vue-router'
409
+ import { setupPermissionGuard } from '@lumal/core/permission'
410
+ import { createMenuRouteRuntime } from '@lumal/core/router'
411
+ import { createRouter, createWebHashHistory } from 'vue-router'
412
+ import { permissionStore } from '../services/access'
413
+ import { isAuthenticated } from '../services/session'
414
+ import LoginView from '../views/auth/LoginView.vue'
415
+ import DashboardView from '../views/dashboard/DashboardView.vue'
416
+ import ForbiddenView from '../views/error/ForbiddenView.vue'
417
+ import NotFoundView from '../views/error/NotFoundView.vue'
418
+ import ProjectView from '../views/project/ProjectView.vue'
419
+
420
+ /***********************菜单配置*********************/
421
+ export const adminRouteRecords: LumalStaticMenuRecord[] = [
422
+ {
423
+ component: DashboardView,
424
+ name: 'Dashboard',
425
+ path: '/dashboard',
426
+ meta: {
427
+ authority: ['dashboard:view'],
428
+ icon: 'app:dashboard',
429
+ order: 1,
430
+ title: '工作台',
431
+ },
432
+ },
433
+ {
434
+ component: ProjectView,
435
+ name: 'Project',
436
+ path: '/project',
437
+ meta: {
438
+ authority: ['project:list'],
439
+ icon: 'app:dashboard',
440
+ order: 2,
441
+ title: '项目管理',
442
+ },
443
+ },
444
+ ]
445
+
446
+ const menuRuntimes = new WeakMap<Router, MenuRouteRuntime>()
447
+
448
+ /***********************菜单生成*********************/
449
+ function hasPermission(permissions: string[]): boolean {
450
+ return permissionStore.hasPermission(permissions, 'every')
451
+ }
452
+
453
+ function hasRole(roles: string[]): boolean {
454
+ return permissionStore.hasRole(roles, 'every')
455
+ }
456
+
457
+ function flattenMenuTabs(menus: SidebarMenuItem[]): LumalLayoutTabItem[] {
458
+ return menus.flatMap((menu) => {
459
+ if (menu.children.length > 0) {
460
+ return flattenMenuTabs(menu.children)
461
+ }
462
+
463
+ return {
464
+ icon: menu.icon,
465
+ path: menu.path,
466
+ title: menu.title,
467
+ }
468
+ })
469
+ }
470
+
471
+ export function createAdminSidebarMenus(): SidebarMenuItem[] {
472
+ return menuRuntimes.get(router)?.sidebarMenus ?? []
473
+ }
474
+
475
+ /**
476
+ * 路由驱动模式下的固定页签:仅返回首页,其余页签由 LumalLayout 依据
477
+ * routeTabResolver 在导航时动态追加,并按 tabStorageKey 做会话级持久化。
478
+ */
479
+ export function createAdminTabs(): LumalLayoutTabItem[] {
480
+ const menuTabs = flattenMenuTabs(createAdminSidebarMenus())
481
+ const homeTab = menuTabs.find(tab => tab.path === '/dashboard') ?? menuTabs[0]
482
+
483
+ return homeTab ? [{ ...homeTab, closable: false }] : []
484
+ }
485
+
486
+ /***********************路由创建*********************/
487
+ function createPublicRoutes(resolveHome: () => string): RouteRecordRaw[] {
488
+ return [
489
+ {
490
+ component: LoginView,
491
+ meta: { layout: 'public', requireLogin: false, title: '登录' },
492
+ name: 'Login',
493
+ path: '/login',
494
+ },
495
+ {
496
+ path: '/',
497
+ redirect: () => resolveHome(),
498
+ },
499
+ {
500
+ component: ForbiddenView,
501
+ meta: { title: '无权限' },
502
+ name: 'Forbidden',
503
+ path: '/403',
504
+ },
505
+ {
506
+ component: NotFoundView,
507
+ meta: { title: '页面不存在' },
508
+ name: 'NotFound',
509
+ path: '/:pathMatch(.*)*',
510
+ },
511
+ ]
512
+ }
513
+
514
+ export function createAdminRouter(history: RouterHistory = createWebHashHistory()): Router {
515
+ let menuRuntime: MenuRouteRuntime | undefined
516
+ const targetRouter = createRouter({
517
+ history,
518
+ routes: createPublicRoutes(() => menuRuntime?.firstAccessiblePath || '/403'),
519
+ })
520
+
521
+ menuRuntime = createMenuRouteRuntime({
522
+ hasPermission,
523
+ hasRole,
524
+ router: {
525
+ addRoute: route => targetRouter.addRoute(route as RouteRecordRaw),
526
+ getRoutes: () => targetRouter.getRoutes(),
527
+ hasRoute: name => targetRouter.hasRoute(name),
528
+ removeRoute: name => targetRouter.removeRoute(name),
529
+ } satisfies RouteRegistryRouterLike,
530
+ staticMenus: adminRouteRecords,
531
+ })
532
+ menuRuntimes.set(targetRouter, menuRuntime)
533
+
534
+ setupPermissionGuard(targetRouter, permissionStore, {
535
+ isAuthenticated,
536
+ loginPath: '/login',
537
+ mode: 'every',
538
+ noAccessRedirect: '/403',
539
+ requireLoginByDefault: true,
540
+ roleMode: 'every',
541
+ whiteList: ['/login'],
542
+ })
543
+
544
+ return targetRouter
545
+ }
546
+
547
+ export const router = createAdminRouter()
548
+ `;
549
+ }
550
+ function createDashboardViewVue() {
551
+ return `<script setup lang="ts">
552
+ import { LumalPage } from '@lumal/core/components'
553
+ import { onMounted } from 'vue'
554
+ import RequestExamplePanel from '../../components/request/RequestExamplePanel.vue'
555
+ import { useMockRequestExample } from '../../composables/useMockRequestExample'
556
+
557
+ const metrics = [
558
+ { label: '待处理任务', value: 12, description: '今日需要跟进的事项' },
559
+ { label: '运行服务', value: 6, description: '当前在线的业务服务' },
560
+ { label: '本周发布', value: 3, description: '已完成的版本发布' },
561
+ { label: '系统告警', value: 0, description: '当前无未处理告警' },
562
+ ]
563
+
564
+ /***********************请求示例*********************/
565
+ const {
566
+ authorizationHeader,
567
+ lastUrl,
568
+ loadProjectSummary,
569
+ loading: requestLoading,
570
+ message: requestMessage,
571
+ projectName,
572
+ projectStatus,
573
+ sessionExpiredCount,
574
+ status: requestStatus,
575
+ tokenInjected,
576
+ } = useMockRequestExample()
577
+
578
+ onMounted(() => {
579
+ void loadProjectSummary()
580
+ })
581
+
582
+ </script>
583
+
584
+ <template>
585
+ <main class="lumal-admin-home">
586
+ <LumalPage
587
+ title="工作台"
588
+ description="这是脚手架生成的轻量后台首页,可替换为你的业务指标和快捷入口。"
589
+ >
590
+ <section class="lumal-admin-home__metrics" aria-label="工作台指标">
591
+ <article v-for="metric in metrics" :key="metric.label" class="lumal-admin-home__metric">
592
+ <span>{{ metric.label }}</span>
593
+ <strong>{{ metric.value }}</strong>
594
+ <small>{{ metric.description }}</small>
595
+ </article>
596
+ </section>
597
+ </LumalPage>
598
+
599
+ <RequestExamplePanel
600
+ :authorization-header="authorizationHeader"
601
+ :last-url="lastUrl"
602
+ :loading="requestLoading"
603
+ :message="requestMessage"
604
+ :project-name="projectName"
605
+ :project-status="projectStatus"
606
+ :session-expired-count="sessionExpiredCount"
607
+ :status="requestStatus"
608
+ :token-injected="tokenInjected"
609
+ @refresh="loadProjectSummary"
610
+ />
611
+ </main>
612
+ </template>
613
+ `;
614
+ }
615
+ function createProjectViewVue() {
616
+ return `<script setup lang="ts">
617
+ import { LumalPage } from '@lumal/core/components'
618
+
619
+ /***********************页面数据*********************/
620
+ const features = [
621
+ '菜单节点来自后端菜单格式',
622
+ '路由记录由 @lumal/core/router 生成',
623
+ '权限不足会通过守卫进入 403 页面',
624
+ ]
625
+ </script>
626
+
627
+ <template>
628
+ <main class="lumal-admin-page">
629
+ <LumalPage
630
+ title="项目管理"
631
+ description="这是一个需要 project:list 权限的路由页面。"
632
+ >
633
+ <ul class="lumal-admin-page__list">
634
+ <li
635
+ v-for="feature in features"
636
+ :key="feature"
637
+ class="lumal-admin-page__item"
638
+ >
639
+ {{ feature }}
640
+ </li>
641
+ </ul>
642
+ </LumalPage>
643
+ </main>
644
+ </template>
645
+ `;
646
+ }
647
+ function createForbiddenViewVue() {
648
+ return `<script setup lang="ts">
649
+ import { LumalPage } from '@lumal/core/components'
650
+ import { ElButton } from 'element-plus'
651
+ import { useRouter } from 'vue-router'
652
+
653
+ /***********************路由操作*********************/
654
+ const router = useRouter()
655
+
656
+ function handleBackDashboardClick(): void {
657
+ void router.push('/dashboard')
658
+ }
659
+ </script>
660
+
661
+ <template>
662
+ <main class="lumal-admin-page">
663
+ <LumalPage
664
+ title="无权限"
665
+ description="当前账号没有访问该页面所需的权限。"
666
+ >
667
+ <ElButton type="primary" @click="handleBackDashboardClick">
668
+ 返回工作台
669
+ </ElButton>
670
+ </LumalPage>
671
+ </main>
672
+ </template>
673
+ `;
674
+ }
675
+ function createNotFoundViewVue() {
676
+ return `<script setup lang="ts">
677
+ import { LumalPage } from '@lumal/core/components'
678
+ import { ElButton } from 'element-plus'
679
+ import { useRouter } from 'vue-router'
680
+
681
+ const router = useRouter()
682
+ </script>
683
+
684
+ <template>
685
+ <main class="lumal-admin-page">
686
+ <LumalPage title="页面不存在" description="请检查地址,或返回工作台继续操作。">
687
+ <ElButton type="primary" @click="router.push('/dashboard')">
688
+ 返回工作台
689
+ </ElButton>
690
+ </LumalPage>
691
+ </main>
692
+ </template>
693
+ `;
694
+ }
695
+ function createLoginViewVue() {
696
+ return `<script setup lang="ts">
697
+ import type { FormInstance } from 'element-plus'
698
+ import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus'
699
+ import { reactive, shallowRef, useTemplateRef } from 'vue'
700
+ import { useRoute, useRouter } from 'vue-router'
701
+ import { login } from '../../services/session'
702
+
703
+ const route = useRoute()
704
+ const router = useRouter()
705
+ const formRef = useTemplateRef<FormInstance>('formRef')
706
+ const loading = shallowRef(false)
707
+ const errorMessage = shallowRef('')
708
+ const form = reactive({ username: 'admin', password: 'lumal123' })
709
+
710
+ function resolveRedirect(): string {
711
+ const redirect = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect
712
+ return redirect && redirect.startsWith('/') && !redirect.startsWith('/login') ? redirect : '/dashboard'
713
+ }
714
+
715
+ async function handleSubmit(): Promise<void> {
716
+ if (loading.value) {
717
+ return
718
+ }
719
+
720
+ loading.value = true
721
+ errorMessage.value = ''
722
+
723
+ try {
724
+ const valid = await formRef.value?.validate()
725
+ if (valid === false) {
726
+ return
727
+ }
728
+
729
+ await login(form)
730
+ await router.replace(resolveRedirect())
731
+ }
732
+ catch (error) {
733
+ errorMessage.value = error instanceof Error ? error.message : '登录失败'
734
+ }
735
+ finally {
736
+ loading.value = false
737
+ }
738
+ }
739
+ </script>
740
+
741
+ <template>
742
+ <main class="lumal-admin-login">
743
+ <section class="lumal-admin-login__panel">
744
+ <div class="lumal-admin-login__brand" aria-hidden="true">L</div>
745
+ <h1>Lumal Admin</h1>
746
+ <p>轻量后台基座</p>
747
+
748
+ <ElForm ref="formRef" :model="form" label-position="top" @submit.prevent="handleSubmit">
749
+ <ElFormItem label="用户名" prop="username" :rules="[{ required: true, message: '请输入用户名' }]">
750
+ <ElInput v-model="form.username" autocomplete="username" />
751
+ </ElFormItem>
752
+ <ElFormItem label="密码" prop="password" :rules="[{ required: true, message: '请输入密码' }]">
753
+ <ElInput v-model="form.password" type="password" show-password autocomplete="current-password" />
754
+ </ElFormItem>
755
+ <p v-if="errorMessage" class="lumal-admin-login__error" role="alert">
756
+ {{ errorMessage }}
757
+ </p>
758
+ <ElButton native-type="submit" type="primary" :loading="loading">
759
+ 登录
760
+ </ElButton>
761
+ </ElForm>
762
+
763
+ <small>演示账号:admin / lumal123</small>
764
+ </section>
765
+ </main>
766
+ </template>
767
+ `;
768
+ }
769
+ function createAccessTs() {
770
+ return `import { createPermissionStore } from '@lumal/core/permission'
771
+
772
+ export const permissionStore = createPermissionStore()
773
+
774
+ export function syncAccess(authenticated: boolean): void {
775
+ if (!authenticated) {
776
+ permissionStore.clear()
777
+ return
778
+ }
779
+
780
+ permissionStore.setPermissions(['dashboard:view', 'project:list'])
781
+ permissionStore.setRoles(['admin'])
782
+ }
783
+ `;
784
+ }
785
+ function createSessionTs() {
786
+ return `import { createAuthSession } from '@lumal/core/auth'
787
+ import { readonly, shallowRef } from 'vue'
788
+ import { syncAccess } from './access'
789
+
790
+ export interface LoginPayload {
791
+ password: string
792
+ username: string
793
+ }
794
+
795
+ export interface AdminUser {
796
+ name: string
797
+ username: string
798
+ }
799
+
800
+ const userStorageKey = 'lumal-admin:user'
801
+ export const adminTabSnapshotStorageKey = 'lumal-admin:tabs'
802
+ const currentUserState = shallowRef<AdminUser | null>(null)
803
+
804
+ function readStoredUser(): AdminUser | null {
805
+ const raw = localStorage.getItem(userStorageKey)
806
+ if (!raw) {
807
+ return null
808
+ }
809
+
810
+ try {
811
+ return JSON.parse(raw) as AdminUser
812
+ }
813
+ catch {
814
+ localStorage.removeItem(userStorageKey)
815
+ return null
816
+ }
817
+ }
818
+
819
+ function clearSessionState(): void {
820
+ currentUserState.value = null
821
+ localStorage.removeItem(userStorageKey)
822
+ try {
823
+ sessionStorage.removeItem(adminTabSnapshotStorageKey)
824
+ }
825
+ catch {
826
+ // sessionStorage 不可用时静默跳过。
827
+ }
828
+ syncAccess(false)
829
+ }
830
+
831
+ export const session = createAuthSession({
832
+ onSessionExpired: clearSessionState,
833
+ tokenKey: 'lumal-admin:token',
834
+ })
835
+
836
+ const storedUser = session.isAuthenticated() ? readStoredUser() : null
837
+ currentUserState.value = storedUser
838
+ syncAccess(Boolean(storedUser))
839
+
840
+ export const currentUser = readonly(currentUserState)
841
+
842
+ export function isAuthenticated(): boolean {
843
+ return session.isAuthenticated() && Boolean(currentUserState.value)
844
+ }
845
+
846
+ export async function login(payload: LoginPayload): Promise<AdminUser> {
847
+ await Promise.resolve()
848
+ if (payload.username !== 'admin' || payload.password !== 'lumal123') {
849
+ throw new Error('用户名或密码错误')
850
+ }
851
+
852
+ const user = { name: '管理员', username: payload.username }
853
+ session.setSession({ accessToken: 'lumal-demo-token' })
854
+ currentUserState.value = user
855
+ localStorage.setItem(userStorageKey, JSON.stringify(user))
856
+ syncAccess(true)
857
+ return user
858
+ }
859
+
860
+ export async function logout(): Promise<void> {
861
+ await session.logout()
862
+ }
863
+ `;
864
+ }
865
+ function createPreferencesTs() {
866
+ return `import type { LumalPreferencesDefaults } from '@lumal/core/theme'
867
+ import { createPreferencesStore } from '@lumal/core/theme'
868
+
869
+ export const adminPreferenceDefaults = {
870
+ app: { dynamicTitle: true, layout: 'sidebar-nav' },
871
+ breadcrumb: { enable: true, hideOnlyOne: false, showHome: true, showIcon: true },
872
+ header: { globalSearch: true },
873
+ shortcutKeys: { globalSearch: true },
874
+ sidebar: { width: 220 },
875
+ tabbar: {
876
+ cache: true,
877
+ draggable: true,
878
+ enable: true,
879
+ maxCount: 0,
880
+ middleClickToClose: true,
881
+ persist: true,
882
+ showIcon: true,
883
+ showMaximize: true,
884
+ showMore: true,
885
+ showRefresh: true,
886
+ styleType: 'chrome',
887
+ visitHistory: true,
888
+ wheelable: true,
889
+ },
890
+ theme: { fontSize: 14 },
891
+ transition: { enable: true, name: 'fade-side' },
892
+ } satisfies LumalPreferencesDefaults
893
+
894
+ const preferencesStore = createPreferencesStore({
895
+ defaults: adminPreferenceDefaults,
896
+ storage: localStorage,
897
+ storageKey: 'lumal-admin:preferences',
898
+ })
899
+
900
+ export const adminPreferences = preferencesStore.state
901
+ export const adminResolvedThemeMode = preferencesStore.resolvedThemeMode
902
+
903
+ export function patchAdminPreferences(preferences: Parameters<typeof preferencesStore.patch>[0]): void {
904
+ preferencesStore.patch(preferences)
905
+ }
906
+ `;
907
+ }
908
+ function createHeaderActionsVue() {
909
+ return `<script setup lang="ts">
910
+ import type { ResolvedThemeMode } from '@lumal/core/theme'
911
+ import { ElButton } from 'element-plus'
912
+
913
+ withDefaults(defineProps<{
914
+ resolvedThemeMode: ResolvedThemeMode
915
+ userName?: string
916
+ }>(), {
917
+ userName: '管理员',
918
+ })
919
+
920
+ defineEmits<{
921
+ logout: []
922
+ openSettings: []
923
+ toggleTheme: []
924
+ }>()
925
+ </script>
926
+
927
+ <template>
928
+ <div class="lumal-admin-header-actions">
929
+ <ElButton text :aria-label="resolvedThemeMode === 'dark' ? '切换浅色模式' : '切换深色模式'" @click="$emit('toggleTheme')">
930
+ 主题
931
+ </ElButton>
932
+ <ElButton text aria-label="偏好设置" @click="$emit('openSettings')">
933
+ 设置
934
+ </ElButton>
935
+ <span>{{ userName }}</span>
936
+ <ElButton text aria-label="退出登录" @click="$emit('logout')">
937
+ 退出
938
+ </ElButton>
939
+ </div>
940
+ </template>
941
+ `;
942
+ }
943
+ function createSettingsDrawerVue() {
944
+ return `<script setup lang="ts">
945
+ import type { LumalPreferences, LumalPreferencesDefaults } from '@lumal/core/theme'
946
+ import { LumalThemeSettingsPanel } from '@lumal/core/theme'
947
+ import { ElDrawer } from 'element-plus'
948
+ import { computed } from 'vue'
949
+
950
+ const props = defineProps<{
951
+ defaults: LumalPreferencesDefaults
952
+ preferences: LumalPreferences
953
+ visible: boolean
954
+ }>()
955
+
956
+ const emit = defineEmits<{
957
+ change: [preferences: LumalPreferences]
958
+ reset: [preferences: LumalPreferences]
959
+ 'update:preferences': [preferences: LumalPreferences]
960
+ 'update:visible': [visible: boolean]
961
+ }>()
962
+
963
+ const visible = computed({
964
+ get: () => props.visible,
965
+ set: value => emit('update:visible', value),
966
+ })
967
+ </script>
968
+
969
+ <template>
970
+ <ElDrawer v-model="visible" title="偏好设置" size="384px" append-to-body>
971
+ <LumalThemeSettingsPanel
972
+ :defaults="defaults"
973
+ :preferences="preferences"
974
+ @change="$emit('change', $event)"
975
+ @reset="$emit('reset', $event)"
976
+ @update:preferences="$emit('update:preferences', $event)"
977
+ />
978
+ </ElDrawer>
979
+ </template>
980
+ `;
981
+ }
982
+ function createRequestExampleTs() {
983
+ return `import type { RequestContext } from '@lumal/core/request'
984
+ import { createRequestClient } from '@lumal/core/request'
985
+ import { computed, readonly, shallowRef } from 'vue'
986
+
987
+ export type RequestExampleStatus = 'error' | 'idle' | 'loading' | 'success'
988
+
989
+ interface ProjectSummary {
990
+ id: string
991
+ name: string
992
+ status: 'disabled' | 'enabled'
993
+ updatedAt: string
994
+ }
995
+
996
+ interface ApiResponse<TData> {
997
+ code: number
998
+ data: TData
999
+ message: string
1000
+ }
1001
+
1002
+ /***********************Mock 服务*********************/
1003
+ function createJsonResponse<TData>(payload: ApiResponse<TData>, init?: ResponseInit): Response {
1004
+ return new Response(JSON.stringify(payload), {
1005
+ headers: {
1006
+ 'content-type': 'application/json',
1007
+ },
1008
+ status: 200,
1009
+ ...init,
1010
+ })
1011
+ }
1012
+
1013
+ function resolveRequestUrl(input: Parameters<typeof fetch>[0]): string {
1014
+ if (typeof input === 'string') {
1015
+ return input
1016
+ }
1017
+
1018
+ if (input instanceof URL) {
1019
+ return input.toString()
1020
+ }
1021
+
1022
+ return input.url
1023
+ }
1024
+
1025
+ /***********************请求示例*********************/
1026
+ export function useMockRequestExample() {
1027
+ const status = shallowRef<RequestExampleStatus>('idle')
1028
+ const message = shallowRef('等待发起请求')
1029
+ const lastUrl = shallowRef('-')
1030
+ const authorizationHeader = shallowRef('未请求')
1031
+ const sessionExpiredCount = shallowRef(0)
1032
+ const project = shallowRef<ProjectSummary | null>(null)
1033
+
1034
+ const mockFetch: typeof fetch = async (input, init) => {
1035
+ lastUrl.value = resolveRequestUrl(input)
1036
+ authorizationHeader.value = new Headers(init?.headers).get('Authorization') ?? '未注入'
1037
+
1038
+ return createJsonResponse({
1039
+ code: 0,
1040
+ data: {
1041
+ id: 'demo-001',
1042
+ name: 'Lumal 示例项目',
1043
+ status: 'enabled',
1044
+ updatedAt: '2026-07-09 21:00',
1045
+ },
1046
+ message: 'ok',
1047
+ })
1048
+ }
1049
+
1050
+ const request = createRequestClient({
1051
+ baseURL: '/mock-api',
1052
+ fetch: mockFetch,
1053
+ getToken: () => 'mock-token-001',
1054
+ onResponse: <TResult>(context: RequestContext) => {
1055
+ const payload = context.data as ApiResponse<ProjectSummary>
1056
+
1057
+ if (payload.code !== 0) {
1058
+ throw new Error(payload.message || '请求失败')
1059
+ }
1060
+
1061
+ return payload.data as TResult
1062
+ },
1063
+ onSessionExpired: () => {
1064
+ sessionExpiredCount.value += 1
1065
+ },
1066
+ })
1067
+
1068
+ const loading = computed(() => status.value === 'loading')
1069
+ const projectName = computed(() => project.value?.name ?? '暂无数据')
1070
+ const projectStatus = computed(() => project.value?.status === 'enabled' ? '启用' : '停用')
1071
+ const tokenInjected = computed(() => authorizationHeader.value.startsWith('Bearer '))
1072
+
1073
+ async function loadProjectSummary(): Promise<void> {
1074
+ status.value = 'loading'
1075
+ message.value = '正在请求项目摘要'
1076
+
1077
+ try {
1078
+ project.value = await request.get<ProjectSummary>('/projects/summary', {
1079
+ query: {
1080
+ scene: 'admin-demo',
1081
+ },
1082
+ })
1083
+ status.value = 'success'
1084
+ message.value = '请求成功,已完成 token 注入和响应解析'
1085
+ }
1086
+ catch (error) {
1087
+ status.value = 'error'
1088
+ message.value = error instanceof Error ? error.message : '请求失败'
1089
+ }
1090
+ }
1091
+
1092
+ return {
1093
+ authorizationHeader: readonly(authorizationHeader),
1094
+ lastUrl: readonly(lastUrl),
1095
+ loadProjectSummary,
1096
+ loading,
1097
+ message: readonly(message),
1098
+ project: readonly(project),
1099
+ projectName,
1100
+ projectStatus,
1101
+ sessionExpiredCount: readonly(sessionExpiredCount),
1102
+ status: readonly(status),
1103
+ tokenInjected,
1104
+ }
1105
+ }
1106
+ `;
1107
+ }
1108
+ function createRequestPanelVue() {
1109
+ return `<script setup lang="ts">
1110
+ import type { RequestExampleStatus } from '../../composables/useMockRequestExample'
1111
+ import { ElButton, ElTag } from 'element-plus'
1112
+ import { computed } from 'vue'
1113
+
1114
+ interface Props {
1115
+ authorizationHeader: string
1116
+ lastUrl: string
1117
+ loading: boolean
1118
+ message: string
1119
+ projectName: string
1120
+ projectStatus: string
1121
+ sessionExpiredCount: number
1122
+ status: RequestExampleStatus
1123
+ tokenInjected: boolean
1124
+ }
1125
+
1126
+ interface Emits {
1127
+ refresh: []
1128
+ }
1129
+
1130
+ const props = defineProps<Props>()
1131
+ const emit = defineEmits<Emits>()
1132
+
1133
+ /***********************展示状态*********************/
1134
+ const statusLabel = computed(() => {
1135
+ const labels: Record<RequestExampleStatus, string> = {
1136
+ error: '失败',
1137
+ idle: '待请求',
1138
+ loading: '请求中',
1139
+ success: '成功',
1140
+ }
1141
+
1142
+ return labels[props.status]
1143
+ })
1144
+
1145
+ const statusType = computed(() => props.status === 'error' ? 'danger' : props.status === 'success' ? 'success' : 'info')
1146
+ </script>
1147
+
1148
+ <template>
1149
+ <section class="lumal-request-panel">
1150
+ <div class="lumal-request-panel__header">
1151
+ <div class="lumal-request-panel__title-group">
1152
+ <span class="lumal-request-panel__eyebrow">Request Client</span>
1153
+ <h2 class="lumal-request-panel__title">
1154
+ 请求封装示例
1155
+ </h2>
1156
+ </div>
1157
+
1158
+ <ElButton
1159
+ type="primary"
1160
+ :loading="loading"
1161
+ @click="emit('refresh')"
1162
+ >
1163
+ 重新请求
1164
+ </ElButton>
1165
+ </div>
1166
+
1167
+ <div class="lumal-request-panel__meta">
1168
+ <div class="lumal-request-panel__item">
1169
+ <span class="lumal-request-panel__label">状态</span>
1170
+ <ElTag :type="statusType">
1171
+ {{ statusLabel }}
1172
+ </ElTag>
1173
+ </div>
1174
+ <div class="lumal-request-panel__item">
1175
+ <span class="lumal-request-panel__label">项目</span>
1176
+ <strong class="lumal-request-panel__value">{{ projectName }}</strong>
1177
+ </div>
1178
+ <div class="lumal-request-panel__item">
1179
+ <span class="lumal-request-panel__label">业务状态</span>
1180
+ <strong class="lumal-request-panel__value">{{ projectStatus }}</strong>
1181
+ </div>
1182
+ <div class="lumal-request-panel__item">
1183
+ <span class="lumal-request-panel__label">Token</span>
1184
+ <strong class="lumal-request-panel__value">{{ tokenInjected ? '已注入' : '未注入' }}</strong>
1185
+ </div>
1186
+ </div>
1187
+
1188
+ <dl class="lumal-request-panel__details">
1189
+ <div class="lumal-request-panel__detail">
1190
+ <dt class="lumal-request-panel__detail-label">
1191
+ 请求地址
1192
+ </dt>
1193
+ <dd class="lumal-request-panel__detail-value">
1194
+ {{ lastUrl }}
1195
+ </dd>
1196
+ </div>
1197
+ <div class="lumal-request-panel__detail">
1198
+ <dt class="lumal-request-panel__detail-label">
1199
+ Authorization
1200
+ </dt>
1201
+ <dd class="lumal-request-panel__detail-value">
1202
+ {{ authorizationHeader }}
1203
+ </dd>
1204
+ </div>
1205
+ <div class="lumal-request-panel__detail">
1206
+ <dt class="lumal-request-panel__detail-label">
1207
+ 会话过期次数
1208
+ </dt>
1209
+ <dd class="lumal-request-panel__detail-value">
1210
+ {{ sessionExpiredCount }}
1211
+ </dd>
1212
+ </div>
1213
+ </dl>
1214
+
1215
+ <p class="lumal-request-panel__message">
1216
+ {{ message }}
1217
+ </p>
1218
+ </section>
1219
+ </template>
1220
+
1221
+ <style scoped lang="scss">
1222
+ .lumal-request-panel {
1223
+ box-sizing: border-box;
1224
+ width: min(920px, 100%);
1225
+ padding: 24px;
1226
+ border: 1px solid var(--el-border-color-lighter);
1227
+ border-radius: 8px;
1228
+ background: var(--el-bg-color);
1229
+ box-shadow: var(--lumal-shadow-card);
1230
+ }
1231
+
1232
+ .lumal-request-panel__header {
1233
+ display: flex;
1234
+ align-items: center;
1235
+ justify-content: space-between;
1236
+ gap: 16px;
1237
+ }
1238
+
1239
+ .lumal-request-panel__title-group {
1240
+ display: grid;
1241
+ gap: 6px;
1242
+ }
1243
+
1244
+ .lumal-request-panel__eyebrow {
1245
+ color: var(--el-color-primary);
1246
+ font-size: 12px;
1247
+ font-weight: 700;
1248
+ letter-spacing: 0;
1249
+ text-transform: uppercase;
1250
+ }
1251
+
1252
+ .lumal-request-panel__title {
1253
+ margin: 0;
1254
+ color: var(--el-text-color-primary);
1255
+ font-size: 20px;
1256
+ font-weight: 700;
1257
+ }
1258
+
1259
+ .lumal-request-panel__meta {
1260
+ display: grid;
1261
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1262
+ gap: 12px;
1263
+ margin-top: 20px;
1264
+ }
1265
+
1266
+ .lumal-request-panel__item {
1267
+ display: grid;
1268
+ gap: 8px;
1269
+ min-width: 0;
1270
+ padding: 14px;
1271
+ border: 1px solid var(--el-border-color-lighter);
1272
+ border-radius: 6px;
1273
+ background: var(--el-fill-color-light);
1274
+ }
1275
+
1276
+ .lumal-request-panel__label,
1277
+ .lumal-request-panel__detail-label {
1278
+ color: var(--el-text-color-secondary);
1279
+ font-size: 12px;
1280
+ }
1281
+
1282
+ .lumal-request-panel__value {
1283
+ overflow: hidden;
1284
+ color: var(--el-text-color-primary);
1285
+ font-size: 14px;
1286
+ text-overflow: ellipsis;
1287
+ white-space: nowrap;
1288
+ }
1289
+
1290
+ .lumal-request-panel__details {
1291
+ display: grid;
1292
+ gap: 10px;
1293
+ margin: 18px 0 0;
1294
+ }
1295
+
1296
+ .lumal-request-panel__detail {
1297
+ display: grid;
1298
+ grid-template-columns: 132px minmax(0, 1fr);
1299
+ gap: 12px;
1300
+ align-items: center;
1301
+ }
1302
+
1303
+ .lumal-request-panel__detail-value {
1304
+ min-width: 0;
1305
+ margin: 0;
1306
+ overflow-wrap: anywhere;
1307
+ color: var(--el-text-color-regular);
1308
+ font-family:
1309
+ "JetBrains Mono",
1310
+ Consolas,
1311
+ monospace;
1312
+ font-size: 13px;
1313
+ }
1314
+
1315
+ .lumal-request-panel__message {
1316
+ margin: 18px 0 0;
1317
+ color: var(--el-text-color-secondary);
1318
+ font-size: 13px;
1319
+ }
1320
+
1321
+ @media (max-width: 760px) {
1322
+ .lumal-request-panel__header {
1323
+ align-items: flex-start;
1324
+ flex-direction: column;
1325
+ }
1326
+
1327
+ .lumal-request-panel__meta {
1328
+ grid-template-columns: 1fr;
1329
+ }
1330
+
1331
+ .lumal-request-panel__detail {
1332
+ grid-template-columns: 1fr;
1333
+ }
1334
+ }
1335
+ </style>
1336
+ `;
1337
+ }
1338
+ function createStylesScss() {
1339
+ return `:root {
1340
+ color: var(--el-text-color-primary);
1341
+ background: var(--el-bg-color-page);
1342
+ font-family:
1343
+ Inter,
1344
+ "Segoe UI",
1345
+ "Microsoft YaHei",
1346
+ Arial,
1347
+ sans-serif;
1348
+ }
1349
+
1350
+ body {
1351
+ margin: 0;
1352
+ min-width: 320px;
1353
+ overflow-x: hidden;
1354
+ }
1355
+
1356
+ .lumal-admin-home {
1357
+ display: grid;
1358
+ gap: var(--lumal-page-gutter, 20px);
1359
+ box-sizing: border-box;
1360
+ padding: var(--lumal-page-gutter, 20px);
1361
+ }
1362
+
1363
+ .lumal-admin-home__metrics {
1364
+ display: grid;
1365
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1366
+ gap: 16px;
1367
+ }
1368
+
1369
+ .lumal-admin-home__metric {
1370
+ display: grid;
1371
+ min-width: 0;
1372
+ gap: 8px;
1373
+ padding: 18px;
1374
+ border: 1px solid var(--el-border-color-lighter);
1375
+ border-radius: var(--lumal-radius-base, 8px);
1376
+ background: var(--el-bg-color);
1377
+ }
1378
+
1379
+ .lumal-admin-home__metric strong {
1380
+ color: var(--el-color-primary);
1381
+ font-size: 28px;
1382
+ }
1383
+
1384
+ .lumal-admin-home__metric small {
1385
+ color: var(--el-text-color-secondary);
1386
+ }
1387
+
1388
+ .lumal-admin-page {
1389
+ box-sizing: border-box;
1390
+ padding: var(--lumal-page-gutter, 20px);
1391
+ }
1392
+
1393
+ .lumal-admin-page__list {
1394
+ display: grid;
1395
+ gap: 10px;
1396
+ margin: 0;
1397
+ padding-left: 18px;
1398
+ }
1399
+
1400
+ .lumal-admin-page__item {
1401
+ color: var(--el-text-color-regular);
1402
+ font-size: 14px;
1403
+ line-height: 1.6;
1404
+ }
1405
+
1406
+ .lumal-admin-header-actions {
1407
+ display: flex;
1408
+ min-width: 0;
1409
+ align-items: center;
1410
+ gap: 4px;
1411
+ }
1412
+
1413
+ .lumal-admin-header-actions .el-button {
1414
+ min-width: 44px;
1415
+ min-height: 44px;
1416
+ margin: 0;
1417
+ }
1418
+
1419
+ .lumal-admin-header-actions span {
1420
+ max-width: 120px;
1421
+ overflow: hidden;
1422
+ color: var(--el-text-color-regular);
1423
+ font-size: 13px;
1424
+ text-overflow: ellipsis;
1425
+ white-space: nowrap;
1426
+ }
1427
+
1428
+ .lumal-admin-login {
1429
+ display: grid;
1430
+ min-height: 100dvh;
1431
+ place-items: center;
1432
+ padding: 24px;
1433
+ background: var(--el-bg-color-page);
1434
+ }
1435
+
1436
+ .lumal-admin-login__panel {
1437
+ box-sizing: border-box;
1438
+ width: min(400px, 100%);
1439
+ padding: 32px;
1440
+ border: 1px solid var(--el-border-color-lighter);
1441
+ border-radius: var(--lumal-radius-large, 12px);
1442
+ background: var(--el-bg-color);
1443
+ box-shadow: var(--lumal-shadow-dialog);
1444
+ }
1445
+
1446
+ .lumal-admin-login__brand {
1447
+ display: grid;
1448
+ width: 48px;
1449
+ height: 48px;
1450
+ place-items: center;
1451
+ border-radius: var(--lumal-radius-base, 8px);
1452
+ color: var(--el-color-white);
1453
+ background: var(--el-color-primary);
1454
+ font-size: 24px;
1455
+ font-weight: 800;
1456
+ }
1457
+
1458
+ .lumal-admin-login h1 {
1459
+ margin: 20px 0 6px;
1460
+ }
1461
+
1462
+ .lumal-admin-login p,
1463
+ .lumal-admin-login small {
1464
+ color: var(--el-text-color-secondary);
1465
+ }
1466
+
1467
+ .lumal-admin-login .el-form {
1468
+ margin: 24px 0 16px;
1469
+ }
1470
+
1471
+ .lumal-admin-login .el-button {
1472
+ width: 100%;
1473
+ min-height: 44px;
1474
+ }
1475
+
1476
+ .lumal-admin-login__error {
1477
+ color: var(--el-color-danger) !important;
1478
+ font-size: 13px;
1479
+ }
1480
+
1481
+ @media (max-width: 900px) {
1482
+ .lumal-admin-home__metrics {
1483
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1484
+ }
1485
+ }
1486
+
1487
+ @media (max-width: 640px) {
1488
+ .lumal-admin-home,
1489
+ .lumal-admin-page {
1490
+ padding: 12px;
1491
+ }
1492
+
1493
+ .lumal-admin-home__metrics {
1494
+ grid-template-columns: minmax(0, 1fr);
1495
+ }
1496
+
1497
+ .lumal-admin-header-actions span {
1498
+ display: none;
1499
+ }
1500
+
1501
+ .lumal-admin-login {
1502
+ padding: 16px;
1503
+ }
1504
+
1505
+ .lumal-admin-login__panel {
1506
+ padding: 24px 20px;
1507
+ }
1508
+ }
1509
+
1510
+ @media (prefers-reduced-motion: reduce) {
1511
+ *,
1512
+ *::before,
1513
+ *::after {
1514
+ scroll-behavior: auto !important;
1515
+ }
1516
+ }
1517
+ `;
1518
+ }
1519
+ function createTemplateFiles(projectName, apiMode) {
1520
+ const files = [
1521
+ { path: 'index.html', content: createIndexHtml() },
1522
+ { path: 'package.json', content: createPackageJson(projectName) },
1523
+ { path: 'src/App.vue', content: createAppVue() },
1524
+ { path: 'src/components/app/AppHeaderActions.vue', content: createHeaderActionsVue() },
1525
+ { path: 'src/components/app/AppSettingsDrawer.vue', content: createSettingsDrawerVue() },
1526
+ { path: 'src/components/request/RequestExamplePanel.vue', content: createRequestPanelVue() },
1527
+ { path: 'src/composables/useMockRequestExample.ts', content: createRequestExampleTs() },
1528
+ { path: 'src/main.ts', content: createMainTs() },
1529
+ { path: 'src/router/index.ts', content: createRouterTs() },
1530
+ { path: 'src/services/access.ts', content: createAccessTs() },
1531
+ { path: 'src/services/preferences.ts', content: createPreferencesTs() },
1532
+ { path: 'src/services/session.ts', content: createSessionTs() },
1533
+ { path: 'src/styles.scss', content: createStylesScss() },
1534
+ { path: 'src/views/auth/LoginView.vue', content: createLoginViewVue() },
1535
+ { path: 'src/views/dashboard/DashboardView.vue', content: createDashboardViewVue() },
1536
+ { path: 'src/views/error/ForbiddenView.vue', content: createForbiddenViewVue() },
1537
+ { path: 'src/views/error/NotFoundView.vue', content: createNotFoundViewVue() },
1538
+ { path: 'src/views/project/ProjectView.vue', content: createProjectViewVue() },
1539
+ { path: 'tsconfig.json', content: createTsConfig() },
1540
+ { path: 'vite.config.ts', content: createViteConfig() },
1541
+ ];
1542
+ if (apiMode === 'http') {
1543
+ files.push({ path: '.env.development', content: 'VITE_API_BASE_URL=/api\n' }, { path: '.env.production.example', content: 'VITE_API_BASE_URL=/api\n' }, { path: 'API-CONTRACT.md', content: createApiContractMd() }, { path: 'src/services/auth-api.ts', content: createHttpAuthApiTs() }, { path: 'src/services/request.ts', content: createHttpRequestTs() });
1544
+ }
1545
+ return files;
1546
+ }
1547
+ /***********************目录写入*********************/
1548
+ async function assertEmptyTargetDir(targetDir) {
1549
+ await mkdir(targetDir, { recursive: true });
1550
+ const entries = await readdir(targetDir);
1551
+ if (entries.length > 0) {
1552
+ throw new Error('目标目录不是空目录');
1553
+ }
1554
+ }
1555
+ async function writeTemplateFile(targetDir, file) {
1556
+ const filePath = join(targetDir, file.path);
1557
+ const directoryPath = dirname(filePath);
1558
+ if (directoryPath) {
1559
+ await mkdir(directoryPath, { recursive: true });
1560
+ }
1561
+ await writeFile(filePath, file.content, 'utf8');
1562
+ }
1563
+ /***********************公开 API*********************/
1564
+ export async function createLumalAdminProject(options) {
1565
+ const targetDir = resolve(options.targetDir);
1566
+ const projectName = resolveProjectName(options);
1567
+ const templateFiles = createTemplateFiles(projectName, options.apiMode ?? 'local');
1568
+ await assertEmptyTargetDir(targetDir);
1569
+ for (const file of templateFiles) {
1570
+ await writeTemplateFile(targetDir, file);
1571
+ }
1572
+ return {
1573
+ targetDir,
1574
+ createdFiles: templateFiles.map(file => file.path),
1575
+ };
1576
+ }
1577
+ //# sourceMappingURL=scaffold.js.map