2 Commits

Author SHA1 Message Date
545aa2448b commit at 2026-04-16-17-55-13 2026-04-16 17:55:13 +08:00
11278d0bcd backup before modification at 2026-04-16-17-40-20 2026-04-16 17:40:20 +08:00
10 changed files with 650 additions and 17 deletions

View File

@@ -67,9 +67,9 @@ export default function ReportEditor() {
const videoInputRef = useRef<HTMLInputElement>(null); const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false); const contentLoadedRef = useRef(false);
const contentRef = useRef(''); 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}` : ''; const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
@@ -83,12 +83,12 @@ export default function ReportEditor() {
}; };
const saveDraftToStorage = React.useCallback(() => { const saveDraftToStorage = React.useCallback(() => {
stateRef.current = { reportData, videos, capturedFrames, activeTab, loadedTemplateId };
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : ''; const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) { if (key) {
storage.set(key, { storage.set(key, {
content: contentRef.current, content: contentRef.current,
loadedTemplateId,
draftReportId: reportId || null, draftReportId: reportId || null,
...stateRef.current ...stateRef.current
}); });
@@ -398,6 +398,7 @@ export default function ReportEditor() {
})); }));
const combined = [...videos, ...newVideos]; const combined = [...videos, ...newVideos];
setVideos(combined); setVideos(combined);
stateRef.current = { ...stateRef.current, videos: combined };
setCurrentVideoIndex(videos.length); // select first newly uploaded video setCurrentVideoIndex(videos.length); // select first newly uploaded video
if (videoInputRef.current) videoInputRef.current.value = ''; if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage(); saveDraftToStorage();
@@ -407,15 +408,18 @@ export default function ReportEditor() {
const idx = videos.findIndex(v => v.id === id); const idx = videos.findIndex(v => v.id === id);
const updated = videos.filter(v => v.id !== id); const updated = videos.filter(v => v.id !== id);
setVideos(updated); setVideos(updated);
stateRef.current = { ...stateRef.current, videos: updated };
if (currentVideoIndex >= updated.length) { if (currentVideoIndex >= updated.length) {
setCurrentVideoIndex(updated.length > 0 ? 0 : -1); setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
} else if (currentVideoIndex === idx && updated.length > 0) { } else if (currentVideoIndex === idx && updated.length > 0) {
setCurrentVideoIndex(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 }; if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
return f; 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(); saveDraftToStorage();
}; };
@@ -449,7 +453,9 @@ export default function ReportEditor() {
dataUrl: canvas.toDataURL('image/jpeg', 0.9), dataUrl: canvas.toDataURL('image/jpeg', 0.9),
isManual: true 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(); saveDraftToStorage();
}; };
@@ -482,7 +488,7 @@ export default function ReportEditor() {
canvas.width = video.videoWidth; canvas.width = video.videoWidth;
canvas.height = video.videoHeight; canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
newFrames.push({ const newFrame: CapturedFrame = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
videoIndex: currentVideoIndex, videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name, videoName: videos[currentVideoIndex].name,
@@ -490,9 +496,25 @@ export default function ReportEditor() {
timeFormatted: formatTime(time), timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.9), dataUrl: canvas.toDataURL('image/jpeg', 0.9),
isManual: false 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(); if (wasPlaying) video.play();
saveDraftToStorage(); saveDraftToStorage();
}; };
@@ -1316,16 +1338,18 @@ export default function ReportEditor() {
<div className="relative"> <div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" /> <img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>} {frame.isManual && <span className="manual-frame-badge"></span>}
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="absolute inset-0 m-auto w-fit h-fit px-3 py-1.5 bg-emerald-500 text-white text-[10px] font-bold rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-emerald-600"
>
</button>
</div> </div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center"> <div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span> <span>{frame.timeFormatted}</span>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span> <div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="px-2 py-0.5 bg-accent text-white text-[9px] font-bold rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-700"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div> </div>
<button <button
onClick={(e) => { e.stopPropagation(); setCapturedFrames(prev => prev.filter(f => f.id !== frame.id).sort((a, b) => a.time - b.time)); saveDraftToStorage(); }} onClick={(e) => { e.stopPropagation(); setCapturedFrames(prev => prev.filter(f => f.id !== frame.id).sort((a, b) => a.time - b.time)); saveDraftToStorage(); }}

View File

@@ -14,7 +14,8 @@ export default function SystemSettings() {
apiEndpoint: '', apiEndpoint: '',
apiKey: '', apiKey: '',
defaultTemplate: '', defaultTemplate: '',
frameMode: 'uniform' frameMode: 'uniform',
autoInsertFrames: false
}); });
const [templates, setTemplates] = useState<Template[]>([]); const [templates, setTemplates] = useState<Template[]>([]);
const [isSaved, setIsSaved] = useState(false); const [isSaved, setIsSaved] = useState(false);
@@ -172,6 +173,18 @@ export default function SystemSettings() {
</div> </div>
</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"> <div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (%)</label> <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"> <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" 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> <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 <button
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -75,4 +75,5 @@ export interface SystemSettings {
apiKey: string; apiKey: string;
defaultTemplate?: string; defaultTemplate?: string;
frameMode?: 'uniform' | 'keep'; frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean;
} }

View File

@@ -0,0 +1,77 @@
# 实现方案 — 2026-04-16-17-21-58
## 技术思路
`ReportEditor.tsx` 中关键帧卡片的 "插入" 按钮从图片层的 absolute 覆盖层移回底部文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。颜色恢复为与 "可拖拽" 一致的蓝色(`bg-accent` / `text-white`),但保留实体胶囊按钮样式。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 "插入" 按钮位置、调整颜色为蓝色 |
---
## 关键代码变更说明
### 当前 JSX 片段(约第 1316~1331 行)
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="absolute inset-0 m-auto w-fit h-fit px-3 py-1.5 bg-emerald-500 text-white text-[10px] font-bold rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-emerald-600"
>
</button>
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
```
### 修改后 JSX 片段
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="px-2 py-0.5 bg-accent text-white text-[9px] font-bold rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-700"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
```
### 样式说明
- 按钮位于底部 `timeFormatted` 与 "可拖拽" 之间,不再覆盖图片。
- `px-2 py-0.5 bg-accent text-white rounded-full shadow-sm`:蓝色实体小胶囊按钮,与 "可拖拽" 蓝色一致。
- `hover:bg-blue-700`:加深蓝色反馈。
- `opacity-0 group-hover:opacity-100 transition-opacity`:保持 hover 显隐与 "可拖拽" 同步。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| 按钮太小导致不易点击 | 体验下降 | 保留 `px-2 py-0.5` 的实体按钮,比纯文字链接更易点击 |
| 底部文字区域变宽 | 布局错乱 | 使用 `flex items-center gap-2` 控制间距,保持在一行内 |
---
## 改动范围总结
- 纯 JSX 结构和样式的微调整,不修改任何逻辑函数。
- 不引入新依赖。

View 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` 兜底 |
---
## 改动范围总结
- 三个文件修改,不引入新依赖。
- 自动帧插入默认关闭,不影响现有用户体验。

