diff --git a/atsf_server/common/embed-file-system.go b/atsf_server/common/embed-file-system.go index 3ea02cf8..0c45367d 100644 --- a/atsf_server/common/embed-file-system.go +++ b/atsf_server/common/embed-file-system.go @@ -1,32 +1,39 @@ -package common - -import ( - "embed" - "github.com/gin-contrib/static" - "io/fs" - "net/http" -) - -// Credit: https://github.com/gin-contrib/static/issues/19 - -type embedFileSystem struct { - http.FileSystem -} - -func (e embedFileSystem) Exists(prefix string, path string) bool { - _, err := e.Open(path) - if err != nil { - return false - } - return true -} - -func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { - efs, err := fs.Sub(fsEmbed, targetPath) - if err != nil { - panic(err) - } - return embedFileSystem{ - FileSystem: http.FS(efs), - } -} +package common + +import ( + "embed" + "github.com/gin-contrib/static" + "io/fs" + "net/http" + "strings" +) + +// Credit: https://github.com/gin-contrib/static/issues/19 + +type embedFileSystem struct { + http.FileSystem +} + +func (e embedFileSystem) Exists(prefix string, path string) bool { + cleanPath := strings.TrimPrefix(path, prefix) + cleanPath = strings.TrimPrefix(cleanPath, "/") + if cleanPath == "" { + return false + } + + _, err := e.Open(cleanPath) + if err != nil { + return false + } + return true +} + +func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { + efs, err := fs.Sub(fsEmbed, targetPath) + if err != nil { + panic(err) + } + return embedFileSystem{ + FileSystem: http.FS(efs), + } +} diff --git a/atsf_server/main.go b/atsf_server/main.go index 9444c350..01bb1353 100644 --- a/atsf_server/main.go +++ b/atsf_server/main.go @@ -16,7 +16,7 @@ import ( "strconv" ) -//go:embed web/build +//go:embed all:web/build var buildFS embed.FS //go:embed web/build/index.html diff --git a/atsf_server/router/web-router.go b/atsf_server/router/web-router.go index ff836efb..1be2f194 100644 --- a/atsf_server/router/web-router.go +++ b/atsf_server/router/web-router.go @@ -7,16 +7,59 @@ import ( "embed" "github.com/gin-contrib/static" "github.com/gin-gonic/gin" + "io/fs" "net/http" + pathpkg "path" + "strings" ) func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + exportedBuildFS, err := fs.Sub(buildFS, "web/build") + if err != nil { + panic(err) + } + router.Use(middleware.GlobalWebRateLimit()) fileDownloadRoute := router.Group("/") fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile) router.Use(middleware.Cache()) router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build"))) router.NoRoute(func(c *gin.Context) { + if serveExportedPage(c, exportedBuildFS) { + return + } + + if isStaticAssetRequest(c.Request.URL.Path) { + c.Status(http.StatusNotFound) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) }) } + +func serveExportedPage(c *gin.Context, buildFS fs.FS) bool { + requestPath := strings.Trim(c.Request.URL.Path, "/") + + candidates := []string{"index.html"} + if requestPath != "" { + candidates = []string{ + requestPath + ".html", + pathpkg.Join(requestPath, "index.html"), + } + } + + for _, candidate := range candidates { + content, err := fs.ReadFile(buildFS, candidate) + if err == nil { + c.Data(http.StatusOK, "text/html; charset=utf-8", content) + return true + } + } + + return false +} + +func isStaticAssetRequest(requestPath string) bool { + return strings.HasPrefix(requestPath, "/_next/") || pathpkg.Ext(requestPath) != "" +} diff --git a/atsf_server/web/app/globals.css b/atsf_server/web/app/globals.css index d6cd5a6c..3578abea 100644 --- a/atsf_server/web/app/globals.css +++ b/atsf_server/web/app/globals.css @@ -6,18 +6,27 @@ } html { + background: var(--surface-canvas); +} + +html[data-theme='light'] { + color-scheme: light; +} + +html[data-theme='dark'] { color-scheme: dark; } body { min-height: 100vh; - background: - radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 30%), - linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(8, 17, 31, 1)); + background: var(--body-background); color: var(--foreground-primary); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + transition: + background 0.2s ease, + color 0.2s ease; } a { @@ -30,6 +39,6 @@ button { } ::selection { - background: rgba(56, 189, 248, 0.32); - color: #f8fafc; + background: var(--selection-background); + color: var(--selection-foreground); } diff --git a/atsf_server/web/app/layout.tsx b/atsf_server/web/app/layout.tsx index f26c7054..66d512d7 100644 --- a/atsf_server/web/app/layout.tsx +++ b/atsf_server/web/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from 'next'; +import Script from 'next/script'; import type { ReactNode } from 'react'; import { AppProviders } from '@/components/providers/app-providers'; +import { getThemeInitScript } from '@/lib/theme/theme'; import './globals.css'; @@ -22,6 +24,9 @@ export default function RootLayout({ children }: RootLayoutProps) { return ( + {children} diff --git a/atsf_server/web/app/not-found.tsx b/atsf_server/web/app/not-found.tsx index 414e841f..d5c740ab 100644 --- a/atsf_server/web/app/not-found.tsx +++ b/atsf_server/web/app/not-found.tsx @@ -8,7 +8,7 @@ export default function NotFound() {

