新增功能
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# TUICallKit API 名称变更修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
升级到 `@trtc/calls-uikit-vue` 4.2.2 后,出现编译错误:
|
||||
```
|
||||
模块"@trtc/calls-uikit-vue"没有导出的成员"TUICallKitServer"
|
||||
```
|
||||
|
||||
## 根本原因
|
||||
|
||||
新版本的包有两个重要变更:
|
||||
1. API 名称从 `TUICallKitServer` 改为 `TUICallKitAPI`
|
||||
2. 发起通话的方法从 `call()` 改为 `calls()`,参数也有变化
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 1. 更新导入语句
|
||||
|
||||
**旧版本 (4.0.12):**
|
||||
```typescript
|
||||
import { TUICallKitServer, TUICallKit, TUICallType, STATUS } from '@tencentcloud/call-uikit-vue'
|
||||
```
|
||||
|
||||
**新版本 (4.2.2):**
|
||||
```typescript
|
||||
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||||
```
|
||||
|
||||
### 2. 更新发起通话的方法
|
||||
|
||||
**旧版本 (4.0.12):**
|
||||
```typescript
|
||||
// 一对一通话
|
||||
await TUICallKitServer.call({
|
||||
userID: 'user123',
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
```
|
||||
|
||||
**新版本 (4.2.2):**
|
||||
```typescript
|
||||
// 一对一通话 - 使用 calls 方法,参数改为 userIDList 数组
|
||||
await TUICallKitAPI.calls({
|
||||
userIDList: ['user123'],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
// 多人通话 - 同样使用 calls 方法
|
||||
await TUICallKitAPI.calls({
|
||||
userIDList: ['user1', 'user2', 'user3'],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 更新其他 API 调用
|
||||
|
||||
将所有 `TUICallKitServer` 替换为 `TUICallKitAPI`:
|
||||
|
||||
```typescript
|
||||
// 初始化
|
||||
TUICallKitAPI.init({ ... })
|
||||
|
||||
// 发起通话(注意:方法名改为 calls,参数改为 userIDList)
|
||||
TUICallKitAPI.calls({
|
||||
userIDList: ['user123'],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
// 挂断
|
||||
TUICallKitAPI.hangup()
|
||||
|
||||
// 接听
|
||||
TUICallKitAPI.accept()
|
||||
|
||||
// 拒绝
|
||||
TUICallKitAPI.reject()
|
||||
|
||||
// 设置回调
|
||||
TUICallKitAPI.setCallback({ ... })
|
||||
|
||||
// 浮窗设置
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
```
|
||||
|
||||
## 已修复的文件
|
||||
|
||||
1. ✅ `admin/src/components/video-call/index.vue`
|
||||
2. ✅ `admin/src/views/test/patient-call.vue`
|
||||
3. ✅ `UIKIT_UPGRADE_COMPLETE.md`
|
||||
|
||||
## 下一步操作
|
||||
|
||||
### 1. 重新安装依赖
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
|
||||
# 删除旧依赖
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
# 安装新依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 验证修复
|
||||
|
||||
- ✅ 编译成功,没有错误
|
||||
- ✅ 可以正常登录
|
||||
- ✅ 可以发起通话
|
||||
- ✅ 可以接听通话
|
||||
|
||||
## API 对比表
|
||||
|
||||
| 功能 | 旧版本 (4.0.12) | 新版本 (4.2.2) |
|
||||
|------|----------------|----------------|
|
||||
| 包名 | @tencentcloud/call-uikit-vue | @trtc/calls-uikit-vue |
|
||||
| API名称 | TUICallKitServer | TUICallKitAPI |
|
||||
| 初始化 | TUICallKitServer.init() | TUICallKitAPI.init() |
|
||||
| 发起通话 | TUICallKitServer.call({userID, type}) | TUICallKitAPI.calls({userIDList, type}) |
|
||||
| 挂断 | TUICallKitServer.hangup() | TUICallKitAPI.hangup() |
|
||||
| 接听 | TUICallKitServer.accept() | TUICallKitAPI.accept() |
|
||||
| 拒绝 | TUICallKitServer.reject() | TUICallKitAPI.reject() |
|
||||
| 设置回调 | TUICallKitServer.setCallback() | TUICallKitAPI.setCallback() |
|
||||
| 浮窗设置 | TUICallKitServer.enableFloatWindow() | TUICallKitAPI.enableFloatWindow() |
|
||||
|
||||
## 重要变更说明
|
||||
|
||||
### 1. API 名称变更
|
||||
- `TUICallKitServer` → `TUICallKitAPI`
|
||||
|
||||
### 2. 发起通话方法变更
|
||||
- 方法名:`call()` → `calls()`
|
||||
- 参数变化:
|
||||
- 旧版本:`{ userID: string, type: TUICallType }`
|
||||
- 新版本:`{ userIDList: string[], type: TUICallType }`
|
||||
- 说明:新版本统一使用数组参数,一对一通话时数组只包含一个元素
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API 名称变更**:`TUICallKitServer` → `TUICallKitAPI`
|
||||
2. **发起通话方法变更**:
|
||||
- 方法名:`call()` → `calls()`
|
||||
- 参数:`userID` → `userIDList`(数组)
|
||||
3. **组件名称不变**:`TUICallKit` 组件名称保持不变
|
||||
4. **类型定义不变**:`TUICallType`、`STATUS` 等类型定义保持不变
|
||||
5. **回调机制不变**:`statusChanged`、`afterCalling` 等回调保持不变
|
||||
6. **其他方法不变**:`init()`、`hangup()`、`accept()`、`reject()` 等方法参数保持不变
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [官方文档 - Web&H5 (Vue3)](https://www.tencentcloud.com/document/product/647/50993)
|
||||
- [API 文档](https://web.sdk.qcloud.com/component/trtccalling/doc/web/zh-cn/)
|
||||
|
||||
---
|
||||
|
||||
**修复日期:** 2024-03-04
|
||||
**修复版本:** 4.0.12 → 4.2.2
|
||||
**状态:** ✅ 完成
|
||||
@@ -0,0 +1,246 @@
|
||||
# 患者预约挂号功能实现总结
|
||||
|
||||
## 实现完成 ✅
|
||||
|
||||
已成功实现基于医生排班的患者预约挂号系统,界面设计参考提供的UI原型图。
|
||||
|
||||
## 核心文件
|
||||
|
||||
### 1. API接口文件
|
||||
**文件**: `admin/src/api/doctor.ts`
|
||||
|
||||
新增接口:
|
||||
- `getAvailableSlots()` - 获取医生可用时间段
|
||||
- `createAppointment()` - 创建预约
|
||||
- `cancelAppointment()` - 取消预约
|
||||
- `appointmentLists()` - 获取预约列表
|
||||
- `appointmentDetail()` - 获取预约详情
|
||||
|
||||
### 2. 预约弹窗组件
|
||||
**文件**: `admin/src/views/tcm/diagnosis/appointment.vue`
|
||||
|
||||
主要功能:
|
||||
- ✅ 显示患者上次就诊记录
|
||||
- ✅ 预约方式选择(选择时段/选择医生)
|
||||
- ✅ 预约类型选择(视频问诊)
|
||||
- ✅ 患者信息显示
|
||||
- ✅ 医生列表选择(带可用号源数量标签)
|
||||
- ✅ 日期选择(未来7天,横向按钮布局)
|
||||
- ✅ 时间段网格显示(4列布局,30分钟间隔)
|
||||
- ✅ 备注输入
|
||||
|
||||
### 3. 诊断列表页面
|
||||
**文件**: `admin/src/views/tcm/diagnosis/index.vue`
|
||||
|
||||
更新内容:
|
||||
- ✅ 导入预约组件
|
||||
- ✅ 添加预约组件引用
|
||||
- ✅ 实现挂号按钮点击处理函数
|
||||
- ✅ 挂号成功后刷新列表
|
||||
|
||||
## 界面特性
|
||||
|
||||
### 视觉设计
|
||||
1. **医生选择**
|
||||
- 单选按钮形式
|
||||
- 每个医生显示可用号源标签
|
||||
- 有号源:绿色标签 + 数字
|
||||
- 无号源:灰色标签 + "无"
|
||||
|
||||
2. **日期选择**
|
||||
- 横向按钮布局
|
||||
- 显示格式:02月26日 (四)
|
||||
- 选中状态:蓝色按钮高亮
|
||||
|
||||
3. **时间段显示**
|
||||
- 4列网格布局
|
||||
- 每个时间段包含时间和号源标签
|
||||
- 可预约:白色背景 + 蓝色标签
|
||||
- 不可预约:灰色背景 + 灰色"无"标签
|
||||
- 已选择:蓝色背景 + 白色文字
|
||||
|
||||
### 交互逻辑
|
||||
1. **级联选择**
|
||||
- 选择医生 → 加载该医生的可用时间段
|
||||
- 选择日期 → 刷新时间段列表
|
||||
- 选择时间段 → 高亮显示
|
||||
|
||||
2. **数据验证**
|
||||
- 必须选择医生
|
||||
- 必须选择日期
|
||||
- 必须选择时间段
|
||||
- 不可预约的时间段禁止点击
|
||||
|
||||
3. **用户反馈**
|
||||
- 加载状态显示
|
||||
- 操作成功/失败提示
|
||||
- 空状态友好提示
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 时间段生成
|
||||
```typescript
|
||||
// 30分钟间隔
|
||||
// 上午: 09:00-12:00
|
||||
// 下午: 13:00-20:30
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
```typescript
|
||||
- selectedDoctorId: 选中的医生ID
|
||||
- form.date: 选中的日期
|
||||
- form.appointmentTime: 选中的时间
|
||||
- timeSlots: 可用时间段列表
|
||||
```
|
||||
|
||||
### 数据流
|
||||
```
|
||||
1. 打开弹窗 → 加载医生列表
|
||||
2. 选择医生 + 日期 → 加载时间段
|
||||
3. 选择时间段 → 更新表单
|
||||
4. 确认预约 → 调用API → 成功提示 → 关闭弹窗
|
||||
```
|
||||
|
||||
## 后端API要求
|
||||
|
||||
### 1. 获取可用时间段
|
||||
```
|
||||
GET /doctor.appointment/availableSlots
|
||||
参数: { doctor_id, date, period }
|
||||
返回: { slots: [{ time, available, quota }] }
|
||||
```
|
||||
|
||||
### 2. 创建预约
|
||||
```
|
||||
POST /doctor.appointment/create
|
||||
参数: {
|
||||
patient_id,
|
||||
doctor_id,
|
||||
appointment_date,
|
||||
period,
|
||||
appointment_time,
|
||||
appointment_type,
|
||||
remark
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 医生可用号源统计(建议新增)
|
||||
```
|
||||
GET /doctor.appointment/doctorAvailability
|
||||
参数: { doctor_id, date }
|
||||
返回: { available_count }
|
||||
```
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### doctor_appointment 表
|
||||
```sql
|
||||
- id: 主键
|
||||
- patient_id: 患者ID
|
||||
- doctor_id: 医生ID
|
||||
- roster_id: 排班ID
|
||||
- appointment_date: 预约日期
|
||||
- period: 时段(morning/afternoon)
|
||||
- appointment_time: 预约时间
|
||||
- appointment_type: 预约类型(video/text/phone)
|
||||
- status: 状态(1=已预约,2=已取消,3=已完成)
|
||||
- remark: 备注
|
||||
- create_time: 创建时间
|
||||
- update_time: 更新时间
|
||||
|
||||
唯一索引: (doctor_id, appointment_date, appointment_time, status)
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 在诊断列表中调用
|
||||
```vue
|
||||
<template>
|
||||
<!-- 挂号按钮 -->
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/guahao']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleAppointment(row)"
|
||||
>
|
||||
挂号
|
||||
</el-button>
|
||||
|
||||
<!-- 预约组件 -->
|
||||
<appointment-popup ref="appointmentRef" @success="getLists" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppointmentPopup from './appointment.vue'
|
||||
|
||||
const appointmentRef = ref()
|
||||
|
||||
const handleAppointment = (row) => {
|
||||
appointmentRef.value?.open(row)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 权限配置
|
||||
|
||||
需要在后端添加权限节点:
|
||||
- `tcm.diagnosis/guahao` - 挂号权限
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 功能测试
|
||||
- [ ] 医生列表正确加载
|
||||
- [ ] 日期选择器显示未来7天
|
||||
- [ ] 时间段根据医生和日期正确加载
|
||||
- [ ] 已占用时间段不可选择
|
||||
- [ ] 预约成功后列表刷新
|
||||
- [ ] 表单验证正确工作
|
||||
|
||||
### 边界测试
|
||||
- [ ] 医生无排班时的处理
|
||||
- [ ] 所有时间段已满时的提示
|
||||
- [ ] 网络错误时的错误处理
|
||||
- [ ] 并发预约的冲突处理
|
||||
|
||||
### UI测试
|
||||
- [ ] 响应式布局在不同屏幕尺寸下正常
|
||||
- [ ] 选中状态视觉反馈明显
|
||||
- [ ] 加载状态显示正确
|
||||
- [ ] 空状态提示友好
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### 功能增强
|
||||
1. **智能推荐**: 根据患者历史推荐医生
|
||||
2. **快速预约**: 一键预约最近可用时间
|
||||
3. **预约提醒**: 预约前发送提醒通知
|
||||
4. **预约改期**: 支持修改预约时间
|
||||
5. **候补机制**: 号源满时支持候补
|
||||
|
||||
### 性能优化
|
||||
1. **缓存策略**: 缓存医生列表和排班数据
|
||||
2. **懒加载**: 时间段按需加载
|
||||
3. **防抖处理**: 避免频繁请求
|
||||
|
||||
### 用户体验
|
||||
1. **日历视图**: 提供月历视图
|
||||
2. **收藏医生**: 快速访问常用医生
|
||||
3. **历史记录**: 显示患者预约历史
|
||||
4. **评价系统**: 就诊后评价医生
|
||||
|
||||
## 文档
|
||||
|
||||
- `APPOINTMENT_SYSTEM.md` - 完整系统设计文档
|
||||
- `APPOINTMENT_UI_UPDATE.md` - UI更新说明
|
||||
- 本文件 - 实现总结
|
||||
|
||||
## 状态
|
||||
|
||||
✅ 前端实现完成
|
||||
⏳ 等待后端API实现
|
||||
⏳ 等待集成测试
|
||||
|
||||
---
|
||||
|
||||
**创建时间**: 2024-02-26
|
||||
**最后更新**: 2024-02-26
|
||||
@@ -0,0 +1,342 @@
|
||||
# 患者挂号系统实现文档
|
||||
|
||||
## 功能概述
|
||||
|
||||
实现了基于医生排班的患者挂号系统,支持15分钟间隔的时间段预约,防止重复预约,并根据医生排班状态控制挂号可用性。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 时间段管理
|
||||
- 每个时间段间隔15分钟
|
||||
- 上午时段:08:00 - 12:00(16个时间段)
|
||||
- 下午时段:14:00 - 18:00(16个时间段)
|
||||
- 每个时间段只能被一个患者预约
|
||||
|
||||
### 2. 排班状态控制
|
||||
- 出诊(status=1):允许挂号
|
||||
- 停诊(status=2):不允许挂号
|
||||
- 休息(status=3):不允许挂号
|
||||
- 请假(status=4):不允许挂号
|
||||
|
||||
### 3. 号源管理
|
||||
- 医生排班时设置号源数(quota)
|
||||
- 已预约数量不能超过号源数
|
||||
- 实时显示可用/已占用状态
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
admin/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ └── doctor.ts # 新增挂号相关API
|
||||
│ └── views/
|
||||
│ ├── doctor/
|
||||
│ │ └── roster.vue # 医生排班管理(已存在)
|
||||
│ └── tcm/
|
||||
│ └── diagnosis/
|
||||
│ ├── index.vue # 诊断列表(已更新)
|
||||
│ └── appointment.vue # 挂号弹窗组件(新增)
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 1. 获取可用时间段
|
||||
```typescript
|
||||
GET /doctor.appointment/availableSlots
|
||||
参数:
|
||||
{
|
||||
doctor_id: number, // 医生ID
|
||||
date: string, // 日期 YYYY-MM-DD
|
||||
period: 'morning' | 'afternoon' // 时段
|
||||
}
|
||||
|
||||
返回:
|
||||
{
|
||||
slots: [
|
||||
{
|
||||
time: '08:00', // 时间
|
||||
available: true // 是否可预约
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建挂号
|
||||
```typescript
|
||||
POST /doctor.appointment/create
|
||||
参数:
|
||||
{
|
||||
patient_id: number, // 患者ID
|
||||
doctor_id: number, // 医生ID
|
||||
appointment_date: string, // 预约日期 YYYY-MM-DD
|
||||
period: 'morning' | 'afternoon', // 时段
|
||||
appointment_time: string, // 预约时间 HH:mm
|
||||
remark: string // 备注(可选)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 取消挂号
|
||||
```typescript
|
||||
POST /doctor.appointment/cancel
|
||||
参数:
|
||||
{
|
||||
id: number // 挂号记录ID
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 挂号列表
|
||||
```typescript
|
||||
GET /doctor.appointment/lists
|
||||
参数:
|
||||
{
|
||||
page_no: number,
|
||||
page_size: number,
|
||||
patient_id?: number,
|
||||
doctor_id?: number,
|
||||
date?: string
|
||||
}
|
||||
```
|
||||
|
||||
## 后端实现要点
|
||||
|
||||
### 1. 数据库表设计
|
||||
|
||||
```sql
|
||||
CREATE TABLE `doctor_appointment` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`patient_id` int(11) NOT NULL COMMENT '患者ID',
|
||||
`doctor_id` int(11) NOT NULL COMMENT '医生ID',
|
||||
`roster_id` int(11) NOT NULL COMMENT '排班ID',
|
||||
`appointment_date` date NOT NULL COMMENT '预约日期',
|
||||
`period` enum('morning','afternoon') NOT NULL COMMENT '时段',
|
||||
`appointment_time` time NOT NULL COMMENT '预约时间',
|
||||
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1=已预约,2=已取消,3=已完成',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_appointment` (`doctor_id`,`appointment_date`,`appointment_time`,`status`),
|
||||
KEY `idx_patient` (`patient_id`),
|
||||
KEY `idx_doctor_date` (`doctor_id`,`appointment_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生挂号表';
|
||||
```
|
||||
|
||||
### 2. 获取可用时间段逻辑
|
||||
|
||||
```php
|
||||
public function availableSlots()
|
||||
{
|
||||
$doctorId = $this->request->get('doctor_id');
|
||||
$date = $this->request->get('date');
|
||||
$period = $this->request->get('period');
|
||||
|
||||
// 1. 检查医生排班
|
||||
$roster = DoctorRoster::where([
|
||||
'doctor_id' => $doctorId,
|
||||
'date' => $date,
|
||||
'period' => $period
|
||||
])->find();
|
||||
|
||||
// 如果没有排班或状态不是出诊,返回空数组
|
||||
if (!$roster || $roster->status != 1) {
|
||||
return $this->success(['slots' => []]);
|
||||
}
|
||||
|
||||
// 2. 生成时间段
|
||||
$slots = [];
|
||||
if ($period == 'morning') {
|
||||
$startHour = 8;
|
||||
$endHour = 12;
|
||||
} else {
|
||||
$startHour = 14;
|
||||
$endHour = 18;
|
||||
}
|
||||
|
||||
for ($hour = $startHour; $hour < $endHour; $hour++) {
|
||||
for ($minute = 0; $minute < 60; $minute += 15) {
|
||||
$time = sprintf('%02d:%02d', $hour, $minute);
|
||||
$slots[] = [
|
||||
'time' => $time,
|
||||
'available' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查询已预约的时间段
|
||||
$appointments = DoctorAppointment::where([
|
||||
'doctor_id' => $doctorId,
|
||||
'appointment_date' => $date,
|
||||
'period' => $period,
|
||||
'status' => 1 // 只查询有效预约
|
||||
])->column('appointment_time');
|
||||
|
||||
// 4. 标记已占用的时间段
|
||||
foreach ($slots as &$slot) {
|
||||
if (in_array($slot['time'], $appointments)) {
|
||||
$slot['available'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 检查号源限制
|
||||
$appointmentCount = count($appointments);
|
||||
if ($appointmentCount >= $roster->quota) {
|
||||
// 如果已达到号源上限,所有未预约的时间段也标记为不可用
|
||||
foreach ($slots as &$slot) {
|
||||
if ($slot['available']) {
|
||||
$slot['available'] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['slots' => $slots]);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建挂号逻辑
|
||||
|
||||
```php
|
||||
public function create()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
|
||||
// 1. 验证参数
|
||||
$validate = Validate::rule([
|
||||
'patient_id|患者ID' => 'require|integer',
|
||||
'doctor_id|医生ID' => 'require|integer',
|
||||
'appointment_date|预约日期' => 'require|date',
|
||||
'period|时段' => 'require|in:morning,afternoon',
|
||||
'appointment_time|预约时间' => 'require'
|
||||
]);
|
||||
|
||||
if (!$validate->check($data)) {
|
||||
return $this->fail($validate->getError());
|
||||
}
|
||||
|
||||
// 2. 检查医生排班
|
||||
$roster = DoctorRoster::where([
|
||||
'doctor_id' => $data['doctor_id'],
|
||||
'date' => $data['appointment_date'],
|
||||
'period' => $data['period']
|
||||
])->find();
|
||||
|
||||
if (!$roster) {
|
||||
return $this->fail('该医生当天未排班');
|
||||
}
|
||||
|
||||
if ($roster->status != 1) {
|
||||
return $this->fail('该医生当天不出诊');
|
||||
}
|
||||
|
||||
// 3. 检查时间段是否已被预约
|
||||
$exists = DoctorAppointment::where([
|
||||
'doctor_id' => $data['doctor_id'],
|
||||
'appointment_date' => $data['appointment_date'],
|
||||
'appointment_time' => $data['appointment_time'],
|
||||
'status' => 1
|
||||
])->find();
|
||||
|
||||
if ($exists) {
|
||||
return $this->fail('该时间段已被预约');
|
||||
}
|
||||
|
||||
// 4. 检查号源是否已满
|
||||
$appointmentCount = DoctorAppointment::where([
|
||||
'doctor_id' => $data['doctor_id'],
|
||||
'appointment_date' => $data['appointment_date'],
|
||||
'period' => $data['period'],
|
||||
'status' => 1
|
||||
])->count();
|
||||
|
||||
if ($appointmentCount >= $roster->quota) {
|
||||
return $this->fail('该时段号源已满');
|
||||
}
|
||||
|
||||
// 5. 创建挂号记录
|
||||
$appointment = DoctorAppointment::create([
|
||||
'patient_id' => $data['patient_id'],
|
||||
'doctor_id' => $data['doctor_id'],
|
||||
'roster_id' => $roster->id,
|
||||
'appointment_date' => $data['appointment_date'],
|
||||
'period' => $data['period'],
|
||||
'appointment_time' => $data['appointment_time'],
|
||||
'remark' => $data['remark'] ?? '',
|
||||
'status' => 1
|
||||
]);
|
||||
|
||||
return $this->success('挂号成功', $appointment);
|
||||
}
|
||||
```
|
||||
|
||||
## 前端组件说明
|
||||
|
||||
### appointment.vue 组件
|
||||
|
||||
挂号弹窗组件,包含以下功能:
|
||||
|
||||
1. **医生选择**:从医生列表中选择
|
||||
2. **日期选择**:不能选择过去的日期
|
||||
3. **时段选择**:上午/下午
|
||||
4. **时间段选择**:
|
||||
- 网格布局显示所有时间段
|
||||
- 可用时间段:蓝色,可点击
|
||||
- 已占用时间段:灰色,不可点击
|
||||
- 已选择时间段:深蓝色高亮
|
||||
5. **备注输入**:可选的备注信息
|
||||
|
||||
### 使用方式
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<appointment-popup ref="appointmentRef" @success="handleSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppointmentPopup from './appointment.vue'
|
||||
|
||||
const appointmentRef = ref()
|
||||
|
||||
const handleAppointment = (patient) => {
|
||||
appointmentRef.value?.open(patient)
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
// 挂号成功后的处理
|
||||
console.log('挂号成功')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 权限控制
|
||||
|
||||
在诊断列表中,挂号按钮使用权限指令:
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/guahao']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleAppointment(row)"
|
||||
>
|
||||
挂号
|
||||
</el-button>
|
||||
```
|
||||
|
||||
需要在后端权限系统中添加对应的权限节点。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **时区处理**:确保前后端时间格式一致
|
||||
2. **并发控制**:使用数据库唯一索引防止重复预约
|
||||
3. **事务处理**:创建挂号时使用事务确保数据一致性
|
||||
4. **缓存策略**:可以对排班数据进行缓存提高性能
|
||||
5. **通知机制**:挂号成功后可以发送短信/消息通知
|
||||
|
||||
## 扩展功能建议
|
||||
|
||||
1. **预约提醒**:在预约时间前发送提醒
|
||||
2. **候补机制**:号源满时支持候补排队
|
||||
3. **预约改期**:支持修改预约时间
|
||||
4. **统计报表**:医生预约统计、患者预约历史
|
||||
5. **评价系统**:就诊后患者评价医生
|
||||
@@ -0,0 +1,213 @@
|
||||
# 预约问诊界面更新说明
|
||||
|
||||
## 更新内容
|
||||
|
||||
根据UI设计图更新了预约问诊组件,使其更符合实际业务需求。
|
||||
|
||||
## 新增功能
|
||||
|
||||
### 1. 预约方式选择
|
||||
- **选择时段预约有专医生**:先选时间段,系统推荐有空的医生
|
||||
- **选择医生预约有专时段**:先选医生,显示该医生的可用时间段
|
||||
|
||||
### 2. 预约类型
|
||||
- 视频问诊(可扩展:图文问诊、电话问诊等)
|
||||
|
||||
### 3. 患者信息
|
||||
- 显示当前患者姓名
|
||||
- 显示上次就诊记录
|
||||
|
||||
### 4. 医生选择优化
|
||||
- 单选按钮形式选择医生
|
||||
- 每个医生名称后显示可用号源数量标签
|
||||
- 有号源:绿色标签显示数字
|
||||
- 无号源:灰色标签显示"无"
|
||||
|
||||
### 5. 日期选择优化
|
||||
- 横向按钮式布局
|
||||
- 显示未来7天
|
||||
- 格式:02月26日 (四)
|
||||
- 选中状态:蓝色按钮
|
||||
|
||||
### 6. 时间段显示优化
|
||||
- 4列网格布局
|
||||
- 每个时间段包含:
|
||||
- 时间(如:09:00-09:30)
|
||||
- 可用号源数量标签
|
||||
- 状态样式:
|
||||
- 可预约:白色背景 + 蓝色数字标签
|
||||
- 不可预约:灰色背景 + 灰色"无"标签
|
||||
- 已选择:蓝色背景 + 白色文字
|
||||
|
||||
## 界面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 预约问诊 [X] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 上次就诊: 无就诊记录 │
|
||||
│ │
|
||||
│ 预约方式: ○ 选择时段预约有专医生 │
|
||||
│ ● 选择医生预约有专时段 │
|
||||
│ │
|
||||
│ 预约类型: ● 视频问诊 │
|
||||
│ │
|
||||
│ 选择患者: ● 刘炳希 │
|
||||
│ │
|
||||
│ 预约医生: ● 樊平 [7] ○ 樊雪芹 [7] ○ 刘文英 [7] │
|
||||
│ ○ 王西诗 [7] ○ 吉红梅 [7] ○ 李世伟 │
|
||||
│ ○ 李新星 [7] ○ 喻小勇 │
|
||||
│ │
|
||||
│ 预约时间: │
|
||||
│ [02月26日(四)] [02月27日(五)] [02月28日(六)] │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │09:00-09:30│ │09:30-10:00│ │10:00-10:30│ │10:30-11:00│ │
|
||||
│ │ 无 │ │ 无 │ │ 无 │ │ 无 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │13:00-13:30│ │13:30-14:00│ │14:00-14:30│ │14:30-15:00│ │
|
||||
│ │ 无 │ │ 无 │ │ 无 │ │ 无 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │15:00-15:30│ │15:30-16:00│ │16:00-16:30│ │16:30-17:00│ │
|
||||
│ │ 无 │ │ 无 │ │ 无 │ │ 无 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │17:00-17:30│ │17:30-18:00│ │18:00-18:30│ │
|
||||
│ │ 无 │ │ 无 │ │ 无 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │19:00-19:30│ │19:30-20:00│ │20:00-20:30│ │
|
||||
│ │ 无 │ │ 无 │ │ 6 │ ← 选中 │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ 备注: _______________________________________________ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ [取消] [确定] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 技术实现要点
|
||||
|
||||
### 1. 时间段生成
|
||||
```typescript
|
||||
// 生成30分钟间隔的时间段
|
||||
const generateTimeSlots = () => {
|
||||
const slots = []
|
||||
// 上午: 09:00-12:00
|
||||
for (let hour = 9; hour < 12; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
slots.push({
|
||||
time: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`,
|
||||
period: 'morning'
|
||||
})
|
||||
}
|
||||
}
|
||||
// 下午: 13:00-20:30
|
||||
for (let hour = 13; hour <= 20; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
if (hour === 20 && minute > 30) break
|
||||
slots.push({
|
||||
time: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`,
|
||||
period: 'afternoon'
|
||||
})
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 医生可用号源统计
|
||||
```typescript
|
||||
// 获取医生在指定日期的可用号源总数
|
||||
const getDoctorAvailability = async (doctorId: number, date: string) => {
|
||||
const morningSlots = await getAvailableSlots({
|
||||
doctor_id: doctorId,
|
||||
date: date,
|
||||
period: 'morning'
|
||||
})
|
||||
|
||||
const afternoonSlots = await getAvailableSlots({
|
||||
doctor_id: doctorId,
|
||||
date: date,
|
||||
period: 'afternoon'
|
||||
})
|
||||
|
||||
const totalAvailable = [
|
||||
...(morningSlots?.slots || []),
|
||||
...(afternoonSlots?.slots || [])
|
||||
].filter(slot => slot.available && slot.quota > 0).length
|
||||
|
||||
return totalAvailable
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 响应式布局
|
||||
```scss
|
||||
.time-slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.time-slots-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 后端API需要返回的数据
|
||||
|
||||
### 1. 可用时间段接口增强
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"time": "09:00",
|
||||
"available": true,
|
||||
"quota": 5, // 剩余号源数
|
||||
"total": 10 // 总号源数
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 医生可用号源统计接口(新增)
|
||||
```
|
||||
GET /doctor.appointment/doctorAvailability
|
||||
参数:
|
||||
{
|
||||
"doctor_id": 1,
|
||||
"date": "2024-02-26"
|
||||
}
|
||||
|
||||
返回:
|
||||
{
|
||||
"available_count": 7, // 可用号源总数
|
||||
"total_count": 20 // 总号源数
|
||||
}
|
||||
```
|
||||
|
||||
## 用户体验优化
|
||||
|
||||
1. **加载状态**:在获取数据时显示加载动画
|
||||
2. **空状态提示**:当没有可用时间段时显示友好提示
|
||||
3. **实时更新**:选择医生或日期后立即加载时间段
|
||||
4. **视觉反馈**:
|
||||
- 可点击元素有hover效果
|
||||
- 选中状态明显区分
|
||||
- 不可用状态灰色显示
|
||||
5. **错误处理**:网络错误时显示错误提示
|
||||
|
||||
## 扩展建议
|
||||
|
||||
1. **智能推荐**:根据患者历史就诊记录推荐医生
|
||||
2. **快速预约**:一键预约最近可用时间
|
||||
3. **批量查看**:同时查看多个医生的可用时间
|
||||
4. **日历视图**:提供月历视图方便选择日期
|
||||
5. **收藏医生**:支持收藏常用医生快速预约
|
||||
@@ -0,0 +1,194 @@
|
||||
# 批量排班示例
|
||||
|
||||
## 示例1:为新医生创建一周排班
|
||||
|
||||
### 场景
|
||||
新入职医生张医生,需要为其创建下周的工作日排班。
|
||||
|
||||
### 操作步骤
|
||||
1. 选择医生:张医生
|
||||
2. 日期范围:2024-03-04 至 2024-03-10
|
||||
3. 时段:上午、下午
|
||||
4. 星期:周一、周二、周三、周四、周五
|
||||
5. 状态:出诊
|
||||
6. 号源数:20
|
||||
7. 最大接诊数:30
|
||||
|
||||
### 生成结果
|
||||
- 周一上午、下午:2条
|
||||
- 周二上午、下午:2条
|
||||
- 周三上午、下午:2条
|
||||
- 周四上午、下午:2条
|
||||
- 周五上午、下午:2条
|
||||
- 总计:10条排班记录
|
||||
|
||||
## 示例2:批量设置周末休息
|
||||
|
||||
### 场景
|
||||
为所有医生设置本月所有周末为休息日。
|
||||
|
||||
### 操作步骤
|
||||
1. 选择医生:全选(假设3位医生)
|
||||
2. 日期范围:2024-03-01 至 2024-03-31
|
||||
3. 时段:上午、下午
|
||||
4. 星期:周六、周日
|
||||
5. 状态:休息
|
||||
|
||||
### 生成结果
|
||||
- 3月份共有4个周末(8天)
|
||||
- 3位医生 × 8天 × 2时段 = 48条记录
|
||||
|
||||
## 示例3:节假日调整
|
||||
|
||||
### 场景
|
||||
清明节假期(4月4日-4月6日),所有医生休息。
|
||||
|
||||
### 操作步骤
|
||||
1. 选择医生:全选
|
||||
2. 日期范围:2024-04-04 至 2024-04-06
|
||||
3. 时段:上午、下午
|
||||
4. 星期:全选(因为是连续假期)
|
||||
5. 状态:休息
|
||||
6. 备注:清明节假期
|
||||
|
||||
### 生成结果
|
||||
- 假设5位医生
|
||||
- 5位医生 × 3天 × 2时段 = 30条记录
|
||||
|
||||
## 示例4:临时停诊
|
||||
|
||||
### 场景
|
||||
医院装修,某科室所有医生本周三停诊。
|
||||
|
||||
### 操作步骤
|
||||
1. 选择医生:该科室所有医生(假设4位)
|
||||
2. 日期范围:2024-03-06 至 2024-03-06(单天)
|
||||
3. 时段:上午、下午
|
||||
4. 星期:周三
|
||||
5. 状态:停诊
|
||||
6. 备注:科室装修
|
||||
|
||||
### 生成结果
|
||||
- 4位医生 × 1天 × 2时段 = 8条记录
|
||||
|
||||
## 示例5:月度排班
|
||||
|
||||
### 场景
|
||||
为下个月创建完整的排班计划。
|
||||
|
||||
### 第一步:工作日出诊
|
||||
1. 选择医生:全选(10位医生)
|
||||
2. 日期范围:2024-04-01 至 2024-04-30
|
||||
3. 时段:上午、下午
|
||||
4. 星期:周一至周五
|
||||
5. 状态:出诊
|
||||
6. 号源数:20
|
||||
|
||||
结果:10 × 22工作日 × 2时段 = 440条记录
|
||||
|
||||
### 第二步:周末休息
|
||||
1. 选择医生:全选(10位医生)
|
||||
2. 日期范围:2024-04-01 至 2024-04-30
|
||||
3. 时段:上午、下午
|
||||
4. 星期:周六、周日
|
||||
5. 状态:休息
|
||||
|
||||
结果:10 × 8天 × 2时段 = 160条记录
|
||||
|
||||
### 总计
|
||||
600条排班记录,覆盖整个月。
|
||||
|
||||
## 示例6:特殊排班
|
||||
|
||||
### 场景
|
||||
某医生只在周一、周三、周五上午出诊。
|
||||
|
||||
### 操作步骤
|
||||
1. 选择医生:该医生
|
||||
2. 日期范围:2024-03-04 至 2024-03-31
|
||||
3. 时段:上午
|
||||
4. 星期:周一、周三、周五
|
||||
5. 状态:出诊
|
||||
6. 号源数:15
|
||||
|
||||
### 生成结果
|
||||
- 3月份周一、周三、周五共约12天
|
||||
- 1位医生 × 12天 × 1时段 = 12条记录
|
||||
|
||||
## API 调用示例
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
POST /adminapi/doctor.roster/batchSave
|
||||
|
||||
{
|
||||
"rosters": [
|
||||
{
|
||||
"doctor_id": 1,
|
||||
"date": "2024-03-04",
|
||||
"period": "morning",
|
||||
"status": 1,
|
||||
"quota": 20,
|
||||
"max_patients": 30,
|
||||
"remark": "工作日排班"
|
||||
},
|
||||
{
|
||||
"doctor_id": 1,
|
||||
"date": "2024-03-04",
|
||||
"period": "afternoon",
|
||||
"status": 1,
|
||||
"quota": 20,
|
||||
"max_patients": 30,
|
||||
"remark": "工作日排班"
|
||||
},
|
||||
// ... 更多记录
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "批量保存成功",
|
||||
"data": {
|
||||
"success_count": 10,
|
||||
"failed_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **性能优化**:一次批量操作建议不超过100条记录
|
||||
2. **数据验证**:确保所有必填字段都已填写
|
||||
3. **重复处理**:已存在的排班会被更新,不会重复创建
|
||||
4. **事务处理**:批量操作使用事务,要么全部成功,要么全部失败
|
||||
5. **权限检查**:确保当前用户有批量排班权限
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 错误1:没有生成任何记录
|
||||
|
||||
原因:选择的星期与日期范围不匹配
|
||||
解决:检查日期范围内是否包含选中的星期
|
||||
|
||||
### 错误2:记录数量不符合预期
|
||||
|
||||
原因:计算错误或日期范围理解错误
|
||||
解决:使用公式验证:医生数 × 符合条件的天数 × 时段数
|
||||
|
||||
### 错误3:批量保存失败
|
||||
|
||||
原因:数据验证失败或数据库错误
|
||||
解决:检查日志,确认数据格式正确
|
||||
|
||||
## 最佳实践建议
|
||||
|
||||
1. **分批操作**:大量排班分多次批量操作
|
||||
2. **先测试**:先用少量数据测试,确认无误后再大批量操作
|
||||
3. **备份数据**:重要操作前备份数据库
|
||||
4. **逐步完善**:先创建基础排班,再根据实际情况调整
|
||||
5. **定期检查**:定期检查排班数据的准确性
|
||||
@@ -0,0 +1,214 @@
|
||||
# 批量排班功能使用指南
|
||||
|
||||
## 功能概述
|
||||
|
||||
批量排班功能允许管理员一次性为多个医生、多个日期、多个时段创建排班记录,大大提高排班效率。
|
||||
|
||||
## 使用场景
|
||||
|
||||
1. 新周期排班:为下周或下月批量创建排班
|
||||
2. 多医生排班:为多个医生同时设置相同的排班规则
|
||||
3. 节假日调整:批量设置节假日休息
|
||||
4. 临时调整:批量修改某段时间的排班状态
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. 打开批量排班弹窗
|
||||
|
||||
点击排班管理页面右上角的"批量排班"按钮。
|
||||
|
||||
### 2. 选择医生
|
||||
|
||||
- 可以选择一个或多个医生
|
||||
- 支持搜索医生姓名
|
||||
- 必填项
|
||||
|
||||
### 3. 选择日期范围
|
||||
|
||||
- 选择开始日期和结束日期
|
||||
- 系统会在这个范围内生成排班
|
||||
- 必填项
|
||||
|
||||
### 4. 选择时段
|
||||
|
||||
- 上午:morning
|
||||
- 下午:afternoon
|
||||
- 可以同时选择两个时段
|
||||
- 必填项
|
||||
|
||||
### 5. 选择星期
|
||||
|
||||
- 周一至周日可多选
|
||||
- 只有选中的星期才会生成排班
|
||||
- 例如:只选周一至周五,则周末不会生成排班
|
||||
- 必填项
|
||||
|
||||
### 6. 设置排班状态
|
||||
|
||||
- 出诊:正常出诊,需要设置号源数
|
||||
- 停诊:临时停诊
|
||||
- 休息:正常休息日
|
||||
- 请假:医生请假
|
||||
|
||||
### 7. 设置号源数(出诊时)
|
||||
|
||||
- 号源数:可预约的号源数量
|
||||
- 最大接诊数:最多可接诊的患者数
|
||||
- 只有状态为"出诊"时才需要设置
|
||||
|
||||
### 8. 添加备注(可选)
|
||||
|
||||
可以为这批排班添加统一的备注信息。
|
||||
|
||||
### 9. 确认创建
|
||||
|
||||
点击"确定"按钮,系统会自动生成所有符合条件的排班记录。
|
||||
|
||||
## 计算规则
|
||||
|
||||
生成的排班记录数 = 医生数 × 符合条件的日期数 × 时段数
|
||||
|
||||
### 示例1:工作日排班
|
||||
|
||||
- 医生:张医生、李医生(2人)
|
||||
- 日期:2024-03-04 至 2024-03-10(7天)
|
||||
- 时段:上午、下午(2个)
|
||||
- 星期:周一至周五(5天)
|
||||
- 结果:2 × 5 × 2 = 20条记录
|
||||
|
||||
### 示例2:单医生全周排班
|
||||
|
||||
- 医生:张医生(1人)
|
||||
- 日期:2024-03-04 至 2024-03-10(7天)
|
||||
- 时段:上午(1个)
|
||||
- 星期:周一至周日(7天)
|
||||
- 结果:1 × 7 × 1 = 7条记录
|
||||
|
||||
### 示例3:周末休息
|
||||
|
||||
- 医生:张医生、李医生、王医生(3人)
|
||||
- 日期:2024-03-09 至 2024-03-10(2天)
|
||||
- 时段:上午、下午(2个)
|
||||
- 星期:周六、周日(2天)
|
||||
- 状态:休息
|
||||
- 结果:3 × 2 × 2 = 12条记录
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 重复排班处理
|
||||
|
||||
如果某个医生在某天某时段已有排班,批量操作会更新该排班记录,而不是创建新记录。
|
||||
|
||||
### 2. 数据验证
|
||||
|
||||
- 所有必填项必须填写
|
||||
- 日期范围不能为空
|
||||
- 至少选择一个时段
|
||||
- 至少选择一个星期
|
||||
|
||||
### 3. 性能考虑
|
||||
|
||||
- 一次批量操作建议不超过100条记录
|
||||
- 如需创建大量排班,建议分批操作
|
||||
- 操作过程中请勿关闭页面
|
||||
|
||||
### 4. 权限要求
|
||||
|
||||
需要有 `doctor.roster/batchSave` 权限才能使用批量排班功能。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 批量排班后发现错误怎么办?
|
||||
|
||||
A: 可以通过以下方式修改:
|
||||
1. 单个修改:点击对应的排班单元格进行修改
|
||||
2. 批量删除:暂不支持,需要逐个删除
|
||||
3. 重新批量:再次批量排班会覆盖已有的排班
|
||||
|
||||
### Q2: 如何快速设置工作日排班?
|
||||
|
||||
A:
|
||||
1. 选择所有需要排班的医生
|
||||
2. 选择日期范围(如一个月)
|
||||
3. 选择上午和下午
|
||||
4. 只选择周一至周五
|
||||
5. 设置为出诊状态
|
||||
|
||||
### Q3: 如何批量设置节假日休息?
|
||||
|
||||
A:
|
||||
1. 选择所有医生
|
||||
2. 选择节假日日期范围
|
||||
3. 选择上午和下午
|
||||
4. 选择所有星期
|
||||
5. 设置为休息状态
|
||||
|
||||
### Q4: 批量排班会覆盖已有排班吗?
|
||||
|
||||
A: 是的,如果某个时段已有排班,批量操作会更新该排班的状态和号源数。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 月度排班流程
|
||||
|
||||
1. 每月月底为下月创建基础排班
|
||||
2. 使用批量排班设置工作日出诊
|
||||
3. 使用批量排班设置周末休息
|
||||
4. 根据实际情况单独调整特殊日期
|
||||
|
||||
### 2. 新医生入职
|
||||
|
||||
1. 在管理员表中添加医生账号(role_id=1)
|
||||
2. 使用批量排班为新医生创建排班
|
||||
3. 根据医生专长调整排班时段
|
||||
|
||||
### 3. 临时调整
|
||||
|
||||
1. 医生请假:单独修改对应日期的排班状态
|
||||
2. 临时加班:单独添加额外的排班时段
|
||||
3. 节假日调整:使用批量排班统一设置
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 前端逻辑
|
||||
|
||||
```typescript
|
||||
// 生成排班数据
|
||||
const rosters: any[] = []
|
||||
const startDate = dayjs(batchForm.value.dateRange[0])
|
||||
const endDate = dayjs(batchForm.value.dateRange[1])
|
||||
|
||||
// 遍历日期范围
|
||||
let currentDate = startDate
|
||||
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
|
||||
const weekday = currentDate.day() // 0-6,0是周日
|
||||
|
||||
// 检查是否在选中的星期内
|
||||
if (batchForm.value.weekdays.includes(weekday)) {
|
||||
// 遍历医生
|
||||
batchForm.value.doctorIds.forEach(doctorId => {
|
||||
// 遍历时段
|
||||
batchForm.value.periods.forEach(period => {
|
||||
rosters.push({
|
||||
doctor_id: doctorId,
|
||||
date: currentDate.format('YYYY-MM-DD'),
|
||||
period: period,
|
||||
status: batchForm.value.status,
|
||||
quota: batchForm.value.quota,
|
||||
max_patients: batchForm.value.maxPatients
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
currentDate = currentDate.add(1, 'day')
|
||||
}
|
||||
```
|
||||
|
||||
### 后端处理
|
||||
|
||||
后端使用事务处理批量插入,如果某条记录已存在(根据唯一索引),则更新该记录。
|
||||
|
||||
## 更新日志
|
||||
|
||||
- 2024-03-02: 初始版本,支持基本的批量排班功能
|
||||
@@ -0,0 +1,86 @@
|
||||
# TUICallKit 升级指南
|
||||
|
||||
## 版本升级
|
||||
|
||||
从 `2.5.2` 升级到 `4.0.12`
|
||||
|
||||
## 升级步骤
|
||||
|
||||
### 1. 删除旧的依赖
|
||||
```bash
|
||||
cd admin
|
||||
rm -rf node_modules
|
||||
rm package-lock.json
|
||||
```
|
||||
|
||||
### 2. 安装新版本
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
或者直接更新单个包:
|
||||
```bash
|
||||
npm install @tencentcloud/call-uikit-vue@4.0.12
|
||||
```
|
||||
|
||||
## 可能的破坏性变更
|
||||
|
||||
从 2.x 升级到 4.x 可能存在 API 变更,需要检查以下文件:
|
||||
|
||||
### 需要检查的文件
|
||||
1. `admin/src/components/video-call/index.vue` - 视频通话组件
|
||||
2. `admin/src/main.ts` - TUICallKit 初始化代码
|
||||
|
||||
### 常见变更点
|
||||
|
||||
#### 1. 导入方式
|
||||
```typescript
|
||||
// 旧版本 (2.x)
|
||||
import { TUICallKit } from '@tencentcloud/call-uikit-vue'
|
||||
|
||||
// 新版本 (4.x) - 可能保持不变或有调整
|
||||
import { TUICallKit } from '@tencentcloud/call-uikit-vue'
|
||||
```
|
||||
|
||||
#### 2. 初始化方法
|
||||
检查 `TUICallKit.init()` 的参数是否有变化
|
||||
|
||||
#### 3. 组件属性
|
||||
检查组件的 props 是否有变更
|
||||
|
||||
#### 4. 事件监听
|
||||
检查事件名称和回调参数是否有变化
|
||||
|
||||
## 升级后测试清单
|
||||
|
||||
- [ ] 视频通话功能正常启动
|
||||
- [ ] 音频通话功能正常
|
||||
- [ ] 视频通话功能正常
|
||||
- [ ] 通话邀请功能正常
|
||||
- [ ] 通话接听功能正常
|
||||
- [ ] 通话挂断功能正常
|
||||
- [ ] 摄像头切换功能正常
|
||||
- [ ] 麦克风切换功能正常
|
||||
- [ ] 扬声器切换功能正常
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [TUICallKit 官方文档](https://www.tencentcloud.com/document/product/647/50993)
|
||||
- [TUICallKit GitHub](https://github.com/tencentyun/TUICallKit)
|
||||
- [TUICallKit NPM](https://www.npmjs.com/package/@tencentcloud/call-uikit-vue)
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果升级后出现问题,可以回滚到旧版本:
|
||||
|
||||
```bash
|
||||
npm install @tencentcloud/call-uikit-vue@2.5.2
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 升级前建议备份当前代码
|
||||
2. 在开发环境充分测试后再部署到生产环境
|
||||
3. 查看官方 CHANGELOG 了解详细变更
|
||||
4. 如遇到问题,查看官方文档或 GitHub Issues
|
||||
np
|
||||
@@ -0,0 +1,251 @@
|
||||
# 关键修复:TUICallKit 初始化问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
在使用 TUICallKit 组件时,出现以下错误:
|
||||
|
||||
```
|
||||
API<getDeviceList>: init or login is not complete
|
||||
TUICallEngine 初始化登录未完成
|
||||
<ERROR_INIT_FAIL: -1201>
|
||||
```
|
||||
|
||||
## 根本原因
|
||||
|
||||
TUICallKit 组件在挂载(mount)时会立即调用内部 API(如 `getDeviceList`),但此时 `TUICallKitServer.init()` 可能还没有完全完成初始化。
|
||||
|
||||
即使我们在代码中等待了 `await TUICallKitServer.init()`,组件内部的初始化仍需要额外的时间。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案 1: 增加等待时间(当前采用)
|
||||
|
||||
```typescript
|
||||
// 1. 调用 init
|
||||
await TUICallKitServer.init({
|
||||
userID: res.userId,
|
||||
userSig: res.userSig,
|
||||
SDKAppID: res.sdkAppId
|
||||
})
|
||||
|
||||
// 2. 等待 2 秒确保初始化完全完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 3. 显示组件
|
||||
isInitialized.value = true
|
||||
|
||||
// 4. 等待 DOM 更新
|
||||
await nextTick()
|
||||
|
||||
// 5. 再等待 500ms
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 6. 发起通话
|
||||
await TUICallKitServer.call({ ... })
|
||||
```
|
||||
|
||||
### 方案 2: 不使用 TUICallKit 组件(备选)
|
||||
|
||||
如果方案 1 仍然有问题,可以考虑不使用 TUICallKit 组件,而是使用 div 容器:
|
||||
|
||||
```vue
|
||||
<!-- 不使用组件 -->
|
||||
<div v-else id="TUICallKit" class="tui-call-kit"></div>
|
||||
```
|
||||
|
||||
然后 TUICallKit 会自动将 UI 渲染到这个 div 中。
|
||||
|
||||
## 当前实现
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
const startCall = async () => {
|
||||
try {
|
||||
initializing.value = true
|
||||
statusText.value = '正在获取签名...'
|
||||
|
||||
// 1. 获取签名
|
||||
const res = await getCallSignature({ ... })
|
||||
|
||||
statusText.value = '正在初始化通话组件...'
|
||||
|
||||
// 2. 初始化
|
||||
await TUICallKitServer.init({
|
||||
userID: res.userId,
|
||||
userSig: res.userSig,
|
||||
SDKAppID: res.sdkAppId
|
||||
})
|
||||
|
||||
statusText.value = '等待初始化完成...'
|
||||
|
||||
// 3. 等待 2 秒(关键!)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
statusText.value = '初始化完成,准备发起通话...'
|
||||
|
||||
// 4. 显示组件
|
||||
isInitialized.value = true
|
||||
calling.value = true
|
||||
|
||||
// 5. 等待 DOM 更新
|
||||
await nextTick()
|
||||
|
||||
// 6. 再等待 500ms
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 7. 发起通话
|
||||
await TUICallKitServer.call({
|
||||
userID: callInfo.value.userId,
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
feedback.msgSuccess('通话已发起,等待对方接听...')
|
||||
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模板代码
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="video-call-container">
|
||||
<!-- 初始化前显示等待界面 -->
|
||||
<div v-if="!isInitialized" class="call-waiting">
|
||||
<el-icon class="loading-icon"><VideoCamera /></el-icon>
|
||||
<p>{{ statusText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 初始化完成后显示容器 -->
|
||||
<div v-else id="TUICallKit" class="tui-call-kit"></div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 时间线
|
||||
|
||||
```
|
||||
0ms 用户点击"开始通话"
|
||||
↓
|
||||
100ms 开始获取签名
|
||||
↓
|
||||
500ms 签名获取完成
|
||||
↓
|
||||
600ms 调用 TUICallKitServer.init()
|
||||
↓
|
||||
1000ms init() Promise resolved
|
||||
↓
|
||||
3000ms 等待 2000ms 完成(确保内部初始化完成)
|
||||
↓
|
||||
3000ms 设置 isInitialized = true
|
||||
↓
|
||||
3000ms Vue 开始渲染容器
|
||||
↓
|
||||
3010ms nextTick() 完成
|
||||
↓
|
||||
3510ms 等待 500ms 完成
|
||||
↓
|
||||
3510ms 调用 TUICallKitServer.call()
|
||||
↓
|
||||
3600ms 通话发起成功
|
||||
```
|
||||
|
||||
## 为什么需要这么长的等待时间?
|
||||
|
||||
1. **TUICallKitServer.init() 的异步性**
|
||||
- 虽然 Promise resolved,但内部可能还有异步操作
|
||||
- 需要初始化 WebRTC、设备检测等
|
||||
|
||||
2. **设备枚举需要时间**
|
||||
- `getDeviceList` 需要访问摄像头和麦克风
|
||||
- 浏览器需要时间来枚举设备
|
||||
|
||||
3. **权限请求**
|
||||
- 如果是首次访问,需要用户授权
|
||||
- 授权过程是异步的
|
||||
|
||||
4. **组件渲染**
|
||||
- Vue 的响应式更新需要时间
|
||||
- DOM 操作需要时间
|
||||
|
||||
## 调试建议
|
||||
|
||||
### 1. 查看控制台日志
|
||||
|
||||
```typescript
|
||||
console.log('1. 签名获取成功')
|
||||
console.log('2. TUICallKit init 方法调用完成')
|
||||
console.log('3. 等待完成,准备显示通话界面')
|
||||
console.log('4. 准备发起通话')
|
||||
console.log('5. 通话已发起')
|
||||
```
|
||||
|
||||
### 2. 检查初始化状态
|
||||
|
||||
在浏览器控制台执行:
|
||||
|
||||
```javascript
|
||||
// 检查 TUICallKitServer 状态
|
||||
console.log(window.TUICallKitServer)
|
||||
```
|
||||
|
||||
### 3. 监控设备访问
|
||||
|
||||
打开浏览器开发者工具 → Console,查看是否有设备访问相关的日志。
|
||||
|
||||
## 如果仍然失败
|
||||
|
||||
### 增加等待时间
|
||||
|
||||
如果 2 秒还不够,可以增加到 3 秒:
|
||||
|
||||
```typescript
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
```
|
||||
|
||||
### 使用轮询检查
|
||||
|
||||
```typescript
|
||||
// 轮询检查初始化状态
|
||||
let retries = 0
|
||||
const maxRetries = 20
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
// 尝试调用一个需要初始化的 API
|
||||
await TUICallKitServer.call({ ... })
|
||||
break
|
||||
} catch (error) {
|
||||
if (error.code === -1201) {
|
||||
// 还没初始化完成,继续等待
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
retries++
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 联系技术支持
|
||||
|
||||
如果以上方法都不行,可能需要:
|
||||
1. 检查腾讯云 TRTC 配置
|
||||
2. 检查网络连接
|
||||
3. 查看腾讯云控制台的错误日志
|
||||
4. 联系腾讯云技术支持
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [TUICallKit 初始化问题](https://cloud.tencent.com/document/product/647/78769#3a61f42b-e06f-49af-88bf-362d40025887)
|
||||
- [初始化流程详解](./INITIALIZATION_GUIDE.md)
|
||||
- [故障排查指南](./TROUBLESHOOTING.md)
|
||||
|
||||
## 更新日志
|
||||
|
||||
- 2024-03-02: 初始版本,等待时间 500ms + 300ms
|
||||
- 2024-03-02: 增加等待时间到 2000ms + 500ms
|
||||
- 2024-03-02: 添加 nextTick() 确保 DOM 更新
|
||||
- 2024-03-02: 改用 div 容器而不是 TUICallKit 组件
|
||||
@@ -0,0 +1,404 @@
|
||||
# 医生排班管理功能说明
|
||||
|
||||
## 数据来源
|
||||
|
||||
### 医生数据
|
||||
|
||||
医生数据来自系统管理员表 `zyt_admin`,筛选条件:
|
||||
- `role_id = 1`(医生角色)
|
||||
- 使用管理员的 `id` 作为医生ID
|
||||
- 使用管理员的 `name` 或 `account` 作为医生姓名
|
||||
|
||||
### 排班数据
|
||||
|
||||
排班数据存储在 `la_doctor_roster` 表中,通过 `doctor_id` 关联到 `zyt_admin` 表。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 时间维度
|
||||
|
||||
- 周期:周一至周日,7天
|
||||
- 时段:上午、下午(可扩展为全天)
|
||||
- 支持按周查询和切换
|
||||
|
||||
### 2. 排班状态
|
||||
|
||||
- 出诊:医生正常出诊,可预约
|
||||
- 停诊:临时停诊,不可预约
|
||||
- 休息:正常休息日
|
||||
- 请假:医生请假
|
||||
|
||||
### 3. 排班规则
|
||||
|
||||
- 同一医生同一天可排上午、下午其中一个或两个
|
||||
- 同一时段可排多名医生
|
||||
- 支持设置可预约号源数/最大接诊数
|
||||
|
||||
### 4. 核心功能
|
||||
|
||||
- 排班配置:新增、编辑、删除排班
|
||||
- 排班展示:周视图展示,直观清晰
|
||||
- 排班查询:按医院、科室、医生筛选
|
||||
- 排班修改:快速修改排班状态和号源数
|
||||
- 批量排班:支持多医生、多日期、多时段批量设置排班
|
||||
|
||||
## 页面结构
|
||||
|
||||
### 查询区域
|
||||
|
||||
- 医院选择
|
||||
- 科室选择
|
||||
- 医生选择
|
||||
- 周选择器
|
||||
- 查询/重置按钮
|
||||
|
||||
### 排班表格
|
||||
|
||||
- 横向:周一至周日
|
||||
- 纵向:医生列表
|
||||
- 单元格:上午/下午两个时段
|
||||
- 颜色标识:
|
||||
- 绿色:出诊
|
||||
- 红色:停诊
|
||||
- 蓝色:休息
|
||||
- 橙色:请假
|
||||
- 灰色:未排班
|
||||
|
||||
### 操作功能
|
||||
|
||||
- 上一周/下一周:切换周
|
||||
- 本周:快速回到当前周
|
||||
- 点击单元格:编辑排班
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 排班记录
|
||||
|
||||
```typescript
|
||||
interface Roster {
|
||||
id: number // 排班ID
|
||||
doctor_id: number // 医生ID(对应 zyt_admin 表的 id)
|
||||
date: string // 日期 YYYY-MM-DD
|
||||
period: string // 时段 morning/afternoon
|
||||
status: number // 状态 1-出诊 2-停诊 3-休息 4-请假
|
||||
quota: number // 号源数
|
||||
max_patients: number // 最大接诊数
|
||||
remark: string // 备注
|
||||
create_time: string // 创建时间
|
||||
update_time: string // 更新时间
|
||||
}
|
||||
```
|
||||
|
||||
### 医生信息
|
||||
|
||||
医生信息来自 `zyt_admin` 表:
|
||||
|
||||
```typescript
|
||||
interface Doctor {
|
||||
id: number // 管理员ID(作为医生ID使用)
|
||||
name: string // 姓名
|
||||
account: string // 账号
|
||||
role_id: number // 角色ID(1=医生)
|
||||
// ... 其他管理员字段
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 获取医生列表
|
||||
|
||||
**接口**: `GET /perms.admin/lists`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"role_id": 1,
|
||||
"page_no": 1,
|
||||
"page_size": 1000
|
||||
}
|
||||
```
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "张医生",
|
||||
"account": "doctor1",
|
||||
"role_id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取排班列表
|
||||
|
||||
**接口**: `GET /doctor.roster/lists`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"hospital_id": 1,
|
||||
"department_id": 2,
|
||||
"doctor_id": 3,
|
||||
"start_date": "2024-03-04",
|
||||
"end_date": "2024-03-10"
|
||||
}
|
||||
```
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"lists": [
|
||||
{
|
||||
"doctor_id": 1,
|
||||
"doctor_name": "张医生",
|
||||
"department_id": 2,
|
||||
"department_name": "内科",
|
||||
"rosters": [
|
||||
{
|
||||
"id": 1,
|
||||
"date": "2024-03-04",
|
||||
"period": "morning",
|
||||
"status": 1,
|
||||
"quota": 20,
|
||||
"max_patients": 30,
|
||||
"remark": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 保存排班
|
||||
|
||||
**接口**: `POST /doctor.roster/save`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"doctor_id": 1,
|
||||
"date": "2024-03-04",
|
||||
"period": "morning",
|
||||
"status": 1,
|
||||
"quota": 20,
|
||||
"max_patients": 30,
|
||||
"remark": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 删除排班
|
||||
|
||||
**接口**: `POST /doctor.roster/delete`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 批量保存排班
|
||||
|
||||
**接口**: `POST /doctor.roster/batchSave`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"rosters": [
|
||||
{
|
||||
"doctor_id": 1,
|
||||
"date": "2024-03-04",
|
||||
"period": "morning",
|
||||
"status": 1,
|
||||
"quota": 20,
|
||||
"max_patients": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 复制排班
|
||||
|
||||
**接口**: `POST /doctor.roster/copy`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"source_start_date": "2024-03-04",
|
||||
"source_end_date": "2024-03-10",
|
||||
"target_start_date": "2024-03-11",
|
||||
"doctor_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 排班表 (la_doctor_roster)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `la_doctor_roster` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`doctor_id` int(11) NOT NULL COMMENT '医生ID',
|
||||
`hospital_id` int(11) DEFAULT NULL COMMENT '医院ID',
|
||||
`department_id` int(11) DEFAULT NULL COMMENT '科室ID',
|
||||
`date` date NOT NULL COMMENT '日期',
|
||||
`period` varchar(20) NOT NULL COMMENT '时段 morning-上午 afternoon-下午',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1-出诊 2-停诊 3-休息 4-请假',
|
||||
`quota` int(11) DEFAULT '0' COMMENT '号源数',
|
||||
`max_patients` int(11) DEFAULT '0' COMMENT '最大接诊数',
|
||||
`booked_count` int(11) DEFAULT '0' COMMENT '已预约数',
|
||||
`remark` varchar(500) DEFAULT '' COMMENT '备注',
|
||||
`create_time` int(11) NOT NULL COMMENT '创建时间',
|
||||
`update_time` int(11) NOT NULL COMMENT '更新时间',
|
||||
`delete_time` int(11) DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_doctor_date_period` (`doctor_id`,`date`,`period`,`delete_time`),
|
||||
KEY `idx_date` (`date`),
|
||||
KEY `idx_doctor` (`doctor_id`),
|
||||
KEY `idx_hospital` (`hospital_id`),
|
||||
KEY `idx_department` (`department_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 查看排班
|
||||
|
||||
1. 选择医院、科室、医生(可选)
|
||||
2. 选择要查看的周
|
||||
3. 点击"查询"按钮
|
||||
4. 查看排班表格
|
||||
|
||||
### 2. 新增排班
|
||||
|
||||
1. 点击表格中的空白单元格
|
||||
2. 在弹窗中设置排班信息:
|
||||
- 排班状态
|
||||
- 号源数(出诊时)
|
||||
- 最大接诊数(出诊时)
|
||||
- 备注
|
||||
3. 点击"保存"
|
||||
|
||||
### 3. 编辑排班
|
||||
|
||||
1. 点击表格中已有排班的单元格
|
||||
2. 修改排班信息
|
||||
3. 点击"保存"
|
||||
|
||||
### 4. 删除排班
|
||||
|
||||
1. 点击表格中已有排班的单元格
|
||||
2. 在弹窗中点击"删除排班"
|
||||
3. 确认删除
|
||||
|
||||
### 5. 周切换
|
||||
|
||||
- 点击"上一周"/"下一周"切换周
|
||||
- 点击"本周"快速回到当前周
|
||||
- 使用周选择器选择特定周
|
||||
|
||||
### 6. 批量排班
|
||||
|
||||
1. 点击"批量排班"按钮
|
||||
2. 选择要排班的医生(可多选)
|
||||
3. 选择日期范围
|
||||
4. 选择时段(上午/下午,可多选)
|
||||
5. 选择星期(周一至周日,可多选)
|
||||
6. 设置排班状态和号源数
|
||||
7. 点击"确定"批量创建排班
|
||||
|
||||
批量排班会根据选择的条件自动生成所有符合条件的排班记录。例如:
|
||||
- 选择2位医生
|
||||
- 日期范围:2024-03-04 至 2024-03-10(7天)
|
||||
- 时段:上午、下午
|
||||
- 星期:周一至周五(5天)
|
||||
- 结果:2 × 5 × 2 = 20条排班记录
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 1. 批量操作
|
||||
|
||||
- 批量设置某医生一周的排班
|
||||
- 批量复制排班到其他周
|
||||
- 批量导入/导出排班
|
||||
|
||||
### 2. 统计分析
|
||||
|
||||
- 医生出诊统计
|
||||
- 号源使用率统计
|
||||
- 预约情况分析
|
||||
|
||||
### 3. 提醒功能
|
||||
|
||||
- 排班冲突提醒
|
||||
- 号源不足提醒
|
||||
- 排班变更通知
|
||||
|
||||
### 4. 权限控制
|
||||
|
||||
- 科室主任:管理本科室排班
|
||||
- 医生:查看自己的排班
|
||||
- 管理员:管理所有排班
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 同一医生同一天同一时段只能有一条排班记录
|
||||
2. 修改排班时需要考虑已有预约
|
||||
3. 删除排班前需要检查是否有预约
|
||||
4. 号源数不能小于已预约数
|
||||
5. 建议提前一周设置排班
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 周视图实现
|
||||
|
||||
使用 dayjs 的 isoWeek 插件实现 ISO 8601 标准的周计算(周一为一周的开始)
|
||||
|
||||
```typescript
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
const weekStart = dayjs().startOf('isoWeek')
|
||||
```
|
||||
|
||||
### 2. 数据结构优化
|
||||
|
||||
后端返回数据按医生分组,每个医生包含其所有排班记录,前端根据日期和时段快速查找
|
||||
|
||||
### 3. 交互优化
|
||||
|
||||
- 点击单元格直接编辑
|
||||
- 颜色区分不同状态
|
||||
- 今日高亮显示
|
||||
- 响应式布局
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- 按周加载数据,减少数据量
|
||||
- 使用计算属性缓存结果
|
||||
- 防抖处理频繁操作
|
||||
|
||||
## 后续优化
|
||||
|
||||
1. 支持全天时段
|
||||
2. 支持自定义时段(如夜诊)
|
||||
3. 支持排班模板
|
||||
4. 支持排班审批流程
|
||||
5. 移动端适配
|
||||
6. 打印排班表
|
||||
7. 排班日历视图
|
||||
8. 排班冲突检测
|
||||
@@ -0,0 +1,179 @@
|
||||
# 群组视频通话功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
在诊断列表页面新增了群组视频通话功能,支持三方视频通话(医助、患者、当前登录用户)。
|
||||
|
||||
## 实现原理
|
||||
|
||||
由于腾讯云 TUICallKit 的 `groupCall` 方法需要预先创建的 IM 群组,本实现采用了以下策略:
|
||||
|
||||
1. 当前登录用户作为发起者
|
||||
2. 先发起与患者的一对一通话
|
||||
3. 当患者接听后,自动邀请医助加入通话
|
||||
4. 这样可以实现三方视频通话,无需预先创建 IM 群组
|
||||
|
||||
参与顺序:当前登录用户(发起者)→ 患者 → 医助
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 一对一视频通话
|
||||
|
||||
点击"视频通话"按钮,发起与患者的一对一视频通话。
|
||||
|
||||
- 参与者:当前登录用户、患者
|
||||
|
||||
### 2. 群组视频通话
|
||||
|
||||
点击"群通话"按钮,发起三方群组视频通话。
|
||||
|
||||
- 参与者:医助、患者、当前登录用户
|
||||
- 前提条件:该诊单必须已指派医助
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 前端修改
|
||||
|
||||
#### 1. 诊断列表页面 (`admin/src/views/tcm/diagnosis/index.vue`)
|
||||
|
||||
新增 `handleGroupVideoCall` 方法:
|
||||
|
||||
```typescript
|
||||
// 群组视频通话(三方通话)
|
||||
const handleGroupVideoCall = (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
if (!row.assistant_id) {
|
||||
feedback.msgWarning('该诊单未指派医助,无法发起群组通话')
|
||||
return
|
||||
}
|
||||
|
||||
// 群组通话参与者顺序:当前登录用户(发起者)、患者、医助
|
||||
const userIds = [
|
||||
`patient_${row.patient_id}`, // 先邀请患者
|
||||
`doctor_${row.assistant_id}` // 再邀请医助
|
||||
]
|
||||
|
||||
videoCallRef.value?.open({
|
||||
diagnosisId: row.id,
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
assistantId: row.assistant_id,
|
||||
userIds: userIds,
|
||||
isGroup: true
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 视频通话组件 (`admin/src/components/video-call/index.vue`)
|
||||
|
||||
更新 `CallInfo` 接口:
|
||||
|
||||
```typescript
|
||||
interface CallInfo {
|
||||
diagnosisId: number
|
||||
patientId: number
|
||||
patientName: string
|
||||
userId?: string // 一对一通话时使用
|
||||
assistantId?: number // 群组通话时的医助ID
|
||||
userIds?: string[] // 群组通话时的用户ID列表
|
||||
isGroup: boolean // 是否为群组通话
|
||||
}
|
||||
```
|
||||
|
||||
更新 `startCall` 方法,支持多人通话:
|
||||
|
||||
```typescript
|
||||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 0) {
|
||||
// 多人通话 - 先邀请第一个用户建立通话
|
||||
await TUICallKitServer.call({
|
||||
userID: callInfo.value.userIds[0],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
// 在通话接通后,通过 handleStatusChange 回调自动邀请其他用户
|
||||
} else {
|
||||
// 一对一通话
|
||||
await TUICallKitServer.call({
|
||||
userID: callInfo.value.userId || '',
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
新增 `inviteOtherUsers` 方法,在通话接通后邀请其他用户:
|
||||
|
||||
```typescript
|
||||
const inviteOtherUsers = async () => {
|
||||
if (!callInfo.value.userIds || callInfo.value.userIds.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从第二个用户开始邀请
|
||||
for (let i = 1; i < callInfo.value.userIds.length; i++) {
|
||||
await TUICallKitServer.call({
|
||||
userID: callInfo.value.userIds[i],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
// 等待一下再邀请下一个
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `handleStatusChange` 中监听通话接通事件:
|
||||
|
||||
```typescript
|
||||
// 通话接通
|
||||
if (newStatus === STATUS.CALLING_C2C_VIDEO || newStatus === STATUS.CALLING_GROUP_VIDEO) {
|
||||
callConnected.value = true
|
||||
|
||||
// 如果是多人通话且还有其他用户需要邀请
|
||||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 1) {
|
||||
inviteOtherUsers()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 用户ID格式
|
||||
|
||||
- 医助:`doctor_{assistant_id}`
|
||||
- 患者:`patient_{patient_id}`
|
||||
- 当前登录用户:由后端返回的 `userId` 决定
|
||||
|
||||
## 通话流程
|
||||
|
||||
### 一对一视频通话
|
||||
|
||||
1. 点击"视频通话"按钮
|
||||
2. 系统发起与患者的通话邀请
|
||||
3. 患者接听后开始通话
|
||||
|
||||
### 群组视频通话
|
||||
|
||||
1. 点击"群通话"按钮
|
||||
2. 系统检查是否已指派医助
|
||||
3. 先向患者发起通话邀请
|
||||
4. 患者接听后,系统自动邀请医助加入
|
||||
5. 医助接听后,三方通话建立完成
|
||||
|
||||
参与顺序:当前登录用户(发起者)→ 患者 → 医助
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 多人通话需要诊单已指派医助,否则会提示错误
|
||||
2. 多人通话采用"先建立通话,再邀请加入"的方式,避免了创建 IM 群组的复杂性
|
||||
3. 第一个用户必须接听后,才会邀请其他用户
|
||||
4. 当前登录用户作为发起者会自动加入通话,无需在 `userIds` 中添加
|
||||
5. 需要确保后端 `getCallSignature` 接口支持多人通话的签名生成
|
||||
6. 所有参与者都需要在腾讯云 IM 中注册并在线才能接收到通话邀请
|
||||
7. 邀请其他用户时会有短暂延迟(500ms),避免请求过快
|
||||
|
||||
## 权限控制
|
||||
|
||||
- 一对一视频通话:`tcm.diagnosis/video-call`
|
||||
- 群组视频通话:`tcm.diagnosis/video-group`
|
||||
@@ -0,0 +1,311 @@
|
||||
# HTTPS 部署快速指南
|
||||
|
||||
## 为什么需要 HTTPS?
|
||||
|
||||
WebRTC(视频通话功能)要求:
|
||||
- ✅ HTTPS 协议
|
||||
- ✅ 或者 localhost/127.0.0.1
|
||||
|
||||
生产环境必须使用 HTTPS,否则浏览器会阻止访问摄像头和麦克风。
|
||||
|
||||
## 快速部署步骤
|
||||
|
||||
### 步骤1:申请 SSL 证书
|
||||
|
||||
#### 方案A:Let's Encrypt(免费,推荐)
|
||||
|
||||
```bash
|
||||
# 安装 certbot
|
||||
sudo apt-get update
|
||||
sudo apt-get install certbot python3-certbot-nginx
|
||||
|
||||
# 申请证书(自动配置 Nginx)
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
|
||||
# 或者只申请证书,手动配置
|
||||
sudo certbot certonly --standalone -d your-domain.com
|
||||
```
|
||||
|
||||
证书位置:
|
||||
- 证书:`/etc/letsencrypt/live/your-domain.com/fullchain.pem`
|
||||
- 私钥:`/etc/letsencrypt/live/your-domain.com/privkey.pem`
|
||||
|
||||
#### 方案B:阿里云/腾讯云免费证书
|
||||
|
||||
1. 登录云服务商控制台
|
||||
2. 搜索"SSL 证书"
|
||||
3. 申请免费证书(通常有效期1年)
|
||||
4. 下载证书文件
|
||||
5. 上传到服务器
|
||||
|
||||
### 步骤2:配置 Nginx
|
||||
|
||||
#### 2.1 复制配置文件
|
||||
|
||||
```bash
|
||||
# 复制示例配置
|
||||
sudo cp nginx-https-example.conf /etc/nginx/sites-available/admin-https
|
||||
|
||||
# 修改配置文件
|
||||
sudo nano /etc/nginx/sites-available/admin-https
|
||||
```
|
||||
|
||||
#### 2.2 修改配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com; # 改成你的域名
|
||||
|
||||
# 改成你的证书路径
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# 改成你的前端路径
|
||||
location /admin/ {
|
||||
alias /var/www/html/admin/dist/;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# 改成你的后端地址
|
||||
location /adminapi/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
# ... 其他配置
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 启用配置
|
||||
|
||||
```bash
|
||||
# 创建软链接
|
||||
sudo ln -s /etc/nginx/sites-available/admin-https /etc/nginx/sites-enabled/
|
||||
|
||||
# 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 重启 Nginx
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 步骤3:更新前端配置
|
||||
|
||||
#### 3.1 修改 .env.production
|
||||
|
||||
```bash
|
||||
# admin/.env.production
|
||||
VITE_APP_BASE_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
#### 3.2 重新构建
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 3.3 部署到服务器
|
||||
|
||||
```bash
|
||||
# 上传构建文件
|
||||
scp -r dist/* user@your-server:/var/www/html/admin/dist/
|
||||
|
||||
# 或使用 rsync
|
||||
rsync -avz --delete dist/ user@your-server:/var/www/html/admin/dist/
|
||||
```
|
||||
|
||||
### 步骤4:验证部署
|
||||
|
||||
#### 4.1 检查 HTTPS
|
||||
|
||||
访问:`https://your-domain.com/admin/`
|
||||
|
||||
浏览器地址栏应该显示绿色锁图标 🔒
|
||||
|
||||
#### 4.2 检查 WebRTC
|
||||
|
||||
打开浏览器控制台,输入:
|
||||
|
||||
```javascript
|
||||
console.log('Protocol:', window.location.protocol)
|
||||
console.log('getUserMedia:', !!navigator.mediaDevices?.getUserMedia)
|
||||
console.log('RTCPeerConnection:', !!window.RTCPeerConnection)
|
||||
```
|
||||
|
||||
应该输出:
|
||||
```
|
||||
Protocol: https:
|
||||
getUserMedia: true
|
||||
RTCPeerConnection: true
|
||||
```
|
||||
|
||||
#### 4.3 测试视频通话
|
||||
|
||||
1. 登录系统
|
||||
2. 进入诊断列表
|
||||
3. 点击"视频通话"或"群通话"
|
||||
4. 浏览器会请求摄像头和麦克风权限
|
||||
5. 允许权限后应该能正常发起通话
|
||||
|
||||
## 开发环境测试
|
||||
|
||||
如果需要在开发环境测试 HTTPS:
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# 生成证书
|
||||
setup-https-dev.bat
|
||||
|
||||
# 修改 vite.config.ts(见下方)
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Linux/Mac
|
||||
|
||||
```bash
|
||||
# 生成证书
|
||||
chmod +x setup-https-dev.sh
|
||||
./setup-https-dev.sh
|
||||
|
||||
# 修改 vite.config.ts(见下方)
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 修改 vite.config.ts
|
||||
|
||||
```typescript
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: {
|
||||
key: fs.readFileSync('.cert/key.pem'),
|
||||
cert: fs.readFileSync('.cert/cert.pem')
|
||||
},
|
||||
hmr: true,
|
||||
open: true
|
||||
},
|
||||
// ... 其他配置
|
||||
})
|
||||
```
|
||||
|
||||
访问:`https://localhost:5173/admin/`
|
||||
|
||||
浏览器会提示证书不安全,点击"继续访问"即可。
|
||||
|
||||
## 证书自动续期
|
||||
|
||||
Let's Encrypt 证书有效期90天,需要定期续期。
|
||||
|
||||
### 自动续期
|
||||
|
||||
```bash
|
||||
# 测试续期
|
||||
sudo certbot renew --dry-run
|
||||
|
||||
# 添加定时任务
|
||||
sudo crontab -e
|
||||
|
||||
# 添加以下行(每天凌晨2点检查续期)
|
||||
0 2 * * * certbot renew --quiet --post-hook "systemctl reload nginx"
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 证书申请失败
|
||||
|
||||
**原因**:
|
||||
- 域名未解析到服务器
|
||||
- 80端口被占用
|
||||
- 防火墙阻止
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查域名解析
|
||||
nslookup your-domain.com
|
||||
|
||||
# 检查80端口
|
||||
sudo netstat -tlnp | grep :80
|
||||
|
||||
# 开放80和443端口
|
||||
sudo ufw allow 80
|
||||
sudo ufw allow 443
|
||||
```
|
||||
|
||||
### Q2: Nginx 配置错误
|
||||
|
||||
**检查配置**:
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
**查看错误日志**:
|
||||
```bash
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
### Q3: 混合内容警告
|
||||
|
||||
如果页面加载了 HTTP 资源,浏览器会警告。
|
||||
|
||||
**解决**:确保所有资源都使用 HTTPS:
|
||||
- API 请求:`https://your-domain.com/adminapi/`
|
||||
- 静态资源:使用相对路径或 HTTPS CDN
|
||||
|
||||
### Q4: 视频通话还是不工作
|
||||
|
||||
**检查清单**:
|
||||
- [ ] 使用 HTTPS 访问
|
||||
- [ ] 证书有效(绿色锁图标)
|
||||
- [ ] 浏览器已授权摄像头和麦克风
|
||||
- [ ] 使用支持的浏览器(Chrome/Edge/Safari)
|
||||
- [ ] 检查浏览器控制台错误
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **使用强密码套件**:配置文件中已包含
|
||||
2. **启用 HSTS**:强制使用 HTTPS
|
||||
3. **定期更新证书**:设置自动续期
|
||||
4. **监控证书过期**:提前30天收到提醒
|
||||
5. **备份证书**:定期备份证书文件
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **启用 HTTP/2**:`listen 443 ssl http2;`
|
||||
2. **启用 GZIP**:压缩传输内容
|
||||
3. **设置缓存**:静态资源长期缓存
|
||||
4. **CDN 加速**:使用 CDN 分发静态资源
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 证书过期监控
|
||||
|
||||
```bash
|
||||
# 检查证书有效期
|
||||
echo | openssl s_client -servername your-domain.com -connect your-domain.com:443 2>/dev/null | openssl x509 -noout -dates
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
|
||||
使用工具:
|
||||
- SSL Labs: https://www.ssllabs.com/ssltest/
|
||||
- WebPageTest: https://www.webpagetest.org/
|
||||
|
||||
## 总结
|
||||
|
||||
完成以上步骤后,你的系统应该:
|
||||
- ✅ 使用 HTTPS 协议
|
||||
- ✅ 证书有效且自动续期
|
||||
- ✅ WebRTC 功能正常工作
|
||||
- ✅ 安全性和性能都得到保障
|
||||
|
||||
如有问题,请查看:
|
||||
- Nginx 错误日志:`/var/log/nginx/error.log`
|
||||
- Certbot 日志:`/var/log/letsencrypt/letsencrypt.log`
|
||||
- 浏览器控制台:F12 查看错误信息
|
||||
@@ -0,0 +1,342 @@
|
||||
# TUICallKit 初始化流程详解
|
||||
|
||||
## 问题背景
|
||||
|
||||
TUICallKit 组件需要在完全初始化后才能使用,如果在初始化完成前渲染组件,会出现以下错误:
|
||||
|
||||
```
|
||||
API<getDeviceList>: init or login is not complete
|
||||
ERROR_INIT_FAIL: -1201
|
||||
```
|
||||
|
||||
## 正确的初始化流程
|
||||
|
||||
### 1. 流程图
|
||||
|
||||
```
|
||||
用户点击"视频通话"
|
||||
↓
|
||||
打开对话框(显示"准备通话...")
|
||||
↓
|
||||
用户点击"开始通话"
|
||||
↓
|
||||
获取腾讯云签名(显示"正在获取签名...")
|
||||
↓
|
||||
调用 TUICallKitServer.init()(显示"正在初始化通话组件...")
|
||||
↓
|
||||
等待 500ms 确保初始化完成
|
||||
↓
|
||||
设置 isInitialized = true(显示"初始化完成,准备发起通话...")
|
||||
↓
|
||||
渲染 TUICallKit 组件
|
||||
↓
|
||||
等待 300ms 让组件完全渲染
|
||||
↓
|
||||
调用 TUICallKitServer.call() 发起通话
|
||||
↓
|
||||
通话进行中
|
||||
```
|
||||
|
||||
### 2. 关键代码实现
|
||||
|
||||
#### 步骤 1: 状态管理
|
||||
|
||||
```typescript
|
||||
const isInitialized = ref(false) // 是否已初始化
|
||||
const initializing = ref(false) // 是否正在初始化
|
||||
const calling = ref(false) // 是否正在通话
|
||||
const statusText = ref('准备通话...') // 状态提示文本
|
||||
```
|
||||
|
||||
#### 步骤 2: 条件渲染
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="video-call-container">
|
||||
<!-- 初始化前显示等待界面 -->
|
||||
<div v-if="!isInitialized" class="call-waiting">
|
||||
<el-icon class="loading-icon" :size="60"><VideoCamera /></el-icon>
|
||||
<p class="mt-4">{{ statusText }}</p>
|
||||
<p class="text-gray-500">{{ callInfo.patientName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 初始化完成后才显示 TUICallKit 组件 -->
|
||||
<TUICallKit v-else class="tui-call-kit" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 步骤 3: 初始化逻辑
|
||||
|
||||
```typescript
|
||||
const startCall = async () => {
|
||||
try {
|
||||
initializing.value = true
|
||||
|
||||
// 1. 获取签名
|
||||
statusText.value = '正在获取签名...'
|
||||
const res = await getCallSignature({
|
||||
diagnosis_id: callInfo.value.diagnosisId,
|
||||
patient_id: callInfo.value.patientId
|
||||
})
|
||||
|
||||
if (!res || !res.userSig) {
|
||||
throw new Error('获取签名失败')
|
||||
}
|
||||
|
||||
// 2. 初始化 TUICallKit
|
||||
statusText.value = '正在初始化通话组件...'
|
||||
await TUICallKitServer.init({
|
||||
userID: res.userId,
|
||||
userSig: res.userSig,
|
||||
SDKAppID: res.sdkAppId
|
||||
})
|
||||
|
||||
// 3. 等待初始化完全完成(重要!)
|
||||
statusText.value = '初始化完成,准备发起通话...'
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 4. 设置初始化完成标志,触发组件渲染
|
||||
isInitialized.value = true
|
||||
calling.value = true
|
||||
|
||||
// 5. 等待组件渲染完成(重要!)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// 6. 发起通话
|
||||
await TUICallKitServer.call({
|
||||
userID: callInfo.value.userId,
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
feedback.msgSuccess('通话已发起,等待对方接听...')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('启动通话失败:', error)
|
||||
feedback.msgError(error.message || '启动通话失败')
|
||||
isInitialized.value = false
|
||||
calling.value = false
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键点说明
|
||||
|
||||
### 1. 为什么需要等待?
|
||||
|
||||
```typescript
|
||||
// 等待 500ms 确保初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- `TUICallKitServer.init()` 是异步操作
|
||||
- 即使 Promise resolve 了,内部可能还有一些异步初始化
|
||||
- 等待一小段时间确保所有初始化完全完成
|
||||
|
||||
### 2. 为什么需要两次等待?
|
||||
|
||||
```typescript
|
||||
// 第一次等待:初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
isInitialized.value = true
|
||||
|
||||
// 第二次等待:组件渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
await TUICallKitServer.call({ ... })
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- 第一次等待:确保 TUICallKitServer 初始化完成
|
||||
- 设置 `isInitialized = true` 后,Vue 会渲染 TUICallKit 组件
|
||||
- 第二次等待:确保 TUICallKit 组件完全渲染和挂载
|
||||
- 然后才能调用 `call()` 方法
|
||||
|
||||
### 3. 状态提示的作用
|
||||
|
||||
```typescript
|
||||
statusText.value = '正在获取签名...'
|
||||
// ... 执行操作 ...
|
||||
statusText.value = '正在初始化通话组件...'
|
||||
// ... 执行操作 ...
|
||||
statusText.value = '初始化完成,准备发起通话...'
|
||||
```
|
||||
|
||||
**作用**:
|
||||
- 让用户知道当前进度
|
||||
- 提升用户体验
|
||||
- 便于调试和排查问题
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 错误 1: 过早渲染组件
|
||||
|
||||
```typescript
|
||||
// ❌ 错误做法
|
||||
await TUICallKitServer.init({ ... })
|
||||
isInitialized.value = true // 立即设置为 true
|
||||
await TUICallKitServer.call({ ... }) // 可能失败
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 正确做法
|
||||
await TUICallKitServer.init({ ... })
|
||||
await new Promise(resolve => setTimeout(resolve, 500)) // 等待
|
||||
isInitialized.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 300)) // 再等待
|
||||
await TUICallKitServer.call({ ... }) // 成功
|
||||
```
|
||||
|
||||
### 错误 2: 没有条件渲染
|
||||
|
||||
```vue
|
||||
<!-- ❌ 错误做法:组件一直存在 -->
|
||||
<TUICallKit class="tui-call-kit" />
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ 正确做法:条件渲染 -->
|
||||
<TUICallKit v-if="isInitialized" class="tui-call-kit" />
|
||||
```
|
||||
|
||||
### 错误 3: 没有错误处理
|
||||
|
||||
```typescript
|
||||
// ❌ 错误做法:没有 try-catch
|
||||
await TUICallKitServer.init({ ... })
|
||||
isInitialized.value = true
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 正确做法:完整的错误处理
|
||||
try {
|
||||
await TUICallKitServer.init({ ... })
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
isInitialized.value = true
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error)
|
||||
isInitialized.value = false
|
||||
feedback.msgError('初始化失败')
|
||||
}
|
||||
```
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 添加日志
|
||||
|
||||
```typescript
|
||||
console.log('1. 开始获取签名')
|
||||
const res = await getCallSignature({ ... })
|
||||
console.log('2. 签名获取成功:', res)
|
||||
|
||||
console.log('3. 开始初始化')
|
||||
await TUICallKitServer.init({ ... })
|
||||
console.log('4. 初始化完成')
|
||||
|
||||
console.log('5. 等待初始化稳定')
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
console.log('6. 等待完成')
|
||||
|
||||
console.log('7. 设置初始化标志')
|
||||
isInitialized.value = true
|
||||
console.log('8. 标志已设置')
|
||||
|
||||
console.log('9. 等待组件渲染')
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
console.log('10. 组件渲染完成')
|
||||
|
||||
console.log('11. 发起通话')
|
||||
await TUICallKitServer.call({ ... })
|
||||
console.log('12. 通话已发起')
|
||||
```
|
||||
|
||||
### 2. 监控状态变化
|
||||
|
||||
```typescript
|
||||
watch(isInitialized, (newVal) => {
|
||||
console.log('isInitialized 变化:', newVal)
|
||||
})
|
||||
|
||||
watch(calling, (newVal) => {
|
||||
console.log('calling 变化:', newVal)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 检查初始化状态
|
||||
|
||||
在浏览器控制台执行:
|
||||
|
||||
```javascript
|
||||
// 检查 TUICallKitServer 是否已初始化
|
||||
console.log('TUICallKitServer:', window.TUICallKitServer)
|
||||
```
|
||||
|
||||
## 时序图
|
||||
|
||||
```
|
||||
时间轴 →
|
||||
|
||||
0ms 用户点击"开始通话"
|
||||
↓
|
||||
100ms 获取签名开始
|
||||
↓
|
||||
500ms 获取签名完成
|
||||
↓
|
||||
600ms TUICallKitServer.init() 开始
|
||||
↓
|
||||
1000ms TUICallKitServer.init() 完成
|
||||
↓
|
||||
1500ms 等待 500ms 完成(确保初始化稳定)
|
||||
↓
|
||||
1500ms 设置 isInitialized = true
|
||||
↓
|
||||
1500ms Vue 开始渲染 TUICallKit 组件
|
||||
↓
|
||||
1800ms 等待 300ms 完成(确保组件渲染完成)
|
||||
↓
|
||||
1800ms 调用 TUICallKitServer.call()
|
||||
↓
|
||||
2000ms 通话发起成功
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **总是使用条件渲染**
|
||||
```vue
|
||||
<TUICallKit v-if="isInitialized" />
|
||||
```
|
||||
|
||||
2. **总是等待初始化完成**
|
||||
```typescript
|
||||
await TUICallKitServer.init({ ... })
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
```
|
||||
|
||||
3. **总是等待组件渲染**
|
||||
```typescript
|
||||
isInitialized.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
```
|
||||
|
||||
4. **总是添加错误处理**
|
||||
```typescript
|
||||
try {
|
||||
// 初始化逻辑
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
isInitialized.value = false
|
||||
}
|
||||
```
|
||||
|
||||
5. **总是提供状态反馈**
|
||||
```typescript
|
||||
statusText.value = '正在初始化...'
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [TUICallKit 官方文档](https://cloud.tencent.com/document/product/647/78742)
|
||||
- [初始化常见问题](https://cloud.tencent.com/document/product/647/78769#3a61f42b-e06f-49af-88bf-362d40025887)
|
||||
- [故障排查指南](./TROUBLESHOOTING.md)
|
||||
@@ -0,0 +1,103 @@
|
||||
# 音视频通话功能安装说明
|
||||
|
||||
## 快速安装
|
||||
|
||||
### 方式一:使用安装脚本(推荐)
|
||||
|
||||
#### Windows 系统
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
install_video_call.bat
|
||||
```
|
||||
|
||||
#### Linux/Mac 系统
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
chmod +x install_video_call.sh
|
||||
./install_video_call.sh
|
||||
```
|
||||
|
||||
### 方式二:手动安装
|
||||
|
||||
#### 1. 安装前端依赖
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
npm install @tencentcloud/call-uikit-vue
|
||||
```
|
||||
|
||||
#### 2. 执行数据库迁移
|
||||
|
||||
```bash
|
||||
mysql -u root -p your_database < server/sql/tcm_call_record.sql
|
||||
```
|
||||
|
||||
#### 3. 配置腾讯云 TRTC
|
||||
|
||||
编辑 `server/.env` 文件:
|
||||
|
||||
```env
|
||||
TRTC_SDK_APP_ID=你的SDKAppID
|
||||
TRTC_SECRET_KEY=你的密钥
|
||||
TRTC_ENABLE=true
|
||||
```
|
||||
|
||||
#### 4. 构建前端
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 验证安装
|
||||
|
||||
1. 启动开发服务器
|
||||
```bash
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. 登录后台管理系统
|
||||
|
||||
3. 访问"中医诊单"页面
|
||||
|
||||
4. 点击"视频通话"按钮测试
|
||||
|
||||
## 获取腾讯云配置
|
||||
|
||||
1. 访问 [腾讯云控制台](https://console.cloud.tencent.com/trtc)
|
||||
2. 创建应用
|
||||
3. 获取 SDKAppID 和密钥
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. npm install 失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 清除缓存
|
||||
npm cache clean --force
|
||||
|
||||
# 使用淘宝镜像
|
||||
npm install --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
### 2. 数据库迁移失败
|
||||
|
||||
**解决方案**:
|
||||
- 检查数据库连接
|
||||
- 确认表名前缀是否正确
|
||||
- 手动执行 SQL 语句
|
||||
|
||||
### 3. 视频通话无法发起
|
||||
|
||||
**解决方案**:
|
||||
- 检查浏览器权限
|
||||
- 确认使用 HTTPS 或 localhost
|
||||
- 查看浏览器控制台错误
|
||||
|
||||
## 更多文档
|
||||
|
||||
- [完整配置指南](../TRTC_SETUP_GUIDE.md)
|
||||
- [功能说明文档](./README_VIDEO_CALL.md)
|
||||
- [API 接口文档](../API接口文档.md)
|
||||
@@ -0,0 +1,129 @@
|
||||
# TUICallKit 4.2.2 快速参考
|
||||
|
||||
## 📦 安装
|
||||
|
||||
```bash
|
||||
npm install @trtc/calls-uikit-vue
|
||||
```
|
||||
|
||||
## 📥 导入
|
||||
|
||||
```typescript
|
||||
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||||
```
|
||||
|
||||
## 🔧 常用 API
|
||||
|
||||
### 初始化
|
||||
|
||||
```typescript
|
||||
await TUICallKitAPI.init({
|
||||
userID: 'doctor_1',
|
||||
userSig: 'xxx',
|
||||
SDKAppID: 123456
|
||||
})
|
||||
```
|
||||
|
||||
### 发起通话
|
||||
|
||||
```typescript
|
||||
// 一对一通话
|
||||
await TUICallKitAPI.calls({
|
||||
userIDList: ['patient_2'],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
|
||||
// 多人通话
|
||||
await TUICallKitAPI.calls({
|
||||
userIDList: ['patient_2', 'doctor_3'],
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
```
|
||||
|
||||
### 接听/拒绝
|
||||
|
||||
```typescript
|
||||
// 接听
|
||||
await TUICallKitAPI.accept()
|
||||
|
||||
// 拒绝
|
||||
await TUICallKitAPI.reject()
|
||||
```
|
||||
|
||||
### 挂断
|
||||
|
||||
```typescript
|
||||
await TUICallKitAPI.hangup()
|
||||
```
|
||||
|
||||
### 设置回调
|
||||
|
||||
```typescript
|
||||
TUICallKitAPI.setCallback({
|
||||
statusChanged: ({ oldStatus, newStatus }) => {
|
||||
console.log('状态变化:', oldStatus, '->', newStatus)
|
||||
},
|
||||
afterCalling: () => {
|
||||
console.log('通话结束')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 浮窗设置
|
||||
|
||||
```typescript
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
```
|
||||
|
||||
## 📊 状态常量
|
||||
|
||||
```typescript
|
||||
STATUS.IDLE // 空闲
|
||||
STATUS.DIALING_C2C // 一对一呼叫中
|
||||
STATUS.DIALING_GROUP // 群组呼叫中
|
||||
STATUS.CALLING_C2C_VIDEO // 一对一视频通话中
|
||||
STATUS.CALLING_C2C_AUDIO // 一对一音频通话中
|
||||
STATUS.CALLING_GROUP_VIDEO // 群组视频通话中
|
||||
STATUS.CALLING_GROUP_AUDIO // 群组音频通话中
|
||||
```
|
||||
|
||||
## 🎨 组件使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TUICallKit
|
||||
style="width: 650px; height: 500px"
|
||||
:allowedFullScreen="false"
|
||||
:allowedMinimized="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **userId 格式**:必须是字符串,建议格式 `prefix_number`(如 `doctor_1`)
|
||||
2. **SDKAppID**:必须是数字类型,使用 `Number()` 转换
|
||||
3. **HTTPS**:Web 端必须使用 HTTPS(或 localhost)
|
||||
4. **userIDList**:发起通话时使用数组,即使是一对一通话
|
||||
|
||||
## 🔄 从 4.0.12 迁移
|
||||
|
||||
| 项目 | 4.0.12 | 4.2.2 |
|
||||
|------|--------|-------|
|
||||
| 包名 | @tencentcloud/call-uikit-vue | @trtc/calls-uikit-vue |
|
||||
| API | TUICallKitServer | TUICallKitAPI |
|
||||
| 发起通话 | call({ userID, type }) | calls({ userIDList, type }) |
|
||||
|
||||
## 📚 完整文档
|
||||
|
||||
- [官方文档](https://www.tencentcloud.com/document/product/647/50993)
|
||||
- [API 文档](https://web.sdk.qcloud.com/component/trtccalling/doc/web/zh-cn/)
|
||||
|
||||
---
|
||||
|
||||
**版本:** 4.2.2
|
||||
**更新日期:** 2024-03-04
|
||||
@@ -0,0 +1,263 @@
|
||||
# 升级后快速测试指南
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 步骤 1:重新安装依赖
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
|
||||
# 删除旧依赖
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
# 安装新依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
✅ 安装成功
|
||||
✅ 没有错误
|
||||
✅ package-lock.json 已生成
|
||||
```
|
||||
|
||||
### 步骤 2:启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
✅ 编译成功
|
||||
✅ 没有错误
|
||||
✅ 服务器启动在 http://localhost:xxxx
|
||||
```
|
||||
|
||||
### 步骤 3:登录系统
|
||||
|
||||
1. 打开浏览器
|
||||
2. 访问 `http://localhost:xxxx`
|
||||
3. 登录医生账号
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
✅ 登录成功
|
||||
✅ 进入系统首页
|
||||
✅ 没有控制台错误
|
||||
```
|
||||
|
||||
### 步骤 4:测试视频通话
|
||||
|
||||
1. 进入患者管理页面
|
||||
2. 找到一个患者
|
||||
3. 点击"视频通话"按钮
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
✅ 弹出通话窗口
|
||||
✅ 显示"准备通话..."
|
||||
✅ 点击"开始通话"后初始化成功
|
||||
✅ 显示"等待对方接听..."
|
||||
```
|
||||
|
||||
### 步骤 5:小程序端接听
|
||||
|
||||
1. 小程序端登录对应的患者
|
||||
2. 等待来电
|
||||
3. 点击"接听"
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
✅ 小程序收到来电
|
||||
✅ 显示来电界面
|
||||
✅ 点击接听后通话接通
|
||||
✅ 双方视频和音频正常
|
||||
```
|
||||
|
||||
## 📋 检查清单
|
||||
|
||||
### 编译检查
|
||||
- [ ] `npm install` 成功
|
||||
- [ ] `npm run dev` 成功
|
||||
- [ ] 没有编译错误
|
||||
- [ ] 没有类型错误
|
||||
|
||||
### 功能检查
|
||||
- [ ] 登录成功
|
||||
- [ ] 视频通话窗口正常打开
|
||||
- [ ] 初始化成功
|
||||
- [ ] 可以发起通话
|
||||
- [ ] 小程序端可以接收来电
|
||||
- [ ] 通话可以接通
|
||||
- [ ] 视频显示正常
|
||||
- [ ] 音频传输正常
|
||||
- [ ] 可以正常挂断
|
||||
|
||||
### 控制台检查
|
||||
- [ ] 没有红色错误
|
||||
- [ ] 没有导入错误
|
||||
- [ ] 状态回调正常
|
||||
- [ ] userId 格式正确
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### 问题 1:安装失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
npm ERR! code ERESOLVE
|
||||
npm ERR! ERESOLVE unable to resolve dependency tree
|
||||
```
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
### 问题 2:编译错误
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Cannot find module '@trtc/calls-uikit-vue'
|
||||
```
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 1. 确认 package.json 中的包名正确
|
||||
"@trtc/calls-uikit-vue": "^4.2.2"
|
||||
|
||||
# 2. 删除依赖重新安装
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### 问题 3:运行时错误
|
||||
|
||||
**症状:**
|
||||
```
|
||||
TUICallKitServer is undefined
|
||||
```
|
||||
|
||||
**解决:**
|
||||
1. 检查导入语句:
|
||||
```typescript
|
||||
import { TUICallKitServer } from '@trtc/calls-uikit-vue'
|
||||
```
|
||||
2. 重启开发服务器
|
||||
3. 清除浏览器缓存
|
||||
|
||||
### 问题 4:通话失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Error: 对方不在线 (60011)
|
||||
```
|
||||
|
||||
**解决:**
|
||||
1. 检查小程序端是否已登录
|
||||
2. 检查 userId 格式是否一致
|
||||
3. 查看详细日志
|
||||
|
||||
## 🔍 详细日志检查
|
||||
|
||||
### Web 端日志
|
||||
|
||||
**登录后:**
|
||||
```javascript
|
||||
WebRTC 环境检测: { isHttps: true, ... }
|
||||
签名获取成功: { doctorUserId: "doctor_1", patientUserId: "patient_2", ... }
|
||||
TUICallKit init 方法调用完成
|
||||
初始化完成,显示通话界面
|
||||
```
|
||||
|
||||
**发起通话后:**
|
||||
```javascript
|
||||
=== 发起一对一通话 ===
|
||||
后端返回的 patientUserId: patient_2
|
||||
最终使用的 targetUserId: patient_2
|
||||
通话已发起,目标用户: patient_2
|
||||
>>> 正在呼叫
|
||||
```
|
||||
|
||||
**接通后:**
|
||||
```javascript
|
||||
>>> 通话已接通
|
||||
```
|
||||
|
||||
### 小程序端日志
|
||||
|
||||
**登录后:**
|
||||
```javascript
|
||||
=== 页面已挂载 ===
|
||||
=== 已设置详细日志级别 ===
|
||||
获取签名成功: { userId: "patient_2", ... }
|
||||
TUICallKit 初始化成功, userId: patient_2
|
||||
=== TUICallKit 初始化完成,等待来电 ===
|
||||
```
|
||||
|
||||
**收到来电后:**
|
||||
```javascript
|
||||
[tuikit engine wasm] onNewMessageReceived
|
||||
=== 通话状态变化 === calling
|
||||
=== 通话角色 === callee
|
||||
=== 通话类型 === 2
|
||||
```
|
||||
|
||||
## 🎯 成功标准
|
||||
|
||||
当你看到以下情况时,说明升级成功:
|
||||
|
||||
### Web 端
|
||||
- ✅ 编译成功,没有错误
|
||||
- ✅ 可以正常登录
|
||||
- ✅ 可以打开通话窗口
|
||||
- ✅ 可以初始化 TUICallKit
|
||||
- ✅ 可以发起通话
|
||||
- ✅ 状态回调正常
|
||||
|
||||
### 小程序端
|
||||
- ✅ 可以正常登录
|
||||
- ✅ 可以接收来电
|
||||
- ✅ 状态监听正常
|
||||
- ✅ 可以正常接听
|
||||
|
||||
### 互通测试
|
||||
- ✅ Web → 小程序通话正常
|
||||
- ✅ 小程序 → Web 通话正常
|
||||
- ✅ 视频和音频都正常
|
||||
- ✅ 通话稳定,不掉线
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果测试失败,请提供:
|
||||
|
||||
1. **安装日志**
|
||||
```bash
|
||||
npm install > install.log 2>&1
|
||||
```
|
||||
|
||||
2. **编译日志**
|
||||
- 完整的编译错误信息
|
||||
- 截图
|
||||
|
||||
3. **运行时日志**
|
||||
- 浏览器控制台日志
|
||||
- 小程序控制台日志
|
||||
|
||||
4. **错误信息**
|
||||
- 错误代码
|
||||
- 错误提示
|
||||
- 完整的错误堆栈
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **UIKIT_UPGRADE_COMPLETE.md** - 完整升级说明
|
||||
- **WEB_UIKIT_UPGRADE_GUIDE.md** - Web 端升级指南
|
||||
- **USERID_FORMAT_CHECK.md** - userId 格式检查
|
||||
- **FINAL_TEST_CHECKLIST.md** - 完整测试清单
|
||||
|
||||
---
|
||||
|
||||
**最后更新:** 2024-03-04
|
||||
**版本:** 1.0
|
||||
@@ -0,0 +1,229 @@
|
||||
# 音视频通话功能使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
基于腾讯云 TRTC(实时音视频)和 TUICallKit 组件实现的医患音视频通话功能。
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
npm install @tencentcloud/call-uikit-vue
|
||||
```
|
||||
|
||||
### 2. 组件说明
|
||||
|
||||
- **位置**: `admin/src/components/video-call/index.vue`
|
||||
- **功能**:
|
||||
- 初始化 TUICallKit
|
||||
- 发起视频/语音通话
|
||||
- 监听通话状态
|
||||
- 记录通话时长
|
||||
|
||||
### 3. API 接口
|
||||
|
||||
- **位置**: `admin/src/api/tcm.ts`
|
||||
- **接口列表**:
|
||||
- `getCallSignature`: 获取通话签名
|
||||
- `startCall`: 发起通话
|
||||
- `endCall`: 结束通话
|
||||
- `getCallRecords`: 获取通话记录
|
||||
|
||||
### 4. 使用方式
|
||||
|
||||
在诊断列表页面点击"视频通话"按钮即可发起通话。
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 控制器
|
||||
|
||||
- **位置**: `server/app/adminapi/controller/tcm/DiagnosisController.php`
|
||||
- **方法**:
|
||||
- `getCallSignature()`: 生成腾讯云 UserSig
|
||||
- `startCall()`: 创建通话记录
|
||||
- `endCall()`: 更新通话记录
|
||||
- `getCallRecords()`: 查询通话记录
|
||||
|
||||
### 2. 逻辑层
|
||||
|
||||
- **位置**: `server/app/adminapi/logic/tcm/DiagnosisLogic.php`
|
||||
- **核心方法**:
|
||||
- `generateUserSig()`: 生成 UserSig 签名
|
||||
- `getTrtcConfig()`: 获取 TRTC 配置
|
||||
|
||||
### 3. 数据模型
|
||||
|
||||
- **位置**: `server/app/common/model/tcm/CallRecord.php`
|
||||
- **表名**: `la_tcm_call_record`
|
||||
- **字段**:
|
||||
- `diagnosis_id`: 诊单ID
|
||||
- `caller_id`: 呼叫方ID
|
||||
- `callee_id`: 被叫方ID
|
||||
- `call_type`: 通话类型(1-语音 2-视频)
|
||||
- `status`: 状态(1-进行中 2-已结束 3-未接听 4-已取消)
|
||||
- `start_time`: 开始时间
|
||||
- `end_time`: 结束时间
|
||||
- `duration`: 通话时长
|
||||
|
||||
### 4. 数据库迁移
|
||||
|
||||
```bash
|
||||
# 执行 SQL 文件创建通话记录表
|
||||
mysql -u root -p database_name < server/sql/tcm_call_record.sql
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 1. 腾讯云 TRTC 配置
|
||||
|
||||
在腾讯云控制台获取配置信息:https://console.cloud.tencent.com/trtc
|
||||
|
||||
### 2. 后端配置
|
||||
|
||||
编辑 `server/.env` 文件,添加以下配置:
|
||||
|
||||
```env
|
||||
# 腾讯云实时音视频(TRTC)配置
|
||||
TRTC_SDK_APP_ID=你的SDKAppID
|
||||
TRTC_SECRET_KEY=你的密钥
|
||||
TRTC_ENABLE=true
|
||||
```
|
||||
|
||||
或者直接修改 `server/config/project.php`:
|
||||
|
||||
```php
|
||||
'trtc' => [
|
||||
'sdkAppId' => 1400000000, // 你的 SDKAppID
|
||||
'secretKey' => 'your_secret_key', // 你的密钥
|
||||
'expireTime' => 86400, // UserSig 过期时间(秒)
|
||||
'enable' => true,
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 权限配置
|
||||
|
||||
确保管理员拥有 `tcm.diagnosis/video-call` 权限。
|
||||
|
||||
## API 接口文档
|
||||
|
||||
### 1. 获取通话签名
|
||||
|
||||
**接口**: `POST /adminapi/tcm.diagnosis/getCallSignature`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"diagnosis_id": 1,
|
||||
"patient_id": 10001
|
||||
}
|
||||
```
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"sdkAppId": 1400000000,
|
||||
"userId": "doctor_1",
|
||||
"userSig": "eJw1jk...",
|
||||
"expireTime": 86400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 发起通话
|
||||
|
||||
**接口**: `POST /adminapi/tcm.diagnosis/startCall`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"diagnosis_id": 1,
|
||||
"patient_id": 10001,
|
||||
"call_type": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 结束通话
|
||||
|
||||
**接口**: `POST /adminapi/tcm.diagnosis/endCall`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"diagnosis_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 获取通话记录
|
||||
|
||||
**接口**: `GET /adminapi/tcm.diagnosis/getCallRecords`
|
||||
|
||||
**请求参数**:
|
||||
```
|
||||
?diagnosis_id=1
|
||||
```
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"diagnosis_id": 1,
|
||||
"caller_id": 1,
|
||||
"caller_type": "doctor",
|
||||
"callee_id": 10001,
|
||||
"callee_type": "patient",
|
||||
"call_type": 2,
|
||||
"status": 2,
|
||||
"start_time": 1709222400,
|
||||
"end_time": 1709222700,
|
||||
"duration": 300,
|
||||
"start_time_text": "2024-03-01 10:00:00",
|
||||
"end_time_text": "2024-03-01 10:05:00",
|
||||
"duration_text": "5分0秒"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**: UserSig 包含敏感信息,不要在前端硬编码
|
||||
2. **过期时间**: UserSig 默认24小时过期,需要定期刷新
|
||||
3. **网络要求**: 音视频通话需要良好的网络环境
|
||||
4. **浏览器兼容**: 建议使用 Chrome、Edge、Safari 等现代浏览器
|
||||
5. **权限申请**: 需要用户授权摄像头和麦克风权限
|
||||
6. **费用**: 腾讯云 TRTC 按使用量计费,请注意成本控制
|
||||
|
||||
## 测试流程
|
||||
|
||||
1. 配置腾讯云 TRTC 参数
|
||||
2. 执行数据库迁移
|
||||
3. 安装前端依赖
|
||||
4. 在诊断列表页面点击"视频通话"
|
||||
5. 授权摄像头和麦克风
|
||||
6. 等待患者接听(需要患者端也集成 TUICallKit)
|
||||
|
||||
## 扩展功能
|
||||
|
||||
可以基于此功能扩展:
|
||||
|
||||
- 通话录制
|
||||
- 通话质量监控
|
||||
- 通话记录回放
|
||||
- 多人会议
|
||||
- 屏幕共享
|
||||
- 实时字幕
|
||||
|
||||
## 技术支持
|
||||
|
||||
- 腾讯云 TRTC 文档: https://cloud.tencent.com/document/product/647
|
||||
- TUICallKit 文档: https://cloud.tencent.com/document/product/647/78742
|
||||
- 问题反馈: 提交 Issue 到项目仓库
|
||||
@@ -0,0 +1,147 @@
|
||||
# 医生排班管理 - 数据源更新说明
|
||||
|
||||
## 更新内容
|
||||
|
||||
医生数据已更新为从系统管理员表获取,不再使用独立的医生表。
|
||||
|
||||
## 数据来源
|
||||
|
||||
### 医生数据
|
||||
- **表名**: `zyt_admin`
|
||||
- **筛选条件**: `role_id = 1`(医生角色)
|
||||
- **字段映射**:
|
||||
- `id` → 医生ID
|
||||
- `name` 或 `account` → 医生姓名
|
||||
|
||||
### 排班数据
|
||||
- **表名**: `la_doctor_roster`
|
||||
- **关联字段**: `doctor_id` 对应 `zyt_admin.id`
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
只需要一张排班表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `la_doctor_roster` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`doctor_id` int(11) NOT NULL COMMENT '医生ID(对应 zyt_admin.id)',
|
||||
`date` date NOT NULL COMMENT '日期',
|
||||
`period` varchar(20) NOT NULL COMMENT '时段 morning/afternoon',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1-出诊 2-停诊 3-休息 4-请假',
|
||||
`quota` int(11) DEFAULT '0' COMMENT '号源数',
|
||||
`max_patients` int(11) DEFAULT '0' COMMENT '最大接诊数',
|
||||
`booked_count` int(11) DEFAULT '0' COMMENT '已预约数',
|
||||
`remark` varchar(500) DEFAULT '',
|
||||
`create_time` int(11) NOT NULL,
|
||||
`update_time` int(11) NOT NULL,
|
||||
`delete_time` int(11) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_doctor_date_period` (`doctor_id`,`date`,`period`,`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
## API 调用
|
||||
|
||||
### 获取医生列表
|
||||
|
||||
```typescript
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
|
||||
// 获取所有医生(role_id=1)
|
||||
const res = await adminLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
})
|
||||
```
|
||||
|
||||
### 获取排班数据
|
||||
|
||||
```typescript
|
||||
import { rosterLists } from '@/api/doctor'
|
||||
|
||||
const res = await rosterLists({
|
||||
start_date: '2024-03-04',
|
||||
end_date: '2024-03-10'
|
||||
})
|
||||
```
|
||||
|
||||
### 保存排班
|
||||
|
||||
```typescript
|
||||
import { rosterSave } from '@/api/doctor'
|
||||
|
||||
await rosterSave({
|
||||
id: 1, // 可选,有则更新,无则新增
|
||||
doctor_id: 1,
|
||||
date: '2024-03-04',
|
||||
period: 'morning',
|
||||
status: 1,
|
||||
quota: 20
|
||||
})
|
||||
```
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 加载医生列表
|
||||
|
||||
```typescript
|
||||
const loadDoctors = async () => {
|
||||
const res = await adminLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
})
|
||||
|
||||
// 转换为表格数据
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
doctorName: doctor.name || doctor.account,
|
||||
rosters: {}
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### 加载排班数据
|
||||
|
||||
```typescript
|
||||
const loadRosterData = async () => {
|
||||
const res = await rosterLists({
|
||||
start_date: currentWeekStart.value.format('YYYY-MM-DD'),
|
||||
end_date: currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
|
||||
})
|
||||
|
||||
// 填充排班数据到医生行
|
||||
const rosters = res?.lists || []
|
||||
tableData.value.forEach(doctor => {
|
||||
doctor.rosters = {}
|
||||
rosters.forEach((roster: any) => {
|
||||
if (roster.doctor_id === doctor.doctorId) {
|
||||
const key = `${roster.date}_${roster.period}`
|
||||
doctor.rosters[key] = {
|
||||
id: roster.id,
|
||||
status: roster.status,
|
||||
quota: roster.quota
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保 `zyt_admin` 表中有 `role_id=1` 的用户(医生)
|
||||
2. 排班表的 `doctor_id` 必须对应 `zyt_admin` 表中存在的医生ID
|
||||
3. 删除排班时使用软删除(设置 `delete_time`)
|
||||
4. 同一医生同一天同一时段只能有一条排班记录(通过唯一索引保证)
|
||||
|
||||
## 后端接口要求
|
||||
|
||||
后端需要实现以下接口:
|
||||
|
||||
1. `GET /doctor.roster/lists` - 获取排班列表
|
||||
2. `POST /doctor.roster/save` - 保存排班(新增或更新)
|
||||
3. `POST /doctor.roster/delete` - 删除排班
|
||||
|
||||
详细接口文档请参考 `DOCTOR_ROSTER.md`。
|
||||
@@ -0,0 +1,406 @@
|
||||
# 音视频通话测试指南
|
||||
|
||||
## 问题说明
|
||||
|
||||
当前错误的根本原因:
|
||||
|
||||
```
|
||||
目标用户 patient_1 没有在腾讯云 TRTC 中登录/初始化
|
||||
```
|
||||
|
||||
### 为什么会出现这个错误?
|
||||
|
||||
1. **医生端**(当前系统)已经初始化:`doctor_1`
|
||||
2. **患者端**需要呼叫:`patient_1`
|
||||
3. **问题**:`patient_1` 没有登录 TRTC,无法接听
|
||||
|
||||
这就像打电话:
|
||||
- 你(医生)的手机已经开机 ✅
|
||||
- 对方(患者)的手机没开机 ❌
|
||||
- 所以无法接通
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案 1: 双浏览器测试(推荐)
|
||||
|
||||
使用两个浏览器窗口模拟医生和患者:
|
||||
|
||||
#### 步骤 1: 创建测试页面
|
||||
|
||||
创建一个简单的患者端测试页面:
|
||||
|
||||
```html
|
||||
<!-- public/patient-test.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>患者端测试</title>
|
||||
<script src="https://web.sdk.qcloud.com/trtc/webrtc/v5/TUICallKit.iife.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>患者端 - 等待来电</h1>
|
||||
<div id="status">初始化中...</div>
|
||||
<div id="TUICallKit"></div>
|
||||
|
||||
<script>
|
||||
// 配置信息(从后端获取)
|
||||
const config = {
|
||||
SDKAppID: 你的SDKAppID, // 替换为实际值
|
||||
userID: 'patient_1',
|
||||
userSig: '从后端获取的userSig' // 需要为 patient_1 生成
|
||||
};
|
||||
|
||||
// 初始化
|
||||
TUICallKitServer.init({
|
||||
userID: config.userID,
|
||||
userSig: config.userSig,
|
||||
SDKAppID: config.SDKAppID
|
||||
}).then(() => {
|
||||
document.getElementById('status').textContent = '等待来电...';
|
||||
console.log('患者端初始化成功,等待来电');
|
||||
}).catch(error => {
|
||||
document.getElementById('status').textContent = '初始化失败: ' + error.message;
|
||||
console.error('初始化失败:', error);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### 步骤 2: 后端添加获取患者签名的接口
|
||||
|
||||
```php
|
||||
// server/app/adminapi/controller/tcm/DiagnosisController.php
|
||||
|
||||
/**
|
||||
* @notes 获取患者通话签名(用于测试)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getPatientSignature()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
|
||||
if (empty($params['patient_id'])) {
|
||||
return $this->fail('患者ID不能为空');
|
||||
}
|
||||
|
||||
$result = DiagnosisLogic::getPatientSignature($params);
|
||||
if ($result) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// server/app/adminapi/logic/tcm/DiagnosisLogic.php
|
||||
|
||||
/**
|
||||
* @notes 获取患者通话签名
|
||||
* @param array $params
|
||||
* @return array|bool
|
||||
*/
|
||||
public static function getPatientSignature(array $params)
|
||||
{
|
||||
try {
|
||||
$config = self::getTrtcConfig();
|
||||
|
||||
if (!$config) {
|
||||
self::setError('请先配置腾讯云TRTC参数');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 患者的 userId
|
||||
$userId = 'patient_' . $params['patient_id'];
|
||||
|
||||
// 生成 UserSig
|
||||
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $userId);
|
||||
|
||||
if (!$userSig) {
|
||||
self::setError('生成签名失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'sdkAppId' => $config['sdkAppId'],
|
||||
'userId' => $userId,
|
||||
'userSig' => $userSig,
|
||||
'expireTime' => 86400
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 步骤 3: 测试流程
|
||||
|
||||
1. **打开患者端**
|
||||
- 浏览器 1:访问 `http://localhost/patient-test.html?patient_id=1`
|
||||
- 获取签名并初始化
|
||||
- 等待来电
|
||||
|
||||
2. **打开医生端**
|
||||
- 浏览器 2:登录后台管理系统
|
||||
- 进入诊断列表
|
||||
- 点击"视频通话"
|
||||
- 发起通话
|
||||
|
||||
3. **验证**
|
||||
- 患者端应该收到来电提示
|
||||
- 点击接听
|
||||
- 双方可以进行视频通话
|
||||
|
||||
### 方案 2: 使用腾讯云 Demo(快速测试)
|
||||
|
||||
1. 访问腾讯云官方 Demo:
|
||||
```
|
||||
https://web.sdk.qcloud.com/trtc/webrtc/demo/latest/official-demo/index.html
|
||||
```
|
||||
|
||||
2. 输入你的配置:
|
||||
- SDKAppID
|
||||
- SecretKey
|
||||
- UserID: `patient_1`
|
||||
|
||||
3. 点击"进入房间"
|
||||
|
||||
4. 在你的系统中发起通话
|
||||
|
||||
### 方案 3: 自测试(单用户测试)
|
||||
|
||||
如果只是想测试初始化是否成功,可以呼叫自己:
|
||||
|
||||
```typescript
|
||||
// 修改前端代码,呼叫自己
|
||||
await TUICallKitServer.call({
|
||||
userID: res.userId, // 呼叫自己
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
```
|
||||
|
||||
这样可以验证:
|
||||
- ✅ 初始化是否成功
|
||||
- ✅ 签名是否正确
|
||||
- ✅ 配置是否正确
|
||||
- ❌ 但无法测试真实的通话流程
|
||||
|
||||
## 完整的测试方案
|
||||
|
||||
### 创建患者端测试组件
|
||||
|
||||
```vue
|
||||
<!-- admin/src/views/test/patient-call.vue -->
|
||||
<template>
|
||||
<div class="patient-test">
|
||||
<el-card>
|
||||
<h2>患者端测试 - 接听来电</h2>
|
||||
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="患者ID">
|
||||
<el-input v-model="form.patientId" placeholder="输入患者ID,如:1" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="initPatient" :loading="loading">
|
||||
初始化患者端
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="initialized" class="status">
|
||||
<el-alert type="success" :closable="false">
|
||||
患者端已初始化,等待来电...
|
||||
<br>
|
||||
患者ID: {{ currentUserId }}
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div id="TUICallKit" class="call-container"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { TUICallKitServer, TUICallType } from '@tencentcloud/call-uikit-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const form = ref({
|
||||
patientId: '1'
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const currentUserId = ref('')
|
||||
|
||||
const initPatient = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 获取患者签名
|
||||
const response = await fetch(`/adminapi/tcm.diagnosis/getPatientSignature?patient_id=${form.value.patientId}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code !== 1) {
|
||||
throw new Error(result.msg || '获取签名失败')
|
||||
}
|
||||
|
||||
const { sdkAppId, userId, userSig } = result.data
|
||||
currentUserId.value = userId
|
||||
|
||||
// 初始化
|
||||
await TUICallKitServer.init({
|
||||
userID: userId,
|
||||
userSig: userSig,
|
||||
SDKAppID: sdkAppId
|
||||
})
|
||||
|
||||
// 等待初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
initialized.value = true
|
||||
ElMessage.success('患者端初始化成功,等待来电...')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('初始化失败:', error)
|
||||
ElMessage.error(error.message || '初始化失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.patient-test {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.call-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 添加路由
|
||||
|
||||
```typescript
|
||||
// admin/src/router/index.ts
|
||||
{
|
||||
path: '/test/patient-call',
|
||||
name: 'PatientCallTest',
|
||||
component: () => import('@/views/test/patient-call.vue'),
|
||||
meta: { title: '患者端测试' }
|
||||
}
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 完整测试流程
|
||||
|
||||
1. **准备两个浏览器窗口**
|
||||
- 窗口 1:Chrome(医生端)
|
||||
- 窗口 2:Chrome 无痕模式(患者端)
|
||||
|
||||
2. **初始化患者端**
|
||||
- 在窗口 2 中访问:`http://localhost:5173/#/test/patient-call`
|
||||
- 输入患者ID:1
|
||||
- 点击"初始化患者端"
|
||||
- 等待提示"患者端初始化成功"
|
||||
|
||||
3. **发起通话**
|
||||
- 在窗口 1 中进入诊断列表
|
||||
- 找到患者ID为1的诊单
|
||||
- 点击"视频通话"
|
||||
- 点击"开始通话"
|
||||
|
||||
4. **接听通话**
|
||||
- 窗口 2 应该收到来电提示
|
||||
- 点击"接听"
|
||||
- 开始视频通话
|
||||
|
||||
5. **验证功能**
|
||||
- ✅ 视频画面正常
|
||||
- ✅ 音频正常
|
||||
- ✅ 可以挂断
|
||||
- ✅ 通话记录保存
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 患者端没有收到来电
|
||||
|
||||
**可能原因**:
|
||||
1. 患者端没有初始化
|
||||
2. 患者ID不匹配
|
||||
3. 网络问题
|
||||
|
||||
**解决方案**:
|
||||
```javascript
|
||||
// 在患者端控制台检查
|
||||
console.log('当前用户ID:', TUICallKitServer.userID)
|
||||
// 应该显示: patient_1
|
||||
```
|
||||
|
||||
### Q2: 提示用户不存在
|
||||
|
||||
**原因**: UserSig 生成的 userId 和呼叫的 userId 不一致
|
||||
|
||||
**解决方案**: 确保:
|
||||
```typescript
|
||||
// 医生端
|
||||
const doctorUserId = 'doctor_1' // 医生登录时使用
|
||||
|
||||
// 患者端
|
||||
const patientUserId = 'patient_1' // 患者登录时使用
|
||||
|
||||
// 呼叫时
|
||||
await TUICallKitServer.call({
|
||||
userID: 'patient_1' // 必须和患者登录的 userId 一致
|
||||
})
|
||||
```
|
||||
|
||||
### Q3: 两个窗口都是同一个用户
|
||||
|
||||
**原因**: 使用了相同的浏览器和相同的 localStorage
|
||||
|
||||
**解决方案**: 使用无痕模式或不同的浏览器
|
||||
|
||||
## 生产环境部署
|
||||
|
||||
在生产环境中,需要:
|
||||
|
||||
1. **患者端应用**
|
||||
- 开发患者端 H5/小程序/App
|
||||
- 集成 TUICallKit
|
||||
- 实现登录和来电监听
|
||||
|
||||
2. **推送通知**
|
||||
- 当医生发起通话时,推送通知给患者
|
||||
- 患者点击通知进入通话界面
|
||||
|
||||
3. **在线状态管理**
|
||||
- 检查患者是否在线
|
||||
- 如果不在线,提示医生
|
||||
|
||||
4. **通话记录**
|
||||
- 保存通话记录
|
||||
- 统计通话时长
|
||||
- 生成通话报表
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [TUICallKit 快速集成](https://cloud.tencent.com/document/product/647/78742)
|
||||
- [多端互通](https://cloud.tencent.com/document/product/647/78769)
|
||||
- [常见问题](https://cloud.tencent.com/document/product/647/78769#3a61f42b-e06f-49af-88bf-362d40025887)
|
||||
@@ -0,0 +1,415 @@
|
||||
# 音视频通话功能 - 故障排查指南
|
||||
|
||||
## 常见错误及解决方案
|
||||
|
||||
### 1. 导入错误:does not provide an export named 'default'
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
SyntaxError: The requested module '/admin/node_modules/.vite/deps/@tencentcloud_call-uikit-vue.js?v=xxx' does not provide an export named 'default'
|
||||
```
|
||||
|
||||
**原因**: TUICallKit 不支持默认导入,需要使用命名导入。
|
||||
|
||||
**解决方案**:
|
||||
```typescript
|
||||
// ❌ 错误的导入方式
|
||||
import TUICallKit from '@tencentcloud/call-uikit-vue'
|
||||
|
||||
// ✅ 正确的导入方式
|
||||
import { TUICallKit, TUICallKitServer, TUICallType } from '@tencentcloud/call-uikit-vue'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 类型错误:Property 'init' does not exist
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
Property 'init' does not exist on type 'typeof import(...)'
|
||||
```
|
||||
|
||||
**原因**: TUICallKit 是一个组件,不是通过 `init` 方法初始化的。
|
||||
|
||||
**解决方案**:
|
||||
```typescript
|
||||
// ❌ 错误的使用方式
|
||||
const tuiCallKit = TUICallKit.init({ ... })
|
||||
|
||||
// ✅ 正确的使用方式
|
||||
// 1. 在模板中使用组件
|
||||
<TUICallKit class="tui-call-kit" />
|
||||
|
||||
// 2. 使用 TUICallKitServer 进行初始化和调用
|
||||
await TUICallKitServer.init({
|
||||
userID: 'user_123',
|
||||
userSig: 'xxx',
|
||||
SDKAppID: 1400000000
|
||||
})
|
||||
|
||||
await TUICallKitServer.call({
|
||||
userID: 'target_user',
|
||||
type: TUICallType.VIDEO_CALL
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取签名失败
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
获取签名失败
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
1. 后端配置错误
|
||||
2. SDKAppID 或 SecretKey 不正确
|
||||
3. 网络请求失败
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. 检查后端配置
|
||||
```bash
|
||||
# 查看 .env 文件
|
||||
cat server/.env | grep TRTC
|
||||
|
||||
# 应该看到:
|
||||
# TRTC_SDK_APP_ID=1400000000
|
||||
# TRTC_SECRET_KEY=your_secret_key
|
||||
# TRTC_ENABLE=true
|
||||
```
|
||||
|
||||
2. 测试 API 接口
|
||||
```bash
|
||||
curl -X POST http://localhost/adminapi/tcm.diagnosis/getCallSignature \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"diagnosis_id": 1, "patient_id": 10001}'
|
||||
```
|
||||
|
||||
3. 查看后端日志
|
||||
```bash
|
||||
tail -f server/runtime/log/error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 摄像头/麦克风权限被拒绝
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
NotAllowedError: Permission denied
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查浏览器权限设置
|
||||
- Chrome: 地址栏左侧 → 网站设置 → 摄像头/麦克风
|
||||
- Edge: 同 Chrome
|
||||
- Safari: Safari → 偏好设置 → 网站 → 摄像头/麦克风
|
||||
|
||||
2. 确保使用 HTTPS 或 localhost
|
||||
```
|
||||
✅ https://your-domain.com
|
||||
✅ http://localhost:5173
|
||||
❌ http://192.168.1.100:5173
|
||||
```
|
||||
|
||||
3. 重新授权
|
||||
- 刷新页面
|
||||
- 清除浏览器缓存
|
||||
- 重新点击"允许"
|
||||
|
||||
---
|
||||
|
||||
### 5. 通话无画面
|
||||
|
||||
**可能原因**:
|
||||
1. 摄像头被其他应用占用
|
||||
2. 摄像头驱动问题
|
||||
3. 网络连接问题
|
||||
|
||||
**排查步骤**:
|
||||
|
||||
1. 测试摄像头
|
||||
```javascript
|
||||
// 在浏览器控制台执行
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||
.then(stream => {
|
||||
console.log('摄像头正常', stream)
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
})
|
||||
.catch(err => console.error('摄像头错误', err))
|
||||
```
|
||||
|
||||
2. 检查网络连接
|
||||
- 打开浏览器开发者工具 → Network
|
||||
- 查看是否有请求失败
|
||||
- 检查 WebSocket 连接状态
|
||||
|
||||
3. 查看控制台错误
|
||||
- F12 打开开发者工具
|
||||
- 查看 Console 标签页
|
||||
- 查找红色错误信息
|
||||
|
||||
---
|
||||
|
||||
### 6. 对方无法接听
|
||||
|
||||
**可能原因**:
|
||||
1. 对方未登录系统
|
||||
2. 对方的 userId 不正确
|
||||
3. 对方未集成 TUICallKit
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认对方 userId
|
||||
```typescript
|
||||
// 确保 userId 格式正确
|
||||
const userId = `patient_${patientId}` // 例如: patient_10001
|
||||
```
|
||||
|
||||
2. 检查对方是否在线
|
||||
- 对方需要登录系统
|
||||
- 对方需要初始化 TUICallKit
|
||||
- 对方需要监听来电事件
|
||||
|
||||
3. 测试环境
|
||||
- 可以在两个浏览器窗口测试
|
||||
- 使用不同的 userId 登录
|
||||
|
||||
---
|
||||
|
||||
### 7. 通话中断/掉线
|
||||
|
||||
**可能原因**:
|
||||
1. 网络不稳定
|
||||
2. 防火墙阻止
|
||||
3. 服务器问题
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查网络质量
|
||||
- 使用有线网络
|
||||
- 关闭其他占用带宽的应用
|
||||
- 测试网络延迟
|
||||
|
||||
2. 配置防火墙
|
||||
- 允许 UDP 端口
|
||||
- 允许 WebRTC 连接
|
||||
- 检查企业防火墙设置
|
||||
|
||||
3. 查看 TRTC 控制台
|
||||
- 登录腾讯云控制台
|
||||
- 查看实时监控
|
||||
- 检查用量和质量
|
||||
|
||||
---
|
||||
|
||||
### 8. 初始化未完成错误
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
API<getDeviceList>: init or login is not complete, this API needs to be used after init.
|
||||
TUICallEngine 初始化登录未完成,需要在 init 或 login 完成后使用此 API
|
||||
<ERROR_INIT_FAIL: -1201>
|
||||
```
|
||||
|
||||
**原因**: TUICallKit 组件在初始化完成之前就被渲染了。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
已在代码中修复,确保:
|
||||
|
||||
1. 初始化完成后再显示组件
|
||||
```typescript
|
||||
// 初始化
|
||||
await TUICallKitServer.init({ ... })
|
||||
|
||||
// 等待初始化完全完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 设置标志,显示组件
|
||||
isInitialized.value = true
|
||||
|
||||
// 等待组件渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// 发起通话
|
||||
await TUICallKitServer.call({ ... })
|
||||
```
|
||||
|
||||
2. 使用条件渲染
|
||||
```vue
|
||||
<!-- 只在初始化完成后显示组件 -->
|
||||
<TUICallKit v-if="isInitialized" class="tui-call-kit" />
|
||||
```
|
||||
|
||||
3. 添加状态提示
|
||||
```vue
|
||||
<div v-if="!isInitialized" class="call-waiting">
|
||||
<p>{{ statusText }}</p>
|
||||
<!-- 显示:正在获取签名... → 正在初始化... → 初始化完成 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**验证修复**:
|
||||
1. 点击"视频通话"按钮
|
||||
2. 观察状态提示的变化
|
||||
3. 等待初始化完成
|
||||
4. 应该不再出现 ERROR_INIT_FAIL 错误
|
||||
|
||||
---
|
||||
|
||||
### 9. 编译错误
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
Module not found: Can't resolve '@tencentcloud/call-uikit-vue'
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 安装依赖
|
||||
```bash
|
||||
cd admin
|
||||
npm install @tencentcloud/call-uikit-vue
|
||||
```
|
||||
|
||||
2. 清除缓存
|
||||
```bash
|
||||
rm -rf node_modules/.vite
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 检查 package.json
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@tencentcloud/call-uikit-vue": "^2.3.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. 样式问题
|
||||
|
||||
**问题**: TUICallKit 组件样式不正常
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 导入样式文件
|
||||
```typescript
|
||||
// 在 main.ts 中添加
|
||||
import '@tencentcloud/call-uikit-vue/dist/style.css'
|
||||
```
|
||||
|
||||
2. 设置容器尺寸
|
||||
```vue
|
||||
<TUICallKit style="width: 650px; height: 500px;" />
|
||||
```
|
||||
|
||||
3. 检查 CSS 冲突
|
||||
- 使用浏览器开发者工具检查元素
|
||||
- 查看是否有样式被覆盖
|
||||
- 调整 z-index
|
||||
|
||||
---
|
||||
|
||||
### 10. 性能问题
|
||||
|
||||
**问题**: 通话卡顿、延迟高
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 优化视频参数
|
||||
```typescript
|
||||
await TUICallKitServer.call({
|
||||
userID: 'target_user',
|
||||
type: TUICallType.VIDEO_CALL,
|
||||
// 可以添加更多配置
|
||||
})
|
||||
```
|
||||
|
||||
2. 降低视频质量
|
||||
- 使用语音通话替代视频通话
|
||||
- 降低视频分辨率
|
||||
- 降低帧率
|
||||
|
||||
3. 优化网络
|
||||
- 使用有线网络
|
||||
- 关闭其他应用
|
||||
- 选择就近的服务器
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 启用详细日志
|
||||
|
||||
```typescript
|
||||
// 在初始化前设置
|
||||
TUICallKitServer.setLogLevel(0) // 0-详细 1-普通 2-警告 3-错误
|
||||
```
|
||||
|
||||
### 2. 监听所有事件
|
||||
|
||||
```typescript
|
||||
// 监听通话开始
|
||||
TUICallKitServer.on('onCallBegin', () => {
|
||||
console.log('通话开始')
|
||||
})
|
||||
|
||||
// 监听通话结束
|
||||
TUICallKitServer.on('onCallEnd', () => {
|
||||
console.log('通话结束')
|
||||
})
|
||||
|
||||
// 监听错误
|
||||
TUICallKitServer.on('onError', (error) => {
|
||||
console.error('通话错误', error)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 使用浏览器开发者工具
|
||||
|
||||
1. 打开开发者工具 (F12)
|
||||
2. 查看 Console 标签页
|
||||
3. 查看 Network 标签页
|
||||
4. 查看 Application → Local Storage
|
||||
|
||||
### 4. 查看后端日志
|
||||
|
||||
```bash
|
||||
# 实时查看错误日志
|
||||
tail -f server/runtime/log/error.log
|
||||
|
||||
# 查看最近的日志
|
||||
tail -n 100 server/runtime/log/error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 联系支持
|
||||
|
||||
如果以上方法都无法解决问题,请提供以下信息:
|
||||
|
||||
1. 错误截图或日志
|
||||
2. 浏览器版本和操作系统
|
||||
3. 操作步骤
|
||||
4. 配置信息(脱敏后)
|
||||
5. 网络环境
|
||||
|
||||
联系方式:
|
||||
- 技术支持邮箱: support@example.com
|
||||
- 腾讯云工单: https://console.cloud.tencent.com/workorder
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [TUICallKit 官方文档](https://cloud.tencent.com/document/product/647/78742)
|
||||
- [TRTC 常见问题](https://cloud.tencent.com/document/product/647/43018)
|
||||
- [WebRTC 调试指南](https://webrtc.org/getting-started/testing)
|
||||
@@ -0,0 +1,110 @@
|
||||
# TUICallKit 升级步骤
|
||||
|
||||
## 当前状态
|
||||
- 旧版本: 2.5.2
|
||||
- 新版本: 4.0.12
|
||||
- package.json 已更新
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 删除旧依赖
|
||||
```bash
|
||||
cd admin
|
||||
rm -rf node_modules
|
||||
rm package-lock.json
|
||||
```
|
||||
|
||||
### 2. 安装新版本
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 代码修复
|
||||
|
||||
#### 问题1: `onError` 回调不存在
|
||||
|
||||
在 `admin/src/components/video-call/index.vue` 中,需要移除 `onError` 回调:
|
||||
|
||||
```typescript
|
||||
// 旧代码(需要删除)
|
||||
TUICallKitServer.setCallback({
|
||||
statusChanged: handleStatusChange,
|
||||
afterCalling: handleAfterCalling,
|
||||
onError: handleCallError // ❌ 这个在 4.x 中不存在
|
||||
})
|
||||
|
||||
// 新代码
|
||||
TUICallKitServer.setCallback({
|
||||
statusChanged: handleStatusChange,
|
||||
afterCalling: handleAfterCalling
|
||||
// onError 已被移除,错误处理通过 try-catch 或 statusChanged 中的状态判断
|
||||
})
|
||||
```
|
||||
|
||||
#### 问题2: 错误处理方式变更
|
||||
|
||||
4.x 版本中,错误不再通过 `onError` 回调,而是:
|
||||
1. 通过 `try-catch` 捕获异步操作的错误
|
||||
2. 通过 `statusChanged` 回调中的状态变化判断错误情况
|
||||
|
||||
需要在代码中增强 try-catch 错误处理。
|
||||
|
||||
### 4. 测试清单
|
||||
|
||||
升级后需要测试:
|
||||
|
||||
- [ ] 组件能否正常初始化
|
||||
- [ ] 能否正常发起一对一视频通话
|
||||
- [ ] 能否正常发起群组视频通话
|
||||
- [ ] 对方拒绝时的提示是否正常
|
||||
- [ ] 对方未接听时的提示是否正常
|
||||
- [ ] 通话接通后的视频显示是否正常
|
||||
- [ ] 挂断功能是否正常
|
||||
- [ ] 窗口拖拽和调整大小是否正常
|
||||
- [ ] 错误提示是否友好
|
||||
|
||||
### 5. 可能的其他变更
|
||||
|
||||
根据官方文档,4.x 版本可能还有以下变更:
|
||||
|
||||
1. **初始化参数变化**
|
||||
- 检查 `TUICallKitServer.init()` 的参数是否有变化
|
||||
|
||||
2. **通话类型常量**
|
||||
- `TUICallType.VIDEO_CALL` 和 `TUICallType.AUDIO_CALL` 可能有变化
|
||||
|
||||
3. **状态常量**
|
||||
- `STATUS` 枚举值可能有变化
|
||||
|
||||
4. **组件属性**
|
||||
- `TUICallKit` 组件的 props 可能有变化
|
||||
|
||||
### 6. 如果遇到问题
|
||||
|
||||
1. 查看浏览器控制台的错误信息
|
||||
2. 查看官方文档: https://www.tencentcloud.com/document/product/647/50993
|
||||
3. 查看 GitHub Issues: https://github.com/tencentyun/TUICallKit/issues
|
||||
4. 如果无法解决,可以回滚到旧版本:
|
||||
```bash
|
||||
npm install @tencentcloud/call-uikit-vue@2.5.2
|
||||
```
|
||||
|
||||
## 升级后的代码修改
|
||||
|
||||
需要修改 `admin/src/components/video-call/index.vue`:
|
||||
|
||||
```typescript
|
||||
// 删除 handleCallError 函数的使用
|
||||
// 删除这一行:
|
||||
// onError: handleCallError
|
||||
|
||||
// 保留 handleCallError 函数用于其他地方的错误处理
|
||||
// 但不再作为回调注册
|
||||
```
|
||||
|
||||
## 完成后
|
||||
|
||||
1. 提交代码变更
|
||||
2. 在开发环境充分测试
|
||||
3. 部署到测试环境
|
||||
4. 确认无误后部署到生产环境
|
||||
@@ -0,0 +1,292 @@
|
||||
# WebRTC 生产环境问题解决方案
|
||||
|
||||
## 问题描述
|
||||
|
||||
在 `npm run build` 打包后,视频通话功能报错:
|
||||
|
||||
```
|
||||
【CallService】_handleError, errorCode: 60006
|
||||
errorMessage: 当前环境不支持 WebRTC
|
||||
isMediaDevicesSupported: false
|
||||
```
|
||||
|
||||
开发环境(`npm run dev`)正常,生产环境不正常。
|
||||
|
||||
## 问题原因
|
||||
|
||||
WebRTC 的 `getUserMedia` API 有安全限制:
|
||||
|
||||
1. **HTTPS 要求**:生产环境必须使用 HTTPS 协议
|
||||
2. **localhost 例外**:只有 `localhost` 和 `127.0.0.1` 可以在 HTTP 下使用
|
||||
3. **权限问题**:浏览器需要用户授权访问摄像头和麦克风
|
||||
|
||||
开发环境通常使用 `localhost`,所以正常。但生产环境如果使用 HTTP 协议的 IP 地址或域名,就会被浏览器阻止。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:使用 HTTPS(推荐)
|
||||
|
||||
#### 1.1 申请 SSL 证书
|
||||
|
||||
**免费证书**:
|
||||
- Let's Encrypt(推荐)
|
||||
- 阿里云免费证书
|
||||
- 腾讯云免费证书
|
||||
|
||||
**申请步骤**(以 Let's Encrypt 为例):
|
||||
|
||||
```bash
|
||||
# 安装 certbot
|
||||
sudo apt-get update
|
||||
sudo apt-get install certbot
|
||||
|
||||
# 申请证书
|
||||
sudo certbot certonly --standalone -d your-domain.com
|
||||
```
|
||||
|
||||
#### 1.2 配置 Nginx HTTPS
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# SSL 配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
location /admin/ {
|
||||
alias /path/to/admin/dist/;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
location /adminapi/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 更新前端配置
|
||||
|
||||
确保 API 请求也使用 HTTPS:
|
||||
|
||||
```typescript
|
||||
// admin/src/utils/request/index.ts
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_URL || 'https://your-domain.com'
|
||||
```
|
||||
|
||||
### 方案2:开发环境使用 HTTPS
|
||||
|
||||
如果需要在开发环境测试 HTTPS:
|
||||
|
||||
#### 2.1 生成自签名证书
|
||||
|
||||
```bash
|
||||
# 进入 admin 目录
|
||||
cd admin
|
||||
|
||||
# 创建证书目录
|
||||
mkdir -p .cert
|
||||
|
||||
# 生成自签名证书
|
||||
openssl req -x509 -newkey rsa:4096 -keyout .cert/key.pem -out .cert/cert.pem -days 365 -nodes
|
||||
```
|
||||
|
||||
#### 2.2 修改 vite.config.ts
|
||||
|
||||
```typescript
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: {
|
||||
key: fs.readFileSync('.cert/key.pem'),
|
||||
cert: fs.readFileSync('.cert/cert.pem')
|
||||
},
|
||||
hmr: true,
|
||||
open: true
|
||||
},
|
||||
// ... 其他配置
|
||||
})
|
||||
```
|
||||
|
||||
### 方案3:使用 localhost 访问
|
||||
|
||||
如果只是测试,可以通过 localhost 访问:
|
||||
|
||||
1. 在服务器上运行:`npm run preview`
|
||||
2. 使用 SSH 隧道转发:
|
||||
|
||||
```bash
|
||||
ssh -L 4173:localhost:4173 user@your-server
|
||||
```
|
||||
|
||||
3. 本地浏览器访问:`http://localhost:4173/admin/`
|
||||
|
||||
### 方案4:修改 hosts(临时方案)
|
||||
|
||||
将服务器 IP 映射到 localhost:
|
||||
|
||||
```bash
|
||||
# Windows: C:\Windows\System32\drivers\etc\hosts
|
||||
# Linux/Mac: /etc/hosts
|
||||
|
||||
127.0.0.1 your-domain.local
|
||||
```
|
||||
|
||||
然后通过 `http://your-domain.local` 访问。
|
||||
|
||||
## 检测 WebRTC 支持
|
||||
|
||||
### 在线检测
|
||||
|
||||
访问腾讯云提供的检测页面:
|
||||
https://web.sdk.qcloud.com/trtc/webrtc/demo/detect/index.html
|
||||
|
||||
### 代码检测
|
||||
|
||||
在视频通话组件中添加检测:
|
||||
|
||||
```typescript
|
||||
// admin/src/components/video-call/index.vue
|
||||
|
||||
const checkWebRTCSupport = () => {
|
||||
const checks = {
|
||||
isHttps: window.location.protocol === 'https:' || window.location.hostname === 'localhost',
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
hasRTCPeerConnection: !!window.RTCPeerConnection
|
||||
}
|
||||
|
||||
console.log('WebRTC Support:', checks)
|
||||
|
||||
if (!checks.isHttps) {
|
||||
feedback.msgError('视频通话需要 HTTPS 环境,请使用 HTTPS 访问')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!checks.hasGetUserMedia) {
|
||||
feedback.msgError('浏览器不支持访问摄像头和麦克风')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const open = async (info: CallInfo) => {
|
||||
// 先检查支持情况
|
||||
if (!checkWebRTCSupport()) {
|
||||
return
|
||||
}
|
||||
|
||||
// ... 其他代码
|
||||
}
|
||||
```
|
||||
|
||||
## 浏览器权限设置
|
||||
|
||||
### Chrome
|
||||
|
||||
1. 点击地址栏左侧的锁图标
|
||||
2. 选择"网站设置"
|
||||
3. 允许"摄像头"和"麦克风"权限
|
||||
|
||||
### Firefox
|
||||
|
||||
1. 点击地址栏左侧的锁图标
|
||||
2. 点击"连接安全"
|
||||
3. 点击"更多信息"
|
||||
4. 选择"权限"标签
|
||||
5. 允许"使用摄像头"和"使用麦克风"
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么开发环境正常,生产环境不行?
|
||||
|
||||
A: 开发环境使用 `localhost`,浏览器允许 HTTP 下使用 WebRTC。生产环境使用 IP 或域名,必须使用 HTTPS。
|
||||
|
||||
### Q2: 使用了 HTTPS 还是不行?
|
||||
|
||||
A: 检查:
|
||||
1. 证书是否有效(浏览器地址栏有绿色锁图标)
|
||||
2. 是否有混合内容(HTTPS 页面加载 HTTP 资源)
|
||||
3. 浏览器是否授权了摄像头和麦克风权限
|
||||
|
||||
### Q3: 如何在内网测试?
|
||||
|
||||
A:
|
||||
1. 使用自签名证书(需要浏览器信任)
|
||||
2. 使用 SSH 隧道转发到 localhost
|
||||
3. 修改 hosts 文件
|
||||
|
||||
### Q4: 移动端如何测试?
|
||||
|
||||
A:
|
||||
1. 必须使用 HTTPS
|
||||
2. 确保移动设备信任证书
|
||||
3. 在移动浏览器中授权摄像头和麦克风
|
||||
|
||||
## 推荐配置
|
||||
|
||||
### 生产环境
|
||||
|
||||
```
|
||||
协议: HTTPS
|
||||
域名: your-domain.com
|
||||
证书: Let's Encrypt 或商业证书
|
||||
Web服务器: Nginx with SSL
|
||||
```
|
||||
|
||||
### 测试环境
|
||||
|
||||
```
|
||||
协议: HTTPS (自签名证书)
|
||||
域名: test.your-domain.com
|
||||
或使用: localhost + SSH隧道
|
||||
```
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **检查协议**:确保使用 HTTPS
|
||||
```javascript
|
||||
console.log(window.location.protocol) // 应该是 "https:"
|
||||
```
|
||||
|
||||
2. **检查 API 支持**:
|
||||
```javascript
|
||||
console.log('getUserMedia:', !!navigator.mediaDevices?.getUserMedia)
|
||||
console.log('RTCPeerConnection:', !!window.RTCPeerConnection)
|
||||
```
|
||||
|
||||
3. **测试权限**:
|
||||
```javascript
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||
.then(() => console.log('权限已授予'))
|
||||
.catch(err => console.error('权限被拒绝:', err))
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
**最佳解决方案**:为生产环境配置 HTTPS
|
||||
|
||||
1. 申请 SSL 证书(Let's Encrypt 免费)
|
||||
2. 配置 Nginx HTTPS
|
||||
3. 更新前端 API 地址为 HTTPS
|
||||
4. 测试视频通话功能
|
||||
|
||||
这样可以确保在任何环境下都能正常使用 WebRTC 功能。
|
||||
@@ -0,0 +1,159 @@
|
||||
# Web 端 TUICallKit 升级指南
|
||||
|
||||
## 📦 版本变化
|
||||
|
||||
### 旧版本
|
||||
```json
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12"
|
||||
```
|
||||
|
||||
### 新版本
|
||||
```json
|
||||
"@trtc/calls-uikit-vue": "^4.2.2"
|
||||
```
|
||||
|
||||
## 🔄 主要变化
|
||||
|
||||
### 1. 包名变化
|
||||
- **旧包名:** `@tencentcloud/call-uikit-vue`
|
||||
- **新包名:** `@trtc/calls-uikit-vue`
|
||||
|
||||
### 2. 导入方式(保持不变)
|
||||
```typescript
|
||||
// 4.2.x 版本的导入方式
|
||||
import { TUICallKitServer, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||||
```
|
||||
|
||||
### 3. API 兼容性
|
||||
4.2.x 版本与 4.0.x 版本的 API 基本兼容,主要改进:
|
||||
- 更好的性能
|
||||
- 更稳定的连接
|
||||
- 修复了一些已知问题
|
||||
- 改进了错误处理
|
||||
|
||||
## ✅ 已更新的代码
|
||||
|
||||
### 更新内容
|
||||
1. ✅ 更新了导入语句
|
||||
2. ✅ 保持了原有的 API 调用方式
|
||||
3. ✅ 保持了原有的回调处理
|
||||
|
||||
### 无需更改的部分
|
||||
- `TUICallKitServer.init()` - 初始化方法
|
||||
- `TUICallKitServer.call()` - 发起通话方法
|
||||
- `TUICallKitServer.hangup()` - 挂断方法
|
||||
- `TUICallKitServer.setCallback()` - 设置回调
|
||||
- `TUICallKitServer.enableFloatWindow()` - 浮窗设置
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 步骤 1:安装依赖
|
||||
```bash
|
||||
cd admin
|
||||
npm install
|
||||
```
|
||||
|
||||
### 步骤 2:启动开发服务器
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 步骤 3:测试通话功能
|
||||
1. 登录系统
|
||||
2. 打开患者详情页
|
||||
3. 点击"视频通话"按钮
|
||||
4. 确认通话正常
|
||||
|
||||
## 📋 检查清单
|
||||
|
||||
### 编译检查
|
||||
- [ ] 没有编译错误
|
||||
- [ ] 没有类型错误
|
||||
- [ ] 没有导入错误
|
||||
|
||||
### 功能检查
|
||||
- [ ] 可以正常初始化
|
||||
- [ ] 可以发起通话
|
||||
- [ ] 可以接听通话
|
||||
- [ ] 可以挂断通话
|
||||
- [ ] 视频和音频正常
|
||||
- [ ] 状态回调正常
|
||||
|
||||
### 兼容性检查
|
||||
- [ ] Chrome 浏览器正常
|
||||
- [ ] Edge 浏览器正常
|
||||
- [ ] Safari 浏览器正常(Mac)
|
||||
- [ ] 与小程序端互通正常
|
||||
|
||||
## ⚠️ 可能的问题
|
||||
|
||||
### 问题 1:导入错误
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Cannot find module '@trtc/calls-uikit-vue'
|
||||
```
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 删除 node_modules 和 package-lock.json
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
# 重新安装
|
||||
npm install
|
||||
```
|
||||
|
||||
### 问题 2:类型错误
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Property 'xxx' does not exist on type 'xxx'
|
||||
```
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 清除 TypeScript 缓存
|
||||
rm -rf node_modules/.vite
|
||||
|
||||
# 重新启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 问题 3:运行时错误
|
||||
|
||||
**症状:**
|
||||
```
|
||||
TUICallKitServer.xxx is not a function
|
||||
```
|
||||
|
||||
**解决:**
|
||||
1. 确认包版本正确
|
||||
2. 清除缓存重新安装
|
||||
3. 检查 API 调用方式
|
||||
|
||||
## 🔧 如果需要回滚
|
||||
|
||||
如果新版本有问题,可以回滚到旧版本:
|
||||
|
||||
```bash
|
||||
# 1. 修改 package.json
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12"
|
||||
|
||||
# 2. 修改导入语句
|
||||
import { ... } from '@tencentcloud/call-uikit-vue'
|
||||
|
||||
# 3. 重新安装
|
||||
npm install
|
||||
```
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如果遇到问题:
|
||||
1. 查看腾讯云官方文档
|
||||
2. 检查控制台错误信息
|
||||
3. 提供完整的错误日志
|
||||
|
||||
---
|
||||
|
||||
**最后更新:** 2024-03-04
|
||||
**版本:** 1.0
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 医生排班表
|
||||
CREATE TABLE IF NOT EXISTS `zyt_doctor_roster` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`doctor_id` int(11) NOT NULL COMMENT '医生ID(对应 zyt_admin 表的 id,role_id=1)',
|
||||
`date` date NOT NULL COMMENT '日期',
|
||||
`period` varchar(20) NOT NULL COMMENT '时段 morning-上午 afternoon-下午',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1-出诊 2-停诊 3-休息 4-请假',
|
||||
`quota` int(11) DEFAULT '0' COMMENT '号源数',
|
||||
`max_patients` int(11) DEFAULT '0' COMMENT '最大接诊数',
|
||||
`booked_count` int(11) DEFAULT '0' COMMENT '已预约数',
|
||||
`remark` varchar(500) DEFAULT '' COMMENT '备注',
|
||||
`create_time` int(11) NOT NULL COMMENT '创建时间',
|
||||
`update_time` int(11) NOT NULL COMMENT '更新时间',
|
||||
`delete_time` int(11) DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_doctor_date_period` (`doctor_id`,`date`,`period`,`delete_time`),
|
||||
KEY `idx_date` (`date`),
|
||||
KEY `idx_doctor` (`doctor_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';
|
||||
|
||||
-- 注意:医生数据来自 zyt_admin 表,筛选条件为 role_id=1
|
||||
-- 不需要单独的医生表、医院表、科室表
|
||||
|
||||
-- 插入示例排班数据(假设 zyt_admin 表中 id=1 和 id=2 的用户是医生,role_id=1)
|
||||
INSERT INTO `la_doctor_roster` (`doctor_id`, `date`, `period`, `status`, `quota`, `max_patients`, `booked_count`, `create_time`, `update_time`) VALUES
|
||||
-- 医生1本周排班
|
||||
(1, CURDATE(), 'morning', 1, 20, 30, 5, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, CURDATE(), 'afternoon', 1, 20, 30, 3, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 1 DAY), 'morning', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 2 DAY), 'morning', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 2 DAY), 'afternoon', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 3 DAY), 'morning', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 4 DAY), 'morning', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 4 DAY), 'afternoon', 1, 20, 30, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
-- 周末休息
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 5 DAY), 'morning', 3, 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 5 DAY), 'afternoon', 3, 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 6 DAY), 'morning', 3, 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(1, DATE_ADD(CURDATE(), INTERVAL 6 DAY), 'afternoon', 3, 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
@@ -0,0 +1,77 @@
|
||||
# Nginx HTTPS 配置示例
|
||||
# 用于生产环境部署
|
||||
|
||||
# HTTPS 服务器配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com; # 修改为你的域名
|
||||
|
||||
# SSL 证书配置
|
||||
ssl_certificate /path/to/your/fullchain.pem; # 修改为你的证书路径
|
||||
ssl_certificate_key /path/to/your/privkey.pem; # 修改为你的私钥路径
|
||||
|
||||
# SSL 安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# HSTS (可选,增强安全性)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# 前端静态文件
|
||||
location /admin/ {
|
||||
alias /var/www/html/admin/dist/; # 修改为你的前端构建目录
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
|
||||
# 缓存配置
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# 后端 API 代理
|
||||
location /adminapi/ {
|
||||
proxy_pass http://127.0.0.1:8000; # 修改为你的后端地址
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持(如果需要)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超时配置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 日志配置
|
||||
access_log /var/log/nginx/admin_access.log;
|
||||
error_log /var/log/nginx/admin_error.log;
|
||||
}
|
||||
|
||||
# HTTP 自动重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com; # 修改为你的域名
|
||||
|
||||
# 重定向所有 HTTP 请求到 HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# 使用 Let's Encrypt 证书的配置示例
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
#
|
||||
# # ... 其他配置同上
|
||||
# }
|
||||
Generated
+2731
-2663
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@trtc/calls-uikit-vue": "^4.2.2",
|
||||
"@vue/shared": "^3.5.13",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
REM 为开发环境生成自签名 HTTPS 证书(Windows)
|
||||
REM 用于测试 WebRTC 功能
|
||||
|
||||
echo === 生成开发环境 HTTPS 证书 ===
|
||||
echo.
|
||||
|
||||
REM 创建证书目录
|
||||
if not exist ".cert" mkdir .cert
|
||||
|
||||
REM 生成自签名证书
|
||||
openssl req -x509 -newkey rsa:4096 ^
|
||||
-keyout .cert/key.pem ^
|
||||
-out .cert/cert.pem ^
|
||||
-days 365 ^
|
||||
-nodes ^
|
||||
-subj "/C=CN/ST=State/L=City/O=Organization/CN=localhost"
|
||||
|
||||
echo.
|
||||
echo ✅ 证书生成成功!
|
||||
echo.
|
||||
echo 证书位置:
|
||||
echo - 私钥: .cert/key.pem
|
||||
echo - 证书: .cert/cert.pem
|
||||
echo.
|
||||
echo 下一步:
|
||||
echo 1. 修改 vite.config.ts,添加 HTTPS 配置
|
||||
echo 2. 运行 npm run dev
|
||||
echo 3. 浏览器访问 https://localhost:5173/admin/
|
||||
echo 4. 信任自签名证书(浏览器会提示不安全,点击继续访问)
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 为开发环境生成自签名 HTTPS 证书
|
||||
# 用于测试 WebRTC 功能
|
||||
|
||||
echo "=== 生成开发环境 HTTPS 证书 ==="
|
||||
|
||||
# 创建证书目录
|
||||
mkdir -p .cert
|
||||
|
||||
# 生成自签名证书
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout .cert/key.pem \
|
||||
-out .cert/cert.pem \
|
||||
-days 365 \
|
||||
-nodes \
|
||||
-subj "/C=CN/ST=State/L=City/O=Organization/CN=localhost"
|
||||
|
||||
echo ""
|
||||
echo "✅ 证书生成成功!"
|
||||
echo ""
|
||||
echo "证书位置:"
|
||||
echo " - 私钥: .cert/key.pem"
|
||||
echo " - 证书: .cert/cert.pem"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo "1. 修改 vite.config.ts,添加 HTTPS 配置"
|
||||
echo "2. 运行 npm run dev"
|
||||
echo "3. 浏览器访问 https://localhost:5173/admin/"
|
||||
echo "4. 信任自签名证书(浏览器会提示不安全,点击继续访问)"
|
||||
echo ""
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 医生列表(从管理员表获取,role_id=1)
|
||||
export function doctorLists(params: any) {
|
||||
return request.get({ url: '/perms.admin/lists', params: { ...params, role_id: 1 } })
|
||||
}
|
||||
|
||||
// 医生详情
|
||||
export function doctorDetail(params: any) {
|
||||
return request.get({ url: '/perms.admin/detail', params })
|
||||
}
|
||||
|
||||
// ========== 排班管理 ==========
|
||||
|
||||
// 获取排班列表(按周)
|
||||
export function rosterLists(params: any) {
|
||||
return request.get({ url: '/doctor.roster/lists', params })
|
||||
}
|
||||
|
||||
// 添加/更新排班
|
||||
export function rosterSave(params: any) {
|
||||
return request.post({ url: '/doctor.roster/save', params })
|
||||
}
|
||||
|
||||
// 删除排班
|
||||
export function rosterDelete(params: any) {
|
||||
return request.post({ url: '/doctor.roster/delete', params })
|
||||
}
|
||||
|
||||
// 批量设置排班
|
||||
export function rosterBatchSave(params: any) {
|
||||
return request.post({ url: '/doctor.roster/batchSave', params })
|
||||
}
|
||||
|
||||
// 获取医生某天的排班详情
|
||||
export function rosterDetail(params: any) {
|
||||
return request.get({ url: '/doctor.roster/detail', params })
|
||||
}
|
||||
|
||||
// 复制排班(复制某周到另一周)
|
||||
export function rosterCopy(params: any) {
|
||||
return request.post({ url: '/doctor.roster/copy', params })
|
||||
}
|
||||
// ========== 挂号管理 ==========
|
||||
|
||||
// 获取医生某天的可用时间段
|
||||
export function getAvailableSlots(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/availableSlots', params })
|
||||
}
|
||||
|
||||
// 创建挂号
|
||||
export function createAppointment(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/create', params })
|
||||
}
|
||||
|
||||
// 取消挂号
|
||||
export function cancelAppointment(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/cancel', params })
|
||||
}
|
||||
|
||||
// 获取挂号列表
|
||||
export function appointmentLists(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/lists', params })
|
||||
}
|
||||
|
||||
// 获取挂号详情
|
||||
export function appointmentDetail(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/detail', params })
|
||||
}
|
||||
|
||||
// 完成挂号
|
||||
export function completeAppointment(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/complete', params })
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 中医诊单列表
|
||||
export function tcmDiagnosisLists(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
||||
}
|
||||
|
||||
// 添加中医诊单
|
||||
export function tcmDiagnosisAdd(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/add', params })
|
||||
}
|
||||
|
||||
// 编辑中医诊单
|
||||
export function tcmDiagnosisEdit(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/edit', params })
|
||||
}
|
||||
|
||||
// 删除中医诊单
|
||||
export function tcmDiagnosisDelete(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/delete', params })
|
||||
}
|
||||
|
||||
// 中医诊单详情
|
||||
export function tcmDiagnosisDetail(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||
}
|
||||
|
||||
// 检查手机号是否重复
|
||||
export function checkPhone(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/checkPhone', params })
|
||||
}
|
||||
|
||||
// 检查身份证号是否重复
|
||||
export function checkIdCard(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/checkIdCard', params })
|
||||
}
|
||||
|
||||
// 指派医助
|
||||
export function tcmDiagnosisAssign(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/assign', params })
|
||||
}
|
||||
|
||||
// 获取医助列表
|
||||
export function getAssistants() {
|
||||
return request.get({ url: '/tcm.diagnosis/getAssistants' })
|
||||
}
|
||||
|
||||
// 获取医生列表
|
||||
export function getDoctors() {
|
||||
return request.get({ url: '/tcm.diagnosis/getDoctors' })
|
||||
}
|
||||
|
||||
// ========== 血糖血压记录 ==========
|
||||
|
||||
// 添加血糖血压记录
|
||||
export function bloodRecordAdd(params: any) {
|
||||
return request.post({ url: '/tcm.bloodRecord/add', params })
|
||||
}
|
||||
|
||||
// 编辑血糖血压记录
|
||||
export function bloodRecordEdit(params: any) {
|
||||
return request.post({ url: '/tcm.bloodRecord/edit', params })
|
||||
}
|
||||
|
||||
// 删除血糖血压记录
|
||||
export function bloodRecordDelete(params: any) {
|
||||
return request.post({ url: '/tcm.bloodRecord/delete', params })
|
||||
}
|
||||
|
||||
// 血糖血压记录详情
|
||||
export function bloodRecordDetail(params: any) {
|
||||
return request.get({ url: '/tcm.bloodRecord/detail', params })
|
||||
}
|
||||
|
||||
// 获取患者的血糖血压记录列表
|
||||
export function getRecordsByPatient(params: any) {
|
||||
return request.get({ url: '/tcm.bloodRecord/getRecordsByPatient', params })
|
||||
}
|
||||
|
||||
// ========== 音视频通话 ==========
|
||||
|
||||
// 获取通话签名
|
||||
export function getCallSignature(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/getCallSignature', params })
|
||||
}
|
||||
|
||||
// 发起通话
|
||||
export function startCall(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/startCall', params })
|
||||
}
|
||||
|
||||
// 结束通话
|
||||
export function endCall(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/endCall', params })
|
||||
}
|
||||
|
||||
// 获取通话记录
|
||||
export function getCallRecords(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/getCallRecords', params })
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
:append-to-body="true"
|
||||
:width="width"
|
||||
:close-on-click-modal="clickModalClose"
|
||||
:z-index="zIndex"
|
||||
@closed="close"
|
||||
>
|
||||
<!-- 弹窗内容 -->
|
||||
@@ -88,6 +89,10 @@ export default defineComponent({
|
||||
customClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
emits: ['confirm', 'cancel', 'close', 'open'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,13 @@
|
||||
:root {
|
||||
// 确保消息提示在最上层
|
||||
.el-message {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.el-notification {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
// 弹窗居中
|
||||
.el-overlay-dialog {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
完整的排班管理代码请参考 DOCTOR_ROSTER.md 文档
|
||||
|
||||
由于文件较大,建议手动创建或从以下地址获取完整代码:
|
||||
https://github.com/your-repo/roster.vue
|
||||
|
||||
或者使用以下简化版本开始开发。
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="doctor-roster p-4">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-medium">医生排班管理</span>
|
||||
<div>
|
||||
<el-button type="success" @click="showBatchDialog">批量排班</el-button>
|
||||
<el-button @click="prevWeek">上一周</el-button>
|
||||
<el-button @click="nextWeek">下一周</el-button>
|
||||
<el-button type="primary" @click="goToday">本周</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-4">
|
||||
<el-form inline>
|
||||
<el-form-item label="医生">
|
||||
<el-input v-model="searchName" placeholder="请输入医生姓名" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadData">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4 text-gray-600">
|
||||
{{ weekText }}
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" border v-loading="loading">
|
||||
<el-table-column prop="doctorName" label="医生" width="120" fixed />
|
||||
<el-table-column v-for="day in weekDays" :key="day.date" :label="day.label" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'morning')">
|
||||
上午: {{ getRosterText(row, day.date, 'morning') }}
|
||||
</div>
|
||||
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'afternoon')">
|
||||
下午: {{ getRosterText(row, day.date, 'afternoon') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="编辑排班" width="500px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="医生">
|
||||
<span>{{ editForm.doctorName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期">
|
||||
<span>{{ editForm.date }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="时段">
|
||||
<span>{{ editForm.period === 'morning' ? '上午' : '下午' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio :label="1">出诊</el-radio>
|
||||
<el-radio :label="2">停诊</el-radio>
|
||||
<el-radio :label="3">休息</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="号源数" v-if="editForm.status === 1">
|
||||
<el-input-number v-model="editForm.quota" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRoster">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量排班弹窗 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量排班" width="700px">
|
||||
<el-form :model="batchForm" label-width="100px">
|
||||
<el-form-item label="选择医生" required>
|
||||
<el-select v-model="batchForm.doctorIds" multiple placeholder="请选择医生" class="w-full">
|
||||
<el-option
|
||||
v-for="doctor in tableData"
|
||||
:key="doctor.doctorId"
|
||||
:label="doctor.doctorName"
|
||||
:value="doctor.doctorId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="日期范围" required>
|
||||
<el-date-picker
|
||||
v-model="batchForm.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择时段" required>
|
||||
<el-checkbox-group v-model="batchForm.periods">
|
||||
<el-checkbox label="morning">上午</el-checkbox>
|
||||
<el-checkbox label="afternoon">下午</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择星期" required>
|
||||
<el-checkbox-group v-model="batchForm.weekdays">
|
||||
<el-checkbox :label="1">周一</el-checkbox>
|
||||
<el-checkbox :label="2">周二</el-checkbox>
|
||||
<el-checkbox :label="3">周三</el-checkbox>
|
||||
<el-checkbox :label="4">周四</el-checkbox>
|
||||
<el-checkbox :label="5">周五</el-checkbox>
|
||||
<el-checkbox :label="6">周六</el-checkbox>
|
||||
<el-checkbox :label="0">周日</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排班状态" required>
|
||||
<el-radio-group v-model="batchForm.status">
|
||||
<el-radio :label="1">出诊</el-radio>
|
||||
<el-radio :label="2">停诊</el-radio>
|
||||
<el-radio :label="3">休息</el-radio>
|
||||
<el-radio :label="4">请假</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="号源数" v-if="batchForm.status === 1">
|
||||
<el-input-number v-model="batchForm.quota" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大接诊数" v-if="batchForm.status === 1">
|
||||
<el-input-number v-model="batchForm.maxPatients" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="batchForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleBatchSave" :loading="batchSaving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="doctorRoster">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
interface RosterItem {
|
||||
id?: number
|
||||
status: number
|
||||
quota: number
|
||||
}
|
||||
|
||||
interface DoctorRow {
|
||||
doctorId: number
|
||||
doctorName: string
|
||||
rosters: Record<string, RosterItem>
|
||||
}
|
||||
|
||||
const searchName = ref('')
|
||||
const currentWeekStart = ref(dayjs().startOf('isoWeek'))
|
||||
const dialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const doctorOptions = ref<any[]>([])
|
||||
|
||||
// 批量排班
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchSaving = ref(false)
|
||||
const batchForm = ref({
|
||||
doctorIds: [] as number[],
|
||||
dateRange: [] as string[],
|
||||
periods: ['morning', 'afternoon'] as string[],
|
||||
weekdays: [1, 2, 3, 4, 5] as number[], // 默认周一到周五
|
||||
status: 1,
|
||||
quota: 20,
|
||||
maxPatients: 30,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 加载医生列表(role_id=1)
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1 // 只获取医生角色
|
||||
})
|
||||
doctorOptions.value = res?.lists || []
|
||||
|
||||
// 转换为表格数据格式
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
doctorName: doctor.name || doctor.account,
|
||||
rosters: {}
|
||||
}))
|
||||
|
||||
// 加载排班数据
|
||||
await loadRosterData()
|
||||
} catch (error) {
|
||||
console.error('加载医生列表失败:', error)
|
||||
feedback.msgError('加载医生列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载排班数据
|
||||
const loadRosterData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
start_date: currentWeekStart.value.format('YYYY-MM-DD'),
|
||||
end_date: currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const res = await rosterLists(params)
|
||||
const rosters = res?.lists || []
|
||||
|
||||
// 将排班数据填充到医生行中
|
||||
tableData.value.forEach(doctor => {
|
||||
doctor.rosters = {}
|
||||
rosters.forEach((roster: any) => {
|
||||
if (roster.doctor_id === doctor.doctorId) {
|
||||
const key = `${roster.date}_${roster.period}`
|
||||
doctor.rosters[key] = {
|
||||
id: roster.id,
|
||||
status: roster.status,
|
||||
quota: roster.quota || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载排班数据失败:', error)
|
||||
feedback.msgError('加载排班数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const days = []
|
||||
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = currentWeekStart.value.add(i, 'day')
|
||||
days.push({
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
label: `${labels[i]} ${date.format('MM-DD')}`
|
||||
})
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
const weekText = computed(() => {
|
||||
const start = currentWeekStart.value.format('YYYY-MM-DD')
|
||||
const end = currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
|
||||
return `${start} 至 ${end}`
|
||||
})
|
||||
|
||||
const tableData = ref<DoctorRow[]>([])
|
||||
|
||||
const editForm = ref({
|
||||
id: null as number | null,
|
||||
doctorId: 0,
|
||||
doctorName: '',
|
||||
date: '',
|
||||
period: 'morning',
|
||||
status: 1,
|
||||
quota: 20
|
||||
})
|
||||
|
||||
const getRosterText = (row: DoctorRow, date: string, period: string) => {
|
||||
const key = `${date}_${period}`
|
||||
const roster = row.rosters[key]
|
||||
if (!roster) return '未排班'
|
||||
const statusMap: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息' }
|
||||
return roster.status === 1 ? `${statusMap[roster.status]}(${roster.quota})` : statusMap[roster.status]
|
||||
}
|
||||
|
||||
const editRoster = (row: DoctorRow, date: string, period: string) => {
|
||||
const key = `${date}_${period}`
|
||||
const roster = row.rosters[key] || { status: 1, quota: 20 }
|
||||
editForm.value = {
|
||||
id: roster.id || null,
|
||||
doctorId: row.doctorId,
|
||||
doctorName: row.doctorName,
|
||||
date,
|
||||
period,
|
||||
status: roster.status,
|
||||
quota: roster.quota
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveRoster = async () => {
|
||||
try {
|
||||
await rosterSave({
|
||||
id: editForm.value.id,
|
||||
doctor_id: editForm.value.doctorId,
|
||||
date: editForm.value.date,
|
||||
period: editForm.value.period,
|
||||
status: editForm.value.status,
|
||||
quota: editForm.value.status === 1 ? editForm.value.quota : 0
|
||||
})
|
||||
|
||||
feedback.msgSuccess('保存成功')
|
||||
dialogVisible.value = false
|
||||
await loadRosterData()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
feedback.msgError('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = () => {
|
||||
loadRosterData()
|
||||
}
|
||||
|
||||
const prevWeek = () => {
|
||||
currentWeekStart.value = currentWeekStart.value.subtract(1, 'week')
|
||||
loadRosterData()
|
||||
}
|
||||
|
||||
const nextWeek = () => {
|
||||
currentWeekStart.value = currentWeekStart.value.add(1, 'week')
|
||||
loadRosterData()
|
||||
}
|
||||
|
||||
const goToday = () => {
|
||||
currentWeekStart.value = dayjs().startOf('isoWeek')
|
||||
loadRosterData()
|
||||
}
|
||||
|
||||
// 显示批量排班弹窗
|
||||
const showBatchDialog = () => {
|
||||
batchForm.value = {
|
||||
doctorIds: [],
|
||||
dateRange: [],
|
||||
periods: ['morning', 'afternoon'],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
status: 1,
|
||||
quota: 20,
|
||||
maxPatients: 30,
|
||||
remark: ''
|
||||
}
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 批量保存排班
|
||||
const handleBatchSave = async () => {
|
||||
// 验证
|
||||
if (batchForm.value.doctorIds.length === 0) {
|
||||
feedback.msgWarning('请选择医生')
|
||||
return
|
||||
}
|
||||
if (!batchForm.value.dateRange || batchForm.value.dateRange.length !== 2) {
|
||||
feedback.msgWarning('请选择日期范围')
|
||||
return
|
||||
}
|
||||
if (batchForm.value.periods.length === 0) {
|
||||
feedback.msgWarning('请选择时段')
|
||||
return
|
||||
}
|
||||
if (batchForm.value.weekdays.length === 0) {
|
||||
feedback.msgWarning('请选择星期')
|
||||
return
|
||||
}
|
||||
|
||||
batchSaving.value = true
|
||||
try {
|
||||
// 生成排班数据
|
||||
const rosters: any[] = []
|
||||
const startDate = dayjs(batchForm.value.dateRange[0])
|
||||
const endDate = dayjs(batchForm.value.dateRange[1])
|
||||
|
||||
// 遍历日期范围
|
||||
let currentDate = startDate
|
||||
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
|
||||
const weekday = currentDate.day() // 0-6,0是周日
|
||||
|
||||
// 检查是否在选中的星期内
|
||||
if (batchForm.value.weekdays.includes(weekday)) {
|
||||
// 遍历医生
|
||||
batchForm.value.doctorIds.forEach(doctorId => {
|
||||
// 遍历时段
|
||||
batchForm.value.periods.forEach(period => {
|
||||
rosters.push({
|
||||
doctor_id: doctorId,
|
||||
date: currentDate.format('YYYY-MM-DD'),
|
||||
period: period,
|
||||
status: batchForm.value.status,
|
||||
quota: batchForm.value.status === 1 ? batchForm.value.quota : 0,
|
||||
max_patients: batchForm.value.status === 1 ? batchForm.value.maxPatients : 0,
|
||||
remark: batchForm.value.remark
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
currentDate = currentDate.add(1, 'day')
|
||||
}
|
||||
|
||||
if (rosters.length === 0) {
|
||||
feedback.msgWarning('没有符合条件的排班数据')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用批量保存接口
|
||||
await rosterBatchSave({ rosters })
|
||||
|
||||
feedback.msgSuccess(`批量保存成功!共处理 ${rosters.length} 条记录`)
|
||||
batchDialogVisible.value = false
|
||||
await loadRosterData()
|
||||
} catch (error) {
|
||||
console.error('批量保存失败:', error)
|
||||
feedback.msgError('批量保存失败')
|
||||
} finally {
|
||||
batchSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDoctors()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-roster {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="appointment-list">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<!-- 搜索表单 -->
|
||||
<el-form class="ls-form" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="患者姓名">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入患者姓名"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="w-[280px]" label="医生姓名">
|
||||
<el-input
|
||||
v-model="formData.doctor_name"
|
||||
placeholder="请输入医生姓名"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="w-[280px]" label="预约状态">
|
||||
<el-select v-model="formData.status" placeholder="请选择" clearable>
|
||||
<el-option label="已预约" :value="1" />
|
||||
<el-option label="已取消" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预约日期">
|
||||
<daterange-picker
|
||||
v-model:startTime="formData.start_date"
|
||||
v-model:endTime="formData.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
>
|
||||
<el-table-column label="ID" prop="id" width="80" />
|
||||
|
||||
<el-table-column label="患者信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.patient_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.patient_phone }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="医生" prop="doctor_name" min-width="120" />
|
||||
|
||||
<el-table-column label="预约日期" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.appointment_date }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="预约时间" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.appointment_time }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="时段" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.period === 'morning' ? 'success' : 'warning'" size="small">
|
||||
{{ row.period === 'morning' ? '上午' : '下午' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="预约类型" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.appointment_type_desc }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 1 ? 'success' : row.status === 2 ? 'info' : 'primary'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status_desc }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备注" prop="remark" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="创建时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/detail']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleDetail(row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
v-perms="['doctor.appointment/cancel']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleCancel(row)"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
v-perms="['doctor.appointment/complete']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleComplete(row)"
|
||||
>
|
||||
完成
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="挂号详情"
|
||||
width="600px"
|
||||
>
|
||||
<el-descriptions :column="2" border v-if="detailData">
|
||||
<el-descriptions-item label="挂号ID">
|
||||
{{ detailData.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag
|
||||
:type="detailData.status === 1 ? 'success' : detailData.status === 2 ? 'info' : 'primary'"
|
||||
size="small"
|
||||
>
|
||||
{{ detailData.status_desc }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="患者姓名">
|
||||
{{ detailData.patient_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="患者电话">
|
||||
{{ detailData.patient_phone }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="医生姓名">
|
||||
{{ detailData.doctor_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预约日期">
|
||||
{{ detailData.appointment_date }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预约时间">
|
||||
{{ detailData.appointment_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="时段">
|
||||
{{ detailData.period === 'morning' ? '上午' : '下午' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预约类型">
|
||||
{{ detailData.appointment_type_desc }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ detailData.create_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ detailData.remark || '无' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const formData = reactive({
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
status: '',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: appointmentLists,
|
||||
params: formData
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
|
||||
// 查看详情
|
||||
const handleDetail = async (row: any) => {
|
||||
try {
|
||||
const res = await appointmentDetail({ id: row.id })
|
||||
detailData.value = res
|
||||
detailVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消挂号
|
||||
const handleCancel = async (row: any) => {
|
||||
try {
|
||||
await feedback.confirm('确定要取消该挂号吗?')
|
||||
await cancelAppointment({ id: row.id })
|
||||
feedback.msgSuccess('取消成功')
|
||||
getLists()
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 完成挂号
|
||||
const handleComplete = async (row: any) => {
|
||||
try {
|
||||
await feedback.confirm('确认患者已就诊完成?')
|
||||
await completeAppointment({ id: row.id })
|
||||
feedback.msgSuccess('操作成功')
|
||||
getLists()
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.appointment-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="预约问诊"
|
||||
direction="rtl"
|
||||
size="60%"
|
||||
:close-on-click-modal="false"
|
||||
:z-index="2000"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-form :model="form" label-width="100px" class="appointment-form">
|
||||
<!-- 上次就诊 -->
|
||||
<el-form-item label="上次就诊:">
|
||||
<span class="text-gray-500">{{ lastVisit || '无就诊记录' }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 预约方式 -->
|
||||
<el-form-item label="预约方式:">
|
||||
<el-radio-group v-model="form.appointmentMethod" @change="handleMethodChange">
|
||||
<el-radio value="time">选择时段预约有专医生</el-radio>
|
||||
<!-- <el-radio value="doctor">选择医生预约有专时段</el-radio> -->
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 预约类型 -->
|
||||
<el-form-item label="预约类型:">
|
||||
<el-radio-group v-model="form.appointmentType">
|
||||
<el-radio value="video">视频问诊</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 选择患者 -->
|
||||
<el-form-item label="选择患者:">
|
||||
<el-radio-group v-model="form.patientId">
|
||||
<el-radio :value="patientInfo.id">{{ patientInfo.name }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 预约医生 -->
|
||||
<el-form-item label="预约医生:">
|
||||
<div class="doctor-list">
|
||||
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
|
||||
<el-radio
|
||||
v-for="doctor in doctorList"
|
||||
:key="doctor.id"
|
||||
:value="doctor.id"
|
||||
class="doctor-radio"
|
||||
>
|
||||
{{ doctor.name }}
|
||||
<!-- <el-tag
|
||||
v-if="getDoctorAvailability(doctor.id)"
|
||||
:type="getDoctorAvailability(doctor.id) > 0 ? 'success' : 'info'"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ getDoctorAvailability(doctor.id) > 0 ? getDoctorAvailability(doctor.id) : '无' }}
|
||||
</el-tag> -->
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 预约时间 -->
|
||||
<el-form-item label="预约时间:">
|
||||
<!-- 未选择医生提示 -->
|
||||
<el-empty
|
||||
v-if="!selectedDoctorId"
|
||||
description="请先选择医生"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<!-- 医生无排班提示 -->
|
||||
<el-empty
|
||||
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
|
||||
description="该医生暂无排班"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<!-- 有排班时显示日期和时间段 -->
|
||||
<template v-else>
|
||||
<!-- 日期选择 -->
|
||||
<div class="date-selector mb-5">
|
||||
<el-button
|
||||
v-for="dateOption in dateOptions"
|
||||
:key="dateOption.date"
|
||||
:type="form.date === dateOption.date ? 'primary' : ''"
|
||||
@click="selectDate(dateOption.date)"
|
||||
class="date-button"
|
||||
>
|
||||
{{ dateOption.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 时间段网格 -->
|
||||
<div v-if="form.date" class="time-slots-grid">
|
||||
<div
|
||||
v-for="slot in filteredTimeSlots"
|
||||
:key="slot.time"
|
||||
class="time-slot-item"
|
||||
:class="{
|
||||
'available': slot.available,
|
||||
'unavailable': !slot.available,
|
||||
'selected': form.appointmentTime === slot.time
|
||||
}"
|
||||
@click="selectTimeSlot(slot)"
|
||||
>
|
||||
<div class="slot-time">{{ slot.time }}</div>
|
||||
<el-tag
|
||||
:type="slot.available ? 'success' : 'info'"
|
||||
size="small"
|
||||
class="slot-badge"
|
||||
>
|
||||
{{ slot.available ? '可约' : '已约' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 备注 -->
|
||||
<el-form-item label="备注:">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入备注信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { getDoctors } from '@/api/tcm'
|
||||
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
interface TimeSlot {
|
||||
time: string
|
||||
available: boolean
|
||||
quota: number
|
||||
}
|
||||
|
||||
interface DateOption {
|
||||
date: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const doctorList = ref<any[]>([])
|
||||
const timeSlots = ref<TimeSlot[]>([])
|
||||
const lastVisit = ref('')
|
||||
const selectedDoctorId = ref(0)
|
||||
const doctorRosterDates = ref<string[]>([]) // 医生有排班的日期列表
|
||||
|
||||
const patientInfo = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
gender: '',
|
||||
age: 0
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
appointmentMethod: 'time' as 'time' | 'doctor',
|
||||
appointmentType: 'video',
|
||||
patientId: 0,
|
||||
date: '',
|
||||
appointmentTime: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
|
||||
const dateOptions = computed<DateOption[]>(() => {
|
||||
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const options: DateOption[] = []
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const today = dayjs().startOf('day')
|
||||
|
||||
// 只显示有排班的日期,且日期大于等于今天
|
||||
for (const date of doctorRosterDates.value) {
|
||||
const dateObj = dayjs(date)
|
||||
|
||||
// 过滤掉今天之前的日期
|
||||
if (dateObj.isBefore(today)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const label = `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
|
||||
options.push({
|
||||
date: date,
|
||||
label
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 过滤时间段:如果是今天,只显示当前时间之后的时间段
|
||||
const filteredTimeSlots = computed(() => {
|
||||
if (!form.date || timeSlots.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const isToday = form.date === today
|
||||
|
||||
if (!isToday) {
|
||||
// 不是今天,显示所有时间段
|
||||
return timeSlots.value
|
||||
}
|
||||
|
||||
// 是今天,只显示当前时间之后的时间段
|
||||
const now = dayjs()
|
||||
return timeSlots.value.map(slot => {
|
||||
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
|
||||
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
|
||||
|
||||
// 如果时间已过,标记为不可用
|
||||
if (isPast) {
|
||||
return {
|
||||
...slot,
|
||||
available: false
|
||||
}
|
||||
}
|
||||
return slot
|
||||
})
|
||||
})
|
||||
|
||||
// 是否可以提交
|
||||
const canSubmit = computed(() => {
|
||||
return selectedDoctorId.value && form.date && form.appointmentTime
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
// 打开弹窗
|
||||
const open = async (patient: any) => {
|
||||
visible.value = true
|
||||
patientInfo.id = patient.patient_id || patient.id
|
||||
patientInfo.name = patient.patient_name || patient.name
|
||||
patientInfo.gender = patient.gender_desc || patient.gender
|
||||
patientInfo.age = patient.age || 0
|
||||
|
||||
// 重置表单
|
||||
form.appointmentMethod = 'time'
|
||||
form.appointmentType = 'video'
|
||||
form.patientId = patientInfo.id
|
||||
selectedDoctorId.value = 0
|
||||
form.date = ''
|
||||
form.appointmentTime = ''
|
||||
form.remark = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
lastVisit.value = ''
|
||||
|
||||
// 加载医生列表
|
||||
await loadDoctors()
|
||||
|
||||
// 查询该患者的最近一次就诊记录
|
||||
await loadLastVisit()
|
||||
}
|
||||
|
||||
// 加载患者最近一次就诊记录(从预约表查询已完成的预约)
|
||||
const loadLastVisit = async () => {
|
||||
try {
|
||||
const res = await appointmentLists({
|
||||
patient_id: patientInfo.id,
|
||||
status: 3, // 只查询已完成的预约
|
||||
page_no: 1,
|
||||
page_size: 1
|
||||
})
|
||||
|
||||
if (res?.lists && res.lists.length > 0) {
|
||||
const lastRecord = res.lists[0]
|
||||
// 格式化显示:日期 + 时间
|
||||
if (lastRecord.appointment_date && lastRecord.appointment_time) {
|
||||
lastVisit.value = `${lastRecord.appointment_date} ${lastRecord.appointment_time}`
|
||||
} else if (lastRecord.appointment_date) {
|
||||
lastVisit.value = lastRecord.appointment_date
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载就诊记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载医生列表
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await getDoctors()
|
||||
doctorList.value = res || []
|
||||
} catch (error) {
|
||||
console.error('加载医生列表失败:', error)
|
||||
feedback.msgError('加载医生列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取医生可用号源数
|
||||
const getDoctorAvailability = (doctorId: number) => {
|
||||
// 这里应该从后端获取医生的可用号源数
|
||||
// 暂时返回模拟数据
|
||||
return Math.floor(Math.random() * 10)
|
||||
}
|
||||
|
||||
// 预约方式改变
|
||||
const handleMethodChange = () => {
|
||||
selectedDoctorId.value = 0
|
||||
form.date = ''
|
||||
form.appointmentTime = ''
|
||||
timeSlots.value = []
|
||||
}
|
||||
|
||||
// 医生改变
|
||||
const handleDoctorChange = async () => {
|
||||
form.appointmentTime = ''
|
||||
form.date = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
|
||||
if (selectedDoctorId.value) {
|
||||
await loadDoctorRoster()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载医生排班信息
|
||||
const loadDoctorRoster = async () => {
|
||||
if (!selectedDoctorId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 获取未来7天的日期范围
|
||||
const startDate = dayjs().format('YYYY-MM-DD')
|
||||
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
|
||||
|
||||
// 查询医生在这个日期范围内的排班
|
||||
const res = await rosterLists({
|
||||
doctor_id: selectedDoctorId.value,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
status: 1 // 只查询出诊状态
|
||||
})
|
||||
|
||||
// 提取有排班的日期
|
||||
if (res?.lists && res.lists.length > 0) {
|
||||
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))].sort()
|
||||
|
||||
// 自动选择日期:优先选择今天,如果今天没有排班则选择第一个有排班的日期
|
||||
await nextTick()
|
||||
if (doctorRosterDates.value.length > 0) {
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const defaultDate = doctorRosterDates.value.includes(today)
|
||||
? today
|
||||
: doctorRosterDates.value[0]
|
||||
selectDate(defaultDate)
|
||||
}
|
||||
} else {
|
||||
doctorRosterDates.value = []
|
||||
feedback.msgWarning('该医生暂无排班')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载医生排班失败:', error)
|
||||
feedback.msgError('加载医生排班失败')
|
||||
doctorRosterDates.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择日期
|
||||
const selectDate = (date: string) => {
|
||||
form.date = date
|
||||
form.appointmentTime = ''
|
||||
if (selectedDoctorId.value) {
|
||||
loadTimeSlots()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时间段
|
||||
const loadTimeSlots = async () => {
|
||||
if (!selectedDoctorId.value || !form.date) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 加载全天时段(9:00-18:00,每15分钟一个)
|
||||
const response = await getAvailableSlots({
|
||||
doctor_id: selectedDoctorId.value,
|
||||
appointment_date: form.date,
|
||||
period: 'all' // 获取全天时段
|
||||
})
|
||||
|
||||
console.log('时间段数据:', response)
|
||||
|
||||
timeSlots.value = (response?.slots || []).map((slot: any) => ({
|
||||
time: slot.time,
|
||||
available: slot.available,
|
||||
quota: slot.available ? 1 : 0 // 每个时间段只能预约1次
|
||||
}))
|
||||
|
||||
console.log('处理后的时间段:', timeSlots.value)
|
||||
} catch (error) {
|
||||
console.error('加载时间段失败:', error)
|
||||
feedback.msgError('加载时间段失败')
|
||||
timeSlots.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择时间段
|
||||
const selectTimeSlot = (slot: TimeSlot) => {
|
||||
if (!slot.available) {
|
||||
feedback.msgWarning('该时间段不可预约')
|
||||
return
|
||||
}
|
||||
form.appointmentTime = slot.time
|
||||
}
|
||||
|
||||
// 确认挂号
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedDoctorId.value) {
|
||||
feedback.msgWarning('请选择医生')
|
||||
return
|
||||
}
|
||||
if (!form.date) {
|
||||
feedback.msgWarning('请选择日期')
|
||||
return
|
||||
}
|
||||
if (!form.appointmentTime) {
|
||||
feedback.msgWarning('请选择预约时间')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
// 构建请求参数
|
||||
const params = {
|
||||
patient_id: patientInfo.id,
|
||||
doctor_id: selectedDoctorId.value,
|
||||
appointment_date: form.date,
|
||||
period: 'all', // 全天时段
|
||||
appointment_time: form.appointmentTime,
|
||||
appointment_type: form.appointmentType,
|
||||
remark: form.remark
|
||||
}
|
||||
|
||||
console.log('提交预约参数:', params)
|
||||
|
||||
await createAppointment(params)
|
||||
|
||||
feedback.msgSuccess('预约成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('预约失败:', error)
|
||||
feedback.msgError('预约失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.appointment-form {
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.doctor-radio {
|
||||
margin-right: 0;
|
||||
|
||||
:deep(.el-radio__label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.date-button {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.time-slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
|
||||
.time-slot-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background-color: #fff;
|
||||
|
||||
.slot-time {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&.available {
|
||||
border-color: #d1d5db;
|
||||
background-color: #fff;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.unavailable {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
|
||||
.slot-time {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #3b82f6;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
|
||||
.slot-time {
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="blood-record-list">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">添加记录</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="recordList" border>
|
||||
<el-table-column prop="record_date" label="记录日期" width="120" />
|
||||
<el-table-column prop="record_time" label="记录时间" width="100" />
|
||||
<el-table-column prop="blood_sugar" label="血糖(mmol/L)" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.blood_sugar || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="血压(mmHg)" width="150">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.systolic_pressure && row.diastolic_pressure">
|
||||
{{ row.systolic_pressure }}/{{ row.diastolic_pressure }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||
<el-form :model="recordForm" label-width="120px">
|
||||
<el-form-item label="记录日期" required>
|
||||
<el-date-picker
|
||||
v-model="recordForm.record_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="记录时间">
|
||||
<el-time-picker
|
||||
v-model="recordForm.record_time"
|
||||
placeholder="选择时间"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="血糖值">
|
||||
<el-input-number
|
||||
v-model="recordForm.blood_sugar"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:min="0"
|
||||
:max="50"
|
||||
placeholder="请输入血糖值"
|
||||
/>
|
||||
<span class="ml-2">mmol/L</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="收缩压(高压)">
|
||||
<el-input-number
|
||||
v-model="recordForm.systolic_pressure"
|
||||
:min="0"
|
||||
:max="300"
|
||||
placeholder="请输入收缩压"
|
||||
/>
|
||||
<span class="ml-2">mmHg</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="舒张压(低压)">
|
||||
<el-input-number
|
||||
v-model="recordForm.diastolic_pressure"
|
||||
:min="0"
|
||||
:max="200"
|
||||
placeholder="请输入舒张压"
|
||||
/>
|
||||
<span class="ml-2">mmHg</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="recordForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { bloodRecordAdd, bloodRecordEdit, bloodRecordDelete, getRecordsByPatient } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const props = defineProps({
|
||||
diagnosisId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
patientId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const recordList = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
|
||||
|
||||
const recordForm = ref({
|
||||
id: '',
|
||||
diagnosis_id: props.diagnosisId,
|
||||
patient_id: props.patientId,
|
||||
record_date: '',
|
||||
record_time: '',
|
||||
blood_sugar: null,
|
||||
systolic_pressure: null,
|
||||
diastolic_pressure: null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 获取记录列表
|
||||
const getRecords = async () => {
|
||||
if (!props.diagnosisId && !props.patientId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getRecordsByPatient({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
patient_id: props.patientId
|
||||
})
|
||||
recordList.value = data
|
||||
} catch (error) {
|
||||
console.error('获取记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录
|
||||
const handleAdd = () => {
|
||||
recordForm.value = {
|
||||
id: '',
|
||||
diagnosis_id: props.diagnosisId,
|
||||
patient_id: props.patientId,
|
||||
record_date: '',
|
||||
record_time: '',
|
||||
blood_sugar: null,
|
||||
systolic_pressure: null,
|
||||
diastolic_pressure: null,
|
||||
remark: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑记录
|
||||
const handleEdit = (row: any) => {
|
||||
recordForm.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const handleDelete = async (row: any) => {
|
||||
await feedback.confirm('确定要删除这条记录吗?')
|
||||
try {
|
||||
await bloodRecordDelete({ id: row.id })
|
||||
feedback.msgSuccess('删除成功')
|
||||
getRecords()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!recordForm.value.record_date) {
|
||||
feedback.msgWarning('请选择记录日期')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (recordForm.value.id) {
|
||||
await bloodRecordEdit(recordForm.value)
|
||||
feedback.msgSuccess('编辑成功')
|
||||
} else {
|
||||
await bloodRecordAdd(recordForm.value)
|
||||
feedback.msgSuccess('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getRecords()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getRecords()
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
watch(() => [props.diagnosisId, props.patientId], () => {
|
||||
getRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.blood-record-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="detail-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
title="诊单详情"
|
||||
width="800px"
|
||||
:show-confirm="false"
|
||||
:z-index="1500"
|
||||
>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="患者ID">
|
||||
{{ detail.patient_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="患者姓名">
|
||||
{{ detail.patient_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ detail.gender_desc }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">
|
||||
{{ detail.age }}岁
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="诊断日期">
|
||||
{{ detail.diagnosis_date_text }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="诊断类型">
|
||||
{{ detail.diagnosis_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="证型" :span="2">
|
||||
{{ detail.syndrome_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="既往史" :span="2">
|
||||
<el-tag
|
||||
v-for="item in pastHistoryList"
|
||||
:key="item"
|
||||
class="mr-2"
|
||||
type="info"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="!pastHistoryList.length">无</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<el-descriptions-item label="外伤史">
|
||||
{{ detail.trauma_history ? '有' : '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="手术史">
|
||||
{{ detail.surgery_history ? '有' : '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过敏史">
|
||||
{{ detail.allergy_history ? '有' : '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="家族病史">
|
||||
{{ detail.family_history ? '有' : '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="妊娠哺乳史" :span="2">
|
||||
{{ detail.pregnancy_history ? '有' : '无' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<!-- 舌苔照片 -->
|
||||
<el-descriptions-item label="舌苔照片" :span="2">
|
||||
<div v-if="detail.tongue_images && detail.tongue_images.length" class="flex gap-2">
|
||||
<el-image
|
||||
v-for="(img, index) in detail.tongue_images"
|
||||
:key="index"
|
||||
:src="img"
|
||||
:preview-src-list="detail.tongue_images"
|
||||
style="width: 100px; height: 100px"
|
||||
fit="cover"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>暂无</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<!-- 检查报告 -->
|
||||
<el-descriptions-item label="检查报告" :span="2">
|
||||
<div v-if="detail.report_files && detail.report_files.length" class="flex flex-wrap gap-2">
|
||||
<el-image
|
||||
v-for="(file, index) in detail.report_files"
|
||||
:key="index"
|
||||
:src="file"
|
||||
:preview-src-list="detail.report_files"
|
||||
style="width: 100px; height: 100px"
|
||||
fit="cover"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>暂无</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="症状" :span="2">
|
||||
{{ detail.symptoms || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="舌苔">
|
||||
{{ detail.tongue_coating || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="脉象">
|
||||
{{ detail.pulse || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="治则" :span="2">
|
||||
{{ detail.treatment_principle || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方" :span="2">
|
||||
<div style="white-space: pre-wrap">{{ detail.prescription || '无' }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="医嘱" :span="2">
|
||||
<div style="white-space: pre-wrap">{{ detail.doctor_advice || '无' }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ detail.remark || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag v-if="detail.status == 1" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">禁用</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ detail.create_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { tcmDiagnosisDetail } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
|
||||
const popupRef = ref()
|
||||
const detail = ref<any>({})
|
||||
|
||||
// 获取字典选项
|
||||
const pastHistoryOptions = ref<any[]>([])
|
||||
|
||||
const getDictOptions = async () => {
|
||||
try {
|
||||
const pastHistory = await getDictData({ type: 'past_history' })
|
||||
pastHistoryOptions.value = pastHistory?.past_history || []
|
||||
} catch (error) {
|
||||
console.error('获取字典数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 既往史显示列表
|
||||
const pastHistoryList = computed(() => {
|
||||
if (!detail.value.past_history || !detail.value.past_history.length) {
|
||||
return []
|
||||
}
|
||||
return detail.value.past_history.map((value: string) => {
|
||||
const option = pastHistoryOptions.value.find((item: any) => item.value === value)
|
||||
return option ? option.name : value
|
||||
})
|
||||
})
|
||||
|
||||
const open = async (id: number) => {
|
||||
popupRef.value.open()
|
||||
await getDictOptions()
|
||||
detail.value = await tcmDiagnosisDetail({ id })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,813 @@
|
||||
<template>
|
||||
<div class="edit-drawer">
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
size="60%"
|
||||
:before-close="handleClose"
|
||||
:z-index="1500"
|
||||
:modal="true"
|
||||
>
|
||||
<el-tabs v-model="activeTab" class="px-4">
|
||||
<!-- 基本信息标签页 -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-form-item label="患者ID">
|
||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="患者姓名" prop="patient_name">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入患者姓名"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号" prop="id_card">
|
||||
<el-input
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="0">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年龄" prop="age">
|
||||
<el-input-number
|
||||
v-model="formData.age"
|
||||
:min="0"
|
||||
:max="150"
|
||||
placeholder="请输入年龄"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="诊断日期" prop="diagnosis_date">
|
||||
<el-date-picker
|
||||
v-model="formData.diagnosis_date"
|
||||
type="date"
|
||||
placeholder="请选择诊断日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
popper-class="high-z-index"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="诊断类型" prop="diagnosis_type">
|
||||
<el-select
|
||||
v-model="formData.diagnosis_type"
|
||||
placeholder="请选择诊断类型"
|
||||
class="w-full"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
popper-class="high-z-index"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in diagnosisTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="证型" prop="syndrome_type">
|
||||
<el-select
|
||||
v-model="formData.syndrome_type"
|
||||
placeholder="请选择证型"
|
||||
class="w-full"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
popper-class="high-z-index"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in syndromeTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 现病史 -->
|
||||
<el-divider content-position="left">现病史</el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="口腔感觉">
|
||||
<el-radio-group v-model="formData.appetite">
|
||||
<el-radio v-for="item in appetiteOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="每日饮水量">
|
||||
<el-radio-group v-model="formData.water_intake">
|
||||
<el-radio v-for="item in waterIntakeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="饮食情况">
|
||||
<el-checkbox-group v-model="formData.diet_condition">
|
||||
<el-checkbox v-for="item in dietConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="体重变化">
|
||||
<el-radio-group v-model="formData.weight_change">
|
||||
<el-radio v-for="item in weightChangeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="肢体感觉">
|
||||
<el-checkbox-group v-model="formData.body_feeling">
|
||||
<el-checkbox v-for="item in bodyFeelingOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="睡眠情况">
|
||||
<el-checkbox-group v-model="formData.sleep_condition">
|
||||
<el-checkbox v-for="item in sleepConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="眼睛情况">
|
||||
<el-checkbox-group v-model="formData.eye_condition">
|
||||
<el-checkbox v-for="item in eyeConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="头部感觉">
|
||||
<el-checkbox-group v-model="formData.head_feeling">
|
||||
<el-checkbox v-for="item in headFeelingOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="出汗情况">
|
||||
<el-checkbox-group v-model="formData.sweat_condition">
|
||||
<el-checkbox v-for="item in sweatConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="皮肤情况">
|
||||
<el-checkbox-group v-model="formData.skin_condition">
|
||||
<el-checkbox v-for="item in skinConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="小便情况">
|
||||
<el-checkbox-group v-model="formData.urine_condition">
|
||||
<el-checkbox v-for="item in urineConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="大便情况">
|
||||
<el-checkbox-group v-model="formData.stool_condition">
|
||||
<el-checkbox v-for="item in stoolConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="腰肾情况">
|
||||
<el-checkbox-group v-model="formData.kidney_condition">
|
||||
<el-checkbox v-for="item in kidneyConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="脂肪肝程度">
|
||||
<el-radio-group v-model="formData.fatty_liver_degree">
|
||||
<el-radio v-for="item in fattyLiverDegreeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 既往史 -->
|
||||
<el-divider content-position="left">既往史</el-divider>
|
||||
|
||||
<el-form-item label="既往史" prop="past_history">
|
||||
<el-checkbox-group v-model="formData.past_history">
|
||||
<el-checkbox
|
||||
v-for="item in pastHistoryOptions"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<el-divider content-position="left">其他病史</el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="外伤史">
|
||||
<el-radio-group v-model="formData.trauma_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="手术史">
|
||||
<el-radio-group v-model="formData.surgery_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="过敏史">
|
||||
<el-radio-group v-model="formData.allergy_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="家族病史">
|
||||
<el-radio-group v-model="formData.family_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="妊娠哺乳史">
|
||||
<el-radio-group v-model="formData.pregnancy_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 诊断信息 -->
|
||||
<el-divider content-position="left">诊断信息</el-divider>
|
||||
|
||||
<el-form-item label="舌苔照片">
|
||||
<material-picker
|
||||
v-model="formData.tongue_images"
|
||||
:limit="9"
|
||||
type="image"
|
||||
/>
|
||||
<div class="form-tips">支持上传多张舌苔照片,最多9张</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="检查报告">
|
||||
<material-picker
|
||||
v-model="formData.report_files"
|
||||
:limit="10"
|
||||
type="image"
|
||||
/>
|
||||
<div class="form-tips">支持上传检查报告、病历、彩超等影像科检查图片,最多10个文件</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="症状" prop="symptoms">
|
||||
<el-input
|
||||
v-model="formData.symptoms"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入症状"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="舌苔" prop="tongue_coating">
|
||||
<el-input
|
||||
v-model="formData.tongue_coating"
|
||||
placeholder="请输入舌苔情况"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="脉象" prop="pulse">
|
||||
<el-input
|
||||
v-model="formData.pulse"
|
||||
placeholder="请输入脉象"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="治则" prop="treatment_principle">
|
||||
<el-input
|
||||
v-model="formData.treatment_principle"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入治则"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方" prop="prescription">
|
||||
<el-input
|
||||
v-model="formData.prescription"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入处方"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="医嘱" prop="doctor_advice">
|
||||
<el-input
|
||||
v-model="formData.doctor_advice"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入医嘱"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 血糖血压记录标签页 -->
|
||||
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
||||
<blood-record-list
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 px-4 pb-4">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'basic'"
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BloodRecordList from './components/BloodRecordList.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const formRef = ref()
|
||||
const mode = ref('add')
|
||||
const submitting = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => (mode.value === 'add' ? '新增诊单' : '编辑诊单'))
|
||||
|
||||
const formData = ref({
|
||||
id: '',
|
||||
patient_id: '',
|
||||
patient_name: '',
|
||||
id_card: '',
|
||||
phone: '',
|
||||
gender: 1,
|
||||
age: undefined as number | undefined,
|
||||
diagnosis_date: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
// 现病史字段
|
||||
appetite: '',
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
weight_change: '',
|
||||
body_feeling: [],
|
||||
sleep_condition: [],
|
||||
eye_condition: [],
|
||||
head_feeling: [],
|
||||
sweat_condition: [],
|
||||
skin_condition: [],
|
||||
urine_condition: [],
|
||||
stool_condition: [],
|
||||
kidney_condition: [],
|
||||
fatty_liver_degree: '',
|
||||
// 既往史
|
||||
past_history: [],
|
||||
// 其他病史
|
||||
trauma_history: 0,
|
||||
surgery_history: 0,
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
// 文件上传
|
||||
tongue_images: [],
|
||||
report_files: [],
|
||||
// 诊断信息
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
pulse: '',
|
||||
treatment_principle: '',
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 自定义验证规则
|
||||
const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入手机号'))
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
callback(new Error('手机号格式不正确'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||
id_card: [{ validator: validateIdCard, trigger: 'blur' }],
|
||||
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
|
||||
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 获取字典选项
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
const syndromeTypeOptions = ref<any[]>([])
|
||||
const pastHistoryOptions = ref<any[]>([])
|
||||
// 现病史字典选项
|
||||
const appetiteOptions = ref<any[]>([])
|
||||
const waterIntakeOptions = ref<any[]>([])
|
||||
const dietConditionOptions = ref<any[]>([])
|
||||
const weightChangeOptions = ref<any[]>([])
|
||||
const bodyFeelingOptions = ref<any[]>([])
|
||||
const sleepConditionOptions = ref<any[]>([])
|
||||
const eyeConditionOptions = ref<any[]>([])
|
||||
const headFeelingOptions = ref<any[]>([])
|
||||
const sweatConditionOptions = ref<any[]>([])
|
||||
const skinConditionOptions = ref<any[]>([])
|
||||
const urineConditionOptions = ref<any[]>([])
|
||||
const stoolConditionOptions = ref<any[]>([])
|
||||
const kidneyConditionOptions = ref<any[]>([])
|
||||
const fattyLiverDegreeOptions = ref<any[]>([])
|
||||
|
||||
const getDictOptions = async () => {
|
||||
try {
|
||||
const [
|
||||
diagnosisType,
|
||||
syndromeType,
|
||||
pastHistory,
|
||||
appetite,
|
||||
waterIntake,
|
||||
dietCondition,
|
||||
weightChange,
|
||||
bodyFeeling,
|
||||
sleepCondition,
|
||||
eyeCondition,
|
||||
headFeeling,
|
||||
sweatCondition,
|
||||
skinCondition,
|
||||
urineCondition,
|
||||
stoolCondition,
|
||||
kidneyCondition,
|
||||
fattyLiverDegree
|
||||
] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getDictData({ type: 'past_history' }),
|
||||
getDictData({ type: 'appetite' }),
|
||||
getDictData({ type: 'water_intake' }),
|
||||
getDictData({ type: 'diet_condition' }),
|
||||
getDictData({ type: 'weight_change' }),
|
||||
getDictData({ type: 'body_feeling' }),
|
||||
getDictData({ type: 'sleep_condition' }),
|
||||
getDictData({ type: 'eye_condition' }),
|
||||
getDictData({ type: 'head_feeling' }),
|
||||
getDictData({ type: 'sweat_condition' }),
|
||||
getDictData({ type: 'skin_condition' }),
|
||||
getDictData({ type: 'urine_condition' }),
|
||||
getDictData({ type: 'stool_condition' }),
|
||||
getDictData({ type: 'kidney_condition' }),
|
||||
getDictData({ type: 'fatty_liver_degree' })
|
||||
])
|
||||
|
||||
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
|
||||
pastHistoryOptions.value = pastHistory?.past_history || []
|
||||
appetiteOptions.value = appetite?.appetite || []
|
||||
waterIntakeOptions.value = waterIntake?.water_intake || []
|
||||
dietConditionOptions.value = dietCondition?.diet_condition || []
|
||||
weightChangeOptions.value = weightChange?.weight_change || []
|
||||
bodyFeelingOptions.value = bodyFeeling?.body_feeling || []
|
||||
sleepConditionOptions.value = sleepCondition?.sleep_condition || []
|
||||
eyeConditionOptions.value = eyeCondition?.eye_condition || []
|
||||
headFeelingOptions.value = headFeeling?.head_feeling || []
|
||||
sweatConditionOptions.value = sweatCondition?.sweat_condition || []
|
||||
skinConditionOptions.value = skinCondition?.skin_condition || []
|
||||
urineConditionOptions.value = urineCondition?.urine_condition || []
|
||||
stoolConditionOptions.value = stoolCondition?.stool_condition || []
|
||||
kidneyConditionOptions.value = kidneyCondition?.kidney_condition || []
|
||||
fattyLiverDegreeOptions.value = fattyLiverDegree?.fatty_liver_degree || []
|
||||
} catch (error) {
|
||||
console.error('获取字典数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号失焦检查
|
||||
const handlePhoneBlur = async () => {
|
||||
if (!formData.value.phone || !/^1[3-9]\d{9}$/.test(formData.value.phone)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkPhone({
|
||||
phone: formData.value.phone,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证号失焦检查
|
||||
const handleIdCardBlur = async () => {
|
||||
if (!formData.value.id_card || !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
const data = await tcmDiagnosisDetail({ id })
|
||||
formData.value = data
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'add') {
|
||||
const result = await tcmDiagnosisAdd(formData.value)
|
||||
|
||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||
if (result && result.id) {
|
||||
mode.value = 'edit'
|
||||
formData.value.id = result.id
|
||||
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
try {
|
||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||
formData.value.patient_id = detail.patient_id
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tcmDiagnosisEdit(formData.value)
|
||||
}
|
||||
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {
|
||||
id: '',
|
||||
patient_id: '',
|
||||
patient_name: '',
|
||||
id_card: '',
|
||||
phone: '',
|
||||
gender: 1,
|
||||
age: undefined as number | undefined,
|
||||
diagnosis_date: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
appetite: '',
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
weight_change: '',
|
||||
body_feeling: [],
|
||||
sleep_condition: [],
|
||||
eye_condition: [],
|
||||
head_feeling: [],
|
||||
sweat_condition: [],
|
||||
skin_condition: [],
|
||||
urine_condition: [],
|
||||
stool_condition: [],
|
||||
kidney_condition: [],
|
||||
fatty_liver_degree: '',
|
||||
past_history: [],
|
||||
trauma_history: 0,
|
||||
surgery_history: 0,
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
tongue_images: [],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
pulse: '',
|
||||
treatment_principle: '',
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-drawer {
|
||||
:deep(.el-checkbox-group) {
|
||||
.el-checkbox {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
.el-radio {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tips {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 全局样式,用于提高弹出层的z-index */
|
||||
.high-z-index {
|
||||
z-index: 3000 !important;
|
||||
}
|
||||
|
||||
/* 降低抽屉及其遮罩层的z-index */
|
||||
.el-drawer {
|
||||
z-index: 1500 !important;
|
||||
}
|
||||
|
||||
.el-overlay {
|
||||
z-index: 1499 !important;
|
||||
}
|
||||
|
||||
/* 确保日期选择器和下拉选择器的弹出层在最上层 */
|
||||
.el-picker__popper,
|
||||
.el-select__popper,
|
||||
.el-popper {
|
||||
z-index: 3000 !important;
|
||||
}
|
||||
|
||||
/* 确保Element Plus的弹出层容器在最上层 */
|
||||
.el-picker-panel,
|
||||
.el-select-dropdown {
|
||||
z-index: 3000 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,778 @@
|
||||
<template>
|
||||
<div class="edit-drawer">
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
size="70%"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<el-tabs v-model="activeTab" class="px-4">
|
||||
<!-- 基本信息标签页 -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-form-item label="患者ID">
|
||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="患者姓名" prop="patient_name">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入患者姓名"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号" prop="id_card">
|
||||
<el-input
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="0">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年龄" prop="age">
|
||||
<el-input-number
|
||||
v-model="formData.age"
|
||||
:min="0"
|
||||
:max="150"
|
||||
placeholder="请输入年龄"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="诊断日期" prop="diagnosis_date">
|
||||
<el-date-picker
|
||||
v-model="formData.diagnosis_date"
|
||||
type="date"
|
||||
placeholder="请选择诊断日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="诊断类型" prop="diagnosis_type">
|
||||
<el-select
|
||||
v-model="formData.diagnosis_type"
|
||||
placeholder="请选择诊断类型"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in diagnosisTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="证型" prop="syndrome_type">
|
||||
<el-select
|
||||
v-model="formData.syndrome_type"
|
||||
placeholder="请选择证型"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in syndromeTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 现病史 -->
|
||||
<el-divider content-position="left">现病史</el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="口腔感觉">
|
||||
<el-radio-group v-model="formData.appetite">
|
||||
<el-radio v-for="item in appetiteOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="每日饮水量">
|
||||
<el-radio-group v-model="formData.water_intake">
|
||||
<el-radio v-for="item in waterIntakeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="饮食情况">
|
||||
<el-checkbox-group v-model="formData.diet_condition">
|
||||
<el-checkbox v-for="item in dietConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="体重变化">
|
||||
<el-radio-group v-model="formData.weight_change">
|
||||
<el-radio v-for="item in weightChangeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="肢体感觉">
|
||||
<el-checkbox-group v-model="formData.body_feeling">
|
||||
<el-checkbox v-for="item in bodyFeelingOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="睡眠情况">
|
||||
<el-checkbox-group v-model="formData.sleep_condition">
|
||||
<el-checkbox v-for="item in sleepConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="眼睛情况">
|
||||
<el-checkbox-group v-model="formData.eye_condition">
|
||||
<el-checkbox v-for="item in eyeConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="头部感觉">
|
||||
<el-checkbox-group v-model="formData.head_feeling">
|
||||
<el-checkbox v-for="item in headFeelingOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="出汗情况">
|
||||
<el-checkbox-group v-model="formData.sweat_condition">
|
||||
<el-checkbox v-for="item in sweatConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="皮肤情况">
|
||||
<el-checkbox-group v-model="formData.skin_condition">
|
||||
<el-checkbox v-for="item in skinConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="小便情况">
|
||||
<el-checkbox-group v-model="formData.urine_condition">
|
||||
<el-checkbox v-for="item in urineConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="大便情况">
|
||||
<el-checkbox-group v-model="formData.stool_condition">
|
||||
<el-checkbox v-for="item in stoolConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="腰肾情况">
|
||||
<el-checkbox-group v-model="formData.kidney_condition">
|
||||
<el-checkbox v-for="item in kidneyConditionOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="脂肪肝程度">
|
||||
<el-radio-group v-model="formData.fatty_liver_degree">
|
||||
<el-radio v-for="item in fattyLiverDegreeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 既往史 -->
|
||||
<el-divider content-position="left">既往史</el-divider>
|
||||
|
||||
<el-form-item label="既往史" prop="past_history">
|
||||
<el-checkbox-group v-model="formData.past_history">
|
||||
<el-checkbox
|
||||
v-for="item in pastHistoryOptions"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<el-divider content-position="left">其他病史</el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="外伤史">
|
||||
<el-radio-group v-model="formData.trauma_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="手术史">
|
||||
<el-radio-group v-model="formData.surgery_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="过敏史">
|
||||
<el-radio-group v-model="formData.allergy_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="家族病史">
|
||||
<el-radio-group v-model="formData.family_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="妊娠哺乳史">
|
||||
<el-radio-group v-model="formData.pregnancy_history">
|
||||
<el-radio :label="1">有</el-radio>
|
||||
<el-radio :label="0">无</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 诊断信息 -->
|
||||
<el-divider content-position="left">诊断信息</el-divider>
|
||||
|
||||
<el-form-item label="舌苔照片">
|
||||
<material-picker
|
||||
v-model="formData.tongue_images"
|
||||
:limit="9"
|
||||
type="image"
|
||||
/>
|
||||
<div class="form-tips">支持上传多张舌苔照片,最多9张</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="检查报告">
|
||||
<material-picker
|
||||
v-model="formData.report_files"
|
||||
:limit="10"
|
||||
type="image"
|
||||
/>
|
||||
<div class="form-tips">支持上传检查报告、病历、彩超等影像科检查图片,最多10个文件</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="症状" prop="symptoms">
|
||||
<el-input
|
||||
v-model="formData.symptoms"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入症状"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="舌苔" prop="tongue_coating">
|
||||
<el-input
|
||||
v-model="formData.tongue_coating"
|
||||
placeholder="请输入舌苔情况"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="脉象" prop="pulse">
|
||||
<el-input
|
||||
v-model="formData.pulse"
|
||||
placeholder="请输入脉象"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="治则" prop="treatment_principle">
|
||||
<el-input
|
||||
v-model="formData.treatment_principle"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入治则"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方" prop="prescription">
|
||||
<el-input
|
||||
v-model="formData.prescription"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入处方"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="医嘱" prop="doctor_advice">
|
||||
<el-input
|
||||
v-model="formData.doctor_advice"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入医嘱"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 血糖血压记录标签页 -->
|
||||
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
||||
<blood-record-list
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 px-4 pb-4">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'basic'"
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BloodRecordList from './components/BloodRecordList.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const formRef = ref()
|
||||
const mode = ref('add')
|
||||
const submitting = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => (mode.value === 'add' ? '新增诊单' : '编辑诊单'))
|
||||
|
||||
const formData = ref({
|
||||
id: '',
|
||||
patient_id: '',
|
||||
patient_name: '',
|
||||
id_card: '',
|
||||
phone: '',
|
||||
gender: 1,
|
||||
age: undefined as number | undefined,
|
||||
diagnosis_date: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
// 现病史字段
|
||||
appetite: '',
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
weight_change: '',
|
||||
body_feeling: [],
|
||||
sleep_condition: [],
|
||||
eye_condition: [],
|
||||
head_feeling: [],
|
||||
sweat_condition: [],
|
||||
skin_condition: [],
|
||||
urine_condition: [],
|
||||
stool_condition: [],
|
||||
kidney_condition: [],
|
||||
fatty_liver_degree: '',
|
||||
// 既往史
|
||||
past_history: [],
|
||||
// 其他病史
|
||||
trauma_history: 0,
|
||||
surgery_history: 0,
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
// 文件上传
|
||||
tongue_images: [],
|
||||
report_files: [],
|
||||
// 诊断信息
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
pulse: '',
|
||||
treatment_principle: '',
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 自定义验证规则
|
||||
const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入手机号'))
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
callback(new Error('手机号格式不正确'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||
id_card: [{ validator: validateIdCard, trigger: 'blur' }],
|
||||
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
|
||||
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 获取字典选项
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
const syndromeTypeOptions = ref<any[]>([])
|
||||
const pastHistoryOptions = ref<any[]>([])
|
||||
// 现病史字典选项
|
||||
const appetiteOptions = ref<any[]>([])
|
||||
const waterIntakeOptions = ref<any[]>([])
|
||||
const dietConditionOptions = ref<any[]>([])
|
||||
const weightChangeOptions = ref<any[]>([])
|
||||
const bodyFeelingOptions = ref<any[]>([])
|
||||
const sleepConditionOptions = ref<any[]>([])
|
||||
const eyeConditionOptions = ref<any[]>([])
|
||||
const headFeelingOptions = ref<any[]>([])
|
||||
const sweatConditionOptions = ref<any[]>([])
|
||||
const skinConditionOptions = ref<any[]>([])
|
||||
const urineConditionOptions = ref<any[]>([])
|
||||
const stoolConditionOptions = ref<any[]>([])
|
||||
const kidneyConditionOptions = ref<any[]>([])
|
||||
const fattyLiverDegreeOptions = ref<any[]>([])
|
||||
|
||||
const getDictOptions = async () => {
|
||||
try {
|
||||
const [
|
||||
diagnosisType,
|
||||
syndromeType,
|
||||
pastHistory,
|
||||
appetite,
|
||||
waterIntake,
|
||||
dietCondition,
|
||||
weightChange,
|
||||
bodyFeeling,
|
||||
sleepCondition,
|
||||
eyeCondition,
|
||||
headFeeling,
|
||||
sweatCondition,
|
||||
skinCondition,
|
||||
urineCondition,
|
||||
stoolCondition,
|
||||
kidneyCondition,
|
||||
fattyLiverDegree
|
||||
] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getDictData({ type: 'past_history' }),
|
||||
getDictData({ type: 'appetite' }),
|
||||
getDictData({ type: 'water_intake' }),
|
||||
getDictData({ type: 'diet_condition' }),
|
||||
getDictData({ type: 'weight_change' }),
|
||||
getDictData({ type: 'body_feeling' }),
|
||||
getDictData({ type: 'sleep_condition' }),
|
||||
getDictData({ type: 'eye_condition' }),
|
||||
getDictData({ type: 'head_feeling' }),
|
||||
getDictData({ type: 'sweat_condition' }),
|
||||
getDictData({ type: 'skin_condition' }),
|
||||
getDictData({ type: 'urine_condition' }),
|
||||
getDictData({ type: 'stool_condition' }),
|
||||
getDictData({ type: 'kidney_condition' }),
|
||||
getDictData({ type: 'fatty_liver_degree' })
|
||||
])
|
||||
|
||||
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
|
||||
pastHistoryOptions.value = pastHistory?.past_history || []
|
||||
appetiteOptions.value = appetite?.appetite || []
|
||||
waterIntakeOptions.value = waterIntake?.water_intake || []
|
||||
dietConditionOptions.value = dietCondition?.diet_condition || []
|
||||
weightChangeOptions.value = weightChange?.weight_change || []
|
||||
bodyFeelingOptions.value = bodyFeeling?.body_feeling || []
|
||||
sleepConditionOptions.value = sleepCondition?.sleep_condition || []
|
||||
eyeConditionOptions.value = eyeCondition?.eye_condition || []
|
||||
headFeelingOptions.value = headFeeling?.head_feeling || []
|
||||
sweatConditionOptions.value = sweatCondition?.sweat_condition || []
|
||||
skinConditionOptions.value = skinCondition?.skin_condition || []
|
||||
urineConditionOptions.value = urineCondition?.urine_condition || []
|
||||
stoolConditionOptions.value = stoolCondition?.stool_condition || []
|
||||
kidneyConditionOptions.value = kidneyCondition?.kidney_condition || []
|
||||
fattyLiverDegreeOptions.value = fattyLiverDegree?.fatty_liver_degree || []
|
||||
} catch (error) {
|
||||
console.error('获取字典数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号失焦检查
|
||||
const handlePhoneBlur = async () => {
|
||||
if (!formData.value.phone || !/^1[3-9]\d{9}$/.test(formData.value.phone)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkPhone({
|
||||
phone: formData.value.phone,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证号失焦检查
|
||||
const handleIdCardBlur = async () => {
|
||||
if (!formData.value.id_card || !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
const data = await tcmDiagnosisDetail({ id })
|
||||
formData.value = data
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'add') {
|
||||
const result = await tcmDiagnosisAdd(formData.value)
|
||||
feedback.msgSuccess('添加成功')
|
||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||
if (result && result.id) {
|
||||
mode.value = 'edit'
|
||||
formData.value.id = result.id
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
try {
|
||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||
formData.value.patient_id = detail.patient_id
|
||||
feedback.msgSuccess('诊单已保存,现在可以添加血糖血压记录了')
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
// 即使获取详情失败,也不影响主流程
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tcmDiagnosisEdit(formData.value)
|
||||
feedback.msgSuccess('编辑成功')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {
|
||||
id: '',
|
||||
patient_id: '',
|
||||
patient_name: '',
|
||||
id_card: '',
|
||||
phone: '',
|
||||
gender: 1,
|
||||
age: undefined as number | undefined,
|
||||
diagnosis_date: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
appetite: '',
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
weight_change: '',
|
||||
body_feeling: [],
|
||||
sleep_condition: [],
|
||||
eye_condition: [],
|
||||
head_feeling: [],
|
||||
sweat_condition: [],
|
||||
skin_condition: [],
|
||||
urine_condition: [],
|
||||
stool_condition: [],
|
||||
kidney_condition: [],
|
||||
fatty_liver_degree: '',
|
||||
past_history: [],
|
||||
trauma_history: 0,
|
||||
surgery_history: 0,
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
tongue_images: [],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
pulse: '',
|
||||
treatment_principle: '',
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-drawer {
|
||||
:deep(.el-checkbox-group) {
|
||||
.el-checkbox {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
.el-radio {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tips {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="tcm-diagnosis">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="患者姓名">
|
||||
<el-input
|
||||
placeholder="请输入患者姓名"
|
||||
v-model="formData.patient_name"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="w-[280px]" label="诊断类型">
|
||||
<el-select v-model="formData.diagnosis_type" placeholder="请选择" clearable>
|
||||
<el-option
|
||||
v-for="item in diagnosisTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="w-[280px]" label="证型">
|
||||
<el-select v-model="formData.syndrome_type" placeholder="请选择" clearable>
|
||||
<el-option
|
||||
v-for="item in syndromeTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="w-[280px]" label="医助">
|
||||
<el-select v-model="formData.assistant_id" placeholder="请选择" clearable filterable>
|
||||
<el-option
|
||||
v-for="item in assistantOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<el-button type="primary" @click="handleAdd" v-perms="['tcm.diagnosis/add']">新增患者</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/assign']"
|
||||
type="success"
|
||||
@click="handleBatchAssign"
|
||||
:disabled="selectedIds.length === 0"
|
||||
>
|
||||
批量指派医助
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="selectedIds.length > 0" class="text-gray-500">
|
||||
已选择 {{ selectedIds.length }} 条记录
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
v-loading="pager.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="ID" prop="id" width="80" />
|
||||
<el-table-column label="患者姓名" prop="patient_name" min-width="100" />
|
||||
<el-table-column label="性别" prop="gender_desc" width="80" />
|
||||
<el-table-column label="年龄" prop="age" width="80" />
|
||||
<el-table-column label="诊断日期" prop="diagnosis_date_text" min-width="120" />
|
||||
<el-table-column label="诊断类型" prop="diagnosis_type" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ getDictLabel(diagnosisTypeOptions, row.diagnosis_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证型" prop="syndrome_type" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ getDictLabel(syndromeTypeOptions, row.syndrome_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指派医助" prop="assistant_name" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.status == 1" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleDetail(row.id)">
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/assign']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleSingleAssign(row)"
|
||||
>
|
||||
指派
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/video-call']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleVideoCall(row)"
|
||||
>
|
||||
1V1通话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/video-group']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleGroupVideoCall(row)"
|
||||
>
|
||||
群通话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/guahao']"
|
||||
type="success"
|
||||
link
|
||||
@click="handleAppointment(row)"
|
||||
>
|
||||
挂号
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row.id)" v-perms="['tcm.diagnosis/delete']">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex mt-4 justify-end">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
<detail-popup ref="detailRef" />
|
||||
<video-call ref="videoCallRef" />
|
||||
<appointment-popup ref="appointmentRef" @success="getLists" />
|
||||
|
||||
<!-- 批量指派医助弹窗 -->
|
||||
<el-dialog
|
||||
v-model="assignDialogVisible"
|
||||
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="assignForm" label-width="100px">
|
||||
<el-form-item label="选择医助" required>
|
||||
<el-select
|
||||
v-model="assignForm.assistant_id"
|
||||
placeholder="请选择医助"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
value-key="id"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in assistantOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="诊单信息">
|
||||
<div class="text-gray-500">
|
||||
<div>患者:{{ currentRow?.patient_name }}</div>
|
||||
<div>诊断日期:{{ currentRow?.diagnosis_date_text }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleConfirmAssign"
|
||||
:loading="assignLoading"
|
||||
>
|
||||
确定指派
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmDiagnosis">
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import EditPopup from './edit.vue'
|
||||
import DetailPopup from './detail.vue'
|
||||
import VideoCall from '@/components/video-call/index.vue'
|
||||
import AppointmentPopup from './appointment.vue'
|
||||
|
||||
const formData = ref({
|
||||
patient_name: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
assistant_id: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
})
|
||||
|
||||
// 获取字典选项
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
const syndromeTypeOptions = ref<any[]>([])
|
||||
const assistantOptions = ref<any[]>([])
|
||||
|
||||
const getDictOptions = async () => {
|
||||
try {
|
||||
const [diagnosisType, syndromeType, assistants] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getAssistants()
|
||||
])
|
||||
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
|
||||
assistantOptions.value = assistants || []
|
||||
|
||||
// 调试:打印医助列表
|
||||
console.log('医助列表数据:', assistants)
|
||||
console.log('assistantOptions:', assistantOptions.value)
|
||||
} catch (error) {
|
||||
console.error('获取字典数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典标签
|
||||
const getDictLabel = (options: any[], value: string) => {
|
||||
const item = options.find(opt => opt.value === value)
|
||||
return item ? item.name : value || '-'
|
||||
}
|
||||
|
||||
// 获取医助名称
|
||||
const getAssistantName = (assistantId: number | string) => {
|
||||
// 如果没有assistant_id,返回"-"
|
||||
if (!assistantId && assistantId !== 0) return '-'
|
||||
|
||||
// 确保类型匹配,转换为数字进行比较
|
||||
const id = typeof assistantId === 'string' ? parseInt(assistantId) : assistantId
|
||||
|
||||
// 如果医助列表还没加载,返回"加载中..."
|
||||
if (assistantOptions.value.length === 0) return '...'
|
||||
|
||||
const assistant = assistantOptions.value.find(item => {
|
||||
const itemId = typeof item.id === 'string' ? parseInt(item.id) : item.id
|
||||
return itemId === id
|
||||
})
|
||||
|
||||
return assistant ? assistant.name : '-'
|
||||
}
|
||||
|
||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
fetchFun: tcmDiagnosisLists,
|
||||
params: formData.value
|
||||
})
|
||||
|
||||
const editRef = ref()
|
||||
const detailRef = ref()
|
||||
const videoCallRef = ref()
|
||||
const appointmentRef = ref()
|
||||
|
||||
// 批量指派相关
|
||||
const selectedIds = ref<number[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const assignLoading = ref(false)
|
||||
const assignMode = ref<'batch' | 'single'>('batch')
|
||||
const currentRow = ref<any>(null)
|
||||
const assignForm = ref({
|
||||
assistant_id: null as number | null
|
||||
})
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
|
||||
// 单独指派
|
||||
const handleSingleAssign = (row: any) => {
|
||||
assignMode.value = 'single'
|
||||
currentRow.value = row
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
|
||||
assignDialogVisible.value = true
|
||||
|
||||
// 调试
|
||||
console.log('当前行数据:', row)
|
||||
console.log('assistant_id:', row.assistant_id, '类型:', typeof row.assistant_id)
|
||||
console.log('转换后:', assignForm.value.assistant_id, '类型:', typeof assignForm.value.assistant_id)
|
||||
console.log('医助选项:', assistantOptions.value)
|
||||
}
|
||||
|
||||
// 批量指派
|
||||
const handleBatchAssign = () => {
|
||||
if (selectedIds.value.length === 0) {
|
||||
feedback.msgWarning('请先选择要指派的诊单')
|
||||
return
|
||||
}
|
||||
assignMode.value = 'batch'
|
||||
currentRow.value = null
|
||||
assignForm.value.assistant_id = null
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmAssign = async () => {
|
||||
if (!assignForm.value.assistant_id) {
|
||||
feedback.msgWarning('请选择医助')
|
||||
return
|
||||
}
|
||||
|
||||
assignLoading.value = true
|
||||
try {
|
||||
if (assignMode.value === 'single') {
|
||||
// 单独指派
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
})
|
||||
} else {
|
||||
// 批量指派
|
||||
const promises = selectedIds.value.map(id =>
|
||||
tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
})
|
||||
)
|
||||
await Promise.all(promises)
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
assignDialogVisible.value = false
|
||||
getLists()
|
||||
} catch (error) {
|
||||
console.error('指派失败:', error)
|
||||
} finally {
|
||||
assignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
editRef.value.open('add')
|
||||
}
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
editRef.value.open('edit', id)
|
||||
}
|
||||
|
||||
const handleDetail = (id: number) => {
|
||||
detailRef.value.open(id)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await feedback.confirm('确定要删除该诊单吗?')
|
||||
await tcmDiagnosisDelete({ id })
|
||||
getLists()
|
||||
}
|
||||
|
||||
// 视频通话(一对一)
|
||||
const handleVideoCall = (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
videoCallRef.value?.open({
|
||||
diagnosisId: row.id,
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
userId: `patient_${row.patient_id}`, // 患者的用户ID
|
||||
isGroup: false
|
||||
})
|
||||
}
|
||||
|
||||
// 群组视频通话(三方通话)
|
||||
const handleGroupVideoCall = (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
if (!row.assistant_id) {
|
||||
feedback.msgWarning('该诊单未指派医助,无法发起群组通话')
|
||||
return
|
||||
}
|
||||
|
||||
// 群组通话参与者顺序:当前登录用户(发起者)、患者、医助
|
||||
const userIds = [
|
||||
`patient_${row.patient_id}`, // 先邀请患者
|
||||
`doctor_${row.assistant_id}` // 再邀请医助
|
||||
]
|
||||
|
||||
videoCallRef.value?.open({
|
||||
diagnosisId: row.id,
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
assistantId: row.assistant_id,
|
||||
userIds: userIds,
|
||||
isGroup: true
|
||||
})
|
||||
}
|
||||
|
||||
// 患者挂号
|
||||
const handleAppointment = (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
appointmentRef.value?.open(row)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getDictOptions()
|
||||
getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="patient-test">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>患者端测试 - 接听来电</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
>
|
||||
<p>此页面用于测试音视频通话功能</p>
|
||||
<p>使用方法:</p>
|
||||
<ol>
|
||||
<li>在此页面初始化患者端</li>
|
||||
<li>在另一个浏览器窗口登录医生端</li>
|
||||
<li>医生端发起通话</li>
|
||||
<li>此页面会收到来电提示</li>
|
||||
</ol>
|
||||
</el-alert>
|
||||
|
||||
<el-form :model="form" label-width="100px" v-if="!initialized">
|
||||
<el-form-item label="患者ID">
|
||||
<el-input
|
||||
v-model="form.patientId"
|
||||
placeholder="输入患者ID,如:1"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="initPatient"
|
||||
:loading="loading"
|
||||
>
|
||||
初始化患者端
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="initialized" class="status">
|
||||
<el-alert
|
||||
:type="hasIncomingCall ? 'warning' : 'success'"
|
||||
:closable="false"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<el-icon class="mr-2" :class="{ 'animate-pulse': hasIncomingCall }">
|
||||
<Phone />
|
||||
</el-icon>
|
||||
<span>{{ hasIncomingCall ? '收到来电!' : '患者端已初始化,等待来电...' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<p><strong>患者ID:</strong> {{ currentUserId }}</p>
|
||||
<p><strong>状态:</strong> {{ hasIncomingCall ? '来电中' : '在线' }}</p>
|
||||
<p v-if="hasIncomingCall && incomingCallInfo">
|
||||
<strong>通话类型:</strong> 视频通话
|
||||
</p>
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<!-- 来电操作按钮 -->
|
||||
<div v-if="hasIncomingCall" class="call-actions mt-4">
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
@click="acceptCall"
|
||||
:icon="Phone"
|
||||
>
|
||||
接听
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="large"
|
||||
@click="rejectCall"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-else
|
||||
type="danger"
|
||||
@click="reset"
|
||||
class="mt-4"
|
||||
>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div id="call-container" class="call-container">
|
||||
<TUICallKit v-if="initialized" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Phone } from '@element-plus/icons-vue'
|
||||
import { TUICallKitAPI, TUICallKit, STATUS } from '@trtc/calls-uikit-vue'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const form = ref({
|
||||
patientId: '1'
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const currentUserId = ref('')
|
||||
const hasIncomingCall = ref(false)
|
||||
const incomingCallInfo = ref<any>(null)
|
||||
|
||||
// 监听状态变化
|
||||
const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||||
console.log('通话状态变化:', { oldStatus, newStatus })
|
||||
|
||||
// 收到来电邀请
|
||||
if (newStatus === STATUS.BE_INVITED) {
|
||||
hasIncomingCall.value = true
|
||||
incomingCallInfo.value = { oldStatus, newStatus }
|
||||
|
||||
// 显示来电通知
|
||||
ElNotification({
|
||||
title: '来电提醒',
|
||||
message: '收到视频通话请求',
|
||||
type: 'info',
|
||||
duration: 0, // 不自动关闭
|
||||
position: 'top-right'
|
||||
})
|
||||
|
||||
ElMessage.success('收到来电,请点击接听按钮')
|
||||
}
|
||||
|
||||
// 通话结束,回到空闲状态
|
||||
if (newStatus === STATUS.IDLE && hasIncomingCall.value) {
|
||||
hasIncomingCall.value = false
|
||||
incomingCallInfo.value = null
|
||||
ElMessage.info('通话已结束')
|
||||
}
|
||||
}
|
||||
|
||||
const initPatient = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
ElMessage.info('正在获取签名...')
|
||||
|
||||
// 获取患者签名
|
||||
const result = await request.get({
|
||||
url: '/tcm.diagnosis/getPatientSignature',
|
||||
params: {
|
||||
patient_id: form.value.patientId
|
||||
}
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error('获取签名失败')
|
||||
}
|
||||
|
||||
const { sdkAppId, userId, userSig } = result
|
||||
currentUserId.value = userId
|
||||
|
||||
console.log('患者签名获取成功:', { userId, sdkAppId, userSigLength: userSig.length })
|
||||
ElMessage.info('正在初始化通话组件...')
|
||||
|
||||
// 设置状态变化回调
|
||||
TUICallKitAPI.setCallback({
|
||||
statusChanged: handleStatusChange
|
||||
})
|
||||
|
||||
// 初始化(注意:SDKAppID必须是数字类型)
|
||||
await TUICallKitAPI.init({
|
||||
userID: userId,
|
||||
userSig: userSig,
|
||||
SDKAppID: Number(sdkAppId) // 转换为数字类型
|
||||
})
|
||||
|
||||
console.log('TUICallKitAPI.init 完成')
|
||||
console.log('已注册状态监听回调')
|
||||
ElMessage.info('等待初始化完成...')
|
||||
|
||||
// 等待初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
initialized.value = true
|
||||
ElMessage.success('患者端初始化成功,等待来电...')
|
||||
|
||||
console.log('患者端初始化完成,userId:', userId)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('初始化失败:', error)
|
||||
ElMessage.error(error.message || '初始化失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 接听来电
|
||||
const acceptCall = async () => {
|
||||
try {
|
||||
console.log('接听来电')
|
||||
await TUICallKitAPI.accept()
|
||||
ElMessage.success('已接听通话')
|
||||
hasIncomingCall.value = false
|
||||
} catch (error: any) {
|
||||
console.error('接听失败:', error)
|
||||
ElMessage.error(error.message || '接听失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝来电
|
||||
const rejectCall = async () => {
|
||||
try {
|
||||
console.log('拒绝来电')
|
||||
await TUICallKitAPI.reject()
|
||||
ElMessage.info('已拒绝通话')
|
||||
hasIncomingCall.value = false
|
||||
incomingCallInfo.value = null
|
||||
} catch (error: any) {
|
||||
console.error('拒绝失败:', error)
|
||||
ElMessage.error(error.message || '拒绝失败')
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
initialized.value = false
|
||||
currentUserId.value = ''
|
||||
hasIncomingCall.value = false
|
||||
incomingCallInfo.value = null
|
||||
form.value.patientId = '1'
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (initialized.value) {
|
||||
// 清理资源
|
||||
hasIncomingCall.value = false
|
||||
incomingCallInfo.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.patient-test {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.call-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
margin-top: 20px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 确保 TUICallKit 组件正确显示 */
|
||||
:deep(.call-container) {
|
||||
.tui-call-kit {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-alert__description) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-alert__description ol) {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-alert__description li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user