Fix new template disappearing after save

- Update TemplateManage save flows to use the current in-memory template list instead of stale localStorage as the source of truth.

- Merge current templates back into the compatibility cache so newly created backend templates are not dropped on content save.

- Add an accessible label for the new-template button to support reliable E2E coverage.

- Add template list merge unit tests covering stale-cache replacement and newly created templates.

- Add Playwright coverage for creating a template, saving template content, and confirming it remains visible and persisted.

- Update feature, progress, testing, and AGENTS documentation for the template management fix.
This commit is contained in:
2026-05-02 03:42:41 +08:00
parent b346b7e194
commit d61ee4f83a
8 changed files with 96 additions and 20 deletions

View File

@@ -343,12 +343,13 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
- 报告管理按角色过滤
- 管理员本部门报告范围和医生本人报告范围
- 模板可用范围,含部门模板和医生个人模板
- Playwright E2E 通过真实后端 API seed 覆盖登录、报告权限、报告修订版本、医生个人模板、路由守卫和审计日志
- Playwright E2E 通过真实后端 API seed 覆盖登录、报告权限、报告修订版本、医生个人模板、模板管理新增保存、路由守卫和审计日志
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
- 后端 Dashboard 统计按角色范围过滤
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
- 后端报告 schema 区分草稿和完成状态,草稿可暂缺患者姓名/住院号,完成报告必须填写
- 后端模板 DTO 和权限资源映射
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验
- 后端 AI 入参和讯飞语音代理帧处理

View File

@@ -32,7 +32,7 @@
| 报告查看 | 真实集成 | `ReportView` 优先调用 `GET /api/reports/:id`,后端校验查看权限;页面渲染报告 HTML只有开发/显式回退模式下 API 不可用才回退本地权限检查。 |
| PDF 导出 | 真实集成 | 通过隐藏 iframe 或 `window.print()` 调用浏览器打印,用户手动保存为 PDF不是后端 PDF 生成。 |
| JSON 导出 | 真实可用 | Blob 下载结构化报告字段或模板包。 |
| 模板管理 | 真实集成 | `TemplateManage` 优先调用 `/api/templates?access=manage`,新增/编辑/删除会写后端并清洗 HTML字段库优先调用 `/api/library/fields`,模板图片资源优先调用 `/api/files`。导入导出仍主要在前端处理。 |
| 模板管理 | 真实集成 | `TemplateManage` 优先调用 `/api/templates?access=manage`,新增/编辑/删除会写后端并清洗 HTML新增后保存内容以当前页面模板列表为准并同步兼容缓存,避免旧本地缓存覆盖新模板;字段库优先调用 `/api/library/fields`,模板图片资源优先调用 `/api/files`。导入导出仍主要在前端处理。 |
| 模板权限 | 真实集成 | 后端按部门模板、部门授权和个人模板过滤 `access=use/manage`;迁移期仍同步 `localStorage.templates`,仅在开发/显式回退模式下作为回退。 |
| 我的个人模板 | 真实集成 | 医生在报告编辑器中保存个人模板时优先调用 `POST /api/templates`,后端把模板归属当前用户;只有开发/显式回退模式下 API 不可用才回退本地模板。 |
| 用户管理 | 真实集成 | `UserManage` 优先调用 `/api/users` 增删改查,后端校验超级管理员/管理员范围、管理员唯一性和医生创建约束;只有开发/显式回退模式下 API 不可用才保留本地回退。 |
@@ -66,6 +66,7 @@
| `src/auth/backendUser.test.ts` | 后端用户 DTO 到前端兼容用户的角色和模板权限映射。 |
| `src/pages/ReportManage.test.tsx` | 医生/管理员报告可见范围。 |
| `src/utils/permissions.test.ts` | 报告权限、管理员本部门范围、医生个人模板和部门模板范围。 |
| `src/utils/templateList.test.ts` | 模板列表合并,覆盖新增模板不被旧缓存覆盖。 |
| `src/utils/storage.test.ts` | 本地存储、系统设置混淆兼容、会话恢复键、默认 Provider 不携带内置 Key 的契约。 |
| `src/utils/defaultContent.test.ts` | 默认模板结构、智能字段、图片占位符、AI 区域、字段和 Provider 配置。 |
| `src/utils/print.test.ts` | 浏览器打印导出入口。 |

View File

