Compare commits
1 Commits
v1.2.4
...
545aa2448b
| Author | SHA1 | Date | |
|---|---|---|---|
| 545aa2448b |
@@ -67,9 +67,9 @@ export default function ReportEditor() {
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const contentLoadedRef = useRef(false);
|
||||
const contentRef = useRef('');
|
||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab });
|
||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
|
||||
|
||||
stateRef.current = { reportData, videos, capturedFrames, activeTab };
|
||||
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
|
||||
|
||||
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
||||
|
||||
@@ -83,12 +83,12 @@ export default function ReportEditor() {
|
||||
};
|
||||
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
const key = user ? `reportEditorDraft_${user.username}` : '';
|
||||
if (key) {
|
||||
storage.set(key, {
|
||||
content: contentRef.current,
|
||||
loadedTemplateId,
|
||||
draftReportId: reportId || null,
|
||||
...stateRef.current
|
||||
});
|
||||
@@ -398,6 +398,7 @@ export default function ReportEditor() {
|
||||
}));
|
||||
const combined = [...videos, ...newVideos];
|
||||
setVideos(combined);
|
||||
stateRef.current = { ...stateRef.current, videos: combined };
|
||||
setCurrentVideoIndex(videos.length); // select first newly uploaded video
|
||||
if (videoInputRef.current) videoInputRef.current.value = '';
|
||||
saveDraftToStorage();
|
||||
@@ -407,15 +408,18 @@ export default function ReportEditor() {
|
||||
const idx = videos.findIndex(v => v.id === id);
|
||||
const updated = videos.filter(v => v.id !== id);
|
||||
setVideos(updated);
|
||||
stateRef.current = { ...stateRef.current, videos: updated };
|
||||
if (currentVideoIndex >= updated.length) {
|
||||
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
|
||||
} else if (currentVideoIndex === idx && updated.length > 0) {
|
||||
setCurrentVideoIndex(0);
|
||||
}
|
||||
setCapturedFrames(prev => prev.filter(f => f.videoIndex !== idx).map(f => {
|
||||
const nextFrames = capturedFrames.filter(f => f.videoIndex !== idx).map(f => {
|
||||
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
|
||||
return f;
|
||||
}).sort((a, b) => a.time - b.time));
|
||||
}).sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
@@ -449,7 +453,9 @@ export default function ReportEditor() {
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||
isManual: true
|
||||
};
|
||||
setCapturedFrames(prev => [...prev, newFrame].sort((a, b) => a.time - b.time));
|
||||
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
@@ -482,7 +488,7 @@ export default function ReportEditor() {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
newFrames.push({
|
||||
const newFrame: CapturedFrame = {
|
||||
id: Date.now() + Math.random(),
|
||||
videoIndex: currentVideoIndex,
|
||||
videoName: videos[currentVideoIndex].name,
|
||||
@@ -490,9 +496,25 @@ export default function ReportEditor() {
|
||||
timeFormatted: formatTime(time),
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||
isManual: false
|
||||
});
|
||||
};
|
||||
newFrames.push(newFrame);
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
}
|
||||
}
|
||||
}
|
||||
setCapturedFrames(prev => [...prev, ...newFrames].sort((a, b) => a.time - b.time));
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
if (wasPlaying) video.play();
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@ export default function SystemSettings() {
|
||||
apiEndpoint: '',
|
||||
apiKey: '',
|
||||
defaultTemplate: '',
|
||||
frameMode: 'uniform'
|
||||
frameMode: 'uniform',
|
||||
autoInsertFrames: false
|
||||
});
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
@@ -172,6 +173,18 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoInsertFrames"
|
||||
checked={settings.autoInsertFrames || false}
|
||||
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
|
||||
className="w-4 h-4 accent-accent cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer">开启自动帧插入</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">开启后,视频自动抽帧的每一帧将按顺序自动插入到报告的空置图片占位符中,插满后不再提示。</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">抽帧位置百分比 (%)</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
@@ -191,6 +204,11 @@ export default function SystemSettings() {
|
||||
className="input-minimal w-full pr-6 text-center"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
|
||||
{settings.autoInsertFrames && (
|
||||
<span className="absolute bottom-0.5 right-1 text-green-500 pointer-events-none">
|
||||
<Check size={10} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -75,4 +75,5 @@ export interface SystemSettings {
|
||||
apiKey: string;
|
||||
defaultTemplate?: string;
|
||||
frameMode?: 'uniform' | 'keep';
|
||||
autoInsertFrames?: boolean;
|
||||
}
|
||||
|
||||
191
工程分析/实现方案-2026-04-16-17-40-20.md
Normal file
191
工程分析/实现方案-2026-04-16-17-40-20.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 实现方案 — 2026-04-16-17-40-20
|
||||
|
||||
## 技术思路
|
||||
|
||||
本次需求包含三个相互关联的修复/优化点:
|
||||
1. **自动抽帧图片在页面切换后丢失**:根因在于 `saveDraftToStorage` 依赖的 `stateRef.current` 在 `setCapturedFrames` 异步更新后未及时同步,导致组件卸载前保存的 draft 中缺失自动帧。
|
||||
2. **编辑后切换界面模板选择器显示"无"**:根因是 `loadedTemplateId` 未纳入 `stateRef.current`,且 `saveDraftToStorage` 因 stale closure 捕获了过时的 `loadedTemplateId` 值(空字符串)。
|
||||
3. **自动帧插入开关**:在 `SystemSettings` 中新增 `autoInsertFrames` 布尔开关,并在 `autoCaptureFrames` 中支持自动将新生成的帧填充到第一个空置占位符。
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `src/types.ts` | 修改 | `SystemSettings` 增加 `autoInsertFrames?: boolean` |
|
||||
| `src/pages/SystemSettings.tsx` | 修改 | 新增"开启自动帧插入"开关及抽帧位置对勾标记 |
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | 修复 `stateRef` 同步逻辑、自动帧插入实现 |
|
||||
|
||||
---
|
||||
|
||||
## 关键代码变更说明
|
||||
|
||||
### 1. `src/types.ts` — 扩展类型
|
||||
|
||||
```tsx
|
||||
export interface SystemSettings {
|
||||
frameCount: number;
|
||||
framePositions: number[];
|
||||
apiEndpoint: string;
|
||||
apiKey: string;
|
||||
defaultTemplate?: string;
|
||||
frameMode?: 'uniform' | 'keep';
|
||||
autoInsertFrames?: boolean; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/pages/SystemSettings.tsx` — 新增开关与 UI 标记
|
||||
|
||||
在"视频抽帧配置"区域(`framePositions` 列表上方)增加开关:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoInsertFrames"
|
||||
checked={settings.autoInsertFrames || false}
|
||||
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
|
||||
className="w-4 h-4 accent-accent cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer">开启自动帧插入</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">开启后,视频自动抽帧的每一帧将按顺序自动插入到报告的空置图片占位符中,插满后不再提示。</p>
|
||||
```
|
||||
|
||||
在 `settings.framePositions` 的每个 input 框中,当 `autoInsertFrames` 开启时显示绿色对勾(`Check` 图标):
|
||||
|
||||
```tsx
|
||||
{settings.autoInsertFrames && (
|
||||
<span className="absolute bottom-0.5 right-1 text-green-500 pointer-events-none">
|
||||
<Check size={10} />
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
注意:需确保 `Check` 图标已从 `lucide-react` 导入(当前页面已导入)。
|
||||
|
||||
### 3. `src/pages/ReportEditor.tsx` — 修复状态同步与自动插入
|
||||
|
||||
#### 3.1 将 `loadedTemplateId` 纳入 `stateRef`
|
||||
|
||||
修改第 72 行附近的 ref 定义:
|
||||
|
||||
```tsx
|
||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
|
||||
// ...
|
||||
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
|
||||
```
|
||||
|
||||
#### 3.2 精简 `saveDraftToStorage`
|
||||
|
||||
移除独立的 `loadedTemplateId` 字段(已由 `stateRef.current` 包含),并加入 `syncStateToRef`:
|
||||
|
||||
```tsx
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
const key = user ? `reportEditorDraft_${user.username}` : '';
|
||||
if (key) {
|
||||
storage.set(key, {
|
||||
content: contentRef.current,
|
||||
draftReportId: reportId || null,
|
||||
...stateRef.current
|
||||
});
|
||||
}
|
||||
}, [reportId, loadedTemplateId]);
|
||||
```
|
||||
|
||||
> 保留 `loadedTemplateId` 在依赖数组中无实际副作用,但可确保 lint 不报警。
|
||||
|
||||
#### 3.3 修复 `autoCaptureFrames` 中 `capturedFrames` 不同步问题
|
||||
|
||||
在 `autoCaptureFrames` 函数中,生成所有帧后、调用 `saveDraftToStorage` 前,手动将最新帧列表同步到 `stateRef.current`:
|
||||
|
||||
```tsx
|
||||
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
if (wasPlaying) video.play();
|
||||
saveDraftToStorage();
|
||||
```
|
||||
|
||||
同时,若开启了 `autoInsertFrames`,在循环中每生成一帧立即尝试插入第一个空置占位符(不触发 save):
|
||||
|
||||
```tsx
|
||||
for (const pos of positions) {
|
||||
... // 生成 frame
|
||||
newFrames.push(newFrame);
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
}
|
||||
}
|
||||
}
|
||||
// 循环结束后统一更新 contentRef 与 save
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
const nextFrames = [...capturedFrames, ...newFrames].sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
if (wasPlaying) video.play();
|
||||
saveDraftToStorage();
|
||||
```
|
||||
|
||||
#### 3.4 修复 `captureFrame` 中状态同步
|
||||
|
||||
```tsx
|
||||
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
|
||||
setCapturedFrames(nextFrames);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
|
||||
saveDraftToStorage();
|
||||
```
|
||||
|
||||
#### 3.5 修复 `removeVideo` 中状态同步
|
||||
|
||||
```tsx
|
||||
setVideos(updated);
|
||||
stateRef.current = { ...stateRef.current, videos: updated };
|
||||
// ... 调整 currentVideoIndex 后
|
||||
const nextCaptured = prev => prev.filter(...).map(...).sort(...);
|
||||
setCapturedFrames(nextCaptured);
|
||||
stateRef.current = { ...stateRef.current, capturedFrames: nextCaptured };
|
||||
saveDraftToStorage();
|
||||
```
|
||||
|
||||
#### 3.6 修复 `handleVideoUpload` 中状态同步
|
||||
|
||||
```tsx
|
||||
setVideos(combined);
|
||||
stateRef.current = { ...stateRef.current, videos: combined };
|
||||
setCurrentVideoIndex(videos.length);
|
||||
if (videoInputRef.current) videoInputRef.current.value = '';
|
||||
saveDraftToStorage();
|
||||
```
|
||||
|
||||
#### 3.7 恢复 draft 时 `loadedTemplateId` 读取调整
|
||||
|
||||
由于 `loadedTemplateId` 现在已通过 `stateRef.current` 保存到 draft 中(键名为 `loadedTemplateId`),恢复逻辑中的 `setLoadedTemplateId(draft.loadedTemplateId || '')` 保持不变即可。但因为 `saveDraftToStorage` 每次调用前都会同步 `stateRef.current`,draft 中的 `loadedTemplateId` 将始终是最新的。
|
||||
|
||||
---
|
||||
|
||||
## 风险点及应对策略
|
||||
|
||||
| 风险 | 影响 | 应对策略 |
|
||||
|------|------|----------|
|
||||
| `autoInsertFrames` 开启时循环内频繁写 DOM | 性能轻微下降 | 循环内只修改 DOM,不调用 `saveDraftToStorage`,统一在循环结束后保存 |
|
||||
| `stateRef.current` 手动同步遗漏其他 setState 点 | 其他状态仍可能丢失 | 本次重点修复 `capturedFrames`、`videos`、`loadedTemplateId` 三个关键字段;`reportData` 和 `activeTab` 在现有代码中已在事件处理内手动同步到 `stateRef` |
|
||||
| `Check` 图标与现有 % 符号/删除按钮重叠 | 布局错乱 | 将 Check 放在 input 框内右下角 (`bottom-0.5 right-1`),不与居中 % 符号重叠 |
|
||||
| 旧 `systemSettings` 没有 `autoInsertFrames` 字段 | 读取 undefined | 所有访问点使用 `settings.autoInsertFrames || false` 兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 改动范围总结
|
||||
- 三个文件修改,不引入新依赖。
|
||||
- 自动帧插入默认关闭,不影响现有用户体验。
|
||||
130
工程分析/测试方案-2026-04-16-17-40-20.md
Normal file
130
工程分析/测试方案-2026-04-16-17-40-20.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 测试方案 — 2026-04-16-17-40-20
|
||||
|
||||
## 测试环境准备
|
||||
1. 项目依赖已安装,构建通过。
|
||||
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
|
||||
3. 确保系统设置中已配置默认模板,且已存在至少一个报告模板。
|
||||
|
||||
---
|
||||
|
||||
## 测试项清单
|
||||
|
||||
### 测试项 A:自动抽帧图片在切换页面后不丢失
|
||||
**测试步骤**:
|
||||
1. 进入 **图文报告生成** 页面,上传一个手术视频。
|
||||
2. 等待系统自动抽帧完成(关键帧列表中出现多个自动帧)。
|
||||
3. 切换到 **报告管理**(`/report-manage`)页面。
|
||||
4. 通过左侧菜单再次进入 **图文报告生成** 页面。
|
||||
|
||||
**预期结果**:
|
||||
- 视频分析面板中,刚才自动抽取的所有关键帧图片**全部保留**,没有丢失。
|
||||
- 手动截取的帧(如果有)也一并保留。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 B:手动抽帧图片在切换页面后保留
|
||||
**测试步骤**:
|
||||
1. 在视频分析面板中点击 **"截取当前帧"** 按钮,手动截取一帧。
|
||||
2. 切换到其他页面(如 `/dashboard`)。
|
||||
3. 返回 **图文报告生成** 页面。
|
||||
|
||||
**预期结果**:
|
||||
- 手动截取的帧**仍然存在于关键帧列表中**。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 C:编辑报告后切换界面,模板选择器不显示"无"
|
||||
**测试步骤**:
|
||||
1. 进入 **图文报告生成** 页面(新建报告)。
|
||||
2. 确认顶部模板选择器显示了默认模板名称(如 "腹腔镜胆囊切除术报告")。
|
||||
3. 在编辑器中随意输入几个字或修改基本信息。
|
||||
4. 切换到 **报告管理** 页面。
|
||||
5. 再次进入 **图文报告生成** 页面。
|
||||
|
||||
**预期结果**:
|
||||
- 模板选择器**仍然显示默认模板名称**,**不显示"无"**。
|
||||
- 编辑器内容恢复为刚才编辑的状态。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 D:编辑已有报告后切换界面,模板选择器状态正确
|
||||
**测试步骤**:
|
||||
1. 在 **报告管理** 中打开一份已有报告进行编辑。
|
||||
2. 在编辑器中修改内容。
|
||||
3. 切换到其他页面后再返回该报告编辑页。
|
||||
|
||||
**预期结果**:
|
||||
- 报告内容正确恢复。
|
||||
- 若该报告是从模板创建的,"当前模板"应显示对应模板名称;若不是从模板创建,显示"无"也属正常。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 E:system-settings 新增"开启自动帧插入"开关
|
||||
**测试步骤**:
|
||||
1. 进入 **系统设置** 页面。
|
||||
2. 观察 **视频抽帧配置** 区域。
|
||||
|
||||
**预期结果**:
|
||||
- 存在一个 **"开启自动帧插入"** 的复选框开关,默认未勾选。
|
||||
- 勾选后,每个 **抽帧位置百分比** 输入框的右下角(或旁边)显示一个 **绿色对勾(✓)** 标记。
|
||||
- 取消勾选后,绿色对勾标记消失。
|
||||
- 点击 **保存全局配置** 后刷新页面,开关状态正确保持。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 F:开启自动帧插入后,自动抽帧会按顺序填充占位符
|
||||
**测试步骤**:
|
||||
1. 在 **系统设置** 中勾选 **"开启自动帧插入"**,保存。
|
||||
2. 进入 **图文报告生成** 页面(新建报告),确认编辑器中有多个空置 `image-placeholder`。
|
||||
3. 在视频分析面板中上传一个手术视频,等待自动抽帧完成。
|
||||
|
||||
**预期结果**:
|
||||
- 自动抽取的每一帧会**按从前到后的顺序**,依次填充到编辑器中的空置占位符里。
|
||||
- 如果占位符数量多于自动帧数量,剩余占位符保持空置。
|
||||
- 自动插入过程中**没有任何弹窗提示**。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 G:自动帧插入满后静默处理(无弹窗)
|
||||
**测试步骤**:
|
||||
1. 确保 **开启自动帧插入** 已打开。
|
||||
2. 新建报告,编辑器中只有 1 个或少量空置占位符。
|
||||
3. 上传视频,等待自动抽帧(通常会产生 12 帧或更多)。
|
||||
|
||||
**预期结果**:
|
||||
- 前几个空置占位符被自动填充。
|
||||
- 当所有占位符都填满后,剩余的自动帧**仅在关键帧列表中显示**,不再尝试插入。
|
||||
- **全程不出现** "没有可插入图片的空位" 的 alert 提示。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 H:关闭自动帧插入后行为回退
|
||||
**测试步骤**:
|
||||
1. 在 **系统设置** 中**取消勾选** "开启自动帧插入",保存。
|
||||
2. 进入 **图文报告生成** 页面,上传视频等待自动抽帧。
|
||||
|
||||
**预期结果**:
|
||||
- 自动抽帧完成后,编辑器中的占位符**全部保持空置**。
|
||||
- 用户需要通过手动拖拽或点击 "插入" 按钮来填充占位符。
|
||||
|
||||
---
|
||||
|
||||
### 测试项 I:构建与类型检查回归
|
||||
**测试步骤**:
|
||||
1. 在项目根目录执行:
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- `npm run lint` 无 TypeScript 编译错误。
|
||||
- `npm run build` 构建成功。
|
||||
|
||||
---
|
||||
|
||||
## 回归验证范围
|
||||
- [ ] 拖拽插入、手动截取、删除关键帧功能正常。
|
||||
- [ ] 报告保存(草稿/完成)功能正常。
|
||||
- [ ] 编辑器打印、模板加载功能正常。
|
||||
- [ ] 其他系统设置项(API 地址、默认模板等)保存和读取正常。
|
||||
27
工程分析/经验记录.md
27
工程分析/经验记录.md
@@ -83,3 +83,30 @@
|
||||
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
||||
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
||||
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
||||
|
||||
---
|
||||
|
||||
## 记录 5:自动抽帧丢失、模板选择器变"无"与自动帧插入开关
|
||||
|
||||
**A. 具体问题**
|
||||
用户反馈三个关联问题:
|
||||
1. 从其他页面返回 `/report-editor` 后,自动抽帧的图片全部丢失。
|
||||
2. 编辑报告后切换界面再返回,"当前模板"显示为"无"。
|
||||
3. 希望 system-settings 增加"开启自动帧插入"开关,让自动抽帧直接填充占位符。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **自动抽帧丢失**:`autoCaptureFrames`、`captureFrame`、`removeVideo`、`handleVideoUpload` 中调用 `setCapturedFrames`/`setVideos` 后立即调用 `saveDraftToStorage`,但 `stateRef.current` 中的 `capturedFrames` 和 `videos` 仍是旧值(React 的 setState 是异步的)。因此组件卸载前保存的 draft 里缺少新生成的自动帧。
|
||||
2. **模板选择器变"无"**:`saveDraftToStorage` 使用 `useCallback` 捕获了旧的 `loadedTemplateId`(stale closure)。编辑报告时 `loadedTemplateId` 未通过 `stateRef.current` 同步,导致 draft 中的 `loadedTemplateId` 为空字符串,恢复后模板选择器显示"无"。
|
||||
3. **自动帧插入需求**:用户希望减少手动拖拽操作,提升效率。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修复状态同步**:将 `loadedTemplateId` 纳入 `stateRef.current`,并在 `saveDraftToStorage` 调用前先强制同步 `stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId }`。同时,在所有修改 `videos` 和 `capturedFrames` 的函数(`handleVideoUpload`、`removeVideo`、`captureFrame`、`autoCaptureFrames`)中,手动将最新状态同步到 `stateRef.current` 后再 `saveDraftToStorage`。
|
||||
2. **新增自动帧插入开关**:
|
||||
- `SystemSettings` 类型增加 `autoInsertFrames?: boolean`。
|
||||
- `SystemSettings.tsx` 增加 checkbox 开关,并在每个 `framePositions` 百分比旁显示绿色 `Check` 对勾。
|
||||
- `ReportEditor.tsx` 的 `autoCaptureFrames` 中,每生成一帧后若开关开启,则查找第一个 `.image-placeholder:not(.has-image)` 并填充图片;若找不到则静默跳过,不弹窗。循环结束后统一更新 `contentRef.current` 并保存草稿。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当多个状态需要同时持久化到 localStorage(如通过 draft 机制),应避免依赖异步 setState 后的自动同步。在保存前手动将最新计算值同步到 ref 或直接在保存函数中传入最新值,是更可靠的做法。
|
||||
- `useCallback` 的依赖数组虽然能触发重新创建函数,但函数体内部若同时读取 state 和 ref,仍可能出现 stale closure。将可变状态统一收敛到 ref 中,并在保存前强制刷新 ref,可有效避免此类问题。
|
||||
- 新增业务开关时,类型定义、设置页面、消费逻辑三个点必须同步修改,并在 UI 上给予用户明确的视觉反馈(如此次的绿色对勾标记)。
|
||||
|
||||
50
工程分析/需求分析-2026-04-16-17-40-20.md
Normal file
50
工程分析/需求分析-2026-04-16-17-40-20.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析 — 2026-04-16-17-40-20
|
||||
|
||||
## 需求背景
|
||||
用户在持续使用 report-editor 过程中发现了三个关联问题/优化点,涉及草稿恢复、模板状态显示以及视频自动抽帧后的自动插入体验。
|
||||
|
||||
## 功能目标
|
||||
|
||||
### 需求 A:自动抽帧图片在切换页面后丢失
|
||||
**现状**:从 `/report-manage` 等其他界面进入 `/report-editor` 后,**视频分析 - 关键帧摘取** 中自动摘取的图片全部丢失,但手动摘取的可能保留。
|
||||
|
||||
**期望**:无论是自动抽帧还是手动截取的图片,在用户切换界面后重新进入 report-editor 时都应正确保留。
|
||||
|
||||
**初步分析**:
|
||||
- `saveDraftToStorage` 在组件卸载时会保存 `capturedFrames`,但 `autoCaptureFrames` 中生成的帧 `isManual` 为 `false`。
|
||||
- 在恢复草稿时,条件可能过度过滤了 `isManual === false` 的帧,或者在某个 effect 中清除了自动帧。
|
||||
- 需要审阅 `ReportEditor.tsx` 中所有涉及 `capturedFrames` 赋值/过滤的代码路径,尤其是 `isManual` 字段的处理。
|
||||
|
||||
### 需求 B:编辑报告后切换界面,模板选择器显示"无"
|
||||
**现状**:如果用户在 report-editor 中**编辑了报告内容**(如输入文字、插入图片),然后切换到其他界面再返回,顶部 **"当前模板(及重置模板):"** 会显示为 **"无"**。但如果不编辑直接进去,能正确显示原模板名称。
|
||||
|
||||
**期望**:无论从何种状态返回 report-editor,模板选择器都应正确反映当前加载的模板(或默认模板),不应因编辑过内容而变为"无"。
|
||||
|
||||
**初步分析**:
|
||||
- 之前修复空白模板时,在 `saveDraftToStorage` 中追加了 `loadedTemplateId`,但可能在**编辑已有报告**(`reportId` 存在)的场景下,`loadedTemplateId` 未被正确写入草稿,或恢复时未正确读取。
|
||||
- 编辑已有报告时,报告的 `content` 来自已有报告而非模板,`loadedTemplateId` 可能为空,导致恢复草稿后模板选择器显示"无"。
|
||||
- 另一种可能是:编辑已有报告后,`loadedTemplateId` 在 saveDraft 时被覆盖了,但恢复时该字段未同步。
|
||||
|
||||
### 需求 C:system-settings 增加"开启自动帧插入"选项
|
||||
**现状**:视频上传后系统按百分比自动抽帧,但用户需要手动(拖拽或点击"插入")将每个关键帧放入报告占位符中。
|
||||
|
||||
**期望**:
|
||||
1. 在 **系统设置 - 视频抽帧配置** 中增加一个 **"开启自动帧插入"** 的开关(如 Checkbox 或 Toggle)。
|
||||
2. 开启后,在 **抽帧位置百分比 (%)** 列表中,每个百分比数字的下方或旁边显示一个 **对勾(✓)** 或其他标记,表示该位置抽取的帧将**自动按顺序插入**到报告的空置 `image-placeholder` 中。
|
||||
3. 自动插入的行为:当视频上传后触发自动抽帧时,每抽取一帧,立即尝试插入到报告中第一个空置的 `image-placeholder`。如果插满了,后续帧**静默丢弃**,**不再弹出** "没有可插入图片的空位" 提示。
|
||||
4. 该开关状态保存在 `systemSettings` 中。
|
||||
|
||||
**涉及页面/模块**:
|
||||
- `src/pages/ReportEditor.tsx` —— 草稿恢复逻辑(需求 A、B)、自动抽帧后插入逻辑(需求 C)
|
||||
- `src/pages/SystemSettings.tsx` —— 新增"开启自动帧插入"开关及 UI 标记(需求 C)
|
||||
- `src/types.ts` —— `SystemSettings` 类型扩展(需求 C)
|
||||
- `src/utils/storage.ts` —— 无需修改,使用现有 `storage` API
|
||||
|
||||
## 验收标准
|
||||
- [ ] 自动抽帧图片在切换页面后重新进入 report-editor 时不丢失。
|
||||
- [ ] 手动抽帧图片在切换页面后依然保留。
|
||||
- [ ] 编辑已有报告后切换界面再返回,模板选择器正确显示(不显示"无")。
|
||||
- [ ] system-settings 中出现"开启自动帧插入"开关,状态可保存。
|
||||
- [ ] 开关开启后,自动抽帧的每帧会按顺序自动插入空置占位符,插满后无弹窗提示。
|
||||
- [ ] 开关关闭后,自动抽帧行为与现在一致(不自动插入)。
|
||||
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。
|
||||
Reference in New Issue
Block a user