Refresh AI region list after editor content loads

- Extract AI region scanning into a reusable utility with unit coverage.

- Refresh AI region dropdown state after drafts, reports, default templates, and selected templates write HTML into the editor.

- Keep the existing MutationObserver path for later DOM edits and inserted AI regions.

- Add E2E coverage for existing template AI regions appearing on initial report editor load.

- Update README, AGENTS, report editor, progress, and testing docs for AI region synchronization behavior.
This commit is contained in:
2026-05-02 04:57:00 +08:00
parent 558498a4bb
commit 3774657ef5
9 changed files with 83 additions and 29 deletions

View File

@@ -351,6 +351,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
- 后端模板 DTO 和权限资源映射
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
- 模板导入导出工具,覆盖 HTML 模板包生成、字段库元数据回导和旧 JSON 导入兼容
- AI 区域扫描工具,覆盖从报告正文 HTML 识别 `.ai-region`
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验
- 后端 AI 入参和讯飞语音代理帧处理

View File

@@ -201,6 +201,7 @@ cp .env.example .env.local
- 本地存储封装和系统设置兼容。
- 默认报告模板结构和字段配置。
- 模板 HTML 导出包字段库元数据。
- AI 区域扫描和报告编辑器加载后同步。
- 打印导出入口。
- 后端权限策略、AI 入参和语音代理帧处理。

View File

@@ -71,7 +71,7 @@ AI 面板支持两种模式:
- 对话模式:根据当前报告内容和图片上下文回答问题。
- 修改模式:选中 `.ai-region` 后要求模型返回 JSON其中包含 `reply``updatedHtml`
编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。
编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。草稿、报告详情、默认模板和切换模板写入正文后也会主动刷新 AI 区域列表,避免已有区域等到下一次 DOM 编辑后才显示。
模型返回 HTML 后,系统会清理换行和 `<br>`,生成差异预览。用户确认后才写入目标 `.ai-content`

View File

@@ -89,3 +89,4 @@
| 2026-05-02 | 修复报告编辑器新增 AI 可编辑区域后 AI 撰写下拉栏不立即更新的问题,并补充 E2E。 |
| 2026-05-02 | 移除前端用户可见 JSON 导出入口,保留模板历史 JSON 导入兼容。 |
| 2026-05-02 | 模板 HTML 导出包补充模板字段和字段管理设置,导入时恢复字段库元数据。 |
| 2026-05-02 | 修复报告编辑器加载已有 AI 区域后下拉栏初始显示“无可用 AI 区域”的问题。 |

View File

@@ -82,13 +82,14 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 系统设置混淆兼容 | 已覆盖 | `storage.test.ts` |
| 默认报告模板结构 | 已覆盖 | `defaultContent.test.ts` |
| 默认字段和 AI Provider | 已覆盖 | `defaultContent.test.ts` |
| AI 区域扫描工具 | 已覆盖 | `aiRegions.test.ts`,覆盖从编辑器 HTML 识别 `.ai-region` 和标题。 |
| 打印导出入口 | 已覆盖 | `print.test.ts` |
| 模板列表合并工具 | 已覆盖 | `templateList.test.ts`,防止新增模板被旧本地缓存覆盖。 |
| 模板导入导出工具 | 已覆盖 | `templateExport.test.ts`,覆盖 HTML 模板包、字段库元数据回导、旧 JSON 导入兼容和文件名清理。 |
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |
| 报告 AI 区域同步 E2E | 已覆盖 | `e2e/report-ai-region.spec.ts` |
| 报告 AI 区域同步 E2E | 已覆盖 | `e2e/report-ai-region.spec.ts`,覆盖已有模板 AI 区域初始显示和新增区域即时显示。 |
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
| 模板管理新增保存 E2E | 已覆盖 | `e2e/template-management.spec.ts` |
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |

View File

