feat: Add theme and dark mode

This commit is contained in:
shiyu
2025-09-08 15:20:49 +08:00
parent f40ff4d751
commit d41b72d0ce
22 changed files with 525 additions and 100 deletions
+8 -1
View File
@@ -1,13 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Foxel</title> <title>Foxel</title>
<link rel='stylesheet'
href='https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css' />
</head> </head>
<body> <body>
<style>
* {
font-family: 'Maple Mono CN';
}
</style>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+3
View File
@@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext.tsx';
import { status as getStatus } from './api/config.ts'; import { status as getStatus } from './api/config.ts';
import type { SystemStatus } from './api/config.ts'; import type { SystemStatus } from './api/config.ts';
import { SystemContext } from './contexts/SystemContext.tsx'; import { SystemContext } from './contexts/SystemContext.tsx';
import { ThemeProvider } from './contexts/ThemeContext.tsx';
import { Spin } from 'antd'; import { Spin } from 'antd';
import { Routes, Route, Navigate } from 'react-router'; import { Routes, Route, Navigate } from 'react-router';
import SetupPage from './pages/SetupPage.tsx'; import SetupPage from './pages/SetupPage.tsx';
@@ -38,6 +39,7 @@ function App() {
return ( return (
<SystemContext.Provider value={status}> <SystemContext.Provider value={status}>
<AuthProvider> <AuthProvider>
<ThemeProvider>
{!status.is_initialized ? ( {!status.is_initialized ? (
<Routes> <Routes>
<Route path="/setup" element={<SetupPage />} /> <Route path="/setup" element={<SetupPage />} />
@@ -46,6 +48,7 @@ function App() {
) : ( ) : (
<AppRouter /> <AppRouter />
)} )}
</ThemeProvider>
</AuthProvider> </AuthProvider>
</SystemContext.Provider> </SystemContext.Provider>
); );
+8 -8
View File
@@ -243,8 +243,8 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
left: w.maximized ? 0 : w.x, left: w.maximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width, width: w.maximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height, height: w.maximized ? '100vh' : w.height,
background: 'rgba(240, 242, 245, 0.7)', // Semi-transparent background background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
border: '1px solid rgba(255, 255, 255, 0.18)', border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
borderRadius: w.maximized ? 0 : 12, borderRadius: w.maximized ? 0 : 12,
boxShadow: w.maximized boxShadow: w.maximized
? 'none' ? 'none'
@@ -254,7 +254,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
backdropFilter: 'blur(20px) saturate(180%)', // Enhanced blur effect backdropFilter: 'blur(12px) saturate(150%)',
zIndex: 3000 + idx, zIndex: 3000 + idx,
willChange: 'left,top,width,height', willChange: 'left,top,width,height',
transition: interacting ? 'none' : 'top .15s,left .15s,width .15s,height .15s,box-shadow .25s' transition: interacting ? 'none' : 'top .15s,left .15s,width .15s,height .15s,box-shadow .25s'
@@ -269,9 +269,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 12px', padding: '0 12px',
background: 'rgba(0, 0, 0, 0.25)', // Lighter, transparent title bar background: 'var(--ant-color-fill-secondary, rgba(0,0,0,0.25))',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)', borderBottom: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.1))',
color: '#333', // Darker text for readability color: 'var(--ant-color-text, #333)',
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
letterSpacing: .2, letterSpacing: .2,
@@ -298,7 +298,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />} icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => onToggleMax(w.id)} onClick={() => onToggleMax(w.id)}
style={{ style={{
color: '#555', color: 'var(--ant-color-text-secondary, #555)',
width: 30, width: 30,
height: 30, height: 30,
display: 'flex', display: 'flex',
@@ -314,7 +314,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
icon={<CloseOutlined />} icon={<CloseOutlined />}
onClick={() => onClose(w.id)} onClick={() => onClose(w.id)}
style={{ style={{
color: '#ff4d4f', color: 'var(--ant-color-error, #ff4d4f)',
width: 30, width: 30,
height: 30, height: 30,
display: 'flex', display: 'flex',
+1 -1
View File
@@ -177,7 +177,7 @@ export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, o
if (err) { if (err) {
return ( return (
<div style={{ <div style={{
color: '#f5222d', color: 'var(--ant-color-error, #f5222d)',
padding: 16, padding: 16,
background: 'rgba(20,20,20,0.8)', background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)' backdropFilter: 'blur(24px)'
+1 -1
View File
@@ -60,7 +60,7 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
} }
return ( return (
<div style={{ width: '100%', height: '100%', background: '#fff' }}> <div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
{url ? ( {url ? (
<iframe <iframe
src={url} src={url}
+4 -4
View File
@@ -64,19 +64,19 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
}, [handleSave]); }, [handleSave]);
return ( return (
<Layout style={{ height: '100%', background: '#ffffff' }}> <Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
<Header <Header
style={{ style={{
background: '#f0f2f5', background: 'var(--ant-color-bg-layout, #f0f2f5)',
padding: '0 16px', padding: '0 16px',
height: 40, height: 40,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
borderBottom: '1px solid #d9d9d9' borderBottom: '1px solid var(--ant-color-border-secondary, #d9d9d9)'
}} }}
> >
<span style={{ color: 'rgba(0, 0, 0, 0.88)' }}> <span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
{entry.name} {isDirty && '*'} {entry.name} {isDirty && '*'}
</span> </span>
<Space> <Space>
+185
View File
@@ -0,0 +1,185 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { ConfigProvider, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import type { ThemeConfig } from 'antd/es/config-provider/context';
import { getAllConfig } from '../api/config';
import { useAuth } from './AuthContext';
import baseTheme from '../theme';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
primaryColor?: string | null;
borderRadius?: number | null;
customTokens?: Record<string, any> | null;
customCSS?: string | null;
}
interface ThemeContextType {
refreshTheme: () => Promise<void>;
previewTheme: (patch: Partial<ThemeState>) => void;
mode: ThemeMode;
resolvedMode: ThemeMode;
}
const Ctx = createContext<ThemeContextType>({} as any);
const CONFIG_KEYS = {
MODE: 'THEME_MODE',
PRIMARY: 'THEME_PRIMARY_COLOR',
RADIUS: 'THEME_BORDER_RADIUS',
TOKENS: 'THEME_CUSTOM_TOKENS',
CSS: 'THEME_CUSTOM_CSS',
};
function parseJSON<T = any>(text: string | null | undefined): T | null {
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
function useSystemDarkPreferred() {
const [isDark, setIsDark] = useState<boolean>(
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
);
useEffect(() => {
if (!window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
mql.addEventListener?.('change', handler);
return () => mql.removeEventListener?.('change', handler);
}, []);
return isDark;
}
function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
const resolvedMode: ThemeMode = state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode;
const algorithm = resolvedMode === 'dark'
? [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm]
: [antdTheme.defaultAlgorithm, antdTheme.compactAlgorithm];
const safeBaseTokens: Record<string, any> = resolvedMode === 'dark'
? {
borderRadius: baseTheme.token?.borderRadius,
fontSize: baseTheme.token?.fontSize,
controlHeight: baseTheme.token?.controlHeight,
boxShadow: baseTheme.token?.boxShadow,
}
: { ...(baseTheme.token as any) };
const token = {
...safeBaseTokens,
...(state.primaryColor ? { colorPrimary: state.primaryColor } : {}),
...(state.borderRadius != null ? { borderRadius: state.borderRadius } : {}),
...(state.customTokens || {}),
} as any;
const baseComponents = { ...(baseTheme.components as any) };
if (resolvedMode === 'dark' && baseComponents) {
if (baseComponents.Menu) {
const { itemHoverColor, itemHoverBg, itemSelectedBg, itemSelectedColor, ...rest } = baseComponents.Menu;
baseComponents.Menu = rest;
}
if (baseComponents.Dropdown) {
const { controlItemBgHover, ...rest } = baseComponents.Dropdown;
baseComponents.Dropdown = rest;
}
if (baseComponents.Table) {
const { headerBg, rowHoverBg, ...rest } = baseComponents.Table;
baseComponents.Table = rest;
}
}
return { algorithm, token, components: baseComponents } satisfies ThemeConfig;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const systemDark = useSystemDarkPreferred();
const [state, setState] = useState<ThemeState>({ mode: 'light' });
const styleTagRef = useRef<HTMLStyleElement | null>(null);
const ensureStyleTag = () => {
if (styleTagRef.current) return styleTagRef.current;
let styleEl = document.getElementById('foxel-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'foxel-custom-css';
document.head.appendChild(styleEl);
}
styleTagRef.current = styleEl;
return styleEl;
};
const applyCustomCSS = (cssText: string | null | undefined) => {
const el = ensureStyleTag();
el.textContent = cssText || '';
};
const applyHtmlDataTheme = (mode: ThemeMode) => {
const finalMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', finalMode);
};
const refreshTheme = async () => {
if (!isAuthenticated) {
applyHtmlDataTheme(state.mode || 'light');
applyCustomCSS(state.customCSS || '');
return;
}
try {
const cfg = await getAllConfig();
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
const radius = radiusStr != null ? Number(radiusStr) : null;
const customTokens = parseJSON<Record<string, any>>(cfg[CONFIG_KEYS.TOKENS]);
const customCSS = (cfg[CONFIG_KEYS.CSS] as string) || '';
setState({ mode, primaryColor: primary, borderRadius: radius, customTokens, customCSS });
applyHtmlDataTheme(mode);
applyCustomCSS(customCSS);
} catch (e) {
applyHtmlDataTheme('light');
applyCustomCSS('');
}
};
const previewTheme = (patch: Partial<ThemeState>) => {
const next: ThemeState = { ...state, ...patch };
setState(next);
applyHtmlDataTheme(next.mode || 'light');
applyCustomCSS(next.customCSS || '');
};
useEffect(() => {
refreshTheme();
}, [isAuthenticated, systemDark]);
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
const ctxValue = useMemo<ThemeContextType>(() => ({
refreshTheme,
previewTheme,
mode: state.mode,
resolvedMode,
}), [state.mode, resolvedMode]);
return (
<Ctx.Provider value={ctxValue}>
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={zhCN}>
{children}
</ConfigProvider>
</Ctx.Provider>
);
}
export function useTheme() {
return useContext(Ctx);
}
+17 -16
View File
@@ -1,41 +1,42 @@
html,body,#root { height: 100%; } html,body,#root { height: 100%; }
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background:#f9f9f9; } body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 4px; } ::-webkit-scrollbar-thumb { background: var(--ant-color-fill-tertiary, #d9d9d9); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #bfbfbf; } ::-webkit-scrollbar-thumb:hover { background: var(--ant-color-fill-secondary, #bfbfbf); }
.fx-surface { background:#fff; border:1px solid #eaeaea; border-radius:12px; } .fx-surface { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:12px; }
.fx-card { background:linear-gradient(#fff,#fafafa); border:1px solid #eaeaea; border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); } .fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
.fx-fade-text { color:#555; } .fx-fade-text { color: var(--ant-color-text-secondary, #555); }
.fx-quiet-btn.ant-btn-text:not(:hover) { color:#666; } .fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
.ant-layout { background:#f9f9f9; } /* 使用 antd 默认布局背景 */
.ant-layout { background: transparent; }
/* Menu compact spacing adjustments */ /* Menu compact spacing adjustments */
.ant-menu-inline .ant-menu-item { margin-block:2px; } .ant-menu-inline .ant-menu-item { margin-block:2px; }
/* Sidebar high-contrast selection */ /* Sidebar high-contrast selection */
.sider-menu .ant-menu-item-selected { .sider-menu .ant-menu-item-selected {
background:#111 !important; background: var(--ant-color-primary, #111) !important;
color:#fff !important; color:#fff !important;
} }
.sider-menu .ant-menu-item-selected .ant-menu-item-icon, .sider-menu .ant-menu-item-selected .ant-menu-item-icon,
.sider-menu .ant-menu-item-selected .anticon { color:#fff !important; } .sider-menu .ant-menu-item-selected .anticon { color:#fff !important; }
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background:#f2f2f2; } .sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background: var(--ant-color-fill-tertiary, #f2f2f2); }
.row-selected td { background: rgba(24,144,255,0.12) !important; } .row-selected td { background: rgba(24,144,255,0.12) !important; }
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; } .row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
.fx-grid { display:flex; flex-wrap:wrap; gap:20px; } .fx-grid { display:flex; flex-wrap:wrap; gap:20px; }
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background:#f5f5f5; position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; } .fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
.fx-grid-item.dir { background:#f3f3f3; } .fx-grid-item.dir { background: var(--ant-color-fill-secondary, #f3f3f3); }
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background:#acc0c0; } .fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background: var(--ant-color-primary-bg, #e6f4ff); }
.fx-grid-item:hover { background:#d2d1d1a7; box-shadow:0 1px 4px rgba(0,0,0,.06); } .fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
.fx-grid-item .thumb { height:120px; border-radius:10px; background:#fff; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px #eee; } .fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; } .fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background:#111; color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; } .fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
.fx-grid-item .name { font-weight:600; font-size:13px; } .fx-grid-item .name { font-weight:600; font-size:13px; }
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } .ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
+10 -2
View File
@@ -15,6 +15,7 @@ import {
import '../styles/sider-menu.css'; import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts'; import { getLatestVersion } from '../api/config.ts';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { useTheme } from '../contexts/ThemeContext';
const { Sider } = Layout; const { Sider } = Layout;
export interface SideNavProps { export interface SideNavProps {
@@ -27,6 +28,7 @@ export interface SideNavProps {
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) { const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
const status = useSystemStatus(); const status = useSystemStatus();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState<{ const [latestVersion, setLatestVersion] = useState<{
@@ -85,10 +87,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
height: 24, height: 24,
objectFit: 'contain', objectFit: 'contain',
marginRight: collapsed ? 0 : 8, marginRight: collapsed ? 0 : 8,
...(status?.logo?.endsWith('.svg') && { filter: 'brightness(0) saturate(100%)' }) ...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
}} }}
/> />
{!collapsed && <span style={{ fontWeight: 700 }}>{status?.title}</span>} {!collapsed && (
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
{status?.title}
</span>
)}
</div> </div>
{/* 展开时显示收缩按钮 */} {/* 展开时显示收缩按钮 */}
{!collapsed && ( {!collapsed && (
-5
View File
@@ -1,17 +1,12 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import '@ant-design/v5-patch-for-react-19'; import '@ant-design/v5-patch-for-react-19';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import 'antd/dist/reset.css'; import 'antd/dist/reset.css';
import foxelTheme from './theme';
import './global.css'; import './global.css';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<ConfigProvider locale={zhCN} theme={foxelTheme}>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</ConfigProvider>
); );
@@ -35,7 +35,7 @@ function renderExif(exif: Record<string, any>) {
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}> <div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} /> <InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
<div>EXIF信息</div> <div>EXIF信息</div>
</div> </div>
@@ -49,11 +49,11 @@ function renderExif(exif: Record<string, any>) {
bordered bordered
items={items.map(item => ({ items={items.map(item => ({
key: item.key, key: item.key,
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>, label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
children: <span style={{ color: '#262626' }}>{item.value}</span> children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
}))} }))}
contentStyle={{ padding: '8px 12px' }} contentStyle={{ padding: '8px 12px' }}
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }} labelStyle={{ padding: '8px 12px', backgroundColor: 'var(--ant-color-fill-tertiary, #fafafa)', width: '30%' }}
/> />
); );
} }
@@ -17,11 +17,25 @@ import {
FontSizeOutlined, FontSizeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
export const getFileIcon = (fileName: string, size: number = 16) => { const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
export const getFileIcon = (fileName: string, size: number = 16, resolvedMode: 'light' | 'dark' | 'system' = 'light') => {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; const ext = fileName.split('.').pop()?.toLowerCase() || '';
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 }; const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color } }); const adj = (color: string) => (resolvedMode === 'dark' ? lightenColor(color, 0.3) : color);
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color: adj(color) } });
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a'); if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c'); if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');
@@ -4,6 +4,7 @@ import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutli
import type { VfsEntry } from '../../../api/client'; import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons'; import { getFileIcon } from './FileIcons';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry'; import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import { useTheme } from '../../../contexts/ThemeContext';
interface FileListViewProps { interface FileListViewProps {
entries: VfsEntry[]; entries: VfsEntry[];
@@ -31,6 +32,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
onContextMenu, onContextMenu,
}) => { }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
const columns = [ const columns = [
{ {
@@ -40,9 +54,9 @@ export const FileListView: React.FC<FileListViewProps> = ({
render: (_: any, r: VfsEntry) => ( render: (_: any, r: VfsEntry) => (
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}> <span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
{r.is_dir ? ( {r.is_dir ? (
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} /> <FolderFilled style={{ color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary, marginRight: 6 }} />
) : ( ) : (
getFileIcon(r.name, 16) getFileIcon(r.name, 16, resolvedMode)
)} )}
{r.name} {r.name}
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>} {r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
@@ -4,6 +4,7 @@ import { FolderFilled, PictureOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client'; import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons'; import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { useTheme } from '../../../contexts/ThemeContext';
interface Props { interface Props {
entries: VfsEntry[]; entries: VfsEntry[];
@@ -26,6 +27,28 @@ const formatSize = (size: number) => {
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => { export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const lightenColor = (hex: string, amount: number) => {
const parseHex = (h: string) => {
const s = h.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return null;
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
};
const rgb = parseHex(hex);
if (!rgb) return hex;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const r = mix(rgb.r);
const g = mix(rgb.g);
const b = mix(rgb.b);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({}); const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{ x: number, y: number } | null>(null); const startRef = useRef<{ x: number, y: number } | null>(null);
@@ -111,9 +134,24 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
onContextMenu={(e) => onContextMenu(e, ent)} onContextMenu={(e) => onContextMenu(e, ent)}
style={{ userSelect: 'none' }} style={{ userSelect: 'none' }}
> >
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}> <div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
{ent.is_dir && <FolderFilled style={{ fontSize: 32, color: token.colorPrimary }} />} {ent.is_dir && (
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: '#8c8c8c' }} /> : getFileIcon(ent.name, 32))} <FolderFilled
style={{
fontSize: 32,
color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary,
}}
/>
)}
{!ent.is_dir && (
isImg ? (
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
) : isPictureType ? (
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
) : (
getFileIcon(ent.name, 32, resolvedMode)
)
)}
{ent.type === 'mount' && <span className="badge">M</span>} {ent.type === 'mount' && <span className="badge">M</span>}
</div> </div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip> <Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
@@ -129,8 +167,8 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
top: rect.top, top: rect.top,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
border: '1px dashed rgba(0,0,0,0.4)', border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
background: 'rgba(0, 120, 212, 0.08)', background: 'var(--ant-color-primary-bg, rgba(0, 120, 212, 0.08))',
zIndex: 999 zIndex: 999
}} }}
/> />
@@ -32,7 +32,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
case 'success': case 'success':
return ( return (
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<CheckCircleFilled style={{ color: '#52c41a' }} /> <CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}></Typography.Text> <Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}></Typography.Text>
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" /> <Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
</Flex> </Flex>
@@ -40,7 +40,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
case 'error': case 'error':
return ( return (
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<CloseCircleFilled style={{ color: '#ff4d4f' }} /> <CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
<Typography.Text type="danger" title={file.error}></Typography.Text> <Typography.Text type="danger" title={file.error}></Typography.Text>
</Flex> </Flex>
); );
@@ -71,7 +71,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
borderRadius: 8, borderRadius: 8,
transition: 'background-color 0.2s', transition: 'background-color 0.2s',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--ant-color-fill-tertiary, #f0f0f0)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
> >
<Flex justify="space-between" align="center" style={{ width: '100%' }}> <Flex justify="space-between" align="center" style={{ width: '100%' }}>
+14 -14
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
height: '100vh', height: '100vh',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)' background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}> }}>
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -52,11 +52,11 @@ export default function LoginPage() {
maxWidth: '1200px', maxWidth: '1200px',
height: '70%', height: '70%',
maxHeight: '700px', maxHeight: '700px',
backgroundColor: 'rgba(255, 255, 255, 0.8)', backgroundColor: 'var(--ant-color-bg-container, #fff)',
borderRadius: '20px', borderRadius: '20px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)', boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(5px)', backdropFilter: 'blur(5px)',
border: '1px solid rgba(255, 255, 255, 0.3)', border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
<div style={{ <div style={{
@@ -71,7 +71,7 @@ export default function LoginPage() {
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} /> <img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
<Title level={2} style={{ margin: 0, color: '#111' }}></Title> <Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}></Title>
</div> </div>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}> Foxel </Text> <Text type="secondary" style={{ display: 'block', textAlign: 'center' }}> Foxel </Text>
</div> </div>
@@ -115,8 +115,8 @@ export default function LoginPage() {
</div> </div>
<div style={{ <div style={{
width: '50%', width: '50%',
backgroundColor: '#f0f2f5', backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
backgroundImage: `radial-gradient(#d7d7d7 1px, transparent 1px)`, backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`,
backgroundSize: '16px 16px', backgroundSize: '16px 16px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -131,27 +131,27 @@ export default function LoginPage() {
</Text> </Text>
<div style={{ marginTop: '32px' }}> <div style={{ marginTop: '32px' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space> <Space>
<CloudSyncOutlined style={{ fontSize: '20px', color: '#1677ff' }} /> <CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>访</Text> <Text>访</Text>
</Space> </Space>
</Card> </Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space> <Space>
<SearchOutlined style={{ fontSize: '20px', color: '#1677ff' }} /> <SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>AI </Text> <Text>AI </Text>
</Space> </Space>
</Card> </Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space> <Space>
<ShareAltOutlined style={{ fontSize: '20px', color: '#1677ff' }} /> <ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text></Text> <Text></Text>
</Space> </Space>
</Card> </Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space> <Space>
<ApartmentOutlined style={{ fontSize: '20px', color: '#1677ff' }} /> <ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text></Text> <Text></Text>
</Space> </Space>
</Card> </Card>
+1 -1
View File
@@ -143,7 +143,7 @@ const LogsPage = memo(function LogsPage() {
width={800} width={800}
> >
{selectedLog && ( {selectedLog && (
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: '#f5f5f5', padding: 12 }}> <pre style={{ maxHeight: '60vh', overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
{JSON.stringify(selectedLog.details, null, 2)} {JSON.stringify(selectedLog.details, null, 2)}
</pre> </pre>
)} )}
+1 -1
View File
@@ -190,7 +190,7 @@ const SetupPage = () => {
height: '100vh', height: '100vh',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)' background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}> }}>
<Card style={{ width: 'clamp(400px, 40vw, 600px)', padding: '24px 16px' }}> <Card style={{ width: 'clamp(400px, 40vw, 600px)', padding: '24px 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
@@ -1,9 +1,11 @@
import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal } from 'antd'; import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal, Radio, InputNumber } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PageCard from '../../components/PageCard'; import PageCard from '../../components/PageCard';
import { getAllConfig, setConfig } from '../../api/config'; import { getAllConfig, setConfig } from '../../api/config';
import { vectorDBApi } from '../../api/vectorDB'; import { vectorDBApi } from '../../api/vectorDB';
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons'; import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
import { useTheme } from '../../contexts/ThemeContext';
import '../../styles/settings-tabs.css';
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [ const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
{ key: 'APP_NAME', label: '应用名称' }, { key: 'APP_NAME', label: '应用名称' },
@@ -26,10 +28,20 @@ const EMBED_CONFIG_KEYS = [
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS]; const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS];
// Theme related config keys
const THEME_KEYS = {
MODE: 'THEME_MODE',
PRIMARY: 'THEME_PRIMARY_COLOR',
RADIUS: 'THEME_BORDER_RADIUS',
TOKENS: 'THEME_CUSTOM_TOKENS',
CSS: 'THEME_CUSTOM_CSS',
};
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [config, setConfigState] = useState<Record<string, string> | null>(null); const [config, setConfigState] = useState<Record<string, string> | null>(null);
const [activeTab, setActiveTab] = useState('app'); const [activeTab, setActiveTab] = useState('appearance');
const { refreshTheme, previewTheme } = useTheme();
useEffect(() => { useEffect(() => {
getAllConfig().then((data) => setConfigState(data as Record<string, string>)); getAllConfig().then((data) => setConfigState(data as Record<string, string>));
@@ -43,12 +55,23 @@ export default function SystemSettingsPage() {
} }
message.success('保存成功'); message.success('保存成功');
setConfigState({ ...config, ...values }); setConfigState({ ...config, ...values });
// trigger theme refresh if related keys changed
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
await refreshTheme();
}
} catch (e: any) { } catch (e: any) {
message.error(e.message || '保存失败'); message.error(e.message || '保存失败');
} }
setLoading(false); setLoading(false);
}; };
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)
useEffect(() => {
if (activeTab !== 'appearance') {
refreshTheme();
}
}, [activeTab]);
if (!config) { if (!config) {
return <PageCard title='系统设置'><div>...</div></PageCard>; return <PageCard title='系统设置'><div>...</div></PageCard>;
} }
@@ -59,11 +82,92 @@ export default function SystemSettingsPage() {
> >
<Space direction="vertical" style={{ width: '100%' }} size={32}> <Space direction="vertical" style={{ width: '100%' }} size={32}>
<Tabs <Tabs
className="fx-settings-tabs"
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
centered centered
tabPosition="left" tabPosition="left"
items={[ items={[
{
key: 'appearance',
label: (
<span>
<SkinOutlined style={{ marginRight: 8 }} />
</span>
),
children: (
<Form
layout="vertical"
initialValues={{
[THEME_KEYS.MODE]: config[THEME_KEYS.MODE] ?? 'light',
[THEME_KEYS.PRIMARY]: config[THEME_KEYS.PRIMARY] ?? '#111111',
[THEME_KEYS.RADIUS]: Number(config[THEME_KEYS.RADIUS] ?? '10'),
[THEME_KEYS.TOKENS]: config[THEME_KEYS.TOKENS] ?? '',
[THEME_KEYS.CSS]: config[THEME_KEYS.CSS] ?? '',
}}
onValuesChange={(_, all) => {
try {
const tokens = all[THEME_KEYS.TOKENS] ? JSON.parse(all[THEME_KEYS.TOKENS]) : undefined;
previewTheme({
mode: all[THEME_KEYS.MODE],
primaryColor: all[THEME_KEYS.PRIMARY],
borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined,
customTokens: tokens,
customCSS: all[THEME_KEYS.CSS],
});
} catch {
// JSON 不合法时忽略 tokens 预览,其他项仍然生效
previewTheme({
mode: all[THEME_KEYS.MODE],
primaryColor: all[THEME_KEYS.PRIMARY],
borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined,
customCSS: all[THEME_KEYS.CSS],
});
}
}}
onFinish={async (vals) => {
// Validate JSON if provided
if (vals[THEME_KEYS.TOKENS]) {
try { JSON.parse(vals[THEME_KEYS.TOKENS]); }
catch { return message.error('高级 Token 需为合法 JSON'); }
}
await handleSave(vals);
}}
style={{ marginTop: 24 }}
key={'appearance-' + JSON.stringify(config)}
>
<Card title="主题">
<Form.Item name={THEME_KEYS.MODE} label="主题模式">
<Radio.Group buttonStyle="solid">
<Radio.Button value="light"></Radio.Button>
<Radio.Button value="dark"></Radio.Button>
<Radio.Button value="system"></Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name={THEME_KEYS.PRIMARY} label="主色">
<Input type="color" size="large" />
</Form.Item>
<Form.Item name={THEME_KEYS.RADIUS} label="圆角">
<InputNumber min={0} max={24} style={{ width: '100%' }} />
</Form.Item>
</Card>
<Card title="高级" style={{ marginTop: 24 }}>
<Form.Item name={THEME_KEYS.TOKENS} label="覆盖 AntD TokenJSON" tooltip="例如:{ &quot;colorText&quot;: &quot;#222&quot; }">
<Input.TextArea autoSize={{ minRows: 4 }} placeholder='{ "colorText": "#222" }' />
</Form.Item>
<Form.Item name={THEME_KEYS.CSS} label="自定义 CSS">
<Input.TextArea autoSize={{ minRows: 6 }} placeholder={":root{ }\n/* 支持任意 CSS */"} />
</Form.Item>
</Card>
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
)
},
{ {
key: 'app', key: 'app',
label: ( label: (
+4 -4
View File
@@ -18,17 +18,17 @@ const LayoutShell = memo(function LayoutShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
<SideNav <SideNav
collapsed={collapsed} collapsed={collapsed}
onToggle={() => setCollapsed(c => !c)} onToggle={() => setCollapsed(c => !c)}
activeKey={navKey} activeKey={navKey}
onChange={(key) => navigate(`/${key}`)} onChange={(key) => navigate(`/${key}`)}
/> />
<Layout> <Layout style={{ background: 'var(--ant-color-bg-layout)' }}>
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} /> <TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
<Layout.Content style={{ padding: 16 }}> <Layout.Content style={{ padding: 16, background: 'var(--ant-color-bg-layout)' }}>
<div style={{ minHeight: 'calc(100vh - 56px - 32px)' }}> <div style={{ minHeight: 'calc(100vh - 56px - 32px)', background: 'var(--ant-color-bg-layout)' }}>
<Flex vertical gap={16}> <Flex vertical gap={16}>
{navKey === 'adapters' && <AdaptersPage />} {navKey === 'adapters' && <AdaptersPage />}
{navKey === 'files' && <FileExplorerPage />} {navKey === 'files' && <FileExplorerPage />}
+40
View File
@@ -0,0 +1,40 @@
.fx-settings-tabs .ant-tabs-nav-list {
padding: 8px 4px;
}
.fx-settings-tabs .ant-tabs-tab {
margin: 4px 0 !important;
border-radius: 8px;
padding: 6px 10px !important;
}
.fx-settings-tabs .ant-tabs-tab .ant-tabs-tab-btn {
color: var(--ant-color-text-secondary) !important;
}
.fx-settings-tabs .ant-tabs-tab:hover {
background: var(--ant-color-fill-tertiary) !important;
}
/* 选中态:按主题细分 */
html[data-theme='dark'] .fx-settings-tabs .ant-tabs-tab-active {
background: var(--ant-color-primary-bg) !important;
}
html[data-theme='dark'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
html[data-theme='dark'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-btn .anticon {
color: var(--ant-color-text) !important;
font-weight: 600;
}
html[data-theme='light'] .fx-settings-tabs .ant-tabs-tab-active {
background: var(--ant-color-primary) !important;
}
html[data-theme='light'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
html[data-theme='light'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-btn .anticon {
color: var(--ant-color-text-light-solid) !important;
font-weight: 600;
}
.fx-settings-tabs .ant-tabs-ink-bar {
background: var(--ant-color-primary) !important;
}
+21 -5
View File
@@ -5,13 +5,24 @@
margin-block: 2px; margin-block: 2px;
} }
.foxel-sider-menu .ant-menu-item-selected { .foxel-sider-menu .ant-menu-item-selected { font-weight: 600; }
font-weight: 600;
/* 亮色主题:选中项使用主色,文字用浅色文本 */
html[data-theme='light'] .foxel-sider-menu .ant-menu-item-selected {
background: var(--ant-color-primary) !important;
color: var(--ant-color-text-light-solid) !important;
}
html[data-theme='light'] .foxel-sider-menu .ant-menu-item-selected .ant-menu-item-icon {
color: var(--ant-color-text-light-solid) !important;
} }
.foxel-sider-menu .ant-menu-item-selected, /* 暗色主题:选中项使用主色背景(浅),文字使用常规文本色以保持对比度 */
.foxel-sider-menu .ant-menu-item-selected .ant-menu-item-icon { html[data-theme='dark'] .foxel-sider-menu .ant-menu-item-selected {
color: #fff !important; background: var(--ant-color-primary-bg) !important;
color: var(--ant-color-text) !important;
}
html[data-theme='dark'] .foxel-sider-menu .ant-menu-item-selected .ant-menu-item-icon {
color: var(--ant-color-text) !important;
} }
.foxel-sider-menu .ant-menu-item-selected::after { .foxel-sider-menu .ant-menu-item-selected::after {
@@ -21,3 +32,8 @@
.foxel-sider-menu .ant-menu-item .ant-menu-item-icon { .foxel-sider-menu .ant-menu-item .ant-menu-item-icon {
transition: color .18s; transition: color .18s;
} }
/* 悬停(未选中)背景 */
.foxel-sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover {
background: var(--ant-color-fill-tertiary) !important;
}