@@ -14,6 +14,7 @@
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage``ReportView``ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
- 后端报告校验已区分草稿和完成状态:草稿允许患者姓名/住院号暂空,完成报告仍强制要求。
- 模板管理、字段库、模板导入导出已实现;模板 API 已支持可用/可管理列表、详情、创建、更新、删除和个人模板;字段库已优先接入 `/api/library/fields`
- 模板管理新增后保存内容已改为基于当前页面 state 更新,并与本地兼容缓存合并,避免旧缓存把新建模板从列表中冲掉。
- 用户管理、部门管理员约束和部门模板授权已优先接入后端 Users/Departments API签名上传和模板图片资源已通过 Files API 写入后端文件资源。
- 系统设置、抽帧策略、AI Provider、语音参数和默认模板已优先接入 Settings API只有开发/显式回退模式下才保留本地缓存回退。
- Docker/Nginx 静态部署配置已存在。
@@ -81,3 +82,4 @@
| 2026-05-02 | 修正报告草稿后端校验和保存失败提示,补充麦克风启动前置检查。 |
| 2026-05-02 | 增加 Nginx 和 NestJS 请求体上限配置,修复大图文报告保存 `request entity too large`。 |
| 2026-05-02 | 新增 Docker HTTPS 演示入口和麦克风访问说明,解决非安全上下文下语音听写不可启动的问题。 |
| 2026-05-02 | 修复模板管理中新建模板后点击保存内容导致模板从列表消失的问题,并补充单元测试和 E2E。 |

View File

@@ -43,6 +43,7 @@ npm run build
| E2E 权限过滤 | Playwright 验证超级管理员、管理员、医生在报告管理页的可见范围。 |
| E2E 报告修订 | Playwright 验证已完成报告再次完成保存后 `revision` 递增并保留历史。 |
| E2E 个人模板 | Playwright 验证医生可保存个人模板且模板仅归属本人。 |
| E2E 模板管理 | Playwright 验证管理员新增模板后点击保存内容,模板仍保留在列表且后端可查询。 |
| E2E 路由守卫和审计日志 | Playwright 验证医生不能直进管理页,超级管理员可查看审计日志。 |
| 后端权限策略 | Vitest 验证报告、模板、用户和管理员创建权限策略。 |
@@ -81,10 +82,12 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 默认报告模板结构 | 已覆盖 | `defaultContent.test.ts` |
| 默认字段和 AI Provider | 已覆盖 | `defaultContent.test.ts` |
| 打印导出入口 | 已覆盖 | `print.test.ts` |
| 模板列表合并工具 | 已覆盖 | `templateList.test.ts`,防止新增模板被旧本地缓存覆盖。 |
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
| 模板管理新增保存 E2E | 已覆盖 | `e2e/template-management.spec.ts` |
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |
| 后端报告/模板/用户权限策略 | 已覆盖 | `server/src/permissions/permissions.policy.test.ts` |
| 后端报告 schema | 已覆盖 | `server/src/reports/reports.schemas.test.ts` |

View File

@@ -0,0 +1,22 @@
import { expect, test } from '@playwright/test';
import { apiRequest, loginByApi, uniqueId } from './helpers';
test('admin created template remains visible after saving content', async ({ page }) => {
await loginByApi(page, 'admin');
const templateName = `E2E新增模板 ${uniqueId('tpl')}`;
await page.goto('/template-manage');
await page.getByRole('button', { name: '新增模板' }).click();
await page.getByPlaceholder('请输入模板名称').fill(templateName);
await page.getByPlaceholder('请输入模板描述').fill('新增后保存内容仍留在列表中');
await page.getByRole('button', { name: '保存模板信息' }).click();
await expect(page.getByText(templateName).first()).toBeVisible();
await page.getByRole('button', { name: '保存模板' }).click();
await expect(page.getByText(templateName).first()).toBeVisible();
await expect.poll(async () => {
const data = await apiRequest<{ items: any[] }>(page.request, 'get', '/api/templates?access=manage');
return data.items.some((template) => template.name === templateName);
}).toBe(true);
});

View File

