diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index e909482..82cfe9b 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 145380, + "pid": 384720, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1780622650728 + "startedAt": 1780763058452 } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3b29b15..d3eb3f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 船长 · HeiXiu — 前端架构文档 -> 最后更新:2026-06-03 +> 最后更新:2026-06-07 ## 目录结构 @@ -156,7 +156,23 @@ NavBar / 设置页 → AppProvider.logout() - 纯发布-订阅,不依赖任何框架 - 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context) -- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` +- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` + +### 页面叠加层模式(QT 多窗口模型映射) + +本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则: + +| QT 原版 | Web 对应 | 实现方式 | +|---------|---------|---------| +| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 | +| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage | + +**当前叠加层**: +- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发 +- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发 +- AnnouncementDrawer — Drawer,NavBar 内部 state 控制 + +**反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。 ### API 模块规范 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be8e83..68d4711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ --- +## 0.0.13(2026-06-07) + +- 修复主题切换后首页状态重置问题:设置页去路由化,改为事件总线驱动的 Modal 叠加 +- 首页三栏分隔条视觉对齐:移除右侧多余 margin,左右完全对称 +- 轮播图新增关闭按钮:session 级别隐藏,每次启动重新展示 +- 确立 QT 多窗口模型 → Web 架构映射原则:叠加层走 Modal/Drawer + 事件总线,不走路由 + +--- + ## 0.0.12(2026-06-04) - 重构更新检查流程:无更新不再抛异常,PreCheck → 直接 IPC 通知 diff --git a/PROJECT.md b/PROJECT.md index 1f3742c..a3a53d4 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -46,10 +46,11 @@ 4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 -- 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。 +- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**: + - 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载 + - 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage -- 辅助窗口(设置、预览、导入导出等)为独立 BrowserWindow,加载独立的 HTML 入口(可根据路由参数或 windowId 决定内部路由),不与主窗口共享 - DOM。 +- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。 # 目录结构 @@ -175,6 +176,36 @@ - 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。 +# 安全与认证 + +## Token 加密存储 + +- `access_token` 和 `refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文: + - macOS → Keychain + - Windows → DPAPI + - Linux → libsecret +- 实现文件: + - `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`) + - `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`) + - `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 + - `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取 + +## 双 Token 机制 + +- `access_token`(短效)+ `refresh_token`(长效),登录成功同时下发 +- 请求拦截器自动附加 `Authorization: Bearer ` +- 401 响应 → 双重校验(HTTP `status` + 业务 `data.code`)→ 仅 `code === 401` 时触发刷新 +- 刷新期间并发请求排队(`failedQueue`),刷新完成后批量重试 +- refresh 接口自身 401 直接拒绝(防死循环),login 接口 401 穿透透传后端 `message` +- 启动时 `refresh_token` 静默续期(自动登录),失败仅清除标记不影响用户名回填 +- 登录/退出通过事件总线解耦(`LOGIN_SUCCESS` / `LOGOUT` / `AUTH_REQUIRED`) + +## 日志脱敏 + +- `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理 +- 覆盖字段:用户 ID(SHA256 哈希)、邮箱(`***@domain`)、手机号(掩码)、用户名称、Token(截断) +- 主进程 `electron/main/logger.ts` 和渲染进程 `src/utils/logger.ts` 均写入前调用 `sanitizeLogEntry()` + # 样式与主题 - 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置 @@ -240,6 +271,17 @@ | `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | | `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | +## 增量更新(差分下载) + +- `package.json` 配置 `"differentialPackage": true`,electron-builder 打包时自动生成 `.exe.blockmap` 文件 +- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` / `HeiXiuAppImageUpdater extends AppImageUpdater` +- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由 electron-updater 父类原生处理 +- blockmap 文件流程: + - `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size` 到 `update-info.json` + - `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS(支持 Node.js 直传和 ossutil 两种模式) + - `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API +- 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量 + ## 环境变量加载机制(主进程) 加载顺序(后者覆盖前者,OS 环境变量始终优先): diff --git a/TODO.md b/TODO.md index a5554fe..a316c44 100644 --- a/TODO.md +++ b/TODO.md @@ -1,49 +1,6 @@ # TODO — 船长·HeiXiu 待办与优化清单 -> 最后更新:2026-06-03 - ---- - -## 🔴 安全加固(优先级高) - -### 1. 敏感数据加密存储 - -当前所有敏感数据存储在 `localStorage` 明文/Base64: - -| 数据 | 现状 | 目标方案 | -|------------------|-----------|----------------------------------------| -| `access_token` | 明文 | Electron `safeStorage.encryptString()` | -| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` | -| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 | - -**方案 A(推荐)**:使用 Electron `safeStorage` API - -- macOS → Keychain -- Windows → DPAPI -- Linux → libsecret - -```ts -// preload.ts 暴露安全方法 -contextBridge.exposeInMainWorld('safeStorage', { - encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain), - decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted), -}); - -// 替代现有 setToken / setStoredRefreshToken -// 渲染进程不再直接操作 localStorage -``` - -**方案 B**:敏感操作下沉到主进程 - -- `access_token` / `refresh_token` 由主进程管理 -- 渲染进程通过 IPC 发起请求时由主进程追加 Token -- 渲染进程永不持有明文 Token - -### 2. 密码存储策略调整 - -- [ ] "记住密码" → 仅回填用户名,不再存储密码(已完成) -- [ ] 清理 `StoredAuth.passwordBase64` 字段 -- [ ] "自动登录" 完全依赖 `refresh_token` 静默续期 +> 最后更新:2026-06-07 --- @@ -87,54 +44,6 @@ contextBridge.exposeInMainWorld('safeStorage', { - [ ] 单元测试覆盖(Vitest) - [ ] Bundle 体积分析优化(rollup-plugin-visualizer 已有,定期检查) -### 增量更新改造 - -当前状态: - -- `electron-builder` 已配置 `"differential": true`,打包时已生成 `.exe.blockmap` 文件 ✅ -- `electron/main/updater.ts` 为**手写全量下载**(`net.request`),未使用 `electron-updater` npm 包 ❌ -- blockmap 文件生成了但从未被消费,每次更新都是完整下载 - -目标方案 — 自定义 Provider + electron-updater: - -```ts -// electron/main/updater-v2.ts -import { autoUpdater } from 'electron-updater'; -import { Provider } from 'electron-updater/out/providers/Provider'; - -class HeiXiuProvider extends Provider { - // 版本检查 → 走自定义 API(保留灰度/强更控制) - async getLatestVersion(): Promise { - const res = await fetch(`${API_BASE}/api/v1/update/check?...`); - const data = await res.json(); - return { - version: data.latestVersion, - files: [{ - url: data.downloadUrl, - sha512: data.sha512, - size: data.fileSize, - blockMapSize: data.blockMapSize, // 后端新增字段 - }], - path: data.downloadUrl, - sha512: data.sha512, - releaseDate: data.releaseDate, - }; - } -} -``` - -改造内容: - -- [ ] 安装 `electron-updater` npm 包 -- [ ] 重写 `electron/main/updater.ts` 为自定义 Provider 模式 -- [ ] 发布脚本 `upload-oss.mjs` 增加 blockmap 文件上传 -- [ ] `generate-update-info.mjs` 增加 blockmap 字段(`blockMapUrl`、`blockMapSize`) -- [ ] 后端 API `/api/v1/update/check` 响应增加 `blockMapUrl`、`blockMapSize` 字段 -- [ ] 后端 `releases` 表增加 `blockmap_url`、`blockmap_size` 列 -- [ ] OSS 上传每次版本需同时上传 `.exe` + `.exe.blockmap` 两个文件 - -增量效果预估:日常更新下载量从完整安装包 → 仅变化的块(预计节省 60%~90% 流量)。 - ### 代码质量 - [ ] 移除未使用的 `shared/` 类型和常量 @@ -152,17 +61,19 @@ class HeiXiuProvider extends Provider { ## ⚠️ 已知技术债务 -| 问题 | 说明 | 影响 | -|-----------------|---------------------------------------|----------------| -| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | -| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | -| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | -| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | +| 问题 | 说明 | 影响 | +|-----------------|---------------------------------------|------------------| +| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | +| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | +| ~~主题切换状态重置~~ | ✅ 已修复 — 根因:设置页 `navigate('/settings')` 导致路由 `/*` 仅匹配 index,HomePage 被卸载重挂载。修复:SettingsPage 改为事件总线驱动的 Modal 居中叠加,不走路由 | ~~用户体验差~~ | +| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | +| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | --- ## 📋 下次开发计划 -1. **safeStorage 加密改造**(安全加固 #1) -2. **合规素材库页面**(功能待完善 Route 页面) -3. **各页面 nav 导航补齐** +1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) +2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) +3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好) +4. **轮播图关闭按钮状态优化**(当前为 session 级别 useState,可考虑加 localStorage 记录关闭时间戳,超过 N 天再显示) diff --git a/src/App.tsx b/src/App.tsx index ffff8f1..1d591ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { HashRouter } from 'react-router-dom'; import { useAppContext } from './contexts/app-context'; import { LoginPage } from './pages/login'; +import { SettingsPage } from './pages/settings'; import { on, off, EVENTS } from './utils/event-bus'; import { AppRoutes } from './router'; @@ -12,7 +13,12 @@ import { AppRoutes } from './router'; * 层级: * HashRouter * ├── LoginPage(Modal 弹层,事件总线驱动) - * ├── AppRoutes(页面路由 + SettingsPage Drawer 叠加) + * ├── SettingsPage(Modal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口) + * ├── AppRoutes(页面路由) + * + * 设计原则(对应 QT 原版多窗口模型): + * - 叠加层(设置、公告等)→ Modal/Drawer + 事件总线,主页面不卸载 + * - 页面跳转 → React Router,需要跨页保持的状态放 Provider 或 localStorage */ function App() { const { isLoggedIn } = useAppContext(); @@ -42,12 +48,25 @@ function App() { setLoginOpen(false); }, []); + // 设置 Drawer 状态 + const [settingsOpen, setSettingsOpen] = useState(false); + + // 监听打开设置 Drawer + useEffect(() => { + const handler = () => setSettingsOpen(true); + on(EVENTS.OPEN_SETTINGS, handler); + return () => { off(EVENTS.OPEN_SETTINGS, handler); }; + }, []); + return ( {/* 登录 Modal — 可关闭,关闭后主页可操作 */} - {/* 页面路由 + SettingsPage Drawer 叠加 */} + {/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */} + setSettingsOpen(false)} /> + + {/* 页面路由 */} ); diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index 49172df..efb6caa 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -79,6 +79,8 @@ export function NavBar() { case 'button': if (item.key === 'logout') { logout(); + } else if (item.key === 'settings') { + emit(EVENTS.OPEN_SETTINGS); } break; diff --git a/src/components/navbar/nav-config.ts b/src/components/navbar/nav-config.ts index 3b266a4..4217f60 100644 --- a/src/components/navbar/nav-config.ts +++ b/src/components/navbar/nav-config.ts @@ -99,8 +99,7 @@ const personalRight: NavItemConfig[] = [ key: 'settings', label: '设置', iconSrc: settingsSvg, - action: 'navigate', - target: '/settings', + action: 'button', position: 'right', }, { @@ -168,8 +167,7 @@ const enterpriseRight: NavItemConfig[] = [ key: 'settings', label: '设置', iconSrc: settingsSvg, - action: 'navigate', - target: '/settings', + action: 'button', position: 'right', }, { diff --git a/src/pages/home/HomeContent.tsx b/src/pages/home/HomeContent.tsx index fb84a50..f528f0f 100644 --- a/src/pages/home/HomeContent.tsx +++ b/src/pages/home/HomeContent.tsx @@ -239,10 +239,7 @@ export function HomeContent() { {/* 右分隔条 */}
setHoveredDivider('right')} onMouseLeave={() => setHoveredDivider(null)} > @@ -261,7 +258,7 @@ export function HomeContent() {
{/* 右列 */} -
+
diff --git a/src/pages/home/components/BannerCarousel.tsx b/src/pages/home/components/BannerCarousel.tsx index af2d2b8..3654bc1 100644 --- a/src/pages/home/components/BannerCarousel.tsx +++ b/src/pages/home/components/BannerCarousel.tsx @@ -1,10 +1,16 @@ // ============================================================ // BannerCarousel — 首页顶部轮播图 // 使用 useBannerList() Hook 获取数据,组件仅负责渲染 +// +// 关闭行为: +// - 关闭后当前会话不再显示(组件内 useState 驱动) +// - 刷新/重启应用 → state 重置 → 重新展示 +// - 不做 localStorage 持久化,确保每次启动都可见 // ============================================================ -import { useRef, useMemo } from 'react'; -import { Carousel, Skeleton } from 'antd'; +import { useRef, useMemo, useState } from 'react'; +import { Carousel, Skeleton, Button } from 'antd'; +import { CloseOutlined } from '@ant-design/icons'; import type { CarouselRef } from 'antd/es/carousel'; import { useBannerList } from '@/hooks/use-api'; @@ -22,6 +28,9 @@ export function BannerCarousel({ className }: BannerCarouselProps) { const carouselRef = useRef(null); const { data: rawBanners, loading } = useBannerList(); + /** 当前会话是否已关闭轮播图(组件卸载即重置,下次启动重新展示) */ + const [dismissed, setDismissed] = useState(false); + // 过滤 + 排序(纯计算,无副作用) const banners = useMemo(() => { if (!rawBanners) return []; @@ -31,8 +40,8 @@ export function BannerCarousel({ className }: BannerCarouselProps) { .sort((a, b) => a.sort_order - b.sort_order); }, [rawBanners]); - // 无数据时不渲染 - if (!loading && banners.length === 0) { + // 用户已关闭或加载完成但无数据 → 不渲染 + if (dismissed || (!loading && banners.length === 0)) { return null; } @@ -69,6 +78,35 @@ export function BannerCarousel({ className }: BannerCarouselProps) { ))} + + {/* 关闭按钮 — 右上角悬浮,半透明底色,hover 加深 */} +