create-super-admin 0.0.0-bootstrap.0

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,998 @@
1
+ import { themeDefinitions } from './theme-options.js';
2
+ const SUPER_ADMIN_VERSION_RANGE = '^0.1.0';
3
+ function formatStringList(values) {
4
+ return values.map((value) => `'${value}'`).join(', ');
5
+ }
6
+ export function createPackageJson(input) {
7
+ const dependencies = {
8
+ '@super-admin-org/core': SUPER_ADMIN_VERSION_RANGE,
9
+ '@super-admin-org/theme': SUPER_ADMIN_VERSION_RANGE,
10
+ '@super-admin-org/ui': SUPER_ADMIN_VERSION_RANGE,
11
+ '@tanstack/vue-query': '^5.0.0',
12
+ 'lucide-vue-next': '^0.555.0',
13
+ pinia: '^3.0.0',
14
+ vue: '^3.5.0',
15
+ 'vue-i18n': '^11.4.4',
16
+ 'vue-router': '^4.5.0'
17
+ };
18
+ for (const themeId of input.themes.installed) {
19
+ dependencies[themeDefinitions[themeId].packageName] = SUPER_ADMIN_VERSION_RANGE;
20
+ }
21
+ return `${JSON.stringify({
22
+ name: input.packageName,
23
+ version: '0.0.0',
24
+ private: true,
25
+ type: 'module',
26
+ scripts: {
27
+ dev: 'vite',
28
+ build: 'vue-tsc --noEmit && vite build',
29
+ typecheck: 'vue-tsc --noEmit',
30
+ preview: 'vite preview'
31
+ },
32
+ dependencies,
33
+ devDependencies: {
34
+ '@tailwindcss/vite': '^4.0.0',
35
+ '@vitejs/plugin-vue': '^6.0.0',
36
+ '@vue/tsconfig': '^0.8.0',
37
+ tailwindcss: '^4.0.0',
38
+ typescript: '^5.0.0',
39
+ vite: '^7.0.0',
40
+ 'vue-tsc': '^3.0.0'
41
+ }
42
+ }, null, 2)}\n`;
43
+ }
44
+ export function createIndexHtml(projectName) {
45
+ return `<!doctype html>
46
+ <html lang="zh-CN">
47
+ <head>
48
+ <meta charset="UTF-8" />
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
50
+ <title>${projectName}</title>
51
+ </head>
52
+ <body>
53
+ <div id="app"></div>
54
+ <script type="module" src="/src/main.ts"></script>
55
+ </body>
56
+ </html>
57
+ `;
58
+ }
59
+ export function createViteConfig() {
60
+ return `import { fileURLToPath, URL } from 'node:url'
61
+ import tailwindcss from '@tailwindcss/vite'
62
+ import vue from '@vitejs/plugin-vue'
63
+ import { defineConfig } from 'vite'
64
+
65
+ export default defineConfig({
66
+ plugins: [vue(), tailwindcss()],
67
+ resolve: {
68
+ alias: {
69
+ '@': fileURLToPath(new URL('./src', import.meta.url))
70
+ }
71
+ }
72
+ })
73
+ `;
74
+ }
75
+ export function createTsconfig() {
76
+ return `${JSON.stringify({
77
+ extends: '@vue/tsconfig/tsconfig.dom.json',
78
+ compilerOptions: {
79
+ baseUrl: '.',
80
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
81
+ paths: {
82
+ '@/*': ['src/*']
83
+ },
84
+ target: 'ES2022',
85
+ strict: true,
86
+ noEmit: true,
87
+ types: ['vite/client']
88
+ },
89
+ include: ['super-admin.config.ts', 'src/**/*.ts', 'src/**/*.vue', 'src/**/*.d.ts']
90
+ }, null, 2)}\n`;
91
+ }
92
+ export function createReadme(projectName, packageManager) {
93
+ return `# ${projectName}
94
+
95
+ Super Admin starter project generated by \`create-super-admin\`.
96
+
97
+ ## Scripts
98
+
99
+ \`\`\`bash
100
+ ${packageManager} install
101
+ ${packageManager} run dev
102
+ ${packageManager} run typecheck
103
+ ${packageManager} run build
104
+ \`\`\`
105
+
106
+ ## Guide
107
+
108
+ - 删除示例、连接 API、添加测试或 lint:查看 Super Admin 文档。
109
+ - 修改主题:编辑 \`super-admin.config.ts\` 和 \`src/super-admin/theme-registry.generated.ts\`。
110
+ - 修改语言:编辑 \`src/i18n/\`。
111
+ `;
112
+ }
113
+ export function createSuperAdminConfig(input) {
114
+ return `export default {
115
+ themes: {
116
+ installed: [${formatStringList(input.themes.installed)}],
117
+ default: '${input.themes.default}',
118
+ switcher: '${input.themes.installed.length > 1 ? 'auto' : 'off'}'
119
+ },
120
+ i18n: {
121
+ installed: [${formatStringList(input.i18n.installed)}],
122
+ defaultLocale: '${input.i18n.default}',
123
+ switcher: ${String(input.i18n.switcher)}
124
+ }
125
+ } as const
126
+ `;
127
+ }
128
+ export function createThemeRegistry(themes, defaultTheme) {
129
+ const imports = themes
130
+ .map((themeId) => {
131
+ const definition = themeDefinitions[themeId];
132
+ return `import { ${definition.profileExport} } from '${definition.packageName}'`;
133
+ })
134
+ .join('\n');
135
+ const profileExports = themes.map((themeId) => themeDefinitions[themeId].profileExport);
136
+ const fallbackProfile = themeDefinitions[defaultTheme].profileExport;
137
+ return `import type { DesignProfile, DesignProfileId } from '@super-admin-org/core'
138
+ ${imports}
139
+
140
+ export const builtInDesignProfiles = [${profileExports.join(', ')}] as const
141
+
142
+ export function getBuiltInDesignProfile(profileId: DesignProfileId): DesignProfile {
143
+ return builtInDesignProfiles.find((profile) => profile.id === profileId) ?? ${fallbackProfile}
144
+ }
145
+ `;
146
+ }
147
+ export function createEnvDts() {
148
+ return `declare module '*.vue' {
149
+ import type { DefineComponent } from 'vue'
150
+
151
+ const component: DefineComponent<object, object, unknown>
152
+ export default component
153
+ }
154
+
155
+ interface ImportMetaEnv {
156
+ readonly VITE_SUPER_ADMIN_ASSISTANT_ENDPOINT?: string
157
+ }
158
+
159
+ interface ImportMeta {
160
+ readonly env: ImportMetaEnv
161
+ }
162
+
163
+ import type { PageShellMeta } from '@super-admin-org/core'
164
+
165
+ declare module 'vue-router' {
166
+ interface RouteMeta extends Partial<PageShellMeta> {
167
+ authLayout?: boolean
168
+ workspaceTitle?: string
169
+ }
170
+ }
171
+ `;
172
+ }
173
+ export function createUsersApi() {
174
+ return `import { createPageListResult } from '@super-admin-org/core'
175
+ import { mockUsers } from '@/api/mock/users.mock'
176
+ import type { MockUser } from '@/api/mock/users.mock'
177
+ import type { UserListParams, UserListResult, UserRecord } from '@/modules/users/users.types'
178
+
179
+ const LOADING_DELAY_MS = 700
180
+
181
+ function delay(ms: number): Promise<void> {
182
+ return new Promise((resolve) => {
183
+ setTimeout(resolve, ms)
184
+ })
185
+ }
186
+
187
+ function matchesKeyword(user: UserRecord, keyword: string): boolean {
188
+ const term = keyword.trim().toLowerCase()
189
+ if (!term) {
190
+ return true
191
+ }
192
+
193
+ return [user.name, user.email, user.role, user.status, user.region].some((value) => value.toLowerCase().includes(term))
194
+ }
195
+
196
+ function normalizeUser(user: MockUser): UserRecord {
197
+ return {
198
+ id: user.userId,
199
+ name: user.displayName,
200
+ email: user.emailAddress,
201
+ role: user.roleName,
202
+ status: user.state,
203
+ region: user.regionName,
204
+ notes: user.profileNotes
205
+ }
206
+ }
207
+
208
+ function filterUsers(params: UserListParams): UserRecord[] {
209
+ return mockUsers.map(normalizeUser).filter((user) => {
210
+ const matchesStatus = params.status === 'all' || user.status === params.status
211
+
212
+ return matchesStatus && matchesKeyword(user, params.keyword ?? '')
213
+ })
214
+ }
215
+
216
+ export async function listUsers(params: UserListParams): Promise<UserListResult> {
217
+ if (params.scenario === 'error') {
218
+ throw new Error('Unable to load users')
219
+ }
220
+
221
+ if (params.scenario === 'loading') {
222
+ await delay(LOADING_DELAY_MS)
223
+ }
224
+
225
+ const filteredUsers = params.scenario === 'empty' ? [] : filterUsers(params)
226
+ const start = (params.page - 1) * params.pageSize
227
+ const end = start + params.pageSize
228
+
229
+ return createPageListResult(filteredUsers.slice(start, end), filteredUsers.length, params)
230
+ }
231
+ `;
232
+ }
233
+ export function createAuthTypes() {
234
+ return `export type AuthFieldErrors<Field extends string = string> = Partial<Record<Field, string>>
235
+
236
+ export type LoginInput = {
237
+ email: string
238
+ password: string
239
+ }
240
+
241
+ export type RegisterInput = {
242
+ name: string
243
+ email: string
244
+ organization: string
245
+ password: string
246
+ }
247
+
248
+ export type AuthUser = {
249
+ email: string
250
+ id: string
251
+ name: string
252
+ role: string
253
+ }
254
+
255
+ export type AuthSession = {
256
+ permissions: string[]
257
+ token: string
258
+ tokenType: 'Bearer'
259
+ user: AuthUser
260
+ }
261
+ `;
262
+ }
263
+ export function createAuthSession() {
264
+ return `import type { AuthSession } from './auth.types'
265
+
266
+ export function createTemplateAuthSession(): AuthSession {
267
+ return {
268
+ permissions: ['users:read'],
269
+ token: 'template-session-token',
270
+ tokenType: 'Bearer',
271
+ user: {
272
+ email: 'mira.owner@example.com',
273
+ id: 'template-user',
274
+ name: 'Mira Chen',
275
+ role: 'Owner'
276
+ }
277
+ }
278
+ }
279
+ `;
280
+ }
281
+ export function createAuthSessionStore() {
282
+ return `import { defineStore } from 'pinia'
283
+ import { computed, shallowRef } from 'vue'
284
+ import type { AuthSession } from '@/modules/auth/auth.types'
285
+
286
+ const STORAGE_KEY = 'super-admin:auth-session'
287
+
288
+ function getStorage(): Storage | null {
289
+ return typeof window === 'undefined' ? null : window.localStorage
290
+ }
291
+
292
+ export const useAuthSessionStore = defineStore('authSession', () => {
293
+ const session = shallowRef<AuthSession | null>(null)
294
+
295
+ const isAuthenticated = computed(() => session.value !== null)
296
+ const authorizationHeader = computed(() => (session.value ? \`\${session.value.tokenType} \${session.value.token}\` : undefined))
297
+ const currentUser = computed(() => session.value?.user ?? null)
298
+
299
+ function setTemplateSession(nextSession: AuthSession): void {
300
+ session.value = nextSession
301
+ getStorage()?.removeItem(STORAGE_KEY)
302
+ }
303
+
304
+ function clearSession(): void {
305
+ session.value = null
306
+ getStorage()?.removeItem(STORAGE_KEY)
307
+ }
308
+
309
+ return {
310
+ authorizationHeader,
311
+ clearSession,
312
+ currentUser,
313
+ isAuthenticated,
314
+ session,
315
+ setTemplateSession
316
+ }
317
+ })
318
+ `;
319
+ }
320
+ export function createLoginPage() {
321
+ return `<script setup lang="ts">
322
+ import { ArrowRight, KeyRound } from 'lucide-vue-next'
323
+ import { computed, reactive, shallowRef } from 'vue'
324
+ import { useRoute, useRouter } from 'vue-router'
325
+ import { useI18n } from 'vue-i18n'
326
+ import { AdminAlert, AdminButton, AdminField, AdminTextInput, AdminValidationSummary } from '@super-admin-org/ui'
327
+ import { resolvePostLoginPath } from '@/router/auth-guard'
328
+ import { useAuthSessionStore } from '@/stores/auth-session.store'
329
+ import { createTemplateAuthSession } from './auth-session'
330
+ import AuthLayout from './components/AuthLayout.vue'
331
+ import { validateLoginInput } from './auth.validation'
332
+ import type { AuthFieldErrors, LoginInput } from './auth.types'
333
+
334
+ const router = useRouter()
335
+ const route = useRoute()
336
+ const { t } = useI18n()
337
+ const session = useAuthSessionStore()
338
+ const form = reactive<LoginInput>({
339
+ email: 'mira.owner@example.com',
340
+ password: 'reference-admin'
341
+ })
342
+ const fieldErrors = shallowRef<AuthFieldErrors<keyof LoginInput>>({})
343
+ const submitError = shallowRef('')
344
+ const isSubmitting = shallowRef(false)
345
+
346
+ const validationMessages = computed(() => Object.values(fieldErrors.value).filter((message) => message !== undefined))
347
+
348
+ async function submitLogin(): Promise<void> {
349
+ fieldErrors.value = validateLoginInput(form, t)
350
+ submitError.value = ''
351
+
352
+ if (Object.keys(fieldErrors.value).length > 0) {
353
+ return
354
+ }
355
+
356
+ isSubmitting.value = true
357
+
358
+ try {
359
+ session.setTemplateSession(createTemplateAuthSession())
360
+ await router.push(resolvePostLoginPath(route.query.redirect))
361
+ } catch (error) {
362
+ submitError.value = error instanceof Error ? error.message : t('auth.login.unableToSignIn')
363
+ } finally {
364
+ isSubmitting.value = false
365
+ }
366
+ }
367
+ </script>
368
+
369
+ <template>
370
+ <AuthLayout
371
+ :eyebrow="t('auth.login.eyebrow')"
372
+ :title="t('auth.login.title')"
373
+ :description="t('auth.login.description')"
374
+ >
375
+ <div class="grid gap-5">
376
+ <div>
377
+ <div class="inline-flex size-11 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface-sunken)] text-[var(--primary)] shadow-[var(--glow)]">
378
+ <KeyRound class="size-5" />
379
+ </div>
380
+ <h2 class="mt-4 [font-family:var(--font-display)] text-3xl leading-tight">{{ t('auth.login.heading') }}</h2>
381
+ <p class="mt-2 text-sm leading-6 text-[var(--muted-foreground)]">
382
+ {{ t('auth.login.intro') }}
383
+ </p>
384
+ </div>
385
+
386
+ <AdminAlert
387
+ v-if="submitError"
388
+ tone="danger"
389
+ :title="t('auth.login.failedTitle')"
390
+ :description="submitError"
391
+ />
392
+
393
+ <AdminValidationSummary :errors="validationMessages" />
394
+
395
+ <form class="grid gap-4" @submit.prevent="submitLogin">
396
+ <AdminField :label="t('auth.login.email')" for="auth-email" required :error="fieldErrors.email">
397
+ <AdminTextInput id="auth-email" v-model="form.email" type="email" :invalid="Boolean(fieldErrors.email)" autocomplete="email" />
398
+ </AdminField>
399
+
400
+ <AdminField :label="t('auth.login.password')" for="auth-password" required :error="fieldErrors.password">
401
+ <AdminTextInput id="auth-password" v-model="form.password" type="password" :invalid="Boolean(fieldErrors.password)" autocomplete="current-password" />
402
+ </AdminField>
403
+
404
+ <AdminButton type="submit" variant="primary" :disabled="isSubmitting" class="w-full">
405
+ <span>{{ isSubmitting ? t('auth.login.submitting') : t('auth.login.submit') }}</span>
406
+ <ArrowRight class="size-4" />
407
+ </AdminButton>
408
+ </form>
409
+
410
+ <div class="grid gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface-sunken)] p-3 text-xs text-[var(--muted-foreground)]">
411
+ <div class="flex items-center justify-between gap-3">
412
+ <span>{{ t('auth.login.referenceEmail') }}</span>
413
+ <span class="text-[var(--foreground)]">mira.owner@example.com</span>
414
+ </div>
415
+ <div class="flex items-center justify-between gap-3">
416
+ <span>{{ t('auth.login.referencePassword') }}</span>
417
+ <span class="text-[var(--foreground)]">reference-admin</span>
418
+ </div>
419
+ </div>
420
+
421
+ <p class="text-center text-sm text-[var(--muted-foreground)]">
422
+ {{ t('auth.login.onboardingQuestion') }}
423
+ <RouterLink class="text-[var(--primary)] underline-offset-4 hover:underline" to="/auth/register">{{ t('auth.login.createAccount') }}</RouterLink>
424
+ </p>
425
+ </div>
426
+ </AuthLayout>
427
+ </template>
428
+ `;
429
+ }
430
+ export function createI18nIndex(locales) {
431
+ const includeEnglish = locales.includes('en-US');
432
+ const imports = includeEnglish ? "import enUS from './locales/en-US'\nimport zhCN from './locales/zh-CN'" : "import zhCN from './locales/zh-CN'";
433
+ const messages = includeEnglish ? ` [DEFAULT_LOCALE]: zhCN,\n [OPTIONAL_LOCALE]: enUS` : ' [DEFAULT_LOCALE]: zhCN';
434
+ const optionalLocale = includeEnglish ? "\nexport const OPTIONAL_LOCALE = 'en-US'" : '';
435
+ return `import { createI18n } from 'vue-i18n'
436
+ ${imports}
437
+
438
+ export const DEFAULT_LOCALE = 'zh-CN'${optionalLocale}
439
+
440
+ export const messages = {
441
+ ${messages}
442
+ } as const
443
+
444
+ export type Locale = keyof typeof messages
445
+ export type LocaleCatalog = Record<string, unknown>
446
+ export type MessageValues = Record<string, string | number>
447
+ export type MessageTranslator = (key: string, values?: MessageValues) => string
448
+
449
+ function flattenMessageKeys(value: unknown, prefix = ''): string[] {
450
+ if (typeof value === 'string') {
451
+ return [prefix]
452
+ }
453
+
454
+ if (typeof value !== 'object' || value === null) {
455
+ return []
456
+ }
457
+
458
+ return Object.entries(value).flatMap(([key, child]) => {
459
+ const nextPrefix = prefix ? \`\${prefix}.\${key}\` : key
460
+ return flattenMessageKeys(child, nextPrefix)
461
+ })
462
+ }
463
+
464
+ function hasMessageKey(value: unknown, path: string): boolean {
465
+ return path.split('.').every((segment, index, segments) => {
466
+ if (typeof value !== 'object' || value === null || !(segment in value)) {
467
+ return false
468
+ }
469
+
470
+ value = (value as Record<string, unknown>)[segment]
471
+ return index < segments.length - 1 || typeof value === 'string'
472
+ })
473
+ }
474
+
475
+ export function resolveLocale(locale: string | undefined): Locale {
476
+ return Object.hasOwn(messages, locale ?? '') ? (locale as Locale) : DEFAULT_LOCALE
477
+ }
478
+
479
+ export function findMissingLocaleKeys(source: LocaleCatalog, target: LocaleCatalog): string[] {
480
+ return flattenMessageKeys(source).filter((key) => !hasMessageKey(target, key))
481
+ }
482
+
483
+ export function createAdminI18n(locale: Locale = DEFAULT_LOCALE) {
484
+ return createI18n({
485
+ fallbackLocale: DEFAULT_LOCALE,
486
+ legacy: false,
487
+ locale,
488
+ messages,
489
+ missing: (_locale, key) => key
490
+ })
491
+ }
492
+
493
+ export const i18n = createAdminI18n()
494
+
495
+ export function getActiveLocale(adminI18n: ReturnType<typeof createAdminI18n> = i18n): Locale {
496
+ const locale = adminI18n.global.locale
497
+ return resolveLocale(typeof locale === 'string' ? locale : locale.value)
498
+ }
499
+
500
+ export function setActiveLocale(locale: Locale, adminI18n: ReturnType<typeof createAdminI18n> = i18n): void {
501
+ adminI18n.global.locale.value = locale
502
+ }
503
+
504
+ export function createMessageTranslator(locale: Locale = DEFAULT_LOCALE): MessageTranslator {
505
+ const localI18n = createAdminI18n(locale)
506
+ return (key, values) => localI18n.global.t(key, values ?? {})
507
+ }
508
+
509
+ export const translateAdminMessage: MessageTranslator = (key, values) => i18n.global.t(key, values ?? {})
510
+ `;
511
+ }
512
+ export function createPreferencesStore() {
513
+ return `import {
514
+ defaultAiAvailability,
515
+ mergeAppearanceState,
516
+ type AiAvailability,
517
+ type AppearanceState,
518
+ type AppearanceStateInput,
519
+ type ColorMode,
520
+ type Density,
521
+ type DesignProfileId,
522
+ type LayoutPresetId,
523
+ type ResolvedColorMode,
524
+ type StageManagerPresentationMode
525
+ } from '@super-admin-org/core'
526
+ import { defineStore } from 'pinia'
527
+ import { computed, reactive, shallowRef } from 'vue'
528
+ import superAdminConfig from '../../super-admin.config'
529
+ import { DEFAULT_LOCALE, resolveLocale, setActiveLocale, type Locale } from '@/i18n'
530
+
531
+ const STORAGE_KEY = 'super-admin:preferences'
532
+ const installedProfiles: DesignProfileId[] = [...superAdminConfig.themes.installed]
533
+ const defaultProfile = superAdminConfig.themes.default
534
+
535
+ function readStoredPreferences(): AppearanceStateInput {
536
+ const raw = window.localStorage.getItem(STORAGE_KEY)
537
+ if (!raw) {
538
+ return {}
539
+ }
540
+
541
+ try {
542
+ const parsed: unknown = JSON.parse(raw)
543
+ return typeof parsed === 'object' && parsed !== null ? (parsed as AppearanceStateInput) : {}
544
+ } catch {
545
+ return {}
546
+ }
547
+ }
548
+
549
+ function resolveProfileId(profileId: DesignProfileId | undefined): DesignProfileId {
550
+ return profileId && installedProfiles.includes(profileId) ? profileId : defaultProfile
551
+ }
552
+
553
+ function createInitialAppearanceState(): AppearanceState {
554
+ const state = mergeAppearanceState(readStoredPreferences())
555
+
556
+ return {
557
+ ...state,
558
+ locale: resolveLocale(state.locale),
559
+ profileId: resolveProfileId(state.profileId)
560
+ }
561
+ }
562
+
563
+ export const usePreferencesStore = defineStore('preferences', () => {
564
+ const state = reactive<AppearanceState>(createInitialAppearanceState())
565
+ const systemMode = shallowRef<ResolvedColorMode>('dark')
566
+ const providerMode = shallowRef<'mock' | 'custom'>('mock')
567
+ const aiAvailability = shallowRef<AiAvailability>(defaultAiAvailability)
568
+ const controlCenterOpen = shallowRef(false)
569
+ const stageManagerOpen = shallowRef(false)
570
+ const aiAssistantOpen = shallowRef(false)
571
+
572
+ const profileId = computed(() => state.profileId)
573
+ const locale = computed(() => state.locale)
574
+ const colorMode = computed(() => state.colorMode)
575
+ const density = computed(() => state.density)
576
+ const layoutPreset = computed(() => state.layoutPreset)
577
+ const workspaceTabs = computed(() => state.workspaceTabs)
578
+ const stageManager = computed(() => state.stageManager)
579
+ const summary = computed(
580
+ () => \`\${state.profileId} / \${state.colorMode} / \${state.layoutPreset}\`
581
+ )
582
+
583
+ function persist(): void {
584
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
585
+ }
586
+
587
+ setActiveLocale(resolveLocale(state.locale ?? DEFAULT_LOCALE))
588
+
589
+ function setProfile(profileId: DesignProfileId): void {
590
+ state.profileId = resolveProfileId(profileId)
591
+ persist()
592
+ }
593
+
594
+ function setLocale(locale: Locale): void {
595
+ state.locale = resolveLocale(locale)
596
+ setActiveLocale(state.locale)
597
+ persist()
598
+ }
599
+
600
+ function setColorMode(colorMode: ColorMode): void {
601
+ state.colorMode = colorMode
602
+ persist()
603
+ }
604
+
605
+ function setDensity(density: Density): void {
606
+ state.density = density
607
+ persist()
608
+ }
609
+
610
+ function setLayoutPreset(layoutPreset: LayoutPresetId): void {
611
+ state.layoutPreset = layoutPreset
612
+ persist()
613
+ }
614
+
615
+ function setTabsEnabled(enabled: boolean): void {
616
+ state.workspaceTabs.enabled = enabled
617
+ persist()
618
+ }
619
+
620
+ function setStageManagerEnabled(enabled: boolean): void {
621
+ state.stageManager.enabled = enabled
622
+ if (!enabled) {
623
+ stageManagerOpen.value = false
624
+ }
625
+ persist()
626
+ }
627
+
628
+ function setStageManagerPresentationMode(presentationMode: StageManagerPresentationMode): void {
629
+ state.stageManager.presentationMode = presentationMode
630
+ persist()
631
+ }
632
+
633
+ function openControlCenter(): void {
634
+ controlCenterOpen.value = true
635
+ }
636
+
637
+ function closeControlCenter(): void {
638
+ controlCenterOpen.value = false
639
+ }
640
+
641
+ function openStageManager(): void {
642
+ if (!state.stageManager.enabled) {
643
+ return
644
+ }
645
+
646
+ stageManagerOpen.value = true
647
+ }
648
+
649
+ function closeStageManager(): void {
650
+ stageManagerOpen.value = false
651
+ }
652
+
653
+ function openAiAssistant(): void {
654
+ aiAssistantOpen.value = true
655
+ }
656
+
657
+ function closeAiAssistant(): void {
658
+ aiAssistantOpen.value = false
659
+ }
660
+
661
+ function bindSystemColorMode(): void {
662
+ const query = window.matchMedia('(prefers-color-scheme: dark)')
663
+ const update = (): void => {
664
+ systemMode.value = query.matches ? 'dark' : 'light'
665
+ }
666
+ update()
667
+ query.addEventListener('change', update)
668
+ }
669
+
670
+ return {
671
+ providerMode,
672
+ aiAvailability,
673
+ aiAssistantOpen,
674
+ closeAiAssistant,
675
+ closeControlCenter,
676
+ closeStageManager,
677
+ controlCenterOpen,
678
+ openControlCenter,
679
+ openAiAssistant,
680
+ openStageManager,
681
+ stageManagerOpen,
682
+ systemMode,
683
+ summary,
684
+ profileId,
685
+ locale,
686
+ colorMode,
687
+ density,
688
+ layoutPreset,
689
+ workspaceTabs,
690
+ stageManager,
691
+ bindSystemColorMode,
692
+ setColorMode,
693
+ setDensity,
694
+ setLayoutPreset,
695
+ setLocale,
696
+ setProfile,
697
+ setStageManagerPresentationMode,
698
+ setTabsEnabled,
699
+ setStageManagerEnabled
700
+ }
701
+ })
702
+ `;
703
+ }
704
+ export function createGlobalPreferences(options) {
705
+ const themeImports = options.includeThemeSwitcher
706
+ ? " type DesignProfileId,\n"
707
+ : '';
708
+ const localeImport = options.includeLocaleSwitcher ? "import type { Locale } from '@/i18n'\n" : '';
709
+ const registryImport = options.includeThemeSwitcher
710
+ ? "import { builtInDesignProfiles } from '@/super-admin/theme-registry.generated'\n"
711
+ : "import { getBuiltInDesignProfile } from '@/super-admin/theme-registry.generated'\n";
712
+ const activeProfile = options.includeThemeSwitcher
713
+ ? "const activeProfileName = computed(\n () => builtInDesignProfiles.find((profile) => profile.id === preferences.profileId)?.name ?? preferences.profileId\n)"
714
+ : "const activeProfileName = computed(() => getBuiltInDesignProfile(preferences.profileId).name)";
715
+ const localeOptions = options.includeLocaleSwitcher
716
+ ? "\nconst localeOptions = computed<{ id: Locale; label: string; detail: string }[]>(() => [\n { id: 'zh-CN', label: t('shell.preferences.locales.zhCN.label'), detail: t('shell.preferences.locales.zhCN.detail') },\n { id: 'en-US', label: t('shell.preferences.locales.enUS.label'), detail: t('shell.preferences.locales.enUS.detail') }\n])\n"
717
+ : '';
718
+ const selectProfile = options.includeThemeSwitcher
719
+ ? "\nfunction selectProfile(profileId: DesignProfileId): void {\n preferences.setProfile(profileId)\n}\n"
720
+ : '';
721
+ const selectLocale = options.includeLocaleSwitcher
722
+ ? "\nfunction selectLocale(locale: Locale): void {\n preferences.setLocale(locale)\n}\n"
723
+ : '';
724
+ const themeSection = options.includeThemeSwitcher
725
+ ? `
726
+ <div class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--surface-sunken)] p-4">
727
+ <div class="flex items-center justify-between gap-3">
728
+ <div>
729
+ <h3 class="[font-family:var(--font-display)] text-lg">{{ t('shell.preferences.themeProfile') }}</h3>
730
+ <p class="text-xs text-[var(--muted-foreground)]">{{ t('shell.preferences.themeProfileDescription') }}</p>
731
+ </div>
732
+ <StatusPill :label="activeProfileName" />
733
+ </div>
734
+ <div class="mt-4 grid gap-2 sm:grid-cols-2">
735
+ <button
736
+ v-for="profile in builtInDesignProfiles"
737
+ :key="profile.id"
738
+ type="button"
739
+ class="rounded-[var(--radius-md)] border p-3 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
740
+ :class="profile.id === preferences.profileId ? 'border-[var(--border-strong)] bg-[var(--active-tab-background)]' : 'border-[var(--border)] bg-[var(--surface)] hover:border-[var(--border-strong)]'"
741
+ @click="selectProfile(profile.id)"
742
+ >
743
+ <span class="[font-family:var(--font-display)] text-base">{{ profile.name }}</span>
744
+ <p class="mt-2 line-clamp-2 text-xs text-[var(--muted-foreground)]">{{ profile.description }}</p>
745
+ </button>
746
+ </div>
747
+ </div>`
748
+ : '';
749
+ const localeSection = options.includeLocaleSwitcher
750
+ ? `
751
+ <div>
752
+ <div class="flex items-center justify-between gap-3 pb-2">
753
+ <span class="text-sm">{{ t('shell.preferences.locale') }}</span>
754
+ <span class="text-[11px] text-[var(--muted-foreground)]">{{ t('shell.preferences.localeDescription') }}</span>
755
+ </div>
756
+ <div class="grid gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-2 sm:grid-cols-2">
757
+ <button
758
+ v-for="localeOption in localeOptions"
759
+ :key="localeOption.id"
760
+ type="button"
761
+ class="rounded-[var(--radius-sm)] px-3 py-2 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
762
+ :class="localeOption.id === preferences.locale ? 'bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[var(--glow)]' : 'text-[var(--muted-foreground)] hover:bg-[var(--surface-raised)]'"
763
+ @click="selectLocale(localeOption.id)"
764
+ >
765
+ <span class="block text-sm">{{ localeOption.label }}</span>
766
+ <span class="block text-[11px] opacity-75">{{ localeOption.detail }}</span>
767
+ </button>
768
+ </div>
769
+ </div>`
770
+ : '';
771
+ return `<script setup lang="ts">
772
+ import { Settings2, X } from 'lucide-vue-next'
773
+ import { computed } from 'vue'
774
+ import { useI18n } from 'vue-i18n'
775
+ import {
776
+ builtInLayoutPresets,
777
+ type ColorMode,
778
+ type Density,
779
+ ${themeImports} type LayoutPresetId,
780
+ type StageManagerPresentationMode
781
+ } from '@super-admin-org/core'
782
+ import { AdminButton, AdminScrollArea, StatusPill } from '@super-admin-org/ui'
783
+ ${registryImport}${localeImport}import { usePreferencesStore } from '@/stores/preferences.store'
784
+
785
+ const props = withDefaults(
786
+ defineProps<{
787
+ trigger?: 'floating' | 'auth' | 'none'
788
+ }>(),
789
+ {
790
+ trigger: 'floating'
791
+ }
792
+ )
793
+
794
+ const preferences = usePreferencesStore()
795
+ const { t } = useI18n()
796
+
797
+ const modeOptions = computed<{ id: ColorMode; label: string; detail: string }[]>(() => [
798
+ { id: 'light', label: t('shell.preferences.modes.light.label'), detail: t('shell.preferences.modes.light.detail') },
799
+ { id: 'dark', label: t('shell.preferences.modes.dark.label'), detail: t('shell.preferences.modes.dark.detail') },
800
+ { id: 'system', label: t('shell.preferences.modes.system.label'), detail: t('shell.preferences.modes.system.detail') }
801
+ ])
802
+ ${localeOptions}
803
+ const densityOptions = computed<{ id: Density; label: string; detail: string }[]>(() => [
804
+ { id: 'comfortable', label: t('shell.preferences.density.comfortable.label'), detail: t('shell.preferences.density.comfortable.detail') },
805
+ { id: 'compact', label: t('shell.preferences.density.compact.label'), detail: t('shell.preferences.density.compact.detail') }
806
+ ])
807
+
808
+ const stagePresentationOptions = computed<{ id: StageManagerPresentationMode; label: string; detail: string }[]>(() => [
809
+ { id: 'side-dock', label: t('shell.preferences.stageModes.sideDock.label'), detail: t('shell.preferences.stageModes.sideDock.detail') },
810
+ { id: 'all-windows', label: t('shell.preferences.stageModes.allWindows.label'), detail: t('shell.preferences.stageModes.allWindows.detail') }
811
+ ])
812
+
813
+ ${activeProfile}
814
+ const activeModeName = computed(
815
+ () => modeOptions.value.find((mode) => mode.id === preferences.colorMode)?.label ?? preferences.colorMode
816
+ )
817
+ const triggerTitle = computed(() =>
818
+ props.trigger === 'auth'
819
+ ? t('shell.preferences.open', { profile: activeProfileName.value, mode: activeModeName.value })
820
+ : t('shell.preferences.title')
821
+ )
822
+ const triggerSize = computed(() => (props.trigger === 'auth' ? 'md' : 'icon'))
823
+ const showTrigger = computed(() => props.trigger !== 'none')
824
+ const triggerClass = computed(() =>
825
+ props.trigger === 'auth'
826
+ ? 'shadow-[var(--card-shadow)]'
827
+ : 'shadow-[var(--panel-shadow)]'
828
+ )
829
+ ${selectProfile}
830
+ function selectMode(colorMode: ColorMode): void {
831
+ preferences.setColorMode(colorMode)
832
+ }
833
+ ${selectLocale}
834
+ function selectLayout(layoutPreset: LayoutPresetId): void {
835
+ preferences.setLayoutPreset(layoutPreset)
836
+ }
837
+
838
+ function selectDensity(density: Density): void {
839
+ preferences.setDensity(density)
840
+ }
841
+
842
+ function selectStagePresentationMode(presentationMode: StageManagerPresentationMode): void {
843
+ preferences.setStageManagerPresentationMode(presentationMode)
844
+ }
845
+ </script>
846
+
847
+ <template>
848
+ <div>
849
+ <AdminButton
850
+ v-if="showTrigger"
851
+ variant="secondary"
852
+ :size="triggerSize"
853
+ :class="triggerClass"
854
+ :title="triggerTitle"
855
+ @click="preferences.openControlCenter()"
856
+ >
857
+ <Settings2 class="size-4" />
858
+ <span v-if="props.trigger === 'auth'" class="text-xs">
859
+ {{ activeProfileName }} / {{ activeModeName }}
860
+ </span>
861
+ </AdminButton>
862
+
863
+ <Teleport to="body">
864
+ <div v-if="preferences.controlCenterOpen" class="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm" @keydown.esc="preferences.closeControlCenter()">
865
+ <section
866
+ class="max-h-[88vh] w-full max-w-5xl overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-strong)] bg-[var(--surface)] shadow-[var(--panel-shadow)]"
867
+ role="dialog"
868
+ aria-modal="true"
869
+ aria-labelledby="control-center-title"
870
+ >
871
+ <header class="flex items-start justify-between gap-4 border-b border-[var(--border)] bg-[var(--header-background)] p-5">
872
+ <div>
873
+ <div class="flex items-center gap-2">
874
+ <StatusPill :label="t('shell.preferences.live')" />
875
+ <span class="text-xs uppercase tracking-[0.18em] text-[var(--muted-foreground)]">{{ t('shell.preferences.title') }}</span>
876
+ </div>
877
+ <h2 id="control-center-title" class="mt-2 [font-family:var(--font-display)] text-2xl text-[var(--foreground)]">
878
+ {{ t('shell.preferences.workspaceConfiguration', { profile: activeProfileName }) }}
879
+ </h2>
880
+ <p class="mt-1 text-sm text-[var(--muted-foreground)]">
881
+ {{ t('shell.preferences.immediateUpdate') }}
882
+ </p>
883
+ </div>
884
+ <AdminButton variant="ghost" size="icon" :title="t('shell.preferences.close')" @click="preferences.closeControlCenter()">
885
+ <X class="size-4" />
886
+ </AdminButton>
887
+ </header>
888
+
889
+ <AdminScrollArea class="max-h-[calc(88vh-92px)]" view-class="grid gap-5 p-5 md:grid-cols-[1fr_1.15fr]">
890
+ <section class="grid gap-4">${themeSection}
891
+ <div class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--surface-sunken)] p-4">
892
+ <h3 class="[font-family:var(--font-display)] text-lg">{{ t('shell.preferences.modeDensity') }}</h3>
893
+ <div class="mt-4 grid gap-3">${localeSection}
894
+ <div class="grid gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-2 sm:grid-cols-3">
895
+ <button
896
+ v-for="mode in modeOptions"
897
+ :key="mode.id"
898
+ type="button"
899
+ class="rounded-[var(--radius-sm)] px-3 py-2 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
900
+ :class="mode.id === preferences.colorMode ? 'bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[var(--glow)]' : 'text-[var(--muted-foreground)] hover:bg-[var(--surface-raised)]'"
901
+ @click="selectMode(mode.id)"
902
+ >
903
+ <span class="block text-sm">{{ mode.label }}</span>
904
+ <span class="block text-[11px] opacity-75">{{ mode.detail }}</span>
905
+ </button>
906
+ </div>
907
+
908
+ <div class="grid gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-2 sm:grid-cols-2">
909
+ <button
910
+ v-for="density in densityOptions"
911
+ :key="density.id"
912
+ type="button"
913
+ class="rounded-[var(--radius-sm)] px-3 py-2 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
914
+ :class="density.id === preferences.density ? 'bg-[var(--active-tab-background)] text-[var(--foreground)]' : 'text-[var(--muted-foreground)] hover:bg-[var(--surface-raised)]'"
915
+ @click="selectDensity(density.id)"
916
+ >
917
+ <span class="block text-sm">{{ density.label }}</span>
918
+ <span class="block text-[11px]">{{ density.detail }}</span>
919
+ </button>
920
+ </div>
921
+ </div>
922
+ </div>
923
+ </section>
924
+
925
+ <section class="grid gap-4">
926
+ <div class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--surface-sunken)] p-4">
927
+ <h3 class="[font-family:var(--font-display)] text-lg">{{ t('shell.preferences.layout') }}</h3>
928
+ <p class="text-xs text-[var(--muted-foreground)]">{{ t('shell.preferences.layoutDescription') }}</p>
929
+ <div class="mt-4 grid gap-3 xl:grid-cols-3">
930
+ <button
931
+ v-for="layout in builtInLayoutPresets"
932
+ :key="layout.id"
933
+ type="button"
934
+ class="rounded-[var(--radius-md)] border bg-[var(--surface)] p-3 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
935
+ :class="layout.id === preferences.layoutPreset ? 'border-[var(--border-strong)] shadow-[var(--glow)]' : 'border-[var(--border)] hover:border-[var(--border-strong)]'"
936
+ @click="selectLayout(layout.id)"
937
+ >
938
+ <div class="[font-family:var(--font-display)] text-base">{{ layout.name }}</div>
939
+ <p class="mt-1 line-clamp-2 text-xs text-[var(--muted-foreground)]">{{ layout.description }}</p>
940
+ </button>
941
+ </div>
942
+ </div>
943
+
944
+ <div class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--surface-sunken)] p-4">
945
+ <div class="flex items-center justify-between gap-3">
946
+ <div>
947
+ <h3 class="[font-family:var(--font-display)] text-lg">{{ t('shell.preferences.workspace') }}</h3>
948
+ <p class="text-xs text-[var(--muted-foreground)]">{{ t('shell.preferences.workspaceDescription') }}</p>
949
+ </div>
950
+ <StatusPill :label="t('shell.preferences.keepAlive')" tone="success" />
951
+ </div>
952
+ <div class="mt-4 grid gap-2 sm:grid-cols-2">
953
+ <button
954
+ type="button"
955
+ class="rounded-[var(--radius-md)] border p-3 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
956
+ :class="preferences.workspaceTabs.enabled ? 'border-[var(--border-strong)] bg-[var(--active-tab-background)] text-[var(--foreground)]' : 'border-[var(--border)] bg-[var(--surface)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]'"
957
+ @click="preferences.setTabsEnabled(!preferences.workspaceTabs.enabled)"
958
+ >
959
+ <span class="text-sm">{{ t('shell.preferences.workspaceTabs') }}</span>
960
+ <span class="float-right text-xs">{{ preferences.workspaceTabs.enabled ? t('shell.preferences.on') : t('shell.preferences.off') }}</span>
961
+ <span class="mt-2 block text-[11px] opacity-75">{{ t('shell.preferences.tabsDescription') }}</span>
962
+ </button>
963
+
964
+ <button
965
+ type="button"
966
+ class="rounded-[var(--radius-md)] border p-3 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
967
+ :class="preferences.stageManager.enabled ? 'border-[var(--border-strong)] bg-[var(--active-tab-background)] text-[var(--foreground)]' : 'border-[var(--border)] bg-[var(--surface)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]'"
968
+ @click="preferences.setStageManagerEnabled(!preferences.stageManager.enabled)"
969
+ >
970
+ <span class="text-sm">{{ t('shell.preferences.stageManagerShortcut') }}</span>
971
+ <span class="float-right text-xs">{{ preferences.stageManager.enabled ? t('shell.preferences.on') : t('shell.preferences.off') }}</span>
972
+ <span class="mt-2 block text-[11px] opacity-75">{{ t('shell.preferences.stageDescription') }}</span>
973
+ </button>
974
+ </div>
975
+
976
+ <div class="mt-4 grid gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-2 sm:grid-cols-2">
977
+ <button
978
+ v-for="stageMode in stagePresentationOptions"
979
+ :key="stageMode.id"
980
+ type="button"
981
+ class="rounded-[var(--radius-sm)] px-3 py-2 text-left transition focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
982
+ :class="stageMode.id === preferences.stageManager.presentationMode ? 'bg-[var(--active-tab-background)] text-[var(--foreground)] shadow-[var(--glow)]' : 'text-[var(--muted-foreground)] hover:bg-[var(--surface-raised)]'"
983
+ @click="selectStagePresentationMode(stageMode.id)"
984
+ >
985
+ <span class="block text-sm">{{ stageMode.label }}</span>
986
+ <span class="block text-[11px] opacity-75">{{ stageMode.detail }}</span>
987
+ </button>
988
+ </div>
989
+ </div>
990
+ </section>
991
+ </AdminScrollArea>
992
+ </section>
993
+ </div>
994
+ </Teleport>
995
+ </div>
996
+ </template>
997
+ `;
998
+ }