请返回总览页,或继续通过侧边导航访问已初始化的模块入口。

- + 返回总览
diff --git a/atsf_server/web/components/feedback/empty-state.tsx b/atsf_server/web/components/feedback/empty-state.tsx index 36cf798c..3d92b179 100644 --- a/atsf_server/web/components/feedback/empty-state.tsx +++ b/atsf_server/web/components/feedback/empty-state.tsx @@ -5,8 +5,8 @@ interface EmptyStateProps { export function EmptyState({ title, description }: EmptyStateProps) { return ( -
-

{title}

+
+

{title}

{description}

); diff --git a/atsf_server/web/components/feedback/error-state.tsx b/atsf_server/web/components/feedback/error-state.tsx index 8511aa10..e1b8b317 100644 --- a/atsf_server/web/components/feedback/error-state.tsx +++ b/atsf_server/web/components/feedback/error-state.tsx @@ -5,9 +5,9 @@ interface ErrorStateProps { export function ErrorState({ title, description }: ErrorStateProps) { return ( -
-

{title}

-

{description}

+
+

{title}

+

{description}

); } diff --git a/atsf_server/web/components/feedback/feature-placeholder.tsx b/atsf_server/web/components/feedback/feature-placeholder.tsx index e8da127c..cda1bb65 100644 --- a/atsf_server/web/components/feedback/feature-placeholder.tsx +++ b/atsf_server/web/components/feedback/feature-placeholder.tsx @@ -21,7 +21,7 @@ export function FeaturePlaceholder({
    {milestones.map((item) => (
  • - + {item}
  • ))} diff --git a/atsf_server/web/components/feedback/loading-state.tsx b/atsf_server/web/components/feedback/loading-state.tsx index 6f9883e0..4305074f 100644 --- a/atsf_server/web/components/feedback/loading-state.tsx +++ b/atsf_server/web/components/feedback/loading-state.tsx @@ -7,9 +7,9 @@ interface LoadingStateProps { export function LoadingState({ className }: LoadingStateProps) { return (
    -
    -
    -
    +
    +
    +
    ); } diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx index 78787cc2..e8be5f04 100644 --- a/atsf_server/web/components/layout/dashboard-sidebar.tsx +++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx @@ -21,13 +21,13 @@ export function DashboardSidebar() { )} >
    -
    -
    +
    +
    AF
    {!isSidebarCollapsed ? (
    -

    ATSFlare

    +

    ATSFlare

    控制面新版工程

    ) : null} @@ -44,11 +44,11 @@ export function DashboardSidebar() { className={cn( 'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors', active - ? 'border-sky-300/30 bg-sky-400/10 text-white' - : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-white/5 hover:text-white', + ? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]' + : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]', )} > - + {item.shortLabel.slice(0, 1)} {!isSidebarCollapsed ? ( @@ -65,7 +65,7 @@ export function DashboardSidebar() { {!isSidebarCollapsed ? ( -
    +
    阶段 1 已完成目录骨架、静态导出与基础布局初始化。
    ) : null} diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index 001fc1ea..2ea0b9a5 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ThemeToggle } from '@/components/ui/theme-toggle'; import { getCurrentNavigationItem } from '@/lib/utils/navigation'; import { publicEnv } from '@/lib/env/public-env'; import { useAppShellStore } from '@/store/app-shell'; @@ -18,20 +19,21 @@ export function DashboardTopbar() {

    当前模块

    -

    +

    {currentItem?.label ?? 'ATSFlare 控制台'}

    + 静态导出模式 diff --git a/atsf_server/web/components/layout/page-header.tsx b/atsf_server/web/components/layout/page-header.tsx index 4864923f..a83dd323 100644 --- a/atsf_server/web/components/layout/page-header.tsx +++ b/atsf_server/web/components/layout/page-header.tsx @@ -6,9 +6,9 @@ interface PageHeaderProps { export function PageHeader({ title, description }: PageHeaderProps) { return (
    -

    ATSFlare

    +

    ATSFlare

    -

    {title}

    +

    {title}

    {description}

    diff --git a/atsf_server/web/components/layout/public-shell.tsx b/atsf_server/web/components/layout/public-shell.tsx index 76dc44a8..33b6fea4 100644 --- a/atsf_server/web/components/layout/public-shell.tsx +++ b/atsf_server/web/components/layout/public-shell.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; +import { ThemeToggle } from '@/components/ui/theme-toggle'; import { publicEnv } from '@/lib/env/public-env'; interface PublicShellProps { @@ -13,13 +14,14 @@ export function PublicShell({ children }: PublicShellProps) {
    -

    ATSFlare

    -

    管理端改造进行中

    +

    ATSFlare

    +

    管理端改造进行中

    认证流程将在阶段 2 接入。当前页面用于承载入口骨架与路由占位。

    -
    +
    + {publicEnv.appVersion} @@ -29,7 +31,7 @@ export function PublicShell({ children }: PublicShellProps) {
    {children}
    - + 返回新版总览
    diff --git a/atsf_server/web/components/providers/app-providers.tsx b/atsf_server/web/components/providers/app-providers.tsx index 73b5fcae..33c66beb 100644 --- a/atsf_server/web/components/providers/app-providers.tsx +++ b/atsf_server/web/components/providers/app-providers.tsx @@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { type ReactNode, useState } from 'react'; +import { ThemeProvider } from '@/components/providers/theme-provider'; + interface AppProvidersProps { children: ReactNode; } @@ -24,5 +26,9 @@ export function AppProviders({ children }: AppProvidersProps) { }), ); - return {children}; + return ( + + {children} + + ); } diff --git a/atsf_server/web/components/providers/theme-provider.tsx b/atsf_server/web/components/providers/theme-provider.tsx new file mode 100644 index 00000000..594b30e4 --- /dev/null +++ b/atsf_server/web/components/providers/theme-provider.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + isThemeMode, + resolveTheme, + THEME_MEDIA_QUERY, + THEME_STORAGE_KEY, + type ResolvedTheme, + type ThemeMode, +} from '@/lib/theme/theme'; + +interface ThemeContextValue { + themeMode: ThemeMode; + resolvedTheme: ResolvedTheme; + setThemeMode: (mode: ThemeMode) => void; +} + +const ThemeContext = createContext(null); + +interface ThemeProviderProps { + children: ReactNode; +} + +function getSystemPreference() { + return window.matchMedia(THEME_MEDIA_QUERY).matches; +} + +function applyTheme(mode: ThemeMode) { + const resolvedTheme = resolveTheme(mode, getSystemPreference()); + const root = document.documentElement; + + root.dataset.themeMode = mode; + root.dataset.theme = resolvedTheme; + root.style.colorScheme = resolvedTheme; + window.localStorage.setItem(THEME_STORAGE_KEY, mode); + + return resolvedTheme; +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + const [themeMode, setThemeModeState] = useState('system'); + const [resolvedTheme, setResolvedTheme] = useState('dark'); + + useEffect(() => { + const root = document.documentElement; + const domThemeMode = root.dataset.themeMode; + const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system'; + const nextResolvedTheme = applyTheme(nextThemeMode); + + setThemeModeState(nextThemeMode); + setResolvedTheme(nextResolvedTheme); + }, []); + + useEffect(() => { + const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY); + + const handleChange = () => { + if (themeMode !== 'system') { + return; + } + + setResolvedTheme(applyTheme('system')); + }; + + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, [themeMode]); + + const setThemeMode = useCallback((mode: ThemeMode) => { + setThemeModeState(mode); + setResolvedTheme(applyTheme(mode)); + }, []); + + const value = useMemo( + () => ({ + themeMode, + resolvedTheme, + setThemeMode, + }), + [resolvedTheme, setThemeMode, themeMode], + ); + + return {children}; +} + +export function useTheme() { + const context = useContext(ThemeContext); + + if (!context) { + throw new Error('useTheme must be used within ThemeProvider'); + } + + return context; +} diff --git a/atsf_server/web/components/ui/app-card.tsx b/atsf_server/web/components/ui/app-card.tsx index d76846bb..879a7dc5 100644 --- a/atsf_server/web/components/ui/app-card.tsx +++ b/atsf_server/web/components/ui/app-card.tsx @@ -27,7 +27,7 @@ export function AppCard({ {(title || description || action) && (
    - {title ?

    {title}

    : null} + {title ?

    {title}

    : null} {description ? (

    {description}

    ) : null} diff --git a/atsf_server/web/components/ui/status-badge.tsx b/atsf_server/web/components/ui/status-badge.tsx index efcff794..8f19c770 100644 --- a/atsf_server/web/components/ui/status-badge.tsx +++ b/atsf_server/web/components/ui/status-badge.tsx @@ -1,10 +1,14 @@ import { cn } from '@/lib/utils/cn'; const variantClasses = { - success: 'border-emerald-400/25 bg-emerald-500/15 text-emerald-200', - warning: 'border-amber-400/25 bg-amber-500/15 text-amber-200', - danger: 'border-rose-400/25 bg-rose-500/15 text-rose-200', - info: 'border-sky-400/25 bg-sky-500/15 text-sky-200', + success: + 'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]', + warning: + 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]', + danger: + 'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]', + info: + 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]', } as const; interface StatusBadgeProps { diff --git a/atsf_server/web/components/ui/theme-toggle.tsx b/atsf_server/web/components/ui/theme-toggle.tsx new file mode 100644 index 00000000..83a16da1 --- /dev/null +++ b/atsf_server/web/components/ui/theme-toggle.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { cn } from '@/lib/utils/cn'; +import type { ThemeMode } from '@/lib/theme/theme'; +import { useTheme } from '@/components/providers/theme-provider'; + +const themeOptions: Array<{ value: ThemeMode; label: string }> = [ + { value: 'light', label: '浅色' }, + { value: 'system', label: '跟随系统' }, + { value: 'dark', label: '深色' }, +]; + +interface ThemeToggleProps { + className?: string; +} + +export function ThemeToggle({ className }: ThemeToggleProps) { + const { themeMode, resolvedTheme, setThemeMode } = useTheme(); + + return ( +
    + {themeOptions.map((option) => { + const active = themeMode === option.value; + + return ( + + ); + })} +
    + ); +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index f358f163..201d4847 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -31,9 +31,9 @@ export function DashboardOverview() { {readinessItems.map((item) => (
    -

    {item.title}

    +

    {item.title}

    {item.description}

    @@ -49,9 +49,9 @@ export function DashboardOverview() { -

    {item.label}

    +

    {item.label}

    {item.description}

    diff --git a/atsf_server/web/lib/theme/theme.ts b/atsf_server/web/lib/theme/theme.ts new file mode 100644 index 00000000..7e96308c --- /dev/null +++ b/atsf_server/web/lib/theme/theme.ts @@ -0,0 +1,40 @@ +export const THEME_STORAGE_KEY = 'atsflare-theme-mode'; +export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)'; + +export const themeModes = ['light', 'dark', 'system'] as const; + +export type ThemeMode = (typeof themeModes)[number]; +export type ResolvedTheme = Exclude; + +export function isThemeMode(value: string | null | undefined): value is ThemeMode { + return themeModes.includes(value as ThemeMode); +} + +export function resolveTheme( + mode: ThemeMode, + systemPrefersDark: boolean, +): ResolvedTheme { + if (mode === 'system') { + return systemPrefersDark ? 'dark' : 'light'; + } + + return mode; +} + +export function getThemeInitScript() { + return `(() => { + const storageKey = '${THEME_STORAGE_KEY}'; + const mediaQuery = '${THEME_MEDIA_QUERY}'; + const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system'; + const storedValue = window.localStorage.getItem(storageKey); + const themeMode = isThemeMode(storedValue) ? storedValue : 'system'; + const resolvedTheme = themeMode === 'system' + ? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light') + : themeMode; + const root = document.documentElement; + + root.dataset.themeMode = themeMode; + root.dataset.theme = resolvedTheme; + root.style.colorScheme = resolvedTheme; + })();`; +} diff --git a/atsf_server/web/styles/tokens.css b/atsf_server/web/styles/tokens.css index 002a8bc2..f660852a 100644 --- a/atsf_server/web/styles/tokens.css +++ b/atsf_server/web/styles/tokens.css @@ -1,19 +1,80 @@ :root { + --surface-canvas: #eff6ff; + --surface-panel: #ffffff; + --surface-card: rgba(255, 255, 255, 0.92); + --surface-muted: rgba(226, 232, 240, 0.65); + --surface-elevated: rgba(255, 255, 255, 0.78); + --control-background: rgba(15, 23, 42, 0.04); + --control-background-hover: rgba(15, 23, 42, 0.08); + --accent-soft: rgba(14, 165, 233, 0.12); + --border-default: rgba(148, 163, 184, 0.22); + --border-strong: rgba(14, 165, 233, 0.26); + --foreground-primary: #0f172a; + --foreground-secondary: #475569; + --foreground-muted: #64748b; + --foreground-inverse: #f8fafc; + --brand-primary: #0284c7; + --brand-secondary: #6366f1; + --brand-primary-soft: rgba(14, 165, 233, 0.12); + --status-success: #22c55e; + --status-warning: #f59e0b; + --status-danger: #ef4444; + --status-info: #38bdf8; + --status-success-soft: rgba(34, 197, 94, 0.12); + --status-success-border: rgba(34, 197, 94, 0.2); + --status-success-foreground: #166534; + --status-warning-soft: rgba(245, 158, 11, 0.14); + --status-warning-border: rgba(245, 158, 11, 0.22); + --status-warning-foreground: #92400e; + --status-danger-soft: rgba(239, 68, 68, 0.12); + --status-danger-border: rgba(239, 68, 68, 0.2); + --status-danger-foreground: #b91c1c; + --status-info-soft: rgba(56, 189, 248, 0.12); + --status-info-border: rgba(56, 189, 248, 0.22); + --status-info-foreground: #0369a1; + --body-background: + radial-gradient(circle at top, rgba(56, 189, 248, 0.14), transparent 30%), + linear-gradient(180deg, #f8fbff, #eff6ff); + --selection-background: rgba(14, 165, 233, 0.24); + --selection-foreground: #082f49; + --shadow-soft: 0 16px 40px rgba(15, 23, 42, 0.08); + --radius-xl: 1.25rem; +} + +:root[data-theme='dark'] { --surface-canvas: #08111f; --surface-panel: #0f172a; - --surface-card: #111c33; - --surface-muted: #16243d; + --surface-card: rgba(17, 28, 51, 0.9); + --surface-muted: rgba(255, 255, 255, 0.05); + --surface-elevated: rgba(15, 23, 42, 0.95); + --control-background: rgba(255, 255, 255, 0.05); + --control-background-hover: rgba(255, 255, 255, 0.1); + --accent-soft: rgba(56, 189, 248, 0.12); --border-default: rgba(148, 163, 184, 0.18); --border-strong: rgba(125, 211, 252, 0.28); --foreground-primary: #e2e8f0; --foreground-secondary: #94a3b8; --foreground-muted: #64748b; + --foreground-inverse: #082f49; --brand-primary: #38bdf8; --brand-secondary: #818cf8; - --status-success: #22c55e; - --status-warning: #f59e0b; - --status-danger: #ef4444; - --status-info: #38bdf8; + --brand-primary-soft: rgba(56, 189, 248, 0.15); + --status-success-soft: rgba(34, 197, 94, 0.15); + --status-success-border: rgba(52, 211, 153, 0.26); + --status-success-foreground: #d1fae5; + --status-warning-soft: rgba(245, 158, 11, 0.15); + --status-warning-border: rgba(251, 191, 36, 0.28); + --status-warning-foreground: #fde68a; + --status-danger-soft: rgba(239, 68, 68, 0.14); + --status-danger-border: rgba(251, 113, 133, 0.24); + --status-danger-foreground: #ffe4e6; + --status-info-soft: rgba(14, 165, 233, 0.14); + --status-info-border: rgba(56, 189, 248, 0.24); + --status-info-foreground: #bae6fd; + --body-background: + radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 30%), + linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(8, 17, 31, 1)); + --selection-background: rgba(56, 189, 248, 0.32); + --selection-foreground: #f8fafc; --shadow-soft: 0 16px 40px rgba(8, 15, 31, 0.35); - --radius-xl: 1.25rem; } diff --git a/atsf_server/web/tests/unit/theme-utils.test.ts b/atsf_server/web/tests/unit/theme-utils.test.ts new file mode 100644 index 00000000..4796d44e --- /dev/null +++ b/atsf_server/web/tests/unit/theme-utils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { isThemeMode, resolveTheme } from '@/lib/theme/theme'; + +describe('theme utils', () => { + it('validates theme mode values', () => { + expect(isThemeMode('light')).toBe(true); + expect(isThemeMode('dark')).toBe(true); + expect(isThemeMode('system')).toBe(true); + expect(isThemeMode('custom')).toBe(false); + }); + + it('resolves system mode using system preference', () => { + expect(resolveTheme('system', true)).toBe('dark'); + expect(resolveTheme('system', false)).toBe('light'); + expect(resolveTheme('dark', false)).toBe('dark'); + expect(resolveTheme('light', true)).toBe('light'); + }); +}); diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 3e3a5b83..f7e379a2 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -220,6 +220,7 @@ V3 新增行为: * API 请求统一收敛到 `atsf_server/web/lib/api/` * 页面路由与布局放在 `app/`,业务逻辑放在 `features/` * 构建产物必须保持可被 Go Server 静态托管 +* 新前端必须支持亮色 / 暗色模式切换,且主题能力不得只停留在局部页面或单个组件 如果第三版要新增页面,优先原则: diff --git a/docs/frontend-development-guidelines.md b/docs/frontend-development-guidelines.md index 14c42cb5..5fee49eb 100644 --- a/docs/frontend-development-guidelines.md +++ b/docs/frontend-development-guidelines.md @@ -30,7 +30,7 @@ * TanStack Query * React Hook Form + Zod * Zustand(仅限轻量客户端状态) -* Vitest + Testing Library + Playwright +* Vitest + Testing Library 要求: @@ -39,6 +39,7 @@ * 默认使用 App Router,不新建 Pages Router 结构 * 默认使用 Tailwind CSS,不再引入新的大型样式框架 * 默认使用 NextUI 作为统一视觉组件基础 +* 前端必须支持亮色 / 暗色模式切换,且主题切换能力应作为基础能力贯穿布局、组件与页面实现 禁止: @@ -310,6 +311,8 @@ tests/ * Tailwind CSS 为布局、间距、响应式与业务样式扩展的基础方案 * 样式通过设计 token、NextUI 主题能力与语义类组合实现 * 页面视觉风格统一、留白一致、层级清晰 +* 所有新页面与基础组件必须同时兼容亮色与暗色主题,禁止只实现单一主题 +* 主题切换必须可由用户主动触发,并在路由切换和刷新后保持一致 ### 9.2 设计 token @@ -318,6 +321,7 @@ tests/ * 主色、成功色、警告色、危险色 * 边框色、背景色、弱文本色、强文本色 * 圆角、阴影、间距、层级 +* 亮色 / 暗色两套语义 token 映射,以及主题切换所需的前景色、表面色、分隔色 ### 9.3 组件外观要求 @@ -330,6 +334,7 @@ tests/ * 大量硬编码颜色值 * 在 JSX 中堆砌不可读的超长类名且不抽组件 * 同一个状态在不同页面使用不同颜色语义 +* 仅在暗色或仅在亮色模式下校验视觉效果后直接交付 --- diff --git a/docs/frontend-revamp-plan.md b/docs/frontend-revamp-plan.md index 715be3c7..2ba52f43 100644 --- a/docs/frontend-revamp-plan.md +++ b/docs/frontend-revamp-plan.md @@ -59,6 +59,7 @@ * 使用 TypeScript 建立明确类型边界 * 建立可维护的目录结构、组件分层与请求层规范 * 提升首屏体验、构建质量、代码可测试性与长期可演进性 +* 建立统一的亮色 / 暗色主题体系,并支持用户切换 ### 3.3 约束目标 @@ -255,12 +256,29 @@ atsf_server/web/ 2. 接入 ESLint、Prettier、基础测试框架 3. 建立 `app/`、`features/`、`components/`、`lib/` 基础结构 4. 完成全局布局、主题变量、基础 UI 组件骨架 +5. 建立亮色 / 暗色主题 token 与主题切换基础设施 + +模式切换补充要求: + +* 阶段 1 即完成全局主题模式基础设施,不将模式切换延后到业务页面迁移阶段 +* 默认支持“跟随系统”与“用户手动切换”两种模式来源 +* 至少支持 `light`、`dark`、`system` 三种主题状态 +* 用户手动选择后必须持久化,并在刷新、重新进入页面、路由切换后保持一致 +* 首屏渲染应尽量避免主题闪烁,不能出现明显的先亮后暗或先暗后亮跳变 +* 布局层、导航层、页面容器、基础卡片、按钮、表单容器等基础骨架必须率先接入双主题 token +* 主题切换实现应基于统一主题上下文或全局主题状态,不允许页面各自维护一套切换逻辑 +* 所有新增颜色变量应优先落在语义 token 层,不直接把亮暗配色散落在业务组件中 验收: * 可本地启动开发环境 * 可生成静态构建产物 * Go Server 可正确托管构建结果 +* 亮色 / 暗色主题可切换,且基础布局在两种主题下均可正常显示 +* 首次进入页面时可正确应用默认主题策略 +* 用户切换主题后刷新页面仍保持所选模式 +* 首页、公共布局、后台主框架在 `light` / `dark` 下均无明显可读性问题 +* 阶段 1 交付的基础组件不依赖单一暗色样式前提 ### 阶段 2:认证与框架层迁移