Files
ele-HeiXiu/src/components/providers/AppProvider.tsx
YoungestSongMo 8b697e775d feat: 去除构建时版控变量 + 窗口标题动态版控 + 退出登录保留缓存 (0.0.24)
版控体系重构(13 文件):
- 移除 build.mjs --personal/--enterprise 参数和 EDITION 环境变量
- 统一安装包文件名:船长-HeiXiu-{os}-{版本号}-Setup.{ext}
- 简化 package.json 构建脚本,移除 :enterprise 变体
- scripts/*.mjs 不再接受 edition 参数
- 移除 shared/constants/app.ts 的 parseEdition()

窗口标题动态版控:
- 新格式:船长-HeiXiu-{os}-{版本号}(登录前)/ ·{版控} 后缀(登录后)
- 链路:AppProvider(account_type) → useEffect → appRuntime IPC → main process
- 移除 updater.ts 更新检查 API 的 edition 查询参数

本地存储生命周期修正:
- 退出登录不再自动清空缓存(user_id 隔离,重新登录命中缓存)
- clearUserStorage 添加 isOpen() 竞态守卫
- 移除 closeDatabase() 避免退出→重新登录后数据库不可用
- 手动「清理缓存」按钮保留

文档:
- CHANGELOG.md 新增 0.0.24 条目
- TODO.md 新增已完成项
2026-06-22 13:36:42 +08:00

218 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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/env';
import { safeIpcOn, safeIpcOff } from '@/utils/bus';
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/bus';
import { logger } from '@/utils/bus';
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';
import {initStorage} from '@/infrastructure/storage';
// ---------- 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]);
// 登录/登出后同步窗口标题版控后缀到主进程
useEffect(() => {
window.appRuntime?.setWindowEdition(isLoggedIn ? edition : null);
}, [edition, isLoggedIn]);
// ---------- 登录 / 退出 ----------
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);
// 注意:退出登录不自动清理本地缓存
// —— 数据已按 user_id 隔离,同一用户重新登录后缓存仍可命中
// 用户主动清理请使用设置页面的「清理缓存」按钮
}, []);
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
//
// 注意React StrictMode开发模式会挂载→卸载→重新挂载以暴露副作用问题。
// aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。
useEffect(() => {
let aborted = false;
// 从加密存储恢复 token + 模型缓存 到内存缓存
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache(), initStorage()]).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>;
}