Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ddff9b26 | ||
|
|
3246735b18 | ||
|
|
f9ab5e18fb |
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# 添加剂量单位和剂数字段到订单编辑表单
|
||||
|
||||
## 任务概述
|
||||
在处方订单编辑表单中添加 `dose_unit`(剂量单位)和 `dose_count`(剂数)字段,支持用户选择和编辑。
|
||||
|
||||
## 实现内容
|
||||
|
||||
### 1. 前端表单字段(admin/src/views/consumer/prescription/order_list.vue)
|
||||
|
||||
#### 添加的表单项:
|
||||
- **剂量单位** (`dose_unit`): 下拉选择框,7个选项
|
||||
- 剂、丸、袋、盒、瓶、膏、贴
|
||||
- 默认值:剂
|
||||
|
||||
- **剂数** (`dose_count`): 数字输入框
|
||||
- 最小值:1
|
||||
- 默认值:1
|
||||
|
||||
#### 修改位置:
|
||||
- 表单定义:在 `medication_days` 字段后添加
|
||||
- 数据加载:`openEdit()` 函数中加载 `dose_count` 和 `dose_unit`
|
||||
- 数据提交:`submitEdit()` 函数中包含 `dose_count` 和 `dose_unit`
|
||||
|
||||
### 2. 后端逻辑(server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php)
|
||||
|
||||
#### 修改的方法:
|
||||
- `edit()` 方法添加字段处理:
|
||||
```php
|
||||
$order->dose_unit = (string) ($params['dose_unit'] ?? '剂');
|
||||
$order->dose_count = isset($params['dose_count']) && (int)$params['dose_count'] > 0 ? (int)$params['dose_count'] : 1;
|
||||
```
|
||||
|
||||
### 3. 数据库迁移(add_dose_unit_to_prescription_order.sql)
|
||||
|
||||
#### 添加的字段:
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `dose_unit` varchar(20) NOT NULL DEFAULT '剂' COMMENT '剂量单位:剂、丸、袋、盒、瓶、膏、贴' AFTER `medication_days`,
|
||||
ADD COLUMN `dose_count` int(11) NOT NULL DEFAULT 1 COMMENT '剂数' AFTER `dose_unit`;
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| dose_unit | varchar(20) | 剂 | 剂量单位:剂、丸、袋、盒、瓶、膏、贴 |
|
||||
| dose_count | int(11) | 1 | 剂数,最小值为1 |
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. **执行数据库迁移**:
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_dose_unit_to_prescription_order.sql
|
||||
```
|
||||
|
||||
2. **部署后端代码**:
|
||||
- 更新 `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
3. **部署前端代码**:
|
||||
- 更新 `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- 重新构建前端:`npm run build`
|
||||
|
||||
## 测试要点
|
||||
|
||||
- [ ] 创建新订单时,`dose_unit` 默认为"剂",`dose_count` 默认为1
|
||||
- [ ] 编辑订单时,能正确加载已保存的 `dose_unit` 和 `dose_count` 值
|
||||
- [ ] 修改 `dose_unit` 和 `dose_count` 后能正确保存
|
||||
- [ ] 验证 `dose_count` 不能小于1
|
||||
- [ ] 验证所有7个剂量单位选项都能正常选择和保存
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端订单列表和编辑表单
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端订单编辑逻辑
|
||||
- `add_dose_unit_to_prescription_order.sql` - 数据库迁移脚本
|
||||
@@ -1,208 +0,0 @@
|
||||
# 预约列表按钮显示优化
|
||||
|
||||
## 问题描述
|
||||
|
||||
在预约列表中,同样的情况下,有的预约显示"开方"按钮,有的不显示。
|
||||
|
||||
## 问题原因
|
||||
|
||||
操作列的按钮过多,导致"开方"按钮被挤出可视区域或被其他按钮遮挡。
|
||||
|
||||
原有按钮(从左到右):
|
||||
1. 编辑患者
|
||||
2. 视频二维码
|
||||
3. 通话
|
||||
4. 完成
|
||||
5. 开方/查看
|
||||
6. 更多
|
||||
|
||||
当所有按钮都显示时,操作列宽度为 380px,可能不够容纳所有按钮。
|
||||
|
||||
## 解决方案
|
||||
|
||||
根据预约状态优化按钮显示:
|
||||
|
||||
### 已完成状态(status = 3)
|
||||
|
||||
只显示核心按钮:
|
||||
- 编辑患者
|
||||
- 开方/查看
|
||||
- 更多
|
||||
|
||||
隐藏按钮:
|
||||
- 视频二维码(已完成,不需要视频)
|
||||
- 通话(已完成,不需要通话)
|
||||
- 完成(已经完成)
|
||||
|
||||
### 其他状态(待接诊、已过号、已取消)
|
||||
|
||||
显示所有按钮:
|
||||
- 编辑患者
|
||||
- 视频二维码
|
||||
- 通话
|
||||
- 完成
|
||||
- 开方/查看
|
||||
- 更多
|
||||
|
||||
## 修改内容
|
||||
|
||||
**文件**: `admin/src/views/tcm/appointment/list.vue`
|
||||
|
||||
### 修改前
|
||||
|
||||
```vue
|
||||
<el-table-column label="操作" width="380" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
<el-button>编辑患者</el-button>
|
||||
<el-button>视频二维码</el-button>
|
||||
<el-button>通话</el-button>
|
||||
<el-button>完成</el-button>
|
||||
<el-button>{{ prescriptionActionLabel(row) }}</el-button>
|
||||
<el-dropdown>更多</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
### 修改后
|
||||
|
||||
```vue
|
||||
<el-table-column label="操作" width="380" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
<el-button>编辑患者</el-button>
|
||||
|
||||
<!-- 已完成状态不显示这些按钮 -->
|
||||
<template v-if="row.status !== 3">
|
||||
<el-button>视频二维码</el-button>
|
||||
<el-button>通话</el-button>
|
||||
<el-button>完成</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 开方/查看按钮始终显示 -->
|
||||
<el-button>{{ prescriptionActionLabel(row) }}</el-button>
|
||||
|
||||
<el-dropdown>更多</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
## 按钮显示规则
|
||||
|
||||
### 预约状态说明
|
||||
|
||||
| 状态值 | 状态名称 | 说明 |
|
||||
|-------|---------|------|
|
||||
| 1 | 待接诊 | 患者已挂号,等待医生接诊 |
|
||||
| 2 | 已取消 | 挂号已取消 |
|
||||
| 3 | 已完成 | 医生已完成接诊 |
|
||||
| 4 | 已过号 | 患者未按时就诊,已过号 |
|
||||
|
||||
### 按钮显示矩阵
|
||||
|
||||
| 按钮 | 待接诊(1) | 已取消(2) | 已完成(3) | 已过号(4) |
|
||||
|-----|----------|----------|----------|----------|
|
||||
| 编辑患者 | ✅ | ✅ | ✅ | ✅ |
|
||||
| 视频二维码 | ✅ | ✅ | ❌ | ✅ |
|
||||
| 通话 | ✅ | ✅ | ❌ | ✅ |
|
||||
| 完成 | ✅ | ✅ | ❌ | ✅ |
|
||||
| 开方/查看 | ✅ | ✅ | ✅ | ✅ |
|
||||
| 更多 | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
## 优势
|
||||
|
||||
### 1. 确保核心按钮始终可见
|
||||
|
||||
- "开方/查看"按钮是核心功能,必须始终显示
|
||||
- 已完成状态下,减少不必要的按钮,确保核心按钮可见
|
||||
|
||||
### 2. 优化用户体验
|
||||
|
||||
- 已完成的预约不需要"视频二维码"、"通话"、"完成"按钮
|
||||
- 减少按钮数量,界面更简洁
|
||||
|
||||
### 3. 避免按钮被遮挡
|
||||
|
||||
- 减少按钮数量后,操作列宽度足够容纳所有按钮
|
||||
- 避免按钮被挤出可视区域
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 测试用例 1: 待接诊状态
|
||||
|
||||
1. 找到一个待接诊的预约(status = 1)
|
||||
2. 检查操作列是否显示所有按钮:
|
||||
- ✅ 编辑患者
|
||||
- ✅ 视频二维码
|
||||
- ✅ 通话
|
||||
- ✅ 完成
|
||||
- ✅ 开方/查看
|
||||
- ✅ 更多
|
||||
|
||||
### 测试用例 2: 已完成状态
|
||||
|
||||
1. 找到一个已完成的预约(status = 3)
|
||||
2. 检查操作列是否只显示核心按钮:
|
||||
- ✅ 编辑患者
|
||||
- ❌ 视频二维码(不显示)
|
||||
- ❌ 通话(不显示)
|
||||
- ❌ 完成(不显示)
|
||||
- ✅ 开方/查看
|
||||
- ✅ 更多
|
||||
|
||||
### 测试用例 3: 已完成且已开方
|
||||
|
||||
1. 找到一个已完成且已开方的预约
|
||||
2. 检查"开方/查看"按钮是否显示为"查看"
|
||||
3. 点击"查看"按钮,应该可以查看处方(只读模式)
|
||||
|
||||
### 测试用例 4: 已完成但未开方
|
||||
|
||||
1. 找到一个已完成但未开方的预约
|
||||
2. 检查"开方/查看"按钮是否显示为"开方"
|
||||
3. 点击"开方"按钮,应该可以创建新处方
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 权限检查
|
||||
|
||||
所有按钮都有权限检查,如果用户没有相应权限,按钮会被隐藏:
|
||||
- `tcm.diagnosis/edit` - 编辑患者
|
||||
- `tcm.diagnosis/videoQr` - 视频二维码
|
||||
- `doctor.appointment/prescription` - 通话
|
||||
- `doctor.appointment/complete` - 完成
|
||||
- `tcm.diagnosis/kaifang` - 开方/查看
|
||||
|
||||
### 2. 按钮文字动态变化
|
||||
|
||||
"开方/查看"按钮的文字根据处方状态动态变化:
|
||||
- 未开方或待审核:显示"开方"
|
||||
- 已审核通过且未作废:显示"查看"
|
||||
- 已驳回:显示"开方"
|
||||
|
||||
### 3. 操作列宽度
|
||||
|
||||
操作列宽度为 380px,足够容纳:
|
||||
- 已完成状态:3个按钮(编辑患者、开方/查看、更多)
|
||||
- 其他状态:6个按钮(编辑患者、视频二维码、通话、完成、开方/查看、更多)
|
||||
|
||||
如果按钮仍然被遮挡,可以考虑:
|
||||
- 增加操作列宽度(如 420px)
|
||||
- 将更多按钮移到下拉菜单中
|
||||
- 使用图标按钮减少宽度
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `PRESCRIPTION_BUTTON_DISPLAY_CHECK.md` - 处方按钮显示检查文档
|
||||
- `PRESCRIPTION_APPROVED_LOCK.md` - 处方审核通过后锁定编辑文档
|
||||
|
||||
## 总结
|
||||
|
||||
通过根据预约状态优化按钮显示,确保了:
|
||||
- ✅ "开方/查看"按钮始终可见
|
||||
- ✅ 已完成状态下界面更简洁
|
||||
- ✅ 避免按钮被挤出可视区域
|
||||
- ✅ 优化用户体验
|
||||
- ✅ 保持核心功能的可访问性
|
||||
@@ -1,176 +0,0 @@
|
||||
# Bug Fixes Summary
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 1. 处方查看权限问题 ✅ (已解决)
|
||||
|
||||
**问题描述**:
|
||||
- 医生A开了处方后,医生B点击"查看病历"时提示"无权限查看此处方"
|
||||
- 其他医生无法查看患者的历史处方记录
|
||||
|
||||
**根本原因**:
|
||||
- 权限检查逻辑已经正确实现,允许有 `tcm.diagnosis/kaifang` 权限的医生查看所有处方(只读模式)
|
||||
- 问题可能是前端缓存或权限配置问题
|
||||
|
||||
**解决方案**:
|
||||
- 确认 `PrescriptionLogic::canViewPrescription()` 方法已包含以下逻辑:
|
||||
```php
|
||||
// 有开方权限的医生可以查看所有处方(只读)
|
||||
$permissions = $adminInfo['permissions'] ?? [];
|
||||
if (in_array('tcm.diagnosis/kaifang', $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
- 权限层级(从高到低):
|
||||
1. 超级管理员
|
||||
2. 创建人
|
||||
3. 医助
|
||||
4. 共享处方
|
||||
5. 可见角色
|
||||
6. 有开方权限的医生(`tcm.diagnosis/kaifang`)
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
---
|
||||
|
||||
### 2. 处方组件重复请求问题 ✅ (已解决)
|
||||
|
||||
**问题描述**:
|
||||
- 点击"开处方"或"查看病历"按钮时,重复请求 `/tcm.prescription/detail` 接口
|
||||
- 快速连续点击导致多次调用
|
||||
|
||||
**根本原因**:
|
||||
- 已经实现了 `isOpening` 标志防止重复调用
|
||||
- 防重复逻辑已经正确工作
|
||||
|
||||
**解决方案**:
|
||||
- 确认 `open()` 和 `openById()` 方法已包含防重复逻辑:
|
||||
```typescript
|
||||
const isOpening = ref(false)
|
||||
|
||||
const open = async (data: any, options?: { templateId?: number; stationName?: string }) => {
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
return
|
||||
}
|
||||
isOpening.value = true
|
||||
try {
|
||||
// ... 处理逻辑
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**文件**: `admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
---
|
||||
|
||||
### 3. 订单撤回后处方审核状态重置 ✅ (已修复)
|
||||
|
||||
**问题描述**:
|
||||
- 撤回订单后,关联的处方审核状态没有重置为"待审核"
|
||||
- 缺少 `audit_by_name` 字段的清空
|
||||
|
||||
**解决方案**:
|
||||
- 在 `withdraw()` 方法中添加了 `audit_by_name` 字段的清空:
|
||||
```php
|
||||
Prescription::where('id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'audit_status' => 0, // 0=待审核
|
||||
'audit_time' => null,
|
||||
'audit_by' => null,
|
||||
'audit_by_name' => '', // 新增:清空审核人姓名
|
||||
'audit_remark' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
```
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
---
|
||||
|
||||
### 4. 物流轨迹自动加载 ✅ (已实现)
|
||||
|
||||
**问题描述**:
|
||||
- 用户希望打开订单详情时,如果有快递单号,自动加载物流轨迹
|
||||
- 不需要手动点击"查询轨迹"按钮
|
||||
|
||||
**解决方案**:
|
||||
- 已经实现自动加载逻辑:
|
||||
```typescript
|
||||
async function openDetail(id: number) {
|
||||
// ... 加载订单详情
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace() // 自动加载物流轨迹
|
||||
}
|
||||
fetchLogs(id)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 物流查询优先从数据库读取,如果数据库没有数据,再调用快递100 API
|
||||
- 按钮文字改为"刷新轨迹",用于手动刷新最新物流信息
|
||||
|
||||
**文件**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 处方查看权限测试
|
||||
1. 使用医生A账号创建处方
|
||||
2. 使用医生B账号(有 `tcm.diagnosis/kaifang` 权限)点击"查看病历"
|
||||
3. 确认可以正常查看处方详情(只读模式)
|
||||
4. 确认医生B不能编辑医生A创建的处方
|
||||
|
||||
### 2. 重复请求测试
|
||||
1. 快速连续点击"开处方"按钮多次
|
||||
2. 检查浏览器开发者工具的Network面板
|
||||
3. 确认只发送一次 `/tcm.prescription/detail` 请求
|
||||
4. 确认控制台显示"处方正在打开中,请勿重复点击"警告
|
||||
|
||||
### 3. 订单撤回测试
|
||||
1. 创建一个处方业务订单(处方审核状态为"已通过")
|
||||
2. 点击"撤回"按钮
|
||||
3. 确认订单状态变为"已取消"
|
||||
4. 在处方列表中确认该处方的审核状态变为"待审核"
|
||||
5. 确认审核人、审核时间、审核意见都已清空
|
||||
|
||||
### 4. 物流轨迹自动加载测试
|
||||
1. 创建一个订单并填写快递单号
|
||||
2. 点击"查看"按钮打开订单详情
|
||||
3. 确认物流轨迹自动加载(如果数据库有数据,显示数据来源为"database")
|
||||
4. 点击"刷新轨迹"按钮,确认可以手动刷新物流信息
|
||||
5. 如果数据库没有数据,确认调用快递100 API(显示数据来源为"api")
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `PRESCRIPTION_VIEW_PERMISSION_FIX.md` - 处方查看权限修复文档
|
||||
- `PRESCRIPTION_DUPLICATE_REQUEST_FIX.md` - 处方重复请求修复文档
|
||||
- `PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md` - 订单撤回重置审核状态文档
|
||||
- `EXPRESS_TRACKING_AUTO_LOAD.md` - 物流轨迹自动加载文档
|
||||
- `EXPRESS_TRACKING_DATABASE_QUERY.md` - 物流追踪数据库查询优化文档
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
所有问题都已经解决或确认正常工作:
|
||||
|
||||
1. ✅ 处方查看权限:已实现,有开方权限的医生可以查看所有处方
|
||||
2. ✅ 重复请求防护:已实现,使用 `isOpening` 标志防止重复调用
|
||||
3. ✅ 撤回重置审核:已修复,添加了 `audit_by_name` 字段的清空
|
||||
4. ✅ 物流自动加载:已实现,打开详情时自动加载物流轨迹
|
||||
|
||||
如果仍然遇到问题,请检查:
|
||||
- 用户权限配置是否正确
|
||||
- 浏览器缓存是否需要清除
|
||||
- 数据库表结构是否完整
|
||||
- 快递100账号是否已充值
|
||||
@@ -1,91 +0,0 @@
|
||||
# 检查处方用量字段是否已添加到数据库
|
||||
|
||||
## 问题描述
|
||||
处方的用量相关字段(`dosage_amount`、`dosage_unit`、`need_decoction`)没有保存到数据库。
|
||||
|
||||
## 检查步骤
|
||||
|
||||
### 1. 检查数据库表结构
|
||||
|
||||
执行以下SQL查询,检查字段是否存在:
|
||||
|
||||
```sql
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
```
|
||||
|
||||
### 2. 如果字段不存在,执行迁移
|
||||
|
||||
如果上述查询返回空结果,说明字段还未添加,需要执行迁移文件:
|
||||
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
或者直接在数据库中执行:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
```
|
||||
|
||||
### 3. 验证字段已添加
|
||||
|
||||
再次执行步骤1的查询,应该看到3个字段:
|
||||
|
||||
| COLUMN_NAME | DATA_TYPE | COLUMN_DEFAULT | COLUMN_COMMENT |
|
||||
|-------------|-----------|----------------|----------------|
|
||||
| dosage_amount | decimal | NULL | 用量数值 |
|
||||
| dosage_unit | varchar | '' | 用量单位(g/ml) |
|
||||
| need_decoction | tinyint | 0 | 是否代煎(0否1是,仅饮片) |
|
||||
|
||||
## 代码检查
|
||||
|
||||
### 前端代码 ✅
|
||||
- `admin/src/components/tcm-prescription/index.vue` 已正确实现
|
||||
- 包含 `dosage_amount`、`dosage_unit`、`need_decoction` 字段
|
||||
- 有 `watch` 监听处方类型变化,自动设置单位
|
||||
|
||||
### 后端代码 ✅
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` 的 `add()` 方法已包含这些字段
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` 的 `edit()` 方法已包含这些字段
|
||||
|
||||
### 数据库模型 ✅
|
||||
- `server/app/common/model/tcm/Prescription.php` 已配置类型转换:
|
||||
```php
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
代码层面一切正常,问题很可能是**数据库迁移未执行**。
|
||||
|
||||
请执行上述步骤1检查数据库表结构,如果字段不存在,执行步骤2的迁移SQL。
|
||||
|
||||
## 测试步骤
|
||||
|
||||
迁移完成后,测试以下场景:
|
||||
|
||||
1. **创建新处方**
|
||||
- 选择"浓缩水丸",用量应为 1-10g 下拉选择
|
||||
- 选择"饮片",用量应为 50-250ml 下拉选择,并显示代煎选项
|
||||
- 选择其他类型,用量应为自由输入
|
||||
- 保存后检查数据库,确认字段已保存
|
||||
|
||||
2. **编辑已有处方**
|
||||
- 打开已有处方
|
||||
- 修改用量和代煎选项
|
||||
- 保存后检查数据库,确认字段已更新
|
||||
|
||||
3. **查看处方详情**
|
||||
- 在订单详情中查看处方
|
||||
- 确认用量和代煎信息正确显示
|
||||
@@ -1,93 +0,0 @@
|
||||
# 完单申请功能实现总结
|
||||
|
||||
## 功能概述
|
||||
|
||||
在补齐支付单时,用户可以选择是否申请完成订单。如果勾选了完单申请,审核人员在进行支付审核时可以看到该申请信息。
|
||||
|
||||
## 数据库修改
|
||||
|
||||
### 新增字段(zyt_tcm_prescription_order表)
|
||||
|
||||
```sql
|
||||
-- 执行 add_completion_request_field.sql
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `completion_request` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请:0=未申请,1=已申请' AFTER `payment_slip_audit_remark`,
|
||||
ADD COLUMN `completion_request_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请时间' AFTER `completion_request`,
|
||||
ADD COLUMN `completion_request_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请人ID' AFTER `completion_request_time`,
|
||||
ADD COLUMN `completion_request_by_name` varchar(64) NOT NULL DEFAULT '' COMMENT '完单申请人姓名' AFTER `completion_request_by`;
|
||||
```
|
||||
|
||||
## 前端修改
|
||||
|
||||
### 1. 补齐支付单对话框(order_list.vue)
|
||||
|
||||
- 添加"完单申请"单选项(不申请/申请完成订单)
|
||||
- 添加提示文字说明
|
||||
- 表单数据中添加 `completion_request` 字段(默认值0)
|
||||
|
||||
### 2. 提交逻辑
|
||||
|
||||
- 手动创建支付单时,将 `completion_request` 字段传递给后端
|
||||
- 关联已有支付单时,同样传递 `completion_request` 字段
|
||||
|
||||
### 3. 审核对话框
|
||||
|
||||
- 如果订单有完单申请(`completion_request=1`),显示警告提示框
|
||||
- 显示申请人姓名和申请时间
|
||||
- 审核人员可以清楚看到该订单已申请完成
|
||||
|
||||
## 后端修改
|
||||
|
||||
### 1. 新增支付单接口(addPayOrder)
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
- 接收 `completion_request` 参数
|
||||
- 如果值为1,记录完单申请信息:
|
||||
- `completion_request = 1`
|
||||
- `completion_request_time = 当前时间戳`
|
||||
- `completion_request_by = 操作人ID`
|
||||
- `completion_request_by_name = 操作人姓名`
|
||||
- 操作日志中记录"并申请完成订单"
|
||||
|
||||
### 2. 关联支付单接口(linkPayOrder)
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
- 接收 `completion_request` 参数
|
||||
- 如果值为1,记录完单申请信息(同上)
|
||||
- 操作日志中记录"并申请完成订单"
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. **补齐支付单**
|
||||
- 用户在"已发货"状态下点击"新增支付单"
|
||||
- 选择手动创建或关联已有支付单
|
||||
- 勾选"申请完成订单"选项
|
||||
- 提交后,订单记录完单申请信息
|
||||
|
||||
2. **支付审核**
|
||||
- 审核人员点击"支付审核"
|
||||
- 如果该订单有完单申请,对话框顶部显示黄色警告框
|
||||
- 显示申请人和申请时间
|
||||
- 审核人员可以根据完单申请决定是否通过
|
||||
|
||||
3. **完成订单**
|
||||
- 支付审核通过后,订单状态变为"已发货"
|
||||
- 满足完成条件(已发货+支付审核通过)时,可以点击"完成订单"
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 完单申请只是一个提示信息,不会自动完成订单
|
||||
2. 审核人员仍需手动点击"完成订单"按钮
|
||||
3. 完单申请信息在订单详情中可见
|
||||
4. 操作日志会记录完单申请的操作
|
||||
|
||||
## 测试要点
|
||||
|
||||
- [ ] 手动创建支付单时勾选完单申请
|
||||
- [ ] 关联已有支付单时勾选完单申请
|
||||
- [ ] 审核对话框正确显示完单申请信息
|
||||
- [ ] 申请人姓名和时间正确显示
|
||||
- [ ] 操作日志正确记录
|
||||
- [ ] 不勾选完单申请时,审核对话框不显示提示
|
||||
@@ -1,259 +0,0 @@
|
||||
# Diagnosis Image URL Prefix Fix (诊断图片URL前缀修复)
|
||||
|
||||
## Issue
|
||||
Image URLs in the diagnosis detail (tongue_images and report_files) were stored as relative paths without the domain prefix, causing issues when accessing them from external sources or different domains.
|
||||
|
||||
## Solution
|
||||
Added logic to automatically prepend the current domain to image URLs that don't already have an `http://` or `https://` prefix.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `server/app/adminapi/logic/tcm/DiagnosisLogic.php`
|
||||
|
||||
#### 1. Updated tongue_images Processing
|
||||
Added domain prefix logic after parsing the images array:
|
||||
|
||||
```php
|
||||
if (!empty($diagnosis['tongue_images'])) {
|
||||
// 先尝试 JSON 解析(兼容旧数据)
|
||||
$files = json_decode($diagnosis['tongue_images'], true);
|
||||
if (is_array($files)) {
|
||||
$diagnosis['tongue_images'] = $files;
|
||||
} else {
|
||||
// JSON 解析失败则按逗号分割
|
||||
$diagnosis['tongue_images'] = array_filter(explode(',', $diagnosis['tongue_images'])) ?: [];
|
||||
}
|
||||
|
||||
// 为没有 http 前缀的图片添加域名
|
||||
$domain = request()->domain();
|
||||
$diagnosis['tongue_images'] = array_map(function($url) use ($domain) {
|
||||
if (empty($url)) {
|
||||
return $url;
|
||||
}
|
||||
// 如果已经包含 http:// 或 https://,则跳过
|
||||
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
|
||||
return $url;
|
||||
}
|
||||
// 添加域名前缀
|
||||
return $domain . $url;
|
||||
}, $diagnosis['tongue_images']);
|
||||
} else {
|
||||
$diagnosis['tongue_images'] = [];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Updated report_files Processing
|
||||
Applied the same logic to report files:
|
||||
|
||||
```php
|
||||
if (!empty($diagnosis['report_files'])) {
|
||||
// 先尝试 JSON 解析(兼容旧数据)
|
||||
$files = json_decode($diagnosis['report_files'], true);
|
||||
if (is_array($files)) {
|
||||
$diagnosis['report_files'] = $files;
|
||||
} else {
|
||||
// JSON 解析失败则按逗号分割
|
||||
$diagnosis['report_files'] = array_filter(explode(',', $diagnosis['report_files'])) ?: [];
|
||||
}
|
||||
|
||||
// 为没有 http 前缀的文件添加域名
|
||||
$domain = request()->domain();
|
||||
$diagnosis['report_files'] = array_map(function($url) use ($domain) {
|
||||
if (empty($url)) {
|
||||
return $url;
|
||||
}
|
||||
// 如果已经包含 http:// 或 https://,则跳过
|
||||
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
|
||||
return $url;
|
||||
}
|
||||
// 添加域名前缀
|
||||
return $domain . $url;
|
||||
}, $diagnosis['report_files']);
|
||||
} else {
|
||||
$diagnosis['report_files'] = [];
|
||||
}
|
||||
```
|
||||
|
||||
## Logic Flow
|
||||
|
||||
### URL Processing Logic:
|
||||
1. Parse the images/files array (JSON or comma-separated)
|
||||
2. Get current domain using `request()->domain()`
|
||||
3. For each URL in the array:
|
||||
- Check if URL is empty → skip
|
||||
- Check if URL starts with `http://` or `https://` → skip (already has protocol)
|
||||
- Otherwise → prepend domain to the URL
|
||||
|
||||
### Examples:
|
||||
|
||||
#### Relative Path (Needs Domain):
|
||||
```php
|
||||
Input: "/uploads/images/tongue/2024/01/image.jpg"
|
||||
Domain: "https://admin.zhenyangtang.com.cn"
|
||||
Output: "https://admin.zhenyangtang.com.cn/uploads/images/tongue/2024/01/image.jpg"
|
||||
```
|
||||
|
||||
#### Absolute URL (Skip):
|
||||
```php
|
||||
Input: "https://cdn.example.com/images/tongue.jpg"
|
||||
Output: "https://cdn.example.com/images/tongue.jpg"
|
||||
```
|
||||
|
||||
#### HTTP URL (Skip):
|
||||
```php
|
||||
Input: "http://example.com/image.jpg"
|
||||
Output: "http://example.com/image.jpg"
|
||||
```
|
||||
|
||||
#### Empty URL (Skip):
|
||||
```php
|
||||
Input: ""
|
||||
Output: ""
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Cross-Domain Compatibility
|
||||
Images can now be accessed from different domains or external applications without path issues.
|
||||
|
||||
### 2. API Response Consistency
|
||||
API responses always return complete, accessible URLs regardless of how they were stored.
|
||||
|
||||
### 3. Backward Compatibility
|
||||
- Existing relative paths are automatically converted
|
||||
- Existing absolute URLs are preserved
|
||||
- No database migration required
|
||||
|
||||
### 4. Flexible Storage
|
||||
- Database can store either relative or absolute paths
|
||||
- System handles both formats transparently
|
||||
- Future-proof for CDN integration
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Mobile App Access
|
||||
Mobile apps can directly use the returned URLs without needing to construct them.
|
||||
|
||||
### 2. Third-Party Integration
|
||||
External systems can access images using the complete URLs from API responses.
|
||||
|
||||
### 3. CDN Migration
|
||||
When migrating to CDN, images with absolute CDN URLs will work alongside local relative paths.
|
||||
|
||||
### 4. Multi-Domain Setup
|
||||
System works correctly across different domains (dev, staging, production).
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Relative Path URLs
|
||||
1. Create a diagnosis with tongue images stored as relative paths:
|
||||
```
|
||||
/uploads/images/tongue/2024/01/image.jpg
|
||||
```
|
||||
2. Fetch diagnosis detail via API
|
||||
3. Verify response contains:
|
||||
```json
|
||||
{
|
||||
"tongue_images": [
|
||||
"https://admin.zhenyangtang.com.cn/uploads/images/tongue/2024/01/image.jpg"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test Absolute URLs
|
||||
1. Create a diagnosis with absolute URL:
|
||||
```
|
||||
https://cdn.example.com/images/tongue.jpg
|
||||
```
|
||||
2. Fetch diagnosis detail
|
||||
3. Verify URL is unchanged:
|
||||
```json
|
||||
{
|
||||
"tongue_images": [
|
||||
"https://cdn.example.com/images/tongue.jpg"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Mixed URLs
|
||||
1. Create a diagnosis with both relative and absolute URLs:
|
||||
```
|
||||
[
|
||||
"/uploads/local.jpg",
|
||||
"https://cdn.example.com/remote.jpg"
|
||||
]
|
||||
```
|
||||
2. Fetch diagnosis detail
|
||||
3. Verify correct processing:
|
||||
```json
|
||||
{
|
||||
"tongue_images": [
|
||||
"https://admin.zhenyangtang.com.cn/uploads/local.jpg",
|
||||
"https://cdn.example.com/remote.jpg"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test Empty/Null Values
|
||||
1. Create a diagnosis with empty images
|
||||
2. Fetch diagnosis detail
|
||||
3. Verify empty array is returned:
|
||||
```json
|
||||
{
|
||||
"tongue_images": []
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Test Report Files
|
||||
Repeat all tests above for `report_files` field.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Domain Detection
|
||||
Uses `request()->domain()` which returns the current request domain including protocol:
|
||||
- Development: `http://localhost:8000`
|
||||
- Production: `https://admin.zhenyangtang.com.cn`
|
||||
|
||||
### Case-Insensitive Protocol Check
|
||||
Uses `stripos()` for case-insensitive checking:
|
||||
- Matches: `http://`, `HTTP://`, `Http://`
|
||||
- Matches: `https://`, `HTTPS://`, `Https://`
|
||||
|
||||
### Array Processing
|
||||
Uses `array_map()` for efficient processing of all URLs in the array.
|
||||
|
||||
## Related Files
|
||||
|
||||
### Backend:
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisLogic.php`
|
||||
- Updated `tongue_images` processing
|
||||
- Updated `report_files` processing
|
||||
|
||||
### Database:
|
||||
- `tcm_diagnosis` table
|
||||
- `tongue_images` column (stores JSON or comma-separated paths)
|
||||
- `report_files` column (stores JSON or comma-separated paths)
|
||||
|
||||
## Notes
|
||||
|
||||
### Storage Format
|
||||
The database continues to store paths as-is (relative or absolute). The conversion happens only when reading data for API responses.
|
||||
|
||||
### Performance
|
||||
The `array_map()` operation is efficient and adds minimal overhead. For typical diagnosis records with 1-5 images, the performance impact is negligible.
|
||||
|
||||
### Future Enhancements
|
||||
If needed, this logic could be extracted into a helper function for reuse across other file/image fields in the system.
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Automatically adds domain prefix to relative image URLs
|
||||
✅ Preserves absolute URLs (http/https)
|
||||
✅ Handles both tongue_images and report_files
|
||||
✅ Backward compatible with existing data
|
||||
✅ Case-insensitive protocol detection
|
||||
✅ Handles empty/null values gracefully
|
||||
✅ No database migration required
|
||||
✅ Works across different domains
|
||||
|
||||
Image URLs in diagnosis details are now always complete and accessible, improving API usability and cross-domain compatibility!
|
||||
@@ -1,90 +0,0 @@
|
||||
# 诊单列表排序规则修改
|
||||
|
||||
## 修改内容
|
||||
|
||||
修改了诊单列表的排序逻辑,按照新的业务规则对数据进行排序。
|
||||
|
||||
## 排序规则
|
||||
|
||||
### 优先级顺序
|
||||
1. **已过号** (status=4) - 最优先显示
|
||||
2. **已预约** (status=1) - 第二优先
|
||||
3. **已完成** (status=3) - 第三优先
|
||||
4. **其他状态** - 最后显示
|
||||
|
||||
### 同状态内排序
|
||||
在同一状态内,按以下规则升序排列:
|
||||
- 挂号日期 (appointment_date)
|
||||
- 挂号时间 (appointment_time)
|
||||
- 诊单ID (id) 降序
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 排序SQL逻辑
|
||||
|
||||
```php
|
||||
// 获取最早的挂号状态(用于排序优先级)
|
||||
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt
|
||||
WHERE apt.patient_id = ' . $diagTbl . '.id
|
||||
AND apt.status IN (1,3,4)' . $minAptDateCond . '
|
||||
ORDER BY CASE apt.status
|
||||
WHEN 4 THEN 1
|
||||
WHEN 1 THEN 2
|
||||
WHEN 3 THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
apt.appointment_date ASC,
|
||||
apt.appointment_time ASC
|
||||
LIMIT 1)';
|
||||
|
||||
// 获取最早的挂号时间(用于同状态内排序)
|
||||
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \',
|
||||
IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\')))
|
||||
FROM ' . $aptTbl . ' apt
|
||||
WHERE apt.patient_id = ' . $diagTbl . '.id
|
||||
AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
||||
|
||||
// 最终排序
|
||||
->orderRaw('CASE IFNULL(' . $minAptStatusExpr . ', 999)
|
||||
WHEN 4 THEN 1
|
||||
WHEN 1 THEN 2
|
||||
WHEN 3 THEN 3
|
||||
ELSE 4
|
||||
END ASC,
|
||||
IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC,
|
||||
{$diagTbl}.id DESC")
|
||||
```
|
||||
|
||||
### 关键点
|
||||
|
||||
1. **子查询获取状态**: 使用子查询获取每个诊单最早的挂号状态
|
||||
2. **CASE表达式**: 将状态映射为排序优先级数字 (1-4)
|
||||
3. **IFNULL处理**: 对于没有挂号的诊单,使用默认值 999 和 '9999-12-31 23:59:59'
|
||||
4. **日期筛选支持**: 如果传入 `appointment_date` 参数,只按该日期的挂号排序
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `server/app/adminapi/lists/tcm/DiagnosisLists.php`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 清除缓存: `php think clear` ✅
|
||||
2. 访问诊单列表页面
|
||||
3. 验证排序顺序:
|
||||
- 已过号的诊单显示在最前面
|
||||
- 然后是已预约的诊单
|
||||
- 最后是已完成的诊单
|
||||
4. 验证同状态内按挂号时间升序排列
|
||||
|
||||
## 业务场景
|
||||
|
||||
此排序规则适用于以下场景:
|
||||
- 医生/医助需要优先处理已过号的患者(可能需要重新安排)
|
||||
- 然后关注即将到来的预约
|
||||
- 最后查看已完成的历史记录
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 排序只考虑状态为 1(已预约)、3(已完成)、4(已过号) 的挂号记录
|
||||
- 没有挂号记录的诊单会排在最后
|
||||
- 如果一个诊单有多条挂号记录,取最早的一条进行排序
|
||||
@@ -1,204 +0,0 @@
|
||||
# Dynamic Dose Count Label (动态剂数标签)
|
||||
|
||||
## Feature
|
||||
Made the dose count label dynamic based on the selected dose unit. When the user selects a different unit (剂/丸/袋/盒/瓶/膏/贴), the label automatically updates to match.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. Added Computed Property
|
||||
Added a computed property that generates the label dynamically:
|
||||
|
||||
```typescript
|
||||
// 动态剂数标签(根据剂量单位变化)
|
||||
const doseCountLabel = computed(() => {
|
||||
const unit = editForm.dose_unit || '剂'
|
||||
return `${unit}数`
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Updated Form Item Label
|
||||
Changed from static label to dynamic computed property:
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
<el-form-item label="剂数" prop="dose_count">
|
||||
<el-input-number
|
||||
v-model="editForm.dose_count"
|
||||
:min="1"
|
||||
placeholder="剂数"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<el-form-item :label="doseCountLabel" prop="dose_count">
|
||||
<el-input-number
|
||||
v-model="editForm.dose_count"
|
||||
:min="1"
|
||||
:placeholder="doseCountLabel"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Label Generation Logic
|
||||
```typescript
|
||||
const unit = editForm.dose_unit || '剂' // Get current unit, default to '剂'
|
||||
return `${unit}数` // Append '数' to create label
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
| Selected Unit | Label Display |
|
||||
|---------------|---------------|
|
||||
| 剂 | 剂数 |
|
||||
| 丸 | 丸数 |
|
||||
| 袋 | 袋数 |
|
||||
| 盒 | 盒数 |
|
||||
| 瓶 | 瓶数 |
|
||||
| 膏 | 膏数 |
|
||||
| 贴 | 贴数 |
|
||||
|
||||
### Reactive Updates
|
||||
The label updates automatically when:
|
||||
1. User selects a different dose unit from the dropdown
|
||||
2. Form is loaded with existing data
|
||||
3. Form is reset or cleared
|
||||
|
||||
## User Experience
|
||||
|
||||
### Before:
|
||||
- Label always shows "剂数" regardless of selected unit
|
||||
- Confusing when unit is "贴" but label says "剂数"
|
||||
|
||||
### After:
|
||||
- Label dynamically matches the selected unit
|
||||
- Shows "贴数" when unit is "贴"
|
||||
- Shows "丸数" when unit is "丸"
|
||||
- More intuitive and consistent
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Computed Property Benefits
|
||||
1. **Reactive**: Automatically updates when `editForm.dose_unit` changes
|
||||
2. **Efficient**: Only recalculates when dependency changes
|
||||
3. **Clean**: No need for watchers or manual updates
|
||||
|
||||
### Default Value
|
||||
Uses `'剂'` as default when `dose_unit` is empty or undefined:
|
||||
```typescript
|
||||
const unit = editForm.dose_unit || '剂'
|
||||
```
|
||||
|
||||
### Placeholder Update
|
||||
Also updated the placeholder to match the label:
|
||||
```vue
|
||||
:placeholder="doseCountLabel"
|
||||
```
|
||||
|
||||
This ensures consistency between the label and placeholder text.
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Label Changes
|
||||
1. Open order edit dialog
|
||||
2. Check initial label (should be "剂数" if dose_unit is "剂")
|
||||
3. Change dose unit to "贴"
|
||||
4. Verify label changes to "贴数"
|
||||
5. Try all other units and verify labels update correctly
|
||||
|
||||
### 2. Test with Existing Data
|
||||
1. Edit an order that has dose_unit = "丸"
|
||||
2. Verify label shows "丸数" when dialog opens
|
||||
3. Change to different unit
|
||||
4. Verify label updates immediately
|
||||
|
||||
### 3. Test Default Behavior
|
||||
1. Create a new order (dose_unit is empty)
|
||||
2. Verify label shows "剂数" (default)
|
||||
3. Select a unit
|
||||
4. Verify label updates
|
||||
|
||||
### 4. Test Placeholder
|
||||
1. Clear the dose_count field
|
||||
2. Verify placeholder text matches the label
|
||||
3. Change dose unit
|
||||
4. Verify placeholder updates with label
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
### Empty/Undefined Unit
|
||||
```typescript
|
||||
const unit = editForm.dose_unit || '剂'
|
||||
```
|
||||
Falls back to '剂' if unit is empty, null, or undefined.
|
||||
|
||||
### Form Reset
|
||||
When form is reset, the computed property automatically recalculates based on the new (or default) value.
|
||||
|
||||
### Multiple Edits
|
||||
Label updates correctly even when editing multiple orders in sequence without closing the dialog.
|
||||
|
||||
## Related Code
|
||||
|
||||
### Form Structure
|
||||
```vue
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<!-- Dynamic label based on dose_unit -->
|
||||
<el-form-item :label="doseCountLabel" prop="dose_count">
|
||||
<el-input-number v-model="editForm.dose_count" ... />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- Dose unit selector -->
|
||||
<el-form-item label="剂量单位" prop="dose_unit">
|
||||
<el-select v-model="editForm.dose_unit" ...>
|
||||
<el-option label="剂" value="剂" />
|
||||
<el-option label="丸" value="丸" />
|
||||
<!-- ... other options ... -->
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
User selects dose_unit → editForm.dose_unit changes →
|
||||
doseCountLabel computed property recalculates →
|
||||
Label and placeholder update in UI
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Possible Improvements
|
||||
1. **Validation Message**: Update validation messages to use dynamic unit
|
||||
2. **Display in Detail View**: Show unit-specific label in order detail view
|
||||
3. **Internationalization**: Support multiple languages for unit names
|
||||
4. **Custom Units**: Allow admin to configure custom dose units
|
||||
|
||||
### Example: Dynamic Validation
|
||||
```typescript
|
||||
const doseCountRules = computed(() => [
|
||||
{ required: true, message: `请输入${editForm.dose_unit || '剂'}数`, trigger: 'blur' }
|
||||
])
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Added dynamic label that changes based on selected dose unit
|
||||
✅ Label updates automatically when unit changes
|
||||
✅ Placeholder also updates to match label
|
||||
✅ Default to "剂数" when unit is empty
|
||||
✅ Handles all 7 dose units (剂/丸/袋/盒/瓶/膏/贴)
|
||||
✅ Reactive and efficient using computed property
|
||||
✅ Better user experience with consistent terminology
|
||||
|
||||
The dose count label now dynamically reflects the selected unit, making the form more intuitive and user-friendly!
|
||||
@@ -1,161 +0,0 @@
|
||||
# 甘草 API "Array to string conversion" 错误修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
调用甘草处方下单接口时出现错误:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 根本原因
|
||||
|
||||
经过分析代码和 API 文档,发现以下几个问题:
|
||||
|
||||
### 1. 变量作用域问题
|
||||
在 `PrescriptionOrderLogic::submitGancaoRecipel()` 方法中,`$appNo` 变量在返回语句中被引用,但实际定义在更早的位置(line 1638),然后又在 line 1658 被重新赋值。这可能导致变量引用错误。
|
||||
|
||||
### 2. 空字符串处理问题
|
||||
`buildSubmitPayload()` 方法中的 `cradle_store` 字段可能为空字符串,而甘草 API 可能不接受空字符串。
|
||||
|
||||
### 3. 患者年龄格式问题
|
||||
原代码使用 `sprintf('%d.00', $ageInt)` 格式化年龄,但 API 可能期望简单的整数字符串。
|
||||
|
||||
### 4. doct_advice 字段处理
|
||||
`doct_advice` 数组中的某些字段可能包含空字符串或未正确处理的值,导致 JSON 编码时出现问题。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 修复 `GancaoScmRecipelService.php`
|
||||
|
||||
#### 修改患者年龄格式
|
||||
```php
|
||||
// 修改前
|
||||
$patientAge = sprintf('%d.00', $ageInt);
|
||||
|
||||
// 修改后
|
||||
$patientAge = (string) $ageInt;
|
||||
```
|
||||
|
||||
#### 确保 cradle_store 不为空
|
||||
```php
|
||||
$preview['cradle_store'] = mb_substr(trim((string) ($c['cradle_store'] ?? '')), 0, 32);
|
||||
if ($preview['cradle_store'] === '') {
|
||||
$preview['cradle_store'] = 'default';
|
||||
}
|
||||
```
|
||||
|
||||
#### 优化 doct_advice 字段处理
|
||||
```php
|
||||
// 确保所有字段都是字符串,避免数组到字符串的转换错误
|
||||
$tabooStr = $taboo !== '' ? $taboo : '无';
|
||||
$usageTimeStr = $usageTime;
|
||||
$usageBriefStr = $usageBrief !== '' ? $usageBrief : '遵医嘱';
|
||||
$othersStr = trim((string) ($rx['usage_notes'] ?? ''));
|
||||
$notesDoctorStr = trim((string) ($order['remark_extra'] ?? ''));
|
||||
|
||||
$preview['doct_advice'] = [
|
||||
'taboo' => $tabooStr,
|
||||
'usage_time' => $usageTimeStr,
|
||||
'usage_brief' => $usageBriefStr,
|
||||
'others' => $othersStr !== '' ? $othersStr : '',
|
||||
'notes_doctor' => $notesDoctorStr !== '' ? $notesDoctorStr : '',
|
||||
];
|
||||
```
|
||||
|
||||
#### 添加调试日志
|
||||
```php
|
||||
public static function ctmSubmit(array $submitPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
// 记录请求负载以便调试
|
||||
Log::info('Gancao CTM_SUBMIT_RECIPEL payload', ['payload' => $submitPayload]);
|
||||
|
||||
return $transport->post($submitPayload);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修复 `PrescriptionOrderLogic.php`
|
||||
|
||||
#### 修复变量引用问题
|
||||
```php
|
||||
// 在返回语句中使用正确的变量引用
|
||||
$fee = $subBody['result']['fee'] ?? [];
|
||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||
|
||||
return [
|
||||
'recipel_order_no' => $gcNo,
|
||||
'app_order_no' => $appNo,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
];
|
||||
```
|
||||
|
||||
#### 添加详细错误日志
|
||||
```php
|
||||
use think\facade\Log;
|
||||
|
||||
// 在 submitGancaoRecipel 方法中添加错误日志
|
||||
$subRet = GancaoScmRecipelService::ctmSubmit($submitPayload);
|
||||
if ((int) ($subRet['state'] ?? 0) !== 1) {
|
||||
$errMsg = (string) ($subRet['msg'] ?? '');
|
||||
$response = isset($subRet['response']) ? mb_substr((string) $subRet['response'], 0, 500) : '';
|
||||
self::$error = '甘草下单通信失败:' . $errMsg . ($response !== '' ? ';响应:' . $response : '');
|
||||
Log::error('Gancao CTM_SUBMIT failed', ['subRet' => $subRet, 'payload' => $submitPayload]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$subBody = $subRet['body'] ?? [];
|
||||
if (!GancaoScmRecipelService::isApiSuccess($subBody)) {
|
||||
self::$error = '甘草下单失败:' . GancaoScmRecipelService::apiStatusMessage($subBody);
|
||||
Log::error('Gancao CTM_SUBMIT api error', ['body' => $subBody, 'payload' => $submitPayload]);
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **检查日志文件**:修复后再次调用接口,查看 `runtime/log/` 目录下的日志文件,查找 `Gancao CTM_SUBMIT_RECIPEL payload` 日志,确认发送的数据格式是否正确。
|
||||
|
||||
2. **验证必填字段**:根据甘草 API 文档,确保以下字段都有有效值:
|
||||
- `token`:有效的 API token
|
||||
- `df_id`:剂型 ID(如 101 表示饮片)
|
||||
- `amount`:剂量数量
|
||||
- `m_list`:药材列表(每个药材必须有 `id`、`quantity`、`brief` 字段)
|
||||
- `express_to`:收货地址信息
|
||||
- `patient`:患者信息
|
||||
- `doctor`:医生信息
|
||||
- `callback_url`:回调地址(必须是 HTTPS)
|
||||
|
||||
3. **检查配置**:确认 `.env` 文件中的甘草配置完整:
|
||||
```env
|
||||
GANCAO_SCM_ENABLED=true
|
||||
GANCAO_SCM_GATEWAY_URL=https://xxx
|
||||
GANCAO_SCM_GATEWAY_AK=xxx
|
||||
GANCAO_SCM_GATEWAY_SK=xxx
|
||||
GANCAO_SCM_BIZ_AK=xxx
|
||||
GANCAO_SCM_BIZ_SK=xxx
|
||||
GANCAO_SCM_CALLBACK_URL=https://xxx
|
||||
GANCAO_SCM_CRADLE_STORE=xxx
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
```
|
||||
|
||||
4. **测试流程**:
|
||||
- 创建一个测试处方订单
|
||||
- 确保处方审核通过
|
||||
- 调用 `submitGancaoRecipel` 接口
|
||||
- 查看返回结果和日志
|
||||
|
||||
## 可能的其他原因
|
||||
|
||||
如果修复后仍然出现错误,可能是以下原因:
|
||||
|
||||
1. **药材 ID 映射问题**:某些药材的甘草 ID(`gid`)未正确配置
|
||||
2. **剂型参数问题**:`df101ext` 等剂型扩展参数不符合 API 要求
|
||||
3. **地址解析问题**:收货地址无法正确解析为省市区
|
||||
4. **电话号码格式**:电话号码不是 11 位数字
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
- 特别关注「中药处方下单」和「中药处方下单参数预检查」章节
|
||||
@@ -1,278 +0,0 @@
|
||||
# 甘草 API "Array to string conversion" 错误完整解决方案
|
||||
|
||||
## 问题现象
|
||||
|
||||
调用甘草处方下单接口时返回:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
"Array to string conversion" 错误通常发生在以下情况:
|
||||
|
||||
1. **配置文件中的值被错误地解析为数组**
|
||||
- 例如:`.env` 中 `GANCAO_SCM_EXPRESS_TYPE=["sf"]` 会被解析为数组
|
||||
- 正确应该是:`GANCAO_SCM_EXPRESS_TYPE=sf`
|
||||
|
||||
2. **PHP 尝试将数组转换为字符串**
|
||||
- 在字符串拼接、JSON 编码或其他操作中
|
||||
|
||||
3. **配置读取问题**
|
||||
- ThinkPHP 的配置解析可能将某些值错误地转换为数组
|
||||
|
||||
## 完整修复步骤
|
||||
|
||||
### 步骤 1: 检查配置文件
|
||||
|
||||
运行配置检查脚本:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
这个脚本会检查:
|
||||
- 所有必需的配置项是否存在
|
||||
- 配置值的类型是否正确
|
||||
- 是否有数组值被错误地用作字符串
|
||||
|
||||
### 步骤 2: 修复 .env 配置
|
||||
|
||||
确保 `.env` 文件中的甘草配置格式正确:
|
||||
|
||||
```env
|
||||
# 甘草配置必须写在文件顶部,或者使用 [GANCAO_SCM] 分区
|
||||
# 不要写在 [trtc] 等其他分区内
|
||||
|
||||
GANCAO_SCM_ENABLED=true
|
||||
GANCAO_SCM_GATEWAY_URL=https://your-gateway-url.com
|
||||
GANCAO_SCM_GATEWAY_AK=your_gateway_ak
|
||||
GANCAO_SCM_GATEWAY_SK=your_gateway_sk_16chars
|
||||
GANCAO_SCM_BIZ_AK=your_biz_ak
|
||||
GANCAO_SCM_BIZ_SK=your_biz_sk
|
||||
GANCAO_SCM_CALLBACK_URL=https://your-domain.com/api/gancao/callback
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
GANCAO_SCM_CRADLE_STORE=your_store_name
|
||||
GANCAO_SCM_DF_ID=101
|
||||
|
||||
# 可选配置
|
||||
GANCAO_SCM_DEFAULT_IS_DECOCT=1
|
||||
GANCAO_SCM_DEFAULT_TIMES_PER_DAY=2
|
||||
GANCAO_SCM_DEFAULT_NUM_PER_PACK=2
|
||||
GANCAO_SCM_DEFAULT_DOSE_ML=150
|
||||
```
|
||||
|
||||
**重要注意事项:**
|
||||
|
||||
1. ❌ **错误示例**(会导致数组转字符串错误):
|
||||
```env
|
||||
GANCAO_SCM_EXPRESS_TYPE=["sf"]
|
||||
GANCAO_SCM_CALLBACK_URL=["https://..."]
|
||||
```
|
||||
|
||||
2. ✅ **正确示例**:
|
||||
```env
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
GANCAO_SCM_CALLBACK_URL=https://...
|
||||
```
|
||||
|
||||
3. 不要在值周围加引号(除非值本身包含空格)
|
||||
4. 不要使用 JSON 格式
|
||||
5. 不要使用数组语法 `[]`
|
||||
|
||||
### 步骤 3: 检查配置文件
|
||||
|
||||
如果使用了 `config/gancao_scm.php` 配置文件,确保格式正确:
|
||||
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'enabled' => env('gancao_scm.enabled', false),
|
||||
'gateway_url' => env('gancao_scm.gateway_url', ''),
|
||||
'gateway_ak' => env('gancao_scm.gateway_ak', ''),
|
||||
'gateway_sk' => env('gancao_scm.gateway_sk', ''),
|
||||
'biz_ak' => env('gancao_scm.biz_ak', ''),
|
||||
'biz_sk' => env('gancao_scm.biz_sk', ''),
|
||||
'callback_url' => env('gancao_scm.callback_url', ''),
|
||||
'express_type' => env('gancao_scm.express_type', 'sf'), // 确保是字符串
|
||||
'cradle_store' => env('gancao_scm.cradle_store', ''),
|
||||
'df_id' => (int)env('gancao_scm.df_id', 101),
|
||||
|
||||
// 可选配置
|
||||
'default_is_decoct' => (int)env('gancao_scm.default_is_decoct', 1),
|
||||
'default_times_per_day' => (int)env('gancao_scm.default_times_per_day', 2),
|
||||
'default_num_per_pack' => (int)env('gancao_scm.default_num_per_pack', 2),
|
||||
'default_dose_ml' => (int)env('gancao_scm.default_dose_ml', 150),
|
||||
|
||||
// 药材 ID 映射(这个可以是数组)
|
||||
'herb_id_map' => [
|
||||
// '药材名' => 甘草ID
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 步骤 4: 清除缓存
|
||||
|
||||
```bash
|
||||
# 清除 ThinkPHP 缓存
|
||||
cd server
|
||||
php think clear
|
||||
|
||||
# 或者手动删除缓存目录
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 5: 重启服务
|
||||
|
||||
```bash
|
||||
# 如果使用 PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# 如果使用 Nginx
|
||||
sudo systemctl restart nginx
|
||||
|
||||
# 如果使用 Apache
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### 步骤 6: 查看日志
|
||||
|
||||
修复后再次调用接口,查看日志文件:
|
||||
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
日志会显示:
|
||||
- `Gancao CTM_SUBMIT_RECIPEL payload` - 发送的完整负载
|
||||
- `Gancao CTM_SUBMIT failed` - 如果通信失败
|
||||
- `Gancao CTM_SUBMIT api error` - 如果 API 返回错误
|
||||
- `submitGancaoRecipel exception` - 如果发生异常
|
||||
|
||||
## 代码修复说明
|
||||
|
||||
已修复的文件:
|
||||
|
||||
### 1. `GancaoScmRecipelService.php`
|
||||
|
||||
- ✅ 修复 `express_type` 可能是数组的问题
|
||||
- ✅ 修复 `callback_url` 可能是数组的问题
|
||||
- ✅ 修复患者年龄格式
|
||||
- ✅ 优化 `doct_advice` 字段处理
|
||||
- ✅ 添加调试日志
|
||||
|
||||
### 2. `PrescriptionOrderLogic.php`
|
||||
|
||||
- ✅ 修复 `$appNo` 变量引用问题
|
||||
- ✅ 添加详细错误日志
|
||||
- ✅ 添加异常捕获
|
||||
|
||||
### 3. `PrescriptionOrderController.php`
|
||||
|
||||
- ✅ 添加 try-catch 异常处理
|
||||
- ✅ 添加返回值类型检查
|
||||
- ✅ 添加详细日志记录
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. **运行配置检查**:
|
||||
```bash
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
2. **检查输出**:
|
||||
- 所有配置项应该显示 ✓
|
||||
- 不应该有 ❌ 或 ⚠️ 标记
|
||||
- JSON 编码应该成功
|
||||
|
||||
3. **调用接口**:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 123}'
|
||||
```
|
||||
|
||||
4. **查看日志**:
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
## 常见错误和解决方案
|
||||
|
||||
### 错误 1: "Array to string conversion"
|
||||
|
||||
**原因**:配置值是数组而不是字符串
|
||||
|
||||
**解决**:
|
||||
1. 运行 `php test_gancao_config.php` 找出哪个配置是数组
|
||||
2. 修改 `.env` 文件,移除 `[]` 和引号
|
||||
3. 清除缓存并重启服务
|
||||
|
||||
### 错误 2: "callback_url is invalid"
|
||||
|
||||
**原因**:回调地址未配置或格式错误
|
||||
|
||||
**解决**:
|
||||
1. 确保 `GANCAO_SCM_CALLBACK_URL` 配置正确
|
||||
2. 必须是 HTTPS 地址
|
||||
3. 格式:`https://your-domain.com/api/gancao/callback`
|
||||
|
||||
### 错误 3: "express_type is invalid"
|
||||
|
||||
**原因**:快递类型配置错误
|
||||
|
||||
**解决**:
|
||||
1. 检查 `GANCAO_SCM_EXPRESS_TYPE` 配置
|
||||
2. 有效值:`sf`(顺丰)、`jd`(京东)、`jt`(极兔)、`auto`(自动)
|
||||
3. 默认使用 `sf`
|
||||
|
||||
### 错误 4: JSON 编码失败
|
||||
|
||||
**原因**:数据中包含无法编码的内容
|
||||
|
||||
**解决**:
|
||||
1. 检查处方数据中是否有特殊字符
|
||||
2. 检查药材名称是否包含非 UTF-8 字符
|
||||
3. 查看日志中的完整错误信息
|
||||
|
||||
## 验证修复
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ 配置检查脚本全部通过
|
||||
2. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC202604...",
|
||||
"app_order_no": "PO123",
|
||||
"fee": {
|
||||
"total": 150.00,
|
||||
"medicine": 120.00,
|
||||
"process": 20.00,
|
||||
"express": 10.00
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. ✅ 日志中显示 `submitGancaoRecipel success`
|
||||
4. ✅ 数据库中 `gancao_reciperl_order_no` 字段已更新
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
如果以上步骤都无法解决问题,请提供:
|
||||
|
||||
1. 配置检查脚本的完整输出
|
||||
2. 错误日志的完整内容(`runtime/log/*.log`)
|
||||
3. 接口返回的完整响应
|
||||
4. `.env` 文件中的甘草配置部分(隐藏敏感信息)
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
- ThinkPHP 配置文档:https://www.kancloud.cn/manual/thinkphp6_0/1037488
|
||||
@@ -1,355 +0,0 @@
|
||||
# 甘草订单状态回调功能配置指南
|
||||
|
||||
## 功能说明
|
||||
|
||||
实现甘草订单状态回调功能,当甘草订单状态发生变化时(如审核通过、制作中、发货、完成等),甘草系统会主动回调我们的接口,更新订单状态。
|
||||
|
||||
## 已创建的文件
|
||||
|
||||
### 1. 回调控制器
|
||||
**文件:** `server/app/api/controller/GancaoCallbackController.php`
|
||||
|
||||
**功能:**
|
||||
- 接收甘草订单状态回调
|
||||
- 验证回调签名(防止伪造)
|
||||
- 更新订单状态
|
||||
- 记录回调日志
|
||||
|
||||
**支持的订单状态:**
|
||||
- `10` - 系统审核中
|
||||
- `11` - 系统审核通过
|
||||
- `110` - 订单药房流转制作中(包含:派单、审方、调配、复核、浸泡、煎药、包装、发货等流程)
|
||||
- `20` - 物流中
|
||||
- `30` - 完成(终态)
|
||||
- `90` - 拦截(终止流转:可恢复)
|
||||
- `91` - 主动撤单(退费:终态)
|
||||
- `92` - 驳回(无法制作并退费:终态)
|
||||
|
||||
### 2. 数据库迁移文件
|
||||
**文件:** `add_gancao_callback_fields.sql`
|
||||
|
||||
**新增字段:**
|
||||
- `gancao_order_state` - 甘草订单状态
|
||||
- `gancao_flow_name` - 订单流程名称
|
||||
- `gancao_supplier` - 供应商/药房名称
|
||||
- `gancao_remark` - 订单备注
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 步骤 1: 执行数据库迁移
|
||||
|
||||
```bash
|
||||
# 连接到数据库
|
||||
mysql -u root -p your_database
|
||||
|
||||
# 执行 SQL 文件
|
||||
source /path/to/add_gancao_callback_fields.sql;
|
||||
|
||||
# 或者直接执行
|
||||
mysql -u root -p your_database < add_gancao_callback_fields.sql
|
||||
```
|
||||
|
||||
### 步骤 2: 添加路由
|
||||
|
||||
在 `server/route/api.php` 文件中添加回调路由:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use think\facade\Route;
|
||||
|
||||
// 甘草订单状态回调(不需要登录验证)
|
||||
Route::post('gancao/callback/order-status', 'GancaoCallbackController@orderStatus');
|
||||
```
|
||||
|
||||
**注意:** 这个路由不需要登录验证,因为是甘草系统主动回调。
|
||||
|
||||
### 步骤 3: 配置回调 URL
|
||||
|
||||
在 `.env` 文件中配置回调地址:
|
||||
|
||||
```env
|
||||
# 甘草回调配置
|
||||
GANCAO_SCM_CALLBACK_URL=https://your-domain.com/api/gancao/callback/order-status
|
||||
|
||||
# 回调签名验证(可选,如果甘草提供了独立的回调密钥)
|
||||
# GANCAO_SCM_CALLBACK_APPKEY=your_callback_appkey
|
||||
# GANCAO_SCM_CALLBACK_SECRET_KEY=your_callback_secret_key
|
||||
```
|
||||
|
||||
**重要:**
|
||||
1. 回调地址必须是 **HTTPS**
|
||||
2. 回调地址必须能从公网访问
|
||||
3. 如果是开发环境,可以使用内网穿透工具(如 ngrok)
|
||||
|
||||
### 步骤 4: 更新配置文件
|
||||
|
||||
在 `server/config/gancao_scm.php` 中添加回调配置:
|
||||
|
||||
```php
|
||||
return [
|
||||
// ... 其他配置
|
||||
|
||||
'callback_url' => (string) zyt_gancao_scm_env('CALLBACK_URL', ''),
|
||||
|
||||
// 回调签名验证(如果甘草提供了独立的密钥)
|
||||
'callback_appkey' => (string) zyt_gancao_scm_env('CALLBACK_APPKEY', ''),
|
||||
'callback_secret_key' => (string) zyt_gancao_scm_env('CALLBACK_SECRET_KEY', ''),
|
||||
];
|
||||
```
|
||||
|
||||
### 步骤 5: 测试回调接口
|
||||
|
||||
#### 方法 A:使用 curl 测试
|
||||
|
||||
```bash
|
||||
# 模拟甘草回调请求
|
||||
curl -X POST https://your-domain.com/api/gancao/callback/order-status \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "access-appkey: your_appkey" \
|
||||
-H "access-nonce: test1234" \
|
||||
-H "access-timestamp: $(date +%s)" \
|
||||
-H "access-sign: calculated_sign" \
|
||||
-d '{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 11,
|
||||
"ext": {
|
||||
"flow_name": "审核通过"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
#### 方法 B:查看日志
|
||||
|
||||
```bash
|
||||
# 查看回调日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i "gancao callback"
|
||||
```
|
||||
|
||||
### 步骤 6: 向甘草提供回调地址
|
||||
|
||||
联系甘草技术支持,提供以下信息:
|
||||
1. 回调地址:`https://your-domain.com/api/gancao/callback/order-status`
|
||||
2. 确认回调签名验证方式
|
||||
3. 测试回调是否正常
|
||||
|
||||
## 回调数据格式
|
||||
|
||||
### 请求头
|
||||
|
||||
```
|
||||
access-appkey: ak-xxxxx
|
||||
access-nonce: random_string
|
||||
access-timestamp: 1234567890
|
||||
access-sign: md5_hash
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {
|
||||
"flow_name": "煎药开始",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态 110(制作中)的扩展信息
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {
|
||||
"flow_name": "派单|审方|调配|复核|浸泡|煎药开始|包装|发货|寄出",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态 20(物流中)的扩展信息
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 20,
|
||||
"ext": {
|
||||
"shipping_name": "顺丰速运",
|
||||
"nu": "SF1234567890",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 签名验证
|
||||
|
||||
### 签名算法
|
||||
|
||||
```
|
||||
access_sign = md5(access-appkey + secret-key + access-nonce + access-timestamp + request_body)
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
access-appkey: ak-36b05d5034f13a86b102c97e72f14
|
||||
secret-key: f8c1f3c58b55a61a4d41242016d314ea
|
||||
access-nonce: 1jd4u8ii
|
||||
access-timestamp: 1723014934
|
||||
Body: {"recipel_order_no":"1234","state":10,"ext":{"flow_name":"浸泡开始"}}
|
||||
|
||||
计算:
|
||||
md5("ak-36b05d5034f13a86b102c97e72f14" + "f8c1f3c58b55a61a4d41242016d314ea" + "1jd4u8ii" + "1723014934" + '{"recipel_order_no":"1234","state":10,"ext":{"flow_name":"浸泡开始"}}')
|
||||
= 03b46e3b385d1dceb183cad08e44f31f
|
||||
```
|
||||
|
||||
## 订单状态映射
|
||||
|
||||
### 甘草状态 → 系统履约状态
|
||||
|
||||
| 甘草状态 | 状态名称 | 系统履约状态 | 说明 |
|
||||
|---------|---------|-------------|------|
|
||||
| 10 | 系统审核中 | 保持不变 | 甘草内部审核 |
|
||||
| 11 | 系统审核通过 | 保持不变 | 审核通过,准备制作 |
|
||||
| 110 | 订单药房流转制作中 | 2(履约中) | 药房制作流程 |
|
||||
| 110(发货) | 发货/寄出 | 5(已发货) | 药房已发货 |
|
||||
| 20 | 物流中 | 5(已发货) | 快递运输中 |
|
||||
| 30 | 完成 | 3(已完成) | 订单完成 |
|
||||
| 90 | 拦截 | 保持不变 | 订单被拦截 |
|
||||
| 91 | 主动撤单 | 4(已取消) | 撤单并退费 |
|
||||
| 92 | 驳回 | 4(已取消) | 无法制作并退费 |
|
||||
|
||||
## 日志记录
|
||||
|
||||
### 回调接收日志
|
||||
|
||||
```
|
||||
[info] Gancao callback received: {
|
||||
"headers": {...},
|
||||
"body": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 回调处理日志
|
||||
|
||||
```
|
||||
[info] Gancao callback processed: {
|
||||
"order_id": 123,
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 订单日志
|
||||
|
||||
在 `zyt_prescription_order_log` 表中记录:
|
||||
- `admin_name`: "甘草系统"
|
||||
- `action`: "gancao_callback"
|
||||
- `summary`: "甘草订单状态更新:系统审核通过"
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
```
|
||||
[warning] Gancao callback sign verification failed
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",避免甘草重试
|
||||
|
||||
### 订单不存在
|
||||
|
||||
```
|
||||
[warning] Gancao callback order not found
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",记录日志
|
||||
|
||||
### 更新失败
|
||||
|
||||
```
|
||||
[error] Gancao callback update order failed
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",记录错误日志
|
||||
|
||||
## 重试机制
|
||||
|
||||
甘草的重试策略:
|
||||
- 如果回调失败(未返回 "ok" 或超时),会进行重试
|
||||
- 最多重试 10 次
|
||||
- 重试间隔:失败次数 × 5 分钟
|
||||
|
||||
**建议:**
|
||||
1. 接口必须在 5 秒内返回 "ok"
|
||||
2. 使用异步处理复杂逻辑
|
||||
3. 即使处理失败也返回 "ok",避免重复回调
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **启用签名验证**:防止伪造回调
|
||||
2. **记录所有回调**:便于排查问题
|
||||
3. **限制访问频率**:防止恶意请求
|
||||
4. **使用 HTTPS**:保护数据传输安全
|
||||
5. **IP 白名单**:只允许甘草服务器 IP 访问(可选)
|
||||
|
||||
## 监控和告警
|
||||
|
||||
### 监控指标
|
||||
|
||||
1. 回调接收数量
|
||||
2. 签名验证失败次数
|
||||
3. 订单更新失败次数
|
||||
4. 回调处理耗时
|
||||
|
||||
### 告警规则
|
||||
|
||||
1. 签名验证失败率 > 10%
|
||||
2. 订单更新失败率 > 5%
|
||||
3. 回调处理耗时 > 3 秒
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [ ] 数据库字段已添加
|
||||
- [ ] 路由已配置
|
||||
- [ ] 回调 URL 已配置
|
||||
- [ ] 签名验证正常
|
||||
- [ ] 订单状态更新正常
|
||||
- [ ] 日志记录正常
|
||||
- [ ] 错误处理正常
|
||||
- [ ] 已向甘草提供回调地址
|
||||
- [ ] 已测试真实回调
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:收不到回调
|
||||
|
||||
**检查:**
|
||||
1. 回调 URL 是否正确
|
||||
2. 服务器是否能从公网访问
|
||||
3. 防火墙是否开放端口
|
||||
4. 路由是否配置正确
|
||||
|
||||
### 问题 2:签名验证失败
|
||||
|
||||
**检查:**
|
||||
1. appkey 是否正确
|
||||
2. secret-key 是否正确
|
||||
3. 签名算法是否正确
|
||||
4. 请求体是否被修改
|
||||
|
||||
### 问题 3:订单状态未更新
|
||||
|
||||
**检查:**
|
||||
1. 订单是否存在
|
||||
2. 数据库字段是否添加
|
||||
3. 更新逻辑是否正确
|
||||
4. 查看错误日志
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#订单状态回调
|
||||
- ThinkPHP 路由文档:https://www.kancloud.cn/manual/thinkphp6_0/1037493
|
||||
@@ -1,318 +0,0 @@
|
||||
# 甘草 API "Array to string conversion" 最终修复
|
||||
|
||||
## 问题根源
|
||||
|
||||
错误发生在 `GancaoScmRecipelService::getToken()` 方法中:
|
||||
|
||||
```php
|
||||
// 第 126 行
|
||||
if ($code === '10103' || str_contains($apiMsg, '10103')) {
|
||||
```
|
||||
|
||||
**原因:** 当甘草 API 返回错误时,`$body['status']['msg']` 可能是**数组**而不是字符串,导致:
|
||||
1. `apiStatusMessage()` 方法尝试将数组转换为字符串
|
||||
2. `str_contains()` 函数接收到数组参数,触发 "Array to string conversion" 错误
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
### 1. `apiStatusMessage()` 方法
|
||||
|
||||
**修复前:**
|
||||
```php
|
||||
$msg = (string) ($body['status']['msg'] ?? '');
|
||||
```
|
||||
|
||||
**修复后:**
|
||||
```php
|
||||
$msg = $body['status']['msg'] ?? '';
|
||||
|
||||
// 确保 msg 是字符串
|
||||
if (is_array($msg)) {
|
||||
$msg = json_encode($msg, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$msg = (string) $msg;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `getToken()` 方法中的 `str_contains()` 调用
|
||||
|
||||
**修复前:**
|
||||
```php
|
||||
if ($code === '10103' || str_contains($apiMsg, '10103')) {
|
||||
```
|
||||
|
||||
**修复后:**
|
||||
```php
|
||||
// 确保 $apiMsg 是字符串,避免 Array to string conversion 错误
|
||||
$apiMsgStr = is_string($apiMsg) ? $apiMsg : json_encode($apiMsg, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($code === '10103' || str_contains($apiMsgStr, '10103')) {
|
||||
```
|
||||
|
||||
### 3. 其他已修复的问题
|
||||
|
||||
#### SSL 连接问题(`GancaoOpenApiTransport.php`)
|
||||
- ✅ 改用 HTTP/1.1
|
||||
- ✅ 强制使用 TLS 1.2
|
||||
- ✅ 降低 OpenSSL 安全级别
|
||||
- ✅ 增加连接超时
|
||||
- ✅ 添加 TCP keepalive
|
||||
|
||||
#### 配置类型问题(`GancaoScmRecipelService.php`)
|
||||
- ✅ 确保 `express_type` 是字符串
|
||||
- ✅ 确保 `callback_url` 是字符串
|
||||
- ✅ 修复患者年龄格式
|
||||
- ✅ 优化 `doct_advice` 字段处理
|
||||
|
||||
#### 错误处理(`PrescriptionOrderLogic.php` 和 `PrescriptionOrderController.php`)
|
||||
- ✅ 添加详细错误日志
|
||||
- ✅ 添加异常捕获
|
||||
- ✅ 添加返回值类型检查
|
||||
|
||||
## 完整的修复文件列表
|
||||
|
||||
1. ✅ `server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
2. ✅ `server/app/common/service/gancao/GancaoOpenApiTransport.php`
|
||||
3. ✅ `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
4. ✅ `server/app/adminapi/controller/tcm/PrescriptionOrderController.php`
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤 1: 上传所有修复文件
|
||||
|
||||
```bash
|
||||
# 上传修复后的文件
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php user@server:/path/to/server/app/adminapi/logic/tcm/
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php user@server:/path/to/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传测试脚本
|
||||
scp test_gancao_config.php user@server:/path/to/
|
||||
scp test_gancao_ssl.php user@server:/path/to/
|
||||
scp diagnose_gancao_payload.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 验证文件已上传
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
|
||||
# 检查文件修改时间
|
||||
ls -la server/app/common/service/gancao/GancaoScmRecipelService.php
|
||||
ls -la server/app/common/service/gancao/GancaoOpenApiTransport.php
|
||||
ls -la server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
ls -la server/app/adminapi/controller/tcm/PrescriptionOrderController.php
|
||||
```
|
||||
|
||||
### 步骤 3: 运行配置检查
|
||||
|
||||
```bash
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
**期望输出:** 所有配置项显示 ✓
|
||||
|
||||
### 步骤 4: 运行 SSL 测试
|
||||
|
||||
```bash
|
||||
php test_gancao_ssl.php
|
||||
```
|
||||
|
||||
**期望输出:** 至少一种 SSL 配置测试成功,并且能成功获取 token
|
||||
|
||||
### 步骤 5: 清除缓存
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 6: 重启服务
|
||||
|
||||
```bash
|
||||
# PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# Nginx
|
||||
sudo systemctl restart nginx
|
||||
|
||||
# 或者 Apache
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### 步骤 7: 测试接口
|
||||
|
||||
**开启日志监控:**
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -E "(Gancao|gancao|submitGancao)"
|
||||
```
|
||||
|
||||
**在另一个终端调用接口:**
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_admin_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
## 预期结果
|
||||
|
||||
### 成功的情况
|
||||
|
||||
**接口响应:**
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"show": 0,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC202604...",
|
||||
"app_order_no": "PO123",
|
||||
"fee": {
|
||||
"total": 150.00,
|
||||
"medicine": 120.00,
|
||||
"process": 20.00,
|
||||
"express": 10.00
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**日志内容:**
|
||||
```
|
||||
[info] Gancao MAKE_TOKEN request: {...}
|
||||
[info] Gancao MAKE_TOKEN response: {...}
|
||||
[info] Gancao CTM_SUBMIT_RECIPEL payload: {...}
|
||||
[info] submitGancaoRecipel success: {...}
|
||||
```
|
||||
|
||||
**数据库变化:**
|
||||
- `prescription_order` 表中对应订单的 `gancao_reciperl_order_no` 字段已更新
|
||||
- `gancao_submit_time` 字段已更新为当前时间戳
|
||||
|
||||
### 失败的情况
|
||||
|
||||
如果仍然失败,日志会显示详细错误信息:
|
||||
|
||||
**配置错误:**
|
||||
```
|
||||
[error] Gancao MAKE_TOKEN api error: {"body": {...}, "apiMsg": "..."}
|
||||
```
|
||||
→ 检查 `.env` 配置,特别是 AK/SK
|
||||
|
||||
**SSL 连接错误:**
|
||||
```
|
||||
[error] Gancao CTM_SUBMIT failed: {"subRet": {...}, "payload": {...}}
|
||||
```
|
||||
→ 运行 `php test_gancao_ssl.php` 诊断 SSL 问题
|
||||
|
||||
**业务逻辑错误:**
|
||||
```
|
||||
[error] submitGancaoRecipel exception: {"message": "...", "trace": "..."}
|
||||
```
|
||||
→ 检查处方数据是否完整,药材 ID 是否正确映射
|
||||
|
||||
## 常见错误和解决方案
|
||||
|
||||
### 错误 1: "Array to string conversion"
|
||||
|
||||
**已修复!** 如果仍然出现,请检查:
|
||||
1. 文件是否正确上传
|
||||
2. 缓存是否已清除
|
||||
3. 服务是否已重启
|
||||
|
||||
### 错误 2: SSL 连接错误
|
||||
|
||||
**解决方案:**
|
||||
1. 运行 `php test_gancao_ssl.php`
|
||||
2. 查看哪种 SSL 配置成功
|
||||
3. 如果都失败,检查 OpenSSL 版本:`openssl version`
|
||||
4. 联系甘草技术支持确认 SSL 要求
|
||||
|
||||
### 错误 3: "MAKE_TOKEN 失败:[10103]"
|
||||
|
||||
**原因:** AK/SK 不匹配或密码计算错误
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `.env` 中的配置:
|
||||
```env
|
||||
GANCAO_SCM_BIZ_AK=xxx
|
||||
GANCAO_SCM_BIZ_SK=xxx
|
||||
```
|
||||
2. 确认 BIZ AK/SK 与网关 AK/SK 是否相同
|
||||
3. 检查服务器时间是否正确:`date`
|
||||
4. 联系甘草获取正确的 AK/SK
|
||||
|
||||
### 错误 4: "药材无法匹配甘草药ID"
|
||||
|
||||
**原因:** 药材 ID 映射缺失
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `zyt_doctor_medicine` 表中药材的 `gid` 字段
|
||||
2. 或在 `.env` 配置 `herb_id_map`:
|
||||
```php
|
||||
'herb_id_map' => [
|
||||
'当归' => 1001,
|
||||
'黄芪' => 1002,
|
||||
]
|
||||
```
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
✅ **所有以下条件都满足,说明修复成功:**
|
||||
|
||||
1. `php test_gancao_config.php` 全部通过
|
||||
2. `php test_gancao_ssl.php` 显示 "✓ 成功获取 token"
|
||||
3. 接口返回 `{"code": 1, "msg": "甘草药方上传成功"}`
|
||||
4. 日志中显示 `submitGancaoRecipel success`
|
||||
5. 数据库中订单已更新 `gancao_reciperl_order_no`
|
||||
6. 没有 "Array to string conversion" 错误
|
||||
7. 没有 SSL 连接错误
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果以上步骤都无法解决问题,请收集以下信息:
|
||||
|
||||
1. **配置检查输出:**
|
||||
```bash
|
||||
php test_gancao_config.php > config_check.txt 2>&1
|
||||
```
|
||||
|
||||
2. **SSL 测试输出:**
|
||||
```bash
|
||||
php test_gancao_ssl.php > ssl_check.txt 2>&1
|
||||
```
|
||||
|
||||
3. **错误日志:**
|
||||
```bash
|
||||
tail -200 server/runtime/log/$(date +%Y%m%d).log > error.log
|
||||
```
|
||||
|
||||
4. **接口响应:**
|
||||
```bash
|
||||
curl -X POST ... > response.json 2>&1
|
||||
```
|
||||
|
||||
5. **系统信息:**
|
||||
```bash
|
||||
php -v > system_info.txt
|
||||
openssl version >> system_info.txt
|
||||
curl --version >> system_info.txt
|
||||
cat /etc/os-release >> system_info.txt
|
||||
```
|
||||
|
||||
将这些文件发送给技术支持进行分析。
|
||||
|
||||
## 总结
|
||||
|
||||
这次修复解决了三个主要问题:
|
||||
|
||||
1. **Array to string conversion** - 甘草 API 返回的错误消息可能是数组
|
||||
2. **SSL 连接失败** - TLS 版本和 HTTP 版本不兼容
|
||||
3. **配置类型错误** - 某些配置值被错误地解析为数组
|
||||
|
||||
所有问题都已修复,现在应该可以正常调用甘草 API 了!
|
||||
@@ -1,286 +0,0 @@
|
||||
# 甘草 API "Array to string conversion" 错误修复总结
|
||||
|
||||
## 问题描述
|
||||
在 Linux 服务器上调用甘草处方下单接口时,返回错误:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 已完成的修复
|
||||
|
||||
### 1. 代码层面修复
|
||||
|
||||
#### ✅ `GancaoScmRecipelService.php`
|
||||
- 修复 `express_type` 字段:确保是字符串而不是数组
|
||||
- 修复 `callback_url` 字段:确保是字符串而不是数组
|
||||
- 修复患者年龄格式:从 `sprintf('%d.00', $ageInt)` 改为 `(string) $ageInt`
|
||||
- 优化 `doct_advice` 字段处理:确保所有子字段都是字符串
|
||||
- 添加调试日志:记录发送的完整负载
|
||||
|
||||
#### ✅ `PrescriptionOrderLogic.php`
|
||||
- 修复 `$appNo` 变量引用问题
|
||||
- 添加详细错误日志:记录失败时的完整请求和响应
|
||||
- 添加 `use think\facade\Log;` 导入
|
||||
|
||||
#### ✅ `PrescriptionOrderController.php`
|
||||
- 添加 try-catch 异常处理
|
||||
- 添加返回值类型检查
|
||||
- 添加详细日志记录
|
||||
|
||||
### 2. 诊断工具
|
||||
|
||||
创建了三个诊断脚本:
|
||||
|
||||
1. **`test_gancao_config.php`** - 配置检查脚本
|
||||
- 检查所有配置项是否存在
|
||||
- 检查配置值类型是否正确
|
||||
- 检查是否有数组值被错误地用作字符串
|
||||
- 测试 JSON 编码
|
||||
|
||||
2. **`diagnose_gancao_payload.php`** - 负载诊断脚本
|
||||
- 测试 `buildPreviewPayload` 方法
|
||||
- 测试 `buildSubmitPayload` 方法
|
||||
- 检查每个字段的类型
|
||||
- 测试 JSON 编码
|
||||
- 保存负载到文件以便检查
|
||||
|
||||
3. **文档**:
|
||||
- `GANCAO_API_FIX.md` - 初步修复说明
|
||||
- `GANCAO_ARRAY_TO_STRING_FIX.md` - 完整解决方案
|
||||
- `GANCAO_FIX_SUMMARY.md` - 本文档
|
||||
|
||||
## 下一步操作
|
||||
|
||||
### 步骤 1: 上传修复后的代码到服务器
|
||||
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php user@server:/path/to/server/app/adminapi/logic/tcm/
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php user@server:/path/to/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传诊断脚本
|
||||
scp test_gancao_config.php user@server:/path/to/
|
||||
scp diagnose_gancao_payload.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 在服务器上运行配置检查
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
|
||||
# 运行配置检查
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
=== 甘草配置检查 ===
|
||||
|
||||
1. 检查配置是否存在:
|
||||
✓ 配置已加载
|
||||
|
||||
2. 检查各项配置:
|
||||
- enabled: ✓ boolean = true
|
||||
- gateway_url: ✓ string = https://...
|
||||
- gateway_ak: ✓ string = xxx
|
||||
- gateway_sk: ✓ string = xxx
|
||||
- biz_ak: ✓ string = xxx
|
||||
- biz_sk: ✓ string = xxx
|
||||
- callback_url: ✓ string = https://...
|
||||
- express_type: ✓ string = sf
|
||||
- cradle_store: ✓ string = xxx
|
||||
- df_id: ✓ integer = 101
|
||||
|
||||
...
|
||||
|
||||
=== 检查结果: ✓ 配置正常 ===
|
||||
```
|
||||
|
||||
**如果看到 ❌ 或 ⚠️**:
|
||||
- 记录哪个字段有问题
|
||||
- 检查 `.env` 文件中该字段的配置
|
||||
- 修复后重新运行检查
|
||||
|
||||
### 步骤 3: 运行负载诊断
|
||||
|
||||
```bash
|
||||
php diagnose_gancao_payload.php
|
||||
```
|
||||
|
||||
这会:
|
||||
- 测试负载构建过程
|
||||
- 检查每个字段的类型
|
||||
- 测试 JSON 编码
|
||||
- 生成一个 JSON 文件(`gancao_payload_YYYYMMDDHHMMSS.json`)
|
||||
|
||||
检查生成的 JSON 文件,确保:
|
||||
- 没有字段的值是 `"Array"`
|
||||
- 所有字符串字段都是字符串
|
||||
- 所有数组字段都是数组
|
||||
|
||||
### 步骤 4: 清除缓存
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
|
||||
# 或手动删除
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 5: 重启服务
|
||||
|
||||
```bash
|
||||
# PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# Nginx
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 步骤 6: 测试接口
|
||||
|
||||
```bash
|
||||
# 查看实时日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
在另一个终端调用接口:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
### 步骤 7: 检查日志
|
||||
|
||||
日志中应该看到:
|
||||
|
||||
**成功的情况**:
|
||||
```
|
||||
[info] Gancao CTM_SUBMIT_RECIPEL payload: {...}
|
||||
[info] submitGancaoRecipel success: {...}
|
||||
```
|
||||
|
||||
**失败的情况**:
|
||||
```
|
||||
[error] Gancao CTM_SUBMIT failed: {...}
|
||||
[error] submitGancaoRecipel exception: {...}
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 问题 1: 配置检查显示某个字段是数组
|
||||
|
||||
**解决方案**:
|
||||
1. 编辑 `.env` 文件
|
||||
2. 找到该字段的配置行
|
||||
3. 移除 `[]` 和多余的引号
|
||||
4. 示例:
|
||||
```env
|
||||
# 错误
|
||||
GANCAO_SCM_EXPRESS_TYPE=["sf"]
|
||||
|
||||
# 正确
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
```
|
||||
5. 保存后清除缓存并重启服务
|
||||
|
||||
### 问题 2: JSON 中包含 "Array" 字符串
|
||||
|
||||
**解决方案**:
|
||||
1. 运行 `diagnose_gancao_payload.php` 找出是哪个字段
|
||||
2. 检查该字段在代码中的处理
|
||||
3. 确保使用 `(string)` 强制转换
|
||||
4. 检查配置文件中该字段的值
|
||||
|
||||
### 问题 3: 仍然报 "Array to string conversion"
|
||||
|
||||
**可能原因**:
|
||||
1. 缓存未清除
|
||||
2. 服务未重启
|
||||
3. 上传的文件未生效
|
||||
4. 配置文件有语法错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 确认文件已上传:`ls -la server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
2. 检查文件修改时间:`stat server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
3. 清除所有缓存:`rm -rf server/runtime/*`
|
||||
4. 重启所有服务
|
||||
5. 检查 PHP 错误日志:`tail -f /var/log/php-fpm/error.log`
|
||||
|
||||
### 问题 4: 配置检查通过但接口仍然失败
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查日志中的完整错误信息
|
||||
2. 查看 `Gancao CTM_SUBMIT_RECIPEL payload` 日志,确认发送的数据
|
||||
3. 检查甘草 API 返回的错误信息
|
||||
4. 确认药材 ID 映射是否正确
|
||||
5. 确认处方数据是否完整
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ `test_gancao_config.php` 全部通过
|
||||
2. ✅ `diagnose_gancao_payload.php` 全部通过
|
||||
3. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC...",
|
||||
"app_order_no": "PO...",
|
||||
"fee": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. ✅ 日志显示 `submitGancaoRecipel success`
|
||||
5. ✅ 数据库 `prescription_order` 表中 `gancao_reciperl_order_no` 字段已更新
|
||||
|
||||
## 需要进一步帮助
|
||||
|
||||
如果以上步骤都无法解决问题,请提供:
|
||||
|
||||
1. **配置检查输出**:
|
||||
```bash
|
||||
php test_gancao_config.php > config_check.txt 2>&1
|
||||
```
|
||||
|
||||
2. **负载诊断输出**:
|
||||
```bash
|
||||
php diagnose_gancao_payload.php > payload_check.txt 2>&1
|
||||
```
|
||||
|
||||
3. **错误日志**:
|
||||
```bash
|
||||
tail -100 server/runtime/log/$(date +%Y%m%d).log > error.log
|
||||
```
|
||||
|
||||
4. **接口响应**:
|
||||
```bash
|
||||
curl -X POST ... > response.json 2>&1
|
||||
```
|
||||
|
||||
5. **`.env` 配置**(隐藏敏感信息):
|
||||
```bash
|
||||
grep GANCAO_SCM .env > gancao_config.txt
|
||||
```
|
||||
|
||||
将这些文件发送给技术支持进行分析。
|
||||
|
||||
## 总结
|
||||
|
||||
这次修复主要解决了配置值类型错误的问题。关键点:
|
||||
|
||||
1. **配置必须是正确的类型**:字符串字段不能是数组
|
||||
2. **`.env` 格式很重要**:不要使用 JSON 语法或数组语法
|
||||
3. **缓存必须清除**:修改配置后必须清除缓存
|
||||
4. **日志很重要**:添加了详细日志帮助排查问题
|
||||
|
||||
希望这次修复能解决你的问题!
|
||||
@@ -1,212 +0,0 @@
|
||||
# 甘草预下单测试功能
|
||||
|
||||
## 功能概述
|
||||
在订单编辑表单中添加"甘草预下单测试"功能,允许用户在不真实提交订单的情况下,测试当前配置的甘草预下单价格。测试结果以抽屉形式展示,包含完整的价格、配送、规则检查和药材明细信息。
|
||||
|
||||
## 实现内容
|
||||
|
||||
### 1. 后端接口
|
||||
|
||||
#### 控制器方法 (`server/app/adminapi/controller/tcm/PrescriptionOrderController.php`)
|
||||
```php
|
||||
public function previewGancaoRecipel()
|
||||
```
|
||||
- 调用验证器场景:`previewGancaoRecipel`
|
||||
- 调用逻辑方法:`PrescriptionOrderLogic::previewGancaoRecipel()`
|
||||
- 返回预览结果(包含价格信息)
|
||||
|
||||
#### 逻辑方法 (`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`)
|
||||
```php
|
||||
public static function previewGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
```
|
||||
- 验证订单和处方存在性
|
||||
- 检查药材是否能匹配甘草药ID
|
||||
- 获取甘草token
|
||||
- 调用 `CTM_PREVIEW` 接口
|
||||
- 检查规则拦截和药材可用性
|
||||
- 返回完整的预览数据(不提交订单)
|
||||
|
||||
#### 验证器 (`server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php`)
|
||||
- 添加场景:`previewGancaoRecipel => ['id']`
|
||||
|
||||
### 2. 前端实现
|
||||
|
||||
#### API接口 (`admin/src/api/tcm.ts`)
|
||||
```typescript
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number })
|
||||
```
|
||||
|
||||
#### UI组件 (`admin/src/views/consumer/prescription/order_list.vue`)
|
||||
|
||||
**测试按钮位置**:在"剂数"和"剂量单位"字段后,"上次医生"字段前
|
||||
|
||||
**组件结构**:
|
||||
```vue
|
||||
<el-button @click="testGancaoPreview">测试价格</el-button>
|
||||
<el-drawer v-model="gancaoPreviewDrawerVisible">
|
||||
<!-- 价格信息 -->
|
||||
<!-- 配送信息 -->
|
||||
<!-- 规则检查 -->
|
||||
<!-- 药材明细 -->
|
||||
<!-- 订单信息 -->
|
||||
</el-drawer>
|
||||
```
|
||||
|
||||
### 3. 抽屉展示内容
|
||||
|
||||
#### 价格信息卡片
|
||||
- **药材成本** (`m_cost`): 橙色显示,单位:元
|
||||
- **加工费** (`proces_cost`): 蓝色显示,单位:分转元
|
||||
- **物流费** (`lis_cost`): 绿色显示,单位:分转元
|
||||
- **总计**: 红色加粗显示,自动计算总和
|
||||
|
||||
#### 配送信息卡片
|
||||
- 调配中心名称 (`ds_name`)
|
||||
- 调配中心ID (`ds_id`)
|
||||
- 服用天数范围 (`take_days.min` - `take_days.max`)
|
||||
- 默认服用天数 (`take_days.default`)
|
||||
|
||||
#### 规则检查卡片
|
||||
- 显示所有规则检查结果 (`rule_check`)
|
||||
- 根据 `type` 显示不同颜色:
|
||||
- `type >= 2`: 错误(红色)
|
||||
- `type === 1`: 警告(黄色)
|
||||
- `type === 0`: 信息(蓝色)
|
||||
- 显示规则代码 (`code`) 和消息 (`msg`)
|
||||
|
||||
#### 药材明细表格
|
||||
- 序号
|
||||
- 药材名称 (`title`)
|
||||
- 用量 (`quantity` + `unit`)
|
||||
- 单价 (`price`,元/克,保留4位小数)
|
||||
- 小计(单价 × 用量,橙色显示)
|
||||
- 库存状态 (`is_available`,1=有货/0=缺货)
|
||||
- 备注 (`brief`)
|
||||
|
||||
#### 订单信息卡片
|
||||
- 订单号 (`order_no`):跨2列显示
|
||||
- 剂数 (`dose_count`):显示格式"X剂"(如:5剂)
|
||||
- 单剂量:自动计算所有药材用量总和,显示格式"X克"(如:196克)
|
||||
|
||||
## 接口返回数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "预下单成功",
|
||||
"data": {
|
||||
"success": true,
|
||||
"fee": {
|
||||
"m_cost": "112.88",
|
||||
"proces_cost": 80,
|
||||
"lis_cost": 0
|
||||
},
|
||||
"result": {
|
||||
"aa_id": 73,
|
||||
"ds_id": 135,
|
||||
"ds_name": "郑州-甘草调配中心√",
|
||||
"take_days": {
|
||||
"min": 7,
|
||||
"max": 35,
|
||||
"default": 28
|
||||
},
|
||||
"rule_check": [
|
||||
{
|
||||
"code": "pill_yield_notice",
|
||||
"msg": "出丸量约98g±5%",
|
||||
"type": 0
|
||||
}
|
||||
],
|
||||
"fee": {
|
||||
"m_cost": "112.88",
|
||||
"proces_cost": 80,
|
||||
"lis_cost": 0
|
||||
},
|
||||
"m_list": [
|
||||
{
|
||||
"id": 420,
|
||||
"title": "龙胆",
|
||||
"price": 0.405028,
|
||||
"unit": "克",
|
||||
"quantity": "6",
|
||||
"is_available": 1,
|
||||
"brief": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"order_no": "PO20260414160525788214",
|
||||
"dose_count": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI设计特点
|
||||
|
||||
### 布局
|
||||
- 使用 `el-drawer` 抽屉组件,宽度 800px
|
||||
- 内容分为5个卡片区域,使用 `el-card` 组件
|
||||
- 使用 `el-descriptions` 展示键值对信息
|
||||
- 使用 `el-table` 展示药材明细列表
|
||||
|
||||
### 颜色方案
|
||||
- 药材成本:橙色 (`text-orange-600`)
|
||||
- 加工费:蓝色 (`text-blue-600`)
|
||||
- 物流费:绿色 (`text-green-600`)
|
||||
- 总计:红色加粗 (`text-red-600 font-bold`)
|
||||
- 小计:橙色 (`text-orange-600`)
|
||||
|
||||
### 交互
|
||||
- 点击"测试价格"按钮触发预下单
|
||||
- 按钮显示加载状态(loading)
|
||||
- 成功后自动打开抽屉展示结果
|
||||
- 失败时显示错误提示消息
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 打开订单编辑对话框
|
||||
2. 配置剂数(`dose_count`)和剂量单位(`dose_unit`)
|
||||
3. 点击"测试价格"按钮
|
||||
4. 系统调用甘草 `CTM_PREVIEW` 接口
|
||||
5. 自动打开抽屉展示完整的预下单结果
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 预下单测试**不会**真实提交订单到甘草
|
||||
- 预下单测试**不会**扣费
|
||||
- 仅用于验证配置和查看价格
|
||||
- 需要先保存订单才能测试(`editForm.id` 必须存在)
|
||||
- 测试结果会检查:
|
||||
- 药材是否匹配甘草药ID
|
||||
- 规则是否拦截
|
||||
- 药材是否缺货
|
||||
- 抽屉关闭时自动销毁内容(`destroy-on-close`)
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 后端
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php`
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php`
|
||||
- `server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
|
||||
### 前端
|
||||
- `admin/src/api/tcm.ts`
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
## 测试要点
|
||||
|
||||
- [ ] 点击"测试价格"按钮能正常调用接口
|
||||
- [ ] 成功时自动打开抽屉展示结果
|
||||
- [ ] 价格信息显示正确(药材成本、加工费、物流费、总计)
|
||||
- [ ] 配送信息显示完整(调配中心、服用天数范围)
|
||||
- [ ] 规则检查按类型显示不同颜色
|
||||
- [ ] 药材明细表格显示完整(名称、用量、单价、小计、库存状态)
|
||||
- [ ] 订单信息显示正确(订单号、剂数)
|
||||
- [ ] 失败时显示清晰的错误信息
|
||||
- [ ] 按钮加载状态正常显示
|
||||
- [ ] 价格单位正确(分转元)
|
||||
- [ ] 仅在编辑已存在订单时显示测试按钮
|
||||
- [ ] 测试不会真实提交订单到甘草
|
||||
- [ ] 测试不会修改订单状态
|
||||
- [ ] 抽屉关闭后内容正确销毁
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
# 甘草 SSL 连接错误修复指南
|
||||
|
||||
## 错误信息
|
||||
|
||||
```
|
||||
甘草网关通信失败:通信失败:HTTP 200 curl#56 OpenSSL SSL_read: error:0A000126:SSL routines::unexpected eof while reading, errno 0
|
||||
```
|
||||
|
||||
## 问题分析
|
||||
|
||||
这个错误表示:
|
||||
- ✅ TCP 连接成功(HTTP 200)
|
||||
- ✅ SSL 握手开始
|
||||
- ❌ SSL 读取数据时连接意外断开
|
||||
|
||||
**常见原因:**
|
||||
1. TLS 版本不兼容(服务器要求 TLS 1.2+,客户端使用旧版本)
|
||||
2. SSL 加密套件不匹配
|
||||
3. HTTP 版本问题(HTTP/1.0 vs HTTP/1.1)
|
||||
4. OpenSSL 安全级别过高
|
||||
5. 服务器端提前关闭连接
|
||||
|
||||
## 已实施的修复
|
||||
|
||||
### 修改 `GancaoOpenApiTransport.php`
|
||||
|
||||
```php
|
||||
// 1. 改用 HTTP/1.1(原来是 HTTP/1.0)
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
|
||||
// 2. 增加连接超时
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 从 5 秒增加到 10 秒
|
||||
|
||||
// 3. 完全禁用 SSL 验证
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 从 2 改为 0
|
||||
|
||||
// 4. 强制使用 TLS 1.2
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
|
||||
|
||||
// 5. 降低 OpenSSL 安全级别
|
||||
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1');
|
||||
|
||||
// 6. 添加 TCP keepalive
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 步骤 1: 上传修复后的文件
|
||||
|
||||
```bash
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp test_gancao_ssl.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 运行 SSL 测试
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
php test_gancao_ssl.php
|
||||
```
|
||||
|
||||
**期望输出:**
|
||||
```
|
||||
=== 甘草 SSL 连接测试 ===
|
||||
|
||||
网关地址: https://xxx.com
|
||||
|
||||
1. DNS 解析测试:
|
||||
✓ xxx.com -> 1.2.3.4
|
||||
|
||||
2. TCP 连接测试:
|
||||
✓ TCP 连接成功
|
||||
|
||||
3. SSL/TLS 支持检测:
|
||||
- SSLv2: ✗ 不支持
|
||||
- SSLv3: ✗ 不支持
|
||||
- TLS 1.0: ✓ 支持
|
||||
- TLS 1.1: ✓ 支持
|
||||
- TLS 1.2: ✓ 支持
|
||||
- TLS 1.3: ✓ 支持
|
||||
|
||||
4. cURL SSL 测试:
|
||||
测试 HTTP/1.0 + TLS 1.2:
|
||||
❌ 失败: [56] OpenSSL SSL_read...
|
||||
测试 HTTP/1.1 + TLS 1.2:
|
||||
✓ 成功 (HTTP 200)
|
||||
测试 HTTP/1.1 + TLS 1.2 + SECLEVEL=1:
|
||||
✓ 成功 (HTTP 200)
|
||||
|
||||
5. OpenSSL 版本信息:
|
||||
版本: OpenSSL 1.1.1...
|
||||
|
||||
6. cURL 版本信息:
|
||||
版本: 7.x.x
|
||||
SSL 版本: OpenSSL/1.1.1...
|
||||
|
||||
7. 测试实际 API 调用 (MAKE_TOKEN):
|
||||
✓ 成功获取 token (长度: 64)
|
||||
```
|
||||
|
||||
### 步骤 3: 清除缓存并重启
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
rm -rf runtime/cache/*
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart php-fpm
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 步骤 4: 测试接口
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
在另一个终端调用接口:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
## 如果仍然失败
|
||||
|
||||
### 方案 A: 检查 OpenSSL 版本
|
||||
|
||||
```bash
|
||||
openssl version
|
||||
```
|
||||
|
||||
**要求:** OpenSSL 1.0.2 或更高版本
|
||||
|
||||
**如果版本过低,升级 OpenSSL:**
|
||||
|
||||
```bash
|
||||
# CentOS/RHEL
|
||||
sudo yum update openssl
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get update
|
||||
sudo apt-get install --only-upgrade openssl
|
||||
|
||||
# 重新编译 PHP(如果需要)
|
||||
```
|
||||
|
||||
### 方案 B: 修改 OpenSSL 配置
|
||||
|
||||
编辑 `/etc/ssl/openssl.cnf`:
|
||||
|
||||
```ini
|
||||
# 在文件开头添加
|
||||
openssl_conf = openssl_init
|
||||
|
||||
[openssl_init]
|
||||
ssl_conf = ssl_sect
|
||||
|
||||
[ssl_sect]
|
||||
system_default = system_default_sect
|
||||
|
||||
[system_default_sect]
|
||||
MinProtocol = TLSv1.2
|
||||
CipherString = DEFAULT@SECLEVEL=1
|
||||
```
|
||||
|
||||
重启服务:
|
||||
```bash
|
||||
sudo systemctl restart php-fpm
|
||||
```
|
||||
|
||||
### 方案 C: 使用不同的 TLS 版本
|
||||
|
||||
如果 TLS 1.2 不工作,尝试其他版本。
|
||||
|
||||
修改 `GancaoOpenApiTransport.php`:
|
||||
|
||||
```php
|
||||
// 尝试 TLS 1.3
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_3);
|
||||
|
||||
// 或者让 cURL 自动选择
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_DEFAULT);
|
||||
```
|
||||
|
||||
### 方案 D: 禁用 HTTP/2
|
||||
|
||||
如果使用了 HTTP/2,尝试禁用:
|
||||
|
||||
```php
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
```
|
||||
|
||||
### 方案 E: 增加调试信息
|
||||
|
||||
临时添加详细的 cURL 调试:
|
||||
|
||||
```php
|
||||
// 在 GancaoOpenApiTransport.php 的 post 方法中添加
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
$verbose = fopen('php://temp', 'w+');
|
||||
curl_setopt($ch, CURLOPT_STDERR, $verbose);
|
||||
|
||||
// 在 curl_exec 之后添加
|
||||
rewind($verbose);
|
||||
$verboseLog = stream_get_contents($verbose);
|
||||
\think\facade\Log::info('cURL verbose output', ['log' => $verboseLog]);
|
||||
```
|
||||
|
||||
查看详细日志:
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
### 方案 F: 联系甘草技术支持
|
||||
|
||||
提供以下信息:
|
||||
1. `php test_gancao_ssl.php` 的完整输出
|
||||
2. `openssl version` 输出
|
||||
3. `php -v` 输出
|
||||
4. `curl --version` 输出
|
||||
5. 服务器操作系统版本
|
||||
|
||||
询问:
|
||||
- 甘草网关支持的 TLS 版本
|
||||
- 推荐的 SSL 加密套件
|
||||
- 是否有特殊的 HTTP 头要求
|
||||
- 是否有 IP 白名单限制
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么 HTTP/1.0 会导致问题?
|
||||
|
||||
A: 某些现代服务器不再支持 HTTP/1.0,或者在 HTTP/1.0 下有不同的 SSL 处理逻辑。HTTP/1.1 是更标准的选择。
|
||||
|
||||
### Q2: SECLEVEL=1 是什么?
|
||||
|
||||
A: OpenSSL 1.1.0+ 引入了安全级别概念:
|
||||
- SECLEVEL=2(默认):要求 112 位安全性
|
||||
- SECLEVEL=1:要求 80 位安全性(更宽松)
|
||||
- SECLEVEL=0:允许所有加密套件(不推荐)
|
||||
|
||||
降低安全级别可以兼容更多服务器,但会降低安全性。
|
||||
|
||||
### Q3: 为什么要禁用 SSL 验证?
|
||||
|
||||
A: 在开发/测试环境中,禁用 SSL 验证可以避免证书问题。**生产环境建议启用验证**。
|
||||
|
||||
### Q4: TCP keepalive 有什么用?
|
||||
|
||||
A: 保持 TCP 连接活跃,防止长时间传输时连接被中断。
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ `php test_gancao_ssl.php` 显示 "✓ 成功获取 token"
|
||||
2. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
3. ✅ 日志中没有 SSL 错误
|
||||
4. ✅ 数据库中订单已更新
|
||||
|
||||
## 总结
|
||||
|
||||
SSL 连接问题通常是由于:
|
||||
1. **TLS 版本不匹配** → 使用 TLS 1.2
|
||||
2. **HTTP 版本问题** → 使用 HTTP/1.1
|
||||
3. **OpenSSL 安全级别过高** → 降低到 SECLEVEL=1
|
||||
4. **连接超时** → 增加超时时间
|
||||
|
||||
修复后应该能正常连接甘草 API。如果仍有问题,运行 `test_gancao_ssl.php` 并根据输出进一步诊断。
|
||||
@@ -1,169 +0,0 @@
|
||||
# 甘草上传药方按钮显示条件
|
||||
|
||||
## 需求
|
||||
"上传药方"按钮应该只在特定条件下显示:
|
||||
1. 处方审核已通过
|
||||
2. 还没有上传过甘草订单(gancao_reciperl_order_no 为空)
|
||||
|
||||
## 实现
|
||||
|
||||
### 文件:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. 添加条件判断函数
|
||||
|
||||
```typescript
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑说明**:
|
||||
- `prescription_audit_status === 1`:处方审核状态为"已通过"
|
||||
- `gancao_reciperl_order_no` 为空:还没有上传过甘草订单
|
||||
|
||||
#### 2. 更新按钮显示条件
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
```
|
||||
|
||||
## 显示逻辑
|
||||
|
||||
### 场景 1:处方未审核
|
||||
```
|
||||
prescription_audit_status: 0 (待审核)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
### 场景 2:处方已驳回
|
||||
```
|
||||
prescription_audit_status: 2 (已驳回)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
### 场景 3:处方已通过,未上传
|
||||
```
|
||||
prescription_audit_status: 1 (已通过)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮显示 ✅
|
||||
```
|
||||
|
||||
### 场景 4:处方已通过,已上传
|
||||
```
|
||||
prescription_audit_status: 1 (已通过)
|
||||
gancao_reciperl_order_no: 'GC202604150001'
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
## 审核状态说明
|
||||
|
||||
| 状态值 | 说明 | 是否显示按钮 |
|
||||
|--------|------|-------------|
|
||||
| 0 | 待审核 | ❌ |
|
||||
| 1 | 已通过 | ✅ (如果未上传) |
|
||||
| 2 | 已驳回 | ❌ |
|
||||
|
||||
## 业务流程
|
||||
|
||||
```
|
||||
创建订单
|
||||
↓
|
||||
处方待审核 (prescription_audit_status = 0)
|
||||
↓ 审核
|
||||
处方已通过 (prescription_audit_status = 1)
|
||||
↓ 显示"上传药方"按钮
|
||||
点击上传
|
||||
↓ 调用甘草API
|
||||
上传成功,保存订单号 (gancao_reciperl_order_no = 'GC...')
|
||||
↓ 按钮隐藏
|
||||
订单继续履约流程
|
||||
```
|
||||
|
||||
## 相关字段
|
||||
|
||||
### prescription_audit_status (处方审核状态)
|
||||
- **类型**: tinyint(2)
|
||||
- **值**:
|
||||
- 0: 待审核
|
||||
- 1: 已通过
|
||||
- 2: 已驳回
|
||||
|
||||
### gancao_reciperl_order_no (甘草处方订单号)
|
||||
- **类型**: varchar(32)
|
||||
- **说明**: 上传甘草成功后返回的订单号
|
||||
- **示例**: `GC202604150001`
|
||||
- **空值**: 表示还未上传
|
||||
|
||||
## 防止重复上传
|
||||
|
||||
通过检查 `gancao_reciperl_order_no` 字段,确保:
|
||||
1. 每个订单只能上传一次
|
||||
2. 上传成功后按钮自动隐藏
|
||||
3. 避免重复提交导致的数据混乱
|
||||
|
||||
## 用户体验
|
||||
|
||||
### 上传前
|
||||
```
|
||||
操作列:
|
||||
[详情/审核] [编辑] [上传药方] [确认发货] ...
|
||||
```
|
||||
|
||||
### 上传后
|
||||
```
|
||||
操作列:
|
||||
[详情/审核] [编辑] [确认发货] ...
|
||||
```
|
||||
"上传药方"按钮消失,界面更简洁。
|
||||
|
||||
## 错误处理
|
||||
|
||||
如果用户尝试重复上传(通过其他方式),后端也有验证:
|
||||
|
||||
```php
|
||||
// server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
if (!self::canUploadGancaoRecipel($item, $adminId, $adminInfo, $assistantByDiag)) {
|
||||
self::$error = '须处方审核通过后方可上传甘草;已完成/已取消订单不可上传;同一订单仅可上传一次';
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [ ] 创建新订单,处方待审核,按钮不显示
|
||||
- [ ] 处方审核通过,按钮显示
|
||||
- [ ] 点击上传药方,上传成功
|
||||
- [ ] 刷新页面,按钮消失
|
||||
- [ ] 处方被驳回,按钮不显示
|
||||
- [ ] 已上传的订单,按钮不显示
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表页面
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端业务逻辑
|
||||
- `GANCAO_CALLBACK_SETUP.md` - 甘草回调功能文档
|
||||
|
||||
## 总结
|
||||
|
||||
通过添加 `canUploadGancaoRow()` 函数,实现了智能的按钮显示控制:
|
||||
- ✅ 只在处方审核通过后显示
|
||||
- ✅ 上传成功后自动隐藏
|
||||
- ✅ 防止重复上传
|
||||
- ✅ 提升用户体验
|
||||
|
||||
这样可以避免用户误操作,确保业务流程的正确性。
|
||||
@@ -1,255 +0,0 @@
|
||||
# IM 聊天记录接口性能优化
|
||||
|
||||
## 问题描述
|
||||
|
||||
接口 `tcm.diagnosis/getImChatMessages?diagnosis_id=377` 响应超时(30秒),错误信息:
|
||||
```
|
||||
Maximum execution time of 30 seconds exceeded
|
||||
```
|
||||
|
||||
错误发生在 `TencentImService.php` 第 346 行的 `curl_exec($ch)` 调用。
|
||||
|
||||
## 根本原因
|
||||
|
||||
1. **查询范围过大**
|
||||
- 原代码调用 `collectAllDoctorImPeerAccounts()` 获取所有医生和医助账号
|
||||
- 如果系统有 50+ 个医生/医助,就需要对每个账号调用腾讯云 IM API
|
||||
- 每个账号可能需要多次分页请求(每次最多 100 条消息)
|
||||
|
||||
2. **API 调用次数过多**
|
||||
- 假设有 50 个医生,每个医生有 300 条消息
|
||||
- 总 API 调用次数 = 50 × (300/100) = 150 次
|
||||
- 每次调用耗时 0.5-1 秒,总耗时 75-150 秒
|
||||
|
||||
3. **超时设置不合理**
|
||||
- PHP 默认执行时间限制:30 秒
|
||||
- 单个 HTTP 请求超时:30 秒
|
||||
- 实际需要的时间远超这些限制
|
||||
|
||||
## 优化方案
|
||||
|
||||
### 1. 只查询指派的医助(核心优化)
|
||||
|
||||
修改 `getImChatMessagesForDiagnosis` 方法:
|
||||
|
||||
**优化前:**
|
||||
```php
|
||||
// 查询所有医生/医助账号(可能有几十个)
|
||||
$doctorAccounts = self::collectAllDoctorImPeerAccounts();
|
||||
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
|
||||
if ($assistantId > 0) {
|
||||
$doctorAccounts[] = 'doctor_' . $assistantId;
|
||||
}
|
||||
```
|
||||
|
||||
**优化后:**
|
||||
```php
|
||||
// 只查询指派给该诊单的医助
|
||||
$doctorAccounts = [];
|
||||
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
|
||||
if ($assistantId > 0) {
|
||||
$doctorAccounts[] = 'doctor_' . $assistantId;
|
||||
}
|
||||
|
||||
// 如果没有指派医助,直接返回归档记录
|
||||
if (empty($doctorAccounts)) {
|
||||
$lists = self::enrichImMessagesWithStaffNames($archived);
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'patient_im_id' => $patientImId,
|
||||
'patient_name' => $diag['patient_name'] ?? '',
|
||||
'doctor_accounts_queried' => [],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**效果:**
|
||||
- API 调用次数从 150 次降低到 3-5 次
|
||||
- 响应时间从 75-150 秒降低到 2-5 秒
|
||||
|
||||
### 2. 增加 HTTP 请求超时时间
|
||||
|
||||
修改 `TencentImService::adminGetRoamMsg` 方法:
|
||||
|
||||
```php
|
||||
// 从 30 秒增加到 60 秒
|
||||
$result = $this->httpPost($url, json_encode($data), 60);
|
||||
```
|
||||
|
||||
### 3. 增加 PHP 执行时间限制
|
||||
|
||||
修改 `DiagnosisController::getImChatMessages` 方法:
|
||||
|
||||
```php
|
||||
public function getImChatMessages()
|
||||
{
|
||||
// 增加执行时间限制到 120 秒
|
||||
set_time_limit(120);
|
||||
|
||||
// ... 其他代码
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 新增优化版拉取方法
|
||||
|
||||
添加 `pullLiveImChatMessagesForDiagnosisOptimized` 方法:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 优化版:只拉取指定医生账号的消息(避免查询所有医生导致超时)
|
||||
*/
|
||||
private static function pullLiveImChatMessagesForDiagnosisOptimized($diag, array $doctorAccounts): array
|
||||
{
|
||||
// 只查询指定的医生账号,大大减少API调用次数
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 查询的医生账号数 | 50+ | 1 | 98% ↓ |
|
||||
| API 调用次数 | 150+ | 3-5 | 97% ↓ |
|
||||
| 响应时间 | 75-150秒 | 2-5秒 | 95% ↓ |
|
||||
| 超时风险 | 高 | 低 | - |
|
||||
|
||||
## 业务逻辑说明
|
||||
|
||||
### 为什么只查询指派的医助?
|
||||
|
||||
1. **业务场景**
|
||||
- 每个诊单都会指派一个医助(`assistant_id`)
|
||||
- 患者主要与指派的医助沟通
|
||||
- 其他医生/医助不会与该患者聊天
|
||||
|
||||
2. **数据准确性**
|
||||
- 只显示与该诊单相关的聊天记录
|
||||
- 避免显示无关的聊天记录
|
||||
- 提高数据查询的精准度
|
||||
|
||||
3. **归档机制**
|
||||
- 系统有定时任务将 IM 消息归档到本地数据库
|
||||
- 归档数据突破腾讯云 7 天限制
|
||||
- 即使不查询所有医生,历史记录也不会丢失
|
||||
|
||||
### 如果需要查询所有医生怎么办?
|
||||
|
||||
如果确实需要查询所有医生的聊天记录(例如管理员查看),可以:
|
||||
|
||||
1. **使用定时任务归档**
|
||||
```bash
|
||||
php think tcm:sync-im-chat-archive --days=7 --limit=100
|
||||
```
|
||||
|
||||
2. **异步查询**
|
||||
- 将查询任务放入队列
|
||||
- 后台慢慢处理
|
||||
- 完成后通知用户
|
||||
|
||||
3. **分页查询**
|
||||
- 前端分批请求不同医生的记录
|
||||
- 每次只查询 1-2 个医生
|
||||
- 避免单次请求超时
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **测试正常场景**
|
||||
```
|
||||
GET /adminapi/tcm.diagnosis/getImChatMessages?diagnosis_id=377
|
||||
```
|
||||
- 应该在 5 秒内返回
|
||||
- 返回指派医助的聊天记录
|
||||
|
||||
2. **测试无指派医助场景**
|
||||
- 创建一个未指派医助的诊单
|
||||
- 应该返回归档记录(如果有)
|
||||
- 不应该报错
|
||||
|
||||
3. **测试大量消息场景**
|
||||
- 找一个有 500+ 条消息的诊单
|
||||
- 应该能正常返回
|
||||
- 响应时间应该在 10 秒内
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### 1. 添加缓存
|
||||
|
||||
```php
|
||||
public static function getImChatMessagesForDiagnosis(int $diagnosisId)
|
||||
{
|
||||
// 缓存 5 分钟
|
||||
$cacheKey = 'im_chat_messages_' . $diagnosisId;
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// ... 查询逻辑
|
||||
|
||||
Cache::set($cacheKey, $result, 300);
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用 Redis 队列
|
||||
|
||||
```php
|
||||
// 将耗时的查询放入队列
|
||||
Queue::push(SyncImChatMessagesJob::class, [
|
||||
'diagnosis_id' => $diagnosisId
|
||||
]);
|
||||
|
||||
// 前端轮询查询结果
|
||||
```
|
||||
|
||||
### 3. WebSocket 实时推送
|
||||
|
||||
```php
|
||||
// 使用 WebSocket 实时推送新消息
|
||||
// 避免每次都查询腾讯云 API
|
||||
```
|
||||
|
||||
### 4. 增量同步
|
||||
|
||||
```php
|
||||
// 只同步最近 1 小时的新消息
|
||||
// 历史消息从归档表读取
|
||||
$minTime = time() - 3600;
|
||||
$result = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime);
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisLogic.php` - 主要逻辑
|
||||
- `server/app/common/service/TencentImService.php` - IM 服务
|
||||
- `server/app/adminapi/controller/tcm/DiagnosisController.php` - 控制器
|
||||
|
||||
## 监控建议
|
||||
|
||||
1. **添加性能日志**
|
||||
```php
|
||||
$startTime = microtime(true);
|
||||
// ... 查询逻辑
|
||||
$duration = microtime(true) - $startTime;
|
||||
Log::info('IM消息查询耗时', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'duration' => $duration,
|
||||
'doctor_accounts' => count($doctorAccounts),
|
||||
'message_count' => count($merged),
|
||||
]);
|
||||
```
|
||||
|
||||
2. **设置告警**
|
||||
- 响应时间 > 10 秒:警告
|
||||
- 响应时间 > 30 秒:严重
|
||||
- API 调用失败率 > 5%:严重
|
||||
|
||||
3. **定期检查**
|
||||
- 每周检查平均响应时间
|
||||
- 每月检查 API 调用次数
|
||||
- 及时发现性能退化
|
||||
|
||||
---
|
||||
|
||||
最后更新:2024-04-11
|
||||
@@ -1,142 +0,0 @@
|
||||
# 内部成本字段权限控制
|
||||
|
||||
## 需求说明
|
||||
|
||||
在业务订单列表中添加内部成本字段的显示,但只有指定角色的用户才能看到该字段。
|
||||
|
||||
## 权限配置
|
||||
|
||||
文件:`server/config/project.php`
|
||||
|
||||
```php
|
||||
// 处方业务订单 internal_cost 等财务字段:以下角色 ID + root 可见
|
||||
'prescription_order_finance_roles' => [0, 3, 6],
|
||||
```
|
||||
|
||||
- 角色 0:超级管理员
|
||||
- 角色 3:管理员
|
||||
- 角色 6:药师
|
||||
|
||||
## 后端权限控制
|
||||
|
||||
文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
后端已经实现了权限控制逻辑:
|
||||
|
||||
```php
|
||||
public static function canViewInternalCost(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$allow = Config::get('project.prescription_order_finance_roles', [0, 3]);
|
||||
$allow = array_map('intval', is_array($allow) ? $allow : []);
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $allow)) > 0;
|
||||
}
|
||||
|
||||
public static function maskInternalCostIfNeeded(array &$row, array $adminInfo): void
|
||||
{
|
||||
if (!self::canViewInternalCost($adminInfo)) {
|
||||
unset($row['internal_cost']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端在返回数据前会自动移除 `internal_cost` 字段,如果用户没有权限。
|
||||
|
||||
## 前端权限控制
|
||||
|
||||
文件:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
### 1. 添加权限检查函数
|
||||
|
||||
```typescript
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 与 server/config/project.php prescription_order_finance_roles 保持一致 */
|
||||
const FINANCE_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/** 判断当前用户是否有查看财务字段(内部成本)的权限 */
|
||||
const canViewFinanceFields = () => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 在列表中添加权限控制
|
||||
|
||||
**表格列(只有有权限的用户才能看到整列):**
|
||||
|
||||
```vue
|
||||
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92">
|
||||
<template #default="{ row }">
|
||||
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
**金额信息弹出框(只有有权限的用户才能看到内部成本行):**
|
||||
|
||||
```vue
|
||||
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
|
||||
<span class="text-gray-500">内部成本:</span>
|
||||
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 权限控制层级
|
||||
|
||||
### 后端(第一层防护)
|
||||
|
||||
1. 在 `PrescriptionOrderLogic::detail()` 和列表方法中调用 `maskInternalCostIfNeeded()`
|
||||
2. 如果用户没有权限,直接从返回数据中移除 `internal_cost` 字段
|
||||
3. 前端收到的数据中不包含该字段
|
||||
|
||||
### 前端(第二层防护)
|
||||
|
||||
1. 使用 `v-if="canViewFinanceFields()"` 控制整个表格列的显示
|
||||
2. 在弹出框中也使用 `canViewFinanceFields()` 控制内部成本行的显示
|
||||
3. 即使后端数据泄露,前端也不会显示
|
||||
|
||||
## 双重防护的优势
|
||||
|
||||
1. **后端防护**:确保数据安全,无权限用户无法获取敏感数据
|
||||
2. **前端防护**:优化用户体验,无权限用户不会看到空白或无意义的列
|
||||
3. **配置同步**:前端 `FINANCE_ROLE_IDS` 与后端配置保持一致
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 有权限的用户(角色 0, 3, 6)
|
||||
|
||||
1. 登录系统
|
||||
2. 进入"消费者处方订单"列表
|
||||
3. 可以看到"内部成本"列
|
||||
4. 点击金额信息图标,弹出框中显示内部成本
|
||||
|
||||
### 无权限的用户(其他角色)
|
||||
|
||||
1. 登录系统
|
||||
2. 进入"消费者处方订单"列表
|
||||
3. 看不到"内部成本"列
|
||||
4. 点击金额信息图标,弹出框中不显示内部成本
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **配置同步**:修改后端配置后,需要同步修改前端 `FINANCE_ROLE_IDS` 常量
|
||||
2. **缓存清除**:修改配置后需要运行 `php think clear` 清除缓存
|
||||
3. **角色分配**:确保用户的角色ID正确分配在数据库中
|
||||
4. **超级管理员**:`root=1` 的用户始终有权限查看所有字段
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `server/config/project.php` - 权限配置
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端权限逻辑
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端显示控制
|
||||
- `admin/src/stores/modules/user.ts` - 用户信息存储
|
||||
@@ -1,167 +0,0 @@
|
||||
# Order Detail Address Display Fix (订单详情地址显示修复)
|
||||
|
||||
## Issue
|
||||
The order detail view was only showing the detailed address (`shipping_address`) without displaying the province, city, and district information.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Added Computed Property for Full Address
|
||||
**File**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
Added `detailFullAddress` computed property that combines all address components:
|
||||
|
||||
```typescript
|
||||
// 完整收货地址(省市区 + 详细地址)
|
||||
const detailFullAddress = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return '—'
|
||||
|
||||
const parts = []
|
||||
if (d.shipping_province) parts.push(d.shipping_province)
|
||||
if (d.shipping_city) parts.push(d.shipping_city)
|
||||
if (d.shipping_district) parts.push(d.shipping_district)
|
||||
if (d.shipping_address) parts.push(d.shipping_address)
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Updated Display Template
|
||||
Changed the shipping address display from:
|
||||
```vue
|
||||
<el-descriptions-item label="收货地址" :span="2">
|
||||
{{ detailData.shipping_address }}
|
||||
</el-descriptions-item>
|
||||
```
|
||||
|
||||
To:
|
||||
```vue
|
||||
<el-descriptions-item label="收货地址" :span="2">
|
||||
{{ detailFullAddress }}
|
||||
</el-descriptions-item>
|
||||
```
|
||||
|
||||
## Display Format
|
||||
|
||||
### Before:
|
||||
```
|
||||
收货地址: 顶顶顶
|
||||
```
|
||||
|
||||
### After:
|
||||
```
|
||||
收货地址: 北京市 北京市 丰台区 顶顶顶
|
||||
```
|
||||
|
||||
## Logic
|
||||
|
||||
The `detailFullAddress` computed property:
|
||||
1. Checks if `detailData` exists
|
||||
2. Collects non-empty address components in order:
|
||||
- `shipping_province` (省)
|
||||
- `shipping_city` (市)
|
||||
- `shipping_district` (区/县)
|
||||
- `shipping_address` (详细地址)
|
||||
3. Joins them with spaces
|
||||
4. Returns '—' if no address components exist
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Address:
|
||||
```
|
||||
Input:
|
||||
shipping_province: "四川省"
|
||||
shipping_city: "成都市"
|
||||
shipping_district: "武侯区"
|
||||
shipping_address: "天府大道中段666号"
|
||||
|
||||
Output:
|
||||
四川省 成都市 武侯区 天府大道中段666号
|
||||
```
|
||||
|
||||
### Partial Address (Missing Province):
|
||||
```
|
||||
Input:
|
||||
shipping_province: null
|
||||
shipping_city: "成都市"
|
||||
shipping_district: "武侯区"
|
||||
shipping_address: "天府大道中段666号"
|
||||
|
||||
Output:
|
||||
成都市 武侯区 天府大道中段666号
|
||||
```
|
||||
|
||||
### Only Detailed Address:
|
||||
```
|
||||
Input:
|
||||
shipping_province: null
|
||||
shipping_city: null
|
||||
shipping_district: null
|
||||
shipping_address: "天府大道中段666号"
|
||||
|
||||
Output:
|
||||
天府大道中段666号
|
||||
```
|
||||
|
||||
### No Address:
|
||||
```
|
||||
Input:
|
||||
shipping_province: null
|
||||
shipping_city: null
|
||||
shipping_district: null
|
||||
shipping_address: null
|
||||
|
||||
Output:
|
||||
—
|
||||
```
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Complete Address Display
|
||||
1. Open an order that has province/city/district data
|
||||
2. Check the "收货地址" field in the detail view
|
||||
3. Verify it shows: `省 市 区 详细地址`
|
||||
|
||||
### 2. Test Legacy Orders (No Region Data)
|
||||
1. Open an old order that only has `shipping_address`
|
||||
2. Verify it still displays the detailed address correctly
|
||||
3. Should show just the detailed address without extra spaces
|
||||
|
||||
### 3. Test Empty Address
|
||||
1. Create an order without any address information
|
||||
2. Verify it displays "—" instead of empty string
|
||||
|
||||
### 4. Test Different Address Combinations
|
||||
Test various combinations:
|
||||
- Full address (province + city + district + detail)
|
||||
- City + district + detail (no province)
|
||||
- District + detail (no province/city)
|
||||
- Detail only (no region data)
|
||||
- Empty (no data at all)
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Added `detailFullAddress` computed property
|
||||
- Updated shipping address display template
|
||||
|
||||
### Backend:
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
- Already returns `shipping_province`, `shipping_city`, `shipping_district` in detail response
|
||||
|
||||
### Database:
|
||||
- `tcm_prescription_order` table
|
||||
- Columns: `shipping_province`, `shipping_city`, `shipping_district`, `shipping_address`
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Complete Information**: Users can now see the full address including province/city/district
|
||||
✅ **Better Readability**: Address components are clearly separated with spaces
|
||||
✅ **Backward Compatible**: Works with legacy orders that only have detailed address
|
||||
✅ **Graceful Handling**: Shows "—" when no address data exists
|
||||
✅ **Flexible**: Handles partial address data (missing province, city, or district)
|
||||
|
||||
## Summary
|
||||
|
||||
The order detail view now displays the complete shipping address including province, city, district, and detailed address, providing users with full address information at a glance!
|
||||
@@ -1,191 +0,0 @@
|
||||
# Order List Region Selector Update (订单列表省市区选择器更新)
|
||||
|
||||
## Changes Made
|
||||
|
||||
Updated the order list view (`order_list.vue`) to use the same API-based region data loading as the prescription index view.
|
||||
|
||||
### 1. Replaced Static Region Data with API Loading
|
||||
**File**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### Before:
|
||||
- Static hardcoded region data with limited provinces/cities
|
||||
- Only included: 四川省, 北京市, 上海市, 广东省
|
||||
|
||||
#### After:
|
||||
- Dynamic API-based region data loading
|
||||
- Fetches complete China region data from `/api-proxy/area/ChinaCitys.json`
|
||||
- Includes all provinces, cities, and districts
|
||||
- Falls back to basic data if API fails
|
||||
|
||||
### 2. Added Data Transformation Function
|
||||
Added `transformRegionData()` function to convert API format to el-cascader format:
|
||||
- API format: `{province, citys: [{city, areas: [{area}]}]}`
|
||||
- Cascader format: `{value, label, children: [{value, label, children: [{value, label}]}]}`
|
||||
|
||||
### 3. Updated Cascader Component
|
||||
Added missing props to the cascader:
|
||||
- `emitPath: true` - Returns full path array instead of just last value
|
||||
- `filterable` - Enables search functionality
|
||||
|
||||
### 4. Updated onMounted Hook
|
||||
Changed from:
|
||||
```typescript
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
})
|
||||
```
|
||||
|
||||
To:
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
await loadRegionData()
|
||||
getLists()
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Added Region Fields to Quick Edit
|
||||
Updated the quick tracking number edit function to include region fields:
|
||||
```typescript
|
||||
shipping_province: d.shipping_province || '',
|
||||
shipping_city: d.shipping_city || '',
|
||||
shipping_district: d.shipping_district || '',
|
||||
```
|
||||
|
||||
This ensures region data is preserved when only updating tracking numbers.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Loading Region Data:
|
||||
1. Component mounts
|
||||
2. `loadRegionData()` fetches from `/api-proxy/area/ChinaCitys.json`
|
||||
3. `transformRegionData()` converts API format to cascader format
|
||||
4. `regionOptions` is populated with all China regions
|
||||
5. If API fails, falls back to basic region data
|
||||
|
||||
### Editing Order with Region:
|
||||
1. User opens edit dialog
|
||||
2. Region data is loaded from order: `[province, city, district]`
|
||||
3. Cascader displays the selected region
|
||||
4. User can change region selection
|
||||
5. On save, region array is split into three fields:
|
||||
- `shipping_province`
|
||||
- `shipping_city`
|
||||
- `shipping_district`
|
||||
6. Backend saves the three separate fields
|
||||
|
||||
### Quick Edit (Tracking Number):
|
||||
1. User clicks quick edit for tracking number
|
||||
2. System fetches full order details
|
||||
3. Includes region fields in the update payload
|
||||
4. Region data is preserved while updating tracking number
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Region Data Loading:
|
||||
```typescript
|
||||
const regionOptions = ref([])
|
||||
|
||||
const transformRegionData = (data: any[]) => {
|
||||
// Converts API format to cascader format
|
||||
}
|
||||
|
||||
const loadRegionData = async () => {
|
||||
// Fetches from API and transforms data
|
||||
// Falls back to basic data on error
|
||||
}
|
||||
```
|
||||
|
||||
### Region Field Handling in Edit:
|
||||
```typescript
|
||||
// Loading data into form:
|
||||
const province = d.shipping_province || ''
|
||||
const city = d.shipping_city || ''
|
||||
const district = d.shipping_district || ''
|
||||
if (province || city || district) {
|
||||
editForm.region = [province, city, district].filter(v => v !== '')
|
||||
}
|
||||
|
||||
// Saving data from form:
|
||||
if (editForm.region && editForm.region.length > 0) {
|
||||
payload.shipping_province = editForm.region[0] || ''
|
||||
payload.shipping_city = editForm.region[1] || ''
|
||||
payload.shipping_district = editForm.region[2] || ''
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Region Data Loading
|
||||
1. Open order list page
|
||||
2. Check browser console for:
|
||||
```
|
||||
开始加载省市区数据...
|
||||
响应状态: 200
|
||||
加载的数据条数: XX
|
||||
regionOptions 已设置,条数: XX
|
||||
```
|
||||
3. If you see errors, check that dev server was restarted
|
||||
|
||||
### 2. Test Edit Order with Region
|
||||
1. Click "编辑" on any order
|
||||
2. Check that the region cascader shows the current region (if set)
|
||||
3. Change the region selection
|
||||
4. Save the order
|
||||
5. Verify database is updated:
|
||||
```sql
|
||||
SELECT shipping_province, shipping_city, shipping_district
|
||||
FROM tcm_prescription_order
|
||||
WHERE id = XX;
|
||||
```
|
||||
|
||||
### 3. Test Region Search
|
||||
1. Open edit dialog
|
||||
2. Click on region cascader
|
||||
3. Type a province/city name in the search box
|
||||
4. Verify search results appear
|
||||
5. Select a region from search results
|
||||
|
||||
### 4. Test Quick Edit Preserves Region
|
||||
1. Find an order with region data set
|
||||
2. Click "快递单号" quick edit button
|
||||
3. Update tracking number
|
||||
4. Save
|
||||
5. Verify region data is still present in database
|
||||
|
||||
### 5. Test Fallback Data
|
||||
1. Stop the dev server
|
||||
2. Open order list page
|
||||
3. Verify fallback region data is used (四川省, 北京市)
|
||||
4. Restart dev server
|
||||
5. Refresh page
|
||||
6. Verify full region data is loaded
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Added `loadRegionData()` and `transformRegionData()` functions
|
||||
- Updated `onMounted()` to load region data
|
||||
- Updated cascader component with `emitPath: true` and `filterable`
|
||||
- Added region fields to quick edit function
|
||||
- Region field handling already existed in main edit function
|
||||
|
||||
### Backend:
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
- Already handles `shipping_province`, `shipping_city`, `shipping_district` in both `create()` and `edit()` methods
|
||||
|
||||
### Configuration:
|
||||
- `admin/vite.config.ts`
|
||||
- Proxy configuration for `/api-proxy` already exists
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Replaced static region data with API-based loading
|
||||
✅ Added data transformation function
|
||||
✅ Updated cascader with `emitPath: true` and `filterable`
|
||||
✅ Updated `onMounted()` to load region data
|
||||
✅ Added region fields to quick edit function
|
||||
✅ Fallback data available if API fails
|
||||
✅ Search functionality enabled
|
||||
|
||||
The order list now uses the same comprehensive region data as the prescription index, providing access to all provinces, cities, and districts in China!
|
||||
@@ -1,299 +0,0 @@
|
||||
# Order Status Tabs UI (订单状态标签页UI)
|
||||
|
||||
## Feature
|
||||
Redesigned the fulfillment status filter from a dropdown select to clickable tabs for better user experience and faster filtering.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. Replaced Dropdown with Tab UI
|
||||
**Before:**
|
||||
```vue
|
||||
<el-form-item class="w-[180px]" label="履约状态">
|
||||
<el-select v-model="queryParams.fulfillment_status" placeholder="全部" clearable>
|
||||
<el-option label="待双审通过" :value="1" />
|
||||
<el-option label="履约中" :value="2" />
|
||||
<el-option label="已发货" :value="5" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已取消" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<!-- 状态标签页 -->
|
||||
<el-card class="!border-none shadow-sm mb-4" shadow="never">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div
|
||||
v-for="tab in statusTabs"
|
||||
:key="tab.value"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg cursor-pointer transition-all duration-200 select-none',
|
||||
queryParams.fulfillment_status === tab.value
|
||||
? 'bg-primary text-white shadow-md'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
]"
|
||||
@click="handleStatusTabClick(tab.value)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ tab.label }}</span>
|
||||
<span v-if="tab.count !== undefined" :class="[
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
queryParams.fulfillment_status === tab.value
|
||||
? 'bg-white/20'
|
||||
: 'bg-gray-200'
|
||||
]">
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
```
|
||||
|
||||
#### 2. Added Status Tabs Configuration
|
||||
```typescript
|
||||
// 状态标签页配置
|
||||
const statusTabs = ref([
|
||||
{ label: '全部', value: '' as number | '', count: undefined },
|
||||
{ label: '待双审通过', value: 1, count: undefined },
|
||||
{ label: '履约中', value: 2, count: undefined },
|
||||
{ label: '已发货', value: 5, count: undefined },
|
||||
{ label: '已完成', value: 3, count: undefined },
|
||||
{ label: '已取消', value: 4, count: undefined }
|
||||
])
|
||||
```
|
||||
|
||||
#### 3. Added Click Handler
|
||||
```typescript
|
||||
// 处理状态标签点击
|
||||
const handleStatusTabClick = (value: number | '') => {
|
||||
queryParams.fulfillment_status = value
|
||||
resetPage()
|
||||
}
|
||||
```
|
||||
|
||||
## UI Design
|
||||
|
||||
### Visual States
|
||||
|
||||
**Active Tab:**
|
||||
- Primary color background (`bg-primary`)
|
||||
- White text
|
||||
- Shadow effect
|
||||
- Badge with semi-transparent white background
|
||||
|
||||
**Inactive Tab:**
|
||||
- Light gray background (`bg-gray-50`)
|
||||
- Gray text
|
||||
- Hover effect (lighter gray)
|
||||
- Badge with gray background
|
||||
|
||||
### Layout
|
||||
- Horizontal flex layout with gap
|
||||
- Wraps on smaller screens
|
||||
- Smooth transitions (200ms)
|
||||
- Rounded corners
|
||||
- Consistent padding
|
||||
|
||||
### Badge (Count Display)
|
||||
- Small text size
|
||||
- Rounded badge
|
||||
- Shows count for each status (optional feature)
|
||||
- Different styling for active/inactive states
|
||||
|
||||
## User Experience Improvements
|
||||
|
||||
### Before (Dropdown):
|
||||
1. User clicks dropdown
|
||||
2. Dropdown opens with options
|
||||
3. User scrolls to find status
|
||||
4. User clicks option
|
||||
5. Dropdown closes
|
||||
6. User clicks "查询" button
|
||||
|
||||
**Total: 3-4 clicks + scrolling**
|
||||
|
||||
### After (Tabs):
|
||||
1. User clicks tab
|
||||
2. Filter applies immediately
|
||||
|
||||
**Total: 1 click**
|
||||
|
||||
### Benefits:
|
||||
- ✅ **Faster filtering**: One-click access to any status
|
||||
- ✅ **Visual feedback**: Clear indication of current filter
|
||||
- ✅ **Better discoverability**: All options visible at once
|
||||
- ✅ **No extra clicks**: Auto-triggers search on click
|
||||
- ✅ **Modern UI**: Follows common tab pattern
|
||||
- ✅ **Mobile friendly**: Wraps on small screens
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Active State Indication
|
||||
The currently selected tab is highlighted with primary color, making it immediately clear which filter is active.
|
||||
|
||||
### 2. Hover Effects
|
||||
Inactive tabs show a subtle hover effect, indicating they are clickable.
|
||||
|
||||
### 3. Count Badges (Optional)
|
||||
Each tab can display a count badge showing the number of orders in that status. Currently set to `undefined` but can be populated with actual counts.
|
||||
|
||||
### 4. Responsive Design
|
||||
Tabs wrap to multiple lines on smaller screens using `flex-wrap`.
|
||||
|
||||
### 5. Smooth Transitions
|
||||
All state changes have smooth 200ms transitions for a polished feel.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Tab Configuration
|
||||
```typescript
|
||||
{
|
||||
label: string, // Display text
|
||||
value: number | '', // Filter value ('' for "All")
|
||||
count: number | undefined // Optional count badge
|
||||
}
|
||||
```
|
||||
|
||||
### Click Handler Logic
|
||||
```typescript
|
||||
const handleStatusTabClick = (value: number | '') => {
|
||||
queryParams.fulfillment_status = value // Update filter
|
||||
resetPage() // Trigger search
|
||||
}
|
||||
```
|
||||
|
||||
### Styling Classes
|
||||
- `bg-primary`: Primary brand color (active state)
|
||||
- `bg-gray-50`: Light gray (inactive state)
|
||||
- `hover:bg-gray-100`: Hover effect
|
||||
- `shadow-md`: Shadow for active tab
|
||||
- `transition-all duration-200`: Smooth transitions
|
||||
- `select-none`: Prevent text selection on click
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Add Count Badges
|
||||
Fetch and display order counts for each status:
|
||||
|
||||
```typescript
|
||||
// After fetching data
|
||||
const updateStatusCounts = (data: any) => {
|
||||
statusTabs.value[0].count = data.total
|
||||
statusTabs.value[1].count = data.pending
|
||||
statusTabs.value[2].count = data.processing
|
||||
// ... etc
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Icons
|
||||
Add status-specific icons for better visual recognition:
|
||||
|
||||
```vue
|
||||
<svg class="w-4 h-4" v-if="tab.value === 1">
|
||||
<!-- Clock icon for pending -->
|
||||
</svg>
|
||||
```
|
||||
|
||||
### 3. Add Keyboard Navigation
|
||||
Support arrow keys for tab navigation:
|
||||
|
||||
```typescript
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
// Navigate to previous tab
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Navigate to next tab
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Loading State
|
||||
Show loading indicator on active tab while fetching:
|
||||
|
||||
```vue
|
||||
<el-icon v-if="pager.loading && queryParams.fulfillment_status === tab.value" class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
```
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Tab Switching
|
||||
1. Open order list page
|
||||
2. Click on different status tabs
|
||||
3. Verify:
|
||||
- Active tab is highlighted
|
||||
- Table updates with filtered results
|
||||
- URL parameters update (if using router)
|
||||
|
||||
### 2. Test Visual States
|
||||
1. Hover over inactive tabs
|
||||
2. Verify hover effect appears
|
||||
3. Click a tab
|
||||
4. Verify active state styling applies
|
||||
|
||||
### 3. Test "全部" Tab
|
||||
1. Click "全部" tab
|
||||
2. Verify all orders are shown
|
||||
3. Verify no status filter is applied
|
||||
|
||||
### 4. Test Responsive Behavior
|
||||
1. Resize browser window to small width
|
||||
2. Verify tabs wrap to multiple lines
|
||||
3. Verify all tabs remain accessible
|
||||
|
||||
### 5. Test with Search
|
||||
1. Enter order number in search
|
||||
2. Click different status tabs
|
||||
3. Verify both filters work together
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Current Implementation:
|
||||
- ✅ Keyboard accessible (clickable divs)
|
||||
- ✅ Visual feedback on hover
|
||||
- ✅ Clear active state indication
|
||||
- ✅ Sufficient color contrast
|
||||
|
||||
### Recommended Improvements:
|
||||
- Add `role="tab"` and `aria-selected` attributes
|
||||
- Add `tabindex="0"` for keyboard navigation
|
||||
- Add keyboard event handlers (Enter/Space)
|
||||
- Add `aria-label` for screen readers
|
||||
|
||||
### Example:
|
||||
```vue
|
||||
<div
|
||||
role="tab"
|
||||
:aria-selected="queryParams.fulfillment_status === tab.value"
|
||||
:tabindex="0"
|
||||
@keydown.enter="handleStatusTabClick(tab.value)"
|
||||
@keydown.space.prevent="handleStatusTabClick(tab.value)"
|
||||
>
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Added status tabs UI
|
||||
- Added `statusTabs` configuration
|
||||
- Added `handleStatusTabClick` handler
|
||||
- Removed dropdown select
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Replaced dropdown with clickable tabs
|
||||
✅ One-click filtering (no need to click "查询")
|
||||
✅ Visual active state indication
|
||||
✅ Hover effects for better UX
|
||||
✅ Responsive design with flex-wrap
|
||||
✅ Smooth transitions
|
||||
✅ Support for count badges (optional)
|
||||
✅ Cleaner, more modern UI
|
||||
|
||||
The status filter is now much more intuitive and efficient, following modern UI patterns commonly seen in admin dashboards!
|
||||
@@ -1,308 +0,0 @@
|
||||
# 订单管理员角色配置修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
编辑业务订单时,关联支付单失败,提示"无权限关联所选支付单"。
|
||||
|
||||
**错误信息**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"show": 1,
|
||||
"msg": "无权限关联所选支付单",
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
**接口**: `/adminapi/tcm.prescriptionOrder/edit`
|
||||
|
||||
## 问题原因
|
||||
|
||||
`OrderLogic::isOrderListSupervisor()` 方法中硬编码了管理员角色 `[0, 3]`,没有读取配置文件中的 `order_edit_all_roles` 配置。
|
||||
|
||||
### 配置文件
|
||||
|
||||
**文件**: `server/config/project.php`
|
||||
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3, 6], // 0=超级管理员, 3=管理员, 6=药师
|
||||
```
|
||||
|
||||
### 代码问题
|
||||
|
||||
**文件**: `server/app/adminapi/logic/order/OrderLogic.php`
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$supervisorRoles = [0, 3]; // 硬编码,没有读取配置
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 硬编码了 `[0, 3]`,即使配置文件中设置了 `[0, 3, 6]`,角色 6(药师)也无法获得管理员权限
|
||||
- 导致药师无法关联其他人创建的支付单
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改 `isOrderListSupervisor()` 方法,从配置文件读取管理员角色列表。
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$supervisorRoles = config('project.order_edit_all_roles', [0, 3]); // 从配置读取
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
|
||||
}
|
||||
```
|
||||
|
||||
**改进点**:
|
||||
- 从配置文件读取 `order_edit_all_roles`
|
||||
- 支持动态配置管理员角色
|
||||
- 默认值为 `[0, 3]`,保持向后兼容
|
||||
|
||||
## 权限规则
|
||||
|
||||
### 支付单关联权限
|
||||
|
||||
在关联支付单到业务订单时,需要满足以下条件之一:
|
||||
|
||||
1. **超级管理员** (`root = 1`)
|
||||
- 可以关联所有支付单
|
||||
|
||||
2. **管理员角色** (`order_edit_all_roles` 配置的角色)
|
||||
- 默认:`[0, 3]`(超级管理员、管理员)
|
||||
- 可配置:`[0, 3, 6]`(超级管理员、管理员、药师)
|
||||
- 可以关联所有支付单
|
||||
|
||||
3. **诊单医助** (`assistant_id` 匹配)
|
||||
- 可以关联该诊单的所有支付单
|
||||
|
||||
4. **支付单创建人** (`creator_id` 匹配)
|
||||
- 只能关联自己创建的支付单
|
||||
|
||||
### 权限检查逻辑
|
||||
|
||||
```php
|
||||
public static function assertPayOrdersLinkable(array $ids, int $diagnosisId, int $adminId, array $adminInfo): ?string
|
||||
{
|
||||
// ... 其他检查 ...
|
||||
|
||||
$supervisor = self::isOrderListSupervisor($adminId, $adminInfo);
|
||||
$assistantId = (int) Diagnosis::where('id', $diagnosisId)->value('assistant_id');
|
||||
$isDiagAssistant = $assistantId === $adminId;
|
||||
|
||||
foreach ($orders as $o) {
|
||||
// 检查权限
|
||||
if (! $supervisor && ! $isDiagAssistant && (int) $o->creator_id !== $adminId) {
|
||||
return '无权限关联所选支付单';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 配置文件位置
|
||||
|
||||
`server/config/project.php`
|
||||
|
||||
### 配置项
|
||||
|
||||
```php
|
||||
return [
|
||||
// 订单管理全部权限角色(可以查看和编辑所有订单)
|
||||
'order_edit_all_roles' => [0, 3, 6],
|
||||
|
||||
// 其他相关配置
|
||||
'prescription_library_manage_all_roles' => [0, 3], // 处方库管理全部权限角色
|
||||
'prescription_audit_roles' => [0, 3, 6], // 处方审核权限角色
|
||||
'prescription_order_finance_roles' => [0, 3], // 处方订单财务权限角色
|
||||
'prescription_order_payment_audit_roles' => [0, 3], // 处方订单支付审核权限角色
|
||||
];
|
||||
```
|
||||
|
||||
### 角色ID说明
|
||||
|
||||
| 角色ID | 角色名称 | 说明 |
|
||||
|-------|---------|------|
|
||||
| 0 | 超级管理员 | 拥有所有权限 |
|
||||
| 1 | 医生 | 开方、诊断等医疗权限 |
|
||||
| 2 | 医助 | 协助医生工作 |
|
||||
| 3 | 管理员 | 管理订单、审核等权限 |
|
||||
| 6 | 药师 | 审核处方、管理药品等权限 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景1:药师关联支付单
|
||||
|
||||
**配置前**:
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3], // 药师(角色6)无权限
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 药师无法关联其他人创建的支付单
|
||||
- 提示"无权限关联所选支付单"
|
||||
|
||||
**配置后**:
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3, 6], // 药师(角色6)有权限
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 药师可以关联所有支付单
|
||||
- 可以正常编辑业务订单
|
||||
|
||||
### 场景2:医生关联支付单
|
||||
|
||||
**情况1:医生是支付单创建人**
|
||||
- 可以关联自己创建的支付单
|
||||
- 不需要管理员权限
|
||||
|
||||
**情况2:医生不是支付单创建人**
|
||||
- 如果医生是诊单医助,可以关联该诊单的所有支付单
|
||||
- 如果医生不是诊单医助,无法关联其他人创建的支付单
|
||||
|
||||
**情况3:医生是管理员角色**
|
||||
- 如果医生同时拥有管理员角色(如角色3),可以关联所有支付单
|
||||
|
||||
### 场景3:管理员关联支付单
|
||||
|
||||
**配置**:
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3, 6],
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 超级管理员(角色0):可以关联所有支付单
|
||||
- 管理员(角色3):可以关联所有支付单
|
||||
- 药师(角色6):可以关联所有支付单
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 测试用例 1: 药师关联支付单
|
||||
|
||||
**前提条件**:
|
||||
- 配置 `order_edit_all_roles` 包含角色 6
|
||||
- 用户角色为药师(角色6)
|
||||
|
||||
**测试步骤**:
|
||||
1. 创建一个业务订单
|
||||
2. 选择其他人创建的支付单
|
||||
3. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存成功
|
||||
- 支付单成功关联到业务订单
|
||||
|
||||
### 测试用例 2: 医生关联自己的支付单
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为医生(角色1)
|
||||
- 支付单由该医生创建
|
||||
|
||||
**测试步骤**:
|
||||
1. 创建一个业务订单
|
||||
2. 选择自己创建的支付单
|
||||
3. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存成功
|
||||
- 支付单成功关联到业务订单
|
||||
|
||||
### 测试用例 3: 医生关联其他人的支付单(无权限)
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为医生(角色1)
|
||||
- 支付单由其他人创建
|
||||
- 医生不是诊单医助
|
||||
|
||||
**测试步骤**:
|
||||
1. 创建一个业务订单
|
||||
2. 选择其他人创建的支付单
|
||||
3. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存失败
|
||||
- 提示"无权限关联所选支付单"
|
||||
|
||||
### 测试用例 4: 诊单医助关联支付单
|
||||
|
||||
**前提条件**:
|
||||
- 用户是该诊单的医助
|
||||
- 支付单由其他人创建
|
||||
|
||||
**测试步骤**:
|
||||
1. 创建一个业务订单
|
||||
2. 选择该诊单的支付单(其他人创建)
|
||||
3. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存成功
|
||||
- 支付单成功关联到业务订单
|
||||
|
||||
## 相关配置
|
||||
|
||||
### 其他权限配置
|
||||
|
||||
在 `server/config/project.php` 中,还有其他相关的权限配置:
|
||||
|
||||
```php
|
||||
return [
|
||||
// 订单管理全部权限角色
|
||||
'order_edit_all_roles' => [0, 3, 6],
|
||||
|
||||
// 处方库管理全部权限角色(可以查看所有处方)
|
||||
'prescription_library_manage_all_roles' => [0, 3],
|
||||
|
||||
// 处方审核权限角色
|
||||
'prescription_audit_roles' => [0, 3, 6],
|
||||
|
||||
// 处方订单财务权限角色(可以查看内部成本)
|
||||
'prescription_order_finance_roles' => [0, 3],
|
||||
|
||||
// 处方订单支付审核权限角色
|
||||
'prescription_order_payment_audit_roles' => [0, 3],
|
||||
];
|
||||
```
|
||||
|
||||
### 配置建议
|
||||
|
||||
根据实际业务需求配置角色权限:
|
||||
|
||||
**推荐配置**:
|
||||
```php
|
||||
return [
|
||||
'order_edit_all_roles' => [0, 3, 6], // 管理员和药师可以管理所有订单
|
||||
'prescription_library_manage_all_roles' => [0, 3], // 管理员可以查看所有处方
|
||||
'prescription_audit_roles' => [0, 3, 6], // 管理员和药师可以审核处方
|
||||
'prescription_order_finance_roles' => [0, 3], // 管理员可以查看财务数据
|
||||
'prescription_order_payment_audit_roles' => [0, 3], // 管理员可以审核支付单
|
||||
];
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过修改 `isOrderListSupervisor()` 方法,确保了:
|
||||
- ✅ 从配置文件读取管理员角色列表
|
||||
- ✅ 支持动态配置管理员角色
|
||||
- ✅ 药师(角色6)可以关联所有支付单
|
||||
- ✅ 保持向后兼容(默认值为 `[0, 3]`)
|
||||
- ✅ 统一权限管理,避免硬编码
|
||||
|
||||
现在,只需要在配置文件中添加角色ID,就可以赋予该角色管理员权限,无需修改代码。
|
||||
@@ -1,84 +0,0 @@
|
||||
# 支付单审核权限修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
接口 `/adminapi/tcm.prescriptionOrder/auditPayment` 返回错误:
|
||||
```json
|
||||
{"code":0,"show":1,"msg":"无支付单审核权限","data":[]}
|
||||
```
|
||||
|
||||
药师角色(角色ID=6)无法审核支付单。
|
||||
|
||||
## 问题原因
|
||||
|
||||
配置文件 `server/config/project.php` 中的 `prescription_order_payment_audit_roles` 已经包含角色 6,但由于 ThinkPHP 的配置缓存机制,旧的配置仍在使用。
|
||||
|
||||
## 解决方案
|
||||
|
||||
清除 ThinkPHP 缓存,使新配置生效:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
```
|
||||
|
||||
## 权限配置确认
|
||||
|
||||
文件:`server/config/project.php`
|
||||
|
||||
```php
|
||||
// 业务订单「关联支付单」审核:以下角色 ID + root 可操作(可与财务角色一致)
|
||||
'prescription_order_payment_audit_roles' => [0, 3, 6],
|
||||
```
|
||||
|
||||
- 角色 0:超级管理员
|
||||
- 角色 3:管理员
|
||||
- 角色 6:药师
|
||||
|
||||
## 权限检查逻辑
|
||||
|
||||
文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
```php
|
||||
public static function canAuditPaymentSlipOrder(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$allow = Config::get('project.prescription_order_payment_audit_roles', [0, 3]);
|
||||
|
||||
return self::roleIntersect($adminInfo, is_array($allow) ? $allow : []);
|
||||
}
|
||||
```
|
||||
|
||||
该方法从配置文件读取允许的角色列表,并检查当前用户的角色是否在列表中。
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. 清除缓存后,药师角色(角色ID=6)应该能够:
|
||||
- 调用 `/adminapi/tcm.prescriptionOrder/auditPayment` 接口
|
||||
- 审核通过或驳回支付单
|
||||
|
||||
2. 验证步骤:
|
||||
- 使用药师账号登录
|
||||
- 打开业务订单详情
|
||||
- 点击"支付单审核"按钮
|
||||
- 确认可以正常审核
|
||||
|
||||
## 相关配置总结
|
||||
|
||||
药师角色(角色ID=6)的完整权限配置:
|
||||
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3, 6], // 订单管理全部权限
|
||||
'prescription_library_manage_all_roles' => [0, 3], // 处方库管理全部权限
|
||||
'prescription_audit_roles' => [0, 3, 6], // 消费者处方审核权限
|
||||
'prescription_order_finance_roles' => [0, 3], // 财务字段查看权限
|
||||
'prescription_order_payment_audit_roles' => [0, 3, 6], // 支付单审核权限
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 每次修改 `server/config/project.php` 后,都需要清除缓存
|
||||
2. 生产环境部署时,确保运行 `php think clear` 命令
|
||||
3. 如果问题仍然存在,检查用户的角色ID是否正确分配
|
||||
@@ -1,358 +0,0 @@
|
||||
# 药师角色权限配置
|
||||
|
||||
## 概述
|
||||
|
||||
为药师角色(角色ID = 6)配置完整的权限,使其能够:
|
||||
- 审核处方
|
||||
- 审核支付单
|
||||
- 管理订单
|
||||
- 关联支付单
|
||||
|
||||
## 配置文件
|
||||
|
||||
**文件**: `server/config/project.php`
|
||||
|
||||
### 完整配置
|
||||
|
||||
```php
|
||||
return [
|
||||
// 订单管理全部权限角色
|
||||
'order_edit_all_roles' => [0, 3, 6],
|
||||
|
||||
// 处方库管理全部权限角色
|
||||
'prescription_library_manage_all_roles' => [0, 3],
|
||||
|
||||
// 消费者处方审核权限角色
|
||||
'prescription_audit_roles' => [0, 3, 6],
|
||||
|
||||
// 处方业务订单财务字段查看权限角色
|
||||
'prescription_order_finance_roles' => [0, 3],
|
||||
|
||||
// 业务订单支付单审核权限角色
|
||||
'prescription_order_payment_audit_roles' => [0, 3, 6],
|
||||
];
|
||||
```
|
||||
|
||||
## 权限说明
|
||||
|
||||
### 1. 订单管理权限 (`order_edit_all_roles`)
|
||||
|
||||
**配置**: `[0, 3, 6]`
|
||||
|
||||
**权限内容**:
|
||||
- 查看所有订单(不限制创建人)
|
||||
- 编辑所有订单
|
||||
- 关联所有支付单(不限制创建人)
|
||||
|
||||
**适用场景**:
|
||||
- 编辑业务订单
|
||||
- 关联支付单到业务订单
|
||||
- 查看订单列表
|
||||
|
||||
**角色**:
|
||||
- 0: 超级管理员
|
||||
- 3: 管理员
|
||||
- 6: 药师 ✅
|
||||
|
||||
### 2. 处方库管理权限 (`prescription_library_manage_all_roles`)
|
||||
|
||||
**配置**: `[0, 3]`
|
||||
|
||||
**权限内容**:
|
||||
- 查看所有处方库模板
|
||||
- 编辑所有处方库模板
|
||||
- 删除所有处方库模板
|
||||
|
||||
**适用场景**:
|
||||
- 管理处方库
|
||||
- 查看处方模板
|
||||
|
||||
**角色**:
|
||||
- 0: 超级管理员
|
||||
- 3: 管理员
|
||||
|
||||
**说明**: 药师不需要此权限,因为处方库主要由医生和管理员管理
|
||||
|
||||
### 3. 消费者处方审核权限 (`prescription_audit_roles`)
|
||||
|
||||
**配置**: `[0, 3, 6]`
|
||||
|
||||
**权限内容**:
|
||||
- 审核待审核的处方
|
||||
- 通过或驳回处方
|
||||
- 驳回时会同时作废处方
|
||||
|
||||
**适用场景**:
|
||||
- 消费者处方列表中的"审核"按钮
|
||||
- 处方审核流程
|
||||
|
||||
**角色**:
|
||||
- 0: 超级管理员
|
||||
- 3: 管理员
|
||||
- 6: 药师 ✅
|
||||
|
||||
### 4. 财务字段查看权限 (`prescription_order_finance_roles`)
|
||||
|
||||
**配置**: `[0, 3]`
|
||||
|
||||
**权限内容**:
|
||||
- 查看业务订单的 `internal_cost`(内部成本)字段
|
||||
- 查看其他财务相关字段
|
||||
|
||||
**适用场景**:
|
||||
- 业务订单详情
|
||||
- 业务订单列表
|
||||
|
||||
**角色**:
|
||||
- 0: 超级管理员
|
||||
- 3: 管理员
|
||||
|
||||
**说明**: 药师不需要此权限,财务数据仅管理员可见
|
||||
|
||||
### 5. 支付单审核权限 (`prescription_order_payment_audit_roles`)
|
||||
|
||||
**配置**: `[0, 3, 6]`
|
||||
|
||||
**权限内容**:
|
||||
- 审核业务订单的关联支付单
|
||||
- 通过或驳回支付单审核
|
||||
|
||||
**适用场景**:
|
||||
- 业务订单详情中的"支付审核"按钮
|
||||
- 支付单审核流程
|
||||
|
||||
**角色**:
|
||||
- 0: 超级管理员
|
||||
- 3: 管理员
|
||||
- 6: 药师 ✅
|
||||
|
||||
## 角色权限矩阵
|
||||
|
||||
| 权限 | 超级管理员(0) | 医生(1) | 医助(2) | 管理员(3) | 药师(6) |
|
||||
|-----|-------------|---------|---------|----------|---------|
|
||||
| 订单管理全部权限 | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
| 处方库管理全部权限 | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| 消费者处方审核 | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
| 财务字段查看 | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| 支付单审核 | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
|
||||
## 修改记录
|
||||
|
||||
### 修改1: 订单管理权限
|
||||
|
||||
**文件**: `server/app/adminapi/logic/order/OrderLogic.php`
|
||||
|
||||
**问题**: 硬编码了管理员角色 `[0, 3]`,没有读取配置
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$supervisorRoles = [0, 3]; // 硬编码
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$supervisorRoles = config('project.order_edit_all_roles', [0, 3]); // 从配置读取
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 修改2: 支付单审核权限
|
||||
|
||||
**文件**: `server/config/project.php`
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
'prescription_order_payment_audit_roles' => [0, 3],
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
'prescription_order_payment_audit_roles' => [0, 3, 6],
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景1: 药师审核处方
|
||||
|
||||
**流程**:
|
||||
1. 医生创建处方(待审核状态)
|
||||
2. 药师登录系统
|
||||
3. 进入消费者处方列表
|
||||
4. 点击"审核"按钮
|
||||
5. 选择"通过"或"驳回"
|
||||
6. 填写审核意见
|
||||
7. 提交审核
|
||||
|
||||
**权限检查**:
|
||||
- `prescription_audit_roles` 包含角色 6 ✅
|
||||
- 药师可以审核处方
|
||||
|
||||
### 场景2: 药师审核支付单
|
||||
|
||||
**流程**:
|
||||
1. 管理员创建业务订单并关联支付单
|
||||
2. 药师登录系统
|
||||
3. 进入业务订单详情
|
||||
4. 点击"支付审核"按钮
|
||||
5. 选择"通过"或"驳回"
|
||||
6. 填写审核意见
|
||||
7. 提交审核
|
||||
|
||||
**权限检查**:
|
||||
- `prescription_order_payment_audit_roles` 包含角色 6 ✅
|
||||
- 药师可以审核支付单
|
||||
|
||||
### 场景3: 药师关联支付单
|
||||
|
||||
**流程**:
|
||||
1. 药师创建业务订单
|
||||
2. 选择支付单(其他人创建的)
|
||||
3. 点击保存
|
||||
|
||||
**权限检查**:
|
||||
- `order_edit_all_roles` 包含角色 6 ✅
|
||||
- 药师可以关联所有支付单
|
||||
|
||||
### 场景4: 药师编辑订单
|
||||
|
||||
**流程**:
|
||||
1. 药师查看业务订单列表
|
||||
2. 点击"编辑"按钮(其他人创建的订单)
|
||||
3. 修改订单信息
|
||||
4. 点击保存
|
||||
|
||||
**权限检查**:
|
||||
- `order_edit_all_roles` 包含角色 6 ✅
|
||||
- 药师可以编辑所有订单
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 测试用例 1: 药师审核处方
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为药师(角色6)
|
||||
- 存在待审核的处方
|
||||
|
||||
**测试步骤**:
|
||||
1. 登录药师账号
|
||||
2. 进入消费者处方列表
|
||||
3. 找到待审核的处方
|
||||
4. 点击"审核"按钮
|
||||
5. 选择"通过"
|
||||
6. 点击确定
|
||||
|
||||
**预期结果**:
|
||||
- 审核成功
|
||||
- 处方状态变为"已通过"
|
||||
|
||||
### 测试用例 2: 药师审核支付单
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为药师(角色6)
|
||||
- 存在待审核的业务订单
|
||||
|
||||
**测试步骤**:
|
||||
1. 登录药师账号
|
||||
2. 进入业务订单列表
|
||||
3. 点击"查看"按钮
|
||||
4. 点击"支付审核"按钮
|
||||
5. 选择"通过"
|
||||
6. 点击确定
|
||||
|
||||
**预期结果**:
|
||||
- 审核成功
|
||||
- 支付单审核状态变为"已通过"
|
||||
|
||||
### 测试用例 3: 药师关联支付单
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为药师(角色6)
|
||||
- 存在其他人创建的支付单
|
||||
|
||||
**测试步骤**:
|
||||
1. 登录药师账号
|
||||
2. 创建业务订单
|
||||
3. 选择其他人创建的支付单
|
||||
4. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存成功
|
||||
- 支付单成功关联到业务订单
|
||||
|
||||
### 测试用例 4: 药师编辑订单
|
||||
|
||||
**前提条件**:
|
||||
- 用户角色为药师(角色6)
|
||||
- 存在其他人创建的业务订单
|
||||
|
||||
**测试步骤**:
|
||||
1. 登录药师账号
|
||||
2. 进入业务订单列表
|
||||
3. 点击"编辑"按钮(其他人创建的订单)
|
||||
4. 修改订单信息
|
||||
5. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 保存成功
|
||||
- 订单信息更新
|
||||
|
||||
## 错误排查
|
||||
|
||||
### 错误1: "无权限关联所选支付单"
|
||||
|
||||
**原因**: `order_edit_all_roles` 配置中没有包含角色 6
|
||||
|
||||
**解决方案**:
|
||||
```php
|
||||
'order_edit_all_roles' => [0, 3, 6], // 添加角色 6
|
||||
```
|
||||
|
||||
### 错误2: "无支付单审核权限"
|
||||
|
||||
**原因**: `prescription_order_payment_audit_roles` 配置中没有包含角色 6
|
||||
|
||||
**解决方案**:
|
||||
```php
|
||||
'prescription_order_payment_audit_roles' => [0, 3, 6], // 添加角色 6
|
||||
```
|
||||
|
||||
### 错误3: "无处方审核权限"
|
||||
|
||||
**原因**: `prescription_audit_roles` 配置中没有包含角色 6
|
||||
|
||||
**解决方案**:
|
||||
```php
|
||||
'prescription_audit_roles' => [0, 3, 6], // 添加角色 6
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `ORDER_SUPERVISOR_ROLE_CONFIG_FIX.md` - 订单管理员角色配置修复文档
|
||||
- `server/config/project.php` - 项目配置文件
|
||||
|
||||
## 总结
|
||||
|
||||
通过配置文件,为药师角色(角色6)赋予了以下权限:
|
||||
- ✅ 订单管理全部权限(查看、编辑、关联支付单)
|
||||
- ✅ 消费者处方审核权限(通过、驳回)
|
||||
- ✅ 支付单审核权限(通过、驳回)
|
||||
- ❌ 处方库管理权限(不需要)
|
||||
- ❌ 财务字段查看权限(不需要)
|
||||
|
||||
药师现在可以完整地参与处方审核和订单管理流程。
|
||||
@@ -1,275 +0,0 @@
|
||||
# 药材名称拼音首字母功能说明
|
||||
|
||||
## 概述
|
||||
系统使用 `overtrue/pinyin` 包自动生成中文药材名称的拼音首字母缩写,存储在 `name_pinyin_abbr` 字段中,用于快速检索。
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 依赖包
|
||||
```json
|
||||
"overtrue/pinyin": "^6.0"
|
||||
```
|
||||
已在 `server/composer.json` 中配置。
|
||||
|
||||
### 2. 服务类:`MedicineNameAbbrService`
|
||||
**文件位置:** `server/app/common/service/doctor/MedicineNameAbbrService.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\common\service\doctor;
|
||||
|
||||
use Overtrue\Pinyin\Pinyin;
|
||||
|
||||
class MedicineNameAbbrService
|
||||
{
|
||||
public static function build(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
if (!class_exists(Pinyin::class)) {
|
||||
return '';
|
||||
}
|
||||
$abbr = (string) Pinyin::abbr($name)->join('');
|
||||
|
||||
return strtolower($abbr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**功能说明:**
|
||||
- 输入:中文药材名称(如 "当归")
|
||||
- 输出:拼音首字母小写连写(如 "dg")
|
||||
- 自动处理:去除空格、转小写
|
||||
- 容错:如果 Pinyin 类不存在,返回空字符串
|
||||
|
||||
### 3. 使用示例
|
||||
|
||||
#### 示例 1:当归
|
||||
```php
|
||||
MedicineNameAbbrService::build('当归');
|
||||
// 输出: "dg"
|
||||
```
|
||||
|
||||
#### 示例 2:黄芪
|
||||
```php
|
||||
MedicineNameAbbrService::build('黄芪');
|
||||
// 输出: "hq"
|
||||
```
|
||||
|
||||
#### 示例 3:党参
|
||||
```php
|
||||
MedicineNameAbbrService::build('党参');
|
||||
// 输出: "ds"
|
||||
```
|
||||
|
||||
#### 示例 4:白术
|
||||
```php
|
||||
MedicineNameAbbrService::build('白术');
|
||||
// 输出: "bs"
|
||||
```
|
||||
|
||||
## 自动生成时机
|
||||
|
||||
### 1. 创建药材时
|
||||
**文件:** `server/app/adminapi/logic/doctor/MedicineLogic.php`
|
||||
|
||||
```php
|
||||
Medicine::create([
|
||||
'name' => $params['name'],
|
||||
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
|
||||
// ... 其他字段
|
||||
]);
|
||||
```
|
||||
|
||||
### 2. 编辑药材时
|
||||
```php
|
||||
Medicine::update([
|
||||
'id' => $params['id'],
|
||||
'name' => $params['name'],
|
||||
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
|
||||
// ... 其他字段
|
||||
]);
|
||||
```
|
||||
|
||||
## 批量回填命令
|
||||
|
||||
### 命令:`sync_medicine_pinyin_abbr`
|
||||
**文件:** `server/app/common/command/SyncMedicinePinyinAbbr.php`
|
||||
|
||||
用于批量生成或更新已有药材的拼音首字母。
|
||||
|
||||
### 使用方法
|
||||
|
||||
#### 1. 仅更新空字段(默认)
|
||||
```bash
|
||||
cd server
|
||||
php think sync_medicine_pinyin_abbr
|
||||
```
|
||||
只处理 `name_pinyin_abbr` 为空的记录。
|
||||
|
||||
#### 2. 重算全部记录
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --all
|
||||
```
|
||||
重新计算所有药材的拼音首字母(覆盖现有值)。
|
||||
|
||||
#### 3. 预览模式(不写入数据库)
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --dry
|
||||
```
|
||||
只统计将更新多少条,不实际写入。
|
||||
|
||||
#### 4. 组合使用
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --all --dry
|
||||
```
|
||||
预览全部重算的结果。
|
||||
|
||||
### 命令输出示例
|
||||
```
|
||||
模式: 仅空 abbr,待处理 150 条
|
||||
完成: 已更新 145 条,跳过 5 条。
|
||||
```
|
||||
|
||||
## 检索功能
|
||||
|
||||
### 列表搜索
|
||||
**文件:** `server/app/adminapi/lists/doctor/MedicineLists.php`
|
||||
|
||||
```php
|
||||
$query->where(function ($q) use ($kw, $keyword) {
|
||||
$q->where('name_pinyin_abbr', 'like', '%' . $kw . '%')
|
||||
->whereOr('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
```
|
||||
|
||||
**功能说明:**
|
||||
- 用户输入 "dg" 可以搜索到 "当归"
|
||||
- 用户输入 "当归" 也可以搜索到
|
||||
- 支持模糊匹配(LIKE 查询)
|
||||
|
||||
### 搜索示例
|
||||
|
||||
| 用户输入 | 匹配药材 |
|
||||
|---------|---------|
|
||||
| dg | 当归 |
|
||||
| hq | 黄芪 |
|
||||
| ds | 党参、丹参 |
|
||||
| bs | 白术、白芍 |
|
||||
| 当归 | 当归 |
|
||||
|
||||
## 数据库字段
|
||||
|
||||
### 表:`zyt_doctor_medicine`
|
||||
```sql
|
||||
`name_pinyin_abbr` varchar(64) DEFAULT NULL COMMENT '名称拼音首字母缩写(小写连写)'
|
||||
```
|
||||
|
||||
### 索引建议
|
||||
如果药材数量很大(>10000条),建议添加索引:
|
||||
```sql
|
||||
ALTER TABLE `zyt_doctor_medicine`
|
||||
ADD INDEX `idx_name_pinyin_abbr` (`name_pinyin_abbr`);
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 如何安装 overtrue/pinyin?
|
||||
```bash
|
||||
cd server
|
||||
composer require overtrue/pinyin
|
||||
```
|
||||
(已在项目中安装,无需手动执行)
|
||||
|
||||
### Q2: 多音字如何处理?
|
||||
`overtrue/pinyin` 会自动选择最常用的读音。例如:
|
||||
- "重" → "z" (重量) 或 "c" (重复)
|
||||
- 默认使用最常见的读音
|
||||
|
||||
### Q3: 如何处理英文或数字?
|
||||
- 英文字母:保留原样并转小写
|
||||
- 数字:保留原样
|
||||
- 示例:`"维生素B12"` → `"wssbB12"` → `"wssbb12"`
|
||||
|
||||
### Q4: 为什么有些药材搜索不到?
|
||||
可能原因:
|
||||
1. `name_pinyin_abbr` 字段为空 → 运行批量回填命令
|
||||
2. 药材名称包含特殊字符 → 检查数据质量
|
||||
3. 索引未生效 → 检查数据库索引
|
||||
|
||||
### Q5: 如何在其他模块使用?
|
||||
```php
|
||||
use app\common\service\doctor\MedicineNameAbbrService;
|
||||
|
||||
// 生成拼音首字母
|
||||
$abbr = MedicineNameAbbrService::build('当归');
|
||||
echo $abbr; // 输出: dg
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 1. 批量处理
|
||||
命令使用 `chunk(200)` 分批处理,避免内存溢出:
|
||||
```php
|
||||
$makeQuery()->chunk(200, function ($rows) {
|
||||
// 每次处理 200 条
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 异步生成
|
||||
对于大量数据,建议:
|
||||
- 使用队列异步处理
|
||||
- 在低峰期运行批量命令
|
||||
- 使用 `--dry` 先预览再执行
|
||||
|
||||
### 3. 缓存策略
|
||||
如果药材数据不常变动,可以:
|
||||
- 缓存常用药材的拼音首字母
|
||||
- 使用 Redis 存储热门搜索词
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/app/common/service/doctor/MedicineNameAbbrService.php` | 拼音首字母生成服务 |
|
||||
| `server/app/common/command/SyncMedicinePinyinAbbr.php` | 批量回填命令 |
|
||||
| `server/app/adminapi/logic/doctor/MedicineLogic.php` | 药材增删改逻辑 |
|
||||
| `server/app/adminapi/lists/doctor/MedicineLists.php` | 药材列表搜索 |
|
||||
| `server/app/common/model/doctor/Medicine.php` | 药材模型 |
|
||||
|
||||
## 扩展应用
|
||||
|
||||
此功能可以扩展到其他需要中文检索的模块:
|
||||
|
||||
### 1. 患者姓名检索
|
||||
```php
|
||||
use app\common\service\doctor\MedicineNameAbbrService;
|
||||
|
||||
$patientName = '张三';
|
||||
$abbr = MedicineNameAbbrService::build($patientName); // "zs"
|
||||
```
|
||||
|
||||
### 2. 医生姓名检索
|
||||
```php
|
||||
$doctorName = '李医生';
|
||||
$abbr = MedicineNameAbbrService::build($doctorName); // "lys"
|
||||
```
|
||||
|
||||
### 3. 诊断名称检索
|
||||
```php
|
||||
$diagnosis = '感冒';
|
||||
$abbr = MedicineNameAbbrService::build($diagnosis); // "gm"
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
- **核心类:** `MedicineNameAbbrService::build()`
|
||||
- **依赖包:** `overtrue/pinyin`
|
||||
- **存储字段:** `name_pinyin_abbr`
|
||||
- **批量命令:** `php think sync_medicine_pinyin_abbr`
|
||||
- **应用场景:** 药材快速检索、模糊搜索
|
||||
|
||||
这个功能大大提升了中文药材名称的搜索体验,用户可以通过拼音首字母快速找到目标药材。
|
||||
@@ -1,384 +0,0 @@
|
||||
# 处方审核通过后锁定编辑和作废
|
||||
|
||||
## 修改说明
|
||||
|
||||
处方审核通过后,禁止编辑和作废操作,确保已审核通过的处方不被修改,保证处方的完整性和可追溯性。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 前端修改
|
||||
|
||||
**文件**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
#### 编辑按钮
|
||||
|
||||
**修改前**:
|
||||
```vue
|
||||
<el-button
|
||||
v-if="!isApprovedActivePrescription(row) || Number(row.business_prescription_audit_rejected) === 1"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
v-perms="['cf.prescription/edit']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```vue
|
||||
<el-button
|
||||
v-if="!isApprovedActivePrescription(row)"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
v-perms="['cf.prescription/edit']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
```
|
||||
|
||||
**改进点**:
|
||||
- 移除了业务订单驳回的例外情况
|
||||
- 只要处方审核通过且未作废,就不显示编辑按钮
|
||||
- 简化了逻辑,更加严格
|
||||
|
||||
#### 删除按钮
|
||||
|
||||
删除按钮已经使用了 `isApprovedActivePrescription(row)` 判断,无需修改:
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
v-if="!isApprovedActivePrescription(row)"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
v-perms="['cf.prescription/del']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
```
|
||||
|
||||
#### 判断函数
|
||||
|
||||
```typescript
|
||||
/** 已通过审核且未作废:仅可查看,不可编辑/删除 */
|
||||
function isApprovedActivePrescription(row: { audit_status?: number; void_status?: number }) {
|
||||
return Number(row.audit_status) === 1 && Number(row.void_status) !== 1
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 后端修改
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
#### 编辑方法
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
// 已通过且未作废:不可编辑;例外:业务订单「处方审核」已驳回时允许医生改方,保存后业务订单侧审核会重置为待审
|
||||
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||
if (!PrescriptionOrderLogic::allowDoctorEditApprovedPrescriptionDueToBusinessReject((int) $params['id'])) {
|
||||
self::setError('该处方已通过审核,不可编辑');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
// 已通过且未作废:不可编辑
|
||||
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||
self::setError('该处方已通过审核,不可编辑');
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**改进点**:
|
||||
- 移除了业务订单驳回的例外情况
|
||||
- 简化了逻辑,更加严格
|
||||
- 不再调用 `PrescriptionOrderLogic::allowDoctorEditApprovedPrescriptionDueToBusinessReject()`
|
||||
|
||||
#### 删除方法
|
||||
|
||||
删除方法已经有正确的检查,无需修改:
|
||||
|
||||
```php
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
try {
|
||||
$prescription = Prescription::find($id);
|
||||
if (!$prescription) {
|
||||
self::setError('处方不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||
self::setError('该处方已通过审核,不可删除');
|
||||
return false;
|
||||
}
|
||||
|
||||
$prescription->delete();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 审核方法(作废)
|
||||
|
||||
审核驳回会同时作废处方,已经有正确的状态检查:
|
||||
|
||||
```php
|
||||
public static function audit(int $id, string $action, string $remark, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
// ...
|
||||
|
||||
// 只有待审核状态才能进行审核
|
||||
if ((int) ($row->audit_status ?? 1) !== 0) {
|
||||
self::setError('当前状态不可审核');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
if ($action === 'reject') {
|
||||
if (trim($remark) === '') {
|
||||
self::setError('驳回时请填写审核意见');
|
||||
return false;
|
||||
}
|
||||
$row->audit_status = 2;
|
||||
$row->audit_time = $now;
|
||||
$row->audit_by = $adminId;
|
||||
$row->audit_by_name = $name;
|
||||
$row->audit_remark = $remark;
|
||||
$row->void_status = 1; // 驳回时同时作废
|
||||
$row->void_time = $now;
|
||||
$row->void_by = $adminId;
|
||||
$row->void_by_name = $name;
|
||||
|
||||
$ok = (bool) $row->save();
|
||||
if ($ok) {
|
||||
self::$lastAuditWecomNotify = self::notifyCreatorAuditResult($row, 'reject', $remark, $adminInfo);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 权限规则
|
||||
|
||||
### 编辑权限
|
||||
|
||||
处方可以编辑的条件:
|
||||
1. **待审核状态** (`audit_status = 0`)
|
||||
2. **已驳回状态** (`audit_status = 2`),且满足以下条件:
|
||||
- 该诊单当天没有其他已通过的处方
|
||||
- 编辑后会清除驳回状态,重新进入待审核
|
||||
|
||||
处方不可编辑的条件:
|
||||
1. **已通过审核且未作废** (`audit_status = 1` && `void_status = 0`)
|
||||
- 即使业务订单驳回了处方审核,也不允许编辑
|
||||
- 必须先撤回业务订单,处方审核状态会重置为待审核,然后才能编辑
|
||||
|
||||
### 删除权限
|
||||
|
||||
处方可以删除的条件:
|
||||
1. **待审核状态** (`audit_status = 0`)
|
||||
2. **已驳回状态** (`audit_status = 2`)
|
||||
3. **已作废状态** (`void_status = 1`)
|
||||
|
||||
处方不可删除的条件:
|
||||
1. **已通过审核且未作废** (`audit_status = 1` && `void_status = 0`)
|
||||
|
||||
### 作废权限(审核驳回)
|
||||
|
||||
处方可以作废的条件:
|
||||
1. **待审核状态** (`audit_status = 0`)
|
||||
2. 有审核权限的用户
|
||||
|
||||
处方不可作废的条件:
|
||||
1. **已通过审核** (`audit_status = 1`)
|
||||
2. **已驳回** (`audit_status = 2`)
|
||||
|
||||
## 业务流程
|
||||
|
||||
### 场景1:处方审核通过后需要修改
|
||||
|
||||
**旧流程**(已废弃):
|
||||
1. 处方审核通过
|
||||
2. 创建业务订单
|
||||
3. 业务订单处方审核驳回
|
||||
4. 医生可以直接编辑已通过的处方 ❌
|
||||
|
||||
**新流程**(推荐):
|
||||
1. 处方审核通过
|
||||
2. 创建业务订单
|
||||
3. 业务订单处方审核驳回
|
||||
4. 管理员撤回业务订单(处方审核状态重置为待审核)
|
||||
5. 医生编辑处方
|
||||
6. 处方重新审核
|
||||
7. 创建新的业务订单
|
||||
|
||||
### 场景2:处方审核通过后发现错误
|
||||
|
||||
**解决方案**:
|
||||
1. 如果已创建业务订单,先撤回业务订单
|
||||
2. 处方审核状态会自动重置为待审核
|
||||
3. 医生编辑处方
|
||||
4. 处方重新审核
|
||||
5. 创建新的业务订单
|
||||
|
||||
### 场景3:处方审核驳回后修改
|
||||
|
||||
**流程**:
|
||||
1. 处方审核驳回(同时作废)
|
||||
2. 医生编辑处方(编辑后会清除驳回和作废状态)
|
||||
3. 处方重新进入待审核状态
|
||||
4. 重新审核
|
||||
|
||||
## 优势
|
||||
|
||||
### 1. 数据完整性
|
||||
- 已审核通过的处方不可修改,保证了处方的完整性
|
||||
- 避免审核后的处方被随意修改,导致审核记录与实际内容不符
|
||||
|
||||
### 2. 可追溯性
|
||||
- 每次修改都需要重新审核,留下完整的审核记录
|
||||
- 便于追溯处方的修改历史和审核历史
|
||||
|
||||
### 3. 流程规范
|
||||
- 强制执行"撤回 → 编辑 → 重新审核"的流程
|
||||
- 避免绕过审核流程直接修改处方
|
||||
|
||||
### 4. 责任明确
|
||||
- 审核人员对已审核的处方负责
|
||||
- 修改处方需要重新审核,责任链清晰
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 业务订单撤回
|
||||
|
||||
如果需要修改已审核通过的处方,必须先撤回业务订单:
|
||||
- 撤回业务订单会自动重置处方审核状态为待审核
|
||||
- 撤回后才能编辑处方
|
||||
- 编辑后需要重新审核处方
|
||||
- 审核通过后可以创建新的业务订单
|
||||
|
||||
### 2. 处方驳回
|
||||
|
||||
处方驳回会同时作废处方:
|
||||
- 驳回后处方状态变为"已驳回"(`audit_status = 2`)
|
||||
- 同时处方会被作废(`void_status = 1`)
|
||||
- 医生可以编辑已驳回的处方
|
||||
- 编辑后会清除驳回和作废状态,重新进入待审核
|
||||
|
||||
### 3. 权限配置
|
||||
|
||||
确保相关权限配置正确:
|
||||
- `cf.prescription/edit` - 编辑处方权限
|
||||
- `cf.prescription/del` - 删除处方权限
|
||||
- `cf.prescription/audit` - 审核处方权限
|
||||
- `tcm.prescriptionOrder/withdraw` - 撤回业务订单权限
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 编辑权限测试
|
||||
|
||||
**测试用例 1: 待审核处方可以编辑**
|
||||
- 创建处方(待审核状态)
|
||||
- 点击"编辑"按钮
|
||||
- 预期:可以正常编辑
|
||||
|
||||
**测试用例 2: 已通过处方不可编辑**
|
||||
- 创建处方并审核通过
|
||||
- 点击"编辑"按钮
|
||||
- 预期:按钮不显示或提示"该处方已通过审核,不可编辑"
|
||||
|
||||
**测试用例 3: 业务订单驳回后不可直接编辑**
|
||||
- 创建处方并审核通过
|
||||
- 创建业务订单
|
||||
- 业务订单处方审核驳回
|
||||
- 点击"编辑"按钮
|
||||
- 预期:按钮不显示或提示"该处方已通过审核,不可编辑"
|
||||
|
||||
**测试用例 4: 撤回业务订单后可以编辑**
|
||||
- 创建处方并审核通过
|
||||
- 创建业务订单
|
||||
- 撤回业务订单
|
||||
- 点击"编辑"按钮
|
||||
- 预期:可以正常编辑
|
||||
|
||||
**测试用例 5: 已驳回处方可以编辑**
|
||||
- 创建处方
|
||||
- 审核驳回
|
||||
- 点击"编辑"按钮
|
||||
- 预期:可以正常编辑,编辑后清除驳回和作废状态
|
||||
|
||||
### 2. 删除权限测试
|
||||
|
||||
**测试用例 6: 待审核处方可以删除**
|
||||
- 创建处方(待审核状态)
|
||||
- 点击"删除"按钮
|
||||
- 预期:可以正常删除
|
||||
|
||||
**测试用例 7: 已通过处方不可删除**
|
||||
- 创建处方并审核通过
|
||||
- 点击"删除"按钮
|
||||
- 预期:按钮不显示或提示"该处方已通过审核,不可删除"
|
||||
|
||||
**测试用例 8: 已驳回处方可以删除**
|
||||
- 创建处方
|
||||
- 审核驳回
|
||||
- 点击"删除"按钮
|
||||
- 预期:可以正常删除
|
||||
|
||||
### 3. 作废权限测试
|
||||
|
||||
**测试用例 9: 待审核处方可以驳回作废**
|
||||
- 创建处方(待审核状态)
|
||||
- 点击"审核"按钮,选择"驳回"
|
||||
- 预期:处方被驳回并作废
|
||||
|
||||
**测试用例 10: 已通过处方不可驳回作废**
|
||||
- 创建处方并审核通过
|
||||
- 点击"审核"按钮
|
||||
- 预期:按钮不显示或提示"当前状态不可审核"
|
||||
|
||||
**测试用例 11: 已驳回处方不可再次驳回**
|
||||
- 创建处方
|
||||
- 审核驳回
|
||||
- 再次点击"审核"按钮
|
||||
- 预期:按钮不显示或提示"当前状态不可审核"
|
||||
|
||||
### 4. 业务流程测试
|
||||
|
||||
**测试用例 12: 完整的修改流程**
|
||||
1. 创建处方并审核通过
|
||||
2. 创建业务订单
|
||||
3. 业务订单处方审核驳回
|
||||
4. 尝试编辑处方 → 预期:不可编辑
|
||||
5. 撤回业务订单
|
||||
6. 编辑处方 → 预期:可以编辑
|
||||
7. 处方重新审核通过
|
||||
8. 创建新的业务订单 → 预期:成功
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md` - 订单撤回重置审核状态文档
|
||||
- `BUG_FIXES_SUMMARY.md` - Bug修复总结文档
|
||||
|
||||
## 总结
|
||||
|
||||
通过移除业务订单驳回的例外情况,确保了:
|
||||
- ✅ 已审核通过的处方不可编辑
|
||||
- ✅ 已审核通过的处方不可删除
|
||||
- ✅ 已审核通过的处方不可作废(驳回)
|
||||
- ✅ 强制执行"撤回 → 编辑 → 重新审核"的规范流程
|
||||
- ✅ 保证处方的完整性和可追溯性
|
||||
- ✅ 责任链清晰,审核记录完整
|
||||
- ✅ 前后端双重校验,防止绕过限制
|
||||
@@ -1,204 +0,0 @@
|
||||
# 处方按钮显示检查
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户反馈:处方审核通过后,"开方"按钮不显示了。
|
||||
|
||||
## 代码分析
|
||||
|
||||
### 前端代码
|
||||
|
||||
**文件**: `admin/src/views/tcm/appointment/list.vue`
|
||||
|
||||
#### 按钮显示逻辑
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/kaifang']"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handlePrescription(row)"
|
||||
>
|
||||
{{ prescriptionActionLabel(row) }}
|
||||
</el-button>
|
||||
```
|
||||
|
||||
#### 按钮文字逻辑
|
||||
|
||||
```typescript
|
||||
function prescriptionActionLabel(row: any) {
|
||||
if (
|
||||
row?.prescription_audit_status === 1 &&
|
||||
Number(row?.prescription_void_status) !== 1
|
||||
) {
|
||||
return '查看'
|
||||
}
|
||||
return '开方'
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑说明**:
|
||||
- 如果处方审核通过(`prescription_audit_status === 1`)且未作废(`prescription_void_status !== 1`),按钮文字显示"查看"
|
||||
- 否则,按钮文字显示"开方"
|
||||
|
||||
### 后端代码
|
||||
|
||||
**文件**: `server/app/adminapi/lists/doctor/AppointmentLists.php`
|
||||
|
||||
```php
|
||||
$apptId = (int) ($item['id'] ?? 0);
|
||||
$apptRx = $rxByAppointmentId[$apptId] ?? null;
|
||||
$item['prescription_audit_status'] = $apptRx !== null ? (int) ($apptRx['audit_status'] ?? -1) : -1;
|
||||
$item['prescription_void_status'] = $apptRx !== null ? (int) ($apptRx['void_status'] ?? 0) : 0;
|
||||
```
|
||||
|
||||
**逻辑说明**:
|
||||
- 如果预约有关联的处方,返回处方的审核状态和作废状态
|
||||
- 如果预约没有关联的处方,`prescription_audit_status` 为 `-1`,`prescription_void_status` 为 `0`
|
||||
|
||||
## 可能的原因
|
||||
|
||||
### 1. 权限问题
|
||||
|
||||
按钮有权限检查 `v-perms="['tcm.diagnosis/kaifang']"`,如果用户没有这个权限,按钮会被隐藏。
|
||||
|
||||
**检查方法**:
|
||||
1. 登录用户账号
|
||||
2. 检查用户角色是否有 `tcm.diagnosis/kaifang` 权限
|
||||
3. 在浏览器控制台查看按钮元素是否存在
|
||||
|
||||
### 2. 数据问题
|
||||
|
||||
后端返回的 `prescription_audit_status` 或 `prescription_void_status` 字段值不正确。
|
||||
|
||||
**检查方法**:
|
||||
1. 打开浏览器开发者工具
|
||||
2. 查看预约列表接口的响应数据
|
||||
3. 检查 `prescription_audit_status` 和 `prescription_void_status` 字段的值
|
||||
|
||||
**预期值**:
|
||||
- 未开方:`prescription_audit_status = -1`, `prescription_void_status = 0`
|
||||
- 待审核:`prescription_audit_status = 0`, `prescription_void_status = 0`
|
||||
- 已通过:`prescription_audit_status = 1`, `prescription_void_status = 0`
|
||||
- 已驳回:`prescription_audit_status = 2`, `prescription_void_status = 1`
|
||||
|
||||
### 3. 按钮文字问题
|
||||
|
||||
按钮确实显示了,但是文字从"开方"变成了"查看",用户可能误以为按钮消失了。
|
||||
|
||||
**检查方法**:
|
||||
1. 查看预约列表
|
||||
2. 找到已审核通过的处方对应的预约
|
||||
3. 检查按钮文字是否显示为"查看"
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:确认权限配置
|
||||
|
||||
确保用户角色有 `tcm.diagnosis/kaifang` 权限:
|
||||
|
||||
1. 进入后台管理 → 权限管理 → 角色管理
|
||||
2. 找到用户所属的角色
|
||||
3. 检查是否勾选了"中医诊断 → 开方"权限(`tcm.diagnosis/kaifang`)
|
||||
4. 如果没有勾选,勾选后保存
|
||||
|
||||
### 方案2:检查数据返回
|
||||
|
||||
在浏览器开发者工具中检查接口返回:
|
||||
|
||||
1. 打开浏览器开发者工具(F12)
|
||||
2. 切换到 Network 标签
|
||||
3. 刷新预约列表页面
|
||||
4. 找到预约列表接口(通常是 `/doctor.appointment/lists`)
|
||||
5. 查看响应数据中的 `prescription_audit_status` 和 `prescription_void_status` 字段
|
||||
|
||||
**示例数据**:
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"patient_name": "张三",
|
||||
"prescription_audit_status": 1, // 1=已通过
|
||||
"prescription_void_status": 0, // 0=未作废
|
||||
"has_prescription": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 方案3:按钮文字说明
|
||||
|
||||
如果按钮文字从"开方"变成了"查看",这是正常的:
|
||||
|
||||
- **未开方或待审核**:按钮显示"开方",点击后可以创建或编辑处方
|
||||
- **已审核通过**:按钮显示"查看",点击后只能查看处方(只读模式)
|
||||
|
||||
这是为了区分"可编辑"和"只读"两种模式。
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 测试1:未开方的预约
|
||||
|
||||
1. 找到一个未开方的预约(`has_prescription = 0`)
|
||||
2. 检查按钮是否显示"开方"
|
||||
3. 点击按钮,应该可以创建新处方
|
||||
|
||||
### 测试2:待审核的处方
|
||||
|
||||
1. 创建一个处方(待审核状态)
|
||||
2. 返回预约列表
|
||||
3. 检查按钮是否显示"开方"
|
||||
4. 点击按钮,应该可以编辑处方
|
||||
|
||||
### 测试3:已审核通过的处方
|
||||
|
||||
1. 创建一个处方并审核通过
|
||||
2. 返回预约列表
|
||||
3. 检查按钮是否显示"查看"
|
||||
4. 点击按钮,应该只能查看处方(只读模式)
|
||||
|
||||
### 测试4:已驳回的处方
|
||||
|
||||
1. 创建一个处方并审核驳回
|
||||
2. 返回预约列表
|
||||
3. 检查按钮是否显示"开方"
|
||||
4. 点击按钮,应该可以编辑处方
|
||||
|
||||
## 预期行为
|
||||
|
||||
| 处方状态 | `prescription_audit_status` | `prescription_void_status` | 按钮文字 | 点击行为 |
|
||||
|---------|---------------------------|--------------------------|---------|---------|
|
||||
| 未开方 | -1 | 0 | 开方 | 创建新处方 |
|
||||
| 待审核 | 0 | 0 | 开方 | 编辑处方 |
|
||||
| 已通过 | 1 | 0 | 查看 | 查看处方(只读) |
|
||||
| 已驳回 | 2 | 1 | 开方 | 编辑处方 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么审核通过后按钮文字变成"查看"?
|
||||
|
||||
A: 这是为了区分"可编辑"和"只读"两种模式。已审核通过的处方不可编辑,只能查看。
|
||||
|
||||
### Q2: 如果需要修改已审核通过的处方怎么办?
|
||||
|
||||
A: 需要先撤回业务订单(如果已创建),处方审核状态会自动重置为待审核,然后才能编辑。
|
||||
|
||||
### Q3: 按钮完全不显示怎么办?
|
||||
|
||||
A: 检查用户是否有 `tcm.diagnosis/kaifang` 权限。如果没有,需要在角色管理中添加该权限。
|
||||
|
||||
### Q4: 点击"查看"按钮后能编辑吗?
|
||||
|
||||
A: 不能。已审核通过的处方只能查看,不能编辑。如果需要编辑,需要先撤回业务订单。
|
||||
|
||||
## 总结
|
||||
|
||||
按钮的显示逻辑是正确的:
|
||||
- ✅ 按钮始终显示(只要有 `tcm.diagnosis/kaifang` 权限)
|
||||
- ✅ 按钮文字根据处方状态动态变化("开方" 或 "查看")
|
||||
- ✅ 点击行为根据处方状态不同(可编辑 或 只读)
|
||||
|
||||
如果用户反馈"按钮不显示",可能是以下原因:
|
||||
1. 用户没有 `tcm.diagnosis/kaifang` 权限
|
||||
2. 按钮文字从"开方"变成了"查看",用户误以为按钮消失了
|
||||
3. 数据返回有问题,需要检查后端接口
|
||||
|
||||
建议先检查权限配置和数据返回,确认按钮是否真的不显示。
|
||||
@@ -1,283 +0,0 @@
|
||||
# 处方用量功能完整实现总结
|
||||
|
||||
## 概述
|
||||
在两个处方页面中实现了用量、单位和代煎功能:
|
||||
1. **处方组件** (`admin/src/components/tcm-prescription/index.vue`) - 创建/编辑处方
|
||||
2. **处方列表** (`admin/src/views/consumer/prescription/index.vue`) - 查看/编辑处方
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 数据库层 ✅
|
||||
**文件**: `add_prescription_dosage_fields.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
```
|
||||
|
||||
### 2. 后端层 ✅
|
||||
|
||||
#### 模型 (`server/app/common/model/tcm/Prescription.php`)
|
||||
```php
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
```
|
||||
|
||||
#### 业务逻辑 (`server/app/adminapi/logic/tcm/PrescriptionLogic.php`)
|
||||
- ✅ `add()` 方法:保存新处方
|
||||
- ✅ `edit()` 方法:编辑处方
|
||||
|
||||
### 3. 前端层 ✅
|
||||
|
||||
#### 3.1 处方组件 (`admin/src/components/tcm-prescription/index.vue`)
|
||||
|
||||
**数据结构**:
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined
|
||||
dosage_unit: string
|
||||
need_decoction: boolean
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**响应式数据**:
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**自动切换逻辑**:
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.2 处方列表 (`admin/src/views/consumer/prescription/index.vue`)
|
||||
|
||||
**数据结构**:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**自动切换逻辑**:
|
||||
```typescript
|
||||
watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## UI 组件实现
|
||||
|
||||
### 浓缩水丸 (1-10g)
|
||||
```vue
|
||||
<el-select v-model="formData.dosage_amount">
|
||||
<el-option label="1g" :value="1" />
|
||||
<el-option label="2g" :value="2" />
|
||||
<!-- ... 3-10g -->
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 饮片 (50-250ml)
|
||||
```vue
|
||||
<el-select v-model="formData.dosage_amount">
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
|
||||
<!-- 代煎选项 -->
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
```
|
||||
|
||||
### 其他类型
|
||||
```vue
|
||||
<el-input-number
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
```
|
||||
|
||||
## 功能规则
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- **单位**: g (克)
|
||||
- **范围**: 1-10g
|
||||
- **UI**: 下拉选择
|
||||
- **代煎**: 不显示
|
||||
|
||||
### 2. 饮片
|
||||
- **单位**: ml (毫升)
|
||||
- **范围**: 50-250ml (步进50ml)
|
||||
- **UI**: 下拉选择
|
||||
- **代煎**: 显示单选按钮(代煎/不代煎)
|
||||
|
||||
### 3. 其他类型(颗粒、丸剂、散剂、膏方、汤剂)
|
||||
- **单位**: g (克)
|
||||
- **范围**: 不限制
|
||||
- **UI**: 数字输入框(支持小数)
|
||||
- **代煎**: 不显示
|
||||
|
||||
## 数据流转示例
|
||||
|
||||
### 创建浓缩水丸处方
|
||||
```
|
||||
用户操作:
|
||||
1. 选择"浓缩水丸"
|
||||
2. 自动设置: 单位=g, 用量=1g
|
||||
3. 选择用量: 5g
|
||||
4. 保存
|
||||
|
||||
数据库存储:
|
||||
prescription_type: "浓缩水丸"
|
||||
dosage_amount: 5.00
|
||||
dosage_unit: "g"
|
||||
need_decoction: 0
|
||||
```
|
||||
|
||||
### 创建饮片处方(代煎)
|
||||
```
|
||||
用户操作:
|
||||
1. 选择"饮片"
|
||||
2. 自动设置: 单位=ml, 用量=50ml
|
||||
3. 选择用量: 150ml
|
||||
4. 选择"代煎"
|
||||
5. 保存
|
||||
|
||||
数据库存储:
|
||||
prescription_type: "饮片"
|
||||
dosage_amount: 150.00
|
||||
dosage_unit: "ml"
|
||||
need_decoction: 1
|
||||
```
|
||||
|
||||
## 部署清单
|
||||
|
||||
### 1. 数据库迁移 ⚠️ 必须执行
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 2. 后端文件
|
||||
- ✅ `server/app/common/model/tcm/Prescription.php`
|
||||
- ✅ `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
### 3. 前端文件
|
||||
- ✅ `admin/src/components/tcm-prescription/index.vue`
|
||||
- ✅ `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 处方组件测试
|
||||
- [ ] 创建浓缩水丸处方,选择1-10g
|
||||
- [ ] 创建饮片处方,选择50-250ml
|
||||
- [ ] 饮片处方选择"代煎"
|
||||
- [ ] 饮片处方选择"不代煎"
|
||||
- [ ] 创建颗粒处方,输入自定义用量
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存处方,验证数据正确存储
|
||||
|
||||
### 处方列表测试
|
||||
- [ ] 编辑浓缩水丸处方,修改用量
|
||||
- [ ] 编辑饮片处方,修改用量和代煎选项
|
||||
- [ ] 编辑其他类型处方,输入自定义用量
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存修改,验证数据正确更新
|
||||
- [ ] 查看处方详情,验证显示正确
|
||||
|
||||
### 数据验证测试
|
||||
- [ ] 浓缩水丸用量范围:1-10g
|
||||
- [ ] 饮片用量范围:50-250ml
|
||||
- [ ] 饮片用量步进:50ml
|
||||
- [ ] 代煎选项仅在饮片时显示
|
||||
- [ ] 切换类型时代煎选项自动隐藏
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_dosage_fields.sql` - 数据库迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方列表
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 数据库实现
|
||||
- `PRESCRIPTION_DOSAGE_COMPLETE_SUMMARY.md` - 本文档
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据库迁移必须先执行**,否则保存时会报错
|
||||
2. **旧数据兼容**:旧处方的用量字段为NULL,不影响显示
|
||||
3. **类型切换**:切换处方类型时,用量和单位会自动调整
|
||||
4. **代煎选项**:仅在选择"饮片"时显示
|
||||
5. **数据验证**:前端使用下拉选择确保数据有效性
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **后端验证**:添加用量范围的后端验证逻辑
|
||||
2. **打印显示**:在处方打印时显示用量和代煎信息
|
||||
3. **统计报表**:按处方类型和用量统计
|
||||
4. **价格联动**:根据用量自动计算处方价格
|
||||
5. **库存检查**:代煎选项关联药房库存
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **数据库层**:3个字段已定义
|
||||
✅ **后端层**:模型和业务逻辑已实现
|
||||
✅ **前端层**:2个页面都已实现
|
||||
✅ **UI组件**:下拉选择器和单选按钮
|
||||
✅ **自动切换**:watch监听实现
|
||||
✅ **数据流转**:完整的保存和读取
|
||||
|
||||
**状态**: 功能完整实现,只需执行数据库迁移即可使用!🎉
|
||||
@@ -1,366 +0,0 @@
|
||||
# 处方用量数据库实现完整指南
|
||||
|
||||
## 概述
|
||||
为处方系统添加用量、单位和代煎字段的完整数据库实现。
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
### 执行SQL文件
|
||||
**文件**: `add_prescription_dosage_fields.sql`
|
||||
|
||||
```sql
|
||||
-- 添加处方用量相关字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
```
|
||||
|
||||
### 执行方式
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `dosage_amount` | decimal(10,2) | NULL | 用量数值,支持小数 |
|
||||
| `dosage_unit` | varchar(10) | '' | 用量单位 (g/ml) |
|
||||
| `need_decoction` | tinyint(1) | 0 | 是否代煎 (0=否, 1=是) |
|
||||
|
||||
## 2. 后端实现
|
||||
|
||||
### 2.1 模型层 (Model)
|
||||
**文件**: `server/app/common/model/tcm/Prescription.php`
|
||||
|
||||
```php
|
||||
class Prescription extends BaseModel
|
||||
{
|
||||
// ... 其他配置
|
||||
|
||||
// 字段类型转换
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**作用**: 自动将数据库字段转换为正确的PHP类型。
|
||||
|
||||
### 2.2 业务逻辑层 (Logic)
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
#### add() 方法 - 添加处方
|
||||
```php
|
||||
$data = [
|
||||
// ... 其他字段
|
||||
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
// ... 其他字段
|
||||
];
|
||||
```
|
||||
|
||||
#### edit() 方法 - 编辑处方
|
||||
```php
|
||||
$data = [
|
||||
// ... 其他字段
|
||||
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||
// ... 其他字段
|
||||
];
|
||||
```
|
||||
|
||||
## 3. 前端实现
|
||||
|
||||
### 3.1 TypeScript 接口
|
||||
**文件**: `admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined
|
||||
dosage_unit: string
|
||||
need_decoction: boolean
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 响应式数据
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ... 其他字段
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 自动切换逻辑
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 4. 数据流转
|
||||
|
||||
### 4.1 保存流程
|
||||
```
|
||||
前端表单
|
||||
↓
|
||||
{
|
||||
prescription_type: "浓缩水丸",
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false
|
||||
}
|
||||
↓
|
||||
后端 PrescriptionLogic::add()
|
||||
↓
|
||||
数据库 zyt_tcm_prescription
|
||||
↓
|
||||
dosage_amount: 5.00
|
||||
dosage_unit: "g"
|
||||
need_decoction: 0
|
||||
```
|
||||
|
||||
### 4.2 读取流程
|
||||
```
|
||||
数据库查询
|
||||
↓
|
||||
Prescription Model (类型转换)
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5.0, // float
|
||||
dosage_unit: "g", // string
|
||||
need_decoction: 0 // int
|
||||
}
|
||||
↓
|
||||
前端接收
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据示例
|
||||
|
||||
### 5.1 浓缩水丸处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "浓缩水丸",
|
||||
"dosage_amount": 5.00,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 饮片处方(代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 150.00,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 颗粒处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "颗粒",
|
||||
"dosage_amount": 15.50,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 部署步骤
|
||||
|
||||
### 步骤 1: 备份数据库
|
||||
```bash
|
||||
mysqldump -u用户名 -p数据库名 > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
### 步骤 2: 执行数据库迁移
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 步骤 3: 验证字段添加
|
||||
```sql
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'dosage%';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'need_decoction';
|
||||
```
|
||||
|
||||
### 步骤 4: 部署后端代码
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
- server/app/common/model/tcm/Prescription.php
|
||||
- server/app/adminapi/logic/tcm/PrescriptionLogic.php
|
||||
```
|
||||
|
||||
### 步骤 5: 部署前端代码
|
||||
```bash
|
||||
# 构建前端
|
||||
cd admin
|
||||
npm run build
|
||||
|
||||
# 上传构建产物
|
||||
# 上传 dist/ 目录到服务器
|
||||
```
|
||||
|
||||
### 步骤 6: 清除缓存(如需要)
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
```
|
||||
|
||||
### 步骤 7: 测试功能
|
||||
1. 创建新处方,选择不同类型
|
||||
2. 验证用量字段正确保存
|
||||
3. 编辑处方,验证数据正确加载
|
||||
4. 查看处方详情,验证显示正确
|
||||
|
||||
## 7. 数据验证
|
||||
|
||||
### 7.1 前端验证
|
||||
```typescript
|
||||
// 浓缩水丸:1-10g
|
||||
if (formData.prescription_type === '浓缩水丸') {
|
||||
if (formData.dosage_amount < 1 || formData.dosage_amount > 10) {
|
||||
// 错误提示
|
||||
}
|
||||
}
|
||||
|
||||
// 饮片:50-250ml,步进50
|
||||
if (formData.prescription_type === '饮片') {
|
||||
if (formData.dosage_amount % 50 !== 0) {
|
||||
// 错误提示
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 后端验证(建议添加)
|
||||
```php
|
||||
// 在 PrescriptionLogic::add() 中添加
|
||||
if ($params['prescription_type'] === '浓缩水丸') {
|
||||
$amount = (float)($params['dosage_amount'] ?? 0);
|
||||
if ($amount < 1 || $amount > 10) {
|
||||
self::setError('浓缩水丸用量必须在1-10g之间');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($params['prescription_type'] === '饮片') {
|
||||
$amount = (float)($params['dosage_amount'] ?? 0);
|
||||
if ($amount < 50 || $amount > 250 || $amount % 50 !== 0) {
|
||||
self::setError('饮片用量必须为50-250ml,且为50的倍数');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### Q1: 字段已存在错误
|
||||
```
|
||||
ERROR 1060 (42S21): Duplicate column name 'dosage_amount'
|
||||
```
|
||||
**解决**: 字段已存在,跳过此步骤或先删除字段再添加。
|
||||
|
||||
### Q2: 旧数据如何处理?
|
||||
**方案**: 旧数据的 `dosage_amount` 为 NULL,前端显示为空,不影响功能。
|
||||
|
||||
### Q3: 如何批量更新旧数据?
|
||||
```sql
|
||||
-- 为浓缩水丸类型设置默认用量
|
||||
UPDATE `zyt_tcm_prescription`
|
||||
SET
|
||||
`dosage_amount` = 1,
|
||||
`dosage_unit` = 'g'
|
||||
WHERE `prescription_type` = '浓缩水丸'
|
||||
AND `dosage_amount` IS NULL;
|
||||
|
||||
-- 为饮片类型设置默认用量
|
||||
UPDATE `zyt_tcm_prescription`
|
||||
SET
|
||||
`dosage_amount` = 50,
|
||||
`dosage_unit` = 'ml'
|
||||
WHERE `prescription_type` = '饮片'
|
||||
AND `dosage_amount` IS NULL;
|
||||
```
|
||||
|
||||
## 9. 回滚方案
|
||||
|
||||
如果需要回滚,执行以下SQL:
|
||||
|
||||
```sql
|
||||
-- 删除添加的字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
DROP COLUMN `dosage_amount`,
|
||||
DROP COLUMN `dosage_unit`,
|
||||
DROP COLUMN `need_decoction`;
|
||||
```
|
||||
|
||||
## 10. 相关文件清单
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_dosage_fields.sql` - 数据库迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明文档
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 本文档
|
||||
|
||||
## 11. 测试清单
|
||||
|
||||
- [ ] 数据库字段添加成功
|
||||
- [ ] 创建浓缩水丸处方,用量1-10g
|
||||
- [ ] 创建饮片处方,用量50-250ml
|
||||
- [ ] 饮片处方代煎选项正常
|
||||
- [ ] 编辑处方,数据正确加载
|
||||
- [ ] 切换处方类型,用量自动调整
|
||||
- [ ] 保存处方,数据正确存储
|
||||
- [ ] 查看处方详情,显示正确
|
||||
- [ ] 旧数据兼容性测试
|
||||
|
||||
## 总结
|
||||
|
||||
此实现完整覆盖了从数据库到前端的所有层次:
|
||||
1. ✅ 数据库字段添加
|
||||
2. ✅ 模型层类型转换
|
||||
3. ✅ 业务逻辑层数据处理
|
||||
4. ✅ 前端表单交互
|
||||
5. ✅ 自动切换逻辑
|
||||
6. ✅ 数据验证
|
||||
|
||||
所有代码已实现,只需执行数据库迁移即可使用!
|
||||
@@ -1,307 +0,0 @@
|
||||
# 处方用量功能实现
|
||||
|
||||
## 概述
|
||||
根据处方类型自动调整用量单位和范围,并为饮片类型添加代煎选项。
|
||||
|
||||
## 功能说明
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- **单位**: g (克)
|
||||
- **范围**: 1-10g
|
||||
- **步进**: 1g
|
||||
- **默认值**: 1g
|
||||
|
||||
### 2. 饮片
|
||||
- **单位**: ml (毫升)
|
||||
- **范围**: 50-250ml
|
||||
- **步进**: 50ml (每50ml一个单位)
|
||||
- **默认值**: 50ml
|
||||
- **特殊选项**: 是否代煎(代煎/不代煎)
|
||||
|
||||
### 3. 其他类型(颗粒、丸剂、散剂、膏方、汤剂)
|
||||
- **单位**: g (克)
|
||||
- **范围**: 不限制
|
||||
- **精度**: 小数点后2位
|
||||
- **默认值**: 无
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 文件:`admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
#### 1. TypeScript 接口
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
prescription_type: string // 处方类型
|
||||
dosage_amount: number | undefined // 用量数值
|
||||
dosage_unit: string // 用量单位 (g 或 ml)
|
||||
need_decoction: boolean // 是否代煎(仅饮片)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 响应式数据
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ...
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. 自动切换逻辑
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. UI 组件
|
||||
```vue
|
||||
<!-- 用量字段 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g -->
|
||||
<el-input-number
|
||||
v-if="formData.prescription_type === '浓缩水丸'"
|
||||
v-model="formData.dosage_amount"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="1"
|
||||
class="!w-32"
|
||||
/>
|
||||
<!-- 饮片:50-250ml,步进50 -->
|
||||
<el-input-number
|
||||
v-else-if="formData.prescription_type === '饮片'"
|
||||
v-model="formData.dosage_amount"
|
||||
:min="50"
|
||||
:max="250"
|
||||
:step="50"
|
||||
class="!w-32"
|
||||
/>
|
||||
<!-- 其他类型:自由输入 -->
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="!w-32"
|
||||
/>
|
||||
<span class="ml-2">{{ formData.dosage_unit }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="12">
|
||||
<el-form-item label="是否代煎" prop="need_decoction">
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
```
|
||||
|
||||
## 用户交互流程
|
||||
|
||||
### 场景 1:选择浓缩水丸
|
||||
1. 用户选择"浓缩水丸"
|
||||
2. 系统自动设置:
|
||||
- 用量单位:g
|
||||
- 默认用量:1g
|
||||
- 用量范围:1-10g
|
||||
- 隐藏代煎选项
|
||||
|
||||
### 场景 2:选择饮片
|
||||
1. 用户选择"饮片"
|
||||
2. 系统自动设置:
|
||||
- 用量单位:ml
|
||||
- 默认用量:50ml
|
||||
- 用量范围:50-250ml(步进50ml)
|
||||
- 显示代煎选项(默认"不代煎")
|
||||
3. 用户可选择:
|
||||
- 用量:50ml, 100ml, 150ml, 200ml, 250ml
|
||||
- 是否代煎:代煎 / 不代煎
|
||||
|
||||
### 场景 3:选择其他类型
|
||||
1. 用户选择"颗粒"、"丸剂"等
|
||||
2. 系统自动设置:
|
||||
- 用量单位:g
|
||||
- 默认用量:空
|
||||
- 用量范围:不限制(可输入小数)
|
||||
- 隐藏代煎选项
|
||||
|
||||
## 数据示例
|
||||
|
||||
### 浓缩水丸处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "浓缩水丸",
|
||||
"dosage_amount": 5,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
### 饮片处方(代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 150,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": true
|
||||
}
|
||||
```
|
||||
|
||||
### 饮片处方(不代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 200,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
### 颗粒处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "颗粒",
|
||||
"dosage_amount": 15.5,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
## 后端存储建议
|
||||
|
||||
### 数据库字段
|
||||
建议在处方表中添加以下字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
```
|
||||
|
||||
### 后端验证逻辑
|
||||
```php
|
||||
// 浓缩水丸验证
|
||||
if ($prescriptionType === '浓缩水丸') {
|
||||
if ($dosageAmount < 1 || $dosageAmount > 10) {
|
||||
throw new Exception('浓缩水丸用量必须在1-10g之间');
|
||||
}
|
||||
if ($dosageUnit !== 'g') {
|
||||
throw new Exception('浓缩水丸单位必须为g');
|
||||
}
|
||||
}
|
||||
|
||||
// 饮片验证
|
||||
if ($prescriptionType === '饮片') {
|
||||
if ($dosageAmount < 50 || $dosageAmount > 250 || $dosageAmount % 50 !== 0) {
|
||||
throw new Exception('饮片用量必须为50-250ml,且为50的倍数');
|
||||
}
|
||||
if ($dosageUnit !== 'ml') {
|
||||
throw new Exception('饮片单位必须为ml');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI 布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [浓缩水丸 ▼] 用量: [1] g │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [饮片 ▼] 用量: [150] ml │
|
||||
│ 是否代煎: ⦿ 代煎 ○ 不代煎 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [颗粒 ▼] 用量: [15.5] g │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- 用于浓缩制剂,按克计量
|
||||
- 通常用量较小,1-10g范围合理
|
||||
- 不需要代煎
|
||||
|
||||
### 2. 饮片
|
||||
- 用于传统中药饮片
|
||||
- 按煎煮后的药液体积计量(ml)
|
||||
- 每次50ml为一个标准单位
|
||||
- 可选择是否由药房代煎
|
||||
- 代煎:药房负责煎煮,患者直接服用
|
||||
- 不代煎:患者自行煎煮
|
||||
|
||||
### 3. 其他类型
|
||||
- 根据实际情况灵活设置用量
|
||||
- 支持小数点精度
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **单位自动切换**:切换处方类型时,单位和默认值会自动更新
|
||||
2. **代煎选项**:仅在选择"饮片"时显示
|
||||
3. **用量限制**:不同类型有不同的用量范围限制
|
||||
4. **数据验证**:前端和后端都应进行用量范围验证
|
||||
5. **用户体验**:使用 `el-input-number` 组件,支持键盘上下键调整
|
||||
|
||||
## 扩展建议
|
||||
|
||||
1. **价格联动**:根据用量自动计算处方价格
|
||||
2. **库存检查**:代煎选项可关联药房库存
|
||||
3. **历史记录**:记录患者常用的用量设置
|
||||
4. **模板保存**:将用量设置保存到处方模板
|
||||
5. **打印显示**:在处方打印时显示用量和代煎信息
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件主文件
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 测试 1:浓缩水丸用量
|
||||
- 选择"浓缩水丸"
|
||||
- 验证用量范围:1-10g
|
||||
- 验证步进:1g
|
||||
- 验证代煎选项隐藏
|
||||
|
||||
### 测试 2:饮片用量
|
||||
- 选择"饮片"
|
||||
- 验证用量范围:50-250ml
|
||||
- 验证步进:50ml
|
||||
- 验证代煎选项显示
|
||||
- 测试代煎/不代煎切换
|
||||
|
||||
### 测试 3:类型切换
|
||||
- 从"浓缩水丸"切换到"饮片"
|
||||
- 验证单位从g变为ml
|
||||
- 验证用量自动调整
|
||||
- 验证代煎选项出现
|
||||
|
||||
### 测试 4:数据保存
|
||||
- 填写完整处方信息
|
||||
- 保存处方
|
||||
- 验证用量数据正确存储
|
||||
- 验证代煎选项正确存储
|
||||
@@ -1,321 +0,0 @@
|
||||
# 处方组件重复请求修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
在预约列表页面点击"开处方"或"查看病历"按钮时,会出现重复请求 `/tcm.prescription/detail` 接口的问题,导致错误。
|
||||
|
||||
## 问题原因
|
||||
|
||||
### 1. 快速连续点击
|
||||
|
||||
用户可能快速连续点击按钮,导致 `open()` 或 `openById()` 方法被多次调用。
|
||||
|
||||
### 2. 异步操作未加锁
|
||||
|
||||
`open()` 和 `openById()` 方法是异步的,但没有加锁机制,导致:
|
||||
- 第一次点击开始执行
|
||||
- 第二次点击也开始执行
|
||||
- 两次请求同时发送
|
||||
|
||||
### 3. 代码流程
|
||||
|
||||
```javascript
|
||||
const open = async (data: any) => {
|
||||
// 没有防重复检查
|
||||
await loadDictOptions()
|
||||
|
||||
if (appointmentId) {
|
||||
const existing = await prescriptionGetByAppointment(...) // 请求1
|
||||
if (existing?.id) {
|
||||
const detail = await prescriptionDetail(...) // 请求2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 添加防重复打开标志
|
||||
|
||||
使用 `isOpening` 标志来防止重复调用:
|
||||
|
||||
```typescript
|
||||
// 防止重复打开
|
||||
const isOpening = ref(false)
|
||||
|
||||
const open = async (data: any, options?: { templateId?: number; stationName?: string }) => {
|
||||
// 防止重复打开
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
return
|
||||
}
|
||||
|
||||
isOpening.value = true
|
||||
|
||||
try {
|
||||
// 原有逻辑...
|
||||
} finally {
|
||||
// 延迟重置,避免快速连续点击
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键点
|
||||
|
||||
1. **检查标志**:方法开始时检查 `isOpening.value`,如果为 true 则直接返回
|
||||
2. **设置标志**:开始执行时设置 `isOpening.value = true`
|
||||
3. **finally 重置**:无论成功还是失败,都在 finally 中重置标志
|
||||
4. **延迟重置**:使用 `setTimeout` 延迟 500ms 重置,防止快速连续点击
|
||||
|
||||
## 修改的文件
|
||||
|
||||
**文件**:`admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
### 1. 添加防重复标志
|
||||
|
||||
```typescript
|
||||
// 防止重复打开
|
||||
const isOpening = ref(false)
|
||||
```
|
||||
|
||||
### 2. 修改 open 方法
|
||||
|
||||
```typescript
|
||||
const open = async (data: any, options?: { templateId?: number; stationName?: string }) => {
|
||||
// 防止重复打开
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
return
|
||||
}
|
||||
|
||||
isOpening.value = true
|
||||
|
||||
try {
|
||||
// 原有逻辑...
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 修改 openById 方法
|
||||
|
||||
```typescript
|
||||
const openById = async (prescriptionId: number) => {
|
||||
// 防止重复打开
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
return
|
||||
}
|
||||
|
||||
isOpening.value = true
|
||||
|
||||
try {
|
||||
const res = await prescriptionDetail({ id: prescriptionId })
|
||||
savedPrescription.value = res
|
||||
if (res?.case_record && Object.keys(res.case_record).length > 0) {
|
||||
await loadDictOptions()
|
||||
}
|
||||
visible.value = true
|
||||
} catch (e) {
|
||||
feedback.msgError('加载处方失败')
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 修复前
|
||||
|
||||
```
|
||||
用户点击"开处方"按钮
|
||||
↓
|
||||
调用 open() 方法
|
||||
↓
|
||||
用户再次点击(快速连续点击)
|
||||
↓
|
||||
再次调用 open() 方法
|
||||
↓
|
||||
两次请求同时发送
|
||||
↓
|
||||
❌ 重复请求错误
|
||||
```
|
||||
|
||||
### 修复后
|
||||
|
||||
```
|
||||
用户点击"开处方"按钮
|
||||
↓
|
||||
调用 open() 方法
|
||||
↓
|
||||
设置 isOpening = true
|
||||
↓
|
||||
用户再次点击(快速连续点击)
|
||||
↓
|
||||
检查 isOpening = true
|
||||
↓
|
||||
✓ 直接返回,不执行
|
||||
↓
|
||||
第一次请求完成
|
||||
↓
|
||||
500ms 后重置 isOpening = false
|
||||
```
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1:快速连续点击
|
||||
|
||||
**操作**:快速连续点击"开处方"按钮 2-3 次
|
||||
|
||||
**预期结果**:
|
||||
- 只发送一次请求
|
||||
- 控制台显示警告:"处方正在打开中,请勿重复点击"
|
||||
- 处方正常打开
|
||||
|
||||
---
|
||||
|
||||
### 场景2:正常点击
|
||||
|
||||
**操作**:正常点击"开处方"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 发送请求
|
||||
- 处方正常打开
|
||||
- 无警告信息
|
||||
|
||||
---
|
||||
|
||||
### 场景3:间隔点击
|
||||
|
||||
**操作**:点击"开处方",等待 1 秒后再次点击
|
||||
|
||||
**预期结果**:
|
||||
- 第一次点击正常打开
|
||||
- 第二次点击也正常打开(因为已过 500ms)
|
||||
|
||||
---
|
||||
|
||||
### 场景4:查看已有处方
|
||||
|
||||
**操作**:点击"查看病历"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 调用 `prescriptionGetByAppointment` 查询
|
||||
- 如果有处方,调用 `prescriptionDetail` 获取详情
|
||||
- 只发送一次 detail 请求
|
||||
|
||||
---
|
||||
|
||||
### 场景5:请求失败
|
||||
|
||||
**操作**:点击"开处方",但请求失败
|
||||
|
||||
**预期结果**:
|
||||
- 显示错误提示
|
||||
- 500ms 后 `isOpening` 重置
|
||||
- 可以再次点击
|
||||
|
||||
## 其他优化建议
|
||||
|
||||
### 1. 按钮禁用状态
|
||||
|
||||
可以在按钮上添加 loading 状态:
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
:loading="isOpening"
|
||||
@click="handlePrescription(row)"
|
||||
>
|
||||
{{ prescriptionActionLabel(row) }}
|
||||
</el-button>
|
||||
```
|
||||
|
||||
但这需要将 `isOpening` 暴露给父组件,可能不太合适。
|
||||
|
||||
### 2. 防抖处理
|
||||
|
||||
可以使用 lodash 的 debounce 函数:
|
||||
|
||||
```typescript
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const debouncedOpen = debounce(open, 500, { leading: true, trailing: false })
|
||||
```
|
||||
|
||||
但当前的解决方案已经足够简单有效。
|
||||
|
||||
### 3. 请求取消
|
||||
|
||||
可以使用 AbortController 取消重复请求:
|
||||
|
||||
```typescript
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const open = async (data: any) => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
abortController = new AbortController()
|
||||
|
||||
// 在请求中使用 signal
|
||||
await fetch(url, { signal: abortController.signal })
|
||||
}
|
||||
```
|
||||
|
||||
但这需要修改 API 调用层,改动较大。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 延迟时间
|
||||
|
||||
当前设置为 500ms,可以根据实际情况调整:
|
||||
- 太短(< 300ms):可能无法有效防止快速连续点击
|
||||
- 太长(> 1000ms):用户体验不好,需要等待较长时间
|
||||
|
||||
### 2. 控制台警告
|
||||
|
||||
`console.warn` 只在开发环境有用,生产环境可以考虑:
|
||||
- 移除警告
|
||||
- 或者改为用户提示:`feedback.msgWarning('请勿重复点击')`
|
||||
|
||||
### 3. 多个入口
|
||||
|
||||
如果有多个地方调用 `open()` 方法,都会受到这个锁的保护。
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 修改的文件
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
|
||||
### 调用的文件
|
||||
- `admin/src/views/tcm/appointment/list.vue` - 预约列表
|
||||
- 其他可能调用处方组件的页面
|
||||
|
||||
## 总结
|
||||
|
||||
✅ 添加 `isOpening` 标志防止重复打开
|
||||
✅ 使用 try-finally 确保标志正确重置
|
||||
✅ 延迟 500ms 重置,防止快速连续点击
|
||||
✅ 同时修复 `open()` 和 `openById()` 方法
|
||||
✅ 添加控制台警告提示
|
||||
|
||||
**效果**:
|
||||
- 防止重复请求
|
||||
- 避免接口错误
|
||||
- 提升用户体验
|
||||
- 代码简单易维护
|
||||
|
||||
**下一步**:
|
||||
1. 测试快速连续点击
|
||||
2. 测试正常点击流程
|
||||
3. 测试请求失败情况
|
||||
4. 考虑是否需要用户提示
|
||||
@@ -1,251 +0,0 @@
|
||||
# 处方字段完整修复总结
|
||||
|
||||
## 问题描述
|
||||
前端添加了新字段(用量、代煎、每天几次),但后端没有相应处理,导致编辑保存时数据丢失。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 数据库字段 ✅
|
||||
|
||||
**文件**: `add_prescription_complete_fields.sql`
|
||||
|
||||
```sql
|
||||
-- 用量相关字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN IF NOT EXISTS `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN IF NOT EXISTS `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
|
||||
-- 每天几次字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `times_per_day` int(11) NOT NULL DEFAULT 2 COMMENT '每天服用次数';
|
||||
```
|
||||
|
||||
### 2. 后端修复 ✅
|
||||
|
||||
#### 文件: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
**add() 方法** - 添加处方时保存新字段:
|
||||
```php
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? 2),
|
||||
```
|
||||
|
||||
**edit() 方法** - 编辑处方时更新新字段:
|
||||
```php
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? $prescription->times_per_day ?? 2),
|
||||
```
|
||||
|
||||
### 3. 前端修复 ✅
|
||||
|
||||
#### 文件: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
**editForm 定义** - 添加新字段:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
// ...
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
times_per_day: 2,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**handleEdit() 方法** - 加载数据时填充新字段:
|
||||
```typescript
|
||||
editForm.dosage_amount = row.dosage_amount ?? undefined
|
||||
editForm.dosage_unit = row.dosage_unit || ''
|
||||
editForm.need_decoction = row.need_decoction === 1 || row.need_decoction === true
|
||||
editForm.times_per_day = row.times_per_day ?? 2
|
||||
```
|
||||
|
||||
**watch 监听** - 处方类型变化时自动调整:
|
||||
```typescript
|
||||
watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `dosage_amount` | decimal(10,2) | NULL | 用量数值 |
|
||||
| `dosage_unit` | varchar(10) | '' | 用量单位 (g/ml) |
|
||||
| `need_decoction` | tinyint(1) | 0 | 是否代煎 (0=否, 1=是) |
|
||||
| `times_per_day` | int(11) | 2 | 每天服用次数 |
|
||||
|
||||
## 数据流转
|
||||
|
||||
### 创建处方
|
||||
```
|
||||
前端表单
|
||||
↓
|
||||
{
|
||||
prescription_type: "浓缩水丸",
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false,
|
||||
times_per_day: 2
|
||||
}
|
||||
↓
|
||||
PrescriptionLogic::add()
|
||||
↓
|
||||
数据库保存
|
||||
```
|
||||
|
||||
### 编辑处方
|
||||
```
|
||||
数据库读取
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5.00,
|
||||
dosage_unit: "g",
|
||||
need_decoction: 0,
|
||||
times_per_day: 2
|
||||
}
|
||||
↓
|
||||
前端 editForm 填充
|
||||
↓
|
||||
用户修改
|
||||
↓
|
||||
PrescriptionLogic::edit()
|
||||
↓
|
||||
数据库更新
|
||||
```
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 备份数据库 ⚠️
|
||||
```bash
|
||||
mysqldump -u用户名 -p数据库名 > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
### 2. 执行数据库迁移 ⚠️ 必须
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_complete_fields.sql
|
||||
```
|
||||
|
||||
### 3. 验证字段添加
|
||||
```sql
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'dosage%';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'need_decoction';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'times_per_day';
|
||||
```
|
||||
|
||||
### 4. 部署后端代码
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
- server/app/adminapi/logic/tcm/PrescriptionLogic.php
|
||||
- server/app/common/model/tcm/Prescription.php
|
||||
```
|
||||
|
||||
### 5. 部署前端代码
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
# 上传 dist/ 目录
|
||||
```
|
||||
|
||||
### 6. 清除缓存(如需要)
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 创建处方测试
|
||||
- [ ] 创建浓缩水丸处方,设置用量5g,每天2次
|
||||
- [ ] 创建饮片处方,设置用量150ml,代煎,每天3次
|
||||
- [ ] 创建颗粒处方,设置用量15.5g,每天2次
|
||||
- [ ] 保存后查看数据库,验证字段正确存储
|
||||
|
||||
### 编辑处方测试
|
||||
- [ ] 编辑已有处方,修改用量
|
||||
- [ ] 编辑饮片处方,修改代煎选项
|
||||
- [ ] 编辑处方,修改每天几次
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存后查看数据库,验证字段正确更新
|
||||
|
||||
### 数据加载测试
|
||||
- [ ] 打开编辑对话框,验证用量正确显示
|
||||
- [ ] 验证代煎选项正确显示
|
||||
- [ ] 验证每天几次正确显示
|
||||
- [ ] 验证旧数据(无新字段)不报错
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 问题 1: 用量字段不保存 ✅
|
||||
**原因**: 后端 `add()` 和 `edit()` 方法没有处理 `dosage_amount`, `dosage_unit`, `need_decoction` 字段
|
||||
**修复**: 在两个方法中添加字段处理逻辑
|
||||
|
||||
### 问题 2: 每天几次不保存 ✅
|
||||
**原因**:
|
||||
1. 数据库没有 `times_per_day` 字段
|
||||
2. 后端没有处理该字段
|
||||
3. 前端 editForm 定义错误(空字符串而非数字)
|
||||
|
||||
**修复**:
|
||||
1. 添加数据库字段
|
||||
2. 后端添加字段处理
|
||||
3. 前端修正默认值为数字 2
|
||||
|
||||
### 问题 3: 编辑时数据不加载 ✅
|
||||
**原因**: `handleEdit()` 方法没有加载新字段
|
||||
**修复**: 在加载数据时填充所有新字段
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_complete_fields.sql` - 完整字段迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方列表
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 数据库实现
|
||||
- `PRESCRIPTION_DOSAGE_COMPLETE_SUMMARY.md` - 完整总结
|
||||
- `PRESCRIPTION_FIELDS_FIX_SUMMARY.md` - 本文档
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **必须先执行数据库迁移**,否则保存时会报错
|
||||
2. **旧数据兼容**:旧处方的新字段为 NULL 或默认值,不影响显示
|
||||
3. **类型转换**:
|
||||
- `dosage_amount`: float
|
||||
- `need_decoction`: boolean (前端) → int (后端/数据库)
|
||||
- `times_per_day`: int
|
||||
4. **数据验证**:前端使用下拉和数字输入框确保数据有效性
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **数据库**: 4个字段已添加
|
||||
✅ **后端**: add() 和 edit() 方法已更新
|
||||
✅ **前端**: editForm 定义和数据加载已修复
|
||||
✅ **数据流转**: 完整的保存和读取流程
|
||||
|
||||
**状态**: 所有问题已修复,功能完整可用!🎉
|
||||
@@ -1,228 +0,0 @@
|
||||
# 预约处方查询权限控制
|
||||
|
||||
## 修改说明
|
||||
|
||||
为 `getByAppointment` 接口添加了权限检查,确保用户只能查询自己有权限查看的处方。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. Controller 层修改
|
||||
|
||||
**文件**: `server/app/adminapi/controller/tcm/PrescriptionController.php`
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
public function getByAppointment()
|
||||
{
|
||||
$appointmentId = (int)($this->request->get('appointment_id') ?? 0);
|
||||
if (!$appointmentId) {
|
||||
return $this->fail('预约ID不能为空');
|
||||
}
|
||||
$prescription = PrescriptionLogic::getByAppointment($appointmentId);
|
||||
return $this->data($prescription ?? []);
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
public function getByAppointment()
|
||||
{
|
||||
$appointmentId = (int)($this->request->get('appointment_id') ?? 0);
|
||||
if (!$appointmentId) {
|
||||
return $this->fail('预约ID不能为空');
|
||||
}
|
||||
$prescription = PrescriptionLogic::getByAppointment($appointmentId, (int)$this->adminId, $this->adminInfo);
|
||||
if ($prescription === null) {
|
||||
$msg = PrescriptionLogic::getError();
|
||||
return $this->fail($msg !== '' ? $msg : '未找到处方或无权限查看');
|
||||
}
|
||||
return $this->data($prescription);
|
||||
}
|
||||
```
|
||||
|
||||
**改进点**:
|
||||
- 传入当前登录用户的 `adminId` 和 `adminInfo`
|
||||
- 处理权限检查失败的情况,返回明确的错误信息
|
||||
- 区分"未找到处方"和"无权限查看"两种情况
|
||||
|
||||
### 2. Logic 层修改
|
||||
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
**修改前**:
|
||||
```php
|
||||
/**
|
||||
* 根据预约ID获取处方
|
||||
*/
|
||||
public static function getByAppointment(int $appointmentId): ?array
|
||||
{
|
||||
$row = Prescription::where('appointment_id', $appointmentId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
return $row ? $row->toArray() : null;
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```php
|
||||
/**
|
||||
* 根据预约ID获取处方(带权限检查)
|
||||
*/
|
||||
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
|
||||
{
|
||||
self::$error = '';
|
||||
$row = Prescription::where('appointment_id', $appointmentId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 权限检查:只返回当前用户有权限查看的处方
|
||||
if (!self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)) {
|
||||
self::setError('无权限查看此处方');
|
||||
return null;
|
||||
}
|
||||
|
||||
return $row->toArray();
|
||||
}
|
||||
```
|
||||
|
||||
**改进点**:
|
||||
- 添加 `viewerAdminId` 和 `viewerAdminInfo` 参数
|
||||
- 使用 `canViewPrescription()` 方法进行权限检查
|
||||
- 设置错误信息,便于前端显示
|
||||
|
||||
## 权限规则
|
||||
|
||||
根据 `canViewPrescription()` 方法,以下用户可以查看处方:
|
||||
|
||||
1. **超级管理员** - 可以查看所有处方
|
||||
2. **处方创建人** - 可以查看自己创建的处方
|
||||
3. **医助** - 可以查看自己协助的处方
|
||||
4. **共享处方** - 所有人可以查看标记为共享的处方
|
||||
5. **可见角色** - 处方指定的可见角色可以查看
|
||||
6. **有开方权限的医生** - 拥有 `tcm.diagnosis/kaifang` 权限的医生可以查看所有处方(只读)
|
||||
|
||||
## 使用场景
|
||||
|
||||
这个接口主要用于以下场景:
|
||||
|
||||
1. **查看病历按钮** - 在预约列表中点击"查看病历"时调用
|
||||
2. **处方历史查询** - 根据预约ID查询患者的历史处方
|
||||
3. **医生协作** - 其他医生查看患者的处方记录(需要有开方权限)
|
||||
|
||||
## 前端调用示例
|
||||
|
||||
```typescript
|
||||
// admin/src/views/tcm/appointment/list.vue
|
||||
const handleViewCase = async (row: any) => {
|
||||
if (!row.id) {
|
||||
feedback.msgWarning('预约信息不完整')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const prescription = await prescriptionGetByAppointment({ appointment_id: row.id })
|
||||
if (prescription?.id) {
|
||||
prescriptionRef.value?.openById(prescription.id)
|
||||
} else {
|
||||
feedback.msgWarning('该预约暂无病历记录,请先开方')
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 权限不足或其他错误
|
||||
feedback.msgError(error?.msg || '获取病历失败')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 可能的错误信息
|
||||
|
||||
1. **"预约ID不能为空"** - 未传入 `appointment_id` 参数
|
||||
2. **"未找到处方或无权限查看"** - 处方不存在或当前用户无权限查看
|
||||
3. **"无权限查看此处方"** - 处方存在但当前用户无权限查看
|
||||
|
||||
### 前端处理建议
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const prescription = await prescriptionGetByAppointment({ appointment_id: row.id })
|
||||
// 成功获取处方
|
||||
} catch (error: any) {
|
||||
if (error?.msg?.includes('无权限')) {
|
||||
feedback.msgWarning('您没有权限查看此处方')
|
||||
} else if (error?.msg?.includes('未找到')) {
|
||||
feedback.msgWarning('该预约暂无病历记录')
|
||||
} else {
|
||||
feedback.msgError(error?.msg || '获取病历失败')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 权限测试
|
||||
|
||||
**测试用例 1: 创建人查看自己的处方**
|
||||
- 医生A创建处方
|
||||
- 医生A查看该处方
|
||||
- 预期:成功返回处方数据
|
||||
|
||||
**测试用例 2: 其他医生查看处方(有开方权限)**
|
||||
- 医生A创建处方
|
||||
- 医生B(有 `tcm.diagnosis/kaifang` 权限)查看该处方
|
||||
- 预期:成功返回处方数据(只读)
|
||||
|
||||
**测试用例 3: 其他医生查看处方(无开方权限)**
|
||||
- 医生A创建处方
|
||||
- 医生C(无 `tcm.diagnosis/kaifang` 权限)查看该处方
|
||||
- 预期:返回"无权限查看此处方"错误
|
||||
|
||||
**测试用例 4: 医助查看协助的处方**
|
||||
- 医生A创建处方,医助B协助
|
||||
- 医助B查看该处方
|
||||
- 预期:成功返回处方数据
|
||||
|
||||
**测试用例 5: 查看共享处方**
|
||||
- 医生A创建处方并标记为共享
|
||||
- 任何用户查看该处方
|
||||
- 预期:成功返回处方数据
|
||||
|
||||
### 2. 边界测试
|
||||
|
||||
**测试用例 6: 预约不存在**
|
||||
- 传入不存在的 `appointment_id`
|
||||
- 预期:返回"未找到处方或无权限查看"
|
||||
|
||||
**测试用例 7: 预约存在但无处方**
|
||||
- 传入存在的 `appointment_id`,但该预约没有创建处方
|
||||
- 预期:返回"未找到处方或无权限查看"
|
||||
|
||||
**测试用例 8: 处方已删除**
|
||||
- 传入已删除处方的 `appointment_id`
|
||||
- 预期:返回"未找到处方或无权限查看"
|
||||
|
||||
## 安全性说明
|
||||
|
||||
1. **防止越权访问** - 用户只能查看自己有权限的处方,防止查看其他医生的私有处方
|
||||
2. **数据隔离** - 通过权限检查实现数据隔离,保护患者隐私
|
||||
3. **审计追踪** - 所有查询操作都会记录当前用户信息,便于审计
|
||||
4. **错误信息脱敏** - 对于无权限的情况,不暴露处方是否存在的详细信息
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `PRESCRIPTION_VIEW_PERMISSION_FIX.md` - 处方查看权限修复文档
|
||||
- `BUG_FIXES_SUMMARY.md` - Bug修复总结文档
|
||||
|
||||
## 总结
|
||||
|
||||
通过添加权限检查,确保了:
|
||||
- ✅ 用户只能查询自己有权限查看的处方
|
||||
- ✅ 防止越权访问其他医生的私有处方
|
||||
- ✅ 支持医生协作(有开方权限的医生可以查看所有处方)
|
||||
- ✅ 保护患者隐私和数据安全
|
||||
- ✅ 提供明确的错误信息,便于前端处理
|
||||
@@ -1,318 +0,0 @@
|
||||
# 处方订单最小金额验证优化
|
||||
|
||||
## 需求说明
|
||||
|
||||
在创建处方订单时,如果关联的支付单总金额小于配置的最小金额(`PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT`),则不允许创建订单。
|
||||
|
||||
## 配置说明
|
||||
|
||||
**配置文件**:`server/.env`
|
||||
|
||||
```env
|
||||
PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="100"
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 单位:元(人民币)
|
||||
- 默认值:0(不校验)
|
||||
- 当前配置:100元
|
||||
|
||||
## 验证规则
|
||||
|
||||
### 修改前(原逻辑)
|
||||
|
||||
```
|
||||
1. 订单金额 >= 最小金额 ✓
|
||||
2. 每个关联支付单金额 >= 最小金额 ✓
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 要求每个支付单都必须 >= 100元
|
||||
- 不允许多个小额支付单组合(如 60元 + 50元 = 110元)
|
||||
|
||||
### 修改后(新逻辑)
|
||||
|
||||
```
|
||||
1. 订单金额 >= 最小金额 ✓
|
||||
2. 必须关联至少一个支付单 ✓
|
||||
3. 关联支付单总金额 >= 最小金额 ✓
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- 允许多个小额支付单组合
|
||||
- 更灵活的支付方式
|
||||
- 总金额达标即可
|
||||
|
||||
## 代码修改
|
||||
|
||||
**文件**:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
**方法**:`assertDepositAndLinkPayRules()`
|
||||
|
||||
### 修改内容
|
||||
|
||||
```php
|
||||
public static function assertDepositAndLinkPayRules(float $orderAmount, array $payOrderIds): ?string
|
||||
{
|
||||
$min = self::depositMinAmount();
|
||||
if ($min <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 检查订单金额
|
||||
if ($orderAmount < $min) {
|
||||
return '订单金额须大于等于定金门槛 ¥' . $min . '(当前 ¥' . $orderAmount . ')';
|
||||
}
|
||||
|
||||
// 2. 检查是否关联支付单
|
||||
if ($payOrderIds === []) {
|
||||
return '未关联支付单,关联支付单总金额须大于等于定金门槛 ¥' . $min;
|
||||
}
|
||||
|
||||
// 3. 检查关联支付单的总金额
|
||||
$rows = Order::whereIn('id', $payOrderIds)->whereNull('delete_time')->column('amount', 'id');
|
||||
$totalAmount = 0.0;
|
||||
foreach ($payOrderIds as $pid) {
|
||||
$pid = (int) $pid;
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$amt = round((float) ($rows[$pid] ?? 0), 2);
|
||||
$totalAmount += $amt;
|
||||
}
|
||||
|
||||
if ($totalAmount < $min) {
|
||||
return '关联支付单总金额须大于等于定金门槛 ¥' . $min . '(当前总金额 ¥' . $totalAmount . ')';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## 验证场景
|
||||
|
||||
### 场景1:订单金额不足
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:80元
|
||||
- 关联支付单:[100元]
|
||||
|
||||
**结果**:❌ 失败
|
||||
**错误信息**:订单金额须大于等于定金门槛 ¥100(当前 ¥80)
|
||||
|
||||
---
|
||||
|
||||
### 场景2:未关联支付单
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:150元
|
||||
- 关联支付单:[]
|
||||
|
||||
**结果**:❌ 失败
|
||||
**错误信息**:未关联支付单,关联支付单总金额须大于等于定金门槛 ¥100
|
||||
|
||||
---
|
||||
|
||||
### 场景3:关联支付单总金额不足
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:150元
|
||||
- 关联支付单:[30元, 40元, 20元]
|
||||
- 总金额:90元
|
||||
|
||||
**结果**:❌ 失败
|
||||
**错误信息**:关联支付单总金额须大于等于定金门槛 ¥100(当前总金额 ¥90)
|
||||
|
||||
---
|
||||
|
||||
### 场景4:多个小额支付单组合达标
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:150元
|
||||
- 关联支付单:[60元, 50元]
|
||||
- 总金额:110元
|
||||
|
||||
**结果**:✅ 成功
|
||||
**说明**:虽然单个支付单都小于100元,但总金额达标
|
||||
|
||||
---
|
||||
|
||||
### 场景5:单个大额支付单
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:150元
|
||||
- 关联支付单:[120元]
|
||||
- 总金额:120元
|
||||
|
||||
**结果**:✅ 成功
|
||||
|
||||
---
|
||||
|
||||
### 场景6:配置为0(不校验)
|
||||
|
||||
**配置**:最小金额 = 0元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:10元
|
||||
- 关联支付单:[]
|
||||
|
||||
**结果**:✅ 成功
|
||||
**说明**:最小金额为0时,不进行任何校验
|
||||
|
||||
---
|
||||
|
||||
### 场景7:多个支付单,总金额刚好达标
|
||||
|
||||
**配置**:最小金额 = 100元
|
||||
|
||||
**输入**:
|
||||
- 订单金额:150元
|
||||
- 关联支付单:[30元, 30元, 40元]
|
||||
- 总金额:100元
|
||||
|
||||
**结果**:✅ 成功
|
||||
**说明**:总金额刚好等于最小金额,符合"大于等于"的要求
|
||||
|
||||
## 前端提示优化
|
||||
|
||||
**文件**:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
**当前提示**:
|
||||
```
|
||||
支付单审核时可对照此处关联的收款记录。
|
||||
列表仅展示金额大于等于定金门槛的收款单(门槛为 0 时展示全部)。
|
||||
```
|
||||
|
||||
**建议修改为**:
|
||||
```
|
||||
支付单审核时可对照此处关联的收款记录。
|
||||
关联支付单总金额须大于等于定金门槛(当前门槛:¥100)。
|
||||
列表仅展示金额大于等于定金门槛的收款单(门槛为 0 时展示全部)。
|
||||
```
|
||||
|
||||
## 配置管理
|
||||
|
||||
### 查看当前配置
|
||||
|
||||
```bash
|
||||
# 查看 .env 文件
|
||||
cat server/.env | grep PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT
|
||||
```
|
||||
|
||||
### 修改配置
|
||||
|
||||
```bash
|
||||
# 编辑 .env 文件
|
||||
vim server/.env
|
||||
|
||||
# 修改为 200 元
|
||||
PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="200"
|
||||
|
||||
# 修改为 0(不校验)
|
||||
PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="0"
|
||||
```
|
||||
|
||||
### 配置生效
|
||||
|
||||
修改 `.env` 文件后,配置会立即生效,无需重启服务。
|
||||
|
||||
## 业务逻辑说明
|
||||
|
||||
### 为什么需要最小金额限制?
|
||||
|
||||
1. **定金机制**:确保客户已支付足够的定金
|
||||
2. **风险控制**:避免小额订单的履约风险
|
||||
3. **业务规范**:统一订单金额标准
|
||||
|
||||
### 为什么改为总金额验证?
|
||||
|
||||
1. **灵活性**:允许客户分多次支付
|
||||
2. **用户体验**:不强制单次支付大额
|
||||
3. **实际需求**:多次小额支付也能达到定金要求
|
||||
|
||||
### 为什么必须关联支付单?
|
||||
|
||||
1. **资金确认**:确保订单有对应的收款记录
|
||||
2. **审核依据**:支付单审核时需要对照
|
||||
3. **财务管理**:便于对账和统计
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 单元测试
|
||||
|
||||
```php
|
||||
// 测试订单金额不足
|
||||
$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(80, [1]);
|
||||
assert($result !== null);
|
||||
|
||||
// 测试未关联支付单
|
||||
$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, []);
|
||||
assert($result !== null);
|
||||
|
||||
// 测试总金额不足
|
||||
$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, [1, 2]); // 假设总金额 < 100
|
||||
assert($result !== null);
|
||||
|
||||
// 测试总金额达标
|
||||
$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, [3, 4]); // 假设总金额 >= 100
|
||||
assert($result === null);
|
||||
```
|
||||
|
||||
### 2. 集成测试
|
||||
|
||||
1. 创建多个小额支付单(如 60元、50元)
|
||||
2. 创建订单,关联这些支付单
|
||||
3. 验证是否能成功创建
|
||||
4. 验证错误提示是否正确
|
||||
|
||||
### 3. 边界测试
|
||||
|
||||
- 总金额刚好等于最小金额(100元)
|
||||
- 总金额略小于最小金额(99.99元)
|
||||
- 总金额略大于最小金额(100.01元)
|
||||
- 最小金额为0
|
||||
- 最小金额为负数(应该按0处理)
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 配置文件
|
||||
- `server/.env` - 环境配置
|
||||
- `server/config/prescription_order.php` - 订单配置
|
||||
|
||||
### 后端文件
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 订单逻辑
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php` - 订单验证
|
||||
|
||||
### 前端文件
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表页面
|
||||
|
||||
## 总结
|
||||
|
||||
✅ 修改验证逻辑:从"每个支付单"改为"支付单总金额"
|
||||
✅ 添加必须关联支付单的验证
|
||||
✅ 优化错误提示信息,显示当前总金额
|
||||
✅ 支持多个小额支付单组合
|
||||
✅ 保持订单金额验证不变
|
||||
✅ 配置为0时不进行校验
|
||||
|
||||
**优势**:
|
||||
- 更灵活的支付方式
|
||||
- 更好的用户体验
|
||||
- 更清晰的错误提示
|
||||
- 更符合实际业务需求
|
||||
|
||||
**下一步**:
|
||||
1. 测试各种场景
|
||||
2. 更新前端提示文字
|
||||
3. 编写单元测试
|
||||
4. 更新用户文档
|
||||
@@ -1,387 +0,0 @@
|
||||
# 订单撤回后重置处方审核状态
|
||||
|
||||
## 需求说明
|
||||
|
||||
当用户在订单列表中点击"撤回"按钮后,除了将订单状态改为"已取消",还需要将关联的处方审核状态重置为"待审核",以便重新审核。
|
||||
|
||||
## 业务场景
|
||||
|
||||
### 场景描述
|
||||
|
||||
1. 医生创建处方
|
||||
2. 处方审核通过(`audit_status = 1`)
|
||||
3. 创建业务订单(`fulfillment_status = 1` 待双审通过)
|
||||
4. 用户点击"撤回"订单
|
||||
5. **期望**:处方审核状态重置为"待审核"(`audit_status = 0`)
|
||||
|
||||
### 为什么需要重置?
|
||||
|
||||
1. **重新审核**:撤回订单可能是因为处方有问题,需要重新审核
|
||||
2. **状态一致性**:订单撤回后,处方应该回到初始状态
|
||||
3. **业务流程**:允许医生修改处方后重新提交审核
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### 处方表(zyt_tcm_prescription)
|
||||
|
||||
```sql
|
||||
`audit_status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '审核:0待审核 1已通过 2已驳回(同时作废)'
|
||||
`audit_time` int(10) unsigned DEFAULT NULL COMMENT '审核时间'
|
||||
`audit_by` int(11) unsigned DEFAULT NULL COMMENT '审核人ID'
|
||||
`audit_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '审核意见'
|
||||
```
|
||||
|
||||
**审核状态**:
|
||||
- 0 = 待审核
|
||||
- 1 = 已通过
|
||||
- 2 = 已驳回(同时作废)
|
||||
|
||||
### 业务订单表(zyt_tcm_prescription_order)
|
||||
|
||||
```sql
|
||||
`prescription_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '消费者处方ID'
|
||||
`fulfillment_status` tinyint(2) unsigned NOT NULL DEFAULT 1 COMMENT '1待双审通过 2履约中 3已完成 4已取消'
|
||||
```
|
||||
|
||||
**履约状态**:
|
||||
- 1 = 待双审通过
|
||||
- 2 = 履约中
|
||||
- 3 = 已完成
|
||||
- 4 = 已取消(撤回)
|
||||
|
||||
## 代码修改
|
||||
|
||||
**文件**:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
**方法**:`withdraw()`
|
||||
|
||||
### 修改内容
|
||||
|
||||
```php
|
||||
public static function withdraw(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::$error = '订单不存在';
|
||||
return false;
|
||||
}
|
||||
if (!self::canWithdrawOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限撤回';
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status !== 1) {
|
||||
self::$error = '仅「待双审通过」状态可撤回;双审通过后请走履约/完成流程';
|
||||
return false;
|
||||
}
|
||||
|
||||
$order->fulfillment_status = 4;
|
||||
self::clearPayOrderLinks($order);
|
||||
|
||||
try {
|
||||
$order->save();
|
||||
|
||||
// 撤回订单后,将关联的处方审核状态改回"待审核"
|
||||
$prescriptionId = (int) $order->prescription_id;
|
||||
if ($prescriptionId > 0) {
|
||||
Prescription::where('id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'audit_status' => 0, // 0=待审核
|
||||
'audit_time' => null,
|
||||
'audit_by' => null,
|
||||
'audit_remark' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
```
|
||||
|
||||
### 修改说明
|
||||
|
||||
1. **保存订单状态**:`$order->save()`
|
||||
2. **获取处方ID**:`$prescriptionId = (int) $order->prescription_id`
|
||||
3. **重置处方审核状态**:
|
||||
- `audit_status = 0`(待审核)
|
||||
- `audit_time = null`(清空审核时间)
|
||||
- `audit_by = null`(清空审核人)
|
||||
- `audit_remark = ''`(清空审核意见)
|
||||
- `update_time = time()`(更新时间)
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 完整流程
|
||||
|
||||
```
|
||||
用户点击"撤回"按钮
|
||||
↓
|
||||
调用 withdraw() 方法
|
||||
↓
|
||||
验证权限和状态
|
||||
↓
|
||||
更新订单状态为"已取消"(4)
|
||||
↓
|
||||
清除支付单关联
|
||||
↓
|
||||
保存订单
|
||||
↓
|
||||
获取关联的处方ID
|
||||
↓
|
||||
重置处方审核状态为"待审核"(0)
|
||||
↓
|
||||
清空审核时间、审核人、审核意见
|
||||
↓
|
||||
返回成功
|
||||
```
|
||||
|
||||
### 前端显示
|
||||
|
||||
**订单列表页面**:
|
||||
- 订单状态:已取消
|
||||
- 撤回按钮:消失(只有"待双审通过"状态才显示)
|
||||
|
||||
**处方列表页面**:
|
||||
- 审核状态:待审核
|
||||
- 可以重新审核
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1:正常撤回
|
||||
|
||||
**初始状态**:
|
||||
- 处方审核状态:已通过(1)
|
||||
- 订单履约状态:待双审通过(1)
|
||||
|
||||
**操作**:点击"撤回"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 订单履约状态:已取消(4)
|
||||
- 处方审核状态:待审核(0)
|
||||
- 审核时间:null
|
||||
- 审核人:null
|
||||
- 审核意见:空
|
||||
|
||||
---
|
||||
|
||||
### 场景2:撤回后重新审核
|
||||
|
||||
**步骤**:
|
||||
1. 撤回订单(处方状态变为"待审核")
|
||||
2. 医生修改处方
|
||||
3. 重新提交审核
|
||||
4. 审核通过
|
||||
5. 创建新订单
|
||||
|
||||
**预期结果**:流程正常,可以重新审核和创建订单
|
||||
|
||||
---
|
||||
|
||||
### 场景3:无权限撤回
|
||||
|
||||
**初始状态**:
|
||||
- 当前用户:非创建人、非医助
|
||||
|
||||
**操作**:点击"撤回"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 错误提示:"无权限撤回"
|
||||
- 处方状态不变
|
||||
|
||||
---
|
||||
|
||||
### 场景4:非待双审状态撤回
|
||||
|
||||
**初始状态**:
|
||||
- 订单履约状态:履约中(2)或已完成(3)
|
||||
|
||||
**操作**:点击"撤回"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 错误提示:"仅「待双审通过」状态可撤回;双审通过后请走履约/完成流程"
|
||||
- 处方状态不变
|
||||
|
||||
---
|
||||
|
||||
### 场景5:处方不存在
|
||||
|
||||
**初始状态**:
|
||||
- 订单关联的处方已被删除
|
||||
|
||||
**操作**:点击"撤回"按钮
|
||||
|
||||
**预期结果**:
|
||||
- 订单状态:已取消(4)
|
||||
- 不会报错(因为有 `if ($prescriptionId > 0)` 判断)
|
||||
|
||||
## 边界情况处理
|
||||
|
||||
### 1. 处方ID为0或null
|
||||
|
||||
```php
|
||||
if ($prescriptionId > 0) {
|
||||
// 只有处方ID有效时才更新
|
||||
}
|
||||
```
|
||||
|
||||
**处理**:跳过处方状态更新,不报错
|
||||
|
||||
### 2. 处方已被删除
|
||||
|
||||
```php
|
||||
->whereNull('delete_time')
|
||||
```
|
||||
|
||||
**处理**:不会更新已删除的处方
|
||||
|
||||
### 3. 数据库更新失败
|
||||
|
||||
```php
|
||||
try {
|
||||
$order->save();
|
||||
// 更新处方状态
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**处理**:捕获异常,返回错误信息
|
||||
|
||||
### 4. 事务一致性
|
||||
|
||||
**问题**:订单保存成功,但处方更新失败怎么办?
|
||||
|
||||
**当前实现**:
|
||||
- 订单和处方更新在同一个 try-catch 块中
|
||||
- 如果处方更新失败,会抛出异常
|
||||
- 异常会导致整个方法返回 false
|
||||
|
||||
**建议优化**:使用数据库事务
|
||||
|
||||
```php
|
||||
Db::startTrans();
|
||||
try {
|
||||
$order->save();
|
||||
|
||||
// 更新处方状态
|
||||
Prescription::where('id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->update([...]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 相关影响
|
||||
|
||||
### 1. 处方列表页面
|
||||
|
||||
**文件**:`admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
**影响**:
|
||||
- 撤回后,处方审核状态显示为"待审核"
|
||||
- 可以重新点击"审核"按钮
|
||||
|
||||
### 2. 订单列表页面
|
||||
|
||||
**文件**:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
**影响**:
|
||||
- 撤回后,订单状态显示为"已取消"
|
||||
- "撤回"按钮消失(只有"待双审通过"状态才显示)
|
||||
|
||||
### 3. 业务流程
|
||||
|
||||
**影响**:
|
||||
- 允许撤回后重新审核处方
|
||||
- 允许重新创建订单
|
||||
- 支付单关联被清除,可以重新关联
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 权限控制
|
||||
|
||||
只有以下用户可以撤回订单:
|
||||
- 订单创建人
|
||||
- 诊单医助
|
||||
- 主管角色
|
||||
|
||||
### 2. 状态限制
|
||||
|
||||
只有"待双审通过"状态的订单可以撤回:
|
||||
- 待双审通过(1):✅ 可以撤回
|
||||
- 履约中(2):❌ 不可撤回
|
||||
- 已完成(3):❌ 不可撤回
|
||||
- 已取消(4):❌ 不可撤回
|
||||
|
||||
### 3. 数据一致性
|
||||
|
||||
撤回操作会:
|
||||
- 更新订单状态
|
||||
- 清除支付单关联
|
||||
- 重置处方审核状态
|
||||
|
||||
建议使用数据库事务确保一致性。
|
||||
|
||||
### 4. 审核历史
|
||||
|
||||
当前实现会清空审核历史(审核时间、审核人、审核意见)。
|
||||
|
||||
如果需要保留审核历史,可以考虑:
|
||||
- 不清空这些字段
|
||||
- 或者创建审核历史表记录
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 后端文件
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 订单逻辑
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
- `server/app/common/model/tcm/PrescriptionOrder.php` - 订单模型
|
||||
|
||||
### 前端文件
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方列表
|
||||
|
||||
### 数据库文件
|
||||
- `server/database/migrations/2026_03_30_prescription_visible_roles_audit.sql` - 处方审核字段
|
||||
- `server/database/migrations/2026_04_07_create_tcm_prescription_order.sql` - 订单表
|
||||
|
||||
## 总结
|
||||
|
||||
✅ 撤回订单时重置处方审核状态为"待审核"
|
||||
✅ 清空审核时间、审核人、审核意见
|
||||
✅ 更新处方的 update_time
|
||||
✅ 保持订单状态为"已取消"
|
||||
✅ 清除支付单关联
|
||||
✅ 异常处理和错误提示
|
||||
|
||||
**优势**:
|
||||
- 支持撤回后重新审核
|
||||
- 状态一致性
|
||||
- 业务流程完整
|
||||
- 用户体验友好
|
||||
|
||||
**建议优化**:
|
||||
- 使用数据库事务确保一致性
|
||||
- 考虑保留审核历史记录
|
||||
- 添加操作日志
|
||||
|
||||
**下一步**:
|
||||
1. 测试撤回功能
|
||||
2. 验证处方状态变化
|
||||
3. 测试重新审核流程
|
||||
4. 考虑添加事务支持
|
||||
@@ -1,199 +0,0 @@
|
||||
# 处方"每天几次"功能添加说明
|
||||
|
||||
## 功能描述
|
||||
|
||||
在中医处方单中添加"每天几次"字段,用于记录患者每天需要服用药物的次数。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 表单界面(admin/src/components/tcm-prescription/index.vue)
|
||||
|
||||
#### 添加表单字段(第 139-145 行)
|
||||
|
||||
在"服用天数"字段后面添加"每天几次"输入框:
|
||||
|
||||
```vue
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数" prop="usage_days">
|
||||
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次" prop="times_per_day">
|
||||
<el-input-number v-model="formData.times_per_day" :min="1" :max="10" class="!w-full" placeholder="每天服用次数" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- 字段名:`times_per_day`
|
||||
- 类型:数字输入框
|
||||
- 最小值:1
|
||||
- 最大值:10
|
||||
- 默认值:2
|
||||
|
||||
### 2. TypeScript 类型定义(第 580-615 行)
|
||||
|
||||
在 `PrescriptionForm` 接口中添加字段:
|
||||
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
usage_days: number
|
||||
times_per_day: number // 新增
|
||||
usage_instruction: string
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据模型初始化(第 650-680 行)
|
||||
|
||||
在 `formData` 中添加默认值:
|
||||
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ... 其他字段
|
||||
usage_days: 7,
|
||||
times_per_day: 2, // 新增,默认每天2次
|
||||
usage_instruction: '水煎服,一日二次',
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 4. 处方预览显示(第 280-285 行)
|
||||
|
||||
在处方打印预览中显示"每天几次"信息:
|
||||
|
||||
```vue
|
||||
<div class="footer-dosage">
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
|
||||
{{ savedPrescription.usage_instruction }}
|
||||
</div>
|
||||
```
|
||||
|
||||
**显示效果:**
|
||||
- 如果设置了 `times_per_day`:显示"服用7天,每天2次, 浓缩水丸 水煎服,一日二次"
|
||||
- 如果未设置:显示"服用7天, 浓缩水丸 水煎服,一日二次"
|
||||
|
||||
## 后端数据库支持
|
||||
|
||||
### 需要添加的数据库字段
|
||||
|
||||
如果后端数据库表中还没有 `times_per_day` 字段,需要执行以下 SQL:
|
||||
|
||||
```sql
|
||||
-- 在处方表中添加 times_per_day 字段
|
||||
ALTER TABLE `zyt_prescription`
|
||||
ADD COLUMN `times_per_day` INT(11) DEFAULT 2 COMMENT '每天服用次数' AFTER `usage_days`;
|
||||
|
||||
-- 如果需要更新现有数据的默认值
|
||||
UPDATE `zyt_prescription` SET `times_per_day` = 2 WHERE `times_per_day` IS NULL;
|
||||
```
|
||||
|
||||
### 后端 API 修改
|
||||
|
||||
确保后端接口支持 `times_per_day` 字段:
|
||||
|
||||
1. **创建/更新处方接口**:接收 `times_per_day` 参数
|
||||
2. **查询处方接口**:返回 `times_per_day` 字段
|
||||
3. **验证规则**:`times_per_day` 应该在 1-10 之间
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 医生操作流程
|
||||
|
||||
1. 打开处方单编辑界面
|
||||
2. 填写患者信息和诊断信息
|
||||
3. 添加中药处方
|
||||
4. 在"服用天数"字段输入天数(如:7)
|
||||
5. 在"每天几次"字段输入次数(如:2)
|
||||
6. 系统会在处方预览中显示:"服用7天,每天2次"
|
||||
|
||||
### 常见用法
|
||||
|
||||
- **每天2次**:早晚各一次(最常见)
|
||||
- **每天3次**:早中晚各一次
|
||||
- **每天1次**:每日一次
|
||||
- **每天4次**:每6小时一次
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. ✅ 输入框显示正常
|
||||
2. ✅ 最小值限制(不能小于1)
|
||||
3. ✅ 最大值限制(不能大于10)
|
||||
4. ✅ 默认值为2
|
||||
5. ✅ 预览显示正确
|
||||
6. ✅ 保存后数据正确
|
||||
|
||||
### 后端测试
|
||||
|
||||
1. ✅ 创建处方时保存 `times_per_day`
|
||||
2. ✅ 更新处方时更新 `times_per_day`
|
||||
3. ✅ 查询处方时返回 `times_per_day`
|
||||
4. ✅ 数据验证(1-10范围)
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. ✅ 创建处方 → 保存 → 查看预览 → 确认显示正确
|
||||
2. ✅ 编辑处方 → 修改次数 → 保存 → 确认更新成功
|
||||
3. ✅ 打印处方 → 确认打印内容包含"每天X次"
|
||||
|
||||
## 界面效果
|
||||
|
||||
### 编辑界面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 处方类型: [浓缩水丸 ▼] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 服用天数: [ 7 ] 每天几次: [ 2 ] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 用法: 水煎服,一日二次 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 预览界面
|
||||
|
||||
```
|
||||
服用7天,每天2次, 浓缩水丸 水煎服,一日二次
|
||||
服用时间: 饭前 服用方式: 温水送服
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **默认值**:新建处方时默认为每天2次(最常见的用法)
|
||||
2. **范围限制**:限制在1-10次之间,超出范围的输入会被自动调整
|
||||
3. **可选字段**:如果不填写,预览中不会显示"每天X次"
|
||||
4. **与用法说明的关系**:`times_per_day` 是结构化数据,`usage_instruction` 是文字描述,两者可以互补
|
||||
|
||||
## 兼容性
|
||||
|
||||
- ✅ 兼容现有处方数据(旧数据没有此字段时不显示)
|
||||
- ✅ 不影响现有功能
|
||||
- ✅ 向后兼容
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **智能提示**:根据处方类型自动推荐合适的服用次数
|
||||
2. **关联验证**:与"用法"字段进行一致性检查
|
||||
3. **统计分析**:统计不同处方类型的常用服用次数
|
||||
4. **模板支持**:在处方模板中包含 `times_per_day` 字段
|
||||
|
||||
## 完成状态
|
||||
|
||||
- ✅ 前端界面添加完成
|
||||
- ✅ 数据模型更新完成
|
||||
- ✅ 类型定义更新完成
|
||||
- ✅ 预览显示更新完成
|
||||
- ⏳ 等待后端数据库字段添加
|
||||
- ⏳ 等待后端 API 支持
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件主文件
|
||||
- 后端需要修改的文件(待确认):
|
||||
- 处方模型文件
|
||||
- 处方控制器文件
|
||||
- 数据库迁移文件
|
||||
@@ -1,129 +0,0 @@
|
||||
# 业务订单中处方查看权限修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
药师角色(角色ID=6)在查看业务订单详情时,提示"无权限查看此处方"。
|
||||
|
||||
虽然药师有业务订单管理权限(`order_edit_all_roles` 包含角色6),但在业务订单详情中需要显示关联的处方信息时,`PrescriptionLogic::canViewPrescription()` 方法会拒绝访问。
|
||||
|
||||
## 问题原因
|
||||
|
||||
`canViewPrescription` 方法的权限检查层级为:
|
||||
1. 超级管理员
|
||||
2. 处方创建人
|
||||
3. 医助
|
||||
4. 共享处方
|
||||
5. 可见角色
|
||||
6. 有开方权限的医生(`tcm.diagnosis/kaifang`)
|
||||
|
||||
药师角色通常没有 `tcm.diagnosis/kaifang` 权限,因此无法通过权限检查。
|
||||
|
||||
## 解决方案
|
||||
|
||||
在 `canViewPrescription` 方法中添加新的权限检查层级:
|
||||
|
||||
**有业务订单管理权限的角色(如药师)可以查看处方(只读)**
|
||||
|
||||
这样药师可以在业务订单中查看关联的处方信息,以便进行订单审核和管理。
|
||||
|
||||
## 代码修改
|
||||
|
||||
文件:`server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
```php
|
||||
public static function canViewPrescription($row, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
// 超级管理员
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 创建人
|
||||
$creatorId = is_array($row) ? (int) ($row['creator_id'] ?? 0) : (int) $row->creator_id;
|
||||
if ($creatorId === $adminId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 医助
|
||||
$assistantId = is_array($row) ? (int) ($row['assistant_id'] ?? 0) : (int) ($row->assistant_id ?? 0);
|
||||
if ($assistantId > 0 && $assistantId === $adminId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 共享处方
|
||||
$isShared = is_array($row) ? (int) ($row['is_shared'] ?? 0) : (int) $row->is_shared;
|
||||
if ($isShared === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 可见角色
|
||||
$vis = is_array($row) ? (string) ($row['visible_role_ids'] ?? '') : (string) ($row->visible_role_ids ?? '');
|
||||
$allowed = array_filter(array_map('intval', explode(',', $vis)));
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
if (count(array_intersect($myRoles, $allowed)) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 有开方权限的医生可以查看所有处方(只读)
|
||||
$permissions = $adminInfo['permissions'] ?? [];
|
||||
if (in_array('tcm.diagnosis/kaifang', $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 【新增】有业务订单管理权限的角色(如药师)可以查看处方(只读)
|
||||
$orderEditAllRoles = Config::get('project.order_edit_all_roles', [0, 3]);
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
$allowedRoles = array_map('intval', is_array($orderEditAllRoles) ? $orderEditAllRoles : []);
|
||||
if (count(array_intersect($myRoles, $allowedRoles)) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 权限层级(更新后)
|
||||
|
||||
1. 超级管理员(root=1)
|
||||
2. 处方创建人
|
||||
3. 医助
|
||||
4. 共享处方(is_shared=1)
|
||||
5. 可见角色(visible_role_ids)
|
||||
6. 有开方权限的医生(`tcm.diagnosis/kaifang`)
|
||||
7. **有业务订单管理权限的角色(`order_edit_all_roles`)** ← 新增
|
||||
|
||||
## 配置文件
|
||||
|
||||
文件:`server/config/project.php`
|
||||
|
||||
```php
|
||||
// 医助创建订单权限:拥有以下角色ID的用户可修改任意订单,其他用户只能修改自己创建的订单
|
||||
'order_edit_all_roles' => [0, 3, 6],
|
||||
```
|
||||
|
||||
- 角色 0:超级管理员
|
||||
- 角色 3:管理员
|
||||
- 角色 6:药师
|
||||
|
||||
## 使用场景
|
||||
|
||||
药师角色现在可以:
|
||||
1. 查看业务订单列表
|
||||
2. 打开业务订单详情
|
||||
3. 查看订单中关联的处方信息(只读)
|
||||
4. 进行处方审核
|
||||
5. 进行支付单审核
|
||||
6. 编辑业务订单信息
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. 使用药师账号登录
|
||||
2. 进入"消费者处方订单"列表
|
||||
3. 点击任意订单的"详情"按钮
|
||||
4. 确认可以正常查看处方信息,不再提示"无权限查看此处方"
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 此权限为只读权限,药师不能编辑处方内容
|
||||
2. 药师只能通过业务订单查看处方,不能直接在处方列表中查看其他医生的处方
|
||||
3. 修改代码后需要清除缓存:`php think clear`
|
||||
@@ -1,164 +0,0 @@
|
||||
# 企业微信客户同步问题修复指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
同步企业微信客户时,能成功获取企业成员列表(207人),但无法获取任何客户数据,所有API调用都返回错误码 `48002: "api forbidden"`。
|
||||
|
||||
## 根本原因
|
||||
|
||||
企业微信应用未开启"客户联系"API权限。错误码 `48002` 表示API接口被禁用或未授权。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案一:开启API权限(推荐)
|
||||
|
||||
1. 登录企业微信管理后台
|
||||
- 访问:https://work.weixin.qq.com/
|
||||
- 使用管理员账号登录
|
||||
|
||||
2. 进入应用管理
|
||||
- 点击"应用管理"
|
||||
- 找到您正在使用的应用(使用 `external_pay_secret` 的应用)
|
||||
|
||||
3. 开启客户联系权限
|
||||
- 在应用详情页面,找到"客户联系"权限设置
|
||||
- 开启以下权限:
|
||||
* ✓ 客户联系 - 获取客户列表
|
||||
* ✓ 客户联系 - 获取客户详情
|
||||
* ✓ 客户联系 - 获取客户数据统计(可选)
|
||||
|
||||
4. 配置可信IP
|
||||
- 在应用设置中找到"企业可信IP"
|
||||
- 添加您的服务器IP地址
|
||||
- 保存配置
|
||||
|
||||
5. 等待生效
|
||||
- 权限修改后可能需要几分钟生效
|
||||
- 建议等待5-10分钟后再测试
|
||||
|
||||
### 方案二:升级企业微信版本
|
||||
|
||||
如果您的企业微信版本不支持客户联系API:
|
||||
|
||||
1. 检查当前版本是否支持"客户联系"功能
|
||||
2. 如需要,升级到支持该功能的版本
|
||||
3. 部分功能可能需要付费版本
|
||||
|
||||
### 方案三:使用替代方案
|
||||
|
||||
如果无法开启API权限:
|
||||
|
||||
1. 使用企业微信后台手动导出客户数据
|
||||
2. 通过CSV导入到系统
|
||||
3. 或使用企业微信的webhook回调功能(需要配置)
|
||||
|
||||
## 诊断工具
|
||||
|
||||
### 1. 运行权限检查命令
|
||||
|
||||
```bash
|
||||
php think qywx:check-permissions
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
- 检查应用配置是否正确
|
||||
- 测试获取部门成员权限
|
||||
- 测试获取客户列表权限
|
||||
- 显示详细的错误信息和解决建议
|
||||
|
||||
### 2. 查看日志
|
||||
|
||||
日志文件位置:`runtime/log/`
|
||||
|
||||
关键日志信息:
|
||||
```
|
||||
企业微信-获取成员客户列表响应 userId=XXX: {"errcode":48002,"errmsg":"api forbidden"...}
|
||||
```
|
||||
|
||||
如果看到 `errcode: 48002`,说明是权限问题。
|
||||
|
||||
## 代码改进
|
||||
|
||||
已对代码进行以下改进:
|
||||
|
||||
1. **更好的错误处理**
|
||||
- `getExternalContactList()` 方法现在会检测 `48002` 错误
|
||||
- 权限错误时返回 `false` 而不是空数组
|
||||
- 同步逻辑会捕获权限错误并抛出明确的异常
|
||||
|
||||
2. **新增诊断方法**
|
||||
- `WechatWorkService::checkAppPermissions()` - 检查应用权限
|
||||
- `QywxCheckPermissions` 命令 - 完整的权限诊断工具
|
||||
|
||||
3. **改进的日志记录**
|
||||
- 权限错误会记录更详细的信息
|
||||
- 包含解决方案提示
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. 确认已开启API权限
|
||||
2. 运行诊断命令:
|
||||
```bash
|
||||
php think qywx:check-permissions
|
||||
```
|
||||
|
||||
3. 如果诊断通过,运行同步:
|
||||
```bash
|
||||
php think qywx:sync-customer
|
||||
```
|
||||
|
||||
4. 检查同步结果:
|
||||
- 查看日志文件
|
||||
- 检查数据库表 `la_qywx_external_contact`
|
||||
- 在管理后台查看客户列表
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 权限已开启,但仍然报错48002
|
||||
A:
|
||||
- 等待5-10分钟让权限生效
|
||||
- 清除企业微信access_token缓存:删除缓存键 `qywx_access_token`
|
||||
- 检查服务器IP是否在可信IP列表中
|
||||
|
||||
### Q2: 找不到"客户联系"权限选项
|
||||
A:
|
||||
- 您的企业微信版本可能不支持此功能
|
||||
- 需要升级到企业版或专业版
|
||||
- 联系企业微信客服确认
|
||||
|
||||
### Q3: 部分成员能获取客户,部分不能
|
||||
A:
|
||||
- 检查成员是否有"客户联系"权限
|
||||
- 在企业微信后台为成员分配权限
|
||||
- 确认成员确实有添加客户
|
||||
|
||||
### Q4: 同步速度很慢
|
||||
A:
|
||||
- 这是正常的,因为需要逐个成员获取客户列表
|
||||
- 207个成员 × 每个成员的客户数 = 大量API调用
|
||||
- 建议设置定时任务,每小时或每天同步一次
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `server/app/adminapi/logic/qywx/CustomerLogic.php` - 同步逻辑
|
||||
- `server/app/common/service/wechat/WechatWorkService.php` - API服务
|
||||
- `server/app/command/QywxSyncCustomer.php` - 同步命令
|
||||
- `server/app/command/QywxCheckPermissions.php` - 诊断命令(新增)
|
||||
- `server/config/project.php` - 配置文件
|
||||
|
||||
## 联系支持
|
||||
|
||||
如果按照以上步骤仍无法解决:
|
||||
|
||||
1. 收集以下信息:
|
||||
- 诊断命令的完整输出
|
||||
- 最近的日志文件
|
||||
- 企业微信应用的权限截图
|
||||
|
||||
2. 联系企业微信技术支持
|
||||
- 官方文档:https://developer.work.weixin.qq.com/
|
||||
- 开发者社区:https://developers.weixin.qq.com/community/business
|
||||
|
||||
---
|
||||
|
||||
最后更新:2024-04-11
|
||||
@@ -1,246 +0,0 @@
|
||||
# 企业微信客户同步功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
自动从企业微信同步外部联系人(客户)信息,包括客户基本信息、跟进人等,用于患者信息匹配和自动关联。
|
||||
|
||||
同步流程:
|
||||
1. 获取企业成员列表(从指定部门开始,递归获取所有子部门成员)
|
||||
2. 遍历每个成员,获取其客户列表
|
||||
3. 获取客户详情并保存到数据库
|
||||
4. 自动去重,避免重复处理同一客户
|
||||
|
||||
## 功能特性
|
||||
|
||||
1. **手动同步**:点击"立即同步"按钮手动触发同步
|
||||
2. **自动同步**:配置定时自动同步,支持多种时间间隔
|
||||
3. **客户管理**:查看客户列表、搜索、查看详情
|
||||
4. **统计信息**:显示总客户数、今日新增、最后同步时间等
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
执行SQL文件创建数据表:
|
||||
|
||||
```bash
|
||||
mysql -u用户名 -p密码 数据库名 < server/database/migrations/create_qywx_external_contact.sql
|
||||
```
|
||||
|
||||
### 2. 配置企业微信
|
||||
|
||||
在 `server/.env` 文件中配置企业微信信息:
|
||||
|
||||
```env
|
||||
# 企业微信配置
|
||||
WECHAT_WORK_CORP_ID=你的企业ID
|
||||
WECHAT_WORK_EXTERNAL_PAY_SECRET=你的应用Secret
|
||||
```
|
||||
|
||||
在 `server/config/project.php` 文件中配置同步部门(可选,默认为1即根部门):
|
||||
|
||||
```php
|
||||
// 企业微信客户同步配置
|
||||
// 从哪个部门开始同步(1=根部门,会递归获取所有子部门成员)
|
||||
'qywx_sync_department_id' => 1,
|
||||
```
|
||||
|
||||
获取方式:
|
||||
- 企业ID:企业微信管理后台 → 我的企业 → 企业信息 → 企业ID
|
||||
- Secret:企业微信管理后台 → 应用管理 → 选择应用 → Secret
|
||||
- 部门ID:企业微信管理后台 → 通讯录 → 选择部门 → 查看部门ID(默认根部门ID为1)
|
||||
|
||||
### 3. 配置权限
|
||||
|
||||
需要在企业微信应用中开启以下权限:
|
||||
- 通讯录管理 → 成员信息读取(用于获取企业成员列表)
|
||||
- 客户联系 → 客户基础信息
|
||||
- 客户联系 → 客户标签
|
||||
- 客户联系 → 客户联系人
|
||||
|
||||
注意:Secret必须是对应应用的Secret,且该应用需要有上述权限。
|
||||
|
||||
### 4. 配置IP白名单(重要!)
|
||||
|
||||
企业微信API有IP白名单限制,必须将服务器IP添加到白名单:
|
||||
|
||||
1. 获取服务器公网IP:`curl ifconfig.me`
|
||||
2. 登录企业微信管理后台 → 应用管理 → 选择应用
|
||||
3. 找到"企业可信IP"设置,添加服务器IP
|
||||
4. 保存并等待几分钟生效
|
||||
|
||||
详细说明请查看:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md)
|
||||
|
||||
### 5. 配置定时任务(可选)
|
||||
|
||||
如果需要自动同步,配置Linux crontab:
|
||||
|
||||
```bash
|
||||
# 编辑crontab
|
||||
crontab -e
|
||||
|
||||
# 添加以下行(每小时执行一次)
|
||||
0 * * * * cd /path/to/your/project/server && php think qywx:sync-customer >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
或者手动强制同步(忽略自动同步设置):
|
||||
```bash
|
||||
php think qywx:sync-customer --force
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 前端页面
|
||||
|
||||
访问路径:`/fans/qywx`
|
||||
|
||||
功能:
|
||||
- 查看客户列表
|
||||
- 搜索客户(按名称、跟进人)
|
||||
- 查看客户详情
|
||||
- 手动同步
|
||||
- 配置自动同步
|
||||
|
||||
### 同步设置
|
||||
|
||||
1. 点击"同步设置"按钮
|
||||
2. 开启"自动同步"开关
|
||||
3. 选择同步间隔(1小时、2小时、4小时、6小时、12小时、24小时)
|
||||
4. 点击"保存"
|
||||
|
||||
### API接口
|
||||
|
||||
#### 1. 获取客户列表
|
||||
```
|
||||
GET /qywx.customer/lists
|
||||
参数:
|
||||
- name: 客户名称(可选)
|
||||
- follow_user: 跟进人(可选)
|
||||
- page: 页码
|
||||
- limit: 每页数量
|
||||
```
|
||||
|
||||
#### 2. 同步客户
|
||||
```
|
||||
POST /qywx.customer/sync
|
||||
返回:
|
||||
- sync_count: 同步数量
|
||||
- new_count: 新增数量
|
||||
- update_count: 更新数量
|
||||
```
|
||||
|
||||
#### 3. 获取统计信息
|
||||
```
|
||||
GET /qywx.customer/stats
|
||||
返回:
|
||||
- total: 总客户数
|
||||
- today: 今日新增
|
||||
- lastSync: 最后同步时间
|
||||
- syncStatus: 同步状态
|
||||
```
|
||||
|
||||
#### 4. 获取同步设置
|
||||
```
|
||||
GET /qywx.customer/getSyncSettings
|
||||
返回:
|
||||
- auto_sync: 是否自动同步
|
||||
- interval: 同步间隔(秒)
|
||||
- last_sync_time: 最后同步时间
|
||||
```
|
||||
|
||||
#### 5. 保存同步设置
|
||||
```
|
||||
POST /qywx.customer/saveSyncSettings
|
||||
参数:
|
||||
- auto_sync: 是否自动同步(boolean)
|
||||
- interval: 同步间隔(秒,3600-86400)
|
||||
```
|
||||
|
||||
## 数据表结构
|
||||
|
||||
### zyt_qywx_external_contact(外部联系人表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键ID |
|
||||
| external_userid | varchar(64) | 外部联系人ID(唯一) |
|
||||
| name | varchar(64) | 客户名称 |
|
||||
| avatar | varchar(255) | 头像URL |
|
||||
| type | tinyint | 类型:1=微信用户,2=企业微信用户 |
|
||||
| gender | tinyint | 性别:0=未知,1=男,2=女 |
|
||||
| unionid | varchar(64) | 微信unionid |
|
||||
| position | varchar(64) | 职位 |
|
||||
| corp_name | varchar(128) | 企业名称 |
|
||||
| corp_full_name | varchar(255) | 企业全称 |
|
||||
| external_profile | text | 扩展信息JSON |
|
||||
| follow_users | text | 跟进人列表JSON |
|
||||
| create_time | int | 添加时间 |
|
||||
| update_time | int | 更新时间 |
|
||||
| delete_time | int | 删除时间 |
|
||||
|
||||
### zyt_qywx_sync_settings(同步设置表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键ID |
|
||||
| setting_key | varchar(64) | 设置键(唯一) |
|
||||
| setting_value | text | 设置值JSON |
|
||||
| create_time | int | 创建时间 |
|
||||
| update_time | int | 更新时间 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API调用限制**:企业微信API有调用频率限制,建议同步间隔不要太短
|
||||
2. **Secret安全**:请妥善保管企业微信Secret,不要泄露
|
||||
3. **权限配置**:确保应用有足够的权限访问客户信息
|
||||
4. **错误处理**:同步失败会记录日志,可在系统日志中查看详细错误信息
|
||||
5. **数据更新**:同步会更新已存在的客户信息,不会删除已有数据
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 1. 错误码 60020 - IP白名单限制
|
||||
|
||||
错误信息:`not allow to access from your ip`
|
||||
|
||||
解决方法:
|
||||
- 将服务器IP添加到企业微信应用的"企业可信IP"白名单
|
||||
- 详细说明:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md)
|
||||
|
||||
### 2. 同步失败
|
||||
|
||||
检查:
|
||||
- 企业微信配置是否正确(corp_id、secret)
|
||||
- 应用权限是否开启
|
||||
- IP是否在白名单中(最常见问题)
|
||||
- 网络是否正常
|
||||
- 查看系统日志:`server/runtime/log/`
|
||||
|
||||
### 3. 获取不到客户
|
||||
|
||||
检查:
|
||||
- 应用是否有客户联系权限和通讯录读取权限
|
||||
- 是否有外部联系人
|
||||
- Secret是否正确(需要使用对应应用的Secret)
|
||||
- 企业成员是否有添加客户
|
||||
- 部门ID配置是否正确(默认1为根部门)
|
||||
|
||||
### 4. missing field `userid` 错误
|
||||
|
||||
这个错误已修复。新版本会先获取企业成员列表,然后遍历每个成员获取其客户。如果仍然出现此错误,请检查:
|
||||
- 应用是否有"通讯录管理-成员信息读取"权限
|
||||
- 配置的Secret是否正确
|
||||
|
||||
### 5. 定时任务不执行
|
||||
|
||||
检查:
|
||||
- crontab是否配置正确
|
||||
- PHP路径是否正确
|
||||
- 项目路径是否正确
|
||||
- 是否开启了自动同步
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [企业微信API文档](https://developer.work.weixin.qq.com/document/)
|
||||
- [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200)
|
||||
- [获取成员客户列表](https://developer.work.weixin.qq.com/document/path/92113)
|
||||
- [获取客户详情](https://developer.work.weixin.qq.com/document/path/92114)
|
||||
@@ -1,88 +0,0 @@
|
||||
# 企业微信IP白名单配置指南
|
||||
|
||||
## 问题现象
|
||||
|
||||
同步企业微信客户时报错:
|
||||
```
|
||||
errcode: 60020
|
||||
errmsg: not allow to access from your ip
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
企业微信API有IP白名单限制,只有在白名单中的IP才能调用API。
|
||||
|
||||
## 解决方法
|
||||
|
||||
### 1. 获取服务器IP地址
|
||||
|
||||
在服务器上运行以下命令获取公网IP:
|
||||
|
||||
```bash
|
||||
curl ifconfig.me
|
||||
```
|
||||
|
||||
或者查看日志中的IP地址,例如:
|
||||
```
|
||||
from ip: 8.219.70.152
|
||||
```
|
||||
|
||||
### 2. 配置企业微信IP白名单
|
||||
|
||||
1. 登录企业微信管理后台:https://work.weixin.qq.com/
|
||||
2. 进入"应用管理"
|
||||
3. 选择你配置的应用(使用该应用的Secret)
|
||||
4. 找到"企业可信IP"设置
|
||||
5. 点击"配置"或"修改"
|
||||
6. 添加服务器的公网IP地址
|
||||
7. 保存配置
|
||||
|
||||
### 3. 验证配置
|
||||
|
||||
配置完成后,等待几分钟让配置生效,然后重新运行同步命令:
|
||||
|
||||
```bash
|
||||
php think qywx:sync-customer --force
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **IP地址格式**:
|
||||
- 单个IP:`8.219.70.152`
|
||||
- IP段:`8.219.70.0/24`
|
||||
|
||||
2. **多个IP**:如果有多个服务器,需要添加所有服务器的IP
|
||||
|
||||
3. **动态IP**:如果服务器IP会变化,建议:
|
||||
- 使用固定IP的服务器
|
||||
- 或配置IP段
|
||||
- 或使用代理服务器
|
||||
|
||||
4. **测试环境**:开发环境和生产环境可能有不同的IP,都需要添加
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 添加IP后还是报错?
|
||||
A: 等待5-10分钟让配置生效,清除缓存后重试:
|
||||
```bash
|
||||
php think clear
|
||||
php think qywx:sync-customer --force
|
||||
```
|
||||
|
||||
### Q: 不知道服务器IP?
|
||||
A: 在服务器上运行:
|
||||
```bash
|
||||
curl ifconfig.me
|
||||
# 或
|
||||
curl ip.sb
|
||||
# 或
|
||||
curl ipinfo.io/ip
|
||||
```
|
||||
|
||||
### Q: 本地开发如何测试?
|
||||
A: 将本地公网IP也添加到白名单,或使用内网穿透工具(如ngrok)
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [企业微信API - IP白名单说明](https://developer.work.weixin.qq.com/document/path/90238)
|
||||
- [企业微信错误码查询](https://open.work.weixin.qq.com/devtool/query)
|
||||
@@ -1,108 +0,0 @@
|
||||
# 企业微信客户同步功能修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
调用企业微信API获取客户列表时报错:
|
||||
```
|
||||
missing field `userid`. invalid Request Parameter
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
根据企业微信API文档,获取客户列表接口 `/cgi-bin/externalcontact/list` 需要传入 `userid` 参数(企业成员ID)。之前的实现直接调用该接口,没有传入必需的 `userid` 参数。
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改同步逻辑,采用正确的流程:
|
||||
|
||||
1. 先调用 `/cgi-bin/user/list` 获取企业成员列表
|
||||
2. 遍历每个成员,调用 `/cgi-bin/externalcontact/list?userid=xxx` 获取该成员的客户列表
|
||||
3. 对每个客户调用 `/cgi-bin/externalcontact/get` 获取详情
|
||||
4. 保存到数据库,自动去重
|
||||
|
||||
## 修改文件
|
||||
|
||||
### 1. server/app/common/service/wechat/WechatWorkService.php
|
||||
|
||||
新增方法:
|
||||
- `getDepartmentUserList()` - 获取部门成员列表
|
||||
|
||||
修改方法:
|
||||
- `getExternalContactList($userId)` - 添加 `$userId` 参数,获取指定成员的客户列表
|
||||
|
||||
### 2. server/app/adminapi/logic/qywx/CustomerLogic.php
|
||||
|
||||
修改 `syncCustomers()` 方法:
|
||||
- 先获取企业成员列表
|
||||
- 遍历成员获取其客户
|
||||
- 使用 `$processedCustomers` 数组避免重复处理同一客户
|
||||
|
||||
### 3. server/config/project.php
|
||||
|
||||
新增配置项:
|
||||
```php
|
||||
// 企业微信客户同步配置
|
||||
// 从哪个部门开始同步(1=根部门,会递归获取所有子部门成员)
|
||||
'qywx_sync_department_id' => 1,
|
||||
```
|
||||
|
||||
### 4. QYWX_CUSTOMER_SYNC_GUIDE.md
|
||||
|
||||
更新文档:
|
||||
- 添加同步流程说明
|
||||
- 添加部门ID配置说明
|
||||
- 添加通讯录权限要求
|
||||
- 添加 `missing field userid` 错误排查说明
|
||||
|
||||
## 权限要求
|
||||
|
||||
企业微信应用需要开启以下权限:
|
||||
- 通讯录管理 → 成员信息读取(新增)
|
||||
- 客户联系 → 客户基础信息
|
||||
- 客户联系 → 客户标签
|
||||
- 客户联系 → 客户联系人
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 必需配置(server/.env)
|
||||
```env
|
||||
WECHAT_WORK_CORP_ID=你的企业ID
|
||||
WECHAT_WORK_EXTERNAL_PAY_SECRET=你的应用Secret
|
||||
```
|
||||
|
||||
### 可选配置(server/config/project.php)
|
||||
```php
|
||||
'qywx_sync_department_id' => 1, // 默认1(根部门),会递归获取所有子部门成员
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 确保配置正确
|
||||
2. 清除缓存:`php think clear`
|
||||
3. 在前端页面点击"立即同步"按钮
|
||||
4. 或运行命令:`php think qywx:sync-customer`
|
||||
|
||||
## 测试验证
|
||||
|
||||
修复后,同步功能应该能够:
|
||||
- 成功获取企业成员列表
|
||||
- 遍历每个成员获取其客户
|
||||
- 正确保存客户信息到数据库
|
||||
- 显示同步统计(总数、新增、更新)
|
||||
|
||||
注意:首次使用需要配置IP白名单,否则会报错 60020。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 错误码 60020 - IP白名单限制
|
||||
|
||||
如果遇到 `not allow to access from your ip` 错误,需要:
|
||||
1. 获取服务器公网IP:`curl ifconfig.me`
|
||||
2. 在企业微信管理后台添加IP到白名单
|
||||
3. 详细说明:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md)
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200)
|
||||
- [获取成员客户列表](https://developer.work.weixin.qq.com/document/path/92113)
|
||||
- [获取客户详情](https://developer.work.weixin.qq.com/document/path/92114)
|
||||
@@ -1,288 +0,0 @@
|
||||
# Region API Environment Variable Update (省市区API环境变量更新)
|
||||
|
||||
## Change Summary
|
||||
Updated the region data API URL to use the `VITE_APP_BASE_URL` environment variable instead of a hardcoded URL, making it more flexible and maintainable across different environments.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated prescription/index.vue
|
||||
**File**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json'
|
||||
: 'https://admin.zhenyangtang.com.cn/area/ChinaCitys.json'
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json'
|
||||
: `${import.meta.env.VITE_APP_BASE_URL}area/ChinaCitys.json`
|
||||
```
|
||||
|
||||
### 2. Updated prescription/order_list.vue
|
||||
**File**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
Applied the same change.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Production Environment
|
||||
**File**: `admin/.env.production`
|
||||
```env
|
||||
VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn/'
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
**File**: `admin/.env.development` (if exists)
|
||||
```env
|
||||
VITE_APP_BASE_URL='http://localhost:8080/'
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### URL Construction
|
||||
The production URL is now constructed dynamically:
|
||||
```typescript
|
||||
`${import.meta.env.VITE_APP_BASE_URL}area/ChinaCitys.json`
|
||||
```
|
||||
|
||||
With `VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn/'`, this becomes:
|
||||
```
|
||||
https://cs.zhenyangtang.com.cn/area/ChinaCitys.json
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
**Development Mode:**
|
||||
- Uses proxy: `/api-proxy/area/ChinaCitys.json`
|
||||
- Vite proxy rewrites to external URL
|
||||
- Avoids CORS issues
|
||||
|
||||
**Production Mode:**
|
||||
- Uses: `${VITE_APP_BASE_URL}area/ChinaCitys.json`
|
||||
- Resolves to: `https://cs.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
- Direct fetch from configured base URL
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Environment Flexibility
|
||||
- ✅ Different URLs for different environments (dev, staging, production)
|
||||
- ✅ No code changes needed when switching environments
|
||||
- ✅ Single source of truth for base URL
|
||||
|
||||
### 2. Easier Deployment
|
||||
- ✅ Change URL by updating `.env.production` file
|
||||
- ✅ No need to modify source code
|
||||
- ✅ Supports multiple deployment targets
|
||||
|
||||
### 3. Consistency
|
||||
- ✅ Uses same base URL as other API calls
|
||||
- ✅ Maintains consistency across the application
|
||||
- ✅ Easier to manage and maintain
|
||||
|
||||
### 4. Configuration Management
|
||||
- ✅ Centralized configuration in environment files
|
||||
- ✅ Easy to override for different environments
|
||||
- ✅ Follows best practices for environment-specific settings
|
||||
|
||||
## Environment Files
|
||||
|
||||
### Structure
|
||||
```
|
||||
admin/
|
||||
├── .env.development # Development environment
|
||||
├── .env.production # Production environment
|
||||
├── .env.staging # Staging environment (optional)
|
||||
└── .env.local # Local overrides (gitignored)
|
||||
```
|
||||
|
||||
### Example Configurations
|
||||
|
||||
**Development** (`.env.development`):
|
||||
```env
|
||||
VITE_APP_BASE_URL='http://localhost:8080/'
|
||||
VITE_APP_REQUEST_TIMEOUT=60000
|
||||
```
|
||||
|
||||
**Staging** (`.env.staging`):
|
||||
```env
|
||||
VITE_APP_BASE_URL='https://staging.zhenyangtang.com.cn/'
|
||||
VITE_APP_REQUEST_TIMEOUT=60000
|
||||
```
|
||||
|
||||
**Production** (`.env.production`):
|
||||
```env
|
||||
VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn/'
|
||||
VITE_APP_REQUEST_TIMEOUT=60000
|
||||
```
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Verify Environment Variable
|
||||
Check that the environment variable is loaded:
|
||||
```typescript
|
||||
console.log('Base URL:', import.meta.env.VITE_APP_BASE_URL)
|
||||
```
|
||||
|
||||
Expected output in production:
|
||||
```
|
||||
Base URL: https://cs.zhenyangtang.com.cn/
|
||||
```
|
||||
|
||||
### 2. Test Development Mode
|
||||
1. Run development server:
|
||||
```bash
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
2. Open browser console
|
||||
3. Check constructed URL uses proxy
|
||||
4. Verify region selector loads data
|
||||
|
||||
### 3. Test Production Build
|
||||
1. Build for production:
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
```
|
||||
2. Check built files use production URL
|
||||
3. Serve and test:
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
4. Verify region selector loads from: `https://cs.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
|
||||
### 4. Test Different Environments
|
||||
Create a staging build:
|
||||
```bash
|
||||
npm run build -- --mode staging
|
||||
```
|
||||
|
||||
Verify it uses the staging URL from `.env.staging`.
|
||||
|
||||
## URL Resolution Examples
|
||||
|
||||
### With Trailing Slash (Current)
|
||||
```typescript
|
||||
VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn/'
|
||||
Result: https://cs.zhenyangtang.com.cn/area/ChinaCitys.json ✅
|
||||
```
|
||||
|
||||
### Without Trailing Slash
|
||||
```typescript
|
||||
VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn'
|
||||
Result: https://cs.zhenyangtang.com.cnarea/ChinaCitys.json ❌
|
||||
```
|
||||
|
||||
**Important**: Ensure `VITE_APP_BASE_URL` always ends with `/`
|
||||
|
||||
### Handling Missing Trailing Slash
|
||||
If you want to be defensive, you could add:
|
||||
```typescript
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_URL.endsWith('/')
|
||||
? import.meta.env.VITE_APP_BASE_URL
|
||||
: `${import.meta.env.VITE_APP_BASE_URL}/`
|
||||
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json'
|
||||
: `${baseUrl}area/ChinaCitys.json`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Region selector not loading in production
|
||||
**Check:**
|
||||
1. Verify `.env.production` file exists
|
||||
2. Check `VITE_APP_BASE_URL` is set correctly
|
||||
3. Ensure URL ends with `/`
|
||||
4. Verify file exists at: `${VITE_APP_BASE_URL}area/ChinaCitys.json`
|
||||
|
||||
### Issue: 404 Not Found
|
||||
**Solution:**
|
||||
1. Check the constructed URL in browser Network tab
|
||||
2. Verify the file is accessible at that URL
|
||||
3. Check server configuration for the path
|
||||
|
||||
### Issue: CORS Error
|
||||
**Solution:**
|
||||
1. Ensure server has proper CORS headers
|
||||
2. Check `Access-Control-Allow-Origin` header
|
||||
3. Verify the domain is allowed
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### File Location
|
||||
Ensure the file exists at:
|
||||
```
|
||||
https://cs.zhenyangtang.com.cn/area/ChinaCitys.json
|
||||
```
|
||||
|
||||
### Nginx Configuration Example
|
||||
```nginx
|
||||
location /area/ {
|
||||
alias /path/to/public/area/;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
```
|
||||
|
||||
### Apache Configuration Example
|
||||
```apache
|
||||
<Directory "/path/to/public/area">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Cache-Control "public, max-age=86400"
|
||||
</Directory>
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/index.vue`
|
||||
- Updated to use `VITE_APP_BASE_URL`
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Updated to use `VITE_APP_BASE_URL`
|
||||
|
||||
### Configuration:
|
||||
- `admin/.env.production`
|
||||
- Contains `VITE_APP_BASE_URL='https://cs.zhenyangtang.com.cn/'`
|
||||
- `admin/.env.development`
|
||||
- Should contain development base URL
|
||||
- `admin/vite.config.ts`
|
||||
- Proxy configuration for development
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Environment Variables
|
||||
✅ Do: `${import.meta.env.VITE_APP_BASE_URL}path/to/resource`
|
||||
❌ Don't: `https://hardcoded-domain.com/path/to/resource`
|
||||
|
||||
### 2. Ensure Trailing Slashes
|
||||
✅ Do: `VITE_APP_BASE_URL='https://example.com/'`
|
||||
❌ Don't: `VITE_APP_BASE_URL='https://example.com'`
|
||||
|
||||
### 3. Use Consistent Naming
|
||||
All Vite environment variables should start with `VITE_` to be exposed to the client.
|
||||
|
||||
### 4. Document Environment Variables
|
||||
Keep a `.env.example` file with all required variables:
|
||||
```env
|
||||
# Base URL for API and static resources
|
||||
VITE_APP_BASE_URL='https://example.com/'
|
||||
|
||||
# Request timeout in milliseconds
|
||||
VITE_APP_REQUEST_TIMEOUT=60000
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Replaced hardcoded URL with environment variable
|
||||
✅ Uses `VITE_APP_BASE_URL` from `.env.production`
|
||||
✅ More flexible for different environments
|
||||
✅ Easier to deploy and maintain
|
||||
✅ Consistent with other API configurations
|
||||
✅ Applied to both prescription index and order list views
|
||||
|
||||
The region API now uses the configured base URL, making it easier to manage across different environments!
|
||||
@@ -1,243 +0,0 @@
|
||||
# Region API Production URL Fix (省市区API生产环境URL修复)
|
||||
|
||||
## Issue
|
||||
The region data API was using a proxy path (`/api-proxy/area/ChinaCitys.json`) which only works in development mode. After building for production, the proxy is not available, causing the region selector to fail.
|
||||
|
||||
## Root Cause
|
||||
Vite's proxy configuration (`vite.config.ts`) only works during development (`npm run dev`). When the application is built for production (`npm run build`), the proxy is not included in the build output, and the relative path `/api-proxy/...` becomes invalid.
|
||||
|
||||
## Solution
|
||||
Use environment detection to switch between proxy URL (development) and full URL (production).
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated prescription/index.vue
|
||||
**File**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
Changed from:
|
||||
```typescript
|
||||
const response = await fetch('/api-proxy/area/ChinaCitys.json')
|
||||
```
|
||||
|
||||
To:
|
||||
```typescript
|
||||
// 开发环境使用代理,生产环境使用完整URL
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json'
|
||||
: 'https://admin.zhenyangtang.com.cn/area/ChinaCitys.json'
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
```
|
||||
|
||||
### 2. Updated prescription/order_list.vue
|
||||
**File**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
Applied the same change to the order list view.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Environment Detection
|
||||
Uses Vite's built-in environment variable `import.meta.env.DEV`:
|
||||
- **Development** (`npm run dev`): `import.meta.env.DEV = true`
|
||||
- **Production** (`npm run build`): `import.meta.env.DEV = false`
|
||||
|
||||
### URL Selection Logic
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json' // Development: Use proxy
|
||||
: 'https://admin.zhenyangtang.com.cn/area/ChinaCitys.json' // Production: Use full URL
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
- Uses proxy path: `/api-proxy/area/ChinaCitys.json`
|
||||
- Vite proxy rewrites to: `https://admin.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
- Avoids CORS issues during development
|
||||
|
||||
### Production Mode
|
||||
- Uses full URL: `https://admin.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
- Direct fetch from the actual server
|
||||
- No proxy needed
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Works in Both Environments
|
||||
- ✅ Development: Uses proxy for CORS-free development
|
||||
- ✅ Production: Uses direct URL for deployed application
|
||||
|
||||
### 2. No Build Configuration Changes
|
||||
- No need to modify build scripts
|
||||
- No environment-specific builds
|
||||
- Single codebase for all environments
|
||||
|
||||
### 3. Automatic Detection
|
||||
- Automatically switches based on environment
|
||||
- No manual configuration needed
|
||||
- No risk of using wrong URL
|
||||
|
||||
### 4. Maintains CORS Handling
|
||||
- Development: Proxy handles CORS
|
||||
- Production: Server should have proper CORS headers
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Development Mode
|
||||
1. Run development server:
|
||||
```bash
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
2. Open browser console
|
||||
3. Check for log: `开始加载省市区数据...`
|
||||
4. Verify it uses proxy URL
|
||||
5. Check region selector loads data
|
||||
|
||||
### 2. Test Production Build
|
||||
1. Build for production:
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
```
|
||||
2. Serve the built files:
|
||||
```bash
|
||||
npm run preview
|
||||
# or deploy to production server
|
||||
```
|
||||
3. Open browser console
|
||||
4. Check for log: `开始加载省市区数据...`
|
||||
5. Verify it uses full URL
|
||||
6. Check region selector loads data
|
||||
|
||||
### 3. Verify URL in Network Tab
|
||||
**Development:**
|
||||
- Request URL: `http://localhost:5173/api-proxy/area/ChinaCitys.json`
|
||||
- Proxied to: `https://admin.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
|
||||
**Production:**
|
||||
- Request URL: `https://admin.zhenyangtang.com.cn/area/ChinaCitys.json`
|
||||
- Direct request (no proxy)
|
||||
|
||||
### 4. Test Fallback Data
|
||||
If API fails in either environment, verify fallback data is used:
|
||||
```typescript
|
||||
regionOptions.value = [
|
||||
{
|
||||
name: '四川省',
|
||||
children: [...]
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## Alternative Solutions Considered
|
||||
|
||||
### Option 1: Environment Variables (Not Chosen)
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.VITE_REGION_API_URL || '/api-proxy/area/ChinaCitys.json'
|
||||
```
|
||||
**Pros**: More flexible for different environments
|
||||
**Cons**: Requires environment-specific configuration files
|
||||
|
||||
### Option 2: Relative Path (Not Chosen)
|
||||
```typescript
|
||||
const apiUrl = '/area/ChinaCitys.json'
|
||||
```
|
||||
**Pros**: Simple
|
||||
**Cons**: Requires copying file to public folder, increases bundle size
|
||||
|
||||
### Option 3: Backend API Endpoint (Not Chosen)
|
||||
Create a backend endpoint to serve region data
|
||||
**Pros**: More control, can cache, can update without frontend deploy
|
||||
**Cons**: Requires backend changes, adds server load
|
||||
|
||||
### Option 4: Current Solution (Chosen) ✅
|
||||
Use environment detection with conditional URL
|
||||
**Pros**:
|
||||
- Simple implementation
|
||||
- No additional configuration
|
||||
- Works in both environments
|
||||
- No backend changes needed
|
||||
**Cons**:
|
||||
- Hardcoded production URL (acceptable for this use case)
|
||||
|
||||
## Production Server Requirements
|
||||
|
||||
### CORS Headers
|
||||
The production server hosting `ChinaCitys.json` should have proper CORS headers:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
# or specific domain:
|
||||
Access-Control-Allow-Origin: https://your-admin-domain.com
|
||||
```
|
||||
|
||||
### File Availability
|
||||
Ensure the file is accessible at:
|
||||
```
|
||||
https://admin.zhenyangtang.com.cn/area/ChinaCitys.json
|
||||
```
|
||||
|
||||
### Alternative: Use Backend Proxy
|
||||
If CORS is an issue in production, consider creating a backend endpoint:
|
||||
```php
|
||||
// server/app/adminapi/controller/RegionController.php
|
||||
public function getChinaCitys()
|
||||
{
|
||||
$url = 'https://admin.zhenyangtang.com.cn/area/ChinaCitys.json';
|
||||
$data = file_get_contents($url);
|
||||
return json(['data' => json_decode($data, true)]);
|
||||
}
|
||||
```
|
||||
|
||||
Then update frontend to use:
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.DEV
|
||||
? '/api-proxy/area/ChinaCitys.json'
|
||||
: '/adminapi/region/getChinaCitys' // Backend endpoint
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/index.vue`
|
||||
- Updated `loadRegionData()` function
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Updated `loadRegionData()` function
|
||||
|
||||
### Configuration:
|
||||
- `admin/vite.config.ts`
|
||||
- Proxy configuration (development only)
|
||||
|
||||
### Documentation:
|
||||
- `REGION_DATA_SETUP.md` (should be updated to mention production URL)
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Vite Built-in Variables:
|
||||
- `import.meta.env.DEV` - Boolean, true in development
|
||||
- `import.meta.env.PROD` - Boolean, true in production
|
||||
- `import.meta.env.MODE` - String, 'development' or 'production'
|
||||
|
||||
### Usage Example:
|
||||
```typescript
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Running in development mode')
|
||||
}
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
console.log('Running in production mode')
|
||||
}
|
||||
|
||||
console.log('Current mode:', import.meta.env.MODE)
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Fixed production build issue with region API
|
||||
✅ Uses proxy in development for CORS-free development
|
||||
✅ Uses direct URL in production for deployed application
|
||||
✅ Automatic environment detection
|
||||
✅ No build configuration changes needed
|
||||
✅ Maintains fallback data for both environments
|
||||
✅ Applied to both prescription index and order list views
|
||||
|
||||
The region selector now works correctly in both development and production environments!
|
||||
@@ -1,170 +0,0 @@
|
||||
# Region Database Save Fix (省市区数据库保存修复)
|
||||
|
||||
## Issue
|
||||
The region fields (province, city, district) were being sent in the API request payload but were not being saved to the database.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Frontend Issue
|
||||
The frontend was sending the wrong parameter names:
|
||||
- Sent: `province`, `city`, `district`
|
||||
- Expected by backend: `shipping_province`, `shipping_city`, `shipping_district`
|
||||
|
||||
### Backend Issue
|
||||
The `create()` method in `PrescriptionOrderLogic.php` was not handling the province/city/district fields at all, even though the `edit()` method had the code to handle them.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Frontend - Fixed Parameter Names
|
||||
**File**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
Changed the payload to use the correct parameter names:
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
payload.province = createOrderForm.region[0]
|
||||
payload.city = createOrderForm.region[1]
|
||||
payload.district = createOrderForm.region[2]
|
||||
|
||||
// After:
|
||||
payload.shipping_province = createOrderForm.region[0]
|
||||
payload.shipping_city = createOrderForm.region[1]
|
||||
payload.shipping_district = createOrderForm.region[2]
|
||||
```
|
||||
|
||||
### 2. Backend - Added Region Fields to create() Method
|
||||
**File**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
Added code to handle province/city/district fields in the `create()` method:
|
||||
|
||||
```php
|
||||
// 处理省市区字段
|
||||
if (isset($params['shipping_province'])) {
|
||||
$order->shipping_province = (string) $params['shipping_province'];
|
||||
}
|
||||
if (isset($params['shipping_city'])) {
|
||||
$order->shipping_city = (string) $params['shipping_city'];
|
||||
}
|
||||
if (isset($params['shipping_district'])) {
|
||||
$order->shipping_district = (string) $params['shipping_district'];
|
||||
}
|
||||
```
|
||||
|
||||
This matches the existing code in the `edit()` method.
|
||||
|
||||
## Data Flow (After Fix)
|
||||
|
||||
### Create Order Flow:
|
||||
1. User selects region: 北京市 → 北京市 → 丰台区
|
||||
2. Frontend stores: `createOrderForm.region = ['北京市', '北京市', '丰台区']`
|
||||
3. Frontend sends payload:
|
||||
```json
|
||||
{
|
||||
"shipping_province": "北京市",
|
||||
"shipping_city": "北京市",
|
||||
"shipping_district": "丰台区",
|
||||
...other fields
|
||||
}
|
||||
```
|
||||
4. Backend `create()` method processes:
|
||||
```php
|
||||
$order->shipping_province = "北京市";
|
||||
$order->shipping_city = "北京市";
|
||||
$order->shipping_district = "丰台区";
|
||||
```
|
||||
5. Database columns are populated:
|
||||
```
|
||||
shipping_province: 北京市
|
||||
shipping_city: 北京市
|
||||
shipping_district: 丰台区
|
||||
```
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Create Order with Region
|
||||
1. Open prescription list page
|
||||
2. Click "创建订单" on any prescription
|
||||
3. Fill in required fields:
|
||||
- Select patient
|
||||
- Enter recipient name and phone
|
||||
- **Select region**: 北京市 → 北京市 → 丰台区
|
||||
- Enter detailed address
|
||||
4. Click "创建业务订单"
|
||||
5. Check browser Network tab:
|
||||
- Verify payload includes:
|
||||
```json
|
||||
{
|
||||
"shipping_province": "北京市",
|
||||
"shipping_city": "北京市",
|
||||
"shipping_district": "丰台区"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Database
|
||||
After creating the order, check the database:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
order_no,
|
||||
shipping_province,
|
||||
shipping_city,
|
||||
shipping_district,
|
||||
shipping_address
|
||||
FROM tcm_prescription_order
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Expected result:
|
||||
```
|
||||
| id | order_no | shipping_province | shipping_city | shipping_district | shipping_address |
|
||||
|----|----------|-------------------|---------------|-------------------|------------------|
|
||||
| XX | POXXXXXX | 北京市 | 北京市 | 丰台区 | 顶顶顶 |
|
||||
```
|
||||
|
||||
### 3. Test Different Regions
|
||||
Test with various regions to ensure all work correctly:
|
||||
- 四川省 → 成都市 → 武侯区
|
||||
- 上海市 → 上海市 → 浦东新区
|
||||
- 广东省 → 深圳市 → 南山区
|
||||
|
||||
### 4. Test Edit Order
|
||||
1. Edit an existing order
|
||||
2. Change the region
|
||||
3. Save and verify the database is updated
|
||||
|
||||
## Database Schema
|
||||
|
||||
The database should have these columns (from `add_shipping_region_fields.sql`):
|
||||
|
||||
```sql
|
||||
ALTER TABLE `tcm_prescription_order`
|
||||
ADD COLUMN `shipping_province` VARCHAR(50) DEFAULT NULL COMMENT '收货省份' AFTER `shipping_address`,
|
||||
ADD COLUMN `shipping_city` VARCHAR(50) DEFAULT NULL COMMENT '收货城市' AFTER `shipping_province`,
|
||||
ADD COLUMN `shipping_district` VARCHAR(50) DEFAULT NULL COMMENT '收货区县' AFTER `shipping_city`;
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/index.vue`
|
||||
- Changed parameter names in `submitCreateOrderFromPrescription()`
|
||||
|
||||
### Backend:
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
- Added province/city/district handling in `create()` method
|
||||
- Already had province/city/district handling in `edit()` method
|
||||
|
||||
### Database:
|
||||
- `add_shipping_region_fields.sql` (migration file)
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Fixed frontend parameter names: `province` → `shipping_province`, etc.
|
||||
✅ Added province/city/district handling to backend `create()` method
|
||||
✅ Backend `edit()` method already had the correct code
|
||||
✅ Database columns will now be populated correctly on order creation
|
||||
✅ Database columns will be updated correctly on order edit
|
||||
|
||||
The region fields are now properly saved to the database for both create and edit operations!
|
||||
@@ -1,72 +0,0 @@
|
||||
# 省市区数据加载说明
|
||||
|
||||
## 问题
|
||||
省市区下拉框显示为空
|
||||
|
||||
## 原因
|
||||
1. Vite代理配置需要重启开发服务器才能生效
|
||||
2. 如果代理加载失败,会使用后备数据
|
||||
|
||||
## 解决步骤
|
||||
|
||||
### 1. 重启开发服务器
|
||||
```bash
|
||||
# 停止当前运行的开发服务器 (Ctrl+C)
|
||||
# 然后重新启动
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 2. 检查控制台日志
|
||||
打开浏览器开发者工具的Console标签,应该能看到:
|
||||
- "开始加载省市区数据..."
|
||||
- "响应状态: 200"
|
||||
- "加载的数据条数: XX"
|
||||
- "regionOptions 已设置,条数: XX"
|
||||
|
||||
### 3. 如果代理失败
|
||||
如果看到错误信息,会自动使用后备数据,包含:
|
||||
- 四川省(成都市、绵阳市)
|
||||
- 北京市
|
||||
- 上海市
|
||||
- 广东省(广州市、深圳市)
|
||||
|
||||
## 配置说明
|
||||
|
||||
### Vite代理配置 (admin/vite.config.ts)
|
||||
```typescript
|
||||
server: {
|
||||
proxy: {
|
||||
'/api-proxy': {
|
||||
target: 'https://admin.zhenyangtang.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api-proxy/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端请求 (admin/src/views/consumer/prescription/index.vue)
|
||||
```javascript
|
||||
const response = await fetch('/api-proxy/area/ChinaCitys.json')
|
||||
```
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
生产环境需要在nginx配置代理:
|
||||
|
||||
```nginx
|
||||
location /api-proxy/ {
|
||||
proxy_pass https://admin.zhenyangtang.com.cn/;
|
||||
proxy_set_header Host admin.zhenyangtang.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
1. 打开创建订单对话框
|
||||
2. 查看"省市区"下拉框
|
||||
3. 应该能看到省份列表
|
||||
4. 选择省份后能看到城市列表
|
||||
5. 选择城市后能看到区县列表
|
||||
@@ -1,153 +0,0 @@
|
||||
# Region Field Submission Fix (省市区字段提交修复)
|
||||
|
||||
## Issue
|
||||
The region selector (省市区) in the create order form was not submitting the selected province, city, and district values to the database.
|
||||
|
||||
## Root Cause
|
||||
1. The cascader component had `emitPath: false`, which only returned the last selected value (district name) instead of the full path
|
||||
2. The `submitCreateOrderFromPrescription()` function was not including the region fields in the payload sent to the backend
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated Cascader Configuration
|
||||
**File**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
Changed `emitPath` from `false` to `true` to get the full path array:
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
:props="{ value: 'name', label: 'name', children: 'children', emitPath: false, checkStrictly: false }"
|
||||
|
||||
// After:
|
||||
:props="{ value: 'name', label: 'name', children: 'children', emitPath: true, checkStrictly: false }"
|
||||
```
|
||||
|
||||
**Effect**: Now `createOrderForm.region` will contain an array like `['四川省', '成都市', '锦江区']` instead of just `'锦江区'`
|
||||
|
||||
### 2. Updated Form Submission
|
||||
**File**: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
Added code to extract province, city, and district from the region array and include them in the payload:
|
||||
|
||||
```typescript
|
||||
// 添加省市区字段
|
||||
if (createOrderForm.region && createOrderForm.region.length >= 3) {
|
||||
payload.province = createOrderForm.region[0]
|
||||
payload.city = createOrderForm.region[1]
|
||||
payload.district = createOrderForm.region[2]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Updated handleRegionChange Comment
|
||||
Added clarification that the value is now an array of the full path.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Before Fix:
|
||||
1. User selects: 四川省 → 成都市 → 锦江区
|
||||
2. `createOrderForm.region` = `'锦江区'` (only last value)
|
||||
3. Payload sent to backend: **No province/city/district fields**
|
||||
4. Database: province, city, district columns remain empty
|
||||
|
||||
### After Fix:
|
||||
1. User selects: 四川省 → 成都市 → 锦江区
|
||||
2. `createOrderForm.region` = `['四川省', '成都市', '锦江区']` (full path)
|
||||
3. Payload sent to backend:
|
||||
```json
|
||||
{
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"district": "锦江区",
|
||||
...other fields
|
||||
}
|
||||
```
|
||||
4. Database: province, city, district columns are populated correctly
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Region Selection and Submission
|
||||
1. Open the prescription list page
|
||||
2. Click "创建订单" button on any prescription
|
||||
3. Fill in the required fields:
|
||||
- Select a patient (患者)
|
||||
- Enter recipient name (收货人)
|
||||
- Enter recipient phone (收货手机)
|
||||
- **Select province/city/district** (省市区): e.g., 四川省 → 成都市 → 锦江区
|
||||
- Enter detailed address (详细地址)
|
||||
4. Click "创建业务订单" button
|
||||
5. Check browser Network tab:
|
||||
- Find the API request to create order
|
||||
- Verify the payload includes:
|
||||
```json
|
||||
{
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"district": "锦江区"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Database Storage
|
||||
After creating an order, check the database:
|
||||
|
||||
```sql
|
||||
SELECT id, order_no, province, city, district, shipping_address
|
||||
FROM tcm_prescription_order
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Expected result:
|
||||
```
|
||||
| id | order_no | province | city | district | shipping_address |
|
||||
|----|----------|----------|--------|----------|------------------|
|
||||
| XX | XXXXXX | 四川省 | 成都市 | 锦江区 | 中关村... |
|
||||
```
|
||||
|
||||
### 3. Test Different Regions
|
||||
Test with various regions to ensure the data structure works correctly:
|
||||
- 北京市 → 北京市 → 朝阳区
|
||||
- 上海市 → 上海市 → 浦东新区
|
||||
- 广东省 → 深圳市 → 南山区
|
||||
|
||||
### 4. Test Edge Cases
|
||||
1. **No region selected**: Should still allow submission (if not required)
|
||||
2. **Only province selected**: Should not submit incomplete data
|
||||
3. **Clear selection**: Click the clear button (×) and verify region is cleared
|
||||
|
||||
## Backend Verification
|
||||
|
||||
The backend should already have the database columns from the migration `add_shipping_region_fields.sql`:
|
||||
- `province` (varchar)
|
||||
- `city` (varchar)
|
||||
- `district` (varchar)
|
||||
|
||||
If the backend validation or logic needs to be updated, check:
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php`
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
## Console Debugging
|
||||
|
||||
If you need to debug, check the browser console for the log message:
|
||||
```
|
||||
选择的省市区: ['四川省', '成都市', '锦江区']
|
||||
```
|
||||
|
||||
This confirms the cascader is emitting the correct data structure.
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/index.vue` (main changes)
|
||||
|
||||
### Backend:
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` (should handle province/city/district)
|
||||
- `add_shipping_region_fields.sql` (database migration)
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Changed cascader `emitPath` to `true` to get full path array
|
||||
✅ Added province/city/district extraction in form submission
|
||||
✅ Payload now includes all three region fields
|
||||
✅ Database columns will be populated correctly
|
||||
|
||||
The region selector now properly submits province, city, and district values to the database.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Region Selector and Dosage Fields Fix
|
||||
|
||||
## Issues Identified
|
||||
|
||||
### 1. Region Selector Empty (省市区下拉为空)
|
||||
**Problem**: The region cascader dropdown is empty when opening the create order form.
|
||||
|
||||
**Root Cause**: The Vite proxy configuration is correct, but the development server needs to be restarted for the proxy to take effect.
|
||||
|
||||
**Solution**:
|
||||
- The proxy configuration in `admin/vite.config.ts` is already set up correctly:
|
||||
```typescript
|
||||
proxy: {
|
||||
'/api-proxy': {
|
||||
target: 'https://admin.zhenyangtang.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api-proxy/, '')
|
||||
}
|
||||
}
|
||||
```
|
||||
- The `transformRegionData()` function correctly converts the API format to el-cascader format
|
||||
- **ACTION REQUIRED**: Restart the development server
|
||||
|
||||
### 2. Dosage Dropdown Not Showing Selected Value on First Load
|
||||
**Problem**: When editing a prescription, the dosage dropdown (用量) appears empty on first load, but shows correctly when reopening the dialog.
|
||||
|
||||
**Root Cause**: Timing issue with the `isLoadingData` flag. The `nextTick()` callback was executing before all data was fully rendered.
|
||||
|
||||
**Solution**: Changed from `nextTick()` to `setTimeout(..., 0)` to ensure all data is rendered before re-enabling the watch function.
|
||||
|
||||
**Changed in**: `admin/src/views/consumer/prescription/index.vue`
|
||||
```typescript
|
||||
// Before:
|
||||
nextTick(() => {
|
||||
isLoadingData.value = false
|
||||
})
|
||||
|
||||
// After:
|
||||
setTimeout(() => {
|
||||
isLoadingData.value = false
|
||||
}, 0)
|
||||
```
|
||||
|
||||
### 3. Dosage Fields Not Saving to Database
|
||||
**Problem**: User reported that dosage fields (用量、单位、代煎、每贴出包数) are not being saved to the database.
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Backend validation (`PrescriptionValidate.php`) already includes all dosage fields in `sceneAdd()` and `sceneEdit()`
|
||||
- ✅ Frontend component (`tcm-prescription/index.vue`) includes all fields in `handleSave()`
|
||||
- ✅ Frontend prescription list view (`prescription/index.vue`) includes all fields in form submission
|
||||
- ✅ Watch function properly resets values when prescription type changes
|
||||
- ✅ `isLoadingData` flag prevents watch from overwriting loaded data
|
||||
|
||||
**Verification Needed**:
|
||||
1. Check if database migration was executed: `add_prescription_dosage_fields.sql`
|
||||
2. Check browser console for any API errors when saving
|
||||
3. Verify that the API response shows the fields were saved
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. admin/src/views/consumer/prescription/index.vue
|
||||
- Changed `nextTick()` to `setTimeout(..., 0)` in `handleEdit()` function (line ~2187)
|
||||
|
||||
## Files Already Correct (No Changes Needed)
|
||||
|
||||
### 1. admin/vite.config.ts
|
||||
- Proxy configuration is correct
|
||||
|
||||
### 2. admin/src/views/consumer/prescription/index.vue
|
||||
- `transformRegionData()` function correctly converts API format
|
||||
- `loadRegionData()` function properly fetches and transforms data
|
||||
- Watch function includes `isLoadingData` flag to prevent overwriting
|
||||
- All dosage fields are included in form submission
|
||||
|
||||
### 3. admin/src/components/tcm-prescription/index.vue
|
||||
- All dosage fields are included in `handleSave()`
|
||||
- Watch function properly handles prescription type changes
|
||||
- bags_per_dose field is displayed for 饮片 type
|
||||
|
||||
### 4. server/app/adminapi/validate/tcm/PrescriptionValidate.php
|
||||
- All dosage fields are included in `sceneAdd()` and `sceneEdit()`
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### Test 1: Region Selector
|
||||
1. **Restart the development server**:
|
||||
```bash
|
||||
# Stop current server (Ctrl+C)
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
2. Open the create order form (创建订单)
|
||||
3. Click on the "省市区" cascader
|
||||
4. Verify that all provinces are displayed
|
||||
5. Select a province and verify cities are displayed
|
||||
6. Select a city and verify districts are displayed
|
||||
7. Check browser console for any errors
|
||||
|
||||
### Test 2: Dosage Dropdown Display
|
||||
1. Create a new prescription with type "饮片" and dosage "100ml"
|
||||
2. Save the prescription
|
||||
3. **Refresh the page** (F5)
|
||||
4. Edit the same prescription
|
||||
5. Verify that the dosage dropdown shows "100ml" immediately on first load
|
||||
6. Close and reopen the edit dialog
|
||||
7. Verify it still shows "100ml"
|
||||
|
||||
### Test 3: Dosage Fields Saving
|
||||
1. Create a new prescription:
|
||||
- Type: 饮片
|
||||
- Dosage: 150ml
|
||||
- Decoction: 代煎
|
||||
- Bags per dose: 3包
|
||||
2. Save the prescription
|
||||
3. Check browser Network tab for the API request payload
|
||||
4. Verify the payload includes:
|
||||
```json
|
||||
{
|
||||
"dosage_amount": 150,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": true,
|
||||
"bags_per_dose": 3
|
||||
}
|
||||
```
|
||||
5. Refresh the page and edit the prescription
|
||||
6. Verify all fields display the saved values
|
||||
|
||||
### Test 4: Different Prescription Types
|
||||
1. Test 浓缩水丸:
|
||||
- Dosage: 1-10g dropdown
|
||||
- No decoction option
|
||||
- No bags_per_dose field
|
||||
2. Test 饮片:
|
||||
- Dosage: 50-250ml dropdown
|
||||
- Decoction radio buttons (代煎/不代煎)
|
||||
- Bags per dose: 1-9包 dropdown
|
||||
3. Test 其他类型 (颗粒、丸剂、散剂、膏方、汤剂):
|
||||
- Dosage: Free input with decimal support
|
||||
- No decoction option
|
||||
- No bags_per_dose field
|
||||
|
||||
## Database Verification
|
||||
|
||||
If dosage fields are still not saving, verify the database schema:
|
||||
|
||||
```sql
|
||||
-- Check if columns exist
|
||||
DESCRIBE tcm_prescription;
|
||||
|
||||
-- Should show these columns:
|
||||
-- dosage_amount (decimal or varchar)
|
||||
-- dosage_unit (varchar)
|
||||
-- need_decoction (tinyint)
|
||||
-- bags_per_dose (int)
|
||||
-- times_per_day (int)
|
||||
|
||||
-- If columns are missing, run the migration:
|
||||
SOURCE add_prescription_dosage_fields.sql;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Region Selector Still Empty After Restart
|
||||
1. Check browser console for CORS errors
|
||||
2. Verify the proxy is working:
|
||||
```bash
|
||||
# In browser console:
|
||||
fetch('/api-proxy/area/ChinaCitys.json').then(r => r.json()).then(console.log)
|
||||
```
|
||||
3. Check if the API endpoint is accessible:
|
||||
```bash
|
||||
curl https://admin.zhenyangtang.com.cn/area/ChinaCitys.json
|
||||
```
|
||||
|
||||
### Dosage Dropdown Still Empty on First Load
|
||||
1. Check browser console for errors
|
||||
2. Verify `dosage_amount` is a number in the database (not string)
|
||||
3. Add console.log in `handleEdit()` to debug:
|
||||
```typescript
|
||||
console.log('dosage_amount from DB:', row.dosage_amount, typeof row.dosage_amount)
|
||||
console.log('dosage_amount after conversion:', editForm.dosage_amount, typeof editForm.dosage_amount)
|
||||
```
|
||||
|
||||
### Dosage Fields Not Saving
|
||||
1. Check browser Network tab for API errors
|
||||
2. Verify database migration was executed
|
||||
3. Check backend logs for validation errors
|
||||
4. Verify the API endpoint is receiving the fields:
|
||||
```php
|
||||
// In PrescriptionLogic.php
|
||||
Log::write('Prescription params: ' . json_encode($params));
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
- ✅ Region selector: Proxy configured, needs server restart
|
||||
- ✅ Dosage dropdown: Fixed timing issue with `setTimeout`
|
||||
- ✅ Dosage fields saving: All code is correct, verify database migration
|
||||
- ✅ bags_per_dose: Already implemented in both component and list view
|
||||
@@ -1,357 +0,0 @@
|
||||
# 省市区选择器功能说明
|
||||
|
||||
## 功能描述
|
||||
|
||||
在创建处方业务订单时,添加省市区级联选择器,方便用户选择收货地址的省市区信息。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 表单界面(admin/src/views/consumer/prescription/index.vue)
|
||||
|
||||
#### 添加省市区选择器(第 792-810 行)
|
||||
|
||||
```vue
|
||||
<el-col :span="12">
|
||||
<el-form-item label="收货手机" prop="recipient_phone">
|
||||
<el-input v-model="createOrderForm.recipient_phone" placeholder="用于物流联系" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="省市区" prop="region">
|
||||
<el-cascader
|
||||
v-model="createOrderForm.region"
|
||||
:options="regionOptions"
|
||||
:props="{ value: 'name', label: 'name', children: 'children', emitPath: false, checkStrictly: false }"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
filterable
|
||||
class="w-full"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="详细地址" prop="shipping_address">
|
||||
<el-input
|
||||
v-model="createOrderForm.shipping_address"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入街道、门牌号等详细地址"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
```
|
||||
|
||||
**组件配置说明:**
|
||||
- `v-model="createOrderForm.region"`:绑定选中的省市区数组
|
||||
- `:options="regionOptions"`:省市区数据源
|
||||
- `value: 'name'`:使用 name 字段作为值
|
||||
- `label: 'name'`:使用 name 字段作为显示文本
|
||||
- `emitPath: false`:只返回最后一级的值(区/县)
|
||||
- `checkStrictly: false`:必须选择到最后一级
|
||||
- `clearable`:可清空
|
||||
- `filterable`:可搜索
|
||||
|
||||
### 2. 数据模型更新(第 1175-1195 行)
|
||||
|
||||
```typescript
|
||||
const createOrderForm = reactive({
|
||||
patient_id: '' as number | string,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
region: [] as string[], // 新增:省市区数组
|
||||
shipping_address: '', // 改为详细地址
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 省市区数据(第 1195-1340 行)
|
||||
|
||||
添加了常用省市区数据:
|
||||
- 四川省(成都市、绵阳市)
|
||||
- 北京市
|
||||
- 上海市
|
||||
- 广东省(广州市、深圳市)
|
||||
|
||||
```typescript
|
||||
const regionOptions = ref([
|
||||
{
|
||||
name: '四川省',
|
||||
children: [
|
||||
{
|
||||
name: '成都市',
|
||||
children: [
|
||||
{ name: '锦江区' },
|
||||
{ name: '青羊区' },
|
||||
// ... 更多区县
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
// ... 更多省份
|
||||
])
|
||||
```
|
||||
|
||||
### 4. 事件处理方法
|
||||
|
||||
```typescript
|
||||
const handleRegionChange = (value: string[]) => {
|
||||
console.log('选择的省市区:', value)
|
||||
}
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. 点击"创建业务订单"按钮
|
||||
2. 选择诊单患者
|
||||
3. 填写收货人和收货手机
|
||||
4. **点击"省市区"选择器**
|
||||
5. 依次选择:省 → 市 → 区/县
|
||||
6. 在"详细地址"中填写街道、门牌号等信息
|
||||
7. 填写其他订单信息
|
||||
8. 提交订单
|
||||
|
||||
### 界面效果
|
||||
|
||||
**编辑时:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 收货人: [张三] 收货手机: [138****0000] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 省市区: [四川省 / 成都市 / 双流区 ▼] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 详细地址: │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 黄甲街道黄龙大道二段280号 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**完整地址组合:**
|
||||
```
|
||||
四川省成都市双流区黄甲街道黄龙大道二段280号
|
||||
```
|
||||
|
||||
## 后端支持
|
||||
|
||||
### 数据库字段建议
|
||||
|
||||
如果需要单独存储省市区信息,可以添加以下字段:
|
||||
|
||||
```sql
|
||||
-- 在 prescription_order 表中添加省市区字段
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD COLUMN `province` VARCHAR(50) DEFAULT '' COMMENT '省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `city` VARCHAR(50) DEFAULT '' COMMENT '城市' AFTER `province`,
|
||||
ADD COLUMN `district` VARCHAR(50) DEFAULT '' COMMENT '区/县' AFTER `city`;
|
||||
|
||||
-- 或者保持现有的 shipping_address 字段,前端组合完整地址
|
||||
```
|
||||
|
||||
### 后端 API 修改
|
||||
|
||||
#### 方案 A:前端组合完整地址(推荐)
|
||||
|
||||
前端在提交时将省市区和详细地址组合:
|
||||
|
||||
```typescript
|
||||
const submitOrder = async () => {
|
||||
const fullAddress = createOrderForm.region.join('') + createOrderForm.shipping_address
|
||||
|
||||
await createPrescriptionOrder({
|
||||
...createOrderForm,
|
||||
shipping_address: fullAddress // 四川省成都市双流区黄甲街道...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案 B:分别存储省市区
|
||||
|
||||
后端接收独立的省市区字段:
|
||||
|
||||
```php
|
||||
// 创建订单时
|
||||
$order->province = $params['province'] ?? '';
|
||||
$order->city = $params['city'] ?? '';
|
||||
$order->district = $params['district'] ?? '';
|
||||
$order->shipping_address = $params['shipping_address'] ?? '';
|
||||
|
||||
// 查询时返回
|
||||
return [
|
||||
'province' => $order->province,
|
||||
'city' => $order->city,
|
||||
'district' => $order->district,
|
||||
'shipping_address' => $order->shipping_address,
|
||||
'full_address' => $order->province . $order->city . $order->district . $order->shipping_address
|
||||
];
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 1. 使用完整的省市区数据
|
||||
|
||||
当前只包含了部分常用省市区,实际项目中应该使用完整的数据。
|
||||
|
||||
**方案 A:使用 npm 包**
|
||||
|
||||
```bash
|
||||
npm install element-china-area-data
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { regionData } from 'element-china-area-data'
|
||||
|
||||
const regionOptions = ref(regionData)
|
||||
```
|
||||
|
||||
**方案 B:从后端 API 获取**
|
||||
|
||||
```typescript
|
||||
import { getRegionTree } from '@/api/common'
|
||||
|
||||
const regionOptions = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getRegionTree()
|
||||
regionOptions.value = res.data
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 自动填充历史地址
|
||||
|
||||
根据患者历史订单自动填充地址:
|
||||
|
||||
```typescript
|
||||
const loadPatientLastAddress = async (patientId: number) => {
|
||||
const res = await getPatientLastOrder(patientId)
|
||||
if (res.data) {
|
||||
createOrderForm.region = [res.data.province, res.data.city, res.data.district]
|
||||
createOrderForm.shipping_address = res.data.detail_address
|
||||
createOrderForm.recipient_name = res.data.recipient_name
|
||||
createOrderForm.recipient_phone = res.data.recipient_phone
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 地址簿功能
|
||||
|
||||
保存常用地址,快速选择:
|
||||
|
||||
```vue
|
||||
<el-select v-model="selectedAddressId" placeholder="选择常用地址" @change="fillAddress">
|
||||
<el-option
|
||||
v-for="addr in addressBook"
|
||||
:key="addr.id"
|
||||
:label="`${addr.name} ${addr.phone} ${addr.full_address}`"
|
||||
:value="addr.id"
|
||||
/>
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 4. 地址验证
|
||||
|
||||
验证地址的完整性和合法性:
|
||||
|
||||
```typescript
|
||||
const validateAddress = () => {
|
||||
if (createOrderForm.region.length !== 3) {
|
||||
feedback.msgError('请选择完整的省市区')
|
||||
return false
|
||||
}
|
||||
if (!createOrderForm.shipping_address.trim()) {
|
||||
feedback.msgError('请填写详细地址')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据格式**:
|
||||
- `region` 是数组:`['四川省', '成都市', '双流区']`
|
||||
- 提交时需要组合成完整地址字符串
|
||||
|
||||
2. **必填验证**:
|
||||
- 省市区选择器应该设置为必填
|
||||
- 详细地址也应该必填
|
||||
|
||||
3. **字符限制**:
|
||||
- 详细地址限制 200 字符
|
||||
- 建议省市区 + 详细地址总长度不超过 250 字符
|
||||
|
||||
4. **兼容性**:
|
||||
- 兼容现有的 `shipping_address` 字段
|
||||
- 旧数据可能没有省市区信息,需要做好兼容处理
|
||||
|
||||
5. **性能优化**:
|
||||
- 省市区数据较大时,考虑懒加载
|
||||
- 使用虚拟滚动优化长列表
|
||||
|
||||
## 表单验证规则
|
||||
|
||||
更新验证规则:
|
||||
|
||||
```typescript
|
||||
const createOrderRules: FormRules = {
|
||||
// ... 其他规则
|
||||
region: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择省市区',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value || value.length !== 3) {
|
||||
callback(new Error('请选择完整的省市区'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
shipping_address: [
|
||||
{ required: true, message: '请输入详细地址', trigger: 'blur' },
|
||||
{ min: 5, message: '详细地址至少5个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. ✅ 省市区选择器显示正常
|
||||
2. ✅ 可以正常选择省市区
|
||||
3. ✅ 搜索功能正常
|
||||
4. ✅ 清空功能正常
|
||||
5. ✅ 必填验证正常
|
||||
6. ✅ 数据绑定正确
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. ✅ 创建订单 → 选择省市区 → 填写详细地址 → 提交成功
|
||||
2. ✅ 查看订单 → 地址显示完整
|
||||
3. ✅ 编辑订单 → 省市区回显正确
|
||||
4. ✅ 打印订单 → 地址格式正确
|
||||
|
||||
## 完成状态
|
||||
|
||||
- ✅ 前端界面添加完成
|
||||
- ✅ 数据模型更新完成
|
||||
- ✅ 省市区数据添加完成(简化版)
|
||||
- ✅ 事件处理方法添加完成
|
||||
- ⏳ 等待完整省市区数据集成
|
||||
- ⏳ 等待后端 API 支持
|
||||
- ⏳ 等待表单验证规则更新
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方订单列表页面
|
||||
- 后端需要修改的文件(待确认):
|
||||
- 订单模型文件
|
||||
- 订单控制器文件
|
||||
- 数据库迁移文件(如果需要单独存储省市区)
|
||||
@@ -1,226 +0,0 @@
|
||||
# 撤回处方审核功能修复
|
||||
|
||||
## 问题描述
|
||||
撤回处方审核时,只更新了业务订单表(`zyt_tcm_prescription_order`)的 `prescription_audit_status`,但没有同步更新处方表(`zyt_tcm_prescription`)的 `audit_status`,导致处方表的审核状态不一致。
|
||||
|
||||
## 影响
|
||||
- 业务订单显示"待审核",但处方表仍显示"已通过"
|
||||
- 处方列表页面显示的审核状态与实际不符
|
||||
- 可能导致业务流程混乱
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
在 `revokeRxAudit()` 方法中添加同步处方表审核状态的逻辑:
|
||||
|
||||
```php
|
||||
// 同步撤回处方表的审核状态
|
||||
$prescriptionId = (int) $order->prescription_id;
|
||||
if ($prescriptionId > 0) {
|
||||
try {
|
||||
$prescription = Prescription::where('id', $prescriptionId)->whereNull('delete_time')->find();
|
||||
if ($prescription) {
|
||||
$prescription->audit_status = 0;
|
||||
$prescription->audit_remark = '';
|
||||
$prescription->audit_time = null;
|
||||
$prescription->audit_by = null;
|
||||
$prescription->audit_by_name = '';
|
||||
$prescription->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 记录日志但不影响主流程
|
||||
Log::error('撤回处方审核时同步处方表失败: ' . $e->getMessage(), [
|
||||
'prescription_id' => $prescriptionId,
|
||||
'order_id' => $id
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 业务订单表更新(原有逻辑)
|
||||
**表**: `zyt_tcm_prescription_order`
|
||||
|
||||
```php
|
||||
$order->prescription_audit_status = 0; // 重置为待审核
|
||||
$order->prescription_audit_remark = ''; // 清空审核意见
|
||||
```
|
||||
|
||||
### 2. 处方表同步更新(新增逻辑)
|
||||
**表**: `zyt_tcm_prescription`
|
||||
|
||||
```php
|
||||
$prescription->audit_status = 0; // 重置为待审核
|
||||
$prescription->audit_remark = ''; // 清空审核意见
|
||||
$prescription->audit_time = null; // 清空审核时间
|
||||
$prescription->audit_by = null; // 清空审核人ID
|
||||
$prescription->audit_by_name = ''; // 清空审核人姓名
|
||||
```
|
||||
|
||||
## 数据流转
|
||||
|
||||
### 撤回前
|
||||
```
|
||||
业务订单表 (zyt_tcm_prescription_order):
|
||||
prescription_audit_status: 1 (已通过)
|
||||
prescription_audit_remark: "审核通过"
|
||||
|
||||
处方表 (zyt_tcm_prescription):
|
||||
audit_status: 1 (已通过)
|
||||
audit_remark: "审核通过"
|
||||
audit_time: 1713168000
|
||||
audit_by: 5
|
||||
audit_by_name: "张医生"
|
||||
```
|
||||
|
||||
### 撤回后
|
||||
```
|
||||
业务订单表 (zyt_tcm_prescription_order):
|
||||
prescription_audit_status: 0 (待审核)
|
||||
prescription_audit_remark: ""
|
||||
|
||||
处方表 (zyt_tcm_prescription):
|
||||
audit_status: 0 (待审核)
|
||||
audit_remark: ""
|
||||
audit_time: null
|
||||
audit_by: null
|
||||
audit_by_name: ""
|
||||
```
|
||||
|
||||
## 审核状态说明
|
||||
|
||||
| 状态值 | 说明 |
|
||||
|--------|------|
|
||||
| 0 | 待审核 |
|
||||
| 1 | 已通过 |
|
||||
| 2 | 已驳回 |
|
||||
|
||||
## 业务流程
|
||||
|
||||
```
|
||||
创建订单
|
||||
↓
|
||||
处方待审核 (audit_status = 0)
|
||||
↓ 审核通过
|
||||
处方已通过 (audit_status = 1)
|
||||
↓ 撤回审核
|
||||
处方待审核 (audit_status = 0) ← 两个表都重置
|
||||
↓ 重新审核
|
||||
处方已通过 (audit_status = 1)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 容错设计
|
||||
```php
|
||||
try {
|
||||
// 更新处方表
|
||||
$prescription->save();
|
||||
} catch (\Throwable $e) {
|
||||
// 记录日志但不影响主流程
|
||||
Log::error('撤回处方审核时同步处方表失败: ' . $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 即使处方表更新失败,业务订单表的撤回仍然成功
|
||||
- 错误会被记录到日志中,便于排查
|
||||
- 不会因为处方表问题导致整个撤回操作失败
|
||||
|
||||
## 相关表结构
|
||||
|
||||
### 业务订单表 (zyt_tcm_prescription_order)
|
||||
```sql
|
||||
prescription_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT '消费者处方ID'
|
||||
prescription_audit_status tinyint(2) unsigned NOT NULL DEFAULT 0 COMMENT '处方审核 0待审1通过2驳回'
|
||||
prescription_audit_remark varchar(500) NOT NULL DEFAULT '' COMMENT '处方审核意见'
|
||||
```
|
||||
|
||||
### 处方表 (zyt_tcm_prescription)
|
||||
```sql
|
||||
audit_status tinyint(1) NOT NULL DEFAULT 1 COMMENT '审核:0待审核 1已通过 2已驳回'
|
||||
audit_time int(10) unsigned DEFAULT NULL COMMENT '审核时间'
|
||||
audit_by int(11) unsigned DEFAULT NULL COMMENT '审核人ID'
|
||||
audit_by_name varchar(50) NOT NULL DEFAULT '' COMMENT '审核人姓名'
|
||||
audit_remark varchar(500) NOT NULL DEFAULT '' COMMENT '审核意见'
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 正常流程测试
|
||||
- [ ] 创建订单,处方待审核
|
||||
- [ ] 审核通过处方
|
||||
- [ ] 验证业务订单表和处方表状态都为"已通过"
|
||||
- [ ] 撤回处方审核
|
||||
- [ ] 验证业务订单表和处方表状态都为"待审核"
|
||||
- [ ] 验证处方表的审核时间、审核人等字段已清空
|
||||
|
||||
### 边界情况测试
|
||||
- [ ] 撤回时处方不存在(已删除)
|
||||
- [ ] 撤回时处方ID为0
|
||||
- [ ] 撤回时数据库连接失败
|
||||
- [ ] 验证错误被正确记录到日志
|
||||
|
||||
### 权限测试
|
||||
- [ ] 无审核权限的用户不能撤回
|
||||
- [ ] 有审核权限的用户可以撤回
|
||||
- [ ] 订单已结束时不能撤回
|
||||
|
||||
## 日志记录
|
||||
|
||||
### 成功日志
|
||||
```
|
||||
操作日志 (zyt_tcm_prescription_order_log):
|
||||
action: "revoke_rx_audit"
|
||||
summary: "撤回处方审核,状态重置为待审核"
|
||||
```
|
||||
|
||||
### 错误日志
|
||||
```
|
||||
系统日志:
|
||||
level: ERROR
|
||||
message: "撤回处方审核时同步处方表失败: [错误信息]"
|
||||
context: {
|
||||
"prescription_id": 123,
|
||||
"order_id": 456
|
||||
}
|
||||
```
|
||||
|
||||
## 相关功能
|
||||
|
||||
### 1. 审核处方
|
||||
**方法**: `PrescriptionOrderLogic::auditPrescription()`
|
||||
- 同时更新业务订单表和处方表的审核状态
|
||||
|
||||
### 2. 撤回处方审核
|
||||
**方法**: `PrescriptionOrderLogic::revokeRxAudit()`
|
||||
- 同时重置业务订单表和处方表的审核状态(本次修复)
|
||||
|
||||
### 3. 撤回支付单审核
|
||||
**方法**: `PrescriptionOrderLogic::revokePayAudit()`
|
||||
- 只更新业务订单表的支付审核状态
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据一致性**:确保两个表的审核状态始终保持一致
|
||||
2. **容错处理**:处方表更新失败不影响主流程
|
||||
3. **日志记录**:所有错误都会被记录,便于排查
|
||||
4. **权限控制**:只有有审核权限的用户才能撤回
|
||||
5. **状态限制**:订单已结束时不能撤回
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 业务逻辑
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php` - 控制器
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端页面
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **修复前**:撤回审核只更新业务订单表
|
||||
✅ **修复后**:撤回审核同时更新业务订单表和处方表
|
||||
✅ **容错处理**:处方表更新失败不影响主流程
|
||||
✅ **日志记录**:错误会被记录到系统日志
|
||||
|
||||
这样可以确保数据一致性,避免审核状态不同步的问题。
|
||||
@@ -1,293 +0,0 @@
|
||||
# Service Package Multi-Select Implementation (服务套餐多选实现)
|
||||
|
||||
## Changes Made
|
||||
|
||||
Updated the service package field from a simple text input to a multi-select dropdown with data loaded from the dictionary API.
|
||||
|
||||
### 1. Added Dictionary API Import
|
||||
**File**: `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
```typescript
|
||||
import { getDictData } from '@/api/app'
|
||||
```
|
||||
|
||||
### 2. Added Service Package Options State
|
||||
```typescript
|
||||
// 服务套餐选项
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
```
|
||||
|
||||
### 3. Added Loading Function
|
||||
```typescript
|
||||
// 加载服务套餐选项
|
||||
const loadServicePackageOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'server_order' })
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
||||
} catch (error) {
|
||||
console.error('加载服务套餐选项失败:', error)
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Updated onMounted Hook
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
await loadRegionData()
|
||||
await loadServicePackageOptions() // Added
|
||||
getLists()
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Changed Form Field Type
|
||||
Changed `editForm.service_package` from string to array:
|
||||
```typescript
|
||||
// Before:
|
||||
service_package: '',
|
||||
|
||||
// After:
|
||||
service_package: [] as string[],
|
||||
```
|
||||
|
||||
### 6. Updated Edit Form UI
|
||||
Changed from text input to multi-select:
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
<el-input v-model="editForm.service_package" maxlength="100" />
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<el-select
|
||||
v-model="editForm.service_package"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="请选择服务套餐"
|
||||
clearable
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in servicePackageOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 7. Updated Data Loading Logic
|
||||
Added logic to handle both array and string formats when loading data:
|
||||
|
||||
```typescript
|
||||
// 处理服务套餐:如果是字符串,转换为数组
|
||||
if (d.service_package) {
|
||||
if (Array.isArray(d.service_package)) {
|
||||
editForm.service_package = d.service_package
|
||||
} else if (typeof d.service_package === 'string') {
|
||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Updated Form Submission
|
||||
Convert array back to comma-separated string for backend:
|
||||
|
||||
```typescript
|
||||
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
||||
```
|
||||
|
||||
### 9. Added Display Formatter
|
||||
Added `formatServicePackage()` function to display service packages in detail view:
|
||||
|
||||
```typescript
|
||||
// 格式化服务套餐显示
|
||||
function formatServicePackage(value: any): string {
|
||||
if (!value) return '—'
|
||||
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').filter(v => v.trim() !== '')
|
||||
}
|
||||
|
||||
if (packages.length === 0) return '—'
|
||||
|
||||
// 将值转换为名称
|
||||
const names = packages.map(val => {
|
||||
const option = servicePackageOptions.value.find(opt => opt.value === val)
|
||||
return option ? option.name : val
|
||||
})
|
||||
|
||||
return names.join('、')
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Updated Detail Display
|
||||
```vue
|
||||
<el-descriptions-item label="服务套餐">
|
||||
{{ formatServicePackage(detailData.service_package) }}
|
||||
</el-descriptions-item>
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Loading Options:
|
||||
1. Component mounts
|
||||
2. `loadServicePackageOptions()` calls API: `/adminapi/config/dict?type=server_order`
|
||||
3. Filters out disabled options (status !== 0)
|
||||
4. Stores in `servicePackageOptions`
|
||||
|
||||
### Editing Order:
|
||||
1. User opens edit dialog
|
||||
2. Backend returns `service_package` as string: `"package1,package2,package3"`
|
||||
3. Frontend splits string into array: `['package1', 'package2', 'package3']`
|
||||
4. Multi-select displays selected options
|
||||
5. User can add/remove selections
|
||||
6. On save, array is joined back to string: `"package1,package2,package3"`
|
||||
7. Backend receives comma-separated string
|
||||
|
||||
### Displaying in Detail:
|
||||
1. Backend returns `service_package` as string
|
||||
2. `formatServicePackage()` splits string into array
|
||||
3. Maps values to display names using `servicePackageOptions`
|
||||
4. Joins names with Chinese comma: `"套餐A、套餐B、套餐C"`
|
||||
|
||||
## UI Features
|
||||
|
||||
### Multi-Select Features:
|
||||
- **Multiple Selection**: Users can select multiple service packages
|
||||
- **Collapse Tags**: Selected items are collapsed with a count badge when many are selected
|
||||
- **Collapse Tags Tooltip**: Hover over collapsed tags to see all selections
|
||||
- **Clearable**: Users can clear all selections with one click
|
||||
- **Searchable**: Built-in search functionality (default for el-select)
|
||||
|
||||
### Display Format:
|
||||
- Detail view: `套餐A、套餐B、套餐C` (Chinese comma separator)
|
||||
- Edit form: Tag-style display with collapse for many items
|
||||
- Empty state: Shows `—` when no packages selected
|
||||
|
||||
## Backend Compatibility
|
||||
|
||||
### Database Storage:
|
||||
The backend stores service packages as a comma-separated string in the database:
|
||||
```
|
||||
service_package: "package1,package2,package3"
|
||||
```
|
||||
|
||||
### API Response:
|
||||
The backend returns the string as-is, and the frontend handles the conversion.
|
||||
|
||||
### API Request:
|
||||
The frontend sends the string format, maintaining backward compatibility.
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Test Options Loading
|
||||
1. Open order list page
|
||||
2. Check browser console for: `服务套餐选项已加载: X`
|
||||
3. Open edit dialog
|
||||
4. Click on service package dropdown
|
||||
5. Verify options are displayed
|
||||
|
||||
### 2. Test Multi-Selection
|
||||
1. Edit an order
|
||||
2. Click service package dropdown
|
||||
3. Select multiple packages
|
||||
4. Verify tags appear below the dropdown
|
||||
5. Select many packages to test collapse behavior
|
||||
6. Hover over collapsed tags to see tooltip
|
||||
|
||||
### 3. Test Saving
|
||||
1. Select multiple service packages
|
||||
2. Save the order
|
||||
3. Check browser Network tab
|
||||
4. Verify payload contains: `service_package: "value1,value2,value3"`
|
||||
5. Verify database is updated
|
||||
|
||||
### 4. Test Display
|
||||
1. Open order detail view
|
||||
2. Check "服务套餐" field
|
||||
3. Verify it shows: `套餐A、套餐B、套餐C`
|
||||
4. Test with orders that have:
|
||||
- Multiple packages
|
||||
- Single package
|
||||
- No packages (should show `—`)
|
||||
|
||||
### 5. Test Backward Compatibility
|
||||
1. Edit an old order that has service_package as string
|
||||
2. Verify it loads correctly into the multi-select
|
||||
3. Verify you can modify and save
|
||||
|
||||
### 6. Test Clear Functionality
|
||||
1. Edit an order with service packages selected
|
||||
2. Click the clear button (×) on the select
|
||||
3. Verify all selections are cleared
|
||||
4. Save and verify database is updated to empty string
|
||||
|
||||
## Dictionary API Format
|
||||
|
||||
The API endpoint `/adminapi/config/dict?type=server_order` should return:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_order": [
|
||||
{
|
||||
"name": "套餐A",
|
||||
"value": "package_a",
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"name": "套餐B",
|
||||
"value": "package_b",
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"name": "套餐C (已停用)",
|
||||
"value": "package_c",
|
||||
"status": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Options with `status: 0` are filtered out and not displayed.
|
||||
|
||||
## Related Files
|
||||
|
||||
### Frontend:
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- Added `getDictData` import
|
||||
- Added `servicePackageOptions` state
|
||||
- Added `loadServicePackageOptions()` function
|
||||
- Updated `editForm.service_package` to array type
|
||||
- Updated edit form UI to multi-select
|
||||
- Added data loading/saving conversion logic
|
||||
- Added `formatServicePackage()` display function
|
||||
- Updated detail display
|
||||
|
||||
### Backend:
|
||||
- `/adminapi/config/dict` endpoint (should already exist)
|
||||
- Dictionary data for `type=server_order`
|
||||
|
||||
## Summary
|
||||
|
||||
✅ Changed from text input to multi-select dropdown
|
||||
✅ Loads options from dictionary API
|
||||
✅ Supports multiple selection with tag display
|
||||
✅ Collapse tags for better UI when many items selected
|
||||
✅ Converts between array (frontend) and string (backend)
|
||||
✅ Displays formatted names in detail view
|
||||
✅ Backward compatible with existing string data
|
||||
✅ Filters out disabled options
|
||||
✅ Clearable and searchable
|
||||
|
||||
The service package field now provides a better user experience with predefined options and multi-selection capability!
|
||||
@@ -1,179 +0,0 @@
|
||||
# 收货地址省市区功能实现
|
||||
|
||||
## 概述
|
||||
为处方业务订单编辑表单添加省/市/区选择器,将地址信息结构化存储。
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 新增字段
|
||||
在 `zyt_tcm_prescription_order` 表中添加三个字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `shipping_province` varchar(50) NOT NULL DEFAULT '' COMMENT '收货省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `shipping_city` varchar(50) NOT NULL DEFAULT '' COMMENT '收货城市' AFTER `shipping_province`,
|
||||
ADD COLUMN `shipping_district` varchar(50) NOT NULL DEFAULT '' COMMENT '收货区县' AFTER `shipping_city`;
|
||||
```
|
||||
|
||||
**执行方式:**
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_shipping_region_fields.sql
|
||||
```
|
||||
|
||||
## 后端变更
|
||||
|
||||
### 文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
在 `edit()` 方法中添加省市区字段处理:
|
||||
|
||||
```php
|
||||
// 处理省市区字段
|
||||
if (isset($params['shipping_province'])) {
|
||||
$order->shipping_province = (string) $params['shipping_province'];
|
||||
}
|
||||
if (isset($params['shipping_city'])) {
|
||||
$order->shipping_city = (string) $params['shipping_city'];
|
||||
}
|
||||
if (isset($params['shipping_district'])) {
|
||||
$order->shipping_district = (string) $params['shipping_district'];
|
||||
}
|
||||
```
|
||||
|
||||
## 前端变更
|
||||
|
||||
### 文件:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. 数据结构
|
||||
在 `editForm` 中添加 `region` 字段:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
// ... 其他字段
|
||||
region: [] as string[], // 新增
|
||||
shipping_address: '',
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. UI 组件
|
||||
在收货手机下方添加省市区选择器:
|
||||
```vue
|
||||
<el-form-item label="省/市/区" prop="region">
|
||||
<el-cascader
|
||||
v-model="editForm.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
#### 3. 数据提交
|
||||
在 `submitEdit()` 中将 region 数组拆分为三个字段:
|
||||
```typescript
|
||||
if (editForm.region && editForm.region.length > 0) {
|
||||
payload.shipping_province = editForm.region[0] || ''
|
||||
payload.shipping_city = editForm.region[1] || ''
|
||||
payload.shipping_district = editForm.region[2] || ''
|
||||
} else {
|
||||
payload.shipping_province = ''
|
||||
payload.shipping_city = ''
|
||||
payload.shipping_district = ''
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 数据加载
|
||||
在 `openEdit()` 中从后端数据填充 region:
|
||||
```typescript
|
||||
const province = d.shipping_province || ''
|
||||
const city = d.shipping_city || ''
|
||||
const district = d.shipping_district || ''
|
||||
if (province || city || district) {
|
||||
editForm.region = [province, city, district].filter(v => v !== '')
|
||||
} else {
|
||||
editForm.region = []
|
||||
}
|
||||
```
|
||||
|
||||
## 省市区数据
|
||||
|
||||
当前使用简化的示例数据,包含:
|
||||
- 四川省(成都市、绵阳市)
|
||||
- 北京市
|
||||
- 上海市
|
||||
- 广东省(广州市、深圳市)
|
||||
|
||||
### 生产环境建议
|
||||
|
||||
**方案 1:使用完整数据集**
|
||||
- 集成中国行政区划完整数据(约 3000+ 区县)
|
||||
- 推荐数据源:
|
||||
- [element-china-area-data](https://www.npmjs.com/package/element-china-area-data)
|
||||
- [china-division](https://github.com/modood/Administrative-divisions-of-China)
|
||||
|
||||
**方案 2:使用 API 服务**
|
||||
- 调用第三方地址 API(如高德地图、腾讯地图)
|
||||
- 优点:数据实时更新,减少前端体积
|
||||
- 缺点:依赖网络,需要 API Key
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. **执行数据库迁移**
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_shipping_region_fields.sql
|
||||
```
|
||||
|
||||
2. **重启后端服务**(如需要)
|
||||
|
||||
3. **测试功能**
|
||||
- 打开处方业务订单列表
|
||||
- 点击"编辑"按钮
|
||||
- 选择省/市/区
|
||||
- 填写详细地址
|
||||
- 保存并验证数据已正确存储
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 前端存储格式
|
||||
```typescript
|
||||
region: ['四川省', '成都市', '武侯区']
|
||||
```
|
||||
|
||||
### 后端存储格式
|
||||
```php
|
||||
[
|
||||
'shipping_province' => '四川省',
|
||||
'shipping_city' => '成都市',
|
||||
'shipping_district' => '武侯区',
|
||||
'shipping_address' => '天府大道中段123号' // 详细地址
|
||||
]
|
||||
```
|
||||
|
||||
### 数据库存储
|
||||
```
|
||||
shipping_province: 四川省
|
||||
shipping_city: 成都市
|
||||
shipping_district: 武侯区
|
||||
shipping_address: 天府大道中段123号
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **向后兼容**:旧数据的省市区字段为空字符串,不影响现有功能
|
||||
2. **可选字段**:省市区选择器可清空,允许用户只填写详细地址
|
||||
3. **数据验证**:前端使用 cascader 确保选择的层级关系正确
|
||||
4. **字段长度**:省市区字段各限制 50 字符
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `add_shipping_region_fields.sql` - 数据库迁移文件
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端订单列表页面
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端业务逻辑
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. 在订单详情页面也显示省市区信息
|
||||
2. 在订单列表的收货人列中显示省市信息
|
||||
3. 添加按省市区筛选订单的功能
|
||||
4. 集成完整的中国行政区划数据
|
||||
5. 考虑添加邮政编码字段
|
||||
@@ -1,348 +0,0 @@
|
||||
# TRTC 录制存储迁移:VOD → COS
|
||||
|
||||
## 变更说明
|
||||
|
||||
将 TRTC 云端录制的存储方式从**腾讯云点播(VOD)**改为**对象存储(COS)**。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### File: `server/app/common/service/TrtcCloudRecordingService.php`
|
||||
|
||||
#### 1. 存储配置变更
|
||||
|
||||
**Before (VOD):**
|
||||
```php
|
||||
$tencentVod = new \TencentCloud\Trtc\V20190722\Models\TencentVod();
|
||||
$tencentVod->ExpireTime = 0;
|
||||
$tencentVod->MediaType = 0;
|
||||
$tencentVod->UserDefineRecordId = $prefix;
|
||||
$tencentVod->SubAppId = $vodSubApp;
|
||||
|
||||
$cloudVod = new \TencentCloud\Trtc\V20190722\Models\CloudVod();
|
||||
$cloudVod->TencentVod = $tencentVod;
|
||||
|
||||
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
|
||||
$storage->CloudVod = $cloudVod;
|
||||
```
|
||||
|
||||
**After (COS):**
|
||||
```php
|
||||
$cloudStorage = new \TencentCloud\Trtc\V20190722\Models\CloudStorage();
|
||||
$cloudStorage->Vendor = 0; // 0=腾讯云
|
||||
$cloudStorage->Region = 'ap-guangzhou';
|
||||
$cloudStorage->Bucket = 'your-bucket-name';
|
||||
$cloudStorage->Prefix = 'trtc-recording/';
|
||||
|
||||
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
|
||||
$storage->CloudStorage = $cloudStorage;
|
||||
```
|
||||
|
||||
#### 2. 更新日志信息
|
||||
- 日志中标注存储类型为 COS
|
||||
- 记录 bucket、region、prefix 等信息
|
||||
- 更新提示信息
|
||||
|
||||
#### 3. 更新路径清理函数
|
||||
- 允许路径中包含 `/` 字符(COS 路径分隔符)
|
||||
- 从 `sanitizeVodUserDefineRecordId` 改为支持 COS 路径格式
|
||||
|
||||
## 配置要求
|
||||
|
||||
### 必需配置项
|
||||
|
||||
在 `config/trtc.php` 或 `.env` 中添加以下配置:
|
||||
|
||||
```php
|
||||
// config/trtc.php
|
||||
return [
|
||||
// ... 其他配置 ...
|
||||
|
||||
// COS 存储配置
|
||||
'recording_cos_vendor' => 0, // 0=腾讯云
|
||||
'recording_cos_region' => env('TRTC_RECORDING_COS_REGION', 'ap-guangzhou'),
|
||||
'recording_cos_bucket' => env('TRTC_RECORDING_COS_BUCKET', ''),
|
||||
'recording_cos_prefix' => env('TRTC_RECORDING_COS_PREFIX', 'trtc-recording/'),
|
||||
];
|
||||
```
|
||||
|
||||
### .env 配置示例
|
||||
|
||||
```env
|
||||
# TRTC 录制 COS 存储配置
|
||||
TRTC_RECORDING_COS_REGION=ap-guangzhou
|
||||
TRTC_RECORDING_COS_BUCKET=your-bucket-1234567890
|
||||
TRTC_RECORDING_COS_PREFIX=trtc-recording/
|
||||
```
|
||||
|
||||
## COS Bucket 准备
|
||||
|
||||
### 1. 创建 COS 存储桶
|
||||
|
||||
1. 登录 [腾讯云 COS 控制台](https://console.cloud.tencent.com/cos)
|
||||
2. 创建存储桶
|
||||
- 名称:例如 `trtc-recording-1234567890`
|
||||
- 地域:选择与 TRTC 应用相同或就近的地域
|
||||
- 访问权限:私有读写(推荐)
|
||||
|
||||
### 2. 配置 CORS(如需前端访问)
|
||||
|
||||
如果需要前端直接访问录制文件,配置 CORS 规则:
|
||||
|
||||
```json
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": [],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 配置生命周期(可选)
|
||||
|
||||
设置自动删除过期录制文件:
|
||||
|
||||
- 规则名称:`auto-delete-old-recordings`
|
||||
- 应用范围:前缀 `trtc-recording/`
|
||||
- 删除策略:创建后 30 天删除
|
||||
|
||||
## 存储路径结构
|
||||
|
||||
### 默认路径格式
|
||||
|
||||
```
|
||||
bucket-name/
|
||||
└── trtc-recording/
|
||||
├── room_123_20240101_120000.mp4
|
||||
├── room_456_20240101_130000.mp4
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 自定义前缀路径
|
||||
|
||||
如果传入 `$vodUserDefineRecordId` 参数:
|
||||
|
||||
```
|
||||
bucket-name/
|
||||
└── custom-prefix/
|
||||
├── file1.mp4
|
||||
├── file2.mp4
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 文件命名规则
|
||||
|
||||
COS 存储的文件名由 TRTC 自动生成,通常包含:
|
||||
- 房间号
|
||||
- 时间戳
|
||||
- 用户 ID(单流录制)
|
||||
- 文件格式(.mp4)
|
||||
|
||||
示例:
|
||||
```
|
||||
trtc-recording/sdkappid_roomid_userid_timestamp.mp4
|
||||
```
|
||||
|
||||
## 权限配置
|
||||
|
||||
### TRTC 服务需要的 COS 权限
|
||||
|
||||
确保 TRTC 服务有权限写入 COS:
|
||||
|
||||
1. **方式一:使用主账号密钥**(不推荐生产环境)
|
||||
- 使用主账号的 SecretId 和 SecretKey
|
||||
|
||||
2. **方式二:使用子账号密钥**(推荐)
|
||||
- 创建子账号
|
||||
- 授予 COS 写入权限策略:
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"statement": [
|
||||
{
|
||||
"effect": "allow",
|
||||
"action": [
|
||||
"cos:PutObject",
|
||||
"cos:PostObject",
|
||||
"cos:InitiateMultipartUpload",
|
||||
"cos:UploadPart",
|
||||
"cos:CompleteMultipartUpload"
|
||||
],
|
||||
"resource": [
|
||||
"qcs::cos:ap-guangzhou:uid/1234567890:your-bucket-1234567890/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. **方式三:使用服务角色**(最佳实践)
|
||||
- 为 TRTC 服务创建角色
|
||||
- 授予角色 COS 写入权限
|
||||
- TRTC 自动使用角色权限
|
||||
|
||||
## 对比:VOD vs COS
|
||||
|
||||
| 特性 | VOD(点播) | COS(对象存储) |
|
||||
|------|------------|----------------|
|
||||
| **存储成本** | 较高 | 较低 |
|
||||
| **功能** | 媒体处理、转码、播放 | 纯存储 |
|
||||
| **访问方式** | 点播 API/播放器 | HTTP/HTTPS 直接访问 |
|
||||
| **适用场景** | 需要转码、加密、播放统计 | 简单存储和下载 |
|
||||
| **计费** | 存储+流量+转码 | 存储+流量 |
|
||||
| **管理** | 媒资管理系统 | 文件管理 |
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### 1. 准备 COS 存储桶
|
||||
按照上述"COS Bucket 准备"步骤创建并配置存储桶
|
||||
|
||||
### 2. 更新配置文件
|
||||
在 `.env` 中添加 COS 配置项
|
||||
|
||||
### 3. 部署代码
|
||||
部署更新后的 `TrtcCloudRecordingService.php`
|
||||
|
||||
### 4. 测试录制
|
||||
1. 发起一次测试录制
|
||||
2. 检查 COS 控制台是否有文件上传
|
||||
3. 验证文件可以正常下载和播放
|
||||
|
||||
### 5. 监控日志
|
||||
查看日志确认录制状态:
|
||||
```
|
||||
CreateCloudRecording mix (COS)
|
||||
DescribeCloudRecording(合流校验-COS)
|
||||
```
|
||||
|
||||
## 获取录制文件
|
||||
|
||||
### 方式一:COS 控制台
|
||||
1. 登录 COS 控制台
|
||||
2. 进入对应存储桶
|
||||
3. 浏览 `trtc-recording/` 目录
|
||||
4. 下载或获取文件 URL
|
||||
|
||||
### 方式二:COS SDK
|
||||
```php
|
||||
use Qcloud\Cos\Client;
|
||||
|
||||
$cosClient = new Client([
|
||||
'region' => 'ap-guangzhou',
|
||||
'credentials' => [
|
||||
'secretId' => 'your-secret-id',
|
||||
'secretKey' => 'your-secret-key',
|
||||
],
|
||||
]);
|
||||
|
||||
// 列出录制文件
|
||||
$result = $cosClient->listObjects([
|
||||
'Bucket' => 'your-bucket-1234567890',
|
||||
'Prefix' => 'trtc-recording/',
|
||||
]);
|
||||
|
||||
foreach ($result['Contents'] as $file) {
|
||||
echo $file['Key'] . "\n";
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:生成临时访问 URL
|
||||
```php
|
||||
$url = $cosClient->getObjectUrl(
|
||||
'your-bucket-1234567890',
|
||||
'trtc-recording/file.mp4',
|
||||
'+10 minutes' // URL 有效期
|
||||
);
|
||||
```
|
||||
|
||||
## 回调通知
|
||||
|
||||
### COS 事件通知
|
||||
可以配置 COS 事件通知,在文件上传完成时触发回调:
|
||||
|
||||
1. 在 COS 控制台配置事件通知
|
||||
2. 选择事件类型:`cos:ObjectCreated:*`
|
||||
3. 配置回调 URL
|
||||
4. 接收通知并处理
|
||||
|
||||
### 回调数据示例
|
||||
```json
|
||||
{
|
||||
"Records": [
|
||||
{
|
||||
"event": {
|
||||
"eventName": "cos:ObjectCreated:Put",
|
||||
"eventTime": "2024-01-01T12:00:00Z"
|
||||
},
|
||||
"cos": {
|
||||
"cosObject": {
|
||||
"key": "trtc-recording/room_123_20240101_120000.mp4",
|
||||
"size": 12345678,
|
||||
"url": "https://bucket.cos.ap-guangzhou.myqcloud.com/..."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:录制文件未上传到 COS
|
||||
**检查项:**
|
||||
- COS Bucket 名称是否正确
|
||||
- Region 是否匹配
|
||||
- TRTC 服务是否有 COS 写入权限
|
||||
- 查看日志中的错误信息
|
||||
|
||||
### 问题2:无法访问录制文件
|
||||
**检查项:**
|
||||
- Bucket 访问权限设置
|
||||
- 文件路径是否正确
|
||||
- 是否需要签名 URL
|
||||
|
||||
### 问题3:录制状态一直是 Idle
|
||||
**检查项:**
|
||||
- RoomIdType 是否与客户端一致
|
||||
- 房间内是否有用户推流
|
||||
- 网络连接是否正常
|
||||
|
||||
## 成本估算
|
||||
|
||||
### COS 存储成本(以广州地域为例)
|
||||
|
||||
**存储费用:**
|
||||
- 标准存储:0.118 元/GB/月
|
||||
- 低频存储:0.08 元/GB/月
|
||||
|
||||
**流量费用:**
|
||||
- 外网下行流量:0.5 元/GB
|
||||
- CDN 回源流量:0.15 元/GB
|
||||
|
||||
**示例计算:**
|
||||
- 每天录制 10 小时,每小时 500MB
|
||||
- 月存储量:10 × 30 × 0.5 = 150GB
|
||||
- 月存储费用:150 × 0.118 = 17.7 元
|
||||
- 月流量(假设下载 50GB):50 × 0.5 = 25 元
|
||||
- **月总费用:约 42.7 元**
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [TRTC 云端录制](https://cloud.tencent.com/document/product/647/16823)
|
||||
- [COS 对象存储](https://cloud.tencent.com/document/product/436)
|
||||
- [COS PHP SDK](https://cloud.tencent.com/document/product/436/12266)
|
||||
|
||||
## 总结
|
||||
|
||||
✅ 存储方式从 VOD 改为 COS
|
||||
✅ 降低存储成本
|
||||
✅ 简化文件管理
|
||||
✅ 支持自定义路径前缀
|
||||
✅ 更新日志和提示信息
|
||||
✅ 需要配置 COS Bucket 和权限
|
||||
|
||||
迁移到 COS 后,录制文件将直接存储到对象存储桶,成本更低,管理更简单!
|
||||
@@ -729,14 +729,11 @@ const { proxy } = getCurrentInstance()
|
||||
|
||||
if(options.msg==1){
|
||||
console.log('msg我进来了',options.msg)
|
||||
await waitForIMReady(); // 确保 SDK 网络就绪后再发消息
|
||||
await sendConfirmationMessage(); // 进入聊天时自动发送知情确认
|
||||
await sendConfirmationMessage(); // 进入聊天时自动发送知情确认
|
||||
}
|
||||
loadDoctorInfo(); // 加载医生卡片信息(对方为医生时)
|
||||
uni.$on('TUICall:callStart', onVideoCallStart);
|
||||
uni.$on('TUICall:callEnd', onVideoCallEnd);
|
||||
// 提前申请音视频权限,避免来电接通时才弹「去设置」提示
|
||||
requestAVPermissions({ silent: true, scene: 'onLoad' });
|
||||
//uni.showToast({ title: '聊天已就绪', icon: 'success' });
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
@@ -1139,25 +1136,18 @@ const { proxy } = getCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待 IM SDK 网络握手完成,给 SDK 一段时间确保就绪后再发消息 */
|
||||
function waitForIMReady() {
|
||||
// 使用固定延迟代替事件监听:SDK_READY 事件可能在注册前已触发,
|
||||
// 事件监听会等满 maxMs 超时,在微信小程序中引发 "Error: timeout"。
|
||||
return new Promise((resolve) => setTimeout(resolve, 800));
|
||||
}
|
||||
|
||||
async function sendConfirmationMessage() {
|
||||
const alreadySent = messages.value.some(
|
||||
(m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text))
|
||||
);
|
||||
if (alreadySent) return;
|
||||
|
||||
const pushSent = () => {
|
||||
// 防止重复 push(relogin 后重试时再次检查)
|
||||
const already = messages.value.some(
|
||||
(m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text))
|
||||
);
|
||||
if (already) return;
|
||||
try {
|
||||
const message = chat.createTextMessage({
|
||||
to: targetUserID,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { text: CONFIRMATION_MESSAGE }
|
||||
});
|
||||
await chat.sendMessage(message);
|
||||
messages.value.push({
|
||||
type: 'confirmation',
|
||||
text: CONFIRMATION_MESSAGE,
|
||||
@@ -1167,27 +1157,8 @@ const { proxy } = getCurrentInstance()
|
||||
});
|
||||
scrollToBottom();
|
||||
setTimeout(() => scrollToBottom(), 150);
|
||||
};
|
||||
const doSend = async () => {
|
||||
const message = chat.createTextMessage({
|
||||
to: targetUserID,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { text: CONFIRMATION_MESSAGE }
|
||||
});
|
||||
await chat.sendMessage(message);
|
||||
};
|
||||
try {
|
||||
await doSend();
|
||||
pushSent();
|
||||
} catch (error) {
|
||||
console.error('发送确认信息失败,尝试重新登录后重试:', error);
|
||||
try {
|
||||
await reloginIMChat();
|
||||
await doSend();
|
||||
pushSent();
|
||||
} catch (e2) {
|
||||
console.error('重登后确认信息仍发送失败:', e2);
|
||||
}
|
||||
console.error('发送确认信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,78 +1335,7 @@ const { proxy } = getCurrentInstance()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请摄像头 + 麦克风权限。
|
||||
* silent=true 时仅静默尝试,不弹引导框(用于页面加载时提前申请)。
|
||||
* silent=false 时若被拒绝主动引导用户去设置页开启。
|
||||
* scene 标识触发场景(makeCall / incoming),用于日志分析。
|
||||
*/
|
||||
// #ifdef MP-WEIXIN
|
||||
function requestAVPermissions({ silent = false, scene = 'unknown' } = {}) {
|
||||
/** 上报权限被拒日志到后端 */
|
||||
const logDenied = (deniedScope, action) => {
|
||||
try {
|
||||
const wxVersion = wx.getSystemInfoSync?.()?.SDKVersion || '';
|
||||
proxy.apiUrl({
|
||||
url: '/api/chat/logAvPermission',
|
||||
method: 'POST',
|
||||
data: {
|
||||
patient_id: currentUserID || '',
|
||||
doctor_id: targetUserID || '',
|
||||
denied_scope: deniedScope,
|
||||
scene,
|
||||
action,
|
||||
wx_version: wxVersion
|
||||
}
|
||||
}, false).catch(() => {});
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const guideToSetting = (deniedScope) => {
|
||||
if (silent) {
|
||||
// 静默模式:仅记录被拒,不弹弹窗
|
||||
logDenied(deniedScope, 'silent_denied');
|
||||
return;
|
||||
}
|
||||
uni.showModal({
|
||||
title: '需要摄像头和麦克风权限',
|
||||
content: '视频通话需要摄像头和麦克风权限,请点击「去设置」开启后重试',
|
||||
confirmText: '去设置',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
logDenied(deniedScope, 'open_setting');
|
||||
wx.openSetting();
|
||||
} else {
|
||||
// 用户选择「取消」,记录日志
|
||||
logDenied(deniedScope, 'cancel');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
wx.authorize({
|
||||
scope: 'scope.camera',
|
||||
success: () => {
|
||||
wx.authorize({
|
||||
scope: 'scope.record',
|
||||
success: () => resolve(true),
|
||||
fail: () => { guideToSetting('record'); resolve(false); }
|
||||
});
|
||||
},
|
||||
fail: () => { guideToSetting('camera'); resolve(false); }
|
||||
});
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
function requestAVPermissions() { return Promise.resolve(true); }
|
||||
// #endif
|
||||
|
||||
async function makeVideoCall() {
|
||||
// 发起通话前先检查权限,若被拒引导去设置
|
||||
const granted = await requestAVPermissions({ silent: false, scene: 'makeCall' });
|
||||
if (!granted) return;
|
||||
try {
|
||||
hideDoctorCardByCall();
|
||||
if (!uni.CallManager) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import App from './App'
|
||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||
var baseUrl ='https://capi.zzzhengyangtang.cn/';
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
|
||||
@@ -625,47 +625,11 @@ const getLocalDiagnosisText = () => {
|
||||
return String(val) + '、';
|
||||
};
|
||||
|
||||
// ---- 音视频权限辅助(仅微信小程序) ----
|
||||
// #ifdef MP-WEIXIN
|
||||
const requestAVPermissionsInModal = () => new Promise((resolve) => {
|
||||
const guide = (deniedScope) => {
|
||||
uni.showModal({
|
||||
title: '需要摄像头和麦克风权限',
|
||||
content: '视频问诊需要摄像头和麦克风权限,请点击「去设置」开启后重试',
|
||||
confirmText: '去设置',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.openSetting();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
wx.authorize({
|
||||
scope: 'scope.camera',
|
||||
success: () => {
|
||||
wx.authorize({
|
||||
scope: 'scope.record',
|
||||
success: () => resolve(true),
|
||||
fail: () => guide('record')
|
||||
});
|
||||
},
|
||||
fail: () => guide('camera')
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const requestAVPermissionsInModal = () => Promise.resolve(true);
|
||||
// #endif
|
||||
|
||||
// 关闭就诊人信息弹窗
|
||||
const closePatientModal = async () => {
|
||||
if (confirmBtnLoading.value) return;
|
||||
confirmBtnLoading.value = true;
|
||||
try {
|
||||
// 在用户主动点击的时机申请摄像头 + 麦克风权限
|
||||
// (首次会弹原生授权弹窗;已拒绝则引导去设置页)
|
||||
const granted = await requestAVPermissionsInModal();
|
||||
if (!granted) return; // 未授权,停止跳转
|
||||
const app = getApp();
|
||||
// const userID = app.globalData.userID;
|
||||
// const userSig = app.globalData.userSig;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 添加每贴出包数字段到处方表
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `bags_per_dose` TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '每贴出包数(饮片专用,1-9包)' AFTER `need_decoction`;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 为处方业务订单表添加完单申请字段
|
||||
-- 用于记录补齐支付单时是否申请完成订单
|
||||
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `completion_request` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请:0=未申请,1=已申请' AFTER `payment_slip_audit_remark`,
|
||||
ADD COLUMN `completion_request_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请时间' AFTER `completion_request`,
|
||||
ADD COLUMN `completion_request_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请人ID' AFTER `completion_request_time`,
|
||||
ADD COLUMN `completion_request_by_name` varchar(64) NOT NULL DEFAULT '' COMMENT '完单申请人姓名' AFTER `completion_request_by`;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 添加剂量单位和剂数字段到处方业务订单表
|
||||
-- 用于记录订单的剂量单位和剂数信息
|
||||
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `dose_unit` varchar(20) NOT NULL DEFAULT '剂' COMMENT '剂量单位:剂、丸、袋、盒、瓶、膏、贴' AFTER `medication_days`,
|
||||
ADD COLUMN `dose_count` int(11) NOT NULL DEFAULT 1 COMMENT '剂数' AFTER `dose_unit`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||
AND COLUMN_NAME IN ('dose_unit', 'dose_count');
|
||||
@@ -1,12 +0,0 @@
|
||||
-- 添加甘草订单回调相关字段
|
||||
-- 在 zyt_prescription_order 表中添加
|
||||
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD COLUMN `gancao_order_state` INT(11) DEFAULT 0 COMMENT '甘草订单状态:10=审核中,11=审核通过,110=制作中,20=物流中,30=完成,90=拦截,91=撤单,92=驳回' AFTER `gancao_submit_time`,
|
||||
ADD COLUMN `gancao_flow_name` VARCHAR(100) DEFAULT '' COMMENT '甘草订单流程名称(如:派单、审方、调配、复核、浸泡、煎药、包装、发货等)' AFTER `gancao_order_state`,
|
||||
ADD COLUMN `gancao_supplier` VARCHAR(100) DEFAULT '' COMMENT '甘草供应商/药房名称' AFTER `gancao_flow_name`,
|
||||
ADD COLUMN `gancao_remark` VARCHAR(500) DEFAULT '' COMMENT '甘草订单备注(拦截原因、驳回原因等)' AFTER `gancao_supplier`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD INDEX `idx_gancao_order_state` (`gancao_order_state`);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 添加处方完整字段(用量、代煎、每天几次)
|
||||
-- 执行前请先备份数据库
|
||||
|
||||
-- 1. 添加用量相关字段(如果不存在)
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN IF NOT EXISTS `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN IF NOT EXISTS `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
|
||||
-- 2. 添加每天几次字段(如果不存在)
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `times_per_day` int(11) NOT NULL DEFAULT 2 COMMENT '每天服用次数' AFTER `usage_days`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction', 'times_per_day')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 添加处方用量相关字段
|
||||
-- 用于存储处方类型对应的用量、单位和代煎信息
|
||||
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 添加省市区字段到处方业务订单表
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `shipping_province` varchar(50) NOT NULL DEFAULT '' COMMENT '收货省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `shipping_city` varchar(50) NOT NULL DEFAULT '' COMMENT '收货城市' AFTER `shipping_province`,
|
||||
ADD COLUMN `shipping_district` varchar(50) NOT NULL DEFAULT '' COMMENT '收货区县' AFTER `shipping_city`;
|
||||
@@ -82,15 +82,3 @@ export function completeAppointment(params: any) {
|
||||
export function getDoctorStatistics(params: any) {
|
||||
return request.get({ url: '/doctor.statistics/lists', params })
|
||||
}
|
||||
|
||||
// ========== 部门统计 ==========
|
||||
|
||||
// 获取部门列表
|
||||
export function getDeptList() {
|
||||
return request.get({ url: '/dept.dept/all' })
|
||||
}
|
||||
|
||||
// 获取部门统计
|
||||
export function getDeptStatistics(params: any) {
|
||||
return request.get({ url: '/doctor.statistics/deptLists', params })
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取企业微信客户列表
|
||||
export function qywxCustomerLists(params: any) {
|
||||
return request.get({ url: '/qywx.customer/lists', params })
|
||||
}
|
||||
|
||||
// 同步企业微信客户
|
||||
export function qywxCustomerSync() {
|
||||
return request.post({ url: '/qywx.customer/sync' })
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
export function qywxCustomerStats() {
|
||||
return request.get({ url: '/qywx.customer/stats' })
|
||||
}
|
||||
|
||||
// 获取同步设置
|
||||
export function qywxSyncSettingsGet() {
|
||||
return request.get({ url: '/qywx.customer/getSyncSettings' })
|
||||
}
|
||||
|
||||
// 保存同步设置
|
||||
export function qywxSyncSettingsSave(params: any) {
|
||||
return request.post({ url: '/qywx.customer/saveSyncSettings', params })
|
||||
}
|
||||
+2
-123
@@ -91,70 +91,6 @@ export function getRecordsByPatient(params: any) {
|
||||
return request.get({ url: '/tcm.bloodRecord/getRecordsByPatient', params })
|
||||
}
|
||||
|
||||
// 获取血糖趋势图数据
|
||||
export function getBloodSugarTrend(params: any) {
|
||||
return request.get({ url: '/tcm.bloodRecord/getBloodSugarTrend', params })
|
||||
}
|
||||
|
||||
// ========== 饮食记录 ==========
|
||||
|
||||
// 添加饮食记录
|
||||
export function dietRecordAdd(params: any) {
|
||||
return request.post({ url: '/tcm.dietRecord/add', params })
|
||||
}
|
||||
|
||||
// 编辑饮食记录
|
||||
export function dietRecordEdit(params: any) {
|
||||
return request.post({ url: '/tcm.dietRecord/edit', params })
|
||||
}
|
||||
|
||||
// 删除饮食记录
|
||||
export function dietRecordDelete(params: any) {
|
||||
return request.post({ url: '/tcm.dietRecord/delete', params })
|
||||
}
|
||||
|
||||
// 饮食记录详情
|
||||
export function dietRecordDetail(params: any) {
|
||||
return request.get({ url: '/tcm.dietRecord/detail', params })
|
||||
}
|
||||
|
||||
// 获取患者的饮食记录列表
|
||||
export function getDietRecordsByPatient(params: any) {
|
||||
return request.get({ url: '/tcm.dietRecord/getRecordsByPatient', params })
|
||||
}
|
||||
|
||||
// ========== 运动记录 ==========
|
||||
|
||||
// 添加运动记录
|
||||
export function exerciseRecordAdd(params: any) {
|
||||
return request.post({ url: '/tcm.exerciseRecord/add', params })
|
||||
}
|
||||
|
||||
// 编辑运动记录
|
||||
export function exerciseRecordEdit(params: any) {
|
||||
return request.post({ url: '/tcm.exerciseRecord/edit', params })
|
||||
}
|
||||
|
||||
// 删除运动记录
|
||||
export function exerciseRecordDelete(params: any) {
|
||||
return request.post({ url: '/tcm.exerciseRecord/delete', params })
|
||||
}
|
||||
|
||||
// 运动记录详情
|
||||
export function exerciseRecordDetail(params: any) {
|
||||
return request.get({ url: '/tcm.exerciseRecord/detail', params })
|
||||
}
|
||||
|
||||
// 获取患者的运动记录列表
|
||||
export function getExerciseRecordsByPatient(params: any) {
|
||||
return request.get({ url: '/tcm.exerciseRecord/getRecordsByPatient', params })
|
||||
}
|
||||
|
||||
// 获取运动趋势图数据
|
||||
export function getExerciseTrend(params: any) {
|
||||
return request.get({ url: '/tcm.exerciseRecord/getExerciseTrend', params })
|
||||
}
|
||||
|
||||
// ========== 音视频通话 ==========
|
||||
|
||||
// 获取通话签名(忽略取消令牌,避免 open + 会话切换 watch 重复请求互斥取消)
|
||||
@@ -221,7 +157,7 @@ export function generateOrderQrcode(params: any) {
|
||||
// ========== IM / 企业微信聊天记录 ==========
|
||||
|
||||
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_*) */
|
||||
export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) {
|
||||
export function getImChatMessages(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
||||
}
|
||||
|
||||
@@ -307,7 +243,7 @@ export function prescriptionOrderLists(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
|
||||
}
|
||||
|
||||
/** 诊单下可关联的支付单(待支付/已支付的 zyt_order,已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */
|
||||
/** 诊单下可关联的已支付 zyt_order(已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */
|
||||
export function prescriptionOrderPaidPayOrders(params: {
|
||||
diagnosis_id: number
|
||||
prescription_order_id?: number
|
||||
@@ -348,63 +284,6 @@ export function prescriptionOrderAuditPayment(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/auditPayment', params })
|
||||
}
|
||||
|
||||
/** 撤回处方审核 */
|
||||
export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||
}
|
||||
|
||||
/** 撤回支付单审核 */
|
||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||
}
|
||||
|
||||
/** 确认发货:将 fulfillment_status 从 2(履约中)推进到 5(已发货) */
|
||||
export function prescriptionOrderShip(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/ship', params })
|
||||
}
|
||||
|
||||
/** 为「已发货」订单新增支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderAddPayOrder(params: {
|
||||
id: number
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
|
||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderLinkPayOrder(params: {
|
||||
id: number
|
||||
pay_order_id: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 将「已发货」且支付审核通过的订单标记为「已完成」 */
|
||||
export function prescriptionOrderComplete(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL) */
|
||||
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 订单操作日志 */
|
||||
export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 工作台相关API
|
||||
*/
|
||||
export const workbenchApi = {
|
||||
/**
|
||||
* 获取工作台数据
|
||||
*/
|
||||
getWorkbenchData: () => {
|
||||
return request.get({
|
||||
url: '/workbench/index'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
class="chat-window-header"
|
||||
@mousedown="onHeaderMouseDown"
|
||||
>
|
||||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
||||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 聊天</span>
|
||||
<div class="chat-header-actions" @mousedown.stop>
|
||||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
@@ -74,8 +74,7 @@
|
||||
</div>
|
||||
<!-- 挂载 TUICallKit 组件(可拖动,仅在通话时显示) -->
|
||||
<div
|
||||
v-if="isCallReady"
|
||||
v-show="showCallKitWindow"
|
||||
v-if="isCallReady && showCallKitWindow"
|
||||
ref="callKitWrapperRef"
|
||||
class="chat-dialog-call-kit-wrapper"
|
||||
:style="callKitStyle"
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<div class="info-section">
|
||||
<el-row :gutter="16">
|
||||
|
||||
<el-col :span="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="处方类型" prop="prescription_type">
|
||||
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
|
||||
<el-option label="浓缩水丸" value="浓缩水丸" />
|
||||
@@ -135,92 +135,11 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 用量字段 -->
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||
<el-select
|
||||
v-if="formData.prescription_type === '浓缩水丸'"
|
||||
v-model="formData.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option :label="1 + 'g'" :value="1" />
|
||||
<el-option :label="2 + 'g'" :value="2" />
|
||||
<el-option :label="3 + 'g'" :value="3" />
|
||||
<el-option :label="4 + 'g'" :value="4" />
|
||||
<el-option :label="5 + 'g'" :value="5" />
|
||||
<el-option :label="6 + 'g'" :value="6" />
|
||||
<el-option :label="7 + 'g'" :value="7" />
|
||||
<el-option :label="8 + 'g'" :value="8" />
|
||||
<el-option :label="9 + 'g'" :value="9" />
|
||||
<el-option :label="10 + 'g'" :value="10" />
|
||||
</el-select>
|
||||
<!-- 饮片:50-250ml 下拉选择,步进50 -->
|
||||
<el-select
|
||||
v-else-if="formData.prescription_type === '饮片'"
|
||||
v-model="formData.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="120ml" :value="120" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="180ml" :value="180" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
<!-- 其他类型:自由输入 -->
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="!w-full"
|
||||
/>
|
||||
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片每贴出包数 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="每贴出包数" prop="bags_per_dose">
|
||||
<el-select v-model="formData.bags_per_dose" placeholder="请选择" class="!w-full">
|
||||
<el-option label="1包" :value="1" />
|
||||
<el-option label="2包" :value="2" />
|
||||
<el-option label="3包" :value="3" />
|
||||
<el-option label="4包" :value="4" />
|
||||
<el-option label="5包" :value="5" />
|
||||
<el-option label="6包" :value="6" />
|
||||
<el-option label="7包" :value="7" />
|
||||
<el-option label="8包" :value="8" />
|
||||
<el-option label="9包" :value="9" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="是否代煎" prop="need_decoction">
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数" prop="usage_days">
|
||||
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次" prop="times_per_day">
|
||||
<el-input-number v-model="formData.times_per_day" :min="1" :max="6" class="!w-full" placeholder="每天服用次数" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- <el-col :span="8">
|
||||
<el-form-item label="剂数" prop="dose_count">
|
||||
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-full" />
|
||||
@@ -400,7 +319,7 @@
|
||||
<div class="prescription-footer">
|
||||
<div class="footer-left">
|
||||
<div class="footer-dosage">
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天, {{ savedPrescription.prescription_type }}
|
||||
{{ savedPrescription.usage_instruction }}
|
||||
|
||||
</div>
|
||||
@@ -451,7 +370,7 @@
|
||||
<div class="cr-item"><span class="cr-label">姓名</span>{{ savedPrescription.case_record.patient_name || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">身份证号</span>{{ savedPrescription.case_record.id_card || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">手机号</span>{{ savedPrescription.case_record.phone || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">性别</span>{{ savedPrescription.gender_desc }}</div>
|
||||
<div class="cr-item"><span class="cr-label">性别</span>{{ savedPrescription.case_record.gender === 1 ? '男' : '女' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">年龄</span>{{ savedPrescription.case_record.age ?? '—' }}岁</div>
|
||||
<div class="cr-item"><span class="cr-label">婚姻状态</span>{{ ['未婚','已婚','离异'][savedPrescription.case_record.marital_status] ?? '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">身高</span>{{ savedPrescription.case_record.height ?? '—' }}cm</div>
|
||||
@@ -658,10 +577,6 @@ interface PrescriptionForm {
|
||||
appointment_id: number
|
||||
prescription_name: string
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined // 用量数值
|
||||
dosage_unit: string // 用量单位 (g 或 ml)
|
||||
need_decoction: boolean // 是否代煎(仅饮片)
|
||||
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
|
||||
patient_name: string
|
||||
gender: number
|
||||
gender_desc: string
|
||||
@@ -679,7 +594,6 @@ interface PrescriptionForm {
|
||||
dose_count: number
|
||||
dose_unit: string
|
||||
usage_days: number
|
||||
times_per_day: number
|
||||
usage_instruction: string
|
||||
usage_time: string
|
||||
usage_way: string
|
||||
@@ -721,9 +635,6 @@ const showLibraryDialog = ref(false)
|
||||
const libraryLoading = ref(false)
|
||||
const libraryList = ref<any[]>([])
|
||||
const librarySearchName = ref('')
|
||||
|
||||
// 防止重复打开
|
||||
const isOpening = ref(false)
|
||||
const libraryPage = ref(1)
|
||||
const libraryPageSize = ref(15)
|
||||
const libraryTotal = ref(0)
|
||||
@@ -739,10 +650,6 @@ const formData = reactive<PrescriptionForm>({
|
||||
appointment_id: 0,
|
||||
prescription_name: '',
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
patient_name: '',
|
||||
gender: 0,
|
||||
gender_desc: '女',
|
||||
@@ -760,7 +667,6 @@ const formData = reactive<PrescriptionForm>({
|
||||
dose_count: 1,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
times_per_day: 2,
|
||||
usage_instruction: '水煎服,一日二次',
|
||||
usage_time: '饭前',
|
||||
usage_way: '温水送服',
|
||||
@@ -879,103 +785,78 @@ const buildClinicalDiagnosis = (diagnosis: any) => {
|
||||
}
|
||||
|
||||
const open = async (data: any, options?: { templateId?: number; stationName?: string }) => {
|
||||
// 防止重复打开
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
if (options?.stationName) templateConfig.stationName = options.stationName
|
||||
|
||||
const diagnosis = data.diagnosis || data
|
||||
const diagnosisId = data.diagnosis_id ?? diagnosis?.id ?? data.id
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('缺少诊单信息')
|
||||
return
|
||||
}
|
||||
|
||||
isOpening.value = true
|
||||
|
||||
try {
|
||||
if (options?.stationName) templateConfig.stationName = options.stationName
|
||||
|
||||
const diagnosis = data.diagnosis || data
|
||||
const diagnosisId = data.diagnosis_id ?? diagnosis?.id ?? data.id
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('缺少诊单信息')
|
||||
return
|
||||
}
|
||||
await loadDictOptions()
|
||||
|
||||
await loadDictOptions()
|
||||
|
||||
const appointmentId = Number(data.id ?? data.appointment_id ?? 0)
|
||||
if (appointmentId) {
|
||||
try {
|
||||
|
||||
const existing = await prescriptionGetByAppointment({ appointment_id: appointmentId })
|
||||
console.log('-----------',existing)
|
||||
if (existing?.id) {
|
||||
const detail = await prescriptionDetail({ id: existing.id })
|
||||
savedPrescription.value = detail
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 获取详细诊单作为病历快照(每次开方保存一份)
|
||||
let caseRecord = data.case_record && Object.keys(data.case_record).length > 0 ? data.case_record : null
|
||||
if (!caseRecord && diagnosisId) {
|
||||
try {
|
||||
const res = await tcmDiagnosisDetail({ id: Number(diagnosisId) })
|
||||
caseRecord = res && typeof res === 'object' ? res : null
|
||||
} catch (e) {
|
||||
console.warn('获取诊单详情失败:', e)
|
||||
const appointmentId = Number(data.id ?? data.appointment_id ?? 0)
|
||||
if (appointmentId) {
|
||||
try {
|
||||
const existing = await prescriptionGetByAppointment({ appointment_id: appointmentId })
|
||||
if (existing?.id) {
|
||||
const detail = await prescriptionDetail({ id: existing.id })
|
||||
savedPrescription.value = detail
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
formData.diagnosis_id = Number(diagnosisId)
|
||||
formData.appointment_id = appointmentId
|
||||
formData.prescription_name = ''
|
||||
formData.prescription_type = '浓缩水丸'
|
||||
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
||||
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
||||
formData.gender_desc = formData.gender === 1 ? '男' : '女'
|
||||
formData.age = data.patient_age ?? diagnosis.age ?? 0
|
||||
formData.phone = data.patient_phone || diagnosis.phone || ''
|
||||
formData.visit_no = data.id ? `1K${String(data.id).padStart(8, '0')}` : ''
|
||||
formData.prescription_date = new Date().toISOString().slice(0, 10)
|
||||
formData.pulse = diagnosis.pulse || ''
|
||||
formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || ''
|
||||
formData.tongue_image = ''
|
||||
formData.pulse_condition = ''
|
||||
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
|
||||
formData.case_record = caseRecord
|
||||
formData.herbs = []
|
||||
formData.dose_count = 1
|
||||
formData.dose_unit = '剂'
|
||||
formData.usage_days = 7
|
||||
formData.usage_instruction = '水煎服,一日二次'
|
||||
formData.usage_time = '饭前'
|
||||
formData.usage_way = '温水送服'
|
||||
formData.dietary_taboo = []
|
||||
formData.usage_notes = ''
|
||||
formData.amount = 0
|
||||
formData.doctor_name = userStore.userInfo?.name || ''
|
||||
formData.doctor_signature = ''
|
||||
formData.is_shared = 0
|
||||
|
||||
savedPrescription.value = null
|
||||
visible.value = true
|
||||
setTimeout(() => initSignatureCanvas(), 100)
|
||||
} finally {
|
||||
// 延迟重置,避免快速连续点击
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 获取详细诊单作为病历快照(每次开方保存一份)
|
||||
let caseRecord = data.case_record && Object.keys(data.case_record).length > 0 ? data.case_record : null
|
||||
if (!caseRecord && diagnosisId) {
|
||||
try {
|
||||
const res = await tcmDiagnosisDetail({ id: Number(diagnosisId) })
|
||||
caseRecord = res && typeof res === 'object' ? res : null
|
||||
} catch (e) {
|
||||
console.warn('获取诊单详情失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
formData.diagnosis_id = Number(diagnosisId)
|
||||
formData.appointment_id = appointmentId
|
||||
formData.prescription_name = ''
|
||||
formData.prescription_type = '浓缩水丸'
|
||||
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
||||
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
||||
formData.gender_desc = formData.gender === 1 ? '男' : '女'
|
||||
formData.age = data.patient_age ?? diagnosis.age ?? 0
|
||||
formData.phone = data.patient_phone || diagnosis.phone || ''
|
||||
formData.visit_no = data.id ? `1K${String(data.id).padStart(8, '0')}` : ''
|
||||
formData.prescription_date = new Date().toISOString().slice(0, 10)
|
||||
formData.pulse = diagnosis.pulse || ''
|
||||
formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || ''
|
||||
formData.tongue_image = ''
|
||||
formData.pulse_condition = ''
|
||||
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
|
||||
formData.case_record = caseRecord
|
||||
formData.herbs = []
|
||||
formData.dose_count = 1
|
||||
formData.dose_unit = '剂'
|
||||
formData.usage_days = 7
|
||||
formData.usage_instruction = '水煎服,一日二次'
|
||||
formData.usage_time = '饭前'
|
||||
formData.usage_way = '温水送服'
|
||||
formData.dietary_taboo = []
|
||||
formData.usage_notes = ''
|
||||
formData.amount = 0
|
||||
formData.doctor_name = userStore.userInfo?.name || ''
|
||||
formData.doctor_signature = ''
|
||||
formData.is_shared = 0
|
||||
|
||||
savedPrescription.value = null
|
||||
visible.value = true
|
||||
setTimeout(() => initSignatureCanvas(), 100)
|
||||
}
|
||||
|
||||
const openById = async (prescriptionId: number) => {
|
||||
// 防止重复打开
|
||||
if (isOpening.value) {
|
||||
console.warn('处方正在打开中,请勿重复点击')
|
||||
return
|
||||
}
|
||||
|
||||
isOpening.value = true
|
||||
|
||||
try {
|
||||
const res = await prescriptionDetail({ id: prescriptionId })
|
||||
savedPrescription.value = res
|
||||
@@ -985,11 +866,6 @@ const openById = async (prescriptionId: number) => {
|
||||
visible.value = true
|
||||
} catch (e) {
|
||||
feedback.msgError('加载处方失败')
|
||||
} finally {
|
||||
// 延迟重置,避免快速连续点击
|
||||
setTimeout(() => {
|
||||
isOpening.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,26 +1021,6 @@ watch(showLibraryDialog, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听处方类型变化,自动调整用量单位和默认值
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
formData.bags_per_dose = 1
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
formData.bags_per_dose = 1
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
formData.bags_per_dose = 1
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.clinical_diagnosis?.trim()) {
|
||||
feedback.msgWarning('请输入临床诊断')
|
||||
@@ -1212,12 +1068,7 @@ const handleSave = async () => {
|
||||
herbs: formData.herbs,
|
||||
dose_count: formData.dose_count,
|
||||
dose_unit: formData.dose_unit,
|
||||
dosage_amount: formData.dosage_amount,
|
||||
dosage_unit: formData.dosage_unit,
|
||||
need_decoction: formData.need_decoction,
|
||||
bags_per_dose: formData.bags_per_dose,
|
||||
usage_days: formData.usage_days,
|
||||
times_per_day: formData.times_per_day,
|
||||
usage_instruction: formData.usage_instruction,
|
||||
usage_time: formData.usage_time,
|
||||
usage_way: formData.usage_way,
|
||||
|
||||
@@ -16,49 +16,25 @@ export function formatBizTime(t: unknown): string | undefined {
|
||||
}
|
||||
|
||||
export function parseRtcInner(inner: Record<string, unknown>): FriendlyParse {
|
||||
const callType = inner.call_type ?? inner.callType ?? 2
|
||||
const callType = inner.call_type
|
||||
const callTypeLabel =
|
||||
callType === 2 ? '视频' : callType === 1 ? '语音通话' : '通话'
|
||||
|
||||
let cmd = String(inner.cmd || inner.action || '').toLowerCase()
|
||||
if (!cmd) {
|
||||
const at = Number(inner.actionType ?? inner.action_type ?? inner.call_action)
|
||||
if (at === 1) cmd = 'invite'
|
||||
else if (at === 2) cmd = 'cancel'
|
||||
else if (at === 3) cmd = 'accept'
|
||||
else if (at === 4) cmd = 'reject'
|
||||
else if (at === 5) cmd = 'timeout'
|
||||
else if (at === 6) cmd = 'hangup'
|
||||
else if (at === 7) cmd = 'linebusy'
|
||||
}
|
||||
|
||||
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
|
||||
const cmd = String(inner.cmd || '')
|
||||
const cmdMap: Record<string, string> = {
|
||||
hangup: '已结束',
|
||||
invite: '发起通话',
|
||||
linebusy: '对方忙线',
|
||||
cancel: '已取消呼叫',
|
||||
reject: '已拒绝接听',
|
||||
cancel: '已取消',
|
||||
reject: '已拒绝',
|
||||
accept: '已接听',
|
||||
timeout: '无人接听'
|
||||
}
|
||||
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
|
||||
|
||||
// 如果有通话时长,追加显示
|
||||
const duration = Number(inner.duration ?? inner.call_duration)
|
||||
const durationText = (duration > 0 && (action === '已结束' || action === '已挂断')) ? ` (${Math.floor(duration / 60)}分${duration % 60}秒)` : ''
|
||||
|
||||
const main = action ? `${callTypeLabel} · ${action}${durationText}` : callTypeLabel
|
||||
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
|
||||
const parts: string[] = []
|
||||
//if (inner.inviter) parts.push(`发起方: ${inner.inviter}`)
|
||||
if (inner.invitee) parts.push(`对方: ${inner.invitee}`)
|
||||
if (inner.groupID) parts.push(`群组: ${inner.groupID}`)
|
||||
|
||||
// 强制输出兜底调试信息(仅未识别到 action 时触发)
|
||||
if (!action) {
|
||||
const dbg = JSON.stringify(inner).replace(/"/g, '')
|
||||
parts.push(`(原始参数: ${dbg})`)
|
||||
}
|
||||
|
||||
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
|
||||
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
|
||||
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
|
||||
return {
|
||||
main,
|
||||
sub: parts.length ? parts.join(',') : undefined,
|
||||
@@ -107,18 +83,19 @@ export function parseImBusinessPayload(raw: string): FriendlyParse | null {
|
||||
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 1 || o.businessID === '1' || o.businessID === 'av_call') {
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
let inner: unknown = o.data
|
||||
if (typeof inner === 'string') {
|
||||
try {
|
||||
inner = JSON.parse(inner)
|
||||
} catch {
|
||||
// fallback
|
||||
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
|
||||
}
|
||||
}
|
||||
// 深远兼容:合并外层字段和 data 层字段,避免各端版本取参有遗漏
|
||||
const merged = { ...o, ...(typeof inner === 'object' && inner ? inner : {}) }
|
||||
return parseRtcInner(merged as Record<string, unknown>)
|
||||
if (inner && typeof inner === 'object') {
|
||||
return parseRtcInner(inner as Record<string, unknown>)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || o.cmd) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,356 +0,0 @@
|
||||
<template>
|
||||
<div class="dept-statistics-page">
|
||||
<!-- 筛选区域卡片 -->
|
||||
<el-card class="mb-4" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">筛选条件</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||
<el-form-item label="部门">
|
||||
<el-select
|
||||
v-model="queryParams.dept_id"
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dept in deptList"
|
||||
:key="dept.id"
|
||||
:label="dept.name"
|
||||
:value="dept.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getStatistics" :loading="loading">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计数据卡片 -->
|
||||
<el-card v-loading="loading" class="mb-4" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">部门统计数据</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="statisticsList.length > 0">
|
||||
<el-table
|
||||
:data="statisticsList"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: '600' }"
|
||||
>
|
||||
<el-table-column prop="dept_name" label="部门名称" width="120" />
|
||||
|
||||
<!-- 状态明细 -->
|
||||
<el-table-column label="状态明细" min-width="700">
|
||||
<template #default="{ row }">
|
||||
<div class="status-overview">
|
||||
<div class="status-tags">
|
||||
<el-tag type="info" size="small" class="compact-tag">
|
||||
已挂号: {{ row.registered_count }}
|
||||
</el-tag>
|
||||
<el-tag type="success" size="small" class="compact-tag">
|
||||
已完成: {{ row.completed_count }}
|
||||
</el-tag>
|
||||
<el-tag type="danger" size="small" class="compact-tag">
|
||||
已取消: {{ row.canceled_count }}
|
||||
</el-tag>
|
||||
<el-tag type="warning" size="small" class="compact-tag">
|
||||
已过号: {{ row.expired_count }}
|
||||
</el-tag>
|
||||
<el-tag type="primary" size="small" class="compact-tag">
|
||||
总数量: {{ row.total_count }}
|
||||
</el-tag>
|
||||
<el-tag :type="getCompletionRateType(row.completion_rate)" size="small" class="compact-tag">
|
||||
完成率: {{ row.completion_rate }}%
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="time_range" label="统计时间" min-width="200" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else class="empty-data">
|
||||
<el-empty description="暂无统计数据" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getDeptStatistics, getDeptList } from '@/api/doctor'
|
||||
|
||||
const loading = ref(false)
|
||||
const deptList = ref<any[]>([])
|
||||
const statisticsList = ref<any[]>([])
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
const queryParams = reactive({
|
||||
dept_id: '',
|
||||
time_type: 'today',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const getDeptLists = async () => {
|
||||
try {
|
||||
const res = await getDeptList()
|
||||
// 将树形结构转换为扁平化列表
|
||||
const flattenDepts = (depts) => {
|
||||
let result = []
|
||||
depts.forEach(dept => {
|
||||
result.push({ id: dept.id, name: dept.name })
|
||||
if (dept.children && dept.children.length > 0) {
|
||||
result = result.concat(flattenDepts(dept.children))
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
deptList.value = flattenDepts(res) || []
|
||||
} catch (error) {
|
||||
console.error('获取部门列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
time_type: queryParams.time_type
|
||||
}
|
||||
|
||||
if (queryParams.dept_id) {
|
||||
params.dept_id = queryParams.dept_id
|
||||
}
|
||||
|
||||
if (queryParams.time_type === 'custom' && dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
|
||||
const res = await getDeptStatistics(params)
|
||||
statisticsList.value = res.lists || []
|
||||
} catch (error: any) {
|
||||
console.error('获取统计数据错误:', error)
|
||||
ElMessage.error(error.msg || '获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.dept_id = ''
|
||||
queryParams.time_type = 'today'
|
||||
dateRange.value = []
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
// 获取完成率标签类型
|
||||
const getCompletionRateType = (rate: number) => {
|
||||
if (rate >= 80) {
|
||||
return 'success'
|
||||
} else if (rate >= 60) {
|
||||
return 'primary'
|
||||
} else {
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDeptLists()
|
||||
getStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-statistics-page {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
padding: 10px 0;
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status-overview {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.status-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-tag {
|
||||
font-size: 12px !important;
|
||||
padding: 4px 12px !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.compact-tag:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.empty-data {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-table:hover) {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background-color: #f5f7fa !important;
|
||||
font-weight: 600 !important;
|
||||
color: #606266 !important;
|
||||
border-bottom: 1px solid #ebeef5 !important;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover) {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__cell) {
|
||||
text-align: left !important;
|
||||
vertical-align: top !important;
|
||||
padding: 12px 15px !important;
|
||||
}
|
||||
|
||||
/* 卡片样式优化 */
|
||||
:deep(.el-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1) !important;
|
||||
margin-bottom: 16px !important;
|
||||
transition: box-shadow 0.3s ease;
|
||||
border: none !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-card:hover) {
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background-color: #ffffff !important;
|
||||
border-bottom: 1px solid #ebeef5 !important;
|
||||
padding: 15px 20px !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px !important;
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
:deep(.el-button) {
|
||||
border-radius: 4px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-button:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 选择器样式优化 */
|
||||
:deep(.el-select) {
|
||||
border-radius: 4px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-select:hover) {
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 标签样式优化 */
|
||||
:deep(.el-tag) {
|
||||
border-radius: 4px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-tag:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -25,9 +25,33 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button-group>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="isPrevDayDisabled"
|
||||
@click="handlePrevDay"
|
||||
>
|
||||
前一天
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleToday"
|
||||
>
|
||||
今天
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="isNextDayDisabled"
|
||||
@click="handleNextDay"
|
||||
>
|
||||
后一天
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
@@ -157,10 +181,13 @@ const loading = ref(false)
|
||||
const doctorList = ref<any[]>([])
|
||||
const statisticsList = ref<any[]>([])
|
||||
const dateRange = ref<string[]>([])
|
||||
const currentDate = ref<string>(new Date().toISOString().split('T')[0])
|
||||
const isPrevDayDisabled = ref<boolean>(false)
|
||||
const isNextDayDisabled = ref<boolean>(false)
|
||||
|
||||
const queryParams = reactive({
|
||||
doctor_id: '',
|
||||
time_type: 'today',
|
||||
time_type: 'week',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
@@ -178,16 +205,20 @@ const getStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
time_type: queryParams.time_type
|
||||
time_type: 'custom' // 始终使用custom类型,只传时间区间
|
||||
}
|
||||
|
||||
if (queryParams.doctor_id) {
|
||||
params.doctor_id = queryParams.doctor_id
|
||||
}
|
||||
|
||||
if (queryParams.time_type === 'custom' && dateRange.value && dateRange.value.length === 2) {
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
} else {
|
||||
// 否则使用currentDate作为查询参数
|
||||
params.start_date = currentDate.value
|
||||
params.end_date = currentDate.value
|
||||
}
|
||||
|
||||
const res = await getDoctorStatistics(params)
|
||||
@@ -215,12 +246,67 @@ const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
}
|
||||
|
||||
// 根据时间类型更新currentDate
|
||||
switch (queryParams.time_type) {
|
||||
case 'week':
|
||||
currentDate.value = new Date().toISOString().split('T')[0]
|
||||
break
|
||||
case 'month':
|
||||
currentDate.value = new Date().toISOString().split('T')[0]
|
||||
break
|
||||
case 'custom':
|
||||
// 自定义类型不更新currentDate
|
||||
break
|
||||
}
|
||||
|
||||
updateDateButtons()
|
||||
}
|
||||
|
||||
// 更新日期按钮状态
|
||||
const updateDateButtons = () => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const thirtyDaysAgo = new Date()
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0]
|
||||
|
||||
// 后一天超过今天则禁用
|
||||
isNextDayDisabled.value = currentDate.value >= today
|
||||
// 前一天超过30天则禁用
|
||||
isPrevDayDisabled.value = currentDate.value <= thirtyDaysAgoStr
|
||||
}
|
||||
|
||||
// 处理前一天
|
||||
const handlePrevDay = () => {
|
||||
const date = new Date(currentDate.value)
|
||||
date.setDate(date.getDate() - 1)
|
||||
currentDate.value = date.toISOString().split('T')[0]
|
||||
updateDateButtons()
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
// 处理后一天
|
||||
const handleNextDay = () => {
|
||||
const date = new Date(currentDate.value)
|
||||
date.setDate(date.getDate() + 1)
|
||||
currentDate.value = date.toISOString().split('T')[0]
|
||||
updateDateButtons()
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
// 处理今天
|
||||
const handleToday = () => {
|
||||
currentDate.value = new Date().toISOString().split('T')[0]
|
||||
updateDateButtons()
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.doctor_id = ''
|
||||
queryParams.time_type = 'today'
|
||||
dateRange.value = []
|
||||
currentDate.value = new Date().toISOString().split('T')[0]
|
||||
updateDateButtons()
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
@@ -260,6 +346,7 @@ const getStatusType = (status: string | number) => {
|
||||
|
||||
onMounted(() => {
|
||||
getDoctorList()
|
||||
updateDateButtons()
|
||||
getStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
<template>
|
||||
<div class="qywx-customer-page p-4">
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-semibold">企业微信客户管理</span>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" :loading="syncing" @click="syncCustomers">
|
||||
<template #icon><Refresh /></template>
|
||||
立即同步
|
||||
</el-button>
|
||||
<el-button @click="showSyncSettings = true">
|
||||
<template #icon><Setting /></template>
|
||||
同步设置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
title="功能说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="text-sm">
|
||||
<p>自动从企业微信同步外部联系人(客户)信息,包括客户基本信息、跟进人等。</p>
|
||||
<p class="mt-1">同步后的数据可用于患者信息匹配和自动关联。</p>
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 mb-4">
|
||||
<el-card shadow="hover">
|
||||
<div class="text-gray-500 text-sm mb-1">总客户数</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ stats.total }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover">
|
||||
<div class="text-gray-500 text-sm mb-1">今日新增</div>
|
||||
<div class="text-2xl font-bold text-success">{{ stats.today }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover">
|
||||
<div class="text-gray-500 text-sm mb-1">最后同步</div>
|
||||
<div class="text-sm font-medium">{{ stats.lastSync || '未同步' }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover">
|
||||
<div class="text-gray-500 text-sm mb-1">同步状态</div>
|
||||
<el-tag :type="stats.syncStatus === 'success' ? 'success' : 'info'" size="small">
|
||||
{{ stats.syncStatusText }}
|
||||
</el-tag>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-form :model="queryParams" inline class="mb-4">
|
||||
<el-form-item label="客户名称">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="搜索客户名称"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
placeholder="跟进人姓名"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="pager.loading" :data="pager.lists" stripe>
|
||||
<el-table-column label="客户信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="40" :src="row.avatar" />
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.external_userid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.type === 1 ? '微信' : '企微' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="60">
|
||||
<template #default="{ row }">
|
||||
{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '未知' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="跟进人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.follow_users && row.follow_users.length">
|
||||
<el-tag
|
||||
v-for="(user, idx) in row.follow_users"
|
||||
:key="idx"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ user.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.update_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(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="showSyncSettings"
|
||||
title="同步设置"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="syncSettings" label-width="120px">
|
||||
<el-form-item label="自动同步">
|
||||
<el-switch v-model="syncSettings.auto_sync" />
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
开启后将定时自动同步企业微信客户数据
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="同步间隔">
|
||||
<el-select v-model="syncSettings.interval" :disabled="!syncSettings.auto_sync">
|
||||
<el-option label="每小时" :value="3600" />
|
||||
<el-option label="每2小时" :value="7200" />
|
||||
<el-option label="每4小时" :value="14400" />
|
||||
<el-option label="每6小时" :value="21600" />
|
||||
<el-option label="每12小时" :value="43200" />
|
||||
<el-option label="每天" :value="86400" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showSyncSettings = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSyncSettings">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 客户详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="showDetail"
|
||||
title="客户详情"
|
||||
width="600px"
|
||||
>
|
||||
<el-descriptions v-if="currentCustomer" :column="2" border>
|
||||
<el-descriptions-item label="客户名称">{{ currentCustomer.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="External ID">{{ currentCustomer.external_userid }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ currentCustomer.gender === 1 ? '男' : currentCustomer.gender === 2 ? '女' : '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ currentCustomer.type === 1 ? '微信用户' : '企业微信用户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="企业名称" :span="2">
|
||||
{{ currentCustomer.corp_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="职位" :span="2">
|
||||
{{ currentCustomer.position || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加时间" :span="2">
|
||||
{{ formatTime(currentCustomer.create_time) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatTime(currentCustomer.update_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { Refresh, Setting } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxCustomerLists,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerStats,
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave
|
||||
} from '@/api/qywx'
|
||||
|
||||
const syncing = ref(false)
|
||||
const showSyncSettings = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const currentCustomer = ref<any>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
today: 0,
|
||||
lastSync: '',
|
||||
syncStatus: 'idle',
|
||||
syncStatusText: '未同步'
|
||||
})
|
||||
|
||||
const syncSettings = reactive({
|
||||
auto_sync: false,
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
follow_user: ''
|
||||
})
|
||||
|
||||
let syncTimer: number | null = null
|
||||
|
||||
async function fetchLists(params: Record<string, unknown>) {
|
||||
return qywxCustomerLists(params)
|
||||
}
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: fetchLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
resetParams()
|
||||
}
|
||||
|
||||
async function syncCustomers() {
|
||||
syncing.value = true
|
||||
try {
|
||||
await qywxCustomerSync()
|
||||
feedback.msgSuccess('同步成功')
|
||||
await loadStats()
|
||||
await getLists()
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '同步失败')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res: any = await qywxCustomerStats()
|
||||
Object.assign(stats, res.data || res)
|
||||
} catch (e) {
|
||||
console.error('加载统计失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncSettings() {
|
||||
try {
|
||||
const res: any = await qywxSyncSettingsGet()
|
||||
Object.assign(syncSettings, res.data || res)
|
||||
|
||||
// 如果开启了自动同步,启动定时器
|
||||
if (syncSettings.auto_sync) {
|
||||
startSyncTimer()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载同步设置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSyncSettings() {
|
||||
try {
|
||||
await qywxSyncSettingsSave(syncSettings)
|
||||
feedback.msgSuccess('保存成功')
|
||||
showSyncSettings.value = false
|
||||
|
||||
// 重新启动定时器
|
||||
stopSyncTimer()
|
||||
if (syncSettings.auto_sync) {
|
||||
startSyncTimer()
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function startSyncTimer() {
|
||||
if (syncTimer) return
|
||||
|
||||
syncTimer = window.setInterval(() => {
|
||||
syncCustomers()
|
||||
}, syncSettings.interval * 1000)
|
||||
}
|
||||
|
||||
function stopSyncTimer() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(row: any) {
|
||||
currentCustomer.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number | string) {
|
||||
if (!timestamp) return '—'
|
||||
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
loadStats()
|
||||
loadSyncSettings()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSyncTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.qywx-customer-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,6 @@
|
||||
<el-option label="已支付" :value="2" />
|
||||
<el-option label="已取消" :value="3" />
|
||||
<el-option label="已退款" :value="4" />
|
||||
<el-option label="待审核" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -809,8 +808,7 @@ const getStatusText = (status: number) => {
|
||||
1: '待支付',
|
||||
2: '已支付',
|
||||
3: '已取消',
|
||||
4: '已退款',
|
||||
5: '待审核'
|
||||
4: '已退款'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
@@ -821,8 +819,7 @@ const getStatusTag = (status: number): 'warning' | 'success' | 'info' | 'danger'
|
||||
1: 'warning',
|
||||
2: 'success',
|
||||
3: 'info',
|
||||
4: 'danger',
|
||||
5: 'warning'
|
||||
4: 'danger'
|
||||
}
|
||||
return tagMap[status] || 'info'
|
||||
}
|
||||
|
||||
@@ -407,8 +407,7 @@ const resetForm = () => {
|
||||
// 打开抽屉
|
||||
const open = (data: any) => {
|
||||
resetForm()
|
||||
|
||||
|
||||
|
||||
formData.appointment_id = data.id || 0
|
||||
formData.diagnosis_id = data.diagnosis_id || 0
|
||||
formData.patient_name = data.patient_name || ''
|
||||
|
||||
@@ -179,6 +179,15 @@
|
||||
<el-table-column label="操作" width="380" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
<!-- <el-button
|
||||
v-perms="['doctor.appointment/detail']"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handleDetail(row)"
|
||||
>
|
||||
详情
|
||||
</el-button> -->
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/edit']"
|
||||
type="primary"
|
||||
@@ -189,7 +198,6 @@
|
||||
编辑患者
|
||||
</el-button>
|
||||
|
||||
<template v-if="row.status !== 3">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/videoQr']"
|
||||
type="warning"
|
||||
@@ -219,17 +227,15 @@
|
||||
>
|
||||
完成
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/kaifang']"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handlePrescription(row)"
|
||||
>
|
||||
{{ prescriptionActionLabel(row) }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/kaifang']"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handlePrescription(row)"
|
||||
>
|
||||
{{ prescriptionActionLabel(row) }}
|
||||
</el-button>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleAction(cmd, row)" placement="bottom-end">
|
||||
<el-button type="info" link size="small">
|
||||
@@ -953,7 +959,6 @@ function prescriptionActionLabel(row: any) {
|
||||
|
||||
// 开处方 / 查看已通过处方(只读)
|
||||
const handlePrescription = (row: any) => {
|
||||
//console.log(row)
|
||||
if (!row.diagnosis_id && !row.patient_id) {
|
||||
feedback.msgWarning('该预约缺少诊单信息,请先编辑患者')
|
||||
return
|
||||
|
||||
@@ -2,53 +2,14 @@
|
||||
<div class="blood-record-list">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">添加记录</el-button>
|
||||
<el-select v-model="trendDays" @change="handleTrendDaysChange" class="ml-2" style="width: 150px">
|
||||
<el-option label="最近7天" :value="7" />
|
||||
<el-option label="最近30天" :value="30" />
|
||||
<el-option label="最近90天" :value="90" />
|
||||
<el-option label="最近一年" :value="365" />
|
||||
<el-option label="自定义" :value="0" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-if="trendDays === 0"
|
||||
v-model="customDateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="loadTrendData"
|
||||
class="ml-2"
|
||||
style="width: 300px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>血糖趋势图</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 400px"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-table :data="recordList" border style="width: 100%">
|
||||
|
||||
<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 label="空腹血糖(mmol/L)" width="120">
|
||||
<el-table-column prop="blood_sugar" label="血糖(mmol/L)" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatBloodGlucoseCell(row.fasting_blood_sugar) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="餐后2小时血糖(mmol/L)" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatBloodGlucoseCell(row.postprandial_blood_sugar) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="其他血糖(mmol/L)" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatBloodGlucoseCell(row.other_blood_sugar) }}
|
||||
{{ row.blood_sugar || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="血压(mmHg)" width="150">
|
||||
@@ -59,8 +20,6 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="western_medicine" label="西药" show-overflow-tooltip />
|
||||
<el-table-column prop="insulin" label="胰岛素" show-overflow-tooltip />
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
@@ -91,36 +50,14 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空腹血糖">
|
||||
<el-form-item label="血糖值">
|
||||
<el-input-number
|
||||
v-model="recordForm.fasting_blood_sugar"
|
||||
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="餐后2小时血糖">
|
||||
<el-input-number
|
||||
v-model="recordForm.postprandial_blood_sugar"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:min="0"
|
||||
:max="50"
|
||||
placeholder="请输入餐后2小时血糖"
|
||||
/>
|
||||
<span class="ml-2">mmol/L</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="其他血糖">
|
||||
<el-input
|
||||
v-model="recordForm.other_blood_sugar"
|
||||
placeholder="请输入其他血糖"
|
||||
type="number"
|
||||
maxlength="10"
|
||||
@input="handleOtherBloodSugarInput"
|
||||
style="width: 200px"
|
||||
placeholder="请输入血糖值"
|
||||
/>
|
||||
<span class="ml-2">mmol/L</span>
|
||||
</el-form-item>
|
||||
@@ -142,18 +79,6 @@
|
||||
/>
|
||||
<span class="ml-2">mmHg</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="西药">
|
||||
<el-input
|
||||
v-model="recordForm.western_medicine"
|
||||
placeholder="请输入西药"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="胰岛素">
|
||||
<el-input
|
||||
v-model="recordForm.insulin"
|
||||
placeholder="请输入胰岛素"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="recordForm.remark"
|
||||
@@ -172,10 +97,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { bloodRecordAdd, bloodRecordEdit, bloodRecordDelete, getRecordsByPatient, getBloodSugarTrend } from '@/api/tcm'
|
||||
import { bloodRecordAdd, bloodRecordEdit, bloodRecordDelete, getRecordsByPatient } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import * as echarts from 'echarts'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
diagnosisId: {
|
||||
@@ -192,18 +115,6 @@ const recordList = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
|
||||
const trendDays = ref(7)
|
||||
const customDateRange = ref<[Date, Date] | null>(null)
|
||||
const trendData = ref({
|
||||
dates: [],
|
||||
fasting: [],
|
||||
postprandial_2h: [],
|
||||
other: []
|
||||
})
|
||||
const chartRef = ref()
|
||||
let chartInstance: any = null
|
||||
let renderRetryCount = 0
|
||||
const MAX_RETRY_COUNT = 5
|
||||
|
||||
const recordForm = ref({
|
||||
id: '',
|
||||
@@ -211,152 +122,12 @@ const recordForm = ref({
|
||||
patient_id: props.patientId,
|
||||
record_date: '',
|
||||
record_time: '',
|
||||
fasting_blood_sugar: 0,
|
||||
postprandial_blood_sugar: 0,
|
||||
other_blood_sugar: 0,
|
||||
systolic_pressure: 0,
|
||||
diastolic_pressure: 0,
|
||||
western_medicine: '',
|
||||
insulin: '',
|
||||
blood_sugar: null,
|
||||
systolic_pressure: null,
|
||||
diastolic_pressure: null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
/** 列表展示:空值或 0 / 0.00(含字符串)统一显示为 - */
|
||||
function formatBloodGlucoseCell(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '-'
|
||||
const n = Number(String(v).trim())
|
||||
if (!Number.isFinite(n) || n === 0) return '-'
|
||||
return String(n.toFixed(2).replace(/\.?0+$/, ''))
|
||||
}
|
||||
|
||||
const loadTrendData = async () => {
|
||||
if (!props.diagnosisId && !props.patientId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
diagnosis_id: props.diagnosisId,
|
||||
patient_id: props.patientId
|
||||
}
|
||||
|
||||
if (trendDays.value === 0 && customDateRange.value) {
|
||||
params.start_date = Math.floor(customDateRange.value[0].getTime() / 1000)
|
||||
params.end_date = Math.floor(customDateRange.value[1].getTime() / 1000)
|
||||
} else {
|
||||
params.days = trendDays.value
|
||||
}
|
||||
|
||||
const data = await getBloodSugarTrend(params)
|
||||
trendData.value = data
|
||||
renderChart()
|
||||
} catch (error) {
|
||||
console.error('获取趋势图数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrendDaysChange = () => {
|
||||
if (trendDays.value !== 0) {
|
||||
customDateRange.value = null
|
||||
}
|
||||
loadTrendData()
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (!chartRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = chartRef.value
|
||||
|
||||
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
||||
if (renderRetryCount < MAX_RETRY_COUNT) {
|
||||
renderRetryCount++
|
||||
setTimeout(() => renderChart(), 100)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 重置重试计数器
|
||||
renderRetryCount = 0
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
|
||||
chartInstance = echarts.init(container)
|
||||
|
||||
// 准备数据,确保每个日期都有对应的值
|
||||
const prepareData = (dataArray: any[], dates: string[]) => {
|
||||
const dataMap = new Map()
|
||||
dataArray.forEach(item => {
|
||||
dataMap.set(item.date, item.value)
|
||||
})
|
||||
return dates.map(date => dataMap.get(date) || null)
|
||||
}
|
||||
|
||||
const fastingData = prepareData(trendData.value.fasting, trendData.value.dates)
|
||||
const postprandialData = prepareData(trendData.value.postprandial_2h, trendData.value.dates)
|
||||
const otherData = prepareData(trendData.value.other, trendData.value.dates)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['空腹血糖', '餐后2小时血糖', '其他血糖']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trendData.value.dates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '血糖值 (mmol/L)'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '空腹血糖',
|
||||
type: 'line',
|
||||
data: fastingData,
|
||||
smooth: true,
|
||||
connectNulls: true
|
||||
},
|
||||
{
|
||||
name: '餐后2小时血糖',
|
||||
type: 'line',
|
||||
data: postprandialData,
|
||||
smooth: true,
|
||||
connectNulls: true
|
||||
},
|
||||
{
|
||||
name: '其他血糖',
|
||||
type: 'line',
|
||||
data: otherData,
|
||||
smooth: true,
|
||||
connectNulls: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
chartInstance.resize()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取记录列表
|
||||
const getRecords = async () => {
|
||||
if (!props.diagnosisId && !props.patientId) {
|
||||
@@ -382,13 +153,9 @@ const handleAdd = () => {
|
||||
patient_id: props.patientId,
|
||||
record_date: '',
|
||||
record_time: '',
|
||||
fasting_blood_sugar: 0,
|
||||
postprandial_blood_sugar: 0,
|
||||
other_blood_sugar: 0,
|
||||
systolic_pressure: 0,
|
||||
diastolic_pressure: 0,
|
||||
western_medicine: '',
|
||||
insulin: '',
|
||||
blood_sugar: null,
|
||||
systolic_pressure: null,
|
||||
diastolic_pressure: null,
|
||||
remark: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
@@ -396,22 +163,10 @@ const handleAdd = () => {
|
||||
|
||||
// 编辑记录
|
||||
const handleEdit = (row: any) => {
|
||||
recordForm.value = {
|
||||
...row,
|
||||
fasting_blood_sugar: parseFloat(row.fasting_blood_sugar) || 0,
|
||||
postprandial_blood_sugar: parseFloat(row.postprandial_blood_sugar) || 0,
|
||||
other_blood_sugar: parseFloat(row.other_blood_sugar) || 0,
|
||||
systolic_pressure: parseInt(row.systolic_pressure) || 0,
|
||||
diastolic_pressure: parseInt(row.diastolic_pressure) || 0
|
||||
}
|
||||
recordForm.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 处理其他血糖输入,只允许数字
|
||||
const handleOtherBloodSugarInput = (value: string) => {
|
||||
recordForm.value.other_blood_sugar = value.replace(/[^\d.]/g, '')
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const handleDelete = async (row: any) => {
|
||||
await feedback.confirm('确定要删除这条记录吗?')
|
||||
@@ -442,7 +197,6 @@ const handleSubmit = async () => {
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getRecords()
|
||||
loadTrendData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
@@ -451,33 +205,12 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟加载数据,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
getRecords()
|
||||
loadTrendData()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
getRecords()
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
watch(() => [props.diagnosisId, props.patientId], () => {
|
||||
getRecords()
|
||||
loadTrendData()
|
||||
})
|
||||
|
||||
// 监听组件可见性变化(Tab切换时)
|
||||
watch(() => props.diagnosisId, (newVal) => {
|
||||
if (newVal) {
|
||||
// 当组件变为可见时,延迟重新渲染图表
|
||||
setTimeout(() => {
|
||||
renderChart()
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,12 +25,6 @@
|
||||
{{ row.call_type === 1 ? '语音' : '视频' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间号" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长" width="110" prop="duration_text" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
<template>
|
||||
<div class="diet-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 label="早餐" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.breakfast_foods">{{ row.breakfast_foods }}</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="午餐" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.lunch_foods">{{ row.lunch_foods }}</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="晚餐" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.dinner_foods">{{ row.dinner_foods }}</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" 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="800px">
|
||||
<el-form :model="recordForm" label-width="100px">
|
||||
<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-divider content-position="left">早餐</el-divider>
|
||||
<el-form-item label="食物描述">
|
||||
<el-input
|
||||
v-model="recordForm.breakfast_foods"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入早餐食物"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片">
|
||||
<material-picker
|
||||
v-model="recordForm.breakfast_images"
|
||||
:limit="3"
|
||||
type="image"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">午餐</el-divider>
|
||||
<el-form-item label="食物描述">
|
||||
<el-input
|
||||
v-model="recordForm.lunch_foods"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入午餐食物"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片">
|
||||
<material-picker
|
||||
v-model="recordForm.lunch_images"
|
||||
:limit="3"
|
||||
type="image"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">晚餐</el-divider>
|
||||
<el-form-item label="食物描述">
|
||||
<el-input
|
||||
v-model="recordForm.dinner_foods"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入晚餐食物"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片">
|
||||
<material-picker
|
||||
v-model="recordForm.dinner_images"
|
||||
:limit="3"
|
||||
type="image"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="recordForm.note"
|
||||
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 { dietRecordAdd, dietRecordEdit, dietRecordDelete, getDietRecordsByPatient } 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: '',
|
||||
breakfast_foods: '',
|
||||
breakfast_images: [],
|
||||
lunch_foods: '',
|
||||
lunch_images: [],
|
||||
dinner_foods: '',
|
||||
dinner_images: [],
|
||||
note: ''
|
||||
})
|
||||
|
||||
const getRecords = async () => {
|
||||
if (!props.diagnosisId && !props.patientId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getDietRecordsByPatient({
|
||||
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: '',
|
||||
breakfast_foods: '',
|
||||
breakfast_images: [],
|
||||
lunch_foods: '',
|
||||
lunch_images: [],
|
||||
dinner_foods: '',
|
||||
dinner_images: [],
|
||||
note: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
recordForm.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
await feedback.confirm('确定要删除这条记录吗?')
|
||||
try {
|
||||
await dietRecordDelete({ 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 dietRecordEdit(recordForm.value)
|
||||
feedback.msgSuccess('编辑成功')
|
||||
} else {
|
||||
await dietRecordAdd(recordForm.value)
|
||||
feedback.msgSuccess('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getRecords()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getRecords()
|
||||
})
|
||||
|
||||
watch(() => [props.diagnosisId, props.patientId], () => {
|
||||
getRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diet-record-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<div class="exercise-record-list">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">添加记录</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="recordList" border style="width: 100%">
|
||||
<el-table-column prop="record_date" label="记录日期" width="120" />
|
||||
<el-table-column prop="exercise_type" label="运动类型" width="150" />
|
||||
<el-table-column prop="duration" label="运动时长" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.duration }} 分钟
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="运动强度" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ getIntensityText(row.intensity) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" 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="运动类型" required>
|
||||
<el-select v-model="recordForm.exercise_type" placeholder="请选择运动类型" class="w-full">
|
||||
<el-option label="散步" :value="'散步'" />
|
||||
<el-option label="慢跑" :value="'慢跑'" />
|
||||
<el-option label="游泳" :value="'游泳'" />
|
||||
<el-option label="瑜伽" :value="'瑜伽'" />
|
||||
<el-option label="骑自行车" :value="'骑自行车'" />
|
||||
<el-option label="打太极拳" :value="'打太极拳'" />
|
||||
<el-option label="其他" :value="'其他'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运动时长(分钟)" required>
|
||||
<el-input-number
|
||||
v-model="recordForm.duration"
|
||||
:min="0"
|
||||
:max="300"
|
||||
placeholder="请输入运动时长"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="运动强度" required>
|
||||
<el-select v-model="recordForm.intensity" placeholder="请选择运动强度" class="w-full">
|
||||
<el-option label="低强度" :value="1" />
|
||||
<el-option label="中强度" :value="2" />
|
||||
<el-option label="高强度" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运动图片">
|
||||
<material-picker
|
||||
v-model="recordForm.images"
|
||||
:limit="3"
|
||||
type="image"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="recordForm.note"
|
||||
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 { exerciseRecordAdd, exerciseRecordEdit, exerciseRecordDelete, getExerciseRecordsByPatient } 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: '',
|
||||
exercise_type: '',
|
||||
duration: 0,
|
||||
intensity: 1,
|
||||
images: [],
|
||||
note: ''
|
||||
})
|
||||
|
||||
const intensityOptions = [
|
||||
{ label: '低强度', value: 1 },
|
||||
{ label: '中强度', value: 2 },
|
||||
{ label: '高强度', value: 3 }
|
||||
]
|
||||
|
||||
const getIntensityText = (intensity: number) => {
|
||||
const option = intensityOptions.find(opt => opt.value === intensity)
|
||||
return option ? option.label : '未知'
|
||||
}
|
||||
|
||||
const getRecords = async () => {
|
||||
if (!props.diagnosisId && !props.patientId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getExerciseRecordsByPatient({
|
||||
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: '',
|
||||
exercise_type: '',
|
||||
duration: 0,
|
||||
intensity: 1,
|
||||
images: [],
|
||||
note: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
recordForm.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
await feedback.confirm('确定要删除这条记录吗?')
|
||||
try {
|
||||
await exerciseRecordDelete({ id: row.id })
|
||||
feedback.msgSuccess('删除成功')
|
||||
getRecords()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!recordForm.value.record_date) {
|
||||
feedback.msgWarning('请选择记录日期')
|
||||
return
|
||||
}
|
||||
|
||||
if (!recordForm.value.exercise_type) {
|
||||
feedback.msgWarning('请选择运动类型')
|
||||
return
|
||||
}
|
||||
|
||||
if (!recordForm.value.duration) {
|
||||
feedback.msgWarning('请输入运动时长')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (recordForm.value.id) {
|
||||
await exerciseRecordEdit(recordForm.value)
|
||||
feedback.msgSuccess('编辑成功')
|
||||
} else {
|
||||
await exerciseRecordAdd(recordForm.value)
|
||||
feedback.msgSuccess('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getRecords()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getRecords()
|
||||
})
|
||||
|
||||
watch(() => [props.diagnosisId, props.patientId], () => {
|
||||
getRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exercise-record-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -9,9 +9,9 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<el-button type="primary" link :loading="loading" @click="refreshLive">
|
||||
<el-button type="primary" link :loading="loading" @click="load">
|
||||
<el-icon class="mr-1"><Refresh /></el-icon>
|
||||
刷新(同步云端最新)
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -19,42 +19,44 @@
|
||||
<template v-if="!loading && rows.length">
|
||||
<div class="chat-list">
|
||||
<div
|
||||
v-for="item in rows"
|
||||
:key="item.raw.msg_id"
|
||||
v-for="row in rows"
|
||||
:key="row.msg_id"
|
||||
class="chat-row"
|
||||
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
|
||||
:class="row.is_from_doctor ? 'from-doctor' : 'from-patient'"
|
||||
>
|
||||
<div class="meta">
|
||||
<span class="name">{{ senderLabel(item.raw) }}</span>
|
||||
<span class="time">{{ formatTime(item.raw.time) }}</span>
|
||||
<el-tag v-if="item.tag" size="small" type="info" class="ml-2">
|
||||
{{ item.tag }}
|
||||
<span class="name">{{ senderLabel(row) }}</span>
|
||||
<span class="time">{{ formatTime(row.time) }}</span>
|
||||
<el-tag v-if="typeLabel(row)" size="small" type="info" class="ml-2">
|
||||
{{ typeLabel(row) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="bubble">
|
||||
<template v-if="item.raw.msg_type === 'image' && item.raw.image_url">
|
||||
<template v-if="row.msg_type === 'image' && row.image_url">
|
||||
<el-image
|
||||
:src="item.raw.image_url"
|
||||
:preview-src-list="[item.raw.image_url]"
|
||||
:src="row.image_url"
|
||||
:preview-src-list="[row.image_url]"
|
||||
fit="contain"
|
||||
class="chat-img"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="(item.raw.msg_type === 'file' || item.raw.msg_type === 'sound' || item.raw.msg_type === 'video') && item.raw.file_url">
|
||||
<el-link :href="item.raw.file_url" target="_blank" type="primary">
|
||||
{{ item.raw.file_name || '打开文件' }}
|
||||
<template v-else-if="(row.msg_type === 'file' || row.msg_type === 'sound' || row.msg_type === 'video') && row.file_url">
|
||||
<el-link :href="row.file_url" target="_blank" type="primary">
|
||||
{{ row.file_name || '打开文件' }}
|
||||
</el-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="item.friendly" class="friendly-text">
|
||||
<div class="friendly-main">{{ item.friendly.main }}</div>
|
||||
<div v-if="item.friendly.sub" class="friendly-sub">{{ item.friendly.sub }}</div>
|
||||
</div>
|
||||
<div v-else-if="item.raw.msg_type === 'text' && item.raw.text" class="text-content">
|
||||
{{ item.raw.text }}
|
||||
</div>
|
||||
<div v-else-if="item.raw.text" class="text-content">{{ item.raw.text }}</div>
|
||||
<span v-else class="muted">(无法展示该消息类型)</span>
|
||||
<template v-for="fr in [parseImFriendly(row)]" :key="row.msg_id + '-body'">
|
||||
<div v-if="fr" class="friendly-text">
|
||||
<div class="friendly-main">{{ fr.main }}</div>
|
||||
<div v-if="fr.sub" class="friendly-sub">{{ fr.sub }}</div>
|
||||
</div>
|
||||
<div v-else-if="row.msg_type === 'text' && row.text" class="text-content">
|
||||
{{ row.text }}
|
||||
</div>
|
||||
<div v-else-if="row.text" class="text-content">{{ row.text }}</div>
|
||||
<span v-else class="muted">(无法展示该消息类型)</span>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +68,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch, computed } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getImChatMessages } from '@/api/tcm'
|
||||
@@ -78,43 +80,12 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const rawRows = shallowRef<any[]>([])
|
||||
const rows = ref<any[]>([])
|
||||
const patientImId = ref('')
|
||||
const patientName = ref('')
|
||||
|
||||
const patientImHint = computed(() => patientImId.value || 'patient_*')
|
||||
|
||||
interface EnrichedRow {
|
||||
raw: any
|
||||
friendly: FriendlyParse | null
|
||||
tag: string
|
||||
}
|
||||
|
||||
const MSG_TYPE_LABELS: Record<string, string> = {
|
||||
text: '',
|
||||
image: '图片',
|
||||
file: '文件',
|
||||
sound: '语音',
|
||||
video: '视频',
|
||||
location: '位置',
|
||||
custom: '自定义',
|
||||
face: '表情',
|
||||
other: ''
|
||||
}
|
||||
|
||||
const rows = computed<EnrichedRow[]>(() =>
|
||||
rawRows.value.map((row) => {
|
||||
const fr = parseImFriendly(row)
|
||||
let tag = ''
|
||||
if (fr?.tag) {
|
||||
tag = fr.tag
|
||||
} else {
|
||||
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
|
||||
}
|
||||
return { raw: row, friendly: fr, tag }
|
||||
})
|
||||
)
|
||||
|
||||
function formatTime(ts: number | undefined) {
|
||||
if (ts == null || !ts) return '—'
|
||||
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts
|
||||
@@ -132,6 +103,23 @@ function parseImFriendly(row: any): FriendlyParse | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function typeLabel(row: any) {
|
||||
const fr = parseImFriendly(row)
|
||||
if (fr?.tag) return fr.tag
|
||||
const m: Record<string, string> = {
|
||||
text: '',
|
||||
image: '图片',
|
||||
file: '文件',
|
||||
sound: '语音',
|
||||
video: '视频',
|
||||
location: '位置',
|
||||
custom: '自定义',
|
||||
face: '表情',
|
||||
other: ''
|
||||
}
|
||||
return m[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
|
||||
}
|
||||
|
||||
function senderLabel(row: any) {
|
||||
if (row.is_from_doctor) {
|
||||
return row.from_staff_name ? `医生(${row.from_staff_name})` : '医生/员工'
|
||||
@@ -139,42 +127,35 @@ function senderLabel(row: any) {
|
||||
return patientName.value ? `患者(${patientName.value})` : '患者'
|
||||
}
|
||||
|
||||
async function load(onlyArchived = false) {
|
||||
async function load() {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = (await getImChatMessages({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
only_archived: onlyArchived ? 1 : 0
|
||||
})) as {
|
||||
const res = (await getImChatMessages({ diagnosis_id: props.diagnosisId })) as {
|
||||
lists?: any[]
|
||||
patient_im_id?: string
|
||||
patient_name?: string
|
||||
}
|
||||
rawRows.value = res?.lists || []
|
||||
rows.value = res?.lists || []
|
||||
patientImId.value = res?.patient_im_id || ''
|
||||
patientName.value = res?.patient_name || ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
rawRows.value = []
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLive() {
|
||||
load(false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load(true)
|
||||
load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ refresh: refreshLive })
|
||||
defineExpose({ refresh: load })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -282,7 +282,6 @@
|
||||
<el-col :span="20">
|
||||
<el-form-item label="每日饮水量" class="compact-form-item">
|
||||
<el-radio-group v-model="formData.water_intake">
|
||||
<el-radio-button label="">无</el-radio-button>
|
||||
<el-radio-button v-for="item in waterIntakeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio-button>
|
||||
@@ -294,7 +293,6 @@
|
||||
<el-col :span="12">
|
||||
<el-form-item label="近一个月体重变化" class="compact-form-item">
|
||||
<el-radio-group v-model="formData.weight_change">
|
||||
<el-radio-button label="">无</el-radio-button>
|
||||
<el-radio-button v-for="item in weightChangeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio-button>
|
||||
@@ -307,7 +305,6 @@
|
||||
<el-col :span="12">
|
||||
<el-form-item label="脂肪肝程度" class="compact-form-item">
|
||||
<el-radio-group v-model="formData.fatty_liver_degree">
|
||||
<el-radio-button label="">无</el-radio-button>
|
||||
<el-radio-button v-for="item in fattyLiverDegreeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.name }}
|
||||
</el-radio-button>
|
||||
@@ -513,27 +510,6 @@
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 饮食记录标签页 -->
|
||||
<el-tab-pane label="饮食记录" name="diet" :disabled="!formData.id">
|
||||
<diet-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-tab-pane label="运动打卡记录" name="exercise" :disabled="!formData.id">
|
||||
<exercise-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-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||
<div v-if="formData.id" class="bingli-tab-content">
|
||||
@@ -604,10 +580,10 @@
|
||||
lazy
|
||||
>
|
||||
<im-chat-record-panel
|
||||
v-if="activeTab === 'chat' && formData.id"
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
<el-empty v-else-if="!formData.id" description="请先保存诊单后再查看聊天记录" />
|
||||
<el-empty v-else description="请先保存诊单后再查看聊天记录" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="
|
||||
@@ -671,8 +647,6 @@ import useAppStore from '@/stores/modules/app'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import BloodRecordList from './components/BloodRecordList.vue'
|
||||
import DietRecordList from './components/DietRecordList.vue'
|
||||
import ExerciseRecordList from './components/ExerciseRecordList.vue'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
|
||||
@@ -7,23 +7,15 @@
|
||||
<span
|
||||
v-for="tab in dateTabs"
|
||||
:key="tab.value"
|
||||
:class="['chip', { active: formData.pending_assign !== '1' && formData.appointment_date === tab.value }]"
|
||||
:class="['chip', { active: formData.appointment_date === tab.value }]"
|
||||
@click="handleDateTabClick(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<em v-if="tab.value && dateCounts[tab.value] !== undefined">{{ dateCounts[tab.value] }}</em>
|
||||
</span>
|
||||
<span
|
||||
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
|
||||
@click="handlePendingAssignTabClick"
|
||||
v-perms="['tcm.diagnosis/assign']"
|
||||
>
|
||||
待分配医助
|
||||
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
|
||||
</span>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<el-input v-model="formData.keyword" placeholder="患者姓名 / 手机号" clearable class="search-input" @keyup.enter="doSearch" />
|
||||
<el-input v-model="formData.patient_name" placeholder="患者姓名" clearable class="search-input" @keyup.enter="doSearch" />
|
||||
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +111,7 @@
|
||||
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号" min-width="150">
|
||||
<el-table-column label="挂号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
class="appointment-cell"
|
||||
@@ -154,7 +146,7 @@
|
||||
<span v-else class="status-unconfirmed">未确认</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="复诊" min-width="120">
|
||||
<el-table-column label="复诊" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.has_prescription" class="followup-cell">
|
||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
||||
@@ -172,7 +164,7 @@
|
||||
<span v-else class="followup-none">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||
<el-table-column label="助理" width="72" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||
</template>
|
||||
@@ -575,7 +567,7 @@ const maskIdCard = (idCard: string) => {
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
patient_name: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
assistant_id: '',
|
||||
@@ -583,8 +575,7 @@ const formData = reactive({
|
||||
end_time: '',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||
appointment_date: '' as string,
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
pending_assign: '' as '' | '1'
|
||||
has_appointment: '' as '' | '0' | '1'
|
||||
})
|
||||
|
||||
const activeTab = ref('all')
|
||||
@@ -630,39 +621,27 @@ const dateTabs = computed(() => [
|
||||
])
|
||||
|
||||
const dateCounts = ref<Record<string, number>>({})
|
||||
const pendingAssignCount = ref<number | undefined>(undefined)
|
||||
|
||||
const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_assign = ''
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
const handlePendingAssignTabClick = async () => {
|
||||
formData.pending_assign = '1'
|
||||
formData.appointment_date = ''
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
// 获取各日期挂号数量 + 待分配医助数量
|
||||
// 获取各日期挂号数量
|
||||
const fetchDateCounts = async () => {
|
||||
try {
|
||||
const [today, tomorrow, dayAfter, pending] = await Promise.all([
|
||||
const [today, tomorrow, dayAfter] = await Promise.all([
|
||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 })
|
||||
])
|
||||
dateCounts.value = {
|
||||
[todayStr.value]: today?.count ?? 0,
|
||||
[tomorrowStr.value]: tomorrow?.count ?? 0,
|
||||
[dayAfterStr.value]: dayAfter?.count ?? 0
|
||||
}
|
||||
pendingAssignCount.value = pending?.count ?? 0
|
||||
} catch (e) {
|
||||
console.error('获取日期数量失败', e)
|
||||
}
|
||||
@@ -782,14 +761,13 @@ const refreshListAfterPopupSave = () => getLists({ silent: true })
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
activeTab.value = 'all'
|
||||
formData.keyword = ''
|
||||
formData.patient_name = ''
|
||||
formData.diagnosis_type = ''
|
||||
formData.syndrome_type = ''
|
||||
formData.assistant_id = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
formData.pending_assign = ''
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -1516,21 +1494,6 @@ onUnmounted(() => {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chip-pending {
|
||||
margin-left: 8px;
|
||||
color: var(--el-color-warning);
|
||||
background: var(--el-color-warning-light-9);
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-warning-light-8);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
|
||||
+1364
-1187
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
hmr: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api-proxy': {
|
||||
target: 'https://admin.zhenyangtang.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api-proxy/, '')
|
||||
}
|
||||
}
|
||||
open: true
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 甘草 API 修复部署脚本
|
||||
# 使用方法: ./deploy_gancao_fix.sh [server_user@server_ip] [project_path]
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== 甘草 API 修复部署脚本 ===${NC}\n"
|
||||
|
||||
# 检查参数
|
||||
if [ $# -lt 2 ]; then
|
||||
echo -e "${RED}错误:缺少参数${NC}"
|
||||
echo "使用方法: $0 [server_user@server_ip] [project_path]"
|
||||
echo "示例: $0 root@192.168.1.100 /www/wwwroot/your-project"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER=$1
|
||||
PROJECT_PATH=$2
|
||||
|
||||
echo -e "${YELLOW}服务器:${NC}$SERVER"
|
||||
echo -e "${YELLOW}项目路径:${NC}$PROJECT_PATH\n"
|
||||
|
||||
# 确认
|
||||
read -p "确认部署到以上服务器?(y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}步骤 1: 上传修复文件${NC}"
|
||||
|
||||
# 上传 PHP 文件
|
||||
echo "上传 GancaoScmRecipelService.php..."
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/common/service/gancao/
|
||||
|
||||
echo "上传 GancaoOpenApiTransport.php..."
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/common/service/gancao/
|
||||
|
||||
echo "上传 PrescriptionOrderLogic.php..."
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/adminapi/logic/tcm/
|
||||
|
||||
echo "上传 PrescriptionOrderController.php..."
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传测试脚本
|
||||
echo "上传测试脚本..."
|
||||
scp test_gancao_config.php $SERVER:$PROJECT_PATH/
|
||||
scp test_gancao_ssl.php $SERVER:$PROJECT_PATH/
|
||||
scp diagnose_gancao_payload.php $SERVER:$PROJECT_PATH/
|
||||
|
||||
echo -e "${GREEN}✓ 文件上传完成${NC}\n"
|
||||
|
||||
echo -e "${GREEN}步骤 2: 在服务器上执行操作${NC}"
|
||||
|
||||
ssh $SERVER << EOF
|
||||
set -e
|
||||
cd $PROJECT_PATH
|
||||
|
||||
echo "检查文件..."
|
||||
ls -la server/app/common/service/gancao/GancaoScmRecipelService.php
|
||||
ls -la server/app/common/service/gancao/GancaoOpenApiTransport.php
|
||||
|
||||
echo ""
|
||||
echo "清除缓存..."
|
||||
cd server
|
||||
php think clear || true
|
||||
rm -rf runtime/cache/* || true
|
||||
rm -rf runtime/temp/* || true
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "运行配置检查..."
|
||||
php test_gancao_config.php || true
|
||||
|
||||
echo ""
|
||||
echo "运行 SSL 测试..."
|
||||
php test_gancao_ssl.php || true
|
||||
|
||||
echo ""
|
||||
echo "重启服务..."
|
||||
sudo systemctl restart php-fpm || sudo systemctl restart php7.4-fpm || sudo systemctl restart php8.0-fpm || sudo systemctl restart php8.1-fpm || true
|
||||
sudo systemctl restart nginx || true
|
||||
|
||||
echo ""
|
||||
echo "部署完成!"
|
||||
EOF
|
||||
|
||||
echo -e "\n${GREEN}=== 部署完成 ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}下一步:${NC}"
|
||||
echo "1. 查看日志:"
|
||||
echo " ssh $SERVER 'tail -f $PROJECT_PATH/server/runtime/log/\$(date +%Y%m%d).log | grep -i gancao'"
|
||||
echo ""
|
||||
echo "2. 测试接口:"
|
||||
echo " curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \\"
|
||||
echo " -H 'Content-Type: application/json' \\"
|
||||
echo " -H 'token: your_token' \\"
|
||||
echo " -d '{\"id\": 订单ID}'"
|
||||
echo ""
|
||||
echo "3. 如果仍有问题,查看详细文档:"
|
||||
echo " cat GANCAO_FINAL_FIX.md"
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* 甘草负载诊断脚本
|
||||
* 用于检查构建的 API 负载中是否有数组转字符串的问题
|
||||
*/
|
||||
|
||||
require __DIR__ . '/server/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
echo "=== 甘草负载诊断 ===\n\n";
|
||||
|
||||
// 模拟处方数据
|
||||
$rx = [
|
||||
'id' => 1,
|
||||
'diagnosis_id' => 1,
|
||||
'patient_name' => '测试患者',
|
||||
'age' => 30,
|
||||
'gender' => 0,
|
||||
'phone' => '13800138000',
|
||||
'clinical_diagnosis' => '中医辨证论治',
|
||||
'doctor_name' => '测试医生',
|
||||
'doctor_phone' => '13900139000',
|
||||
'usage_time' => '饭后半小时服用',
|
||||
'dietary_taboo' => '忌辛辣',
|
||||
'usage_way' => '口服',
|
||||
'usage_instruction' => '温水送服',
|
||||
'usage_notes' => '注意休息',
|
||||
'dose_count' => 7,
|
||||
'herbs' => [
|
||||
[
|
||||
'name' => '当归',
|
||||
'dosage' => 10,
|
||||
'gc_id' => 1001,
|
||||
'brief' => '',
|
||||
],
|
||||
[
|
||||
'name' => '黄芪',
|
||||
'dosage' => 15,
|
||||
'gc_id' => 1002,
|
||||
'brief' => '',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// 模拟订单数据
|
||||
$order = [
|
||||
'id' => 1,
|
||||
'recipient_name' => '测试患者',
|
||||
'recipient_phone' => '13800138000',
|
||||
'shipping_address' => '四川省成都市武侯区测试街道123号',
|
||||
'remark_extra' => '测试备注',
|
||||
];
|
||||
|
||||
echo "1. 测试配置读取:\n";
|
||||
$config = think\facade\Config::get('gancao_scm', []);
|
||||
|
||||
$criticalFields = ['express_type', 'callback_url', 'cradle_store', 'df_id'];
|
||||
foreach ($criticalFields as $field) {
|
||||
$value = $config[$field] ?? null;
|
||||
$type = gettype($value);
|
||||
echo " - {$field}: {$type}";
|
||||
|
||||
if ($type === 'array') {
|
||||
echo " ❌ 错误!这个字段不应该是数组\n";
|
||||
echo " 值: " . json_encode($value, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
} else {
|
||||
echo " ✓\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n2. 测试 buildPreviewPayload:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$previewPayload = \app\common\service\gancao\GancaoScmRecipelService::buildPreviewPayload($rx, $order, $token);
|
||||
|
||||
echo " ✓ buildPreviewPayload 成功\n";
|
||||
echo " 字段数量: " . count($previewPayload) . "\n";
|
||||
|
||||
// 检查每个字段的类型
|
||||
echo "\n 检查字段类型:\n";
|
||||
foreach ($previewPayload as $key => $value) {
|
||||
$type = gettype($value);
|
||||
echo " - {$key}: {$type}";
|
||||
|
||||
if ($type === 'array') {
|
||||
echo " (包含 " . count($value) . " 个元素)";
|
||||
|
||||
// 检查数组内部是否有问题
|
||||
$hasArrayValue = false;
|
||||
foreach ($value as $subKey => $subValue) {
|
||||
if (is_array($subValue) && !in_array($key, ['m_list', 'df101ext', 'df102ext', 'df103ext', 'df104ext'])) {
|
||||
$hasArrayValue = true;
|
||||
echo "\n ⚠️ 子字段 '{$subKey}' 是数组: " . json_encode($subValue, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 失败: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
}
|
||||
|
||||
echo "\n3. 测试 buildSubmitPayload:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$addrParts = \app\common\service\gancao\GancaoScmRecipelService::splitCnAddress($order['shipping_address']);
|
||||
$appNo = 'PO' . $order['id'];
|
||||
|
||||
$submitPayload = \app\common\service\gancao\GancaoScmRecipelService::buildSubmitPayload(
|
||||
$rx,
|
||||
$order,
|
||||
$token,
|
||||
$addrParts,
|
||||
$appNo
|
||||
);
|
||||
|
||||
echo " ✓ buildSubmitPayload 成功\n";
|
||||
echo " 字段数量: " . count($submitPayload) . "\n";
|
||||
|
||||
// 检查关键字段
|
||||
echo "\n 检查关键字段:\n";
|
||||
$keyFields = ['express_type', 'callback_url', 'app_order_no', 'cradle_store', 'express_to', 'patient', 'doctor', 'doct_advice'];
|
||||
|
||||
foreach ($keyFields as $field) {
|
||||
if (!isset($submitPayload[$field])) {
|
||||
echo " - {$field}: ❌ 缺失\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $submitPayload[$field];
|
||||
$type = gettype($value);
|
||||
echo " - {$field}: {$type}";
|
||||
|
||||
if ($type === 'string') {
|
||||
if (strlen($value) > 50) {
|
||||
echo " (长度: " . strlen($value) . ")";
|
||||
} else {
|
||||
echo " = " . var_export($value, true);
|
||||
}
|
||||
} elseif ($type === 'array') {
|
||||
echo " (包含 " . count($value) . " 个元素)";
|
||||
|
||||
// 检查数组内部
|
||||
foreach ($value as $subKey => $subValue) {
|
||||
$subType = gettype($subValue);
|
||||
if ($subType === 'array') {
|
||||
echo "\n ⚠️ 子字段 '{$subKey}' 是数组(可能有问题)";
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 失败: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
}
|
||||
|
||||
echo "\n4. 测试 JSON 编码:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$addrParts = \app\common\service\gancao\GancaoScmRecipelService::splitCnAddress($order['shipping_address']);
|
||||
$appNo = 'PO' . $order['id'];
|
||||
|
||||
$submitPayload = \app\common\service\gancao\GancaoScmRecipelService::buildSubmitPayload(
|
||||
$rx,
|
||||
$order,
|
||||
$token,
|
||||
$addrParts,
|
||||
$appNo
|
||||
);
|
||||
|
||||
$json = json_encode($submitPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
echo " ❌ JSON 编码失败: " . json_last_error_msg() . "\n";
|
||||
} else {
|
||||
echo " ✓ JSON 编码成功\n";
|
||||
echo " 大小: " . strlen($json) . " 字节\n";
|
||||
|
||||
// 检查是否包含 "Array" 字符串
|
||||
if (strpos($json, '"Array"') !== false) {
|
||||
echo " ❌ 警告:JSON 中包含 'Array' 字符串,说明有数组被转换为字符串\n";
|
||||
|
||||
// 尝试找出是哪个字段
|
||||
$decoded = json_decode($json, true);
|
||||
echo "\n 查找包含 'Array' 的字段:\n";
|
||||
array_walk_recursive($decoded, function($value, $key) {
|
||||
if (is_string($value) && $value === 'Array') {
|
||||
echo " - 字段 '{$key}' 的值是 'Array'\n";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
echo " ✓ JSON 内容正常\n";
|
||||
}
|
||||
|
||||
// 保存到文件以便检查
|
||||
$filename = 'gancao_payload_' . date('YmdHis') . '.json';
|
||||
file_put_contents($filename, json_encode($submitPayload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo " 负载已保存到: {$filename}\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 异常: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
echo " 堆栈:\n" . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== 诊断完成 ===\n";
|
||||
+1
-18
@@ -41,27 +41,10 @@ UNIQUE_IDENTIFICATION = likeadmin
|
||||
# 演示环境
|
||||
DEMO_ENV = false
|
||||
|
||||
; 处方业务订单:关联收款每笔金额须大于等于该值(元),业务订单金额也须大于等于该值;0=不校验
|
||||
PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT = 0
|
||||
|
||||
# 物流轨迹(快递100):https://api.kuaidi100.com
|
||||
; ThinkPHP .env 为 INI:下列建议写在分区声明之前;若误写在 trtc 分区内,config 已兼容
|
||||
# 同时填 CUSTOMER、KEY 即启用;临时关闭设 LOGISTICS_KUAIDI100_DISABLE = true
|
||||
LOGISTICS_KUAIDI100_CUSTOMER =
|
||||
LOGISTICS_KUAIDI100_KEY =
|
||||
# LOGISTICS_KUAIDI100_DISABLE = false
|
||||
# LOGISTICS_KUAIDI100_QUERY_URL = https://poll.kuaidi100.com/poll/query.do
|
||||
|
||||
; 甘草药管家 SCM(须写在任意 [APP]、[trtc] 等分区之前;误放在 [trtc] 下会变成 TRTC_GANCAO_SCM_*,框架已兼容读取)
|
||||
; 也可使用独立分区:[GANCAO_SCM] 下写 ENABLED=、GATEWAY_AK= 等(见 server/config/gancao_scm.php)
|
||||
; GANCAO_SCM_ENABLED = true
|
||||
; GANCAO_SCM_GATEWAY_URL = https://oapi.igancao.com
|
||||
; GANCAO_SCM_GATEWAY_AK =
|
||||
; GANCAO_SCM_GATEWAY_SK =
|
||||
; 业务层(MAKE_TOKEN);常与 GANCAO_SCM_GATEWAY_* 不同,勿默认留空复用错误 sk
|
||||
; GANCAO_SCM_BIZ_AK =
|
||||
; GANCAO_SCM_BIZ_SK =
|
||||
; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify
|
||||
; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院
|
||||
; GANCAO_SCM_DF_ID = 101
|
||||
; GANCAO_SCM_EXPRESS_TYPE = general
|
||||
# LOGISTICS_KUAIDI100_QUERY_URL = https://poll.kuaidi100.com/poll/query.do
|
||||
@@ -20,13 +20,4 @@ class StatisticsController extends BaseAdminController
|
||||
{
|
||||
return $this->dataLists(new StatisticsLists());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 部门统计列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function deptLists()
|
||||
{
|
||||
return $this->dataLists(new StatisticsLists('dept'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\qywx;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
|
||||
/**
|
||||
* 企业微信客户管理控制器
|
||||
*/
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
/**
|
||||
* @notes 客户列表
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new CustomerLists());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
*/
|
||||
public function sync()
|
||||
{
|
||||
$result = CustomerLogic::syncCustomers();
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('同步成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取统计信息
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
$stats = CustomerLogic::getStats();
|
||||
return $this->data($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步设置
|
||||
*/
|
||||
public function getSyncSettings()
|
||||
{
|
||||
$settings = CustomerLogic::getSyncSettings();
|
||||
return $this->data($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 保存同步设置
|
||||
*/
|
||||
public function saveSyncSettings()
|
||||
{
|
||||
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
|
||||
$result = CustomerLogic::saveSyncSettings($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
}
|
||||
@@ -78,15 +78,4 @@ class BloodRecordController extends BaseAdminController
|
||||
$result = BloodRecordLogic::getRecordsByPatient($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取血糖趋势图数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getBloodSugarTrend()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = BloodRecordLogic::getBloodSugarTrend($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,15 +301,11 @@ class DiagnosisController extends BaseAdminController
|
||||
*/
|
||||
public function getImChatMessages()
|
||||
{
|
||||
// 增加执行时间限制到 120 秒
|
||||
set_time_limit(120);
|
||||
|
||||
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
|
||||
if ($diagnosisId <= 0) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
$onlyArchived = (int)$this->request->get('only_archived', 0) === 1;
|
||||
$result = DiagnosisLogic::getImChatMessagesForDiagnosis($diagnosisId, $onlyArchived);
|
||||
$result = DiagnosisLogic::getImChatMessagesForDiagnosis($diagnosisId);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\DietRecordLogic;
|
||||
|
||||
class DietRecordController extends BaseAdminController
|
||||
{
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = DietRecordLogic::add($params);
|
||||
if ($result) {
|
||||
return $this->success('添加成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DietRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = DietRecordLogic::edit($params);
|
||||
if ($result) {
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DietRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = DietRecordLogic::delete($params);
|
||||
if ($result) {
|
||||
return $this->success('删除成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DietRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = DietRecordLogic::detail($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
public function getRecordsByPatient()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = DietRecordLogic::getRecordsByPatient($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\ExerciseRecordLogic;
|
||||
|
||||
class ExerciseRecordController extends BaseAdminController
|
||||
{
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = ExerciseRecordLogic::add($params);
|
||||
if ($result) {
|
||||
return $this->success('添加成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(ExerciseRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = ExerciseRecordLogic::edit($params);
|
||||
if ($result) {
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(ExerciseRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = ExerciseRecordLogic::delete($params);
|
||||
if ($result) {
|
||||
return $this->success('删除成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(ExerciseRecordLogic::getError());
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = ExerciseRecordLogic::detail($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
public function getRecordsByPatient()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = ExerciseRecordLogic::getRecordsByPatient($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
public function getExerciseTrend()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
$result = ExerciseRecordLogic::getExerciseTrend($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ class PrescriptionController extends BaseAdminController
|
||||
{
|
||||
$params = (new PrescriptionValidate())->get()->goCheck('detail');
|
||||
$detail = PrescriptionLogic::detail((int) $params['id'], (int) $this->adminId, $this->adminInfo);
|
||||
|
||||
if (!$detail) {
|
||||
$msg = PrescriptionLogic::getError();
|
||||
|
||||
@@ -130,12 +129,8 @@ class PrescriptionController extends BaseAdminController
|
||||
if (!$appointmentId) {
|
||||
return $this->fail('预约ID不能为空');
|
||||
}
|
||||
$prescription = PrescriptionLogic::getByAppointment($appointmentId, (int)$this->adminId, $this->adminInfo);
|
||||
if ($prescription === null) {
|
||||
//$msg = PrescriptionLogic::getError();
|
||||
return '';
|
||||
}
|
||||
return $this->data($prescription);
|
||||
$prescription = PrescriptionLogic::getByAppointment($appointmentId);
|
||||
return $this->data($prescription ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定诊单下可关联的支付单(待支付/已支付,创建/编辑业务订单时多选关联)
|
||||
* 指定诊单下已支付支付单(创建/编辑业务订单时多选关联)
|
||||
*/
|
||||
public function paidPayOrders()
|
||||
{
|
||||
@@ -34,19 +34,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
$exceptPo > 0 ? $exceptPo : null
|
||||
);
|
||||
|
||||
$min = PrescriptionOrderLogic::depositMinAmount();
|
||||
|
||||
// if ($min > 0) {
|
||||
// $lists = array_values(array_filter(
|
||||
// $lists,
|
||||
// static fn(array $r): bool => round((float) ($r['amount'] ?? 0), 2) >= $min
|
||||
// ));
|
||||
// }
|
||||
|
||||
return $this->success('', [
|
||||
'lists' => $lists,
|
||||
'deposit_min_amount' => $min,
|
||||
]);
|
||||
return $this->success('', ['lists' => $lists]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
@@ -119,24 +107,6 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回处方审核
|
||||
*/
|
||||
public function revokeRxAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
$result = PrescriptionOrderLogic::revokeRxAudit(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('处方审核已撤回', $result);
|
||||
}
|
||||
|
||||
public function auditPayment()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPayment');
|
||||
@@ -154,44 +124,6 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回支付单审核
|
||||
*/
|
||||
public function revokePayAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
$result = PrescriptionOrderLogic::revokePayAudit(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('支付单审核已撤回', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认发货:将履约状态推进到 5(已发货),同时更新快递信息
|
||||
*/
|
||||
public function ship()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('ship');
|
||||
$result = PrescriptionOrderLogic::ship(
|
||||
(int) $params['id'],
|
||||
(string) ($params['express_company'] ?? 'auto'),
|
||||
(string) ($params['tracking_number'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('发货成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助/创建人撤回:仅「待双审通过」可撤(未通过或双审均驳回等仍为该状态)
|
||||
*/
|
||||
@@ -205,124 +137,4 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
|
||||
return $this->success('已撤回', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务订单操作日志
|
||||
*/
|
||||
public function logs()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->get()->goCheck('logs');
|
||||
$result = PrescriptionOrderLogic::getLogs((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
|
||||
// 遇到没有权限或者订单不存在,逻辑里设置了 self::$error
|
||||
if ($result === [] && PrescriptionOrderLogic::getError() !== '') {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
||||
*/
|
||||
public function addPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('addPayOrder');
|
||||
$result = PrescriptionOrderLogic::addPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('新增支付单成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为「已发货」订单关联已有支付单,并重置支付单审核状态为待审核
|
||||
*/
|
||||
public function linkPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('linkPayOrder');
|
||||
$result = PrescriptionOrderLogic::linkPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('关联支付单成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将「已发货」且支付审核已通过的订单标记为「已完成」
|
||||
*/
|
||||
public function complete()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('complete');
|
||||
$result = PrescriptionOrderLogic::complete((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('订单已完成', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:处方下单(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
*/
|
||||
public function submitGancaoRecipel()
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
\think\facade\Log::error('submitGancaoRecipel failed', ['error' => $error, 'params' => $params]);
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
// 确保返回的数据是可序列化的
|
||||
if (!is_array($result)) {
|
||||
\think\facade\Log::error('submitGancaoRecipel result is not array', ['result' => $result]);
|
||||
return $this->fail('返回数据格式错误');
|
||||
}
|
||||
|
||||
return $this->success('甘草药方上传成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('submitGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return $this->fail('系统错误:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||
* 用于在编辑订单时测试价格和配置
|
||||
*/
|
||||
public function previewGancaoRecipel()
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('previewGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::previewGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo, $params);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
\think\facade\Log::error('previewGancaoRecipel failed', ['error' => $error, 'params' => $params]);
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
return $this->success('预下单成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('previewGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
]);
|
||||
return $this->fail('系统错误:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,6 @@ use app\common\model\auth\Admin;
|
||||
*/
|
||||
class StatisticsLists extends BaseAdminDataLists
|
||||
{
|
||||
protected $type = 'doctor';
|
||||
|
||||
/**
|
||||
* @param string $type 统计类型:doctor(医生统计) 或 dept(部门统计)
|
||||
*/
|
||||
public function __construct($type = 'doctor')
|
||||
{
|
||||
parent::__construct();
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 搜索条件
|
||||
* @return array
|
||||
@@ -39,10 +28,6 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
if ($this->type === 'dept') {
|
||||
return $this->getDeptStatistics();
|
||||
}
|
||||
|
||||
$doctorId = $this->params['doctor_id'] ?? '';
|
||||
$timeType = $this->params['time_type'] ?? 'today';
|
||||
$startDate = $this->params['start_date'] ?? '';
|
||||
@@ -53,7 +38,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
|
||||
// 获取医生列表(role_id=1 表示医生角色)
|
||||
$doctorAdminIds = \app\common\model\auth\AdminRole::where('role_id', 1)->column('admin_id');
|
||||
|
||||
|
||||
if (empty($doctorAdminIds)) {
|
||||
return [];
|
||||
}
|
||||
@@ -71,11 +56,11 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
->whereIn('doctor_id', $doctorAdminIds)
|
||||
->where('appointment_date', '>=', $startDate)
|
||||
->where('appointment_date', '<=', $endDate);
|
||||
|
||||
|
||||
if ($doctorId) {
|
||||
$statisticsData->where('doctor_id', $doctorId);
|
||||
}
|
||||
|
||||
|
||||
$statisticsData = $statisticsData->group('doctor_id')->select()->toArray();
|
||||
|
||||
// 将统计数据按 doctor_id 索引
|
||||
@@ -130,7 +115,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
$deptStatusMap = [];
|
||||
$channelStatsMap = [];
|
||||
$channelStatusMap = [];
|
||||
|
||||
|
||||
if (!empty($doctorIdsWithAppointments)) {
|
||||
// 通过 assistant_id → admin_dept → dept 获取部门统计(按状态分组)
|
||||
$deptStats = \think\facade\Db::name('doctor_appointment')
|
||||
@@ -138,9 +123,9 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
->leftJoin('admin_dept ad', 'apt.assistant_id = ad.admin_id')
|
||||
->leftJoin('dept dept', 'ad.dept_id = dept.id')
|
||||
->field([
|
||||
'apt.doctor_id',
|
||||
'apt.doctor_id',
|
||||
'dept.id as dept_id',
|
||||
'dept.name as dept_name',
|
||||
'dept.name as dept_name',
|
||||
'apt.status',
|
||||
'COUNT(*) as count'
|
||||
])
|
||||
@@ -151,7 +136,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
->group('apt.doctor_id, dept.id, apt.status')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
|
||||
// 组装部门统计数据:医生ID => "部门1(数量1) 部门2(数量2)"
|
||||
// 同时组装部门状态数据:医生ID => 部门ID => 状态 => 数量
|
||||
foreach ($deptStats as $stat) {
|
||||
@@ -160,7 +145,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
$deptName = $stat['dept_name'];
|
||||
$status = $stat['status'];
|
||||
$count = $stat['count'];
|
||||
|
||||
|
||||
// 组装部门统计数据
|
||||
if (!isset($deptStatsMap[$doctorId])) {
|
||||
$deptStatsMap[$doctorId] = [];
|
||||
@@ -172,7 +157,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
];
|
||||
}
|
||||
$deptStatsMap[$doctorId][$deptId]['total_count'] += $count;
|
||||
|
||||
|
||||
// 组装部门状态数据
|
||||
if (!isset($deptStatusMap[$doctorId])) {
|
||||
$deptStatusMap[$doctorId] = [];
|
||||
@@ -185,7 +170,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
}
|
||||
$deptStatusMap[$doctorId][$deptId]['statuses'][$status] = $count;
|
||||
}
|
||||
|
||||
|
||||
// 格式化部门统计数据
|
||||
foreach ($deptStatsMap as $doctorId => &$depts) {
|
||||
$formattedDepts = [];
|
||||
@@ -222,7 +207,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
if ($src === '' || $cnt < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// 组装渠道统计数据
|
||||
if (!isset($channelBuckets[$did])) {
|
||||
$channelBuckets[$did] = [];
|
||||
@@ -231,7 +216,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
$channelBuckets[$did][$src] = 0;
|
||||
}
|
||||
$channelBuckets[$did][$src] += $cnt;
|
||||
|
||||
|
||||
// 组装渠道状态数据
|
||||
if (!isset($channelStatusBuckets[$did])) {
|
||||
$channelStatusBuckets[$did] = [];
|
||||
@@ -254,7 +239,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
if ($key === '0' || $cnt < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// 组装渠道统计数据
|
||||
if (!isset($buckets[$did])) {
|
||||
$buckets[$did] = [];
|
||||
@@ -263,7 +248,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
$buckets[$did][$key] = 0;
|
||||
}
|
||||
$buckets[$did][$key] += $cnt;
|
||||
|
||||
|
||||
// 组装渠道状态数据
|
||||
if (!isset($statusBuckets[$did])) {
|
||||
$statusBuckets[$did] = [];
|
||||
@@ -314,7 +299,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
}
|
||||
$channelStatsMap[$did] = $parts;
|
||||
}
|
||||
|
||||
|
||||
// 组装渠道状态明细数据
|
||||
foreach ($channelStatusBuckets as $did => $byKey) {
|
||||
foreach ($byKey as $key => $statuses) {
|
||||
@@ -349,8 +334,8 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
: 0;
|
||||
|
||||
// 计算完成率
|
||||
$completionRate = $stat['total_count'] > 0
|
||||
? round(($stat['completed_count'] / $stat['total_count']) * 100, 2)
|
||||
$completionRate = $stat['total_count'] > 0
|
||||
? round(($stat['completed_count'] / $stat['total_count']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// 格式化部门状态明细
|
||||
@@ -364,7 +349,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 格式化渠道状态明细
|
||||
$channelStatusDetails = [];
|
||||
if (isset($channelStatusMap[$doctor['id']])) {
|
||||
@@ -392,68 +377,6 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取部门统计数据
|
||||
* @return array
|
||||
*/
|
||||
private function getDeptStatistics()
|
||||
{
|
||||
$deptId = $this->params['dept_id'] ?? '';
|
||||
$timeType = $this->params['time_type'] ?? 'today';
|
||||
$startDate = $this->params['start_date'] ?? '';
|
||||
$endDate = $this->params['end_date'] ?? '';
|
||||
|
||||
// 计算时间范围
|
||||
list($startDate, $endDate, $timeRangeText) = $this->getTimeRange($timeType, $startDate, $endDate);
|
||||
|
||||
// 查询部门统计数据
|
||||
$deptStatsQuery = \think\facade\Db::name('doctor_appointment')
|
||||
->alias('apt')
|
||||
->leftJoin('zyt_admin_dept ad', 'apt.assistant_id = ad.admin_id')
|
||||
->leftJoin('zyt_dept dept', 'ad.dept_id = dept.id')
|
||||
->field([
|
||||
'dept.id as dept_id',
|
||||
'dept.name as dept_name',
|
||||
'COUNT(*) as total_count',
|
||||
'SUM(CASE WHEN apt.status = 1 THEN 1 ELSE 0 END) as registered_count',
|
||||
'SUM(CASE WHEN apt.status = 3 THEN 1 ELSE 0 END) as completed_count',
|
||||
'SUM(CASE WHEN apt.status = 2 THEN 1 ELSE 0 END) as canceled_count',
|
||||
'SUM(CASE WHEN apt.status = 4 THEN 1 ELSE 0 END) as expired_count'
|
||||
])
|
||||
->where('apt.appointment_date', '>=', $startDate)
|
||||
->where('apt.appointment_date', '<=', $endDate)
|
||||
->whereNotNull('dept.id');
|
||||
|
||||
if ($deptId) {
|
||||
$deptStatsQuery->where('dept.id', $deptId);
|
||||
}
|
||||
|
||||
$deptStats = $deptStatsQuery->group('dept.id')->select()->toArray();
|
||||
|
||||
// 组装结果
|
||||
$result = [];
|
||||
foreach ($deptStats as $stat) {
|
||||
// 计算完成率
|
||||
$completionRate = $stat['total_count'] > 0
|
||||
? round(($stat['completed_count'] / $stat['total_count']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$result[] = [
|
||||
'dept_id' => $stat['dept_id'],
|
||||
'dept_name' => $stat['dept_name'],
|
||||
'total_count' => (int)$stat['total_count'] ?? 0,
|
||||
'registered_count' => (int)$stat['registered_count'] ?? 0,
|
||||
'completed_count' => (int)$stat['completed_count'] ?? 0,
|
||||
'canceled_count' => (int)$stat['canceled_count'] ?? 0,
|
||||
'expired_count' => (int)$stat['expired_count'] ?? 0,
|
||||
'completion_rate' => $completionRate,
|
||||
'time_range' => $timeRangeText
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取时间范围
|
||||
* @param string $timeType
|
||||
@@ -464,26 +387,26 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
private function getTimeRange($timeType, $customStartDate, $customEndDate)
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
|
||||
|
||||
switch ($timeType) {
|
||||
case 'today':
|
||||
$startDate = $today;
|
||||
$endDate = $today;
|
||||
$timeRangeText = '今天 (' . $today . ')';
|
||||
break;
|
||||
|
||||
|
||||
case 'week':
|
||||
$startDate = date('Y-m-d', strtotime('-6 days'));
|
||||
$endDate = $today;
|
||||
$timeRangeText = '最近7天 (' . $startDate . ' 至 ' . $endDate . ')';
|
||||
break;
|
||||
|
||||
|
||||
case 'month':
|
||||
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||
$endDate = $today;
|
||||
$timeRangeText = '最近30天 (' . $startDate . ' 至 ' . $endDate . ')';
|
||||
break;
|
||||
|
||||
|
||||
case 'custom':
|
||||
if ($customStartDate && $customEndDate) {
|
||||
$startDate = $customStartDate;
|
||||
@@ -495,7 +418,7 @@ class StatisticsLists extends BaseAdminDataLists
|
||||
$timeRangeText = '今天 (' . $today . ')';
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
$startDate = $today;
|
||||
$endDate = $today;
|
||||
|
||||
@@ -82,7 +82,7 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$where[] = ['patient_id', 'in', function ($query) {
|
||||
$query->table('zyt_tcm_diagnosis')
|
||||
->whereNull('delete_time')
|
||||
->where('patient_name|phone', 'like', '%' . $this->params['patient_keyword'] . '%')
|
||||
->where('patient_name|patient_phone', 'like', '%' . $this->params['patient_keyword'] . '%')
|
||||
->field('id');
|
||||
}];
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$where[] = ['patient_id', 'in', function ($query) {
|
||||
$query->table('zyt_tcm_diagnosis')
|
||||
->whereNull('delete_time')
|
||||
->where('patient_name|phone', 'like', '%' . $this->params['patient_keyword'] . '%')
|
||||
->where('patient_name|patient_phone', 'like', '%' . $this->params['patient_keyword'] . '%')
|
||||
->field('id');
|
||||
}];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user