From bd4e1f66cb55b57b411d35f1e33ecdfeb93e896d Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 20 Mar 2026 22:47:49 +0800 Subject: [PATCH] feat: implement theme selection and system integration --- AGENTS.md | 2 +- go-backend/internal/store/model/model.go | 6 +- plans/059-theme-system-design.md | 59 +++ .../src/components/theme-provider.tsx | 57 +-- .../src/components/theme-settings.tsx | 179 ++++++++ vite-frontend/src/pages/node.tsx | 7 - vite-frontend/src/pages/settings.tsx | 2 + .../src/shadcn-bridge/heroui/use-theme.tsx | 83 ++-- vite-frontend/src/themes/README.md | 382 ++++++++++++++++++ vite-frontend/src/themes/context.tsx | 194 +++++++++ vite-frontend/src/themes/default/index.ts | 137 +++++++ .../example-cyberpunk/components/button.tsx | 55 +++ .../src/themes/example-cyberpunk/index.ts | 133 ++++++ vite-frontend/src/themes/index.ts | 45 +++ vite-frontend/src/themes/loader.ts | 36 ++ vite-frontend/src/themes/registry.ts | 263 ++++++++++++ vite-frontend/src/themes/types.ts | 299 ++++++++++++++ 17 files changed, 1834 insertions(+), 105 deletions(-) create mode 100644 plans/059-theme-system-design.md create mode 100644 vite-frontend/src/components/theme-settings.tsx create mode 100644 vite-frontend/src/themes/README.md create mode 100644 vite-frontend/src/themes/context.tsx create mode 100644 vite-frontend/src/themes/default/index.ts create mode 100644 vite-frontend/src/themes/example-cyberpunk/components/button.tsx create mode 100644 vite-frontend/src/themes/example-cyberpunk/index.ts create mode 100644 vite-frontend/src/themes/index.ts create mode 100644 vite-frontend/src/themes/loader.ts create mode 100644 vite-frontend/src/themes/registry.ts create mode 100644 vite-frontend/src/themes/types.ts diff --git a/AGENTS.md b/AGENTS.md index 530e7eb..7dd3fc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ **Generated:** Fri Mar 20 2026 **Commit:** f45f960 **Branch:** main -**Tag:** 2.1.9-beta6 +**Tag:** 2.1.9-beta8 ## OVERVIEW FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers. diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 54f59a1..7e47838 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -675,9 +675,9 @@ func (NodeMetric) TableName() string { return "node_metric" } type TunnelMetric struct { ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` - TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"` - NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"` - Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"` + TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"` + NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"` + Timestamp int64 `gorm:"not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"` BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"` BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"` Connections int64 `gorm:"column:connections" json:"connections"` diff --git a/plans/059-theme-system-design.md b/plans/059-theme-system-design.md new file mode 100644 index 0000000..2e780aa --- /dev/null +++ b/plans/059-theme-system-design.md @@ -0,0 +1,59 @@ +# 059 - 主题系统设计(v2 — 完整可扩展架构) + +## 概述 + +设计一个高度可扩展的主题包架构,允许第三方作者通过代码提交的方式创建主题,覆盖前端所有元素——从 CSS 变量到组件实现、布局结构、甚至整个页面。 + +## 架构 + +``` +src/themes/ +├── types.ts # ThemePackage 接口定义 +├── registry.ts # 主题注册表 + CSS 注入引擎 +├── context.tsx # React Context + Provider + Hooks +├── index.ts # 公共 API barrel +├── loader.ts # 主题加载器(注册所有内置主题) +├── README.md # 主题开发指南 +│ +├── default/ # 默认主题(参考实现) +│ └── index.ts +│ +├── example-cyberpunk/ # 示例主题(赛博朋克) +│ ├── index.ts +│ └── components/ +│ └── button.tsx # 组件覆盖示范 +│ +└── / # 第三方主题 + ├── index.ts + ├── components/ + ├── layouts/ + ├── pages/ + └── assets/ +``` + +## 覆盖层级 + +| 层级 | 字段 | 说明 | +|------|------|------| +| CSS 变量 | `tokens.light` / `tokens.dark` | 80+ 个设计 token(颜色、字体、圆角) | +| 原始 CSS | `css` | 注入自定义 CSS(动画、字体、阴影等) | +| 组件替换 | `components` | 替换任意 UI 组件(30+ 个可替换组件键) | +| 布局替换 | `layouts` | 替换 4 种布局(Admin / H5 / H5Simple / Default) | +| 页面替换 | `pages` | 替换 14 个页面路由实现 | +| 生命周期 | `onActivate` / `onDeactivate` | 主题启用/停用回调 | + +## 任务清单 + +- [x] **T1**: 创建 `src/themes/types.ts` — ThemePackage 接口 + 所有可覆盖键定义 +- [x] **T2**: 创建 `src/themes/registry.ts` — 主题注册/激活/停用/CSS 注入引擎 +- [x] **T3**: 创建 `src/themes/context.tsx` — React Context + ThemeProvider + hooks +- [x] **T4**: 创建 `src/themes/index.ts` — 公共 API barrel +- [x] **T5**: 创建 `src/themes/loader.ts` — 自动加载所有内置主题 +- [x] **T6**: 创建 `src/themes/default/` — 默认主题参考实现 +- [x] **T7**: 创建 `src/themes/example-cyberpunk/` — 完整示例主题(含组件覆盖 + CSS + 生命周期) +- [x] **T8**: 重构 `use-theme.tsx` — 向后兼容包装 +- [x] **T9**: 重构 `theme-provider.tsx` — 集成新主题系统 +- [x] **T10**: 编写 `README.md` — 主题开发完整指南 +- [x] **T11**: TypeScript 编译验证通过 +- [ ] **T12**: (后续) 设置页面集成主题选择器 UI +- [ ] **T13**: (后续) 将现有组件导入逐步迁移到 `useThemedComponent` 模式 diff --git a/vite-frontend/src/components/theme-provider.tsx b/vite-frontend/src/components/theme-provider.tsx index 3e2f6d7..d347244 100644 --- a/vite-frontend/src/components/theme-provider.tsx +++ b/vite-frontend/src/components/theme-provider.tsx @@ -1,51 +1,22 @@ -import React, { useEffect } from "react"; +/** + * ThemeProvider — app-level wrapper + * ================================= + * Loads all registered themes and wraps children with the theme context. + * Import the loader to ensure all built-in themes are registered before + * the provider mounts. + */ -import { useTheme } from "@/shadcn-bridge/heroui/use-theme"; +import React from "react"; + +// Side-effect: registers all built-in themes +import "@/themes/loader"; + +import { ThemeProvider as ThemeContextProvider } from "@/themes/context"; interface ThemeProviderProps { children: React.ReactNode; } export const ThemeProvider: React.FC = ({ children }) => { - const { theme, setTheme } = useTheme(); - - useEffect(() => { - // 确保主题与HTML class同步 - const updateThemeClass = (currentTheme: string) => { - if (currentTheme === "dark") { - document.documentElement.classList.add("dark"); - document.documentElement.style.colorScheme = "dark"; - } else { - document.documentElement.classList.remove("dark"); - document.documentElement.style.colorScheme = "light"; - } - }; - - // 始终跟随系统主题 - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - - if (systemTheme !== theme) { - setTheme(systemTheme); - } - - // 监听主题变化 - updateThemeClass(theme); - - // 监听系统主题变化 - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleThemeChange = (e: MediaQueryListEvent) => { - const newTheme = e.matches ? "dark" : "light"; - - setTheme(newTheme); - }; - - mediaQuery.addEventListener("change", handleThemeChange); - - return () => mediaQuery.removeEventListener("change", handleThemeChange); - }, [theme, setTheme]); - - return <>{children}; + return {children}; }; diff --git a/vite-frontend/src/components/theme-settings.tsx b/vite-frontend/src/components/theme-settings.tsx new file mode 100644 index 0000000..fa874be --- /dev/null +++ b/vite-frontend/src/components/theme-settings.tsx @@ -0,0 +1,179 @@ +/** + * ThemeSettings — theme picker card for the Settings page + * ======================================================== + * Shows: + * • Mode toggle (light / dark / system) + * • Grid of registered themes with preview dots + * • "Reset to default" option + */ + +import React from "react"; +import toast from "react-hot-toast"; + +import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { useThemeContext } from "@/themes/context"; +import type { ThemeMode } from "@/themes/registry"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const MODE_OPTIONS: Array<{ value: ThemeMode; label: string; icon: string }> = [ + { value: "light", label: "亮色", icon: "☀️" }, + { value: "dark", label: "暗色", icon: "🌙" }, + { value: "system", label: "跟随系统", icon: "🖥️" }, +]; + +// ─── Component ────────────────────────────────────────────────────────────── + +export const ThemeSettings: React.FC = () => { + const { + themes, + activeThemeId, + mode, + effectiveMode, + switchTheme, + resetTheme, + setMode, + } = useThemeContext(); + + const handleModeChange = (m: ThemeMode) => { + setMode(m); + const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统"; + toast.success(`已切换为${label}模式`); + }; + + const handleThemeSelect = (id: string) => { + switchTheme(id); + const theme = themes.find((t) => t.id === id); + toast.success(`已切换主题「${theme?.name ?? id}」`); + }; + + const handleReset = () => { + resetTheme(); + toast.success("已恢复默认主题"); + }; + + return ( + + +

