diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c6b49ef..8acac99 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 船长 · HeiXiu — 前端架构文档 -> 最后更新:2026-06-09 +> 最后更新:2026-06-12 ## 目录结构 @@ -14,6 +14,8 @@ src/ ├── contexts/ # React Context 定义 + Consumer Hook │ └── app-context.ts # AppContextValue 类型 + useAppContext() ├── hooks/ # 通用 Hook +│ ├── use-async.ts # 通用异步数据 Hook(useAsyncData / useAsyncMutation) +│ ├── use-api.ts # 业务 API Hook 集中管理(Banner/Model/Task/Auth/Billing) │ ├── use-theme.ts # 主题切换 │ └── use-updater.ts # 版本更新 ├── pages/ # 页面 @@ -213,6 +215,49 @@ App 启动 → AppProvider useEffect > 只有 RefreshError(refresh 接口返回 401)才确认 token 真的过期。 ``` +### 3.2 主动刷新定时器(v0.0.20) + +除被动 401 刷新外,系统通过 JWT `exp` 声明主动续期,避免长时间空闲后 access_token 过期: + +``` +登录/刷新成功 + → scheduleProactiveRefresh(access_token) + → 解码 JWT exp 声明 + → setTimeout(delay = max(60s, remaining * 0.75)) + → 到期触发 → getStoredRefreshToken() + → AuthAPI.refreshToken() + ✅ 成功 → 更新双 token → 递归安排下一次刷新 + ❌ 失败 → 静默(等下次 401 走被动刷新) +``` + +- 主动刷新用 JWT 解码(`atob(token.split('.')[1])`)获取 `exp`,不依赖服务端 `expires_in` +- 在 75% 过期时间点触发,确保在被动 401 之前完成续期 +- 至少间隔 60s(防止 token 极短时频繁刷新) +- 退出登录时 `clearProactiveRefresh()` 清除定时器 + +### 3.3 刷新失败分级处理(v0.0.20) + +`refreshHandler` 通过 Promise 状态区分失败级别,`handle401` 据此决定是否登出: + +| 失败类型 | refreshHandler 行为 | handle401 响应 | +|---------|-------------------|---------------| +| refresh_token 过期/无效 | **throw** `RefreshTokenExpiredError` | 清 token + emit AUTH_REQUIRED → 弹登录 | +| 网络超时/无连接 | **return null** | 仅拒绝当前请求,保留 token → 下次再试 | + +**关键改进**:临时网络故障不再误判为"需要重新登录"。用户在网络恢复后重试操作即可,无需手动登录。 + +``` +handle401 内部: + refreshHandler() + .then(newToken => + newToken → ✅ 用新 token 重试 + null → ⚠️ 临时失败,拒绝但不登出(processQueue 失败,但 clearToken 不调用) + ) + .catch(_err → + ❌ 永久失败 → clearToken + emit(AUTH_REQUIRED) + 弹登录 + ) +``` + ### 4. 退出登录 ``` @@ -382,3 +427,120 @@ export class AuthAPI { - Mac 命令执行:`spawnSync` + 5s 超时,`ioreg`/`sysctl` 始终可用 - 共享同一套 `SHA256(normalizeIdentifier(combined))` 管线 - `getMachineCode()` 跨平台统一接口,自动选择最优采集方式 + +--- + +## 任务状态轮询(v0.0.20) + +### 架构:两层轮询 + Context 桥接 + +``` +TaskHistory (列表页) + ├─ useTaskPolling(tasks, onTerminal) ← 批量轮询 batchTaskStatus + │ └─ 仅轮询 POLLING_STATUSES 中的非终态任务 + │ └─ 轮询结果合并到 polledTasks → dataSource + │ └─ 某任务变为终态 → onTerminal → setRefreshTick(+1) → 完整列表刷新 + │ + └─ useEffect: polledTasks 变化 → 同步 selectedTask 到 Context + +OutputPreview (详情面板) + └─ useEffect: 监听 selectedTask.status 变化 + └─ 非终态→终态 → useTaskDetail.refetch() ← 仅触发详情刷新,不独立轮询 +``` + +**设计原则**: +- 批量轮询负责广度(列表视图状态更新),详情刷新负责深度(终态后获取 output_assets) +- OutputPreview **不独立调用** `getTaskStatus`,避免与批量轮询重复请求 +- 两者通过 `HomeContext.selectedTask` 桥接,不直接通信 + +### 轮询实现细节 + +| 特性 | 实现 | +|------|------| +| 调度方式 | 递归 `setTimeout`(非 `setInterval`),避免请求堆积 | +| 间隔控制 | 服务端 `next_poll_ms`,本地 clamp 2s~30s | +| 错误重试 | 请求失败后 10s 重试 | +| 自动停止 | 所有非终态任务变为终态后停止轮询 | +| 闭包安全 | `mergedRef.current` 确保递归回调读到最新任务列表 | +| 非终态常量 | `POLLING_STATUSES = ['pending', 'submitted', 'processing']` 统一管理 | + +### GET 数组参数序列化 + +axios 默认 `paramsSerializer` 对数组使用 `key[]=v1&key[]=v2`(PHP/Rack bracket 风格),后端不识别。修复方案: + +```typescript +// ❌ 错误:axios 默认序列化为 task_ids[]=v1&task_ids[]=v2 +get(url, { task_ids: ['id1', 'id2'] }) + +// ✅ 正确:逗号拼接为 task_ids=id1,id2(跨框架兼容) +get(url, { task_ids: params.task_ids.join(',') }) +``` + +--- + +## 文件上传(v0.0.20) + +### customRequest 集成模式 + +antd Upload 组件通过 `action` 属性使用自有的 XMLHttpRequest,不走 axios 拦截器,导致 Token 缺失。解决方案: + +``` +useFileUpload() hook + └─ 返回 { customRequest } — 兼容 antd Upload.customRequest 签名 + └─ 内部调用 request.upload(url, formData, onProgress) + └─ 复用 axios 实例:Token 注入、超时、错误处理、业务码校验 +``` + +``` +ImageInput / ImageListInput / AudioListInput + └─ const { customRequest } = useFileUpload(); + └─ + └─ beforeUpload 校验通过 → 返回 true → 放行至 customRequest + └─ customRequest 调用 request.upload() → 上传到 /api/v1/upload + └─ 成功后 antd 将 response 写入 file.response + └─ ModelInputForm 提交时读取 f.response?.url +``` + +### beforeUpload 语义 + +| 返回值 | 含义 | +|-------|------| +| `true` | 校验通过,交由 customRequest 实际上传 | +| `false` | 阻止上传(文件仅加入列表,不发起请求) | +| `Upload.LIST_IGNORE` | 校验失败,文件不加入列表 | + +--- + +## 表单提交防抖(v0.0.20) + +### 两级防护 + +``` +handleSubmit() + ├─ 1. 防抖节流(2s) + │ └─ lastClickTimeRef: now - last < 2s → "你点击的太快了" + │ + ├─ 2. 二次确认 + │ └─ submitCountRef > 0 → Modal.confirm("您已提交,确认要再次提交吗?") + │ └─ 确认 → lastClickTimeRef = 0(重置防抖)→ doSubmit() + │ + └─ 3. 执行提交 + └─ doSubmit(): form.validateFields() → submitTask() → 检查返回值 +``` + +### useRef 而非 useState + +`lastClickTimeRef` 和 `submitCountRef` 使用 `useRef` 而非 `useState`,避免每次点击触发不必要的组件重渲染。 + +### useAsyncMutation 返回值判空 + +`useAsyncMutation.execute()` 内部 catch 所有错误并返回 `undefined`(不 throw)。调用方应检查返回值而非 `try/catch`: + +```typescript +const result = await submitTask({...}); +if (result !== undefined) { + submitCountRef.current += 1; // 成功 +} else { + message.error(errorMessageRef.current || '任务提交失败'); +} +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae28b1..c54cec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- +## 0.0.20(2026-06-12) + +**任务状态轮询 + Token 刷新容错增强 + 提交防抖 + 文件上传 Hook** + +### 任务状态轮询 + +- 新增 `useTaskPolling` hook:批量轮询非终态任务状态,递归 `setTimeout` + `next_poll_ms` 自适应间隔 +- `TaskHistory` 集成批量轮询,`OutputPreview` 改为监听终态转换(Context 桥接)触发 `refetch()`,消除重复请求 +- `POLLING_STATUSES` 统一管理非终态状态,所有轮询逻辑引用此常量 +- `batchTaskStatus` 使用 GET + 逗号拼接 `task_ids=a,b,c`,修复 axios 默认 bracket 序列化 + +### Token 刷新容错增强 + +- **主动刷新**:JWT `exp` 解码驱动定时器,在 75% 过期点通过 `setTimeout` 提前续期 +- **失败分级**:`refreshHandler` 永久失败(refresh_token 过期)→ **throw** → 清 token 弹登录;临时失败(网络超时)→ **return null** → 仅拒绝当前请求不登出 +- 新增 `RefreshTokenExpiredError` 类区分失败类型 +- `AppProvider` 登录/自动登录/退出 启停主动刷新定时器 + +### 提交按钮防抖 + +- 两级防护:2s 节流防抖("你点击的太快了")+ 二次确认弹窗("您已提交,确认要再次提交吗?") +- `doSubmit` 抽取为 `useCallback`,`handleSubmit` 负责门控 +- 修复 `useAsyncMutation.execute` 返回值判空(原 `try/catch` 为死代码) + +### 文件上传 Hook + +- 新增 `useFileUpload` hook:封装 `request.upload()` 为 antd Upload 兼容 `customRequest`,复用全局拦截器 +- `ImageInput` / `ImageListInput` / `AudioListInput` 集成 `customRequest` +- `uploadValidation.beforeUpload` 返回值修正:`false` → `true`(校验通过后放行至 `customRequest`) + +### 其他 + +- `AnnouncementDrawer` 接入公告 API + `EnumResolver` +- billing / announcement / banner 服务模块完善 +- 文档:ARCHITECTURE.md 新增轮询/Token刷新/上传/防抖章节 + +--- + ## 0.0.19(2026-06-11) **数据获取架构重构 + Mac 指纹采集 + Token 刷新容错 + 账单页面** diff --git a/TODO.md b/TODO.md index 9d8d3a7..aeadd34 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO — 船长·HeiXiu 待办与优化清单 -> 最后更新:2026-06-09 +> 最后更新:2026-06-12 --- @@ -30,10 +30,17 @@ ### 公告 +- [x] 公告数据获取与展示(AnnouncementDrawer + fetchAnnouncements API) - [ ] 公告已读/未读状态 - [ ] 公告红点/角标提醒(有新公告时导航栏图标变化) - [ ] 公告缓存(减少重复请求) +### 任务 + +- [x] 任务状态轮询(批量轮询 + Context 桥接 + 自适应间隔) +- [ ] 任务列表导出 CSV 功能完善 +- [ ] 任务重试/重新提交 + --- ## 🟢 工程优化(优先级低) @@ -75,10 +82,12 @@ ## 📋 下次开发计划 -1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) -2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) -3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 +1. **任务列表导出 CSV**(`task.ts` 已定义 `exportTaskCSV` API,前端未对接下载按钮) +2. **文件上传端点确认**(`/api/v1/upload` 待后端确认,当前 useFileUpload 已完成前端集成) +3. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) +4. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) +5. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现) -4. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) -5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 +6. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) +7. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效)