版控体系重构(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 新增已完成项
218 lines
8.0 KiB
TypeScript
218 lines
8.0 KiB
TypeScript
// ============================================================
|
||
// 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>;
|
||
}
|