refactor: 重组 components/ 和 pages/ 文件结构,按职责分层
components/ 拆为三层:
- providers/ — 状态管理(App/Theme/SettingsProvider)
- shell/ — 应用外壳(Layout/NavBar/PageDispatcher)
- ui/ — 通用 UI 原子(FloatingPanel/LegalTextModal)
pages/ 重命名 + 重组:
- headers/ → panels/ NavBar 触发的面板页
- login/ → auth/ 认证模块(含注册/忘记密码)
- settings/blocks/ → sections/
- home/components/ → panels/ features/ controls/ hooks/
按职责分:panels(布局壳) + features(功能面板) + controls(表单控件) + hooks
其他清理:
- 删除空占位 Test3.tsx
- HomeContext 从 HomeContent 拆出独立文件
- uploadValidation 内联到 ImageInput
- 修复过时注释(useUI → controls)
This commit is contained in:
208
src/components/providers/AppProvider.tsx
Normal file
208
src/components/providers/AppProvider.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
// ============================================================
|
||||
// AppProvider — 全局应用状态 Context + Provider + Hook
|
||||
//
|
||||
// 导出:
|
||||
// AppContext, useAppContext() — Context + Consumer Hook
|
||||
// AppContextValue — 类型
|
||||
// AppProvider — Provider 组件
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import type { Platform, Edition } from '@shared/types';
|
||||
|
||||
import { getPlatform, isDev } from '@/utils/platform';
|
||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||
import {
|
||||
initAuthRefresh,
|
||||
getStoredRefreshToken,
|
||||
setStoredRefreshToken,
|
||||
clearStoredRefreshToken,
|
||||
initRefreshTokenStore,
|
||||
scheduleProactiveRefresh,
|
||||
clearProactiveRefresh,
|
||||
} from '@/services/auth-token';
|
||||
import {initModelCache} from '@/services/cache/model-cache';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
export interface AppContextValue {
|
||||
/** 当前操作系统平台 */
|
||||
platform: Platform;
|
||||
/** 产品版本(个人版 / 企业版) */
|
||||
edition: Edition;
|
||||
/** 用户是否已登录 */
|
||||
isLoggedIn: boolean;
|
||||
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
||||
user: LoginResponseBody | null;
|
||||
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */
|
||||
login: (auth: LoginResponseBody) => Promise<void>;
|
||||
/** 退出登录:清除 Token 和用户状态 */
|
||||
logout: () => void;
|
||||
/** 是否为开发环境 */
|
||||
isDevMode: boolean;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
// ---------- Consumer Hook ----------
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------- Provider 组件 ----------
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: AppProviderProps) {
|
||||
const [platform] = useState<Platform>(getPlatform);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
||||
|
||||
/**
|
||||
* edition 从登录用户的 account_type 派生(运行时判断,无需构建变量)
|
||||
*
|
||||
* 映射关系:
|
||||
* "individual" → personal ← 个人用户
|
||||
* "company" | "employee" → enterprise ← 企业用户
|
||||
* null(未登录) → personal ← 默认最小功能集
|
||||
*
|
||||
* 安全性:edition 是 useMemo 派生值,不可独立赋值;
|
||||
* 不写入 localStorage,重启/登出后回退默认值,必须重新登录才能确权。
|
||||
*/
|
||||
const edition: Edition = useMemo(() => {
|
||||
if (!user) return 'personal';
|
||||
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
||||
}, [user]);
|
||||
|
||||
// ---------- 登录 / 退出 ----------
|
||||
|
||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||
await setToken(auth.access_token);
|
||||
await setStoredRefreshToken(auth.refresh_token);
|
||||
// 启动主动刷新定时器(在 access_token 过期前自动续期)
|
||||
scheduleProactiveRefresh(auth.access_token);
|
||||
setUser(auth);
|
||||
setIsLoggedIn(true);
|
||||
logger.info('auth', 'User logged in', {
|
||||
userId: auth.user_id,
|
||||
username: auth.username,
|
||||
});
|
||||
emit(EVENTS.LOGIN_SUCCESS, auth);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logger.info('auth', 'User logged out');
|
||||
clearToken();
|
||||
clearStoredRefreshToken();
|
||||
clearProactiveRefresh();
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
emit(EVENTS.LOGOUT);
|
||||
}, []);
|
||||
|
||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||
//
|
||||
// 注意:React StrictMode(开发模式)会挂载→卸载→重新挂载以暴露副作用问题。
|
||||
// aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
|
||||
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
||||
if (aborted) return;
|
||||
|
||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||
initAuthRefresh();
|
||||
|
||||
// 自动登录(refresh_token 续期)
|
||||
if (!getAutoLoginFlag()) return;
|
||||
|
||||
const refreshToken = getStoredRefreshToken();
|
||||
if (!refreshToken) return;
|
||||
|
||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||
.then(async (res) => {
|
||||
if (aborted) return;
|
||||
await setToken(res.access_token);
|
||||
await setStoredRefreshToken(res.refresh_token);
|
||||
scheduleProactiveRefresh(res.access_token);
|
||||
setIsLoggedIn(true);
|
||||
logger.info('auth', 'Auto-login succeeded');
|
||||
// 补全用户信息
|
||||
AuthAPI.getUserInfo().then((info) => {
|
||||
if (aborted) return;
|
||||
const u: LoginResponseBody = {
|
||||
access_token: res.access_token,
|
||||
refresh_token: res.refresh_token,
|
||||
token_type: res.token_type,
|
||||
expires_in: res.expires_in,
|
||||
refresh_expires_in: res.refresh_expires_in,
|
||||
user_id: info.id,
|
||||
username: info.username,
|
||||
role: info.role,
|
||||
email: info.email ?? '',
|
||||
balance_cent: info.balance_cent,
|
||||
account_type: info.account_type,
|
||||
display_name: info.display_name ?? '',
|
||||
company_id: info.company_id ?? '',
|
||||
real_name: info.real_name ?? '',
|
||||
phone: info.phone,
|
||||
license_code: '',
|
||||
admin_permissions: info.admin_permissions ?? [],
|
||||
};
|
||||
setUser(u);
|
||||
emit(EVENTS.LOGIN_SUCCESS, u);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (aborted) return;
|
||||
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
||||
clearStoredRefreshToken();
|
||||
// 仅清除自动登录标记,保留用户名供下次表单回填
|
||||
clearAutoLoginFlag();
|
||||
});
|
||||
});
|
||||
|
||||
return () => { aborted = true; };
|
||||
}, []);
|
||||
|
||||
// ---------- 监听主进程推送的平台/版本信息 ----------
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (_event: unknown, info: unknown) => {
|
||||
const data = info as { platform?: string; edition?: string } | undefined;
|
||||
if (data) {
|
||||
logger.debug('app', 'Received platform info from main process', data as Record<string, unknown>);
|
||||
}
|
||||
};
|
||||
safeIpcOn('platform-info', handler);
|
||||
return () => {
|
||||
safeIpcOff('platform-info', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AppContextValue = {
|
||||
platform,
|
||||
edition,
|
||||
isLoggedIn,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
isDevMode: isDev(),
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
140
src/components/providers/SettingsProvider.tsx
Normal file
140
src/components/providers/SettingsProvider.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// ============================================================
|
||||
// SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
|
||||
//
|
||||
// 职责:
|
||||
// - 存储路径的集中管理(read/write/clear)
|
||||
// - 持久化到 localStorage,遵循 ele-heixiu-* 键名规范
|
||||
// - 全局组件通过 useSettings() 消费
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
// ---------- localStorage 键 ----------
|
||||
|
||||
const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
|
||||
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
export interface SettingsContextValue {
|
||||
/** 大模型产物存储仓库路径 */
|
||||
outputPath: string;
|
||||
/** 团队创作存储仓库路径(企业版) */
|
||||
teamRepoPath: string;
|
||||
/** 设置产物输出路径 */
|
||||
setOutputPath: (path: string) => void;
|
||||
/** 设置团队创作仓库路径 */
|
||||
setTeamRepoPath: (path: string) => void;
|
||||
/** 清除产物输出路径 */
|
||||
clearOutputPath: () => void;
|
||||
/** 清除团队创作仓库路径 */
|
||||
clearTeamRepoPath: () => void;
|
||||
}
|
||||
|
||||
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||
|
||||
// ---------- 工具函数(localStorage 读写)----------
|
||||
|
||||
export function readStoredOutputPath(): string {
|
||||
try {
|
||||
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredOutputPath(path: string): void {
|
||||
try {
|
||||
localStorage.setItem(OUTPUT_PATH_KEY, path);
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredOutputPath(): void {
|
||||
try {
|
||||
localStorage.removeItem(OUTPUT_PATH_KEY);
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredTeamRepoPath(): string {
|
||||
try {
|
||||
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredTeamRepoPath(path: string): void {
|
||||
try {
|
||||
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredTeamRepoPath(): void {
|
||||
try {
|
||||
localStorage.removeItem(TEAM_REPO_PATH_KEY);
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Consumer Hook ----------
|
||||
|
||||
export function useSettings(): SettingsContextValue {
|
||||
const ctx = useContext(SettingsContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------- Provider 组件 ----------
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const [outputPath, setOutputPathState] = useState<string>(readStoredOutputPath);
|
||||
const [teamRepoPath, setTeamRepoPathState] = useState<string>(readStoredTeamRepoPath);
|
||||
|
||||
// ---------- outputPath ----------
|
||||
|
||||
const setOutputPath = useCallback((path: string) => {
|
||||
setOutputPathState(path);
|
||||
writeStoredOutputPath(path);
|
||||
}, []);
|
||||
|
||||
const clearOutputPath = useCallback(() => {
|
||||
setOutputPathState('');
|
||||
clearStoredOutputPath();
|
||||
}, []);
|
||||
|
||||
// ---------- teamRepoPath ----------
|
||||
|
||||
const setTeamRepoPath = useCallback((path: string) => {
|
||||
setTeamRepoPathState(path);
|
||||
writeStoredTeamRepoPath(path);
|
||||
}, []);
|
||||
|
||||
const clearTeamRepoPath = useCallback(() => {
|
||||
setTeamRepoPathState('');
|
||||
clearStoredTeamRepoPath();
|
||||
}, []);
|
||||
|
||||
const value: SettingsContextValue = {
|
||||
outputPath,
|
||||
teamRepoPath,
|
||||
setOutputPath,
|
||||
setTeamRepoPath,
|
||||
clearOutputPath,
|
||||
clearTeamRepoPath,
|
||||
};
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
161
src/components/providers/ThemeProvider.tsx
Normal file
161
src/components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// ============================================================
|
||||
// ThemeProvider — 主题 Context + Provider + Hook + 工具函数
|
||||
//
|
||||
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
||||
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
||||
// 独立 CSS 过渡,消除 Electron Chromium 的合成器卡顿。
|
||||
// 浏览器不支持时回退到 CSS transition 方案。
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
import { getThemeConfig } from '../../theme/antd-theme';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
export interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
toggleTheme: () => void;
|
||||
setLight: () => void;
|
||||
setDark: () => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
// ---------- localStorage 工具函数 ----------
|
||||
|
||||
export const STORAGE_KEY = 'ele-heixiu-theme';
|
||||
|
||||
export function readStoredTheme(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'dark') return true;
|
||||
if (stored === 'light') return false;
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function writeStoredTheme(isDark: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
export function applyHtmlTheme(isDark: boolean): void {
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Consumer Hook ----------
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------- Provider 组件 ----------
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 检测 View Transitions API 是否可用(Chromium 111+,Electron 28+) */
|
||||
function supportsViewTransition(): boolean {
|
||||
return typeof document !== 'undefined' && 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applyHtmlTheme(isDark);
|
||||
}, [isDark]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
if (supportsViewTransition()) {
|
||||
// View Transitions API:GPU 合成器 cross-fade,1 个动画替代 N 个 CSS 过渡
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTheme(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 回退:普通 React 状态更新 + CSS transition
|
||||
setIsDark((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTheme(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLight = useCallback(() => {
|
||||
if (supportsViewTransition()) {
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark(false);
|
||||
writeStoredTheme(false);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsDark(false);
|
||||
writeStoredTheme(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setDark = useCallback(() => {
|
||||
if (supportsViewTransition()) {
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark(true);
|
||||
writeStoredTheme(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsDark(true);
|
||||
writeStoredTheme(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听系统主题变化(仅用户从未手动切换时跟随)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === null) {
|
||||
setIsDark(e.matches);
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const ctx: ThemeContextValue = { isDark, toggleTheme, setLight, setDark };
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={ctx}>
|
||||
<ConfigProvider theme={getThemeConfig(isDark)} locale={zhCN}>{children}</ConfigProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user