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.
- package/README.md +9 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/generate-starter.d.ts +9 -0
- package/dist/generate-starter.d.ts.map +1 -0
- package/dist/generate-starter.js +150 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/parse-args.d.ts +21 -0
- package/dist/parse-args.d.ts.map +1 -0
- package/dist/parse-args.js +104 -0
- package/dist/run-create-super-admin.d.ts +8 -0
- package/dist/run-create-super-admin.d.ts.map +1 -0
- package/dist/run-create-super-admin.js +33 -0
- package/dist/templates.d.ts +22 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +998 -0
- package/dist/theme-options.d.ts +14 -0
- package/dist/theme-options.d.ts.map +1 -0
- package/dist/theme-options.js +35 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|