docs: 更新架构文档、版本日志、TODO清单 (0.0.20)

- ARCHITECTURE.md: 新增任务轮询/文件上传/提交防抖/主动刷新定时器/失败分级五节,更新 hooks 目录结构,日期→06-12
- CHANGELOG.md: 新增 0.0.20 版本条目
- TODO.md: 标记公告接入/任务轮询为已完成,新增任务CSV导出/上传端点确认待办
- CLAUDE.md 保持不动(按规则不修改)
This commit is contained in:
2026-06-12 19:14:39 +08:00
parent 9e8c5b7109
commit 54d12aaf26
3 changed files with 216 additions and 7 deletions

View File

@@ -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 # 通用异步数据 HookuseAsyncData / 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
> 只有 RefreshErrorrefresh 接口返回 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();
└─ <Upload customRequest={customRequest} beforeUpload={...} />
└─ 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 || '任务提交失败');
}
```