Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86903cdb59 | ||
|
|
7ba9dabdb4 | ||
|
|
611f3dcd5c | ||
|
|
d3388b160e | ||
|
|
f83d4c06cb | ||
|
|
4941ea3e21 | ||
|
|
e35696e153 | ||
|
|
e1df584ed5 | ||
|
|
928f75e016 | ||
|
|
37fa160c24 | ||
|
|
0eaacbb4ce | ||
|
|
1e8b5c4646 | ||
|
|
fabaa84373 | ||
|
|
da017bdf20 | ||
|
|
5d94ffab3e | ||
|
|
796ca12fb8 | ||
|
|
c644f2554f | ||
|
|
e83dac756d | ||
|
|
0c1709d589 | ||
|
|
a3892001d3 | ||
|
|
7add364e20 | ||
|
|
0ebea74fde | ||
|
|
1156787327 | ||
|
|
15cf6324f6 | ||
|
|
1338b8735c | ||
|
|
82f229adf2 | ||
|
|
048e43a662 | ||
|
|
d8d4da197e | ||
|
|
a0325a8fb1 | ||
|
|
091db50841 | ||
|
|
1774a87dd7 | ||
|
|
62d15efbaf | ||
|
|
c468c57cda | ||
|
|
bacdd6386f | ||
|
|
ea290c4c7b | ||
|
|
31b4afdcb7 | ||
|
|
379d2ef404 | ||
|
|
5cdb7ebb9e | ||
|
|
496a2b870b | ||
|
|
471bdf12b7 | ||
|
|
8fe4683e64 | ||
|
|
c34409edde | ||
|
|
0a37335f3c | ||
|
|
985916dea7 | ||
|
|
3330613254 | ||
|
|
3f12829204 | ||
|
|
f28500e449 | ||
|
|
fd9053a290 | ||
|
|
dc1c5d5b17 | ||
|
|
1297d9f510 | ||
|
|
daaf6c4a14 | ||
|
|
68e2e0b7d7 | ||
|
|
c78ba50331 | ||
|
|
0026a92697 | ||
|
|
ee1b140902 | ||
|
|
f81a3b5d94 | ||
|
|
8719cdd6d6 | ||
|
|
33fb055bfe | ||
|
|
14735af0b3 | ||
|
|
623bda2ecb | ||
|
|
dfbb6d82b3 | ||
|
|
7473a3e03d | ||
|
|
eb9b31f8f5 | ||
|
|
f20a45a1ad | ||
|
|
0f66d1438f | ||
|
|
43110e015d | ||
|
|
7d7a4aa4d2 | ||
|
|
8c640abb26 | ||
|
|
ffabe78942 | ||
|
|
81fcef2156 | ||
|
|
4dc9ec1878 | ||
|
|
3f05a610b3 | ||
|
|
6c997559a2 | ||
|
|
d39b1ed7ee | ||
|
|
0e81fb5037 | ||
|
|
36847ea70c | ||
|
|
9b40ebb8b7 | ||
|
|
9948bc28df | ||
|
|
a9474356ff | ||
|
|
4e23c4bf79 | ||
|
|
b6e6394e51 | ||
|
|
576ef8e033 | ||
|
|
a52380aa63 | ||
|
|
49e7169630 | ||
|
|
d53f492d2a | ||
|
|
3d3abff842 | ||
|
|
90ac63c905 | ||
|
|
320d3783ac | ||
|
|
5966049020 | ||
|
|
755491c263 | ||
|
|
79203d948e | ||
|
|
01d56f3b4f | ||
|
|
477fcf6180 | ||
|
|
3014a99db5 | ||
|
|
c4dd196e72 | ||
|
|
581c74f11a | ||
|
|
7a2f285442 | ||
|
|
375f54263a | ||
|
|
ed4a4c4960 | ||
|
|
9d405ebed9 | ||
|
|
a7fd55aa1c | ||
|
|
c389fea73c | ||
|
|
05e8f4ddd7 | ||
|
|
da9a493e8d | ||
|
|
64ab3197ec | ||
|
|
dddc4d202a | ||
|
|
71180d4fd8 | ||
|
|
1942387050 | ||
|
|
77c1fc908d | ||
|
|
ef4800e82a | ||
|
|
815ea9e025 | ||
|
|
f7ce3d18d8 | ||
|
|
de78f3b218 | ||
|
|
671c7f16bd | ||
|
|
30b5a13f3a | ||
|
|
c830008dfc | ||
|
|
919d0f856e | ||
|
|
49d0fed14e | ||
|
|
19bfeef98f | ||
|
|
cd51f55701 | ||
|
|
17cb0e8319 | ||
|
|
3ef10846b4 | ||
|
|
40eec808ef | ||
|
|
03db6d2677 | ||
|
|
c03e7051d7 | ||
|
|
d6e105f912 | ||
|
|
0da27ed745 | ||
|
|
1d6a2b9cbf | ||
|
|
28fbbd28b7 | ||
|
|
22b8b38b5d | ||
|
|
81b2808eaf | ||
|
|
25eb3340de | ||
|
|
c9150ca4b4 | ||
|
|
b9165f6b1b | ||
|
|
e0ab9ebc4b |
@@ -11,7 +11,12 @@
|
|||||||
"Bash(php think *)",
|
"Bash(php think *)",
|
||||||
"Bash(python3 -c ' *)",
|
"Bash(python3 -c ' *)",
|
||||||
"Bash(python3 *)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ bin-release/
|
|||||||
/.shared
|
/.shared
|
||||||
/.cursor
|
/.cursor
|
||||||
/.codex
|
/.codex
|
||||||
|
/.agents
|
||||||
|
/.claude
|
||||||
|
/.claude
|
||||||
|
/.claude
|
||||||
|
/.kbctx
|
||||||
|
/server/.spool
|
||||||
|
/server/.claude
|
||||||
|
/.spool
|
||||||
|
|||||||
@@ -3,20 +3,55 @@
|
|||||||
|
|
||||||
These instructions are for AI assistants working in this project.
|
These instructions are for AI assistants working in this project.
|
||||||
|
|
||||||
Use the `/trellis:start` command when starting a new session to:
|
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
|
||||||
- Initialize your developer identity
|
|
||||||
- Understand current project context
|
|
||||||
- Read relevant guidelines
|
|
||||||
|
|
||||||
Use `@/.trellis/` to learn:
|
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
|
||||||
- Development workflow (`workflow.md`)
|
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
|
||||||
- Project structure guidelines (`spec/`)
|
- `.trellis/workspace/` — per-developer journals and session traces
|
||||||
- Developer workspace (`workspace/`)
|
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
|
||||||
|
|
||||||
If you're using Codex, project-scoped helpers may also live in:
|
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.
|
||||||
- `.agents/skills/` for reusable Trellis skills
|
|
||||||
- `.codex/agents/` for optional custom subagents
|
|
||||||
|
|
||||||
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 -->
|
<!-- TRELLIS:END -->
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
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 Avatar from '../Avatar/Avatar.vue';
|
||||||
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
|
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
|
||||||
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
|
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
|
||||||
@@ -337,6 +337,8 @@ onUnload(() => {
|
|||||||
const callRole = callParticipantInfo?.value?.selfInfo?.role;
|
const callRole = callParticipantInfo?.value?.selfInfo?.role;
|
||||||
|
|
||||||
if (callStatus === CallStatus.IDLE) return;
|
if (callStatus === CallStatus.IDLE) return;
|
||||||
|
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
|
||||||
|
if (callStatus === CallStatus.CONNECTED) return;
|
||||||
if (callStatus === CallStatus.CALLING) {
|
if (callStatus === CallStatus.CALLING) {
|
||||||
if (callRole === CallRole.CALLER) {
|
if (callRole === CallRole.CALLER) {
|
||||||
hangup();
|
hangup();
|
||||||
@@ -344,9 +346,6 @@ onUnload(() => {
|
|||||||
reject();
|
reject();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (callStatus === CallStatus.CONNECTED) {
|
|
||||||
hangup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ const { proxy } = getCurrentInstance()
|
|||||||
let unsubscribeWxAppShow = null;
|
let unsubscribeWxAppShow = null;
|
||||||
let allowChatForegroundRelogin = false;
|
let allowChatForegroundRelogin = false;
|
||||||
let foregroundReloginTimer = null;
|
let foregroundReloginTimer = null;
|
||||||
|
/** 避免 onUnload 重复上报离开 */
|
||||||
|
let patientLeaveNotified = false;
|
||||||
|
|
||||||
const CONFIRMATION_MESSAGE = `我已确认以下信息:
|
const CONFIRMATION_MESSAGE = `我已确认以下信息:
|
||||||
1、本人确认已在线下确诊;
|
1、本人确认已在线下确诊;
|
||||||
@@ -611,6 +613,7 @@ const { proxy } = getCurrentInstance()
|
|||||||
}
|
}
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
try {
|
try {
|
||||||
|
patientLeaveNotified = false;
|
||||||
console.log('进入onMounted:', options);
|
console.log('进入onMounted:', options);
|
||||||
const app = getApp();
|
const app = getApp();
|
||||||
const userID = options.userID|| app.globalData.userID;
|
const userID = options.userID|| app.globalData.userID;
|
||||||
@@ -790,6 +793,11 @@ const { proxy } = getCurrentInstance()
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
|
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
|
||||||
|
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
|
||||||
|
patientLeaveNotified = true;
|
||||||
|
void notifyDoctorOnChatClose();
|
||||||
|
}
|
||||||
allowChatForegroundRelogin = false;
|
allowChatForegroundRelogin = false;
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
if (typeof unsubscribeWxAppShow === 'function') {
|
if (typeof unsubscribeWxAppShow === 'function') {
|
||||||
@@ -855,6 +863,47 @@ const { proxy } = getCurrentInstance()
|
|||||||
console.warn('后端通知失败:', e);
|
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) {
|
function applyDoctorEnteredSignalFromRawMessage(msg) {
|
||||||
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
|
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
|
||||||
@@ -956,6 +1005,9 @@ const { proxy } = getCurrentInstance()
|
|||||||
if (customData.businessID === 'patient_opened_chat') {
|
if (customData.businessID === 'patient_opened_chat') {
|
||||||
return null; // 返回null表示不显示此消息
|
return null; // 返回null表示不显示此消息
|
||||||
}
|
}
|
||||||
|
if (customData.businessID === 'patient_closed_chat') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
|
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
|
||||||
if (customData.businessID === 'doctor_entered_consult_room') {
|
if (customData.businessID === 'doctor_entered_consult_room') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
NODE_ENV = 'development'
|
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=''
|
VITE_APP_BASE_URL=''
|
||||||
Generated
+52
@@ -19,10 +19,12 @@
|
|||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"cos-js-sdk-v5": "^1.10.1",
|
||||||
"css-color-function": "^1.3.3",
|
"css-color-function": "^1.3.3",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^2.5.2",
|
"jspdf": "^2.5.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -6064,6 +6066,16 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
@@ -7580,6 +7592,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -8231,6 +8265,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/html-void-elements": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||||
@@ -12299,6 +12339,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
|
|||||||
@@ -21,10 +21,12 @@
|
|||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"cos-js-sdk-v5": "^1.10.1",
|
||||||
"css-color-function": "^1.3.3",
|
"css-color-function": "^1.3.3",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^2.5.2",
|
"jspdf": "^2.5.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
Generated
+9731
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
|
|||||||
return request.get({ url: '/doctor.appointment/lists', params })
|
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) {
|
export function appointmentDetail(params: any) {
|
||||||
return request.get({ url: '/doctor.appointment/detail', params })
|
return request.get({ url: '/doctor.appointment/detail', params })
|
||||||
|
|||||||
+85
-36
@@ -4,48 +4,50 @@ import useAppStore from '@/stores/modules/app'
|
|||||||
import { getToken } from '@/utils/auth'
|
import { getToken } from '@/utils/auth'
|
||||||
import request from '@/utils/request'
|
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 相对路径 */
|
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
|
||||||
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
|
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
|
||||||
const appStore = useAppStore()
|
const fileObj =
|
||||||
const formData = new FormData()
|
file instanceof File
|
||||||
formData.append('file', file, filename)
|
? file
|
||||||
formData.append('cid', '0')
|
: new File([file], filename, { type: file.type || 'image/jpeg' })
|
||||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
|
return uploadMaterialFile(fileObj, 'image', 0)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
|
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
|
||||||
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
|
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
|
||||||
const appStore = useAppStore()
|
const fileObj =
|
||||||
const formData = new FormData()
|
file instanceof File
|
||||||
formData.append('file', file, filename)
|
? file
|
||||||
formData.append('cid', '0')
|
: new File([file], filename, { type: file.type || 'video/webm' })
|
||||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/video`
|
return uploadMaterialFile(fileObj, 'video', 0)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileCateAdd(params: Record<string, any>) {
|
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 }) {
|
export function fileRename(params: { id: number; name: string }) {
|
||||||
return request.post({ url: '/file/rename', params })
|
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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
|
|||||||
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||||
return request.post({ url: '/order.order/assignAssistant', params })
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function deptDetail(params: any) {
|
|||||||
return request.get({ url: '/dept.dept/detail', params })
|
return request.get({ url: '/dept.dept/detail', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 部门列表全部
|
// 部门列表全部;apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致)
|
||||||
export function deptAll() {
|
export function deptAll(params?: { apply_data_scope?: number }) {
|
||||||
return request.get({ url: '/dept.dept/all' })
|
return request.get({ url: '/dept.dept/all', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,140 @@ export function yejiStatsLeaderboard(params: {
|
|||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.yejiStats/leaderboard', 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|deferred;appt_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 })
|
||||||
|
}
|
||||||
|
|||||||
+103
-9
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
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 })
|
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 })
|
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消) */
|
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id) */
|
||||||
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
|
export function attachLocalCallRecording(params: {
|
||||||
|
diagnosis_id: number
|
||||||
|
file_url: string
|
||||||
|
call_record_id?: number
|
||||||
|
}) {
|
||||||
return request.post(
|
return request.post(
|
||||||
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
|
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
|
||||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
{ 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 只拉流) */
|
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
|
||||||
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
|
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
|
||||||
return request.get({ url: '/tcm.diagnosis/watchCall', params })
|
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 })
|
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) {
|
export function getWechatChatRecords(params: any) {
|
||||||
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
|
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
|
||||||
@@ -298,7 +345,11 @@ export function prescriptionVoid(params: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
/** 处方审核: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 })
|
return request.post({ url: '/tcm.prescription/audit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +363,11 @@ export function prescriptionOrderLists(params: any) {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
|
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 保留当前单已选)。
|
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
|
||||||
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
|
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
|
||||||
@@ -401,10 +457,7 @@ export function prescriptionOrderAddPayOrder(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||||
export function prescriptionOrderLinkPayOrder(params: {
|
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||||
id: number
|
|
||||||
pay_order_id: number
|
|
||||||
}) {
|
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +472,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
/** 甘草药管家:预下单测试(仅 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 })
|
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +485,11 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修改订单金额 */
|
||||||
|
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 处方库 ==========
|
// ========== 处方库 ==========
|
||||||
|
|
||||||
// 处方库列表
|
// 处方库列表
|
||||||
@@ -454,3 +516,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
|
|||||||
export function prescriptionLibraryDetail(params: { id: number }) {
|
export function prescriptionLibraryDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
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"
|
v-for="item in notifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="chat-notify-card"
|
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)"
|
@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">
|
<el-icon :size="22">
|
||||||
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
|
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
|
||||||
<CircleCheck v-else />
|
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
|
||||||
|
<Right v-else />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-notify-body">
|
<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">
|
<div class="chat-notify-desc">
|
||||||
<template v-if="item.type === 'consultation_complete'">
|
<template v-if="item.type === 'consultation_complete'">
|
||||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||||
@@ -27,6 +45,10 @@
|
|||||||
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
|
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
|
||||||
完成,请及时跟进
|
完成,请及时跟进
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'patient_left_chat'">
|
||||||
|
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||||
|
已离开问诊会话页面
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||||
已打开与您的会话,请及时查看
|
已打开与您的会话,请及时查看
|
||||||
@@ -45,14 +67,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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'
|
import { getChatNotifications } from '@/api/chat'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
export interface ChatNotifyItem {
|
export interface ChatNotifyItem {
|
||||||
id: string
|
id: string
|
||||||
type?: 'patient_opened_chat' | 'consultation_complete'
|
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
|
||||||
doctor_id?: number
|
doctor_id?: number
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
patient_id?: string
|
patient_id?: string
|
||||||
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
|
|||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
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 {
|
.chat-notify-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<el-button>导出</el-button>
|
<el-button>导出</el-button>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<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 ref="formRef" :model="formData" label-width="120px" :rules="formRules">
|
||||||
<el-form-item label="数据量:">
|
<el-form-item label="数据量:">
|
||||||
预计导出{{ exportData.count }}条数据, 共{{ exportData.sum_page }}页,每页{{
|
预计导出{{ exportData.count }}条数据, 共{{ exportData.sum_page }}页,每页{{
|
||||||
@@ -79,6 +80,11 @@ const props = defineProps({
|
|||||||
fetchFun: {
|
fetchFun: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
|
||||||
|
exportHint: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
|||||||
@@ -9,7 +9,17 @@
|
|||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<template v-if="!hiddenUpload" #trigger>
|
<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">
|
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
|
||||||
<template v-slot:item="{ element, index }">
|
<template v-slot:item="{ element, index }">
|
||||||
<div
|
<div
|
||||||
@@ -82,8 +92,10 @@
|
|||||||
import { useThrottleFn } from '@vueuse/core'
|
import { useThrottleFn } from '@vueuse/core'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
import { uploadMaterialFile } from '@/api/file'
|
||||||
import Popup from '@/components/popup/index.vue'
|
import Popup from '@/components/popup/index.vue'
|
||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
import FileItem from './file.vue'
|
import FileItem from './file.vue'
|
||||||
import Material from './index.vue'
|
import Material from './index.vue'
|
||||||
@@ -149,6 +161,11 @@ export default defineComponent({
|
|||||||
excludeDomain: {
|
excludeDomain: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
|
||||||
|
uploadCid: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -162,8 +179,149 @@ export default defineComponent({
|
|||||||
const select = ref<any[]>([])
|
const select = ref<any[]>([])
|
||||||
const isAdd = ref(true)
|
const isAdd = ref(true)
|
||||||
const currentIndex = ref(-1)
|
const currentIndex = ref(-1)
|
||||||
|
const dragDepth = ref(0)
|
||||||
|
const externalUploading = ref(false)
|
||||||
const { disabled, limit, modelValue } = toRefs(props)
|
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(() => {
|
const tipsText = computed(() => {
|
||||||
switch (props.type) {
|
switch (props.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
@@ -264,7 +422,13 @@ export default defineComponent({
|
|||||||
showPreview,
|
showPreview,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
handleClose,
|
handleClose,
|
||||||
getImageUrl
|
getImageUrl,
|
||||||
|
dropActive,
|
||||||
|
onTriggerDragEnter,
|
||||||
|
onTriggerDragLeave,
|
||||||
|
onTriggerDragOver,
|
||||||
|
onTriggerDrop,
|
||||||
|
onTriggerPaste
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -272,6 +436,13 @@ export default defineComponent({
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.material-select {
|
.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-upload,
|
||||||
.material-preview {
|
.material-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -75,11 +75,37 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section">
|
<div class="info-section info-section--rp">
|
||||||
<div class="section-title">
|
<div class="rp-toolbar" role="region" aria-label="中药处方">
|
||||||
中药处方 (RP)
|
<div class="rp-toolbar__lead">
|
||||||
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
|
<span class="rp-toolbar__marker" aria-hidden="true" />
|
||||||
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<el-form-item label="处方价格" prop="amount" class="!mb-0">
|
<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-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-option label="散剂" value="散剂" />
|
||||||
<el-option label="膏方" value="膏方" />
|
<el-option label="膏方" value="膏方" />
|
||||||
<el-option label="汤剂" value="汤剂" />
|
<el-option label="汤剂" value="汤剂" /> -->
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -190,6 +216,18 @@
|
|||||||
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
|
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</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">
|
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
|
||||||
@@ -407,9 +445,9 @@
|
|||||||
<div class="prescription-footer">
|
<div class="prescription-footer">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<div class="footer-dosage">
|
<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 }}
|
{{ savedPrescription.usage_instruction }}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-dosage">
|
<div class="footer-dosage">
|
||||||
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
|
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
|
||||||
@@ -649,6 +687,40 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</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="示例: 黄芪15 党参12 茯苓10 柴胡10g、黄芩10、半夏12克 黄芪、白术、茯苓各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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -656,12 +728,13 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
|||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
||||||
|
import { medicineLists } from '@/api/medicine'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import useAppStore from '@/stores/modules/app'
|
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'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
interface Herb {
|
interface Herb {
|
||||||
@@ -676,6 +749,7 @@ interface PrescriptionForm {
|
|||||||
prescription_type: string
|
prescription_type: string
|
||||||
dosage_amount: number | undefined // 用量数值
|
dosage_amount: number | undefined // 用量数值
|
||||||
dosage_unit: string // 用量单位 (g 或 ml)
|
dosage_unit: string // 用量单位 (g 或 ml)
|
||||||
|
dosage_bag_count: number // 用量袋数(浓缩水丸,1-5袋)
|
||||||
need_decoction: boolean // 是否代煎(仅饮片)
|
need_decoction: boolean // 是否代煎(仅饮片)
|
||||||
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
|
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
|
||||||
patient_name: string
|
patient_name: string
|
||||||
@@ -739,6 +813,20 @@ const hasPrescriptionOrderBlockVoid = computed(() => {
|
|||||||
|
|
||||||
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
|
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 showLibraryDialog = ref(false)
|
||||||
const libraryLoading = ref(false)
|
const libraryLoading = ref(false)
|
||||||
@@ -751,6 +839,179 @@ const libraryPage = ref(1)
|
|||||||
const libraryPageSize = ref(15)
|
const libraryPageSize = ref(15)
|
||||||
const libraryTotal = ref(0)
|
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(/[0-9]/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({
|
const templateConfig = reactive({
|
||||||
stationName: '成都双流甄养堂互联网医院',
|
stationName: '成都双流甄养堂互联网医院',
|
||||||
showDisclaimer: true,
|
showDisclaimer: true,
|
||||||
@@ -764,6 +1025,7 @@ const formData = reactive<PrescriptionForm>({
|
|||||||
prescription_type: '浓缩水丸',
|
prescription_type: '浓缩水丸',
|
||||||
dosage_amount: 10,
|
dosage_amount: 10,
|
||||||
dosage_unit: 'g',
|
dosage_unit: 'g',
|
||||||
|
dosage_bag_count: 1,
|
||||||
need_decoction: false,
|
need_decoction: false,
|
||||||
bags_per_dose: 1,
|
bags_per_dose: 1,
|
||||||
patient_name: '',
|
patient_name: '',
|
||||||
@@ -954,6 +1216,7 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
|
|||||||
formData.prescription_type = '浓缩水丸'
|
formData.prescription_type = '浓缩水丸'
|
||||||
formData.dosage_unit = 'g'
|
formData.dosage_unit = 'g'
|
||||||
formData.dosage_amount = 10
|
formData.dosage_amount = 10
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
||||||
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
||||||
formData.gender_desc = formData.gender === 1 ? '男' : '女'
|
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_name = userStore.userInfo?.name || ''
|
||||||
formData.doctor_signature = ''
|
formData.doctor_signature = ''
|
||||||
formData.is_shared = 0
|
formData.is_shared = 0
|
||||||
|
formData.need_decoction = false
|
||||||
|
formData.bags_per_dose = 1
|
||||||
|
|
||||||
savedPrescription.value = null
|
savedPrescription.value = null
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -1206,16 +1471,19 @@ watch(() => formData.prescription_type, (newType) => {
|
|||||||
if (newType === '浓缩水丸') {
|
if (newType === '浓缩水丸') {
|
||||||
formData.dosage_unit = 'g'
|
formData.dosage_unit = 'g'
|
||||||
formData.dosage_amount = 10
|
formData.dosage_amount = 10
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
formData.need_decoction = false
|
formData.need_decoction = false
|
||||||
formData.bags_per_dose = 1
|
formData.bags_per_dose = 1
|
||||||
} else if (newType === '饮片') {
|
} else if (newType === '饮片') {
|
||||||
formData.dosage_unit = 'ml'
|
formData.dosage_unit = 'ml'
|
||||||
formData.dosage_amount = 50
|
formData.dosage_amount = 50
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
formData.bags_per_dose = 1
|
formData.bags_per_dose = 1
|
||||||
// need_decoction 保持用户选择
|
// need_decoction 保持用户选择
|
||||||
} else {
|
} else {
|
||||||
formData.dosage_unit = 'g'
|
formData.dosage_unit = 'g'
|
||||||
formData.dosage_amount = undefined
|
formData.dosage_amount = undefined
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
formData.need_decoction = false
|
formData.need_decoction = false
|
||||||
formData.bags_per_dose = 1
|
formData.bags_per_dose = 1
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1543,7 @@ const handleSave = async () => {
|
|||||||
dose_unit: formData.dose_unit,
|
dose_unit: formData.dose_unit,
|
||||||
dosage_amount: formData.dosage_amount,
|
dosage_amount: formData.dosage_amount,
|
||||||
dosage_unit: formData.dosage_unit,
|
dosage_unit: formData.dosage_unit,
|
||||||
|
dosage_bag_count: formData.dosage_bag_count,
|
||||||
need_decoction: formData.need_decoction,
|
need_decoction: formData.need_decoction,
|
||||||
bags_per_dose: formData.bags_per_dose,
|
bags_per_dose: formData.bags_per_dose,
|
||||||
usage_days: formData.usage_days,
|
usage_days: formData.usage_days,
|
||||||
@@ -1354,12 +1623,15 @@ const handleNewPrescription = async () => {
|
|||||||
if (formData.prescription_type === '浓缩水丸') {
|
if (formData.prescription_type === '浓缩水丸') {
|
||||||
formData.dosage_unit = 'g'
|
formData.dosage_unit = 'g'
|
||||||
formData.dosage_amount = 10
|
formData.dosage_amount = 10
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
} else if (formData.prescription_type === '饮片') {
|
} else if (formData.prescription_type === '饮片') {
|
||||||
formData.dosage_unit = 'ml'
|
formData.dosage_unit = 'ml'
|
||||||
formData.dosage_amount = 50
|
formData.dosage_amount = 50
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
} else {
|
} else {
|
||||||
formData.dosage_unit = 'g'
|
formData.dosage_unit = 'g'
|
||||||
formData.dosage_amount = undefined
|
formData.dosage_amount = undefined
|
||||||
|
formData.dosage_bag_count = 1
|
||||||
}
|
}
|
||||||
formData.patient_name = saved.patient_name || ''
|
formData.patient_name = saved.patient_name || ''
|
||||||
formData.gender = saved.gender ?? 0
|
formData.gender = saved.gender ?? 0
|
||||||
@@ -1514,6 +1786,107 @@ defineExpose({
|
|||||||
align-items: center;
|
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 {
|
.herbs-editor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:data="data"
|
:data="data"
|
||||||
|
:http-request="useDirect ? httpRequest : undefined"
|
||||||
:on-progress="handleProgress"
|
:on-progress="handleProgress"
|
||||||
:on-success="handleSuccess"
|
:on-success="handleSuccess"
|
||||||
:on-exceed="handleExceed"
|
:on-exceed="handleExceed"
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ElUpload } from 'element-plus'
|
import type { ElUpload, UploadRequestOptions } from 'element-plus'
|
||||||
import { computed, defineComponent, ref, shallowRef } from 'vue'
|
import { computed, defineComponent, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
@@ -49,6 +50,10 @@ import { RequestCodeEnum } from '@/enums/requestEnums'
|
|||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import {
|
||||||
|
DirectUploadFallbackError,
|
||||||
|
uploadVideoDirectToCos
|
||||||
|
} from '@/utils/oss-direct-upload'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {},
|
components: {},
|
||||||
@@ -77,6 +82,11 @@ export default defineComponent({
|
|||||||
showProgress: {
|
showProgress: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
||||||
|
direct: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['change', 'error', 'success', 'allSuccess'],
|
emits: ['change', 'error', 'success', 'allSuccess'],
|
||||||
@@ -92,6 +102,9 @@ export default defineComponent({
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const fileList = ref<any[]>([])
|
const fileList = ref<any[]>([])
|
||||||
|
|
||||||
|
// 仅 video + direct 时才接管 http-request
|
||||||
|
const useDirect = computed(() => props.direct && props.type === 'video')
|
||||||
|
|
||||||
const handleProgress = () => {
|
const handleProgress = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
@@ -142,12 +155,73 @@ export default defineComponent({
|
|||||||
return '*'
|
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 {
|
return {
|
||||||
uploadRefs,
|
uploadRefs,
|
||||||
action,
|
action,
|
||||||
headers,
|
headers,
|
||||||
visible,
|
visible,
|
||||||
fileList,
|
fileList,
|
||||||
|
useDirect,
|
||||||
|
httpRequest,
|
||||||
getAccept,
|
getAccept,
|
||||||
handleProgress,
|
handleProgress,
|
||||||
handleSuccess,
|
handleSuccess,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//引入柱状图图表,图表后缀都为 Chart
|
//引入柱状图图表,图表后缀都为 Chart
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
|
FunnelChart,
|
||||||
LineChart,
|
LineChart,
|
||||||
MapChart,
|
MapChart,
|
||||||
PictorialBarChart,
|
PictorialBarChart,
|
||||||
@@ -43,6 +44,7 @@ echarts.use([
|
|||||||
AriaComponent,
|
AriaComponent,
|
||||||
ParallelComponent,
|
ParallelComponent,
|
||||||
BarChart,
|
BarChart,
|
||||||
|
FunnelChart,
|
||||||
LineChart,
|
LineChart,
|
||||||
PieChart,
|
PieChart,
|
||||||
MapChart,
|
MapChart,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-default flex h-screen w-full">
|
<div class="layout-default flex h-screen w-full">
|
||||||
<div class="app-aside">
|
<!-- shrink-0:避免主内容区(宽表格/图表)把侧栏压缩到折叠宽度 -->
|
||||||
|
<div class="app-aside shrink-0">
|
||||||
<layout-sidebar />
|
<layout-sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: '/tcm/diagnosis/h5',
|
path: '/tcm/diagnosis/h5',
|
||||||
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
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',
|
// path: '/dev_tools',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import { getConfig } from '@/api/app'
|
import { getConfig } from '@/api/app'
|
||||||
|
import configs from '@/config'
|
||||||
|
|
||||||
interface AppSate {
|
interface AppSate {
|
||||||
config: Record<string, any>
|
config: Record<string, any>
|
||||||
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
|
|||||||
getImageUrl(url: string) {
|
getImageUrl(url: string) {
|
||||||
if (!url || typeof url !== 'string') return ''
|
if (!url || typeof url !== 'string') return ''
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||||
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
|
const path = url.startsWith('/') ? url : '/' + url
|
||||||
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + 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() {
|
getConfig() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -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 ?? '—'}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 病例 / 接诊页通用展示辅助
|
||||||
|
*
|
||||||
|
* - maskPhone:手机号脱敏 138****1234
|
||||||
|
* - formatGender:0/1 → 女/男
|
||||||
|
* - formatPeriod:morning/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: '诊室' }
|
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') {
|
if (o.businessID === 'user_typing_status') {
|
||||||
return { main: '对方正在输入…', tag: '状态' }
|
return { main: '对方正在输入…', tag: '状态' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,25 +60,26 @@
|
|||||||
|
|
||||||
<el-divider content-position="left">组织信息</el-divider>
|
<el-divider content-position="left">组织信息</el-divider>
|
||||||
|
|
||||||
<!-- 归属部门 -->
|
<!-- 归属部门:数据权限内仅末级可选,父级仅用于展开 -->
|
||||||
<el-form-item label="归属部门" prop="dept_id">
|
<el-form-item label="归属部门" prop="dept_id">
|
||||||
<el-tree-select
|
<div class="w-full">
|
||||||
class="flex-1"
|
<el-tree-select
|
||||||
v-model="formData.dept_id"
|
class="flex-1 w-full"
|
||||||
:data="optionsData.dept"
|
v-model="formData.dept_id"
|
||||||
clearable
|
:data="optionsData.dept"
|
||||||
multiple
|
clearable
|
||||||
node-key="id"
|
multiple
|
||||||
:props="{
|
node-key="id"
|
||||||
label: 'name',
|
:props="{
|
||||||
disabled(data: any) {
|
label: 'name',
|
||||||
return data.status !== 1
|
disabled: deptTreeNodeDisabled
|
||||||
}
|
}"
|
||||||
}"
|
check-strictly
|
||||||
check-strictly
|
:default-expand-all="true"
|
||||||
:default-expand-all="true"
|
placeholder="请选择末级归属部门"
|
||||||
placeholder="请选择归属部门"
|
/>
|
||||||
/>
|
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 岗位 -->
|
<!-- 岗位 -->
|
||||||
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app'
|
|||||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||||
import feedback from '@/utils/feedback'
|
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 emit = defineEmits(['success', 'close'])
|
||||||
const formRef = shallowRef<FormInstance>()
|
const formRef = shallowRef<FormInstance>()
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{
|
|||||||
api: jobsAll
|
api: jobsAll
|
||||||
},
|
},
|
||||||
dept: {
|
dept: {
|
||||||
api: deptAll
|
api: deptAll,
|
||||||
|
params: { apply_data_scope: 1 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -54,25 +54,26 @@
|
|||||||
|
|
||||||
<el-divider content-position="left">组织信息</el-divider>
|
<el-divider content-position="left">组织信息</el-divider>
|
||||||
|
|
||||||
<!-- 归属部门 -->
|
<!-- 归属部门:数据权限内仅末级可选,父级仅用于展开 -->
|
||||||
<el-form-item label="归属部门" prop="dept_id">
|
<el-form-item label="归属部门" prop="dept_id">
|
||||||
<el-tree-select
|
<div class="w-full">
|
||||||
class="flex-1"
|
<el-tree-select
|
||||||
v-model="formData.dept_id"
|
class="flex-1 w-full"
|
||||||
:data="optionsData.dept"
|
v-model="formData.dept_id"
|
||||||
clearable
|
:data="optionsData.dept"
|
||||||
multiple
|
clearable
|
||||||
node-key="id"
|
multiple
|
||||||
:props="{
|
node-key="id"
|
||||||
label: 'name',
|
:props="{
|
||||||
disabled(data: any) {
|
label: 'name',
|
||||||
return data.status !== 1
|
disabled: deptTreeNodeDisabled
|
||||||
}
|
}"
|
||||||
}"
|
check-strictly
|
||||||
check-strictly
|
:default-expand-all="true"
|
||||||
:default-expand-all="true"
|
placeholder="请选择末级归属部门"
|
||||||
placeholder="请选择归属部门"
|
/>
|
||||||
/>
|
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 岗位 -->
|
<!-- 岗位 -->
|
||||||
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app'
|
|||||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||||
import feedback from '@/utils/feedback'
|
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 emit = defineEmits(['success', 'close'])
|
||||||
const formRef = shallowRef<FormInstance>()
|
const formRef = shallowRef<FormInstance>()
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{
|
|||||||
api: jobsAll
|
api: jobsAll
|
||||||
},
|
},
|
||||||
dept: {
|
dept: {
|
||||||
api: deptAll
|
api: deptAll,
|
||||||
|
params: { apply_data_scope: 1 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,12 @@ import EditPopup from './edit.vue'
|
|||||||
|
|
||||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||||
|
|
||||||
// 表单数据 - 固定role_id=1查询医生
|
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
account: '',
|
account: '',
|
||||||
name: '',
|
name: '',
|
||||||
role_id: 1 // 固定为医生角色
|
role_id: 1,
|
||||||
|
apply_data_scope: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const showEdit = ref(false)
|
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>
|
||||||
@@ -548,12 +548,20 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="患者姓名" prop="patient_name">
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="门诊号" prop="visit_no">
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -631,12 +639,22 @@
|
|||||||
|
|
||||||
<el-form-item label="药材配方" prop="herbs" required>
|
<el-form-item label="药材配方" prop="herbs" required>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<el-button type="primary" size="small" @click="addHerb">
|
<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" class="ml-2" @click="openLibraryDialog">
|
</el-button>
|
||||||
从处方库导入
|
<el-button type="success" size="small" @click="openLibraryDialog">
|
||||||
</el-button>
|
从处方库导入
|
||||||
|
</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 :data="editForm.herbs" class="mt-2" border>
|
||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
@@ -669,6 +687,49 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
</el-table>
|
</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>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -755,6 +816,17 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</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">
|
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||||
@@ -1183,7 +1255,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="备注">
|
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="createOrderForm.remark_extra"
|
v-model="createOrderForm.remark_extra"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -1193,6 +1265,16 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -1295,6 +1377,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</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="示例: 黄芪15 党参12 茯苓10 柴胡10g、黄芩10、半夏12克 黄芪、白术、茯苓各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 = ''">
|
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
|
||||||
<p class="text-sm text-gray-600 mb-3">
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
|
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
|
||||||
@@ -1333,10 +1449,11 @@ import {
|
|||||||
prescriptionDetail,
|
prescriptionDetail,
|
||||||
prescriptionOrderCreate,
|
prescriptionOrderCreate,
|
||||||
prescriptionOrderPaidPayOrders,
|
prescriptionOrderPaidPayOrders,
|
||||||
|
prescriptionOrderLists,
|
||||||
getDoctors,
|
getDoctors,
|
||||||
prescriptionLibraryLists
|
prescriptionLibraryLists
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
import { searchPatients as searchPatientsAPI } from '@/api/order'
|
import { medicineLists } from '@/api/medicine'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import { roleAll } from '@/api/perms/role'
|
import { roleAll } from '@/api/perms/role'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
@@ -1345,7 +1462,7 @@ import feedback from '@/utils/feedback'
|
|||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
import MedicineNameSelect from '@/components/medicine-name-select/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 { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
@@ -1475,6 +1592,7 @@ const createOrderForm = reactive({
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
internal_cost: undefined as number | undefined,
|
internal_cost: undefined as number | undefined,
|
||||||
remark_extra: '',
|
remark_extra: '',
|
||||||
|
remark_assistant: '',
|
||||||
pay_order_ids: [] as number[]
|
pay_order_ids: [] as number[]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1714,6 +1832,7 @@ function resetCreateOrderForm() {
|
|||||||
createOrderForm.amount = 0
|
createOrderForm.amount = 0
|
||||||
createOrderForm.internal_cost = undefined
|
createOrderForm.internal_cost = undefined
|
||||||
createOrderForm.remark_extra = ''
|
createOrderForm.remark_extra = ''
|
||||||
|
createOrderForm.remark_assistant = ''
|
||||||
createOrderForm.pay_order_ids = []
|
createOrderForm.pay_order_ids = []
|
||||||
createOrderPatientList.value = []
|
createOrderPatientList.value = []
|
||||||
createOrderPaidOrders.value = []
|
createOrderPaidOrders.value = []
|
||||||
@@ -1884,7 +2003,8 @@ async function submitCreateOrderFromPrescription() {
|
|||||||
tracking_number: createOrderForm.tracking_number || '',
|
tracking_number: createOrderForm.tracking_number || '',
|
||||||
fee_type: createOrderForm.fee_type,
|
fee_type: createOrderForm.fee_type,
|
||||||
amount: createOrderForm.amount,
|
amount: createOrderForm.amount,
|
||||||
remark_extra: createOrderForm.remark_extra || ''
|
remark_extra: createOrderForm.remark_extra || '',
|
||||||
|
remark_assistant: createOrderForm.remark_assistant || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加省市区字段
|
// 添加省市区字段
|
||||||
@@ -2020,15 +2140,19 @@ const rxUsageText = computed(() => {
|
|||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
if (v.usage_text) return v.usage_text
|
if (v.usage_text) return v.usage_text
|
||||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
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 amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||||
const unit = v.dosage_unit || 'g'
|
const unit = v.dosage_unit || 'g'
|
||||||
const usageWay = v.usage_way || '温水送服'
|
const usageWay = v.usage_way || '温水送服'
|
||||||
const usageTime = v.usage_time || ''
|
const usageTime = v.usage_time || ''
|
||||||
const seg: string[] = []
|
const seg: string[] = []
|
||||||
seg.push(`每天${times}次`)
|
seg.push(`每天${times}次`)
|
||||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||||
seg.push(`每包${amount}${unit}`)
|
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)
|
seg.push(usageWay)
|
||||||
if (usageTime) seg.push(usageTime)
|
if (usageTime) seg.push(usageTime)
|
||||||
return seg.join(', ')
|
return seg.join(', ')
|
||||||
@@ -2066,6 +2190,22 @@ const rxOutPelletText = computed(() => {
|
|||||||
|
|
||||||
function computeOutPellet(v: any): string {
|
function computeOutPellet(v: any): string {
|
||||||
if (!v) return ''
|
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
|
const explicit = v.out_pellet || v.total_weight
|
||||||
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
|
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
|
||||||
const list = slipHerbsList.value
|
const list = slipHerbsList.value
|
||||||
@@ -2197,6 +2337,7 @@ const editForm = reactive({
|
|||||||
prescription_type: '浓缩水丸',
|
prescription_type: '浓缩水丸',
|
||||||
dosage_amount: 1 as number | undefined,
|
dosage_amount: 1 as number | undefined,
|
||||||
dosage_unit: 'g',
|
dosage_unit: 'g',
|
||||||
|
dosage_bag_count: 1,
|
||||||
need_decoction: false,
|
need_decoction: false,
|
||||||
bags_per_dose: 1,
|
bags_per_dose: 1,
|
||||||
patient_name: '',
|
patient_name: '',
|
||||||
@@ -2238,6 +2379,52 @@ const editForm = reactive({
|
|||||||
times_per_day: 2
|
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<{
|
const diagnosisViewRef = ref<{
|
||||||
openViewOnly: (id: number) => Promise<void>
|
openViewOnly: (id: number) => Promise<void>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -2264,16 +2451,19 @@ watch(() => editForm.prescription_type, (newType) => {
|
|||||||
if (newType === '浓缩水丸') {
|
if (newType === '浓缩水丸') {
|
||||||
editForm.dosage_unit = 'g'
|
editForm.dosage_unit = 'g'
|
||||||
editForm.dosage_amount = 1
|
editForm.dosage_amount = 1
|
||||||
|
editForm.dosage_bag_count = 1
|
||||||
editForm.need_decoction = false
|
editForm.need_decoction = false
|
||||||
editForm.bags_per_dose = 1
|
editForm.bags_per_dose = 1
|
||||||
} else if (newType === '饮片') {
|
} else if (newType === '饮片') {
|
||||||
editForm.dosage_unit = 'ml'
|
editForm.dosage_unit = 'ml'
|
||||||
editForm.dosage_amount = 50
|
editForm.dosage_amount = 50
|
||||||
|
editForm.dosage_bag_count = 1
|
||||||
editForm.bags_per_dose = 1
|
editForm.bags_per_dose = 1
|
||||||
// need_decoction 保持用户选择
|
// need_decoction 保持用户选择
|
||||||
} else {
|
} else {
|
||||||
editForm.dosage_unit = 'g'
|
editForm.dosage_unit = 'g'
|
||||||
editForm.dosage_amount = undefined
|
editForm.dosage_amount = undefined
|
||||||
|
editForm.dosage_bag_count = 1
|
||||||
editForm.need_decoction = false
|
editForm.need_decoction = false
|
||||||
editForm.bags_per_dose = 1
|
editForm.bags_per_dose = 1
|
||||||
}
|
}
|
||||||
@@ -2833,6 +3023,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(/[0-9]/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 = () => {
|
const addHerb = () => {
|
||||||
editForm.herbs.push({
|
editForm.herbs.push({
|
||||||
@@ -2850,6 +3211,11 @@ const removeHerb = (index: number) => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
editForm.id = 0
|
editForm.id = 0
|
||||||
editForm.prescription_type = '浓缩水丸'
|
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.patient_name = ''
|
||||||
editForm.gender = 1
|
editForm.gender = 1
|
||||||
editForm.age = 0
|
editForm.age = 0
|
||||||
@@ -2882,6 +3248,7 @@ const resetForm = () => {
|
|||||||
editForm.is_system_auto = 0
|
editForm.is_system_auto = 0
|
||||||
editForm.business_prescription_audit_rejected = 0
|
editForm.business_prescription_audit_rejected = 0
|
||||||
editForm.business_prescription_audit_remark = ''
|
editForm.business_prescription_audit_remark = ''
|
||||||
|
clearRxLinkedOrderHint()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增处方
|
// 新增处方
|
||||||
@@ -2966,6 +3333,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.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.bags_per_dose = src.bags_per_dose ? Number(src.bags_per_dose) : 1
|
||||||
editForm.patient_name = src.patient_name || ''
|
editForm.patient_name = src.patient_name || ''
|
||||||
@@ -3009,8 +3377,9 @@ const handleEdit = async (row: any) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoadingData.value = false
|
isLoadingData.value = false
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
|
void loadRxLinkedOrderHintForEdit(Number(editForm.id))
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
formRef.value?.clearValidate()
|
formRef.value?.clearValidate()
|
||||||
scheduleEditSignatureInit()
|
scheduleEditSignatureInit()
|
||||||
@@ -3020,9 +3389,14 @@ const handleEdit = async (row: any) => {
|
|||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formRef.value) return
|
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) {
|
if (!editForm.herbs || editForm.herbs.length === 0) {
|
||||||
feedback.msgError('请至少添加一味药材')
|
feedback.msgError('请至少添加一味药材')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -677,7 +677,19 @@
|
|||||||
<el-row :gutter="16" class="mb-5">
|
<el-row :gutter="16" class="mb-5">
|
||||||
<el-col :xs="12" :sm="6">
|
<el-col :xs="12" :sm="6">
|
||||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
<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>
|
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -819,6 +831,9 @@
|
|||||||
<el-descriptions-item label="用量">
|
<el-descriptions-item label="用量">
|
||||||
<template v-if="detailPrescription.dosage_amount">
|
<template v-if="detailPrescription.dosage_amount">
|
||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ 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">
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||||
</span>
|
</span>
|
||||||
@@ -910,11 +925,12 @@
|
|||||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="82" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||||
{{ row.status_desc || orderStatusText(row.status) }}
|
{{ row.status_desc || orderStatusText(row.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||||
@@ -967,11 +983,12 @@
|
|||||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="82" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||||
{{ row.status_desc || orderStatusText(row.status) }}
|
{{ row.status_desc || orderStatusText(row.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||||
@@ -1091,7 +1108,8 @@
|
|||||||
{{ expressCompanyLabel(detailData.express_company) }}
|
{{ expressCompanyLabel(detailData.express_company) }}
|
||||||
</el-descriptions-item>
|
</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">
|
<el-descriptions-item label="处方审核意见" :span="3">
|
||||||
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -1217,6 +1235,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</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
|
<el-dialog
|
||||||
v-model="editVisible"
|
v-model="editVisible"
|
||||||
@@ -1563,7 +1622,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="备注">
|
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="editForm.remark_extra"
|
v-model="editForm.remark_extra"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -1573,6 +1632,16 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -2328,6 +2397,7 @@ import {
|
|||||||
prescriptionOrderLogisticsTrace,
|
prescriptionOrderLogisticsTrace,
|
||||||
prescriptionOrderShip,
|
prescriptionOrderShip,
|
||||||
prescriptionOrderLogs,
|
prescriptionOrderLogs,
|
||||||
|
prescriptionOrderUpdateAmount,
|
||||||
prescriptionOrderAddPayOrder,
|
prescriptionOrderAddPayOrder,
|
||||||
prescriptionOrderComplete,
|
prescriptionOrderComplete,
|
||||||
prescriptionOrderRevokeRxAudit,
|
prescriptionOrderRevokeRxAudit,
|
||||||
@@ -2789,6 +2859,9 @@ const listStats = computed(() => {
|
|||||||
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
||||||
} else if (scope === 'assistant') {
|
} else if (scope === 'assistant') {
|
||||||
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
||||||
|
} else if (scope === 'own_rx') {
|
||||||
|
scopeHint =
|
||||||
|
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||||
}
|
}
|
||||||
const hasCustomTime =
|
const hasCustomTime =
|
||||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||||
@@ -2979,14 +3052,18 @@ function canEditRow(row: {
|
|||||||
gancao_reciperl_order_no?: string | null
|
gancao_reciperl_order_no?: string | null
|
||||||
gancao_submit_time?: number | null
|
gancao_submit_time?: number | null
|
||||||
}) {
|
}) {
|
||||||
// 已发货(5)、已签收(6)、已完成(3)、已取消(4) 不可编辑
|
|
||||||
const fs = Number(row.fulfillment_status)
|
const fs = Number(row.fulfillment_status)
|
||||||
if (fs !== 1 && fs !== 2) return false
|
if (fs === 3 || fs === 4) return false
|
||||||
// 已成功提交甘草(已生成甘草处方单号 或 提交时间 > 0)不允许再编辑
|
|
||||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||||
const gcTime = Number(row.gancao_submit_time || 0)
|
const gcTime = Number(row.gancao_submit_time || 0)
|
||||||
if (gcNo !== '' || gcTime > 0) return false
|
const gcLocked = gcNo !== '' || gcTime > 0
|
||||||
return true
|
|
||||||
|
if (gcLocked) {
|
||||||
|
return [1, 2, 5, 7].includes(fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs === 1 || fs === 2
|
||||||
}
|
}
|
||||||
|
|
||||||
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
|
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
|
||||||
@@ -3055,9 +3132,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||||
// 已发货(5) / 已签收(6) 后如需修改快递信息可单独使用此功能
|
return Number(row.fulfillment_status) === 5
|
||||||
const fs = Number(row.fulfillment_status)
|
|
||||||
return fs === 5 || fs === 6
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadGancaoRow(row: {
|
function canUploadGancaoRow(row: {
|
||||||
@@ -3116,6 +3191,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
|||||||
else if (cmd === 'withdraw') confirmWithdraw(row as 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 detailVisible = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailData = ref<Record<string, any> | null>(null)
|
const detailData = ref<Record<string, any> | null>(null)
|
||||||
@@ -3327,6 +3408,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
|||||||
const logisticsTracePhoneTail = ref('')
|
const logisticsTracePhoneTail = ref('')
|
||||||
|
|
||||||
const detailLogs = ref<any[]>([])
|
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() {
|
function canViewPrescriptionOrderLogs() {
|
||||||
const p = userStore.perms || []
|
const p = userStore.perms || []
|
||||||
@@ -3362,6 +3466,7 @@ function logActionText(act: string) {
|
|||||||
revoke_pay_audit: '撤回支付审核',
|
revoke_pay_audit: '撤回支付审核',
|
||||||
gancao_submit: '甘草下单',
|
gancao_submit: '甘草下单',
|
||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
|
update_amount: '修改订单金额',
|
||||||
complete: '完成订单'
|
complete: '完成订单'
|
||||||
}
|
}
|
||||||
return m[act] || act
|
return m[act] || act
|
||||||
@@ -3446,6 +3551,24 @@ function resetPatchRxPatientForm() {
|
|||||||
patchRxPatientForm.phone = ''
|
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() {
|
async function refreshCurrentPrescriptionOrderDetail() {
|
||||||
const id = detailData.value?.id
|
const id = detailData.value?.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -3485,6 +3608,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) {
|
async function openDetail(id: number) {
|
||||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||||
detailData.value = null
|
detailData.value = null
|
||||||
@@ -3572,6 +3726,7 @@ const editForm = reactive({
|
|||||||
fee_type: 3,
|
fee_type: 3,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
remark_extra: '',
|
remark_extra: '',
|
||||||
|
remark_assistant: '',
|
||||||
pay_order_ids: [] as number[],
|
pay_order_ids: [] as number[],
|
||||||
internal_cost: undefined as number | string | undefined,
|
internal_cost: undefined as number | string | undefined,
|
||||||
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
||||||
@@ -3822,6 +3977,7 @@ async function openEdit(row: { id: number }) {
|
|||||||
|
|
||||||
editForm.amount = amt
|
editForm.amount = amt
|
||||||
editForm.remark_extra = d.remark_extra || ''
|
editForm.remark_extra = d.remark_extra || ''
|
||||||
|
editForm.remark_assistant = d.remark_assistant || ''
|
||||||
const pids = d.pay_order_ids
|
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.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
|
||||||
editForm.internal_cost =
|
editForm.internal_cost =
|
||||||
@@ -3860,6 +4016,7 @@ async function submitEdit() {
|
|||||||
fee_type: editForm.fee_type,
|
fee_type: editForm.fee_type,
|
||||||
amount: editForm.amount,
|
amount: editForm.amount,
|
||||||
remark_extra: editForm.remark_extra || '',
|
remark_extra: editForm.remark_extra || '',
|
||||||
|
remark_assistant: editForm.remark_assistant || '',
|
||||||
|
|
||||||
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
||||||
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
||||||
@@ -4139,6 +4296,7 @@ async function submitQuickTrack() {
|
|||||||
fee_type: Number(d.fee_type) || 3,
|
fee_type: Number(d.fee_type) || 3,
|
||||||
amount: Number(d.amount) || 0,
|
amount: Number(d.amount) || 0,
|
||||||
remark_extra: d.remark_extra || '',
|
remark_extra: d.remark_extra || '',
|
||||||
|
remark_assistant: d.remark_assistant || '',
|
||||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
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 : [],
|
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||||
// 仅覆盖快递字段
|
// 仅覆盖快递字段
|
||||||
@@ -4156,8 +4314,8 @@ async function submitQuickTrack() {
|
|||||||
if (nd) detailData.value = nd
|
if (nd) detailData.value = nd
|
||||||
} catch { /* 静默 */ }
|
} catch { /* 静默 */ }
|
||||||
}
|
}
|
||||||
// 履约中状态且刚填单号,提示是否立即发货
|
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||||
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
|
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||||
@@ -4492,19 +4650,21 @@ const slipPerBagDosageText = computed(() => {
|
|||||||
const d = prescriptionViewData.value
|
const d = prescriptionViewData.value
|
||||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
|
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
|
||||||
if (d.dosage_amount == null || d.dosage_amount === '') 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 slipPillGrams = computed(() => {
|
||||||
const d = prescriptionViewData.value
|
const d = prescriptionViewData.value
|
||||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
|
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 days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
|
||||||
const times = Number(d.times_per_day)
|
const times = Number(d.times_per_day)
|
||||||
const doseG = Number(d.dosage_amount)
|
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 (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
|
||||||
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
||||||
return days * times * doseG
|
return days * times * doseG * bags
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -4597,21 +4757,25 @@ const rxUsageText = computed(() => {
|
|||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
if (v.usage_text) return v.usage_text
|
if (v.usage_text) return v.usage_text
|
||||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
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 amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||||
const unit = v.dosage_unit || 'g'
|
const unit = v.dosage_unit || 'g'
|
||||||
const usageWay = v.usage_way || '温水送服'
|
const usageWay = v.usage_way || '温水送服'
|
||||||
const usageTime = v.usage_time || ''
|
const usageTime = v.usage_time || ''
|
||||||
const seg: string[] = []
|
const seg: string[] = []
|
||||||
seg.push(`每天${times}次`)
|
seg.push(`每天${times}次`)
|
||||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||||
seg.push(`每包${amount}${unit}`)
|
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)
|
seg.push(usageWay)
|
||||||
if (usageTime) seg.push(usageTime)
|
if (usageTime) seg.push(usageTime)
|
||||||
return seg.join(', ')
|
return seg.join(', ')
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 出丸:优先用 slipPillGrams(按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
|
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||||
const rxOutPelletGrams = computed(() => {
|
const rxOutPelletGrams = computed(() => {
|
||||||
const pill = slipPillGrams.value
|
const pill = slipPillGrams.value
|
||||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,18 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-form-item label="标签">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.tag_ids"
|
v-model="queryParams.tag_ids"
|
||||||
@@ -557,12 +569,34 @@ const syncSettings = reactive({
|
|||||||
interval: 3600
|
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: '',
|
name: '',
|
||||||
follow_user: '',
|
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 {
|
interface TagItem {
|
||||||
tag_id: string
|
tag_id: string
|
||||||
@@ -793,6 +827,9 @@ function handleReset() {
|
|||||||
queryParams.name = ''
|
queryParams.name = ''
|
||||||
queryParams.follow_user = ''
|
queryParams.follow_user = ''
|
||||||
queryParams.tag_ids = []
|
queryParams.tag_ids = []
|
||||||
|
queryParams.add_time_start = ''
|
||||||
|
queryParams.add_time_end = ''
|
||||||
|
addTimeRange.value = null
|
||||||
resetParams()
|
resetParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3820
-355
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="toolbar__title">制定业绩目标</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>
|
</div>
|
||||||
<div class="toolbar__center">
|
<div class="toolbar__center">
|
||||||
@@ -59,8 +63,16 @@
|
|||||||
|
|
||||||
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
||||||
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
||||||
<p class="empty-state__title">暂无部门数据</p>
|
<p class="empty-state__title">
|
||||||
<p class="empty-state__desc">请检查部门配置后重试</p>
|
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
|
||||||
|
</p>
|
||||||
|
<p class="empty-state__desc">
|
||||||
|
{{
|
||||||
|
dataScopeLimited
|
||||||
|
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
|
||||||
|
: '请检查部门配置后重试'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formRows = ref<Row[]>([])
|
const formRows = ref<Row[]>([])
|
||||||
|
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
|
||||||
|
const dataScopeLimited = ref(false)
|
||||||
|
|
||||||
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||||||
const drillStack = ref<Row[]>([])
|
const drillStack = ref<Row[]>([])
|
||||||
@@ -199,8 +213,10 @@ async function loadMatrix() {
|
|||||||
try {
|
try {
|
||||||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||||||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||||||
|
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||||
} catch {
|
} catch {
|
||||||
formRows.value = []
|
formRows.value = []
|
||||||
|
dataScopeLimited.value = false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -217,6 +233,7 @@ async function handleSave() {
|
|||||||
})
|
})
|
||||||
if (res?.rows) {
|
if (res?.rows) {
|
||||||
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
||||||
|
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||||
drillStack.value = []
|
drillStack.value = []
|
||||||
drilledId.value = null
|
drilledId.value = null
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+957
-391
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
|||||||
:src="getImageUrl(url)"
|
:src="getImageUrl(url)"
|
||||||
:preview-src-list="note.tongue_images.map(getImageUrl)"
|
:preview-src-list="note.tongue_images.map(getImageUrl)"
|
||||||
:initial-index="idx"
|
:initial-index="idx"
|
||||||
|
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="timeline-thumb"
|
class="timeline-thumb"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
:src="getImageUrl(url)"
|
:src="getImageUrl(url)"
|
||||||
:preview-src-list="getImagePreviewList(note.report_files)"
|
:preview-src-list="getImagePreviewList(note.report_files)"
|
||||||
:initial-index="getImagePreviewIndex(note.report_files, idx)"
|
:initial-index="getImagePreviewIndex(note.report_files, idx)"
|
||||||
|
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="timeline-thumb"
|
class="timeline-thumb"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
@@ -128,6 +130,9 @@ const reportModel = ref<string[]>([])
|
|||||||
const prevTongueCount = ref(0)
|
const prevTongueCount = ref(0)
|
||||||
const prevReportCount = ref(0)
|
const prevReportCount = ref(0)
|
||||||
|
|
||||||
|
/** 高于诊单抽屉(最高 4000)与多层弹窗叠加后的遮罩,避免预览被挡住 */
|
||||||
|
const IMAGE_PREVIEW_Z_INDEX = 8000
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||||
|
|
||||||
const isImageFile = (url: string): boolean => {
|
const isImageFile = (url: string): boolean => {
|
||||||
@@ -211,6 +216,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.timeline-actions {
|
.timeline-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
|
|||||||
@@ -111,126 +111,10 @@
|
|||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<!-- 患者信息 -->
|
<!-- 患者信息 -->
|
||||||
<div class="card patient-card">
|
<PatientInfoCard :apt="apt" :diag="diag" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 患者病例(完整、字段与 tcm/diagnosis/edit.vue 保持同步,只读展示) -->
|
<!-- 患者病例(与 tcm/diagnosis/readonly 共用同一组件,确保两处字段一致) -->
|
||||||
<div class="card case-card">
|
<PatientCaseCard :apt="apt" :diag="diag" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 医生备注 & 舌苔照片 -->
|
<!-- 医生备注 & 舌苔照片 -->
|
||||||
<div class="card note-card">
|
<div class="card note-card">
|
||||||
@@ -242,65 +126,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 血糖血压记录 -->
|
<!-- 日常记录(只读,与只读病例页同口径) -->
|
||||||
<div class="card metric-card">
|
<div class="card metric-card">
|
||||||
<div class="card-title">血糖血压记录</div>
|
<div class="card-title">日常记录</div>
|
||||||
<div class="metric-viewport">
|
<DailyMatrix
|
||||||
<el-table
|
:diagnosis-id="Number(diag?.id ?? 0)"
|
||||||
:data="bloodRecords"
|
:patient-id="Number(diag?.patient_id ?? 0)"
|
||||||
size="small"
|
:age="diag?.age ?? null"
|
||||||
stripe
|
:patient-name="diag?.patient_name || ''"
|
||||||
empty-text="暂无血糖血压记录"
|
:read-only="true"
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -317,7 +152,11 @@
|
|||||||
>
|
>
|
||||||
备注
|
备注
|
||||||
</el-button>
|
</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>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -349,13 +188,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||||||
import NoteTimeline from './components/NoteTimeline.vue'
|
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 { completeAppointment } from '@/api/doctor'
|
||||||
import { getCallSignature } from '@/api/tcm'
|
import { getCallSignature } from '@/api/tcm'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
import {
|
import {
|
||||||
Phone,
|
Phone,
|
||||||
Search,
|
Search,
|
||||||
@@ -366,55 +209,6 @@ import {
|
|||||||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||||||
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.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 POLL_MS = 5_000
|
||||||
const PAGE_SIZE = 15
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
@@ -474,9 +268,7 @@ const noteSaving = ref(false)
|
|||||||
|
|
||||||
const apt = computed<any>(() => detail.value?.appointment || {})
|
const apt = computed<any>(() => detail.value?.appointment || {})
|
||||||
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||||||
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
|
|
||||||
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||||||
|
|
||||||
const todayMeta = computed(() => {
|
const todayMeta = computed(() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
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 todayStr = () => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
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 formatGenderAge = (row: QueueRow) => {
|
||||||
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
||||||
return parts.filter((p) => p && p !== '—').join(' ')
|
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')
|
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 fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
const res: any = await receptionQueue({
|
const res: any = await receptionQueue({
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const formData = reactive({
|
|||||||
name: '',
|
name: '',
|
||||||
desc: '',
|
desc: '',
|
||||||
sort: 0,
|
sort: 0,
|
||||||
|
data_scope: 1,
|
||||||
menu_id: [] as any[]
|
menu_id: [] as any[]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -105,24 +105,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-row :gutter="20" class="mt-4">
|
<el-row :gutter="20" class="mt-4">
|
||||||
<el-col :xs="24" :lg="14">
|
<el-col :span="24">
|
||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>转化排行</span>
|
<span>转化排行</span>
|
||||||
<span class="card-hint">{{ dateRangeText }}</span>
|
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<v-charts
|
<v-charts
|
||||||
v-if="rankingChartHasData"
|
v-if="rankingChartHasData"
|
||||||
class="stats-chart"
|
class="stats-chart stats-chart-wide"
|
||||||
:option="rankingChartOption"
|
:option="rankingChartOption"
|
||||||
autoresize
|
autoresize
|
||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无图表数据" />
|
<el-empty v-else description="暂无图表数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</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">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -139,10 +142,7 @@
|
|||||||
<el-empty v-else description="暂无金额数据" />
|
<el-empty v-else description="暂无金额数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
<el-col :xs="24" :lg="12">
|
||||||
|
|
||||||
<el-row :gutter="20" class="mt-4">
|
|
||||||
<el-col :xs="24" :lg="10">
|
|
||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -159,35 +159,82 @@
|
|||||||
<el-empty v-else description="暂无加粉数据" />
|
<el-empty v-else description="暂无加粉数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :lg="14">
|
</el-row>
|
||||||
<el-card class="!border-none" shadow="never">
|
|
||||||
|
<el-row :gutter="20" class="mt-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card class="!border-none stats-detail-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>明细列表</span>
|
<div class="card-header-main">
|
||||||
<span class="card-hint">{{ entityLabel }}维度</span>
|
<span class="card-title">明细列表</span>
|
||||||
|
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table
|
<el-table
|
||||||
:data="pager.lists"
|
:data="pager.lists"
|
||||||
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
|
:row-key="rowKeyFn"
|
||||||
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
||||||
:default-expand-all="false"
|
:default-expand-all="false"
|
||||||
size="large"
|
:indent="20"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
size="default"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
max-height="560"
|
max-height="640"
|
||||||
|
border
|
||||||
>
|
>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:label="entityLabel"
|
:label="entityLabel"
|
||||||
prop="name"
|
prop="name"
|
||||||
fixed="left"
|
fixed="left"
|
||||||
min-width="280"
|
min-width="300"
|
||||||
class-name="dept-name-column"
|
class-name="dept-name-column"
|
||||||
show-overflow-tooltip
|
|
||||||
>
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="dept-name-cell" :title="row.name">
|
<div class="dept-name-cell" :title="row.name">
|
||||||
{{ row.name }}
|
<span
|
||||||
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
@@ -215,6 +262,7 @@
|
|||||||
<script setup lang="ts" name="conversionStatsPage">
|
<script setup lang="ts" name="conversionStatsPage">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { User } from '@element-plus/icons-vue'
|
||||||
import vCharts from 'vue-echarts'
|
import vCharts from 'vue-echarts'
|
||||||
|
|
||||||
import { getConversionStatsOverview } from '@/api/stats'
|
import { getConversionStatsOverview } from '@/api/stats'
|
||||||
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
|
|||||||
return String(value ?? 0)
|
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 () => {
|
onMounted(async () => {
|
||||||
await fetchOverview()
|
await fetchOverview()
|
||||||
})
|
})
|
||||||
@@ -672,6 +741,27 @@ onMounted(async () => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
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 {
|
.card-hint {
|
||||||
@@ -680,19 +770,93 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dept-name-cell {
|
.dept-name-cell {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
max-width: 100%;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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) {
|
:deep(.dept-name-column .cell) {
|
||||||
white-space: nowrap;
|
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 {
|
.stats-chart {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-chart-wide {
|
||||||
|
height: 380px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,15 +49,30 @@
|
|||||||
v-model="form.channel_source"
|
v-model="form.channel_source"
|
||||||
placeholder="请选择渠道来源"
|
placeholder="请选择渠道来源"
|
||||||
class="channel-source-select"
|
class="channel-source-select"
|
||||||
|
@change="onChannelSourceChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in channelOptions"
|
v-for="item in channelOptions"
|
||||||
:key="item.value"
|
:key="String(item.value)"
|
||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.value"
|
:value="item.value"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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="预约医生:">
|
<el-form-item label="预约医生:">
|
||||||
<div class="doctor-list">
|
<div class="doctor-list">
|
||||||
@@ -233,9 +248,44 @@ const form = reactive({
|
|||||||
date: '',
|
date: '',
|
||||||
appointmentTime: '',
|
appointmentTime: '',
|
||||||
remark: '',
|
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天的日期选项(只显示有排班的日期,且大于等于今天)
|
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
|
||||||
const dateOptions = computed<DateOption[]>(() => {
|
const dateOptions = computed<DateOption[]>(() => {
|
||||||
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
||||||
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
|
|||||||
selectedDoctorId.value &&
|
selectedDoctorId.value &&
|
||||||
form.date &&
|
form.date &&
|
||||||
form.appointmentTime &&
|
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.appointmentTime = ''
|
||||||
form.remark = ''
|
form.remark = ''
|
||||||
form.channel_source = ''
|
form.channel_source = ''
|
||||||
|
form.channel_source_detail = ''
|
||||||
timeSlots.value = []
|
timeSlots.value = []
|
||||||
doctorRosterDates.value = []
|
doctorRosterDates.value = []
|
||||||
lastVisit.value = ''
|
lastVisit.value = ''
|
||||||
@@ -412,7 +464,17 @@ const loadLastVisit = async () => {
|
|||||||
const loadChannelOptions = async () => {
|
const loadChannelOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getDictData({ type: 'channels' })
|
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) {
|
} catch (e) {
|
||||||
console.error('加载渠道来源失败:', e)
|
console.error('加载渠道来源失败:', e)
|
||||||
channelOptions.value = []
|
channelOptions.value = []
|
||||||
@@ -612,6 +674,10 @@ const handleConfirm = async () => {
|
|||||||
feedback.msgWarning('请选择渠道来源')
|
feedback.msgWarning('请选择渠道来源')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
|
||||||
|
feedback.msgWarning('请填写自媒体渠道补充信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (blockedByTodayDuplicate.value) {
|
if (blockedByTodayDuplicate.value) {
|
||||||
feedback.msgWarning(
|
feedback.msgWarning(
|
||||||
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
||||||
@@ -631,7 +697,8 @@ const handleConfirm = async () => {
|
|||||||
appointment_time: form.appointmentTime,
|
appointment_time: form.appointmentTime,
|
||||||
appointment_type: form.appointmentType,
|
appointment_type: form.appointmentType,
|
||||||
remark: form.remark,
|
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)
|
console.log('提交预约参数:', params)
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
|
|||||||
const raw = row.channel_source ?? row.channels ?? ''
|
const raw = row.channel_source ?? row.channels ?? ''
|
||||||
if (raw === '' || raw === null || raw === undefined) return '—'
|
if (raw === '' || raw === null || raw === undefined) return '—'
|
||||||
const key = String(raw)
|
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[]) {
|
function sortRows(list: any[]) {
|
||||||
@@ -141,6 +143,8 @@ const load = async () => {
|
|||||||
await loadChannels()
|
await loadChannels()
|
||||||
const res = await appointmentLists({
|
const res = await appointmentLists({
|
||||||
patient_id: props.diagnosisId,
|
patient_id: props.diagnosisId,
|
||||||
|
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
|
||||||
|
diag_scope_relax: 1,
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
page_size: 500
|
page_size: 500
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,35 @@
|
|||||||
<div class="assign-log-panel">
|
<div class="assign-log-panel">
|
||||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
|
<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="操作时间" width="175" prop="create_time_text" />
|
||||||
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
|
<el-table-column label="原医助" min-width="120">
|
||||||
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
|
<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="110" prop="operator_name" />
|
||||||
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
|
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
|
||||||
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
|
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
|
||||||
@@ -12,6 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
|
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
|
||||||
|
|
||||||
@@ -22,12 +50,56 @@ const props = defineProps<{
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<any[]>([])
|
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_time(Unix)格式化为可读时间 */
|
||||||
|
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 () => {
|
const load = async () => {
|
||||||
if (!props.diagnosisId) return
|
if (!props.diagnosisId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
|
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
|
||||||
rows.value = Array.isArray(data) ? data : []
|
const list = Array.isArray(data) ? data : []
|
||||||
|
rows.value = list
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
rows.value = []
|
rows.value = []
|
||||||
@@ -46,3 +118,12 @@ watch(
|
|||||||
|
|
||||||
defineExpose({ refresh: load })
|
defineExpose({ refresh: load })
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div class="call-record-panel">
|
<div class="call-record-panel">
|
||||||
<el-alert
|
<div v-if="!readOnly" class="call-record-toolbar">
|
||||||
type="info"
|
<upload
|
||||||
show-icon
|
ref="toolbarUploadRef"
|
||||||
:closable="false"
|
type="video"
|
||||||
class="mb-4"
|
direct
|
||||||
title="视频通话与录制回放"
|
:multiple="false"
|
||||||
>
|
:limit="1"
|
||||||
<!-- <p class="call-record-tip">
|
:show-progress="true"
|
||||||
下列为与本诊单关联的通话记录。医生接通并同步房间号后,服务端会尝试自动发起「云端混流录制」(需配置 CAM 密钥与云点播等);录制完成后由腾讯云回调写入回放地址。同时,管理端浏览器会进行「本地录制」,挂断后自动上传并合并到本条记录。
|
@success="handleToolbarUploadSuccess"
|
||||||
</p>
|
>
|
||||||
<p class="call-record-tip muted">
|
<el-button type="primary">上传视频</el-button>
|
||||||
回调 URL 示例:<code>{{ callbackHint }}</code>
|
</upload>
|
||||||
(若配置了 <code>trtc.recording_callback_token</code>,请在 URL 后附加
|
</div>
|
||||||
<code>?token=你的密钥</code>)
|
|
||||||
</p> -->
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<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="start_time_text" />
|
||||||
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
||||||
<el-table-column label="通话类型" width="100">
|
<el-table-column label="通话类型" width="100">
|
||||||
@@ -25,7 +39,7 @@
|
|||||||
{{ row.call_type === 1 ? '语音' : '视频' }}
|
{{ row.call_type === 1 ? '语音' : '视频' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="房间号" width="140">
|
<el-table-column label="房间号" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
|
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
|
||||||
<span v-else class="text-gray-400">—</span>
|
<span v-else class="text-gray-400">—</span>
|
||||||
@@ -42,27 +56,18 @@
|
|||||||
{{ row.recording_status_text || '—' }}
|
{{ row.recording_status_text || '—' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="录制回放" min-width="280">
|
<el-table-column v-if="!readOnly" label="上传回放" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
|
<upload
|
||||||
<div
|
type="video"
|
||||||
v-for="(url, idx) in row.recording_urls_list"
|
direct
|
||||||
:key="idx"
|
:multiple="false"
|
||||||
class="recording-item"
|
:limit="1"
|
||||||
>
|
:show-progress="true"
|
||||||
<video
|
@success="(response: any) => handleRowUploadSuccess(row, response)"
|
||||||
v-if="isPlayableVideo(url)"
|
>
|
||||||
:src="url"
|
<el-button type="primary" plain size="small">上传视频</el-button>
|
||||||
controls
|
</upload>
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -70,20 +75,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
|
||||||
import { getCallRecords } from '@/api/tcm'
|
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<{
|
type CallRecordRow = {
|
||||||
diagnosisId: number
|
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 loading = ref(false)
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<CallRecordRow[]>([])
|
||||||
|
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
|
||||||
const callbackHint = computed(() => {
|
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
|
||||||
return `${origin}/api/trtc/recording-notify`
|
|
||||||
})
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!props.diagnosisId) return
|
if (!props.diagnosisId) return
|
||||||
@@ -101,7 +122,7 @@ const load = async () => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.diagnosisId,
|
() => props.diagnosisId,
|
||||||
() => {
|
() => {
|
||||||
load()
|
void load()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -118,37 +139,76 @@ function statusText(status: number) {
|
|||||||
return m[status] ?? '—'
|
return m[status] ?? '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlayableVideo(url: string) {
|
async function handleToolbarUploadSuccess(response: any) {
|
||||||
if (!url || typeof url !== 'string') return false
|
const fileUrl = normalizeUploadUrl(response)
|
||||||
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.call-record-panel {
|
.call-record-panel {
|
||||||
.call-record-tip {
|
.call-record-toolbar {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.recording-video {
|
|
||||||
max-width: 100%;
|
.call-record-empty {
|
||||||
max-height: 180px;
|
padding: 28px 12px;
|
||||||
border-radius: 4px;
|
color: var(--el-text-color-secondary);
|
||||||
background: #000;
|
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>
|
</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">
|
<p class="panel-tip">
|
||||||
展示单聊记录:已合并患者 与
|
展示单聊记录:已合并患者 与
|
||||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||||
数据来自 IM 漫游;若未开通漫游或某账号与该患者无会话,对应侧无消息。
|
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。
|
||||||
</p>
|
</p>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<div class="toolbar mb-3">
|
<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-icon class="mr-1"><Refresh /></el-icon>
|
||||||
刷新(同步云端最新)
|
重新加载已归档
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,8 +72,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, shallowRef, watch, computed } from 'vue'
|
import { ref, shallowRef, watch, computed } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Refresh, Promotion } from '@element-plus/icons-vue'
|
||||||
import { getImChatMessages } from '@/api/tcm'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
|
||||||
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
||||||
import { parseImBusinessPayload } 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 loading = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
const rawRows = shallowRef<any[]>([])
|
const rawRows = shallowRef<any[]>([])
|
||||||
const patientImId = ref('')
|
const patientImId = ref('')
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
@@ -139,13 +145,13 @@ function senderLabel(row: any) {
|
|||||||
return patientName.value ? `患者(${patientName.value})` : '患者'
|
return patientName.value ? `患者(${patientName.value})` : '患者'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(onlyArchived = false) {
|
async function load() {
|
||||||
if (!props.diagnosisId) return
|
if (!props.diagnosisId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = (await getImChatMessages({
|
const res = (await getImChatMessages({
|
||||||
diagnosis_id: props.diagnosisId,
|
diagnosis_id: props.diagnosisId,
|
||||||
only_archived: onlyArchived ? 1 : 0
|
only_archived: 1
|
||||||
})) as {
|
})) as {
|
||||||
lists?: any[]
|
lists?: any[]
|
||||||
patient_im_id?: string
|
patient_im_id?: string
|
||||||
@@ -162,19 +168,33 @@ async function load(onlyArchived = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLive() {
|
function reloadArchived() {
|
||||||
load(false)
|
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(
|
watch(
|
||||||
() => props.diagnosisId,
|
() => props.diagnosisId,
|
||||||
() => {
|
() => {
|
||||||
load(true)
|
load()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
defineExpose({ refresh: refreshLive })
|
defineExpose({ refresh: reloadArchived })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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,665 @@
|
|||||||
|
<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">
|
||||||
|
{{ 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="费用类别">{{ 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 } from 'vue'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import {
|
||||||
|
prescriptionOrderLists,
|
||||||
|
prescriptionOrderDetail,
|
||||||
|
prescriptionOrderLogs,
|
||||||
|
prescriptionOrderLogisticsTrace,
|
||||||
|
prescriptionOrderPaidPayOrders
|
||||||
|
} from '@/api/tcm'
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 详情抽屉 ───
|
||||||
|
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)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
超阈值红字↑(年龄分段:<50:空7/餐9,≥50:空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>
|
||||||
@@ -562,39 +562,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="请先保存诊单后查看" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<!-- 血糖血压记录标签页 -->
|
<!-- <el-tab-pane label="跟踪备注" name="trackingNote" :disabled="!formData.id" lazy>
|
||||||
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
<div v-if="formData.id" class="p-4">
|
||||||
<blood-record-list
|
<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"
|
v-if="formData.id"
|
||||||
:diagnosis-id="Number(formData.id)"
|
:diagnosis-id="Number(formData.id)"
|
||||||
:patient-id="Number(formData.patient_id)"
|
:patient-id="Number(formData.patient_id)"
|
||||||
|
:age="formData.age"
|
||||||
|
:patient-name="formData.patient_name"
|
||||||
:read-only="viewOnly"
|
:read-only="viewOnly"
|
||||||
/>
|
/>
|
||||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</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'])">
|
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||||
<div v-if="formData.id" class="bingli-tab-content">
|
<div v-if="formData.id" class="bingli-tab-content">
|
||||||
@@ -610,6 +606,22 @@
|
|||||||
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
|
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
|
||||||
</el-tab-pane>
|
</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
|
<el-tab-pane
|
||||||
v-if="hasPermission(['tcm.diagnosis/huifang'])"
|
v-if="hasPermission(['tcm.diagnosis/huifang'])"
|
||||||
label="视频录制回放记录"
|
label="视频录制回放记录"
|
||||||
@@ -694,7 +706,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { getDictData } from '@/api/app'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
@@ -702,15 +714,15 @@ import useUserStore from '@/stores/modules/user'
|
|||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import BloodRecordList from './components/BloodRecordList.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
import DietRecordList from './components/DietRecordList.vue'
|
|
||||||
import ExerciseRecordList from './components/ExerciseRecordList.vue'
|
|
||||||
import CaseRecordList from './components/CaseRecordList.vue'
|
import CaseRecordList from './components/CaseRecordList.vue'
|
||||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||||
import AssignLogPanel from './components/AssignLogPanel.vue'
|
import AssignLogPanel from './components/AssignLogPanel.vue'
|
||||||
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
||||||
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.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 TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||||
import { getDoctorNotes } from '@/api/patient'
|
import { getDoctorNotes } from '@/api/patient'
|
||||||
|
|
||||||
@@ -730,6 +742,7 @@ const submitting = ref(false)
|
|||||||
|
|
||||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||||
|
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
||||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
||||||
const phoneRevealUnlocked = ref(false)
|
const phoneRevealUnlocked = ref(false)
|
||||||
@@ -748,10 +761,22 @@ const fetchDiagNotes = async () => {
|
|||||||
} catch { diagNotes.value = [] }
|
} 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) => {
|
watch(() => activeTab.value, async (tab) => {
|
||||||
if (tab === 'doctorNote' && formData.value.id) {
|
if (tab === 'doctorNote' && formData.value.id) {
|
||||||
await fetchDiagNotes()
|
await fetchDiagNotes()
|
||||||
}
|
}
|
||||||
|
if (tab === 'trackingNote' && formData.value.id) {
|
||||||
|
await fetchTrackingNotes()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([visible, activeTab], () => {
|
watch([visible, activeTab], () => {
|
||||||
@@ -772,6 +797,9 @@ watch([visible, activeTab], () => {
|
|||||||
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
|
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
|
||||||
activeTab.value = 'basic'
|
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') {
|
} else if (type === 'add') {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
formData.value.assistant_id = userStore.userInfo?.id || ''
|
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 {
|
.el-drawer__body {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-drawer__footer {
|
.el-drawer__footer {
|
||||||
@@ -1587,22 +1618,48 @@ defineExpose({
|
|||||||
box-shadow: 0 -4px 24px rgb(15 23 42 / 7%);
|
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 {
|
.tcm-diagnosis-tabs .el-tabs__nav-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-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 {
|
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar {
|
||||||
display: none;
|
height: 4px;
|
||||||
height: 0;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.tcm-diagnosis-tabs .el-tabs__nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
float: none !important;
|
float: none !important;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tcm-diagnosis-tabs .el-tabs__item {
|
.tcm-diagnosis-tabs .el-tabs__item {
|
||||||
@@ -1769,4 +1826,3 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,35 @@
|
|||||||
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
|
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
|
class="pending-assign-wrap"
|
||||||
@click="handlePendingAssignTabClick"
|
v-perms="['tcm.diagnosis/assign']"
|
||||||
v-perms="['tcm.diagnosis/assign']"
|
|
||||||
>
|
>
|
||||||
待分配医助
|
<span
|
||||||
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
|
: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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
@@ -116,22 +139,26 @@
|
|||||||
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap px-4">
|
<div class="table-wrap px-4">
|
||||||
<el-table
|
<el-table
|
||||||
:data="pager.lists"
|
:data="pager.lists"
|
||||||
|
row-key="id"
|
||||||
size="default"
|
size="default"
|
||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
|
@row-dblclick="goReadonly"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
class="diagnosis-table"
|
class="diagnosis-table"
|
||||||
stripe
|
stripe
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="48" align="center" />
|
<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 }">
|
<template #default="{ row }">
|
||||||
<div class="patient-cell">
|
<div class="patient-cell">
|
||||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
|
||||||
<div class="patient-info">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -151,7 +178,7 @@
|
|||||||
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="挂号" min-width="150">
|
<el-table-column label="挂号" min-width="175">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div
|
<div
|
||||||
class="appointment-cell"
|
class="appointment-cell"
|
||||||
@@ -168,6 +195,17 @@
|
|||||||
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
|
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
|
||||||
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
|
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
|
||||||
<div class="apt-time">{{ apt.time_text || '-' }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -215,6 +253,20 @@
|
|||||||
<span v-else class="status-unprescribed">未开方</span>
|
<span v-else class="status-unprescribed">未开方</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="video-watch-col">
|
<div class="video-watch-col">
|
||||||
@@ -239,9 +291,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<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 type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-perms="['tcm.diagnosis/kaifang']"
|
v-perms="['tcm.diagnosis/kaifang']"
|
||||||
@@ -280,7 +341,12 @@
|
|||||||
</el-dropdown-item>
|
</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="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="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 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 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>
|
<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"
|
v-model="assignDialogVisible"
|
||||||
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
|
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
|
||||||
width="500px"
|
width="500px"
|
||||||
|
@closed="resetAssignDialog"
|
||||||
>
|
>
|
||||||
<el-form :model="assignForm" label-width="100px">
|
<el-form :model="assignForm" label-width="100px">
|
||||||
<el-form-item label="选择医助" required>
|
<el-form-item label="选择医助" required>
|
||||||
@@ -380,7 +447,6 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
value-key="id"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in assistantOptions"
|
v-for="item in assistantOptions"
|
||||||
@@ -391,7 +457,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
<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>
|
||||||
<el-form-item v-else label="诊单信息">
|
<el-form-item v-else label="诊单信息">
|
||||||
<div class="text-gray-500">
|
<div class="text-gray-500">
|
||||||
@@ -616,7 +682,7 @@ import feedback from '@/utils/feedback'
|
|||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
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 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 { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@@ -665,7 +731,11 @@ const formData = reactive({
|
|||||||
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
|
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
|
||||||
pending_booking: '' as '' | '1',
|
pending_booking: '' as '' | '1',
|
||||||
/** 顶部「已完成」Tab:至少有一条挂号记录为已完成 status=3 */
|
/** 顶部「已完成」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')
|
const activeTab = ref('all')
|
||||||
@@ -700,7 +770,91 @@ const setConfirmed = (v: string) => {
|
|||||||
fetchDateCounts()
|
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 = () => {
|
const doSearch = () => {
|
||||||
|
clearSecondaryFiltersWhenPendingAssignWideSearch()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -723,6 +877,8 @@ const pendingAssignCount = ref<number | undefined>(undefined)
|
|||||||
|
|
||||||
const handleDateTabClick = async (value: string) => {
|
const handleDateTabClick = async (value: string) => {
|
||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -735,6 +891,8 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
|
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
|
||||||
const handlePendingBookingTabClick = async () => {
|
const handlePendingBookingTabClick = async () => {
|
||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
formData.pending_booking = '1'
|
formData.pending_booking = '1'
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
@@ -747,6 +905,8 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
|
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
|
||||||
const handleCompletedVisitTabClick = async () => {
|
const handleCompletedVisitTabClick = async () => {
|
||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -762,6 +922,18 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
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
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -779,7 +951,7 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, 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({ 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 = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[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
|
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
|
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
|
||||||
@@ -913,7 +1085,7 @@ const getAssistantName = (assistantId: number | string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||||
fetchFun: tcmDiagnosisLists,
|
fetchFun: fetchTcmDiagnosisListsForPaging,
|
||||||
params: formData
|
params: formData
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -931,6 +1103,8 @@ const handleReset = () => {
|
|||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -944,6 +1118,19 @@ const isDiagnosisConfirmed = (row: any) => {
|
|||||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
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 }) => {
|
const getRowClassName = ({ row }: { row: any }) => {
|
||||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||||
@@ -1146,6 +1333,8 @@ const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
|
|||||||
|
|
||||||
// 批量指派相关
|
// 批量指派相关
|
||||||
const selectedIds = ref<number[]>([])
|
const selectedIds = ref<number[]>([])
|
||||||
|
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
|
||||||
|
const batchAssignIds = ref<number[]>([])
|
||||||
const assignDialogVisible = ref(false)
|
const assignDialogVisible = ref(false)
|
||||||
const assignLoading = ref(false)
|
const assignLoading = ref(false)
|
||||||
const assignMode = ref<'batch' | 'single'>('batch')
|
const assignMode = ref<'batch' | 'single'>('batch')
|
||||||
@@ -1154,6 +1343,11 @@ const assignForm = ref({
|
|||||||
assistant_id: null as number | null
|
assistant_id: null as number | null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function resetAssignDialog() {
|
||||||
|
batchAssignIds.value = []
|
||||||
|
assignForm.value.assistant_id = null
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionChange = (selection: any[]) => {
|
const handleSelectionChange = (selection: any[]) => {
|
||||||
selectedIds.value = selection.map(item => item.id)
|
selectedIds.value = selection.map(item => item.id)
|
||||||
}
|
}
|
||||||
@@ -1175,8 +1369,9 @@ const canBatchCancelAssign = computed(() => {
|
|||||||
const handleSingleAssign = (row: any) => {
|
const handleSingleAssign = (row: any) => {
|
||||||
assignMode.value = 'single'
|
assignMode.value = 'single'
|
||||||
currentRow.value = row
|
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
|
assignDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,6 +1384,7 @@ const handleBatchAssign = () => {
|
|||||||
assignMode.value = 'batch'
|
assignMode.value = 'batch'
|
||||||
currentRow.value = null
|
currentRow.value = null
|
||||||
assignForm.value.assistant_id = null
|
assignForm.value.assistant_id = null
|
||||||
|
batchAssignIds.value = [...selectedIds.value]
|
||||||
assignDialogVisible.value = true
|
assignDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,15 +1403,20 @@ const handleConfirmAssign = async () => {
|
|||||||
assistant_id: assignForm.value.assistant_id
|
assistant_id: assignForm.value.assistant_id
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 批量指派
|
// 批量指派:使用打开弹窗时的 id 快照(避免弹窗内操作导致表格勾选被清空后提交为空)
|
||||||
const promises = selectedIds.value.map(id =>
|
const ids = batchAssignIds.value
|
||||||
tcmDiagnosisAssign({
|
if (!ids.length) {
|
||||||
id,
|
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
|
||||||
assistant_id: assignForm.value.assistant_id
|
return
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
await tcmDiagnosisAssign({
|
||||||
|
id,
|
||||||
|
assistant_id: assignForm.value.assistant_id!,
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
await Promise.all(promises)
|
|
||||||
selectedIds.value = []
|
selectedIds.value = []
|
||||||
|
batchAssignIds.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
assignDialogVisible.value = false
|
assignDialogVisible.value = false
|
||||||
@@ -1263,10 +1464,13 @@ const handleEdit = (id: number) => {
|
|||||||
editRef.value.open('edit', id)
|
editRef.value.open('edit', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方 */
|
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
|
||||||
function prescriptionActionLabel(row: any) {
|
function prescriptionActionLabel(row: any) {
|
||||||
|
if (formData.pending_assign === '1') {
|
||||||
|
return '开方'
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
row?.prescription_audit_status === 1 &&
|
Number(row?.prescription_audit_status) === 1 &&
|
||||||
Number(row?.prescription_void_status) !== 1
|
Number(row?.prescription_void_status) !== 1
|
||||||
) {
|
) {
|
||||||
return '查看'
|
return '查看'
|
||||||
@@ -1371,13 +1575,29 @@ const appointmentCellClasses = (row: any) => {
|
|||||||
const isAppointmentActiveForVideo = (row: any) =>
|
const isAppointmentActiveForVideo = (row: any) =>
|
||||||
row.has_appointment && Number(row.appointment_status) === 1
|
row.has_appointment && Number(row.appointment_status) === 1
|
||||||
|
|
||||||
/** 已预约、已过号可取消(后端同步限制) */
|
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||||
const canCancelAppointmentRow = (row: any) => {
|
const canCancelAppointmentRow = (row: any) => {
|
||||||
const s = Number(row.appointment_status)
|
const s = Number(row.appointment_status)
|
||||||
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
|
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) => {
|
const handleCancelAppointment = async (row: any) => {
|
||||||
if (!canCancelAppointmentRow(row)) {
|
if (!canCancelAppointmentRow(row)) {
|
||||||
const s = Number(row.appointment_status)
|
const s = Number(row.appointment_status)
|
||||||
@@ -1388,9 +1608,39 @@ const handleCancelAppointment = async (row: any) => {
|
|||||||
}
|
}
|
||||||
return
|
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 {
|
try {
|
||||||
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
|
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('取消挂号成功')
|
feedback.msgSuccess('取消挂号成功')
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -1706,6 +1956,17 @@ const handleDeleteRecord = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
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(() => {
|
onMounted(() => {
|
||||||
// 默认展示当天挂号,突出重点
|
// 默认展示当天挂号,突出重点
|
||||||
formData.appointment_date = todayStr.value
|
formData.appointment_date = todayStr.value
|
||||||
@@ -1829,6 +2090,27 @@ onUnmounted(() => {
|
|||||||
background: var(--el-color-warning);
|
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 {
|
.search-row {
|
||||||
@@ -2013,6 +2295,11 @@ onUnmounted(() => {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apt-actions {
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
&.apt-item-active {
|
&.apt-item-active {
|
||||||
.apt-badge {
|
.apt-badge {
|
||||||
color: #3a4acc;
|
color: #3a4acc;
|
||||||
@@ -2091,6 +2378,33 @@ onUnmounted(() => {
|
|||||||
color: var(--el-text-color-placeholder);
|
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 {
|
.diagnosis-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dh5-card-sub">
|
<div class="dh5-card-sub">
|
||||||
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
|
#{{ 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>
|
</div>
|
||||||
<div class="dh5-card-tags">
|
<div class="dh5-card-tags">
|
||||||
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
|
|||||||
flex-wrap: wrap;
|
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-gender,
|
||||||
.dh5-meta-age {
|
.dh5-meta-age {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -97,12 +97,14 @@
|
|||||||
stripe
|
stripe
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="48" align="center" />
|
<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 }">
|
<template #default="{ row }">
|
||||||
<div class="patient-cell">
|
<div class="patient-cell">
|
||||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
|
||||||
<div class="patient-info">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1088,7 +1090,7 @@ const handleSingleAssign = (row: any) => {
|
|||||||
assignMode.value = 'single'
|
assignMode.value = 'single'
|
||||||
currentRow.value = row
|
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
|
assignDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+16
-2
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
|
|||||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { fileURLToPath, URL } from 'url'
|
import { fileURLToPath, URL } from 'url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
||||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
||||||
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
|
|||||||
return stylePaths
|
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/',
|
base: '/admin/',
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
hmr: true,
|
hmr: true,
|
||||||
open: true,
|
open: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
|
||||||
|
'/uploads': {
|
||||||
|
target: uploadsProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
'/api-proxy': {
|
'/api-proxy': {
|
||||||
target: 'https://admin.zhenyangtang.com.cn',
|
target: 'https://admin.zhenyangtang.com.cn',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -112,4 +125,5 @@ export default defineConfig({
|
|||||||
__VUE_PROD_HYDRATION__: true,
|
__VUE_PROD_HYDRATION__: true,
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
open_basedir=/www/wwwroot/zyt/server/:/tmp/
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
namespace app\adminapi\controller;
|
namespace app\adminapi\controller;
|
||||||
|
|
||||||
|
|
||||||
|
use app\common\service\DirectUploadService;
|
||||||
use app\common\service\UploadService;
|
use app\common\service\UploadService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use think\response\Json;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,11 @@ class DeptController extends BaseAdminController
|
|||||||
*/
|
*/
|
||||||
public function all()
|
public function all()
|
||||||
{
|
{
|
||||||
$result = DeptLogic::getAllData();
|
$apply = (int) ($this->request->get('apply_data_scope', 0));
|
||||||
|
$result = $apply === 1
|
||||||
|
? DeptLogic::getAllDataScoped($this->adminId, $this->adminInfo)
|
||||||
|
: DeptLogic::getAllData();
|
||||||
|
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,40 @@ class AppointmentController extends BaseAdminController
|
|||||||
return $this->dataLists(new AppointmentLists());
|
return $this->dataLists(new AppointmentLists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台编辑挂号记录(消费者处方-挂号列表等,perms: doctor.appointment/edit)
|
||||||
|
*/
|
||||||
|
public function edit()
|
||||||
|
{
|
||||||
|
$params = (new AppointmentValidate())->post()->goCheck('adminEdit');
|
||||||
|
$post = $this->request->post();
|
||||||
|
if (array_key_exists('assistant_id', $post)) {
|
||||||
|
$params['assistant_id'] = $post['assistant_id'];
|
||||||
|
}
|
||||||
|
$result = AppointmentLogic::adminEdit($params, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(AppointmentLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量修改挂号渠道来源(消费者处方-挂号列表) */
|
||||||
|
public function batchEditChannel()
|
||||||
|
{
|
||||||
|
$params = (new AppointmentValidate())->post()->goCheck('batchEditChannel');
|
||||||
|
$post = $this->request->post();
|
||||||
|
if (\array_key_exists('channel_source_detail', $post)) {
|
||||||
|
$params['channel_source_detail'] = $post['channel_source_detail'];
|
||||||
|
}
|
||||||
|
$result = AppointmentLogic::adminBatchEditChannel($params, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(AppointmentLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success((string) ($result['msg'] ?? '操作成功'), $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 预约详情
|
* @notes 预约详情
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class DeptPerformanceTargetController extends BaseAdminController
|
|||||||
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
|
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
|
||||||
$ym = (string) $params['year_month'];
|
$ym = (string) $params['year_month'];
|
||||||
|
|
||||||
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym));
|
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function batchSave()
|
public function batchSave()
|
||||||
@@ -37,12 +37,18 @@ class DeptPerformanceTargetController extends BaseAdminController
|
|||||||
$ym,
|
$ym,
|
||||||
$items,
|
$items,
|
||||||
$this->adminId,
|
$this->adminId,
|
||||||
|
$this->adminInfo,
|
||||||
(string) ($this->adminInfo['name'] ?? '')
|
(string) ($this->adminInfo['name'] ?? '')
|
||||||
);
|
);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return $this->fail(DeptPerformanceTargetLogic::getError());
|
return $this->fail(DeptPerformanceTargetLogic::getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success('保存成功', DeptPerformanceTargetLogic::monthMatrix($ym), 1, 1);
|
return $this->success(
|
||||||
|
'保存成功',
|
||||||
|
DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo),
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -557,6 +557,24 @@ class OrderController extends BaseAdminController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setExempt()
|
||||||
|
{
|
||||||
|
$id = (int) $this->request->post('id', 0);
|
||||||
|
$isExempt = (int) $this->request->post('is_exempt', 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return $this->fail('参数错误');
|
||||||
|
}
|
||||||
|
if (!in_array($isExempt, [0, 1], true)) {
|
||||||
|
return $this->fail('is_exempt 值无效');
|
||||||
|
}
|
||||||
|
$result = OrderLogic::setExempt($id, $isExempt);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(OrderLogic::getError());
|
||||||
|
}
|
||||||
|
$this->logOrderAction($id, 'set_exempt', $isExempt === 1 ? '设置豁免权' : '取消豁免权');
|
||||||
|
return $this->success($isExempt === 1 ? '已设置豁免权' : '已取消豁免权');
|
||||||
|
}
|
||||||
|
|
||||||
private function logOrderAction(int $orderId, string $action, string $summary = ''): void
|
private function logOrderAction(int $orderId, string $action, string $summary = ''): void
|
||||||
{
|
{
|
||||||
OrderActionLogLogic::record($orderId, (int) $this->adminId, $this->adminInfo, $action, $summary);
|
OrderActionLogLogic::record($orderId, (int) $this->adminId, $this->adminInfo, $action, $summary);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
|
||||||
|
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
|
||||||
|
* 提成结算业绩(独立于业绩看板 yejiStats 接口)
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
* - GET stats.commissionSettlement/overview
|
||||||
|
|
||||||
|
* - GET stats.commissionSettlement/orderLines
|
||||||
|
|
||||||
|
* - GET stats.commissionSettlement/confirmStatus
|
||||||
|
|
||||||
|
* - POST stats.commissionSettlement/saveReconcile
|
||||||
|
|
||||||
|
* - POST stats.commissionSettlement/confirmFinalize
|
||||||
|
|
||||||
|
* - POST stats.commissionSettlement/confirmRevoke
|
||||||
|
|
||||||
|
* - GET stats.commissionSettlement/deptOptions
|
||||||
|
|
||||||
|
* - GET stats.commissionSettlement/channelOptions
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CommissionSettlementController extends BaseAdminController
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
public function overview()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
@set_time_limit(120);
|
||||||
|
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementOverview($params, $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function orderLines()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
@set_time_limit(120);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementOrderLines($this->request->get(), $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function confirmStatus()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementConfirmStatus($this->request->get(), $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function saveReconcile()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementSaveReconcile($this->request->post(), $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function confirmFinalize()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
@set_time_limit(120);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementConfirmFinalize($this->request->post(), $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function confirmRevoke()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::commissionSettlementConfirmRevoke($this->request->post(), $this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function deptOptions()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function channelOptions()
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::channelOptions(false));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
|
|||||||
{
|
{
|
||||||
public function overview()
|
public function overview()
|
||||||
{
|
{
|
||||||
$result = ConversionLogic::overview($this->request->get());
|
$result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
|
||||||
|
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生日统计(系统/手动开方、成交、挂号状态)
|
||||||
|
*
|
||||||
|
* - GET stats.doctorDailyStats/overview
|
||||||
|
* ?start_date=&end_date=&channel_code=
|
||||||
|
* 不传/忽略 dept_ids(医生统计不按展示部门检索);未传日期时由后端默认当日。
|
||||||
|
*/
|
||||||
|
class DoctorDailyStatsController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(DoctorDailyStatsLogic::overview($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,13 @@ use app\adminapi\logic\stats\YejiStatsLogic;
|
|||||||
/**
|
/**
|
||||||
* 业绩看板(部门 × 时间区间)控制器
|
* 业绩看板(部门 × 时间区间)控制器
|
||||||
*
|
*
|
||||||
* - GET stats.yeji-stats/overview 单区间
|
* - GET stats.yejiStats/overview 单区间
|
||||||
* - GET stats.yeji-stats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
|
* - GET stats.yejiStats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
|
||||||
* - GET stats.yeji-stats/dept-options 部门下拉
|
* - GET stats.yejiStats/deptOptions 部门下拉
|
||||||
* - GET stats.yeji-stats/leaderboard 医助排行榜(按展示部门分表)
|
* - GET stats.yejiStats/leaderboard 医助排行榜(按展示部门分表)
|
||||||
|
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
|
||||||
|
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
||||||
|
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
||||||
*/
|
*/
|
||||||
class YejiStatsController extends BaseAdminController
|
class YejiStatsController extends BaseAdminController
|
||||||
{
|
{
|
||||||
@@ -23,7 +26,7 @@ class YejiStatsController extends BaseAdminController
|
|||||||
@set_time_limit(120);
|
@set_time_limit(120);
|
||||||
$params = $this->request->get();
|
$params = $this->request->get();
|
||||||
|
|
||||||
return $this->data(YejiStatsLogic::overview($params));
|
return $this->data(YejiStatsLogic::overview($params, $this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,13 +82,13 @@ class YejiStatsController extends BaseAdminController
|
|||||||
|
|
||||||
return $this->data([
|
return $this->data([
|
||||||
'ranges' => $ranges,
|
'ranges' => $ranges,
|
||||||
'tables' => YejiStatsLogic::overviewBatch($ranges, $base),
|
'tables' => YejiStatsLogic::overviewBatch($ranges, $base, $this->adminId, $this->adminInfo),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deptOptions()
|
public function deptOptions()
|
||||||
{
|
{
|
||||||
return $this->data(YejiStatsLogic::deptOptions());
|
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 渠道(标签)下拉,按 source_group_name 分组 */
|
/** 渠道(标签)下拉,按 source_group_name 分组 */
|
||||||
@@ -100,6 +103,42 @@ class YejiStatsController extends BaseAdminController
|
|||||||
@set_time_limit(120);
|
@set_time_limit(120);
|
||||||
$params = $this->request->get();
|
$params = $this->request->get();
|
||||||
|
|
||||||
return $this->data(YejiStatsLogic::assistantLeaderboards($params));
|
return $this->data(YejiStatsLogic::assistantLeaderboards($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「未归属中心」行:按诊单医助拆解业绩(与表格补差、合计业绩诊单医助归属一致) */
|
||||||
|
public function unassignedBreakdown()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::unassignedCenterBreakdown($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进线数据明细:与看板「进线数据」同口径 */
|
||||||
|
public function leadLines()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::leadLineList($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医助排行榜「预约诊单」:挂号记录逐条列表(与计数同口径) */
|
||||||
|
public function appointmentLines()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::leaderboardAppointmentLines($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二中心复诊:按部门行 + 复诊分项拆解医助与业务订单笔数 */
|
||||||
|
public function revisitBreakdown()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use app\adminapi\controller\BaseAdminController;
|
|||||||
use app\adminapi\lists\tcm\DiagnosisLists;
|
use app\adminapi\lists\tcm\DiagnosisLists;
|
||||||
use app\adminapi\logic\order\OrderActionLogLogic;
|
use app\adminapi\logic\order\OrderActionLogLogic;
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||||
use app\common\model\Order;
|
use app\common\model\Order;
|
||||||
use app\common\model\WechatChatRecord;
|
use app\common\model\WechatChatRecord;
|
||||||
@@ -113,12 +114,87 @@ class DiagnosisController extends BaseAdminController
|
|||||||
*/
|
*/
|
||||||
public function detail()
|
public function detail()
|
||||||
{
|
{
|
||||||
|
|
||||||
$params = (new DiagnosisValidate())->goCheck('id');
|
$params = (new DiagnosisValidate())->goCheck('id');
|
||||||
$result = DiagnosisLogic::detail($params);
|
$result = DiagnosisLogic::detail($params);
|
||||||
|
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 二诊只读病例详情(患者信息 + 病例 + 跟踪记录)
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/readonlyDetail?id=:diagnosisId
|
||||||
|
* 权限:tcm.diagnosis/readonlyDetail
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function readonlyDetail()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('readonlyDetail');
|
||||||
|
$result = DiagnosisLogic::readonlyDetail($params, $this->adminId, $this->adminInfo);
|
||||||
|
if (empty($result)) {
|
||||||
|
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||||
|
}
|
||||||
|
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 跟踪信息(血糖血压 / 饮食 / 运动)—— 按日期区间 lazy load
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/trackingWindow?id=:diagnosisId&start_date=&end_date=
|
||||||
|
* 权限:tcm.diagnosis/readonlyDetail(与只读病例页同档;接诊台亦复用)
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function trackingWindow()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||||
|
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||||
|
(int) $params['id'],
|
||||||
|
(string) ($params['start_date'] ?? ''),
|
||||||
|
(string) ($params['end_date'] ?? '')
|
||||||
|
);
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 新增跟踪备注(按天合并追加,仅文字)
|
||||||
|
*
|
||||||
|
* 路由:POST /tcm.diagnosis/addTrackingNote
|
||||||
|
* 权限:tcm.diagnosis/addTrackingNote
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function addTrackingNote()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->post()->goCheck('addTrackingNote');
|
||||||
|
$ok = TrackingNoteLogic::addOrAppend([
|
||||||
|
'diagnosis_id' => (int) $params['diagnosis_id'],
|
||||||
|
'admin_id' => (int) $this->adminId,
|
||||||
|
'content' => (string) $params['tracking_content'],
|
||||||
|
]);
|
||||||
|
if ($ok === false) {
|
||||||
|
return $this->fail(TrackingNoteLogic::getError() ?: '保存失败');
|
||||||
|
}
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 拉取跟踪备注列表(note_date DESC)
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/trackingNotes?diagnosis_id=:diagnosisId
|
||||||
|
* 权限:tcm.diagnosis/trackingNotes
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function trackingNotes()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('trackingNotes');
|
||||||
|
return $this->data(TrackingNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
||||||
*/
|
*/
|
||||||
@@ -332,6 +408,36 @@ class DiagnosisController extends BaseAdminController
|
|||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 触发后台异步同步当前诊单的腾讯云 IM 聊天记录到本地归档表
|
||||||
|
* 请求即返回,真正的同步逻辑在 fastcgi_finish_request 之后执行
|
||||||
|
*/
|
||||||
|
public function triggerImChatSync()
|
||||||
|
{
|
||||||
|
$diagnosisId = (int)$this->request->post('diagnosis_id', 0);
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
|
||||||
|
}
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return $this->fail('诊单ID不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
register_shutdown_function(function () use ($diagnosisId) {
|
||||||
|
try {
|
||||||
|
@set_time_limit(300);
|
||||||
|
ignore_user_abort(true);
|
||||||
|
DiagnosisLogic::syncImChatArchiveForDiagnosis($diagnosisId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\think\facade\Log::warning('triggerImChatSync failed', [
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'err' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->success('已发起后台同步,几秒后请重新加载查看', ['queued' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 绑定 TRTC 房间号到当前诊单通话记录(便于云端录制回调关联)
|
* @notes 绑定 TRTC 房间号到当前诊单通话记录(便于云端录制回调关联)
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
@@ -396,6 +502,24 @@ class DiagnosisController extends BaseAdminController
|
|||||||
return $this->fail(DiagnosisLogic::getError());
|
return $this->fail(DiagnosisLogic::getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 手动上传视频前,先创建一条模拟通话记录
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function createManualCallRecord()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->post()->goCheck('trackingNotes');
|
||||||
|
$result = DiagnosisLogic::createManualCallRecord([
|
||||||
|
'diagnosis_id' => (int)$params['diagnosis_id'],
|
||||||
|
'admin_id' => (int)$this->adminId,
|
||||||
|
]);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(DiagnosisLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('创建成功', $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 医助旁观诊单当前视频通话(TRTC 进房参数,仅拉流)
|
* @notes 医助旁观诊单当前视频通话(TRTC 进房参数,仅拉流)
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\tcm\DiagnosisTodoLists;
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
|
||||||
|
use app\adminapi\validate\tcm\DiagnosisTodoValidate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项控制器
|
||||||
|
*
|
||||||
|
* 职责:仅 lists / add / cancel / detail;编辑能力故意不开放(要改 = 取消重建)
|
||||||
|
*
|
||||||
|
* @package app\adminapi\controller\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoController extends BaseAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @notes 待办列表(按 diagnosis_id)
|
||||||
|
*/
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
(new DiagnosisTodoValidate())->goCheck('list');
|
||||||
|
|
||||||
|
return $this->dataLists(new DiagnosisTodoLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 新增待办
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->post()->goCheck('add');
|
||||||
|
$result = DiagnosisTodoLogic::add($params, $this->adminId, $this->adminInfo);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(DiagnosisTodoLogic::getError() ?: '创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('创建成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 取消待办(仅 status=0 可取消,仅创建人/超管)
|
||||||
|
*/
|
||||||
|
public function cancel()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->post()->goCheck('cancel');
|
||||||
|
$result = DiagnosisTodoLogic::cancel((int) $params['id'], $this->adminId);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(DiagnosisTodoLogic::getError() ?: '取消失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('已取消', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 待办详情
|
||||||
|
*/
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->goCheck('detail');
|
||||||
|
$result = DiagnosisTodoLogic::detail((int) $params['id'], $this->adminId);
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->dataLists(new PrescriptionOrderLists());
|
return $this->dataLists(new PrescriptionOrderLists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处方业务订单导出(与 lists 相同筛选条件,Excel;参数 export=1 预估 export=2 下载)
|
||||||
|
*/
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
return $this->dataLists(new PrescriptionOrderLists());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定诊单下可关联的支付单(待支付/已支付,创建/编辑业务订单时多选关联)
|
* 指定诊单下可关联的支付单(待支付/已支付,创建/编辑业务订单时多选关联)
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +113,17 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('保存成功', $result);
|
return $this->success('保存成功', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateAmount()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('updateAmount');
|
||||||
|
$result = PrescriptionOrderLogic::updateAmount($params, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('修改成功');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改关联处方的患者姓名与手机号(订单详情场景)
|
* 修改关联处方的患者姓名与手机号(订单详情场景)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ return [
|
|||||||
'listen' => [
|
'listen' => [
|
||||||
'AppInit' => [],
|
'AppInit' => [],
|
||||||
'HttpRun' => [],
|
'HttpRun' => [],
|
||||||
'HttpEnd' => ['app\adminapi\listener\OperationLog'],
|
// 关闭管理员操作日志写入 zyt_operation_log(需记录时恢复下行 listener)
|
||||||
|
'HttpEnd' => [],
|
||||||
'LogLevel' => [],
|
'LogLevel' => [],
|
||||||
'LogWrite' => [],
|
'LogWrite' => [],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class AuthMiddleware
|
|||||||
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
|
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
|
||||||
$AdminUris = $this->formatUrl($AdminUris);
|
$AdminUris = $this->formatUrl($AdminUris);
|
||||||
|
|
||||||
if (in_array($accessUri, $AdminUris)) {
|
if (in_array($accessUri, $AdminUris) || $this->matchPermissionAlias($accessUri, $AdminUris)) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
return JsonService::fail('权限不足,无法访问或操作');
|
return JsonService::fail('权限不足,无法访问或操作');
|
||||||
@@ -103,6 +103,38 @@ class AuthMiddleware
|
|||||||
}, $data);
|
}, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord,
|
||||||
|
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
|
||||||
|
*/
|
||||||
|
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||||
|
{
|
||||||
|
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
|
||||||
|
&& in_array($accessUri, [
|
||||||
|
'tcm.diagnosistodo/lists',
|
||||||
|
'tcm.diagnosistodo/add',
|
||||||
|
'tcm.diagnosistodo/cancel',
|
||||||
|
'tcm.diagnosis/trackingnotes',
|
||||||
|
'tcm.diagnosis/addtrackingnote',
|
||||||
|
], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出与列表共用 PrescriptionOrderLists 数据域;角色漏勾「导出订单」子权限时仍返回 权限不足
|
||||||
|
if ($accessUri === 'tcm.prescriptionorder/export'
|
||||||
|
&& in_array('tcm.prescriptionorder/lists', $adminUris, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
|
||||||
|
if ($accessUri === 'doctor.appointment/batcheditchannel'
|
||||||
|
&& in_array('doctor.appointment/edit', $adminUris, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
|
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
|
||||||
*/
|
*/
|
||||||
@@ -118,4 +150,4 @@ class AuthMiddleware
|
|||||||
|
|
||||||
return in_array($c, ['auth.admin', 'doctor.appointment'], true);
|
return in_array($c, ['auth.admin', 'doctor.appointment'], true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,9 @@ abstract class BaseAdminDataLists extends BaseDataLists
|
|||||||
protected array $adminInfo;
|
protected array $adminInfo;
|
||||||
protected int $adminId;
|
protected int $adminId;
|
||||||
|
|
||||||
public function __construct()
|
protected function initAdminIdentity(): void
|
||||||
{
|
{
|
||||||
parent::__construct();
|
|
||||||
$this->adminInfo = $this->request->adminInfo;
|
$this->adminInfo = $this->request->adminInfo;
|
||||||
$this->adminId = $this->request->adminId;
|
$this->adminId = $this->request->adminId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,11 @@ use app\common\model\DiagnosisViewRecord;
|
|||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\model\dict\DictData;
|
||||||
use app\common\lists\ListsExtendInterface;
|
use app\common\lists\ListsExtendInterface;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
use app\common\lists\Traits\HasDataScopeFilter;
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医生预约列表
|
* 医生预约列表
|
||||||
@@ -20,6 +22,16 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
{
|
{
|
||||||
use HasDataScopeFilter;
|
use HasDataScopeFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单编辑/详情「挂号记录」Tab:按诊单 ID 拉全量挂号,不做医生/医助角色收窄与数据范围过滤。
|
||||||
|
* 须同时传 patient_id(挂号表存的是诊单 id)与本开关,避免列表页被滥用拓宽可见范围。
|
||||||
|
*/
|
||||||
|
private function appointmentListsScopeRelaxedForDiagnosis(): bool
|
||||||
|
{
|
||||||
|
return (int) ($this->params['diag_scope_relax'] ?? 0) === 1
|
||||||
|
&& (int) ($this->params['patient_id'] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据隔离:医生或医助(u.assistant_id = diag.assistant_id)命中可见集合;progress_board 场景放开(看板跨医生查看)
|
* 数据隔离:医生或医助(u.assistant_id = diag.assistant_id)命中可见集合;progress_board 场景放开(看板跨医生查看)
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +40,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
if ($progressBoard) {
|
if ($progressBoard) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($this->appointmentListsScopeRelaxedForDiagnosis()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!$this->dataScopeShouldApply()) {
|
if (!$this->dataScopeShouldApply()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,6 +58,63 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
$inList = implode(',', $ids);
|
$inList = implode(',', $ids);
|
||||||
$query->whereRaw("(a.doctor_id IN ({$inList}) OR u.assistant_id IN ({$inList}))");
|
$query->whereRaw("(a.doctor_id IN ({$inList}) OR u.assistant_id IN ({$inList}))");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按医助筛选:诊单指派医助或挂号记录上的医助任一命中即可
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyAssistantIdFilter($query): void
|
||||||
|
{
|
||||||
|
$aid = (int) ($this->params['assistant_id'] ?? 0);
|
||||||
|
if ($aid <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->where(function ($q) use ($aid): void {
|
||||||
|
$q->where('u.assistant_id', $aid)->whereOr('a.assistant_id', $aid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyChannelSourceFilter($query, string $chFilter): void
|
||||||
|
{
|
||||||
|
if ($chFilter === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tblFields = Db::name('doctor_appointment')->getTableFields();
|
||||||
|
$cols = \is_array($tblFields) ? $tblFields : [];
|
||||||
|
$hasChannelSource = \in_array('channel_source', $cols, true);
|
||||||
|
$hasChannels = \in_array('channels', $cols, true);
|
||||||
|
if (!$hasChannelSource && !$hasChannels) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where(function ($q) use ($chFilter, $hasChannelSource, $hasChannels): void {
|
||||||
|
if ($hasChannelSource && $hasChannels) {
|
||||||
|
$q->where('a.channel_source', '=', $chFilter)
|
||||||
|
->whereOr('a.channels', '=', $chFilter);
|
||||||
|
if (is_numeric($chFilter)) {
|
||||||
|
$q->whereOr('a.channels', '=', (int) $chFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($hasChannelSource) {
|
||||||
|
$q->where('a.channel_source', '=', $chFilter);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$q->where('a.channels', '=', $chFilter);
|
||||||
|
if (is_numeric($chFilter)) {
|
||||||
|
$q->whereOr('a.channels', '=', (int) $chFilter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 设置搜索条件
|
* @notes 设置搜索条件
|
||||||
* @return array
|
* @return array
|
||||||
@@ -76,6 +148,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
$this->searchWhere[] = ['a.status', '=', $this->params['status']];
|
$this->searchWhere[] = ['a.status', '=', $this->params['status']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渠道字典 value(命中 channel_source 或 legacy channels)
|
||||||
|
$chFilter = isset($this->params['channel_source']) ? trim((string) $this->params['channel_source']) : '';
|
||||||
|
|
||||||
// 处理日期范围搜索
|
// 处理日期范围搜索
|
||||||
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||||
$this->searchWhere[] = ['a.appointment_date', 'between', [$this->params['start_date'], $this->params['end_date']]];
|
$this->searchWhere[] = ['a.appointment_date', 'between', [$this->params['start_date'], $this->params['end_date']]];
|
||||||
@@ -109,11 +184,15 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||||
->leftJoin('admin ad', 'a.doctor_id = ad.id')
|
->leftJoin('admin ad', 'a.doctor_id = ad.id')
|
||||||
->leftJoin('admin asst', 'u.assistant_id = asst.id')
|
->leftJoin('admin asst', 'u.assistant_id = asst.id')
|
||||||
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id');
|
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
|
||||||
if ($this->searchWhere !== []) {
|
if ($this->searchWhere !== []) {
|
||||||
$query->where($this->searchWhere);
|
$query->where($this->searchWhere);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
// 是否确认诊单:1=已确认 0=未确认
|
// 是否确认诊单:1=已确认 0=未确认
|
||||||
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||||
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
||||||
@@ -128,7 +207,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
|
|
||||||
// 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄
|
// 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄
|
||||||
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
|
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
|
||||||
if (!$progressBoard) {
|
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
|
||||||
|
if (!$progressBoard && !$diagScopeRelax) {
|
||||||
// 如果是医生角色(role_id=1),只显示挂自己号的预约
|
// 如果是医生角色(role_id=1),只显示挂自己号的预约
|
||||||
if (in_array(1, $roleIds)) {
|
if (in_array(1, $roleIds)) {
|
||||||
$query->where('a.doctor_id', $this->adminId);
|
$query->where('a.doctor_id', $this->adminId);
|
||||||
@@ -198,6 +278,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$channelNameByValue = DictData::where('type_value', 'channels')->column('name', 'value');
|
||||||
|
|
||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
$statusMap = [
|
$statusMap = [
|
||||||
1 => '已预约',
|
1 => '已预约',
|
||||||
@@ -214,6 +297,24 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
];
|
];
|
||||||
$item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知';
|
$item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知';
|
||||||
|
|
||||||
|
$periodRaw = (string) ($item['period'] ?? ($item['type'] ?? ''));
|
||||||
|
$periodMap = [
|
||||||
|
'morning' => '上午',
|
||||||
|
'afternoon' => '下午',
|
||||||
|
'all' => '全天',
|
||||||
|
];
|
||||||
|
$item['period_desc'] = $periodMap[$periodRaw] ?? ($periodRaw !== '' ? $periodRaw : '—');
|
||||||
|
|
||||||
|
$srcKey = trim((string) ($item['channel_source'] ?? ''));
|
||||||
|
if ($srcKey === '' && isset($item['channels']) && $item['channels'] !== '' && $item['channels'] !== null) {
|
||||||
|
$srcKey = trim((string) $item['channels']);
|
||||||
|
}
|
||||||
|
if ($srcKey !== '') {
|
||||||
|
$item['channel_source_desc'] = (string) ($channelNameByValue[$srcKey] ?? $channelNameByValue[(string) (int) $srcKey] ?? $srcKey);
|
||||||
|
} else {
|
||||||
|
$item['channel_source_desc'] = '—';
|
||||||
|
}
|
||||||
|
|
||||||
$item['diagnosis_confirmed'] = isset($confirmedMap[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
|
$item['diagnosis_confirmed'] = isset($confirmedMap[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
|
||||||
$item['has_prescription'] = isset($prescribedDiagnosisIds[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
|
$item['has_prescription'] = isset($prescribedDiagnosisIds[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
|
||||||
|
|
||||||
@@ -253,6 +354,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
if ($applyStatusFilter && isset($this->params['status']) && $this->params['status'] !== '') {
|
if ($applyStatusFilter && isset($this->params['status']) && $this->params['status'] !== '') {
|
||||||
$query->where('a.status', '=', $this->params['status']);
|
$query->where('a.status', '=', $this->params['status']);
|
||||||
}
|
}
|
||||||
|
$chFilter = isset($this->params['channel_source']) ? trim((string) $this->params['channel_source']) : '';
|
||||||
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||||
$query->whereBetween('a.appointment_date', [$this->params['start_date'], $this->params['end_date']]);
|
$query->whereBetween('a.appointment_date', [$this->params['start_date'], $this->params['end_date']]);
|
||||||
} elseif (!empty($this->params['start_date'])) {
|
} elseif (!empty($this->params['start_date'])) {
|
||||||
@@ -269,6 +371,10 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
$query->where('a.doctor_id', '=', (int) $this->params['doctor_id']);
|
$query->where('a.doctor_id', '=', (int) $this->params['doctor_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
||||||
$sf = $this->params['status'] ?? '';
|
$sf = $this->params['status'] ?? '';
|
||||||
if ($sf === '' || (int) $sf !== 2) {
|
if ($sf === '' || (int) $sf !== 2) {
|
||||||
@@ -288,7 +394,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
}
|
}
|
||||||
|
|
||||||
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
|
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
|
||||||
if (!$progressBoard) {
|
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
|
||||||
|
if (!$progressBoard && !$diagScopeRelax) {
|
||||||
if (in_array(1, $roleIds)) {
|
if (in_array(1, $roleIds)) {
|
||||||
$query->where('a.doctor_id', $this->adminId);
|
$query->where('a.doctor_id', $this->adminId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加时间:与 lists 排序口径一致(external_first_add_time 优先,0 则 create_time)
|
||||||
|
$addStart = trim((string) ($this->params['add_time_start'] ?? ''));
|
||||||
|
$addEnd = trim((string) ($this->params['add_time_end'] ?? ''));
|
||||||
|
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
|
||||||
|
if ($addStart !== '') {
|
||||||
|
$t = strtotime($addStart . ' 00:00:00');
|
||||||
|
if ($t !== false) {
|
||||||
|
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($addEnd !== '') {
|
||||||
|
$t = strtotime($addEnd . ' 23:59:59');
|
||||||
|
if ($t !== false) {
|
||||||
|
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ namespace app\adminapi\lists\tcm;
|
|||||||
use app\adminapi\lists\BaseAdminDataLists;
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\BloodRecord;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
|
use app\common\model\Order;
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\model\tcm\CallRecord;
|
use app\common\model\tcm\CallRecord;
|
||||||
@@ -62,36 +64,36 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
|
|
||||||
// 待分配医助筛选:assistant_id 为空或 0(放在最前面判断,便于跳过医助角色的自我过滤)
|
// 待分配医助筛选:assistant_id 为空或 0(放在最前面判断,便于跳过医助角色的自我过滤)
|
||||||
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
||||||
|
$pendingWideSearch = $this->isPendingAssignWideKeywordSearchActive();
|
||||||
|
// 仅「待分配列表」收窄到未分配医助;宽搜不套此池,与「全部」一致可命中已分配医助的诊单
|
||||||
|
$narrowPendingAssignPool = $pendingAssign && !$pendingWideSearch;
|
||||||
|
|
||||||
// 构建查询
|
// 构建查询(待分配+关键词检索时不带诊单启用/禁用 status 条件,并弱化挂号/确认等 Tab 残留参数)
|
||||||
$query = Diagnosis::where($this->searchWhere);
|
$baseWhere = $pendingWideSearch
|
||||||
|
? $this->searchWhereWithoutDiagnosisStatus($this->searchWhere)
|
||||||
|
: $this->searchWhere;
|
||||||
|
$query = Diagnosis::where($baseWhere);
|
||||||
|
|
||||||
// 如果是医助角色(role_id=2),只显示自己添加的患者;
|
// 医助角色(role_id=2):仅在普通列表(非待分配、非待分配宽搜)下限制为本人诊单;待分配窄列表与宽搜均不设此限
|
||||||
// 但当前在查"待分配医助"时应放开,否则 NULL 记录永远不会匹配 assistant_id=adminId
|
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
|
||||||
if (in_array(2, $roleIds) && !$pendingAssign) {
|
|
||||||
$query->where('assistant_id', $this->adminId);
|
$query->where('assistant_id', $this->adminId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$pendingAssign) {
|
// 数据权限:待分配(窄列表与宽搜)均不按 assistant 范围过滤;宽搜额外豁免以便全库关键词命中(与即时需求对齐)
|
||||||
|
if (!$pendingAssign && !$pendingWideSearch) {
|
||||||
$this->applyDataScopeByOwner($query, 'assistant_id');
|
$this->applyDataScopeByOwner($query, 'assistant_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键字搜索:支持患者姓名或手机号(模糊匹配)
|
// 关键字:待分配 Tab 用多字段宽搜;其它 Tab 仍为姓名/手机。
|
||||||
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
|
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
|
||||||
$keyword = trim((string) $this->params['keyword']);
|
|
||||||
$query->where(function ($q) use ($keyword) {
|
|
||||||
$q->whereLike('patient_name', '%' . $keyword . '%')
|
|
||||||
->whereOr('phone', 'like', '%' . $keyword . '%');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 待分配医助:assistant_id 为 NULL 或 0(使用 whereRaw 避免闭包 whereOr 歧义)
|
// 待分配医助列表:assistant_id 为 NULL 或 0(宽搜不加此条件)
|
||||||
if ($pendingAssign) {
|
if ($narrowPendingAssignPool) {
|
||||||
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
|
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 是否确认诊单:1=已确认 0=未确认
|
// 是否确认诊单:1=已确认 0=未确认(待分配+关键词检索时不限制确认态,避免搜不到)
|
||||||
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
if (!$pendingWideSearch && isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||||
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
||||||
$dvrTbl = (new DiagnosisViewRecord())->getTable();
|
$dvrTbl = (new DiagnosisViewRecord())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
@@ -103,16 +105,16 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂号日期筛选:当天/明天/后天(1=已预约 3=已完成 4=已过号,需展示以便取消等操作)
|
// 挂号日期筛选:当天/明天/后天…(待分配+关键词检索时不限制日期)
|
||||||
if (!empty($this->params['appointment_date'])) {
|
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||||
$aptDate = addslashes($this->params['appointment_date']);
|
$aptDate = addslashes($this->params['appointment_date']);
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.appointment_date = '{$aptDate}' AND apt.status IN (1,3,4)");
|
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.appointment_date = '{$aptDate}' AND apt.status IN (1,3,4)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂号状态:1=已挂号(含已预约/已完成/已过号)0=未挂号
|
// 挂号状态:1=已挂号 0=未挂号(待分配+关键词检索时不限制)
|
||||||
if (isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
if (!$pendingWideSearch && isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
if ((int)$this->params['has_appointment'] === 1) {
|
if ((int)$this->params['has_appointment'] === 1) {
|
||||||
@@ -122,22 +124,24 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 至少有一条挂号为「已完成」(status=3),用于列表顶部「已完成」Tab
|
// 至少有一条挂号为「已完成」(status=3)(待分配+关键词检索时不限制)
|
||||||
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
if (!$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅已开方(有处方)的诊单:随访列表传 only_has_prescription=1;问诊等页面不传则不过滤
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
if (isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
|
||||||
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
|
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||||
$rxTbl = (new Prescription())->getTable();
|
$rxTbl = (new Prescription())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.diagnosis_id = {$diagTbl}.id AND rx.delete_time IS NULL");
|
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.diagnosis_id = {$diagTbl}.id AND rx.delete_time IS NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 医助所属部门(随访等页面传 assistant_dept_id):选父级时包含全部子级部门下医助
|
// 医助所属部门(待分配+关键词检索时不按部门收窄)
|
||||||
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
if (!$pendingWideSearch && isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
||||||
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
||||||
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
||||||
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
||||||
@@ -158,7 +162,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$minAptDateCond = '';
|
$minAptDateCond = '';
|
||||||
if (!empty($this->params['appointment_date'])) {
|
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||||
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
||||||
}
|
}
|
||||||
@@ -169,18 +173,18 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
// 获取最早的挂号时间(用于同状态内排序)
|
// 获取最早的挂号时间(用于同状态内排序)
|
||||||
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
||||||
|
|
||||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序,最新在最前;有 appointment_date 时仍只在该日内比
|
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
|
||||||
$isCompletedTab = isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
||||||
if ($isCompletedTab) {
|
if ($isCompletedTab) {
|
||||||
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
||||||
$orderRaw = 'IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
||||||
} else {
|
} else {
|
||||||
$orderRaw = 'CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
$lists = $query
|
$lists = $query
|
||||||
->with(['DiagnosisViewRecord'])
|
->with(['DiagnosisViewRecord'])
|
||||||
->field(['id', 'patient_id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'assistant_id', 'status', 'create_time', 'update_time'])
|
->field(['id', 'patient_id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'assistant_id', 'assign_read_at', 'status', 'create_time', 'update_time'])
|
||||||
->append(['gender_desc', 'status_desc', 'diagnosis_date_text'])
|
->append(['gender_desc', 'status_desc', 'diagnosis_date_text'])
|
||||||
->orderRaw($orderRaw)
|
->orderRaw($orderRaw)
|
||||||
->limit($this->limitOffset, $this->limitLength)
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
@@ -381,6 +385,25 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
|
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
|
||||||
}
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
|
// 未服务天数:以血糖记录最近一条 record_date 为锚点(口径见 prd T3)
|
||||||
|
// 单次 GROUP BY 聚合,不会成 N+1;走 idx_diagnosis_id 索引。
|
||||||
|
$diagIdsForUnserved = array_values(array_filter(array_unique(array_column($lists, 'id'))));
|
||||||
|
$maxBloodByDiag = [];
|
||||||
|
if (!empty($diagIdsForUnserved)) {
|
||||||
|
$maxBloodByDiag = BloodRecord::whereIn('diagnosis_id', $diagIdsForUnserved)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->group('diagnosis_id')
|
||||||
|
->column('MAX(record_date)', 'diagnosis_id');
|
||||||
|
}
|
||||||
|
$nowTs = time();
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$last = (int) ($maxBloodByDiag[(int) $item['id']] ?? 0);
|
||||||
|
$item['last_blood_record_at'] = $last > 0 ? date('Y-m-d', $last) : null;
|
||||||
|
$item['unserved_days'] = $last > 0 ? max(0, (int) floor(($nowTs - $last) / 86400)) : null;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
return $lists;
|
return $lists;
|
||||||
}
|
}
|
||||||
@@ -458,33 +481,29 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
||||||
|
|
||||||
// 构建查询
|
// 构建查询
|
||||||
$query = Diagnosis::where($this->searchWhere);
|
$pendingWideSearch = $this->isPendingAssignWideKeywordSearchActive();
|
||||||
|
$narrowPendingAssignPool = $pendingAssign && !$pendingWideSearch;
|
||||||
|
$baseWhere = $pendingWideSearch
|
||||||
|
? $this->searchWhereWithoutDiagnosisStatus($this->searchWhere)
|
||||||
|
: $this->searchWhere;
|
||||||
|
$query = Diagnosis::where($baseWhere);
|
||||||
|
|
||||||
// 如果是医助角色(role_id=2),只显示自己添加的患者;查"待分配医助"时放开
|
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
|
||||||
if (in_array(2, $roleIds) && !$pendingAssign) {
|
|
||||||
$query->where('assistant_id', $this->adminId);
|
$query->where('assistant_id', $this->adminId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$pendingAssign) {
|
if (!$pendingAssign && !$pendingWideSearch) {
|
||||||
$this->applyDataScopeByOwner($query, 'assistant_id');
|
$this->applyDataScopeByOwner($query, 'assistant_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键字搜索:支持患者姓名或手机号(模糊匹配)
|
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
|
||||||
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
|
|
||||||
$keyword = trim((string) $this->params['keyword']);
|
|
||||||
$query->where(function ($q) use ($keyword) {
|
|
||||||
$q->whereLike('patient_name', '%' . $keyword . '%')
|
|
||||||
->whereOr('phone', 'like', '%' . $keyword . '%');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 待分配医助:assistant_id 为 NULL 或 0
|
if ($narrowPendingAssignPool) {
|
||||||
if ($pendingAssign) {
|
|
||||||
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
|
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 是否确认诊单
|
// 是否确认诊单
|
||||||
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
if (!$pendingWideSearch && isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||||
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
||||||
$dvrTbl = (new DiagnosisViewRecord())->getTable();
|
$dvrTbl = (new DiagnosisViewRecord())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
@@ -497,7 +516,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 挂号日期筛选
|
// 挂号日期筛选
|
||||||
if (!empty($this->params['appointment_date'])) {
|
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||||
$aptDate = addslashes($this->params['appointment_date']);
|
$aptDate = addslashes($this->params['appointment_date']);
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
@@ -505,7 +524,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 挂号状态
|
// 挂号状态
|
||||||
if (isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
if (!$pendingWideSearch && isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
if ((int)$this->params['has_appointment'] === 1) {
|
if ((int)$this->params['has_appointment'] === 1) {
|
||||||
@@ -516,12 +535,171 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 至少有一条挂号为「已完成」(status=3)
|
// 至少有一条挂号为「已完成」(status=3)
|
||||||
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
if (!$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
||||||
$aptTbl = (new Appointment())->getTable();
|
$aptTbl = (new Appointment())->getTable();
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
|
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||||
|
$rxTbl = (new Prescription())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.diagnosis_id = {$diagTbl}.id AND rx.delete_time IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医助所属部门(待分配+关键词检索时不按部门收窄)
|
||||||
|
if (!$pendingWideSearch && isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
||||||
|
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
||||||
|
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
||||||
|
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
||||||
|
return $id > 0;
|
||||||
|
}));
|
||||||
|
if ($deptIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
} else {
|
||||||
|
$inList = implode(',', $deptIds);
|
||||||
|
$adTbl = (new AdminDept())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$query->whereExists("SELECT 1 FROM {$adTbl} ad WHERE ad.admin_id = {$diagTbl}.assistant_id AND ad.dept_id IN ({$inList})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
|
|
||||||
return $query->count();
|
return $query->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配 Tab 且有关键词时:放宽列表条件(与 `applyDiagnosisListKeywordFilter` 判定一致)。
|
||||||
|
*/
|
||||||
|
private function isPendingAssignWideKeywordSearchActive(): bool
|
||||||
|
{
|
||||||
|
$pending = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
||||||
|
if (!$pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$wideKw = trim((string) ($this->params['pending_assign_keyword'] ?? ''));
|
||||||
|
if ($wideKw === '') {
|
||||||
|
$wideKw = trim((string) ($this->params['keyword'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wideKw !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 `createWhere` 结果中移除诊单 `status`(启用/禁用),便于宽检索命中任意状态诊单。
|
||||||
|
*
|
||||||
|
* @param list<array{0:mixed,1?:mixed,2?:mixed}> $searchWhere
|
||||||
|
* @return list<array{0:mixed,1?:mixed,2?:mixed}>
|
||||||
|
*/
|
||||||
|
private function searchWhereWithoutDiagnosisStatus(array $searchWhere): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($searchWhere as $cond) {
|
||||||
|
if (is_array($cond) && isset($cond[0]) && $cond[0] === 'status') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = $cond;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表关键字:待分配医助 Tab 为「多字段」检索(pending_assign_keyword 优先,否则回落 keyword);
|
||||||
|
* 其它 Tab 仍为姓名、手机。待分配 Tab 本身已不套 assistant_id 数据权限,检索可触达全部待分配诊单。
|
||||||
|
*/
|
||||||
|
private function applyDiagnosisListKeywordFilter($query, bool $pendingAssign): void
|
||||||
|
{
|
||||||
|
if ($pendingAssign) {
|
||||||
|
$wideKw = trim((string) ($this->params['pending_assign_keyword'] ?? ''));
|
||||||
|
if ($wideKw === '') {
|
||||||
|
$wideKw = trim((string) ($this->params['keyword'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($wideKw !== '') {
|
||||||
|
$this->applyPendingAssignWideKeywordSearch($query, $wideKw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
|
||||||
|
$keyword = trim((string) $this->params['keyword']);
|
||||||
|
$query->where(function ($q) use ($keyword) {
|
||||||
|
$q->whereLike('patient_name', '%' . $keyword . '%')
|
||||||
|
->whereOr('phone', 'like', '%' . $keyword . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配医助场景:多列模糊 + 纯数字时诊单 id / 患者号精确匹配。
|
||||||
|
*/
|
||||||
|
private function applyPendingAssignWideKeywordSearch($query, string $keyword): void
|
||||||
|
{
|
||||||
|
$kw = trim($keyword);
|
||||||
|
if ($kw === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where(function ($q) use ($kw) {
|
||||||
|
$q->whereLike('patient_name', '%' . $kw . '%')
|
||||||
|
->whereOr('phone', 'like', '%' . $kw . '%')
|
||||||
|
->whereOr('id_card', 'like', '%' . $kw . '%')
|
||||||
|
->whereOr('remark', 'like', '%' . $kw . '%')
|
||||||
|
->whereOr('symptoms', 'like', '%' . $kw . '%')
|
||||||
|
->whereOr('current_medications', 'like', '%' . $kw . '%');
|
||||||
|
if (preg_match('/^\d+$/', $kw)) {
|
||||||
|
$n = (int) $kw;
|
||||||
|
if ($n > 0) {
|
||||||
|
$q->whereOr('id', '=', $n)
|
||||||
|
->whereOr('patient_id', '=', $n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「待分配医助」Tab:按月份筛选——诊单在指定自然月内需存在业务订单(order.patient_id = 诊单 id)
|
||||||
|
* create_time 兼容整型时间戳与 datetime 字符串
|
||||||
|
*/
|
||||||
|
private function applyPendingAssignBusinessOrderMonthFilter($query): void
|
||||||
|
{
|
||||||
|
if ($this->isPendingAssignWideKeywordSearchActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
||||||
|
if (!$pendingAssign || empty($this->params['pending_assign_order_month'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$month = trim((string) $this->params['pending_assign_order_month']);
|
||||||
|
if (!preg_match('/^(\d{4})-(\d{2})$/', $month, $m)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$y = (int) $m[1];
|
||||||
|
$mo = (int) $m[2];
|
||||||
|
if ($y < 1970 || $y > 2100 || $mo < 1 || $mo > 12) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tStart = strtotime(sprintf('%04d-%02d-01 00:00:00', $y, $mo));
|
||||||
|
if ($tStart === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tEnd = strtotime(date('Y-m-t 23:59:59', $tStart));
|
||||||
|
if ($tEnd === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$dsStart = date('Y-m-d H:i:s', $tStart);
|
||||||
|
$dsEnd = date('Y-m-d H:i:s', $tEnd);
|
||||||
|
|
||||||
|
$orderTbl = (new Order())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$sql = "SELECT 1 FROM `{$orderTbl}` o WHERE o.patient_id = {$diagTbl}.id "
|
||||||
|
. 'AND o.delete_time IS NULL AND ('
|
||||||
|
. "(o.create_time >= {$tStart} AND o.create_time <= {$tEnd}) OR "
|
||||||
|
. "(o.create_time >= '{$dsStart}' AND o.create_time <= '{$dsEnd}')"
|
||||||
|
. ')';
|
||||||
|
$query->whereExists($sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项列表
|
||||||
|
*
|
||||||
|
* 默认按 remind_time desc 排序;按 diagnosis_id 过滤。
|
||||||
|
*
|
||||||
|
* @package app\adminapi\lists\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||||
|
{
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'=' => ['diagnosis_id', 'status', 'creator_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$lists = DiagnosisTodo::where($this->searchWhere)
|
||||||
|
->field([
|
||||||
|
'id', 'diagnosis_id', 'patient_id', 'content', 'remind_time',
|
||||||
|
'status', 'creator_id', 'creator_name', 'notified_at', 'error',
|
||||||
|
'cancelled_at', 'cancelled_by', 'create_time', 'update_time',
|
||||||
|
])
|
||||||
|
->append([
|
||||||
|
'status_text',
|
||||||
|
'remind_time_text',
|
||||||
|
'notified_at_text',
|
||||||
|
'cancelled_at_text',
|
||||||
|
])
|
||||||
|
->order('remind_time', 'desc')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 业务态:是否能被「我」取消(前端按钮显隐用)
|
||||||
|
$adminId = (int) $this->adminId;
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$item['can_cancel'] = DiagnosisTodoLogic::canCancel($item, $adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return DiagnosisTodo::where($this->searchWhere)->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ namespace app\adminapi\lists\tcm;
|
|||||||
|
|
||||||
use app\adminapi\lists\BaseAdminDataLists;
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||||
|
use app\common\enum\ExportEnum;
|
||||||
|
use app\common\lists\ListsExcelInterface;
|
||||||
use app\common\lists\ListsExtendInterface;
|
use app\common\lists\ListsExtendInterface;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
use app\common\lists\Traits\HasDataScopeFilter;
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
@@ -19,16 +22,19 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
|
|||||||
use app\common\model\ExpressTracking;
|
use app\common\model\ExpressTracking;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
|
use think\facade\Db;
|
||||||
use think\db\Query;
|
use think\db\Query;
|
||||||
|
|
||||||
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
|
||||||
{
|
{
|
||||||
use HasDataScopeFilter;
|
use HasDataScopeFilter;
|
||||||
|
|
||||||
|
private const SCENE_DIAGNOSIS_EDIT = 'diagnosis_edit';
|
||||||
|
|
||||||
public function setSearch(): array
|
public function setSearch(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'=' => ['prescription_id', 'fulfillment_status', 'prescription_audit_status', 'payment_slip_audit_status'],
|
'=' => ['prescription_id', 'diagnosis_id', 'fulfillment_status', 'prescription_audit_status', 'payment_slip_audit_status'],
|
||||||
'%like%' => ['order_no'],
|
'%like%' => ['order_no'],
|
||||||
'between_time' => 'create_time',
|
'between_time' => 'create_time',
|
||||||
];
|
];
|
||||||
@@ -95,13 +101,59 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
{
|
{
|
||||||
$this->applyDoctorAssistantFilters($query);
|
$this->applyDoctorAssistantFilters($query);
|
||||||
$this->applyPatientKeywordFilter($query);
|
$this->applyPatientKeywordFilter($query);
|
||||||
|
$this->applyPatientIdFilter($query);
|
||||||
$this->applyExpressCompanyFilter($query);
|
$this->applyExpressCompanyFilter($query);
|
||||||
$this->applyExpressKeywordFilter($query);
|
$this->applyExpressKeywordFilter($query);
|
||||||
|
$this->applyServiceChannelFilter($query);
|
||||||
$this->applySupplyModeFilter($query);
|
$this->applySupplyModeFilter($query);
|
||||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||||
$query->where('creator_id', $this->adminId);
|
$this->applyCreatorOrOwnPrescriptionVisibility($query);
|
||||||
|
$this->applyDataScopeForPrescriptionOrder($query);
|
||||||
}
|
}
|
||||||
$this->applyDataScopeForPrescriptionOrder($query);
|
// 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致
|
||||||
|
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
|
||||||
|
}
|
||||||
|
$this->applyYejiErCenterRevisitOrderFilter($query);
|
||||||
|
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_time;revisit_slot=0 为全部复诊分项合计)。
|
||||||
|
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
|
||||||
|
*/
|
||||||
|
private function applyYejiErCenterRevisitOrderFilter($query): void
|
||||||
|
{
|
||||||
|
if ((int) ($this->params['yeji_er_center_revisit_only'] ?? 0) !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
|
||||||
|
$slot = (int) ($this->params['yeji_er_center_revisit_slot'] ?? 0);
|
||||||
|
if ($assistantId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$st = trim((string) ($this->params['start_time'] ?? ''));
|
||||||
|
$et = trim((string) ($this->params['end_time'] ?? ''));
|
||||||
|
if ($st === '' || $et === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$t0 = strtotime($st);
|
||||||
|
$t1 = strtotime($et);
|
||||||
|
if ($t0 === false || $t1 === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = DeptLogic::listErCenterRevisitOrderIdsForCreator(
|
||||||
|
$assistantId,
|
||||||
|
$slot,
|
||||||
|
(int) $t0,
|
||||||
|
(int) $t1
|
||||||
|
);
|
||||||
|
if ($ids === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->whereIn('id', $ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +201,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$query->whereRaw("(($po) OR ($et))");
|
$query->whereRaw("(($po) OR ($et))");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务渠道:未指派传 service_channel=0 时命中「空串」或「0」(varchar)
|
||||||
|
*/
|
||||||
|
private function applyServiceChannelFilter(Query $query): void
|
||||||
|
{
|
||||||
|
$raw = $this->params['service_channel'] ?? '';
|
||||||
|
if ($raw === '' || $raw === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$v = trim((string) $raw);
|
||||||
|
if ($v === '0') {
|
||||||
|
$query->whereRaw("(TRIM(IFNULL(`service_channel`,'')) = '' OR `service_channel` = '0')");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->where('service_channel', '=', $v);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
|
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
|
||||||
*
|
*
|
||||||
@@ -172,6 +242,73 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则包含关联处方开方人为本人的订单;
|
||||||
|
* 另含「关联诊单医助为本人」以便医助跟进出单与履约。
|
||||||
|
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按「诊单医助 / 订单创建人」命中(与 applyDoctorAssistantFilters 一致)。
|
||||||
|
*/
|
||||||
|
private function applyCreatorOrOwnPrescriptionVisibility($query): void
|
||||||
|
{
|
||||||
|
if (PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$adminId = (int) $this->adminId;
|
||||||
|
$poTbl = (new PrescriptionOrder())->getTable();
|
||||||
|
$rxTbl = (new Prescription())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
|
||||||
|
$explicitAssistant = (int) ($this->params['assistant_id'] ?? 0);
|
||||||
|
$allowExplicitAssistantVisibility = false;
|
||||||
|
if ($explicitAssistant > 0) {
|
||||||
|
if (!$this->dataScopeShouldApply()) {
|
||||||
|
$allowExplicitAssistantVisibility = true;
|
||||||
|
} else {
|
||||||
|
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||||
|
if ($visibleIds === null || in_array($explicitAssistant, $visibleIds, true)) {
|
||||||
|
$allowExplicitAssistantVisibility = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where(function ($q) use (
|
||||||
|
$poTbl,
|
||||||
|
$rxTbl,
|
||||||
|
$diagTbl,
|
||||||
|
$adminId,
|
||||||
|
$explicitAssistant,
|
||||||
|
$allowExplicitAssistantVisibility
|
||||||
|
) {
|
||||||
|
if (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
|
||||||
|
$q->where(function ($qq) use ($poTbl, $rxTbl, $diagTbl, $adminId) {
|
||||||
|
$qq->where('creator_id', $adminId);
|
||||||
|
$qq->whereOrRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||||
|
. " AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$adminId})"
|
||||||
|
);
|
||||||
|
$qq->whereOrRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||||
|
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$q->where(function ($qq) use ($poTbl, $diagTbl, $adminId) {
|
||||||
|
$qq->where('creator_id', $adminId);
|
||||||
|
$qq->whereOrRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||||
|
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($allowExplicitAssistantVisibility) {
|
||||||
|
$q->whereOr('creator_id', $explicitAssistant);
|
||||||
|
$q->whereOrRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||||
|
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$explicitAssistant})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据隔离:创建者 ∈ 可见 admin,或 关联诊单医助 ∈ 可见 admin
|
* 数据隔离:创建者 ∈ 可见 admin,或 关联诊单医助 ∈ 可见 admin
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +338,29 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅诊单编辑页内的患者订单查询可跳过列表页权限,但必须显式带场景且保留患者/诊单范围收敛。
|
||||||
|
*/
|
||||||
|
private function shouldBypassListVisibilityForDiagnosisEdit(): bool
|
||||||
|
{
|
||||||
|
$scene = trim((string) ($this->params['scene'] ?? ''));
|
||||||
|
if ($scene !== self::SCENE_DIAGNOSIS_EDIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientId = (int) ($this->params['patient_id'] ?? 0);
|
||||||
|
$contextDiagnosisId = (int) ($this->params['context_diagnosis_id'] ?? 0);
|
||||||
|
if ($patientId <= 0 || $contextDiagnosisId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagnosisPatientId = (int) Diagnosis::where('id', $contextDiagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->value('patient_id');
|
||||||
|
|
||||||
|
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医助:仅「诊单医助=本人」的订单计业绩
|
* 医助:仅「诊单医助=本人」的订单计业绩
|
||||||
*/
|
*/
|
||||||
@@ -216,28 +376,36 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计用查询骨架:不含 create_time 区间;其它筛选项 + 统计专用数据域(不重复 where)
|
* 统计用查询骨架:不含 create_time 区间;其它筛选项 + 统计专用数据域(不重复 where)
|
||||||
|
*
|
||||||
|
* @param bool $applyYejiDrawerChannel 是否应用业绩侧栏渠道收窄(合计业绩统计须传 false)
|
||||||
*/
|
*/
|
||||||
private function buildBaseQueryForStats(): Query
|
private function buildBaseQueryForStats(bool $applyYejiDrawerChannel = true): Query
|
||||||
{
|
{
|
||||||
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
|
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
|
||||||
$this->applyDoctorAssistantFilters($query);
|
$this->applyDoctorAssistantFilters($query);
|
||||||
$this->applyPatientKeywordFilter($query);
|
$this->applyPatientKeywordFilter($query);
|
||||||
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
|
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
|
||||||
$this->applyDataScopeForPrescriptionOrder($query);
|
$this->applyDataScopeForPrescriptionOrder($query);
|
||||||
return $query;
|
} else {
|
||||||
|
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||||
|
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
|
||||||
|
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
|
||||||
|
$this->applyAssistantDiagnosisOnlyFilter($query);
|
||||||
|
$this->applyDataScopeForPrescriptionOrder($query);
|
||||||
|
} else {
|
||||||
|
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||||
|
$this->applyCreatorOrOwnPrescriptionVisibility($query);
|
||||||
|
}
|
||||||
|
$this->applyDataScopeForPrescriptionOrder($query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
// 与看板「合计业绩」一致:NULL 安全剔除履约 4/9/10
|
||||||
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
|
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
|
||||||
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
|
||||||
$this->applyAssistantDiagnosisOnlyFilter($query);
|
|
||||||
$this->applyDataScopeForPrescriptionOrder($query);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
}
|
||||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
if ($applyYejiDrawerChannel) {
|
||||||
$query->where('creator_id', $this->adminId);
|
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
|
||||||
}
|
}
|
||||||
$this->applyDataScopeForPrescriptionOrder($query);
|
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
@@ -365,8 +533,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
if ($diagIdsForAssist !== []) {
|
if ($diagIdsForAssist !== []) {
|
||||||
$assistantByDiag = Diagnosis::whereIn('id', $diagIdsForAssist)->whereNull('delete_time')->column('assistant_id', 'id');
|
$assistantByDiag = Diagnosis::whereIn('id', $diagIdsForAssist)->whereNull('delete_time')->column('assistant_id', 'id');
|
||||||
}
|
}
|
||||||
|
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_values($assistantByDiag)))));
|
||||||
|
$assistantNames = $assistantIds !== []
|
||||||
|
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
|
||||||
|
: [];
|
||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||||
|
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
|
||||||
$pid = (int) ($item['id'] ?? 0);
|
$pid = (int) ($item['id'] ?? 0);
|
||||||
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
|
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
|
||||||
'prescription_order_id',
|
'prescription_order_id',
|
||||||
@@ -380,11 +553,19 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$this->adminInfo,
|
$this->adminInfo,
|
||||||
$assistantByDiag
|
$assistantByDiag
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加创建人姓名
|
// 添加创建人姓名
|
||||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||||
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
|
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
|
||||||
|
|
||||||
|
// 添加医助(来自诊单 assistant_id)
|
||||||
|
$diagIdForAssist = (int) ($item['diagnosis_id'] ?? 0);
|
||||||
|
$assistantIdForItem = (int) ($assistantByDiag[$diagIdForAssist] ?? 0);
|
||||||
|
$item['assistant_id'] = $assistantIdForItem;
|
||||||
|
$item['assistant_name'] = $assistantIdForItem > 0
|
||||||
|
? ($assistantNames[$assistantIdForItem] ?? '')
|
||||||
|
: '';
|
||||||
|
|
||||||
// 添加开方人姓名、处方登记手机(与业务订单收货手机比对用)
|
// 添加开方人姓名、处方登记手机(与业务订单收货手机比对用)
|
||||||
$rxId = (int) ($item['prescription_id'] ?? 0);
|
$rxId = (int) ($item['prescription_id'] ?? 0);
|
||||||
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
|
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
|
||||||
@@ -392,6 +573,16 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
|
|
||||||
|
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
||||||
|
|
||||||
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
||||||
|
PrescriptionOrderLogic::appendLinkedAppointmentsToListRows($lists, $this->params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $this->export === ExportEnum::EXPORT) {
|
||||||
|
PrescriptionOrderLogic::appendPrescriptionOrderListExportRows($lists, $this->adminInfo);
|
||||||
|
}
|
||||||
|
|
||||||
return $lists;
|
return $lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,19 +591,19 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$s = $this->computeListStatsForExtend();
|
$s = $this->computeListStatsForExtend();
|
||||||
$focus = $this->computeFocusCountsForExtend();
|
$focus = $this->computeFocusCountsForExtend();
|
||||||
|
|
||||||
return [
|
$base = [
|
||||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||||
'stats_order_amount' => $s['order_amount'],
|
'stats_order_amount' => $s['order_amount'],
|
||||||
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
||||||
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
||||||
/** 业绩:业务订单金额,剔除履约已取消(fulfillment_status=4),其余状态全部计入 */
|
/** 业绩:业务订单金额,剔除履约已取消/拒收/退款(4/9/10),NULL 与其它状态计入 */
|
||||||
'stats_order_amount_performance' => $s['order_amount_not_cancelled'],
|
'stats_order_amount_performance' => $s['order_amount_performance'] ?? $s['order_amount_not_cancelled'],
|
||||||
'stats_linked_pay_amount' => $s['linked_pay_amount'],
|
'stats_linked_pay_amount' => $s['linked_pay_amount'],
|
||||||
'stats_linked_pay_amount_not_cancelled' => $s['linked_pay_amount_not_cancelled'],
|
'stats_linked_pay_amount_not_cancelled' => $s['linked_pay_amount_not_cancelled'],
|
||||||
'stats_linked_pay_amount_cancelled' => $s['linked_pay_amount_cancelled'],
|
'stats_linked_pay_amount_cancelled' => $s['linked_pay_amount_cancelled'],
|
||||||
/** 业绩-关联实付:剔除履约已取消订单的关联支付金额 */
|
/** 业绩-关联实付:剔除履约 4/9/10 订单的关联支付金额 */
|
||||||
'stats_linked_pay_amount_performance' => $s['linked_pay_amount_not_cancelled'],
|
'stats_linked_pay_amount_performance' => $s['linked_pay_amount_performance'] ?? $s['linked_pay_amount_not_cancelled'],
|
||||||
'stats_time_start' => $s['label_start'],
|
'stats_time_start' => $s['label_start'],
|
||||||
'stats_time_end' => $s['label_end'],
|
'stats_time_end' => $s['label_end'],
|
||||||
/** 统计口径:all=支付审核白名单全量;assistant=医助仅诊单协助业绩;restricted=与列表同域 */
|
/** 统计口径:all=支付审核白名单全量;assistant=医助仅诊单协助业绩;restricted=与列表同域 */
|
||||||
@@ -422,6 +613,153 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'focus_pending_ship_count' => $focus['pending_ship'],
|
'focus_pending_ship_count' => $focus['pending_ship'],
|
||||||
'focus_risk_count' => $focus['risk'],
|
'focus_risk_count' => $focus['risk'],
|
||||||
];
|
];
|
||||||
|
$yejiConsult = $this->computeYejiDrawerConsultCountIfRequested();
|
||||||
|
if ($yejiConsult !== null) {
|
||||||
|
$base['stats_yeji_consult_count'] = $yejiConsult;
|
||||||
|
}
|
||||||
|
$yejiDrawerTotal = $this->computeYejiDrawerPerformanceTotalForExtend();
|
||||||
|
if ($yejiDrawerTotal !== null) {
|
||||||
|
$base['stats_yeji_drawer_total_performance_amount'] = $yejiDrawerTotal['amount'];
|
||||||
|
$base['stats_yeji_drawer_total_performance_order_count'] = $yejiDrawerTotal['order_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFileName(): string
|
||||||
|
{
|
||||||
|
return '处方业务订单';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExcelFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'export_fulfillment_status_text' => '履约状态',
|
||||||
|
'export_order_time' => '创建时间',
|
||||||
|
'doctor_name' => '医生姓名',
|
||||||
|
'export_patient_name' => '患者姓名',
|
||||||
|
'export_patient_gender' => '性别',
|
||||||
|
'export_patient_age' => '年龄',
|
||||||
|
'export_patient_phone' => '手机号',
|
||||||
|
'assistant_name' => '医助名字',
|
||||||
|
'export_center_label' => '一中心还是二中心',
|
||||||
|
'export_assistant_dept' => '医助部门',
|
||||||
|
'export_channel' => '渠道',
|
||||||
|
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
||||||
|
'export_medication_form' => '药品形态',
|
||||||
|
'export_medication_days' => '天数',
|
||||||
|
'export_amount' => '总金额',
|
||||||
|
'export_paid_amount' => '已付金额',
|
||||||
|
'export_agency_collect' => '代收金额',
|
||||||
|
'export_tracking_number' => '快递单号',
|
||||||
|
'export_supply_mode' => '甘草还是自营',
|
||||||
|
'export_gancao_prescription_cost' => '处方成本',
|
||||||
|
'export_first_visit_assistant' => '初诊医助',
|
||||||
|
'export_rx_audit_time' => '审核时间',
|
||||||
|
'export_rx_auditor' => '审核人',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
|
||||||
|
* (doctor_appointment.status=3,appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
|
||||||
|
* 请求须带 yeji_order_drawer=1,且 assistant_id 或 assistant_dept_id 与起止时间有效。
|
||||||
|
*/
|
||||||
|
private function computeYejiDrawerConsultCountIfRequested(): ?int
|
||||||
|
{
|
||||||
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
|
||||||
|
$deptRootId = (int) ($this->params['assistant_dept_id'] ?? 0);
|
||||||
|
if ($assistantId <= 0 && $deptRootId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$startDate, $endDate] = $this->resolveYejiConsultAppointmentDateRange();
|
||||||
|
if ($startDate === '' || $endDate === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eff = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||||
|
$query = Db::name('doctor_appointment')->alias('a')
|
||||||
|
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||||
|
->where('a.status', 3)
|
||||||
|
->whereBetween('a.appointment_date', [$startDate, $endDate])
|
||||||
|
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
|
||||||
|
|
||||||
|
if ($assistantId > 0) {
|
||||||
|
$query->whereRaw("({$eff}) = " . $assistantId);
|
||||||
|
} else {
|
||||||
|
$deptIds = DeptLogic::getSelfAndDescendantIds($deptRootId);
|
||||||
|
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
||||||
|
return $id > 0;
|
||||||
|
}));
|
||||||
|
if ($deptIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$adTbl = (new AdminDept())->getTable();
|
||||||
|
$inList = implode(',', $deptIds);
|
||||||
|
$query->whereRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.admin_id = {$eff} AND ad.dept_id IN ({$inList}))"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $query->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string} Y-m-d
|
||||||
|
*/
|
||||||
|
private function resolveYejiConsultAppointmentDateRange(): array
|
||||||
|
{
|
||||||
|
$st = trim((string) ($this->params['start_time'] ?? ''));
|
||||||
|
$et = trim((string) ($this->params['end_time'] ?? ''));
|
||||||
|
if ($st === '' || $et === '') {
|
||||||
|
return ['', ''];
|
||||||
|
}
|
||||||
|
$t0 = strtotime($st);
|
||||||
|
$t1 = strtotime($et);
|
||||||
|
if ($t0 === false || $t1 === false) {
|
||||||
|
return ['', ''];
|
||||||
|
}
|
||||||
|
if ($t1 < $t0) {
|
||||||
|
$tmp = $t0;
|
||||||
|
$t0 = $t1;
|
||||||
|
$t1 = $tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [date('Y-m-d', $t0), date('Y-m-d', $t1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏已选渠道时:「合计业绩」金额与订单数(与看板列同口径,不限渠道收窄)
|
||||||
|
*
|
||||||
|
* @return array{amount: float, order_count: int}|null
|
||||||
|
*/
|
||||||
|
private function computeYejiDrawerPerformanceTotalForExtend(): ?array
|
||||||
|
{
|
||||||
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trim((string) ($this->params['channel_code'] ?? '')) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$t0, $t1] = $this->resolveStatsTimeWindow();
|
||||||
|
if ($t0 <= 0 || $t1 <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($t1 < $t0) {
|
||||||
|
$tmp = $t0;
|
||||||
|
$t0 = $t1;
|
||||||
|
$t1 = $tmp;
|
||||||
|
}
|
||||||
|
$q = $this->buildBaseQueryForStats(false)->whereBetween('create_time', [$t0, $t1]);
|
||||||
|
// 全量「合计业绩」与看板列同口径:始终剔除履约 4/9/10,不依赖 exclude 参数
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
||||||
|
$amount = round((float) (clone $q)->sum('amount'), 2);
|
||||||
|
$count = (int) (clone $q)->count();
|
||||||
|
|
||||||
|
return ['amount' => $amount, 'order_count' => $count];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -434,9 +772,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$query = PrescriptionOrder::where($this->searchWhereWithoutStatusFilters())->whereNull('delete_time');
|
$query = PrescriptionOrder::where($this->searchWhereWithoutStatusFilters())->whereNull('delete_time');
|
||||||
$this->applyPermissionAndExtraFilters($query);
|
$this->applyPermissionAndExtraFilters($query);
|
||||||
|
|
||||||
|
$pendingRxQuery = clone $query;
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($pendingRxQuery, '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// 处方审核待处理
|
// 处方审核待处理(不含履约 4/9/10)
|
||||||
'pending_rx' => (int) (clone $query)->where('prescription_audit_status', 0)->count(),
|
'pending_rx' => (int) $pendingRxQuery->where('prescription_audit_status', 0)->count(),
|
||||||
// 支付审核待处理(前提:处方已通过)
|
// 支付审核待处理(前提:处方已通过)
|
||||||
'pending_pay' => (int) (clone $query)->where('prescription_audit_status', 1)->where('payment_slip_audit_status', 0)->count(),
|
'pending_pay' => (int) (clone $query)->where('prescription_audit_status', 1)->where('payment_slip_audit_status', 0)->count(),
|
||||||
// 履约待发货
|
// 履约待发货
|
||||||
@@ -481,8 +822,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
$q = $this->buildBaseQueryForStats()->whereBetween('create_time', [$t0, $t1]);
|
$q = $this->buildBaseQueryForStats()->whereBetween('create_time', [$t0, $t1]);
|
||||||
$orderTotal = round((float) (clone $q)->sum('amount'), 2);
|
$orderTotal = round((float) (clone $q)->sum('amount'), 2);
|
||||||
$orderCancelled = round((float) (clone $q)->where('fulfillment_status', 4)->sum('amount'), 2);
|
$orderCancelled = round((float) (clone $q)->where('fulfillment_status', 4)->sum('amount'), 2);
|
||||||
$orderNotCancelled = round($orderTotal - $orderCancelled, 2);
|
$orderNotCancelled = round($orderTotal - $orderCancelled, 2);
|
||||||
|
|
||||||
$idsAll = (clone $q)->column('id');
|
$idsAll = (clone $q)->column('id');
|
||||||
@@ -490,7 +831,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
return $id > 0;
|
return $id > 0;
|
||||||
}));
|
}));
|
||||||
if ($idsAll === []) {
|
if ($idsAll === []) {
|
||||||
return $this->statsExtendPayload(
|
$empty = $this->statsExtendPayload(
|
||||||
$orderTotal,
|
$orderTotal,
|
||||||
$orderNotCancelled,
|
$orderNotCancelled,
|
||||||
$orderCancelled,
|
$orderCancelled,
|
||||||
@@ -500,6 +841,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$l0,
|
$l0,
|
||||||
$l1
|
$l1
|
||||||
);
|
);
|
||||||
|
$qPerfEmpty = clone $q;
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($qPerfEmpty, '');
|
||||||
|
$empty['order_amount_performance'] = round((float) $qPerfEmpty->sum('amount'), 2);
|
||||||
|
$empty['linked_pay_amount_performance'] = 0.0;
|
||||||
|
|
||||||
|
return $empty;
|
||||||
}
|
}
|
||||||
$idsCancel = (clone $q)->where('fulfillment_status', 4)->column('id');
|
$idsCancel = (clone $q)->where('fulfillment_status', 4)->column('id');
|
||||||
$idsCancel = array_values(array_filter(array_map('intval', $idsCancel), static function (int $id): bool {
|
$idsCancel = array_values(array_filter(array_map('intval', $idsCancel), static function (int $id): bool {
|
||||||
@@ -511,7 +858,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$linkedNotCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsNotCancelled);
|
$linkedNotCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsNotCancelled);
|
||||||
$linkedCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsCancel);
|
$linkedCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsCancel);
|
||||||
|
|
||||||
return $this->statsExtendPayload(
|
$base = $this->statsExtendPayload(
|
||||||
$orderTotal,
|
$orderTotal,
|
||||||
$orderNotCancelled,
|
$orderNotCancelled,
|
||||||
$orderCancelled,
|
$orderCancelled,
|
||||||
@@ -521,6 +868,21 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$l0,
|
$l0,
|
||||||
$l1
|
$l1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$qPerf = clone $q;
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($qPerf, '');
|
||||||
|
$base['order_amount_performance'] = round((float) $qPerf->sum('amount'), 2);
|
||||||
|
|
||||||
|
$idsExcludedPerf = (clone $q)
|
||||||
|
->whereIn('fulfillment_status', YejiStatsLogic::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE)
|
||||||
|
->column('id');
|
||||||
|
$idsExcludedPerf = array_values(array_filter(array_map('intval', $idsExcludedPerf), static function (int $id): bool {
|
||||||
|
return $id > 0;
|
||||||
|
}));
|
||||||
|
$idsPerf = array_values(array_diff($idsAll, $idsExcludedPerf));
|
||||||
|
$base['linked_pay_amount_performance'] = $this->sumLinkedPayForPrescriptionOrderIds($idsPerf);
|
||||||
|
|
||||||
|
return $base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -528,7 +890,11 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
*/
|
*/
|
||||||
private function statsZeroPayload(): array
|
private function statsZeroPayload(): array
|
||||||
{
|
{
|
||||||
return $this->statsExtendPayload(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, '', '');
|
$z = $this->statsExtendPayload(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, '', '');
|
||||||
|
$z['order_amount_performance'] = 0.0;
|
||||||
|
$z['linked_pay_amount_performance'] = 0.0;
|
||||||
|
|
||||||
|
return $z;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -551,6 +917,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$ar = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
$ar = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||||
if ($ar > 0 && in_array($ar, array_map('intval', $this->adminInfo['role_id'] ?? []), true)) {
|
if ($ar > 0 && in_array($ar, array_map('intval', $this->adminInfo['role_id'] ?? []), true)) {
|
||||||
$scope = 'assistant';
|
$scope = 'assistant';
|
||||||
|
} elseif (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
|
||||||
|
$scope = 'own_rx';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +943,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按开方医生(关联处方 creator_id)/ 诊单医助(关联诊单 assistant_id)筛选
|
* 按开方医生(关联处方 creator_id)/ 诊单医助筛选
|
||||||
|
*
|
||||||
|
* 医助:诊单 assistant_id 与业务订单 creator_id 并举——医助常代建单但诊单未写 assistant_id。
|
||||||
*/
|
*/
|
||||||
private function applyDoctorAssistantFilters($query): void
|
private function applyDoctorAssistantFilters($query): void
|
||||||
{
|
{
|
||||||
@@ -587,13 +957,60 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}");
|
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业绩看板入口(yeji_order_drawer=1)所有筛选统一按订单创建人;其它入口(处方订单列表 / 患者跟进等)保留旧口径(创建人 ∪ 诊单医助) */
|
||||||
|
$yejiPanel = (int) ($this->params['yeji_order_drawer'] ?? 0) === 1;
|
||||||
|
|
||||||
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
|
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
|
||||||
$assistantId = (int) $this->params['assistant_id'];
|
$assistantId = (int) $this->params['assistant_id'];
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
if ($yejiPanel) {
|
||||||
$query->whereExists("SELECT 1 FROM {$diagTbl} dg WHERE dg.id = {$poTbl}.diagnosis_id AND dg.delete_time IS NULL AND dg.assistant_id = {$assistantId}");
|
$query->where('creator_id', $assistantId);
|
||||||
|
} else {
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
|
||||||
|
$q->where('creator_id', $assistantId);
|
||||||
|
$q->whereOrRaw(
|
||||||
|
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||||
|
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
||||||
|
if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) {
|
||||||
|
$rowDeptId = (int) $this->params['assistant_dept_id'];
|
||||||
|
$adminIds = YejiStatsLogic::collectAdminIdsAttributedToYejiTableRow(
|
||||||
|
$rowDeptId,
|
||||||
|
$this->params['dept_ids'] ?? null
|
||||||
|
);
|
||||||
|
if ($adminIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->applyPerformanceAttributionAdminInFilter($query, $adminIds);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$yejiTableRowDeptIds = [];
|
||||||
|
if (isset($this->params['yeji_table_row_dept_ids']) && trim((string) $this->params['yeji_table_row_dept_ids']) !== '') {
|
||||||
|
foreach (explode(',', (string) $this->params['yeji_table_row_dept_ids']) as $p) {
|
||||||
|
$v = (int) trim((string) $p);
|
||||||
|
if ($v > 0) {
|
||||||
|
$yejiTableRowDeptIds[] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1 && $yejiTableRowDeptIds !== []) {
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderListAlignedWinnerDeptFilter(
|
||||||
|
$query,
|
||||||
|
(int) $this->params['assistant_dept_id'],
|
||||||
|
$yejiTableRowDeptIds,
|
||||||
|
$this->params['dept_ids'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
||||||
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
||||||
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
||||||
@@ -605,15 +1022,47 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$inList = implode(',', $deptIds);
|
$inList = implode(',', $deptIds);
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
|
||||||
$adTbl = (new AdminDept())->getTable();
|
$adTbl = (new AdminDept())->getTable();
|
||||||
$query->whereExists(
|
if ($yejiPanel) {
|
||||||
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
|
/** 业绩看板部门侧栏:仅订单创建人人事部门 ∈ 子树(与「合计业绩」/「接诊诊单」/「复诊」聚合同口径) */
|
||||||
. "WHERE dg.id = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
|
$query->whereExists(
|
||||||
);
|
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
|
||||||
|
$q->whereExists(
|
||||||
|
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
|
||||||
|
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
|
||||||
|
);
|
||||||
|
$q->whereExists(
|
||||||
|
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
|
||||||
|
'OR'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单
|
||||||
|
* (与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致——按订单创建人)。
|
||||||
|
*
|
||||||
|
* @param int[] $adminIds
|
||||||
|
*/
|
||||||
|
private function applyPerformanceAttributionAdminInFilter($query, array $adminIds): void
|
||||||
|
{
|
||||||
|
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static function (int $x): bool {
|
||||||
|
return $x > 0;
|
||||||
|
})));
|
||||||
|
if ($adminIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->whereIn('creator_id', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按诊单患者姓名 / 手机号模糊检索(关联 diagnosis_id)
|
* 按诊单患者姓名 / 手机号模糊检索(关联 diagnosis_id)
|
||||||
*/
|
*/
|
||||||
@@ -636,6 +1085,26 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按真实患者ID精确过滤(tcm_diagnosis.patient_id 是跨诊单的患者唯一ID)
|
||||||
|
* 用于诊单编辑抽屉「业务订单」tab 列出该患者所有诊单产生的处方业务订单。
|
||||||
|
*/
|
||||||
|
private function applyPatientIdFilter($query): void
|
||||||
|
{
|
||||||
|
$patientId = (int) ($this->params['patient_id'] ?? 0);
|
||||||
|
if ($patientId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$poTbl = (new PrescriptionOrder())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
|
||||||
|
$query->whereExists(
|
||||||
|
"SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` "
|
||||||
|
. "AND dg.`delete_time` IS NULL AND dg.`patient_id` = {$patientId}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按快递单号 / 快递公司 模糊检索
|
* 按快递单号 / 快递公司 模糊检索
|
||||||
*/
|
*/
|
||||||
@@ -661,4 +1130,118 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
. ")"
|
. ")"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
|
||||||
|
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $lists
|
||||||
|
*/
|
||||||
|
private function appendPrescriptionOrderAssignSnapshotErCenterFlags(array &$lists): void
|
||||||
|
{
|
||||||
|
if ($lists === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagIds = [];
|
||||||
|
foreach ($lists as $row) {
|
||||||
|
$d = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
|
if ($d > 0) {
|
||||||
|
$diagIds[$d] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$diagIdList = array_keys($diagIds);
|
||||||
|
$latestSnapLogByTriple = [];
|
||||||
|
if ($diagIdList !== []) {
|
||||||
|
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->whereIn('diagnosis_id', $diagIdList)
|
||||||
|
->field(['id', 'diagnosis_id', 'to_assistant_id', 'related_po_creator_id', 'related_po_create_time'])
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($logRows as $log) {
|
||||||
|
$d = (int) ($log['diagnosis_id'] ?? 0);
|
||||||
|
$rcp = (int) ($log['related_po_creator_id'] ?? 0);
|
||||||
|
$rpct = (int) ($log['related_po_create_time'] ?? 0);
|
||||||
|
if ($d <= 0 || $rcp <= 0 || $rpct <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tripleKey = $d . ':' . $rcp . ':' . $rpct;
|
||||||
|
if (!isset($latestSnapLogByTriple[$tripleKey])) {
|
||||||
|
$latestSnapLogByTriple[$tripleKey] = $log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$toAssistantIdsHit = [];
|
||||||
|
foreach ($lists as $item) {
|
||||||
|
$diagId = (int) ($item['diagnosis_id'] ?? 0);
|
||||||
|
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||||
|
$poCt = (int) ($item['create_time'] ?? 0);
|
||||||
|
$tripleKey = $diagId . ':' . $creatorId . ':' . $poCt;
|
||||||
|
$log = $latestSnapLogByTriple[$tripleKey] ?? null;
|
||||||
|
if (\is_array($log)) {
|
||||||
|
$tid = (int) ($log['to_assistant_id'] ?? 0);
|
||||||
|
if ($tid > 0) {
|
||||||
|
$toAssistantIdsHit[$tid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$toAssistantIdList = array_keys($toAssistantIdsHit);
|
||||||
|
|
||||||
|
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
$adminErCenter = [];
|
||||||
|
if ($toAssistantIdList !== [] && $erDeptSet !== []) {
|
||||||
|
$adRows = AdminDept::whereIn('admin_id', $toAssistantIdList)
|
||||||
|
->field(['admin_id', 'dept_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
foreach ($adRows as $ad) {
|
||||||
|
$aid = (int) ($ad['admin_id'] ?? 0);
|
||||||
|
$did = (int) ($ad['dept_id'] ?? 0);
|
||||||
|
if ($aid > 0 && $did > 0 && isset($erDeptSet[$did])) {
|
||||||
|
$adminErCenter[$aid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameMap = [];
|
||||||
|
if ($toAssistantIdList !== []) {
|
||||||
|
$nameMap = \app\common\model\auth\Admin::whereIn('id', $toAssistantIdList)->column('name', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$diagId = (int) ($item['diagnosis_id'] ?? 0);
|
||||||
|
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||||
|
$poCt = (int) ($item['create_time'] ?? 0);
|
||||||
|
|
||||||
|
$item['po_assign_snapshot_hit'] = 0;
|
||||||
|
$item['po_assign_to_assistant_id'] = 0;
|
||||||
|
$item['po_assign_to_assistant_name'] = '';
|
||||||
|
$item['po_assign_to_er_center'] = 0;
|
||||||
|
$item['po_assign_is_release'] = 0;
|
||||||
|
|
||||||
|
if ($diagId <= 0 || $creatorId <= 0 || $poCt <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tripleKey = $diagId . ':' . $creatorId . ':' . $poCt;
|
||||||
|
$log = $latestSnapLogByTriple[$tripleKey] ?? null;
|
||||||
|
if (!\is_array($log)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item['po_assign_snapshot_hit'] = 1;
|
||||||
|
$toId = (int) ($log['to_assistant_id'] ?? 0);
|
||||||
|
$item['po_assign_to_assistant_id'] = $toId;
|
||||||
|
if ($toId <= 0) {
|
||||||
|
$item['po_assign_is_release'] = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$item['po_assign_to_assistant_name'] = (string) ($nameMap[$toId] ?? '');
|
||||||
|
$item['po_assign_to_er_center'] = isset($adminErCenter[$toId]) ? 1 : 0;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,13 +86,17 @@ class RoleLogic extends BaseLogic
|
|||||||
try {
|
try {
|
||||||
$menuId = !empty($params['menu_id']) ? $params['menu_id'] : [];
|
$menuId = !empty($params['menu_id']) ? $params['menu_id'] : [];
|
||||||
|
|
||||||
SystemRole::update([
|
$roleRow = [
|
||||||
'id' => $params['id'],
|
'id' => $params['id'],
|
||||||
'name' => $params['name'],
|
'name' => $params['name'],
|
||||||
'desc' => $params['desc'] ?? '',
|
'desc' => $params['desc'] ?? '',
|
||||||
'sort' => $params['sort'] ?? 0,
|
'sort' => $params['sort'] ?? 0,
|
||||||
'data_scope' => self::normalizeDataScope($params['data_scope'] ?? null),
|
];
|
||||||
]);
|
// 分配权限等非编辑页提交的 edit 若不传 data_scope,不得把库里的范围覆盖成默认全部
|
||||||
|
if (array_key_exists('data_scope', $params)) {
|
||||||
|
$roleRow['data_scope'] = self::normalizeDataScope($params['data_scope'] ?? null);
|
||||||
|
}
|
||||||
|
SystemRole::update($roleRow);
|
||||||
|
|
||||||
if (!empty($menuId)) {
|
if (!empty($menuId)) {
|
||||||
SystemRoleMenu::where(['role_id' => $params['id']])->delete();
|
SystemRoleMenu::where(['role_id' => $params['id']])->delete();
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
|
|
||||||
namespace app\adminapi\logic\dept;
|
namespace app\adminapi\logic\dept;
|
||||||
|
|
||||||
use app\common\enum\YesNoEnum;
|
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\common\model\auth\AdminDept;
|
use app\common\model\auth\AdminDept;
|
||||||
use app\common\model\dept\Dept;
|
use app\common\model\dept\Dept;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +66,9 @@ class DeptLogic extends BaseLogic
|
|||||||
if (!empty($lists)) {
|
if (!empty($lists)) {
|
||||||
$pid = min(array_column($lists, 'pid'));
|
$pid = min(array_column($lists, 'pid'));
|
||||||
}
|
}
|
||||||
return self::getTree($lists, $pid);
|
$tree = self::getTree($lists, $pid);
|
||||||
|
|
||||||
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +145,540 @@ class DeptLogic extends BaseLogic
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称含「二中心」的部门 id(与业绩看板二中心规则一致)。
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function findErCenterRootDeptIds(): array
|
||||||
|
{
|
||||||
|
$rows = Dept::whereNull('delete_time')
|
||||||
|
->field(['id', 'name'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$name = (string) ($r['name'] ?? '');
|
||||||
|
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
|
||||||
|
$out[] = (int) $r['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $rootIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function unionErCenterSubtreeDeptIds(array $rootIds): array
|
||||||
|
{
|
||||||
|
$set = [];
|
||||||
|
foreach ($rootIds as $rid) {
|
||||||
|
$rid = (int) $rid;
|
||||||
|
if ($rid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::getSelfAndDescendantIds($rid) as $id) {
|
||||||
|
$set[(int) $id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称含「二中心」的部门及其全部下级 id(map),与业绩看板判定一致。
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
public static function getErCenterSubtreeDeptIdSet(): array
|
||||||
|
{
|
||||||
|
$erRoots = self::findErCenterRootDeptIds();
|
||||||
|
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||||
|
|
||||||
|
return array_fill_keys($subtreeIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
||||||
|
*
|
||||||
|
* @return list<array{diagnosis_id: int, create_time: int, id: int, assistant_id: int}>
|
||||||
|
*/
|
||||||
|
private static function fetchErCenterRevisitCandidateOrders(?int $orderStartTs = null, ?int $orderEndTs = null): array
|
||||||
|
{
|
||||||
|
$q = Db::name('tcm_prescription_order')
|
||||||
|
->alias('o')
|
||||||
|
->join('tcm_diagnosis dg', 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
|
->whereNull('o.delete_time')
|
||||||
|
->where('o.diagnosis_id', '>', 0)
|
||||||
|
->where('dg.assistant_id', '>', 0);
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||||
|
if ($orderStartTs !== null && $orderEndTs !== null) {
|
||||||
|
$q->where('o.create_time', 'between', [$orderStartTs, $orderEndTs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q
|
||||||
|
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id', 'o.creator_id'])
|
||||||
|
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二中心子树:各部门复诊 rollup(按订单创建人 creator_id 归属)。
|
||||||
|
* 候选订单仍要求 dg.assistant_id > 0(保持「er-center 案例 = 有医助的诊单」的定义),
|
||||||
|
* 但**复诊归属**改为订单创建人——leaf 部门归属取 creator_id 在二中心子树内的 canonical 部门,
|
||||||
|
* 并产出 by_creator_slots 供医助排行榜复诊列复用,与诊金 / 接诊诊单口径完全一致。
|
||||||
|
*
|
||||||
|
* @param list<int> $subtreeDeptIds
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* totals: array<int, int>,
|
||||||
|
* slots: array<int, array<int, int>>,
|
||||||
|
* by_creator_slots: array<int, array<int, int>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function computeErCenterRevisitFullPack(
|
||||||
|
array $subtreeDeptIds,
|
||||||
|
?int $orderStartTs = null,
|
||||||
|
?int $orderEndTs = null
|
||||||
|
): array {
|
||||||
|
$emptySlots = [];
|
||||||
|
$emptyTotals = [];
|
||||||
|
foreach ($subtreeDeptIds as $did) {
|
||||||
|
$did = (int) $did;
|
||||||
|
$emptySlots[$did] = [];
|
||||||
|
$emptyTotals[$did] = 0;
|
||||||
|
}
|
||||||
|
if ($subtreeDeptIds === []) {
|
||||||
|
return ['totals' => [], 'slots' => [], 'by_creator_slots' => []];
|
||||||
|
}
|
||||||
|
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
|
||||||
|
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
|
||||||
|
if ($orderRows === []) {
|
||||||
|
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_creator_slots' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$byPatient = [];
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$pid = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byPatient[$pid][] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creatorNeed = [];
|
||||||
|
foreach ($byPatient as $orders) {
|
||||||
|
$n = \count($orders);
|
||||||
|
for ($i = 1; $i < $n; $i++) {
|
||||||
|
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||||
|
if ($cid > 0) {
|
||||||
|
$creatorNeed[$cid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$creatorIds = array_keys($creatorNeed);
|
||||||
|
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
|
||||||
|
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
|
||||||
|
|
||||||
|
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ],slot≥2 */
|
||||||
|
$leafByDeptSlot = [];
|
||||||
|
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
|
||||||
|
$byCreatorSlot = [];
|
||||||
|
foreach ($byPatient as $orders) {
|
||||||
|
$n = \count($orders);
|
||||||
|
for ($i = 1; $i < $n; $i++) {
|
||||||
|
$slot = $i + 1;
|
||||||
|
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||||
|
$deptId = $canonicalDept[$cid] ?? null;
|
||||||
|
if ($deptId === null || !isset($subtreeSet[$deptId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($leafByDeptSlot[$deptId])) {
|
||||||
|
$leafByDeptSlot[$deptId] = [];
|
||||||
|
}
|
||||||
|
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
|
||||||
|
if ($cid > 0) {
|
||||||
|
if (!isset($byCreatorSlot[$cid])) {
|
||||||
|
$byCreatorSlot[$cid] = [];
|
||||||
|
}
|
||||||
|
$byCreatorSlot[$cid][$slot] = ($byCreatorSlot[$cid][$slot] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$childrenByPid = self::buildAllDeptChildrenByPid();
|
||||||
|
$rollupSlots = self::rollupRevisitSlotsBySubtree($subtreeDeptIds, $subtreeSet, $childrenByPid, $leafByDeptSlot);
|
||||||
|
$rollupTotals = [];
|
||||||
|
foreach ($subtreeDeptIds as $id) {
|
||||||
|
$id = (int) $id;
|
||||||
|
$rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($byCreatorSlot as $cid => $sm) {
|
||||||
|
if ($sm !== []) {
|
||||||
|
ksort($byCreatorSlot[$cid], SORT_NUMERIC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'totals' => $rollupTotals,
|
||||||
|
'slots' => $rollupSlots,
|
||||||
|
'by_creator_slots' => $byCreatorSlot,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二中心子树:各部门复诊合计与分项(第 2 笔订单=复诊2…),供业绩看板等复用。
|
||||||
|
* $orderStartTs/$orderEndTs 均非 null 时仅统计该 create_time 窗口内业务单(与看板区间一致);均为 null 时不限时间(慎用,数据量大)。
|
||||||
|
*
|
||||||
|
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
|
||||||
|
*/
|
||||||
|
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array
|
||||||
|
{
|
||||||
|
$erRoots = self::findErCenterRootDeptIds();
|
||||||
|
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||||
|
if ($subtreeIds === []) {
|
||||||
|
return ['totals' => [], 'slots' => []];
|
||||||
|
}
|
||||||
|
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
|
||||||
|
|
||||||
|
return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二中心子树复诊:按订单创建人 creator_id 聚合(与部门 rollup 同源,与诊金/接诊诊单口径一致)。
|
||||||
|
*
|
||||||
|
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
|
||||||
|
*/
|
||||||
|
public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array
|
||||||
|
{
|
||||||
|
$erRoots = self::findErCenterRootDeptIds();
|
||||||
|
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||||
|
if ($subtreeIds === []) {
|
||||||
|
return ['totals' => [], 'slots' => []];
|
||||||
|
}
|
||||||
|
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
|
||||||
|
$by = $pack['by_creator_slots'];
|
||||||
|
$totals = [];
|
||||||
|
$slots = [];
|
||||||
|
foreach ($by as $cid => $slotMap) {
|
||||||
|
$cid = (int) $cid;
|
||||||
|
$totals[$cid] = (int) array_sum($slotMap);
|
||||||
|
$slots[$cid] = $slotMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['totals' => $totals, 'slots' => $slots];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二中心子树复诊:列出某「订单创建人」在区间内、复诊序列(slot≥2)命中的业务订单 id(用于看板侧栏按复诊笔数下钻)。
|
||||||
|
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function listErCenterRevisitOrderIdsForCreator(
|
||||||
|
int $creatorId,
|
||||||
|
int $revisitSlot,
|
||||||
|
int $orderStartTs,
|
||||||
|
int $orderEndTs
|
||||||
|
): array {
|
||||||
|
if ($creatorId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($revisitSlot < 0 || $revisitSlot === 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
|
||||||
|
if ($orderRows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$byPatient = [];
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$pid = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byPatient[$pid][] = $r;
|
||||||
|
}
|
||||||
|
$ids = [];
|
||||||
|
foreach ($byPatient as $orders) {
|
||||||
|
$n = \count($orders);
|
||||||
|
for ($i = 1; $i < $n; $i++) {
|
||||||
|
$slot = $i + 1;
|
||||||
|
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||||
|
if ($cid !== $creatorId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$oid = (int) ($orders[$i]['id'] ?? 0);
|
||||||
|
if ($oid > 0) {
|
||||||
|
$ids[$oid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按**订单创建人**拆解笔数。
|
||||||
|
* 与看板行内「复诊」列口径一致:订单序列第 2 笔起;创建人 canonical 部门须落在二中心子树且在该部门行的子树内。
|
||||||
|
*
|
||||||
|
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
|
||||||
|
*/
|
||||||
|
public static function getErCenterRevisitAssistantBreakdownForDept(
|
||||||
|
int $rootDeptId,
|
||||||
|
int $revisitSlot,
|
||||||
|
int $orderStartTs,
|
||||||
|
int $orderEndTs
|
||||||
|
): array {
|
||||||
|
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
if ($revisitSlot < 0 || $revisitSlot === 1) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
$erSubtreeSet = self::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erSubtreeSet === []) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
$descIds = self::getSelfAndDescendantIds($rootDeptId);
|
||||||
|
$descFlip = [];
|
||||||
|
foreach ($descIds as $did) {
|
||||||
|
$did = (int) $did;
|
||||||
|
if ($did > 0) {
|
||||||
|
$descFlip[$did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
|
||||||
|
if ($orderRows === []) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
$byPatient = [];
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$pid = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byPatient[$pid][] = $r;
|
||||||
|
}
|
||||||
|
$creatorNeed = [];
|
||||||
|
foreach ($byPatient as $orders) {
|
||||||
|
$n = \count($orders);
|
||||||
|
for ($i = 1; $i < $n; $i++) {
|
||||||
|
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||||
|
if ($cid > 0) {
|
||||||
|
$creatorNeed[$cid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
|
||||||
|
/** @var array<int, int> $counts */
|
||||||
|
$counts = [];
|
||||||
|
foreach ($byPatient as $orders) {
|
||||||
|
$n = \count($orders);
|
||||||
|
for ($i = 1; $i < $n; $i++) {
|
||||||
|
$slot = $i + 1;
|
||||||
|
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||||
|
if ($cid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$canon = $canonicalDept[$cid] ?? null;
|
||||||
|
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$counts[$cid] = ($counts[$cid] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($counts === []) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
$ids = array_keys($counts);
|
||||||
|
$nameRows = [];
|
||||||
|
if ($ids !== []) {
|
||||||
|
$nameRows = Db::name('admin')
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->column('name', 'id');
|
||||||
|
}
|
||||||
|
$rows = [];
|
||||||
|
foreach ($counts as $cid => $cnt) {
|
||||||
|
$rows[] = [
|
||||||
|
'admin_id' => (int) $cid,
|
||||||
|
'name' => (string) ($nameRows[$cid] ?? ('#' . $cid)),
|
||||||
|
'order_count' => (int) $cnt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
if ($a['order_count'] !== $b['order_count']) {
|
||||||
|
return $b['order_count'] <=> $a['order_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) $a['name'], (string) $b['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['rows' => $rows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, list<int>>
|
||||||
|
*/
|
||||||
|
private static function buildAllDeptChildrenByPid(): array
|
||||||
|
{
|
||||||
|
$rows = Dept::field(['id', 'pid'])->select()->toArray();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$pid = (int) ($r['pid'] ?? 0);
|
||||||
|
$id = (int) ($r['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($out[$pid])) {
|
||||||
|
$out[$pid] = [];
|
||||||
|
}
|
||||||
|
$out[$pid][] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<int, int>> $leafByDeptSlot
|
||||||
|
*
|
||||||
|
* @return array<int, array<int, int>> dept_id => [ slot => count ],已含下级汇总
|
||||||
|
*/
|
||||||
|
private static function rollupRevisitSlotsBySubtree(
|
||||||
|
array $subtreeDeptIds,
|
||||||
|
array $subtreeSet,
|
||||||
|
array $childrenByPid,
|
||||||
|
array $leafByDeptSlot
|
||||||
|
): array {
|
||||||
|
$memo = [];
|
||||||
|
$dfs = static function (int $id) use (&$dfs, $childrenByPid, $leafByDeptSlot, $subtreeSet, &$memo): array {
|
||||||
|
if (isset($memo[$id])) {
|
||||||
|
return $memo[$id];
|
||||||
|
}
|
||||||
|
$acc = [];
|
||||||
|
if (isset($leafByDeptSlot[$id])) {
|
||||||
|
foreach ($leafByDeptSlot[$id] as $slot => $cnt) {
|
||||||
|
$acc[(int) $slot] = (int) $cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($childrenByPid[$id] ?? [] as $cid) {
|
||||||
|
$cid = (int) $cid;
|
||||||
|
if (!isset($subtreeSet[$cid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$childAcc = $dfs($cid);
|
||||||
|
foreach ($childAcc as $slot => $cnt) {
|
||||||
|
$acc[$slot] = ($acc[$slot] ?? 0) + $cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($acc !== []) {
|
||||||
|
ksort($acc, SORT_NUMERIC);
|
||||||
|
}
|
||||||
|
$memo[$id] = $acc;
|
||||||
|
|
||||||
|
return $acc;
|
||||||
|
};
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($subtreeDeptIds as $id) {
|
||||||
|
$id = (int) $id;
|
||||||
|
$out[$id] = $dfs($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $assistantIds
|
||||||
|
* @param array<int, true> $subtreeSet
|
||||||
|
*
|
||||||
|
* @return array<int, int> admin_id => dept_id
|
||||||
|
*/
|
||||||
|
private static function buildAssistantCanonicalDeptInSubtree(array $assistantIds, array $subtreeSet): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $assistantIds), static function (int $v): bool {
|
||||||
|
return $v > 0;
|
||||||
|
})));
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$subtreeIdList = array_keys($subtreeSet);
|
||||||
|
$rows = AdminDept::whereIn('admin_id', $ids)
|
||||||
|
->whereIn('dept_id', $subtreeIdList)
|
||||||
|
->field(['admin_id', 'dept_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$a = (int) ($r['admin_id'] ?? 0);
|
||||||
|
$d = (int) ($r['dept_id'] ?? 0);
|
||||||
|
if ($a > 0 && $d > 0) {
|
||||||
|
$candidates[$a][] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$depthMap = self::buildDeptIdDepthMap();
|
||||||
|
$out = [];
|
||||||
|
foreach ($candidates as $aid => $depts) {
|
||||||
|
$best = null;
|
||||||
|
$bestDepth = -1;
|
||||||
|
foreach ($depts as $d) {
|
||||||
|
$depth = (int) ($depthMap[$d] ?? 0);
|
||||||
|
if (
|
||||||
|
$best === null
|
||||||
|
|| $depth > $bestDepth
|
||||||
|
|| ($depth === $bestDepth && $d < (int) $best)
|
||||||
|
) {
|
||||||
|
$bestDepth = $depth;
|
||||||
|
$best = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($best !== null) {
|
||||||
|
$out[$aid] = (int) $best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int> dept_id => 从根层深度 0 起的层级
|
||||||
|
*/
|
||||||
|
private static function buildDeptIdDepthMap(): array
|
||||||
|
{
|
||||||
|
$rows = Dept::field(['id', 'pid'])->select()->toArray();
|
||||||
|
$pidById = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$pidById[(int) $r['id']] = (int) $r['pid'];
|
||||||
|
}
|
||||||
|
$depth = [];
|
||||||
|
$getDepth = static function (int $id) use (&$getDepth, &$depth, $pidById): int {
|
||||||
|
if (isset($depth[$id])) {
|
||||||
|
return $depth[$id];
|
||||||
|
}
|
||||||
|
$p = (int) ($pidById[$id] ?? 0);
|
||||||
|
$depth[$id] = $p > 0 ? $getDepth($p) + 1 : 0;
|
||||||
|
|
||||||
|
return $depth[$id];
|
||||||
|
};
|
||||||
|
foreach (array_keys($pidById) as $id) {
|
||||||
|
$getDepth((int) $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $depth;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 列表树状结构
|
* @notes 列表树状结构
|
||||||
* @param $array
|
* @param $array
|
||||||
@@ -271,15 +809,74 @@ class DeptLogic extends BaseLogic
|
|||||||
*/
|
*/
|
||||||
public static function getAllData()
|
public static function getAllData()
|
||||||
{
|
{
|
||||||
$data = Dept::where(['status' => YesNoEnum::YES])
|
// 与业绩看板等统计口径一致:含全部未软删部门(不再仅限 status=启用),
|
||||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
// 避免外链 assistant_dept_id 在树下拉中不存在导致 TreeSelect 初始化异常。
|
||||||
|
$data = Dept::order(['sort' => 'desc', 'id' => 'desc'])
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
if ($data === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$pid = min(array_column($data, 'pid'));
|
$pid = min(array_column($data, 'pid'));
|
||||||
return self::getTree($data, $pid);
|
return self::getTree($data, $pid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门树(与 getAllData 同结构),按当前管理员角色数据权限收窄可选节点;
|
||||||
|
* 保留必选祖先节点以便树形展示(与业绩看板 deptOptions 的 allowed 集合一致)。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $adminInfo BaseAdminController::$adminInfo 形态(须含 root、role_id 等)
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function getAllDataScoped(int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
$full = self::getAllData();
|
||||||
|
if ($full === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
|
||||||
|
return $full;
|
||||||
|
}
|
||||||
|
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||||
|
if ($allowed === null) {
|
||||||
|
return $full;
|
||||||
|
}
|
||||||
|
if ($allowed === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::filterDeptTreeByAllowedIds($full, $allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $nodes
|
||||||
|
* @param array<int, true> $allowedIdMap
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function filterDeptTreeByAllowedIds(array $nodes, array $allowedIdMap): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
$id = (int) ($node['id'] ?? 0);
|
||||||
|
$rawChildren = $node['children'] ?? [];
|
||||||
|
$children = \is_array($rawChildren) && $rawChildren !== []
|
||||||
|
? self::filterDeptTreeByAllowedIds($rawChildren, $allowedIdMap)
|
||||||
|
: [];
|
||||||
|
$inAllowed = isset($allowedIdMap[$id]);
|
||||||
|
if ($inAllowed || $children !== []) {
|
||||||
|
$row = $node;
|
||||||
|
$row['children'] = $children;
|
||||||
|
$out[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定部门及其全部下级部门 id(含自身)。筛选时选父级可匹配子级下成员(admin_dept.dept_id)。
|
* 指定部门及其全部下级部门 id(含自身)。筛选时选父级可匹配子级下成员(admin_dept.dept_id)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ use app\common\service\doctor\RosterSegmentService;
|
|||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\tcm\DiagnosisGuahaoLog;
|
use app\common\model\tcm\DiagnosisGuahaoLog;
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
use app\api\logic\ChatNotifyLogic;
|
use app\api\logic\ChatNotifyLogic;
|
||||||
use app\adminapi\logic\tcm\PrescriptionLogic;
|
use app\adminapi\logic\tcm\PrescriptionLogic;
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
use app\adminapi\logic\tcm\BloodRecordLogic;
|
use app\adminapi\logic\tcm\BloodRecordLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
@@ -27,6 +30,75 @@ use think\facade\Log;
|
|||||||
*/
|
*/
|
||||||
class AppointmentLogic extends BaseLogic
|
class AppointmentLogic extends BaseLogic
|
||||||
{
|
{
|
||||||
|
/** 仅这些字典名称需填写 channel_source_detail(与 admin 预约弹窗白名单一致,按 name 全等) */
|
||||||
|
private const CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL = [
|
||||||
|
'自媒体4H',
|
||||||
|
'自媒体3Q',
|
||||||
|
'自媒体3H',
|
||||||
|
'自媒体2H',
|
||||||
|
'自媒体2Q',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static function channelDictNameRequiresSourceDetail(string $name): bool
|
||||||
|
{
|
||||||
|
return in_array($name, self::CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, mixed> $cols Db::name('doctor_appointment')->getTableFields()
|
||||||
|
*/
|
||||||
|
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
|
||||||
|
{
|
||||||
|
$hasChannelSource = in_array('channel_source', $cols, true);
|
||||||
|
$hasChannels = in_array('channels', $cols, true);
|
||||||
|
if (!$hasChannelSource && !$hasChannels) {
|
||||||
|
return '挂号表缺少渠道字段(channel_source 或 channels),无法保存渠道来源';
|
||||||
|
}
|
||||||
|
if (!$hasChannelSource && $hasChannels && $chSrc !== '' && !is_numeric($chSrc)) {
|
||||||
|
return '当前挂号表仅有数值型渠道字段 channels,无法写入该渠道;请在数据库增加 channel_source 列';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @param array<int|string, mixed> $cols
|
||||||
|
*/
|
||||||
|
private static function mergeAppointmentChannelIntoRow(array &$row, array $cols, string $chSrc, string $chDetail): void
|
||||||
|
{
|
||||||
|
$hasChannelSource = in_array('channel_source', $cols, true);
|
||||||
|
$hasChannelDetail = in_array('channel_source_detail', $cols, true);
|
||||||
|
$hasChannels = in_array('channels', $cols, true);
|
||||||
|
if ($hasChannelSource) {
|
||||||
|
$row['channel_source'] = $chSrc;
|
||||||
|
}
|
||||||
|
if ($hasChannelDetail) {
|
||||||
|
$row['channel_source_detail'] = $chDetail;
|
||||||
|
}
|
||||||
|
if ($hasChannels && is_numeric($chSrc)) {
|
||||||
|
$row['channels'] = (int) $chSrc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @param array<int|string, mixed> $cols
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($row as $k => $v) {
|
||||||
|
if (in_array((string) $k, $cols, true)) {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 获取可用时间段
|
* @notes 获取可用时间段
|
||||||
* @param array $params
|
* @param array $params
|
||||||
@@ -232,23 +304,64 @@ class AppointmentLogic extends BaseLogic
|
|||||||
Db::rollback();
|
Db::rollback();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$data = [
|
|
||||||
'patient_id' => $params['patient_id'],
|
$chSrc = trim((string) ($params['channel_source'] ?? ''));
|
||||||
'assistant_id'=>$params['assistant_id'],
|
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
|
||||||
'doctor_id' => $params['doctor_id'],
|
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
|
||||||
'roster_id' => 0, // 不再关联排班表
|
->where('value', $chSrc)
|
||||||
|
->value('name') ?? ''));
|
||||||
|
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
|
||||||
|
if ($chDetail === '') {
|
||||||
|
self::setError('请填写自媒体渠道补充信息');
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols = Db::name('doctor_appointment')->getTableFields();
|
||||||
|
$cols = is_array($cols) ? $cols : [];
|
||||||
|
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
|
||||||
|
if ($channelErr !== null) {
|
||||||
|
self::setError($channelErr);
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = [
|
||||||
|
'patient_id' => (int) $params['patient_id'],
|
||||||
|
'doctor_id' => (int) $params['doctor_id'],
|
||||||
'appointment_date' => $params['appointment_date'],
|
'appointment_date' => $params['appointment_date'],
|
||||||
'period' => $params['period'] ?? 'all',
|
|
||||||
'appointment_time' => $appointmentTime,
|
'appointment_time' => $appointmentTime,
|
||||||
'appointment_type' => $params['appointment_type'] ?? 'video',
|
'appointment_type' => $params['appointment_type'] ?? 'video',
|
||||||
'remark' => $params['remark'] ?? '',
|
'remark' => $params['remark'] ?? '',
|
||||||
'channels' => $params['channel_source'] ?? '',
|
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
'create_time' => time(),
|
'create_time' => time(),
|
||||||
'update_time' => time(),
|
'update_time' => time(),
|
||||||
];
|
];
|
||||||
|
if (in_array('roster_id', $cols, true)) {
|
||||||
|
$insert['roster_id'] = 0;
|
||||||
|
}
|
||||||
|
if (in_array('assistant_id', $cols, true)) {
|
||||||
|
$insert['assistant_id'] = (int) ($params['assistant_id'] ?? 0);
|
||||||
|
}
|
||||||
|
$periodVal = $params['period'] ?? 'all';
|
||||||
|
if (in_array('period', $cols, true)) {
|
||||||
|
$insert['period'] = $periodVal;
|
||||||
|
} elseif (in_array('type', $cols, true)) {
|
||||||
|
$insert['type'] = $periodVal;
|
||||||
|
}
|
||||||
|
self::mergeAppointmentChannelIntoRow($insert, $cols, $chSrc, $chDetail);
|
||||||
|
|
||||||
$appointment = Appointment::create($data);
|
$insert = self::filterAppointmentRowByExistingColumns($insert, $cols);
|
||||||
|
$newId = (int) Db::name('doctor_appointment')->insertGetId($insert);
|
||||||
|
if ($newId <= 0) {
|
||||||
|
self::setError('预约创建失败');
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
@@ -256,21 +369,21 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$hm = substr((string) $appointmentTime, 0, 5);
|
$hm = substr((string) $appointmentTime, 0, 5);
|
||||||
$summary = sprintf(
|
$summary = sprintf(
|
||||||
'挂号 #%d:医生「%s」,%s %s',
|
'挂号 #%d:医生「%s」,%s %s',
|
||||||
(int) $appointment->id,
|
$newId,
|
||||||
$doctorName !== '' ? $doctorName : ('ID:' . (int) $params['doctor_id']),
|
$doctorName !== '' ? $doctorName : ('ID:' . (int) $params['doctor_id']),
|
||||||
(string) $params['appointment_date'],
|
(string) $params['appointment_date'],
|
||||||
$hm
|
$hm
|
||||||
);
|
);
|
||||||
self::appendDiagnosisGuahaoLog(
|
self::appendDiagnosisGuahaoLog(
|
||||||
(int) $params['patient_id'],
|
(int) $params['patient_id'],
|
||||||
(int) $appointment->id,
|
$newId,
|
||||||
$operatorAdminId,
|
$operatorAdminId,
|
||||||
$operatorAdminInfo,
|
$operatorAdminInfo,
|
||||||
'create',
|
'create',
|
||||||
$summary
|
$summary
|
||||||
);
|
);
|
||||||
|
|
||||||
return ['id' => $appointment->id];
|
return ['id' => $newId];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -465,11 +578,14 @@ class AppointmentLogic extends BaseLogic
|
|||||||
// 面诊结束:通知对应医助(从诊单 assistant_id 获取)
|
// 面诊结束:通知对应医助(从诊单 assistant_id 获取)
|
||||||
try {
|
try {
|
||||||
$diagnosis = Diagnosis::where('id', $appointment->patient_id)->find();
|
$diagnosis = Diagnosis::where('id', $appointment->patient_id)->find();
|
||||||
|
|
||||||
if ($diagnosis && !empty($diagnosis->assistant_id)) {
|
if ($diagnosis && !empty($diagnosis->assistant_id)) {
|
||||||
$assistantId = (int) $diagnosis->assistant_id;
|
$assistantId = (int) $diagnosis->assistant_id;
|
||||||
|
|
||||||
$patientName = $diagnosis->patient_name ?? '患者';
|
$patientName = $diagnosis->patient_name ?? '患者';
|
||||||
$doctor = Admin::where('id', $appointment->doctor_id)->find();
|
$doctor = Admin::where('id', $appointment->doctor_id)->find();
|
||||||
$doctorName = $doctor ? $doctor->name : '医生';
|
$doctorName = $doctor ? $doctor->name : '医生';
|
||||||
|
|
||||||
ChatNotifyLogic::addConsultationCompleteNotify(
|
ChatNotifyLogic::addConsultationCompleteNotify(
|
||||||
$assistantId,
|
$assistantId,
|
||||||
$patientName,
|
$patientName,
|
||||||
@@ -516,10 +632,8 @@ class AppointmentLogic extends BaseLogic
|
|||||||
? DiagnosisLogic::detail(['id' => $diagnosisId])
|
? DiagnosisLogic::detail(['id' => $diagnosisId])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// 3) 血糖血压记录
|
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
|
||||||
$bloodRecords = $diagnosisId > 0
|
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
|
||||||
? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
|
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
|
||||||
$assistantName = '';
|
$assistantName = '';
|
||||||
@@ -551,23 +665,28 @@ class AppointmentLogic extends BaseLogic
|
|||||||
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
|
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// 7.1) 跟踪备注
|
||||||
|
$trackingNotes = $diagnosisId > 0
|
||||||
|
? TrackingNoteLogic::getByDiagnosis($diagnosisId)
|
||||||
|
: [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'appointment' => $appointment,
|
'appointment' => $appointment,
|
||||||
'diagnosis' => $diagnosis,
|
'diagnosis' => $diagnosis,
|
||||||
'blood_records' => $bloodRecords,
|
'doctor_notes' => $doctorNotes,
|
||||||
'doctor_notes' => $doctorNotes,
|
'tracking_notes' => $trackingNotes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
|
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
|
||||||
* 便于前端(接诊台等只读场景)直接渲染,无需加载字典。
|
* 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。
|
||||||
* 原始 code 字段保留不动,edit 场景仍可使用。
|
* 原始 code 字段保留不动,edit 场景仍可使用。
|
||||||
*
|
*
|
||||||
* @param array $diagnosis
|
* @param array $diagnosis
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function enrichDiagnosisLabels(array $diagnosis): array
|
public static function enrichDiagnosisLabels(array $diagnosis): array
|
||||||
{
|
{
|
||||||
if (empty($diagnosis)) {
|
if (empty($diagnosis)) {
|
||||||
return $diagnosis;
|
return $diagnosis;
|
||||||
@@ -752,6 +871,368 @@ class AppointmentLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax)
|
||||||
|
*/
|
||||||
|
private static function appointmentRowManageableByAdmin(
|
||||||
|
Appointment $appointment,
|
||||||
|
?Diagnosis $diag,
|
||||||
|
int $adminId,
|
||||||
|
array $adminInfo
|
||||||
|
): bool {
|
||||||
|
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||||
|
|
||||||
|
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in_array(2, $roleIds, true)) {
|
||||||
|
$asst = $diag ? (int) $diag->assistant_id : 0;
|
||||||
|
if ($asst !== $adminId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DataScopeService::isEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||||
|
if ($ids === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($ids === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$docId = (int) $appointment->doctor_id;
|
||||||
|
$asstId = $diag ? (int) $diag->assistant_id : 0;
|
||||||
|
|
||||||
|
return in_array($docId, $ids, true)
|
||||||
|
|| ($asstId > 0 && in_array($asstId, $ids, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
*/
|
||||||
|
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) ($params['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
self::setError('参数错误');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment = Appointment::findOrEmpty($id);
|
||||||
|
if ($appointment->isEmpty()) {
|
||||||
|
self::setError('预约记录不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diag = Diagnosis::where('id', (int) $appointment->patient_id)->whereNull('delete_time')->find();
|
||||||
|
|
||||||
|
if (!self::appointmentRowManageableByAdmin($appointment, $diag ?: null, $adminId, $adminInfo)) {
|
||||||
|
self::setError('无权限编辑');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = trim((string) ($params['period'] ?? ''));
|
||||||
|
if (!in_array($period, ['morning', 'afternoon', 'all'], true)) {
|
||||||
|
self::setError('时段无效');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointmentDate = trim((string) ($params['appointment_date'] ?? ''));
|
||||||
|
if ($appointmentDate === '') {
|
||||||
|
self::setError('预约日期不能为空');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawTime = trim((string) ($params['appointment_time'] ?? ''));
|
||||||
|
if ($rawTime === '') {
|
||||||
|
self::setError('预约时间不能为空');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$appointmentTime = strlen($rawTime) === 5 ? $rawTime . ':00' : $rawTime;
|
||||||
|
|
||||||
|
$status = (int) ($params['status'] ?? 0);
|
||||||
|
if ($status < 1 || $status > 4) {
|
||||||
|
self::setError('状态无效');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointmentType = trim((string) ($params['appointment_type'] ?? ''));
|
||||||
|
if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) {
|
||||||
|
self::setError('预约类型无效');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remark = isset($params['remark']) ? trim((string) $params['remark']) : '';
|
||||||
|
if (mb_strlen($remark) > 500) {
|
||||||
|
self::setError('备注过长');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chSrc = trim((string) ($params['channel_source'] ?? ''));
|
||||||
|
if ($chSrc === '') {
|
||||||
|
self::setError('请选择渠道来源');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
|
||||||
|
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
|
||||||
|
->where('value', $chSrc)
|
||||||
|
->value('name') ?? ''));
|
||||||
|
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
|
||||||
|
if ($chDetail === '') {
|
||||||
|
self::setError('请填写自媒体渠道补充信息');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mb_strlen($chDetail) > 128) {
|
||||||
|
self::setError('渠道补充说明过长');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$postAssistant = array_key_exists('assistant_id', $params);
|
||||||
|
$assistantId = null;
|
||||||
|
if ($postAssistant) {
|
||||||
|
$assistantRaw = $params['assistant_id'];
|
||||||
|
if ($assistantRaw !== null && $assistantRaw !== '') {
|
||||||
|
$aid = (int) $assistantRaw;
|
||||||
|
$assistantId = $aid > 0 ? $aid : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientId = (int) $appointment->patient_id;
|
||||||
|
$doctorId = (int) $appointment->doctor_id;
|
||||||
|
|
||||||
|
if (in_array($status, [1, 4], true)) {
|
||||||
|
$patientDup = Appointment::where('patient_id', $patientId)
|
||||||
|
->where('appointment_date', $appointmentDate)
|
||||||
|
->whereIn('status', [1, 4])
|
||||||
|
->where('id', '<>', $id)
|
||||||
|
->find();
|
||||||
|
if ($patientDup) {
|
||||||
|
self::setError('该诊单在所选日期已有其他有效挂号(已预约/已过号)');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 1) {
|
||||||
|
$slotDup = Appointment::where('doctor_id', $doctorId)
|
||||||
|
->where('appointment_date', $appointmentDate)
|
||||||
|
->where('appointment_time', $appointmentTime)
|
||||||
|
->where('status', 1)
|
||||||
|
->where('id', '<>', $id)
|
||||||
|
->find();
|
||||||
|
if ($slotDup) {
|
||||||
|
self::setError('该医生在所选时段已被占用');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeDate = (string) ($appointment->appointment_date ?? '');
|
||||||
|
$beforeTimeRaw = (string) ($appointment->appointment_time ?? '');
|
||||||
|
$beforeHm = strlen($beforeTimeRaw) >= 5 ? substr($beforeTimeRaw, 0, 5) : $beforeTimeRaw;
|
||||||
|
$snap = $appointment->toArray();
|
||||||
|
$beforePeriod = (string) ($snap['period'] ?? $snap['type'] ?? '');
|
||||||
|
$beforeStatus = (int) $appointment->status;
|
||||||
|
|
||||||
|
$tblFields = Db::name('doctor_appointment')->getTableFields();
|
||||||
|
$cols = is_array($tblFields) ? $tblFields : [];
|
||||||
|
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
|
||||||
|
if ($channelErr !== null) {
|
||||||
|
self::setError($channelErr);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
|
||||||
|
$saveData = [
|
||||||
|
'appointment_date' => $appointmentDate,
|
||||||
|
'appointment_time' => $appointmentTime,
|
||||||
|
'appointment_type' => $appointmentType,
|
||||||
|
'status' => $status,
|
||||||
|
'remark' => $remark,
|
||||||
|
'update_time' => time(),
|
||||||
|
];
|
||||||
|
if (in_array('assistant_id', $cols, true) && $postAssistant) {
|
||||||
|
$saveData['assistant_id'] = $assistantId;
|
||||||
|
}
|
||||||
|
if (in_array('period', $cols, true)) {
|
||||||
|
$saveData['period'] = $period;
|
||||||
|
} elseif (in_array('type', $cols, true)) {
|
||||||
|
$saveData['type'] = $period;
|
||||||
|
}
|
||||||
|
self::mergeAppointmentChannelIntoRow($saveData, $cols, $chSrc, $chDetail);
|
||||||
|
|
||||||
|
Db::name('doctor_appointment')->where('id', $id)->update($saveData);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
$hm = substr($appointmentTime, 0, 5);
|
||||||
|
$summary = sprintf(
|
||||||
|
'后台修改挂号 #%d:日期 %s %s→%s %s,时段 %s→%s,状态 %d→%d',
|
||||||
|
$id,
|
||||||
|
$beforeDate,
|
||||||
|
$beforeHm,
|
||||||
|
$appointmentDate,
|
||||||
|
$hm,
|
||||||
|
$beforePeriod !== '' ? $beforePeriod : '—',
|
||||||
|
$period,
|
||||||
|
$beforeStatus,
|
||||||
|
$status
|
||||||
|
);
|
||||||
|
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台批量修改挂号渠道(仅渠道号与可选补充)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array{updated:int}|false
|
||||||
|
*/
|
||||||
|
public static function adminBatchEditChannel(array $params, int $adminId, array $adminInfo)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$idsRaw = $params['ids'] ?? [];
|
||||||
|
if (!\is_array($idsRaw) || $idsRaw === []) {
|
||||||
|
self::setError('请选择要修改的挂号记录');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($idsRaw as $raw) {
|
||||||
|
$id = (int) $raw;
|
||||||
|
if ($id > 0) {
|
||||||
|
$ids[$id] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ids = array_values($ids);
|
||||||
|
$maxBatch = 300;
|
||||||
|
if (\count($ids) === 0) {
|
||||||
|
self::setError('预约ID无效');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (\count($ids) > $maxBatch) {
|
||||||
|
self::setError('单次最多修改 ' . $maxBatch . ' 条挂号');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chSrc = trim((string) ($params['channel_source'] ?? ''));
|
||||||
|
if ($chSrc === '') {
|
||||||
|
self::setError('请选择渠道来源');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$chDetail = isset($params['channel_source_detail']) ? trim((string) $params['channel_source_detail']) : '';
|
||||||
|
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
|
||||||
|
->where('value', $chSrc)
|
||||||
|
->value('name') ?? ''));
|
||||||
|
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
|
||||||
|
if ($chDetail === '') {
|
||||||
|
self::setError('请填写自媒体渠道补充信息');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mb_strlen($chDetail) > 128) {
|
||||||
|
self::setError('渠道补充说明过长');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tblFields = Db::name('doctor_appointment')->getTableFields();
|
||||||
|
$cols = \is_array($tblFields) ? $tblFields : [];
|
||||||
|
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
|
||||||
|
if ($channelErr !== null) {
|
||||||
|
self::setError($channelErr);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$appointment = Appointment::findOrEmpty((int) $id);
|
||||||
|
if ($appointment->isEmpty()) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError('预约记录不存在(ID ' . $id . ')');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diag = Diagnosis::where('id', (int) $appointment->patient_id)->whereNull('delete_time')->find();
|
||||||
|
|
||||||
|
if (!self::appointmentRowManageableByAdmin($appointment, $diag ?: null, $adminId, $adminInfo)) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError('无权限修改挂号 #' . $id);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$saveData = [
|
||||||
|
'update_time' => time(),
|
||||||
|
];
|
||||||
|
self::mergeAppointmentChannelIntoRow($saveData, $cols, $chSrc, $chDetail);
|
||||||
|
|
||||||
|
$saveData = self::filterAppointmentRowByExistingColumns($saveData, $cols);
|
||||||
|
|
||||||
|
Db::name('doctor_appointment')->where('id', $id)->update($saveData);
|
||||||
|
|
||||||
|
$patientId = (int) $appointment->patient_id;
|
||||||
|
self::appendDiagnosisGuahaoLog(
|
||||||
|
$patientId,
|
||||||
|
(int) $id,
|
||||||
|
$adminId,
|
||||||
|
$adminInfo,
|
||||||
|
'admin_batch_channel',
|
||||||
|
sprintf('批量修改挂号 #%d:渠道设为 %s', $id, $chSrc !== '' ? $chSrc : '—')
|
||||||
|
);
|
||||||
|
++$updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'updated' => $updated,
|
||||||
|
'msg' => sprintf('已更新 %d 条挂号渠道', $updated),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 诊单列表维度:记录谁在后台发起挂号 / 取消挂号(写入失败不影响主流程)
|
* 诊单列表维度:记录谁在后台发起挂号 / 取消挂号(写入失败不影响主流程)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use app\adminapi\logic\dept\DeptLogic;
|
|||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
use app\common\model\dept\Dept;
|
use app\common\model\dept\Dept;
|
||||||
use app\common\model\finance\DeptPerformanceTarget;
|
use app\common\model\finance\DeptPerformanceTarget;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
class DeptPerformanceTargetLogic extends BaseLogic
|
class DeptPerformanceTargetLogic extends BaseLogic
|
||||||
@@ -15,11 +16,16 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
|||||||
/**
|
/**
|
||||||
* 指定月份:保留部门树层级 + 合并当月已有目标
|
* 指定月份:保留部门树层级 + 合并当月已有目标
|
||||||
*
|
*
|
||||||
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float}
|
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float, data_scope_limited: bool}
|
||||||
*/
|
*/
|
||||||
public static function monthMatrix(string $yearMonth): array
|
public static function monthMatrix(string $yearMonth, int $adminId = 0, array $adminInfo = []): array
|
||||||
{
|
{
|
||||||
$tree = DeptLogic::getAllData();
|
$tree = DeptLogic::getAllData();
|
||||||
|
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||||
|
$scoped = $allowed !== null;
|
||||||
|
if ($scoped) {
|
||||||
|
$tree = self::filterDeptTreeAllowedForest(is_array($tree) ? $tree : [], $allowed);
|
||||||
|
}
|
||||||
|
|
||||||
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
|
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
|
||||||
->select()
|
->select()
|
||||||
@@ -33,19 +39,22 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
|||||||
$total = self::sumTargetsInTree($rows);
|
$total = self::sumTargetsInTree($rows);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'year_month' => $yearMonth,
|
'year_month' => $yearMonth,
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'total_target' => round($total, 2),
|
'total_target' => round($total, 2),
|
||||||
|
'data_scope_limited' => $scoped,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{dept_id?:int|float|string, target_amount?:int|float|string, remark?:string}> $items
|
* @param array<int, array{dept_id?:int|float|string, target_amount?:int|float|string, remark?:string}> $items
|
||||||
*/
|
*/
|
||||||
public static function batchSave(string $yearMonth, array $items, int $adminId, string $adminName): bool
|
public static function batchSave(string $yearMonth, array $items, int $adminId, array $adminInfo, string $adminName): bool
|
||||||
{
|
{
|
||||||
|
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName): void {
|
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName, $allowed): void {
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if (!is_array($item)) {
|
if (!is_array($item)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -54,6 +63,10 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
|||||||
if ($deptId <= 0) {
|
if ($deptId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 仅处理 data scope 内的 dept_id(界面树与 monthMatrix 一致,不再含纯展示用父级)
|
||||||
|
if ($allowed !== null && !isset($allowed[$deptId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
|
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
|
||||||
if (!$dept) {
|
if (!$dept) {
|
||||||
continue;
|
continue;
|
||||||
@@ -162,4 +175,63 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
|||||||
|
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本部门及以下:仅展示 allowed 内节点;无权父级不渲染,从子树中接续(不露出公司/上级中心)。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $nodes
|
||||||
|
* @param array<int, true> $allowedMap
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function filterDeptTreeAllowedForest(array $nodes, array $allowedMap): array
|
||||||
|
{
|
||||||
|
if ($allowedMap === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($nodes as $n) {
|
||||||
|
if (!is_array($n)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$id = (int) ($n['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rawChildren = $n['children'] ?? [];
|
||||||
|
$kids = is_array($rawChildren) ? $rawChildren : [];
|
||||||
|
|
||||||
|
if (isset($allowedMap[$id])) {
|
||||||
|
$childList = [];
|
||||||
|
foreach ($kids as $c) {
|
||||||
|
if (!is_array($c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
|
||||||
|
$childList[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$node = $n;
|
||||||
|
if ($childList !== []) {
|
||||||
|
$node['children'] = $childList;
|
||||||
|
} else {
|
||||||
|
unset($node['children']);
|
||||||
|
}
|
||||||
|
$out[] = $node;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($kids as $c) {
|
||||||
|
if (!is_array($c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1213,7 +1213,7 @@ class OrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$rows = $q
|
$rows = $q
|
||||||
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark'])
|
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
@@ -1400,4 +1400,16 @@ class OrderLogic
|
|||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function setExempt(int $id, int $isExempt): bool
|
||||||
|
{
|
||||||
|
$order = Order::find($id);
|
||||||
|
if (!$order) {
|
||||||
|
self::setError('订单不存在');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$order->is_exempt = $isExempt;
|
||||||
|
$order->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,498 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。
|
||||||
|
*
|
||||||
|
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>(delete_time 为空);再按账号「数据范围」收窄。
|
||||||
|
*
|
||||||
|
* - 系统/手动开方:tcm_prescription.prescription_date ∈ [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
|
||||||
|
* - 成交:订单 create_time、排除履约 4/9/10,按处方 creator_id;渠道/标签同 sumPerformance 口径
|
||||||
|
* - 挂号:appointment_date ∈ [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id ∈ 标签诊单集
|
||||||
|
*/
|
||||||
|
class DoctorDailyStatsLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* start_date?:string,
|
||||||
|
* end_date?:string,
|
||||||
|
* channel_code?:string,
|
||||||
|
* tag_id?:string,
|
||||||
|
* doctor_id?:int|string
|
||||||
|
* } $params
|
||||||
|
*
|
||||||
|
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
|
||||||
|
{
|
||||||
|
// 医生统计不参与「展示部门」检索:忽略 dept_ids,避免与业绩表部门筛选联动
|
||||||
|
$paramsForCtx = $params;
|
||||||
|
unset($paramsForCtx['dept_ids']);
|
||||||
|
|
||||||
|
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($paramsForCtx, $viewerAdminId, $viewerAdminInfo);
|
||||||
|
$startDate = $ctx['startDate'];
|
||||||
|
$endDate = $ctx['endDate'];
|
||||||
|
$startTs = $ctx['startTs'];
|
||||||
|
$endTs = $ctx['endTs'];
|
||||||
|
$appointmentChannelValues = $ctx['appointmentChannelValues'];
|
||||||
|
$channelFilterActive = $ctx['channelFilterActive'];
|
||||||
|
$tagDiagIds = $ctx['tagDiagIds'];
|
||||||
|
$tagAssistantIds = $ctx['tagAssistantIds'];
|
||||||
|
$tagFallback = $ctx['tagFallback'];
|
||||||
|
|
||||||
|
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
|
||||||
|
|
||||||
|
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
|
||||||
|
|
||||||
|
if ($filterDoctorId > 0) {
|
||||||
|
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($doctorIds === []) {
|
||||||
|
return [
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'rows' => [],
|
||||||
|
'total' => self::emptyTotals(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rxMap = self::loadPrescriptionCounts(
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
$doctorIds,
|
||||||
|
$appointmentChannelValues,
|
||||||
|
$channelFilterActive,
|
||||||
|
$tagDiagIds,
|
||||||
|
$tagAssistantIds,
|
||||||
|
$tagFallback
|
||||||
|
);
|
||||||
|
$orderMap = self::loadOrderAggregates(
|
||||||
|
$startTs,
|
||||||
|
$endTs,
|
||||||
|
$doctorIds,
|
||||||
|
$appointmentChannelValues,
|
||||||
|
$channelFilterActive,
|
||||||
|
$tagDiagIds,
|
||||||
|
$tagAssistantIds,
|
||||||
|
$tagFallback
|
||||||
|
);
|
||||||
|
$apptMap = self::loadAppointmentAggregates(
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
$doctorIds,
|
||||||
|
$appointmentChannelValues,
|
||||||
|
$channelFilterActive,
|
||||||
|
$tagDiagIds
|
||||||
|
);
|
||||||
|
|
||||||
|
$adminRows = Admin::whereIn('id', $doctorIds)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field(['id', 'name'])
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$nameById = [];
|
||||||
|
foreach ($adminRows as $r) {
|
||||||
|
$nameById[(int) $r['id']] = (string) ($r['name'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($doctorIds as $aid) {
|
||||||
|
$rx = $rxMap[$aid] ?? ['system' => 0, 'manual' => 0];
|
||||||
|
$ord = $orderMap[$aid] ?? ['amount' => 0.0, 'count' => 0];
|
||||||
|
$ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0];
|
||||||
|
$cnt = (int) $ord['count'];
|
||||||
|
$amt = round((float) $ord['amount'], 2);
|
||||||
|
$rows[] = [
|
||||||
|
'admin_id' => $aid,
|
||||||
|
'doctor_name' => $nameById[$aid] ?? ('#' . $aid),
|
||||||
|
'system_prescription_count' => (int) $rx['system'],
|
||||||
|
'manual_prescription_count' => (int) $rx['manual'],
|
||||||
|
'deal_amount' => $amt,
|
||||||
|
'deal_order_count' => $cnt,
|
||||||
|
'avg_deal_amount' => $cnt > 0 ? round($amt / $cnt, 2) : null,
|
||||||
|
'appointment_completed' => (int) $ap['completed'],
|
||||||
|
'appointment_missed' => (int) $ap['missed'],
|
||||||
|
'appointment_cancelled' => (int) $ap['cancelled'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
if (($a['deal_amount'] ?? 0) != ($b['deal_amount'] ?? 0)) {
|
||||||
|
return ($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) ($a['doctor_name'] ?? ''), (string) ($b['doctor_name'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'rows' => $rows,
|
||||||
|
'total' => self::sumTotals($rows),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生角色(role_id=1)、管理员未删除、且在数据范围内的 admin_id。
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function resolveDoctorAdminIdsForStats(int $viewerAdminId, array $viewerAdminInfo): array
|
||||||
|
{
|
||||||
|
$roleDoctors = Db::name('admin_role')->alias('ar')
|
||||||
|
->join('admin a', 'a.id = ar.admin_id')
|
||||||
|
->where('ar.role_id', 1)
|
||||||
|
->whereNull('a.delete_time')
|
||||||
|
->column('ar.admin_id');
|
||||||
|
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $roleDoctors), static function (int $v): bool {
|
||||||
|
return $v > 0;
|
||||||
|
})));
|
||||||
|
sort($doctorIds);
|
||||||
|
|
||||||
|
if ($viewerAdminId > 0 && DataScopeService::isEnabled()) {
|
||||||
|
$visibleIds = DataScopeService::getVisibleAdminIds($viewerAdminId, $viewerAdminInfo);
|
||||||
|
if ($visibleIds !== null) {
|
||||||
|
if ($visibleIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$flip = array_flip($visibleIds);
|
||||||
|
$doctorIds = array_values(array_filter($doctorIds, static function (int $id) use ($flip): bool {
|
||||||
|
return isset($flip[$id]);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $doctorIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, float|int|null>
|
||||||
|
*/
|
||||||
|
private static function emptyTotals(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'system_prescription_count' => 0,
|
||||||
|
'manual_prescription_count' => 0,
|
||||||
|
'deal_amount' => 0.0,
|
||||||
|
'deal_order_count' => 0,
|
||||||
|
'avg_deal_amount' => null,
|
||||||
|
'appointment_completed' => 0,
|
||||||
|
'appointment_missed' => 0,
|
||||||
|
'appointment_cancelled' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*
|
||||||
|
* @return array<string, float|int|null>
|
||||||
|
*/
|
||||||
|
private static function sumTotals(array $rows): array
|
||||||
|
{
|
||||||
|
$t = self::emptyTotals();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$t['system_prescription_count'] += (int) ($r['system_prescription_count'] ?? 0);
|
||||||
|
$t['manual_prescription_count'] += (int) ($r['manual_prescription_count'] ?? 0);
|
||||||
|
$t['deal_amount'] += (float) ($r['deal_amount'] ?? 0);
|
||||||
|
$t['deal_order_count'] += (int) ($r['deal_order_count'] ?? 0);
|
||||||
|
$t['appointment_completed'] += (int) ($r['appointment_completed'] ?? 0);
|
||||||
|
$t['appointment_missed'] += (int) ($r['appointment_missed'] ?? 0);
|
||||||
|
$t['appointment_cancelled'] += (int) ($r['appointment_cancelled'] ?? 0);
|
||||||
|
}
|
||||||
|
$t['deal_amount'] = round((float) $t['deal_amount'], 2);
|
||||||
|
$dc = (int) $t['deal_order_count'];
|
||||||
|
$t['avg_deal_amount'] = $dc > 0 ? round((float) $t['deal_amount'] / $dc, 2) : null;
|
||||||
|
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $appointmentChannelValues
|
||||||
|
* @param int[]|null $tagDiagIds
|
||||||
|
* @param array<int,int>|null $tagAssistantIds
|
||||||
|
*
|
||||||
|
* @return array<int, array{system:int, manual:int}>
|
||||||
|
*/
|
||||||
|
private static function loadPrescriptionCounts(
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
array $doctorIds,
|
||||||
|
array $appointmentChannelValues,
|
||||||
|
bool $channelFilterActive,
|
||||||
|
?array $tagDiagIds,
|
||||||
|
?array $tagAssistantIds,
|
||||||
|
bool $tagFallback
|
||||||
|
): array {
|
||||||
|
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Db::name('tcm_prescription')
|
||||||
|
->alias('rx')
|
||||||
|
->whereNull('rx.delete_time')
|
||||||
|
->whereRaw('IFNULL(rx.void_status, 0) <> 1')
|
||||||
|
->whereBetween('rx.prescription_date', [$startDate, $endDate])
|
||||||
|
->whereIn('rx.creator_id', $doctorIds)
|
||||||
|
->where('rx.diagnosis_id', '>', 0);
|
||||||
|
|
||||||
|
self::applyPrescriptionChannelTagFilter(
|
||||||
|
$query,
|
||||||
|
'rx',
|
||||||
|
$appointmentChannelValues,
|
||||||
|
$channelFilterActive,
|
||||||
|
$tagDiagIds,
|
||||||
|
$tagAssistantIds,
|
||||||
|
$tagFallback
|
||||||
|
);
|
||||||
|
|
||||||
|
$query->field([
|
||||||
|
'rx.creator_id',
|
||||||
|
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) = 1 THEN 1 ELSE 0 END) AS system_cnt'),
|
||||||
|
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) <> 1 THEN 1 ELSE 0 END) AS manual_cnt'),
|
||||||
|
])->group('rx.creator_id');
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($query->select()->toArray() as $r) {
|
||||||
|
$id = (int) ($r['creator_id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$id] = [
|
||||||
|
'system' => (int) ($r['system_cnt'] ?? 0),
|
||||||
|
'manual' => (int) ($r['manual_cnt'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $appointmentChannelValues
|
||||||
|
* @param int[]|null $tagDiagIds
|
||||||
|
* @param array<int,int>|null $tagAssistantIds
|
||||||
|
*
|
||||||
|
* @return array<int, array{amount: float, count: int}>
|
||||||
|
*/
|
||||||
|
private static function loadOrderAggregates(
|
||||||
|
int $startTs,
|
||||||
|
int $endTs,
|
||||||
|
array $doctorIds,
|
||||||
|
array $appointmentChannelValues,
|
||||||
|
bool $channelFilterActive,
|
||||||
|
?array $tagDiagIds,
|
||||||
|
?array $tagAssistantIds,
|
||||||
|
bool $tagFallback
|
||||||
|
): array {
|
||||||
|
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = Db::name('tcm_prescription_order')
|
||||||
|
->alias('o')
|
||||||
|
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
|
||||||
|
->whereNull('o.delete_time')
|
||||||
|
->whereBetween('o.create_time', [$startTs, $endTs]);
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||||
|
$q->whereIn('rx.creator_id', $doctorIds)
|
||||||
|
->where('o.diagnosis_id', '>', 0);
|
||||||
|
|
||||||
|
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
|
||||||
|
if ($normCh !== []) {
|
||||||
|
$apTable = self::tableWithPrefix('doctor_appointment');
|
||||||
|
$adminRoleTable = self::tableWithPrefix('admin_role');
|
||||||
|
$ph = implode(',', array_fill(0, count($normCh), '?'));
|
||||||
|
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
|
||||||
|
$channelCond = "ap.channels IN ({$ph})";
|
||||||
|
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
|
||||||
|
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id "
|
||||||
|
. "AND ap.status = 3 AND {$channelCond})";
|
||||||
|
$q->whereRaw($existsSql, $strVals);
|
||||||
|
} elseif ($channelFilterActive) {
|
||||||
|
self::applyOrderTagFilter($q, 'o', 'rx', $tagDiagIds, $tagAssistantIds, $tagFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->field([
|
||||||
|
'rx.creator_id',
|
||||||
|
Db::raw('SUM(o.amount) AS amount_sum'),
|
||||||
|
Db::raw('COUNT(*) AS order_cnt'),
|
||||||
|
])->group('rx.creator_id');
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($q->select()->toArray() as $r) {
|
||||||
|
$id = (int) ($r['creator_id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$id] = [
|
||||||
|
'amount' => (float) ($r['amount_sum'] ?? 0),
|
||||||
|
'count' => (int) ($r['order_cnt'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单标签条件(无字典渠道映射时,与业绩 tag 分支一致;开方人维度用 rx.creator_id 兜底)。
|
||||||
|
*
|
||||||
|
* @param \think\db\Query $q
|
||||||
|
*/
|
||||||
|
private static function applyOrderTagFilter(
|
||||||
|
$q,
|
||||||
|
string $orderAlias,
|
||||||
|
string $rxAlias,
|
||||||
|
?array $tagDiagIds,
|
||||||
|
?array $tagAssistantIds,
|
||||||
|
bool $tagFallback
|
||||||
|
): void {
|
||||||
|
if ($tagDiagIds !== null) {
|
||||||
|
$q->whereIn("{$orderAlias}.diagnosis_id", $tagDiagIds);
|
||||||
|
}
|
||||||
|
if ($tagAssistantIds !== null) {
|
||||||
|
$ids = array_keys($tagAssistantIds);
|
||||||
|
if ($tagFallback) {
|
||||||
|
$q->whereIn("{$rxAlias}.creator_id", array_map('intval', $ids));
|
||||||
|
} else {
|
||||||
|
$q->whereIn("{$orderAlias}.creator_id", array_map('intval', $ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \think\db\Query $query rx 别名查询
|
||||||
|
* @param string $rxAlias
|
||||||
|
* @param int[] $appointmentChannelValues
|
||||||
|
* @param int[]|null $tagDiagIds
|
||||||
|
* @param array<int,int>|null $tagAssistantIds
|
||||||
|
*/
|
||||||
|
private static function applyPrescriptionChannelTagFilter(
|
||||||
|
$query,
|
||||||
|
string $rxAlias,
|
||||||
|
array $appointmentChannelValues,
|
||||||
|
bool $channelFilterActive,
|
||||||
|
?array $tagDiagIds,
|
||||||
|
?array $tagAssistantIds,
|
||||||
|
bool $tagFallback
|
||||||
|
): void {
|
||||||
|
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
|
||||||
|
if ($normCh !== []) {
|
||||||
|
$apTable = self::tableWithPrefix('doctor_appointment');
|
||||||
|
$adminRoleTable = self::tableWithPrefix('admin_role');
|
||||||
|
$ph = implode(',', array_fill(0, count($normCh), '?'));
|
||||||
|
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
|
||||||
|
$channelCond = "ap.channels IN ({$ph})";
|
||||||
|
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
|
||||||
|
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$rxAlias}.diagnosis_id "
|
||||||
|
. "AND ap.status = 3 AND {$channelCond})";
|
||||||
|
$query->whereRaw($existsSql, $strVals);
|
||||||
|
} elseif ($channelFilterActive) {
|
||||||
|
if ($tagDiagIds !== null) {
|
||||||
|
$query->whereIn("{$rxAlias}.diagnosis_id", $tagDiagIds);
|
||||||
|
}
|
||||||
|
if ($tagAssistantIds !== null && $tagFallback) {
|
||||||
|
$query->whereIn("{$rxAlias}.creator_id", array_map('intval', array_keys($tagAssistantIds)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签范围显式为空(0 条诊单/医助)时整段统计不再查询。
|
||||||
|
*/
|
||||||
|
private static function tagScopeNonEmpty(?array $tagDiagIds, ?array $tagAssistantIds): bool
|
||||||
|
{
|
||||||
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $appointmentChannelValues
|
||||||
|
* @param int[]|null $tagDiagIds
|
||||||
|
*
|
||||||
|
* @return array<int, array{completed:int, missed:int, cancelled:int}>
|
||||||
|
*/
|
||||||
|
private static function loadAppointmentAggregates(
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
array $doctorIds,
|
||||||
|
array $appointmentChannelValues,
|
||||||
|
bool $channelFilterActive,
|
||||||
|
?array $tagDiagIds
|
||||||
|
): array {
|
||||||
|
$q = Db::name('doctor_appointment')
|
||||||
|
->whereBetween('appointment_date', [$startDate, $endDate])
|
||||||
|
->whereIn('doctor_id', $doctorIds);
|
||||||
|
|
||||||
|
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
|
||||||
|
if ($normCh !== []) {
|
||||||
|
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
|
||||||
|
$q->whereIn('channels', $strVals);
|
||||||
|
} elseif ($channelFilterActive && $tagDiagIds !== null) {
|
||||||
|
if ($tagDiagIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$q->whereIn('patient_id', $tagDiagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->field([
|
||||||
|
'doctor_id',
|
||||||
|
Db::raw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS completed'),
|
||||||
|
Db::raw('SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS missed'),
|
||||||
|
Db::raw('SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cancelled'),
|
||||||
|
])->group('doctor_id');
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($q->select()->toArray() as $r) {
|
||||||
|
$id = (int) ($r['doctor_id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$id] = [
|
||||||
|
'completed' => (int) ($r['completed'] ?? 0),
|
||||||
|
'missed' => (int) ($r['missed'] ?? 0),
|
||||||
|
'cancelled' => (int) ($r['cancelled'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function normalizeAppointmentChannelInts(array $raw): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $v) {
|
||||||
|
$i = (int) $v;
|
||||||
|
|
||||||
|
if ($i > 0) {
|
||||||
|
$out[] = $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tableWithPrefix(string $table): string
|
||||||
|
{
|
||||||
|
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
|
||||||
|
|
||||||
|
return $prefix . $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,13 +15,26 @@
|
|||||||
namespace app\adminapi\logic\tcm;
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\enum\FileEnum;
|
||||||
|
use app\common\model\file\File as FileModel;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\tcm\DiagnosisGuahaoLog;
|
use app\common\model\tcm\DiagnosisGuahaoLog;
|
||||||
use app\common\model\tcm\DiagnosisAssignLog;
|
use app\common\model\tcm\DiagnosisAssignLog;
|
||||||
use app\common\model\tcm\ImChatMessage;
|
use app\common\model\tcm\ImChatMessage;
|
||||||
|
use app\common\model\tcm\BloodRecord;
|
||||||
|
use app\common\model\tcm\DietRecord;
|
||||||
|
use app\common\model\tcm\ExerciseRecord;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
|
use app\common\model\doctor\Appointment;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminRole;
|
||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
|
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
|
use app\common\service\ConfigService;
|
||||||
use app\common\service\FileService;
|
use app\common\service\FileService;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
|
use app\common\service\storage\Driver as StorageDriver;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
/**
|
/**
|
||||||
* 中医辨房病因诊单逻辑
|
* 中医辨房病因诊单逻辑
|
||||||
@@ -129,7 +142,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
private static function generatePatientId(): int
|
private static function generatePatientId(): int
|
||||||
{
|
{
|
||||||
// 获取当前最大的患者ID
|
// 获取当前最大的患者ID
|
||||||
$maxPatientId = Diagnosis::max('patient_id') ?? 100000;
|
$maxPatientId = Diagnosis::max('id') ?? 10000000;
|
||||||
|
|
||||||
// 返回下一个患者ID
|
// 返回下一个患者ID
|
||||||
return $maxPatientId + 1;
|
return $maxPatientId + 1;
|
||||||
@@ -465,7 +478,79 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)
|
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||||
|
*
|
||||||
|
* @return array{creator_id: int, create_time: int}|null
|
||||||
|
*/
|
||||||
|
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$row = Db::name('tcm_prescription_order')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->order('create_time', 'desc')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->field(['creator_id', 'create_time'])
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($row === null || $row === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
||||||
|
'create_time' => (int) ($row['create_time'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
|
||||||
|
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
|
||||||
|
* - 否则若 from_assistant_id > 0:视为释放/取消前的持有人
|
||||||
|
*/
|
||||||
|
private static function inferPreviousAssistantIdFromLatestAssignLog(int $diagnosisId): int
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$prev = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->field(['from_assistant_id', 'to_assistant_id'])
|
||||||
|
->find();
|
||||||
|
if ($prev === null || $prev === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$pTo = (int) ($prev['to_assistant_id'] ?? 0);
|
||||||
|
$pFrom = (int) ($prev['from_assistant_id'] ?? 0);
|
||||||
|
if ($pTo > 0) {
|
||||||
|
return $pTo;
|
||||||
|
}
|
||||||
|
if ($pFrom > 0) {
|
||||||
|
return $pFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function markAssignRead(int $diagnosisId, int $adminId): void
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $diagnosisId)
|
||||||
|
->where('assistant_id', $adminId)
|
||||||
|
->whereNull('assign_read_at')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->update(['assign_read_at' => time()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。
|
||||||
|
* **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。
|
||||||
* @param array $params
|
* @param array $params
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
@@ -480,33 +565,69 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find();
|
$now = time();
|
||||||
if (!$diagnosis) {
|
$poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id);
|
||||||
self::setError('诊单不存在');
|
$relatedPoCreatorId = 0;
|
||||||
|
$relatedPoCreateTime = 0;
|
||||||
|
if ($poSnap !== null) {
|
||||||
|
$relatedPoCreatorId = $poSnap['creator_id'];
|
||||||
|
$relatedPoCreateTime = $poSnap['create_time'];
|
||||||
|
}
|
||||||
|
if ($relatedPoCreateTime <= 0) {
|
||||||
|
$relatedPoCreateTime = $now;
|
||||||
|
$relatedPoCreatorId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
// 事务内 FOR UPDATE 直读库:确保「原医助」与 tcm_diagnosis.assistant_id 一致,避免模型缓存或并发覆盖
|
||||||
|
$diagLock = Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $id)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->lock(true)
|
||||||
|
->field(['id', 'assistant_id'])
|
||||||
|
->find();
|
||||||
|
if ($diagLock === null || $diagLock === []) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$fromAssistantId = (int) ($diagLock['assistant_id'] ?? 0);
|
||||||
|
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
||||||
|
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $id)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->update([
|
||||||
|
'assistant_id' => $toAssistantId,
|
||||||
|
'assign_read_at' => $toAssistantId > 0 ? null : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = request();
|
||||||
|
$admin = $req->adminInfo ?? [];
|
||||||
|
// Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列
|
||||||
|
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||||
|
'diagnosis_id' => $id,
|
||||||
|
'from_assistant_id' => $fromAssistantId,
|
||||||
|
'to_assistant_id' => $toAssistantId,
|
||||||
|
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
|
||||||
|
'operator_name' => (string) ($admin['name'] ?? ''),
|
||||||
|
'operator_account' => (string) ($admin['account'] ?? ''),
|
||||||
|
'ip' => (string) ($req->ip() ?? ''),
|
||||||
|
'related_po_creator_id' => $relatedPoCreatorId,
|
||||||
|
'related_po_create_time' => $relatedPoCreateTime,
|
||||||
|
'create_time' => $now,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0);
|
|
||||||
|
|
||||||
Db::startTrans();
|
|
||||||
Diagnosis::where('id', $id)->whereNull('delete_time')->update([
|
|
||||||
'assistant_id' => $toAssistantId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$req = request();
|
|
||||||
$admin = $req->adminInfo ?? [];
|
|
||||||
DiagnosisAssignLog::create([
|
|
||||||
'diagnosis_id' => $id,
|
|
||||||
'from_assistant_id' => $fromAssistantId,
|
|
||||||
'to_assistant_id' => $toAssistantId,
|
|
||||||
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
|
|
||||||
'operator_name' => (string) ($admin['name'] ?? ''),
|
|
||||||
'operator_account' => (string) ($admin['account'] ?? ''),
|
|
||||||
'ip' => (string) ($req->ip() ?? ''),
|
|
||||||
'create_time' => time(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -523,24 +644,32 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
*/
|
*/
|
||||||
public static function assignLogList(int $diagnosisId): array
|
public static function assignLogList(int $diagnosisId): array
|
||||||
{
|
{
|
||||||
$rows = DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
|
// Db 直查:避免 AssignLog 模型未维护 schema / fields_strict 时 toArray 丢列,导致前后端「医助」为空
|
||||||
|
$rows = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$adminIds = [];
|
$adminIds = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
if (!empty($r['from_assistant_id'])) {
|
$fid = (int) ($r['from_assistant_id'] ?? 0);
|
||||||
$adminIds[] = (int) $r['from_assistant_id'];
|
$tid = (int) ($r['to_assistant_id'] ?? 0);
|
||||||
|
if ($fid > 0) {
|
||||||
|
$adminIds[] = $fid;
|
||||||
}
|
}
|
||||||
if (!empty($r['to_assistant_id'])) {
|
if ($tid > 0) {
|
||||||
$adminIds[] = (int) $r['to_assistant_id'];
|
$adminIds[] = $tid;
|
||||||
|
}
|
||||||
|
$rcp = (int) ($r['related_po_creator_id'] ?? 0);
|
||||||
|
if ($rcp > 0) {
|
||||||
|
$adminIds[] = $rcp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$adminIds = array_values(array_unique(array_filter($adminIds)));
|
$adminIds = array_values(array_unique(array_filter($adminIds)));
|
||||||
$nameMap = [];
|
$nameMap = [];
|
||||||
if ($adminIds !== []) {
|
if ($adminIds !== []) {
|
||||||
$nameMap = \app\common\model\auth\Admin::whereIn('id', $adminIds)->column('name', 'id');
|
$nameMap = Admin::whereIn('id', $adminIds)->column('name', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rows as &$r) {
|
foreach ($rows as &$r) {
|
||||||
@@ -555,10 +684,18 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$r['create_time_text'] = !empty($r['create_time'])
|
$r['create_time_text'] = !empty($r['create_time'])
|
||||||
? date('Y-m-d H:i:s', (int) $r['create_time'])
|
? date('Y-m-d H:i:s', (int) $r['create_time'])
|
||||||
: '';
|
: '';
|
||||||
|
$rcpId = (int) ($r['related_po_creator_id'] ?? 0);
|
||||||
|
$r['related_po_creator_name'] = $rcpId > 0
|
||||||
|
? (string) ($nameMap[$rcpId] ?? ('ID:' . $rcpId))
|
||||||
|
: '';
|
||||||
|
$rpct = (int) ($r['related_po_create_time'] ?? 0);
|
||||||
|
$r['related_po_create_time_text'] = $rpct > 0 ? date('Y-m-d H:i:s', $rpct) : '';
|
||||||
|
$r['related_po_is_cleared_assistant_creator'] =
|
||||||
|
($rcpId > 0 && $fromId > 0 && $rcpId === $fromId) ? 1 : 0;
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
|
|
||||||
return $rows;
|
return array_values($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -954,10 +1091,29 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
if (empty($doctorAccounts)) {
|
if (empty($doctorAccounts)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
$diagnosisId = (int)($diag['id'] ?? 0);
|
||||||
|
// 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点,
|
||||||
|
// 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额)
|
||||||
|
$minTimeMap = [];
|
||||||
|
if ($diagnosisId > 0) {
|
||||||
|
$rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->group('doctor_peer_account')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$acct = (string)($r['doctor_peer_account'] ?? '');
|
||||||
|
if ($acct !== '') {
|
||||||
|
$minTimeMap[$acct] = (int)($r['max_time'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
$imService = new \app\common\service\TencentImService();
|
$imService = new \app\common\service\TencentImService();
|
||||||
$merged = [];
|
$merged = [];
|
||||||
foreach ($doctorAccounts as $docAccount) {
|
foreach ($doctorAccounts as $docAccount) {
|
||||||
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
|
// 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重)
|
||||||
|
$minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0;
|
||||||
|
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime);
|
||||||
foreach ($batch as $row) {
|
foreach ($batch as $row) {
|
||||||
$merged[] = $row;
|
$merged[] = $row;
|
||||||
}
|
}
|
||||||
@@ -1075,23 +1231,44 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer): array
|
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer, int $minTime = 0): array
|
||||||
{
|
{
|
||||||
$out = [];
|
$out = [];
|
||||||
$lastKey = null;
|
$lastKey = null;
|
||||||
$lastTime = null;
|
$lastTime = null;
|
||||||
$guard = 0;
|
$guard = 0;
|
||||||
|
$maxPages = 80;
|
||||||
do {
|
do {
|
||||||
$res = $svc->adminGetRoamMsg($operator, $peer, 100, 0, 4294967295, $lastKey, $lastTime);
|
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
|
||||||
if (!$res['success']) {
|
$res = null;
|
||||||
if (!empty($res['error'])) {
|
$attempt = 0;
|
||||||
\think\facade\Log::warning('IM漫游消息拉取失败', [
|
while ($attempt < 3) {
|
||||||
'operator' => $operator,
|
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
|
||||||
'peer' => $peer,
|
if (!empty($res['success'])) {
|
||||||
'error' => $res['error'],
|
break;
|
||||||
'code' => $res['rawErrorCode'] ?? 0,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
$attempt++;
|
||||||
|
\think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [
|
||||||
|
'operator' => $operator,
|
||||||
|
'peer' => $peer,
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'error' => $res['error'] ?? '',
|
||||||
|
'code' => $res['rawErrorCode'] ?? 0,
|
||||||
|
'page' => $guard + 1,
|
||||||
|
]);
|
||||||
|
if ($attempt < 3) {
|
||||||
|
usleep(300000); // 300ms 退避
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($res['success'])) {
|
||||||
|
\think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [
|
||||||
|
'operator' => $operator,
|
||||||
|
'peer' => $peer,
|
||||||
|
'page' => $guard + 1,
|
||||||
|
'fetched_so_far' => count($out),
|
||||||
|
'error' => $res['error'] ?? '',
|
||||||
|
'code' => $res['rawErrorCode'] ?? 0,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
foreach ($res['msgList'] as $raw) {
|
foreach ($res['msgList'] as $raw) {
|
||||||
@@ -1112,7 +1289,16 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$guard++;
|
$guard++;
|
||||||
if ($guard > 80) {
|
if ($guard >= $maxPages) {
|
||||||
|
// 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多
|
||||||
|
\think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [
|
||||||
|
'operator' => $operator,
|
||||||
|
'peer' => $peer,
|
||||||
|
'min_time' => $minTime,
|
||||||
|
'pages_fetched' => $guard,
|
||||||
|
'fetched_so_far' => count($out),
|
||||||
|
'last_msg_time' => $lastTime,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} while (true);
|
} while (true);
|
||||||
@@ -1711,22 +1897,14 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||||
$adminId = (int)($params['admin_id'] ?? 0);
|
$adminId = (int)($params['admin_id'] ?? 0);
|
||||||
$fileUrl = trim((string)($params['file_url'] ?? ''));
|
$fileUrl = trim((string)($params['file_url'] ?? ''));
|
||||||
|
$callRecordId = (int)($params['call_record_id'] ?? 0);
|
||||||
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
|
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
|
||||||
self::setError('参数错误');
|
self::setError('参数错误');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
|
||||||
->where('caller_id', $adminId)
|
|
||||||
->order('id', 'desc')
|
|
||||||
->find();
|
|
||||||
// 与 startCall 的 caller_id 不一致或竞态时,回退到该诊单最新一条通话记录
|
|
||||||
if (!$record) {
|
|
||||||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
|
||||||
->order('id', 'desc')
|
|
||||||
->find();
|
|
||||||
}
|
|
||||||
if (!$record) {
|
if (!$record) {
|
||||||
self::setError('未找到通话记录');
|
self::setError('未找到通话记录');
|
||||||
|
|
||||||
@@ -1756,6 +1934,158 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
|
||||||
|
* @param array $params
|
||||||
|
* @return array|false
|
||||||
|
*/
|
||||||
|
public static function uploadCallRecording(array $params)
|
||||||
|
{
|
||||||
|
$uploadDir = '';
|
||||||
|
$mergedPath = '';
|
||||||
|
try {
|
||||||
|
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||||
|
$callRecordId = (int)($params['call_record_id'] ?? 0);
|
||||||
|
$adminId = (int)($params['admin_id'] ?? 0);
|
||||||
|
$uploadId = trim((string)($params['upload_id'] ?? ''));
|
||||||
|
$fileName = trim((string)($params['file_name'] ?? ''));
|
||||||
|
$fileSize = (int)($params['file_size'] ?? 0);
|
||||||
|
$chunkIndex = (int)($params['chunk_index'] ?? -1);
|
||||||
|
$chunkTotal = (int)($params['chunk_total'] ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$diagnosisId <= 0 ||
|
||||||
|
$adminId <= 0 ||
|
||||||
|
$uploadId === '' ||
|
||||||
|
$fileName === '' ||
|
||||||
|
$fileSize <= 0 ||
|
||||||
|
$chunkIndex < 0 ||
|
||||||
|
$chunkTotal <= 0
|
||||||
|
) {
|
||||||
|
self::setError('上传参数不完整');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
|
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
|
||||||
|
self::setError('视频格式不支持');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($callRecordId <= 0) {
|
||||||
|
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
|
||||||
|
$callRecordId = (int)($record['id'] ?? 0);
|
||||||
|
} else {
|
||||||
|
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId, false);
|
||||||
|
}
|
||||||
|
if (!$record) {
|
||||||
|
self::setError('通话记录不存在');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunkFile = request()->file('file');
|
||||||
|
if (!$chunkFile) {
|
||||||
|
self::setError('未找到上传分片');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = self::callRecordingChunkDir($uploadId);
|
||||||
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true) && !is_dir($uploadDir)) {
|
||||||
|
self::setError('创建上传目录失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($uploadDir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'call_record_id' => $callRecordId,
|
||||||
|
'admin_id' => $adminId,
|
||||||
|
'file_name' => $fileName,
|
||||||
|
'file_size' => $fileSize,
|
||||||
|
'chunk_total' => $chunkTotal,
|
||||||
|
], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
|
||||||
|
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
|
||||||
|
if (!$moved) {
|
||||||
|
self::setError($chunkFile->getError() ?: '保存分片失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadedCount = self::countUploadedCallRecordingChunks($uploadDir);
|
||||||
|
if ($uploadedCount < $chunkTotal) {
|
||||||
|
return [
|
||||||
|
'completed' => false,
|
||||||
|
'uploaded_chunks' => $uploadedCount,
|
||||||
|
'chunk_total' => $chunkTotal,
|
||||||
|
'call_record_id' => $callRecordId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergedPath = $uploadDir . DIRECTORY_SEPARATOR . 'merged.' . $ext;
|
||||||
|
self::mergeCallRecordingChunks($uploadDir, $mergedPath, $chunkTotal);
|
||||||
|
|
||||||
|
if (!is_file($mergedPath) || filesize($mergedPath) <= 0) {
|
||||||
|
self::setError('合并后的文件无效');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
|
||||||
|
if (empty($uploadResult['uri'])) {
|
||||||
|
self::setError('保存视频失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attached = self::attachLocalCallRecording([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'call_record_id' => $callRecordId,
|
||||||
|
'admin_id' => $adminId,
|
||||||
|
'file_url' => (string)$uploadResult['uri'],
|
||||||
|
]);
|
||||||
|
if (!$attached) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::cleanupCallRecordingChunkDir($uploadDir);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'completed' => true,
|
||||||
|
'uploaded_chunks' => $chunkTotal,
|
||||||
|
'chunk_total' => $chunkTotal,
|
||||||
|
'call_record_id' => $callRecordId,
|
||||||
|
'file_id' => $uploadResult['id'] ?? 0,
|
||||||
|
'file_url' => $uploadResult['uri'] ?? '',
|
||||||
|
'recording_status' => 2,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($mergedPath !== '' && is_file($mergedPath)) {
|
||||||
|
@unlink($mergedPath);
|
||||||
|
}
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createManualCallRecord(array $params)
|
||||||
|
{
|
||||||
|
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||||
|
$adminId = (int)($params['admin_id'] ?? 0);
|
||||||
|
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||||||
|
self::setError('参数错误');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, 'manual-upload');
|
||||||
|
if (!$record) {
|
||||||
|
self::setError(self::getError() ?: '创建通话记录失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)($record['id'] ?? 0),
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
|
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
|
||||||
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
|
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
|
||||||
@@ -3324,4 +3654,418 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
|
|
||||||
return $logs;
|
return $logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 二诊只读病例详情
|
||||||
|
*
|
||||||
|
* 用途:医助从诊单列表「查看 / 双击」进入只读页(T1+T2),返回与接诊台同结构的
|
||||||
|
* 三块内容(appointment + diagnosis + 跟踪记录)。
|
||||||
|
*
|
||||||
|
* 数据权限(与列表 lists 接口一致):
|
||||||
|
* - 医助 (role_id=2):仅能访问 assistant_id == self 的诊单
|
||||||
|
* - DataScope 启用:assistant_id 须落在 visibleAdminIds
|
||||||
|
* - 越权 → setError + 返回 []
|
||||||
|
*
|
||||||
|
* @param array $params 至少含 id
|
||||||
|
* @param int $adminId
|
||||||
|
* @param array<string,mixed> $adminInfo request->adminInfo
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function readonlyDetail(array $params, int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
$diagnosisId = (int) ($params['id'] ?? 0);
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
self::setError('诊单 id 不能为空');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
|
||||||
|
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
|
||||||
|
|
||||||
|
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||||
|
if (in_array(2, $roleIds, true)) {
|
||||||
|
// 医助仅看自己被指派的
|
||||||
|
$accessQuery->where('assistant_id', $adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataScopeService::isEnabled()) {
|
||||||
|
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||||
|
if ($visibleIds === []) {
|
||||||
|
self::setError('诊单不存在或无权访问');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (is_array($visibleIds)) {
|
||||||
|
$accessQuery->whereIn('assistant_id', $visibleIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$accessQuery->find()) {
|
||||||
|
self::setError('诊单不存在或无权访问');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 诊单详情(含图片聚合等)+ 字典翻译
|
||||||
|
$diagnosis = self::detail(['id' => $diagnosisId]);
|
||||||
|
if (empty($diagnosis)) {
|
||||||
|
self::setError('诊单数据为空');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$diagnosis = AppointmentLogic::enrichDiagnosisLabels($diagnosis);
|
||||||
|
|
||||||
|
// 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象)
|
||||||
|
$appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis);
|
||||||
|
|
||||||
|
// 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
|
||||||
|
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
|
||||||
|
|
||||||
|
// 5) 医生备注
|
||||||
|
$doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId);
|
||||||
|
|
||||||
|
// 5.1) 跟踪备注(只读展示,按 note_date DESC)
|
||||||
|
$trackingNotes = TrackingNoteLogic::getByDiagnosis($diagnosisId);
|
||||||
|
|
||||||
|
// 6) 未服务天数(与 T3 列同口径)
|
||||||
|
$maxBloodTs = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||||
|
->max('record_date');
|
||||||
|
$maxBloodTs = (int) $maxBloodTs;
|
||||||
|
$unservedDays = $maxBloodTs > 0 ? max(0, (int) floor((time() - $maxBloodTs) / 86400)) : null;
|
||||||
|
$lastBloodRecordAt = $maxBloodTs > 0 ? date('Y-m-d', $maxBloodTs) : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'appointment' => $appointment,
|
||||||
|
'diagnosis' => $diagnosis,
|
||||||
|
'doctor_notes' => $doctorNotes,
|
||||||
|
'tracking_notes' => $trackingNotes,
|
||||||
|
'unserved_days' => $unservedDays,
|
||||||
|
'last_blood_record_at' => $lastBloodRecordAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与
|
||||||
|
* 医生接诊台 reception 通过独立接口 lazy load。
|
||||||
|
*
|
||||||
|
* 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* blood_records: array<int,array<string,mixed>>,
|
||||||
|
* diet_records: array<int,array<string,mixed>>,
|
||||||
|
* exercise_records: array<int,array<string,mixed>>,
|
||||||
|
* start_date: string,
|
||||||
|
* end_date: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function fetchTrackingWindow(int $diagnosisId, string $startDate = '', string $endDate = ''): array
|
||||||
|
{
|
||||||
|
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
|
||||||
|
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
|
||||||
|
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
|
||||||
|
$untilTs = $untilTs > 0 ? $untilTs : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼一份「最近一条挂号」给只读页 PatientInfoCard 用;没有挂号则给默认空骨架。
|
||||||
|
*
|
||||||
|
* @param int $diagnosisId
|
||||||
|
* @param array<string,mixed> $diagnosis 已字典翻译过的诊单数据
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function buildLatestAppointmentForReadonly(int $diagnosisId, array $diagnosis): array
|
||||||
|
{
|
||||||
|
$apt = Appointment::where('patient_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
$patientName = (string) ($diagnosis['patient_name'] ?? '');
|
||||||
|
$patientPhone = (string) ($diagnosis['phone'] ?? '');
|
||||||
|
|
||||||
|
if (!$apt) {
|
||||||
|
return [
|
||||||
|
'id' => 0,
|
||||||
|
'patient_id' => $diagnosisId,
|
||||||
|
'patient_name' => $patientName,
|
||||||
|
'patient_phone' => $patientPhone,
|
||||||
|
'doctor_id' => 0,
|
||||||
|
'doctor_name' => '',
|
||||||
|
'assistant_id' => (int) ($diagnosis['assistant_id'] ?? 0),
|
||||||
|
'assistant_name' => self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)),
|
||||||
|
'appointment_date' => '',
|
||||||
|
'appointment_time' => '',
|
||||||
|
'period' => '',
|
||||||
|
'status' => 0,
|
||||||
|
'status_desc' => '无挂号',
|
||||||
|
'has_prescription' => 0,
|
||||||
|
'remark' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aptArr = $apt->toArray();
|
||||||
|
$aptArr['patient_name'] = $patientName;
|
||||||
|
$aptArr['patient_phone'] = $patientPhone;
|
||||||
|
// 医生姓名
|
||||||
|
$aptArr['doctor_name'] = '';
|
||||||
|
$docId = (int) ($aptArr['doctor_id'] ?? 0);
|
||||||
|
if ($docId > 0) {
|
||||||
|
$aptArr['doctor_name'] = (string) Admin::where('id', $docId)->value('name');
|
||||||
|
}
|
||||||
|
$aptArr['assistant_id'] = (int) ($diagnosis['assistant_id'] ?? 0);
|
||||||
|
$aptArr['assistant_name'] = self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0));
|
||||||
|
// status_desc 与现有列表口径保持简单映射
|
||||||
|
$statusMap = [
|
||||||
|
0 => '未挂号',
|
||||||
|
1 => '已预约',
|
||||||
|
2 => '已取消',
|
||||||
|
3 => '已完成',
|
||||||
|
4 => '已过号',
|
||||||
|
];
|
||||||
|
$aptArr['status_desc'] = $statusMap[(int) ($aptArr['status'] ?? 0)] ?? '';
|
||||||
|
$aptArr['has_prescription'] = (int) ($diagnosis['has_prescription'] ?? 0);
|
||||||
|
|
||||||
|
return $aptArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveAssistantName(int $assistantId): string
|
||||||
|
{
|
||||||
|
if ($assistantId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) Admin::where('id', $assistantId)->value('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = BloodRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')
|
||||||
|
->order('record_time', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 给前端一个 Y-m-d 字符串方便按日期透视;保留原 record_date 整数戳作日期窗口比较
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = DietRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')->select()->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = ExerciseRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')
|
||||||
|
->append(['intensity_text'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveCallRecordForAttachment(
|
||||||
|
int $diagnosisId,
|
||||||
|
int $adminId,
|
||||||
|
int $callRecordId = 0,
|
||||||
|
bool $allowFallback = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if ($callRecordId > 0) {
|
||||||
|
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->find();
|
||||||
|
if ($record) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
if (!$allowFallback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||||||
|
->where('caller_id', $adminId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if ($record || !$allowFallback) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createSyntheticCallRecord(int $diagnosisId, int $adminId, string $fileName = '')
|
||||||
|
{
|
||||||
|
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
|
||||||
|
if (!$diagnosis) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$roomSeed = substr(md5($diagnosisId . '-' . $adminId . '-' . $fileName . '-' . $now), 0, 8);
|
||||||
|
|
||||||
|
return \app\common\model\tcm\CallRecord::create([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'caller_id' => $adminId,
|
||||||
|
'caller_type' => 'doctor',
|
||||||
|
'callee_id' => (int)($diagnosis['patient_id'] ?? 0),
|
||||||
|
'callee_type' => 'patient',
|
||||||
|
'call_type' => 2,
|
||||||
|
'status' => 2,
|
||||||
|
'start_time' => $now,
|
||||||
|
'end_time' => $now,
|
||||||
|
'duration' => 0,
|
||||||
|
'room_id' => 'manual_upload_' . $roomSeed,
|
||||||
|
'recording_status' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function callRecordingChunkDir(string $uploadId): string
|
||||||
|
{
|
||||||
|
$safeId = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadId);
|
||||||
|
return runtime_path() . 'call_recording_chunks' . DIRECTORY_SEPARATOR . $safeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function callRecordingChunkName(int $chunkIndex): string
|
||||||
|
{
|
||||||
|
return sprintf('chunk_%06d.part', $chunkIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function countUploadedCallRecordingChunks(string $uploadDir): int
|
||||||
|
{
|
||||||
|
$files = glob($uploadDir . DIRECTORY_SEPARATOR . 'chunk_*.part');
|
||||||
|
return is_array($files) ? count($files) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mergeCallRecordingChunks(string $uploadDir, string $mergedPath, int $chunkTotal): void
|
||||||
|
{
|
||||||
|
$out = fopen($mergedPath, 'wb');
|
||||||
|
if ($out === false) {
|
||||||
|
throw new \RuntimeException('创建合并文件失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ($i = 0; $i < $chunkTotal; $i++) {
|
||||||
|
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($i);
|
||||||
|
if (!is_file($chunkPath)) {
|
||||||
|
throw new \RuntimeException('上传分片缺失,无法合并');
|
||||||
|
}
|
||||||
|
$in = fopen($chunkPath, 'rb');
|
||||||
|
if ($in === false) {
|
||||||
|
throw new \RuntimeException('读取上传分片失败');
|
||||||
|
}
|
||||||
|
stream_copy_to_stream($in, $out);
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'default' => ConfigService::get('storage', 'default', 'local'),
|
||||||
|
'engine' => ConfigService::get('storage') ?? ['local' => []],
|
||||||
|
];
|
||||||
|
|
||||||
|
$storageDriver = new StorageDriver($config);
|
||||||
|
$storageDriver->setUploadFileByReal($mergedPath);
|
||||||
|
$saveDir = 'uploads/video/' . date('Ymd');
|
||||||
|
if (!$storageDriver->upload($saveDir)) {
|
||||||
|
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
|
||||||
|
$storedFile = FileModel::create([
|
||||||
|
'cid' => 0,
|
||||||
|
'type' => FileEnum::VIDEO_TYPE,
|
||||||
|
'name' => mb_substr($fileName, 0, 128),
|
||||||
|
'uri' => $relativePath,
|
||||||
|
'source' => FileEnum::SOURCE_ADMIN,
|
||||||
|
'source_id' => $adminId,
|
||||||
|
'create_time' => time(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)($storedFile['id'] ?? 0),
|
||||||
|
'uri' => FileService::getFileUrl($relativePath),
|
||||||
|
'url' => $relativePath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cleanupCallRecordingChunkDir(string $uploadDir): void
|
||||||
|
{
|
||||||
|
if ($uploadDir === '' || !is_dir($uploadDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ((array)glob($uploadDir . DIRECTORY_SEPARATOR . '*') as $item) {
|
||||||
|
if (is_file($item)) {
|
||||||
|
@unlink($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($uploadDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项逻辑
|
||||||
|
*
|
||||||
|
* 状态机:
|
||||||
|
* add() → status=0 (待执行)
|
||||||
|
* cron → status=1 (已发送) / status=3 (失败)
|
||||||
|
* cancel() → status=2 (已取消,仅 status=0 可取消)
|
||||||
|
*
|
||||||
|
* 鉴权:
|
||||||
|
* add:诊单需存在;数据权限交由路由层统一「tcm.diagnosis/dailyRecord」权限域控制,
|
||||||
|
* 并兼容历史 diagnosisTodo/* 接口路由。
|
||||||
|
* cancel:仅创建人本人或超级管理员 (role_id=1) 可取消,且仅 status=0 时。
|
||||||
|
*
|
||||||
|
* @package app\adminapi\logic\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @notes 新增待办
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params 已通过 sceneAdd 验证
|
||||||
|
* @param int $adminId 当前 admin id
|
||||||
|
* @param array<string,mixed> $adminInfo request->adminInfo
|
||||||
|
*/
|
||||||
|
public static function add(array $params, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||||
|
$diagnosis = Diagnosis::findOrEmpty($diagnosisId);
|
||||||
|
if ($diagnosis->isEmpty()) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remindTime = (int) ($params['remind_time'] ?? 0);
|
||||||
|
if ($remindTime <= time()) {
|
||||||
|
self::setError('提醒时间必须晚于当前时间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creatorName = trim((string) ($adminInfo['name'] ?? ''));
|
||||||
|
if ($creatorName === '' && $adminId > 0) {
|
||||||
|
$creatorName = (string) Admin::where('id', $adminId)->value('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosisTodo::create([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'patient_id' => (int) ($diagnosis->getAttr('patient_id') ?? 0),
|
||||||
|
'content' => trim((string) ($params['content'] ?? '')),
|
||||||
|
'remind_time' => $remindTime,
|
||||||
|
'status' => DiagnosisTodo::STATUS_PENDING,
|
||||||
|
'creator_id' => $adminId,
|
||||||
|
'creator_name' => $creatorName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 取消待办(人工)
|
||||||
|
*/
|
||||||
|
public static function cancel(int $id, int $adminId): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$todo = DiagnosisTodo::findOrEmpty($id);
|
||||||
|
if ($todo->isEmpty()) {
|
||||||
|
self::setError('待办不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) $todo->getAttr('status');
|
||||||
|
if ($status !== DiagnosisTodo::STATUS_PENDING) {
|
||||||
|
self::setError('该待办已不是「待执行」状态,无法取消');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creatorId = (int) $todo->getAttr('creator_id');
|
||||||
|
$isSuper = self::isSuperAdmin($adminId);
|
||||||
|
if ($creatorId !== $adminId && !$isSuper) {
|
||||||
|
self::setError('仅创建人或超级管理员可取消');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todo->save([
|
||||||
|
'status' => DiagnosisTodo::STATUS_CANCELLED,
|
||||||
|
'cancelled_at' => time(),
|
||||||
|
'cancelled_by' => $adminId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 详情
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function detail(int $id, int $adminId = 0): array
|
||||||
|
{
|
||||||
|
$todo = DiagnosisTodo::findOrEmpty($id);
|
||||||
|
if ($todo->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$arr = $todo->append([
|
||||||
|
'status_text',
|
||||||
|
'remind_time_text',
|
||||||
|
'notified_at_text',
|
||||||
|
'cancelled_at_text',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
// 业务态:是否能被「我」取消(前端按钮显隐用)
|
||||||
|
$arr['can_cancel'] = self::canCancel($todo->toArray(), $adminId);
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否超管:role_id=1
|
||||||
|
*/
|
||||||
|
public static function isSuperAdmin(int $adminId): bool
|
||||||
|
{
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||||||
|
|
||||||
|
return in_array(1, array_map('intval', $roleIds), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务判定:当前 admin 能否取消该待办
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $todoRow
|
||||||
|
*/
|
||||||
|
public static function canCancel(array $todoRow, int $adminId): bool
|
||||||
|
{
|
||||||
|
if ((int) ($todoRow['status'] ?? -1) !== DiagnosisTodo::STATUS_PENDING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((int) ($todoRow['creator_id'] ?? 0) === $adminId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isSuperAdmin($adminId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use app\common\model\tcm\Prescription;
|
|||||||
use app\common\model\tcm\PrescriptionOrder;
|
use app\common\model\tcm\PrescriptionOrder;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
|
use app\adminapi\logic\auth\AuthLogic;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
|
|
||||||
@@ -122,6 +123,12 @@ class PrescriptionLogic
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有业务订单详情权限的用户可以查看关联处方(只读)
|
||||||
|
$perms = AuthLogic::getAuthByAdminId($adminId);
|
||||||
|
if (in_array('tcm.prescriptionOrder/detail', $perms, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +159,19 @@ class PrescriptionLogic
|
|||||||
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizeDosageBagCount($raw): int
|
||||||
|
{
|
||||||
|
$count = (int) $raw;
|
||||||
|
if ($count < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($count > 5) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同一诊单、同一开方人、同一处方日期:仅允许一张「未作废」的未删除记录;已作废的可再新开一张。
|
* 同一诊单、同一开方人、同一处方日期:仅允许一张「未作废」的未删除记录;已作废的可再新开一张。
|
||||||
*
|
*
|
||||||
@@ -230,6 +250,7 @@ class PrescriptionLogic
|
|||||||
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
|
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
|
||||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||||
|
'dosage_bag_count' => self::normalizeDosageBagCount($params['dosage_bag_count'] ?? 1),
|
||||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||||
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : 1,
|
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : 1,
|
||||||
'diagnosis_id' => (int)($params['diagnosis_id'] ?? 0),
|
'diagnosis_id' => (int)($params['diagnosis_id'] ?? 0),
|
||||||
@@ -359,6 +380,7 @@ class PrescriptionLogic
|
|||||||
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
||||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||||
|
'dosage_bag_count' => self::normalizeDosageBagCount($params['dosage_bag_count'] ?? ($prescription->dosage_bag_count ?? 1)),
|
||||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||||
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : (int)($prescription->bags_per_dose ?? 1),
|
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : (int)($prescription->bags_per_dose ?? 1),
|
||||||
'patient_name' => $params['patient_name'] ?? $prescription->patient_name,
|
'patient_name' => $params['patient_name'] ?? $prescription->patient_name,
|
||||||
@@ -751,6 +773,7 @@ class PrescriptionLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$diagnosisId = (int) ($appointment->patient_id ?? 0);
|
$diagnosisId = (int) ($appointment->patient_id ?? 0);
|
||||||
|
|
||||||
if ($diagnosisId <= 0) {
|
if ($diagnosisId <= 0) {
|
||||||
Log::info('createSystemAutoFromCompletedAppointment: no diagnosis (patient_id), skip appt=' . $appointmentId);
|
Log::info('createSystemAutoFromCompletedAppointment: no diagnosis (patient_id), skip appt=' . $appointmentId);
|
||||||
|
|
||||||
@@ -804,6 +827,18 @@ class PrescriptionLogic
|
|||||||
$visitNo = '1K' . str_pad((string) $diagnosisId, 8, '0', STR_PAD_LEFT);
|
$visitNo = '1K' . str_pad((string) $diagnosisId, 8, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
$herbs = [];
|
$herbs = [];
|
||||||
|
|
||||||
|
/** 与手动开方一致:存一份当前诊单详情快照(/admin 开方时用 tcmDiagnosisDetail → 同源 DiagnosisLogic::detail) */
|
||||||
|
$caseSnapshot = [];
|
||||||
|
try {
|
||||||
|
$snap = DiagnosisLogic::detail(['id' => $diagnosisId]);
|
||||||
|
$caseSnapshot = is_array($snap) ? $snap : [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning(
|
||||||
|
'createSystemAutoFromCompletedAppointment: case_record snapshot failed diagnosis='
|
||||||
|
. $diagnosisId . ' ' . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'sn' => $sn,
|
'sn' => $sn,
|
||||||
@@ -811,6 +846,7 @@ class PrescriptionLogic
|
|||||||
'prescription_type' => '浓缩水丸',
|
'prescription_type' => '浓缩水丸',
|
||||||
'dosage_amount' => 1.0,
|
'dosage_amount' => 1.0,
|
||||||
'dosage_unit' => 'g',
|
'dosage_unit' => 'g',
|
||||||
|
'dosage_bag_count' => 1,
|
||||||
'need_decoction' => 0,
|
'need_decoction' => 0,
|
||||||
'bags_per_dose' => 1,
|
'bags_per_dose' => 1,
|
||||||
'diagnosis_id' => $diagnosisId,
|
'diagnosis_id' => $diagnosisId,
|
||||||
@@ -827,7 +863,7 @@ class PrescriptionLogic
|
|||||||
'tongue' => (string) ($diagnosis->tongue ?? ''),
|
'tongue' => (string) ($diagnosis->tongue ?? ''),
|
||||||
'tongue_image' => '',
|
'tongue_image' => '',
|
||||||
'clinical_diagnosis' => $clinical,
|
'clinical_diagnosis' => $clinical,
|
||||||
'case_record' => [],
|
'case_record' => $caseSnapshot,
|
||||||
'herbs' => $herbs,
|
'herbs' => $herbs,
|
||||||
'dose_count' => 1,
|
'dose_count' => 1,
|
||||||
'dose_unit' => '剂',
|
'dose_unit' => '剂',
|
||||||
@@ -853,7 +889,7 @@ class PrescriptionLogic
|
|||||||
'creator_id' => $doctorId,
|
'creator_id' => $doctorId,
|
||||||
'assistant_id' => $assistantIdForRx,
|
'assistant_id' => $assistantIdForRx,
|
||||||
];
|
];
|
||||||
|
|
||||||
$prescription = new Prescription();
|
$prescription = new Prescription();
|
||||||
$prescription->save($data);
|
$prescription->save($data);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\tcm\TrackingNote;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单跟踪备注 Logic
|
||||||
|
*
|
||||||
|
* - 按 diagnosis_id + 当天 find-or-create
|
||||||
|
* - 多次追加 content(换行分隔,带 [HH:MM] 前缀)
|
||||||
|
* - 不涉及附件(与医生备注 DoctorNoteLogic 不同)
|
||||||
|
*/
|
||||||
|
class TrackingNoteLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 追加跟踪备注(按天合并)
|
||||||
|
*
|
||||||
|
* @param array $params diagnosis_id (int)、admin_id (int)、content (string)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function addOrAppend(array $params): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||||
|
$adminId = (int) ($params['admin_id'] ?? 0);
|
||||||
|
$newContent = trim((string) ($params['content'] ?? ''));
|
||||||
|
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
self::setError('诊单ID缺失');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($newContent === '') {
|
||||||
|
self::setError('备注内容不能为空');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$time = date('H:i');
|
||||||
|
$line = "[{$time}] {$newContent}";
|
||||||
|
|
||||||
|
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
|
||||||
|
->where('note_date', $today)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$prev = trim((string) ($existing->content ?? ''));
|
||||||
|
$existing->content = $prev !== '' ? ($prev . "\n" . $line) : $line;
|
||||||
|
$existing->save();
|
||||||
|
} else {
|
||||||
|
TrackingNote::create([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'admin_id' => $adminId,
|
||||||
|
'note_date' => $today,
|
||||||
|
'content' => $line,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 diagnosis_id 获取跟踪备注列表(note_date DESC)
|
||||||
|
*
|
||||||
|
* @param int $diagnosisId
|
||||||
|
* @param int $limit 最近 N 天
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getByDiagnosis(int $diagnosisId, int $limit = 60): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = TrackingNote::where('diagnosis_id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->order('note_date', 'desc')
|
||||||
|
->limit($limit)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ use app\common\cache\AdminAccountSafeCache;
|
|||||||
use app\common\service\ConfigService;
|
use app\common\service\ConfigService;
|
||||||
use app\common\validate\BaseValidate;
|
use app\common\validate\BaseValidate;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录验证
|
* 登录验证
|
||||||
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
|
|||||||
*/
|
*/
|
||||||
public function password($password, $other, $data)
|
public function password($password, $other, $data)
|
||||||
{
|
{
|
||||||
|
// 本地调试:免密登录开关。须同时满足 APP_DEBUG=1 + LOCAL_DEBUG.LOGIN_SKIP_PASSWORD=true,
|
||||||
|
// 任一不满足都走原始密码校验。每次免密登录都会记一条 warning 日志,便于发现误开。
|
||||||
|
if (self::isLocalDebugLoginBypassEnabled()) {
|
||||||
|
$adminInfo = Admin::where('account', '=', $data['account'])
|
||||||
|
->field(['password,disable'])
|
||||||
|
->findOrEmpty();
|
||||||
|
if ($adminInfo->isEmpty()) {
|
||||||
|
return '账号不存在';
|
||||||
|
}
|
||||||
|
if ((int)($adminInfo['disable'] ?? 0) === 1) {
|
||||||
|
return '账号已禁用';
|
||||||
|
}
|
||||||
|
Log::warning(sprintf(
|
||||||
|
'[LOCAL_DEBUG] login bypass password: account=%s, ip=%s',
|
||||||
|
(string)($data['account'] ?? ''),
|
||||||
|
(string)request()->ip()
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 登录限制
|
// 登录限制
|
||||||
$config = [
|
$config = [
|
||||||
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
|
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
|
||||||
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用"本地调试免密登录"。
|
||||||
|
*
|
||||||
|
* 双重闸门:
|
||||||
|
* 1. APP_DEBUG 必须为 true(即应用本身处于调试模式)
|
||||||
|
* 2. LOCAL_DEBUG.LOGIN_SKIP_PASSWORD 必须显式为 true / 1 / yes / on
|
||||||
|
*
|
||||||
|
* 任何一条不满足都返回 false,强制走原始密码校验。这样即使误把
|
||||||
|
* LOGIN_SKIP_PASSWORD 配进生产,只要 APP_DEBUG=0 仍然安全。
|
||||||
|
*/
|
||||||
|
private static function isLocalDebugLoginBypassEnabled(): bool
|
||||||
|
{
|
||||||
|
$appDebug = strtolower((string) env('app_debug', '0'));
|
||||||
|
if (!in_array($appDebug, ['1', 'true', 'yes', 'on'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$flag = strtolower((string) env('local_debug.login_skip_password', ''));
|
||||||
|
|
||||||
|
return in_array($flag, ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user