mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 00:27:44 +08:00
feat: Add theme and dark mode
This commit is contained in:
+8
-1
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 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',
|
key: 'app',
|
||||||
label: (
|
label: (
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user