Add demo mode factory reset
- Align the backend seeded default surgery template with the report editor's default report content. - Add backend demo defaults for the default template, Kimi provider, and Xunfei speech proxy configuration. - Change system reset into a super-admin demo mode factory reset that clears reports, audit logs, files, custom templates, and non-default users. - Keep only the default admin, manager, doctor, and default surgery template after demo reset. - Replace the old local-only reset all data button with a two-confirmation backend reset flow. - Add tests covering demo default alignment and database-backed demo reset behavior. - Update docs to describe demo mode reset semantics and production credential cautions.
This commit is contained in:
@@ -104,6 +104,7 @@ npm run test:e2e
|
|||||||
- 页面级权限在前端用于体验控制,不能抵御绕过;生产安全边界以后端 API 权限校验为准。
|
- 页面级权限在前端用于体验控制,不能抵御绕过;生产安全边界以后端 API 权限校验为准。
|
||||||
- 报告和模板 HTML 保存时已做服务端白名单清洗;前端仍使用 HTML 渲染,继续修改时要留意 XSS 和打印兼容。
|
- 报告和模板 HTML 保存时已做服务端白名单清洗;前端仍使用 HTML 渲染,继续修改时要留意 XSS 和打印兼容。
|
||||||
- AI Key 和讯飞语音密钥已由后端代理使用,普通用户读取系统设置时不会返回真实密钥。
|
- AI Key 和讯飞语音密钥已由后端代理使用,普通用户读取系统设置时不会返回真实密钥。
|
||||||
|
- 当前 demo mode 后端默认值包含演示用第三方服务凭据,生产化前必须替换或移除,并轮换曾经暴露过的密钥。
|
||||||
- 视频和关键帧文件已优先进入后端文件资源;报告保存时通过 `ReportMedia` 关系表关联,新建报告保存前仍依赖浏览器对象 URL 预览。
|
- 视频和关键帧文件已优先进入后端文件资源;报告保存时通过 `ReportMedia` 关系表关联,新建报告保存前仍依赖浏览器对象 URL 预览。
|
||||||
- `VITE_ENABLE_LOCAL_FALLBACK` 控制生产构建是否允许本地兼容回退;开发模式默认允许,生产默认关闭。
|
- `VITE_ENABLE_LOCAL_FALLBACK` 控制生产构建是否允许本地兼容回退;开发模式默认允许,生产默认关闭。
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ npm run test:e2e
|
|||||||
│ │ ├── audit/ # 审计日志写入和查询 API
|
│ │ ├── audit/ # 审计日志写入和查询 API
|
||||||
│ │ ├── auth/ # 登录、me、logout 接口
|
│ │ ├── auth/ # 登录、me、logout 接口
|
||||||
│ │ ├── dashboard/ # 工作台统计 API
|
│ │ ├── dashboard/ # 工作台统计 API
|
||||||
|
│ │ ├── demo/ # 演示模式默认模板、AI 和语音配置
|
||||||
│ │ ├── reports/ # 报告 API、DTO、metadata 映射和测试
|
│ │ ├── reports/ # 报告 API、DTO、metadata 映射和测试
|
||||||
│ │ ├── templates/ # 模板 API、DTO、权限映射和测试
|
│ │ ├── templates/ # 模板 API、DTO、权限映射和测试
|
||||||
│ │ ├── users/ # 用户、部门和模板授权 API、DTO 映射和测试
|
│ │ ├── users/ # 用户、部门和模板授权 API、DTO 映射和测试
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ docker-compose down
|
|||||||
- 权限判断主要在前端,不能作为生产安全边界。
|
- 权限判断主要在前端,不能作为生产安全边界。
|
||||||
- 报告和模板 HTML 保存时已做服务端白名单清洗,但渲染仍使用 HTML,需要继续做安全评审。
|
- 报告和模板 HTML 保存时已做服务端白名单清洗,但渲染仍使用 HTML,需要继续做安全评审。
|
||||||
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取设置时不会拿到真实密钥。
|
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取设置时不会拿到真实密钥。
|
||||||
|
- 当前 demo mode 后端默认值包含演示用第三方服务凭据,生产部署前必须替换或移除,并轮换曾经暴露过的密钥。
|
||||||
- 视频和关键帧已优先上传后端文件资源,报告保存时通过 `ReportMedia` 关系表关联;新建报告保存前仍依赖本地预览对象。
|
- 视频和关键帧已优先上传后端文件资源,报告保存时通过 `ReportMedia` 关系表关联;新建报告保存前仍依赖本地预览对象。
|
||||||
|
|
||||||
生产化方向见 [docs/backendization-plan.md](./docs/backendization-plan.md)。
|
生产化方向见 [docs/backendization-plan.md](./docs/backendization-plan.md)。
|
||||||
|
|||||||
@@ -471,7 +471,14 @@ pageSize?: number
|
|||||||
|
|
||||||
### `POST /api/settings/system/reset`
|
### `POST /api/settings/system/reset`
|
||||||
|
|
||||||
只有超级管理员可重置全局系统设置。
|
只有超级管理员可执行演示模式恢复出厂设置。当前实现不只是重置系统设置,而是把当前租户恢复为 demo mode:
|
||||||
|
|
||||||
|
- 用户只保留默认 `admin`、`manager`、`0001` 三个账号,并重置为默认角色、部门、状态和密码。
|
||||||
|
- 报告、报告历史、报告媒体、文件资源和审计日志会被清空。
|
||||||
|
- 模板只保留默认“腹腔镜胆囊切除术报告”,模板 HTML 与图文报告生成的默认报告内容保持一致。
|
||||||
|
- 系统设置恢复为演示默认值,包含默认模板、抽帧策略、Kimi Provider 和讯飞语音代理配置。
|
||||||
|
|
||||||
|
前端必须做二次确认。该接口面向演示/测试环境,不应作为生产数据恢复或备份机制。
|
||||||
|
|
||||||
## Signature Files API
|
## Signature Files API
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
- 报告保存为草稿或完成态时优先写入后端;开发回退开启时会同步 `reports` 缓存,API 不可用时才写入本地 `reports`。
|
- 报告保存为草稿或完成态时优先写入后端;开发回退开启时会同步 `reports` 缓存,API 不可用时才写入本地 `reports`。
|
||||||
- 新建报告成功保存后会清理当前用户草稿。
|
- 新建报告成功保存后会清理当前用户草稿。
|
||||||
- 编辑已有报告时会把旧内容推入 `history`。
|
- 编辑已有报告时会把旧内容推入 `history`。
|
||||||
- 开发回退模式下,系统设置页“重置全部数据”会执行 `localStorage.clear()` 并刷新页面;生产构建默认阻止把该操作误认为后端数据重置。
|
- 系统设置页“恢复演示出厂设置”会调用后端 `POST /api/settings/system/reset` 恢复 demo mode,并在成功后清理当前浏览器缓存再刷新页面。
|
||||||
|
|
||||||
## 迁移注意
|
## 迁移注意
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
||||||
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 |
|
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 |
|
||||||
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
||||||
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取、保存和重置抽帧、默认模板、AI Provider、语音配置;只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
|
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取和保存抽帧、默认模板、AI Provider、语音配置;“恢复演示出厂设置”会二次确认后调用后端 demo reset,清空报告/审计并恢复默认用户、模板和演示配置。只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
|
||||||
| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 |
|
| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 |
|
||||||
| Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 |
|
| Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 |
|
||||||
| 后端服务 | 后端骨架 | 已新增 NestJS API:健康检查、认证接口、数据库 Session、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、视频/关键帧文件上传、AI 代理、讯飞语音代理、HTML 清洗、审计日志查询、Prisma/PostgreSQL 数据模型、默认 seed 和权限策略。 |
|
| 后端服务 | 后端骨架 | 已新增 NestJS API:健康检查、认证接口、数据库 Session、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、视频/关键帧文件上传、AI 代理、讯飞语音代理、HTML 清洗、审计日志查询、Prisma/PostgreSQL 数据模型、默认 seed 和权限策略。 |
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ VITE_API_PROXY_TARGET="http://localhost:3002"
|
|||||||
| `manager` | `123456` | 管理员 |
|
| `manager` | `123456` | 管理员 |
|
||||||
| `0001` | `123456` | 医生 |
|
| `0001` | `123456` | 医生 |
|
||||||
|
|
||||||
登录后建议先进入“系统设置”确认 AI Provider、讯飞语音配置、默认模板和抽帧策略。AI 与语音密钥保存在后端 Settings API 中,不应写入源码、文档或提交记录。
|
登录后建议先进入“系统设置”确认 AI Provider、讯飞语音配置、默认模板和抽帧策略。当前 demo mode 已内置演示用 AI 与语音配置;正式生产部署前必须替换或移除这些演示凭据,后续通过后端 Settings API 或正式密钥管理流程维护。
|
||||||
|
|
||||||
## 初次验收
|
## 初次验收
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,12 @@
|
|||||||
|
|
||||||
超级管理员可执行:
|
超级管理员可执行:
|
||||||
|
|
||||||
- 恢复系统设置出厂设置:优先调用 `POST /api/settings/system/reset`,只有本地回退开启时失败才重置本地 `systemSettings`。
|
- 恢复演示出厂设置:调用 `POST /api/settings/system/reset`,前端做二次确认后由后端恢复 demo mode。
|
||||||
- 重置全部数据:仅本地回退开启时执行 `localStorage.clear()` 并刷新;生产构建默认阻止把本地清空误认为后端数据重置。
|
|
||||||
|
当前 demo mode reset 会清空报告、报告历史、报告媒体、文件资源和审计日志;用户只保留 `admin`、`manager`、`0001` 三个默认账号;模板只保留“腹腔镜胆囊切除术报告”;系统设置恢复默认抽帧策略、默认模板、Kimi Provider 和讯飞语音代理配置。该能力用于演示环境快速回到可演示状态,不是生产备份/恢复方案。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- `systemSettings` 的本地混淆不等于安全加密;当前仅作为开发/显式本地回退模式下 API 不可用时的兼容缓存。
|
- `systemSettings` 的本地混淆不等于安全加密;当前仅作为开发/显式本地回退模式下 API 不可用时的兼容缓存。
|
||||||
- 初始化、类型和系统设置页面已统一使用 `xfSpeechConfig`;当前由后端语音代理使用。
|
- 初始化、类型和系统设置页面已统一使用 `xfSpeechConfig`;当前由后端语音代理使用。
|
||||||
- 默认 API Key 或语音密钥不应留在生产前端代码中。
|
- demo mode 内置第三方服务演示凭据,生产部署前必须替换或移除,并通过正式密钥管理流程维护。
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
- 后端认证、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理、语音代理和审计日志 API 已可用;第三方调用摘要、限流和后端导出仍待加强。
|
- 后端认证、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理、语音代理和审计日志 API 已可用;第三方调用摘要、限流和后端导出仍待加强。
|
||||||
- 本地存储仍可能包含病历兼容缓存、旧演示密码字段、模板图片和关键帧,不适合生产;历史浏览器数据中也可能残留旧版语音服务密钥。
|
- 本地存储仍可能包含病历兼容缓存、旧演示密码字段、模板图片和关键帧,不适合生产;历史浏览器数据中也可能残留旧版语音服务密钥。
|
||||||
- `systemSettings` 的混淆存储不是加密。
|
- `systemSettings` 的混淆存储不是加密。
|
||||||
- 旧版本曾在前端默认配置中包含服务密钥痕迹;当前源码默认值已清空,但生产化前仍应轮换曾经暴露过的第三方密钥。
|
- 当前 demo mode 后端默认值包含演示用第三方服务凭据;生产化前必须移除或替换,并轮换曾经暴露过的第三方密钥。
|
||||||
- 报告正文和模板正文保存时已做服务端白名单清洗,但仍以 HTML 字符串存储并通过 `dangerouslySetInnerHTML` 渲染,需要持续安全测试。
|
- 报告正文和模板正文保存时已做服务端白名单清洗,但仍以 HTML 字符串存储并通过 `dangerouslySetInnerHTML` 渲染,需要持续安全测试。
|
||||||
- 大视频和大量 Base64 图片会快速占满浏览器存储空间。
|
- 大视频和大量 Base64 图片会快速占满浏览器存储空间。
|
||||||
- `document.execCommand` 已是过时 API,但当前编辑器大量依赖它。
|
- `document.execCommand` 已是过时 API,但当前编辑器大量依赖它。
|
||||||
@@ -74,3 +74,4 @@
|
|||||||
| 2026-05-02 | 新增审计日志查询 API/页面、Auth Context 路由角色守卫,并把 Playwright E2E 改为真实后端 API seed。 |
|
| 2026-05-02 | 新增审计日志查询 API/页面、Auth Context 路由角色守卫,并把 Playwright E2E 改为真实后端 API seed。 |
|
||||||
| 2026-05-02 | 新增安装与初始设置文档,补充首次启动、端口规划、数据库初始化、验收步骤和常见问题。 |
|
| 2026-05-02 | 新增安装与初始设置文档,补充首次启动、端口规划、数据库初始化、验收步骤和常见问题。 |
|
||||||
| 2026-05-02 | 新增前端组件结构文档,梳理页面组件、公共组件、API/Auth/Utils 分层、数据流和大组件拆分边界。 |
|
| 2026-05-02 | 新增前端组件结构文档,梳理页面组件、公共组件、API/Auth/Utils 分层、数据流和大组件拆分边界。 |
|
||||||
|
| 2026-05-02 | 将默认“腹腔镜胆囊切除术报告”后端 seed 与前端默认报告内容对齐,并把系统设置重置改为演示模式恢复出厂设置。 |
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
- 超级管理员可配置 AI 服务商、接口地址、API Key 和模型名。
|
- 超级管理员可配置 AI 服务商、接口地址、API Key 和模型名。
|
||||||
- 超级管理员可配置讯飞语音听写参数。
|
- 超级管理员可配置讯飞语音听写参数。
|
||||||
- 所有角色可设置默认报告模板。
|
- 所有角色可设置默认报告模板。
|
||||||
- 超级管理员可恢复系统设置;清空全部本地数据仅作为开发/显式本地回退模式下的浏览器缓存操作,不代表清空后端业务数据。
|
- 超级管理员可恢复演示出厂设置;该操作会二次确认后清空当前租户报告、审计、自定义模板和非默认用户,并恢复默认演示账号、模板、AI 和语音配置。
|
||||||
|
|
||||||
## 非功能需求与约束
|
## 非功能需求与约束
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- 开发模式 `localStorage.users` 仍保留兼容缓存和旧演示密码字段;生产构建默认关闭本地回退。
|
- 开发模式 `localStorage.users` 仍保留兼容缓存和旧演示密码字段;生产构建默认关闭本地回退。
|
||||||
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取系统设置时不会返回真实密钥。超级管理员仍可维护全局密钥,应避免把密钥写入源码、日志或文档。
|
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取系统设置时不会返回真实密钥。超级管理员仍可维护全局密钥,应避免把密钥写入源码、日志或文档。
|
||||||
- 旧版本源码中存在默认服务密钥痕迹,应视为已暴露;当前默认值已清空,但生产化前仍需轮换曾经暴露过的第三方密钥。
|
- 当前 demo mode 后端默认值包含演示用第三方服务凭据;旧版本源码中也存在默认服务密钥痕迹,应视为已暴露。生产化前必须移除或替换演示凭据,并轮换曾经暴露过的第三方密钥。
|
||||||
- 报告和模板 HTML 保存时已做服务端白名单清洗,但仍直接渲染 HTML,需要继续做绕过测试和打印兼容测试。
|
- 报告和模板 HTML 保存时已做服务端白名单清洗,但仍直接渲染 HTML,需要继续做绕过测试和打印兼容测试。
|
||||||
- 浏览器存储没有审计、备份、权限隔离和加密能力。
|
- 浏览器存储没有审计、备份、权限隔离和加密能力。
|
||||||
- 医疗病历数据属于敏感数据,不能直接用于公网生产。
|
- 医疗病历数据属于敏感数据,不能直接用于公网生产。
|
||||||
|
|||||||
@@ -90,12 +90,13 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
|||||||
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
|
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
|
||||||
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
|
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
|
||||||
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
|
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
|
||||||
|
| 演示模式默认值 | 已覆盖 | `server/src/demo/demo-defaults.test.ts` 覆盖后端默认模板与前端报告编辑器默认内容一致,并校验语音演示配置完整。 |
|
||||||
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
|
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
|
||||||
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
|
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
|
||||||
| 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` |
|
| 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` |
|
||||||
| 后端文件 schema | 已覆盖 | `server/src/files/files.schemas.test.ts` |
|
| 后端文件 schema | 已覆盖 | `server/src/files/files.schemas.test.ts` |
|
||||||
| 后端 HTTP 集成 | 已覆盖 | `server/src/http.integration.test.ts` |
|
| 后端 HTTP 集成 | 已覆盖 | `server/src/http.integration.test.ts` |
|
||||||
| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗、审计写入和审计查询权限。 |
|
| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗、审计写入、审计查询权限和演示模式恢复出厂设置。 |
|
||||||
| 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 |
|
| 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 |
|
||||||
| 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 |
|
| 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 |
|
||||||
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本和个人模板;模板切换、字段同步仍待补。 |
|
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本和个人模板;模板切换、字段同步仍待补。 |
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
|
import {
|
||||||
|
DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
DEMO_SYSTEM_SETTINGS,
|
||||||
|
DEMO_TEMPLATE_ID,
|
||||||
|
} from '../src/demo/demo-defaults.js';
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error('DATABASE_URL is required to seed the database');
|
throw new Error('DATABASE_URL is required to seed the database');
|
||||||
@@ -12,16 +17,6 @@ const prisma = new PrismaClient({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultTemplateContent = `
|
|
||||||
<h1 style="text-align:center;">手术记录</h1>
|
|
||||||
<p>患者姓名:<span class="field-value" data-bind="patientName" contenteditable="true"></span></p>
|
|
||||||
<p>住院号:<span class="field-value" data-bind="hospitalId" contenteditable="true"></span></p>
|
|
||||||
<p>手术名称:<span class="field-value" data-bind="title" contenteditable="true"></span></p>
|
|
||||||
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤">
|
|
||||||
<div class="ai-content"><p>请在此处填写手术步骤。</p></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const tenant = await prisma.tenant.upsert({
|
const tenant = await prisma.tenant.upsert({
|
||||||
where: { code: 'default' },
|
where: { code: 'default' },
|
||||||
@@ -94,14 +89,22 @@ const main = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const defaultTemplate = await prisma.template.upsert({
|
const defaultTemplate = await prisma.template.upsert({
|
||||||
where: { id: 'tpl_default_surgery' },
|
where: { id: DEMO_TEMPLATE_ID },
|
||||||
update: {},
|
update: {
|
||||||
|
name: '腹腔镜胆囊切除术报告',
|
||||||
|
description: '标准手术记录模板',
|
||||||
|
content: DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
fields: [],
|
||||||
|
scope: 'DEPARTMENT',
|
||||||
|
ownerDepartmentId: surgeryDepartment.id,
|
||||||
|
ownerUserId: null,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
id: 'tpl_default_surgery',
|
id: DEMO_TEMPLATE_ID,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
name: '腹腔镜胆囊切除术报告',
|
name: '腹腔镜胆囊切除术报告',
|
||||||
description: '标准手术记录模板',
|
description: '标准手术记录模板',
|
||||||
content: defaultTemplateContent,
|
content: DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
fields: [],
|
fields: [],
|
||||||
scope: 'DEPARTMENT',
|
scope: 'DEPARTMENT',
|
||||||
ownerDepartmentId: surgeryDepartment.id,
|
ownerDepartmentId: surgeryDepartment.id,
|
||||||
@@ -128,6 +131,25 @@ const main = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingSystemSettings = await prisma.systemSetting.findFirst({
|
||||||
|
where: { tenantId: tenant.id, scope: 'global', departmentId: null, key: 'systemSettings' },
|
||||||
|
});
|
||||||
|
if (existingSystemSettings) {
|
||||||
|
await prisma.systemSetting.update({
|
||||||
|
where: { id: existingSystemSettings.id },
|
||||||
|
data: { value: DEMO_SYSTEM_SETTINGS },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.systemSetting.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
scope: 'global',
|
||||||
|
key: 'systemSettings',
|
||||||
|
value: DEMO_SYSTEM_SETTINGS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import type { SafeUser } from './auth/auth.types.js';
|
|||||||
import { DashboardService } from './dashboard/dashboard.service.js';
|
import { DashboardService } from './dashboard/dashboard.service.js';
|
||||||
import { FilesService } from './files/files.service.js';
|
import { FilesService } from './files/files.service.js';
|
||||||
import { ReportsService } from './reports/reports.service.js';
|
import { ReportsService } from './reports/reports.service.js';
|
||||||
|
import {
|
||||||
|
DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
DEMO_XF_SPEECH_CONFIG,
|
||||||
|
} from './demo/demo-defaults.js';
|
||||||
|
import { SettingsService } from './settings/settings.service.js';
|
||||||
import { TemplatesService } from './templates/templates.service.js';
|
import { TemplatesService } from './templates/templates.service.js';
|
||||||
|
|
||||||
const databaseUrl =
|
const databaseUrl =
|
||||||
@@ -229,6 +234,63 @@ describe('Prisma-backed service integration', () => {
|
|||||||
await filesService.deleteFile(doctorActor, file.id);
|
await filesService.deleteFile(doctorActor, file.id);
|
||||||
await expect(filesService.readFile(doctorActor, file.id)).rejects.toThrow('文件不存在');
|
await expect(filesService.readFile(doctorActor, file.id)).rejects.toThrow('文件不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets the tenant back to demo mode defaults', async () => {
|
||||||
|
const settingsService = new SettingsService(prisma as never);
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
actorUserId: null,
|
||||||
|
actorRole: 'super',
|
||||||
|
action: 'test.audit',
|
||||||
|
targetType: 'Test',
|
||||||
|
targetId: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
departmentId: surgeryDepartmentId,
|
||||||
|
username: `${userPrefix}_temporary`,
|
||||||
|
passwordHash: await argon2.hash('123456'),
|
||||||
|
role: 'DOCTOR',
|
||||||
|
name: '临时医生',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: '临时模板',
|
||||||
|
content: '<p>temporary</p>',
|
||||||
|
fields: [],
|
||||||
|
scope: 'DEPARTMENT',
|
||||||
|
ownerDepartmentId: surgeryDepartmentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetSettings = await settingsService.resetSystemSettings(superActor);
|
||||||
|
|
||||||
|
await expect(prisma.report.count({ where: { tenantId } })).resolves.toBe(0);
|
||||||
|
await expect(prisma.auditLog.count({ where: { tenantId } })).resolves.toBe(0);
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({ where: { tenantId }, orderBy: { username: 'asc' } });
|
||||||
|
expect(users.map((user) => [user.username, user.name, user.role, user.status])).toEqual([
|
||||||
|
['0001', '张医生', 'DOCTOR', 'ACTIVE'],
|
||||||
|
['admin', '超级管理员', 'SUPER', 'ACTIVE'],
|
||||||
|
['manager', '科室管理员', 'ADMIN', 'ACTIVE'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const templates = await prisma.template.findMany({ where: { tenantId } });
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0]).toMatchObject({
|
||||||
|
name: '腹腔镜胆囊切除术报告',
|
||||||
|
content: DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
scope: 'DEPARTMENT',
|
||||||
|
});
|
||||||
|
expect(resetSettings.defaultTemplate).toBe(templates[0].id);
|
||||||
|
expect(resetSettings.aiProviders.kimi.apiKey).not.toBe('');
|
||||||
|
expect(resetSettings.xfSpeechConfig).toEqual(DEMO_XF_SPEECH_CONFIG);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const toActor = (
|
const toActor = (
|
||||||
|
|||||||
26
server/src/demo/demo-defaults.test.ts
Normal file
26
server/src/demo/demo-defaults.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultReportContent } from '../../../src/utils/defaultContent';
|
||||||
|
import {
|
||||||
|
DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
DEMO_SYSTEM_SETTINGS,
|
||||||
|
DEMO_TEMPLATE_ID,
|
||||||
|
DEMO_XF_SPEECH_CONFIG,
|
||||||
|
} from './demo-defaults';
|
||||||
|
|
||||||
|
describe('demo defaults', () => {
|
||||||
|
it('keeps the backend default template aligned with the report editor default content', () => {
|
||||||
|
expect(DEMO_TEMPLATE_ID).toBe('tpl_default_surgery');
|
||||||
|
expect(DEMO_DEFAULT_REPORT_CONTENT).toBe(defaultReportContent);
|
||||||
|
expect(DEMO_DEFAULT_REPORT_CONTENT).toContain('西 安 交 通 大 学 第 一 附 属 医 院');
|
||||||
|
expect(DEMO_DEFAULT_REPORT_CONTENT).toContain('data-ai-id="手术步骤"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes complete demo speech configuration for the backend proxy', () => {
|
||||||
|
expect(DEMO_SYSTEM_SETTINGS.defaultTemplate).toBe(DEMO_TEMPLATE_ID);
|
||||||
|
expect(DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey).not.toBe('');
|
||||||
|
expect(DEMO_SYSTEM_SETTINGS.xfSpeechConfig).toEqual(DEMO_XF_SPEECH_CONFIG);
|
||||||
|
expect(DEMO_XF_SPEECH_CONFIG.appId).not.toBe('');
|
||||||
|
expect(DEMO_XF_SPEECH_CONFIG.apiKey).not.toBe('');
|
||||||
|
expect(DEMO_XF_SPEECH_CONFIG.apiSecret).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
33
server/src/demo/demo-defaults.ts
Normal file
33
server/src/demo/demo-defaults.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -1,29 +1,35 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import argon2 from 'argon2';
|
||||||
import { AuditService } from '../audit/audit.service.js';
|
import { AuditService } from '../audit/audit.service.js';
|
||||||
import type { SafeUser } from '../auth/auth.types.js';
|
import type { SafeUser } from '../auth/auth.types.js';
|
||||||
|
import {
|
||||||
|
DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
DEMO_SYSTEM_SETTINGS,
|
||||||
|
DEMO_TEMPLATE_ID,
|
||||||
|
} from '../demo/demo-defaults.js';
|
||||||
import { isSuper } from '../permissions/permissions.policy.js';
|
import { isSuper } from '../permissions/permissions.policy.js';
|
||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
|
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
|
||||||
|
|
||||||
const DEFAULT_AI_PROVIDERS = {
|
const DEFAULT_AI_PROVIDERS = {
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
|
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'moonshot-v1-32k-vision-preview' },
|
||||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
||||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
||||||
custom: { endpoint: '', apiKey: '', modelName: '' },
|
custom: { endpoint: '', apiKey: '', modelName: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: SystemSettingsInput = {
|
const DEFAULT_SETTINGS: SystemSettingsInput = {
|
||||||
frameCount: 12,
|
frameCount: DEMO_SYSTEM_SETTINGS.frameCount,
|
||||||
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85, 96.3, 98.6],
|
framePositions: [...DEMO_SYSTEM_SETTINGS.framePositions],
|
||||||
defaultTemplate: '',
|
defaultTemplate: DEMO_SYSTEM_SETTINGS.defaultTemplate,
|
||||||
frameMode: 'keep',
|
frameMode: 'keep',
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: DEMO_SYSTEM_SETTINGS.activeAiProvider,
|
||||||
aiProviders: DEFAULT_AI_PROVIDERS,
|
aiProviders: DEFAULT_AI_PROVIDERS,
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: DEMO_SYSTEM_SETTINGS.autoInsertFrames,
|
||||||
autoInsertDelay: 1,
|
autoInsertDelay: DEMO_SYSTEM_SETTINGS.autoInsertDelay,
|
||||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
autoInsertFrameIndices: [...DEMO_SYSTEM_SETTINGS.autoInsertFrameIndices],
|
||||||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
|
xfSpeechConfig: DEMO_SYSTEM_SETTINGS.xfSpeechConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -106,17 +112,160 @@ export class SettingsService {
|
|||||||
if (!isSuper(this.actorToPolicy(actor))) {
|
if (!isSuper(this.actorToPolicy(actor))) {
|
||||||
throw new ForbiddenException('只有超级管理员可以重置系统设置');
|
throw new ForbiddenException('只有超级管理员可以重置系统设置');
|
||||||
}
|
}
|
||||||
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(DEFAULT_SETTINGS));
|
|
||||||
await this.audit?.record({
|
await this.resetDemoData(actor.tenantId);
|
||||||
actor,
|
|
||||||
action: 'settings.system.reset',
|
|
||||||
targetType: 'SystemSetting',
|
|
||||||
targetId: 'systemSettings',
|
|
||||||
metadata: { scope: 'global' },
|
|
||||||
});
|
|
||||||
return this.getSystemSettings(actor);
|
return this.getSystemSettings(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resetDemoData(tenantId: string) {
|
||||||
|
const passwordHash = await argon2.hash('123456');
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const tenant = await tx.tenant.upsert({
|
||||||
|
where: { code: 'default' },
|
||||||
|
update: {},
|
||||||
|
create: { code: 'default', name: '默认医院' },
|
||||||
|
});
|
||||||
|
const effectiveTenantId = tenantId || tenant.id;
|
||||||
|
const demoTemplateId = effectiveTenantId === tenant.id
|
||||||
|
? DEMO_TEMPLATE_ID
|
||||||
|
: `${DEMO_TEMPLATE_ID}_${effectiveTenantId}`;
|
||||||
|
|
||||||
|
const adminDepartment = await tx.department.upsert({
|
||||||
|
where: { tenantId_code: { tenantId: effectiveTenantId, code: 'admin' } },
|
||||||
|
update: { name: '管理部门' },
|
||||||
|
create: { tenantId: effectiveTenantId, code: 'admin', name: '管理部门' },
|
||||||
|
});
|
||||||
|
const surgeryDepartment = await tx.department.upsert({
|
||||||
|
where: { tenantId_code: { tenantId: effectiveTenantId, code: 'surgery' } },
|
||||||
|
update: { name: '外科' },
|
||||||
|
create: { tenantId: effectiveTenantId, code: 'surgery', name: '外科' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.report.deleteMany({ where: { tenantId: effectiveTenantId } });
|
||||||
|
await tx.fileResource.deleteMany({ where: { tenantId: effectiveTenantId } });
|
||||||
|
await tx.templateDepartmentPermission.deleteMany({
|
||||||
|
where: { template: { tenantId: effectiveTenantId } },
|
||||||
|
});
|
||||||
|
await tx.template.deleteMany({ where: { tenantId: effectiveTenantId } });
|
||||||
|
await tx.auditLog.deleteMany({ where: { tenantId: effectiveTenantId } });
|
||||||
|
await tx.systemSetting.deleteMany({ where: { tenantId: effectiveTenantId } });
|
||||||
|
await tx.userSession.deleteMany({ where: { user: { tenantId: effectiveTenantId } } });
|
||||||
|
|
||||||
|
await tx.user.deleteMany({
|
||||||
|
where: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
username: { notIn: ['admin', 'manager', '0001'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.user.upsert({
|
||||||
|
where: { tenantId_username: { tenantId: effectiveTenantId, username: 'admin' } },
|
||||||
|
update: {
|
||||||
|
departmentId: adminDepartment.id,
|
||||||
|
passwordHash,
|
||||||
|
role: 'SUPER',
|
||||||
|
name: '超级管理员',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
phone: null,
|
||||||
|
email: null,
|
||||||
|
signatureFileId: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
departmentId: adminDepartment.id,
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
role: 'SUPER',
|
||||||
|
name: '超级管理员',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.user.upsert({
|
||||||
|
where: { tenantId_username: { tenantId: effectiveTenantId, username: 'manager' } },
|
||||||
|
update: {
|
||||||
|
departmentId: surgeryDepartment.id,
|
||||||
|
passwordHash,
|
||||||
|
role: 'ADMIN',
|
||||||
|
name: '科室管理员',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
phone: null,
|
||||||
|
email: null,
|
||||||
|
signatureFileId: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
departmentId: surgeryDepartment.id,
|
||||||
|
username: 'manager',
|
||||||
|
passwordHash,
|
||||||
|
role: 'ADMIN',
|
||||||
|
name: '科室管理员',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.user.upsert({
|
||||||
|
where: { tenantId_username: { tenantId: effectiveTenantId, username: '0001' } },
|
||||||
|
update: {
|
||||||
|
departmentId: surgeryDepartment.id,
|
||||||
|
passwordHash,
|
||||||
|
role: 'DOCTOR',
|
||||||
|
name: '张医生',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
phone: null,
|
||||||
|
email: null,
|
||||||
|
signatureFileId: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
departmentId: surgeryDepartment.id,
|
||||||
|
username: '0001',
|
||||||
|
passwordHash,
|
||||||
|
role: 'DOCTOR',
|
||||||
|
name: '张医生',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.department.deleteMany({
|
||||||
|
where: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
code: { notIn: ['admin', 'surgery'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await tx.template.create({
|
||||||
|
data: {
|
||||||
|
id: demoTemplateId,
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
name: '腹腔镜胆囊切除术报告',
|
||||||
|
description: '标准手术记录模板',
|
||||||
|
content: DEMO_DEFAULT_REPORT_CONTENT,
|
||||||
|
fields: [],
|
||||||
|
scope: 'DEPARTMENT',
|
||||||
|
ownerDepartmentId: surgeryDepartment.id,
|
||||||
|
ownerUserId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.templateDepartmentPermission.create({
|
||||||
|
data: {
|
||||||
|
templateId: template.id,
|
||||||
|
departmentId: surgeryDepartment.id,
|
||||||
|
canUse: true,
|
||||||
|
canManage: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.systemSetting.create({
|
||||||
|
data: {
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
scope: 'global',
|
||||||
|
key: 'systemSettings',
|
||||||
|
value: this.toJson({ ...DEFAULT_SETTINGS, defaultTemplate: demoTemplateId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private normalize(input: SystemSettingsInput): SystemSettingsInput {
|
private normalize(input: SystemSettingsInput): SystemSettingsInput {
|
||||||
const aiProviders = {
|
const aiProviders = {
|
||||||
...DEFAULT_AI_PROVIDERS,
|
...DEFAULT_AI_PROVIDERS,
|
||||||
|
|||||||
@@ -204,44 +204,22 @@ export default function SystemSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetToDefault = async () => {
|
const resetToDefault = async () => {
|
||||||
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
const firstConfirm = window.confirm('确定要恢复演示出厂设置吗?这会清空报告、审计日志、自定义模板和非默认用户,并恢复默认演示账号、模板、AI/语音配置。');
|
||||||
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
if (!firstConfirm) return;
|
||||||
frameCount: 12,
|
|
||||||
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
|
||||||
defaultTemplate: templates[0]?.id || '',
|
|
||||||
frameMode: 'keep',
|
|
||||||
activeAiProvider: 'kimi',
|
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
|
||||||
autoInsertFrames: true,
|
|
||||||
autoInsertDelay: 1,
|
|
||||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
|
||||||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' }
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const resetSettings = await resetSystemSettings();
|
|
||||||
const normalized = normalizeSettings(resetSettings, templates);
|
|
||||||
setSettings(normalized);
|
|
||||||
storage.set('systemSettings', normalized);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isLocalFallbackEnabled()) {
|
|
||||||
alert(`重置失败: ${(error as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.warn('Reset settings API failed, keeping local compatibility path.', error);
|
|
||||||
setSettings(defaultSettings);
|
|
||||||
storage.set('systemSettings', defaultSettings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAllData = () => {
|
const secondConfirm = window.confirm('请再次确认:该操作会重置当前后端演示数据,且无法从页面撤销。是否继续?');
|
||||||
if (!isLocalFallbackEnabled()) {
|
if (!secondConfirm) return;
|
||||||
alert('生产模式下仅允许清理当前浏览器缓存,请联系管理员执行后端数据维护。');
|
|
||||||
return;
|
try {
|
||||||
}
|
const resetSettings = await resetSystemSettings();
|
||||||
if (window.confirm('确定要重置全部数据吗?这将清除所有报告、模板和用户设置。')) {
|
const normalized = normalizeSettings(resetSettings, templates);
|
||||||
|
setSettings(normalized);
|
||||||
|
storage.set('systemSettings', normalized);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`恢复演示出厂设置失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -592,14 +570,7 @@ export default function SystemSettings() {
|
|||||||
onClick={resetToDefault}
|
onClick={resetToDefault}
|
||||||
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
|
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
恢复系统设置出厂设置
|
恢复演示出厂设置
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetAllData}
|
|
||||||
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
重置全部数据
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user