mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-11 18:10:10 +08:00
feat: Add theme and dark mode
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel</title>
|
||||
<link rel='stylesheet'
|
||||
href='https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css' />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Maple Mono CN';
|
||||
}
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext.tsx';
|
||||
import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
@@ -38,17 +39,19 @@ function App() {
|
||||
return (
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -243,8 +243,8 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
left: w.maximized ? 0 : w.x,
|
||||
width: w.maximized ? '100vw' : w.width,
|
||||
height: w.maximized ? '100vh' : w.height,
|
||||
background: 'rgba(240, 242, 245, 0.7)', // Semi-transparent background
|
||||
border: '1px solid rgba(255, 255, 255, 0.18)',
|
||||
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
|
||||
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
|
||||
borderRadius: w.maximized ? 0 : 12,
|
||||
boxShadow: w.maximized
|
||||
? 'none'
|
||||
@@ -254,7 +254,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backdropFilter: 'blur(20px) saturate(180%)', // Enhanced blur effect
|
||||
backdropFilter: 'blur(12px) saturate(150%)',
|
||||
zIndex: 3000 + idx,
|
||||
willChange: 'left,top,width,height',
|
||||
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',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
background: 'rgba(0, 0, 0, 0.25)', // Lighter, transparent title bar
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#333', // Darker text for readability
|
||||
background: 'var(--ant-color-fill-secondary, rgba(0,0,0,0.25))',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.1))',
|
||||
color: 'var(--ant-color-text, #333)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: .2,
|
||||
@@ -298,7 +298,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => onToggleMax(w.id)}
|
||||
style={{
|
||||
color: '#555',
|
||||
color: 'var(--ant-color-text-secondary, #555)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
@@ -314,7 +314,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => onClose(w.id)}
|
||||
style={{
|
||||
color: '#ff4d4f',
|
||||
color: 'var(--ant-color-error, #ff4d4f)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
|
||||
@@ -177,7 +177,7 @@ export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, o
|
||||
if (err) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#f5222d',
|
||||
color: 'var(--ant-color-error, #f5222d)',
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
|
||||
@@ -60,7 +60,7 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: '#fff' }}>
|
||||
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{url ? (
|
||||
<iframe
|
||||
src={url}
|
||||
@@ -79,4 +79,4 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -64,19 +64,19 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: '#ffffff' }}>
|
||||
<Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
|
||||
<Header
|
||||
style={{
|
||||
background: '#f0f2f5',
|
||||
background: 'var(--ant-color-bg-layout, #f0f2f5)',
|
||||
padding: '0 16px',
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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 && '*'}
|
||||
</span>
|
||||
<Space>
|
||||
@@ -101,4 +101,4 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
185
web/src/contexts/ThemeContext.tsx
Normal file
185
web/src/contexts/ThemeContext.tsx
Normal 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);
|
||||
}
|
||||
@@ -1,41 +1,42 @@
|
||||
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-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #bfbfbf; }
|
||||
::-webkit-scrollbar-thumb { background: var(--ant-color-fill-tertiary, #d9d9d9); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ant-color-fill-secondary, #bfbfbf); }
|
||||
|
||||
.fx-surface { background:#fff; border:1px solid #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-fade-text { color:#555; }
|
||||
.fx-surface { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:12px; }
|
||||
.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: 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 */
|
||||
.ant-menu-inline .ant-menu-item { margin-block:2px; }
|
||||
|
||||
/* Sidebar high-contrast selection */
|
||||
.sider-menu .ant-menu-item-selected {
|
||||
background:#111 !important;
|
||||
background: var(--ant-color-primary, #111) !important;
|
||||
color:#fff !important;
|
||||
}
|
||||
.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: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:hover td { background: rgba(24,144,255,0.2) !important; }
|
||||
|
||||
.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.dir { background:#f3f3f3; }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background:#acc0c0; }
|
||||
.fx-grid-item:hover { background:#d2d1d1a7; 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 { 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: var(--ant-color-fill-secondary, #f3f3f3); }
|
||||
.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: 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: 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 .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; }
|
||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import '../styles/sider-menu.css';
|
||||
import { getLatestVersion } from '../api/config.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
const { Sider } = Layout;
|
||||
|
||||
export interface SideNavProps {
|
||||
@@ -27,6 +28,7 @@ export interface SideNavProps {
|
||||
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
|
||||
const status = useSystemStatus();
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<{
|
||||
@@ -85,10 +87,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
height: 24,
|
||||
objectFit: 'contain',
|
||||
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>
|
||||
{/* 展开时显示收缩按钮 */}
|
||||
{!collapsed && (
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
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 foxelTheme from './theme';
|
||||
import './global.css';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={foxelTheme}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ function renderExif(exif: Record<string, any>) {
|
||||
|
||||
if (items.length === 0) {
|
||||
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 }} />
|
||||
<div>无常见EXIF信息</div>
|
||||
</div>
|
||||
@@ -49,11 +49,11 @@ function renderExif(exif: Record<string, any>) {
|
||||
bordered
|
||||
items={items.map(item => ({
|
||||
key: item.key,
|
||||
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
|
||||
children: <span style={{ color: '#262626' }}>{item.value}</span>
|
||||
label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
|
||||
children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
|
||||
}))}
|
||||
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,
|
||||
} 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 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 (['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 { getFileIcon } from './FileIcons';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
@@ -31,6 +32,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
onContextMenu,
|
||||
}) => {
|
||||
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 = [
|
||||
{
|
||||
@@ -40,9 +54,9 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
render: (_: any, r: VfsEntry) => (
|
||||
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
|
||||
{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.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
@@ -105,4 +119,4 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
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 }) => {
|
||||
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 itemRefs = useRef<Record<string, HTMLDivElement | 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)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
|
||||
{ent.is_dir && <FolderFilled style={{ fontSize: 32, color: token.colorPrimary }} />}
|
||||
{!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))}
|
||||
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{ent.is_dir && (
|
||||
<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>}
|
||||
</div>
|
||||
<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,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
border: '1px dashed rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0, 120, 212, 0.08)',
|
||||
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
|
||||
background: 'var(--ant-color-primary-bg, rgba(0, 120, 212, 0.08))',
|
||||
zIndex: 999
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
case 'success':
|
||||
return (
|
||||
<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>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
@@ -40,7 +40,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
case 'error':
|
||||
return (
|
||||
<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>
|
||||
</Flex>
|
||||
);
|
||||
@@ -71,7 +71,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
borderRadius: 8,
|
||||
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'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
@@ -89,4 +89,4 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadModal;
|
||||
export default UploadModal;
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function LoginPage() {
|
||||
height: '100vh',
|
||||
alignItems: '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={{
|
||||
display: 'flex',
|
||||
@@ -52,11 +52,11 @@ export default function LoginPage() {
|
||||
maxWidth: '1200px',
|
||||
height: '70%',
|
||||
maxHeight: '700px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
backgroundColor: 'var(--ant-color-bg-container, #fff)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: 'blur(5px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
@@ -71,7 +71,7 @@ export default function LoginPage() {
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
|
||||
<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>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>登录到您的 Foxel 账户</Text>
|
||||
</div>
|
||||
@@ -115,8 +115,8 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<div style={{
|
||||
width: '50%',
|
||||
backgroundColor: '#f0f2f5',
|
||||
backgroundImage: `radial-gradient(#d7d7d7 1px, transparent 1px)`,
|
||||
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
|
||||
backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`,
|
||||
backgroundSize: '16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -131,27 +131,27 @@ export default function LoginPage() {
|
||||
</Text>
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<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>
|
||||
<CloudSyncOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>跨平台同步,随时随地访问</Text>
|
||||
</Space>
|
||||
</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>
|
||||
<SearchOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>AI 驱动的智能搜索,快速定位文件</Text>
|
||||
</Space>
|
||||
</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>
|
||||
<ShareAltOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>灵活的分享与协作,提升团队效率</Text>
|
||||
</Space>
|
||||
</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>
|
||||
<ApartmentOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>强大的自动化工作流,简化繁琐任务</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -143,7 +143,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
width={800}
|
||||
>
|
||||
{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)}
|
||||
</pre>
|
||||
)}
|
||||
@@ -152,4 +152,4 @@ const LogsPage = memo(function LogsPage() {
|
||||
);
|
||||
});
|
||||
|
||||
export default LogsPage;
|
||||
export default LogsPage;
|
||||
|
||||
@@ -190,7 +190,7 @@ const SetupPage = () => {
|
||||
height: '100vh',
|
||||
alignItems: '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' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
@@ -235,4 +235,4 @@ const SetupPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupPage;
|
||||
export default SetupPage;
|
||||
|
||||
@@ -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 PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
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}[] = [
|
||||
{ key: 'APP_NAME', label: '应用名称' },
|
||||
@@ -26,10 +28,20 @@ const 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() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('app');
|
||||
const [activeTab, setActiveTab] = useState('appearance');
|
||||
const { refreshTheme, previewTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
getAllConfig().then((data) => setConfigState(data as Record<string, string>));
|
||||
@@ -43,12 +55,23 @@ export default function SystemSettingsPage() {
|
||||
}
|
||||
message.success('保存成功');
|
||||
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) {
|
||||
message.error(e.message || '保存失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'appearance') {
|
||||
refreshTheme();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
if (!config) {
|
||||
return <PageCard title='系统设置'><div>加载中...</div></PageCard>;
|
||||
}
|
||||
@@ -59,11 +82,92 @@ export default function SystemSettingsPage() {
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||
<Tabs
|
||||
className="fx-settings-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
centered
|
||||
tabPosition="left"
|
||||
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 Token(JSON)" tooltip="例如:{ "colorText": "#222" }">
|
||||
<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',
|
||||
label: (
|
||||
|
||||
@@ -18,17 +18,17 @@ const LayoutShell = memo(function LayoutShell() {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
|
||||
<SideNav
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(c => !c)}
|
||||
activeKey={navKey}
|
||||
onChange={(key) => navigate(`/${key}`)}
|
||||
/>
|
||||
<Layout>
|
||||
<Layout style={{ background: 'var(--ant-color-bg-layout)' }}>
|
||||
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
|
||||
<Layout.Content style={{ padding: 16 }}>
|
||||
<div style={{ minHeight: 'calc(100vh - 56px - 32px)' }}>
|
||||
<Layout.Content style={{ padding: 16, background: 'var(--ant-color-bg-layout)' }}>
|
||||
<div style={{ minHeight: 'calc(100vh - 56px - 32px)', background: 'var(--ant-color-bg-layout)' }}>
|
||||
<Flex vertical gap={16}>
|
||||
{navKey === 'adapters' && <AdaptersPage />}
|
||||
{navKey === 'files' && <FileExplorerPage />}
|
||||
|
||||
40
web/src/styles/settings-tabs.css
Normal file
40
web/src/styles/settings-tabs.css
Normal 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;
|
||||
}
|
||||
@@ -5,13 +5,24 @@
|
||||
margin-block: 2px;
|
||||
}
|
||||
|
||||
.foxel-sider-menu .ant-menu-item-selected {
|
||||
font-weight: 600;
|
||||
.foxel-sider-menu .ant-menu-item-selected { 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 {
|
||||
color: #fff !important;
|
||||
/* 暗色主题:选中项使用主色背景(浅),文字使用常规文本色以保持对比度 */
|
||||
html[data-theme='dark'] .foxel-sider-menu .ant-menu-item-selected {
|
||||
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 {
|
||||
@@ -21,3 +32,8 @@
|
||||
.foxel-sider-menu .ant-menu-item .ant-menu-item-icon {
|
||||
transition: color .18s;
|
||||
}
|
||||
|
||||
/* 悬停(未选中)背景 */
|
||||
.foxel-sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover {
|
||||
background: var(--ant-color-fill-tertiary) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user