+ 主题设置 +

+ + {/* ── Mode toggle ────────────────────────────────────── */} +
+

+ 外观模式 +

+
+ {MODE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* ── Theme grid ─────────────────────────────────────── */} +
+

+ 选择主题 + + 共 {themes.length} 个可用主题 + +

+ +
+ {themes.map((theme) => { + const isActive = activeThemeId === theme.id; + // Pick the right token set for preview + const previewTokens = + effectiveMode === "dark" && theme.tokens?.dark + ? theme.tokens.dark + : theme.tokens?.light; + + const primary = previewTokens?.["--primary"] ?? "#2563eb"; + const secondary = previewTokens?.["--secondary"] ?? "#6366f1"; + const success = previewTokens?.["--success"] ?? "#16a34a"; + const danger = previewTokens?.["--danger"] ?? "#dc2626"; + const bg = previewTokens?.["--background"] ?? "#ffffff"; + + return ( + + ); + })} +
+
+ + {/* ── Reset ──────────────────────────────────────────── */} + {activeThemeId && activeThemeId !== "default" && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 97ff29e..dd0e8e4 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1407,13 +1407,6 @@ export default function NodePage() { if (!nodeList || nodeList.length === 0) return []; const sortedByDb = [...nodeList].sort((a, b) => { - const expiryDiff = - getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight - - getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight; - - if (expiryDiff !== 0) { - return expiryDiff; - } const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; diff --git a/vite-frontend/src/pages/settings.tsx b/vite-frontend/src/pages/settings.tsx index 55e3217..bf69d5e 100644 --- a/vite-frontend/src/pages/settings.tsx +++ b/vite-frontend/src/pages/settings.tsx @@ -10,6 +10,7 @@ import { Switch } from "@/shadcn-bridge/heroui/switch"; import { reinitializeBaseURL } from "@/api/network"; import { getConfigByName, updateConfig } from "@/api"; import { BackIcon } from "@/components/icons"; +import { ThemeSettings } from "@/components/theme-settings"; import { type UpdateReleaseChannel, getUpdateReleaseChannel, @@ -181,6 +182,7 @@ export const SettingsPage = () => { {/* 内容区域 */}
+

diff --git a/vite-frontend/src/shadcn-bridge/heroui/use-theme.tsx b/vite-frontend/src/shadcn-bridge/heroui/use-theme.tsx index c1f930f..de9f7df 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/use-theme.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/use-theme.tsx @@ -1,62 +1,43 @@ -import * as React from "react"; +/** + * useTheme — backwards-compatible hook + * ===================================== + * Wraps the new theme system's context to provide the same API that the + * rest of the codebase already expects: `{ theme, setTheme }`. + * + * For full theme system access, use `useThemeContext` from "@/themes". + */ -type ThemeMode = "light" | "dark"; +import { useSyncExternalStore, useCallback } from "react"; -const STORAGE_KEY = "flvx:theme"; +import { + subscribe, + getSavedMode, + getEffectiveMode, + saveMode, + reapplyActiveTheme, + type ThemeMode, +} from "@/themes/registry"; -function resolveInitialTheme(): ThemeMode { - if (typeof window === "undefined") { - return "light"; - } - - const fromStorage = window.localStorage.getItem(STORAGE_KEY); - - if (fromStorage === "dark" || fromStorage === "light") { - return fromStorage; - } - - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; -} - -let currentTheme: ThemeMode = resolveInitialTheme(); -const listeners = new Set<(theme: ThemeMode) => void>(); - -function broadcast(theme: ThemeMode) { - currentTheme = theme; - if (typeof window !== "undefined") { - window.localStorage.setItem(STORAGE_KEY, theme); - } - listeners.forEach((listener) => { - listener(theme); +// Monotonic counter for snapshot identity +let _rev = 0; +const _sub = (cb: () => void) => + subscribe(() => { + _rev++; + cb(); }); -} +const _snap = () => _rev; export function useTheme() { - const [theme, setThemeState] = React.useState(currentTheme); + useSyncExternalStore(_sub, _snap); - React.useEffect(() => { - const listener = (nextTheme: ThemeMode) => { - setThemeState(nextTheme); - }; + const theme = getEffectiveMode(); + const mode = getSavedMode(); - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; + const setTheme = useCallback((next: string) => { + if (next !== "dark" && next !== "light" && next !== "system") return; + saveMode(next as ThemeMode); + reapplyActiveTheme(); }, []); - const setTheme = React.useCallback((nextTheme: string) => { - if (nextTheme !== "dark" && nextTheme !== "light") { - return; - } - broadcast(nextTheme); - }, []); - - return { - setTheme, - theme, - }; + return { theme, mode, setTheme }; } diff --git a/vite-frontend/src/themes/README.md b/vite-frontend/src/themes/README.md new file mode 100644 index 0000000..da992c2 --- /dev/null +++ b/vite-frontend/src/themes/README.md @@ -0,0 +1,382 @@ +# FLVX 主题开发指南 + +## 概述 + +FLVX 主题系统允许你完全自定义前端的外观和行为。一个主题可以覆盖: + +| 覆盖层级 | 说明 | 难度 | +|----------|------|------| +| **CSS 变量** | 修改颜色、字体、圆角等设计 token | ⭐ 简单 | +| **原始 CSS** | 注入自定义 CSS(动画、字体、阴影等) | ⭐⭐ 中等 | +| **组件替换** | 替换任意 UI 组件(按钮、卡片、输入框等) | ⭐⭐⭐ 高级 | +| **布局替换** | 替换整个页面布局结构 | ⭐⭐⭐ 高级 | +| **页面替换** | 替换整个页面实现 | ⭐⭐⭐⭐ 专家 | + +## 快速开始 + +### 1. 创建主题文件夹 + +``` +src/themes/my-theme/ +├── index.ts ← 必须:主题入口,导出 ThemePackage +├── components/ ← 可选:组件覆盖 +│ ├── index.ts +│ └── button.tsx +├── layouts/ ← 可选:布局覆盖 +│ └── admin.tsx +├── pages/ ← 可选:页面覆盖 +│ └── login.tsx +├── assets/ ← 可选:图片、字体等资源 +└── styles.css ← 可选:额外样式文件 +``` + +### 2. 编写主题入口 `index.ts` + +```typescript +import type { ThemePackage } from "../types"; + +const myTheme: ThemePackage = { + id: "my-theme", // 唯一标识(kebab-case) + name: "我的主题", // 显示名称 + author: "Your Name", // 作者 + version: "1.0.0", // 版本号 + description: "一个自定义主题", + + // CSS 变量覆盖 + tokens: { + light: { + "--primary": "#ff6600", + "--primary-foreground": "#ffffff", + "--background": "#fafafa", + }, + dark: { + "--primary": "#ff8833", + "--background": "#1a1a2e", + }, + }, +}; + +export default myTheme; +``` + +### 3. 注册主题 + +打开 `src/themes/loader.ts`,添加两行: + +```typescript +import myTheme from "./my-theme"; +registerTheme(myTheme); +``` + +完成!主题已可用。 + +--- + +## 详细指南 + +### CSS 变量覆盖 + +所有可用的 CSS 变量定义在 `src/themes/types.ts` 的 `ThemeTokens` 接口中。常用的: + +```typescript +tokens: { + light: { + // 基础色 + "--background": "#ffffff", // 页面背景 + "--foreground": "#000000", // 文字颜色 + "--border": "#e5e7eb", // 边框颜色 + "--content1": "#ffffff", // 卡片背景 + + // 品牌色 + "--primary": "#2563eb", // 主色 + "--primary-foreground": "#fff", // 主色上的文字 + "--secondary": "#6366f1", // 辅色 + + // 状态色 + "--danger": "#dc2626", + "--success": "#16a34a", + "--warning": "#d97706", + + // 每种品牌色都有 50-900 共 10 级色阶 + "--primary-50": "#eff6ff", // 最浅 + "--primary-500": "#3b82f6", // 中间 + "--primary-900": "#1e3a8a", // 最深 + + // 字体 + "--font-sans": '"Inter", sans-serif', + "--font-mono": '"Fira Code", monospace', + + // 圆角 + "--radius": "0.5rem", + }, + dark: { + // 暗色模式下的覆盖... + }, +} +``` + +> **提示**: 你不需要定义所有变量,只定义你想修改的,其余沿用默认值。 + +### 原始 CSS 注入 + +`css` 字段可以注入任意 CSS。主题激活时会插入一个 `