mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat: 更新嵌入式文件系统逻辑,支持路径清理和静态资源请求处理,增强前端主题切换能力
This commit is contained in:
@@ -1,32 +1,39 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/gin-contrib/static"
|
"github.com/gin-contrib/static"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
"strings"
|
||||||
|
)
|
||||||
// Credit: https://github.com/gin-contrib/static/issues/19
|
|
||||||
|
// Credit: https://github.com/gin-contrib/static/issues/19
|
||||||
type embedFileSystem struct {
|
|
||||||
http.FileSystem
|
type embedFileSystem struct {
|
||||||
}
|
http.FileSystem
|
||||||
|
}
|
||||||
func (e embedFileSystem) Exists(prefix string, path string) bool {
|
|
||||||
_, err := e.Open(path)
|
func (e embedFileSystem) Exists(prefix string, path string) bool {
|
||||||
if err != nil {
|
cleanPath := strings.TrimPrefix(path, prefix)
|
||||||
return false
|
cleanPath = strings.TrimPrefix(cleanPath, "/")
|
||||||
}
|
if cleanPath == "" {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
_, err := e.Open(cleanPath)
|
||||||
efs, err := fs.Sub(fsEmbed, targetPath)
|
if err != nil {
|
||||||
if err != nil {
|
return false
|
||||||
panic(err)
|
}
|
||||||
}
|
return true
|
||||||
return embedFileSystem{
|
}
|
||||||
FileSystem: http.FS(efs),
|
|
||||||
}
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/build
|
//go:embed all:web/build
|
||||||
var buildFS embed.FS
|
var buildFS embed.FS
|
||||||
|
|
||||||
//go:embed web/build/index.html
|
//go:embed web/build/index.html
|
||||||
|
|||||||
@@ -7,16 +7,59 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"github.com/gin-contrib/static"
|
"github.com/gin-contrib/static"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
pathpkg "path"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
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())
|
router.Use(middleware.GlobalWebRateLimit())
|
||||||
fileDownloadRoute := router.Group("/")
|
fileDownloadRoute := router.Group("/")
|
||||||
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
|
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
|
||||||
router.Use(middleware.Cache())
|
router.Use(middleware.Cache())
|
||||||
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
|
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
|
||||||
router.NoRoute(func(c *gin.Context) {
|
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)
|
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) != ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,18 +6,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background: var(--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));
|
|
||||||
color: var(--foreground-primary);
|
color: var(--foreground-primary);
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -30,6 +39,6 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(56, 189, 248, 0.32);
|
background: var(--selection-background);
|
||||||
color: #f8fafc;
|
color: var(--selection-foreground);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Script from 'next/script';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { AppProviders } from '@/components/providers/app-providers';
|
import { AppProviders } from '@/components/providers/app-providers';
|
||||||
|
import { getThemeInitScript } from '@/lib/theme/theme';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -22,6 +24,9 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<html lang='zh-CN' suppressHydrationWarning>
|
<html lang='zh-CN' suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
|
<Script id='theme-init' strategy='beforeInteractive'>
|
||||||
|
{getThemeInitScript()}
|
||||||
|
</Script>
|
||||||
<AppProviders>{children}</AppProviders>
|
<AppProviders>{children}</AppProviders>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function NotFound() {
|
|||||||
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'>
|
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'>
|
||||||
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
|
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
|
||||||
<Link href='/' className='inline-flex rounded-full bg-sky-400 px-4 py-2 font-medium text-slate-950 transition hover:bg-sky-300'>
|
<Link href='/' className='inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90'>
|
||||||
返回总览
|
返回总览
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
export function EmptyState({ title, description }: EmptyStateProps) {
|
export function EmptyState({ title, description }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-2xl border border-dashed border-white/10 bg-white/5 px-5 py-6 text-sm'>
|
<div className='rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm'>
|
||||||
<p className='text-base font-semibold text-white'>{title}</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{title}</p>
|
||||||
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ interface ErrorStateProps {
|
|||||||
|
|
||||||
export function ErrorState({ title, description }: ErrorStateProps) {
|
export function ErrorState({ title, description }: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-2xl border border-rose-400/20 bg-rose-500/10 px-5 py-6 text-sm'>
|
<div className='rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm'>
|
||||||
<p className='text-base font-semibold text-rose-100'>{title}</p>
|
<p className='text-base font-semibold text-[var(--status-danger-foreground)]'>{title}</p>
|
||||||
<p className='mt-2 leading-6 text-rose-100/80'>{description}</p>
|
<p className='mt-2 leading-6 text-[var(--status-danger-foreground)]/80'>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function FeaturePlaceholder({
|
|||||||
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
{milestones.map((item) => (
|
{milestones.map((item) => (
|
||||||
<li key={item} className='flex gap-3'>
|
<li key={item} className='flex gap-3'>
|
||||||
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-sky-400' />
|
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]' />
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ interface LoadingStateProps {
|
|||||||
export function LoadingState({ className }: LoadingStateProps) {
|
export function LoadingState({ className }: LoadingStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)}>
|
<div className={cn('space-y-3', className)}>
|
||||||
<div className='h-4 w-32 animate-pulse rounded bg-white/10' />
|
<div className='h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]' />
|
||||||
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
|
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
|
||||||
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
|
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export function DashboardSidebar() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex h-full flex-col gap-6'>
|
<div className='flex h-full flex-col gap-6'>
|
||||||
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-white/5 px-3 py-3'>
|
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
|
||||||
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-400/15 text-lg font-semibold text-sky-200'>
|
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-lg font-semibold text-[var(--brand-primary)]'>
|
||||||
AF
|
AF
|
||||||
</div>
|
</div>
|
||||||
{!isSidebarCollapsed ? (
|
{!isSidebarCollapsed ? (
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-semibold text-white'>ATSFlare</p>
|
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
|
||||||
<p className='text-xs text-[var(--foreground-secondary)]'>控制面新版工程</p>
|
<p className='text-xs text-[var(--foreground-secondary)]'>控制面新版工程</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -44,11 +44,11 @@ export function DashboardSidebar() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors',
|
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors',
|
||||||
active
|
active
|
||||||
? 'border-sky-300/30 bg-sky-400/10 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-white/5 hover:text-white',
|
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-xs font-semibold'>
|
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-xs font-semibold'>
|
||||||
{item.shortLabel.slice(0, 1)}
|
{item.shortLabel.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
{!isSidebarCollapsed ? (
|
{!isSidebarCollapsed ? (
|
||||||
@@ -65,7 +65,7 @@ export function DashboardSidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{!isSidebarCollapsed ? (
|
{!isSidebarCollapsed ? (
|
||||||
<div className='rounded-2xl border border-emerald-400/15 bg-emerald-500/10 px-4 py-4 text-xs leading-6 text-emerald-100/90'>
|
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
|
||||||
阶段 1 已完成目录骨架、静态导出与基础布局初始化。
|
阶段 1 已完成目录骨架、静态导出与基础布局初始化。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
|
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
|
||||||
import { publicEnv } from '@/lib/env/public-env';
|
import { publicEnv } from '@/lib/env/public-env';
|
||||||
import { useAppShellStore } from '@/store/app-shell';
|
import { useAppShellStore } from '@/store/app-shell';
|
||||||
@@ -18,20 +19,21 @@ export function DashboardTopbar() {
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-white/5 text-lg text-white transition hover:bg-white/10'
|
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
|
||||||
aria-label='切换侧边栏'
|
aria-label='切换侧边栏'
|
||||||
>
|
>
|
||||||
≡
|
≡
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
|
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
|
||||||
<h2 className='text-lg font-semibold text-white'>
|
<h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>
|
||||||
{currentItem?.label ?? 'ATSFlare 控制台'}
|
{currentItem?.label ?? 'ATSFlare 控制台'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||||
|
<ThemeToggle />
|
||||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||||
静态导出模式
|
静态导出模式
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ interface PageHeaderProps {
|
|||||||
export function PageHeader({ title, description }: PageHeaderProps) {
|
export function PageHeader({ title, description }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
|
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<h1 className='text-3xl font-semibold tracking-tight text-white'>{title}</h1>
|
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1>
|
||||||
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
|
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
import { publicEnv } from '@/lib/env/public-env';
|
import { publicEnv } from '@/lib/env/public-env';
|
||||||
|
|
||||||
interface PublicShellProps {
|
interface PublicShellProps {
|
||||||
@@ -13,13 +14,14 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
|
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
|
||||||
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
|
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
|
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||||
<h1 className='mt-2 text-3xl font-semibold text-white'>管理端改造进行中</h1>
|
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>管理端改造进行中</h1>
|
||||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
认证流程将在阶段 2 接入。当前页面用于承载入口骨架与路由占位。
|
认证流程将在阶段 2 接入。当前页面用于承载入口骨架与路由占位。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-[var(--foreground-secondary)]'>
|
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
||||||
|
<ThemeToggle />
|
||||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||||
{publicEnv.appVersion}
|
{publicEnv.appVersion}
|
||||||
</span>
|
</span>
|
||||||
@@ -29,7 +31,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
<div className='space-y-6'>{children}</div>
|
<div className='space-y-6'>{children}</div>
|
||||||
|
|
||||||
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
|
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
|
||||||
<Link href='/' className='text-sky-300 transition hover:text-sky-200'>
|
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
返回新版总览
|
返回新版总览
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
interface AppProvidersProps {
|
interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -24,5 +26,9 @@ export function AppProviders({ children }: AppProvidersProps) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ThemeContextValue | null>(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<ThemeMode>('system');
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export function AppCard({
|
|||||||
{(title || description || action) && (
|
{(title || description || action) && (
|
||||||
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'>
|
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
{title ? <h2 className='text-lg font-semibold text-white'>{title}</h2> : null}
|
{title ? <h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>{title}</h2> : null}
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
success: 'border-emerald-400/25 bg-emerald-500/15 text-emerald-200',
|
success:
|
||||||
warning: 'border-amber-400/25 bg-amber-500/15 text-amber-200',
|
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||||
danger: 'border-rose-400/25 bg-rose-500/15 text-rose-200',
|
warning:
|
||||||
info: 'border-sky-400/25 bg-sky-500/15 text-sky-200',
|
'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;
|
} as const;
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full border border-[var(--border-default)] bg-[var(--control-background)] p-1 text-xs text-[var(--foreground-secondary)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label='主题切换'
|
||||||
|
>
|
||||||
|
{themeOptions.map((option) => {
|
||||||
|
const active = themeMode === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setThemeMode(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-3 py-1.5 transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||||
|
: 'hover:bg-[var(--control-background-hover)] hover:text-[var(--foreground-primary)]',
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={
|
||||||
|
option.value === 'system'
|
||||||
|
? `当前跟随系统(${resolvedTheme === 'dark' ? '深色' : '浅色'})`
|
||||||
|
: `切换到${option.label}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,9 +31,9 @@ export function DashboardOverview() {
|
|||||||
{readinessItems.map((item) => (
|
{readinessItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.title}
|
key={item.title}
|
||||||
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4'
|
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'
|
||||||
>
|
>
|
||||||
<p className='text-base font-semibold text-white'>{item.title}</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{item.title}</p>
|
||||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -49,9 +49,9 @@ export function DashboardOverview() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4 transition hover:border-sky-300/30 hover:bg-sky-400/10'
|
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--accent-soft)]'
|
||||||
>
|
>
|
||||||
<p className='text-sm font-semibold text-white'>{item.label}</p>
|
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{item.label}</p>
|
||||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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<ThemeMode, 'system'>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
@@ -1,19 +1,80 @@
|
|||||||
:root {
|
: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-canvas: #08111f;
|
||||||
--surface-panel: #0f172a;
|
--surface-panel: #0f172a;
|
||||||
--surface-card: #111c33;
|
--surface-card: rgba(17, 28, 51, 0.9);
|
||||||
--surface-muted: #16243d;
|
--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-default: rgba(148, 163, 184, 0.18);
|
||||||
--border-strong: rgba(125, 211, 252, 0.28);
|
--border-strong: rgba(125, 211, 252, 0.28);
|
||||||
--foreground-primary: #e2e8f0;
|
--foreground-primary: #e2e8f0;
|
||||||
--foreground-secondary: #94a3b8;
|
--foreground-secondary: #94a3b8;
|
||||||
--foreground-muted: #64748b;
|
--foreground-muted: #64748b;
|
||||||
|
--foreground-inverse: #082f49;
|
||||||
--brand-primary: #38bdf8;
|
--brand-primary: #38bdf8;
|
||||||
--brand-secondary: #818cf8;
|
--brand-secondary: #818cf8;
|
||||||
--status-success: #22c55e;
|
--brand-primary-soft: rgba(56, 189, 248, 0.15);
|
||||||
--status-warning: #f59e0b;
|
--status-success-soft: rgba(34, 197, 94, 0.15);
|
||||||
--status-danger: #ef4444;
|
--status-success-border: rgba(52, 211, 153, 0.26);
|
||||||
--status-info: #38bdf8;
|
--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);
|
--shadow-soft: 0 16px 40px rgba(8, 15, 31, 0.35);
|
||||||
--radius-xl: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -220,6 +220,7 @@ V3 新增行为:
|
|||||||
* API 请求统一收敛到 `atsf_server/web/lib/api/`
|
* API 请求统一收敛到 `atsf_server/web/lib/api/`
|
||||||
* 页面路由与布局放在 `app/`,业务逻辑放在 `features/`
|
* 页面路由与布局放在 `app/`,业务逻辑放在 `features/`
|
||||||
* 构建产物必须保持可被 Go Server 静态托管
|
* 构建产物必须保持可被 Go Server 静态托管
|
||||||
|
* 新前端必须支持亮色 / 暗色模式切换,且主题能力不得只停留在局部页面或单个组件
|
||||||
|
|
||||||
如果第三版要新增页面,优先原则:
|
如果第三版要新增页面,优先原则:
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
* TanStack Query
|
* TanStack Query
|
||||||
* React Hook Form + Zod
|
* React Hook Form + Zod
|
||||||
* Zustand(仅限轻量客户端状态)
|
* Zustand(仅限轻量客户端状态)
|
||||||
* Vitest + Testing Library + Playwright
|
* Vitest + Testing Library
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
* 默认使用 App Router,不新建 Pages Router 结构
|
* 默认使用 App Router,不新建 Pages Router 结构
|
||||||
* 默认使用 Tailwind CSS,不再引入新的大型样式框架
|
* 默认使用 Tailwind CSS,不再引入新的大型样式框架
|
||||||
* 默认使用 NextUI 作为统一视觉组件基础
|
* 默认使用 NextUI 作为统一视觉组件基础
|
||||||
|
* 前端必须支持亮色 / 暗色模式切换,且主题切换能力应作为基础能力贯穿布局、组件与页面实现
|
||||||
|
|
||||||
禁止:
|
禁止:
|
||||||
|
|
||||||
@@ -310,6 +311,8 @@ tests/
|
|||||||
* Tailwind CSS 为布局、间距、响应式与业务样式扩展的基础方案
|
* Tailwind CSS 为布局、间距、响应式与业务样式扩展的基础方案
|
||||||
* 样式通过设计 token、NextUI 主题能力与语义类组合实现
|
* 样式通过设计 token、NextUI 主题能力与语义类组合实现
|
||||||
* 页面视觉风格统一、留白一致、层级清晰
|
* 页面视觉风格统一、留白一致、层级清晰
|
||||||
|
* 所有新页面与基础组件必须同时兼容亮色与暗色主题,禁止只实现单一主题
|
||||||
|
* 主题切换必须可由用户主动触发,并在路由切换和刷新后保持一致
|
||||||
|
|
||||||
### 9.2 设计 token
|
### 9.2 设计 token
|
||||||
|
|
||||||
@@ -318,6 +321,7 @@ tests/
|
|||||||
* 主色、成功色、警告色、危险色
|
* 主色、成功色、警告色、危险色
|
||||||
* 边框色、背景色、弱文本色、强文本色
|
* 边框色、背景色、弱文本色、强文本色
|
||||||
* 圆角、阴影、间距、层级
|
* 圆角、阴影、间距、层级
|
||||||
|
* 亮色 / 暗色两套语义 token 映射,以及主题切换所需的前景色、表面色、分隔色
|
||||||
|
|
||||||
### 9.3 组件外观要求
|
### 9.3 组件外观要求
|
||||||
|
|
||||||
@@ -330,6 +334,7 @@ tests/
|
|||||||
* 大量硬编码颜色值
|
* 大量硬编码颜色值
|
||||||
* 在 JSX 中堆砌不可读的超长类名且不抽组件
|
* 在 JSX 中堆砌不可读的超长类名且不抽组件
|
||||||
* 同一个状态在不同页面使用不同颜色语义
|
* 同一个状态在不同页面使用不同颜色语义
|
||||||
|
* 仅在暗色或仅在亮色模式下校验视觉效果后直接交付
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
* 使用 TypeScript 建立明确类型边界
|
* 使用 TypeScript 建立明确类型边界
|
||||||
* 建立可维护的目录结构、组件分层与请求层规范
|
* 建立可维护的目录结构、组件分层与请求层规范
|
||||||
* 提升首屏体验、构建质量、代码可测试性与长期可演进性
|
* 提升首屏体验、构建质量、代码可测试性与长期可演进性
|
||||||
|
* 建立统一的亮色 / 暗色主题体系,并支持用户切换
|
||||||
|
|
||||||
### 3.3 约束目标
|
### 3.3 约束目标
|
||||||
|
|
||||||
@@ -255,12 +256,29 @@ atsf_server/web/
|
|||||||
2. 接入 ESLint、Prettier、基础测试框架
|
2. 接入 ESLint、Prettier、基础测试框架
|
||||||
3. 建立 `app/`、`features/`、`components/`、`lib/` 基础结构
|
3. 建立 `app/`、`features/`、`components/`、`lib/` 基础结构
|
||||||
4. 完成全局布局、主题变量、基础 UI 组件骨架
|
4. 完成全局布局、主题变量、基础 UI 组件骨架
|
||||||
|
5. 建立亮色 / 暗色主题 token 与主题切换基础设施
|
||||||
|
|
||||||
|
模式切换补充要求:
|
||||||
|
|
||||||
|
* 阶段 1 即完成全局主题模式基础设施,不将模式切换延后到业务页面迁移阶段
|
||||||
|
* 默认支持“跟随系统”与“用户手动切换”两种模式来源
|
||||||
|
* 至少支持 `light`、`dark`、`system` 三种主题状态
|
||||||
|
* 用户手动选择后必须持久化,并在刷新、重新进入页面、路由切换后保持一致
|
||||||
|
* 首屏渲染应尽量避免主题闪烁,不能出现明显的先亮后暗或先暗后亮跳变
|
||||||
|
* 布局层、导航层、页面容器、基础卡片、按钮、表单容器等基础骨架必须率先接入双主题 token
|
||||||
|
* 主题切换实现应基于统一主题上下文或全局主题状态,不允许页面各自维护一套切换逻辑
|
||||||
|
* 所有新增颜色变量应优先落在语义 token 层,不直接把亮暗配色散落在业务组件中
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
* 可本地启动开发环境
|
* 可本地启动开发环境
|
||||||
* 可生成静态构建产物
|
* 可生成静态构建产物
|
||||||
* Go Server 可正确托管构建结果
|
* Go Server 可正确托管构建结果
|
||||||
|
* 亮色 / 暗色主题可切换,且基础布局在两种主题下均可正常显示
|
||||||
|
* 首次进入页面时可正确应用默认主题策略
|
||||||
|
* 用户切换主题后刷新页面仍保持所选模式
|
||||||
|
* 首页、公共布局、后台主框架在 `light` / `dark` 下均无明显可读性问题
|
||||||
|
* 阶段 1 交付的基础组件不依赖单一暗色样式前提
|
||||||
|
|
||||||
### 阶段 2:认证与框架层迁移
|
### 阶段 2:认证与框架层迁移
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user