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:
164
ARCHITECTURE.md
164
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();
|
||||
└─ <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 || '任务提交失败');
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user