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}
+
);
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:认证与框架层迁移