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:
@@ -351,6 +351,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
|
||||
- 后端模板 DTO 和权限资源映射
|
||||
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
|
||||
- 模板导入导出工具,覆盖 HTML 模板包生成、字段库元数据回导和旧 JSON 导入兼容
|
||||
- AI 区域扫描工具,覆盖从报告正文 HTML 识别 `.ai-region`
|
||||
- 后端用户 DTO 和部门模板授权映射
|
||||
- 后端系统设置 schema 校验
|
||||
- 后端 AI 入参和讯飞语音代理帧处理
|
||||
|
||||
@@ -201,6 +201,7 @@ cp .env.example .env.local
|
||||
- 本地存储封装和系统设置兼容。
|
||||
- 默认报告模板结构和字段配置。
|
||||
- 模板 HTML 导出包字段库元数据。
|
||||
- AI 区域扫描和报告编辑器加载后同步。
|
||||
- 打印导出入口。
|
||||
- 后端权限策略、AI 入参和语音代理帧处理。
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ AI 面板支持两种模式:
|
||||
- 对话模式:根据当前报告内容和图片上下文回答问题。
|
||||
- 修改模式:选中 `.ai-region` 后要求模型返回 JSON,其中包含 `reply` 和 `updatedHtml`。
|
||||
|
||||
编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后,AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。
|
||||
编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后,AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。草稿、报告详情、默认模板和切换模板写入正文后也会主动刷新 AI 区域列表,避免已有区域等到下一次 DOM 编辑后才显示。
|
||||
|
||||
模型返回 HTML 后,系统会清理换行和 `<br>`,生成差异预览。用户确认后才写入目标 `.ai-content`。
|
||||
|
||||
|
||||
@@ -89,3 +89,4 @@
|
||||
| 2026-05-02 | 修复报告编辑器新增 AI 可编辑区域后 AI 撰写下拉栏不立即更新的问题,并补充 E2E。 |
|
||||
| 2026-05-02 | 移除前端用户可见 JSON 导出入口,保留模板历史 JSON 导入兼容。 |
|
||||
| 2026-05-02 | 模板 HTML 导出包补充模板字段和字段管理设置,导入时恢复字段库元数据。 |
|
||||
| 2026-05-02 | 修复报告编辑器加载已有 AI 区域后下拉栏初始显示“无可用 AI 区域”的问题。 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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<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'));
|
||||
|
||||
29
src/utils/aiRegions.test.ts
Normal file
29
src/utils/aiRegions.test.ts
Normal 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
16
src/utils/aiRegions.ts
Normal 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);
|
||||
Reference in New Issue
Block a user