Compare commits

...
175 Commits
Author SHA1 Message Date
Your Name 752eddb0ba 更新 2026-05-20 16:51:12 +08:00
Your Name 1005859404 新增 2026-05-20 15:43:47 +08:00
Your Name ea6c73c063 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-20 15:40:36 +08:00
long c9ad4ae2ed Update order_list.vue
增加 用法计量 数据权限
2026-05-20 14:29:22 +08:00
Your Name abf8026d45 更新 2026-05-20 10:15:26 +08:00
long 26b442b5da debug
完善自媒体渠道修改
2026-05-19 16:39:29 +08:00
long 1cb00e9fbe add assistant_performance
新增医助个人业绩
执行:add_assistant_performance_menu.sql
2026-05-19 14:41:14 +08:00
Your Name 9208e7eaaa 更新 2026-05-19 11:40:56 +08:00
Your Name a1392f9491 更新 2026-05-19 11:18:23 +08:00
Your Name 2ea0e1d54b 更新 2026-05-19 11:11:37 +08:00
Your Name d96fa6f4a1 更新 2026-05-19 10:51:44 +08:00
Your Name 0fb51e12d1 更新 2026-05-19 10:18:00 +08:00
Your Name 4f9494db0b Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:12:23 +08:00
Your Name 4e87b080a0 更新 2026-05-18 18:12:04 +08:00
long 2f3038aba1 Merge branch '20260518-long' 2026-05-18 18:08:46 +08:00
long 131fb7d9c7 Update edit.vue
增加:账户消耗列表-编辑可以修改自媒体
2026-05-18 18:08:35 +08:00
Your Name 762a724204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:02:15 +08:00
long 8090e0aab3 Merge branch '20260518-long' 2026-05-18 17:43:25 +08:00
long 9df6330d41 update
修改未服务天数判断条件
2026-05-18 17:42:24 +08:00
Your Name fd63e23eaf 更新 2026-05-18 16:56:00 +08:00
gr c1143f4629 geng
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-05-18 16:07:03 +08:00
Your Name 149fdffd49 更新 2026-05-18 15:59:22 +08:00
Your Name 439d5a3ec9 更新 2026-05-18 15:57:09 +08:00
long 9e24d49210 Merge branch '20260518-long' 2026-05-18 14:24:52 +08:00
long 90ac43fc82 update
更新自媒体渠道标签
2026-05-18 14:24:41 +08:00
gr 8b6b709987 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 11:36:21 +08:00
Your Name fb6ca9ce85 更新 2026-05-18 11:23:01 +08:00
Your Name 08290b329e 更新 2026-05-18 11:20:24 +08:00
long 1d66f84bd3 Update PatientOrderList.vue
业务订单增加:服务套餐
2026-05-18 10:16:31 +08:00
Your Name a2d9c1a03f Merge branch 'chufan-2026-5-16' 2026-05-18 09:43:48 +08:00
Your Name d79db88349 更新 2026-05-18 09:42:11 +08:00
Your Name 9f5fb650af 新增功能 2026-05-16 16:08:54 +08:00
Your Name 15b4339c90 更新 2026-05-16 16:05:29 +08:00
gr 5ce331394d Merge branch 'master' of https://gitee.com/v_cms/zyt
你好
2026-05-14 16:27:49 +08:00
Your Name 7ba9dabdb4 更新 2026-05-14 16:26:27 +08:00
Your Name 611f3dcd5c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 16:23:52 +08:00
Your Name d3388b160e 更新 2026-05-14 16:23:29 +08:00
long f83d4c06cb Merge branch 'long-20260514' 2026-05-14 16:22:38 +08:00
long 4941ea3e21 Update DailyMatrix.vue
更新血糖折线图显示
2026-05-14 15:54:30 +08:00
Your Name e35696e153 更新 2026-05-14 15:34:28 +08:00
gr 800c6af7f1 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:31:40 +08:00
Your Name e1df584ed5 更新 2026-05-14 11:25:48 +08:00
gr 876ef0f8a0 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:02:29 +08:00
Your Name 928f75e016 更新 2026-05-14 11:00:56 +08:00
Your Name 37fa160c24 更新 2026-05-13 17:23:29 +08:00
Your Name 0eaacbb4ce 更新 2026-05-13 16:54:54 +08:00
Your Name 1e8b5c4646 修复bug 2026-05-13 09:49:47 +08:00
gr fdcfa30810 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 18:15:10 +08:00
Your Name fabaa84373 新增 2026-05-12 18:14:20 +08:00
Your Name da017bdf20 更新 2026-05-12 18:10:40 +08:00
Your Name 5d94ffab3e 更新 2026-05-12 17:58:54 +08:00
Your Name 796ca12fb8 更新 2026-05-12 17:26:11 +08:00
gr 4caa98af3a Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 11:27:37 +08:00
Your Name c644f2554f 更新 2026-05-12 11:26:51 +08:00
gr bb87e4fb4c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 10:59:56 +08:00
Your Name e83dac756d 新增 2026-05-12 10:59:03 +08:00
Your Name 0c1709d589 Merge branch 'master' into yeji-5-11 2026-05-12 10:43:04 +08:00
Your Name a3892001d3 更新 2026-05-12 10:42:14 +08:00
gr 039105580c Mergt branch 'master' of https://gitee.com/v_cms/zyt 2026-05-11 17:49:51 +08:00
gr f47f431bdf 更新 2026-05-11 17:49:38 +08:00
Your Name 7add364e20 更新 2026-05-11 17:48:47 +08:00
Your Name 0ebea74fde 更新 2026-05-11 17:46:29 +08:00
Your Name 1156787327 更新 2026-05-11 16:34:44 +08:00
Your Name 15cf6324f6 更新 2026-05-11 16:10:06 +08:00
long 1338b8735c Merge branch '20260511-long' 2026-05-11 15:06:07 +08:00
long 82f229adf2 Update 2026_05_11_diagnosis_assign_read.sql 2026-05-11 15:06:01 +08:00
long 048e43a662 Merge branch '20260511-long' 2026-05-11 15:04:33 +08:00
long d8d4da197e 新指派医助提示 2026-05-11 15:04:24 +08:00
gr a0325a8fb1 更新 2026-05-11 12:35:46 +08:00
Your Name 091db50841 更新 2026-05-11 12:32:25 +08:00
long 1774a87dd7 update 2026-05-11 11:41:42 +08:00
Your Name 62d15efbaf 更新 2026-05-11 11:36:26 +08:00
long c468c57cda 关联支付单豁免 2026-05-11 11:25:03 +08:00
Your Name bacdd6386f 新增 2026-05-09 18:36:46 +08:00
long ea290c4c7b Merge branch 'long-0509' 2026-05-09 18:32:23 +08:00
long 31b4afdcb7 Update PrescriptionLogic.php 2026-05-09 18:32:16 +08:00
long 379d2ef404 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 18:07:38 +08:00
long 5cdb7ebb9e Merge branch 'long-0509' 2026-05-09 18:07:37 +08:00
long 496a2b870b 业务订单优化 2026-05-09 18:07:30 +08:00
Your Name 471bdf12b7 更新 2026-05-09 17:45:04 +08:00
long 8fe4683e64 Revert "1"
This reverts commit 0a37335f3c.
2026-05-09 17:35:33 +08:00
long c34409edde Merge branch 'long-0509' 2026-05-09 17:14:13 +08:00
long 0a37335f3c 1 2026-05-09 17:14:03 +08:00
Your Name 985916dea7 修复bug 2026-05-09 17:04:50 +08:00
Your Name 3330613254 更新 2026-05-09 15:57:12 +08:00
Your Name 3f12829204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 11:35:21 +08:00
Your Name f28500e449 更新 2026-05-09 11:35:09 +08:00
long fd9053a290 患者列表优化 2026-05-09 11:30:04 +08:00
Your Name dc1c5d5b17 更新 2026-05-08 18:32:39 +08:00
Your Name 1297d9f510 更新 2026-05-08 17:26:51 +08:00
Your Name daaf6c4a14 Merge branch 'master' into chufang-5-8 2026-05-08 17:13:44 +08:00
long 68e2e0b7d7 Revert "出丸 计算公式修改" 2026-05-08 17:11:39 +08:00
long c78ba50331 Revert "出丸 计算公式修改"
This reverts commit ee1b140902.
2026-05-08 17:02:31 +08:00
Your Name 0026a92697 更新 2026-05-08 16:51:36 +08:00
long ee1b140902 出丸 计算公式修改 2026-05-08 16:51:25 +08:00
long f81a3b5d94 Merge branch 'long-0507' 2026-05-08 16:27:41 +08:00
long 8719cdd6d6 update 2026-05-08 16:27:31 +08:00
long 33fb055bfe Update NoteTimeline.vue
修复图片预览层级
2026-05-08 16:07:57 +08:00
long 14735af0b3 订单修改金额 2026-05-08 16:07:39 +08:00
Your Name 623bda2ecb 更新 2026-05-08 15:20:02 +08:00
Your Name dfbb6d82b3 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-08 14:13:46 +08:00
Your Name 7473a3e03d 更新 2026-05-08 14:13:19 +08:00
long eb9b31f8f5 Merge branch 'long-0507' 2026-05-08 13:57:17 +08:00
long f20a45a1ad Update index.vue 2026-05-08 13:57:09 +08:00
long 0f66d1438f Merge branch 'long-0507' 2026-05-08 10:55:21 +08:00
Your Name 43110e015d 修复bug 2026-05-08 10:49:32 +08:00
long 7d7a4aa4d2 oss 直传
分片上传
2026-05-08 10:49:16 +08:00
Your Name 8c640abb26 更新 2026-05-07 18:35:35 +08:00
Your Name ffabe78942 更新 2026-05-07 18:34:06 +08:00
long 81fcef2156 Merge branch 'long-0507' 2026-05-07 18:33:40 +08:00
long 4dc9ec1878 debug 2026-05-07 18:32:47 +08:00
Your Name 3f05a610b3 新增 2026-05-07 18:31:52 +08:00
Your Name 6c997559a2 更新 2026-05-07 18:21:00 +08:00
Your Name d39b1ed7ee 更新 2026-05-07 17:42:00 +08:00
long 0e81fb5037 Merge branch 'long-0507' 2026-05-07 17:36:11 +08:00
long 36847ea70c 定时拉取聊天记录 2026-05-07 17:36:00 +08:00
Your Name 9b40ebb8b7 更新 2026-05-07 16:53:32 +08:00
Your Name 9948bc28df Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-07 16:42:49 +08:00
Your Name a9474356ff 更新 2026-05-07 16:42:29 +08:00
long 4e23c4bf79 Merge branch 'long-0507' 2026-05-07 16:29:41 +08:00
long b6e6394e51 1 2026-05-07 16:29:31 +08:00
Your Name 576ef8e033 更新 2026-05-07 15:49:55 +08:00
Your Name a52380aa63 Merge branch 'master' into tongji-99 2026-05-07 15:43:49 +08:00
Your Name 49e7169630 更新 2026-05-07 15:27:57 +08:00
long d53f492d2a Merge branch 'long-0507' 2026-05-07 15:04:23 +08:00
long 3d3abff842 1 2026-05-07 15:04:16 +08:00
long 90ac63c905 Merge branch 'long-0507' 2026-05-07 14:46:29 +08:00
long 320d3783ac 日常记录
归总 血糖、饮食、运动、备注、代办
2026-05-07 14:46:12 +08:00
long 5966049020 Merge branch 'long-0507' 2026-05-07 12:44:54 +08:00
long 755491c263 Update ConversionLogic.php 2026-05-07 12:44:43 +08:00
long 79203d948e Update ConversionLogic.php 2026-05-07 12:16:50 +08:00
long 01d56f3b4f Merge branch 'long-0507' 2026-05-07 11:59:45 +08:00
long 477fcf6180 增加开方-用量 袋数 2026-05-07 11:59:15 +08:00
Your Name 3014a99db5 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-07 11:50:58 +08:00
Your Name c4dd196e72 更新 2026-05-07 11:50:48 +08:00
long 581c74f11a Merge branch 'history-order' 2026-05-07 10:50:42 +08:00
long 7a2f285442 1 2026-05-07 10:50:37 +08:00
long 375f54263a Merge branch 'history-order' 2026-05-07 10:48:31 +08:00
long ed4a4c4960 业务订单 2026-05-07 10:48:21 +08:00
Your Name 9d405ebed9 更新 2026-05-06 18:22:24 +08:00
Your Name a7fd55aa1c 更新 2026-05-06 18:17:37 +08:00
Your Name c389fea73c 更新 2026-05-06 14:43:17 +08:00
Your Name 05e8f4ddd7 更新
Please enter the commit message for your changes. Lines starting
2026-05-06 14:21:52 +08:00
Your Name da9a493e8d 更新 2026-05-06 13:53:09 +08:00
Your Name 64ab3197ec 更新 2026-05-06 12:19:46 +08:00
Your Name dddc4d202a 更新 2026-05-06 11:17:31 +08:00
Your Name 71180d4fd8 更新 2026-05-06 10:25:41 +08:00
Your Name 1942387050 更新 2026-05-06 10:16:52 +08:00
Your Name 77c1fc908d 更新 2026-05-06 09:05:29 +08:00
Your Name ef4800e82a 更新 2026-05-06 09:03:08 +08:00
Your Name 815ea9e025 更新 2026-05-05 18:27:58 +08:00
Your Name f7ce3d18d8 Merge branch 'master' into cs 2026-05-05 18:26:03 +08:00
Your Name de78f3b218 更新 2026-05-05 18:25:14 +08:00
long 671c7f16bd Merge branch 'fix-diagnosis-20250505' 2026-05-05 18:07:04 +08:00
long 30b5a13f3a update 2026-05-05 18:06:42 +08:00
long c830008dfc 跟踪备注 2026-05-05 17:52:35 +08:00
long 919d0f856e 1 2026-05-05 16:55:58 +08:00
long 49d0fed14e 1 2026-05-05 16:16:55 +08:00
long 19bfeef98f 1 2026-05-05 15:02:04 +08:00
Your Name cd51f55701 Merge branch 'master' into cs 2026-05-05 14:52:27 +08:00
Your Name 17cb0e8319 更新 2026-05-05 14:51:49 +08:00
Your Name 3ef10846b4 更新 2026-05-05 14:51:35 +08:00
Your Name 40eec808ef 更新 2026-05-05 14:34:39 +08:00
Your Name 03db6d2677 更新 2026-05-05 13:41:37 +08:00
Your Name c03e7051d7 Merge branch 'master' into cs 2026-05-05 13:30:23 +08:00
Your Name d6e105f912 更新 2026-05-05 13:28:45 +08:00
long 0da27ed745 代办任务 2026-05-05 12:42:09 +08:00
long 1d6a2b9cbf Update ConversionLogic.php 2026-05-04 17:37:14 +08:00
long 28fbbd28b7 增加数据权限
增加调试登录
2026-05-04 15:07:44 +08:00
long 22b8b38b5d 1 2026-05-04 13:18:43 +08:00
Your Name 81b2808eaf 更新 2026-04-30 21:53:05 +08:00
Your Name 25eb3340de 更新 2026-04-30 21:43:48 +08:00
Your Name c9150ca4b4 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-04-30 21:43:20 +08:00
long b9165f6b1b 更新权限按钮 2026-04-30 18:06:40 +08:00
long e0ab9ebc4b Update .gitignore 2026-04-30 17:40:30 +08:00
6086 changed files with 41397 additions and 2386 deletions
+6 -1
View File
@@ -11,7 +11,12 @@
"Bash(php think *)",
"Bash(python3 -c ' *)",
"Bash(python3 *)",
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)",
"Bash(npx eslint *)",
"Bash(git *)",
"Bash(tail -c 100000 /Users/long/.claude/projects/-Users-long-Work-zyt/7be9f770-afc3-497d-87a3-2ffa1fbe3fd1.jsonl)",
"Read(//tmp/**)",
"Bash(npx --no-install vue-tsc --noEmit)"
]
}
}
+8
View File
@@ -25,3 +25,11 @@ bin-release/
/.shared
/.cursor
/.codex
/.agents
/.claude
/.claude
/.claude
/.kbctx
/server/.spool
/server/.claude
/.spool
+47 -12
View File
@@ -3,20 +3,55 @@
These instructions are for AI assistants working in this project.
Use the `/trellis:start` command when starting a new session to:
- Initialize your developer identity
- Understand current project context
- Read relevant guidelines
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
Use `@/.trellis/` to learn:
- Development workflow (`workflow.md`)
- Project structure guidelines (`spec/`)
- Developer workspace (`workspace/`)
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
- `.trellis/workspace/` — per-developer journals and session traces
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
If you're using Codex, project-scoped helpers may also live in:
- `.agents/skills/` for reusable Trellis skills
- `.codex/agents/` for optional custom subagents
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
Keep this managed block so 'trellis update' can refresh the instructions.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents
## Subagents
- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups.
- On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time.
- On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing.
- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken.
- Spawn subagents automatically when:
- Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan)
- Long-running or blocking tasks where a worker can run independently
- Isolation for risky changes or checks
### Codex-only — `spawn_agent` parameters
When calling `spawn_agent`, ALWAYS pass `fork_turns="none"`. Without it the child inherits the parent transcript and sees your prior `spawn_agent(...)` records, then applies the "wait for spawned subagents" rule to itself — causing `wait_agent` self-deadlock.
```text
spawn_agent(agent_type="trellis-implement", message="...", fork_turns="none")
```
### Codex-only — multi-subagent close-loop
When `wait` returns a `completed` notification, treat it as an event signal — not as "all done". Run this loop:
1. Maintain an `expected_agents` set of dispatched sub-agent thread IDs.
2. After each `wait` update:
1. Call `list_agents` to inspect ALL live agents' status.
2. For each agent now in a terminal state:
- Verify its promised deliverable exists (e.g. `{task_dir}/research/*.md`).
- Read or summarize as needed.
- `close_agent` to release the slot.
- Remove from `expected_agents`.
3. If `expected_agents` still contains running agents → keep waiting.
4. If `expected_agents` is empty → continue main flow.
3. Never `wait` on an agent that has already reported `completed`.
4. If a `completed` agent is missing its deliverable, treat it as failed — surface that in your report instead of re-waiting.
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
<!-- TRELLIS:END -->
@@ -164,7 +164,7 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { onHide, onUnload } from '@dcloudio/uni-app'
import { onUnload } from '@dcloudio/uni-app'
import Avatar from '../Avatar/Avatar.vue';
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
@@ -337,6 +337,8 @@ onUnload(() => {
const callRole = callParticipantInfo?.value?.selfInfo?.role;
if (callStatus === CallStatus.IDLE) return;
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
if (callStatus === CallStatus.CONNECTED) return;
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
hangup();
@@ -344,9 +346,6 @@ onUnload(() => {
reject();
}
}
if (callStatus === CallStatus.CONNECTED) {
hangup();
}
});
</script>
@@ -277,6 +277,8 @@ const { proxy } = getCurrentInstance()
let unsubscribeWxAppShow = null;
let allowChatForegroundRelogin = false;
let foregroundReloginTimer = null;
/** 避免 onUnload 重复上报离开 */
let patientLeaveNotified = false;
const CONFIRMATION_MESSAGE = `我已确认以下信息:
1、本人确认已在线下确诊;
@@ -611,6 +613,7 @@ const { proxy } = getCurrentInstance()
}
onLoad(async (options) => {
try {
patientLeaveNotified = false;
console.log('进入onMounted:', options);
const app = getApp();
const userID = options.userID|| app.globalData.userID;
@@ -790,6 +793,11 @@ const { proxy } = getCurrentInstance()
});
onUnload(() => {
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
patientLeaveNotified = true;
void notifyDoctorOnChatClose();
}
allowChatForegroundRelogin = false;
// #ifdef MP-WEIXIN
if (typeof unsubscribeWxAppShow === 'function') {
@@ -855,6 +863,47 @@ const { proxy } = getCurrentInstance()
console.warn('后端通知失败:', e);
}
}
/** 离开会话页时通知医生:IM 自定义消息 + 后端全局事件 */
async function notifyDoctorOnChatClose() {
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
const doctorId = targetUserID.replace('doctor_', '');
const userData = uni.getStorageSync('userData') || {};
const patientName = userData.name || patient_data.value?.patient_name || '患者';
const patientId = (currentUserID || '').replace('patient_', '') || '';
try {
if (chat) {
const customPayload = {
businessID: 'patient_closed_chat',
patientId,
patientName,
doctorId,
time: Date.now()
};
const imMsg = chat.createCustomMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { data: JSON.stringify(customPayload) }
});
await chat.sendMessage(imMsg);
}
} catch (e) {
console.warn('IM 离开通知发送失败:', e);
}
try {
await proxy.apiUrl({
url: '/api/chat/notifyClose',
method: 'POST',
data: {
doctor_id: doctorId,
patient_id: patientId,
patient_name: patientName
}
}, false);
} catch (e) {
console.warn('后端离开通知失败:', e);
}
}
function applyDoctorEnteredSignalFromRawMessage(msg) {
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
@@ -956,6 +1005,9 @@ const { proxy } = getCurrentInstance()
if (customData.businessID === 'patient_opened_chat') {
return null; // 返回null表示不显示此消息
}
if (customData.businessID === 'patient_closed_chat') {
return null;
}
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
if (customData.businessID === 'doctor_entered_consult_room') {
return null;
+2 -1
View File
@@ -1,4 +1,5 @@
NODE_ENV = 'development'
# Base API
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
VITE_APP_BASE_URL=''
+52
View File
@@ -19,10 +19,12 @@
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"cos-js-sdk-v5": "^1.10.1",
"css-color-function": "^1.3.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
@@ -6064,6 +6066,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cos-js-sdk-v5": {
"version": "1.10.1",
"resolved": "https://registry.npmmirror.com/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz",
"integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"fast-xml-parser": "4.5.0"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -7580,6 +7592,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -8231,6 +8265,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
@@ -12299,6 +12339,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+2
View File
@@ -21,10 +21,12 @@
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"cos-js-sdk-v5": "^1.10.1",
"css-color-function": "^1.3.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
+9731
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
return request.get({ url: '/doctor.appointment/lists', params })
}
/** 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) */
export function appointmentAdminEdit(params: any) {
return request.post({ url: '/doctor.appointment/edit', params })
}
/** 批量修改挂号渠道来源(与 edit 共用 doctor.appointment/edit 权限) */
export function appointmentBatchEditChannel(params: any) {
return request.post({ url: '/doctor.appointment/batchEditChannel', params })
}
// 获取挂号详情
export function appointmentDetail(params: any) {
return request.get({ url: '/doctor.appointment/detail', params })
+85 -36
View File
@@ -4,48 +4,50 @@ import useAppStore from '@/stores/modules/app'
import { getToken } from '@/utils/auth'
import request from '@/utils/request'
export type MaterialUploadType = 'image' | 'video' | 'file'
/** 本地上传素材(与 `components/upload` 相同:POST /upload/{type},字段 file + cid */
export async function uploadMaterialFile(
file: File,
type: MaterialUploadType = 'image',
cid: string | number = 0
) {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file)
formData.append('cid', String(cid))
const url = `${config.baseUrl}${config.urlPrefix}/upload/${type}`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
}
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file, filename)
formData.append('cid', '0')
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
const fileObj =
file instanceof File
? file
: new File([file], filename, { type: file.type || 'image/jpeg' })
return uploadMaterialFile(fileObj, 'image', 0)
}
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file, filename)
formData.append('cid', '0')
const url = `${config.baseUrl}${config.urlPrefix}/upload/video`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
const fileObj =
file instanceof File
? file
: new File([file], filename, { type: file.type || 'video/webm' })
return uploadMaterialFile(fileObj, 'video', 0)
}
export function fileCateAdd(params: Record<string, any>) {
@@ -88,3 +90,50 @@ export function fileMove(params: Record<string, any>) {
export function fileRename(params: { id: number; name: string }) {
return request.post({ url: '/file/rename', params })
}
/** 浏览器直传 OSS - 凭证类型 */
export interface OssCredentialsResponse {
provider: string
fallback: boolean
bucket?: string
region?: string
host?: string
cdn_domain?: string
key_prefix?: string
max_size?: number
duration?: number
expired_time?: number
start_time?: number
credentials?: {
tmpSecretId: string
tmpSecretKey: string
sessionToken: string
}
}
/** 申请 STS 临时凭证 */
export function getOssCredentials(params: { type: 'video' }) {
return request.post({
url: '/upload/ossCredentials',
params
}) as Promise<OssCredentialsResponse>
}
/** 直传完成回执:写 file 表 + HEAD 校验 */
export function confirmOssUpload(params: {
type: 'video'
key: string
name: string
size: number
content_type: string
cid?: number
}) {
return request.post({ url: '/upload/ossConfirm', params }) as Promise<{
id: number
cid: number
type: number
name: string
uri: string
url: string
}>
}
+5
View File
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/order.order/assignAssistant', params })
}
/** 设置/取消支付单豁免权 */
export function orderSetExempt(params: { id: number; is_exempt: 0 | 1 }) {
return request.post({ url: '/order.order/setExempt', params })
}
+3 -3
View File
@@ -25,7 +25,7 @@ export function deptDetail(params: any) {
return request.get({ url: '/dept.dept/detail', params })
}
// 部门列表全部
export function deptAll() {
return request.get({ url: '/dept.dept/all' })
// 部门列表全部apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致)
export function deptAll(params?: { apply_data_scope?: number }) {
return request.get({ url: '/dept.dept/all', params })
}
+146
View File
@@ -42,3 +42,149 @@ export function yejiStatsLeaderboard(params: {
}) {
return request.get({ url: '/stats.yejiStats/leaderboard', params })
}
/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */
export function yejiStatsUnassignedBreakdown(params: {
start_date: string
end_date: string
dept_ids?: string
channel_code?: string
tag_id?: string
}) {
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
}
/** 部门行「进线数据」逐条明细(与看板同口径) */
export function yejiStatsLeadLines(params: {
start_date: string
end_date: string
dept_id: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/leadLines', params })
}
/** 医助排行榜「预约诊单」逐条挂号明细;业绩主表部门行传 dept_id(可与 assistant_id 二选一) */
export function yejiStatsAppointmentLines(params: {
start_date: string
end_date: string
assistant_id?: number
dept_id?: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/appointmentLines', params })
}
/** 二中心复诊:部门行下钻医助 × 业务订单笔数 */
export function yejiStatsRevisitBreakdown(params: {
start_date: string
end_date: string
dept_id: number
revisit_slot: number
dept_ids?: string
channel_code?: string
tag_id?: string
}) {
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
channel_code?: string
}) {
return request.get({ url: '/stats.doctorDailyStats/overview', params })
}
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
/** 提成结算业绩(独立于业绩看板 yejiStats) */
export function commissionSettlementOverview(params: {
settlement_month: string
/** 与 tcm.prescriptionOrder/lists 同源:create_time between */
start_time?: string
end_time?: string
/** 默认 3=履约完成,与列表 fulfillment_status 一致 */
fulfillment_status?: number
/** 传 1 时仅统计 is_system_auto=1;显式时段下默认不传(含手动) */
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get(
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementDeptOptions() {
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
}
export function commissionSettlementChannelOptions() {
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
}
/** 提成核对:订单明细分页 bucket: 空|current|deferredappt_channel_value 可与 assistant_id/doctor_id 组合(0=未匹配挂号渠道) */
export function commissionSettlementOrderLines(params: {
settlement_month: string
start_time?: string
end_time?: string
fulfillment_status?: number
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
page?: number
page_size?: number
bucket?: string
assistant_id?: number
doctor_id?: number
appt_channel_value?: number
}) {
return request.get(
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementConfirmStatus(params: {
settlement_month: string
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
}
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
}
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
return request.post(
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
/** 撤回「确定本期业绩」:清除顺延结转,状态变为可再次核对/确定 */
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
start_date?: string
end_date?: string
}) {
return request.get({ url: '/stats.assistantPerformance/overview', params })
}
+126 -6
View File
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
return request.get({ url: '/tcm.diagnosis/lists', params })
}
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
export function diagnosisReadonlyDetail(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
}
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
export function diagnosisTrackingWindow(params: {
id: number
start_date?: string
end_date?: string
}) {
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
}
/** 新增跟踪备注(按天合并追加,仅文字) */
export function diagnosisAddTrackingNote(params: {
diagnosis_id: number
tracking_content: string
}) {
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
}
/** 拉取跟踪备注列表(note_date DESC */
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
}
// 医助理诊单统计(按部门、按人)
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
export function assistantDiagnosisStats(params?: {
start_time?: string
end_time?: string
days?: number
}) {
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
}
@@ -198,14 +229,25 @@ export function startCloudRecording(params: { diagnosis_id: number }) {
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
}
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消 */
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id */
export function attachLocalCallRecording(params: {
diagnosis_id: number
file_url: string
call_record_id?: number
}) {
return request.post(
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
export function createManualCallRecord(params: { diagnosis_id: number }) {
return request.post(
{ url: '/tcm.diagnosis/createManualCallRecord', params },
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
return request.get({ url: '/tcm.diagnosis/watchCall', params })
@@ -230,6 +272,11 @@ export function getImChatMessages(params: { diagnosis_id: number; only_archived?
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
}
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
export function triggerImChatSync(data: { diagnosis_id: number }) {
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
}
// 获取企业微信聊天记录
export function getWechatChatRecords(params: any) {
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
@@ -272,6 +319,16 @@ export function prescriptionEdit(params: any) {
return request.post({ url: '/tcm.prescription/edit', params })
}
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
export function prescriptionPatchPatient(params: {
id: number
patient_name: string
phone: string
gender: number
}) {
return request.post({ url: '/tcm.prescription/patchPatient', params })
}
// 删除处方
export function prescriptionDelete(params: { id: number }) {
return request.post({ url: '/tcm.prescription/delete', params })
@@ -298,7 +355,11 @@ export function prescriptionVoid(params: { id: number }) {
}
/** 处方审核:action approve | reject(驳回同时作废处方) */
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
export function prescriptionAudit(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/tcm.prescription/audit', params })
}
@@ -312,6 +373,11 @@ export function prescriptionOrderLists(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
}
/** 处方业务订单导出(export=1 预估条数,export=2 下载 Excel */
export function prescriptionOrderExport(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/export', params })
}
/**
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
@@ -396,6 +462,7 @@ export function prescriptionOrderAddPayOrder(params: {
order_type: number
pay_amount: number
pay_remark?: string
completion_request?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
}
@@ -403,11 +470,18 @@ export function prescriptionOrderAddPayOrder(params: {
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
export function prescriptionOrderLinkPayOrder(params: {
id: number
pay_order_id: number
pay_order_id?: number
pay_order_ids?: number[]
completion_request?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
}
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
@@ -419,7 +493,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number
dose_count?: number
medication_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
}
@@ -428,6 +506,16 @@ export function prescriptionOrderLogs(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
}
/** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
}
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
}
// ========== 处方库 ==========
// 处方库列表
@@ -454,3 +542,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
export function prescriptionLibraryDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
}
// ========== 诊单待办事项(T5 ==========
/** 待办列表(按 diagnosis_id */
export function diagnosisTodoLists(params: {
diagnosis_id: number
page_no?: number
page_size?: number
status?: number
creator_id?: number
}) {
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
}
/** 新增待办(remind_time 为 unix 秒级时间戳) */
export function diagnosisTodoAdd(params: {
diagnosis_id: number
content: string
remind_time: number
}) {
return request.post({ url: '/tcm.diagnosisTodo/add', params })
}
/** 取消待办(仅 status=0 / 创建人或超管) */
export function diagnosisTodoCancel(params: { id: number }) {
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
}
/** 待办详情 */
export function diagnosisTodoDetail(params: { id: number }) {
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
}
@@ -9,17 +9,35 @@
v-for="item in notifications"
:key="item.id"
class="chat-notify-card"
:class="{ 'chat-notify-card-complete': item.type === 'consultation_complete' }"
:class="{
'chat-notify-card-complete': item.type === 'consultation_complete',
'chat-notify-card-left': item.type === 'patient_left_chat'
}"
@click="handleClick(item)"
>
<div class="chat-notify-icon" :class="{ 'chat-notify-icon-complete': item.type === 'consultation_complete' }">
<div
class="chat-notify-icon"
:class="{
'chat-notify-icon-complete': item.type === 'consultation_complete',
'chat-notify-icon-left': item.type === 'patient_left_chat'
}"
>
<el-icon :size="22">
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
<CircleCheck v-else />
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
<Right v-else />
</el-icon>
</div>
<div class="chat-notify-body">
<div class="chat-notify-title">{{ item.type === 'consultation_complete' ? '面诊结束' : '患者打开会话' }}</div>
<div class="chat-notify-title">
{{
item.type === 'consultation_complete'
? '面诊结束'
: item.type === 'patient_left_chat'
? '患者离开会话'
: '患者打开会话'
}}
</div>
<div class="chat-notify-desc">
<template v-if="item.type === 'consultation_complete'">
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
@@ -27,6 +45,10 @@
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
完成请及时跟进
</template>
<template v-else-if="item.type === 'patient_left_chat'">
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
已离开问诊会话页面
</template>
<template v-else>
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
已打开与您的会话请及时查看
@@ -45,14 +67,14 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ChatDotRound, CircleCheck, Close } from '@element-plus/icons-vue'
import { ChatDotRound, CircleCheck, Close, Right } from '@element-plus/icons-vue'
import { getChatNotifications } from '@/api/chat'
const router = useRouter()
export interface ChatNotifyItem {
id: string
type?: 'patient_opened_chat' | 'consultation_complete'
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
doctor_id?: number
assistant_id?: number
patient_id?: string
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.chat-notify-card-left {
border-color: rgba(148, 163, 184, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(148, 163, 184, 0.08);
}
.chat-notify-icon-left {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
}
.chat-notify-body {
flex: 1;
min-width: 0;
@@ -13,6 +13,7 @@
<el-button>导出</el-button>
</template>
<div>
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
<el-form-item label="数据量:">
预计导出{{ exportData.count }}条数据 {{ exportData.sum_page }}每页{{
@@ -79,6 +80,11 @@ const props = defineProps({
fetchFun: {
type: Function,
required: true
},
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
exportHint: {
type: String,
default: ''
}
})
const popupRef = shallowRef<InstanceType<typeof Popup>>()
+174 -3
View File
@@ -9,7 +9,17 @@
@close="handleClose"
>
<template v-if="!hiddenUpload" #trigger>
<div class="material-select__trigger clearfix" @click.stop>
<div
class="material-select__trigger clearfix"
:class="{ 'is-drop-hover': dropActive }"
tabindex="0"
@click.stop
@dragenter.prevent.stop="onTriggerDragEnter"
@dragleave.prevent.stop="onTriggerDragLeave"
@dragover.prevent.stop="onTriggerDragOver"
@drop.prevent.stop="onTriggerDrop"
@paste="onTriggerPaste"
>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
@@ -82,8 +92,10 @@
import { useThrottleFn } from '@vueuse/core'
import Draggable from 'vuedraggable'
import { uploadMaterialFile } from '@/api/file'
import Popup from '@/components/popup/index.vue'
import useAppStore from '@/stores/modules/app'
import feedback from '@/utils/feedback'
import FileItem from './file.vue'
import Material from './index.vue'
@@ -149,6 +161,11 @@ export default defineComponent({
excludeDomain: {
type: Boolean,
default: false
},
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
uploadCid: {
type: [Number, String],
default: 0
}
},
@@ -162,8 +179,149 @@ export default defineComponent({
const select = ref<any[]>([])
const isAdd = ref(true)
const currentIndex = ref(-1)
const dragDepth = ref(0)
const externalUploading = ref(false)
const { disabled, limit, modelValue } = toRefs(props)
const { getImageUrl } = useAppStore()
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
const allowDropPaste = computed(
() =>
!props.hiddenUpload &&
(props.type === 'image' || props.type === 'video' || props.type === 'file')
)
const dropActive = computed(() => dragDepth.value > 0)
function hasFileDrag(dt: DataTransfer | null) {
if (!dt) return false
const types = Array.from(dt.types as unknown as string[])
return types.includes('Files') || types.includes('application/x-moz-file')
}
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
if (uploadType === 'file') return true
const ext = fileName.includes('.')
? fileName.split('.').pop()?.toLowerCase() || ''
: ''
if (uploadType === 'image') {
if (!ext) return true
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
}
if (uploadType === 'video') {
if (!ext) return true
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
ext
)
}
return true
}
function toRelativeStoredUrl(fullUrl: string): string {
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
const oss = String(appStore.config?.oss_domain || '')
.trim()
.replace(/\/$/, '')
if (oss && fullUrl.startsWith(oss)) {
return fullUrl.slice(oss.length).replace(/^\//, '')
}
const origin =
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
if (origin && fullUrl.startsWith(origin)) {
return fullUrl.slice(origin.length).replace(/^\//, '')
}
try {
return new URL(fullUrl).pathname.replace(/^\//, '')
} catch {
return fullUrl
}
}
function clipboardFiles(e: ClipboardEvent): File[] {
const uploadType = props.type
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
isAcceptedExtension(f.name, uploadType)
)
if (fromFiles.length) return fromFiles
if (uploadType !== 'image') return []
const items = e.clipboardData?.items
if (!items) return []
const out: File[] = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) out.push(f)
}
}
return out
}
async function runExternalUpload(files: File[]) {
if (externalUploading.value) return
const maxSlots =
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
if (!maxSlots) {
feedback.msgWarning('已达到上传数量上限')
return
}
const queue = files.slice(0, maxSlots)
if (!queue.length) return
const uploadType =
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
externalUploading.value = true
try {
const paths: string[] = []
for (const file of queue) {
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
}
fileList.value = [...fileList.value, ...paths]
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
} catch (err: any) {
feedback.msgError(err?.message || '上传失败')
} finally {
externalUploading.value = false
}
}
const onTriggerDragEnter = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (!hasFileDrag(e.dataTransfer)) return
dragDepth.value++
}
const onTriggerDragLeave = () => {
if (!allowDropPaste.value) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
const onTriggerDragOver = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
}
const onTriggerDrop = async (e: DragEvent) => {
dragDepth.value = 0
if (!allowDropPaste.value || disabled.value) return
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
isAcceptedExtension(f.name, props.type)
)
if (!files.length) return
await runExternalUpload(files)
}
const onTriggerPaste = async (e: ClipboardEvent) => {
if (!allowDropPaste.value || disabled.value) return
const files = clipboardFiles(e)
if (!files.length) return
e.preventDefault()
await runExternalUpload(files)
}
const tipsText = computed(() => {
switch (props.type) {
case 'image':
@@ -264,7 +422,13 @@ export default defineComponent({
showPreview,
handlePreview,
handleClose,
getImageUrl
getImageUrl,
dropActive,
onTriggerDragEnter,
onTriggerDragLeave,
onTriggerDragOver,
onTriggerDrop,
onTriggerPaste
}
}
})
@@ -272,6 +436,13 @@ export default defineComponent({
<style scoped lang="scss">
.material-select {
.material-select__trigger {
outline: none;
&.is-drop-hover {
border-radius: 4px;
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
}
.material-upload,
.material-preview {
position: relative;
+383 -10
View File
@@ -75,11 +75,37 @@
</el-row>
</div>
<div class="info-section">
<div class="section-title">
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
<div class="info-section info-section--rp">
<div class="rp-toolbar" role="region" aria-label="中药处方">
<div class="rp-toolbar__lead">
<span class="rp-toolbar__marker" aria-hidden="true" />
<div class="rp-toolbar__text">
<h3 class="rp-toolbar__heading">
中药处方
<span class="rp-toolbar__abbr">(RP)</span>
</h3>
<p class="rp-toolbar__meta">
<strong>{{ formData.herbs.length }}</strong> 味药材 · 可手动添加从处方库或粘贴药方批量录入药品库同名匹配
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb">
添加中药
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="CollectionTag"
@click="showLibraryDialog = true"
>
从处方库导入
</el-button>
<el-button type="primary" plain size="small" :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
</div>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -134,11 +160,11 @@
<el-select v-model="formData.prescription_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-option label="汤剂" value="汤剂" />
<el-option label="汤剂" value="汤剂" /> -->
</el-select>
</el-form-item>
</el-col>
@@ -190,6 +216,18 @@
<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="dosage_bag_count" label-width="12px">
<el-select v-model="formData.dosage_bag_count" placeholder="请选择袋数" class="!w-[120px]">
<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-select>
</el-form-item>
</el-col>
<!-- 饮片每贴出包数 -->
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
@@ -407,9 +445,9 @@
<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.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}{{ savedDosageDescText ? '' + savedDosageDescText : '' }}, {{ savedPrescription.prescription_type }}
{{ savedPrescription.usage_instruction }}
</div>
<div class="footer-dosage">
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
@@ -649,6 +687,40 @@
/>
</div>
</el-dialog>
<!-- 粘贴药方文本解析药名与剂量 -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本系统按药名 + 剂量解析药名须与药品库中名称完全一致才会录入否则跳过支持顿号/逗号/换行分隔单行可用空格分味黄芪15g
白术10g黄芪党参各10克等同剂量写法亦可识别
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
@@ -656,12 +728,13 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { CirclePlus, CollectionTag, Download, Document, EditPen } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
@@ -676,6 +749,7 @@ interface PrescriptionForm {
prescription_type: string
dosage_amount: number | undefined // 用量数值
dosage_unit: string // 用量单位 (g 或 ml)
dosage_bag_count: number // 用量袋数(浓缩水丸,1-5袋)
need_decoction: boolean // 是否代煎(仅饮片)
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
patient_name: string
@@ -739,6 +813,20 @@ const hasPrescriptionOrderBlockVoid = computed(() => {
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
/** 处方笺底部"用量/袋数"展示文案:浓缩水丸显示"一次X袋(每袋Yg)",其它类型显示"一次Yg" */
const savedDosageDescText = computed(() => {
const v = savedPrescription.value
if (!v) return ''
if (v.dosage_amount == null || v.dosage_amount === '') return ''
const amount = Number(v.dosage_amount)
const unit = v.dosage_unit || 'g'
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
return `一次${bags}袋(每袋${amount}${unit}`
}
return `一次${amount}${unit}`
})
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
@@ -751,6 +839,179 @@ const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
/** 粘贴药方导入 */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipeInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipeHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
/** 单段解析为多味(含「各」),失败返回 null */
function parseHerbToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipeText(text: string): string[] {
const t = stripRecipeHeaders(normalizeRecipeInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipeText(text)) {
const parsed = parseHerbToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipeToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
formData.herbs = resolved
} else {
formData.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNames()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
@@ -764,6 +1025,7 @@ const formData = reactive<PrescriptionForm>({
prescription_type: '浓缩水丸',
dosage_amount: 10,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
@@ -954,6 +1216,7 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.prescription_type = '浓缩水丸'
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
formData.gender_desc = formData.gender === 1 ? '男' : '女'
@@ -980,6 +1243,8 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.doctor_name = userStore.userInfo?.name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.need_decoction = false
formData.bags_per_dose = 1
savedPrescription.value = null
visible.value = true
@@ -1206,16 +1471,19 @@ watch(() => formData.prescription_type, (newType) => {
if (newType === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
} else if (newType === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
formData.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
}
@@ -1275,6 +1543,7 @@ const handleSave = async () => {
dose_unit: formData.dose_unit,
dosage_amount: formData.dosage_amount,
dosage_unit: formData.dosage_unit,
dosage_bag_count: formData.dosage_bag_count,
need_decoction: formData.need_decoction,
bags_per_dose: formData.bags_per_dose,
usage_days: formData.usage_days,
@@ -1354,12 +1623,15 @@ const handleNewPrescription = async () => {
if (formData.prescription_type === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
} else if (formData.prescription_type === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
}
formData.patient_name = saved.patient_name || ''
formData.gender = saved.gender ?? 0
@@ -1514,6 +1786,107 @@ defineExpose({
align-items: center;
}
&.info-section--rp {
padding-top: 14px;
}
.rp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: 14px;
padding: 14px 16px;
background: linear-gradient(
145deg,
var(--el-bg-color) 0%,
var(--el-fill-color-extra-light) 100%
);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.rp-toolbar:hover {
border-color: var(--el-border-color);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.07);
}
.rp-toolbar__lead {
display: flex;
align-items: stretch;
gap: 12px;
min-width: 0;
flex: 1;
}
.rp-toolbar__marker {
width: 4px;
min-height: 44px;
border-radius: 4px;
background: linear-gradient(
180deg,
var(--el-color-primary) 0%,
var(--el-color-primary-light-5) 100%
);
flex-shrink: 0;
}
.rp-toolbar__text {
min-width: 0;
}
.rp-toolbar__heading {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
line-height: 1.35;
}
.rp-toolbar__abbr {
margin-left: 4px;
font-weight: 500;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta strong {
font-weight: 600;
color: var(--el-text-color-regular);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rp-toolbar__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 960px) {
.rp-toolbar__actions {
width: 100%;
justify-content: flex-start;
}
}
.herbs-editor-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+75 -1
View File
@@ -9,6 +9,7 @@
:show-file-list="false"
:headers="headers"
:data="data"
:http-request="useDirect ? httpRequest : undefined"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-exceed="handleExceed"
@@ -41,7 +42,7 @@
</template>
<script lang="ts">
import type { ElUpload } from 'element-plus'
import type { ElUpload, UploadRequestOptions } from 'element-plus'
import { computed, defineComponent, ref, shallowRef } from 'vue'
import config from '@/config'
@@ -49,6 +50,10 @@ import { RequestCodeEnum } from '@/enums/requestEnums'
import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
DirectUploadFallbackError,
uploadVideoDirectToCos
} from '@/utils/oss-direct-upload'
export default defineComponent({
components: {},
@@ -77,6 +82,11 @@ export default defineComponent({
showProgress: {
type: Boolean,
default: false
},
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
direct: {
type: Boolean,
default: false
}
},
emits: ['change', 'error', 'success', 'allSuccess'],
@@ -92,6 +102,9 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video + direct 时才接管 http-request
const useDirect = computed(() => props.direct && props.type === 'video')
const handleProgress = () => {
visible.value = true
}
@@ -142,12 +155,73 @@ export default defineComponent({
return '*'
}
})
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
const httpRequest = async (options: UploadRequestOptions) => {
visible.value = true
try {
const data = await uploadVideoDirectToCos({
file: options.file,
type: 'video',
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
;(options as any).onProgress?.({
percent: info.percent,
loaded: info.loaded,
total: info.total
})
}
})
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
} catch (err: any) {
if (err instanceof DirectUploadFallbackError) {
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
await defaultXhrUpload(options)
return
}
;(options as any).onError?.(err instanceof Error ? err : new Error(String(err?.message ?? err)))
}
}
// 当 STS 不可用时,回退到 ElUpload 原生 XHR 行为(POST /upload/{type}
const defaultXhrUpload = (options: UploadRequestOptions) =>
new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', action.value)
Object.entries(headers.value).forEach(([k, v]) => xhr.setRequestHeader(k, String(v ?? '')))
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return
const percent = (e.loaded / e.total) * 100
;(options as any).onProgress?.({ percent, loaded: e.loaded, total: e.total })
}
xhr.onload = () => {
try {
const json = JSON.parse(xhr.responseText)
;(options as any).onSuccess?.(json)
} catch (e) {
;(options as any).onError?.(new Error('上传响应解析失败'))
}
resolve()
}
xhr.onerror = () => {
;(options as any).onError?.(new Error('网络错误'))
resolve()
}
const formData = new FormData()
formData.append('file', options.file)
Object.entries(options.data || {}).forEach(([k, v]) => formData.append(k, String(v)))
xhr.send(formData)
})
return {
uploadRefs,
action,
headers,
visible,
fileList,
useDirect,
httpRequest,
getAccept,
handleProgress,
handleSuccess,
+2
View File
@@ -3,6 +3,7 @@
//引入柱状图图表,图表后缀都为 Chart
import {
BarChart,
FunnelChart,
LineChart,
MapChart,
PictorialBarChart,
@@ -43,6 +44,7 @@ echarts.use([
AriaComponent,
ParallelComponent,
BarChart,
FunnelChart,
LineChart,
PieChart,
MapChart,
+2 -1
View File
@@ -1,6 +1,7 @@
<template>
<div class="layout-default flex h-screen w-full">
<div class="app-aside">
<!-- shrink-0避免主内容区宽表格/图表把侧栏压缩到折叠宽度 -->
<div class="app-aside shrink-0">
<layout-sidebar />
</div>
+18
View File
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/tcm/diagnosis/h5',
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
},
{
// T1+T2:患者信息详情页(隐藏路由 — 不在侧边栏出现,由诊单列表的「查看」/双击行驱动跳转)
path: '/tcm/diagnosis-readonly',
component: LAYOUT,
meta: { hidden: true },
children: [
{
path: '',
component: () => import('@/views/tcm/diagnosis/readonly.vue'),
name: 'tcmDiagnosisReadonly',
meta: {
title: '患者信息详情',
hidden: true,
activeMenu: '/tcm/diagnosis'
}
}
]
}
// {
// path: '/dev_tools',
+17 -2
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { getConfig } from '@/api/app'
import configs from '@/config'
interface AppSate {
config: Record<string, any>
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
getImageUrl(url: string) {
if (!url || typeof url !== 'string') return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
const path = url.startsWith('/') ? url : '/' + url
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
if (oss) {
return `${oss}${path}`
}
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
try {
return new URL(path, `${apiRoot}/`).href
} catch {
return `${apiRoot}${path}`
}
}
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return origin ? `${origin}${path}` : path
},
getConfig() {
return new Promise((resolve, reject) => {
+86
View File
@@ -0,0 +1,86 @@
/**
* 血糖 / 血压 阈值与异常判定工具
*
* 阈值规则(与 reception/index.vue 抽离前一致):
* - 年龄 < 50:空腹血糖 ≥ 7、餐后/其他血糖 ≥ 9 视为偏高
* - 年龄 ≥ 50:空腹血糖 ≥ 8、餐后/其他血糖 ≥ 10 视为偏高
* - 收缩压 > 140 / 舒张压 > 90 视为偏高
*
* 任何字段缺失或非数值 → 视为「不偏高」(避免误报红色 ↑)。
*/
/**
* 把任意值转成有限数字;空 / 非法 → null
*/
export function toNumeric(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const num = Number(value as any)
return Number.isFinite(num) ? num : null
}
/**
* 根据年龄返回血糖阈值;年龄缺失/非法 → null(=不判定偏高)
*/
export function getBloodSugarThresholds(
ageValue: unknown
): { fasting: number; postprandial: number } | null {
const age = toNumeric(ageValue)
if (age === null) return null
if (age < 50) return { fasting: 7, postprandial: 9 }
return { fasting: 8, postprandial: 10 }
}
export function isHighFastingBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.fasting
}
export function isHighPostprandialBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
/**
* 「其他血糖」按餐后阈值判定(与 reception 原实现一致)
*/
export function isHighOtherBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
export function isHighSystolicPressure(value: unknown): boolean {
const num = toNumeric(value)
return num !== null && num > 140
}
export function isHighDiastolicPressure(value: unknown): boolean {
const num = toNumeric(value)
return num !== null && num > 90
}
/**
* 一行血糖血压记录是否有任一压偏高
*/
export function isHighBloodPressure(row: {
systolic_pressure?: unknown
diastolic_pressure?: unknown
} | null | undefined): boolean {
if (!row) return false
return isHighSystolicPressure(row.systolic_pressure) || isHighDiastolicPressure(row.diastolic_pressure)
}
/**
* 把一行记录的血压两值格式化为 `xxx / yyy`,缺值 → `—`
*/
export function formatBp(row: {
systolic_pressure?: unknown
diastolic_pressure?: unknown
} | null | undefined): string {
const sys = row?.systolic_pressure
const dia = row?.diastolic_pressure
if (sys == null && dia == null) return '—'
return `${sys ?? '—'} / ${dia ?? '—'}`
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 病例 / 接诊页通用展示辅助
*
* - maskPhone:手机号脱敏 138****1234
* - formatGender0/1 → 女/男
* - formatPeriodmorning/afternoon → 上午/下午
* - joinArray:数组 → 顿号拼接,非数组 → toString
* - makeTextOf:工厂,传入 diag ref/getter,返回闭包后的 textOf 函数
*
* 翻译规则(textOf):诊单字典字段由后端 `AppointmentLogic::enrichDiagnosisLabels`
* 预翻译为 `<field>_text`,前端优先读 `*_text`;找不到时回退原值。
*/
import type { Ref } from 'vue'
export function maskPhone(phone?: string | null): string {
if (!phone) return '—'
if (phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
export function formatGender(g?: number | null): string {
if (g === 1) return '男'
if (g === 0) return '女'
return '—'
}
export function formatPeriod(period?: string | null): string {
if (period === 'morning') return '上午'
if (period === 'afternoon') return '下午'
return '全天'
}
export function joinArray(arr: unknown): string {
if (!arr) return ''
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
return String(arr)
}
/**
* 创建闭包了具体 diag 的 textOf 取值器。
* 同时支持 `Ref` / `() => any` / 普通对象,避免在调用方处反复样板。
*/
export function makeTextOf(diagSource: Ref<any> | (() => any) | Record<string, any>) {
const resolve = (): Record<string, any> => {
if (typeof diagSource === 'function') {
return (diagSource as () => any)() || {}
}
if (diagSource && typeof diagSource === 'object' && 'value' in diagSource) {
return ((diagSource as Ref<any>).value ?? {}) as Record<string, any>
}
return (diagSource as Record<string, any>) || {}
}
return function textOf(field: string): string {
const d = resolve()
const t = d[field + '_text']
if (t !== undefined && t !== null && t !== '') return String(t)
const raw = d[field]
if (raw === undefined || raw === null || raw === '') return ''
return Array.isArray(raw) ? raw.join('、') : String(raw)
}
}
@@ -99,6 +99,15 @@ export function parseImBusinessPayload(raw: string): FriendlyParse | null {
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'patient_closed_chat') {
const parts: string[] = []
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID${String(o.patientId)}`)
const t = formatBizTime(o.time)
if (t) parts.push(t)
return { main: '患者离开聊天页', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'user_typing_status') {
return { main: '对方正在输入…', tag: '状态' }
}
+142
View File
@@ -0,0 +1,142 @@
import COS from 'cos-js-sdk-v5'
import {
confirmOssUpload,
getOssCredentials,
type OssCredentialsResponse
} from '@/api/file'
export type DirectUploadType = 'video'
export interface DirectUploadProgress {
/** 0-100 */
percent: number
loaded: number
total: number
speed: number
}
export interface DirectUploadResult {
id: number
cid: number
type: number
name: string
uri: string
url: string
}
export interface DirectUploadOptions {
file: File
type: DirectUploadType
cid?: number
onProgress?: (progress: DirectUploadProgress) => void
/** 返回 false 时由调用方自行降级到老链路 */
onFallback?: (provider: string) => boolean | void
}
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
const ASYNC_LIMIT = 3
function buildKey(prefix: string, file: File): string {
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 10)
return `${prefix}${ts}-${rand}.${ext}`
}
/**
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
*/
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
if (credentials.fallback) {
const handled = options.onFallback?.(credentials.provider) ?? false
if (!handled) {
throw new DirectUploadFallbackError(credentials.provider)
}
// 调用方自行处理,本函数早退
throw new DirectUploadFallbackError(credentials.provider)
}
if (!credentials.credentials || !credentials.bucket || !credentials.region || !credentials.key_prefix) {
throw new Error('STS 凭证返回缺失字段')
}
if (credentials.max_size && options.file.size > credentials.max_size) {
const mb = Math.round(credentials.max_size / 1024 / 1024)
throw new Error(`视频体积超出上限(${mb}MB`)
}
const cred = credentials.credentials
const expiredTime = credentials.expired_time ?? 0
const startTime = credentials.start_time ?? Math.floor(Date.now() / 1000)
const cos = new COS({
getAuthorization(_options, callback) {
callback({
TmpSecretId: cred.tmpSecretId,
TmpSecretKey: cred.tmpSecretKey,
SecurityToken: cred.sessionToken,
StartTime: startTime,
ExpiredTime: expiredTime
})
}
})
const key = buildKey(credentials.key_prefix, options.file)
await new Promise<void>((resolve, reject) => {
cos.uploadFile(
{
Bucket: credentials.bucket as string,
Region: credentials.region as string,
Key: key,
Body: options.file,
SliceSize: SLICE_SIZE,
AsyncLimit: ASYNC_LIMIT,
onProgress(info) {
if (!options.onProgress) return
const total = info.total || options.file.size
options.onProgress({
percent: Math.min(99, Math.round((info.percent || 0) * 100)),
loaded: info.loaded || 0,
total,
speed: info.speed || 0
})
}
},
(err) => {
if (err) {
reject(err)
return
}
resolve()
}
)
})
const confirmed = await confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
return confirmed
}
/** 当后端 fallback=true 时抛出,调用方据此降级到老链路 */
export class DirectUploadFallbackError extends Error {
readonly provider: string
constructor(provider: string) {
super(`COS 直传不可用,当前驱动: ${provider}`)
this.name = 'DirectUploadFallbackError'
this.provider = provider
}
}
+28 -19
View File
@@ -60,25 +60,26 @@
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门 -->
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<el-tree-select
class="flex-1"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled(data: any) {
return data.status !== 1
}
}"
check-strictly
:default-expand-all="true"
placeholder="请选择归属部门"
/>
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
@@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{
api: jobsAll
},
dept: {
api: deptAll
api: deptAll,
params: { apply_data_scope: 1 }
}
})
+28 -19
View File
@@ -54,25 +54,26 @@
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门 -->
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<el-tree-select
class="flex-1"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled(data: any) {
return data.status !== 1
}
}"
check-strictly
:default-expand-all="true"
placeholder="请选择归属部门"
/>
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
@@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{
api: jobsAll
},
dept: {
api: deptAll
api: deptAll,
params: { apply_data_scope: 1 }
}
})
+3 -2
View File
@@ -99,11 +99,12 @@ import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
// 表单数据 - 固定role_id=1查询医生
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
const formData = reactive({
account: '',
name: '',
role_id: 1 // 固定为医生角色
role_id: 1,
apply_data_scope: 1
})
const showEdit = ref(false)
@@ -0,0 +1,582 @@
<template>
<div class="guahao-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" :inline="true" @submit.prevent>
<el-form-item label="预约日期">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="患者姓名">
<el-input
v-model="queryParams.patient_name"
placeholder="模糊搜索"
clearable
class="!w-[160px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医生">
<el-input
v-model="queryParams.doctor_name"
placeholder="模糊搜索"
clearable
class="!w-[140px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医助">
<el-select
v-model="queryParams.assistant_id"
placeholder="全部"
clearable
filterable
class="!w-[180px]"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.channel_source"
placeholder="全部"
clearable
filterable
class="!w-[200px]"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-button
v-perms="['doctor.appointment/edit']"
type="primary"
:disabled="selectedRows.length === 0"
@click="openBatchChannelDialog"
>
批量修改渠道
</el-button>
<span v-if="selectedRows.length > 0" class="text-sm text-gray-500">
已选 {{ selectedRows.length }}
</span>
</div>
<el-table
ref="tableRef"
v-loading="pager.loading"
row-key="id"
:data="pager.lists"
size="large"
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" align="center" reserve-selection />
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="诊单/患者" min-width="140">
<template #default="{ row }">
<div class="text-sm">
<div class="font-medium">{{ row.patient_name || '—' }}</div>
<div class="text-gray-500">{{ maskPhone(row.patient_phone) }}</div>
<div class="text-xs text-gray-400">诊单 #{{ row.diagnosis_id ?? row.patient_id }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="100" show-overflow-tooltip />
<el-table-column label="医助" prop="assistant_name" width="100" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="128">
<template #default="{ row }">
<div>{{ row.appointment_date }}</div>
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
</template>
</el-table-column>
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
<div v-if="row.channel_source_detail" class="text-xs text-gray-400 truncate">
{{ row.channel_source_detail }}
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag
:type="
row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'
"
size="small"
effect="light"
>
{{ row.status_desc }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button v-perms="['doctor.appointment/edit']" type="primary" link @click="openEdit(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="editVisible" title="编辑挂号" width="560px" destroy-on-close @closed="resetEditForm">
<el-form :model="editForm" label-width="96px">
<el-form-item label="预约日期" required>
<el-date-picker
v-model="editForm.appointment_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="!w-full"
/>
</el-form-item>
<el-form-item label="预约时间" required>
<el-time-picker
v-model="editForm.appointment_time"
format="HH:mm"
value-format="HH:mm"
placeholder="时间"
class="!w-full"
/>
</el-form-item>
<el-form-item label="时段" required>
<el-radio-group v-model="editForm.period">
<el-radio-button label="morning">上午</el-radio-button>
<el-radio-button label="afternoon">下午</el-radio-button>
<el-radio-button label="all">全天</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="问诊类型" required>
<el-select v-model="editForm.appointment_type" class="!w-full">
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
<el-select
v-model="editForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="editForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
<el-form-item label="状态" required>
<el-select v-model="editForm.status" class="!w-full">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="挂号医助">
<el-select
v-model="editForm.assistant_id"
placeholder="不修改可留空"
clearable
filterable
class="!w-full"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchChannelVisible"
title="批量修改渠道"
width="520px"
destroy-on-close
@closed="resetBatchChannelForm"
>
<p class="text-sm text-gray-600 mb-4">将对已选 {{ selectedRows.length }} 条挂号写入同一渠道来源仅改渠道不改其它字段</p>
<el-form :model="batchChannelForm" label-width="96px">
<el-form-item label="渠道来源" required>
<el-select
v-model="batchChannelForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onBatchChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsBatchChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="batchChannelForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchChannelVisible = false">取消</el-button>
<el-button type="primary" :loading="batchChannelSaving" @click="submitBatchChannel">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="consumerPrescriptionGuahao">
import DaterangePicker from '@/components/daterange-picker/index.vue'
import { getDictData } from '@/api/app'
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
const queryParams = reactive({
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: '' as string
})
const queryInit = {
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: ''
}
const { pager, getLists, resetPage } = usePaging({
fetchFun: appointmentLists,
params: queryParams
})
const assistantOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
const selectedRows = ref<Record<string, unknown>[]>([])
function onSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
const channelOptions = ref<Array<{ name: string; value: string }>>([])
const selectedChannelDictName = computed(() => {
const v = editForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsChannelSourceDetail = computed(() => channelNameRequiresSelfMediaDetail(selectedChannelDictName.value))
const batchSelectedChannelDictName = computed(() => {
const v = batchChannelForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsBatchChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(batchSelectedChannelDictName.value)
)
function onBatchChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
batchChannelForm.channel_source_detail = ''
}
}
const batchChannelVisible = ref(false)
const batchChannelSaving = ref(false)
const batchChannelForm = reactive({
channel_source: '' as string,
channel_source_detail: '' as string
})
function openBatchChannelDialog() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
batchChannelVisible.value = true
}
function resetBatchChannelForm() {
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
}
async function submitBatchChannel() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
if (!batchChannelForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsBatchChannelSourceDetail.value && !batchChannelForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
batchChannelSaving.value = true
try {
const ids = selectedRows.value.map((r) => Number(r.id))
await appointmentBatchEditChannel({
ids,
channel_source: batchChannelForm.channel_source,
channel_source_detail: batchChannelForm.channel_source_detail.trim()
})
feedback.msgSuccess('批量修改成功')
batchChannelVisible.value = false
tableRef.value?.clearSelection?.()
selectedRows.value = []
getLists()
} finally {
batchChannelSaving.value = false
}
}
function onChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
editForm.channel_source_detail = ''
}
}
const editVisible = ref(false)
const editSaving = ref(false)
const editForm = reactive({
id: 0,
appointment_date: '',
appointment_time: '' as string,
period: 'morning' as 'morning' | 'afternoon' | 'all',
appointment_type: 'video',
status: 1,
remark: '',
assistant_id: undefined as number | undefined,
channel_source: '' as string,
channel_source_detail: '' as string
})
function maskPhone(phone?: string) {
const p = String(phone || '')
if (p.length >= 11) {
return p.slice(0, 3) + '****' + p.slice(-4)
}
return p || '—'
}
function formatHm(t?: string) {
const s = String(t || '')
return s.length >= 5 ? s.slice(0, 5) : s || '—'
}
function resetFilter() {
Object.assign(queryParams, queryInit)
resetPage()
}
function rowPeriod(row: Record<string, unknown>): 'morning' | 'afternoon' | 'all' {
const v = String(row.period ?? row.type ?? 'morning')
if (v === 'afternoon' || v === 'all') {
return v
}
return 'morning'
}
async function loadAssistants() {
try {
const res: any = await getAssistants()
assistantOptions.value = res?.lists ?? res ?? []
} catch {
assistantOptions.value = []
}
}
async function loadChannelOptions() {
try {
const data: any = await getDictData({ type: 'channels' })
const rows = (data?.channels || []).filter((row: { status?: number }) => row.status !== 0)
rows.sort((a: { sort?: number; id?: number }, b: { sort?: number; id?: number }) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
channelOptions.value = rows
} catch {
channelOptions.value = []
}
}
function openEdit(row: Record<string, unknown>) {
editForm.id = Number(row.id)
editForm.appointment_date = String(row.appointment_date || '')
editForm.appointment_time = formatHm(String(row.appointment_time || ''))
editForm.period = rowPeriod(row)
editForm.appointment_type = String(row.appointment_type || 'video')
editForm.status = Number(row.status)
editForm.remark = String(row.remark || '')
const raw = row.appointment_assistant_id
editForm.assistant_id =
raw !== undefined && raw !== null && raw !== '' ? Number(raw) : undefined
editForm.channel_source = String(row.channel_source ?? row.channels ?? '')
editForm.channel_source_detail = String(row.channel_source_detail ?? '')
editVisible.value = true
}
function resetEditForm() {
editForm.id = 0
editForm.appointment_date = ''
editForm.appointment_time = ''
editForm.period = 'morning'
editForm.appointment_type = 'video'
editForm.status = 1
editForm.remark = ''
editForm.assistant_id = undefined
editForm.channel_source = ''
editForm.channel_source_detail = ''
}
async function submitEdit() {
if (!editForm.appointment_date) {
feedback.msgError('请选择预约日期')
return
}
if (!editForm.appointment_time) {
feedback.msgError('请选择预约时间')
return
}
if (!editForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !editForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
editSaving.value = true
try {
const payload: Record<string, unknown> = {
id: editForm.id,
appointment_date: editForm.appointment_date,
appointment_time: editForm.appointment_time,
period: editForm.period,
appointment_type: editForm.appointment_type,
status: editForm.status,
remark: editForm.remark,
assistant_id: editForm.assistant_id ?? '',
channel_source: editForm.channel_source,
channel_source_detail: editForm.channel_source_detail.trim()
}
await appointmentAdminEdit(payload)
feedback.msgSuccess('保存成功')
editVisible.value = false
getLists()
} finally {
editSaving.value = false
}
}
onMounted(() => {
loadAssistants()
loadChannelOptions()
})
onActivated(() => {
getLists()
})
getLists()
</script>
+505 -19
View File
@@ -216,6 +216,15 @@
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
查看
</el-button>
<el-button
v-if="Number(row.void_status) !== 1"
type="primary"
link
v-perms="['tcm.prescription/patchPatient']"
@click="openPatchPatientDialog(row)"
>
改姓名手机
</el-button>
<el-button
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
type="success"
@@ -548,12 +557,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="editForm.patient_name" placeholder="请输入患者姓名" />
<el-input
v-model="editForm.patient_name"
placeholder="请输入患者姓名"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="editForm.visit_no" placeholder="自动生成或手动输入" />
<el-input
v-model="editForm.visit_no"
placeholder="自动生成或手动输入"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
</el-row>
@@ -631,12 +648,22 @@
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" class="ml-2" @click="openLibraryDialog">
从处方库导入
</el-button>
<div class="flex flex-wrap items-center gap-2">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" @click="openLibraryDialog">
从处方库导入
</el-button>
</div>
<div class="mt-2">
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
<span class="text-xs text-gray-500 ml-2 align-middle">
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
</span>
</div>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
@@ -669,6 +696,49 @@
</el-table-column>
</el-table>
<el-alert
v-if="editMode === 'edit'"
type="warning"
:closable="false"
show-icon
class="mt-3"
v-loading="rxLinkedOrderHint.loading"
>
<template #title>业务订单要求(请与本处方保持一致)</template>
<div class="text-sm mt-1 leading-relaxed">
<template
v-if="
rxLinkedOrderHint.orderNo ||
rxLinkedOrderHint.medicationDays != null ||
rxLinkedOrderHint.remarkAssistant
"
>
<p v-if="rxLinkedOrderHint.orderNo" class="mb-1.5">
最近关联订单(按创建倒序):
<span class="font-mono text-primary">{{ rxLinkedOrderHint.orderNo }}</span>
</p>
<p class="mb-1.5">
<span class="font-semibold">服用天数(订单)</span>
{{
rxLinkedOrderHint.medicationDays != null
? `${rxLinkedOrderHint.medicationDays} 天`
: '— 订单未填写'
}}
</p>
<p>
<span class="font-semibold">医助备注(订单)</span>
<span class="whitespace-pre-wrap">{{
rxLinkedOrderHint.remarkAssistant || '— 无'
}}</span>
</p>
</template>
<p v-else-if="!rxLinkedOrderHint.loading" class="text-gray-600">
当前处方暂无关联业务订单,或您无订单列表权限;若有订单,将在此处显示订单内
<span class="font-semibold">服用天数</span>与
<span class="font-semibold">医助备注</span>。
</p>
</div>
</el-alert>
</div>
</el-form-item>
@@ -755,6 +825,17 @@
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
<el-select v-model="editForm.dosage_bag_count" placeholder="请选择袋数" class="w-[120px]">
<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-select>
</el-form-item>
</el-col>
<!-- 饮片代煎选项 -->
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
@@ -908,6 +989,48 @@
</template>
</el-drawer>
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
<el-dialog
v-model="patchPatientVisible"
title="修正姓名、性别与手机号"
width="440px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchPatientForm"
>
<el-form
ref="patchPatientFormRef"
:model="patchPatientForm"
:rules="patchPatientRules"
label-width="88px"
>
<el-form-item label="处方编号">
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
</el-form-item>
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="patchPatientForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
</el-form-item>
</el-form>
<p class="text-xs text-gray-500 -mt-2 mb-2">
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
</p>
<template #footer>
<el-button @click="patchPatientVisible = false">取消</el-button>
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
保存
</el-button>
</template>
</el-dialog>
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
<el-dialog
v-model="createOrderVisible"
@@ -1183,7 +1306,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
@@ -1193,6 +1316,16 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="createOrderForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
@@ -1295,6 +1428,40 @@
</div>
</el-dialog>
<!-- 粘贴药方文本导入(与诊间 tcm-prescription 逻辑一致:药品库完全同名才录入) -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g
白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
<p class="text-sm text-gray-600 mb-3">
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
@@ -1328,15 +1495,17 @@ import {
prescriptionLists,
prescriptionAdd,
prescriptionEdit,
prescriptionPatchPatient,
prescriptionDelete,
prescriptionAudit,
prescriptionDetail,
prescriptionOrderCreate,
prescriptionOrderPaidPayOrders,
prescriptionOrderLists,
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
import { searchPatients as searchPatientsAPI } from '@/api/order'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
@@ -1345,7 +1514,7 @@ import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import { Search, Download, Printer } from '@element-plus/icons-vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
@@ -1414,6 +1583,66 @@ const auditTargetId = ref(0)
const auditRemark = ref('')
const auditLoading = ref(false)
/** 修正处方笺显示用姓名 / 手机号 */
const patchPatientVisible = ref(false)
const patchPatientSubmitLoading = ref(false)
const patchPatientFormRef = ref<FormInstance>()
const patchPatientForm = reactive({
id: 0,
patient_name: '',
gender: 1 as 0 | 1,
phone: ''
})
const patchPatientRules: FormRules = {
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
function openPatchPatientDialog(row: Record<string, unknown>) {
patchPatientForm.id = Number(row.id) || 0
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
const g = Number(row.gender)
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
patchPatientForm.phone = String(row.phone ?? '').trim()
patchPatientVisible.value = true
}
function resetPatchPatientForm() {
patchPatientForm.id = 0
patchPatientForm.patient_name = ''
patchPatientForm.gender = 1
patchPatientForm.phone = ''
patchPatientFormRef.value?.clearValidate()
}
async function submitPatchPatient() {
const form = patchPatientFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
patchPatientSubmitLoading.value = true
try {
await prescriptionPatchPatient({
id: patchPatientForm.id,
patient_name: patchPatientForm.patient_name.trim(),
phone: patchPatientForm.phone.trim(),
gender: patchPatientForm.gender
})
feedback.msgSuccess('已保存')
patchPatientVisible.value = false
getLists()
} catch (e: unknown) {
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
feedback.msgError(msg || '保存失败')
} finally {
patchPatientSubmitLoading.value = false
}
}
/** 从消费者处方创建订单 */
const createOrderVisible = ref(false)
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
@@ -1475,6 +1704,7 @@ const createOrderForm = reactive({
amount: 0,
internal_cost: undefined as number | undefined,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[]
})
@@ -1714,6 +1944,7 @@ function resetCreateOrderForm() {
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
createOrderForm.remark_extra = ''
createOrderForm.remark_assistant = ''
createOrderForm.pay_order_ids = []
createOrderPatientList.value = []
createOrderPaidOrders.value = []
@@ -1884,7 +2115,8 @@ async function submitCreateOrderFromPrescription() {
tracking_number: createOrderForm.tracking_number || '',
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || ''
remark_extra: createOrderForm.remark_extra || '',
remark_assistant: createOrderForm.remark_assistant || ''
}
// 添加省市区字段
@@ -2020,15 +2252,19 @@ const rxUsageText = computed(() => {
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}次`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}袋`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
@@ -2066,6 +2302,22 @@ const rxOutPelletText = computed(() => {
function computeOutPellet(v: any): string {
if (!v) return ''
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const days = Number(v.medication_days ?? v.usage_days ?? v.dose_count)
const times = Number(v.times_per_day)
const doseG = Number(v.dosage_amount)
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
if (
Number.isFinite(days) &&
Number.isFinite(times) &&
Number.isFinite(doseG) &&
days > 0 &&
times > 0 &&
doseG > 0
) {
return formatNum(days * times * doseG * bags)
}
}
const explicit = v.out_pellet || v.total_weight
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
const list = slipHerbsList.value
@@ -2197,6 +2449,7 @@ const editForm = reactive({
prescription_type: '浓缩水丸',
dosage_amount: 1 as number | undefined,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
@@ -2238,6 +2491,52 @@ const editForm = reactive({
times_per_day: 2
})
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
const rxLinkedOrderHint = reactive({
loading: false,
orderNo: '',
medicationDays: null as number | null,
remarkAssistant: ''
})
function clearRxLinkedOrderHint() {
rxLinkedOrderHint.loading = false
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
}
async function loadRxLinkedOrderHintForEdit(prescriptionId: number) {
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
if (!prescriptionId) {
rxLinkedOrderHint.loading = false
return
}
rxLinkedOrderHint.loading = true
try {
const data: any = await prescriptionOrderLists({
page_no: 1,
page_size: 5,
prescription_id: prescriptionId
})
const lists = data?.lists
const row = Array.isArray(lists) && lists.length ? lists[0] : null
if (row) {
rxLinkedOrderHint.orderNo = String(row.order_no || '').trim()
const md = row.medication_days
const mdn = md !== null && md !== undefined && String(md).trim() !== '' ? Number(md) : NaN
rxLinkedOrderHint.medicationDays = Number.isFinite(mdn) && mdn > 0 ? mdn : null
rxLinkedOrderHint.remarkAssistant = String(row.remark_assistant || '').trim()
}
} catch {
/* 无列表权限或网络错误:保持空 */
} finally {
rxLinkedOrderHint.loading = false
}
}
const diagnosisViewRef = ref<{
openViewOnly: (id: number) => Promise<void>
} | null>(null)
@@ -2264,16 +2563,19 @@ watch(() => editForm.prescription_type, (newType) => {
if (newType === '浓缩水丸') {
editForm.dosage_unit = 'g'
editForm.dosage_amount = 1
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
} else if (newType === '饮片') {
editForm.dosage_unit = 'ml'
editForm.dosage_amount = 50
editForm.dosage_bag_count = 1
editForm.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
editForm.dosage_unit = 'g'
editForm.dosage_amount = undefined
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
@@ -2833,6 +3135,177 @@ watch(showLibraryDialog, (open) => {
}
})
/** 粘贴药方导入(与 components/tcm-prescription 一致) */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipePasteInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipePasteHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbPasteNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
function parseHerbPasteToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbPasteNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbPasteNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbPasteNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipePasteText(text: string): string[] {
const t = stripRecipePasteHeaders(normalizeRecipePasteInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbPasteToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipePasteText(text)) {
const parsed = parseHerbPasteToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipePasteToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Array<{ name: string; dosage: number }> = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
editForm.herbs = resolved
} else {
editForm.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNamesLocal()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
// 添加药材
const addHerb = () => {
editForm.herbs.push({
@@ -2850,6 +3323,11 @@ const removeHerb = (index: number) => {
const resetForm = () => {
editForm.id = 0
editForm.prescription_type = '浓缩水丸'
editForm.dosage_amount = 1
editForm.dosage_unit = 'g'
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
editForm.patient_name = ''
editForm.gender = 1
editForm.age = 0
@@ -2882,6 +3360,7 @@ const resetForm = () => {
editForm.is_system_auto = 0
editForm.business_prescription_audit_rejected = 0
editForm.business_prescription_audit_remark = ''
clearRxLinkedOrderHint()
}
// 新增处方
@@ -2966,6 +3445,7 @@ const handleEdit = async (row: any) => {
}
}
editForm.dosage_bag_count = src.dosage_bag_count ? Number(src.dosage_bag_count) : 1
editForm.need_decoction = src.need_decoction === 1 || src.need_decoction === true
editForm.bags_per_dose = src.bags_per_dose ? Number(src.bags_per_dose) : 1
editForm.patient_name = src.patient_name || ''
@@ -3009,8 +3489,9 @@ const handleEdit = async (row: any) => {
setTimeout(() => {
isLoadingData.value = false
}, 0)
showEdit.value = true
void loadRxLinkedOrderHintForEdit(Number(editForm.id))
nextTick(() => {
formRef.value?.clearValidate()
scheduleEditSignatureInit()
@@ -3020,9 +3501,14 @@ const handleEdit = async (row: any) => {
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
try {
await formRef.value.validate()
} catch {
// Element Plus:校验失败会 reject,表单项已展示错误,勿再向外抛成 Uncaught
return
}
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
File diff suppressed because it is too large Load Diff
@@ -677,7 +677,19 @@
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
@@ -819,6 +831,9 @@
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
@@ -910,11 +925,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -967,11 +983,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -1091,7 +1108,8 @@
{{ expressCompanyLabel(detailData.express_company) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
</el-descriptions-item>
@@ -1217,6 +1235,47 @@
</div>
</el-drawer>
<el-dialog
v-model="updateAmountVisible"
title="修改订单金额"
width="92%"
destroy-on-close
:close-on-click-modal="false"
@closed="resetUpdateAmountDialog"
>
<el-form
ref="updateAmountFormRef"
:model="updateAmountForm"
:rules="updateAmountRules"
label-width="84px"
@submit.prevent="submitUpdateAmount"
>
<el-form-item label="当前金额">
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
</el-form-item>
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
class="w-full"
placeholder="请输入新金额"
/>
</el-form-item>
<div class="text-xs text-gray-400 pl-[84px] -mt-1 leading-relaxed">
已完成或已取消订单不可修改提交成功后会刷新详情与操作日志
</div>
</el-form>
<template #footer>
<el-button @click="updateAmountVisible = false">取消</el-button>
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
确认修改
</el-button>
</template>
</el-dialog>
<!-- 编辑分步向导与创建业务订单弹窗统一 -->
<el-dialog
v-model="editVisible"
@@ -1563,7 +1622,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="editForm.remark_extra"
type="textarea"
@@ -1573,6 +1632,16 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="editForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
@@ -2328,6 +2397,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderShip,
prescriptionOrderLogs,
prescriptionOrderUpdateAmount,
prescriptionOrderAddPayOrder,
prescriptionOrderComplete,
prescriptionOrderRevokeRxAudit,
@@ -2789,6 +2859,9 @@ const listStats = computed(() => {
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
} else if (scope === 'assistant') {
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
} else if (scope === 'own_rx') {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
@@ -2974,19 +3047,57 @@ function formatTime(v: unknown) {
return String(v)
}
/** 与 server/config/project.php order_edit_all_roles 一致 */
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
function canBypassCreatorDualAuditEditLock(): boolean {
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 ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
}
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
const uid = Number(userStore.userInfo?.id)
return uid > 0 && Number(row.creator_id) === uid
}
function isDualAuditPassed(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
}
function canEditRow(row: {
creator_id?: number
fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
// (5)(6)(3)(4)
const fs = Number(row.fulfillment_status)
if (fs !== 1 && fs !== 2) return false
// > 0
if (fs === 3 || fs === 4) return false
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
if (gcNo !== '' || gcTime > 0) return false
return true
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
return fs === 1 || fs === 2
}
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
@@ -3055,9 +3166,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) / (6) 使
const fs = Number(row.fulfillment_status)
return fs === 5 || fs === 6
return Number(row.fulfillment_status) === 5
}
function canUploadGancaoRow(row: {
@@ -3116,6 +3225,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
@@ -3327,6 +3442,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false)
const updateAmountSaving = ref(false)
const updateAmountFormRef = ref<FormInstance>()
const updateAmountForm = reactive({
id: 0,
amount: undefined as number | undefined
})
const updateAmountRules: FormRules = {
amount: [
{ required: true, message: '请输入订单金额', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
@@ -3362,6 +3500,7 @@ function logActionText(act: string) {
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
}
return m[act] || act
@@ -3446,6 +3585,24 @@ function resetPatchRxPatientForm() {
patchRxPatientForm.phone = ''
}
function resetUpdateAmountDialog() {
updateAmountForm.id = 0
updateAmountForm.amount = undefined
updateAmountFormRef.value?.clearValidate()
}
function openUpdateAmount() {
const row = detailData.value
if (!canUpdateAmount(row)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
async function refreshCurrentPrescriptionOrderDetail() {
const id = detailData.value?.id
if (!id) return
@@ -3485,6 +3642,37 @@ async function submitPatchRxPatient() {
}
}
async function submitUpdateAmount() {
const form = updateAmountFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
if (!canUpdateAmount(detailData.value)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
const orderId = updateAmountForm.id
updateAmountSaving.value = true
try {
await prescriptionOrderUpdateAmount({
id: orderId,
amount: Number(updateAmountForm.amount)
})
feedback.msgSuccess('修改成功')
updateAmountVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(orderId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
updateAmountSaving.value = false
}
}
async function openDetail(id: number) {
// Bug
detailData.value = null
@@ -3572,6 +3760,7 @@ const editForm = reactive({
fee_type: 3,
amount: 0,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[],
internal_cost: undefined as number | string | undefined,
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
@@ -3734,7 +3923,25 @@ async function goEditOrderNextStep() {
}
}
async function openEdit(row: { id: number }) {
async function openEdit(row: {
id: number
creator_id?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
if (!canEditRow(row)) {
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
}
return
}
editOrderStep.value = 0
editOrderPrescription.value = null
editVisible.value = true
@@ -3822,6 +4029,7 @@ async function openEdit(row: { id: number }) {
editForm.amount = amt
editForm.remark_extra = d.remark_extra || ''
editForm.remark_assistant = d.remark_assistant || ''
const pids = d.pay_order_ids
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
editForm.internal_cost =
@@ -3860,6 +4068,7 @@ async function submitEdit() {
fee_type: editForm.fee_type,
amount: editForm.amount,
remark_extra: editForm.remark_extra || '',
remark_assistant: editForm.remark_assistant || '',
// BUG FIX:
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
@@ -4139,6 +4348,7 @@ async function submitQuickTrack() {
fee_type: Number(d.fee_type) || 3,
amount: Number(d.amount) || 0,
remark_extra: d.remark_extra || '',
remark_assistant: d.remark_assistant || '',
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
//
@@ -4156,8 +4366,8 @@ async function submitQuickTrack() {
if (nd) detailData.value = nd
} catch { /* 静默 */ }
}
//
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
@@ -4492,19 +4702,21 @@ const slipPerBagDosageText = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}`
})
const slipPillGrams = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG
return days * times * doseG * bags
})
/* ============================================================
@@ -4597,21 +4809,25 @@ const rxUsageText = computed(() => {
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
})
/** 出丸:优先用 slipPillGrams按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
/** 出丸:优先用 slipPillGrams每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const pill = slipPillGrams.value
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -102,6 +102,18 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="添加时间">
<el-date-picker
v-model="addTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
class="!w-[280px]"
@change="onAddTimeRangeChange"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
@@ -557,12 +569,34 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
const queryParams = reactive<{
name: string
follow_user: string
tag_ids: string[]
add_time_start: string
add_time_end: string
}>({
name: '',
follow_user: '',
tag_ids: []
tag_ids: [],
add_time_start: '',
add_time_end: ''
})
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time */
const addTimeRange = ref<[string, string] | null>(null)
function onAddTimeRangeChange(val: [string, string] | null) {
if (val && val.length === 2 && val[0] && val[1]) {
queryParams.add_time_start = val[0]
queryParams.add_time_end = val[1]
} else {
queryParams.add_time_start = ''
queryParams.add_time_end = ''
}
resetPage()
}
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
interface TagItem {
tag_id: string
@@ -793,6 +827,9 @@ function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
addTimeRange.value = null
resetParams()
}
File diff suppressed because it is too large Load Diff
+39 -12
View File
@@ -22,16 +22,25 @@
<el-select
v-model="formData.media_channel_code"
placeholder="请选择自媒体渠道"
class="w-full"
class="account-cost-channel-select w-full"
filterable
:disabled="mode === 'edit'"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
<el-option-group
v-for="g in mediaChannelGroups"
:key="g.group_name"
:label="g.group_name"
>
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="部门" prop="dept_id">
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
import Popup from '@/components/popup/index.vue'
interface MediaChannelOption {
code: string
name: string
interface MediaChannelGroup {
group_name: string
channels: { channel_code: string; channel_name: string }[]
}
interface DeptOption {
@@ -90,7 +99,7 @@ interface DeptOption {
}
const props = defineProps<{
mediaChannelOptions: MediaChannelOption[]
mediaChannelGroups: MediaChannelGroup[]
deptOptions: DeptOption[]
defaultMediaChannelCode: string
}>()
@@ -197,3 +206,21 @@ defineExpose({
getDetail,
})
</script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+83 -10
View File
@@ -27,14 +27,25 @@
placeholder="筛选全部渠道"
clearable
filterable
class="w-[220px]"
class="account-cost-channel-select w-[220px]"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
<el-option label="全部" value="" />
<el-option-group
v-for="g in mediaChannelGroups"
:key="g.group_name"
:label="g.group_name"
>
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="部门">
@@ -78,7 +89,15 @@
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
<el-table-column label="自媒体渠道" min-width="160">
<template #default="{ row }">
<span v-if="getChannelGroupLabel(row.media_channel_code)">
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
{{ row.media_channel_name }}
</span>
<span v-else>{{ row.media_channel_name }}</span>
</template>
</el-table-column>
<el-table-column label="部门" prop="dept_name" min-width="140" />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
@@ -117,7 +136,7 @@
<edit-popup
v-if="showEdit"
ref="editRef"
:media-channel-options="mediaChannelOptions"
:media-channel-groups="mediaChannelGroups"
:dept-options="deptOptions"
:default-media-channel-code="defaultMediaChannelCode"
@success="getLists"
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
interface MediaChannelGroup {
group_name: string
channels: { channel_code: string; channel_name: string }[]
}
interface MediaChannelOption {
code: string
name: string
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
params: queryParams,
})
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return []
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
}))
})
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
const groups = pager.extend.media_channel_groups
if (Array.isArray(groups) && groups.length > 0) {
return groups as MediaChannelGroup[]
}
const flat = mediaChannelOptionsFlat.value
if (!flat.length) {
return []
}
return [
{
group_name: '渠道',
channels: flat.map(item => ({
channel_code: item.code,
channel_name: item.name,
})),
},
]
})
const deptOptions = computed<DeptOption[]>(() => {
const options = pager.extend.dept_options
return Array.isArray(options) ? options : []
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
const getChannelGroupLabel = (channelCode: string): string => {
if (!channelCode) return ''
for (const group of mediaChannelGroups.value) {
const channel = group.channels.find(ch => ch.channel_code === channelCode)
if (channel) {
return group.group_name
}
}
return ''
}
const treeProps = {
value: 'id',
label: 'name',
@@ -213,3 +268,21 @@ const handleReset = () => {
getLists()
</script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+20 -3
View File
@@ -9,7 +9,11 @@
</div>
<div>
<div class="toolbar__title">制定业绩目标</div>
<div class="toolbar__desc">按部门设置每月销售目标 0 并保存将删除该记录</div>
<div class="toolbar__desc">
按部门设置每月销售目标 0 并保存将删除该记录列表按当前账号
<strong>角色数据权限</strong>
仅展示<strong>本部门及下级</strong>不展示权限外的上级组织口径与业绩看板数据范围一致
</div>
</div>
</div>
<div class="toolbar__center">
@@ -59,8 +63,16 @@
<div v-if="!loading && formRows.length === 0" class="empty-state">
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
<p class="empty-state__title">暂无部门数据</p>
<p class="empty-state__desc">请检查部门配置后重试</p>
<p class="empty-state__title">
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
</p>
<p class="empty-state__desc">
{{
dataScopeLimited
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
: '请检查部门配置后重试'
}}
</p>
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
</div>
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
const loading = ref(false)
const saving = ref(false)
const formRows = ref<Row[]>([])
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
const dataScopeLimited = ref(false)
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
const drillStack = ref<Row[]>([])
@@ -199,8 +213,10 @@ async function loadMatrix() {
try {
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
dataScopeLimited.value = Boolean(res?.data_scope_limited)
} catch {
formRows.value = []
dataScopeLimited.value = false
} finally {
loading.value = false
}
@@ -217,6 +233,7 @@ async function handleSave() {
})
if (res?.rows) {
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
dataScopeLimited.value = Boolean(res?.data_scope_limited)
drillStack.value = []
drilledId.value = null
} else {
+25 -2
View File
@@ -180,11 +180,12 @@
</template>
</el-table-column>
<el-table-column label="订单状态" width="100">
<el-table-column label="订单状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTag(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
@@ -270,6 +271,14 @@
>
删除
</el-button>
<el-button
v-perms="['order.order/setExempt']"
:type="row.is_exempt === 1 ? 'warning' : 'default'"
link
@click="handleToggleExempt(row)"
>
{{ row.is_exempt === 1 ? '取消豁免' : '设为豁免' }}
</el-button>
</template>
</el-table-column>
</el-table>
@@ -779,7 +788,8 @@ import {
orderActionLogs,
orderActionLogStats,
orderBatchAssignAssistant,
orderSplit
orderSplit,
orderSetExempt
} from '@/api/order'
import { getAssistants } from '@/api/tcm'
import { generateOrderQrcode } from '@/api/tcm'
@@ -1494,6 +1504,19 @@ const handleDelete = async (row: any) => {
}
}
const handleToggleExempt = async (row: any) => {
const newVal: 0 | 1 = row.is_exempt === 1 ? 0 : 1
const actionText = newVal === 1 ? '设为豁免' : '取消豁免'
try {
await feedback.confirm(`确定要${actionText}该支付单吗?豁免后关联此单的业务订单将跳过定金门槛校验。`)
await orderSetExempt({ id: row.id, is_exempt: newVal })
feedback.msgSuccess(`${actionText}成功`)
getLists()
} catch (error) {
// 用户取消操作
}
}
// 小程序码相关
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
@@ -47,6 +47,7 @@
:src="getImageUrl(url)"
:preview-src-list="note.tongue_images.map(getImageUrl)"
:initial-index="idx"
:z-index="IMAGE_PREVIEW_Z_INDEX"
fit="cover"
class="timeline-thumb"
preview-teleported
@@ -66,6 +67,7 @@
:src="getImageUrl(url)"
:preview-src-list="getImagePreviewList(note.report_files)"
:initial-index="getImagePreviewIndex(note.report_files, idx)"
:z-index="IMAGE_PREVIEW_Z_INDEX"
fit="cover"
class="timeline-thumb"
preview-teleported
@@ -128,6 +130,9 @@ const reportModel = ref<string[]>([])
const prevTongueCount = ref(0)
const prevReportCount = ref(0)
/** 高于诊单抽屉(最高 4000)与多层弹窗叠加后的遮罩,避免预览被挡住 */
const IMAGE_PREVIEW_Z_INDEX = 8000
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const isImageFile = (url: string): boolean => {
@@ -211,6 +216,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
<style scoped lang="scss">
.timeline-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
+22 -323
View File
@@ -111,126 +111,10 @@
<template v-else-if="detail">
<!-- 患者信息 -->
<div class="card patient-card">
<div class="card-title">患者信息</div>
<div class="patient-hero">
<div class="patient-name">{{ apt.patient_name || '—' }}</div>
<div class="patient-meta">
<div>{{ maskPhone(apt.patient_phone) }} · {{ formatGender(diag.gender) }} · {{ diag.age != null ? diag.age + '岁' : '—' }}</div>
<div>{{ diag.height ? diag.height + 'cm' : '—' }} / {{ diag.weight ? diag.weight + 'kg' : '—' }} · {{ diag.region || '—' }}</div>
<div>预约{{ apt.appointment_date }} {{ apt.appointment_time }} · {{ formatPeriod(apt.period) }}</div>
<div>医生{{ apt.doctor_name || '—' }} 医助{{ apt.assistant_name || '—' }}</div>
<div>状态{{ apt.status_desc || '—' }} · {{ apt.has_prescription ? '已开方' : '未开方' }}</div>
<div v-if="apt.remark">备注{{ apt.remark }}</div>
</div>
</div>
</div>
<PatientInfoCard :apt="apt" :diag="diag" />
<!-- 患者病例完整字段 tcm/diagnosis/edit.vue 保持同步只读展示 -->
<div class="card case-card">
<div class="card-title">
患者病例
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag.diagnosis_date || apt.appointment_date || '—' }}</span>
</div>
<!-- 基本信息 -->
<div class="grid-block">
<div class="grid-title">基本信息</div>
<div class="kv-grid four">
<KVItem label="诊单ID" :value="diag.patient_id" />
<KVItem label="姓名" :value="diag.patient_name" />
<KVItem label="身份证号" :value="diag.id_card" />
<KVItem label="手机号" :value="maskPhone(diag.phone || apt.patient_phone)" />
<KVItem label="性别" :value="textOf('gender')" />
<KVItem label="年龄" :value="diag.age != null ? diag.age + '岁' : ''" />
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
<KVItem label="地区" :value="diag.region" />
</div>
</div>
<!-- 生命体征 -->
<div class="grid-block">
<div class="grid-title">生命体征</div>
<div class="kv-grid four">
<KVItem label="身高" :value="diag.height ? diag.height + ' cm' : ''" />
<KVItem label="体重" :value="diag.weight ? diag.weight + ' kg' : ''" />
<MetricKVItem label="高压" :value="diag.systolic_pressure" unit=" mmHg" :high="isHighSystolicPressure(diag.systolic_pressure)" />
<MetricKVItem label="低压" :value="diag.diastolic_pressure" unit=" mmHg" :high="isHighDiastolicPressure(diag.diastolic_pressure)" />
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
<MetricKVItem label="空腹血糖" :value="diag.fasting_blood_sugar" unit=" mmol/L" :high="isHighFastingBloodSugar(diag.fasting_blood_sugar, diag.age)" />
<KVItem label="在用药物" :value="diag.current_medications" multiline />
</div>
</div>
<!-- 主诉 -->
<div class="grid-block">
<div class="grid-title">主诉</div>
<div class="kv-grid four">
<KVItem label="当地医院诊断日期" :value="diag.diagnosis_date" />
<KVItem label="发现糖尿病病史" :value="formatDiabetesDiscoveryDisplay ? formatDiabetesDiscoveryDisplay(diag.diabetes_discovery_year) : diag.diabetes_discovery_year" />
<KVItem label="当地就诊医院" :value="diag.local_hospital_name" />
<KVItem label="当地医院诊断结果" :value="joinArray(diag.local_hospital_diagnosis)" />
</div>
</div>
<!-- 现病史后端已翻译 *_text -->
<div class="grid-block">
<div class="grid-title">现病史</div>
<div class="kv-grid three">
<KVItem label="口腔感觉" :value="textOf('appetite')" />
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
<KVItem label="小便情况" :value="textOf('urine_condition')" />
<KVItem label="大便情况" :value="textOf('stool_condition')" />
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
<KVItem label="其他补充" :value="diag.symptoms" span="3" multiline />
</div>
</div>
<!-- 既往史 -->
<div class="grid-block">
<div class="grid-title">既往史</div>
<div class="kv-grid three">
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
</div>
</div>
<!-- 其他病史 -->
<div class="grid-block">
<div class="grid-title">其他病史</div>
<div class="kv-grid three">
<KVItem label="外伤史" :value="textOf('trauma_history')" />
<KVItem label="手术史" :value="textOf('surgery_history')" />
<KVItem label="过敏史" :value="textOf('allergy_history')" />
<KVItem label="家族病史" :value="textOf('family_history')" />
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
</div>
</div>
<!-- 病史补充 -->
<div class="grid-block" v-if="diag.remark">
<div class="grid-title">病史补充</div>
<div class="kv-grid one">
<KVItem label="" :value="diag.remark" multiline />
</div>
</div>
<!-- 处方意见 -->
<div class="grid-block">
<div class="grid-title">处方意见</div>
<div class="rx-note">
{{ apt.has_prescription ? '已开方。' : '当前未开方。' }}
</div>
</div>
</div>
<!-- 患者病例 tcm/diagnosis/readonly 共用同一组件确保两处字段一致 -->
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 医生备注 & 舌苔照片 -->
<div class="card note-card">
@@ -242,65 +126,16 @@
/>
</div>
<!-- 血糖血压记录 -->
<!-- 日常记录只读与只读病例页同口径 -->
<div class="card metric-card">
<div class="card-title">血糖血压记录</div>
<div class="metric-viewport">
<el-table
:data="bloodRecords"
size="small"
stripe
empty-text="暂无血糖血压记录"
class="metric-table"
max-height="360"
>
<el-table-column prop="record_date" label="记录日期" width="110" />
<el-table-column prop="record_time" label="记录时间" width="100">
<template #default="{ row }">{{ row.record_time || '—' }}</template>
</el-table-column>
<el-table-column label="空腹血糖(mmol/L)" width="150">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age) }">
{{ row.fasting_blood_sugar ?? '—' }}
<span v-if="isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="餐后2小时血糖(mmol/L)" width="180">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age) }">
{{ row.postprandial_blood_sugar ?? '—' }}
<span v-if="isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="其他血糖(mmol/L)" width="150">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighOtherBloodSugar(row.other_blood_sugar, diag.age) }">
{{ row.other_blood_sugar ?? '—' }}
<span v-if="isHighOtherBloodSugar(row.other_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="血压(mmHg)" width="130">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighBloodPressure(row) }">
{{ formatBp(row) }}
<span v-if="isHighBloodPressure(row)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="西药" width="120">
<template #default="{ row }">{{ row.western_medicine || '—' }}</template>
</el-table-column>
<el-table-column label="胰岛素" width="120">
<template #default="{ row }">{{ row.insulin || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="160">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-title">日常记录</div>
<DailyMatrix
:diagnosis-id="Number(diag?.id ?? 0)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
</template>
</section>
@@ -317,7 +152,11 @@
>
备注
</el-button>
<el-button class="float-action edit" size="large" @click="handleEditDiagnosis">
<el-button
v-perms="['tcm.diagnosis/edit']"
class="float-action edit"
size="large"
@click="handleEditDiagnosis">
编辑病历
</el-button>
<el-button
@@ -349,13 +188,17 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent, onMounted, onUnmounted, h } from 'vue'
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
import NoteTimeline from './components/NoteTimeline.vue'
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
import DailyMatrix from '@/views/tcm/diagnosis/components/DailyMatrix.vue'
import { completeAppointment } from '@/api/doctor'
import { getCallSignature } from '@/api/tcm'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { formatGender } from '@/utils/diag-display'
import {
Phone,
Search,
@@ -366,55 +209,6 @@ import {
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
/**
* 局部渲染组件左侧 label + 右侧 value支持 span / 换行
*/
const KVItem = (props: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
const val = props.value
const empty = val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
return h(
'div',
{
class: ['kv-item', props.multiline ? 'multiline' : '', empty ? 'empty' : ''],
style: props.span ? `grid-column: span ${props.span}` : ''
},
[
h('span', { class: 'kv-label' }, props.label),
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
]
)
}
const MetricKVItem = (props: {
label: string
value: any
unit?: string
span?: string | number
high?: boolean
}) => {
const empty = props.value === undefined || props.value === null || props.value === ''
return h(
'div',
{
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
style: props.span ? `grid-column: span ${props.span}` : ''
},
[
h('span', { class: 'kv-label' }, props.label),
h(
'span',
{ class: ['kv-value', 'metric-kv-value', props.high ? 'high' : ''] },
empty
? '—'
: [
`${props.value}${props.unit || ''}`,
props.high ? h('span', { class: 'metric-up' }, '↑') : null
]
)
]
)
}
const POLL_MS = 5_000
const PAGE_SIZE = 15
@@ -474,9 +268,7 @@ const noteSaving = ref(false)
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const todayMeta = computed(() => {
const d = new Date()
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
@@ -485,38 +277,12 @@ const todayMeta = computed(() => {
})
const caseTypeLabel = computed(() => {
if (!diag.value) return '初诊'
return diag.value.consultation_type === 'follow_up' ? '复诊' : '初诊'
})
/**
* 诊单字典字段由后端 AppointmentLogic::enrichDiagnosisLabels 预翻译
* 前端直接读 `<field>_text`找不到时回退原值
*/
const textOf = (field: string): string => {
const d: any = diag.value || {}
const t = d[field + '_text']
if (t !== undefined && t !== null && t !== '') return String(t)
const raw = d[field]
if (raw === undefined || raw === null || raw === '') return ''
return Array.isArray(raw) ? raw.join('、') : String(raw)
}
const todayStr = () => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const maskPhone = (phone?: string) => {
if (!phone) return '—'
if (phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
const formatGender = (g?: number) => (g === 1 ? '男' : g === 0 ? '女' : '—')
const formatGenderAge = (row: QueueRow) => {
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
return parts.filter((p) => p && p !== '—').join(' ')
@@ -527,73 +293,6 @@ const formatTimeHM = (time?: string) => {
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
}
const formatPeriod = (period?: string) => {
if (period === 'morning') return '上午'
if (period === 'afternoon') return '下午'
return '全天'
}
const formatBp = (row: any) => {
const sys = row?.systolic_pressure
const dia = row?.diastolic_pressure
if (sys == null && dia == null) return '—'
return `${sys ?? '—'} / ${dia ?? '—'}`
}
const toNumeric = (value: any) => {
if (value === null || value === undefined || value === '') return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const getBloodSugarThresholds = (ageValue: any) => {
const age = toNumeric(ageValue)
if (age === null) return null
if (age < 50) return { fasting: 7, postprandial: 9 }
if (age >= 50) return { fasting: 8, postprandial: 10 }
return null
}
const isHighFastingBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.fasting
}
const isHighPostprandialBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
const isHighOtherBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
const isHighBloodPressure = (row: any) => {
const sys = toNumeric(row?.systolic_pressure)
const dia = toNumeric(row?.diastolic_pressure)
return (sys !== null && sys > 140) || (dia !== null && dia > 90)
}
const isHighSystolicPressure = (value: any) => {
const num = toNumeric(value)
return num !== null && num > 140
}
const isHighDiastolicPressure = (value: any) => {
const num = toNumeric(value)
return num !== null && num > 90
}
const joinArray = (arr: any): string => {
if (!arr) return ''
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
return String(arr)
}
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
const today = todayStr()
const res: any = await receptionQueue({
+1
View File
@@ -65,6 +65,7 @@ const formData = reactive({
name: '',
desc: '',
sort: 0,
data_scope: 1,
menu_id: [] as any[]
})
@@ -0,0 +1,273 @@
<template>
<div class="assistant-performance-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="stats-filter-form">
<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="yesterday">昨天</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"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ formatValue(card.key, card.type) }}
</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">业绩趋势</span>
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
</div>
</template>
<v-charts
v-if="chartHasData"
class="stats-chart"
:option="chartOption"
autoresize
/>
<el-empty v-else description="暂无数据" />
</el-card>
</div>
</template>
<script setup lang="ts" name="assistantPerformancePage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
import { assistantPerformanceOverview } from '@/api/stats'
const loading = ref(false)
const dateRange = ref<string[]>([])
const queryParams = reactive({
time_type: 'month',
start_date: '',
end_date: ''
})
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {
total_amount: 0,
total_count: 0
},
chart: {
dates: [],
amounts: [],
counts: []
}
})
const summaryCards = [
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
]
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const chartHasData = computed(() => {
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
})
const chartOption = computed(() => ({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0]
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
}
},
grid: { left: 60, right: 24, top: 36, bottom: 36 },
xAxis: {
type: 'category',
data: overview.chart.dates,
axisLabel: {
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
rotate: overview.chart.dates.length > 10 ? 30 : 0
}
},
yAxis: {
type: 'value',
name: '金额(元)',
axisLabel: {
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
}
},
series: [
{
name: '业绩',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 6,
lineStyle: { width: 3, color: '#4a78ff' },
itemStyle: { color: '#4a78ff' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
]
}
},
data: overview.chart.amounts
}
]
}))
const formatValue = (key: string, type: string) => {
const value = overview.summary?.[key] ?? 0
if (type === 'money') return `¥${Number(value).toFixed(2)}`
return String(value)
}
const fetchData = async () => {
loading.value = true
try {
const params: Record<string, any> = { time_type: queryParams.time_type }
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await assistantPerformanceOverview(params)
Object.assign(overview, res || {})
} catch (error: any) {
console.error('获取业绩数据失败:', error)
ElMessage.error(error?.msg || '获取业绩数据失败')
} finally {
loading.value = false
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
fetchData()
}
}
const handleCustomDateChange = () => {
if (dateRange.value?.length === 2) {
fetchData()
}
}
const handleReset = () => {
queryParams.time_type = 'month'
dateRange.value = []
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.assistant-performance-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 20px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.stats-kpi-card.is-cancelled {
background: linear-gradient(145deg, #ffffff, #fff5f5);
border-color: #ffe0e0;
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.stats-kpi-card.is-cancelled .stats-kpi-value {
color: #ef4444;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.stats-chart {
height: 360px;
}
</style>
+187 -23
View File
@@ -105,24 +105,27 @@
</div>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="14">
<el-col :span="24">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>转化排行</span>
<span class="card-hint">{{ dateRangeText }}</span>
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
</div>
</template>
<v-charts
v-if="rankingChartHasData"
class="stats-chart"
class="stats-chart stats-chart-wide"
:option="rankingChartOption"
autoresize
/>
<el-empty v-else description="暂无图表数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
@@ -139,10 +142,7 @@
<el-empty v-else description="暂无金额数据" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="10">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
@@ -159,35 +159,82 @@
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
<el-card class="!border-none" shadow="never">
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :span="24">
<el-card class="!border-none stats-detail-card" shadow="never">
<template #header>
<div class="card-header">
<span>明细列表</span>
<span class="card-hint">{{ entityLabel }}维度</span>
<div class="card-header-main">
<span class="card-title">明细列表</span>
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
</div>
</div>
</template>
<el-table
:data="pager.lists"
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
:row-key="rowKeyFn"
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
:default-expand-all="false"
size="large"
:indent="20"
:row-class-name="rowClassName"
size="default"
style="width: 100%"
max-height="560"
max-height="640"
border
>
<el-table-column
:label="entityLabel"
prop="name"
fixed="left"
min-width="280"
min-width="300"
class-name="dept-name-column"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="dept-name-cell" :title="row.name">
{{ row.name }}
</span>
<div class="dept-name-cell" :title="row.name">
<span
v-if="isMemberRow(row)"
class="member-icon"
:class="{
'is-leader': row.is_leader,
'is-unbound': row.type === 'unbound'
}"
>
<el-icon><User /></el-icon>
</span>
<span class="entity-name">{{ row.name }}</span>
<el-tag
v-if="row.type === 'member' && row.is_leader"
size="small"
type="warning"
effect="light"
round
class="role-tag"
>
组长
</el-tag>
<el-tag
v-if="row.type === 'member' && row.role"
size="small"
:type="row.role === '医生' ? 'success' : 'info'"
effect="plain"
round
class="role-tag"
>
{{ row.role }}
</el-tag>
<el-tag
v-if="row.type === 'unbound'"
size="small"
type="danger"
effect="plain"
round
class="role-tag"
>
未绑定
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column
@@ -215,6 +262,7 @@
<script setup lang="ts" name="conversionStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { getConversionStatsOverview } from '@/api/stats'
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
return String(value ?? 0)
}
const rowKeyFn = (row: Record<string, any>) => {
if (row?.id !== undefined && row?.id !== null) {
return String(row.id)
}
return ''
}
const isMemberRow = (row: Record<string, any>) => {
return row?.type === 'member' || row?.type === 'unbound'
}
const rowClassName = ({ row }: { row: Record<string, any> }) => {
if (row?.type === 'unbound') {
return 'member-row member-row--unbound'
}
if (row?.type === 'member') {
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
}
return ''
}
onMounted(async () => {
await fetchOverview()
})
@@ -672,6 +741,27 @@ onMounted(async () => {
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-header-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.card-header-tip {
display: flex;
align-items: center;
gap: 6px;
color: #909399;
font-size: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
@@ -680,19 +770,93 @@ onMounted(async () => {
}
.dept-name-cell {
display: inline-block;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
max-width: 100%;
white-space: nowrap;
width: 100%;
line-height: 1.4;
overflow: hidden;
}
.entity-name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
color: inherit;
}
.member-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #e6f0ff;
color: #4a78ff;
font-size: 13px;
flex-shrink: 0;
}
.member-icon.is-leader {
background: #fff4e0;
color: #f59e0b;
}
.member-icon.is-unbound {
background: #fde8e8;
color: #ef4444;
}
.role-tag {
margin-left: 0;
flex-shrink: 0;
}
:deep(.dept-name-column .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
display: flex;
align-items: center;
}
:deep(.member-row) td {
background-color: #fafbff !important;
}
:deep(.member-row--leader) td {
background-color: #fff8ec !important;
}
:deep(.member-row--leader .entity-name) {
color: #d97706;
font-weight: 600;
}
:deep(.member-row--unbound) td {
background-color: #fff5f5 !important;
}
:deep(.member-row--unbound .entity-name) {
color: #ef4444;
font-family: monospace;
font-size: 12px;
}
.stats-detail-card :deep(.el-card__body) {
padding-top: 0;
}
.stats-chart {
height: 360px;
}
.stats-chart-wide {
height: 380px;
}
</style>
+72 -5
View File
@@ -49,15 +49,30 @@
v-model="form.channel_source"
placeholder="请选择渠道来源"
class="channel-source-select"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="item.value"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="needsChannelSourceDetail"
label="自媒体补充:"
required
>
<el-input
v-model="form.channel_source_detail"
clearable
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
class="channel-source-select"
/>
</el-form-item>
<!-- 预约医生 -->
<el-form-item label="预约医生:">
<div class="doctor-list">
@@ -233,9 +248,44 @@ const form = reactive({
date: '',
appointmentTime: '',
remark: '',
channel_source: '' as string
channel_source: '' as string,
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
const selectedChannelDictName = computed(() => {
const v = form.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
/** 仅白名单内字典项需填写补充说明 */
const needsChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
)
const onChannelSourceChange = (val: string) => {
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
form.channel_source_detail = ''
}
}
// 7
const dateOptions = computed<DateOption[]>(() => {
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
selectedDoctorId.value &&
form.date &&
form.appointmentTime &&
!!form.channel_source
!!form.channel_source &&
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
)
})
@@ -344,6 +395,7 @@ const open = async (patient: any) => {
form.appointmentTime = ''
form.remark = ''
form.channel_source = ''
form.channel_source_detail = ''
timeSlots.value = []
doctorRosterDates.value = []
lastVisit.value = ''
@@ -412,7 +464,17 @@ const loadLastVisit = async () => {
const loadChannelOptions = async () => {
try {
const data = await getDictData({ type: 'channels' })
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
rows.sort((a: any, b: any) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
// value el-select
channelOptions.value = rows.map((row: any) => ({
...row,
value: row?.value != null && row.value !== '' ? String(row.value) : ''
}))
} catch (e) {
console.error('加载渠道来源失败:', e)
channelOptions.value = []
@@ -612,6 +674,10 @@ const handleConfirm = async () => {
feedback.msgWarning('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
feedback.msgWarning('请填写自媒体渠道补充信息')
return
}
if (blockedByTodayDuplicate.value) {
feedback.msgWarning(
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
@@ -631,7 +697,8 @@ const handleConfirm = async () => {
appointment_time: form.appointmentTime,
appointment_type: form.appointmentType,
remark: form.remark,
channel_source: form.channel_source
channel_source: String(form.channel_source ?? ''),
channel_source_detail: form.channel_source_detail.trim()
}
console.log('提交预约参数:', params)
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
const raw = row.channel_source ?? row.channels ?? ''
if (raw === '' || raw === null || raw === undefined) return '—'
const key = String(raw)
return channelMap.value[key] || key
const base = channelMap.value[key] || key
const extra = String(row.channel_source_detail ?? '').trim()
return extra !== '' ? `${base}${extra}` : base
}
function sortRows(list: any[]) {
@@ -141,6 +143,8 @@ const load = async () => {
await loadChannels()
const res = await appointmentLists({
patient_id: props.diagnosisId,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
page_no: 1,
page_size: 500
})
@@ -2,8 +2,35 @@
<div class="assign-log-panel">
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
<el-table-column label="操作时间" width="175" prop="create_time_text" />
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
<el-table-column label="原医助" min-width="120">
<template #default="{ row }">
{{ assistantLogCellText(row, 'from') }}
</template>
</el-table-column>
<el-table-column label="新医助" min-width="120">
<template #default="{ row }">
{{ assistantLogCellText(row, 'to') }}
</template>
</el-table-column>
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ relatedPoCreatorCell(row) }}
</template>
</el-table-column>
<el-table-column label="快照·业务单创建时间" width="190">
<template #header>
<span>快照·业务单创建时间</span>
<el-tooltip
placement="top"
content="related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"
>
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
{{ relatedPoCreateTimeCell(row) }}
</template>
</el-table-column>
<el-table-column label="操作人" width="110" prop="operator_name" />
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
@@ -12,6 +39,7 @@
</template>
<script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
@@ -22,12 +50,56 @@ const props = defineProps<{
const loading = ref(false)
const rows = ref<any[]>([])
/** 对应日志表 related_po_creator_id:触发本次释放/指派的业务订单 creator */
function relatedPoCreatorCell(row: Record<string, unknown>): string {
const name = row.related_po_creator_name
if (name != null && String(name).trim() !== '') {
return String(name)
}
const id = Number(row.related_po_creator_id)
if (Number.isFinite(id) && id > 0) {
return `ID:${id}`
}
return '—'
}
/** 对应日志表 related_po_create_timeUnix)格式化为可读时间 */
function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
const t = row.related_po_create_time_text
if (t != null && String(t).trim() !== '') {
return String(t)
}
const ts = Number(row.related_po_create_time)
if (!Number.isFinite(ts) || ts <= 0) {
return '—'
}
const d = new Date(ts * 1000)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
const n = row[nameKey]
if (n != null && String(n).trim() !== '' && String(n) !== '—') {
return String(n)
}
const id = Number(row[idKey])
if (Number.isFinite(id) && id > 0) {
return `ID:${id}`
}
return '—'
}
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
rows.value = Array.isArray(data) ? data : []
const list = Array.isArray(data) ? data : []
rows.value = list
} catch (e) {
console.error(e)
rows.value = []
@@ -46,3 +118,12 @@ watch(
defineExpose({ refresh: load })
</script>
<style scoped>
.assign-log-col-hint {
margin-left: 4px;
vertical-align: middle;
color: var(--el-text-color-secondary);
cursor: help;
}
</style>
@@ -1,23 +1,37 @@
<template>
<div class="call-record-panel">
<el-alert
type="info"
show-icon
:closable="false"
class="mb-4"
title="视频通话与录制回放"
>
<!-- <p class="call-record-tip">
下列为与本诊单关联的通话记录医生接通并同步房间号后服务端会尝试自动发起云端混流录制需配置 CAM 密钥与云点播等录制完成后由腾讯云回调写入回放地址同时管理端浏览器会进行本地录制挂断后自动上传并合并到本条记录
</p>
<p class="call-record-tip muted">
回调 URL 示例<code>{{ callbackHint }}</code>
若配置了 <code>trtc.recording_callback_token</code>请在 URL 后附加
<code>?token=你的密钥</code>
</p> -->
</el-alert>
<div v-if="!readOnly" class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="handleToolbarUploadSuccess"
>
<el-button type="primary">上传视频</el-button>
</upload>
</div>
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
<el-table v-loading="loading" :data="rows" border stripe>
<template #empty>
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
{{
readOnly
? '暂无录制回放数据。'
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
}}
</div>
</div>
</template>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
</template>
</el-table-column>
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
@@ -25,7 +39,7 @@
{{ row.call_type === 1 ? '语音' : '视频' }}
</template>
</el-table-column>
<el-table-column label="房间号" width="140">
<el-table-column label="房间号" width="180">
<template #default="{ row }">
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
<span v-else class="text-gray-400"></span>
@@ -42,27 +56,18 @@
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="280">
<el-table-column v-if="!readOnly" label="上传回放" width="180">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="idx"
class="recording-item"
>
<video
v-if="isPlayableVideo(url)"
:src="url"
controls
preload="metadata"
class="recording-video"
/>
<el-link v-else :href="url" target="_blank" type="primary">
打开链接 {{ idx + 1 }}
</el-link>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
<upload
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="(response: any) => handleRowUploadSuccess(row, response)"
>
<el-button type="primary" plain size="small">上传视频</el-button>
</upload>
</template>
</el-table-column>
</el-table>
@@ -70,20 +75,36 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { getCallRecords } from '@/api/tcm'
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
import { ref, useTemplateRef, watch } from 'vue'
const props = defineProps<{
diagnosisId: number
}>()
type CallRecordRow = {
id: number
call_type: number
room_id?: string
status: number
recording_status_text?: string
recording_urls_list?: string[]
start_time_text?: string
end_time_text?: string
duration_text?: string
}
const props = withDefaults(
defineProps<{
diagnosisId: number
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
readOnly?: boolean
}>(),
{ readOnly: false }
)
const loading = ref(false)
const rows = ref<any[]>([])
const callbackHint = computed(() => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/trtc/recording-notify`
})
const rows = ref<CallRecordRow[]>([])
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
const load = async () => {
if (!props.diagnosisId) return
@@ -101,7 +122,7 @@ const load = async () => {
watch(
() => props.diagnosisId,
() => {
load()
void load()
},
{ immediate: true }
)
@@ -118,37 +139,76 @@ function statusText(status: number) {
return m[status] ?? '—'
}
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
const record = await createManualCallRecord({ diagnosis_id: props.diagnosisId })
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(record?.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
async function handleRowUploadSuccess(row: CallRecordRow, response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(row.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
function normalizeUploadUrl(response: any): string {
return String(response?.data?.uri || response?.data?.url || '').trim()
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-tip {
margin: 0 0 8px;
line-height: 1.5;
font-size: 13px;
&.muted {
color: var(--el-text-color-secondary);
}
code {
font-size: 12px;
word-break: break-all;
}
}
.recording-list {
.call-record-toolbar {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
border-radius: 4px;
background: #000;
.call-record-empty {
padding: 28px 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.call-record-empty__title {
font-size: 14px;
color: var(--el-text-color-primary);
}
.call-record-empty__desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
<template>
<div class="diagnosis-todo-list">
<div class="toolbar">
<el-button
type="primary"
:disabled="!props.diagnosisId || !hasDailyRecordPermission"
@click="handleAdd"
v-perms="DAILY_RECORD_PERMS"
>
+ 新增待办
</el-button>
<el-radio-group v-model="filterStatus" class="ml-3" @change="handleFilterChange">
<el-radio-button :value="''">全部</el-radio-button>
<el-radio-button :value="0">待执行</el-radio-button>
<el-radio-button :value="1">已发送</el-radio-button>
<el-radio-button :value="3">失败</el-radio-button>
<el-radio-button :value="2">已取消</el-radio-button>
</el-radio-group>
<el-button class="ml-3" @click="fetchList">刷新</el-button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
class="todo-table"
empty-text="暂无待办"
row-key="id"
>
<el-table-column label="提醒时间" prop="remind_time_text" width="160" />
<el-table-column label="内容" prop="content" min-width="240" show-overflow-tooltip />
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">
{{ row.status_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="120">
<template #default="{ row }">{{ row.creator_name || '-' }}</template>
</el-table-column>
<el-table-column label="推送时间" prop="notified_at_text" width="160">
<template #default="{ row }">{{ row.notified_at_text || '-' }}</template>
</el-table-column>
<el-table-column label="失败原因" prop="error" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 3" class="text-danger">{{ row.error || '未知错误' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="!props.readOnly && row.status === 0 && row.can_cancel"
link
type="danger"
@click="handleCancel(row)"
v-perms="DAILY_RECORD_PERMS"
>
取消
</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<pagination v-model="pager" @change="fetchList" />
</div>
<!-- 新增弹框 -->
<el-dialog
v-model="addDialogVisible"
title="新增待办"
width="520px"
:close-on-click-modal="false"
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
<el-form-item label="提醒时间" prop="remindAt">
<el-date-picker
v-model="addForm.remindAt"
type="datetime"
placeholder="选择提醒时间"
format="YYYY-MM-DD HH:mm"
value-format="x"
:disabled-date="disablePastDate"
class="w-full"
/>
</el-form-item>
<el-form-item label="提醒内容" prop="content">
<el-input
v-model="addForm.content"
type="textarea"
:rows="4"
:maxlength="500"
show-word-limit
placeholder="例:3 天后回访患者复测餐后血糖"
/>
</el-form-item>
<el-form-item label="提醒人">
<span class="text-muted">企微推送给当前登录账号创建人本人</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="handleAddSubmit">
提交
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { diagnosisTodoLists, diagnosisTodoAdd, diagnosisTodoCancel } from '@/api/tcm'
import { computed } from 'vue'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
const DAILY_RECORD_PERMS = ['tcm.diagnosis/dailyRecord']
interface Props {
diagnosisId: number | null | undefined
patientId?: number | null
patientName?: string
readOnly?: boolean
}
const props = defineProps<Props>()
const hasDailyRecordPermission = computed(() => hasPermission(DAILY_RECORD_PERMS))
// pager <pagination v-model="pager" />
const pager = reactive({
page: 1,
size: 15,
count: 0,
loading: false,
lists: [] as any[]
})
const filterStatus = ref<number | ''>('')
const fetchList = async () => {
if (!props.diagnosisId || Number(props.diagnosisId) <= 0 || !hasDailyRecordPermission.value) {
pager.lists = []
pager.count = 0
return
}
pager.loading = true
try {
const params: {
page_no: number
page_size: number
diagnosis_id: number
status?: number
} = {
page_no: pager.page,
page_size: pager.size,
diagnosis_id: Number(props.diagnosisId)
}
if (filterStatus.value !== '' && filterStatus.value !== undefined) {
params.status = Number(filterStatus.value)
}
const res: any = await diagnosisTodoLists(params)
pager.count = Number(res?.count ?? 0)
pager.lists = Array.isArray(res?.lists) ? res.lists : []
} catch {
// request toast
pager.lists = []
pager.count = 0
} finally {
pager.loading = false
}
}
watch(
() => props.diagnosisId,
() => {
pager.page = 1
fetchList()
},
{ immediate: true }
)
const handleFilterChange = () => {
pager.page = 1
fetchList()
}
const statusTagType = (status: number): 'info' | 'success' | 'warning' | 'danger' => {
switch (Number(status)) {
case 0:
return 'info'
case 1:
return 'success'
case 2:
return 'warning'
case 3:
return 'danger'
default:
return 'info'
}
}
// ========== ==========
const addDialogVisible = ref(false)
const addSubmitting = ref(false)
const addFormRef = ref<FormInstance>()
const addForm = reactive({
remindAt: '' as string | number,
content: ''
})
const addRules: FormRules = {
remindAt: [{ required: true, message: '请选择提醒时间', trigger: 'change' }],
content: [
{ required: true, message: '请输入提醒内容', trigger: 'blur' },
{ max: 500, message: '内容最多 500 字', trigger: 'blur' }
]
}
const disablePastDate = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
const handleAdd = () => {
if (!props.diagnosisId || !hasDailyRecordPermission.value) {
feedback.msgWarning('诊单未保存,无法添加待办')
return
}
addForm.remindAt = ''
addForm.content = ''
addDialogVisible.value = true
nextTick(() => addFormRef.value?.clearValidate())
}
const handleAddSubmit = async () => {
if (!addFormRef.value) return
const valid = await addFormRef.value.validate().catch(() => false)
if (!valid) return
const remindMs = Number(addForm.remindAt)
if (!Number.isFinite(remindMs) || remindMs <= Date.now() + 30 * 1000) {
feedback.msgWarning('提醒时间必须晚于当前时间至少 30 秒')
return
}
addSubmitting.value = true
try {
await diagnosisTodoAdd({
diagnosis_id: Number(props.diagnosisId),
content: addForm.content.trim(),
remind_time: Math.floor(remindMs / 1000)
})
addDialogVisible.value = false
fetchList()
} catch {
// request toast
} finally {
addSubmitting.value = false
}
}
// ========== ==========
const handleCancel = async (row: any) => {
if (!hasDailyRecordPermission.value || props.readOnly) return
try {
await feedback.confirm(`确认取消「${row.remind_time_text} · ${truncate(row.content, 30)}」吗?`)
} catch {
return
}
try {
await diagnosisTodoCancel({ id: Number(row.id) })
feedback.msgSuccess('已取消')
fetchList()
} catch {
// request toast
}
}
const truncate = (text: string, len: number) => {
if (!text) return ''
return text.length > len ? text.slice(0, len) + '...' : text
}
defineExpose({ refresh: fetchList })
</script>
<style lang="scss" scoped>
.diagnosis-todo-list {
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.todo-table {
width: 100%;
}
.pagination-wrap {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.text-danger {
color: var(--el-color-danger);
}
.text-muted {
color: var(--el-text-color-placeholder);
}
}
</style>
@@ -4,14 +4,18 @@
<p class="panel-tip">
展示单聊记录已合并患者
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据来自 IM 漫游若未开通漫游或某账号与该患者无会话对应侧无消息
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="loading" @click="refreshLive">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新同步云端最新
重新加载已归档
</el-button>
</div>
@@ -68,8 +72,9 @@
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh } from '@element-plus/icons-vue'
import { getImChatMessages } from '@/api/tcm'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
@@ -78,6 +83,7 @@ const props = defineProps<{
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
@@ -139,13 +145,13 @@ 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
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
@@ -162,19 +168,33 @@ async function load(onlyArchived = false) {
}
}
function refreshLive() {
load(false)
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load(true)
load()
},
{ immediate: true }
)
defineExpose({ refresh: refreshLive })
defineExpose({ refresh: reloadArchived })
</script>
<style scoped lang="scss">
@@ -0,0 +1,369 @@
<template>
<div class="card case-card">
<div class="card-title">
患者病例
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag?.diagnosis_date || apt?.appointment_date || '—' }}</span>
</div>
<!-- 基本信息 -->
<div class="grid-block">
<div class="grid-title">基本信息</div>
<div class="kv-grid four">
<KVItem label="诊单ID" :value="diag?.patient_id" />
<KVItem label="姓名" :value="diag?.patient_name" />
<KVItem label="身份证号" :value="diag?.id_card" />
<KVItem label="手机号" :value="maskPhone(diag?.phone || apt?.patient_phone)" />
<KVItem label="性别" :value="textOf('gender')" />
<KVItem label="年龄" :value="diag?.age != null ? diag.age + '岁' : ''" />
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
<KVItem label="地区" :value="diag?.region" />
</div>
</div>
<!-- 生命体征 -->
<div class="grid-block">
<div class="grid-title">生命体征</div>
<div class="kv-grid four">
<KVItem label="身高" :value="diag?.height ? diag.height + ' cm' : ''" />
<KVItem label="体重" :value="diag?.weight ? diag.weight + ' kg' : ''" />
<MetricKVItem
label="高压"
:value="diag?.systolic_pressure"
unit=" mmHg"
:high="isHighSystolicPressure(diag?.systolic_pressure)"
/>
<MetricKVItem
label="低压"
:value="diag?.diastolic_pressure"
unit=" mmHg"
:high="isHighDiastolicPressure(diag?.diastolic_pressure)"
/>
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
<MetricKVItem
label="空腹血糖"
:value="diag?.fasting_blood_sugar"
unit=" mmol/L"
:high="isHighFastingBloodSugar(diag?.fasting_blood_sugar, diag?.age)"
/>
<KVItem label="在用药物" :value="diag?.current_medications" multiline />
</div>
</div>
<!-- 主诉 -->
<div class="grid-block">
<div class="grid-title">主诉</div>
<div class="kv-grid four">
<KVItem label="当地医院诊断日期" :value="diag?.diagnosis_date" />
<KVItem
label="发现糖尿病病史"
:value="formatDiabetesDiscoveryDisplay
? formatDiabetesDiscoveryDisplay(diag?.diabetes_discovery_year)
: diag?.diabetes_discovery_year"
/>
<KVItem label="当地就诊医院" :value="diag?.local_hospital_name" />
<KVItem label="当地医院诊断结果" :value="joinArray(diag?.local_hospital_diagnosis)" />
</div>
</div>
<!-- 现病史后端已翻译 *_text -->
<div class="grid-block">
<div class="grid-title">现病史</div>
<div class="kv-grid three">
<KVItem label="口腔感觉" :value="textOf('appetite')" />
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
<KVItem label="小便情况" :value="textOf('urine_condition')" />
<KVItem label="大便情况" :value="textOf('stool_condition')" />
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
<KVItem label="其他补充" :value="diag?.symptoms" span="3" multiline />
</div>
</div>
<!-- 既往史 -->
<div class="grid-block">
<div class="grid-title">既往史</div>
<div class="kv-grid three">
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
</div>
</div>
<!-- 其他病史 -->
<div class="grid-block">
<div class="grid-title">其他病史</div>
<div class="kv-grid three">
<KVItem label="外伤史" :value="textOf('trauma_history')" />
<KVItem label="手术史" :value="textOf('surgery_history')" />
<KVItem label="过敏史" :value="textOf('allergy_history')" />
<KVItem label="家族病史" :value="textOf('family_history')" />
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
</div>
</div>
<!-- 病史补充 -->
<div class="grid-block" v-if="diag?.remark">
<div class="grid-title">病史补充</div>
<div class="kv-grid one">
<KVItem label="" :value="diag.remark" multiline />
</div>
</div>
<!-- 处方意见 -->
<div class="grid-block">
<div class="grid-title">处方意见</div>
<div class="rx-note">
{{ apt?.has_prescription ? '已开方。' : '当前未开方。' }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import {
isHighSystolicPressure,
isHighDiastolicPressure,
isHighFastingBloodSugar
} from '@/utils/blood-thresholds'
import { maskPhone, joinArray, makeTextOf } from '@/utils/diag-display'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
interface Props {
apt?: Record<string, any> | null
diag?: Record<string, any> | null
}
const props = withDefaults(defineProps<Props>(), {
apt: () => ({}),
diag: () => ({})
})
const caseTypeLabel = computed(() => {
const d: any = props.diag || {}
return d.consultation_type === 'follow_up' ? '复诊' : '初诊'
})
const textOf = makeTextOf(() => props.diag || {})
// ========== ==========
/**
* 局部渲染组件左侧 label + 右侧 value支持 span / 换行与抽离前 reception 内联实现一致
*/
const KVItem = (kvProps: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
const val = kvProps.value
const empty =
val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
return h(
'div',
{
class: ['kv-item', kvProps.multiline ? 'multiline' : '', empty ? 'empty' : ''],
style: kvProps.span ? `grid-column: span ${kvProps.span}` : ''
},
[
h('span', { class: 'kv-label' }, kvProps.label),
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
]
)
}
/**
* 数值型 KV偏高时红色 + 箭头与抽离前 reception 内联实现一致
*/
const MetricKVItem = (mkvProps: {
label: string
value: any
unit?: string
span?: string | number
high?: boolean
}) => {
const empty = mkvProps.value === undefined || mkvProps.value === null || mkvProps.value === ''
return h(
'div',
{
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
style: mkvProps.span ? `grid-column: span ${mkvProps.span}` : ''
},
[
h('span', { class: 'kv-label' }, mkvProps.label),
h(
'span',
{ class: ['kv-value', 'metric-kv-value', mkvProps.high ? 'high' : ''] },
empty
? '—'
: [
`${mkvProps.value}${mkvProps.unit || ''}`,
mkvProps.high ? h('span', { class: 'metric-up' }, '↑') : null
]
)
]
)
}
</script>
<style lang="scss" scoped>
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: var(--rx-shadow);
}
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.case-sub {
font-size: 12px;
font-weight: 400;
color: var(--rx-soft);
font-family: var(--rx-font-data);
}
.grid-block {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px dashed var(--rx-line);
&:first-of-type {
border-top: none;
padding-top: 0;
}
}
.grid-title {
font-size: 13px;
font-weight: 600;
color: var(--rx-accent-strong);
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 6px;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--rx-accent);
}
}
.kv-grid {
display: grid;
gap: 8px 16px;
&.one {
grid-template-columns: 1fr;
}
&.two {
grid-template-columns: repeat(2, 1fr);
}
&.three {
grid-template-columns: repeat(3, 1fr);
}
&.four {
grid-template-columns: repeat(4, 1fr);
}
}
:deep(.kv-item) {
display: flex;
gap: 6px;
font-size: 12.5px;
line-height: 1.55;
min-width: 0;
&.multiline {
flex-direction: column;
gap: 2px;
}
.kv-label {
color: var(--rx-soft);
flex-shrink: 0;
min-width: 58px;
}
.kv-value {
color: var(--rx-ink);
word-break: break-word;
flex: 1;
font-weight: 500;
}
&.empty .kv-value {
color: #c0c4cc;
font-weight: 400;
}
&.multiline .kv-value {
white-space: pre-wrap;
font-weight: 400;
}
}
:deep(.metric-kv-item .metric-kv-value) {
display: inline-flex;
align-items: center;
gap: 4px;
&.high {
color: #dc2626;
font-weight: 700;
}
}
:deep(.metric-up) {
font-size: 14px;
line-height: 1;
color: #dc2626;
}
.rx-note {
font-size: 13px;
color: var(--rx-muted);
line-height: 1.6;
padding: 12px 14px;
background: linear-gradient(135deg, #fafbfd 0%, #f5f7fb 100%);
border-radius: 8px;
border-left: 3px solid var(--rx-accent);
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="card patient-card">
<div class="card-title">患者信息</div>
<div class="patient-hero">
<div class="patient-name">{{ apt?.patient_name || '—' }}</div>
<div class="patient-meta">
<div>
{{ maskPhone(apt?.patient_phone) }} · {{ formatGender(diag?.gender) }} ·
{{ diag?.age != null ? diag.age + '岁' : '—' }}
</div>
<div>
{{ diag?.height ? diag.height + 'cm' : '—' }} /
{{ diag?.weight ? diag.weight + 'kg' : '—' }} · {{ diag?.region || '—' }}
</div>
<div>
预约{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
{{ formatPeriod(apt?.period) }}
</div>
<div>医生{{ apt?.doctor_name || '—' }} 医助{{ apt?.assistant_name || '—' }}</div>
<div>
状态{{ apt?.status_desc || '—' }} ·
{{ apt?.has_prescription ? '已开方' : '未开方' }}
</div>
<div v-if="apt?.remark">备注{{ apt.remark }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { maskPhone, formatGender, formatPeriod } from '@/utils/diag-display'
interface Props {
apt?: Record<string, any> | null
diag?: Record<string, any> | null
}
withDefaults(defineProps<Props>(), {
apt: () => ({}),
diag: () => ({})
})
</script>
<style lang="scss" scoped>
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: var(--rx-shadow);
}
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.patient-hero {
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
border: 1px solid #dde7ff;
border-radius: 12px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
right: -20px;
top: -20px;
width: 100px;
height: 100px;
border-radius: 50%;
background: radial-gradient(circle, rgba(79, 140, 255, 0.08) 0%, transparent 70%);
}
}
.patient-name {
font-size: 22px;
font-weight: 700;
color: var(--rx-ink);
letter-spacing: -0.01em;
}
.patient-meta {
color: var(--rx-muted);
font-size: 13px;
line-height: 1.7;
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
z-index: 1;
}
</style>
@@ -0,0 +1,716 @@
<template>
<div class="patient-order-list">
<div v-if="!patientIdAvailable" class="po-empty-tip">
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div>
<template v-else>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
stripe
empty-text="暂无业务订单"
>
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ row.doctor_name || '—' }}
</template>
</el-table-column>
<el-table-column label="医助" prop="assistant_name" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ row.assistant_name || '—' }}
</template>
</el-table-column>
<el-table-column label="履约状态" width="110">
<template #default="{ row }">
<el-tag :type="fulfillmentTagType(row.fulfillment_status)" size="small" effect="light">
{{ fulfillmentText(row.fulfillment_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['tcm.prescriptionOrder/detail']"
type="primary"
link
@click="openDetail(row.id)"
>查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<pagination v-model="pager" @change="getLists" />
</div>
</template>
<!-- 业务订单详情抽屉只读 order_list.vue 详情保持一致 -->
<el-drawer
v-model="detailVisible"
size="80%"
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<template #header>
<div class="flex items-center">
<span class="font-semibold text-lg">业务订单详情</span>
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
</template>
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
<!-- 顶部数据概览 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">总金额</div>
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付总额</div>
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">需代收</div>
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单不含测试价格改患者信息药材明细 -->
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">处方详情</span>
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
开方人<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
</span>
</div>
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
</div>
</template>
<template v-if="detailPrescription">
<el-descriptions :column="2" border size="small" class="bg-white">
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
</el-descriptions-item>
<el-descriptions-item label="处方金额">
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="消费者审核">
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
</div>
<div>
<span class="text-gray-500">订单设置</span>
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="无处方数据" :image-size="72" />
</el-card>
<!-- 右侧未关联 + 已关联支付单 -->
<div class="flex flex-col gap-4 h-full">
<el-card shadow="never" class="border-gray-100">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-[15px]">未关联的收款记录</span>
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
</div>
</template>
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="无未关联的收款单" :image-size="60" />
</el-card>
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">关联收款记录</span>
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
</div>
</template>
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
</div>
</div>
<!-- 履约与收货信息 -->
<el-card shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="收货手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 物流轨迹只读 -->
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
</div>
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
<span class="text-gray-800">状态{{ logisticsTracePayload.state_text }}</span>
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
</div>
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
</div>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, watch, ref, onMounted } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import {
prescriptionOrderLists,
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user'
const userStore = useUserStore()
const props = defineProps<{
diagnosisId: number
patientId?: number | string
}>()
const patientIdNum = computed(() => {
const n = Number(props.patientId)
return Number.isFinite(n) && n > 0 ? n : 0
})
const patientIdAvailable = computed(() => patientIdNum.value > 0)
const queryParams = reactive<Record<string, number | string>>({})
const { pager, getLists, resetPage } = usePaging({
fetchFun: prescriptionOrderLists,
params: queryParams,
size: 10
})
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
queryParams.context_diagnosis_id = props.diagnosisId
}
if (patientIdAvailable.value) {
queryParams.patient_id = patientIdNum.value
}
queryParams.scene = 'diagnosis_edit'
}
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
/**
* 是否展示处方审核相关字段
*/
const showPrescriptionAuditFilter = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
//
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
return p && typeof p === 'object' ? p : null
})
const detailLinkedPayOrders = computed(() => {
const arr = detailData.value?.linked_pay_orders
return Array.isArray(arr) ? arr : []
})
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
})
const detailRecipientRxPhoneMismatch = computed(() => {
const rx = normalizeBizPhone(detailPrescription.value?.phone)
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
if (!rx || !biz) return false
return rx !== biz
})
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(' ') : '—'
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
if (!raw) return [] as string[][]
return raw.split('').map((s) => s.trim()).filter(Boolean)
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
.filter((segs) => segs.length > 0)
})
//
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const logisticsTraceList = computed(() => {
const p = logisticsTracePayload.value
if (!p || !Array.isArray(p.traces)) return []
return p.traces as Array<{ time: string; context: string }>
})
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
express_company: detailLogisticsExpress.value
}
if (digits.length >= 4) params.phone_tail = digits
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
} catch {
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
}
}
//
async function openDetail(id: number) {
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id })
const d = res?.data ?? res ?? null
detailData.value = d
if (d) {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
fetchLogs(id)
if (d.diagnosis_id) {
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
}
}
} catch (e: any) {
feedback.msgError(e?.message || '加载详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
try {
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
const lists = res?.lists ?? res?.data?.lists ?? []
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
} catch { detailUnlinkedPayOrders.value = [] }
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
}
async function fetchLogs(id: number) {
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
try {
const res: any = await prescriptionOrderLogs({ id })
detailLogs.value = res?.data ?? res ?? []
} catch { detailLogs.value = [] }
}
//
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
const formatAmount = (value: unknown) => {
const n = Number(value)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatMoney(v: unknown) {
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
const d = new Date(value * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
return String(value)
}
function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
function feeTypeText(t: number | undefined) {
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
return m[Number(t)] ?? '—'
}
// order_list.vue dict server_order
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
try {
const data: any = await getDictData({ type: 'server_order' })
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
servicePackageOptions.value = list.filter((item) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
function formatServicePackage(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value.map((v) => String(v)).filter((v) => v !== '')
} else if (typeof value === 'string') {
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const opt = servicePackageOptions.value.find((o) => o.value === val)
return opt ? opt.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
})
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
function orderStatusText(s: number | undefined) {
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
return m[Number(s)] ?? '—'
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
return m[Number(s)] ?? '—'
}
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
const n = Number(s)
if (n === 3 || n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4 || n === 9 || n === 10) return 'danger'
if (n === 2 || n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
return 'info'
}
function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', complete: '完成订单'
}
return m[act] || act
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
buildParams()
resetPage()
},
{ immediate: true }
)
defineExpose({ refresh: () => getLists() })
</script>
<style scoped lang="scss">
.patient-order-list {
padding: 4px 0;
}
.po-empty-tip {
padding: 24px 0;
}
.audit-stamp {
position: absolute;
top: 12px;
right: 16px;
transform: rotate(15deg);
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.stamp-inner {
border: 3px solid currentColor;
border-radius: 8px;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
}
.stamp-pass { color: #22c55e; }
.stamp-reject { color: #ef4444; }
</style>
@@ -0,0 +1,152 @@
<template>
<div v-if="normalized.length" class="recording-list">
<div class="recording-item">
<template v-if="preferred">
<RecordingVideoPlayer
v-if="shouldInlineRecording(preferred)"
:key="`${recordId}-${preferred}`"
:src="preferred"
/>
<el-link v-else :href="resolveRecordingLink(preferred)" target="_blank" type="primary">
打开回放
</el-link>
</template>
<div v-if="alternates.length" class="recording-alternates">
<span class="recording-alternates__label">备用地址</span>
<div class="recording-alternates__links">
<el-link
v-for="(url, linkIndex) in alternates"
:key="`${recordId}-alt-${linkIndex}-${url.slice(-32)}`"
:href="resolveRecordingLink(url)"
target="_blank"
type="primary"
class="recording-alternates__link"
>
{{ alternateLinkLabel(url, linkIndex) }}
</el-link>
</div>
</div>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
<script setup lang="ts">
import RecordingVideoPlayer from './RecordingVideoPlayer.vue'
import useAppStore from '@/stores/modules/app'
import { computed } from 'vue'
const props = defineProps<{
recordId: number
/** getCallRecords 返回的 recording_urls_list(可多来源:COS/VOD、m3u8/mp4 */
urls?: string[]
}>()
const appStore = useAppStore()
/** 去重、trim */
const normalized = computed(() => {
const raw = props.urls || []
const seen = new Set<string>()
const out: string[] = []
for (const u of raw) {
const s = String(u ?? '').trim()
if (!s || seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
})
/** 优选一条内嵌:mp4 > 点播 m3u8 > COS m3u8 > 其它 m3u8 > 首个 */
const preferred = computed((): string | null => {
const list = normalized.value
if (!list.length) return null
const mp4 = list.find((u) => /\.mp4(\?|#|$)/i.test(u))
if (mp4) return mp4
const vodHls = list.find(
(u) => /\.m3u8(\?|#|$)/i.test(u) && /vod-qcloud\.com/i.test(u)
)
if (vodHls) return vodHls
const cosHls = list.find(
(u) =>
/\.m3u8(\?|#|$)/i.test(u) && /\.cos\.[^/]+\.myqcloud\.com/i.test(u)
)
if (cosHls) return cosHls
const anyHls = list.find((u) => /\.m3u8(\?|#|$)/i.test(u))
if (anyHls) return anyHls
return list[0]
})
const alternates = computed(() => {
const list = normalized.value
const p = preferred.value
if (!p) return list.slice(1)
return list.filter((u) => u !== p)
})
function shouldInlineRecording(url: string) {
if (!url || typeof url !== 'string') return false
const u = url.trim()
if (/^https?:\/\//i.test(u)) {
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u)
}
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u) || u.startsWith('/uploads/')
}
function resolveRecordingLink(url: string) {
return appStore.getImageUrl(String(url || '').trim())
}
function alternateLinkLabel(url: string, index: number): string {
const u = url.trim()
if (/\.mp4(\?|#|$)/i.test(u)) return `MP4 ${index + 1}`
if (/vod-qcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `点播 ${index + 1}`
if (/\.cos\.[^/]+\.myqcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `COS HLS ${index + 1}`
if (/\.m3u8/i.test(u)) return `HLS ${index + 1}`
return `链接 ${index + 1}`
}
</script>
<style scoped lang="scss">
.recording-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
transform: translateZ(0);
}
.recording-alternates {
margin-top: 4px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.recording-alternates__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.recording-alternates__links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.recording-alternates__link {
font-size: 12px;
}
</style>
@@ -0,0 +1,379 @@
<template>
<div ref="wrapRef" class="recording-playback">
<div class="recording-playback__shell">
<video
v-show="!failed"
ref="videoRef"
class="recording-video"
controls
playsinline
webkit-playsinline
:preload="progressivePreload"
@error="onVideoError"
/>
<div
v-if="!failed && showBusyOverlay"
class="recording-playback__overlay"
role="button"
tabindex="0"
@click="forceEnable"
@keydown.enter.prevent="forceEnable"
>
<template v-if="!loadEnabled">
<span class="recording-playback__overlay-title">预览待加载</span>
<span class="recording-playback__overlay-desc">滚动至此处将自动加载减轻并发卡顿也可点击立即加载</span>
</template>
<template v-else>
<el-icon class="recording-playback__spin"><Loading /></el-icon>
<span>正在加载</span>
</template>
</div>
</div>
<template v-if="failed">
<el-link :href="playbackUrl || '#'" target="_blank" type="primary">在新窗口打开</el-link>
<div class="recording-playback__hint">
内嵌播放失败编码不支持跨域或私有存储等时可尝试在新窗口打开
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { Loading } from '@element-plus/icons-vue'
import useAppStore from '@/stores/modules/app'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
src: string
}>()
const appStore = useAppStore()
const wrapRef = ref<HTMLElement | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
const failed = ref(false)
/** 已进入可视区域或用户点击,允许发起请求 */
const loadEnabled = ref(false)
/** 已开始拉流但未就绪 */
const loadingMedia = ref(false)
let hlsInstance: import('hls.js').default | null = null
let intersectionObserver: IntersectionObserver | null = null
/** 防止 tearDown / 上一次 attach 的异步事件误结束 loading */
let attachGeneration = 0
let loadingSlowTimer: ReturnType<typeof setTimeout> | null = null
function clearLoadingSlowTimer(): void {
if (loadingSlowTimer !== null) {
clearTimeout(loadingSlowTimer)
loadingSlowTimer = null
}
}
const playbackUrl = computed(() => {
const raw = String(props.src || '').trim()
if (!raw) return ''
return normalizeTencentRecordingUrl(appStore.getImageUrl(raw))
})
/** COS/VOD 直连 mp4:允许浏览器优先拉元数据;HLS 仍 none,交给 hls.js */
const progressivePreload = computed(() => {
if (!loadEnabled.value) return 'none'
const u = playbackUrl.value
return u && !isHlsUrl(u) ? 'metadata' : 'none'
})
const showBusyOverlay = computed(() => !loadEnabled.value || loadingMedia.value)
/** 腾讯云录制路径里常见未编码的 “+”,部分环境下会导致视频长时间拿不到元数据 */
function normalizeTencentRecordingUrl(url: string): string {
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const u = new URL(url)
const h = u.hostname.toLowerCase()
if (
h.includes('myqcloud.com') ||
h.includes('vod-qcloud.com') ||
h.includes('vod-qcloud')
) {
if (u.pathname.includes('+')) {
u.pathname = u.pathname.replace(/\+/g, '%2B')
}
return u.href
}
} catch {
/* ignore */
}
return url
}
function isHlsUrl(url: string): boolean {
return /\.m3u8(\?|#|$)/i.test(url)
}
function onVideoError(): void {
loadingMedia.value = false
clearLoadingSlowTimer()
const el = videoRef.value
const code = el?.error?.code
if (code === MediaError.MEDIA_ERR_ABORTED || code === 1) {
return
}
failed.value = true
}
function tearDown(): void {
if (hlsInstance) {
hlsInstance.destroy()
hlsInstance = null
}
const el = videoRef.value
if (el) {
el.pause()
el.removeAttribute('src')
el.load()
}
}
function bindProgressiveLoadingEnd(el: HTMLVideoElement, gen: number): void {
let settled = false
const done = () => {
if (settled || gen !== attachGeneration) return
settled = true
clearLoadingSlowTimer()
loadingMedia.value = false
}
el.addEventListener('loadedmetadata', () => done('loadedmetadata'), { once: true })
el.addEventListener('loadeddata', () => done('loadeddata'), { once: true })
el.addEventListener('canplay', () => done('canplay'), { once: true })
el.addEventListener('playing', () => done('playing'), { once: true })
el.addEventListener(
'progress',
() => {
try {
if (el.buffered.length > 0 && el.buffered.end(0) > 0) {
done('progress-buffered')
}
} catch {
/* ignore */
}
},
{ passive: true }
)
}
async function attachSource(): Promise<void> {
if (!loadEnabled.value) return
await nextTick()
const el = videoRef.value
const url = playbackUrl.value
if (!el) return
if (!url) {
loadingMedia.value = false
clearLoadingSlowTimer()
return
}
const gen = ++attachGeneration
loadingMedia.value = true
clearLoadingSlowTimer()
tearDown()
failed.value = false
if (isHlsUrl(url)) {
try {
const mod = await import('hls.js')
const Hls = mod.default
if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: false,
//
maxBufferLength: 20,
maxMaxBufferLength: 120
})
hlsInstance.on(mod.Events.MANIFEST_PARSED, () => {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
})
hlsInstance.on(mod.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
tearDown()
}
})
hlsInstance.loadSource(url)
hlsInstance.attachMedia(el)
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
if (el.canPlayType('application/vnd.apple.mpegurl')) {
bindProgressiveLoadingEnd(el, gen)
el.src = url
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
} catch {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
bindProgressiveLoadingEnd(el, gen)
el.src = url
// moov mp4 loadedmetadata
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 25000)
}
function setupLazyLoad(): void {
const root = wrapRef.value
if (!root || typeof IntersectionObserver === 'undefined') {
loadEnabled.value = true
void attachSource()
return
}
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (en.isIntersecting) {
intersectionObserver?.disconnect()
intersectionObserver = null
loadEnabled.value = true
void attachSource()
break
}
}
},
{ root: null, rootMargin: '240px 0px', threshold: 0 }
)
intersectionObserver.observe(root)
}
function forceEnable(): void {
intersectionObserver?.disconnect()
intersectionObserver = null
if (!loadEnabled.value) {
loadEnabled.value = true
}
void attachSource()
}
onMounted(() => {
void nextTick(() => {
setupLazyLoad()
})
})
watch(playbackUrl, () => {
if (!loadEnabled.value) return
void attachSource()
})
onUnmounted(() => {
intersectionObserver?.disconnect()
intersectionObserver = null
clearLoadingSlowTimer()
attachGeneration++
tearDown()
})
</script>
<style scoped lang="scss">
.recording-playback {
width: 100%;
min-height: 1px;
}
.recording-playback__shell {
position: relative;
width: 100%;
}
.recording-video {
display: block;
width: 100%;
max-width: 100%;
max-height: 180px;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
border-radius: 4px;
background: #000;
transform: translateZ(0);
}
.recording-playback__overlay {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 12px;
line-height: 1.45;
text-align: center;
cursor: pointer;
user-select: none;
}
.recording-playback__overlay-title {
font-weight: 600;
}
.recording-playback__overlay-desc {
opacity: 0.92;
font-size: 11px;
}
.recording-playback__spin {
font-size: 24px;
animation: recording-spin 0.9s linear infinite;
}
@keyframes recording-spin {
to {
transform: rotate(360deg);
}
}
.recording-playback__hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>
@@ -0,0 +1,482 @@
<template>
<div class="tracking-matrix" v-loading="loading">
<div class="toolbar">
<el-radio-group v-model="rangeMode" @change="onRangeModeChange">
<el-radio-button value="7">最近 7 </el-radio-button>
<el-radio-button value="30">最近 30 </el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="rangeMode === 'custom'"
v-model="customRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
size="default"
:disabled-date="disabledFutureDate"
@change="onCustomRangeChange"
/>
<span class="hint">
超阈值红字年龄分段&lt;50空7/餐950空8/餐10
</span>
</div>
<el-empty
v-if="!loading && visibleDates.length === 0"
description="所选区间内无任何打卡记录"
:image-size="80"
/>
<el-table
v-else
:data="rows"
border
stripe
class="matrix-table"
empty-text="暂无数据"
row-key="date"
max-height="640"
>
<el-table-column prop="dateLabel" label="日期" width="120" fixed="left" />
<el-table-column label="血糖 (mmol/L)" min-width="180">
<template #default="{ row }">
<div v-if="row.bloodAny" class="cell-blood">
<span :class="{ 'cell-high': row.fastingHigh }">
<span class="value">{{ row.fasting ?? '—' }}</span>
<span v-if="row.fastingHigh" class="cell-up"></span>
</span>
<span :class="{ 'cell-high': row.postprandialHigh }">
<span class="value">{{ row.postprandial ?? '—' }}</span>
<span v-if="row.postprandialHigh" class="cell-up"></span>
</span>
<span :class="{ 'cell-high': row.otherHigh }">
其他
<span class="value">{{ row.other ?? '—' }}</span>
<span v-if="row.otherHigh" class="cell-up"></span>
</span>
</div>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="血压 (mmHg)" width="120" align="center">
<template #default="{ row }">
<span v-if="row.bp" :class="{ 'cell-high': row.bpHigh }">
{{ row.bp }}
<span v-if="row.bpHigh" class="cell-up"></span>
</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="西药" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.westernMedicine">{{ row.westernMedicine }}</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="胰岛素" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.insulin">{{ row.insulin }}</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="饮食打卡" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.diet"
placement="top"
:disabled="!row.diet"
effect="light"
>
<template #content>
<div class="diet-tip">
<div>{{ row.diet.breakfast || '—' }}</div>
<div>{{ row.diet.lunch || '—' }}</div>
<div>{{ row.diet.dinner || '—' }}</div>
</div>
</template>
<span class="cell-checked">已打卡</span>
</el-tooltip>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="运动打卡" min-width="160" align="center">
<template #default="{ row }">
<el-tooltip v-if="row.exercise" placement="top" effect="light">
<template #content>
<div class="exercise-tip">
<div>强度{{ row.exercise.intensityText || '—' }}</div>
<div>时长{{ row.exercise.duration ? row.exercise.duration + ' min' : '—' }}</div>
<div v-if="row.exercise.remark">备注{{ row.exercise.remark }}</div>
</div>
</template>
<span class="cell-checked">
已打卡
<span v-if="row.exercise.duration" class="meta">· {{ row.exercise.duration }}min</span>
<span v-if="row.exercise.intensityText" class="meta">· {{ row.exercise.intensityText }}</span>
</span>
</el-tooltip>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
isHighFastingBloodSugar,
isHighPostprandialBloodSugar,
isHighOtherBloodSugar,
isHighBloodPressure,
formatBp
} from '@/utils/blood-thresholds'
import { diagnosisTrackingWindow } from '@/api/tcm'
interface BloodRecord {
record_date?: string
record_date_ts?: number
fasting_blood_sugar?: any
postprandial_blood_sugar?: any
other_blood_sugar?: any
systolic_pressure?: any
diastolic_pressure?: any
western_medicine?: string
insulin?: string
[key: string]: any
}
interface DietRecord {
record_date?: string
breakfast?: string
lunch?: string
dinner?: string
[key: string]: any
}
interface ExerciseRecord {
record_date?: string
duration?: number
intensity?: number
intensity_text?: string
remark?: string
[key: string]: any
}
interface Props {
diagnosisId: number
age?: number | null
}
const props = withDefaults(defineProps<Props>(), {
age: null
})
type RangeMode = '7' | '30' | 'custom'
const rangeMode = ref<RangeMode>('30')
const customRange = ref<[string, string] | null>(null)
const loading = ref(false)
const bloodRecords = ref<BloodRecord[]>([])
const dietRecords = ref<DietRecord[]>([])
const exerciseRecords = ref<ExerciseRecord[]>([])
let requestSeq = 0
/** 把今天本地时间格式成 Y-m-d,避免 toISOString 的时区漂移 */
const formatDate = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const computeRange = (mode: RangeMode): { start: string; end: string } => {
if (mode === 'custom') {
const [s, e] = customRange.value || ['', '']
return { start: s || '', end: e || '' }
}
const days = mode === '7' ? 7 : 30
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - (days - 1))
return { start: formatDate(start), end: formatDate(end) }
}
const disabledFutureDate = (date: Date) => date.getTime() > Date.now()
const fetchTracking = async () => {
if (!props.diagnosisId || props.diagnosisId <= 0) {
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
return
}
const range = computeRange(rangeMode.value)
if (rangeMode.value === 'custom' && (!range.start || !range.end)) {
//
return
}
const seq = ++requestSeq
loading.value = true
try {
const res: any = await diagnosisTrackingWindow({
id: props.diagnosisId,
start_date: range.start,
end_date: range.end
})
if (seq !== requestSeq) return
bloodRecords.value = res?.blood_records || []
dietRecords.value = res?.diet_records || []
exerciseRecords.value = res?.exercise_records || []
} catch {
if (seq === requestSeq) {
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
}
} finally {
if (seq === requestSeq) loading.value = false
}
}
const onRangeModeChange = () => {
if (rangeMode.value === 'custom') {
// 30
if (!customRange.value) {
const r = computeRange('30')
customRange.value = [r.start, r.end]
}
}
fetchTracking()
}
const onCustomRangeChange = () => {
if (rangeMode.value === 'custom') fetchTracking()
}
watch(
() => props.diagnosisId,
(id) => {
if (id && id > 0) fetchTracking()
}
)
onMounted(() => {
if (props.diagnosisId && props.diagnosisId > 0) fetchTracking()
})
/**
* 把同一天可能多条的血糖记录聚合每段取第一条非空值 BloodRecordLogic 趋势图一致
*/
const bloodByDate = computed(() => {
const map = new Map<string, BloodRecord>()
for (const r of bloodRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) {
map.set(d, {
record_date: d,
record_date_ts: r.record_date_ts
})
}
const acc = map.get(d)!
const fields: Array<keyof BloodRecord> = [
'fasting_blood_sugar',
'postprandial_blood_sugar',
'other_blood_sugar',
'systolic_pressure',
'diastolic_pressure',
'western_medicine',
'insulin'
]
for (const f of fields) {
if (
(acc[f] === undefined || acc[f] === null || acc[f] === '') &&
r[f] !== undefined &&
r[f] !== null &&
r[f] !== ''
) {
;(acc as any)[f] = r[f]
}
}
}
return map
})
const dietByDate = computed(() => {
const map = new Map<string, DietRecord>()
for (const r of dietRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
}
return map
})
const exerciseByDate = computed(() => {
const map = new Map<string, ExerciseRecord>()
for (const r of exerciseRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
}
return map
})
/**
* 已经由后端按区间过滤前端只需取三类记录的日期并集 + desc 排序
*/
const visibleDates = computed<string[]>(() => {
const set = new Set<string>()
for (const r of bloodRecords.value) if (r.record_date) set.add(String(r.record_date))
for (const r of dietRecords.value) if (r.record_date) set.add(String(r.record_date))
for (const r of exerciseRecords.value) if (r.record_date) set.add(String(r.record_date))
const list = Array.from(set).filter((d) => d.length === 10)
list.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
return list
})
const rows = computed(() =>
visibleDates.value.map((date) => {
const blood = bloodByDate.value.get(date)
const diet = dietByDate.value.get(date)
const exercise = exerciseByDate.value.get(date)
const fasting = blood?.fasting_blood_sugar ?? null
const postprandial = blood?.postprandial_blood_sugar ?? null
const other = blood?.other_blood_sugar ?? null
const sys = blood?.systolic_pressure ?? null
const dia = blood?.diastolic_pressure ?? null
const bloodAny =
(fasting !== null && fasting !== '') ||
(postprandial !== null && postprandial !== '') ||
(other !== null && other !== '')
const bp = sys || dia ? formatBp({ systolic_pressure: sys, diastolic_pressure: dia }) : ''
return {
date,
dateLabel: `${date}`,
//
bloodAny,
fasting: fasting ?? '—',
postprandial: postprandial ?? '—',
other: other ?? '—',
fastingHigh: isHighFastingBloodSugar(fasting, props.age),
postprandialHigh: isHighPostprandialBloodSugar(postprandial, props.age),
otherHigh: isHighOtherBloodSugar(other, props.age),
//
bp,
bpHigh: isHighBloodPressure({ systolic_pressure: sys, diastolic_pressure: dia }),
//
westernMedicine: blood?.western_medicine || '',
insulin: blood?.insulin || '',
//
diet: diet
? {
breakfast: diet.breakfast || '',
lunch: diet.lunch || '',
dinner: diet.dinner || ''
}
: null,
//
exercise: exercise
? {
duration: exercise.duration || 0,
intensityText: exercise.intensity_text || '',
remark: exercise.remark || ''
}
: null
}
})
)
defineExpose({ refresh: fetchTracking })
</script>
<style lang="scss" scoped>
.tracking-matrix {
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
.hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.matrix-table {
width: 100%;
}
.cell-blood {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12.5px;
line-height: 1.55;
> span {
display: inline-flex;
align-items: center;
gap: 4px;
.value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
}
}
.cell-high {
color: #dc2626;
font-weight: 700;
}
.cell-up {
font-size: 13px;
color: #dc2626;
}
.cell-empty {
color: var(--el-text-color-placeholder);
}
.cell-checked {
color: #2563eb;
font-weight: 500;
.meta {
color: var(--el-text-color-secondary);
font-weight: 400;
margin-left: 2px;
}
}
}
.diet-tip,
.exercise-tip {
font-size: 12.5px;
line-height: 1.55;
> div {
margin-bottom: 2px;
&:last-child {
margin-bottom: 0;
}
}
}
</style>
@@ -0,0 +1,179 @@
<template>
<div class="tracking-timeline-wrap">
<!-- 编辑模式输入新备注 -->
<div v-if="!readonly && diagnosisId" class="timeline-input">
<el-input
v-model="draft"
type="textarea"
:rows="2"
placeholder="输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录"
maxlength="1000"
show-word-limit
resize="none"
:disabled="saving"
/>
<div class="input-actions">
<el-button
type="primary"
size="small"
:loading="saving"
:disabled="!draft.trim()"
@click="handleSave"
>
添加
</el-button>
</div>
</div>
<!-- 时间轴 -->
<div v-if="notes.length" class="tracking-timeline">
<div v-for="note in notes" :key="note.id" class="timeline-node">
<div class="timeline-dot"></div>
<div class="timeline-date">{{ note.note_date }}</div>
<div class="timeline-body">
<div v-if="note.content" class="timeline-content">
<div
v-for="(line, idx) in splitLines(note.content)"
:key="idx"
class="content-line"
>
{{ line }}
</div>
</div>
</div>
</div>
</div>
<el-empty v-if="!notes.length && readonly" description="暂无跟踪备注" :image-size="48" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { diagnosisAddTrackingNote } from '@/api/tcm'
import feedback from '@/utils/feedback'
export interface TrackingNoteItem {
id: number
note_date: string
content: string | null
}
const props = defineProps<{
notes: TrackingNoteItem[]
diagnosisId?: number
readonly?: boolean
}>()
const emit = defineEmits<{ refresh: [] }>()
const draft = ref('')
const saving = ref(false)
const splitLines = (content: string | null): string[] => {
if (!content) return []
return content.split('\n').filter(Boolean)
}
const handleSave = async () => {
if (!props.diagnosisId) return
const content = draft.value.trim()
if (!content) return
saving.value = true
try {
await diagnosisAddTrackingNote({
diagnosis_id: props.diagnosisId,
tracking_content: content,
})
feedback.msgSuccess('已添加')
draft.value = ''
emit('refresh')
} finally {
saving.value = false
}
}
</script>
<style scoped lang="scss">
.tracking-timeline-wrap {
display: flex;
flex-direction: column;
gap: 14px;
}
.timeline-input {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px dashed #ebeef5;
}
.input-actions {
display: flex;
justify-content: flex-end;
}
.tracking-timeline {
position: relative;
padding-left: 22px;
}
.tracking-timeline::before {
content: '';
position: absolute;
left: 6px;
top: 4px;
bottom: 4px;
width: 2px;
background: #ebeef5;
border-radius: 1px;
}
.timeline-node {
position: relative;
padding-bottom: 16px;
}
.timeline-node:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -19px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #16a34a;
border: 2px solid #dcfce7;
}
.timeline-date {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
font-family: 'IBM Plex Mono', Consolas, monospace;
}
.timeline-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.content-line {
font-size: 12.5px;
color: #606266;
line-height: 1.6;
word-break: break-word;
}
</style>
+91 -35
View File
@@ -562,39 +562,35 @@
</div>
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<!-- 血糖血压记录标签页 -->
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
<blood-record-list
<!-- <el-tab-pane label="跟踪备注" name="trackingNote" :disabled="!formData.id" lazy>
<div v-if="formData.id" class="p-4">
<TrackingNoteTimeline
:notes="trackingNotes"
:diagnosis-id="Number(formData.id)"
:readonly="viewOnly"
@refresh="fetchTrackingNotes"
/>
</div>
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane> -->
<el-tab-pane
v-if="hasDailyRecordPermission"
label="日常记录"
name="daily"
:disabled="!formData.id"
lazy
>
<daily-matrix
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:age="formData.age"
:patient-name="formData.patient_name"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
<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)"
:read-only="viewOnly"
/>
<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)"
:read-only="viewOnly"
/>
<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">
@@ -610,6 +606,22 @@
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
</el-tab-pane>
<!-- 业务订单复用处方业务订单接口按患者ID聚合 / 按当前诊单过滤 -->
<el-tab-pane
label="业务订单"
name="patientOrders"
:disabled="!formData.id"
lazy
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
>
<patient-order-list
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id) || 0"
/>
<el-empty v-else description="请先保存诊单后查看业务订单" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/huifang'])"
label="视频录制回放记录"
@@ -694,7 +706,7 @@
</template>
<script setup lang="ts">
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
@@ -702,15 +714,15 @@ import useUserStore from '@/stores/modules/user'
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 DailyMatrix from './components/DailyMatrix.vue'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
import TrackingNoteTimeline from './components/TrackingNoteTimeline.vue'
import PatientOrderList from './components/PatientOrderList.vue'
import TcmPrescription from '@/components/tcm-prescription/index.vue'
import { getDoctorNotes } from '@/api/patient'
@@ -730,6 +742,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
const phoneRevealUnlocked = ref(false)
@@ -748,10 +761,22 @@ const fetchDiagNotes = async () => {
} catch { diagNotes.value = [] }
}
const trackingNotes = ref<any[]>([])
const fetchTrackingNotes = async () => {
if (!formData.value.id) return
try {
trackingNotes.value = await diagnosisTrackingNotes({ diagnosis_id: Number(formData.value.id) }) || []
} catch { trackingNotes.value = [] }
}
watch(() => activeTab.value, async (tab) => {
if (tab === 'doctorNote' && formData.value.id) {
await fetchDiagNotes()
}
if (tab === 'trackingNote' && formData.value.id) {
await fetchTrackingNotes()
}
})
watch([visible, activeTab], () => {
@@ -772,6 +797,9 @@ watch([visible, activeTab], () => {
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'daily' && !hasDailyRecordPermission.value) {
activeTab.value = 'basic'
}
})
@@ -1118,6 +1146,8 @@ const open = async (type: string, id?: number) => {
} else if (type === 'add') {
const userStore = useUserStore()
formData.value.assistant_id = userStore.userInfo?.id || ''
// diagnosis_type follow_up server/sql/tcm_diagnosis.sql
formData.value.diagnosis_type = 'follow_up'
}
}
@@ -1579,6 +1609,7 @@ defineExpose({
.el-drawer__body {
background: #f8fafc;
min-width: 0;
}
.el-drawer__footer {
@@ -1587,22 +1618,48 @@ defineExpose({
box-shadow: 0 -4px 24px rgb(15 23 42 / 7%);
}
/* 多 Tab 标签:保证在窄抽屉内可横向滚动(含 flex 子项 min-width 与 EP 内层 overflow 链) */
.tcm-diagnosis-tabs.el-tabs {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.tcm-diagnosis-tabs .el-tabs__header {
min-width: 0;
width: 100%;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap {
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scrollbar-width: thin;
scrollbar-color: rgb(148 163 184 / 55%) transparent;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar {
display: none;
height: 0;
height: 4px;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar-thumb {
background-color: rgb(148 163 184 / 55%);
border-radius: 4px;
}
.tcm-diagnosis-tabs .el-tabs__nav-scroll {
overflow: visible !important;
}
.tcm-diagnosis-tabs .el-tabs__nav {
display: flex;
flex-wrap: nowrap;
float: none !important;
width: max-content;
min-width: 100%;
}
.tcm-diagnosis-tabs .el-tabs__item {
@@ -1769,4 +1826,3 @@ defineExpose({
}
}
</style>
+348 -34
View File
@@ -37,12 +37,35 @@
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
</span>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
v-perms="['tcm.diagnosis/assign']"
class="pending-assign-wrap"
v-perms="['tcm.diagnosis/assign']"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
</span>
<el-date-picker
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_order_month"
type="month"
placeholder="订单月份"
value-format="YYYY-MM"
clearable
class="pending-assign-month"
@change="onPendingAssignOrderMonthChange"
/>
<el-input
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_keyword"
placeholder="搜身份证/诊单ID/患者号/备注…"
clearable
class="pending-assign-search"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</span>
</div>
<div class="search-row">
@@ -116,22 +139,26 @@
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} </div>
</div>
<div class="table-wrap px-4">
<el-table
:data="pager.lists"
<el-table
:data="pager.lists"
row-key="id"
size="default"
v-loading="pager.loading"
@selection-change="handleSelectionChange"
@row-dblclick="goReadonly"
:row-class-name="getRowClassName"
class="diagnosis-table"
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -151,7 +178,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="175">
<template #default="{ row }">
<div
class="appointment-cell"
@@ -168,6 +195,17 @@
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
<div class="apt-time">{{ apt.time_text || '-' }}</div>
<div v-if="canCancelAppointmentItem(apt)" class="apt-actions">
<el-button
v-perms="['tcm.diagnosis/guahao']"
type="danger"
link
size="small"
@click.stop="handleCancelAppointmentItem(row, apt)"
>
取消挂号
</el-button>
</div>
</div>
</template>
<template v-else>
@@ -215,6 +253,20 @@
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column label="未服务天数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
:content="`最近一次健康打卡:${row.last_blood_record_at}`"
placement="top"
>
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
{{ row.unserved_days ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-days unserved-muted"></span>
</template>
</el-table-column>
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
<template #default="{ row }">
<div class="video-watch-col">
@@ -239,9 +291,18 @@
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="left">
<el-table-column label="操作" width="340" fixed="right" align="left">
<template #default="{ row }">
<div class="action-cell">
<div class="action-cell" @click.stop @dblclick.stop>
<el-button
type="primary"
link
size="small"
@click="goReadonly(row)"
v-perms="['tcm.diagnosis/readonlyDetail']"
>
查看
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
@@ -280,7 +341,12 @@
</el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
<el-dropdown-item
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt"
>
<el-icon><CircleClose /></el-icon>取消挂号
</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahaoLogList'])" command="guahaoLogs"><el-icon><List /></el-icon>挂号日志</el-dropdown-item>
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
@@ -371,6 +437,7 @@
v-model="assignDialogVisible"
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
width="500px"
@closed="resetAssignDialog"
>
<el-form :model="assignForm" label-width="100px">
<el-form-item label="选择医助" required>
@@ -380,7 +447,6 @@
class="w-full"
filterable
clearable
value-key="id"
>
<el-option
v-for="item in assistantOptions"
@@ -391,7 +457,7 @@
</el-select>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
</el-form-item>
<el-form-item v-else label="诊单信息">
<div class="text-gray-500">
@@ -616,7 +682,7 @@ import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
@@ -665,7 +731,11 @@ const formData = reactive({
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
pending_booking: '' as '' | '1',
/** 顶部「已完成」Tab:至少有一条挂号记录为已完成 status=3 */
completed_appointment: '' as '' | '1'
completed_appointment: '' as '' | '1',
/** 待分配医助:按业务订单创建月份筛选 YYYY-MM,空表示不限月份 */
pending_assign_order_month: '' as string,
/** 待分配医助 Tab:多字段检索(后端扩展至身份证/诊单ID/备注等;不额外套数据权限) */
pending_assign_keyword: '' as string
})
const activeTab = ref('all')
@@ -700,7 +770,91 @@ const setConfirmed = (v: string) => {
fetchDateCounts()
}
/** 待分配 + 宽搜时:请求只吃分页与关键词,不带其它 Tab/日期/筛选(与 buildTcmDiagnosisListRequestPayload 一致) */
function isPendingAssignWideKeywordParams(req: Record<string, unknown>): boolean {
const pa = req.pending_assign === '1' || req.pending_assign === 1
if (!pa) {
return false
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
return k1 !== '' || k2 !== ''
}
/** 待分配宽搜:仅传分页 + pending_assign + 关键词,丢弃其余查询参数 */
function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Record<string, unknown> {
if (!isPendingAssignWideKeywordParams(req)) {
return req
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
const kw = k1 !== '' ? k1 : k2
return {
page_no: req.page_no,
page_size: req.page_size,
pending_assign: 1,
pending_assign_keyword: kw
}
}
/**
* 待分配订单月份未选月份时未进入 Tab 前默认当月 handlePendingAssignTabClick 一致
* Tab 内清空月份表示不限月份
*/
function resolvePendingAssignOrderMonthForRequest(): string {
const trimmed = String(formData.pending_assign_order_month ?? '').trim()
if (trimmed !== '') return trimmed
if (formData.pending_assign === '1') return ''
return dayjs().format('YYYY-MM')
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload({
...formData,
page_no: 1,
page_size: 1,
pending_assign: 1,
appointment_date: '',
has_appointment: '',
pending_booking: '',
completed_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
} as Record<string, unknown>) as Record<string, unknown>
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
/** 宽搜提交前清理界面上的其它检索,避免 Chip 仍显示「已选」但与实际请求不一致 */
function clearSecondaryFiltersWhenPendingAssignWideSearch() {
if (formData.pending_assign !== '1') {
return
}
const kw1 = (formData.pending_assign_keyword ?? '').trim()
const kw2 = (formData.keyword ?? '').trim()
if (kw1 === '' && kw2 === '') {
return
}
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.diagnosis_confirmed = ''
formData.has_appointment = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.start_time = ''
formData.end_time = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
formData.keyword = ''
}
}
const doSearch = () => {
clearSecondaryFiltersWhenPendingAssignWideSearch()
pager.page = 1
getLists()
fetchDateCounts()
@@ -723,6 +877,8 @@ const pendingAssignCount = ref<number | undefined>(undefined)
const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
@@ -735,6 +891,8 @@ const handleDateTabClick = async (value: string) => {
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.appointment_date = ''
@@ -747,6 +905,8 @@ const handlePendingBookingTabClick = async () => {
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.has_appointment = ''
@@ -762,6 +922,18 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
}
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 待分配:切换订单月份后刷新列表与角标 */
const onPendingAssignOrderMonthChange = async (val: string | null) => {
formData.pending_assign_order_month = val || ''
if (formData.pending_assign !== '1') return
pager.page = 1
await getLists()
fetchDateCounts()
@@ -779,7 +951,7 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -807,7 +979,7 @@ function scheduleDeferredDateCounts() {
}
}
const SILENT_LIST_POLL_MS = 10_000
const SILENT_LIST_POLL_MS = 20_000
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
@@ -913,7 +1085,7 @@ const getAssistantName = (assistantId: number | string) => {
}
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: tcmDiagnosisLists,
fetchFun: fetchTcmDiagnosisListsForPaging,
params: formData
})
@@ -931,6 +1103,8 @@ const handleReset = () => {
formData.appointment_date = ''
formData.has_appointment = ''
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
@@ -944,6 +1118,19 @@ const isDiagnosisConfirmed = (row: any) => {
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
/**
* 未服务天数着色规则计划 T3
* null/undefined >=7 3-6 <=2 绿
*/
const unservedDaysClass = (n: unknown) => {
if (n === null || n === undefined) return 'unserved-muted'
const num = Number(n)
if (!Number.isFinite(num)) return 'unserved-muted'
if (num >= 7) return 'unserved-red'
if (num >= 3) return 'unserved-orange'
return 'unserved-green'
}
//
const getRowClassName = ({ row }: { row: any }) => {
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
@@ -1146,6 +1333,8 @@ const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
//
const selectedIds = ref<number[]>([])
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
const batchAssignIds = ref<number[]>([])
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const assignMode = ref<'batch' | 'single'>('batch')
@@ -1154,6 +1343,11 @@ const assignForm = ref({
assistant_id: null as number | null
})
function resetAssignDialog() {
batchAssignIds.value = []
assignForm.value.assistant_id = null
}
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map(item => item.id)
}
@@ -1175,8 +1369,9 @@ const canBatchCancelAssign = computed(() => {
const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
batchAssignIds.value = []
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
@@ -1189,6 +1384,7 @@ const handleBatchAssign = () => {
assignMode.value = 'batch'
currentRow.value = null
assignForm.value.assistant_id = null
batchAssignIds.value = [...selectedIds.value]
assignDialogVisible.value = true
}
@@ -1207,15 +1403,20 @@ const handleConfirmAssign = async () => {
assistant_id: assignForm.value.assistant_id
})
} else {
//
const promises = selectedIds.value.map(id =>
tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id
// 使 id
const ids = batchAssignIds.value
if (!ids.length) {
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
return
}
for (const id of ids) {
await tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id!,
})
)
await Promise.all(promises)
}
selectedIds.value = []
batchAssignIds.value = []
}
assignDialogVisible.value = false
@@ -1263,10 +1464,13 @@ const handleEdit = (id: number) => {
editRef.value.open('edit', id)
}
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方 */
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
function prescriptionActionLabel(row: any) {
if (formData.pending_assign === '1') {
return '开方'
}
if (
row?.prescription_audit_status === 1 &&
Number(row?.prescription_audit_status) === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
@@ -1371,13 +1575,29 @@ const appointmentCellClasses = (row: any) => {
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/** 已预约、已过号可取消(后端同步限制) */
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
}
//
/** 单条挂号记录是否允许取消(已预约 1 / 已过号 4) */
const canCancelAppointmentItem = (apt: any) => {
const s = Number(apt?.status)
return !!(apt?.id && (s === 1 || s === 4))
}
/**
* 更多里行级取消仅当列表只展示一条挂号时可点避免多条时与主键 appointment_id 不对齐
* 多条时在挂号列每条旁单独取消
*/
const canCancelAppointmentFromDropdown = (row: any) => {
if (!canCancelAppointmentRow(row)) return false
const rows = appointmentRows(row)
return rows.length <= 1
}
//
const handleCancelAppointment = async (row: any) => {
if (!canCancelAppointmentRow(row)) {
const s = Number(row.appointment_status)
@@ -1388,9 +1608,39 @@ const handleCancelAppointment = async (row: any) => {
}
return
}
const rows = appointmentRows(row)
const target = rows.length === 1 ? rows[0] : null
const aptId = target?.id && canCancelAppointmentItem(target) ? target.id : row.appointment_id
try {
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
await cancelAppointment({ id: row.appointment_id })
await cancelAppointment({ id: aptId })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
await fetchGuahaoLogs(row.id)
}
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
/** 取消指定挂号(多条展示时用) */
const handleCancelAppointmentItem = async (row: any, apt: any) => {
if (!canCancelAppointmentItem(apt)) {
const s = Number(apt?.status)
if (s === 3) {
feedback.msgWarning('已完成就诊,无法取消挂号')
} else {
feedback.msgWarning('该挂号无法取消')
}
return
}
const doc = apt.doctor_name || '—'
const t = apt.time_text || '—'
try {
await feedback.confirm(`确定取消「${doc}${t} 的挂号吗?(患者:${row.patient_name || '—'}`)
await cancelAppointment({ id: apt.id })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
@@ -1706,6 +1956,17 @@ const handleDeleteRecord = async (id: number) => {
}
const route = useRoute()
const router = useRouter()
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
const goReadonly = (row: any) => {
// readonlyDetail v-perms
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
const id = Number(row?.id)
if (!id || id <= 0) return
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
}
onMounted(() => {
//
formData.appointment_date = todayStr.value
@@ -1829,6 +2090,27 @@ onUnmounted(() => {
background: var(--el-color-warning);
}
}
.pending-assign-wrap {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-left: 8px;
.chip-pending {
margin-left: 0;
}
}
.pending-assign-month {
width: 128px;
}
.pending-assign-search {
width: 220px;
max-width: 42vw;
}
}
.search-row {
@@ -2013,6 +2295,11 @@ onUnmounted(() => {
margin-top: 2px;
}
.apt-actions {
margin-top: 4px;
line-height: 1.2;
}
&.apt-item-active {
.apt-badge {
color: #3a4acc;
@@ -2091,6 +2378,33 @@ onUnmounted(() => {
color: var(--el-text-color-placeholder);
}
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
.unserved-days {
display: inline-block;
min-width: 28px;
font-weight: 600;
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: default;
}
.unserved-muted {
color: var(--el-text-color-placeholder);
font-weight: 400;
}
.unserved-green {
color: #16a34a;
}
.unserved-orange {
color: #ea580c;
}
.unserved-red {
color: #dc2626;
}
.diagnosis-cell {
display: flex;
flex-direction: column;
@@ -130,6 +130,7 @@
</div>
<div class="dh5-card-sub">
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
</div>
</div>
<div class="dh5-card-tags">
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
flex-wrap: wrap;
}
.dh5-tag-new {
display: inline-block;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
}
.dh5-meta-gender,
.dh5-meta-age {
font-size: 12px;
+411
View File
@@ -0,0 +1,411 @@
<template>
<div class="readonly-page" v-loading="loading">
<!-- 顶部 hero -->
<div class="readonly-hero">
<div class="hero-left">
<el-button link size="default" @click="goBack" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
</el-button>
<span class="title">患者信息详情</span>
</div>
<div v-if="diag?.patient_name" class="hero-right">
<span class="patient-name">{{ diag.patient_name }}</span>
<span class="patient-meta">
{{ formatGender(diag.gender) }}
<template v-if="diag.age != null"> · {{ diag.age }}</template>
</span>
<el-tooltip
v-if="lastBloodAt"
:content="`最近一次健康打卡:${lastBloodAt}`"
placement="bottom"
>
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
未服务 {{ unservedDays ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
</div>
</div>
<!-- 加载失败 / 越权 -->
<el-empty
v-if="!loading && !detail"
:description="errorMsg || '诊单不存在或无权访问'"
class="empty-state"
/>
<template v-else-if="detail">
<PatientInfoCard :apt="apt" :diag="diag" />
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 日常记录只读 -->
<div v-if="hasDailyRecordPermission" class="card tracking-card">
<div class="card-title">日常记录</div>
<div class="card-body">
<DailyMatrix
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
</div>
<!-- 医生备注舌苔照片检查报告只读与编辑页医生备注同源 -->
<div class="card note-card">
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
<div class="card-body card-body--matrix-align">
<el-empty v-if="diagnosisNumericId <= 0" description="诊单数据不完整" />
<el-empty
v-else-if="!doctorNotesLocal.length"
description="暂无医生备注"
/>
<NoteTimeline
v-else
:notes="doctorNotesLocal"
:diagnosis-id="diagnosisNumericId"
:readonly="true"
/>
</div>
</div>
<!-- 业务订单 -->
<div
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
class="card section-card"
>
<div class="card-title">业务订单</div>
<div class="card-body card-body--matrix-align">
<patient-order-list
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId || 0"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 视频录制回放记录 -->
<div v-if="hasPermission(['tcm.diagnosis/huifang'])" class="card section-card">
<div class="card-title">视频录制回放记录</div>
<div class="card-body card-body--matrix-align">
<call-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:read-only="true"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 聊天记录 -->
<div v-if="hasPermission(['tcm.diagnosis/chat'])" class="card section-card">
<div class="card-title">聊天记录</div>
<div class="card-body card-body--matrix-align">
<im-chat-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 指派医助记录 -->
<div
v-if="
hasPermission(['tcm.diagnosis/assign']) ||
hasPermission(['tcm.diagnosis/detail'])
"
class="card section-card"
>
<div class="card-title">指派医助记录</div>
<div class="card-body card-body--matrix-align">
<assign-log-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 挂号记录 -->
<div
v-if="hasPermission(['doctor.appointment/lists'])"
class="card section-card"
>
<div class="card-title">挂号记录</div>
<div class="card-body card-body--matrix-align">
<appointment-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { diagnosisReadonlyDetail } from '@/api/tcm'
import { getDoctorNotes } from '@/api/patient'
import { formatGender } from '@/utils/diag-display'
import { hasPermission } from '@/utils/perm'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.vue'
import DailyMatrix from './components/DailyMatrix.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import PatientOrderList from './components/PatientOrderList.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
const route = useRoute()
const router = useRouter()
const diagnosisIdNum = computed(() => Number(route.query.id || 0))
const loading = ref(false)
const errorMsg = ref('')
const detail = ref<any>(null)
/** 医生备注:readonlyDetail 返回 + getDoctorNotes 对齐接诊台结构 */
const doctorNotesLocal = ref<any[]>([])
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const diagnosisNumericId = computed(() =>
Number(diag.value?.id ?? diagnosisIdNum.value ?? 0)
)
const patientNumericId = computed(() => Number(diag.value?.patient_id ?? 0))
const unservedDays = computed<number | null>(() => {
const v = detail.value?.unserved_days
return v === null || v === undefined ? null : Number(v)
})
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
/**
* 未服务天数着色规则 T3 列表一致
* null/undefined >=7 3-6 <=2 绿
*/
const unservedDaysClass = (n: unknown) => {
if (n === null || n === undefined) return 'unserved-muted'
const num = Number(n)
if (!Number.isFinite(num)) return 'unserved-muted'
if (num >= 7) return 'unserved-red'
if (num >= 3) return 'unserved-orange'
return 'unserved-green'
}
const fetchDoctorNotes = async () => {
const id = diagnosisNumericId.value
if (!id) return
try {
const data = (await getDoctorNotes({ diagnosis_id: id })) || []
doctorNotesLocal.value = data
} catch {
/* 失败时保留 readonlyDetail 已下发的 doctor_notes,避免整块备注空白 */
}
}
const fetchDetail = async () => {
const id = diagnosisIdNum.value
if (!id || id <= 0) {
errorMsg.value = '诊单 id 缺失'
detail.value = null
doctorNotesLocal.value = []
return
}
loading.value = true
errorMsg.value = ''
try {
const res: any = await diagnosisReadonlyDetail({ id })
detail.value = res || null
doctorNotesLocal.value = Array.isArray(res?.doctor_notes) ? res.doctor_notes : []
await fetchDoctorNotes()
} catch (e: any) {
errorMsg.value = e?.message || ''
detail.value = null
doctorNotesLocal.value = []
} finally {
loading.value = false
}
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push({ path: '/tcm/diagnosis' })
}
}
onMounted(fetchDetail)
</script>
<style lang="scss" scoped>
/* 沿用接诊台的设计变量;该文件挂在管理后台,--rx-* 已在 reception/index.vue 全局可见的同款值 */
.readonly-page {
--rx-surface: #ffffff;
--rx-line: #e6ebf2;
--rx-ink: #1f2937;
--rx-muted: #4b5563;
--rx-soft: #6b7280;
--rx-accent: #2563eb;
--rx-accent-strong: #1d4ed8;
--rx-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--rx-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
--rx-font-data: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.readonly-hero {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
border: 1px solid #dde7ff;
border-radius: 12px;
padding: 12px 16px;
.hero-left {
display: flex;
align-items: center;
gap: 12px;
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.title {
font-size: 16px;
font-weight: 700;
color: var(--rx-ink);
}
}
.hero-right {
display: flex;
align-items: center;
gap: 12px;
.patient-name {
font-size: 18px;
font-weight: 700;
color: var(--rx-ink);
}
.patient-meta {
font-size: 13px;
color: var(--rx-muted);
}
}
}
.unserved-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: rgba(255, 255, 255, 0.7);
border: 1px solid #dde7ff;
cursor: default;
&.unserved-muted {
color: var(--el-text-color-placeholder);
font-weight: 400;
}
&.unserved-green {
color: #16a34a;
border-color: #bbf7d0;
background: rgba(22, 163, 74, 0.06);
}
&.unserved-orange {
color: #ea580c;
border-color: #fed7aa;
background: rgba(234, 88, 12, 0.06);
}
&.unserved-red {
color: #dc2626;
border-color: #fecaca;
background: rgba(220, 38, 38, 0.08);
}
}
.empty-state {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 40px 0;
}
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.card-body {
min-width: 0;
}
/* 与 DailyMatrix 根节点 .daily-matrix { padding: 16px } 对齐,使下方区块与「日常记录 / 待办」主内容区左右留白一致 */
.card-body--matrix-align {
padding: 16px;
box-sizing: border-box;
}
.section-card .card-title {
margin-bottom: 0;
}
</style>
+6 -4
View File
@@ -97,12 +97,14 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -1088,7 +1090,7 @@ const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
File diff suppressed because one or more lines are too long
+16 -2
View File
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
return stylePaths
}
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const rawBase = (env.VITE_APP_BASE_URL || '').trim().replace(/\/+$/, '')
const uploadsProxyTarget =
rawBase.startsWith('http://') || rawBase.startsWith('https://')
? rawBase
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
'/uploads': {
target: uploadsProxyTarget,
changeOrigin: true
},
'/api-proxy': {
target: 'https://admin.zhenyangtang.com.cn',
changeOrigin: true,
@@ -112,4 +125,5 @@ export default defineConfig({
__VUE_PROD_HYDRATION__: true,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
}
}
})
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+1
View File
@@ -0,0 +1 @@
open_basedir=/www/wwwroot/zyt/server/:/tmp/
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
+38
View File
@@ -15,6 +15,7 @@
namespace app\adminapi\controller;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
use think\response\Json;
@@ -77,4 +78,41 @@ class UploadController extends BaseAdminController
}
}
/**
* @notes 浏览器直传 OSS - 签发 STS 临时凭证
* @return Json
*/
public function ossCredentials()
{
$type = trim((string)$this->request->post('type', 'video'));
try {
$result = DirectUploadService::issueCredentials($type);
return $this->success('ok', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 浏览器直传 OSS - 上传完成回执(写 file + HEAD 校验)
* @return Json
*/
public function ossConfirm()
{
try {
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'size' => (int)$this->request->post('size', 0),
'content_type' => trim((string)$this->request->post('content_type', '')),
'cid' => (int)$this->request->post('cid', 0),
'admin_id' => $this->adminId,
]);
return $this->success('上传成功', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
}
}
}
View File
View File
View File
View File
View File
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More