View File

@@ -0,0 +1,72 @@
# 测试方案 — 2026-04-16-17-21-58
## 测试环境准备
1. 项目已完成上一次构建,无类型错误。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3. 进入 **图文报告生成** 页面并上传视频完成抽帧,确保视频分析面板有关键帧截图。
---
## 测试项清单
### 测试项 1"插入" 按钮位于底部正确位置且不遮盖图片
**测试步骤**
1. 将鼠标悬停到任意关键帧截图卡片上。
2. 观察 "插入" 按钮的位置。
**预期结果**
- 按钮位于卡片底部,**时间格式(如 `00:15`**和 **"可拖拽"** 提示之间。
- 按钮**不覆盖在图片上**,图片内容完全可见,无遮挡。
---
### 测试项 2"插入" 按钮为蓝色实体按钮样式
**测试步骤**
1. 鼠标悬停到关键帧卡片,观察 "插入" 按钮的颜色和形状。
**预期结果**
- 按钮为**蓝色实体胶囊按钮**`bg-accent` 背景 + 白色文字 + 圆角 + 轻微阴影)。
- 按钮颜色与底部 "可拖拽" 的蓝色一致,视觉上协调统一。
- 不是纯文字链接样式。
---
### 测试项 3显隐行为与 "可拖拽" 同步
**测试步骤**
1. 鼠标移入关键帧卡片。
2. 鼠标移出关键帧卡片。
**预期结果**
- 移入时,"插入" 按钮与 "可拖拽" 文字**同时淡入显示**。
- 移出时,"插入" 按钮与 "可拖拽" 文字**同时淡出隐藏**。
---
### 测试项 4点击功能与事件冒泡正常
**测试步骤**
1. 鼠标悬停到关键帧卡片,点击 **"插入"** 按钮。
**预期结果**
- 编辑器中第一个空置的 `image-placeholder` 被自动填充(或弹出 `没有可插入图片的空位` 提示)。
- 视频播放器时间不发生变化(未触发卡片跳转)。
---
### 测试项 5构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功。
---
## 回归验证范围
- [ ] 拖拽插入功能未被破坏。
- [ ] 关键帧卡片的删除、手动徽章显示正常。
- [ ] 编辑器保存、打印功能正常。

