This commit is contained in:
Administrator
2026-04-18 16:31:09 +08:00
commit 4e24ee15a2
30 changed files with 11182 additions and 0 deletions

9
.env.example Normal file
View 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
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

209
AGENTS.md Normal file
View File

@@ -0,0 +1,209 @@
# 手术图文病历报告系统 —— Agent 开发指南
> 本文件面向 AI 编程助手。修改项目结构、构建流程或关键配置后,请同步更新本文档。
---
## 项目概览
这是一个面向医院场景的**纯前端单页应用SPA**,用于手术记录图文报告的撰写、视频关键帧抽取、模板管理以及基于角色的用户权限控制。
- **名称**:手术图文病历报告生成终端 / 智能图文报告管理系统
- **架构**:纯前端应用,无后端服务。所有数据(用户、报告、模板、设置、图片资源)均持久化在浏览器 `localStorage` / `sessionStorage` 中。
- **技术栈**React 19 + TypeScript 5.8 + Vite 6 + Tailwind CSS 4 + React Router DOM 7 + Lucide React图标
- **语言**:项目界面、注释、文档均为中文。
- **运行环境**:现代浏览器(依赖 `localStorage``URL.createObjectURL``contenteditable``MutationObserver` 等 Web API
---
## 项目结构
```
.
├── docker-compose.yaml # Docker Compose 配置(端口 4002:80
├── Dockerfile # 多阶段构建node:20-alpine 构建 → nginx:alpine 运行
├── nginx.conf # Nginx SPA 路由回退配置try_files
├── package.json # 项目依赖与脚本
├── vite.config.ts # Vite 配置(含 Tailwind CSS 插件、路径别名 @
├── tsconfig.json # TypeScript 配置ES2022、react-jsx、paths: {"@/*": ["./*"] }
├── index.html # Vite 入口 HTML
├── public/ # 静态资源logo_square.png、favicon.ico
└── src/
├── main.tsx # 应用入口React StrictMode + createRoot
├── App.tsx # 根组件定义所有路由BrowserRouter
├── index.css # 全局样式、Tailwind 主题变量、编辑器/打印专用样式
├── types.ts # TypeScript 类型定义与常量User、Report、Template、SystemSettings、FormField 等)
├── components/
│ └── Sidebar.tsx # 侧边导航栏(角色过滤、折叠逻辑、退出登录)
├── pages/
│ ├── Login.tsx # 登录页 + 默认数据初始化(用户、模板、表单字段、系统设置)
│ ├── Dashboard.tsx # 工作台概览统计卡片、7 天趋势 SVG 图表、快捷入口)
│ ├── ReportEditor.tsx # 图文报告编辑器最核心、最复杂页面2000+ 行)
│ ├── ReportManage.tsx # 报告管理(搜索、筛选、查看、编辑、删除、历史、导出)
│ ├── ReportView.tsx # 报告只读查看 + 打印
│ ├── TemplateManage.tsx # 模板管理(富文本编辑器、字段库管理、图片占位符)
│ ├── UserManage.tsx # 用户管理CRUD、签名上传、角色/模板权限分配)
│ └── SystemSettings.tsx # 系统设置视频抽帧参数、API 地址、默认模板)
└── utils/
├── storage.ts # localStorage / sessionStorage 封装JSON 自动序列化)
├── print.ts # 打印工具:通过隐藏 iframe 渲染 A4 内容并调用 window.print()
└── defaultContent.ts # 默认报告模板 HTML含智能字段绑定语法与图片占位符
```
---
## 构建与运行命令
```bash
# 安装依赖
npm install
# 本地开发(端口 3000监听 0.0.0.0
npm run dev
# 生产构建(输出到 dist/
npm run build
# 预览生产构建
npm run preview
# 类型检查(不输出文件)
npm run lint
# 清理构建产物
npm run clean
```
### Docker 部署
```bash
# 构建并运行(访问 http://localhost:4002
docker-compose up -d --build
# 停止
docker-compose down
```
构建流程:
1. **构建阶段**`node:20-alpine` 执行 `npm ci` + `npm run build`
2. **运行阶段**`nginx:alpine` 托管 `dist/` 静态文件,端口 80
3. `nginx.conf` 已配置 `try_files` 回退,支持 SPA 前端路由刷新不 404。
---
## 代码组织与模块划分
### 路由结构(`src/App.tsx`
| 路径 | 页面 | 角色权限 |
|------|------|----------|
| `/` | Login | 公开 |
| `/dashboard` | Dashboard | super / admin / user |
| `/report-editor` | ReportEditor | super / admin / user |
| `/report-manage` | ReportManage | super / admin / user |
| `/report-view/:id` | ReportView | super / admin / useruser 只能看自己的) |
| `/template-manage` | TemplateManage | super / admin |
| `/user-manage` | UserManage | super / admin |
| `/system-settings` | SystemSettings | super / admin / user |
### 核心数据模型(`src/types.ts`
- `User`:用户(`role: 'super' | 'admin' | 'user'`
- `Report`:报告(含患者信息、手术信息、富文本 `content`、视频数组、抽帧数组 `capturedFrames`、历史记录 `history`
- `Template`:模板(名称、描述、富文本 `content`
- `SystemSettings`:系统设置(抽帧数量/位置、API 端点、默认模板、自动插帧配置)
- `FormField` / `FieldType`:表单字段配置(支持文本、单选、多选、时间、日期、签名、图片)
- `CapturedFrame`视频关键帧含视频索引、时间戳、DataURL
### 存储层(`src/utils/storage.ts`
所有业务数据通过 `storage.get<T>(key, fallback)` / `storage.set<T>(key, value)` 读写 `localStorage`(或 `sessionStorage`)。**不存在任何后端 API 调用**。
关键存储键:
- `users` — 用户列表
- `currentUser` — 当前登录用户
- `reports` — 报告列表
- `templates` — 模板列表
- `systemSettings` — 系统设置
- `formFieldsConfig` — 表单字段配置
- `multiSelectOptions` / `anesthesiaOptions` — 下拉选项缓存
- `imageAssets` — 图片资源库DataURL
- `reportEditorDraft_${username}` — 报告编辑器自动草稿
- `restore_${reportId}` — 报告恢复临时内容sessionStorage
---
## 核心功能实现细节
### 1. 图文报告编辑器(`ReportEditor.tsx`
- **富文本实现**:基于原生 `contenteditable` div通过 `document.execCommand` 实现加粗、斜体、下划线、对齐、插入表格等。
- **A4 纸模拟**:编辑器内容区固定宽度 `210mm`,最小高度 `297mm`,通过 `MutationObserver` 动态扩展为多页。
- **图片占位符**`.image-placeholder` 元素,支持点击上传、拖入视频关键帧、或自动插入抽帧结果。占位符分为 `data-mode="frame"`(可拖入关键帧)和 `data-mode="manual"`(仅手动上传,如 Logo、签名
- **智能字段绑定**:模板中可插入 `<span class="smart-field-wrapper" data-bind="key">`,在报告编辑器左侧表单填写后,通过 DOM 查询同步更新编辑器内对应字段值。
- **视频抽帧**:支持上传本地视频(`URL.createObjectURL`),可手动截图或按百分比位置自动均匀抽帧(`autoCaptureFrames`)。抽帧结果支持拖入编辑器占位符。
- **自动草稿**:在 `beforeunload` / `visibilitychange` / 状态变更时自动保存草稿到 `localStorage`
- **打印**:调用 `printDocument()` 将编辑器 HTML 注入隐藏 iframe 并触发打印;`@media print` 样式隐藏所有非打印元素。
### 2. 模板管理(`TemplateManage.tsx`
- 与报告编辑器共享相似的 `contenteditable` 编辑体验。
- 额外支持**字段库管理**:可插入/编辑/删除表单字段,字段变更会同步到 `formFieldsConfig`
- 模板内容即为 HTML 字符串,存储在 `templates` localStorage 键中。
### 3. 用户与权限(`Login.tsx`、`UserManage.tsx`、`Sidebar.tsx`
- **角色体系**
- `super`(超级管理员):拥有所有权限,可管理所有用户。
- `admin`(管理员):可管理同科室的 `user` 角色用户。
- `user`(医生):只能查看/编辑自己创建的报告。
- **权限控制**:路由跳转前检查 `currentUser.role`Sidebar 根据角色过滤导航项;页面内根据角色隐藏按钮或重定向。
- **默认账号**(首次登录或数据缺失时自动初始化):
- `admin` / `123456` — 超级管理员
- `manager` / `123456` — 管理员
- `0001` / `123456` — 医生
---
## 代码风格指南
- **语言**TypeScript严格模式未开启`noEmit: true`),但代码中广泛使用显式类型注解。
- **组件风格**:函数组件 + React Hooks所有页面组件默认导出。
- **CSS**Tailwind CSS 工具类为主,复杂样式(如 `contenteditable` 内部元素、打印样式)写在 `src/index.css``@layer components` 中。
- **路径别名**`@/` 指向项目根目录(`vite.config.ts``tsconfig.json` 均已配置)。
- **图标**:统一使用 `lucide-react`
- **事件处理**:原生 DOM 事件与 React 事件混用(编辑器内大量直接使用 `document.execCommand``addEventListener`)。
- **状态管理**:无 Redux/Zustand所有状态以 React `useState` + `useRef` + `useEffect` 管理,复杂页面(如 ReportEditor使用 `useRef` 保存最新状态快照以绕过闭包问题。
---
## 测试说明
**本项目目前未配置任何测试框架**,没有单元测试、集成测试或 E2E 测试。
如需添加测试,建议:
- 单元测试Vitest与 Vite 生态一致)
- 组件测试React Testing Library
- E2E 测试Playwright测试 `localStorage` 持久化、文件上传、打印流程等)
---
## 安全注意事项
> ⚠️ **关键警告**:本应用为纯前端实现,所有认证与授权逻辑均在客户端执行。
- 用户密码以**明文**形式存储在浏览器 `localStorage` 中,无任何哈希或加密处理。
- 权限控制依赖客户端路由守卫和 UI 隐藏,可被技术手段绕过。
- 所有报告数据、视频对象 URL、图片 DataURL 均保存在用户本地浏览器中,无云端备份。
- **生产环境部署建议**:仅限内网或受信任环境使用,不要直接暴露于公网。
- 环境变量 `.env.local` 中的 `GEMINI_API_KEY` 仅用于预留的 AI 功能,当前主业务逻辑未实际调用 Gemini API。
---
## 常见开发注意事项
1. **编辑器内容初始化**`ReportEditor.tsx` 中编辑器 `innerHTML` 的初始化逻辑分散在 `useEffect`(基于 reportId / draft`useLayoutEffect`(安全兜底)中,修改时需注意两者优先级,避免内容被覆盖。
2. **视频对象 URL 生命周期**:通过 `URL.createObjectURL()` 创建的视频 URL 仅在当前会话有效,刷新页面后视频需重新上传。报告保存时仅保留视频元数据(名称、时长),不保存视频文件本身。
3. **打印样式**`@media print` 规则定义在 `src/index.css` 中,修改编辑器内元素类名时,需同步检查打印样式是否失效。
4. **Tailwind CSS v4**:本项目使用 Tailwind CSS 4`@import "tailwindcss"` + `@theme`),与 v3 的 `tailwind.config.js` 方式不兼容,请勿混用旧版配置。
5. **HMR 特殊处理**`vite.config.ts` 中根据 `DISABLE_HMR` 环境变量控制 HMR 开关,该变量由 AI Studio 运行时注入,通常无需手动修改。

14
Dockerfile Normal file
View 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
View 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` 中(无后端哈希处理)。
- 若用于生产环境,请确保部署在内网或受信任环境中。

11
docker-compose.yaml Normal file
View File

@@ -0,0 +1,11 @@
version: "3.8"
services:
tuwen_system:
build:
context: .
dockerfile: Dockerfile
container_name: tuwen_system
restart: unless-stopped
ports:
- "4002:80"

13
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
public/logo_square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

27
src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

216
src/index.css Normal file
View File

@@ -0,0 +1,216 @@
@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;
}
/* Smart Field Bindable Controls */
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 2px;
vertical-align: text-bottom;
white-space: nowrap;
}
.smart-field-wrapper .field-label {
color: #64748b;
user-select: none;
}
.smart-field-wrapper .field-value {
min-width: 32px;
padding: 0 4px;
margin: 0 2px;
border: 1px solid #cbd5e1;
border-radius: 2px;
display: inline-block;
background: #f8fafc;
color: #0f172a;
line-height: 1.2;
font-size: inherit;
vertical-align: text-bottom;
box-sizing: border-box;
min-height: 1.2em;
outline: none;
}
.smart-field-wrapper .field-value:empty::before {
content: '\200b';
}
.smart-field-wrapper .field-value:focus {
background-color: #e2e8f0;
border-color: #94a3b8;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
}
.smart-field-wrapper .delete-btn {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background: #ef4444;
color: white;
border-radius: 50%;
font-size: 10px;
line-height: 16px;
text-align: center;
cursor: pointer;
user-select: none;
display: none;
z-index: 10;
}
.smart-field-wrapper .delete-btn:hover {
background: #dc2626;
}
.template-editor-mode .smart-field-wrapper:hover .delete-btn,
.template-editor-mode .smart-field-wrapper:focus-within .delete-btn {
display: block;
}
.report-signature-img {
max-width: 120px;
max-height: 40px;
width: auto;
height: auto;
object-fit: contain;
vertical-align: middle;
display: inline-block;
}
}
@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;
}
.print-content .smart-field-wrapper .field-value {
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px !important;
}
.print-content .smart-field-wrapper .delete-btn {
display: none !important;
}
.report-signature-img {
max-width: 120px !important;
max-height: 40px !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
vertical-align: middle !important;
display: inline-block !important;
}
}

10
src/main.tsx Normal file
View 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
View 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>
);
}

230
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS } 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 fieldsConfig = storage.get<FormField[]>('formFieldsConfig', []);
if (fieldsConfig.length === 0) {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []);
if (savedAssets.length === 0) {
fetch('/logo_square.png')
.then(res => res.blob())
.then(blob => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
storage.set('imageAssets', [{ id: 'asset_logo', name: '医院Logo', dataUrl }]);
};
reader.readAsDataURL(blob);
})
.catch(() => {});
}
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',
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
};
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>
);
}

2014
src/pages/ReportEditor.tsx Normal file

File diff suppressed because it is too large Load Diff

476
src/pages/ReportManage.tsx Normal file
View File

@@ -0,0 +1,476 @@
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, Download, Printer } from 'lucide-react';
import { User, Report, DEFAULT_FORM_FIELDS } from '../types';
import { storage } from '../utils/storage';
import { printDocument } from '../utils/print';
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);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportTarget, setExportTarget] = 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);
setSelectedIds(prev => prev.filter(pid => pid !== id));
}
};
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);
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredReports.length && filteredReports.length > 0) {
setSelectedIds([]);
} else {
setSelectedIds(filteredReports.map(r => r.id));
}
};
const handleBulkDelete = () => {
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return;
const updated = reports.filter(r => !selectedIds.includes(r.id));
setReports(updated);
storage.set('reports', updated);
setSelectedIds([]);
};
const buildExportData = (report: Report) => {
const fields: Record<string, any> = {};
DEFAULT_FORM_FIELDS.forEach(f => {
fields[f.key] = (report as any)[f.key];
});
return {
meta: {
id: report.id,
title: report.title,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
author: report.author,
authorName: report.authorName,
status: report.status
},
fields
};
};
const downloadJSON = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportSinglePDF = (report: Report) => {
printDocument(report.content);
};
const exportSingleJSON = (report: Report) => {
const data = buildExportData(report);
downloadJSON(data, `报告_${report.patientName || '未命名'}_${report.id}.json`);
};
const exportBulkPDF = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>');
printDocument(mergedHTML);
};
const exportBulkJSON = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const data = selectedReports.map(r => buildExportData(r));
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadJSON(data, `reports_export_${timestamp}.json`);
};
const openExportModal = (report: Report) => {
setExportTarget(report);
setExportModalOpen(true);
};
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-4">
<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>
{selectedIds.length > 0 && (
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-border rounded-lg">
<span className="text-sm font-semibold text-text-main"> {selectedIds.length} </span>
<div className="flex-1"></div>
<button
onClick={exportBulkPDF}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Printer size={14} /> PDF
</button>
<button
onClick={exportBulkJSON}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Download size={14} /> JSON
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
</button>
<button
onClick={() => setSelectedIds([])}
className="px-3 py-1.5 text-sm font-medium rounded-lg text-text-muted hover:bg-slate-100 transition-colors"
>
</button>
</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-4 py-4 text-left border-b border-border w-10">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={filteredReports.length > 0 && selectedIds.length === filteredReports.length}
onChange={toggleSelectAll}
/>
</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 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-4 py-4 border-b border-border">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={selectedIds.includes(report.id)}
onChange={() => toggleSelect(report.id)}
/>
</td>
<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>
<button
onClick={() => openExportModal(report)}
className="p-2 rounded-lg bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
title="导出"
>
<Download size={16} />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} 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>
)}
{exportModalOpen && exportTarget && (
<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-6 w-full max-w-[360px] shadow-2xl border border-border">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-text-main"></h3>
<button
onClick={() => setExportModalOpen(false)}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<X size={18} />
</button>
</div>
<p className="text-sm text-text-muted mb-4"></p>
<div className="flex flex-col gap-3">
<button
onClick={() => { exportSinglePDF(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Printer size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> PDF</div>
<div className="text-xs text-text-muted"> PDF</div>
</div>
</button>
<button
onClick={() => { exportSingleJSON(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Download size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> JSON</div>
<div className="text-xs text-text-muted"></div>
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
}

117
src/pages/ReportView.tsx Normal file
View 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>
);
}

View File

@@ -0,0 +1,419 @@
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';
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
setSettings(savedSettings);
} else if (savedTemplates.length > 0) {
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
}
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="flex items-center gap-2">
<input
type="checkbox"
id="autoInsertFrames"
checked={settings.autoInsertFrames || false}
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
className="w-4 h-4 accent-accent cursor-pointer"
/>
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer"></label>
</div>
<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"> (s)</label>
<input
type="number"
min={0}
step={0.1}
value={settings.autoInsertDelay || 0}
onChange={(e) => setSettings({ ...settings, autoInsertDelay: Math.max(0, parseFloat(e.target.value) || 0) })}
className="input-minimal bg-white w-full"
/>
</div>
</div>
<p className="text-[11px] text-text-muted"></p>
<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>
{settings.autoInsertFrames && (
<span
onClick={() => {
const current = settings.autoInsertFrameIndices || [];
const next = current.includes(idx)
? current.filter(i => i !== idx)
: [...current, idx].sort((a, b) => a - b);
setSettings({ ...settings, autoInsertFrameIndices: next });
}}
className={`absolute top-1 left-1 cursor-pointer transition-colors ${
(settings.autoInsertFrameIndices || []).includes(idx) ? 'text-green-500' : 'text-slate-300'
}`}
>
<Check size={12} />
</span>
)}
<button
type="button"
onClick={() => {
const newPos = settings.framePositions.filter((_, i) => i !== idx);
const newIndices = (settings.autoInsertFrameIndices || [])
.filter(i => i !== idx)
.map(i => i > idx ? i - 1 : i);
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length, autoInsertFrameIndices: newIndices });
}}
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>
);
}

1381
src/pages/TemplateManage.tsx Normal file

File diff suppressed because it is too large Load Diff

741
src/pages/UserManage.tsx Normal file
View File

@@ -0,0 +1,741 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { UserPlus, Edit, Trash2, Upload, X } 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 compressImage = (file: File, maxSize: number = 500): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let { width, height } = img;
if (width > height && width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
}
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.onerror = reject;
};
reader.onerror = reject;
});
};
const handleSignatureUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const compressed = await compressImage(file);
setFormData(prev => ({ ...prev, signature: compressed }));
} catch {
alert('图片压缩失败,请重试');
}
};
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, signature: formData.signature } 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>
<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.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
}`}>
{user.signature ? '已上传' : '未上传'}
</span>
</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>
)}
<div className="space-y-2">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
{formData.signature ? (
<div className="flex items-center gap-3">
<img
src={formData.signature}
alt="电子签名预览"
className="h-16 border border-border rounded bg-white object-contain"
/>
<div className="flex flex-col gap-2">
<label className="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-1">
<Upload size={12} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, signature: undefined }))}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors inline-flex items-center gap-1"
>
<X size={12} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<label className="px-4 py-2 text-sm font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-2">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<span className="text-xs text-text-muted"> JPGPNG 500px </span>
</div>
)}
</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>
);
}