@@ -1,6 +1,19 @@
import { expect, test } from '@playwright/test';
import { loginByApi, uniqueId } from './helpers';
test('existing template AI region appears in the AI writing target dropdown on page load', async ({ page }) => {
await loginByApi(page, '0001');
await page.goto('/report-editor');
await page.getByRole('button', { name: 'AI撰写' }).click();
const aiRegionSelect = page.locator('select').filter({
has: page.locator('option', { hasText: '手术步骤、术中出现的情况及处理' }),
});
await expect(aiRegionSelect).toHaveValue('手术步骤');
await expect(page.locator('select option', { hasText: '无可用 AI 区域' })).toHaveCount(0);
});
test('newly inserted AI region appears in the AI writing target dropdown immediately', async ({ page }) => {
await loginByApi(page, '0001');
const regionName = `AI区域${uniqueId('region')}`;

View File

@@ -23,28 +23,12 @@ import { getFieldLibrary, updateFieldLibrary } from '../api/library';
import { listFiles, uploadFileResource } from '../api/files';
import { isLocalFallbackEnabled } from '../config/runtime';
import { diffChars } from 'diff';
import { areAiRegionOptionsEqual, getAiRegionOptions, type AiRegionOption } from '../utils/aiRegions';
type AudioWindow = Window & typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
type AiRegionOption = {
id: string;
title: string;
};
const getAiRegionOptions = (root: HTMLElement | null): AiRegionOption[] => {
if (!root) return [];
return Array.from(root.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter((region) => region.id);
};
const areAiRegionOptionsEqual = (a: AiRegionOption[], b: AiRegionOption[]) =>
a.length === b.length && a.every((region, index) => region.id === b[index]?.id && region.title === b[index]?.title);
const escapeHtmlAttribute = (value: string) =>
value
.replace(/&/g, '&amp;')
@@ -250,6 +234,14 @@ export default function ReportEditor() {
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
const refreshEditorDerivedState = () => {
syncAiRegions();
setTimeout(() => {
updatePageHeight();
syncAiRegions();
}, 0);
};
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
@@ -416,7 +408,7 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}
} else {
const reports = storage.get<Report[]>('reports', []);
@@ -445,7 +437,7 @@ export default function ReportEditor() {
contentRef.current = found.content;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}
if (found.capturedFrames) {
setCapturedFrames(found.capturedFrames.sort((a, b) => a.time - b.time));
@@ -484,7 +476,7 @@ export default function ReportEditor() {
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}
}
if (!contentLoadedRef.current && editorRef.current) {
@@ -505,7 +497,7 @@ export default function ReportEditor() {
contentRef.current = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}
}
}, [reportId, navigate, draftKey, restoreFlag]);
@@ -565,7 +557,7 @@ export default function ReportEditor() {
editorRef.current.innerHTML = apiReport.content;
contentRef.current = apiReport.content;
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}
} catch {
if (!isLocalFallbackEnabled()) {
@@ -1588,7 +1580,7 @@ export default function ReportEditor() {
chatInput: '',
activeTab: stateRef.current.activeTab
};
updatePageHeight();
refreshEditorDerivedState();
saveDraftToStorage();
}
setPendingTemplateId(null);
@@ -1618,7 +1610,7 @@ export default function ReportEditor() {
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
return;
}
const reports = storage.get<Report[]>('reports', []);
@@ -1644,7 +1636,7 @@ export default function ReportEditor() {
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
return;
}
} else {
@@ -1660,7 +1652,7 @@ export default function ReportEditor() {
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
return;
}
}
@@ -1682,7 +1674,7 @@ export default function ReportEditor() {
editorRef.current.innerHTML = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
refreshEditorDerivedState();
}, []);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { areAiRegionOptionsEqual, getAiRegionOptions } from './aiRegions';
describe('ai region utilities', () => {
it('finds AI regions from editor HTML', () => {
const root = document.createElement('div');
root.innerHTML = `
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理"></div>
<div class="ai-region" data-ai-id="病灶描述"></div>
<div class="ai-region"></div>
`;
expect(getAiRegionOptions(root)).toEqual([
{ id: '手术步骤', title: '手术步骤、术中出现的情况及处理' },
{ id: '病灶描述', title: '病灶描述' },
]);
});
it('compares region lists by id and title', () => {
expect(areAiRegionOptionsEqual(
[{ id: 'a', title: 'A' }],
[{ id: 'a', title: 'A' }],
)).toBe(true);
expect(areAiRegionOptionsEqual(
[{ id: 'a', title: 'A' }],
[{ id: 'a', title: 'B' }],
)).toBe(false);
});
});

16
src/utils/aiRegions.ts Normal file
View File

@@ -0,0 +1,16 @@
export type AiRegionOption = {
id: string;
title: string;
};
export const getAiRegionOptions = (root: HTMLElement | null): AiRegionOption[] => {
if (!root) return [];
return Array.from(root.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter((region) => region.id);
};
export const areAiRegionOptionsEqual = (a: AiRegionOption[], b: AiRegionOption[]) =>
a.length === b.length && a.every((region, index) => region.id === b[index]?.id && region.title === b[index]?.title);