backup at 2026-04-16-16-39-42
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
203
AGENTS.md
Normal file
203
AGENTS.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 手术图文病历报告系统 —— AI 代理开发指南
|
||||||
|
|
||||||
|
> 本文档面向 AI 编码代理。若你正在阅读此文件,说明你对该项目一无所知,请仔细阅读后再修改代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
**手术图文病历报告系统**(Gemini-图文报告系统-V1.1)是一款基于 **React 19 + TypeScript + Vite + Tailwind CSS 4** 开发的纯前端医疗图文报告管理应用。所有数据持久化在浏览器 `localStorage` 中,无需后端服务即可独立运行。
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **图文报告生成**:基于 `contentEditable` 的富文本编辑器,支持插入表格、图片占位符,可从本地或手术视频中截取关键帧插入报告。
|
||||||
|
- **报告管理**:搜索、筛选、查看、编辑、打印、删除报告;支持历史版本回溯。
|
||||||
|
- **模板管理**:创建和维护报告标准模板,新建报告时自动加载默认模板。
|
||||||
|
- **用户管理**:基于角色的权限控制(超级管理员 / 管理员 / 医生)。
|
||||||
|
- **系统设置**:配置视频自动抽帧百分比、AI API 接口地址、默认模板等全局参数。
|
||||||
|
|
||||||
|
### 默认测试账号
|
||||||
|
| 账号 | 密码 | 角色 |
|
||||||
|
|---------|--------|------------|
|
||||||
|
| admin | 123456 | 超级管理员 |
|
||||||
|
| manager | 123456 | 管理员 |
|
||||||
|
| 0001 | 123456 | 医生 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈与运行时架构
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **框架**:React 19(函数组件 + Hooks)
|
||||||
|
- **路由**:React Router DOM 7(`BrowserRouter`)
|
||||||
|
- **构建工具**:Vite 6
|
||||||
|
- **样式**:Tailwind CSS 4(使用 `@import "tailwindcss"` 和 `@theme` 语法)
|
||||||
|
- **图标**:Lucide React
|
||||||
|
- **动画**:Motion
|
||||||
|
- **语言**:TypeScript 5.8(`tsconfig.json` 中 `jsx: "react-jsx"`、`moduleResolution: "bundler"`)
|
||||||
|
|
||||||
|
### 运行时架构
|
||||||
|
- **纯前端 SPA**:无后端 API,所有业务逻辑在浏览器端执行。
|
||||||
|
- **数据存储**:全部使用 `localStorage`(通过 `src/utils/storage.ts` 封装)和少量 `sessionStorage`(用于版本恢复)。
|
||||||
|
- **安全模型**:客户端认证授权,密码以**明文**形式保存在 `localStorage` 中。项目设计用于内网或受信任环境,**切勿直接暴露到公网**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 项目目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── docker-compose.yaml # Docker Compose 配置(端口 8080:80)
|
||||||
|
├── docker-compose.qnap.yml # QNAP 专用 Docker Compose
|
||||||
|
├── Dockerfile # 多阶段构建:node:20-alpine -> nginx:alpine
|
||||||
|
├── nginx.conf # Nginx SPA 回退配置(try_files)
|
||||||
|
├── package.json # 依赖与脚本
|
||||||
|
├── vite.config.ts # Vite 配置(含 GEMINI_API_KEY 注入)
|
||||||
|
├── tsconfig.json # TypeScript 配置(paths: "@/*": "./*")
|
||||||
|
├── index.html # Vite 入口 HTML
|
||||||
|
├── public/ # 静态资源(logo、favicon)
|
||||||
|
└── src/
|
||||||
|
├── App.tsx # 根组件与路由表
|
||||||
|
├── main.tsx # 应用入口(createRoot + StrictMode)
|
||||||
|
├── index.css # 全局样式、Tailwind 主题、打印样式、编辑器专用样式
|
||||||
|
├── types.ts # 核心 TypeScript 类型定义
|
||||||
|
├── components/
|
||||||
|
│ └── Sidebar.tsx # 左侧导航栏(按角色过滤菜单)
|
||||||
|
├── pages/
|
||||||
|
│ ├── Login.tsx # 登录页(初始化默认数据)
|
||||||
|
│ ├── Dashboard.tsx # 工作台概览(统计图表 + 快捷入口)
|
||||||
|
│ ├── ReportEditor.tsx # 图文报告编辑器(最复杂的页面)
|
||||||
|
│ ├── ReportManage.tsx # 报告列表管理
|
||||||
|
│ ├── ReportView.tsx # 报告查看/打印
|
||||||
|
│ ├── TemplateManage.tsx # 模板管理
|
||||||
|
│ ├── UserManage.tsx # 用户管理
|
||||||
|
│ └── SystemSettings.tsx # 系统设置
|
||||||
|
└── utils/
|
||||||
|
├── storage.ts # localStorage/sessionStorage 封装
|
||||||
|
├── print.ts # 基于 iframe 的 A4 打印实现
|
||||||
|
└── defaultContent.ts # 默认手术报告模板 HTML 字符串
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 构建、运行与部署
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器(端口 3000,监听 0.0.0.0)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可用脚本(package.json)
|
||||||
|
| 脚本 | 作用 |
|
||||||
|
|-----------|-----------------------------------|
|
||||||
|
| `dev` | `vite --port=3000 --host=0.0.0.0` |
|
||||||
|
| `build` | `vite build`(输出到 `dist/`) |
|
||||||
|
| `preview` | `vite preview` |
|
||||||
|
| `lint` | `tsc --noEmit`(类型检查) |
|
||||||
|
| `clean` | `rm -rf dist` |
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
复制 `.env.example` 为 `.env.local`(或 `.env`):
|
||||||
|
- `GEMINI_API_KEY`:Google Gemini API 密钥(预留 AI 功能,Vite 会在构建时通过 `define` 注入为 `process.env.GEMINI_API_KEY`)。
|
||||||
|
- `APP_URL`:应用部署后的访问地址。
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
```bash
|
||||||
|
# 构建并启动(访问 http://localhost:8080)
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
- **构建阶段**:`node:20-alpine` 执行 `npm ci` + `npm run build`
|
||||||
|
- **运行阶段**:`nginx:alpine` 托管 `dist/` 静态资源
|
||||||
|
- **SPA 支持**:`nginx.conf` 已配置 `try_files $uri $uri/ /index.html;`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 代码组织与开发约定
|
||||||
|
|
||||||
|
### 路由结构
|
||||||
|
所有路由定义在 `src/App.tsx`:
|
||||||
|
- `/` → 登录页
|
||||||
|
- `/dashboard` → 工作台
|
||||||
|
- `/report-editor` → 新建报告(`?id=xxx` 为编辑)
|
||||||
|
- `/report-view/:id` → 查看报告
|
||||||
|
- `/report-manage` → 报告管理
|
||||||
|
- `/template-manage` → 模板管理
|
||||||
|
- `/user-manage` → 用户管理
|
||||||
|
- `/system-settings` → 系统设置
|
||||||
|
|
||||||
|
### 权限模型
|
||||||
|
角色分为三种:`super`(超级管理员)、`admin`(管理员)、`user`(医生)。
|
||||||
|
- 各页面在 `useEffect` 中读取 `storage.get('currentUser')`,未登录则 `navigate('/')`。
|
||||||
|
- `Sidebar.tsx` 的 `navItems` 按 `roles` 数组过滤菜单。
|
||||||
|
- `user` 角色只能查看/编辑自己创建的报告;`super`/`admin` 可查看全院报告。
|
||||||
|
|
||||||
|
### 数据持久化约定
|
||||||
|
- **禁止直接调用 `localStorage`**,统一使用 `src/utils/storage.ts` 中的 `storage.get / storage.set / storage.remove`。
|
||||||
|
- localStorage 中存储的 key 包括:`users`、`reports`、`templates`、`systemSettings`、`currentUser`、`multiSelectOptions`、`anesthesiaOptions`、`reportEditorDraft_{username}`、`restore_{reportId}`(sessionStorage)。
|
||||||
|
- 报告编辑器会在 `beforeunload` 和 `visibilitychange` 时自动保存草稿到 `reportEditorDraft_{username}`。
|
||||||
|
|
||||||
|
### 样式约定
|
||||||
|
- 全局使用 Tailwind 工具类;自定义设计变量定义在 `src/index.css` 的 `@theme` 中(如 `--color-bg`、`--color-accent`)。
|
||||||
|
- 通用组件类在 `index.css` 的 `@layer components` 中定义:`.btn-accent`、`.card-minimal`、`.input-minimal`。
|
||||||
|
- **编辑器样式**(`.editor-content`、`.editor-content-wrapper`、`.image-placeholder`)和 **打印样式**(`@media print`)集中在 `index.css` 中维护。
|
||||||
|
- A4 打印尺寸为 `210mm × 297mm`,打印时通过 `visibility` 控制只显示 `.print-content` 区域。
|
||||||
|
|
||||||
|
### 编辑器实现细节
|
||||||
|
- `ReportEditor.tsx` 使用原生 `contentEditable` + `document.execCommand` 实现富文本,而非第三方编辑器。
|
||||||
|
- 图片通过 `.image-placeholder` 占位符插入,支持两种填充方式:
|
||||||
|
1. 点击占位符上传本地图片(Base64 存入 HTML)。
|
||||||
|
2. 从右侧“视频分析”面板拖拽自动抽帧的截图到占位符中。
|
||||||
|
- 视频中上传后会根据 `systemSettings.framePositions` 自动抽帧(通过 `<video>` + `<canvas>` 实现)。
|
||||||
|
|
||||||
|
### TypeScript 类型
|
||||||
|
核心类型定义在 `src/types.ts`:
|
||||||
|
- `User`:用户,角色为 `'super' | 'admin' | 'user'`
|
||||||
|
- `Report`:报告,状态为 `'draft' | 'completed'`,含 `content`(HTML 字符串)
|
||||||
|
- `Template`:模板,结构与报告内容类似
|
||||||
|
- `SystemSettings`:系统设置,含 `frameCount`、`framePositions`、`apiEndpoint` 等
|
||||||
|
- `CapturedFrame`:视频抽帧结果
|
||||||
|
|
||||||
|
### 路径别名
|
||||||
|
- `vite.config.ts` 和 `tsconfig.json` 均配置了 `@/` 指向项目根目录(`.`)。
|
||||||
|
- 源码中导入使用相对路径(如 `../utils/storage`)或 `@/` 均可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
**当前项目没有单元测试或 E2E 测试框架。**
|
||||||
|
|
||||||
|
- 唯一可用的质量检查命令是 `npm run lint`,它执行 `tsc --noEmit` 进行全量类型检查。
|
||||||
|
- 在修改代码后,**务必运行 `npm run lint` 确保无 TypeScript 编译错误**。
|
||||||
|
- 若你引入了新依赖或修改了复杂交互逻辑,建议在本地通过 `npm run dev` 进行手工功能验证(可快速使用登录页的“快捷登录测试账号”)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 安全与部署注意事项
|
||||||
|
|
||||||
|
### 安全警告(必读)
|
||||||
|
1. **无后端哈希**:用户密码以明文保存在浏览器 `localStorage` 中。
|
||||||
|
2. **客户端鉴权**:所有权限判断都在前端执行,易被绕过。
|
||||||
|
3. **因此,该应用仅适合部署在医院内网、受信任的局域网或单人使用的环境中,严禁直接暴露于公网。**
|
||||||
|
|
||||||
|
### 部署检查清单
|
||||||
|
- [ ] `nginx.conf` 中的 `try_files` 确保 SPA 刷新不 404。
|
||||||
|
- [ ] `dist/` 构建产物已包含在 Docker 镜像中。
|
||||||
|
- [ ] 若启用 AI 功能,需正确配置 `GEMINI_API_KEY` 环境变量(Vite 在构建时注入,修改后需重新构建)。
|
||||||
|
- [ ] 确保最终运行环境可访问 `https://fonts.googleapis.com/css2?family=Inter`(否则页面字体会降级为系统默认字体)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 给 AI 代理的快速备忘
|
||||||
|
|
||||||
|
- **不要直接操作 `localStorage`**,用 `src/utils/storage.ts`。
|
||||||
|
- **不要引入重型富文本编辑器**,现有方案基于 `contentEditable` + `document.execCommand`,保持轻量。
|
||||||
|
- **打印逻辑**已封装在 `src/utils/print.ts`,需要打印 A4 报告时直接调用 `printDocument(htmlContent)`。
|
||||||
|
- **修改样式时优先检查 `src/index.css`**,Tailwind v4 的主题变量和打印样式都在那里。
|
||||||
|
- **添加新页面后**,记得在 `src/App.tsx` 注册路由,并在 `src/components/Sidebar.tsx` 的 `navItems` 中配置菜单和可见角色。
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
118
README.md
Normal file
118
README.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 手术图文病历报告系统
|
||||||
|
|
||||||
|
基于 **React 19 + TypeScript + Vite + Tailwind CSS** 开发的医疗图文报告管理前端应用。
|
||||||
|
|
||||||
|
> 适用于医院场景,支持手术记录图文报告的撰写、视频关键帧抽取、模板管理以及基于角色的用户权限控制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **图文报告生成**:富文本编辑器撰写手术记录,支持本地上传图片或从手术视频中截取关键帧插入报告。
|
||||||
|
- **报告管理**:搜索、筛选、查看、编辑、打印和删除报告。
|
||||||
|
- **模板管理**:创建和维护报告标准模板,新建报告时可自动加载默认模板。
|
||||||
|
- **用户管理**:超级管理员可创建/编辑/删除用户,分配角色和可视模板权限。
|
||||||
|
- **系统设置**:配置视频抽帧百分比位置、AI API 接口地址、默认模板等全局参数。
|
||||||
|
- **数据持久化**:所有数据存储在浏览器 `localStorage` 中,无需后端服务即可独立运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 19
|
||||||
|
- React Router DOM 7
|
||||||
|
- TypeScript 5.8
|
||||||
|
- Vite 6
|
||||||
|
- Tailwind CSS 4
|
||||||
|
- Lucide React(图标)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器(端口 3000)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env.local` 并填入实际值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GEMINI_API_KEY`:Google Gemini API 密钥(预留 AI 功能)
|
||||||
|
- `APP_URL`:应用部署后的访问地址
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
项目已内置 `Dockerfile`、`nginx.conf` 和 `docker-compose.yaml`,可直接构建并运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像并启动容器
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 访问应用
|
||||||
|
# http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建说明
|
||||||
|
|
||||||
|
- **构建阶段**:使用 `node:20-alpine` 执行 `npm ci` 和 `npm run build`
|
||||||
|
- **运行阶段**:使用 `nginx:alpine` 托管 `dist/` 静态文件
|
||||||
|
- **SPA 支持**:`nginx.conf` 已配置 `try_files` 路由回退,刷新页面不 404
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 默认测试账号
|
||||||
|
|
||||||
|
| 账号 | 密码 | 角色 |
|
||||||
|
|----------|--------|------------|
|
||||||
|
| admin | 123456 | 超级管理员 |
|
||||||
|
| manager | 123456 | 管理员 |
|
||||||
|
| doctor | 123456 | 医生 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── docker-compose.yaml # Docker Compose 配置
|
||||||
|
├── Dockerfile # 多阶段构建镜像
|
||||||
|
├── nginx.conf # Nginx SPA 路由配置
|
||||||
|
├── package.json # 项目依赖与脚本
|
||||||
|
├── public/ # 静态资源(logo、favicon)
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # 公共组件
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ ├── utils/ # 工具函数(storage、print、defaultContent)
|
||||||
|
│ ├── App.tsx # 根组件与路由
|
||||||
|
│ ├── main.tsx # 应用入口
|
||||||
|
│ ├── index.css # 全局样式与 Tailwind 主题
|
||||||
|
│ └── types.ts # TypeScript 类型定义
|
||||||
|
└── index.html # Vite 入口 HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
- 本应用为纯前端应用,所有认证和授权逻辑在客户端执行。
|
||||||
|
- 用户密码以明文形式保存在浏览器 `localStorage` 中(无后端哈希处理)。
|
||||||
|
- 若用于生产环境,请确保部署在内网或受信任环境中。
|
||||||
49
docker-compose.qnap.yml
Normal file
49
docker-compose.qnap.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
tuwen_system:
|
||||||
|
# 使用官方 Node 镜像,无需 Dockerfile 构建
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: tuwen_system
|
||||||
|
|
||||||
|
# 将 NAS 上的源码目录挂载到容器内
|
||||||
|
volumes:
|
||||||
|
- /share/Container/tuwen_system:/app
|
||||||
|
working_dir: /app
|
||||||
|
|
||||||
|
# 端口映射:NAS 4002 端口 -> 容器 3000 端口
|
||||||
|
# (因为 package.json 中 dev 脚本固定为 --port=3000)
|
||||||
|
ports:
|
||||||
|
- "4002:3000"
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
|
||||||
|
# 启动命令:先检查 node_modules 是否存在,避免每次重启都重新安装
|
||||||
|
# 然后运行开发服务(package.json 中已带 --host=0.0.0.0)
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
echo 'Installing dependencies...' &&
|
||||||
|
npm install;
|
||||||
|
else
|
||||||
|
echo 'Dependencies already installed, skipping npm install...';
|
||||||
|
fi &&
|
||||||
|
echo 'Fixing binary permissions...' &&
|
||||||
|
chmod -R +x node_modules/.bin/ 2>/dev/null || true &&
|
||||||
|
echo 'Starting dev server...' &&
|
||||||
|
npm run dev
|
||||||
|
"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# 应用访问地址(用于系统内跳转链接等场景)
|
||||||
|
- APP_URL=http://192.168.31.5:4002
|
||||||
|
|
||||||
|
# 网络代理配置(国内环境下加速 npm install)
|
||||||
|
# 如果不需要代理,可将下面 5 行注释掉
|
||||||
|
- HTTP_PROXY=http://192.168.31.7:7893
|
||||||
|
- HTTPS_PROXY=http://192.168.31.7:7893
|
||||||
|
- http_proxy=http://192.168.31.7:7893
|
||||||
|
- https_proxy=http://192.168.31.7:7893
|
||||||
|
- NO_PROXY=localhost,127.0.0.1,192.168.31.0/24
|
||||||
11
docker-compose.yaml
Normal file
11
docker-compose.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: medical-report-app
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
18
nginx.conf
Normal file
18
nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
4362
package-lock.json
generated
Normal file
4362
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
public/logo_square.png
Normal file
BIN
public/logo_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
27
src/App.tsx
Normal file
27
src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import ReportEditor from './pages/ReportEditor';
|
||||||
|
import ReportManage from './pages/ReportManage';
|
||||||
|
import ReportView from './pages/ReportView';
|
||||||
|
import TemplateManage from './pages/TemplateManage';
|
||||||
|
import UserManage from './pages/UserManage';
|
||||||
|
import SystemSettings from './pages/SystemSettings';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Login />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/report-editor" element={<ReportEditor />} />
|
||||||
|
<Route path="/report-manage" element={<ReportManage />} />
|
||||||
|
<Route path="/report-view/:id" element={<ReportView />} />
|
||||||
|
<Route path="/template-manage" element={<TemplateManage />} />
|
||||||
|
<Route path="/user-manage" element={<UserManage />} />
|
||||||
|
<Route path="/system-settings" element={<SystemSettings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/Sidebar.tsx
Normal file
81
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FileEdit,
|
||||||
|
FileText,
|
||||||
|
Layout,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
LogOut
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentUser = storage.get<User>('currentUser', {} as User);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
storage.remove('currentUser');
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/dashboard', icon: <LayoutDashboard size={18} />, title: '工作台', roles: ['super', 'admin', 'user'] },
|
||||||
|
{ path: '/report-editor', icon: <FileEdit size={18} />, title: '图文报告生成', roles: ['super', 'admin', 'user'] },
|
||||||
|
{ path: '/report-manage', icon: <FileText size={18} />, title: '报告管理', roles: ['super', 'admin', 'user'] },
|
||||||
|
{ path: '/template-manage', icon: <Layout size={18} />, title: '模板管理', roles: ['super', 'admin'] },
|
||||||
|
{ path: '/user-manage', icon: <Users size={18} />, title: '用户管理', roles: ['super', 'admin'] },
|
||||||
|
{ path: '/system-settings', icon: <Settings size={18} />, title: '系统设置', roles: ['super', 'admin', 'user'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredNavItems = navItems.filter(item => item.roles.includes(currentUser.role));
|
||||||
|
const isCollapsed = location.pathname === '/report-editor' || location.pathname === '/template-manage';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`${isCollapsed ? 'w-20 px-3' : 'w-60 px-6'} bg-sidebar-bg border-r border-border flex flex-col py-8 shrink-0 h-screen sticky top-0 transition-all`}>
|
||||||
|
<div className={`flex items-center gap-3 mb-12 h-12 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||||
|
<img src="/logo_square.png" alt="Logo" className="w-10 h-10 object-contain shrink-0" />
|
||||||
|
<div className={`font-bold text-lg text-text-main leading-tight ${isCollapsed ? 'hidden' : ''}`}>
|
||||||
|
<div>图文手术病历</div>
|
||||||
|
<div>报告生成终端</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1 flex-1">
|
||||||
|
{filteredNavItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
title={item.title}
|
||||||
|
className={`flex items-center rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||||
|
} ${
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'bg-[#EFF6FF] text-accent'
|
||||||
|
: 'text-text-muted hover:bg-bg hover:text-text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className={isCollapsed ? 'hidden' : ''}>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={`mt-auto pt-5 border-t border-border ${isCollapsed ? 'text-center' : ''}`}>
|
||||||
|
<div className={`text-[12px] text-text-muted mb-1 ${isCollapsed ? 'hidden' : ''}`}>当前用户</div>
|
||||||
|
<div className={`font-semibold text-sm text-text-main mb-4 ${isCollapsed ? 'hidden' : ''}`}>{currentUser.name || '未登录'}</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
title="退出登录"
|
||||||
|
className={`flex items-center rounded-lg text-sm font-medium text-text-muted hover:bg-red-50 hover:text-red-600 transition-all ${isCollapsed ? 'justify-center w-full px-2 py-2.5' : 'gap-3 px-3 py-2.5 w-full'}`}
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span className={isCollapsed ? 'hidden' : ''}>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/index.css
Normal file
127
src/index.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--color-bg: #F8FAFC;
|
||||||
|
--color-sidebar-bg: #FFFFFF;
|
||||||
|
--color-accent: #2563EB;
|
||||||
|
--color-text-main: #1E293B;
|
||||||
|
--color-text-muted: #64748B;
|
||||||
|
--color-border: #E2E8F0;
|
||||||
|
--color-card-bg: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-bg text-text-main antialiased;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-accent {
|
||||||
|
@apply bg-accent text-white px-5 py-2.5 rounded-lg font-semibold text-sm transition-all hover:opacity-90 active:scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-minimal {
|
||||||
|
@apply bg-card-bg border border-border rounded-xl shadow-[0_1px_3px_rgba(0,0,0,0.05)] p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-minimal {
|
||||||
|
@apply w-full px-4 py-2.5 border border-border rounded-lg text-sm transition-colors focus:outline-hidden focus:border-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor Styles */
|
||||||
|
.editor-content-wrapper {
|
||||||
|
@apply flex-1 overflow-auto flex justify-center min-w-fit bg-[#e2e8f0] p-6;
|
||||||
|
}
|
||||||
|
.editor-content {
|
||||||
|
@apply w-[210mm] min-h-[297mm] h-auto bg-white p-[40px_48px] shadow-[0_2px_8px_rgba(0,0,0,0.15)] outline-hidden leading-relaxed text-text-main text-sm flex-shrink-0 overflow-visible relative;
|
||||||
|
}
|
||||||
|
.editor-content:focus { outline: none; }
|
||||||
|
.editor-content p { margin: 0; padding: 4px 0; }
|
||||||
|
.editor-content h1 { font-size: 22px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||||
|
.editor-content strong, .editor-content b { font-weight: 600; }
|
||||||
|
.editor-content u { text-decoration: underline; }
|
||||||
|
.editor-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.editor-content td {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.editor-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
@apply border-2 border-dashed border-[#cbd5e1] rounded-lg p-4 mb-2 bg-[#f8fafc] cursor-pointer min-h-[70px] flex flex-col items-center justify-center transition-all relative;
|
||||||
|
}
|
||||||
|
.image-placeholder:hover {
|
||||||
|
@apply border-accent bg-[#f0f7ff];
|
||||||
|
}
|
||||||
|
.image-placeholder.has-image {
|
||||||
|
@apply border-none bg-transparent p-0 min-h-0 cursor-default;
|
||||||
|
}
|
||||||
|
.image-placeholder .delete-btn {
|
||||||
|
@apply absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full items-center justify-center text-[10px] cursor-pointer z-10;
|
||||||
|
display: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.image-placeholder:hover .delete-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.image-placeholder .placeholder-text {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.image-placeholder.has-image .placeholder-text {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.template-info-section {
|
||||||
|
@apply relative mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-frame-badge {
|
||||||
|
@apply absolute top-1 left-1 px-1.5 py-0.5 bg-yellow-400 text-yellow-900 text-[9px] font-bold rounded shadow-sm pointer-events-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
body * { visibility: hidden !important; }
|
||||||
|
.print-content, .print-content * { visibility: visible !important; }
|
||||||
|
.print-wrapper {
|
||||||
|
position: static !important;
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.print-content {
|
||||||
|
position: static !important;
|
||||||
|
width: 210mm !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 10mm !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
.print-content .image-placeholder:not(.has-image) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
213
src/pages/Dashboard.tsx
Normal file
213
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { FileText, Layout, Plus, Settings, TrendingUp, ArrowRight } from 'lucide-react';
|
||||||
|
import { User, Report, Template } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
reportCount: 0,
|
||||||
|
templateCount: 0,
|
||||||
|
userCount: 0,
|
||||||
|
todayCount: 0,
|
||||||
|
trend: [0,0,0,0,0,0,0],
|
||||||
|
trendLabels: ['','','','','','',''],
|
||||||
|
maxTrend: 1
|
||||||
|
});
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
// Load stats
|
||||||
|
const reports = storage.get<Report[]>('reports', []);
|
||||||
|
const templates = storage.get<Template[]>('templates', []);
|
||||||
|
const users = storage.get<User[]>('users', []);
|
||||||
|
|
||||||
|
const userReports = user.role === 'user'
|
||||||
|
? reports.filter(r => r.author === user.username)
|
||||||
|
: reports;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const todayReports = userReports.filter(r => r.createdAt === today);
|
||||||
|
|
||||||
|
// 7-day trend data
|
||||||
|
const trend: number[] = [];
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
const label = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
labels.push(label);
|
||||||
|
trend.push(userReports.filter(r => r.createdAt === dateStr).length);
|
||||||
|
}
|
||||||
|
const maxTrend = Math.max(...trend, 1);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
reportCount: userReports.length,
|
||||||
|
templateCount: templates.length,
|
||||||
|
userCount: users.length,
|
||||||
|
todayCount: todayReports.length,
|
||||||
|
trend,
|
||||||
|
trendLabels: labels,
|
||||||
|
maxTrend
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-bg">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 p-10 overflow-y-auto">
|
||||||
|
<header className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-text-main">工作台概览</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1">实时报告动态与系统状态追踪。</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/report-editor" className="btn-accent inline-flex items-center gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
新建报告
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">本月报告总数</div>
|
||||||
|
<div className="text-3xl font-bold text-text-main">{stats.reportCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">今日新增报告</div>
|
||||||
|
<div className="text-3xl font-bold text-text-main">{stats.todayCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">系统总用户</div>
|
||||||
|
<div className="text-3xl font-bold text-text-main">{stats.userCount}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-6">
|
||||||
|
<div className="card-minimal flex flex-col">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<span className="font-bold text-sm uppercase tracking-wider text-text-main flex items-center gap-2">
|
||||||
|
<TrendingUp size={16} className="text-accent" />
|
||||||
|
报告增长趋势
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-accent font-bold uppercase tracking-wider">最近 7 天</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative">
|
||||||
|
{/* SVG Area Chart */}
|
||||||
|
<svg viewBox="0 0 300 120" className="w-full h-full overflow-visible">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" />
|
||||||
|
<stop offset="100%" stopColor="#2563EB" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Grid lines */}
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1="0"
|
||||||
|
y1={i * 30}
|
||||||
|
x2="300"
|
||||||
|
y2={i * 30}
|
||||||
|
stroke="#E2E8F0"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Area path */}
|
||||||
|
{stats.trend.length > 0 && (() => {
|
||||||
|
const paddingX = 10;
|
||||||
|
const paddingY = 8;
|
||||||
|
const chartW = 300 - paddingX * 2;
|
||||||
|
const chartH = 120 - paddingY * 2;
|
||||||
|
const points = stats.trend.map((count, i) => {
|
||||||
|
const x = paddingX + (i / (stats.trend.length - 1)) * chartW;
|
||||||
|
const y = paddingY + chartH - (stats.maxTrend > 0 ? (count / stats.maxTrend) * chartH : 0);
|
||||||
|
return { x, y, count, label: stats.trendLabels[i] };
|
||||||
|
});
|
||||||
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
||||||
|
const areaPath = `${linePath} L ${points[points.length - 1].x} ${120 - paddingY} L ${points[0].x} ${120 - paddingY} Z`;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path d={areaPath} fill="url(#trendGradient)" />
|
||||||
|
<path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
|
||||||
|
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
|
||||||
|
<text x={p.x} y={120 - 2} textAnchor="middle" fontSize="8" fill="#94A3B8" fontWeight="bold">{p.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="font-bold text-sm uppercase tracking-wider text-text-main mb-6">快捷入口</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link to="/report-manage" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||||
|
<FileText size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-text-main">报告管理</div>
|
||||||
|
<div className="text-[11px] text-text-muted">查看与搜索所有报告</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{(currentUser.role === 'super' || currentUser.role === 'admin') && (
|
||||||
|
<Link to="/template-manage" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||||
|
<Layout size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-text-main">模板管理</div>
|
||||||
|
<div className="text-[11px] text-text-muted">配置报告标准模板</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUser.role === 'super' && (
|
||||||
|
<Link to="/system-settings" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||||
|
<Settings size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-text-main">系统设置</div>
|
||||||
|
<div className="text-[11px] text-text-muted">配置抽帧与API参数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/pages/Login.tsx
Normal file
207
src/pages/Login.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { User, Template, SystemSettings } from '../types';
|
||||||
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
import { User as UserIcon, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initData = () => {
|
||||||
|
const existingUsers = storage.get<User[]>('users', []);
|
||||||
|
const hasAdmin = existingUsers.some((u) => u.username === 'admin' && u.password === '123456');
|
||||||
|
|
||||||
|
let savedTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
if (savedTemplates.length === 0) {
|
||||||
|
const initialTemplate: Template = {
|
||||||
|
id: 'surgery',
|
||||||
|
name: '腹腔镜胆囊切除术报告',
|
||||||
|
desc: '标准手术记录模板',
|
||||||
|
content: defaultReportContent,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author: 'admin'
|
||||||
|
};
|
||||||
|
savedTemplates = [initialTemplate];
|
||||||
|
storage.set('templates', savedTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAdmin) {
|
||||||
|
const allTplIds = savedTemplates.map(t => t.id);
|
||||||
|
const defaultUsers: User[] = [
|
||||||
|
{ username: 'admin', password: '123456', role: 'super', name: '超级管理员', status: 'active', createdAt: '2024-01-01', visibleTemplates: allTplIds, manageableTemplates: allTplIds },
|
||||||
|
{ username: 'manager', password: '123456', role: 'admin', name: '管理员', status: 'active', createdAt: '2024-01-01', department: '外科', visibleTemplates: allTplIds, manageableTemplates: allTplIds },
|
||||||
|
{ username: '0001', password: '123456', role: 'user', name: '张医生', status: 'active', createdAt: '2024-01-01', department: '外科', visibleTemplates: allTplIds, manageableTemplates: [] }
|
||||||
|
];
|
||||||
|
storage.set('users', defaultUsers);
|
||||||
|
console.log('Default users initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
|
if (!settingsRaw.frameCount) {
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
const positions: number[] = [];
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
positions.push(round1((100 / 13) * i));
|
||||||
|
}
|
||||||
|
const defaultSettings = {
|
||||||
|
frameCount: 12,
|
||||||
|
framePositions: positions,
|
||||||
|
apiEndpoint: '',
|
||||||
|
apiKey: '',
|
||||||
|
defaultTemplate: savedTemplates[0]?.id || '',
|
||||||
|
frameMode: 'uniform'
|
||||||
|
};
|
||||||
|
storage.set('systemSettings', defaultSettings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const u = username.trim();
|
||||||
|
const p = password.trim();
|
||||||
|
|
||||||
|
const users = storage.get<User[]>('users', []);
|
||||||
|
let user = users.find(user => user.username === u && user.password === p);
|
||||||
|
|
||||||
|
// Fallback for default accounts if localStorage is messed up
|
||||||
|
if (!user) {
|
||||||
|
const defaults = [
|
||||||
|
{ u: 'admin', p: '123456', r: 'super', n: '超级管理员' },
|
||||||
|
{ u: 'manager', p: '123456', r: 'admin', n: '管理员' },
|
||||||
|
{ u: '0001', p: '123456', r: 'user', n: '张医生' }
|
||||||
|
];
|
||||||
|
const d = defaults.find(item => item.u === u && item.p === p);
|
||||||
|
if (d) {
|
||||||
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
const allTplIds = allTemplates.map(t => t.id);
|
||||||
|
user = { username: d.u, password: d.p, role: d.r as any, name: d.n, status: 'active', createdAt: '2024-01-01', visibleTemplates: allTplIds, manageableTemplates: d.r === 'user' ? [] : allTplIds, department: d.r === 'super' ? '' : '外科' };
|
||||||
|
// Sync back to localStorage
|
||||||
|
const updatedUsers = [...users.filter(item => item.username !== u), user];
|
||||||
|
storage.set('users', updatedUsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (user.status === 'inactive') {
|
||||||
|
setError('该账号已被禁用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storage.set('currentUser', user);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
setError('用户ID或密码错误');
|
||||||
|
console.log('Login failed for:', u);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillLogin = (u: string, p: string) => {
|
||||||
|
setUsername(u);
|
||||||
|
setPassword(p);
|
||||||
|
setTimeout(() => {
|
||||||
|
// Trigger the robust login logic manually
|
||||||
|
const users = storage.get<User[]>('users', []);
|
||||||
|
let user = users.find(user => user.username === u && user.password === p);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const defaults = [
|
||||||
|
{ u: 'admin', p: '123456', r: 'super', n: '超级管理员' },
|
||||||
|
{ u: 'manager', p: '123456', r: 'admin', n: '管理员' },
|
||||||
|
{ u: '0001', p: '123456', r: 'user', n: '张医生' }
|
||||||
|
];
|
||||||
|
const d = defaults.find(item => item.u === u && item.p === p);
|
||||||
|
if (d) {
|
||||||
|
user = { username: d.u, password: d.p, role: d.r as any, name: d.n, status: 'active', createdAt: '2024-01-01' };
|
||||||
|
const updatedUsers = [...users.filter(item => item.username !== u), user];
|
||||||
|
storage.set('users', updatedUsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
storage.set('currentUser', user);
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
|
||||||
|
<div className="bg-white rounded-3xl shadow-[0_20px_50px_-12px_rgba(0,0,0,0.08)] p-12 w-full max-w-[460px] border border-border">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<img src="/logo_square.png" alt="Logo" className="w-16 h-16 object-contain mb-6" />
|
||||||
|
<h1 className="text-2xl font-bold text-text-main tracking-tight mb-1">手术图文病历报告生成终端</h1>
|
||||||
|
<p className="text-xs text-text-muted uppercase tracking-widest font-bold">智能图文报告管理系统</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[10px] font-bold text-text-main uppercase tracking-wider">用户ID</label>
|
||||||
|
<div className="relative">
|
||||||
|
<UserIcon className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入您的用户ID"
|
||||||
|
required
|
||||||
|
className="input-minimal pl-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[10px] font-bold text-text-main uppercase tracking-wider">密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入您的登录密码"
|
||||||
|
required
|
||||||
|
className="input-minimal pl-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-accent w-full py-4 text-base shadow-[0_8px_20px_-4px_rgba(37,99,235,0.2)]"
|
||||||
|
>
|
||||||
|
进入系统
|
||||||
|
</button>
|
||||||
|
{error && <div className="text-red-500 text-xs text-center font-bold animate-pulse">{error}</div>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-10 pt-8 border-t border-border">
|
||||||
|
<h3 className="text-[10px] text-text-muted mb-4 uppercase tracking-widest font-bold text-center">快捷登录测试账号</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{[
|
||||||
|
{ u: 'admin', p: '123456', r: '超级管理员', c: 'bg-amber-100 text-amber-700' },
|
||||||
|
{ u: 'manager', p: '123456', r: '管理员', c: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ u: '0001', p: '123456', r: '医生', c: 'bg-green-100 text-green-700' }
|
||||||
|
].map(test => (
|
||||||
|
<div
|
||||||
|
key={test.u}
|
||||||
|
onClick={() => fillLogin(test.u, test.p)}
|
||||||
|
className="flex justify-between items-center p-3 bg-slate-50 rounded-xl cursor-pointer transition-all hover:bg-white hover:shadow-md border border-transparent hover:border-border group"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-text-main">{test.u} / {test.p}</span>
|
||||||
|
<span className={`text-[9px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider ${test.c}`}>
|
||||||
|
{test.r}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1327
src/pages/ReportEditor.tsx
Normal file
1327
src/pages/ReportEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
302
src/pages/ReportManage.tsx
Normal file
302
src/pages/ReportManage.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { Search, Eye, Edit, Trash2, FileText, History, X } from 'lucide-react';
|
||||||
|
import { User, Report } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
const formatDateTime = (iso: string) => {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReportManage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [filteredReports, setFilteredReports] = useState<Report[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [dateFilter, setDateFilter] = useState('');
|
||||||
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
|
const [historyReport, setHistoryReport] = useState<Report | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
const savedReports = storage.get<Report[]>('reports', []);
|
||||||
|
setReports(savedReports);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
let filtered = [...reports];
|
||||||
|
|
||||||
|
if (currentUser.role === 'user') {
|
||||||
|
filtered = filtered.filter(r => r.author === currentUser.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(r =>
|
||||||
|
r.title.toLowerCase().includes(term) ||
|
||||||
|
r.patientName.toLowerCase().includes(term) ||
|
||||||
|
r.hospitalId.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter) {
|
||||||
|
filtered = filtered.filter(r => r.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFilter) {
|
||||||
|
const now = new Date();
|
||||||
|
filtered = filtered.filter(r => {
|
||||||
|
const reportDate = new Date(r.createdAt);
|
||||||
|
if (dateFilter === 'today') {
|
||||||
|
return reportDate.toDateString() === now.toDateString();
|
||||||
|
} else if (dateFilter === 'week') {
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
return reportDate >= weekAgo;
|
||||||
|
} else if (dateFilter === 'month') {
|
||||||
|
return reportDate.getMonth() === now.getMonth() && reportDate.getFullYear() === now.getFullYear();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredReports(filtered);
|
||||||
|
}, [reports, currentUser, searchTerm, statusFilter, dateFilter]);
|
||||||
|
|
||||||
|
const deleteReport = (id: string) => {
|
||||||
|
if (window.confirm('确定要删除此报告吗?')) {
|
||||||
|
const updatedReports = reports.filter(r => r.id !== id);
|
||||||
|
setReports(updatedReports);
|
||||||
|
storage.set('reports', updatedReports);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewReport = (id: string) => {
|
||||||
|
navigate(`/report-view/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editReport = (id: string) => {
|
||||||
|
navigate(`/report-editor?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistory = (report: Report) => {
|
||||||
|
setHistoryReport(report);
|
||||||
|
setHistoryModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreHistory = (content: string) => {
|
||||||
|
if (!historyReport) return;
|
||||||
|
if (!window.confirm('确定要恢复此历史版本到编辑器吗?当前未保存的内容将丢失。')) return;
|
||||||
|
navigate(`/report-editor?id=${historyReport.id}&restore=1`);
|
||||||
|
storage.setSession(`restore_${historyReport.id}`, content);
|
||||||
|
setHistoryModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-bg">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 p-10 overflow-y-auto">
|
||||||
|
<header className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-text-main">报告管理</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1">
|
||||||
|
{currentUser.role === 'user' ? '查看、编辑、打印自己创建的报告' : '查看/检索全院所有已撰写的报告'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mb-6">
|
||||||
|
<div className="relative flex-1 min-w-[240px] max-w-[400px]">
|
||||||
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索报告标题或患者姓名..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-minimal pl-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="input-minimal max-w-[160px] bg-white"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
|
className="input-minimal max-w-[160px] bg-white"
|
||||||
|
>
|
||||||
|
<option value="">全部时间</option>
|
||||||
|
<option value="today">今天</option>
|
||||||
|
<option value="week">本周</option>
|
||||||
|
<option value="month">本月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-minimal p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50">
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">报告信息</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者号</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">创建者</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40">时间</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24">状态</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filteredReports.length > 0 ? (
|
||||||
|
filteredReports.map((report) => (
|
||||||
|
<tr key={report.id} className="hover:bg-slate-50 transition-colors group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm font-semibold text-text-main">{report.title}</div>
|
||||||
|
<div className="text-xs text-text-muted font-mono mt-1">{report.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{report.patientName}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{report.hospitalId}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{report.authorName}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-muted leading-relaxed">
|
||||||
|
<div>创建: {formatDateTime(report.createdAt)}</div>
|
||||||
|
<div>修改: {formatDateTime(report.updatedAt || report.createdAt)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||||
|
report.status === 'draft'
|
||||||
|
? 'bg-amber-100 text-amber-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{report.status === 'draft' ? '草稿' : '已完成'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => viewReport(report.id)}
|
||||||
|
className="p-2 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||||||
|
title="查看"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</button>
|
||||||
|
{(currentUser.role !== 'user' || report.author === currentUser.username) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => editReport(report.id)}
|
||||||
|
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteReport(report.id)}
|
||||||
|
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => openHistory(report)}
|
||||||
|
className="p-2 rounded-lg bg-amber-50 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||||
|
title="历史版本"
|
||||||
|
>
|
||||||
|
<History size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-24 text-center">
|
||||||
|
<div className="flex flex-col items-center text-text-muted">
|
||||||
|
<FileText size={48} className="mb-4 opacity-20" />
|
||||||
|
<h3 className="text-base font-semibold text-text-main mb-1">暂无报告</h3>
|
||||||
|
<p className="text-sm">点击"新建报告"开始撰写您的第一份报告</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{historyModalOpen && historyReport && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-8 w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl border border-border">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-text-main">操作历史</h3>
|
||||||
|
<p className="text-sm text-text-muted">报告: {historyReport.title}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setHistoryModalOpen(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...(historyReport.history || [])].reverse().map((item, idx) => (
|
||||||
|
<div key={idx} className="border border-border rounded-lg p-4 bg-slate-50">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
|
||||||
|
item.action === 'complete_report'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-amber-100 text-amber-700'
|
||||||
|
}`}>
|
||||||
|
{item.action === 'complete_report' ? '完成报告' : '保存草稿'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-muted">{formatDateTime(item.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-main mb-3">由 {item.updatedBy} {item.action === 'complete_report' ? '完成' : '保存'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => restoreHistory(item.content)}
|
||||||
|
className="text-xs font-bold text-accent hover:underline"
|
||||||
|
>
|
||||||
|
恢复此版本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="border border-border rounded-lg p-4 bg-white">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-xs font-bold text-accent uppercase tracking-wider">当前版本</span>
|
||||||
|
<span className="text-xs text-text-muted">{formatDateTime(historyReport.updatedAt || historyReport.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-main">当前显示内容</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/pages/ReportView.tsx
Normal file
117
src/pages/ReportView.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { Printer, Edit, ChevronLeft } from 'lucide-react';
|
||||||
|
import { User, Report } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
export default function ReportView() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [report, setReport] = useState<Report | null>(null);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
const reports = storage.get<Report[]>('reports', []);
|
||||||
|
const found = reports.find(r => r.id === id);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
alert('报告不存在');
|
||||||
|
navigate('/report-manage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'user' && found.author !== user.username) {
|
||||||
|
alert('您没有权限查看此报告');
|
||||||
|
navigate('/report-manage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReport(found);
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
if (!report || !currentUser) return null;
|
||||||
|
|
||||||
|
const canEdit = currentUser.role !== 'user' || report.author === currentUser.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-bg">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 p-10 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8 print:hidden">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/report-manage')}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-text-main">查看报告</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1 uppercase tracking-wider font-bold">报告编号: {report.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/report-editor?id=${report.id}`)}
|
||||||
|
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
编辑报告
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="btn-accent inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
打印报告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-[0_10px_25px_-5px_rgba(0,0,0,0.05)] p-12 max-w-[900px] mx-auto print:shadow-none print:p-0 print:m-0">
|
||||||
|
<div className="text-center pb-10 border-b border-border mb-10">
|
||||||
|
<h2 className="text-3xl font-bold text-text-main mb-6">{report.title}</h2>
|
||||||
|
<div className="flex justify-center gap-8 flex-wrap">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">患者姓名</span>
|
||||||
|
<span className="text-sm font-bold text-text-main">{report.patientName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">创建者</span>
|
||||||
|
<span className="text-sm font-bold text-text-main">{report.authorName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">创建时间</span>
|
||||||
|
<span className="text-sm font-bold text-text-main">{report.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">报告状态</span>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${
|
||||||
|
report.status === 'draft' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{report.status === 'draft' ? '草稿' : '已完成'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="report-content leading-relaxed text-text-main text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: report.content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
src/pages/SystemSettings.tsx
Normal file
371
src/pages/SystemSettings.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
|
||||||
|
import { User, SystemSettings as ISystemSettings, Template } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
export default function SystemSettings() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
||||||
|
frameCount: 12,
|
||||||
|
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
||||||
|
apiEndpoint: '',
|
||||||
|
apiKey: '',
|
||||||
|
defaultTemplate: '',
|
||||||
|
frameMode: 'uniform'
|
||||||
|
});
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
|
||||||
|
const [modeModalOpen, setModeModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
|
||||||
|
const savedTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
if (savedSettings.frameCount) {
|
||||||
|
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
||||||
|
savedSettings.defaultTemplate = savedTemplates[0].id;
|
||||||
|
}
|
||||||
|
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
|
||||||
|
setSettings(savedSettings);
|
||||||
|
} else if (savedTemplates.length > 0) {
|
||||||
|
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform' }));
|
||||||
|
}
|
||||||
|
setTemplates(savedTemplates);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
const computeFramePositions = (count: number, mode: 'uniform' | 'keep', currentPositions: number[]) => {
|
||||||
|
if (mode === 'uniform') {
|
||||||
|
const positions: number[] = [];
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
positions.push(round1((100 / (count + 1)) * i));
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
const sorted = [...currentPositions].sort((a, b) => a - b);
|
||||||
|
if (count <= sorted.length) {
|
||||||
|
return sorted.slice(0, count);
|
||||||
|
}
|
||||||
|
const need = count - sorted.length;
|
||||||
|
const last = sorted[sorted.length - 1] || 0;
|
||||||
|
const range = 100 - last;
|
||||||
|
for (let i = 1; i <= need; i++) {
|
||||||
|
sorted.push(round1(last + (range / (need + 1)) * i));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sortedPositions = [...settings.framePositions].sort((a, b) => a - b);
|
||||||
|
const finalSettings = { ...settings, framePositions: sortedPositions, frameCount: sortedPositions.length };
|
||||||
|
storage.set('systemSettings', finalSettings);
|
||||||
|
setSettings(finalSettings);
|
||||||
|
setIsSaved(true);
|
||||||
|
setTimeout(() => setIsSaved(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testApi = async () => {
|
||||||
|
if (!settings.apiEndpoint) {
|
||||||
|
alert('请先输入 API 接口地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`正在测试连接到: ${settings.apiEndpoint}\n(模拟测试: 连接成功)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
||||||
|
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
||||||
|
frameCount: 12,
|
||||||
|
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
||||||
|
apiEndpoint: '',
|
||||||
|
apiKey: '',
|
||||||
|
defaultTemplate: templates[0]?.id || '',
|
||||||
|
frameMode: 'uniform'
|
||||||
|
};
|
||||||
|
setSettings(defaultSettings);
|
||||||
|
storage.set('systemSettings', defaultSettings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAllData = () => {
|
||||||
|
if (window.confirm('确定要重置全部数据吗?这将清除所有报告、模板和用户设置。')) {
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-bg">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 p-10 overflow-y-auto">
|
||||||
|
<header className="flex justify-between items-center mb-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-text-main">系统设置</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1">
|
||||||
|
{currentUser.role === 'super' ? '配置全局参数,包括视频抽帧策略与外部 AI API 对接。' : '设置您的默认报告模板。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="max-w-[800px] space-y-8 pb-20">
|
||||||
|
{currentUser.role === 'super' && (
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||||
|
<Video size={20} className="text-accent" />
|
||||||
|
视频抽帧配置
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] font-bold bg-slate-100 text-text-muted px-2 py-1 rounded-full uppercase tracking-wider">
|
||||||
|
当前共 {settings.framePositions.length} 帧
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">抽取帧数</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={settings.frameCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const count = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
|
||||||
|
setSettings({ ...settings, frameCount: count });
|
||||||
|
}}
|
||||||
|
className="input-minimal bg-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingFrameCount(settings.frameCount);
|
||||||
|
setModeModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-xs font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">当前抽帧方式</label>
|
||||||
|
<div className="flex items-center h-[42px]">
|
||||||
|
<span className="text-sm text-text-main">
|
||||||
|
{settings.frameMode === 'uniform' ? '整体均匀抽取' : '保持当前抽帧'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">抽帧位置百分比 (%)</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||||
|
{settings.framePositions.map((pos, idx) => (
|
||||||
|
<div key={idx} className="relative group">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={pos}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newPos = [...settings.framePositions];
|
||||||
|
newPos[idx] = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0));
|
||||||
|
setSettings({ ...settings, framePositions: newPos });
|
||||||
|
}}
|
||||||
|
className="input-minimal w-full pr-6 text-center"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newPos = settings.framePositions.filter((_, i) => i !== idx);
|
||||||
|
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length });
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newPos = [...settings.framePositions, 50];
|
||||||
|
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length });
|
||||||
|
}}
|
||||||
|
className="w-full h-10 rounded-xl border-2 border-dashed border-border flex items-center justify-center text-text-muted hover:border-accent hover:text-accent hover:bg-slate-50 transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-muted mt-2">指定视频进度的百分比位置进行自动抽帧。系统将按照这些位置提取关键帧供 AI 分析。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUser.role === 'super' && (
|
||||||
|
<div className="card-minimal">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||||
|
<Globe size={20} className="text-accent" />
|
||||||
|
AI 接口集成
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testApi}
|
||||||
|
className="text-[10px] font-bold text-accent uppercase tracking-wider hover:underline"
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">报告生成 API 接口 (Endpoint)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={settings.apiEndpoint}
|
||||||
|
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
|
||||||
|
placeholder="https://api.example.com/v1/generate"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API 密钥 (Secret Key)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.apiKey}
|
||||||
|
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-minimal">
|
||||||
|
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center gap-2">
|
||||||
|
<Layout size={20} className="text-accent" />
|
||||||
|
默认报告模板
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">图文报告生成默认模板</label>
|
||||||
|
<select
|
||||||
|
value={settings.defaultTemplate}
|
||||||
|
onChange={(e) => setSettings({ ...settings, defaultTemplate: e.target.value })}
|
||||||
|
className="input-minimal bg-white"
|
||||||
|
>
|
||||||
|
<option value="">未设置 (手动选择)</option>
|
||||||
|
{templates.map(tpl => (
|
||||||
|
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-[11px] text-text-muted">新建报告时将自动加载此模板内容,减少重复操作。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-6 border-t border-border">
|
||||||
|
{currentUser.role === 'super' && (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetToDefault}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isSaved && (
|
||||||
|
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
|
||||||
|
<Check size={14} />
|
||||||
|
设置已保存
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-accent px-12"
|
||||||
|
>
|
||||||
|
保存全局配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{modeModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-8 w-full max-w-[420px] shadow-2xl border border-border">
|
||||||
|
<h3 className="text-lg font-bold text-text-main mb-2">选择抽帧方式</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-6">
|
||||||
|
您将抽取帧数设置为 <strong className="text-accent">{pendingFrameCount}</strong> 帧,请选择重新计算抽帧位置的方式:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingFrameCount !== null) {
|
||||||
|
const newPositions = computeFramePositions(pendingFrameCount, 'uniform', settings.framePositions);
|
||||||
|
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'uniform', framePositions: newPositions });
|
||||||
|
}
|
||||||
|
setModeModalOpen(false);
|
||||||
|
setPendingFrameCount(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
整体均匀抽取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingFrameCount !== null) {
|
||||||
|
const newPositions = computeFramePositions(pendingFrameCount, 'keep', settings.framePositions);
|
||||||
|
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'keep', framePositions: newPositions });
|
||||||
|
}
|
||||||
|
setModeModalOpen(false);
|
||||||
|
setPendingFrameCount(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
保持当前抽帧
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setModeModalOpen(false); setPendingFrameCount(null); }}
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
501
src/pages/TemplateManage.tsx
Normal file
501
src/pages/TemplateManage.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
|
||||||
|
import { User, Template } from '../types';
|
||||||
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
|
import { printDocument } from '../utils/print';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
export default function TemplateManage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({ name: '', desc: '' });
|
||||||
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const savedRangeRef = useRef<Range | null>(null);
|
||||||
|
|
||||||
|
const updatePageHeight = () => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const contentHeight = editorRef.current.scrollHeight;
|
||||||
|
const pageHeightMm = 297;
|
||||||
|
const mmToPx = 3.7795275591;
|
||||||
|
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
|
||||||
|
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user || user.role === 'user') {
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
const savedTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
if (savedTemplates.length === 0) {
|
||||||
|
const initial: Template = {
|
||||||
|
id: 'surgery',
|
||||||
|
name: '腹腔镜胆囊切除术报告',
|
||||||
|
desc: '标准手术记录模板',
|
||||||
|
content: defaultReportContent,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author: 'admin'
|
||||||
|
};
|
||||||
|
setTemplates([initial]);
|
||||||
|
storage.set('templates', [initial]);
|
||||||
|
setCurrentTemplateId(initial.id);
|
||||||
|
} else {
|
||||||
|
const manageable = user.role === 'super'
|
||||||
|
? savedTemplates.map(t => t.id)
|
||||||
|
: (Array.isArray(user.manageableTemplates) ? user.manageableTemplates : savedTemplates.map(t => t.id));
|
||||||
|
const filtered = savedTemplates.filter(t => manageable.includes(t.id));
|
||||||
|
setTemplates(filtered);
|
||||||
|
setCurrentTemplateId(filtered[0]?.id || null);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTemplateId && editorRef.current) {
|
||||||
|
const template = templates.find(t => t.id === currentTemplateId);
|
||||||
|
if (template) {
|
||||||
|
editorRef.current.innerHTML = template.content;
|
||||||
|
}
|
||||||
|
setTimeout(() => updatePageHeight(), 0);
|
||||||
|
}
|
||||||
|
}, [currentTemplateId, templates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updatePageHeight();
|
||||||
|
});
|
||||||
|
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const triggerPlaceholderUpload = (placeholder: HTMLElement) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = (ev) => {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const src = event.target?.result as string;
|
||||||
|
placeholder.innerHTML = `
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||||
|
`;
|
||||||
|
placeholder.classList.add('has-image');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle image placeholder interactions via click capture for reliable contenteditable behavior
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEditorClick = (e: MouseEvent) => {
|
||||||
|
// e.target may be a text node; safely resolve to an Element
|
||||||
|
let node: Node | null = e.target as Node;
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||||
|
const targetEl = node as HTMLElement | null;
|
||||||
|
if (!targetEl) return;
|
||||||
|
|
||||||
|
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
|
||||||
|
if (!placeholder) return;
|
||||||
|
|
||||||
|
if (targetEl.closest('.delete-btn')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (placeholder.classList.contains('has-image')) {
|
||||||
|
placeholder.classList.remove('has-image');
|
||||||
|
placeholder.innerHTML = `
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(placeholder);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('delete');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placeholder.classList.contains('has-image')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
triggerPlaceholderUpload(placeholder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (editor) {
|
||||||
|
editor.addEventListener('click', handleEditorClick, true);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (editor) {
|
||||||
|
editor.removeEventListener('click', handleEditorClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [currentTemplateId, currentUser]);
|
||||||
|
|
||||||
|
const execCmd = (command: string, value: string | undefined = undefined) => {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
editorRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTable = () => {
|
||||||
|
const rowsStr = prompt('请输入行数:', '2');
|
||||||
|
const colsStr = prompt('请输入列数:', '3');
|
||||||
|
if (rowsStr && colsStr) {
|
||||||
|
const rows = parseInt(rowsStr);
|
||||||
|
const cols = parseInt(colsStr);
|
||||||
|
if (isNaN(rows) || isNaN(cols)) return;
|
||||||
|
|
||||||
|
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
table += '<tr>';
|
||||||
|
for (let j = 0; j < cols; j++) {
|
||||||
|
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||||
|
}
|
||||||
|
table += '</tr>';
|
||||||
|
}
|
||||||
|
table += '</table><p></p>';
|
||||||
|
execCmd('insertHTML', table);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertImage = () => {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
const id = 'ph_' + Date.now();
|
||||||
|
const html = `
|
||||||
|
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
execCmd('insertHTML', html);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCurrentTemplate = () => {
|
||||||
|
if (!currentTemplateId || !editorRef.current) return;
|
||||||
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
const updated = allTemplates.map(t => {
|
||||||
|
if (t.id === currentTemplateId) {
|
||||||
|
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
|
||||||
|
storage.set('templates', updated);
|
||||||
|
setIsSaved(true);
|
||||||
|
setTimeout(() => setIsSaved(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTemplate = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setFormData({ name: '', desc: '' });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditInfo = (template: Template) => {
|
||||||
|
setIsEditing(true);
|
||||||
|
setFormData({ name: template.name, desc: template.desc || '' });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = (id: string) => {
|
||||||
|
if (templates.length <= 1) {
|
||||||
|
alert('至少需要保留一个模板');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.confirm('确定要删除此模板吗?')) {
|
||||||
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
const updated = allTemplates.filter(t => t.id !== id);
|
||||||
|
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
|
||||||
|
storage.set('templates', updated);
|
||||||
|
if (currentTemplateId === id) {
|
||||||
|
const visible = updated.filter(t => templates.some(x => x.id === t.id));
|
||||||
|
setCurrentTemplateId(visible[0]?.id || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
|
if (isEditing) {
|
||||||
|
const updated = allTemplates.map(t => {
|
||||||
|
if (t.id === currentTemplateId) {
|
||||||
|
return { ...t, name: formData.name, desc: formData.desc };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
|
||||||
|
storage.set('templates', updated);
|
||||||
|
} else {
|
||||||
|
const newTpl: Template = {
|
||||||
|
id: 'tpl_' + Date.now(),
|
||||||
|
name: formData.name,
|
||||||
|
desc: formData.desc,
|
||||||
|
content: defaultReportContent,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author: currentUser?.username || 'admin'
|
||||||
|
};
|
||||||
|
const updated = [...allTemplates, newTpl];
|
||||||
|
setTemplates([...templates, newTpl]);
|
||||||
|
storage.set('templates', updated);
|
||||||
|
setCurrentTemplateId(newTpl.id);
|
||||||
|
|
||||||
|
// Sync user permissions
|
||||||
|
const savedUsers = storage.get<User[]>('users', []);
|
||||||
|
let updatedUsers = savedUsers;
|
||||||
|
if (currentUser?.role === 'super') {
|
||||||
|
updatedUsers = savedUsers.map(u => {
|
||||||
|
if (u.role === 'super') {
|
||||||
|
const mt = [...(u.manageableTemplates || [])];
|
||||||
|
const vt = [...(u.visibleTemplates || [])];
|
||||||
|
if (!mt.includes(newTpl.id)) mt.push(newTpl.id);
|
||||||
|
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
|
||||||
|
return { ...u, manageableTemplates: mt, visibleTemplates: vt };
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
} else if (currentUser?.role === 'admin') {
|
||||||
|
const dept = currentUser.department || '';
|
||||||
|
updatedUsers = savedUsers.map(u => {
|
||||||
|
if (u.username === currentUser.username) {
|
||||||
|
const mt = [...(u.manageableTemplates || [])];
|
||||||
|
const vt = [...(u.visibleTemplates || [])];
|
||||||
|
if (!mt.includes(newTpl.id)) mt.push(newTpl.id);
|
||||||
|
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
|
||||||
|
return { ...u, manageableTemplates: mt, visibleTemplates: vt };
|
||||||
|
}
|
||||||
|
if (u.role === 'user' && u.department === dept) {
|
||||||
|
const vt = [...(u.visibleTemplates || [])];
|
||||||
|
if (!vt.includes(newTpl.id)) vt.push(newTpl.id);
|
||||||
|
return { ...u, visibleTemplates: vt };
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
storage.set('users', updatedUsers);
|
||||||
|
const currentCached = updatedUsers.find(u => u.username === currentUser?.username);
|
||||||
|
if (currentCached) {
|
||||||
|
storage.set('currentUser', currentCached);
|
||||||
|
setCurrentUser(currentCached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
const currentTemplate = templates.find(t => t.id === currentTemplateId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-bg overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Template List Sidebar */}
|
||||||
|
<aside className="w-72 bg-sidebar-bg border-r border-border flex flex-col shrink-0 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-border flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-text-main uppercase tracking-wider">模板列表</span>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
{templates.map(tpl => (
|
||||||
|
<div
|
||||||
|
key={tpl.id}
|
||||||
|
onClick={() => setCurrentTemplateId(tpl.id)}
|
||||||
|
className={`p-4 rounded-xl border transition-all group ${
|
||||||
|
currentTemplateId === tpl.id
|
||||||
|
? 'bg-white border-accent shadow-sm'
|
||||||
|
: 'bg-transparent border-transparent hover:bg-white hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div className={`text-sm font-bold ${currentTemplateId === tpl.id ? 'text-accent' : 'text-text-main'}`}>
|
||||||
|
{tpl.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-text-muted line-clamp-1 mb-2">{tpl.desc || '无描述'}</div>
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditInfo(tpl); }}
|
||||||
|
className="px-2 py-1 rounded-md bg-slate-100 text-slate-600 text-[10px] font-bold hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
{templates.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
|
||||||
|
className="px-2 py-1 rounded-md bg-red-50 text-red-600 text-[10px] font-bold hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<div className="text-center text-text-muted text-sm py-8">暂无模板</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Editor */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<header className="h-20 bg-white border-b border-border flex items-center justify-between px-8 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-main">模板管理</h1>
|
||||||
|
<p className="text-[10px] text-text-muted mt-0.5 uppercase tracking-wider font-bold">
|
||||||
|
{currentTemplate ? currentTemplate.name : '请选择模板'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isSaved && (
|
||||||
|
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
|
||||||
|
<Check size={14} />
|
||||||
|
已保存
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={saveCurrentTemplate}
|
||||||
|
className="btn-accent inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
保存模板
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
|
||||||
|
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
|
||||||
|
title="打印预览"
|
||||||
|
>
|
||||||
|
<Printer size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
|
||||||
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
|
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
|
||||||
|
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
|
<select
|
||||||
|
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
|
||||||
|
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">选择字体</option>
|
||||||
|
<option value="SimSun">宋体</option>
|
||||||
|
<option value="Microsoft YaHei">微软雅黑</option>
|
||||||
|
<option value="SimHei">黑体</option>
|
||||||
|
<option value="KaiTi">楷体</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
|
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
|
||||||
|
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
|
||||||
|
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
onChange={(e) => execCmd('foreColor', e.target.value)}
|
||||||
|
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
|
||||||
|
title="文字颜色"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
|
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||||
|
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||||
|
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||||
|
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Area */}
|
||||||
|
<div className="editor-content-wrapper print-wrapper">
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
className="editor-content print-content"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] shadow-2xl border border-border">
|
||||||
|
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-8">设置模板的基本名称和描述</p>
|
||||||
|
<form onSubmit={handleModalSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模板名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="请输入模板名称"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模板描述</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.desc}
|
||||||
|
onChange={(e) => setFormData({ ...formData, desc: e.target.value })}
|
||||||
|
placeholder="请输入模板描述"
|
||||||
|
className="input-minimal min-h-[100px] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-accent"
|
||||||
|
>
|
||||||
|
保存模板信息
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
652
src/pages/UserManage.tsx
Normal file
652
src/pages/UserManage.tsx
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import { UserPlus, Edit, Trash2 } from 'lucide-react';
|
||||||
|
import { User, Template } from '../types';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
const ADMIN_DISABLE_AUTH_KEY = 'DISABLE_ADMIN_2024';
|
||||||
|
|
||||||
|
export default function UserManage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Partial<User>>({
|
||||||
|
username: '',
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'user',
|
||||||
|
department: '',
|
||||||
|
status: 'active',
|
||||||
|
visibleTemplates: [],
|
||||||
|
manageableTemplates: []
|
||||||
|
});
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [authKey, setAuthKey] = useState('');
|
||||||
|
const [allTemplates, setAllTemplates] = useState<Template[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
|
if (!user || (user.role !== 'super' && user.role !== 'admin')) {
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
const savedUsers = storage.get<User[]>('users', []).filter(Boolean);
|
||||||
|
setUsers(savedUsers);
|
||||||
|
|
||||||
|
const savedTemplates = storage.get<Template[]>('templates', []).filter(Boolean);
|
||||||
|
setAllTemplates(savedTemplates);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const displayUsers = useMemo(() => {
|
||||||
|
if (!currentUser) return [];
|
||||||
|
const safeUsers = (Array.isArray(users) ? users : []).filter(Boolean);
|
||||||
|
if (currentUser.role === 'super') return safeUsers;
|
||||||
|
return safeUsers.filter(u => u.department === currentUser.department && (u.role === 'user' || u.username === currentUser.username));
|
||||||
|
}, [users, currentUser]);
|
||||||
|
|
||||||
|
const saveToLocalStorage = (updatedUsers: User[]) => {
|
||||||
|
setUsers(updatedUsers);
|
||||||
|
storage.set('users', updatedUsers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (username: string) => {
|
||||||
|
if (username === 'admin') {
|
||||||
|
alert('不能删除默认超级管理员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username === currentUser?.username) {
|
||||||
|
alert('不能删除当前登录账号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.confirm(`确定要删除用户 "${username}" 吗?`)) {
|
||||||
|
const updated = users.filter(u => u.username !== username);
|
||||||
|
saveToLocalStorage(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
if (currentUser?.role === 'admin') {
|
||||||
|
if ((user.role !== 'user' && user.username !== currentUser.username) || user.department !== currentUser.department) {
|
||||||
|
alert('您只能管理同部门的医生或您自己');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsEditing(true);
|
||||||
|
const allTplIds = allTemplates.map(t => t.id);
|
||||||
|
let manageable: string[] = [];
|
||||||
|
let visible: string[] = [];
|
||||||
|
|
||||||
|
if (user.role === 'super') {
|
||||||
|
manageable = allTplIds;
|
||||||
|
visible = allTplIds;
|
||||||
|
} else if (user.role === 'admin') {
|
||||||
|
manageable = Array.isArray(user.manageableTemplates) ? user.manageableTemplates : allTplIds;
|
||||||
|
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : manageable)
|
||||||
|
.filter(id => manageable.includes(id));
|
||||||
|
} else {
|
||||||
|
manageable = [];
|
||||||
|
const deptAdmin = users.find(u => u.role === 'admin' && u.department === user.department);
|
||||||
|
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||||
|
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : [])
|
||||||
|
.filter(id => adminManageable.includes(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
...user,
|
||||||
|
password: '',
|
||||||
|
visibleTemplates: visible,
|
||||||
|
manageableTemplates: manageable
|
||||||
|
});
|
||||||
|
setConfirmPassword('');
|
||||||
|
setAuthKey('');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
const defaultDept = currentUser?.role === 'admin' ? currentUser.department || '' : '';
|
||||||
|
const defaultRole = 'user';
|
||||||
|
let defaultVisible: string[] = [];
|
||||||
|
let defaultManageable: string[] = [];
|
||||||
|
|
||||||
|
if (currentUser?.role === 'admin') {
|
||||||
|
defaultManageable = [];
|
||||||
|
defaultVisible = currentUser.manageableTemplates || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
username: '',
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: defaultRole,
|
||||||
|
department: defaultDept,
|
||||||
|
status: 'active',
|
||||||
|
visibleTemplates: defaultVisible,
|
||||||
|
manageableTemplates: defaultManageable
|
||||||
|
});
|
||||||
|
setConfirmPassword('');
|
||||||
|
setAuthKey('');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (!formData.username) {
|
||||||
|
alert('用户ID不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing && formData.role === 'super') {
|
||||||
|
alert('系统中只能存在一个超级管理员,无法新增');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing && users.find(u => u.username === formData.username)) {
|
||||||
|
alert('用户ID已存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing && formData.password && formData.password !== confirmPassword) {
|
||||||
|
alert('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalRole = formData.role as any;
|
||||||
|
if (currentUser?.role === 'admin') {
|
||||||
|
if (!isEditing) finalRole = 'user';
|
||||||
|
else if (formData.username !== currentUser.username) finalRole = 'user';
|
||||||
|
}
|
||||||
|
const finalDepartment = currentUser?.role === 'admin' ? currentUser.department || '' : (formData.department || '');
|
||||||
|
|
||||||
|
if (finalRole === 'admin') {
|
||||||
|
const existingAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment && u.username !== formData.username);
|
||||||
|
if (existingAdmin) {
|
||||||
|
alert('该部门已存在管理员,一个部门只能有一个管理员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalRole === 'user') {
|
||||||
|
const hasAdminInDept = users.some(u => u.role === 'admin' && u.department === finalDepartment);
|
||||||
|
if (!hasAdminInDept) {
|
||||||
|
alert('该部门暂无管理员,请先建立一个部门管理员再创建医生');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalRole !== 'user' && formData.status === 'inactive') {
|
||||||
|
if (authKey.trim() !== ADMIN_DISABLE_AUTH_KEY) {
|
||||||
|
alert('禁用管理员账号需要输入正确的授权密钥');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTplIds = allTemplates.map(t => t.id);
|
||||||
|
let manageableTemplates: string[] = [];
|
||||||
|
let visibleTemplates: string[] = [];
|
||||||
|
|
||||||
|
if (finalRole === 'super') {
|
||||||
|
manageableTemplates = allTplIds;
|
||||||
|
visibleTemplates = allTplIds;
|
||||||
|
} else if (finalRole === 'admin') {
|
||||||
|
manageableTemplates = (formData.manageableTemplates || []).filter(id => allTplIds.includes(id));
|
||||||
|
visibleTemplates = (formData.visibleTemplates || [])
|
||||||
|
.filter(id => manageableTemplates.includes(id));
|
||||||
|
} else if (finalRole === 'user') {
|
||||||
|
manageableTemplates = [];
|
||||||
|
const deptAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment);
|
||||||
|
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||||
|
visibleTemplates = (formData.visibleTemplates || [])
|
||||||
|
.filter(id => adminManageable.includes(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldUser = isEditing ? users.find(u => u.username === formData.username) : undefined;
|
||||||
|
let updatedUsers: User[];
|
||||||
|
|
||||||
|
if (isEditing && finalRole === 'admin' && oldUser && oldUser.role === 'admin' && currentUser && currentUser.role === 'super') {
|
||||||
|
const oldManageable = Array.isArray(oldUser.manageableTemplates) ? oldUser.manageableTemplates : allTplIds;
|
||||||
|
const removed = oldManageable.filter(id => !manageableTemplates.includes(id));
|
||||||
|
const added = manageableTemplates.filter(id => !oldManageable.includes(id));
|
||||||
|
|
||||||
|
// Ensure admin's own visible gets new templates too
|
||||||
|
let adminVisible = [...visibleTemplates];
|
||||||
|
added.forEach(id => {
|
||||||
|
if (!adminVisible.includes(id)) adminVisible.push(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedUsers = users.map(u => {
|
||||||
|
if (u.username === formData.username) {
|
||||||
|
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password } as User;
|
||||||
|
}
|
||||||
|
if (u.role === 'user' && u.department === (oldUser.department || finalDepartment)) {
|
||||||
|
const currentVisible = Array.isArray(u.visibleTemplates) ? u.visibleTemplates : [];
|
||||||
|
const nextVisible = currentVisible.filter(id => !removed.includes(id));
|
||||||
|
added.forEach(id => {
|
||||||
|
if (!nextVisible.includes(id)) nextVisible.push(id);
|
||||||
|
});
|
||||||
|
return { ...u, visibleTemplates: nextVisible };
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const payload: Partial<User> = {
|
||||||
|
...formData,
|
||||||
|
role: finalRole,
|
||||||
|
department: finalDepartment,
|
||||||
|
manageableTemplates,
|
||||||
|
visibleTemplates
|
||||||
|
};
|
||||||
|
if (!formData.password) {
|
||||||
|
delete payload.password;
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
updatedUsers = users.map(u => {
|
||||||
|
if (u.username === formData.username) {
|
||||||
|
return { ...u, ...payload } as User;
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newUser: User = {
|
||||||
|
...(payload as User),
|
||||||
|
createdAt: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
updatedUsers = [...users, newUser];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToLocalStorage(updatedUsers);
|
||||||
|
// 如果编辑的是当前登录用户,同步更新 currentUser
|
||||||
|
const currentCached = updatedUsers.find(u => u.username === currentUser?.username);
|
||||||
|
if (currentCached) {
|
||||||
|
storage.set('currentUser', currentCached);
|
||||||
|
setCurrentUser(currentCached);
|
||||||
|
}
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('保存失败: ' + (err?.message || String(err)));
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTemplate = (templateId: string, field: 'visibleTemplates' | 'manageableTemplates') => {
|
||||||
|
const current = (formData[field] || []) as string[];
|
||||||
|
if (current.includes(templateId)) {
|
||||||
|
setFormData({ ...formData, [field]: current.filter(id => id !== templateId) });
|
||||||
|
} else {
|
||||||
|
setFormData({ ...formData, [field]: [...current, templateId] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real-time sync: when manageableTemplates changes for admin, ensure visibleTemplates stay within it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isModalOpen || !currentUser) return;
|
||||||
|
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||||||
|
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||||||
|
if (formData.role === 'admin' && !isManageableReadonly) {
|
||||||
|
const m = formData.manageableTemplates || [];
|
||||||
|
const prevVisible = formData.visibleTemplates || [];
|
||||||
|
const nextVisible = prevVisible.filter(id => m.includes(id));
|
||||||
|
if (nextVisible.length !== prevVisible.length) {
|
||||||
|
setFormData(prev => ({ ...prev, visibleTemplates: nextVisible }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formData.manageableTemplates, formData.role, isModalOpen, currentUser, formData.username]);
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
const roleNames = {
|
||||||
|
'super': '超级管理员',
|
||||||
|
'admin': '管理员',
|
||||||
|
'user': '医生'
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdminLogin = currentUser.role === 'admin';
|
||||||
|
const needAuthKey = formData.role !== 'user' && formData.status === 'inactive';
|
||||||
|
|
||||||
|
// 模板权限显示规则
|
||||||
|
const isSuperEditingAdmin = currentUser.role === 'super' && formData.role === 'admin';
|
||||||
|
const isSuperEditingUser = currentUser.role === 'super' && formData.role === 'user';
|
||||||
|
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||||||
|
const isAdminEditingUser = currentUser.role === 'admin' && formData.role === 'user';
|
||||||
|
|
||||||
|
const showManageableTemplates = isSuperEditingAdmin || isAdminEditingSelf || formData.role === 'super';
|
||||||
|
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||||||
|
const showVisibleTemplates = true;
|
||||||
|
|
||||||
|
// 可视模板候选
|
||||||
|
let visibleCandidates = allTemplates;
|
||||||
|
if (isSuperEditingAdmin) {
|
||||||
|
visibleCandidates = allTemplates.filter(t => (formData.manageableTemplates || []).includes(t.id));
|
||||||
|
} else if (isSuperEditingUser) {
|
||||||
|
const deptAdmin = users.find(u => u.role === 'admin' && u.department === formData.department);
|
||||||
|
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||||
|
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||||
|
} else if (isAdminEditingSelf) {
|
||||||
|
const adminManageable = currentUser.manageableTemplates || [];
|
||||||
|
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||||
|
} else if (isAdminEditingUser) {
|
||||||
|
const adminManageable = currentUser.manageableTemplates || [];
|
||||||
|
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-bg">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 p-10 overflow-y-auto">
|
||||||
|
<header className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-text-main">用户管理</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1">
|
||||||
|
{isAdminLogin ? '管理同部门的医生用户' : '管理系统用户,仅超级管理员可赋予管理员权限'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="btn-accent inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
新增用户
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="card-minimal p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50">
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">用户ID</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">姓名</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">邮箱</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">联系电话</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">角色</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">部门</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">状态</th>
|
||||||
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{displayUsers.map((user) => (
|
||||||
|
<tr key={user.username} className="hover:bg-slate-50 transition-colors group">
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main font-mono">{user.username}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main font-semibold">{user.name}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{user.email || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{user.phone || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||||
|
user.role === 'super' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
user.role === 'admin' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{roleNames[user.role]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-text-main">{user.department || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||||
|
user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{user.status === 'active' ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
{user.username !== 'admin' && user.username !== currentUser.username && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.username)}
|
||||||
|
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] max-h-[90vh] overflow-y-auto shadow-2xl border border-border">
|
||||||
|
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑用户' : '新增用户'}</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-8">填写用户信息并分配系统权限。</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">用户ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
disabled={isEditing}
|
||||||
|
required
|
||||||
|
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">姓名 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">电话</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||||
|
{isEditing ? '修改密码 (留空则不修改)' : '密码 *'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required={!isEditing}
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && formData.password && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">确认新密码 *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">角色 *</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRole = e.target.value as any;
|
||||||
|
const allTplIds = allTemplates.map(t => t.id);
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
role: newRole,
|
||||||
|
manageableTemplates: newRole === 'user' ? [] : allTplIds,
|
||||||
|
visibleTemplates: newRole === 'user' ? [] : allTplIds
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
disabled={isAdminLogin || (isEditing && formData.username === 'admin')}
|
||||||
|
className="input-minimal bg-white disabled:bg-slate-50 disabled:text-text-muted"
|
||||||
|
>
|
||||||
|
<option value="user">医生</option>
|
||||||
|
{!isAdminLogin && <option value="admin">管理员</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">部门 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.department}
|
||||||
|
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||||
|
disabled={isAdminLogin}
|
||||||
|
required
|
||||||
|
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">状态</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
className="input-minimal bg-white"
|
||||||
|
>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="inactive">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needAuthKey && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">授权密钥 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authKey}
|
||||||
|
onChange={(e) => setAuthKey(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="请输入授权密钥以禁用管理员"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-text-muted">禁用管理员账号需要输入系统授权密钥进行验证。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showManageableTemplates && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||||
|
{formData.role === 'super' ? '可管理模板' : '可管理模板'}
|
||||||
|
</label>
|
||||||
|
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||||||
|
{allTemplates.map(tpl => (
|
||||||
|
<label key={tpl.id} className={`flex items-center gap-2 p-1 rounded-md transition-colors ${isManageableReadonly ? '' : 'cursor-pointer hover:bg-white'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(formData.manageableTemplates || []).includes(tpl.id)}
|
||||||
|
onChange={() => toggleTemplate(tpl.id, 'manageableTemplates')}
|
||||||
|
disabled={isManageableReadonly}
|
||||||
|
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${isManageableReadonly ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{allTemplates.length === 0 && <p className="text-xs text-text-muted italic">暂无模板</p>}
|
||||||
|
</div>
|
||||||
|
{isManageableReadonly && (
|
||||||
|
<p className="text-[10px] text-text-muted">
|
||||||
|
{formData.role === 'super' ? '超级管理员默认可管理所有模板,不可更改。' : '管理员的模板管理权限由超级管理员设定,不可自行更改。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVisibleTemplates && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||||
|
{formData.role === 'super' ? '可视模板 (全部)' : '可视模板'}
|
||||||
|
</label>
|
||||||
|
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||||||
|
{visibleCandidates.map(tpl => (
|
||||||
|
<label key={tpl.id} className="flex items-center gap-2 cursor-pointer hover:bg-white p-1 rounded-md transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(formData.visibleTemplates || []).includes(tpl.id)}
|
||||||
|
onChange={() => toggleTemplate(tpl.id, 'visibleTemplates')}
|
||||||
|
disabled={formData.role === 'super'}
|
||||||
|
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${formData.role === 'super' ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{visibleCandidates.length === 0 && <p className="text-xs text-text-muted italic">暂无可视模板</p>}
|
||||||
|
</div>
|
||||||
|
{formData.role === 'super' && (
|
||||||
|
<p className="text-[10px] text-text-muted">超级管理员默认可视所有模板。</p>
|
||||||
|
)}
|
||||||
|
{(isSuperEditingAdmin || isAdminEditingSelf) && (
|
||||||
|
<p className="text-[10px] text-text-muted">管理员的可视模板只能从其可管理模板中选择。</p>
|
||||||
|
)}
|
||||||
|
{(isSuperEditingUser || isAdminEditingUser) && (
|
||||||
|
<p className="text-[10px] text-text-muted">医生的可视模板只能从其部门管理员的可管理模板中选择。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-accent"
|
||||||
|
>
|
||||||
|
保存用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/types.ts
Normal file
78
src/types.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
role: 'super' | 'admin' | 'user';
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
department?: string;
|
||||||
|
status?: 'active' | 'inactive';
|
||||||
|
createdAt?: string;
|
||||||
|
visibleTemplates?: string[];
|
||||||
|
manageableTemplates?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
patientName: string;
|
||||||
|
hospitalId: string;
|
||||||
|
patientGender?: string;
|
||||||
|
patientAge?: string;
|
||||||
|
department?: string;
|
||||||
|
bedNumber?: string;
|
||||||
|
surgeryDate?: string;
|
||||||
|
startHour?: string;
|
||||||
|
startMinute?: string;
|
||||||
|
endHour?: string;
|
||||||
|
endMinute?: string;
|
||||||
|
surgeon?: string[];
|
||||||
|
assistant?: string[];
|
||||||
|
anesthesiologist?: string[];
|
||||||
|
anesthesiaType?: string;
|
||||||
|
reportNote?: string;
|
||||||
|
content: string;
|
||||||
|
videos?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
duration: number;
|
||||||
|
}[];
|
||||||
|
capturedFrames?: CapturedFrame[];
|
||||||
|
author: string;
|
||||||
|
authorName: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
status: 'draft' | 'completed';
|
||||||
|
history?: { content: string; updatedAt: string; updatedBy: string; action: 'save_draft' | 'complete_report' }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapturedFrame {
|
||||||
|
id: number;
|
||||||
|
videoIndex: number;
|
||||||
|
videoName: string;
|
||||||
|
time: number;
|
||||||
|
timeFormatted: string;
|
||||||
|
dataUrl: string;
|
||||||
|
isManual?: boolean;
|
||||||
|
manualOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemSettings {
|
||||||
|
frameCount: number;
|
||||||
|
framePositions: number[];
|
||||||
|
apiEndpoint: string;
|
||||||
|
apiKey: string;
|
||||||
|
defaultTemplate?: string;
|
||||||
|
frameMode?: 'uniform' | 'keep';
|
||||||
|
}
|
||||||
160
src/utils/defaultContent.ts
Normal file
160
src/utils/defaultContent.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
export const defaultReportContent = `
|
||||||
|
<!-- 医院Logo -->
|
||||||
|
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
|
||||||
|
<img src="/logo_square.png" alt="医院Logo" style="width: 65px; height: auto; display: block; margin: 0 auto;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 医院名称 -->
|
||||||
|
<p style="text-align: center; font-family: SimSun; margin-bottom: 8px;" contenteditable="false">
|
||||||
|
<strong><u>西 安 交 通 大 学 第 一 附 属 医 院</u></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 报告标题 -->
|
||||||
|
<h1 style="font-family: SimSun; font-size: 20px; margin: 16px 0; text-align: center;" contenteditable="false">手术记录</h1>
|
||||||
|
|
||||||
|
<div class="template-info-section">
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
姓名:<span style="color: #ff0000;">*姓名*</span>
|
||||||
|
性别: <span style="color: #ff0000;">*性别*</span>
|
||||||
|
年龄:<span style="color: #ff0000;">*年龄*</span>
|
||||||
|
科别:<span style="color: #ff0000;">*科室*</span>
|
||||||
|
床号:<span style="color: #ff0000;">*床号*</span>
|
||||||
|
住院号:<span style="color: #ff0000;">*住院号*</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>手术日期:</strong><span style="color: #bdbdbd;">年 月 日</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>术前诊断:</strong><span style="color: #bdbdbd;">术前诊断</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>术后诊断:</strong><span style="color: #bdbdbd;">术后诊断</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>手术名称:</strong>腹腔镜胆囊切除术
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
手术开始时间:<span style="color: #bdbdbd;">时 分</span>
|
||||||
|
手术终止时间:<span style="color: #bdbdbd;">时 分</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
手术者: <span style="color: #bdbdbd;">手术者</span>
|
||||||
|
助手: <span style="color: #bdbdbd;">助手</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
麻醉师:<span style="color: #bdbdbd;">麻醉师</span>
|
||||||
|
麻醉方式: 全麻
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>手术步骤、术中出现的情况及处理:</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 手术图片说明表格 -->
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
||||||
|
<tbody><tr>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
|
||||||
|
</td>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
|
||||||
|
</td>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
|
||||||
|
</td>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
|
||||||
|
</td>
|
||||||
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||||
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
|
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
|
||||||
|
</td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="template-info-section">
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>手术后情况</strong>:患者麻醉恢复后安返病房
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>切除标本描述</strong>:<span style="color: #bdbdbd;">切除标本描述</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>是否送病理检查</strong>:是
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
<strong>冰冻病理结果</strong>:<span style="color: #bdbdbd;">冰冻病理结果</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun;">
|
||||||
|
手术者签名:<span style="color: #bdbdbd;">签名</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="text-align: right; font-family: SimSun; color: #bdbdbd;">
|
||||||
|
年 月 日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Backward compatibility alias
|
||||||
|
export const defaultContent = defaultReportContent;
|
||||||
|
|
||||||
53
src/utils/print.ts
Normal file
53
src/utils/print.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const printDocument = (htmlContent: string) => {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.position = 'fixed';
|
||||||
|
iframe.style.right = '0';
|
||||||
|
iframe.style.bottom = '0';
|
||||||
|
iframe.style.width = '0';
|
||||||
|
iframe.style.height = '0';
|
||||||
|
iframe.style.border = '0';
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
const win = iframe.contentWindow;
|
||||||
|
const doc = iframe.contentDocument || win?.document;
|
||||||
|
if (doc && win) {
|
||||||
|
doc.open();
|
||||||
|
doc.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 10mm; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
||||||
|
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
|
||||||
|
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
|
||||||
|
p { margin: 0; padding: 4px 0; line-height: 1.6; }
|
||||||
|
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||||
|
strong, b { font-weight: 600; }
|
||||||
|
u { text-decoration: underline; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed; }
|
||||||
|
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||||
|
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
|
||||||
|
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
|
||||||
|
.image-placeholder .delete-btn { display: none !important; }
|
||||||
|
.image-placeholder:not(.has-image) { display: none !important; }
|
||||||
|
.template-info-section { position: relative; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="content">${htmlContent}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
doc.close();
|
||||||
|
win.focus();
|
||||||
|
setTimeout(() => {
|
||||||
|
win.print();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (iframe.parentNode) document.body.removeChild(iframe);
|
||||||
|
}, 1000);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
43
src/utils/storage.ts
Normal file
43
src/utils/storage.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const storage = {
|
||||||
|
get<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set<T>(key: string, value: T): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// ignore quota exceeded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key: string): void {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSession<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSession<T>(key: string, value: T): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSession(key: string): void {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
71
工程分析/工作流指南.md
Normal file
71
工程分析/工作流指南.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 项目修改工作流指南
|
||||||
|
|
||||||
|
> 本工作流适用于所有项目修改相关需求。每次收到修改需求时,必须严格按照以下步骤执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流步骤
|
||||||
|
|
||||||
|
### Step 0: 备份与记录时间戳
|
||||||
|
每次执行前,必须先用 Gitea 进行代码备份,并记录问题开始时间:
|
||||||
|
- 时间戳格式:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
|
||||||
|
- 备份命令:
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git checkout -b main
|
||||||
|
git add .
|
||||||
|
git commit -m "backup before modification at {时间戳}"
|
||||||
|
git remote add origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
- 若远程已存在,则使用 `git remote set-url origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git`
|
||||||
|
|
||||||
|
### Step 1: 工程分析目录检查
|
||||||
|
确保 `..\工程分析` 文件夹存在(已创建)。
|
||||||
|
|
||||||
|
### Step 2: 需求分析文档
|
||||||
|
将用户提出的需求整理后写入:
|
||||||
|
```
|
||||||
|
.\工程分析\需求分析-{时间戳}.md
|
||||||
|
```
|
||||||
|
- 内容需包含:需求背景、功能目标、涉及页面/模块、验收标准
|
||||||
|
|
||||||
|
### Step 3: 实现方案文档
|
||||||
|
基于需求分析,编写详细实现方案并写入:
|
||||||
|
```
|
||||||
|
.\工程分析\实现方案-{时间戳}.md
|
||||||
|
```
|
||||||
|
- 内容需包含:技术思路、修改文件清单、关键代码变更说明、风险点
|
||||||
|
- **写完此文档后,必须暂停并交由用户进行二次人工审核确认。未经确认不得继续。**
|
||||||
|
|
||||||
|
### Step 4: 测试方案文档
|
||||||
|
实现方案确认后,编写测试方案并写入:
|
||||||
|
```
|
||||||
|
.\工程分析\测试方案-{时间戳}.md
|
||||||
|
```
|
||||||
|
- 内容需包含:测试项、测试步骤、预期结果、回归验证范围
|
||||||
|
- **写完此文档后,必须暂停并交由用户进行二次人工审核确认。未经确认不得继续。**
|
||||||
|
|
||||||
|
### Step 5: 执行修改与经验记录
|
||||||
|
测试方案确认后,开始执行代码修改:
|
||||||
|
1. 按方案实施修改
|
||||||
|
2. 执行 `npm run lint` 进行类型检查
|
||||||
|
3. 如有必要,执行 `npm run build` 验证构建
|
||||||
|
4. 修改完成后,在以下文档中追加本次执行过程中的关键问题及解决方案:
|
||||||
|
```
|
||||||
|
.\工程分析\经验记录.md
|
||||||
|
```
|
||||||
|
- 记录格式(四段式):
|
||||||
|
- **A. 具体问题**
|
||||||
|
- **B. 产生问题原因**
|
||||||
|
- **C. 解决问题方案**
|
||||||
|
- **D. 后续如何避免问题**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快捷入口
|
||||||
|
|
||||||
|
- **工程分析目录**: `C:\Users\Administrator\Downloads\Gemini-图文报告系统-V1.1\工程分析`
|
||||||
|
- **Gitea 远程地址**: `http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git`
|
||||||
|
- **类型检查命令**: `npm run lint`
|
||||||
|
- **构建命令**: `npm run build`
|
||||||
Reference in New Issue
Block a user