View 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. 切换到其他页面后再返回该报告编辑页。
**预期结果**
- 报告内容正确恢复。
- 若该报告是从模板创建的,"当前模板"应显示对应模板名称;若不是从模板创建,显示"无"也属正常。
---
### 测试项 Esystem-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 地址、默认模板等)保存和读取正常。

View File

@@ -62,3 +62,51 @@
- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。 - 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。
- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。 - 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。
- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。 - 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。
---
## 记录 4关键帧 "插入" 按钮位置微调(从图片中央移回底部)
**A. 具体问题**
用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。
**B. 产生问题原因**
按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。
**C. 解决问题方案**
1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。
2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。
3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。
4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。
**D. 后续如何避免问题**
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
- 在 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 上给予用户明确的视觉反馈(如此次的绿色对勾标记)。

View File

@@ -0,0 +1,22 @@
# 需求分析 — 2026-04-16-17-21-58
## 需求背景
用户对上一个优化后的 "插入" 按钮位置提出了微调诉求:按钮覆盖在图片正中央会遮挡图片内容,不够理想。
## 功能目标
调整关键帧截图卡片上 "插入" 按钮的位置和颜色:
1. **位置调整**:将 "插入" 按钮从图片正中央移回卡片底部,位于**时间格式(如 `00:15`**和 **"可拖拽"** 提示文字之间。
2. **不遮盖图片**:按钮不再以 absolute 覆盖层形式存在于图片之上。
3. **颜色调整**:按钮恢复为与 "可拖拽" 一致的蓝色(`bg-accent` / `text-white`)。
4. **保留实体按钮样式**:保持 `px-3 py-1.5 rounded-full shadow-md` 的实体胶囊按钮外观,不再使用纯文字链接。
## 涉及页面/模块
- `src/pages/ReportEditor.tsx` —— 关键帧卡片 JSX 结构调整
## 验收标准
- [ ] "插入" 按钮位于关键帧卡片底部,时间文字与 "可拖拽" 之间。
- [ ] 按钮不覆盖在图片上,不遮挡图片内容。
- [ ] 按钮为蓝色实体胶囊按钮,与 "可拖拽" 蓝色一致。
- [ ] 悬停时按钮和 "可拖拽" 同时显示,移开时同时隐藏。
- [ ] 点击按钮后插入逻辑正常工作,不触发视频跳转。
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。

View 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 时被覆盖了,但恢复时该字段未同步。
### 需求 Csystem-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` 构建通过。