新增功能

This commit is contained in:
Your Name
2026-03-04 15:32:30 +08:00
parent a07e844c47
commit ab77f5488d
2266 changed files with 177942 additions and 3444 deletions
+165
View File
@@ -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
**状态:** ✅ 完成
+246
View File
@@ -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
+342
View File
@@ -0,0 +1,342 @@
# 患者挂号系统实现文档
## 功能概述
实现了基于医生排班的患者挂号系统,支持15分钟间隔的时间段预约,防止重复预约,并根据医生排班状态控制挂号可用性。
## 核心功能
### 1. 时间段管理
- 每个时间段间隔15分钟
- 上午时段:08:00 - 12:0016个时间段)
- 下午时段:14:00 - 18:0016个时间段)
- 每个时间段只能被一个患者预约
### 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. **评价系统**:就诊后患者评价医生
+213
View File
@@ -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. **收藏医生**:支持收藏常用医生快速预约
+194
View File
@@ -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. **定期检查**:定期检查排班数据的准确性
+214
View File
@@ -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-107天)
- 时段:上午、下午(2个)
- 星期:周一至周五(5天)
- 结果:2 × 5 × 2 = 20条记录
### 示例2:单医生全周排班
- 医生:张医生(1人)
- 日期:2024-03-04 至 2024-03-107天)
- 时段:上午(1个)
- 星期:周一至周日(7天)
- 结果:1 × 7 × 1 = 7条记录
### 示例3:周末休息
- 医生:张医生、李医生、王医生(3人)
- 日期:2024-03-09 至 2024-03-102天)
- 时段:上午、下午(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-60是周日
// 检查是否在选中的星期内
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: 初始版本,支持基本的批量排班功能
+86
View File
@@ -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
+251
View File
@@ -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 组件
+404
View File
@@ -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 // 角色ID1=医生)
// ... 其他管理员字段
}
```
## 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-107天)
- 时段:上午、下午
- 星期:周一至周五(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. 排班冲突检测
+179
View File
@@ -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`
+311
View File
@@ -0,0 +1,311 @@
# HTTPS 部署快速指南
## 为什么需要 HTTPS
WebRTC(视频通话功能)要求:
- ✅ HTTPS 协议
- ✅ 或者 localhost/127.0.0.1
生产环境必须使用 HTTPS,否则浏览器会阻止访问摄像头和麦克风。
## 快速部署步骤
### 步骤1:申请 SSL 证书
#### 方案ALet'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 查看错误信息
+342
View File
@@ -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)
+103
View File
@@ -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)
+129
View File
@@ -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
+263
View File
@@ -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
+229
View File
@@ -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 到项目仓库
+147
View File
@@ -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`
+406
View File
@@ -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. **准备两个浏览器窗口**
- 窗口 1Chrome(医生端)
- 窗口 2:Chrome 无痕模式(患者端)
2. **初始化患者端**
- 在窗口 2 中访问:`http://localhost:5173/#/test/patient-call`
- 输入患者ID1
- 点击"初始化患者端"
- 等待提示"患者端初始化成功"
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)
+415
View File
@@ -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)
+110
View File
@@ -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. 确认无误后部署到生产环境
+292
View File
@@ -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 功能。
+159
View File
@@ -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
+39
View File
@@ -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 表的 idrole_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());
+77
View File
@@ -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;
#
# # ... 其他配置同上
# }
+2731 -2663
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+32
View File
@@ -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
+31
View File
@@ -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 ""
+74
View File
@@ -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 })
}
+100
View File
@@ -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 })
}
+5
View File
@@ -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
+9
View File
@@ -1,4 +1,13 @@
:root {
// 确保消息提示在最上层
.el-message {
z-index: 9999 !important;
}
.el-notification {
z-index: 9999 !important;
}
// 弹窗居中
.el-overlay-dialog {
display: flex;
+6
View File
@@ -0,0 +1,6 @@
完整的排班管理代码请参考 DOCTOR_ROSTER.md 文档
由于文件较大,建议手动创建或从以下地址获取完整代码:
https://github.com/your-repo/roster.vue
或者使用以下简化版本开始开发。
+442
View File
@@ -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-60是周日
// 检查是否在选中的星期内
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>
+259
View File
@@ -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>
+165
View File
@@ -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>
+813
View File
@@ -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>
+778
View File
@@ -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>
+442
View File
@@ -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>
View File
+350
View File
@@ -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>