From 3774657ef58fd00fe56887359704443670210da1 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 2 May 2026 04:57:00 +0800 Subject: [PATCH] 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. --- AGENTS.md | 1 + README.md | 1 + docs/modules/report-editor.md | 2 +- docs/progress.md | 1 + docs/testing.md | 3 ++- e2e/report-ai-region.spec.ts | 13 ++++++++++ src/pages/ReportEditor.tsx | 46 +++++++++++++++-------------------- src/utils/aiRegions.test.ts | 29 ++++++++++++++++++++++ src/utils/aiRegions.ts | 16 ++++++++++++ 9 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 src/utils/aiRegions.test.ts create mode 100644 src/utils/aiRegions.ts diff --git a/AGENTS.md b/AGENTS.md index c1c4dba..91588f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,6 +351,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS - 后端模板 DTO 和权限资源映射 - 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖 - 模板导入导出工具,覆盖 HTML 模板包生成、字段库元数据回导和旧 JSON 导入兼容 +- AI 区域扫描工具,覆盖从报告正文 HTML 识别 `.ai-region` - 后端用户 DTO 和部门模板授权映射 - 后端系统设置 schema 校验 - 后端 AI 入参和讯飞语音代理帧处理 diff --git a/README.md b/README.md index d4e08f1..9ced97e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ cp .env.example .env.local - 本地存储封装和系统设置兼容。 - 默认报告模板结构和字段配置。 - 模板 HTML 导出包字段库元数据。 +- AI 区域扫描和报告编辑器加载后同步。 - 打印导出入口。 - 后端权限策略、AI 入参和语音代理帧处理。 diff --git a/docs/modules/report-editor.md b/docs/modules/report-editor.md index a8e678f..3e16fec 100644 --- a/docs/modules/report-editor.md +++ b/docs/modules/report-editor.md @@ -71,7 +71,7 @@ AI 面板支持两种模式: - 对话模式:根据当前报告内容和图片上下文回答问题。 - 修改模式:选中 `.ai-region` 后要求模型返回 JSON,其中包含 `reply` 和 `updatedHtml`。 -编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后,AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。 +编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后,AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。草稿、报告详情、默认模板和切换模板写入正文后也会主动刷新 AI 区域列表,避免已有区域等到下一次 DOM 编辑后才显示。 模型返回 HTML 后,系统会清理换行和 `
`,生成差异预览。用户确认后才写入目标 `.ai-content`。 diff --git a/docs/progress.md b/docs/progress.md index 8a984ce..e5e6f1e 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -89,3 +89,4 @@ | 2026-05-02 | 修复报告编辑器新增 AI 可编辑区域后 AI 撰写下拉栏不立即更新的问题,并补充 E2E。 | | 2026-05-02 | 移除前端用户可见 JSON 导出入口,保留模板历史 JSON 导入兼容。 | | 2026-05-02 | 模板 HTML 导出包补充模板字段和字段管理设置,导入时恢复字段库元数据。 | +| 2026-05-02 | 修复报告编辑器加载已有 AI 区域后下拉栏初始显示“无可用 AI 区域”的问题。 | diff --git a/docs/testing.md b/docs/testing.md index d12fe4a..ab83c26 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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` | diff --git a/e2e/report-ai-region.spec.ts b/e2e/report-ai-region.spec.ts index fd53f84..a826693 100644 --- a/e2e/report-ai-region.spec.ts +++ b/e2e/report-ai-region.spec.ts @@ -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')}`; diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 524f70d..f32b940 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -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, '&') @@ -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('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('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('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')); diff --git a/src/utils/aiRegions.test.ts b/src/utils/aiRegions.test.ts new file mode 100644 index 0000000..a712e45 --- /dev/null +++ b/src/utils/aiRegions.test.ts @@ -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 = ` +
+
+
+ `; + + 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); + }); +}); diff --git a/src/utils/aiRegions.ts b/src/utils/aiRegions.ts new file mode 100644 index 0000000..b20de31 --- /dev/null +++ b/src/utils/aiRegions.ts @@ -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);