docs: 三端审计修复实施计划 Phase 3 — 6 个 Task (#12-#15)
SSE 分析 API 包装器、AI 触发按钮、家庭成员 Tab、E2E 清理夹具、统计验证
This commit is contained in:
@@ -1108,3 +1108,596 @@ git commit -m "feat(miniprogram): 个人中心添加我的预约+在线咨询入
|
||||
- [ ] 小程序验证:消息页通知 Tab 展示后端消息数据
|
||||
- [ ] 小程序验证:个人中心出现"在线咨询"入口
|
||||
- [ ] `git push` — 推送到远程
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Phase 3 — 功能闭环 (#12-#15)
|
||||
|
||||
### Task 15: SSE 分析 API 封装
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/ai/analysisSse.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 SSE 分析 API 文件**
|
||||
|
||||
创建 `apps/web/src/api/ai/analysisSse.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
|
||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary';
|
||||
|
||||
interface AnalyzeBody {
|
||||
report_id?: string;
|
||||
patient_id?: string;
|
||||
metrics?: string[];
|
||||
}
|
||||
|
||||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||
'lab-report': '/ai/analyze/lab-report',
|
||||
'trends': '/ai/analyze/trends',
|
||||
'checkup-plan': '/ai/analyze/checkup-plan',
|
||||
'report-summary': '/ai/analyze/report-summary',
|
||||
};
|
||||
|
||||
export interface SseCallbacks {
|
||||
onChunk: (content: string, index: number) => void;
|
||||
onError: (message: string) => void;
|
||||
onDone: (analysisId: string) => void;
|
||||
}
|
||||
|
||||
export async function startAnalysis(
|
||||
type: AnalysisType,
|
||||
body: AnalyzeBody,
|
||||
callbacks: SseCallbacks,
|
||||
): Promise<AbortController> {
|
||||
const controller = new AbortController();
|
||||
const endpoint = ENDPOINT_MAP[type];
|
||||
|
||||
const token = localStorage.getItem('hms-token');
|
||||
const resp = await fetch(`/api/v1${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ message: '分析请求失败' }));
|
||||
callbacks.onError(err?.message || `HTTP ${resp.status}`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError('无法读取响应流');
|
||||
return controller;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let chunkIndex = 0;
|
||||
let buffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
if (event.type === 'chunk' && event.content) {
|
||||
callbacks.onChunk(event.content, chunkIndex++);
|
||||
} else if (event.type === 'done' && event.analysis_id) {
|
||||
callbacks.onDone(event.analysis_id);
|
||||
} else if (event.type === 'error') {
|
||||
callbacks.onError(event.message || '分析出错');
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 行,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
callbacks.onError(err instanceof Error ? err.message : '连接中断');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return controller;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/api/ai/analysisSse.ts
|
||||
git commit -m "feat(web): SSE 分析 API 封装 — fetch ReadableStream 解析"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 16: AI 分析触发按钮 — 化验报告页
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/components/LabReportsTab.tsx:138-161`
|
||||
|
||||
- [ ] **Step 1: 在操作列 Space 中添加 "AI 解读" 按钮**
|
||||
|
||||
在 `LabReportsTab.tsx` 操作列(第 138-161 行)的 `<Space>` 内,在"审核"按钮之前添加:
|
||||
|
||||
```tsx
|
||||
<AuthButton code="ai.analysis.manage">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={analyzingReportId === record.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleAiAnalysis(record.id); }}
|
||||
>
|
||||
AI 解读
|
||||
</Button>
|
||||
</AuthButton>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加分析状态和处理函数**
|
||||
|
||||
在组件函数体中添加:
|
||||
|
||||
```typescript
|
||||
import { startAnalysis } from '../../../api/ai/analysisSse';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
|
||||
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
|
||||
const [analysisContent, setAnalysisContent] = useState('');
|
||||
|
||||
const handleAiAnalysis = async (reportId: string) => {
|
||||
setAnalyzingReportId(reportId);
|
||||
setAnalysisContent('');
|
||||
await startAnalysis('lab-report', { report_id: reportId }, {
|
||||
onChunk: (content) => setAnalysisContent(prev => prev + content),
|
||||
onError: (msg) => { message.error(msg); setAnalyzingReportId(null); },
|
||||
onDone: (id) => {
|
||||
message.success('AI 分析完成');
|
||||
setAnalyzingReportId(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加 AI 分析结果展示**
|
||||
|
||||
在 Table 下方添加条件渲染区域:
|
||||
|
||||
```tsx
|
||||
{analysisContent && (
|
||||
<Card title="AI 解读结果" style={{ marginTop: 16 }} size="small">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
|
||||
</Card>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/components/LabReportsTab.tsx
|
||||
git commit -m "feat(web): 化验报告页添加 AI 解读按钮 — SSE 流式分析"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 17: AI 分析触发按钮 — 患者详情页
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx`
|
||||
|
||||
- [ ] **Step 1: 在 AI 建议标签页上方添加分析触发按钮**
|
||||
|
||||
在 `PatientDetail.tsx` 的 Tabs 卡片中,AI 建议标签页(key='ai')的 children 内,或通过修改 `AiSuggestionTab` 组件添加操作按钮。
|
||||
|
||||
推荐方案:在 PatientDetail 的 tabs items 中,AI 标签页之前添加操作:
|
||||
|
||||
在 `ai` tab 的 children 中包裹一个带按钮的 Fragment:
|
||||
|
||||
```tsx
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI 建议',
|
||||
children: id ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<AuthButton code="ai.analysis.manage">
|
||||
<Button size="small" onClick={() => triggerAnalysis('trends')}>
|
||||
趋势分析
|
||||
</Button>
|
||||
<Button size="small" onClick={() => triggerAnalysis('checkup-plan')}>
|
||||
体检方案
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
<AiSuggestionTab patientId={id} />
|
||||
{analysisResult && (
|
||||
<Card title="分析结果" size="small">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisResult}</div>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
```
|
||||
|
||||
添加对应的状态和处理函数:
|
||||
|
||||
```typescript
|
||||
import { startAnalysis, type AnalysisType } from '../../api/ai/analysisSse';
|
||||
|
||||
const [analysisResult, setAnalysisResult] = useState('');
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
|
||||
const triggerAnalysis = async (type: AnalysisType) => {
|
||||
if (!id) return;
|
||||
setAnalyzing(true);
|
||||
setAnalysisResult('');
|
||||
await startAnalysis(type, { patient_id: id }, {
|
||||
onChunk: (content) => setAnalysisResult(prev => prev + content),
|
||||
onError: (msg) => { message.error(msg); setAnalyzing(false); },
|
||||
onDone: () => { message.success('分析完成'); setAnalyzing(false); },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/PatientDetail.tsx
|
||||
git commit -m "feat(web): 患者详情 AI 标签页添加趋势分析+体检方案触发按钮"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 18: 家属管理 Tab 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/components/FamilyMembersTab.tsx`
|
||||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx:228-306`
|
||||
|
||||
- [ ] **Step 1: 创建 FamilyMembersTab 组件**
|
||||
|
||||
创建 `apps/web/src/pages/health/components/FamilyMembersTab.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Table, Button, Form, Input, Select, Drawer, message, Popconfirm, Space } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { patientApi, type FamilyMember, type CreateFamilyMemberReq } from '../../../api/health/patients';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
|
||||
const RELATIONSHIP_OPTIONS = [
|
||||
{ label: '父母', value: 'parent' },
|
||||
{ label: '配偶', value: 'spouse' },
|
||||
{ label: '子女', value: 'child' },
|
||||
{ label: '兄弟姐妹', value: 'sibling' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
export function FamilyMembersTab({ patientId }: Props) {
|
||||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingMember, setEditingMember] = useState<FamilyMember | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await patientApi.listFamilyMembers(patientId);
|
||||
setMembers(data);
|
||||
} catch {
|
||||
message.error('加载家属列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [patientId]);
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
const handleSubmit = async (values: CreateFamilyMemberReq) => {
|
||||
try {
|
||||
if (editingMember) {
|
||||
await patientApi.updateFamilyMember(patientId, editingMember.id, { ...values, version: editingMember.version });
|
||||
message.success('家属信息已更新');
|
||||
} else {
|
||||
await patientApi.createFamilyMember(patientId, values);
|
||||
message.success('家属已添加');
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setEditingMember(null);
|
||||
form.resetFields();
|
||||
fetchMembers();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (memberId: string) => {
|
||||
try {
|
||||
await patientApi.deleteFamilyMember(patientId, memberId);
|
||||
message.success('已删除');
|
||||
fetchMembers();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingMember(null);
|
||||
form.resetFields();
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (member: FamilyMember) => {
|
||||
setEditingMember(member);
|
||||
form.setFieldsValue({
|
||||
name: member.name,
|
||||
relationship: member.relationship,
|
||||
phone: member.phone,
|
||||
id_number: member.id_number,
|
||||
notes: member.notes,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '关系', dataIndex: 'relationship', key: 'relationship',
|
||||
render: (v: string) => RELATIONSHIP_OPTIONS.find(r => r.value === v)?.label || v,
|
||||
},
|
||||
{ title: '电话', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '身份证号', dataIndex: 'id_number', key: 'id_number' },
|
||||
{ title: '备注', dataIndex: 'notes', key: 'notes', ellipsis: true },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 150,
|
||||
render: (_: unknown, record: FamilyMember) => (
|
||||
<Space>
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="link" size="small" onClick={() => openEdit(record)}>编辑</Button>
|
||||
</AuthButton>
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>添加家属</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
<Drawer
|
||||
title={editingMember ? '编辑家属' : '添加家属'}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); setEditingMember(null); }}
|
||||
width={400}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="relationship" label="关系" rules={[{ required: true, message: '请选择关系' }]}>
|
||||
<Select options={RELATIONSHIP_OPTIONS} placeholder="选择关系" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="id_number" label="身份证号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">{editingMember ? '更新' : '添加'}</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 PatientDetail.tsx 注册家属管理标签**
|
||||
|
||||
在 `PatientDetail.tsx` 的 Tabs items 数组中,在 "基本信息"(key='info')之后添加:
|
||||
|
||||
```typescript
|
||||
import { FamilyMembersTab } from './components/FamilyMembersTab';
|
||||
|
||||
// 在 items 数组中 key='info' 项之后添加
|
||||
{
|
||||
key: 'family',
|
||||
label: '家属管理',
|
||||
children: id ? <FamilyMembersTab patientId={id} /> : null,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/components/FamilyMembersTab.tsx apps/web/src/pages/health/PatientDetail.tsx
|
||||
git commit -m "feat(web): 家属管理 Tab — 列表+添加/编辑/删除家属"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 19: E2E 测试数据清理
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/fixtures/cleanup.ts`
|
||||
- Modify: `apps/web/e2e/flows/patient-journey.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 创建清理 fixture**
|
||||
|
||||
创建 `apps/web/e2e/fixtures/cleanup.ts`:
|
||||
|
||||
```typescript
|
||||
import type { ApiClient } from './api-client';
|
||||
|
||||
export async function cleanupE2EData(api: ApiClient): Promise<void> {
|
||||
try {
|
||||
const res = await api.get('/api/v1/health/patients?keyword=E2E');
|
||||
const patients = res?.data?.data || [];
|
||||
for (const p of patients) {
|
||||
if (p.name?.startsWith('E2E')) {
|
||||
await api.delete(`/api/v1/health/patients/${p.id}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不阻塞测试
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 patient-journey.spec.ts 中添加 afterAll**
|
||||
|
||||
在 `apps/web/e2e/flows/patient-journey.spec.ts` 中添加清理导入和 afterAll 钩子:
|
||||
|
||||
```typescript
|
||||
import { cleanupE2EData } from '../fixtures/cleanup';
|
||||
|
||||
afterAll(async () => {
|
||||
// 使用测试中已有的 api client 实例
|
||||
if (apiClient) await cleanupE2EData(apiClient);
|
||||
});
|
||||
```
|
||||
|
||||
> 对其他创建 E2E 数据的 spec 文件(follow-up-flow、appointment-flow 等)同样添加 afterAll 清理。
|
||||
|
||||
- [ ] **Step 3: 手动清理现有污染数据**
|
||||
|
||||
在数据库中执行:
|
||||
|
||||
```sql
|
||||
UPDATE patients SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE name LIKE 'E2E患者_%' AND deleted_at IS NULL;
|
||||
```
|
||||
|
||||
> 此步骤需要手动执行 SQL。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/fixtures/cleanup.ts apps/web/e2e/flows/patient-journey.spec.ts
|
||||
git commit -m "test(e2e): 添加 E2E 测试数据清理 fixture — afterAll 自动 teardown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 20: 统计仪表盘消费验证
|
||||
|
||||
**Files:**
|
||||
- Read-only verification (可能修改 `DoctorDashboard.tsx` 或 `NurseDashboard.tsx`)
|
||||
|
||||
- [ ] **Step 1: 对比后端 personal_stats DTO 与前端展示**
|
||||
|
||||
读取后端 `crates/erp-health/src/handler/points_handler.rs`(或 stats_handler.rs)中 `personal_stats` 返回的字段列表。
|
||||
|
||||
对比 `DoctorDashboard.tsx`(第 43 行起)和 `NurseDashboard.tsx`(第 20 行起)实际展示的字段。
|
||||
|
||||
记录哪些字段被展示、哪些被遗漏。
|
||||
|
||||
- [ ] **Step 2: 补充遗漏字段(如有)**
|
||||
|
||||
如果有遗漏字段,在对应仪表盘组件中补充展示。例如:
|
||||
|
||||
```tsx
|
||||
// 如果 abnormal_vital_signs 被遗漏
|
||||
<Statistic title="异常体征患者" value={personalStats.abnormal_vital_signs || 0} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交(如有修改)**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/StatisticsDashboard/
|
||||
git commit -m "feat(web): 补充统计仪表盘遗漏的个人统计指标"
|
||||
```
|
||||
|
||||
如果无遗漏则无需提交,记录验证结果即可。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 验证清单
|
||||
|
||||
完成所有 Task 后执行:
|
||||
|
||||
- [ ] `cd apps/web && pnpm build` — Web 前端构建通过
|
||||
- [ ] 浏览器验证:化验报告页"AI 解读"按钮 → SSE 流式显示分析结果
|
||||
- [ ] 浏览器验证:患者详情"趋势分析"按钮 → SSE 分析完成
|
||||
- [ ] 浏览器验证:患者详情"家属管理"Tab → 添加/编辑/删除家属
|
||||
- [ ] 浏览器验证:统计报表页所有指标正常展示
|
||||
- [ ] E2E 清理 fixture 可正常工作
|
||||
- [ ] `git push` — 推送到远程
|
||||
|
||||
---
|
||||
|
||||
## 总工作量汇总
|
||||
|
||||
| Phase | Tasks | 工作量 |
|
||||
|-------|-------|--------|
|
||||
| Phase 1 | #1-#6 (Task 1-8) | ~5h |
|
||||
| Phase 2 | #7-#11 (Task 9-14) | ~5.5h |
|
||||
| Phase 3 | #12-#15 (Task 15-20) | ~9h |
|
||||
| **总计** | **15 项修复 / 20 个 Task** | **~19.5h** |
|
||||
|
||||
Reference in New Issue
Block a user