From 3b362500f05bc593c33ef6640bdf53ce81247036 Mon Sep 17 00:00:00 2001 From: Cccc_ Date: Mon, 4 May 2026 15:54:29 +0800 Subject: [PATCH] feat(new-frontend): add theme and templates --- apps/new-frontend/package.json | 1 + apps/new-frontend/src/app/theme.test.ts | 56 +++ apps/new-frontend/src/app/theme.ts | 116 +++++ .../new-frontend/src/components/AppLayout.vue | 81 ++- .../src/components/StateBlock.vue | 20 +- .../templates/TemplateConfigEditor.vue | 246 ++++++++++ .../templates/TemplateFieldNode.vue | 463 ++++++++++++++++++ .../templates/template-config.test.ts | 127 +++++ .../components/templates/template-config.ts | 328 +++++++++++++ apps/new-frontend/src/components/ui.ts | 55 ++- apps/new-frontend/src/main.ts | 3 + apps/new-frontend/src/style.css | 62 +++ apps/new-frontend/src/views/LoginView.vue | 44 +- .../src/views/PendingApprovalView.vue | 14 +- .../src/views/admin/AdminTemplatesView.vue | 52 +- apps/new-frontend/tsconfig.app.json | 2 +- tests/test_new_frontend_architecture.py | 7 + 17 files changed, 1585 insertions(+), 92 deletions(-) create mode 100644 apps/new-frontend/src/app/theme.test.ts create mode 100644 apps/new-frontend/src/app/theme.ts create mode 100644 apps/new-frontend/src/components/templates/TemplateConfigEditor.vue create mode 100644 apps/new-frontend/src/components/templates/TemplateFieldNode.vue create mode 100644 apps/new-frontend/src/components/templates/template-config.test.ts create mode 100644 apps/new-frontend/src/components/templates/template-config.ts diff --git a/apps/new-frontend/package.json b/apps/new-frontend/package.json index a5b871c..056a30d 100644 --- a/apps/new-frontend/package.json +++ b/apps/new-frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", + "test": "node --test --experimental-strip-types src/app/theme.test.ts src/components/templates/template-config.test.ts", "typecheck": "vue-tsc -b", "lint": "eslint . --fix", "lint:check": "eslint .", diff --git a/apps/new-frontend/src/app/theme.test.ts b/apps/new-frontend/src/app/theme.test.ts new file mode 100644 index 0000000..9696172 --- /dev/null +++ b/apps/new-frontend/src/app/theme.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + THEME_STORAGE_KEY, + coerceThemeMode, + getResolvedTheme, + getStoredThemeMode, + nextThemeMode, + persistThemeMode, + type ThemeMode, +} from './theme.ts' + +class MemoryStorage { + private readonly values = new Map() + + getItem(key: string) { + return this.values.get(key) ?? null + } + + setItem(key: string, value: string) { + this.values.set(key, value) + } + + removeItem(key: string) { + this.values.delete(key) + } +} + +test('coerces unknown theme values to system', () => { + assert.equal(coerceThemeMode('light'), 'light') + assert.equal(coerceThemeMode('dark'), 'dark') + assert.equal(coerceThemeMode('system'), 'system') + assert.equal(coerceThemeMode('sepia'), 'system') + assert.equal(coerceThemeMode(null), 'system') +}) + +test('resolves system mode from user preference', () => { + assert.equal(getResolvedTheme('system', true), 'dark') + assert.equal(getResolvedTheme('system', false), 'light') + assert.equal(getResolvedTheme('dark', false), 'dark') +}) + +test('persists and restores theme mode', () => { + const storage = new MemoryStorage() + persistThemeMode('dark', storage) + + assert.equal(storage.getItem(THEME_STORAGE_KEY), 'dark') + assert.equal(getStoredThemeMode(storage), 'dark') +}) + +test('cycles theme modes predictably', () => { + const sequence: ThemeMode[] = ['light', 'dark', 'system', 'light'] + for (let index = 0; index < sequence.length - 1; index += 1) { + assert.equal(nextThemeMode(sequence[index]), sequence[index + 1]) + } +}) diff --git a/apps/new-frontend/src/app/theme.ts b/apps/new-frontend/src/app/theme.ts new file mode 100644 index 0000000..4bd48a4 --- /dev/null +++ b/apps/new-frontend/src/app/theme.ts @@ -0,0 +1,116 @@ +import { computed, reactive } from 'vue' + +export type ThemeMode = 'light' | 'dark' | 'system' +export type ResolvedTheme = 'light' | 'dark' + +export const THEME_STORAGE_KEY = 'checkin.theme.mode' + +type ThemeStorage = Pick + +interface ThemeState { + mode: ThemeMode + resolved: ResolvedTheme + initialized: boolean +} + +const state = reactive({ + mode: 'system', + resolved: 'light', + initialized: false, +}) + +let mediaQuery: MediaQueryList | null = null +let mediaUnsubscribe: (() => void) | null = null + +function canUseDom() { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +export function coerceThemeMode(value: unknown): ThemeMode { + return value === 'light' || value === 'dark' || value === 'system' ? value : 'system' +} + +export function getResolvedTheme(mode: ThemeMode, prefersDark: boolean): ResolvedTheme { + if (mode === 'dark') return 'dark' + if (mode === 'light') return 'light' + return prefersDark ? 'dark' : 'light' +} + +export function getStoredThemeMode(storage?: ThemeStorage): ThemeMode { + if (!storage) return 'system' + return coerceThemeMode(storage.getItem(THEME_STORAGE_KEY)) +} + +export function persistThemeMode(mode: ThemeMode, storage?: ThemeStorage) { + if (!storage) return + storage.setItem(THEME_STORAGE_KEY, mode) +} + +export function nextThemeMode(mode: ThemeMode): ThemeMode { + if (mode === 'light') return 'dark' + if (mode === 'dark') return 'system' + return 'light' +} + +function applyResolvedTheme(resolved: ResolvedTheme) { + if (!canUseDom()) return + document.documentElement.classList.toggle('dark', resolved === 'dark') + document.documentElement.style.colorScheme = resolved +} + +function currentPrefersDark() { + if (!canUseDom()) return false + mediaQuery = mediaQuery ?? window.matchMedia('(prefers-color-scheme: dark)') + return mediaQuery.matches +} + +function syncResolvedTheme() { + state.resolved = getResolvedTheme(state.mode, currentPrefersDark()) + applyResolvedTheme(state.resolved) +} + +function subscribeSystemPreference() { + if (!canUseDom() || mediaUnsubscribe) return + mediaQuery = mediaQuery ?? window.matchMedia('(prefers-color-scheme: dark)') + const listener = () => { + if (state.mode === 'system') syncResolvedTheme() + } + mediaQuery.addEventListener('change', listener) + mediaUnsubscribe = () => mediaQuery?.removeEventListener('change', listener) +} + +export function initTheme() { + if (!canUseDom()) return + state.mode = getStoredThemeMode(window.localStorage) + syncResolvedTheme() + subscribeSystemPreference() + state.initialized = true +} + +export function setThemeMode(mode: ThemeMode) { + state.mode = mode + if (canUseDom()) persistThemeMode(mode, window.localStorage) + syncResolvedTheme() +} + +export function cycleThemeMode() { + setThemeMode(nextThemeMode(state.mode)) +} + +export function disposeThemeListener() { + mediaUnsubscribe?.() + mediaUnsubscribe = null +} + +export function useTheme() { + return { + state, + modeLabel: computed(() => { + if (state.mode === 'light') return '亮色' + if (state.mode === 'dark') return '暗色' + return state.resolved === 'dark' ? '跟随系统:暗色' : '跟随系统:亮色' + }), + setThemeMode, + cycleThemeMode, + } +} diff --git a/apps/new-frontend/src/components/AppLayout.vue b/apps/new-frontend/src/components/AppLayout.vue index b4a9a11..58c1358 100644 --- a/apps/new-frontend/src/components/AppLayout.vue +++ b/apps/new-frontend/src/components/AppLayout.vue @@ -8,6 +8,8 @@ import { LayoutDashboard, LogOut, Menu, + Monitor, + MoonStar, ScrollText, Settings, Shield, @@ -18,9 +20,11 @@ import { import { computed, ref } from 'vue' import { useAuth } from '@/app/auth' import { useRouter } from '@/app/router' +import { useTheme } from '@/app/theme' const { state: authState, isAdmin, logout } = useAuth() const router = useRouter() +const theme = useTheme() const mobileOpen = ref(false) const userLinks = [ @@ -42,6 +46,7 @@ const title = computed(() => router.current.value.title) const isAdminRoute = computed(() => router.state.path.startsWith('/admin')) const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批')) const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户')) +const themeLabel = computed(() => theme.modeLabel.value) function go(path: string) { mobileOpen.value = false @@ -55,13 +60,15 @@ function signOut() {