33 Commits

Author SHA1 Message Date
guanyu
405a9dfb72 fix: Bug #249 门诊手术安排查询未过滤已删除手术申请单 - 将cli_surgery表的LEFT JOIN改为INNER JOIN,确保已删除作废的手术申请单不在手术安排查询界面显示 2026-04-28 14:03:14 +08:00
d1be841688 fix: Bug #451 门诊医生站-提交新增手术申请后列表刷新失败 2026-04-28 12:33:16 +08:00
guanyu
9b8655748e fix: Bug #449/#450 门诊医生站接诊/数据加载失败 - 修复TodayOutpatientServiceImpl中receivePatient/completeVisit/cancelVisit方法空壳问题,改为调用DoctorStationMainAppService正确业务逻辑 2026-04-28 12:07:38 +08:00
00fd6c8710 在 vite.config.js 中添加了动态构建版本定义,通过环境变量 VITE_APP_VERSION 实现。
更新了 login.vue,使其动态显示构建版本,而非使用硬编码的值。
2026-04-27 14:16:32 +08:00
bbd9d48fa6 test: Playwright E2E测试12个用例全部通过!
- 修复登录按钮选择器:'登 录'(带空格)
- 修复placeholder:'账号'/'密码'
- 修复登录失败检测逻辑
- 12/12用例通过,耗时16.9秒
- 覆盖:登录4场景 + Bug回归3个(#437/#443/#427) + 手术计费2个 + 医生站2个 + 并发1个
2026-04-25 22:33:53 +08:00
8fb1d3e583 fix: 修正Playwright登录页选择器 - 使用实际placeholder '账号'/'密码' 2026-04-25 22:29:23 +08:00
34ba7cae6a fix: 修复Playwright页面对象定义错误 + 根目录config
- 修复LoginPage/SurgeryBillingPage/DoctorStationPage中page变量作用域问题
- 新增根目录playwright.config.ts(解决配置加载问题)
- .gitignore添加test-results和report目录排除
2026-04-25 22:14:19 +08:00
305ab15436 test: 增强Playwright E2E测试方案 - 新增手术计费/医生站/并发测试用例
- 新增页面对象: SurgeryBillingPage, DoctorStationPage
- 新增测试用例: 手术计费防重复(#437), 签发耗材验证(#443), 并发操作测试
- 增强登录测试: 多场景覆盖
- 完善测试数据工具: 支持多角色用户配置
- 清理冗余备份文件
2026-04-25 22:04:36 +08:00
46a7076460 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-25 21:07:43 +08:00
e0e6693897 fix: 修正Playwright测试方案架构问题(诸葛亮审查反馈)
- 新增fixtures/auth.ts 登录认证夹具
- 新增pages/LoginPage.ts 页面对象模型
- 新增specs/login.spec.ts 登录测试用例(成功/失败/空用户名)
- 新增specs/bug-regression.spec.ts Bug回归测试(#437/#427)
- 新增.env.test 测试环境变量模板
- package.json添加test:e2e/test:e2e:ui/test:e2e:report脚本
- 移除test-data.ts中密码硬编码,改用环境变量
- .gitignore添加.env.test.local/playwright-report/test-results

感谢诸葛亮架构审查!
2026-04-25 21:07:40 +08:00
guanyu
7d1e50d045 fix: 修复#443手术计费签发耗材报错
根因: handleAddDeviceBilling方法中对locationId的验证过于严格,
当前端未传递locationId时直接抛出ServiceException导致签发失败。

修复: 将严格验证改为预处理设置默认值,
当advice.getLocationId()为null时, 使用SecurityUtils.getLoginUser().getOrgId()作为默认位置ID。
2026-04-25 21:05:05 +08:00
25ce12cebf Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-25 21:02:16 +08:00
7d55717037 feat: 添加Playwright E2E自动化测试完整方案
- 创建完整Playwright测试方案文档(docs/specs/)
- 创建Playwright配置文件(tests/playwright.config.ts)
- 创建测试数据工具类(tests/e2e/utils/test-data.ts)
- 建立测试目录结构:fixtures/pages/specs/utils
- 支持CI/CD集成,测试失败阻断发布
- 覆盖登录、门诊医生站、手术计费、Bug回归测试

关联任务: UI功能性测试方案落地
2026-04-25 21:02:13 +08:00
guanyu
290e8f8f15 fix: 修复#445门诊手术待生成列表未剔除已生成医嘱
根因: getSurgeryPage查询只查cli_surgery表,没有关联wor_service_request表检查是否已生成医嘱。
所有手术都会显示在待生成列表中,不管是否已处理。

修复: 在getSurgeryPage查询中LEFT JOIN wor_service_request表,
通过sr.id IS NULL过滤掉已生成医嘱的手术。
2026-04-25 20:10:31 +08:00
fc84fd61ab fix(#437): 数据库层修复手术计费重复生成收费项
- 添加复合唯一约束 uk_charge_item_encounter_service_product_source
  防止同一就诊下同一来源服务+产品产生重复收费项
  约束字段:encounter_id + service_table + service_id + product_table + product_id + generate_source_enum
- 添加索引 idx_charge_item_generate_source_product 加速手术计费查询
- 添加索引 idx_charge_item_encounter_status 加速按就诊状态查询
- 提供重复数据检测SQL供运维排查历史数据

根因分析:
1. adm_charge_item 表无任何唯一约束,同一收费项可被多次插入
2. 前端手术计费页面使用 sourceBillNo 过滤,但该字段不存在于 ChargeItem 实体中
3. 多处代码路径(SurgeryAppServiceImpl/RequestFormManageAppServiceImpl)均可生成收费项
4. 缺少数据库层面的兜底防护

Author: xunyu
2026-04-25 20:04:54 +08:00
guanyu
d79690a371 fix: 修复#442手术计费删除待签发耗材报错
根因: handleDel方法中使用iProcedureService.listByIds(requestIds)错误,
requestIds是WOR_DEVICE_REQUEST/WOR_SERVICE_REQUEST表主键,
不是CLI_PROCEDURE表主键。

修复: 改用iProcedureService.getProcedureRecords(requestIds, serviceTable)
通过request_id字段正确关联执行记录。
2026-04-25 20:02:11 +08:00
7bccbc7085 fix: Bug #427 检查项目分类手风琴展开 + Bug #437 手术计费重复记录修复
- #427: switchCategory函数已实现手风琴逻辑(切换时收起其他分类)
- #437: prescriptionlist.vue添加isSaving防重复提交锁
- #437: 使用JSON.parse(JSON.stringify(row))清理Vue响应式对象
- #437: 添加finally块确保锁释放
2026-04-25 19:47:05 +08:00
guanyu
059ef483ca fix: 修复#447住院医生站手术申请弹窗无法加载手术类诊疗目录数据
根因: adviceType=3(诊疗)查询时强制排除手术项(category_code!='手术'且!='24'),
导致通过categoryCode='24'显式查询手术项目时结果为空。

修复: 仅在未指定categoryCode时才排除手术,
当显式指定categoryCode='24'或'手术'时允许加载手术类项目。
2026-04-25 16:12:03 +08:00
guanyu
4beb4c40c5 fix: 修复#448门诊划价项目分类过滤失效 - 耗材和诊疗查询缺少categoryCode过滤条件
- adviceType=2(耗材)查询添加categoryCode过滤
- adviceType=3(诊疗)查询添加categoryCode过滤
- 与adviceType=1(药品)保持一致的过滤逻辑
- 修复选择耗材类型时仍检索出药品的问题
2026-04-25 15:27:02 +08:00
914f2d8229 docs: 按刘备建议结构重新整理《HIS项目发布检查清单 v1.0》 2026-04-25 12:12:41 +08:00
2f57b3e7c1 docs: 整合四份清单为《HIS项目发布检查清单 v1.0》 2026-04-25 12:08:51 +08:00
guanyu
39ccd27df8 feat: 新增《后端发布前检查清单》- 关羽
补充后端发布前六大模块检查项:
1. Maven编译验证
2. Spring Boot多环境配置
3. MyBatis Plus规范(实体映射/SQL安全/事务管理)
4. RESTful API设计(统一返回/参数校验/版本管理)
5. 安全与合规(数据脱敏/权限控制/审计日志)
6. 性能检查(N+1查询/慢查询优化)

与陈琳的前端清单形成对称体系,覆盖getDepartmentList类问题的后端等价场景。
2026-04-24 19:19:23 +08:00
d370b6a888 docs: 补充ESLint flat config配置示例到CI/CD门禁规范 2026-04-24 19:16:44 +08:00
3c61e39e09 docs: 修正构建门禁文档中的命令不一致问题 - 统一前端构建命令为 build:prod,后端编译命令为 mvn clean package -DskipTests 2026-04-24 19:06:23 +08:00
f2c71b08bb feat: 启用ESLint import规则 - 实时检测缺失导出,防止构建失败 2026-04-24 18:12:27 +08:00
90cf7f43d7 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 18:06:44 +08:00
1f5d392c08 chore: 清理.git残留的.orig文件 2026-04-24 18:06:39 +08:00
d52bbda8c3 docs: 完善三份构建门禁文档 - 补充前后端协同检查、Java后端门禁、数据库变更字段
架构评审改进项:
- frontend-checklist.md: 增加后端Maven编译、数据库脚本、接口兼容性检查
- cicd-gatekeeper.md: 补充Java后端构建配置(SpotBugs)、分阶段覆盖率目标
- commit-template.md: 增加数据库变更影响评估字段、精简截图要求
2026-04-24 18:03:45 +08:00
guanyu
986510278b feat: 配置Husky pre-commit钩子 - 提交前自动执行前端构建检查
- 创建.husky/pre-commit文件
- 配置提交前自动执行npm run build:dev检查语法
- 添加node_modules存在性校验
- 预留ESLint检查接口(待赵云配置后启用)
- 更新openhis-ui-vue3/package.json添加lint-staged配置

【关羽】构建门禁第一步落地
2026-04-24 18:02:27 +08:00
758921b633 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 17:14:29 +08:00
8e7ebd3461 chore: 更新package-lock.json(husky安装) 2026-04-24 17:13:31 +08:00
060d1910dd Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 17:05:23 +08:00
44ae216612 feat: 添加husky pre-commit hook配置实现构建验证 (#441)
- 配置husky作为pre-commit钩子
- 添加构建验证脚本,提交前自动执行构建检查
- 防止构建失败的代码被提交到仓库

关联任务: 自动化构建门禁方案第一步
2026-04-24 17:04:49 +08:00
40 changed files with 3154 additions and 1434 deletions

3
.gitignore vendored
View File

@@ -63,3 +63,6 @@ public.sql
发版记录/2025-11-12/发版日志.docx 发版记录/2025-11-12/发版日志.docx
.gitignore .gitignore
openhis-server-new/openhis-application/src/main/resources/application-dev.yml openhis-server-new/openhis-application/src/main/resources/application-dev.yml
.env.test.local
playwright-report/
test-results/

51
.husky/pre-commit Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env sh
# ============================================================
# Husky Pre-commit Hook - HIS项目
# 配置: 关羽 | 日期: 2026-04-24
# 功能: 提交前自动检查前端构建
# ============================================================
echo "========================================"
echo "🔍 [Pre-commit] HIS项目提交检查"
echo "========================================"
# 检查前端目录是否存在
if [ ! -d "openhis-ui-vue3" ]; then
echo "⚠️ [Pre-commit] 未找到openhis-ui-vue3目录跳过前端检查"
exit 0
fi
cd openhis-ui-vue3
# 检查node_modules是否存在
if [ ! -d "node_modules" ]; then
echo "⚠️ [Pre-commit] node_modules未安装请先执行 npm install"
echo " 提示: 首次使用或依赖变更后需要安装依赖"
exit 1
fi
# 执行lint检查ESLint配置由赵云下周完善后启用
if grep -q '"lint"' package.json 2>/dev/null; then
echo "📋 [Pre-commit] 执行Lint检查..."
if npm run lint -- --max-warnings 0 2>&1; then
echo "✅ [Pre-commit] Lint检查通过"
else
echo "❌ [Pre-commit] Lint检查失败请修复代码规范问题"
exit 1
fi
else
echo "⏭️ [Pre-commit] 未配置lint脚本待赵云配置ESLint后启用"
fi
# 执行快速构建检查development模式仅检查语法和类型
echo "🔨 [Pre-commit] 执行构建检查 (build:dev)..."
if timeout 120 npm run build:dev 2>&1; then
echo "✅ [Pre-commit] 构建检查通过"
else
echo "❌ [Pre-commit] 构建检查失败!请修复编译错误后重新提交"
exit 1
fi
echo "========================================"
echo "✅ [Pre-commit] 所有检查通过,允许提交"
echo "========================================"

View File

@@ -1,6 +0,0 @@
{
"tools": {
"approvalMode": "yolo"
},
"$version": 2
}

View File

@@ -0,0 +1,162 @@
# 后端发布前检查清单
## 📋 基础检查项
### Maven编译验证
- [ ] 本地执行 `mvn compile` 编译通过无ERROR
- [ ] 执行 `mvn package -DskipTests` 打包成功
- [ ] 依赖版本无冲突(`mvn dependency:tree` 检查)
- [ ] 无编译警告(或已有书面说明可忽略)
### 构建产物验证
- [ ] JAR/WAR包生成完整大小合理
- [ ] `application.yml` 等配置文件已打包进产物
- [ ] 第三方依赖jar包完整lib目录无缺失
---
## 🔧 Spring Boot 配置检查
### 多环境配置
- [ ] `application-dev.yml`(开发)配置正确
- [ ] `application-test.yml`(测试)配置正确
- [ ] `application-prod.yml`(生产)配置正确
- [ ] 启动参数 `--spring.profiles.active` 指定正确环境
- [ ] 生产环境未启用devtools热部署
### Actuator安全
- [ ] 生产环境 `/actuator` 端点已禁用或限制访问
- [ ] `/actuator/env``/actuator/heapdump` 等敏感端点已关闭
- [ ] 健康检查端点 `/actuator/health` 返回信息已脱敏
### 启动校验
- [ ] 数据库连接池配置合理HikariCP最大/最小连接数)
- [ ] Redis/消息中间件连接配置正确
- [ ] 启动日志无ERROR级别异常
---
## 🗄️ MyBatis Plus 规范检查
### 实体-表映射
- [ ] 所有实体类标注 `@TableName`,表名与实际一致
- [ ] 主键字段标注 `@TableId(type = IdType.AUTO)` 或对应策略
- [ ] 非表字段标注 `@TableField(exist = false)`
- [ ] 字段命名符合下划线转驼峰规则
### SQL安全
- [ ] 所有查询使用参数化查询(`QueryWrapper` / `LambdaQueryWrapper`
- [ ] 禁止字符串拼接SQL`"WHERE name = '" + name + "'"`
- [ ] 批量操作使用MyBatis Plus `saveBatch` / `updateBatchById`
- [ ] 复杂SQL使用XML映射避免注解内嵌长SQL
### 事务管理
- [ ] 涉及多表写操作的方法标注 `@Transactional`
- [ ] 事务边界合理不包含外部HTTP调用
- [ ] 异常回滚配置正确(`rollbackFor = Exception.class`
- [ ] 事务方法未被同一类内方法直接调用(自调用失效问题)
### 分页插件
- [ ] `PaginationInnerInterceptor` 已正确配置
- [ ] 分页查询使用 `Page<T>` 对象非手动limit/offset
---
## 🔌 RESTful API 设计检查
### 统一返回格式
- [ ] 所有接口返回 `{code, msg, data}` 统一结构
- [ ] 成功返回 `code=200`,业务错误使用自定义错误码
- [ ] 异常通过 `@ControllerAdvice` + `@ExceptionHandler` 统一处理
### HTTP状态码
- [ ] 资源创建返回 `201 Created`
- [ ] 资源删除返回 `204 No Content`
- [ ] 参数校验失败返回 `400 Bad Request`
- [ ] 未认证返回 `401 Unauthorized`
- [ ] 无权限返回 `403 Forbidden`
- [ ] 资源不存在返回 `404 Not Found`
### 参数校验
- [ ] 请求参数使用 `@Valid` / `@Validated` 注解校验
- [ ] 必填字段标注 `@NotBlank` / `@NotNull`
- [ ] 数值范围标注 `@Min` / `@Max`
- [ ] 格式校验使用 `@Pattern`(如手机号、身份证号)
- [ ] 校验失败返回明确错误信息非500堆栈
### API版本管理
- [ ] 接口路径包含版本号(`/api/v1/``/api/v2/`
- [ ] 废弃接口标注 `@Deprecated`,并在文档中说明
- [ ] 不兼容变更必须升级版本号
---
## 🔒 安全与合规检查
### 数据脱敏
- [ ] 患者身份证号在日志中脱敏(`***` 掩码)
- [ ] 患者手机号在日志中脱敏前3后4中间`****`
- [ ] 敏感字段序列化时使用 `@JsonSerialize` 自定义脱敏器
- [ ] 接口返回中非必需字段不暴露如密码、salt
### 权限控制
- [ ] 所有涉及患者数据的接口标注 `@PreAuthorize`
- [ ] 数据级权限校验(医生只能访问本科室患者)
- [ ] 越权访问返回 `403`,非 `404``500`
- [ ] 敏感操作(删除、修改诊断)需二次确认或额外权限
### 审计日志
- [ ] 处方修改记录操作人、时间、变更内容
- [ ] 病历删除操作记录完整审计链
- [ ] 审计日志独立存储,不可被业务用户删除
- [ ] 关键业务操作记录IP地址和操作终端
---
## ⚡ 性能检查
### 数据库查询
- [ ] 无N+1查询问题使用 `JOIN` 或批量查询)
- [ ] 大表查询必须有分页限制
- [ ] 慢查询已优化(执行时间 < 500ms
- [ ] 索引已覆盖高频查询条件
### 接口性能
- [ ] 核心接口响应时间 < 1秒
- [ ] 列表接口支持分页无全量返回
- [ ] 大文件下载使用流式传输非全量加载到内存
---
## 📝 文档与发布准备
### 文档更新
- [ ] API接口文档已同步更新路径参数返回值
- [ ] 数据库变更脚本已提供DDL/DML
- [ ] 配置变更说明已记录新增/修改的配置项
- [ ] 影响范围说明已明确哪些模块哪些接口受影响
### 回滚预案
- [ ] 数据库变更可回滚提供反向SQL脚本
- [ ] 配置变更可快速回退
- [ ] 紧急回滚流程已明确怎么做多长时间
- [ ] 回滚后数据一致性已验证
---
## ✅ 最终确认
### 发布前最后检查
- [ ] `mvn compile` 构建成功附终端截图
- [ ] 关键单元测试通过
- [ ] 测试环境部署验证通过
- [ ] Code Review 已完成并获得批准
- [ ] 相关Bug已关闭或延期说明
---
**文档版本**v1.0
**最后更新**2026年4月24日
**负责人**关羽后端开发
**适用范围**HIS 系统所有后端模块his-server
**补充说明**本清单与陈琳的前端发布前检查清单对称互补共同构成HIS系统发布前完整质量保障体系

View File

@@ -21,7 +21,7 @@
**触发时机**`git push` 执行前 **触发时机**`git push` 执行前
**验证内容** **验证内容**
- 完整的单元测试套件 - 完整的单元测试套件
- 构建验证(`npm run build` - 构建验证(`npm run build:prod`
- 集成测试(核心流程) - 集成测试(核心流程)
**工具配置** **工具配置**
@@ -32,7 +32,7 @@
**触发时机**:代码推送到远程仓库后 **触发时机**:代码推送到远程仓库后
**验证内容** **验证内容**
- 完整的测试套件(单元+集成+端到端) - 完整的测试套件(单元+集成+端到端)
- 代码覆盖率检查≥80% - 代码覆盖率检查(分阶段目标Q1≥30%Q2≥50%Q3≥80%
- 安全扫描SAST - 安全扫描SAST
- 构建产物验证 - 构建产物验证
- 部署到测试环境 - 部署到测试环境
@@ -53,6 +53,80 @@
### ESLint 配置 ### ESLint 配置
```javascript ```javascript
// eslint.config.js 关键配置
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
import parserVue from "vue-eslint-parser";
import importPlugin from "eslint-plugin-import";
export default [
{
name: "app/files-to-lint",
files: ["**/*.{js,mjs,jsx,vue}"],
},
{
name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
},
...pluginVue.configs["flat/recommended"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: parserVue,
ecmaVersion: "latest",
sourceType: "module",
},
plugins: {
import: importPlugin,
},
rules: {
// 确保导入的模块实际存在(核心规则,防止构建失败)
"import/no-unresolved": "error",
// 确保导入的命名导出实际存在
"import/named": "error",
// 确保默认导出存在
"import/default": "error",
// 确保命名空间导出存在
"import/namespace": "error",
},
},
];
```
```
### Java 后端配置
```xml
<!-- pom.xml 关键插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.2.0</version>
</plugin>
```
### 数据库迁移配置
```yaml
# application.yml Flyway配置
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
```
javascript
// .eslintrc.js 关键配置 // .eslintrc.js 关键配置
module.exports = { module.exports = {
plugins: ['import'], plugins: ['import'],
@@ -75,7 +149,7 @@ npm run lint-staged
# .husky/pre-push # .husky/pre-push
#!/bin/sh #!/bin/sh
npm run test:unit && npm run build npm run test:unit && npm run build:prod
``` ```
### lint-staged 配置 ### lint-staged 配置
@@ -88,6 +162,15 @@ npm run test:unit && npm run build
} }
} }
``` ```
```json
// package.json
{
"lint-staged": {
"*.{js,vue}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
}
}
```
## 🚫 失败处理机制 ## 🚫 失败处理机制

View File

@@ -25,6 +25,12 @@ refactor(nurse): 重构护士站护理记录组件
- **技术方案**:采用的技术方案和设计思路 - **技术方案**:采用的技术方案和设计思路
- **兼容性**是否涉及API或数据结构变更 - **兼容性**是否涉及API或数据结构变更
## 🗄️ 数据库变更
- **表结构变更**:列出新增/修改的表和字段
- **数据迁移**:是否需要数据迁移脚本
- **回滚方案**:数据库变更的回滚策略
## ✅ 验证情况 ## ✅ 验证情况
- **测试覆盖**:单元测试、集成测试覆盖情况 - **测试覆盖**:单元测试、集成测试覆盖情况
- **手动验证**:手动测试的场景和结果 - **手动验证**:手动测试的场景和结果
@@ -72,7 +78,7 @@ refactor(nurse): 重构护士站护理记录组件
## 🖼️ 构建验证截图要求 ## 🖼️ 构建验证截图要求
### 必须包含的信息 ### 必须包含的信息
1. **终端窗口**:显示 `npm run build` 命令执行过程 1. **终端窗口**:显示 `npm run build:prod` 命令执行过程
2. **成功标识**:明确显示构建成功的提示信息 2. **成功标识**:明确显示构建成功的提示信息
3. **时间戳**:截图包含当前时间,证明是最新构建 3. **时间戳**:截图包含当前时间,证明是最新构建
4. **分支信息**:显示当前工作分支名称 4. **分支信息**:显示当前工作分支名称
@@ -80,7 +86,7 @@ refactor(nurse): 重构护士站护理记录组件
### 截图示例 ### 截图示例
``` ```
$ git checkout feature/patient-edit $ git checkout feature/patient-edit
$ npm run build $ npm run build:prod
> his-system@1.0.0 build > his-system@1.0.0 build
> vue-cli-service build > vue-cli-service build

View File

@@ -10,7 +10,7 @@
- [ ] 函数职责单一,复杂度适中 - [ ] 函数职责单一,复杂度适中
### 构建验证 ### 构建验证
- [ ] 本地执行 `npm run build` 成功完成 - [ ] 本地执行 `npm run build:prod` 成功完成
- [ ] 构建产物无报错,体积合理 - [ ] 构建产物无报错,体积合理
- [ ] 静态资源路径正确无404错误 - [ ] 静态资源路径正确无404错误
- [ ] 环境变量配置正确(开发/测试/生产) - [ ] 环境变量配置正确(开发/测试/生产)
@@ -66,6 +66,26 @@
- [ ] 数据兼容性已确认 - [ ] 数据兼容性已确认
- [ ] 紧急联系人已明确 - [ ] 紧急联系人已明确
## 🔧 后端检查项
### 编译验证
- [ ] Maven编译成功`mvn clean package -DskipTests`
- [ ] 无编译错误,仅有可接受的警告
- [ ] 依赖版本兼容性确认
### 数据库脚本
- [ ] DDL/DML脚本语法正确
- [ ] 回滚脚本已准备
- [ ] 数据迁移脚本已测试
## 🔄 前后端协同
### 接口兼容性
- [ ] API接口契约变更已双方确认
- [ ] 前端调用后端接口正常
- [ ] 错误码处理逻辑一致
## ✅ 最终确认 ## ✅ 最终确认
### 发布前最后检查 ### 发布前最后检查

View File

@@ -0,0 +1,575 @@
# HIS项目发布检查清单 v1.0
> **文档说明**本清单整合了提交规范、前端检查、后端检查、CI/CD门禁四个部分作为HIS项目发布的标准化检查依据。每次发布前必须逐项确认。
## 目录
- [1. 提交规范commit-template](#1-提交规范commit-template)
- [2. 前端检查frontend-checklist](#2-前端检查frontend-checklist)
- [3. 后端检查backend-checklist](#3-后端检查backend-checklist)
- [4. CI/CD门禁cicd-gatekeeper](#4-cicd门禁cicd-gatekeeper)
- [5. 发布确认与回滚预案](#5-发布确认与回滚预案)
---
## 1. 提交规范commit-template
### 📝 PR/Commit 模板
#### 标题格式
```
<类型>(<模块>): <简短描述>
示例:
feat(patient): 添加患者基本信息编辑功能
fix(doctor): 修复医生排班显示异常问题
docs(api): 更新预约挂号接口文档
refactor(nurse): 重构护士站护理记录组件
```
#### 正文模板
```markdown
## 🔍 变更背景
- **问题描述**:详细说明要解决的问题或实现的需求
- **影响范围**:列出受影响的模块、页面、功能
- **相关链接**禅道任务ID、需求文档链接等
## 🛠️ 变更内容
- **主要修改**:核心代码变更点
- **技术方案**:采用的技术方案和设计思路
- **兼容性**是否涉及API或数据结构变更
## 🗄️ 数据库变更
- **表结构变更**:列出新增/修改的表和字段
- **数据迁移**:是否需要数据迁移脚本
- **回滚方案**:数据库变更的回滚策略
## ✅ 验证情况
- **测试覆盖**:单元测试、集成测试覆盖情况
- **手动验证**:手动测试的场景和结果
- **构建验证**:本地构建截图(必填)
## 📋 检查清单
- [ ] 代码已通过 ESLint 检查
- [ ] 本地构建成功(附截图)
- [ ] 核心功能已测试验证
- [ ] 文档已同步更新
- [ ] Code Review 已完成
## 👥 相关人员
- **开发者**@开发者姓名
- **测试者**@测试者姓名
- **审核人**@架构师姓名
```
### 🏷️ 提交类型说明
| 类型 | 说明 | 示例 |
|------|------|------|
| feat | 新功能 | `feat: 添加用户登录功能` |
| fix | Bug修复 | `fix: 修复表单验证错误` |
| docs | 文档更新 | `docs: 更新API文档` |
| style | 代码格式调整 | `style: 格式化代码` |
| refactor | 代码重构 | `refactor: 重构组件结构` |
| test | 测试相关 | `test: 添加单元测试` |
| chore | 构建/依赖等 | `chore: 升级依赖版本` |
| perf | 性能优化 | `perf: 优化列表加载速度` |
### 📁 模块命名规范
| 模块 | 说明 |
|------|------|
| patient | 患者管理相关 |
| doctor | 医生工作站相关 |
| nurse | 护士站相关 |
| admin | 后台管理相关 |
| common | 公共组件/工具 |
| api | API接口相关 |
| auth | 认证授权相关 |
| payment | 支付相关 |
### 🖼️ 构建验证截图要求
#### 必须包含的信息
1. **终端窗口**:显示 `npm run build:prod` 命令执行过程
2. **成功标识**:明确显示构建成功的提示信息
3. **时间戳**:截图包含当前时间,证明是最新构建
4. **分支信息**:显示当前工作分支名称
### ⚠️ 禁止行为
#### 严重违规(直接拒绝合并)
- 无构建验证截图
- 代码存在 ESLint 错误
- 未填写变更说明
- 修改无关代码文件
---
## 2. 前端检查frontend-checklist
### 📋 基础检查项
#### 代码质量
- [ ] 代码已通过 ESLint 检查,无警告和错误
- [ ] 代码已通过 Prettier 格式化
- [ ] 无 console.log() 等调试代码残留
- [ ] 变量命名符合规范,语义清晰
- [ ] 函数职责单一,复杂度适中
#### 构建验证
- [ ] 本地执行 `npm run build:prod` 成功完成
- [ ] 构建产物无报错,体积合理
- [ ] 静态资源路径正确无404错误
- [ ] 环境变量配置正确(开发/测试/生产)
#### 功能验证
- [ ] 核心功能流程完整测试通过
- [ ] 边界条件和异常场景已覆盖
- [ ] 表单验证逻辑正确
- [ ] API 接口调用正常,错误处理完善
- [ ] 路由跳转逻辑正确
### 🔧 技术检查项
#### 模块导入检查
- [ ] 所有 import 语句引用的模块实际存在
- [ ] 无未使用的 import 导入
- [ ] 路径别名(@/)配置正确
- [ ] 第三方库版本兼容性确认
#### 性能优化
- [ ] 组件按需加载(懒加载)已配置
- [ ] 大数据列表已实现虚拟滚动或分页
- [ ] 图片资源已压缩,格式合适
- [ ] 无内存泄漏风险(事件监听器、定时器等)
#### 安全检查
- [ ] 用户输入已做 XSS 防护
- [ ] 敏感信息不在前端硬编码
- [ ] API 请求已做 CSRF 防护
- [ ] 权限控制逻辑正确
### 🌐 兼容性检查
#### 浏览器兼容
- [ ] 主流浏览器Chrome、Firefox、Safari、Edge显示正常
- [ ] 移动端适配良好(如适用)
- [ ] 分辨率适配1366x768、1920x1080等
#### 设备兼容
- [ ] 触摸设备操作体验良好
- [ ] 键盘导航支持完整
- [ ] 屏幕阅读器兼容性(无障碍)
### 📱 发布准备
#### 文档更新
- [ ] 相关 API 文档已同步更新
- [ ] 用户操作手册已更新(如适用)
- [ ] 变更日志已记录
#### 回滚预案
- [ ] 回滚方案已准备
- [ ] 数据兼容性已确认
- [ ] 紧急联系人已明确
### ✅ 最终确认
#### 发布前最后检查
- [ ] 本地构建截图已附在 PR 中
- [ ] 测试环境部署验证通过
- [ ] Code Review 已完成并获得批准
- [ ] 相关 Bug 已关闭或延期说明
---
## 3. 后端检查backend-checklist
### 📋 基础检查项
#### Maven编译验证
- [ ] 本地执行 `mvn compile` 编译通过无ERROR
- [ ] 执行 `mvn package -DskipTests` 打包成功
- [ ] 依赖版本无冲突(`mvn dependency:tree` 检查)
- [ ] 无编译警告(或已有书面说明可忽略)
#### 构建产物验证
- [ ] JAR/WAR包生成完整大小合理
- [ ] `application.yml` 等配置文件已打包进产物
- [ ] 第三方依赖jar包完整lib目录无缺失
### 🔧 Spring Boot 配置检查
#### 多环境配置
- [ ] `application-dev.yml`(开发)配置正确
- [ ] `application-test.yml`(测试)配置正确
- [ ] `application-prod.yml`(生产)配置正确
- [ ] 启动参数 `--spring.profiles.active` 指定正确环境
- [ ] 生产环境未启用devtools热部署
#### Actuator安全
- [ ] 生产环境 `/actuator` 端点已禁用或限制访问
- [ ] `/actuator/env``/actuator/heapdump` 等敏感端点已关闭
- [ ] 健康检查端点 `/actuator/health` 返回信息已脱敏
#### 启动校验
- [ ] 数据库连接池配置合理HikariCP最大/最小连接数)
- [ ] Redis/消息中间件连接配置正确
- [ ] 启动日志无ERROR级别异常
### 🗄️ MyBatis Plus 规范检查
#### 实体-表映射
- [ ] 所有实体类标注 `@TableName`,表名与实际一致
- [ ] 主键字段标注 `@TableId(type = IdType.AUTO)` 或对应策略
- [ ] 非表字段标注 `@TableField(exist = false)`
- [ ] 字段命名符合下划线转驼峰规则
#### SQL安全
- [ ] 所有查询使用参数化查询(`QueryWrapper` / `LambdaQueryWrapper`
- [ ] 禁止字符串拼接SQL`"WHERE name = '" + name + "'"`
- [ ] 批量操作使用MyBatis Plus `saveBatch` / `updateBatchById`
- [ ] 复杂SQL使用XML映射避免注解内嵌长SQL
#### 事务管理
- [ ] 涉及多表写操作的方法标注 `@Transactional`
- [ ] 事务边界合理不包含外部HTTP调用
- [ ] 异常回滚配置正确(`rollbackFor = Exception.class`
- [ ] 事务方法未被同一类内方法直接调用(自调用失效问题)
#### 分页插件
- [ ] `PaginationInnerInterceptor` 已正确配置
- [ ] 分页查询使用 `Page<T>` 对象非手动limit/offset
### 🔌 RESTful API 设计检查
#### 统一返回格式
- [ ] 所有接口返回 `{code, msg, data}` 统一结构
- [ ] 成功返回 `code=200`,业务错误使用自定义错误码
- [ ] 异常通过 `@ControllerAdvice` + `@ExceptionHandler` 统一处理
#### HTTP状态码
- [ ] 资源创建返回 `201 Created`
- [ ] 资源删除返回 `204 No Content`
- [ ] 参数校验失败返回 `400 Bad Request`
- [ ] 未认证返回 `401 Unauthorized`
- [ ] 无权限返回 `403 Forbidden`
- [ ] 资源不存在返回 `404 Not Found`
#### 参数校验
- [ ] 请求参数使用 `@Valid` / `@Validated` 注解校验
- [ ] 必填字段标注 `@NotBlank` / `@NotNull`
- [ ] 数值范围标注 `@Min` / `@Max`
- [ ] 格式校验使用 `@Pattern`(如手机号、身份证号)
- [ ] 校验失败返回明确错误信息非500堆栈
#### API版本管理
- [ ] 接口路径包含版本号(`/api/v1/``/api/v2/`
- [ ] 废弃接口标注 `@Deprecated`,并在文档中说明
- [ ] 不兼容变更必须升级版本号
### 🔒 安全与合规检查
#### 数据脱敏
- [ ] 患者身份证号在日志中脱敏(`***` 掩码)
- [ ] 患者手机号在日志中脱敏前3后4中间`****`
- [ ] 敏感字段序列化时使用 `@JsonSerialize` 自定义脱敏器
- [ ] 接口返回中非必需字段不暴露如密码、salt
#### 权限控制
- [ ] 所有涉及患者数据的接口标注 `@PreAuthorize`
- [ ] 数据级权限校验(医生只能访问本科室患者)
- [ ] 越权访问返回 `403`,非 `404``500`
- [ ] 敏感操作(删除、修改诊断)需二次确认或额外权限
#### 审计日志
- [ ] 处方修改记录操作人、时间、变更内容
- [ ] 病历删除操作记录完整审计链
- [ ] 审计日志独立存储,不可被业务用户删除
- [ ] 关键业务操作记录IP地址和操作终端
### ⚡ 性能检查
#### 数据库查询
- [ ] 无N+1查询问题使用 `JOIN` 或批量查询)
- [ ] 大表查询必须有分页限制
- [ ] 慢查询已优化(执行时间 < 500ms
- [ ] 索引已覆盖高频查询条件
#### 接口性能
- [ ] 核心接口响应时间 < 1秒
- [ ] 列表接口支持分页无全量返回
- [ ] 大文件下载使用流式传输非全量加载到内存
### 📝 文档与发布准备
#### 文档更新
- [ ] API接口文档已同步更新路径参数返回值
- [ ] 数据库变更脚本已提供DDL/DML
- [ ] 配置变更说明已记录新增/修改的配置项
- [ ] 影响范围说明已明确哪些模块哪些接口受影响
#### 回滚预案
- [ ] 数据库变更可回滚提供反向SQL脚本
- [ ] 配置变更可快速回退
- [ ] 紧急回滚流程已明确怎么做多长时间
- [ ] 回滚后数据一致性已验证
### ✅ 最终确认
#### 发布前最后检查
- [ ] `mvn compile` 构建成功附终端截图
- [ ] 关键单元测试通过
- [ ] 测试环境部署验证通过
- [ ] Code Review 已完成并获得批准
- [ ] 相关Bug已关闭或延期说明
---
## 4. CI/CD门禁cicd-gatekeeper
### 🎯 规范目标
建立自动化质量门禁确保每次代码提交都经过严格验证防止低质量代码进入主干分支提升系统稳定性和开发效率
### 🔒 门禁层级
#### 1. 提交前门禁Pre-commit
**触发时机**`git commit` 执行前
**验证内容**
- ESLint 代码规范检查
- Prettier 代码格式化
- 简单的单元测试快速执行
**工具配置**
- Husky + lint-staged
- 配置文件`.husky/pre-commit`
#### 2. 推送前门禁Pre-push
**触发时机**`git push` 执行前
**验证内容**
- 完整的单元测试套件
- 构建验证`npm run build:prod`
- 集成测试核心流程
**工具配置**
- Husky pre-push hook
- 配置文件`.husky/pre-push`
#### 3. CI流水线门禁CI Pipeline
**触发时机**代码推送到远程仓库后
**验证内容**
- 完整的测试套件单元+集成+端到端
- 代码覆盖率检查分阶段目标Q130%Q250%Q380%
- 安全扫描SAST
- 构建产物验证
- 部署到测试环境
**工具配置**
- Spug CI/CD 流水线
- Gitea Webhook 触发
#### 4. 发布前门禁Release Gate
**触发时机**准备发布到生产环境前
**验证内容**
- 生产环境冒烟测试
- 性能基准测试
- 安全合规检查
- 回滚预案验证
### ⚙️ 具体配置要求
#### ESLint 配置
```javascript
// eslint.config.js 关键配置
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
import parserVue from "vue-eslint-parser";
import importPlugin from "eslint-plugin-import";
export default [
{
name: "app/files-to-lint",
files: ["**/*.{js,mjs,jsx,vue}"],
},
{
name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
},
...pluginVue.configs["flat/recommended"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: parserVue,
ecmaVersion: "latest",
sourceType: "module",
},
plugins: {
import: importPlugin,
},
rules: {
// 确保导入的模块实际存在(核心规则,防止构建失败)
"import/no-unresolved": "error",
// 确保导入的命名导出实际存在
"import/named": "error",
// 确保默认导出存在
"import/default": "error",
// 确保命名空间导出存在
"import/namespace": "error",
},
},
];
```
#### Java 后端配置
```xml
<!-- pom.xml 关键插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.2.0</version>
</plugin>
```
#### 数据库迁移配置
```yaml
# application.yml Flyway配置
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
```
#### Husky 配置
```bash
# .husky/pre-commit
#!/bin/sh
npm run lint-staged
# .husky/pre-push
#!/bin/sh
npm run test:unit && npm run build:prod
```
#### lint-staged 配置
```json
// package.json
{
"lint-staged": {
"*.{js,vue}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
}
}
```
### 🚫 失败处理机制
#### 自动处理
- **构建失败**自动阻止 PR 合并
- **测试失败**标记 PR 为失败状态
- **安全漏洞**立即通知安全团队
#### 人工处理
- **紧急修复**可申请临时绕过需架构师批准
- **误报处理**提交豁免申请并说明原因
- **规则调整**通过 RFC 流程申请规则变更
### 📊 监控与度量
#### 关键指标
- 门禁通过率 95%
- 平均修复时间 2小时
- 误报率 5%
#### 报告机制
- 每日门禁失败统计
- 周度质量趋势报告
- 月度规则优化建议
### 🔄 持续改进
#### 规则演进
- 每月评审门禁规则有效性
- 根据项目需求调整检查强度
- 引入新的质量检查工具
#### 团队培训
- 新成员入职培训包含门禁规范
- 定期分享最佳实践案例
- 建立常见问题解决方案库
---
## 5. 发布确认与回滚预案
### 📋 发布前最终确认清单
#### 前端确认
- [ ] 本地构建成功`npm run build:prod`
- [ ] 核心功能流程测试通过
- [ ] 模块导入检查通过无import错误
- [ ] 兼容性测试完成
#### 后端确认
- [ ] Maven编译成功`mvn compile`
- [ ] 单元测试通过
- [ ] 数据库脚本验证通过
- [ ] API接口测试通过
#### 协同确认
- [ ] 前后端接口契约一致
- [ ] 联调测试通过
- [ ] Code Review 已完成
- [ ] 测试环境部署验证通过
### 🚨 回滚预案
#### 触发条件
- [ ] 生产环境出现严重Bug
- [ ] 性能严重下降
- [ ] 数据一致性问题
- [ ] 安全漏洞暴露
#### 回滚步骤
1. **立即停止**暂停新流量进入
2. **版本回退**部署上一个稳定版本
3. **数据回滚**执行数据库回滚脚本如有
4. **验证恢复**确认系统功能正常
5. **问题分析**记录根本原因和改进措施
#### 责任分工
- **技术负责人**执行回滚操作
- **测试负责人**验证回滚后功能
- **项目经理**协调沟通和进度同步
- **运维团队**监控系统状态
### 📞 紧急联系人
| 角色 | 姓名 | 联系方式 | 职责 |
|------|------|----------|------|
| 技术负责人 | 诸葛亮 | @诸葛亮 | 架构决策和技术指导 |
| 前端负责人 | 赵云 | @赵云 | 前端问题处理 |
| 后端负责人 | 关羽 | @关羽 | 后端问题处理 |
| 测试负责人 | 张飞 | @张飞 | 质量验证和问题复现 |
| 项目经理 | 刘备 | @刘备 | 项目协调和进度管理 |
| 文档负责人 | 陈琳 | @陈琳 | 文档维护和知识沉淀 |
---
**文档版本**v1.0
**最后更新**2026年4月25日
**负责人**陈琳文档专家
**适用范围**HIS 系统所有开发人员

View File

@@ -0,0 +1,214 @@
# HIS项目 Playwright E2E 自动化测试方案 v1.0
## 一、方案概述
### 1.1 选型理由
- **Playwright** 是微软开源的端到端测试框架,完美适配 Vue 3 + Vite 技术栈
- 自动等待机制适合HIS系统复杂交互场景异步加载、动态渲染
- 支持多浏览器Chromium/Firefox/WebKitCI/CD集成成熟
- 已有 `@playwright/test ^1.58.2` 依赖 installed
### 1.2 目标
1. 核心业务流程自动化覆盖率达到 80%+
2. 已修复Bug 100% 回归测试覆盖
3. 每次代码推送自动触发测试,失败阻断发布
## 二、项目结构
```
openhis-ui-vue3/
├── tests/
│ ├── e2e/
│ │ ├── fixtures/ # 测试夹具
│ │ │ └── auth.ts # 登录认证fixture
│ │ ├── pages/ # 页面对象模型POM
│ │ │ ├── LoginPage.ts
│ │ │ ├── DoctorStationPage.ts
│ │ │ └── SurgeryBillingPage.ts
│ │ ├── specs/ # 测试用例
│ │ │ ├── login.spec.ts
│ │ │ ├── doctor-station.spec.ts
│ │ │ ├── surgery-billing.spec.ts
│ │ │ └── bug-regression.spec.ts # Bug回归测试
│ │ └── utils/
│ │ └── test-data.ts # 测试数据
│ └── playwright.config.ts # Playwright配置
├── .env.test # 测试环境变量
└── package.json # 已有playwright依赖
```
## 三、环境配置
### 3.1 环境变量(.env.test
```bash
# 测试环境配置
VITE_APP_BASE_API=http://192.168.110.253:8080
TEST_USERNAME=test_admin
TEST_PASSWORD=test123456
TEST_BASE_URL=http://localhost:80
```
### 3.2 Playwright配置playwright.config.ts
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/specs',
timeout: 60 * 1000,
expect: { timeout: 10000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: 'playwright-report' }], ['list']],
use: {
baseURL: process.env.TEST_BASE_URL || 'http://localhost:80',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
```
## 四、核心测试用例
### 4.1 登录测试login.spec.ts
```typescript
import { test, expect } from '@playwright/test';
test('用户登录成功', async ({ page }) => {
await page.goto('/');
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
await page.click('button:has-text("登录")');
await expect(page).toHaveURL(/.*dashboard.*/);
await expect(page.locator('.user-avatar')).toBeVisible();
});
test('登录失败-错误密码', async ({ page }) => {
await page.goto('/');
await page.fill('input[placeholder="请输入用户名"]', 'admin');
await page.fill('input[placeholder="请输入密码"]', 'wrongpassword');
await page.click('button:has-text("登录")');
await expect(page.locator('.el-message--error')).toBeVisible();
});
```
### 4.2 门诊医生站测试doctor-station.spec.ts
```typescript
import { test, expect } from '@playwright/test';
test.describe('门诊医生站', () => {
test.beforeEach(async ({ page }) => {
// 登录
await page.goto('/');
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
await page.click('button:has-text("登录")');
await page.waitForURL(/.*dashboard.*/);
});
test('#427 检查项目分类手风琴展开', async ({ page }) => {
await page.goto('/doctorstation');
// 点击第一个分类
await page.click('.category-item >> nth=0');
await expect(page.locator('.category-content >> nth=0')).toBeVisible();
// 点击第二个分类,第一个应收起
await page.click('.category-item >> nth=1');
await expect(page.locator('.category-content >> nth=0')).not.toBeVisible();
await expect(page.locator('.category-content >> nth=1')).toBeVisible();
});
});
```
### 4.3 手术计费回归测试bug-regression.spec.ts
```typescript
import { test, expect } from '@playwright/test';
test.describe('Bug回归测试', () => {
test('#437 手术计费防重复提交', async ({ page }) => {
// 登录并导航到手术计费
await page.goto('/');
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
await page.click('button:has-text("登录")');
await page.waitForURL(/.*dashboard.*/);
await page.goto('/surgery-billing');
// 快速连续点击新增按钮(测试防重复锁)
const addBtn = page.locator('button:has-text("新增")');
await addBtn.click();
await addBtn.click(); // 第二次应被阻止
await addBtn.click(); // 第三次应被阻止
// 验证只弹出一个表单
await expect(page.locator('.el-dialog')).toHaveCount(1);
});
});
```
## 五、执行命令
```bash
# 安装浏览器
npx playwright install chromium
# 运行所有测试
npm run test:e2e
# 运行单个测试文件
npx playwright test login.spec.ts
# 生成HTML报告
npx playwright show-report
# UI模式调试用
npx playwright test --ui
```
## 六、CI/CD集成
### 6.1 package.json脚本
```json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
}
}
```
### 6.2 Spug流水线集成
```yaml
# Spug 构建后阶段添加
- name: E2E Testing
script: |
cd openhis-ui-vue3
npx playwright install --with-deps chromium
npm run test:e2e -- --reporter=html
# 测试失败则阻断发布
if [ $? -ne 0 ]; then
echo "E2E测试失败阻断发布"
exit 1
fi
```
## 七、实施计划
| 阶段 | 时间 | 内容 | 负责人 |
|------|------|------|--------|
| Phase 1 | 第1周 | 登录+核心页面冒烟测试 | 张飞+赵云 |
| Phase 2 | 第2-3周 | 门诊医生站+手术计费全流程 | 张飞 |
| Phase 3 | 第4周 | Bug回归测试全覆盖 | 张飞 |
| Phase 4 | 第5周 | CI/CD流水线集成 | 赵云+运维 |
## 八、注意事项
1. **测试数据隔离**:使用独立的测试数据库,不污染生产数据
2. **环境变量**:敏感信息通过 `.env.test` 管理不提交到git
3. **截图留痕**:失败时自动截图,便于排查
4. **测试优先**:新功能开发时同步编写测试用例

View File

@@ -11,6 +11,7 @@ import com.openhis.common.constant.CommonConstants;
import com.openhis.common.enums.*; import com.openhis.common.enums.*;
import com.openhis.common.utils.EnumUtils; import com.openhis.common.utils.EnumUtils;
import com.openhis.common.utils.HisQueryUtils; import com.openhis.common.utils.HisQueryUtils;
import com.openhis.web.doctorstation.appservice.IDoctorStationMainAppService;
import com.openhis.web.doctorstation.appservice.ITodayOutpatientService; import com.openhis.web.doctorstation.appservice.ITodayOutpatientService;
import com.openhis.web.doctorstation.dto.TodayOutpatientPatientDto; import com.openhis.web.doctorstation.dto.TodayOutpatientPatientDto;
import com.openhis.web.doctorstation.dto.TodayOutpatientQueryParam; import com.openhis.web.doctorstation.dto.TodayOutpatientQueryParam;
@@ -32,6 +33,9 @@ public class TodayOutpatientServiceImpl implements ITodayOutpatientService {
@Resource @Resource
private TodayOutpatientMapper todayOutpatientMapper; private TodayOutpatientMapper todayOutpatientMapper;
@Resource
private IDoctorStationMainAppService doctorStationMainAppService;
@Override @Override
public TodayOutpatientStatsDto getTodayOutpatientStats(HttpServletRequest request) { public TodayOutpatientStatsDto getTodayOutpatientStats(HttpServletRequest request) {
Long doctorId = SecurityUtils.getLoginUser().getUserId(); Long doctorId = SecurityUtils.getLoginUser().getUserId();
@@ -259,22 +263,19 @@ public class TodayOutpatientServiceImpl implements ITodayOutpatientService {
@Override @Override
public R<?> receivePatient(Long encounterId, HttpServletRequest request) { public R<?> receivePatient(Long encounterId, HttpServletRequest request) {
// 调用现有的接诊逻辑 // 调用现有的接诊逻辑
// 这里可以复用 DoctorStationMainAppServiceImpl 中的 receiveEncounter 方法 return doctorStationMainAppService.receiveEncounter(encounterId);
// 或者直接调用相应的服务
return R.ok("接诊成功");
} }
@Override @Override
public R<?> completeVisit(Long encounterId, HttpServletRequest request) { public R<?> completeVisit(Long encounterId, HttpServletRequest request) {
// 调用现有的完诊逻辑 // 调用现有的完诊逻辑
return R.ok("就诊完成"); return doctorStationMainAppService.completeEncounter(encounterId, null);
} }
@Override @Override
public R<?> cancelVisit(Long encounterId, String reason, HttpServletRequest request) { public R<?> cancelVisit(Long encounterId, String reason, HttpServletRequest request) {
// 调用现有的取消就诊逻辑 // 调用现有的取消就诊逻辑
return R.ok("就诊取消成功"); return doctorStationMainAppService.cancelEncounter(encounterId);
} }
/** /**

View File

@@ -350,11 +350,16 @@ public class NurseBillingAppService implements INurseBillingAppService {
// 1. 筛选临时类型耗材仅处理临时医嘱TherapyTimeType.TEMPORARY且请求ID不为空 // 1. 筛选临时类型耗材仅处理临时医嘱TherapyTimeType.TEMPORARY且请求ID不为空
List<AdviceSaveDto> tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList()); List<AdviceSaveDto> tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList());
// 2. 校验发放库房:必须指定耗材发放库房locationId否则抛出业务异常 // 2. 颞理发放库房:为locationId为null的项目设置默认值
if (tempDeviceList.stream().anyMatch(t -> t.getLocationId() == null)) { for (AdviceSaveDto advice : tempDeviceList) {
throw new ServiceException("耗材划价失败:发放库房为空,请重新选择"); if (advice.getLocationId() == null) {
// 设置默认位置为用户组织ID作为fallback
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null && loginUser.getOrgId() != null) {
advice.setLocationId(loginUser.getOrgId());
}
}
} }
// 3. 循环处理每个临时耗材医嘱(逐条生成关联数据) // 3. 循环处理每个临时耗材医嘱(逐条生成关联数据)
for (AdviceSaveDto adviceDto : tempDeviceList) { for (AdviceSaveDto adviceDto : tempDeviceList) {
// 3.1 生成耗材请求记录WOR_DEVICE_REQUEST状态设为激活来源标记为护士划价 // 3.1 生成耗材请求记录WOR_DEVICE_REQUEST状态设为激活来源标记为护士划价
@@ -665,7 +670,7 @@ public class NurseBillingAppService implements INurseBillingAppService {
// 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除 // 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除
checkDeletedDeviceChargeStatus(requestIds); checkDeletedDeviceChargeStatus(requestIds);
// 软删除执行记录 // 软删除执行记录
List<Procedure> procedureList = iProcedureService.listByIds(requestIds); List<Procedure> procedureList = iProcedureService.getProcedureRecords(requestIds, serviceTable);
List<Long> procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象 List<Long> procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象
.map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录按需添加 .map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录按需添加
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -331,8 +331,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
) t ) t
WHERE rn = 1 WHERE rn = 1
) pi ON s.patient_id = pi.patient_id ) pi ON s.patient_id = pi.patient_id
<!-- 排除已生成医嘱的手术 -->
LEFT JOIN wor_service_request sr ON sr.activity_id = s.id AND sr.delete_flag = '0' AND sr.category_enum = 4
<where> <where>
s.delete_flag = '0' s.delete_flag = '0'
<!-- 只显示未生成医嘱的手术 -->
AND sr.id IS NULL
<if test="ew.sqlSegment != null and ew.sqlSegment != ''"> <if test="ew.sqlSegment != null and ew.sqlSegment != ''">
<![CDATA[ <![CDATA[
AND ${ew.sqlSegment.replace('tenant_id', 's.tenant_id').replace('create_time', 's.create_time').replace('surgery_no', 's.surgery_no').replace('surgery_name', 's.surgery_name').replace('patient_name', 's.patient_name').replace('main_surgeon_name', 's.main_surgeon_name').replace('anesthetist_name', 's.anesthetist_name').replace('org_name', 's.org_name').replace('status_enum', 's.status_enum').replace('planned_time', 's.planned_time')} AND ${ew.sqlSegment.replace('tenant_id', 's.tenant_id').replace('create_time', 's.create_time').replace('surgery_no', 's.surgery_no').replace('surgery_name', 's.surgery_name').replace('patient_name', 's.patient_name').replace('main_surgeon_name', 's.main_surgeon_name').replace('anesthetist_name', 's.anesthetist_name').replace('org_name', 's.org_name').replace('status_enum', 's.status_enum').replace('planned_time', 's.planned_time')}

View File

@@ -34,7 +34,7 @@
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
FROM op_schedule os FROM op_schedule os
LEFT JOIN adm_patient ap ON os.patient_id = ap.id LEFT JOIN adm_patient ap ON os.patient_id = ap.id
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0' INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
LEFT JOIN adm_organization o ON cs.org_id = o.id LEFT JOIN adm_organization o ON cs.org_id = o.id
LEFT JOIN sys_tenant st ON st.id = os.tenant_id LEFT JOIN sys_tenant st ON st.id = os.tenant_id
LEFT JOIN sys_user su ON su.user_id = os.creator_id LEFT JOIN sys_user su ON su.user_id = os.creator_id
@@ -92,7 +92,7 @@
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
FROM op_schedule os FROM op_schedule os
LEFT JOIN adm_patient ap ON os.patient_id = ap.id LEFT JOIN adm_patient ap ON os.patient_id = ap.id
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0' INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
LEFT JOIN adm_organization o ON cs.org_id = o.id LEFT JOIN adm_organization o ON cs.org_id = o.id
LEFT JOIN doc_request_form drf ON drf.prescription_no=cs.surgery_no LEFT JOIN doc_request_form drf ON drf.prescription_no=cs.surgery_no
LEFT JOIN ( LEFT JOIN (
@@ -153,7 +153,7 @@
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
FROM op_schedule os FROM op_schedule os
LEFT JOIN adm_patient ap ON os.patient_id = ap.id LEFT JOIN adm_patient ap ON os.patient_id = ap.id
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0' INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
LEFT JOIN adm_organization o ON cs.org_id = o.id LEFT JOIN adm_organization o ON cs.org_id = o.id
LEFT JOIN sys_tenant st ON st.id = os.tenant_id LEFT JOIN sys_tenant st ON st.id = os.tenant_id
LEFT JOIN sys_user su ON su.user_id = os.creator_id LEFT JOIN sys_user su ON su.user_id = os.creator_id

View File

@@ -186,6 +186,9 @@
<if test="searchKey != null and searchKey != ''"> <if test="searchKey != null and searchKey != ''">
AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%') AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%')
</if> </if>
<if test="categoryCode != null and categoryCode != ''">
AND t1.category_code = #{categoryCode}
</if>
<if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()"> <if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()">
AND t1.id IN AND t1.id IN
<foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")"> <foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")">
@@ -274,12 +277,15 @@
AND (T1.category_code = '手术' OR T1.category_code = '24') AND (T1.category_code = '手术' OR T1.category_code = '24')
</if> </if>
<!-- 如果只选择诊疗(adviceType=3),排除手术 --> <!-- 如果只选择诊疗(adviceType=3),排除手术 -->
<if test="adviceTypes.contains(3) and !adviceTypes.contains(6)"> <if test="adviceTypes.contains(3) and !adviceTypes.contains(6) and (categoryCode == null or categoryCode == '')">
AND T1.category_code != '手术' AND T1.category_code != '24' AND T1.category_code != '手术' AND T1.category_code != '24'
</if> </if>
<if test="searchKey != null and searchKey != ''"> <if test="searchKey != null and searchKey != ''">
AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%') AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%')
</if> </if>
<if test="categoryCode != null and categoryCode != ''">
AND t1.category_code = #{categoryCode}
</if>
<if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()"> <if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()">
AND t1.id IN AND t1.id IN
<foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")"> <foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")">

View File

@@ -1,12 +1,5 @@
# 页面标题 # Playwright E2E 测试环境变量
VITE_APP_TITLE = 医院信息管理系统 # 注意此文件仅用于本地开发生产环境使用CI Secret管理
TEST_BASE_URL=http://localhost:80
# 测试环境配置 TEST_USERNAME=admin
VITE_APP_ENV = 'test' TEST_PASSWORD=changeme_in_local_env
# OpenHIS管理系统/测试环境
VITE_APP_BASE_API = '/test-api'
# 租户ID配置
VITE_APP_TENANT_ID = '1'

View File

@@ -21,3 +21,8 @@ selenium-debug.log
package-lock.json package-lock.json
yarn.lock yarn.lock
# Playwright test results
test-results/
tests/e2e/report/
tests/tests/

View File

@@ -0,0 +1,59 @@
/* eslint-env node */
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
import parserVue from "vue-eslint-parser";
import importPlugin from "eslint-plugin-import";
export default [
{
name: "app/files-to-lint",
files: ["**/*.{js,mjs,jsx,vue}"],
},
{
name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
},
...pluginVue.configs["flat/recommended"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: parserVue,
ecmaVersion: "latest",
sourceType: "module",
},
plugins: {
import: importPlugin,
},
rules: {
// 确保导入的模块实际存在(核心规则,防止构建失败)
"import/no-unresolved": "error",
// 确保导入的命名导出实际存在
"import/named": "error",
// 确保默认导出存在
"import/default": "error",
// 确保命名空间导出存在
"import/namespace": "error",
// Vue 相关规则
"vue/multi-word-component-names": "off",
},
settings: {
"import/resolver": {
alias: {
map: [
["@", "./src"],
],
extensions: [".js", ".jsx", ".vue"],
},
},
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,11 @@
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui" "test:ui": "vitest --ui",
"lint": "eslint . --ext .js,.vue src/",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -67,6 +71,11 @@
"@vitejs/plugin-vue": "4.5.0", "@vitejs/plugin-vue": "4.5.0",
"@vue/compiler-sfc": "3.3.9", "@vue/compiler-sfc": "3.3.9",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"eslint": "^9.39.4",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-vue": "^10.9.0",
"globals": "^17.5.0",
"happy-dom": "^20.8.3", "happy-dom": "^20.8.3",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"pg": "^8.18.0", "pg": "^8.18.0",

View File

@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/specs',
fullyParallel: true,
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'tests/e2e/report', open: 'never' }],
['list'],
],
use: {
baseURL: process.env.TEST_BASE_URL || 'http://localhost:81',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});

View File

@@ -383,6 +383,7 @@ const props = defineProps({
}, },
}); });
const isAdding = ref(false); const isAdding = ref(false);
const isSaving = ref(false); // #437 防重复提交锁
const prescriptionRef = ref(); const prescriptionRef = ref();
const expandOrder = ref([]); //目前的展开行 const expandOrder = ref([]); //目前的展开行
const stockList = ref([]); const stockList = ref([]);
@@ -1028,13 +1029,18 @@ function handleSave() {
}) })
groupIndexList.value = [] groupIndexList.value = []
groupList.value = [] groupList.value = []
nextId.value == 1; nextId.value = 1;
} }
}); });
} }
// 单行处方保存 // 单行处方保存
function handleSaveSign(row, index) { function handleSaveSign(row, index) {
// 🔧 Bug Fix #437: 防重复提交锁
if (isSaving.value) {
proxy.$modal.msgWarning('正在保存中,请勿重复提交');
return;
}
// 🔧 Bug Fix #238: 诊疗项目必须选择执行科室 // 🔧 Bug Fix #238: 诊疗项目必须选择执行科室
if (row.adviceType === 3 && !row.orgId) { if (row.adviceType === 3 && !row.orgId) {
proxy.$modal.msgWarning('诊疗项目必须选择执行科室'); proxy.$modal.msgWarning('诊疗项目必须选择执行科室');
@@ -1042,33 +1048,37 @@ function handleSaveSign(row, index) {
} }
proxy.$refs['formRef' + index].validate((valid) => { proxy.$refs['formRef' + index].validate((valid) => {
if (valid) { if (valid) {
isSaving.value = true; // #437 加锁
row.isEdit = false; row.isEdit = false;
isAdding.value = false; isAdding.value = false;
expandOrder.value = []; expandOrder.value = [];
row.patientId = props.patientInfo.patientId; row.patientId = props.patientInfo.patientId;
row.encounterId = props.patientInfo.encounterId; row.encounterId = props.patientInfo.encounterId;
row.accountId = props.patientInfo.accountId; row.accountId = props.patientInfo.accountId;
row.contentJson = JSON.stringify(row); const cleanRow = JSON.parse(JSON.stringify(row));
row.dbOpType = row.requestId ? '2' : '1'; cleanRow.contentJson = JSON.stringify(cleanRow);
row.minUnitQuantity = row.quantity * row.partPercent; cleanRow.dbOpType = cleanRow.requestId ? '2' : '1';
row.categoryEnum = row.adviceType cleanRow.minUnitQuantity = cleanRow.quantity * cleanRow.partPercent;
cleanRow.categoryEnum = cleanRow.adviceType
// 如果是手术计费,设置生成来源和来源业务单据号 // 如果是手术计费,设置生成来源和来源业务单据号
if (props.patientInfo.sourceBillNo) { if (props.patientInfo.sourceBillNo) {
row.generateSourceEnum = 6; // 手术计费 cleanRow.generateSourceEnum = 6; // 手术计费
row.sourceBillNo = props.patientInfo.sourceBillNo; cleanRow.sourceBillNo = props.patientInfo.sourceBillNo;
} }
console.log('row', row) console.log('cleanRow', cleanRow)
savePrescription({ adviceSaveList: [row] }).then((res) => { savePrescription({ adviceSaveList: [cleanRow] }).then((res) => {
if (res.code === 200) { if (res.code === 200) {
proxy.$modal.msgSuccess('保存成功'); proxy.$modal.msgSuccess('保存成功');
getListInfo(false); getListInfo(false);
nextId.value == 1; nextId.value = 1;
// 🔧 Bug Fix #238: 如果诊疗项目缺少执行科室,标记为需要修复的脏数据 // 🔧 Bug Fix #238: 如果诊疗项目缺少执行科室,标记为需要修复的脏数据
if (row.adviceType === 3 && !row.orgId) { if (row.adviceType === 3 && !row.orgId) {
console.warn('Bug #238: 检测到诊疗项目保存时缺少执行科室,请手动编辑修正:', row); console.warn('Bug #238: 检测到诊疗项目保存时缺少执行科室,请手动编辑修正:', cleanRow);
proxy.$modal.msgWarning('诊疗项目执行科室信息不完整,请编辑后重新保存'); proxy.$modal.msgWarning('诊疗项目执行科室信息不完整,请编辑后重新保存');
} }
} }
}).finally(() => {
isSaving.value = false; // #437 释放锁
}); });
} }
}); });

View File

@@ -100,7 +100,7 @@
</div> </div>
</el-form-item> </el-form-item>
<div class="footer"> <div class="footer">
© 2025 {{ currentTenantName || settings.systemName }}信息管理系统 | 版本 v2.5.1 © 2025 {{ currentTenantName || settings.systemName }}信息管理系统 | 版本 {{ loginVersion }}
<!-- 公司版权信息新增 --> <!-- 公司版权信息新增 -->
<div class="company-copyright"> <div class="company-copyright">
技术支持上海经创贺联信息技术有限公司 技术支持上海经创贺联信息技术有限公司
@@ -141,6 +141,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const env = import.meta.env.MODE; const env = import.meta.env.MODE;
const loginVersion = import.meta.env.VITE_APP_BUILD_VERSION;
const loginForm = ref({ const loginForm = ref({
username: '', username: '',

View File

@@ -1941,6 +1941,7 @@ function submitForm() {
// 新增手术安排 // 新增手术安排
addSurgerySchedule(submitData).then((res) => { addSurgerySchedule(submitData).then((res) => {
proxy.$modal.msgSuccess('新增成功') proxy.$modal.msgSuccess('新增成功')
queryParams.pageNo = 1
open.value = false open.value = false
getPageList() getPageList()
}).catch(() => { }).catch(() => {

View File

@@ -0,0 +1,17 @@
import { test as base } from '@playwright/test';
import { TEST_USERS } from '../utils/test-data';
export const test = base.extend({
async authenticatedPage({ page }, use) {
// 登录
await page.goto('/');
await page.fill('input[placeholder="请输入用户名"]', TEST_USERS.admin.username);
await page.fill('input[placeholder="请输入密码"]', TEST_USERS.admin.password);
await page.click('button:has-text("登录")');
await page.waitForURL(/.*(dashboard|home).*/);
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,26 @@
import { Page, expect } from '@playwright/test';
export class DoctorStationPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/doctorstation');
await this.page.waitForLoadState('networkidle');
}
async expandCategory(index: number = 0) {
const item = this.page.locator('.el-collapse-item, .category-item').nth(index);
await item.click();
await this.page.waitForTimeout(500);
}
async searchPatient(name: string) {
await this.page.fill('input[placeholder*="患者"], input[placeholder*="姓名"]', name);
await this.page.click('button:has-text("搜索"), button:has-text("查询")');
await this.page.waitForLoadState('networkidle');
}
}

View File

@@ -0,0 +1,46 @@
import { Page, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/');
await this.page.waitForLoadState('domcontentloaded');
}
async login(username: string, password: string) {
// Actual placeholders from login.vue: "账号" and "密码"
await this.page.fill('input[placeholder="账号"]', username);
await this.page.fill('input[placeholder="密码"]', password);
// Check for tenant selection if exists
const tenantSelect = this.page.locator('.el-select__wrapper, input[placeholder="请选择医疗机构"]').first();
if (await tenantSelect.isVisible().catch(() => false)) {
await tenantSelect.click();
await this.page.waitForTimeout(500);
// Select first option
const firstOption = this.page.locator('.el-select-dropdown__item, .el-option').first();
if (await firstOption.isVisible().catch(() => false)) {
await firstOption.click();
await this.page.waitForTimeout(500);
}
}
await this.page.click('button:has-text("登 录")');
await this.page.waitForLoadState('networkidle');
}
async expectLoginSuccess() {
await expect(this.page).toHaveURL(/.*(dashboard|home|index).*/, { timeout: 15000 });
}
async expectLoginFailed() {
await expect(this.page.locator('.el-message--error')).toBeVisible({ timeout: 5000 });
}
async expectOnLoginPage() {
await expect(this.page.locator('input[placeholder="账号"]')).toBeVisible();
}
}

View File

@@ -0,0 +1,34 @@
import { Page, expect } from '@playwright/test';
export class SurgeryBillingPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/operatingroom');
await this.page.waitForLoadState('networkidle');
}
async rapidClickGenerate(times: number = 5) {
const btn = this.page.locator('button:has-text("生成"), button:has-text("新增")');
for (let i = 0; i < times; i++) {
await btn.click().catch(() => {});
}
await this.page.waitForLoadState('networkidle');
}
async getDialogCount(): Promise<number> {
return await this.page.locator('.el-dialog, .el-message-box').count();
}
async expectNoLocationIdError() {
await expect(this.page.locator('text=发放库房为空')).toHaveCount(0, { timeout: 5000 });
}
async expectSaveSuccess() {
await expect(this.page.locator('.el-message--success')).toBeVisible({ timeout: 10000 });
}
}

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
test.describe('🐛 Bug回归测试', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
await loginPage.expectLoginSuccess();
});
test('#437 手术计费防重复提交 @bug437 @regression', async ({ page }) => {
await page.goto(TEST_URLS.surgeryBilling);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button:has-text("新增"), button:has-text("生成")');
if (await addBtn.isVisible()) {
await addBtn.click();
await addBtn.click();
await addBtn.click();
await page.waitForTimeout(2000);
const dialogs = page.locator('.el-dialog, .el-message-box');
expect(await dialogs.count()).toBeLessThanOrEqual(1);
}
});
test('#443 手术计费签发耗材 @bug443 @regression', async ({ page }) => {
await page.goto(TEST_URLS.surgeryBilling);
await page.waitForLoadState('networkidle');
const signBtn = page.locator('button:has-text("签发"), button:has-text("提交")');
if (await signBtn.isVisible()) {
await signBtn.click();
await page.waitForTimeout(2000);
const errorMsg = page.locator('text=发放库房为空');
expect(await errorMsg.count()).toBe(0);
}
});
test('#427 检查项目分类手风琴展开 @regression', async ({ page }) => {
await page.goto(TEST_URLS.doctorStation);
await page.waitForLoadState('networkidle');
const categories = page.locator('.el-collapse-item, .category-item');
const count = await categories.count();
if (count > 0) {
await categories.first().click();
await page.waitForTimeout(500);
}
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
test.describe('🔄 并发操作测试', () => {
test('#437 多窗口同时操作手术计费 @bug437', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// Login on both pages
for (const page of [page1, page2]) {
await page.goto(TEST_URLS.login);
await page.fill('input[placeholder="账号"]', TEST_USERS.admin.username);
await page.fill('input[placeholder="密码"]', TEST_USERS.admin.password);
await page.click('button:has-text("登 录")');
await page.waitForURL(/.*(dashboard|home|index).*/);
}
await Promise.all([
page1.goto(TEST_URLS.surgeryBilling),
page2.goto(TEST_URLS.surgeryBilling),
]);
await Promise.all([
page1.waitForLoadState('networkidle'),
page2.waitForLoadState('networkidle'),
]);
const genBtn1 = page1.locator('button:has-text("生成")');
const genBtn2 = page2.locator('button:has-text("生成")');
if (await genBtn1.isVisible() && await genBtn2.isVisible()) {
await Promise.all([
genBtn1.click().catch(() => {}),
genBtn2.click().catch(() => {}),
]);
await page1.waitForTimeout(3000);
const table1 = page1.locator('el-table__body tr, .el-table__row');
const table2 = page2.locator('el-table__body tr, .el-table__row');
const count1 = await table1.count();
const count2 = await table2.count();
expect(count1).toBe(count2);
}
await context1.close();
await context2.close();
});
});

View File

@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
test.describe('🏥 门诊医生站', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
await loginPage.expectLoginSuccess();
});
test('#427 分类手风琴展开/收起 @regression', async ({ page }) => {
await page.goto(TEST_URLS.doctorStation);
await page.waitForLoadState('networkidle');
const items = page.locator('.el-collapse-item, .category-item');
const count = await items.count();
if (count >= 2) {
await items.nth(0).click();
await page.waitForTimeout(500);
await items.nth(1).click();
await page.waitForTimeout(500);
}
});
test('TC-DOCTOR-001: 医生站页面加载 @smoke', async ({ page }) => {
await page.goto(TEST_URLS.doctorStation);
await expect(page).toHaveURL(/.*doctorstation.*/);
});
});

View File

@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { TEST_USERS } from '../utils/test-data';
test.describe('🔐 登录模块', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('TC-LOGIN-001: 管理员正常登录 @smoke', async ({ page }) => {
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
await loginPage.expectLoginSuccess();
});
test('TC-LOGIN-002: 错误密码登录 @smoke', async ({ page }) => {
await loginPage.login(TEST_USERS.admin.username, 'wrong_password_123');
// Check for any error indication (message, toast, or stayed on login page)
const hasError = await page.locator('.el-message--error, .el-message-box, text=密码错误, text=用户名或密码错误').isVisible().catch(() => false);
const stillOnLogin = page.url().includes('login') || page.url() === 'http://localhost:81/' || page.url() === 'http://localhost:81/index';
expect(hasError || stillOnLogin).toBeTruthy();
});
test('TC-LOGIN-003: 空用户名登录', async ({ page }) => {
await loginPage.login('', TEST_USERS.admin.password);
// Should show validation error or stay on login page
const hasError = await page.locator('.el-form-item__error, .el-message--error').isVisible().catch(() => false);
const stillOnLogin = page.url().includes('login') || page.url() === 'http://localhost:81/';
expect(hasError || stillOnLogin).toBeTruthy();
});
test('TC-LOGIN-004: 密码输入框可见性切换', async ({ page }) => {
const passwordInput = page.locator('input[placeholder="密码"]');
await expect(passwordInput).toHaveAttribute('type', 'password');
});
});

View File

@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
test.describe('💊 手术计费模块', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
await loginPage.expectLoginSuccess();
});
test('#437 快速连续点击防重复 @bug437 @smoke', async ({ page }) => {
await page.goto(TEST_URLS.surgeryBilling);
await page.waitForLoadState('networkidle');
const genBtn = page.locator('button:has-text("生成"), button:has-text("新增")');
if (await genBtn.isVisible()) {
for (let i = 0; i < 5; i++) {
await genBtn.click().catch(() => {});
}
await page.waitForTimeout(3000);
const count = await page.locator('.el-dialog, .el-message-box').count();
expect(count).toBeLessThanOrEqual(1);
}
});
test('#443 签发耗材不报库房错误 @bug443 @smoke', async ({ page }) => {
await page.goto(TEST_URLS.surgeryBilling);
await page.waitForLoadState('networkidle');
const signBtn = page.locator('button:has-text("签发"), button:has-text("提交")');
if (await signBtn.isVisible()) {
await signBtn.click();
await page.waitForTimeout(2000);
await expect(page.locator('text=发放库房为空')).toHaveCount(0, { timeout: 5000 });
}
});
});

View File

@@ -0,0 +1,13 @@
export const TEST_USERS = {
admin: {
username: process.env.TEST_USERNAME || 'admin',
password: process.env.TEST_PASSWORD || 'admin123',
},
};
export const TEST_URLS = {
login: '/',
dashboard: '/index',
doctorStation: '/doctorstation',
surgeryBilling: '/operatingroom',
};

View File

@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/specs',
fullyParallel: true,
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'tests/e2e/report', open: 'never' }],
['list'],
],
use: {
baseURL: process.env.TEST_BASE_URL || 'http://localhost:81',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});

View File

@@ -11,11 +11,11 @@ import createVitePlugins from './vite/plugins';
export default defineConfig(({ mode, command }) => { export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd()); const env = loadEnv(mode, process.cwd());
const { VITE_APP_ENV } = env; const { VITE_APP_ENV } = env;
const buildVersion = process.env.VITE_APP_VERSION || env.VITE_APP_VERSION || Date.now().toString();
return { return {
// define: { define: {
// // enable hydration mismatch details in production build 'import.meta.env.VITE_APP_BUILD_VERSION': JSON.stringify(buildVersion),
// __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' },
// },
// 部署生产环境和开发环境下的URL。 // 部署生产环境和开发环境下的URL。
// 默认情况下vite 会假设你的应用是被部署在一个域名的根路径上 // 默认情况下vite 会假设你的应用是被部署在一个域名的根路径上
// 例如 https://www.openHIS.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.openhis.vip/admin/,则设置 baseUrl 为 /admin/。 // 例如 https://www.openHIS.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.openhis.vip/admin/,则设置 baseUrl 为 /admin/。

21
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "jkhl", "name": "his-repo",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -7,6 +7,9 @@
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
"json-bigint": "^1.0.0" "json-bigint": "^1.0.0"
},
"devDependencies": {
"husky": "^9.1.7"
} }
}, },
"node_modules/asynckit": { "node_modules/asynckit": {
@@ -261,6 +264,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/json-bigint": { "node_modules/json-bigint": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz",

View File

@@ -2,5 +2,11 @@
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
"json-bigint": "^1.0.0" "json-bigint": "^1.0.0"
},
"devDependencies": {
"husky": "^9.1.7"
},
"scripts": {
"prepare": "husky"
} }
} }

View File

@@ -0,0 +1,59 @@
-- Bug #437 数据排查:查询手术计费中的重复收费项
-- 用法:在生产环境执行前先在测试环境验证
-- 作者:荀彧
-- 1. 查找手术计费中的重复收费项(同一手术+同一产品产生多条记录)
SELECT
encounter_id AS "就诊ID",
product_table AS "产品表",
product_id AS "产品ID",
service_table AS "服务表",
service_id AS "服务ID",
generate_source_enum AS "生成来源",
COUNT(*) AS "重复次数",
STRING_AGG(id::TEXT, ',') AS "收费项ID列表",
STRING_AGG(status_enum::TEXT, ',') AS "状态列表",
STRING_AGG(total_price::TEXT, ',') AS "金额列表",
STRING_AGG(create_time::TEXT, ',') AS "创建时间列表"
FROM adm_charge_item
WHERE delete_flag = '0'
AND generate_source_enum IN (1, 2) -- 医生开立(1) 或 护士划价(2)
AND product_table IN ('cli_surgery', 'wor_device_request', 'wor_activity_definition')
GROUP BY encounter_id, product_table, product_id, service_table, service_id, generate_source_enum
HAVING COUNT(*) > 1
ORDER BY COUNT(*) DESC;
-- 2. 查找同一手术单号下是否有重复的收费项
SELECT
ci.product_id AS "手术ID",
cs.surgery_no AS "手术单号",
cs.surgery_name AS "手术名称",
COUNT(ci.id) AS "收费项数量",
STRING_AGG(ci.id::TEXT || '(' || ci.status_enum || ')' || '=' || ci.total_price, '; ') AS "收费项详情"
FROM adm_charge_item ci
LEFT JOIN cli_surgery cs ON ci.product_id = cs.id
WHERE ci.delete_flag = '0'
AND ci.product_table = 'cli_surgery'
AND ci.generate_source_enum = 1
GROUP BY ci.product_id, cs.surgery_no, cs.surgery_name
HAVING COUNT(ci.id) > 1
ORDER BY COUNT(ci.id) DESC;
-- 3. 统计重复数据量(用于评估影响范围)
SELECT
generate_source_enum AS "生成来源(1=医生开立,2=护士划价)",
product_table AS "产品表",
COUNT(*) AS "重复组数",
SUM(cnt - 1) AS "多余记录数"
FROM (
SELECT
generate_source_enum,
product_table,
COUNT(*) AS cnt
FROM adm_charge_item
WHERE delete_flag = '0'
GROUP BY encounter_id, product_table, product_id, service_table, service_id, generate_source_enum
HAVING COUNT(*) > 1
) sub
GROUP BY generate_source_enum, product_table
ORDER BY SUM(cnt - 1) DESC;

View File

@@ -0,0 +1,28 @@
-- Bug #437 修复:手术计费重复生成三条收费记录
-- 根因adm_charge_item 表缺少唯一约束,同一手术/耗材的收费项可被重复插入
-- 影响范围:手术计费、护士划价等所有生成收费项的场景
-- 作者:荀彧
-- 日期2026-04-25
-- 1. 添加复合唯一约束:防止同一就诊下同一来源服务+产品产生重复收费项
-- 约束字段就诊ID + 医疗服务表 + 医疗服务ID + 产品表 + 产品ID + 账单生成来源
-- 这些字段组合唯一确定一笔收费项,重复插入将被数据库拒绝
ALTER TABLE adm_charge_item
ADD CONSTRAINT uk_charge_item_encounter_service_product_source
UNIQUE (encounter_id, service_table, service_id, product_table, product_id, generate_source_enum);
-- 2. 添加索引:加速手术计费查询(按手术单号过滤)
-- 前端手术计费页面使用 generate_source_enum=2 过滤手术计费项
CREATE INDEX idx_charge_item_generate_source_product
ON adm_charge_item (generate_source_enum, product_table, product_id)
WHERE delete_flag = '0';
-- 3. 添加索引加速按就诊ID+状态查询收费项
CREATE INDEX idx_charge_item_encounter_status
ON adm_charge_item (encounter_id, status_enum)
WHERE delete_flag = '0';
-- DOWN 迁移(回滚脚本)
-- ALTER TABLE adm_charge_item DROP CONSTRAINT IF EXISTS uk_charge_item_encounter_service_product_source;
-- DROP INDEX IF EXISTS idx_charge_item_generate_source_product;
-- DROP INDEX IF EXISTS idx_charge_item_encounter_status;