- {/* 3. Footer with User Info */}
+ {/* Footer / User Profile */}
-
-
-
- {user?.name?.[0]?.toUpperCase() || 'U'}
-
- {!collapsed && (
-
-
{user?.name || 'User'}
-
{user?.role || 'Admin'}
-
+ {collapsed ? (
+
+
+
} style={{ backgroundColor: token.colorPrimary }} />
+
+
+ ) : (
+
+
} style={{ backgroundColor: token.colorPrimary }} />
+
+
{user?.name}
+
{user?.role}
+
+ {onLogout && (
+
+ {
+ e.stopPropagation();
+ onLogout();
+ }}
+ >
+
+
+
)}
- {!collapsed && (
-
-
-
- )}
-
+ )}
);
};
-export default ModernSidebar;
\ No newline at end of file
+export default ModernSidebar;
diff --git a/frontend/src/components/SplitLayout/SplitLayout.css b/frontend/src/components/SplitLayout/SplitLayout.css
index 097fa77..f5cd9a0 100644
--- a/frontend/src/components/SplitLayout/SplitLayout.css
+++ b/frontend/src/components/SplitLayout/SplitLayout.css
@@ -2,7 +2,8 @@
.split-layout {
display: flex;
width: 100%;
- align-items: flex-start;
+ height: 100%;
+ /* align-items: flex-start; Removed to allow stretch */
}
/* 横向布局(左右分栏) */
@@ -32,11 +33,11 @@
/* 右侧扩展区(横向布局) */
.split-layout-extend-right {
- height: 693px;
+ height: 100%;
overflow-y: auto;
overflow-x: hidden;
- position: sticky;
- top: 16px;
+ /* position: sticky; Removed as height is 100% */
+ /* top: 16px; Removed */
padding-right: 4px;
}
diff --git a/frontend/src/components/Toast/Toast.tsx b/frontend/src/components/Toast/Toast.tsx
index 70a921c..162ff9e 100644
--- a/frontend/src/components/Toast/Toast.tsx
+++ b/frontend/src/components/Toast/Toast.tsx
@@ -1,4 +1,5 @@
import { notification } from "antd";
+import { NotificationInstance } from "antd/es/notification/interface";
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -6,6 +7,14 @@ import {
InfoCircleOutlined,
} from "@ant-design/icons";
+// Holder for the notification instance from App.useApp()
+let notificationInstance: NotificationInstance | null = null;
+
+export const setNotificationInstance = (instance: NotificationInstance) => {
+ notificationInstance = instance;
+};
+
+// Fallback configuration for static method
notification.config({
placement: "topRight",
top: 24,
@@ -13,9 +22,11 @@ notification.config({
maxCount: 3,
});
+const getNotification = () => notificationInstance || notification;
+
const Toast = {
success: (message: string, description = "", duration = 3) => {
- notification.success({
+ getNotification().success({
message,
description,
duration,
@@ -28,7 +39,7 @@ const Toast = {
},
error: (message: string, description = "", duration = 3) => {
- notification.error({
+ getNotification().error({
message,
description,
duration,
@@ -41,7 +52,7 @@ const Toast = {
},
warning: (message: string, description = "", duration = 3) => {
- notification.warning({
+ getNotification().warning({
message,
description,
duration,
@@ -54,7 +65,7 @@ const Toast = {
},
info: (message: string, description = "", duration = 3) => {
- notification.info({
+ getNotification().info({
message,
description,
duration,
@@ -66,13 +77,13 @@ const Toast = {
});
},
- custom: (config: Record
) => {
- notification.open({
+ custom: (config: any) => {
+ getNotification().open({
...config,
style: {
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
- ...config.style,
+ ...(config.style || {}),
},
});
},
diff --git a/frontend/src/components/UserAvatar/UserAvatar.tsx b/frontend/src/components/UserAvatar/UserAvatar.tsx
index 258f12d..5b8f40d 100644
--- a/frontend/src/components/UserAvatar/UserAvatar.tsx
+++ b/frontend/src/components/UserAvatar/UserAvatar.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { forwardRef } from 'react';
import { Avatar, AvatarProps } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { resolveUrl } from '../../utils/url';
@@ -11,9 +11,9 @@ interface UserAvatarProps extends AvatarProps {
};
}
-const UserAvatar: React.FC = ({ user, ...rest }) => {
+const UserAvatar = forwardRef(({ user, ...rest }, ref) => {
if (user?.avatar) {
- return ;
+ return ;
}
const name = user?.display_name || user?.username || '?';
@@ -21,12 +21,13 @@ const UserAvatar: React.FC = ({ user, ...rest }) => {
return (
{firstLetter}
);
-};
+});
export default UserAvatar;
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..b9f0837
--- /dev/null
+++ b/frontend/src/contexts/ThemeContext.tsx
@@ -0,0 +1,70 @@
+import React, { createContext, useContext, useState } from 'react';
+import { ConfigProvider, theme } from 'antd';
+import zhCN from 'antd/locale/zh_CN';
+import enUS from 'antd/locale/en_US';
+import { useTranslation } from 'react-i18next';
+
+// Define theme types
+export type ThemeMode = 'light' | 'dark';
+
+interface ThemeContextType {
+ mode: ThemeMode;
+ setMode: (mode: ThemeMode) => void;
+ primaryColor: string;
+ setPrimaryColor: (color: string) => void;
+}
+
+const ThemeContext = createContext(undefined);
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+};
+
+export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const { i18n } = useTranslation();
+
+ // Load from local storage or default
+ const [mode, setModeState] = useState(() => {
+ return (localStorage.getItem('theme_mode') as ThemeMode) || 'light';
+ });
+
+ const [primaryColor, setPrimaryColorState] = useState(() => {
+ return localStorage.getItem('theme_primary_color') || '#1677ff';
+ });
+
+ const setMode = (newMode: ThemeMode) => {
+ setModeState(newMode);
+ localStorage.setItem('theme_mode', newMode);
+ };
+
+ const setPrimaryColor = (newColor: string) => {
+ setPrimaryColorState(newColor);
+ localStorage.setItem('theme_primary_color', newColor);
+ };
+
+ // Ant Design Theme Algorithm
+ const algorithm = mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm;
+
+ // Ant Design Locale
+ const antdLocale = i18n.language === 'en' ? enUS : zhCN;
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
new file mode 100644
index 0000000..9c27903
--- /dev/null
+++ b/frontend/src/i18n.ts
@@ -0,0 +1,26 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import en from './locales/en.json';
+import zh from './locales/zh.json';
+
+const resources = {
+ en: {
+ translation: en,
+ },
+ zh: {
+ translation: zh,
+ },
+};
+
+i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: localStorage.getItem('lang') || 'zh', // default language
+ fallbackLng: 'zh',
+ interpolation: {
+ escapeValue: false,
+ },
+ });
+
+export default i18n;
diff --git a/frontend/src/layout/AppHeader.css b/frontend/src/layout/AppHeader.css
index 537bbc7..0b2223f 100644
--- a/frontend/src/layout/AppHeader.css
+++ b/frontend/src/layout/AppHeader.css
@@ -1,6 +1,6 @@
.app-header-main {
- background: #fff !important;
- border-bottom: 1px solid #e5e7eb;
+ background: var(--header-bg, #fff) !important;
+ border-bottom: 1px solid var(--header-border, #e5e7eb);
padding: 0 24px;
height: 64px;
line-height: 64px;
@@ -12,6 +12,7 @@
position: sticky;
top: 0;
width: 100%;
+ transition: all 0.2s;
}
.header-left {
@@ -26,7 +27,7 @@
.header-icon-btn {
font-size: 18px;
- color: #4b5563;
+ color: var(--header-text-secondary, #4b5563);
cursor: pointer;
display: flex;
align-items: center;
@@ -38,8 +39,8 @@
}
.header-icon-btn:hover {
- background: #f3f4f6;
- color: #2563eb;
+ background: var(--header-hover-bg, #f3f4f6);
+ color: var(--header-active-text, #2563eb);
}
.user-dropdown-trigger {
@@ -50,11 +51,11 @@
}
.user-dropdown-trigger:hover {
- background: #f3f4f6;
+ background: var(--header-hover-bg, #f3f4f6);
}
.display-name {
font-size: 14px;
font-weight: 500;
- color: #374151;
+ color: var(--header-text, #374151);
}
diff --git a/frontend/src/layout/AppHeader.tsx b/frontend/src/layout/AppHeader.tsx
index 242d9f2..e8cd1ee 100644
--- a/frontend/src/layout/AppHeader.tsx
+++ b/frontend/src/layout/AppHeader.tsx
@@ -1,5 +1,5 @@
-import React, { useState } from 'react';
-import { Layout, Space, Badge, Segmented, Tooltip, Avatar, Dropdown, MenuProps } from 'antd';
+import React from 'react';
+import { Layout, Space, Badge, Segmented, Tooltip, Dropdown, MenuProps, ColorPicker, theme } from 'antd';
import {
BellOutlined,
SunOutlined,
@@ -8,9 +8,12 @@ import {
LogoutOutlined,
SettingOutlined
} from '@ant-design/icons';
+import { useTranslation } from 'react-i18next';
+import { useTheme } from '../contexts/ThemeContext';
import './AppHeader.css';
const { Header } = Layout;
+const { useToken } = theme;
interface AppHeaderProps {
displayName: string;
@@ -19,19 +22,40 @@ interface AppHeaderProps {
}
const AppHeader: React.FC = ({ displayName, onLogout, onProfileClick }) => {
- const [isDarkMode, setIsDarkMode] = useState(false);
- const [lang, setLang] = useState('zh');
+ const { token } = useToken();
+ const { t, i18n } = useTranslation();
+ const { mode, setMode, primaryColor, setPrimaryColor } = useTheme();
+
+ const isDarkMode = mode === 'dark';
+
+ const cssVars = {
+ '--header-bg': token.colorBgContainer,
+ '--header-border': token.colorSplit,
+ '--header-text': token.colorText,
+ '--header-text-secondary': token.colorTextSecondary,
+ '--header-hover-bg': token.colorFillTertiary,
+ '--header-active-text': token.colorPrimary,
+ } as React.CSSProperties;
+
+ const handleLangChange = (value: string) => {
+ i18n.changeLanguage(value);
+ localStorage.setItem('lang', value);
+ };
+
+ const handleThemeModeChange = () => {
+ setMode(isDarkMode ? 'light' : 'dark');
+ };
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
- label: '个人资料',
+ label: t('header.profile'),
icon: ,
onClick: onProfileClick,
},
{
key: 'settings',
- label: '个人设置',
+ label: t('header.settings'),
icon: ,
},
{
@@ -39,7 +63,7 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC
},
{
key: 'logout',
- label: '退出登录',
+ label: t('header.logout'),
icon: ,
danger: true,
onClick: onLogout,
@@ -47,7 +71,7 @@ const AppHeader: React.FC = ({ displayName, onLogout, onProfileC
];
return (
-