@@ -11,6 +11,7 @@ import { createTemplate, deleteTemplateFromApi, listTemplates, updateTemplate }
import { getFieldLibrary, updateFieldLibrary } from '../api/library';
import { deleteFileResource, listFiles, uploadFileResource } from '../api/files';
import { isLocalFallbackEnabled } from '../config/runtime';
import { mergeTemplatesById } from '../utils/templateList';
export default function TemplateManage() {
const navigate = useNavigate();
@@ -475,13 +476,13 @@ export default function TemplateManage() {
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
const cachedTemplates = storage.get<Template[]>('templates', []);
const updatedTemplates = templates.map(t =>
t.id === currentTemplateId ? { ...t, content: cleanContent, updatedAt: new Date().toISOString() } : t
);
const currentUpdated = updated.find(t => t.id === currentTemplateId);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
const currentUpdated = updatedTemplates.find(t => t.id === currentTemplateId);
setTemplates(updatedTemplates);
storage.set('templates', mergeTemplatesById(cachedTemplates, updatedTemplates));
if (currentUpdated) {
void updateTemplate(currentTemplateId, currentUpdated).catch(() => {});
}
@@ -680,16 +681,16 @@ export default function TemplateManage() {
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t => {
const cachedTemplates = storage.get<Template[]>('templates', []);
const updatedTemplates = templates.map(t => {
if (t.id === currentTemplateId) {
return { ...t, content: cleanContent, updatedAt: new Date().toISOString() };
}
return t;
});
const currentUpdated = updated.find(t => t.id === currentTemplateId);
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
storage.set('templates', updated);
const currentUpdated = updatedTemplates.find(t => t.id === currentTemplateId);
setTemplates(updatedTemplates);
storage.set('templates', mergeTemplatesById(cachedTemplates, updatedTemplates));
if (currentUpdated) {
void updateTemplate(currentTemplateId, currentUpdated).catch(() => {});
}
@@ -818,10 +819,10 @@ export default function TemplateManage() {
const handleModalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const allTemplates = storage.get<Template[]>('templates', []);
const cachedTemplates = storage.get<Template[]>('templates', []);
if (isEditing) {
let editedTemplate: Template | undefined;
let updated = allTemplates.map(t => {
let updatedTemplates = templates.map(t => {
if (t.id === currentTemplateId) {
editedTemplate = { ...t, name: formData.name, desc: formData.desc };
return editedTemplate;
@@ -831,7 +832,7 @@ export default function TemplateManage() {
if (editedTemplate && currentTemplateId) {
try {
const apiTemplate = await updateTemplate(currentTemplateId, editedTemplate);
updated = updated.map(t => t.id === currentTemplateId ? apiTemplate : t);
updatedTemplates = updatedTemplates.map(t => t.id === currentTemplateId ? apiTemplate : t);
} catch {
if (!isLocalFallbackEnabled()) {
alert('保存模板失败:后端服务不可用');
@@ -839,8 +840,8 @@ export default function TemplateManage() {
}
}
}
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
if (isLocalFallbackEnabled()) storage.set('templates', updated);
setTemplates(updatedTemplates);
storage.set('templates', mergeTemplatesById(cachedTemplates, updatedTemplates));
} else {
let newTpl: Template = {
id: 'tpl_' + Date.now(),
@@ -861,9 +862,9 @@ export default function TemplateManage() {
return;
}
}
const updated = [...allTemplates, newTpl];
setTemplates([...templates, newTpl]);
if (isLocalFallbackEnabled()) storage.set('templates', updated);
const updatedTemplates = [...templates, newTpl];
setTemplates(updatedTemplates);
storage.set('templates', mergeTemplatesById(cachedTemplates, updatedTemplates));
setCurrentTemplateId(newTpl.id);
if (importedContent?.fields && importedContent.fields.length > 0) {
setFormFields(importedContent.fields);
@@ -927,6 +928,8 @@ export default function TemplateManage() {
<button
onClick={handleAddTemplate}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
title="新增模板"
aria-label="新增模板"
>
<Plus size={16} />
</button>

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { Template } from '../types';
import { mergeTemplatesById } from './templateList';
const template = (id: string, name: string): Template => ({
id,
name,
desc: '',
content: `<p>${name}</p>`,
createdAt: '2026-05-02T00:00:00.000Z',
author: 'admin',
scope: 'department',
department: '外科',
});
describe('templateList utilities', () => {
it('keeps newly created templates when merging with stale cached templates', () => {
const cached = [template('tpl_old', '旧模板')];
const current = [template('tpl_old', '旧模板'), template('tpl_new', '新模板')];
expect(mergeTemplatesById(cached, current).map((item) => item.id)).toEqual([
'tpl_old',
'tpl_new',
]);
});
it('lets newer template state replace stale cache entries', () => {
const cached = [template('tpl_old', '旧名称')];
const current = [template('tpl_old', '新名称')];
expect(mergeTemplatesById(cached, current)[0].name).toBe('新名称');
});
});

11
src/utils/templateList.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Template } from '../types';
export const mergeTemplatesById = (...templateGroups: Template[][]) => {
const merged = new Map<string, Template>();
for (const group of templateGroups) {
for (const template of group) {
merged.set(template.id, template);
}
}
return Array.from(merged.values());
};