143
src/types.ts Normal file
View File

@@ -0,0 +1,143 @@
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[];
signature?: 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';
autoInsertFrames?: boolean;
autoInsertFrameIndices?: number[];
autoInsertDelay?: number;
}
export interface BindableField {
key: string;
label: string;
}
export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'patientName', label: '姓名' },
{ key: 'patientGender', label: '性别' },
{ key: 'patientAge', label: '年龄' },
{ key: 'department', label: '科别' },
{ key: 'bedNumber', label: '床号' },
{ key: 'hospitalId', label: '住院号' },
{ key: 'surgeryDate', label: '手术日期' },
{ key: 'title', label: '手术名称' },
{ key: 'startTime', label: '手术开始时间' },
{ key: 'endTime', label: '手术终止时间' },
{ key: 'surgeon', label: '手术者' },
{ key: 'assistant', label: '助手' },
{ key: 'anesthesiologist', label: '麻醉师' },
{ key: 'anesthesiaType', label: '麻醉方式' },
];
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature' | 'image';
export interface FormField {
key: string;
label: string;
category: string;
type: FieldType;
visibleInForm: boolean;
isSystemLocked: boolean;
options?: string[];
timeFormat?: string;
timeDefault?: 'current' | 'specific';
fixedTimeValue?: string;
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'department', label: '科别', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'bedNumber', label: '床号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY-MM-DD', timeDefault: 'specific' },
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
{ key: 'reportDate', label: '撰写时间', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY年MM月DD日', timeDefault: 'current' },
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['张医生', '李医生', '王医生'] },
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['赵医生', '钱医生', '孙医生'] },
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['周医生', '吴医生', '郑医生'] },
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
{ key: 'preoperativeDiagnosis', label: '术前诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postoperativeDiagnosis', label: '术后诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postOpCondition', label: '手术后情况', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['患者麻醉恢复后安返病房'] },
{ key: 'specimenDescription', label: '切除标本描述', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊一枚壁厚约0.3cm,内含数枚结石'] },
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] },
{ key: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
];

164
src/utils/defaultContent.ts Normal file
View File

@@ -0,0 +1,164 @@
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>&#8203;`;
export const defaultReportContent = `
<!-- 医院Logo -->
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
</span>
</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;">
姓名:${smartField('patientName')}
性别:${smartField('patientGender')}
年龄:${smartField('patientAge')}
科别:${smartField('department')}
床号:${smartField('bedNumber')}
住院号:${smartField('hospitalId')}
</p>
<p style="font-family: SimSun;">
<strong>手术日期:</strong>${smartField('surgeryDate')}
</p>
<p style="font-family: SimSun;">
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
</p>
<p style="font-family: SimSun;">
<strong>术后诊断:</strong>${smartField('postoperativeDiagnosis')}
</p>
<p style="font-family: SimSun;">
<strong>手术名称:</strong>${smartField('title')}
</p>
<p style="font-family: SimSun;">
手术开始时间:${smartField('startTime')}
手术终止时间:${smartField('endTime')}
</p>
<p style="font-family: SimSun;">
手术者:${smartField('surgeon')}
助手:${smartField('assistant')}
</p>
<p style="font-family: SimSun;">
麻醉师:${smartField('anesthesiologist')}
麻醉方式:${smartField('anesthesiaType')}
</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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<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>${smartField('postOpCondition')}
</p>
<p style="font-family: SimSun;">
<strong>切除标本描述</strong>${smartField('specimenDescription')}
</p>
<p style="font-family: SimSun;">
<strong>是否送病理检查</strong>${smartField('pathologyCheck')}
</p>
<p style="font-family: SimSun;">
<strong>冰冻病理结果</strong>${smartField('frozenPathology')}
</p>
<p style="font-family: SimSun;">
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
</p>
<p style="text-align: right; font-family: SimSun;">
${smartField('reportDate')}
</p>
</div>
`;
// Backward compatibility alias
export const defaultContent = defaultReportContent;

60
src/utils/print.ts Normal file
View File

@@ -0,0 +1,60 @@
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: 15mm 10mm; }
* { box-sizing: border-box; }
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
.content { width: 100%; 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; }
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
.smart-field-wrapper .field-value { min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em; outline: none; }
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
@media print {
.smart-field-wrapper .field-value { border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px !important; }
}
</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
View 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 (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
},
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 (e) {
console.error('Session storage save failed:', e);
}
},
removeSession(key: string): void {
sessionStorage.removeItem(key);
},
};

26
tsconfig.json Normal file
View 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
View 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',
},
};
});