Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1156787327 | ||
|
|
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(python3 -c ' *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
|
||||
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)",
|
||||
"Bash(npx eslint *)",
|
||||
"Bash(git *)",
|
||||
"Bash(tail -c 100000 /Users/long/.claude/projects/-Users-long-Work-zyt/7be9f770-afc3-497d-87a3-2ffa1fbe3fd1.jsonl)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(npx --no-install vue-tsc --noEmit)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,11 @@ bin-release/
|
||||
/.shared
|
||||
/.cursor
|
||||
/.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.
|
||||
|
||||
Use the `/trellis:start` command when starting a new session to:
|
||||
- Initialize your developer identity
|
||||
- Understand current project context
|
||||
- Read relevant guidelines
|
||||
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
|
||||
|
||||
Use `@/.trellis/` to learn:
|
||||
- Development workflow (`workflow.md`)
|
||||
- Project structure guidelines (`spec/`)
|
||||
- Developer workspace (`workspace/`)
|
||||
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
|
||||
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
|
||||
- `.trellis/workspace/` — per-developer journals and session traces
|
||||
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
|
||||
|
||||
If you're using Codex, project-scoped helpers may also live in:
|
||||
- `.agents/skills/` for reusable Trellis skills
|
||||
- `.codex/agents/` for optional custom subagents
|
||||
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
|
||||
|
||||
Keep this managed block so 'trellis update' can refresh the instructions.
|
||||
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
|
||||
- `.agents/skills/` — reusable Trellis skills
|
||||
- `.codex/agents/` — optional custom subagents
|
||||
|
||||
## Subagents
|
||||
|
||||
- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups.
|
||||
- On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time.
|
||||
- On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing.
|
||||
- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken.
|
||||
- Spawn subagents automatically when:
|
||||
- Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan)
|
||||
- Long-running or blocking tasks where a worker can run independently
|
||||
- Isolation for risky changes or checks
|
||||
|
||||
### Codex-only — `spawn_agent` parameters
|
||||
|
||||
When calling `spawn_agent`, ALWAYS pass `fork_turns="none"`. Without it the child inherits the parent transcript and sees your prior `spawn_agent(...)` records, then applies the "wait for spawned subagents" rule to itself — causing `wait_agent` self-deadlock.
|
||||
|
||||
```text
|
||||
spawn_agent(agent_type="trellis-implement", message="...", fork_turns="none")
|
||||
```
|
||||
|
||||
### Codex-only — multi-subagent close-loop
|
||||
|
||||
When `wait` returns a `completed` notification, treat it as an event signal — not as "all done". Run this loop:
|
||||
|
||||
1. Maintain an `expected_agents` set of dispatched sub-agent thread IDs.
|
||||
2. After each `wait` update:
|
||||
1. Call `list_agents` to inspect ALL live agents' status.
|
||||
2. For each agent now in a terminal state:
|
||||
- Verify its promised deliverable exists (e.g. `{task_dir}/research/*.md`).
|
||||
- Read or summarize as needed.
|
||||
- `close_agent` to release the slot.
|
||||
- Remove from `expected_agents`.
|
||||
3. If `expected_agents` still contains running agents → keep waiting.
|
||||
4. If `expected_agents` is empty → continue main flow.
|
||||
3. Never `wait` on an agent that has already reported `completed`.
|
||||
4. If a `completed` agent is missing its deliverable, treat it as failed — surface that in your report instead of re-waiting.
|
||||
|
||||
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
|
||||
|
||||
<!-- TRELLIS:END -->
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
import Avatar from '../Avatar/Avatar.vue';
|
||||
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
|
||||
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
|
||||
@@ -337,6 +337,8 @@ onUnload(() => {
|
||||
const callRole = callParticipantInfo?.value?.selfInfo?.role;
|
||||
|
||||
if (callStatus === CallStatus.IDLE) return;
|
||||
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
|
||||
if (callStatus === CallStatus.CONNECTED) return;
|
||||
if (callStatus === CallStatus.CALLING) {
|
||||
if (callRole === CallRole.CALLER) {
|
||||
hangup();
|
||||
@@ -344,9 +346,6 @@ onUnload(() => {
|
||||
reject();
|
||||
}
|
||||
}
|
||||
if (callStatus === CallStatus.CONNECTED) {
|
||||
hangup();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -277,6 +277,8 @@ const { proxy } = getCurrentInstance()
|
||||
let unsubscribeWxAppShow = null;
|
||||
let allowChatForegroundRelogin = false;
|
||||
let foregroundReloginTimer = null;
|
||||
/** 避免 onUnload 重复上报离开 */
|
||||
let patientLeaveNotified = false;
|
||||
|
||||
const CONFIRMATION_MESSAGE = `我已确认以下信息:
|
||||
1、本人确认已在线下确诊;
|
||||
@@ -611,6 +613,7 @@ const { proxy } = getCurrentInstance()
|
||||
}
|
||||
onLoad(async (options) => {
|
||||
try {
|
||||
patientLeaveNotified = false;
|
||||
console.log('进入onMounted:', options);
|
||||
const app = getApp();
|
||||
const userID = options.userID|| app.globalData.userID;
|
||||
@@ -790,6 +793,11 @@ const { proxy } = getCurrentInstance()
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
|
||||
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
|
||||
patientLeaveNotified = true;
|
||||
void notifyDoctorOnChatClose();
|
||||
}
|
||||
allowChatForegroundRelogin = false;
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof unsubscribeWxAppShow === 'function') {
|
||||
@@ -855,6 +863,47 @@ const { proxy } = getCurrentInstance()
|
||||
console.warn('后端通知失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 离开会话页时通知医生:IM 自定义消息 + 后端全局事件 */
|
||||
async function notifyDoctorOnChatClose() {
|
||||
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
|
||||
const doctorId = targetUserID.replace('doctor_', '');
|
||||
const userData = uni.getStorageSync('userData') || {};
|
||||
const patientName = userData.name || patient_data.value?.patient_name || '患者';
|
||||
const patientId = (currentUserID || '').replace('patient_', '') || '';
|
||||
try {
|
||||
if (chat) {
|
||||
const customPayload = {
|
||||
businessID: 'patient_closed_chat',
|
||||
patientId,
|
||||
patientName,
|
||||
doctorId,
|
||||
time: Date.now()
|
||||
};
|
||||
const imMsg = chat.createCustomMessage({
|
||||
to: targetUserID,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { data: JSON.stringify(customPayload) }
|
||||
});
|
||||
await chat.sendMessage(imMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('IM 离开通知发送失败:', e);
|
||||
}
|
||||
try {
|
||||
await proxy.apiUrl({
|
||||
url: '/api/chat/notifyClose',
|
||||
method: 'POST',
|
||||
data: {
|
||||
doctor_id: doctorId,
|
||||
patient_id: patientId,
|
||||
patient_name: patientName
|
||||
}
|
||||
}, false);
|
||||
} catch (e) {
|
||||
console.warn('后端离开通知失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDoctorEnteredSignalFromRawMessage(msg) {
|
||||
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
|
||||
@@ -956,6 +1005,9 @@ const { proxy } = getCurrentInstance()
|
||||
if (customData.businessID === 'patient_opened_chat') {
|
||||
return null; // 返回null表示不显示此消息
|
||||
}
|
||||
if (customData.businessID === 'patient_closed_chat') {
|
||||
return null;
|
||||
}
|
||||
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
|
||||
if (customData.businessID === 'doctor_entered_consult_room') {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV = 'development'
|
||||
|
||||
# Base API
|
||||
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080;
|
||||
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
|
||||
VITE_APP_BASE_URL=''
|
||||
Generated
+52
@@ -19,10 +19,12 @@
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.9",
|
||||
"cos-js-sdk-v5": "^1.10.1",
|
||||
"css-color-function": "^1.3.3",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -6064,6 +6066,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cos-js-sdk-v5": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz",
|
||||
"integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@@ -7580,6 +7592,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
|
||||
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/naturalintelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^1.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -8231,6 +8265,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
@@ -12299,6 +12339,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
|
||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.9",
|
||||
"cos-js-sdk-v5": "^1.10.1",
|
||||
"css-color-function": "^1.3.3",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
Generated
+9731
File diff suppressed because it is too large
Load Diff
+85
-36
@@ -4,48 +4,50 @@ import useAppStore from '@/stores/modules/app'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import request from '@/utils/request'
|
||||
|
||||
export type MaterialUploadType = 'image' | 'video' | 'file'
|
||||
|
||||
/** 本地上传素材(与 `components/upload` 相同:POST /upload/{type},字段 file + cid) */
|
||||
export async function uploadMaterialFile(
|
||||
file: File,
|
||||
type: MaterialUploadType = 'image',
|
||||
cid: string | number = 0
|
||||
) {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('cid', String(cid))
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/${type}`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
}
|
||||
|
||||
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
|
||||
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, filename)
|
||||
formData.append('cid', '0')
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
const fileObj =
|
||||
file instanceof File
|
||||
? file
|
||||
: new File([file], filename, { type: file.type || 'image/jpeg' })
|
||||
return uploadMaterialFile(fileObj, 'image', 0)
|
||||
}
|
||||
|
||||
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
|
||||
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, filename)
|
||||
formData.append('cid', '0')
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/video`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
const fileObj =
|
||||
file instanceof File
|
||||
? file
|
||||
: new File([file], filename, { type: file.type || 'video/webm' })
|
||||
return uploadMaterialFile(fileObj, 'video', 0)
|
||||
}
|
||||
|
||||
export function fileCateAdd(params: Record<string, any>) {
|
||||
@@ -88,3 +90,50 @@ export function fileMove(params: Record<string, any>) {
|
||||
export function fileRename(params: { id: number; name: string }) {
|
||||
return request.post({ url: '/file/rename', params })
|
||||
}
|
||||
|
||||
/** 浏览器直传 OSS - 凭证类型 */
|
||||
export interface OssCredentialsResponse {
|
||||
provider: string
|
||||
fallback: boolean
|
||||
bucket?: string
|
||||
region?: string
|
||||
host?: string
|
||||
cdn_domain?: string
|
||||
key_prefix?: string
|
||||
max_size?: number
|
||||
duration?: number
|
||||
expired_time?: number
|
||||
start_time?: number
|
||||
credentials?: {
|
||||
tmpSecretId: string
|
||||
tmpSecretKey: string
|
||||
sessionToken: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 申请 STS 临时凭证 */
|
||||
export function getOssCredentials(params: { type: 'video' }) {
|
||||
return request.post({
|
||||
url: '/upload/ossCredentials',
|
||||
params
|
||||
}) as Promise<OssCredentialsResponse>
|
||||
}
|
||||
|
||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||
export function confirmOssUpload(params: {
|
||||
type: 'video'
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
content_type: string
|
||||
cid?: number
|
||||
}) {
|
||||
return request.post({ url: '/upload/ossConfirm', params }) as Promise<{
|
||||
id: number
|
||||
cid: number
|
||||
type: number
|
||||
name: string
|
||||
uri: string
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
|
||||
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||
return request.post({ url: '/order.order/assignAssistant', params })
|
||||
}
|
||||
|
||||
/** 设置/取消支付单豁免权 */
|
||||
export function orderSetExempt(params: { id: number; is_exempt: 0 | 1 }) {
|
||||
return request.post({ url: '/order.order/setExempt', params })
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function deptDetail(params: any) {
|
||||
return request.get({ url: '/dept.dept/detail', params })
|
||||
}
|
||||
|
||||
// 部门列表全部
|
||||
export function deptAll() {
|
||||
return request.get({ url: '/dept.dept/all' })
|
||||
// 部门列表全部;apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致)
|
||||
export function deptAll(params?: { apply_data_scope?: number }) {
|
||||
return request.get({ url: '/dept.dept/all', params })
|
||||
}
|
||||
|
||||
@@ -42,3 +42,64 @@ export function yejiStatsLeaderboard(params: {
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leaderboard', params })
|
||||
}
|
||||
|
||||
/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */
|
||||
export function yejiStatsUnassignedBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
|
||||
}
|
||||
|
||||
/** 部门行「进线数据」逐条明细(与看板同口径) */
|
||||
export function yejiStatsLeadLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leadLines', params })
|
||||
}
|
||||
|
||||
/** 医助排行榜「预约诊单」逐条挂号明细;业绩主表部门行传 dept_id(可与 assistant_id 二选一) */
|
||||
export function yejiStatsAppointmentLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/appointmentLines', params })
|
||||
}
|
||||
|
||||
/** 二中心复诊:部门行下钻医助 × 业务订单笔数 */
|
||||
export function yejiStatsRevisitBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
revisit_slot: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||
}
|
||||
|
||||
+98
-9
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
||||
}
|
||||
|
||||
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
|
||||
export function diagnosisReadonlyDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
|
||||
}
|
||||
|
||||
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
|
||||
export function diagnosisTrackingWindow(params: {
|
||||
id: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
|
||||
}
|
||||
|
||||
/** 新增跟踪备注(按天合并追加,仅文字) */
|
||||
export function diagnosisAddTrackingNote(params: {
|
||||
diagnosis_id: number
|
||||
tracking_content: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
|
||||
}
|
||||
|
||||
/** 拉取跟踪备注列表(note_date DESC) */
|
||||
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
|
||||
}
|
||||
|
||||
// 医助理诊单统计(按部门、按人)
|
||||
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
|
||||
export function assistantDiagnosisStats(params?: {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
days?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
|
||||
}
|
||||
|
||||
@@ -198,14 +229,25 @@ export function startCloudRecording(params: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
|
||||
}
|
||||
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消) */
|
||||
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id) */
|
||||
export function attachLocalCallRecording(params: {
|
||||
diagnosis_id: number
|
||||
file_url: string
|
||||
call_record_id?: number
|
||||
}) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
export function createManualCallRecord(params: { diagnosis_id: number }) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/createManualCallRecord', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
|
||||
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/watchCall', params })
|
||||
@@ -230,6 +272,11 @@ export function getImChatMessages(params: { diagnosis_id: number; only_archived?
|
||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
||||
}
|
||||
|
||||
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
|
||||
export function triggerImChatSync(data: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
|
||||
}
|
||||
|
||||
// 获取企业微信聊天记录
|
||||
export function getWechatChatRecords(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
|
||||
@@ -298,7 +345,11 @@ export function prescriptionVoid(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
||||
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
|
||||
export function prescriptionAudit(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescription/audit', params })
|
||||
}
|
||||
|
||||
@@ -401,10 +452,7 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
}
|
||||
|
||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderLinkPayOrder(params: {
|
||||
id: number
|
||||
pay_order_id: number
|
||||
}) {
|
||||
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||
}
|
||||
|
||||
@@ -419,7 +467,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
dose_count?: number
|
||||
medication_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||
}
|
||||
|
||||
@@ -428,6 +480,11 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
@@ -454,3 +511,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
|
||||
export function prescriptionLibraryDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
||||
}
|
||||
|
||||
// ========== 诊单待办事项(T5) ==========
|
||||
|
||||
/** 待办列表(按 diagnosis_id) */
|
||||
export function diagnosisTodoLists(params: {
|
||||
diagnosis_id: number
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
creator_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
|
||||
}
|
||||
|
||||
/** 新增待办(remind_time 为 unix 秒级时间戳) */
|
||||
export function diagnosisTodoAdd(params: {
|
||||
diagnosis_id: number
|
||||
content: string
|
||||
remind_time: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/add', params })
|
||||
}
|
||||
|
||||
/** 取消待办(仅 status=0 / 创建人或超管) */
|
||||
export function diagnosisTodoCancel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
|
||||
}
|
||||
|
||||
/** 待办详情 */
|
||||
export function diagnosisTodoDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
|
||||
}
|
||||
|
||||
@@ -9,17 +9,35 @@
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="chat-notify-card"
|
||||
:class="{ 'chat-notify-card-complete': item.type === 'consultation_complete' }"
|
||||
:class="{
|
||||
'chat-notify-card-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-card-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="chat-notify-icon" :class="{ 'chat-notify-icon-complete': item.type === 'consultation_complete' }">
|
||||
<div
|
||||
class="chat-notify-icon"
|
||||
:class="{
|
||||
'chat-notify-icon-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-icon-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
>
|
||||
<el-icon :size="22">
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
|
||||
<CircleCheck v-else />
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
|
||||
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
|
||||
<Right v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="chat-notify-body">
|
||||
<div class="chat-notify-title">{{ item.type === 'consultation_complete' ? '面诊结束' : '患者打开会话' }}</div>
|
||||
<div class="chat-notify-title">
|
||||
{{
|
||||
item.type === 'consultation_complete'
|
||||
? '面诊结束'
|
||||
: item.type === 'patient_left_chat'
|
||||
? '患者离开会话'
|
||||
: '患者打开会话'
|
||||
}}
|
||||
</div>
|
||||
<div class="chat-notify-desc">
|
||||
<template v-if="item.type === 'consultation_complete'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
@@ -27,6 +45,10 @@
|
||||
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
|
||||
完成,请及时跟进
|
||||
</template>
|
||||
<template v-else-if="item.type === 'patient_left_chat'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已离开问诊会话页面
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已打开与您的会话,请及时查看
|
||||
@@ -45,14 +67,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChatDotRound, CircleCheck, Close } from '@element-plus/icons-vue'
|
||||
import { ChatDotRound, CircleCheck, Close, Right } from '@element-plus/icons-vue'
|
||||
import { getChatNotifications } from '@/api/chat'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export interface ChatNotifyItem {
|
||||
id: string
|
||||
type?: 'patient_opened_chat' | 'consultation_complete'
|
||||
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
|
||||
doctor_id?: number
|
||||
assistant_id?: number
|
||||
patient_id?: string
|
||||
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.chat-notify-card-left {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.chat-notify-icon-left {
|
||||
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
|
||||
}
|
||||
|
||||
.chat-notify-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="!hiddenUpload" #trigger>
|
||||
<div class="material-select__trigger clearfix" @click.stop>
|
||||
<div
|
||||
class="material-select__trigger clearfix"
|
||||
:class="{ 'is-drop-hover': dropActive }"
|
||||
tabindex="0"
|
||||
@click.stop
|
||||
@dragenter.prevent.stop="onTriggerDragEnter"
|
||||
@dragleave.prevent.stop="onTriggerDragLeave"
|
||||
@dragover.prevent.stop="onTriggerDragOver"
|
||||
@drop.prevent.stop="onTriggerDrop"
|
||||
@paste="onTriggerPaste"
|
||||
>
|
||||
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
|
||||
<template v-slot:item="{ element, index }">
|
||||
<div
|
||||
@@ -82,8 +92,10 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import { uploadMaterialFile } from '@/api/file'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import FileItem from './file.vue'
|
||||
import Material from './index.vue'
|
||||
@@ -149,6 +161,11 @@ export default defineComponent({
|
||||
excludeDomain: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
|
||||
uploadCid: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
|
||||
@@ -162,8 +179,149 @@ export default defineComponent({
|
||||
const select = ref<any[]>([])
|
||||
const isAdd = ref(true)
|
||||
const currentIndex = ref(-1)
|
||||
const dragDepth = ref(0)
|
||||
const externalUploading = ref(false)
|
||||
const { disabled, limit, modelValue } = toRefs(props)
|
||||
const { getImageUrl } = useAppStore()
|
||||
const appStore = useAppStore()
|
||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||
|
||||
const allowDropPaste = computed(
|
||||
() =>
|
||||
!props.hiddenUpload &&
|
||||
(props.type === 'image' || props.type === 'video' || props.type === 'file')
|
||||
)
|
||||
|
||||
const dropActive = computed(() => dragDepth.value > 0)
|
||||
|
||||
function hasFileDrag(dt: DataTransfer | null) {
|
||||
if (!dt) return false
|
||||
const types = Array.from(dt.types as unknown as string[])
|
||||
return types.includes('Files') || types.includes('application/x-moz-file')
|
||||
}
|
||||
|
||||
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
|
||||
if (uploadType === 'file') return true
|
||||
const ext = fileName.includes('.')
|
||||
? fileName.split('.').pop()?.toLowerCase() || ''
|
||||
: ''
|
||||
if (uploadType === 'image') {
|
||||
if (!ext) return true
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
|
||||
}
|
||||
if (uploadType === 'video') {
|
||||
if (!ext) return true
|
||||
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
|
||||
ext
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toRelativeStoredUrl(fullUrl: string): string {
|
||||
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
|
||||
const oss = String(appStore.config?.oss_domain || '')
|
||||
.trim()
|
||||
.replace(/\/$/, '')
|
||||
if (oss && fullUrl.startsWith(oss)) {
|
||||
return fullUrl.slice(oss.length).replace(/^\//, '')
|
||||
}
|
||||
const origin =
|
||||
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
|
||||
if (origin && fullUrl.startsWith(origin)) {
|
||||
return fullUrl.slice(origin.length).replace(/^\//, '')
|
||||
}
|
||||
try {
|
||||
return new URL(fullUrl).pathname.replace(/^\//, '')
|
||||
} catch {
|
||||
return fullUrl
|
||||
}
|
||||
}
|
||||
|
||||
function clipboardFiles(e: ClipboardEvent): File[] {
|
||||
const uploadType = props.type
|
||||
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
|
||||
isAcceptedExtension(f.name, uploadType)
|
||||
)
|
||||
if (fromFiles.length) return fromFiles
|
||||
if (uploadType !== 'image') return []
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return []
|
||||
const out: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile()
|
||||
if (f) out.push(f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runExternalUpload(files: File[]) {
|
||||
if (externalUploading.value) return
|
||||
const maxSlots =
|
||||
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
|
||||
if (!maxSlots) {
|
||||
feedback.msgWarning('已达到上传数量上限')
|
||||
return
|
||||
}
|
||||
const queue = files.slice(0, maxSlots)
|
||||
if (!queue.length) return
|
||||
|
||||
const uploadType =
|
||||
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
|
||||
externalUploading.value = true
|
||||
try {
|
||||
const paths: string[] = []
|
||||
for (const file of queue) {
|
||||
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
|
||||
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
|
||||
}
|
||||
fileList.value = [...fileList.value, ...paths]
|
||||
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
|
||||
emit('update:modelValue', valueImg)
|
||||
emit('change', valueImg)
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
feedback.msgError(err?.message || '上传失败')
|
||||
} finally {
|
||||
externalUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTriggerDragEnter = (e: DragEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
if (!hasFileDrag(e.dataTransfer)) return
|
||||
dragDepth.value++
|
||||
}
|
||||
|
||||
const onTriggerDragLeave = () => {
|
||||
if (!allowDropPaste.value) return
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
|
||||
const onTriggerDragOver = (e: DragEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
|
||||
}
|
||||
|
||||
const onTriggerDrop = async (e: DragEvent) => {
|
||||
dragDepth.value = 0
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
|
||||
isAcceptedExtension(f.name, props.type)
|
||||
)
|
||||
if (!files.length) return
|
||||
await runExternalUpload(files)
|
||||
}
|
||||
|
||||
const onTriggerPaste = async (e: ClipboardEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
const files = clipboardFiles(e)
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
await runExternalUpload(files)
|
||||
}
|
||||
const tipsText = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'image':
|
||||
@@ -264,7 +422,13 @@ export default defineComponent({
|
||||
showPreview,
|
||||
handlePreview,
|
||||
handleClose,
|
||||
getImageUrl
|
||||
getImageUrl,
|
||||
dropActive,
|
||||
onTriggerDragEnter,
|
||||
onTriggerDragLeave,
|
||||
onTriggerDragOver,
|
||||
onTriggerDrop,
|
||||
onTriggerPaste
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -272,6 +436,13 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.material-select {
|
||||
.material-select__trigger {
|
||||
outline: none;
|
||||
&.is-drop-hover {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||
}
|
||||
}
|
||||
.material-upload,
|
||||
.material-preview {
|
||||
position: relative;
|
||||
|
||||
@@ -75,11 +75,37 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="section-title">
|
||||
中药处方 (RP)
|
||||
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
|
||||
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
|
||||
<div class="info-section info-section--rp">
|
||||
<div class="rp-toolbar" role="region" aria-label="中药处方">
|
||||
<div class="rp-toolbar__lead">
|
||||
<span class="rp-toolbar__marker" aria-hidden="true" />
|
||||
<div class="rp-toolbar__text">
|
||||
<h3 class="rp-toolbar__heading">
|
||||
中药处方
|
||||
<span class="rp-toolbar__abbr">(RP)</span>
|
||||
</h3>
|
||||
<p class="rp-toolbar__meta">
|
||||
共 <strong>{{ formData.herbs.length }}</strong> 味药材 · 可手动添加、从处方库或粘贴药方批量录入(药品库同名匹配)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
|
||||
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb">
|
||||
添加中药
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:icon="CollectionTag"
|
||||
@click="showLibraryDialog = true"
|
||||
>
|
||||
从处方库导入
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" :icon="EditPen" @click="openPasteRecipeDialog">
|
||||
导入药方
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<el-form-item label="处方价格" prop="amount" class="!mb-0">
|
||||
@@ -134,11 +160,11 @@
|
||||
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
|
||||
<el-option label="浓缩水丸" value="浓缩水丸" />
|
||||
<el-option label="饮片" value="饮片" />
|
||||
<el-option label="颗粒" value="颗粒" />
|
||||
<!-- <el-option label="颗粒" value="颗粒" />
|
||||
<el-option label="丸剂" value="丸剂" />
|
||||
<el-option label="散剂" value="散剂" />
|
||||
<el-option label="膏方" value="膏方" />
|
||||
<el-option label="汤剂" value="汤剂" />
|
||||
<el-option label="汤剂" value="汤剂" /> -->
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -190,6 +216,18 @@
|
||||
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col v-if="formData.prescription_type === '浓缩水丸'" :span="8">
|
||||
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
|
||||
<el-select v-model="formData.dosage_bag_count" placeholder="请选择袋数" class="!w-[120px]">
|
||||
<el-option label="1袋" :value="1" />
|
||||
<el-option label="2袋" :value="2" />
|
||||
<el-option label="3袋" :value="3" />
|
||||
<el-option label="4袋" :value="4" />
|
||||
<el-option label="5袋" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片每贴出包数 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
|
||||
@@ -407,9 +445,9 @@
|
||||
<div class="prescription-footer">
|
||||
<div class="footer-left">
|
||||
<div class="footer-dosage">
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}{{ savedDosageDescText ? ',' + savedDosageDescText : '' }}, {{ savedPrescription.prescription_type }}
|
||||
{{ savedPrescription.usage_instruction }}
|
||||
|
||||
|
||||
</div>
|
||||
<div class="footer-dosage">
|
||||
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
|
||||
@@ -649,6 +687,40 @@
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 粘贴药方文本,解析药名与剂量 -->
|
||||
<el-dialog
|
||||
v-model="showPasteRecipeDialog"
|
||||
title="导入药方(识别药材)"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="pasteRecipeText = ''"
|
||||
>
|
||||
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
|
||||
粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g
|
||||
白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="pasteRecipeText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="示例: 黄芪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>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -656,12 +728,13 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
||||
import { medicineLists } from '@/api/medicine'
|
||||
import { getDictData } from '@/api/app'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { Download, Document } from '@element-plus/icons-vue'
|
||||
import { CirclePlus, CollectionTag, Download, Document, EditPen } from '@element-plus/icons-vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
interface Herb {
|
||||
@@ -676,6 +749,7 @@ interface PrescriptionForm {
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined // 用量数值
|
||||
dosage_unit: string // 用量单位 (g 或 ml)
|
||||
dosage_bag_count: number // 用量袋数(浓缩水丸,1-5袋)
|
||||
need_decoction: boolean // 是否代煎(仅饮片)
|
||||
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
|
||||
patient_name: string
|
||||
@@ -739,6 +813,20 @@ const hasPrescriptionOrderBlockVoid = computed(() => {
|
||||
|
||||
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
|
||||
|
||||
/** 处方笺底部"用量/袋数"展示文案:浓缩水丸显示"一次X袋(每袋Yg)",其它类型显示"一次Yg" */
|
||||
const savedDosageDescText = computed(() => {
|
||||
const v = savedPrescription.value
|
||||
if (!v) return ''
|
||||
if (v.dosage_amount == null || v.dosage_amount === '') return ''
|
||||
const amount = Number(v.dosage_amount)
|
||||
const unit = v.dosage_unit || 'g'
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
return `一次${bags}袋(每袋${amount}${unit})`
|
||||
}
|
||||
return `一次${amount}${unit}`
|
||||
})
|
||||
|
||||
// 处方库相关
|
||||
const showLibraryDialog = ref(false)
|
||||
const libraryLoading = ref(false)
|
||||
@@ -751,6 +839,179 @@ const libraryPage = ref(1)
|
||||
const libraryPageSize = ref(15)
|
||||
const libraryTotal = ref(0)
|
||||
|
||||
/** 粘贴药方导入 */
|
||||
const showPasteRecipeDialog = ref(false)
|
||||
const pasteRecipeText = ref('')
|
||||
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
|
||||
const pasteRecipeImportLoading = ref(false)
|
||||
|
||||
const PASTE_RECIPE_SKIP_SEGMENT =
|
||||
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
|
||||
|
||||
function normalizeRecipeInput(raw: string): string {
|
||||
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
s = s.replace(/[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({
|
||||
stationName: '成都双流甄养堂互联网医院',
|
||||
showDisclaimer: true,
|
||||
@@ -764,6 +1025,7 @@ const formData = reactive<PrescriptionForm>({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 10,
|
||||
dosage_unit: 'g',
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
patient_name: '',
|
||||
@@ -954,6 +1216,7 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
|
||||
formData.prescription_type = '浓缩水丸'
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 10
|
||||
formData.dosage_bag_count = 1
|
||||
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
||||
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
||||
formData.gender_desc = formData.gender === 1 ? '男' : '女'
|
||||
@@ -980,6 +1243,8 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
|
||||
formData.doctor_name = userStore.userInfo?.name || ''
|
||||
formData.doctor_signature = ''
|
||||
formData.is_shared = 0
|
||||
formData.need_decoction = false
|
||||
formData.bags_per_dose = 1
|
||||
|
||||
savedPrescription.value = null
|
||||
visible.value = true
|
||||
@@ -1206,16 +1471,19 @@ watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 10
|
||||
formData.dosage_bag_count = 1
|
||||
formData.need_decoction = false
|
||||
formData.bags_per_dose = 1
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
formData.dosage_bag_count = 1
|
||||
formData.bags_per_dose = 1
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.dosage_bag_count = 1
|
||||
formData.need_decoction = false
|
||||
formData.bags_per_dose = 1
|
||||
}
|
||||
@@ -1275,6 +1543,7 @@ const handleSave = async () => {
|
||||
dose_unit: formData.dose_unit,
|
||||
dosage_amount: formData.dosage_amount,
|
||||
dosage_unit: formData.dosage_unit,
|
||||
dosage_bag_count: formData.dosage_bag_count,
|
||||
need_decoction: formData.need_decoction,
|
||||
bags_per_dose: formData.bags_per_dose,
|
||||
usage_days: formData.usage_days,
|
||||
@@ -1354,12 +1623,15 @@ const handleNewPrescription = async () => {
|
||||
if (formData.prescription_type === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 10
|
||||
formData.dosage_bag_count = 1
|
||||
} else if (formData.prescription_type === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
formData.dosage_bag_count = 1
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.dosage_bag_count = 1
|
||||
}
|
||||
formData.patient_name = saved.patient_name || ''
|
||||
formData.gender = saved.gender ?? 0
|
||||
@@ -1514,6 +1786,107 @@ defineExpose({
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.info-section--rp {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.rp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-bg-color) 0%,
|
||||
var(--el-fill-color-extra-light) 100%
|
||||
);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.rp-toolbar:hover {
|
||||
border-color: var(--el-border-color);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.rp-toolbar__lead {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rp-toolbar__marker {
|
||||
width: 4px;
|
||||
min-height: 44px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--el-color-primary) 0%,
|
||||
var(--el-color-primary-light-5) 100%
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rp-toolbar__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rp-toolbar__heading {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rp-toolbar__abbr {
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rp-toolbar__meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rp-toolbar__meta strong {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rp-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rp-toolbar__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.rp-toolbar__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.herbs-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:data="data"
|
||||
:http-request="useDirect ? httpRequest : undefined"
|
||||
:on-progress="handleProgress"
|
||||
:on-success="handleSuccess"
|
||||
:on-exceed="handleExceed"
|
||||
@@ -41,7 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ElUpload } from 'element-plus'
|
||||
import type { ElUpload, UploadRequestOptions } from 'element-plus'
|
||||
import { computed, defineComponent, ref, shallowRef } from 'vue'
|
||||
|
||||
import config from '@/config'
|
||||
@@ -49,6 +50,10 @@ import { RequestCodeEnum } from '@/enums/requestEnums'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
DirectUploadFallbackError,
|
||||
uploadVideoDirectToCos
|
||||
} from '@/utils/oss-direct-upload'
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
@@ -77,6 +82,11 @@ export default defineComponent({
|
||||
showProgress: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
||||
direct: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['change', 'error', 'success', 'allSuccess'],
|
||||
@@ -92,6 +102,9 @@ export default defineComponent({
|
||||
const visible = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
|
||||
// 仅 video + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && props.type === 'video')
|
||||
|
||||
const handleProgress = () => {
|
||||
visible.value = true
|
||||
}
|
||||
@@ -142,12 +155,73 @@ export default defineComponent({
|
||||
return '*'
|
||||
}
|
||||
})
|
||||
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
||||
const httpRequest = async (options: UploadRequestOptions) => {
|
||||
visible.value = true
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
file: options.file,
|
||||
type: 'video',
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
;(options as any).onProgress?.({
|
||||
percent: info.percent,
|
||||
loaded: info.loaded,
|
||||
total: info.total
|
||||
})
|
||||
}
|
||||
})
|
||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||
} catch (err: any) {
|
||||
if (err instanceof DirectUploadFallbackError) {
|
||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||
await defaultXhrUpload(options)
|
||||
return
|
||||
}
|
||||
;(options as any).onError?.(err instanceof Error ? err : new Error(String(err?.message ?? err)))
|
||||
}
|
||||
}
|
||||
|
||||
// 当 STS 不可用时,回退到 ElUpload 原生 XHR 行为(POST /upload/{type})
|
||||
const defaultXhrUpload = (options: UploadRequestOptions) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', action.value)
|
||||
Object.entries(headers.value).forEach(([k, v]) => xhr.setRequestHeader(k, String(v ?? '')))
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return
|
||||
const percent = (e.loaded / e.total) * 100
|
||||
;(options as any).onProgress?.({ percent, loaded: e.loaded, total: e.total })
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText)
|
||||
;(options as any).onSuccess?.(json)
|
||||
} catch (e) {
|
||||
;(options as any).onError?.(new Error('上传响应解析失败'))
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
;(options as any).onError?.(new Error('网络错误'))
|
||||
resolve()
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', options.file)
|
||||
Object.entries(options.data || {}).forEach(([k, v]) => formData.append(k, String(v)))
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
return {
|
||||
uploadRefs,
|
||||
action,
|
||||
headers,
|
||||
visible,
|
||||
fileList,
|
||||
useDirect,
|
||||
httpRequest,
|
||||
getAccept,
|
||||
handleProgress,
|
||||
handleSuccess,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//引入柱状图图表,图表后缀都为 Chart
|
||||
import {
|
||||
BarChart,
|
||||
FunnelChart,
|
||||
LineChart,
|
||||
MapChart,
|
||||
PictorialBarChart,
|
||||
@@ -43,6 +44,7 @@ echarts.use([
|
||||
AriaComponent,
|
||||
ParallelComponent,
|
||||
BarChart,
|
||||
FunnelChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
MapChart,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="layout-default flex h-screen w-full">
|
||||
<div class="app-aside">
|
||||
<!-- shrink-0:避免主内容区(宽表格/图表)把侧栏压缩到折叠宽度 -->
|
||||
<div class="app-aside shrink-0">
|
||||
<layout-sidebar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/tcm/diagnosis/h5',
|
||||
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
||||
},
|
||||
{
|
||||
// T1+T2:患者信息详情页(隐藏路由 — 不在侧边栏出现,由诊单列表的「查看」/双击行驱动跳转)
|
||||
path: '/tcm/diagnosis-readonly',
|
||||
component: LAYOUT,
|
||||
meta: { hidden: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/tcm/diagnosis/readonly.vue'),
|
||||
name: 'tcmDiagnosisReadonly',
|
||||
meta: {
|
||||
title: '患者信息详情',
|
||||
hidden: true,
|
||||
activeMenu: '/tcm/diagnosis'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
// {
|
||||
// path: '/dev_tools',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getConfig } from '@/api/app'
|
||||
import configs from '@/config'
|
||||
|
||||
interface AppSate {
|
||||
config: Record<string, any>
|
||||
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
|
||||
getImageUrl(url: string) {
|
||||
if (!url || typeof url !== 'string') return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
|
||||
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
|
||||
const path = url.startsWith('/') ? url : '/' + url
|
||||
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
|
||||
if (oss) {
|
||||
return `${oss}${path}`
|
||||
}
|
||||
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
|
||||
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
|
||||
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
|
||||
try {
|
||||
return new URL(path, `${apiRoot}/`).href
|
||||
} catch {
|
||||
return `${apiRoot}${path}`
|
||||
}
|
||||
}
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return origin ? `${origin}${path}` : path
|
||||
},
|
||||
getConfig() {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'patient_closed_chat') {
|
||||
const parts: string[] = []
|
||||
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
|
||||
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID:${String(o.patientId)}`)
|
||||
const t = formatBizTime(o.time)
|
||||
if (t) parts.push(t)
|
||||
return { main: '患者离开聊天页', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'user_typing_status') {
|
||||
return { main: '对方正在输入…', tag: '状态' }
|
||||
}
|
||||
|
||||
@@ -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-form-item label="归属部门" prop="dept_id">
|
||||
<el-tree-select
|
||||
class="flex-1"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled(data: any) {
|
||||
return data.status !== 1
|
||||
}
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择归属部门"
|
||||
/>
|
||||
<div class="w-full">
|
||||
<el-tree-select
|
||||
class="flex-1 w-full"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled: deptTreeNodeDisabled
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择末级归属部门"
|
||||
/>
|
||||
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 岗位 -->
|
||||
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app'
|
||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
|
||||
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
|
||||
if (data.status !== 1) return true
|
||||
const ch = data.children
|
||||
return Array.isArray(ch) && ch.length > 0
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const visible = ref(false)
|
||||
@@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{
|
||||
api: jobsAll
|
||||
},
|
||||
dept: {
|
||||
api: deptAll
|
||||
api: deptAll,
|
||||
params: { apply_data_scope: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,25 +54,26 @@
|
||||
|
||||
<el-divider content-position="left">组织信息</el-divider>
|
||||
|
||||
<!-- 归属部门 -->
|
||||
<!-- 归属部门:数据权限内仅末级可选,父级仅用于展开 -->
|
||||
<el-form-item label="归属部门" prop="dept_id">
|
||||
<el-tree-select
|
||||
class="flex-1"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled(data: any) {
|
||||
return data.status !== 1
|
||||
}
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择归属部门"
|
||||
/>
|
||||
<div class="w-full">
|
||||
<el-tree-select
|
||||
class="flex-1 w-full"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled: deptTreeNodeDisabled
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择末级归属部门"
|
||||
/>
|
||||
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 岗位 -->
|
||||
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app'
|
||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
|
||||
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
|
||||
if (data.status !== 1) return true
|
||||
const ch = data.children
|
||||
return Array.isArray(ch) && ch.length > 0
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const visible = ref(false)
|
||||
@@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{
|
||||
api: jobsAll
|
||||
},
|
||||
dept: {
|
||||
api: deptAll
|
||||
api: deptAll,
|
||||
params: { apply_data_scope: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -99,11 +99,12 @@ import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
|
||||
// 表单数据 - 固定role_id=1查询医生
|
||||
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
name: '',
|
||||
role_id: 1 // 固定为医生角色
|
||||
role_id: 1,
|
||||
apply_data_scope: 1
|
||||
})
|
||||
|
||||
const showEdit = ref(false)
|
||||
|
||||
@@ -631,12 +631,22 @@
|
||||
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
<el-button type="primary" size="small" @click="addHerb">
|
||||
添加药材
|
||||
</el-button>
|
||||
<el-button type="success" size="small" class="ml-2" @click="openLibraryDialog">
|
||||
从处方库导入
|
||||
</el-button>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<el-button type="primary" size="small" @click="addHerb">
|
||||
添加药材
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="openLibraryDialog">
|
||||
从处方库导入
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
||||
导入药方
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-500 ml-2 align-middle">
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
@@ -669,6 +679,49 @@
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="editMode === 'edit'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mt-3"
|
||||
v-loading="rxLinkedOrderHint.loading"
|
||||
>
|
||||
<template #title>业务订单要求(请与本处方保持一致)</template>
|
||||
<div class="text-sm mt-1 leading-relaxed">
|
||||
<template
|
||||
v-if="
|
||||
rxLinkedOrderHint.orderNo ||
|
||||
rxLinkedOrderHint.medicationDays != null ||
|
||||
rxLinkedOrderHint.remarkAssistant
|
||||
"
|
||||
>
|
||||
<p v-if="rxLinkedOrderHint.orderNo" class="mb-1.5">
|
||||
最近关联订单(按创建倒序):
|
||||
<span class="font-mono text-primary">{{ rxLinkedOrderHint.orderNo }}</span>
|
||||
</p>
|
||||
<p class="mb-1.5">
|
||||
<span class="font-semibold">服用天数(订单)</span>:
|
||||
{{
|
||||
rxLinkedOrderHint.medicationDays != null
|
||||
? `${rxLinkedOrderHint.medicationDays} 天`
|
||||
: '— 订单未填写'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">医助备注(订单)</span>:
|
||||
<span class="whitespace-pre-wrap">{{
|
||||
rxLinkedOrderHint.remarkAssistant || '— 无'
|
||||
}}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else-if="!rxLinkedOrderHint.loading" class="text-gray-600">
|
||||
当前处方暂无关联业务订单,或您无订单列表权限;若有订单,将在此处显示订单内
|
||||
<span class="font-semibold">服用天数</span>与
|
||||
<span class="font-semibold">医助备注</span>。
|
||||
</p>
|
||||
</div>
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -755,6 +808,17 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
|
||||
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
|
||||
<el-select v-model="editForm.dosage_bag_count" placeholder="请选择袋数" class="w-[120px]">
|
||||
<el-option label="1袋" :value="1" />
|
||||
<el-option label="2袋" :value="2" />
|
||||
<el-option label="3袋" :value="3" />
|
||||
<el-option label="4袋" :value="4" />
|
||||
<el-option label="5袋" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
@@ -1183,7 +1247,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||
<el-input
|
||||
v-model="createOrderForm.remark_extra"
|
||||
type="textarea"
|
||||
@@ -1193,6 +1257,16 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input
|
||||
v-model="createOrderForm.remark_assistant"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,医助对内说明"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -1295,6 +1369,40 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 粘贴药方文本导入(与诊间 tcm-prescription 逻辑一致:药品库完全同名才录入) -->
|
||||
<el-dialog
|
||||
v-model="showPasteRecipeDialog"
|
||||
title="导入药方(识别药材)"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="pasteRecipeText = ''"
|
||||
>
|
||||
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
|
||||
粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g
|
||||
白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="pasteRecipeText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="示例: 黄芪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 = ''">
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
|
||||
@@ -1333,10 +1441,11 @@ import {
|
||||
prescriptionDetail,
|
||||
prescriptionOrderCreate,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderLists,
|
||||
getDoctors,
|
||||
prescriptionLibraryLists
|
||||
} from '@/api/tcm'
|
||||
import { searchPatients as searchPatientsAPI } from '@/api/order'
|
||||
import { medicineLists } from '@/api/medicine'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { roleAll } from '@/api/perms/role'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
@@ -1345,7 +1454,7 @@ import feedback from '@/utils/feedback'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
import { Search, Download, Printer } from '@element-plus/icons-vue'
|
||||
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import html2canvas from 'html2canvas'
|
||||
@@ -1475,6 +1584,7 @@ const createOrderForm = reactive({
|
||||
amount: 0,
|
||||
internal_cost: undefined as number | undefined,
|
||||
remark_extra: '',
|
||||
remark_assistant: '',
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
@@ -1714,6 +1824,7 @@ function resetCreateOrderForm() {
|
||||
createOrderForm.amount = 0
|
||||
createOrderForm.internal_cost = undefined
|
||||
createOrderForm.remark_extra = ''
|
||||
createOrderForm.remark_assistant = ''
|
||||
createOrderForm.pay_order_ids = []
|
||||
createOrderPatientList.value = []
|
||||
createOrderPaidOrders.value = []
|
||||
@@ -1884,7 +1995,8 @@ async function submitCreateOrderFromPrescription() {
|
||||
tracking_number: createOrderForm.tracking_number || '',
|
||||
fee_type: createOrderForm.fee_type,
|
||||
amount: createOrderForm.amount,
|
||||
remark_extra: createOrderForm.remark_extra || ''
|
||||
remark_extra: createOrderForm.remark_extra || '',
|
||||
remark_assistant: createOrderForm.remark_assistant || ''
|
||||
}
|
||||
|
||||
// 添加省市区字段
|
||||
@@ -2020,15 +2132,19 @@ const rxUsageText = computed(() => {
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
||||
seg.push(`每包${amount}${unit}`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
@@ -2066,6 +2182,22 @@ const rxOutPelletText = computed(() => {
|
||||
|
||||
function computeOutPellet(v: any): string {
|
||||
if (!v) return ''
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const days = Number(v.medication_days ?? v.usage_days ?? v.dose_count)
|
||||
const times = Number(v.times_per_day)
|
||||
const doseG = Number(v.dosage_amount)
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
if (
|
||||
Number.isFinite(days) &&
|
||||
Number.isFinite(times) &&
|
||||
Number.isFinite(doseG) &&
|
||||
days > 0 &&
|
||||
times > 0 &&
|
||||
doseG > 0
|
||||
) {
|
||||
return formatNum(days * times * doseG * bags)
|
||||
}
|
||||
}
|
||||
const explicit = v.out_pellet || v.total_weight
|
||||
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
|
||||
const list = slipHerbsList.value
|
||||
@@ -2197,6 +2329,7 @@ const editForm = reactive({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
patient_name: '',
|
||||
@@ -2238,6 +2371,52 @@ const editForm = reactive({
|
||||
times_per_day: 2
|
||||
})
|
||||
|
||||
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
||||
const rxLinkedOrderHint = reactive({
|
||||
loading: false,
|
||||
orderNo: '',
|
||||
medicationDays: null as number | null,
|
||||
remarkAssistant: ''
|
||||
})
|
||||
|
||||
function clearRxLinkedOrderHint() {
|
||||
rxLinkedOrderHint.loading = false
|
||||
rxLinkedOrderHint.orderNo = ''
|
||||
rxLinkedOrderHint.medicationDays = null
|
||||
rxLinkedOrderHint.remarkAssistant = ''
|
||||
}
|
||||
|
||||
async function loadRxLinkedOrderHintForEdit(prescriptionId: number) {
|
||||
rxLinkedOrderHint.orderNo = ''
|
||||
rxLinkedOrderHint.medicationDays = null
|
||||
rxLinkedOrderHint.remarkAssistant = ''
|
||||
if (!prescriptionId) {
|
||||
rxLinkedOrderHint.loading = false
|
||||
return
|
||||
}
|
||||
rxLinkedOrderHint.loading = true
|
||||
try {
|
||||
const data: any = await prescriptionOrderLists({
|
||||
page_no: 1,
|
||||
page_size: 5,
|
||||
prescription_id: prescriptionId
|
||||
})
|
||||
const lists = data?.lists
|
||||
const row = Array.isArray(lists) && lists.length ? lists[0] : null
|
||||
if (row) {
|
||||
rxLinkedOrderHint.orderNo = String(row.order_no || '').trim()
|
||||
const md = row.medication_days
|
||||
const mdn = md !== null && md !== undefined && String(md).trim() !== '' ? Number(md) : NaN
|
||||
rxLinkedOrderHint.medicationDays = Number.isFinite(mdn) && mdn > 0 ? mdn : null
|
||||
rxLinkedOrderHint.remarkAssistant = String(row.remark_assistant || '').trim()
|
||||
}
|
||||
} catch {
|
||||
/* 无列表权限或网络错误:保持空 */
|
||||
} finally {
|
||||
rxLinkedOrderHint.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const diagnosisViewRef = ref<{
|
||||
openViewOnly: (id: number) => Promise<void>
|
||||
} | null>(null)
|
||||
@@ -2264,16 +2443,19 @@ watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.dosage_bag_count = 1
|
||||
editForm.need_decoction = false
|
||||
editForm.bags_per_dose = 1
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
editForm.dosage_bag_count = 1
|
||||
editForm.bags_per_dose = 1
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.dosage_bag_count = 1
|
||||
editForm.need_decoction = false
|
||||
editForm.bags_per_dose = 1
|
||||
}
|
||||
@@ -2833,6 +3015,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 = () => {
|
||||
editForm.herbs.push({
|
||||
@@ -2850,6 +3203,11 @@ const removeHerb = (index: number) => {
|
||||
const resetForm = () => {
|
||||
editForm.id = 0
|
||||
editForm.prescription_type = '浓缩水丸'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_bag_count = 1
|
||||
editForm.need_decoction = false
|
||||
editForm.bags_per_dose = 1
|
||||
editForm.patient_name = ''
|
||||
editForm.gender = 1
|
||||
editForm.age = 0
|
||||
@@ -2882,6 +3240,7 @@ const resetForm = () => {
|
||||
editForm.is_system_auto = 0
|
||||
editForm.business_prescription_audit_rejected = 0
|
||||
editForm.business_prescription_audit_remark = ''
|
||||
clearRxLinkedOrderHint()
|
||||
}
|
||||
|
||||
// 新增处方
|
||||
@@ -2966,6 +3325,7 @@ const handleEdit = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
editForm.dosage_bag_count = src.dosage_bag_count ? Number(src.dosage_bag_count) : 1
|
||||
editForm.need_decoction = src.need_decoction === 1 || src.need_decoction === true
|
||||
editForm.bags_per_dose = src.bags_per_dose ? Number(src.bags_per_dose) : 1
|
||||
editForm.patient_name = src.patient_name || ''
|
||||
@@ -3009,8 +3369,9 @@ const handleEdit = async (row: any) => {
|
||||
setTimeout(() => {
|
||||
isLoadingData.value = false
|
||||
}, 0)
|
||||
|
||||
|
||||
showEdit.value = true
|
||||
void loadRxLinkedOrderHintForEdit(Number(editForm.id))
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
scheduleEditSignatureInit()
|
||||
@@ -3020,9 +3381,14 @@ const handleEdit = async (row: any) => {
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate()
|
||||
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
// Element Plus:校验失败会 reject,表单项已展示错误,勿再向外抛成 Uncaught
|
||||
return
|
||||
}
|
||||
|
||||
// 验证药材
|
||||
if (!editForm.herbs || editForm.herbs.length === 0) {
|
||||
feedback.msgError('请至少添加一味药材')
|
||||
|
||||
@@ -491,7 +491,11 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
<pagination
|
||||
v-model="pager"
|
||||
:page-sizes="[15, 20, 30, 40, 100, 150, 200]"
|
||||
@change="getLists"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -663,7 +667,19 @@
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -801,6 +817,9 @@
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
@@ -892,11 +911,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -949,11 +969,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -974,7 +995,7 @@
|
||||
<span class="font-medium text-[15px]">履约与收货信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="3" border size="small" class="po-desc">
|
||||
<el-descriptions-item label="诊单ID">
|
||||
<el-descriptions-item label="诊单ID" :span="3">
|
||||
<span class="font-mono">{{ detailData.diagnosis_id }}</span>
|
||||
<el-button
|
||||
v-if="Number(detailData.diagnosis_id) > 0"
|
||||
@@ -987,14 +1008,48 @@
|
||||
查看患者信息
|
||||
</el-button>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="1">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务订单创建人" :span="2">
|
||||
<div v-if="Number(detailData.creator_id) > 0 || detailData.creator_name" class="text-[13px] space-y-1">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span>
|
||||
<span v-if="Number(detailData.creator_id) > 0" class="text-gray-500 ml-2 font-mono text-xs"
|
||||
>ID {{ detailData.creator_id }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs leading-relaxed">
|
||||
{{ 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"
|
||||
class="min-w-0"
|
||||
>
|
||||
<el-breadcrumb
|
||||
v-if="segments.length"
|
||||
class="po-diagnosis-creator-dept-breadcrumb text-[13px] leading-relaxed"
|
||||
separator="/"
|
||||
>
|
||||
<el-breadcrumb-item v-for="(name, j) in segments" :key="j">
|
||||
<span class="text-gray-800 break-all">{{ 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="3">
|
||||
<template v-if="detailLinkedAppointment">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
|
||||
<span
|
||||
>预约 <span class="font-mono text-primary">#{{ detailLinkedAppointment.id }}</span></span
|
||||
>流转编号(挂号) <span class="font-mono text-primary">#{{ detailLinkedAppointment.id }}</span></span
|
||||
>
|
||||
<span class="text-gray-500"
|
||||
>时间:{{ detailLinkedAppointment.appointment_date }}
|
||||
@@ -1006,8 +1061,8 @@
|
||||
<span v-if="detailLinkedAppointment.doctor_name"
|
||||
>接诊:{{detailLinkedAppointment.doctor_name }}</span
|
||||
>
|
||||
<span v-if="detailLinkedAppointment.channel_source" class="text-gray-500"
|
||||
>渠道:{{ detailLinkedAppointment.channel_source }}</span
|
||||
<span v-if="detailLinkedAppointmentChannelText" class="text-gray-500"
|
||||
>渠道:{{ detailLinkedAppointmentChannelText }}</span
|
||||
>
|
||||
<el-tag v-if="detailLinkedAppointmentResolvedFromTag" size="small" effect="plain">{{
|
||||
detailLinkedAppointmentResolvedFromTag
|
||||
@@ -1056,7 +1111,8 @@
|
||||
{{ expressCompanyLabel(detailData.express_company) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">
|
||||
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
@@ -1181,6 +1237,48 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="updateAmountVisible"
|
||||
title="修改订单金额"
|
||||
width="420px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetUpdateAmountDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="updateAmountFormRef"
|
||||
:model="updateAmountForm"
|
||||
:rules="updateAmountRules"
|
||||
label-width="96px"
|
||||
class="pr-2"
|
||||
@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-[96px] -mt-1 leading-relaxed">
|
||||
已完成或已取消订单不可修改。提交成功后会刷新详情与操作日志。
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="updateAmountVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑(分步向导与「创建业务订单」弹窗统一) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
@@ -1527,7 +1625,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_extra"
|
||||
type="textarea"
|
||||
@@ -1537,6 +1635,16 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_assistant"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,医助对内说明"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -1912,6 +2020,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="prescriptionViewLoading" class="rx-wrap">
|
||||
<!-- 处方签签 -->
|
||||
<el-tabs v-model="prescriptionTabType" class="mx-4 mt-2" style="margin-bottom: 0px; z-index: 1; position: relative;" v-if="prescriptionViewData">
|
||||
<el-tab-pane label="药房联" name="internal" />
|
||||
<el-tab-pane label="处方联" name="user" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 状态条(不进入打印/导出范围) -->
|
||||
<div class="rx-statusbar" v-if="prescriptionViewData">
|
||||
<el-tag
|
||||
@@ -1962,7 +2076,7 @@
|
||||
|
||||
<!-- A4 处方纸(药房联) -->
|
||||
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
|
||||
<div class="rx-title">处方单</div>
|
||||
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : '处方单' }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
@@ -2020,24 +2134,26 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">药房联</div>
|
||||
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-name" v-else>{{ h.name }}</span>
|
||||
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
<span class="rx-herb-total" v-else>{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2258,6 +2374,7 @@
|
||||
|
||||
<script lang="ts" setup name="prescriptionOrderList">
|
||||
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Refresh, Loading, ArrowDown, InfoFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import {
|
||||
@@ -2271,6 +2388,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderShip,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
@@ -2296,6 +2414,7 @@ import useUserStore from '@/stores/modules/user'
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
const diagnosisViewRef = ref<{
|
||||
openViewOnly: (id: number) => Promise<void>
|
||||
@@ -2496,6 +2615,7 @@ const fulfillmentStatusTabs = [
|
||||
|
||||
function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
activeFocusKey.value = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
queryParams.fulfillment_status = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -2546,7 +2666,9 @@ const queryParams = reactive({
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self'
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
|
||||
exclude_fulfillment_cancelled: 0 as number
|
||||
})
|
||||
|
||||
const rxAuditTabs = [
|
||||
@@ -2564,11 +2686,13 @@ const payAuditTabs = [
|
||||
|
||||
function handleRxAuditTabClick(val: number | '') {
|
||||
activeFocusKey.value = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
queryParams.prescription_audit_status = val
|
||||
resetPage()
|
||||
}
|
||||
function handlePayAuditTabClick(val: number | '') {
|
||||
activeFocusKey.value = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
queryParams.payment_slip_audit_status = val
|
||||
resetPage()
|
||||
}
|
||||
@@ -2580,6 +2704,7 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.fulfillment_status = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
resetPage()
|
||||
return
|
||||
}
|
||||
@@ -2589,9 +2714,11 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.fulfillment_status = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
|
||||
if (key === 'pendingRx') {
|
||||
queryParams.prescription_audit_status = 0
|
||||
queryParams.exclude_fulfillment_cancelled = 1
|
||||
} else if (key === 'pendingPay') {
|
||||
queryParams.prescription_audit_status = 1
|
||||
queryParams.payment_slip_audit_status = 0
|
||||
@@ -2642,6 +2769,7 @@ async function fetchLists(params: Record<string, unknown>) {
|
||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
||||
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
|
||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||
delete p.start_time
|
||||
delete p.end_time
|
||||
@@ -2662,6 +2790,34 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
/** 业绩看板等入口:URL 携带创建区间、诊单医助部门 / 医助 id */
|
||||
function applyRouteQueryToPrescriptionOrderList() {
|
||||
const q = route.query
|
||||
const normDate = (v: unknown) => {
|
||||
const s = v != null ? String(v).trim() : ''
|
||||
if (!s) return ''
|
||||
return s.length >= 10 ? s.slice(0, 10) : s
|
||||
}
|
||||
const st = normDate(q.start_time)
|
||||
const et = normDate(q.end_time)
|
||||
if (st) queryParams.start_time = st
|
||||
if (et) queryParams.end_time = et
|
||||
const ad = q.assistant_dept_id
|
||||
if (ad !== undefined && ad !== null && String(ad) !== '') {
|
||||
const n = Number(ad)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
queryParams.assistant_dept_id = n
|
||||
}
|
||||
}
|
||||
const ai = q.assistant_id
|
||||
if (ai !== undefined && ai !== null && String(ai) !== '') {
|
||||
const n = Number(ai)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
queryParams.assistant_id = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
|
||||
const filteredAssistantOptions = computed(() => {
|
||||
const deptRaw = queryParams.assistant_dept_id
|
||||
@@ -2732,6 +2888,9 @@ const listStats = computed(() => {
|
||||
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
||||
} else if (scope === 'assistant') {
|
||||
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
||||
} else if (scope === 'own_rx') {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
@@ -2799,6 +2958,7 @@ watch(
|
||||
if (show) return
|
||||
if (queryParams.prescription_audit_status === '' || queryParams.prescription_audit_status === undefined) return
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
resetPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -2820,6 +2980,7 @@ function handleReset() {
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -3003,6 +3164,12 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return fs === 5 || fs === 6
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
@@ -3164,6 +3331,46 @@ const detailOrderPhoneDisplay = computed(() => {
|
||||
return s || '—'
|
||||
})
|
||||
|
||||
/** 详情:业务订单创建人部门面包屑(与 edit 表单同结构;后端 order_creator_dept_path / diagnosis_creator_dept_path) */
|
||||
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(
|
||||
(detailData.value as Record<string, unknown> | null)?.order_creator_dept_path ??
|
||||
(detailData.value as Record<string, unknown> | null)?.diagnosis_creator_dept_path ??
|
||||
''
|
||||
).trim()
|
||||
if (!raw) {
|
||||
return [] as string[][]
|
||||
}
|
||||
return raw
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => {
|
||||
if (p.includes(' / ')) {
|
||||
return p
|
||||
.split(' / ')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return p
|
||||
.split('/')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
.filter((segs) => segs.length > 0)
|
||||
})
|
||||
|
||||
const detailOrderCreatorMetaLine = computed(() => {
|
||||
const d = detailData.value as Record<string, unknown> | null
|
||||
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 detailLinkedAppointment = computed(() => {
|
||||
const a = detailData.value?.linked_appointment
|
||||
return a && typeof a === 'object' ? a : null
|
||||
@@ -3176,6 +3383,15 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 渠道展示:后端 linked_appointment.channel_source_desc(字典名),缺省回退原值 */
|
||||
const detailLinkedAppointmentChannelText = computed(() => {
|
||||
const a = detailLinkedAppointment.value
|
||||
if (!a) return ''
|
||||
const desc = String(a.channel_source_desc ?? '').trim()
|
||||
if (desc) return desc
|
||||
return String(a.channel_source ?? '').trim()
|
||||
})
|
||||
|
||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
||||
|
||||
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||
@@ -3235,6 +3451,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
const updateAmountSaving = ref(false)
|
||||
const updateAmountFormRef = ref<FormInstance>()
|
||||
const updateAmountForm = reactive({
|
||||
id: 0,
|
||||
amount: undefined as number | undefined
|
||||
})
|
||||
const updateAmountRules: FormRules = {
|
||||
amount: [
|
||||
{ required: true, message: '请输入订单金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
@@ -3270,6 +3509,7 @@ function logActionText(act: string) {
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
@@ -3354,6 +3594,24 @@ function resetPatchRxPatientForm() {
|
||||
patchRxPatientForm.phone = ''
|
||||
}
|
||||
|
||||
function resetUpdateAmountDialog() {
|
||||
updateAmountForm.id = 0
|
||||
updateAmountForm.amount = undefined
|
||||
updateAmountFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openUpdateAmount() {
|
||||
const row = detailData.value
|
||||
if (!canUpdateAmount(row)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
async function refreshCurrentPrescriptionOrderDetail() {
|
||||
const id = detailData.value?.id
|
||||
if (!id) return
|
||||
@@ -3393,6 +3651,37 @@ async function submitPatchRxPatient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdateAmount() {
|
||||
const form = updateAmountFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!canUpdateAmount(detailData.value)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
const orderId = updateAmountForm.id
|
||||
updateAmountSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderUpdateAmount({
|
||||
id: orderId,
|
||||
amount: Number(updateAmountForm.amount)
|
||||
})
|
||||
feedback.msgSuccess('修改成功')
|
||||
updateAmountVisible.value = false
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
await fetchLogs(orderId)
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
updateAmountSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
@@ -3480,6 +3769,7 @@ const editForm = reactive({
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
remark_extra: '',
|
||||
remark_assistant: '',
|
||||
pay_order_ids: [] as number[],
|
||||
internal_cost: undefined as number | string | undefined,
|
||||
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
||||
@@ -3730,6 +4020,7 @@ async function openEdit(row: { id: number }) {
|
||||
|
||||
editForm.amount = amt
|
||||
editForm.remark_extra = d.remark_extra || ''
|
||||
editForm.remark_assistant = d.remark_assistant || ''
|
||||
const pids = d.pay_order_ids
|
||||
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
|
||||
editForm.internal_cost =
|
||||
@@ -3768,6 +4059,7 @@ async function submitEdit() {
|
||||
fee_type: editForm.fee_type,
|
||||
amount: editForm.amount,
|
||||
remark_extra: editForm.remark_extra || '',
|
||||
remark_assistant: editForm.remark_assistant || '',
|
||||
|
||||
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
||||
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
||||
@@ -3998,6 +4290,8 @@ onMounted(async () => {
|
||||
await loadRegionData()
|
||||
await loadServicePackageOptions()
|
||||
await loadDoctorAssistantOptions()
|
||||
applyRouteQueryToPrescriptionOrderList()
|
||||
await nextTick()
|
||||
getLists()
|
||||
})
|
||||
|
||||
@@ -4047,6 +4341,7 @@ async function submitQuickTrack() {
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
@@ -4373,6 +4668,7 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
|
||||
const prescriptionViewVisible = ref(false)
|
||||
const prescriptionViewLoading = ref(false)
|
||||
const prescriptionViewData = ref<any>(null)
|
||||
const prescriptionTabType = ref('internal')
|
||||
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
|
||||
const prescriptionSlipExporting = ref(false)
|
||||
|
||||
@@ -4400,19 +4696,21 @@ const slipPerBagDosageText = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
|
||||
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}袋`
|
||||
})
|
||||
|
||||
const slipPillGrams = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
|
||||
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
|
||||
const times = Number(d.times_per_day)
|
||||
const doseG = Number(d.dosage_amount)
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
|
||||
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
||||
return days * times * doseG
|
||||
return days * times * doseG * bags
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
@@ -4505,21 +4803,25 @@ const rxUsageText = computed(() => {
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
||||
seg.push(`每包${amount}${unit}`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams(按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
|
||||
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||
const rxOutPelletGrams = computed(() => {
|
||||
const pill = slipPillGrams.value
|
||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||
@@ -4639,6 +4941,7 @@ async function openPrescriptionView(row: any) {
|
||||
prescriptionViewVisible.value = true
|
||||
prescriptionViewLoading.value = true
|
||||
prescriptionViewData.value = null
|
||||
prescriptionTabType.value = 'internal'
|
||||
|
||||
try {
|
||||
const res: any = await prescriptionDetail({ id: row.prescription_id })
|
||||
|
||||
@@ -677,7 +677,19 @@
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -819,6 +831,9 @@
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
@@ -910,11 +925,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -967,11 +983,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -1091,7 +1108,8 @@
|
||||
{{ expressCompanyLabel(detailData.express_company) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">
|
||||
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
@@ -1217,6 +1235,47 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="updateAmountVisible"
|
||||
title="修改订单金额"
|
||||
width="92%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetUpdateAmountDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="updateAmountFormRef"
|
||||
:model="updateAmountForm"
|
||||
:rules="updateAmountRules"
|
||||
label-width="84px"
|
||||
@submit.prevent="submitUpdateAmount"
|
||||
>
|
||||
<el-form-item label="当前金额">
|
||||
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
placeholder="请输入新金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="text-xs text-gray-400 pl-[84px] -mt-1 leading-relaxed">
|
||||
已完成或已取消订单不可修改。提交成功后会刷新详情与操作日志。
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="updateAmountVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑(分步向导与「创建业务订单」弹窗统一) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
@@ -1563,7 +1622,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_extra"
|
||||
type="textarea"
|
||||
@@ -1573,6 +1632,16 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_assistant"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,医助对内说明"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -2328,6 +2397,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderShip,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
@@ -2789,6 +2859,9 @@ const listStats = computed(() => {
|
||||
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
||||
} else if (scope === 'assistant') {
|
||||
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
||||
} else if (scope === 'own_rx') {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
@@ -3116,6 +3189,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
@@ -3327,6 +3406,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
const updateAmountSaving = ref(false)
|
||||
const updateAmountFormRef = ref<FormInstance>()
|
||||
const updateAmountForm = reactive({
|
||||
id: 0,
|
||||
amount: undefined as number | undefined
|
||||
})
|
||||
const updateAmountRules: FormRules = {
|
||||
amount: [
|
||||
{ required: true, message: '请输入订单金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
@@ -3362,6 +3464,7 @@ function logActionText(act: string) {
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
@@ -3446,6 +3549,24 @@ function resetPatchRxPatientForm() {
|
||||
patchRxPatientForm.phone = ''
|
||||
}
|
||||
|
||||
function resetUpdateAmountDialog() {
|
||||
updateAmountForm.id = 0
|
||||
updateAmountForm.amount = undefined
|
||||
updateAmountFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openUpdateAmount() {
|
||||
const row = detailData.value
|
||||
if (!canUpdateAmount(row)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
async function refreshCurrentPrescriptionOrderDetail() {
|
||||
const id = detailData.value?.id
|
||||
if (!id) return
|
||||
@@ -3485,6 +3606,37 @@ async function submitPatchRxPatient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdateAmount() {
|
||||
const form = updateAmountFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!canUpdateAmount(detailData.value)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
const orderId = updateAmountForm.id
|
||||
updateAmountSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderUpdateAmount({
|
||||
id: orderId,
|
||||
amount: Number(updateAmountForm.amount)
|
||||
})
|
||||
feedback.msgSuccess('修改成功')
|
||||
updateAmountVisible.value = false
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
await fetchLogs(orderId)
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
updateAmountSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
@@ -3572,6 +3724,7 @@ const editForm = reactive({
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
remark_extra: '',
|
||||
remark_assistant: '',
|
||||
pay_order_ids: [] as number[],
|
||||
internal_cost: undefined as number | string | undefined,
|
||||
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
||||
@@ -3822,6 +3975,7 @@ async function openEdit(row: { id: number }) {
|
||||
|
||||
editForm.amount = amt
|
||||
editForm.remark_extra = d.remark_extra || ''
|
||||
editForm.remark_assistant = d.remark_assistant || ''
|
||||
const pids = d.pay_order_ids
|
||||
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
|
||||
editForm.internal_cost =
|
||||
@@ -3860,6 +4014,7 @@ async function submitEdit() {
|
||||
fee_type: editForm.fee_type,
|
||||
amount: editForm.amount,
|
||||
remark_extra: editForm.remark_extra || '',
|
||||
remark_assistant: editForm.remark_assistant || '',
|
||||
|
||||
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
||||
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
||||
@@ -4139,6 +4294,7 @@ async function submitQuickTrack() {
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
@@ -4492,19 +4648,21 @@ const slipPerBagDosageText = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
|
||||
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}袋`
|
||||
})
|
||||
|
||||
const slipPillGrams = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
|
||||
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
|
||||
const times = Number(d.times_per_day)
|
||||
const doseG = Number(d.dosage_amount)
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
|
||||
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
||||
return days * times * doseG
|
||||
return days * times * doseG * bags
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
@@ -4597,21 +4755,25 @@ const rxUsageText = computed(() => {
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
||||
seg.push(`每包${amount}${unit}`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams(按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
|
||||
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||
const rxOutPelletGrams = computed(() => {
|
||||
const pill = slipPillGrams.value
|
||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||
|
||||
+3797
-352
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="toolbar__title">制定业绩目标</div>
|
||||
<div class="toolbar__desc">按部门设置每月销售目标,填 0 并保存将删除该记录</div>
|
||||
<div class="toolbar__desc">
|
||||
按部门设置每月销售目标,填 0 并保存将删除该记录。列表按当前账号
|
||||
<strong>角色数据权限</strong>
|
||||
仅展示<strong>本部门及下级</strong>(不展示权限外的上级组织;口径与业绩看板数据范围一致)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar__center">
|
||||
@@ -59,8 +63,16 @@
|
||||
|
||||
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
||||
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
||||
<p class="empty-state__title">暂无部门数据</p>
|
||||
<p class="empty-state__desc">请检查部门配置后重试</p>
|
||||
<p class="empty-state__title">
|
||||
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
|
||||
</p>
|
||||
<p class="empty-state__desc">
|
||||
{{
|
||||
dataScopeLimited
|
||||
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
|
||||
: '请检查部门配置后重试'
|
||||
}}
|
||||
</p>
|
||||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRows = ref<Row[]>([])
|
||||
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
|
||||
const dataScopeLimited = ref(false)
|
||||
|
||||
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||||
const drillStack = ref<Row[]>([])
|
||||
@@ -199,8 +213,10 @@ async function loadMatrix() {
|
||||
try {
|
||||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
} catch {
|
||||
formRows.value = []
|
||||
dataScopeLimited.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -217,6 +233,7 @@ async function handleSave() {
|
||||
})
|
||||
if (res?.rows) {
|
||||
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
drillStack.value = []
|
||||
drilledId.value = null
|
||||
} else {
|
||||
|
||||
@@ -180,11 +180,12 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="订单状态" width="100">
|
||||
<el-table-column label="订单状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTag(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -270,6 +271,14 @@
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['order.order/setExempt']"
|
||||
:type="row.is_exempt === 1 ? 'warning' : 'default'"
|
||||
link
|
||||
@click="handleToggleExempt(row)"
|
||||
>
|
||||
{{ row.is_exempt === 1 ? '取消豁免' : '设为豁免' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -779,7 +788,8 @@ import {
|
||||
orderActionLogs,
|
||||
orderActionLogStats,
|
||||
orderBatchAssignAssistant,
|
||||
orderSplit
|
||||
orderSplit,
|
||||
orderSetExempt
|
||||
} from '@/api/order'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { generateOrderQrcode } from '@/api/tcm'
|
||||
@@ -1494,6 +1504,19 @@ const handleDelete = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleExempt = async (row: any) => {
|
||||
const newVal: 0 | 1 = row.is_exempt === 1 ? 0 : 1
|
||||
const actionText = newVal === 1 ? '设为豁免' : '取消豁免'
|
||||
try {
|
||||
await feedback.confirm(`确定要${actionText}该支付单吗?豁免后关联此单的业务订单将跳过定金门槛校验。`)
|
||||
await orderSetExempt({ id: row.id, is_exempt: newVal })
|
||||
feedback.msgSuccess(`${actionText}成功`)
|
||||
getLists()
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序码相关
|
||||
const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
:src="getImageUrl(url)"
|
||||
:preview-src-list="note.tongue_images.map(getImageUrl)"
|
||||
:initial-index="idx"
|
||||
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||
fit="cover"
|
||||
class="timeline-thumb"
|
||||
preview-teleported
|
||||
@@ -66,6 +67,7 @@
|
||||
:src="getImageUrl(url)"
|
||||
:preview-src-list="getImagePreviewList(note.report_files)"
|
||||
:initial-index="getImagePreviewIndex(note.report_files, idx)"
|
||||
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||
fit="cover"
|
||||
class="timeline-thumb"
|
||||
preview-teleported
|
||||
@@ -128,6 +130,9 @@ const reportModel = ref<string[]>([])
|
||||
const prevTongueCount = ref(0)
|
||||
const prevReportCount = ref(0)
|
||||
|
||||
/** 高于诊单抽屉(最高 4000)与多层弹窗叠加后的遮罩,避免预览被挡住 */
|
||||
const IMAGE_PREVIEW_Z_INDEX = 8000
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||
|
||||
const isImageFile = (url: string): boolean => {
|
||||
@@ -211,6 +216,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
||||
<style scoped lang="scss">
|
||||
.timeline-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -111,126 +111,10 @@
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- 患者信息 -->
|
||||
<div class="card patient-card">
|
||||
<div class="card-title">患者信息</div>
|
||||
<div class="patient-hero">
|
||||
<div class="patient-name">{{ apt.patient_name || '—' }}</div>
|
||||
<div class="patient-meta">
|
||||
<div>{{ maskPhone(apt.patient_phone) }} · {{ formatGender(diag.gender) }} · {{ diag.age != null ? diag.age + '岁' : '—' }}</div>
|
||||
<div>{{ diag.height ? diag.height + 'cm' : '—' }} / {{ diag.weight ? diag.weight + 'kg' : '—' }} · {{ diag.region || '—' }}</div>
|
||||
<div>预约:{{ apt.appointment_date }} {{ apt.appointment_time }} · {{ formatPeriod(apt.period) }}</div>
|
||||
<div>医生:{{ apt.doctor_name || '—' }} 医助:{{ apt.assistant_name || '—' }}</div>
|
||||
<div>状态:{{ apt.status_desc || '—' }} · {{ apt.has_prescription ? '已开方' : '未开方' }}</div>
|
||||
<div v-if="apt.remark">备注:{{ apt.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatientInfoCard :apt="apt" :diag="diag" />
|
||||
|
||||
<!-- 患者病例(完整、字段与 tcm/diagnosis/edit.vue 保持同步,只读展示) -->
|
||||
<div class="card case-card">
|
||||
<div class="card-title">
|
||||
患者病例
|
||||
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag.diagnosis_date || apt.appointment_date || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">基本信息</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="诊单ID" :value="diag.patient_id" />
|
||||
<KVItem label="姓名" :value="diag.patient_name" />
|
||||
<KVItem label="身份证号" :value="diag.id_card" />
|
||||
<KVItem label="手机号" :value="maskPhone(diag.phone || apt.patient_phone)" />
|
||||
<KVItem label="性别" :value="textOf('gender')" />
|
||||
<KVItem label="年龄" :value="diag.age != null ? diag.age + '岁' : ''" />
|
||||
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
|
||||
<KVItem label="地区" :value="diag.region" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生命体征 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">生命体征</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="身高" :value="diag.height ? diag.height + ' cm' : ''" />
|
||||
<KVItem label="体重" :value="diag.weight ? diag.weight + ' kg' : ''" />
|
||||
<MetricKVItem label="高压" :value="diag.systolic_pressure" unit=" mmHg" :high="isHighSystolicPressure(diag.systolic_pressure)" />
|
||||
<MetricKVItem label="低压" :value="diag.diastolic_pressure" unit=" mmHg" :high="isHighDiastolicPressure(diag.diastolic_pressure)" />
|
||||
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
|
||||
<MetricKVItem label="空腹血糖" :value="diag.fasting_blood_sugar" unit=" mmol/L" :high="isHighFastingBloodSugar(diag.fasting_blood_sugar, diag.age)" />
|
||||
<KVItem label="在用药物" :value="diag.current_medications" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">主诉</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="当地医院诊断日期" :value="diag.diagnosis_date" />
|
||||
<KVItem label="发现糖尿病病史" :value="formatDiabetesDiscoveryDisplay ? formatDiabetesDiscoveryDisplay(diag.diabetes_discovery_year) : diag.diabetes_discovery_year" />
|
||||
<KVItem label="当地就诊医院" :value="diag.local_hospital_name" />
|
||||
<KVItem label="当地医院诊断结果" :value="joinArray(diag.local_hospital_diagnosis)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 现病史(后端已翻译 *_text) -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">现病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="口腔感觉" :value="textOf('appetite')" />
|
||||
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
|
||||
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
|
||||
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
|
||||
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
|
||||
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
|
||||
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
|
||||
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
|
||||
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
|
||||
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
|
||||
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
|
||||
<KVItem label="小便情况" :value="textOf('urine_condition')" />
|
||||
<KVItem label="大便情况" :value="textOf('stool_condition')" />
|
||||
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
|
||||
<KVItem label="其他补充" :value="diag.symptoms" span="3" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 既往史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">既往史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">其他病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="外伤史" :value="textOf('trauma_history')" />
|
||||
<KVItem label="手术史" :value="textOf('surgery_history')" />
|
||||
<KVItem label="过敏史" :value="textOf('allergy_history')" />
|
||||
<KVItem label="家族病史" :value="textOf('family_history')" />
|
||||
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 病史补充 -->
|
||||
<div class="grid-block" v-if="diag.remark">
|
||||
<div class="grid-title">病史补充</div>
|
||||
<div class="kv-grid one">
|
||||
<KVItem label="" :value="diag.remark" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处方意见 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">处方意见</div>
|
||||
<div class="rx-note">
|
||||
{{ apt.has_prescription ? '已开方。' : '当前未开方。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 患者病例(与 tcm/diagnosis/readonly 共用同一组件,确保两处字段一致) -->
|
||||
<PatientCaseCard :apt="apt" :diag="diag" />
|
||||
|
||||
<!-- 医生备注 & 舌苔照片 -->
|
||||
<div class="card note-card">
|
||||
@@ -242,65 +126,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 血糖血压记录 -->
|
||||
<!-- 日常记录(只读,与只读病例页同口径) -->
|
||||
<div class="card metric-card">
|
||||
<div class="card-title">血糖血压记录</div>
|
||||
<div class="metric-viewport">
|
||||
<el-table
|
||||
:data="bloodRecords"
|
||||
size="small"
|
||||
stripe
|
||||
empty-text="暂无血糖血压记录"
|
||||
class="metric-table"
|
||||
max-height="360"
|
||||
>
|
||||
<el-table-column prop="record_date" label="记录日期" width="110" />
|
||||
<el-table-column prop="record_time" label="记录时间" width="100">
|
||||
<template #default="{ row }">{{ row.record_time || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="空腹血糖(mmol/L)" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age) }">
|
||||
{{ row.fasting_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="餐后2小时血糖(mmol/L)" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age) }">
|
||||
{{ row.postprandial_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="其他血糖(mmol/L)" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighOtherBloodSugar(row.other_blood_sugar, diag.age) }">
|
||||
{{ row.other_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighOtherBloodSugar(row.other_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="血压(mmHg)" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighBloodPressure(row) }">
|
||||
{{ formatBp(row) }}
|
||||
<span v-if="isHighBloodPressure(row)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="西药" width="120">
|
||||
<template #default="{ row }">{{ row.western_medicine || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="胰岛素" width="120">
|
||||
<template #default="{ row }">{{ row.insulin || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="160">
|
||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="card-title">日常记录</div>
|
||||
<DailyMatrix
|
||||
:diagnosis-id="Number(diag?.id ?? 0)"
|
||||
:patient-id="Number(diag?.patient_id ?? 0)"
|
||||
:age="diag?.age ?? null"
|
||||
:patient-name="diag?.patient_name || ''"
|
||||
:read-only="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
@@ -317,7 +152,11 @@
|
||||
>
|
||||
备注
|
||||
</el-button>
|
||||
<el-button class="float-action edit" size="large" @click="handleEditDiagnosis">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/edit']"
|
||||
class="float-action edit"
|
||||
size="large"
|
||||
@click="handleEditDiagnosis">
|
||||
编辑病历
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -349,13 +188,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, h } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
|
||||
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||||
import NoteTimeline from './components/NoteTimeline.vue'
|
||||
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
|
||||
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
|
||||
import DailyMatrix from '@/views/tcm/diagnosis/components/DailyMatrix.vue'
|
||||
import { completeAppointment } from '@/api/doctor'
|
||||
import { getCallSignature } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||
import { formatGender } from '@/utils/diag-display'
|
||||
import {
|
||||
Phone,
|
||||
Search,
|
||||
@@ -366,55 +209,6 @@ import {
|
||||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||||
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
/**
|
||||
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行
|
||||
*/
|
||||
const KVItem = (props: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
|
||||
const val = props.value
|
||||
const empty = val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', props.multiline ? 'multiline' : '', empty ? 'empty' : ''],
|
||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, props.label),
|
||||
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const MetricKVItem = (props: {
|
||||
label: string
|
||||
value: any
|
||||
unit?: string
|
||||
span?: string | number
|
||||
high?: boolean
|
||||
}) => {
|
||||
const empty = props.value === undefined || props.value === null || props.value === ''
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
|
||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, props.label),
|
||||
h(
|
||||
'span',
|
||||
{ class: ['kv-value', 'metric-kv-value', props.high ? 'high' : ''] },
|
||||
empty
|
||||
? '—'
|
||||
: [
|
||||
`${props.value}${props.unit || ''}`,
|
||||
props.high ? h('span', { class: 'metric-up' }, '↑') : null
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const POLL_MS = 5_000
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
@@ -474,9 +268,7 @@ const noteSaving = ref(false)
|
||||
|
||||
const apt = computed<any>(() => detail.value?.appointment || {})
|
||||
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||||
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
|
||||
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||||
|
||||
const todayMeta = computed(() => {
|
||||
const d = new Date()
|
||||
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
@@ -485,38 +277,12 @@ const todayMeta = computed(() => {
|
||||
})
|
||||
|
||||
|
||||
const caseTypeLabel = computed(() => {
|
||||
if (!diag.value) return '初诊'
|
||||
return diag.value.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
||||
})
|
||||
|
||||
/**
|
||||
* 诊单字典字段由后端 AppointmentLogic::enrichDiagnosisLabels 预翻译,
|
||||
* 前端直接读 `<field>_text`;找不到时回退原值。
|
||||
*/
|
||||
const textOf = (field: string): string => {
|
||||
const d: any = diag.value || {}
|
||||
const t = d[field + '_text']
|
||||
if (t !== undefined && t !== null && t !== '') return String(t)
|
||||
const raw = d[field]
|
||||
if (raw === undefined || raw === null || raw === '') return ''
|
||||
return Array.isArray(raw) ? raw.join('、') : String(raw)
|
||||
}
|
||||
|
||||
const todayStr = () => {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '—'
|
||||
if (phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
const formatGender = (g?: number) => (g === 1 ? '男' : g === 0 ? '女' : '—')
|
||||
|
||||
const formatGenderAge = (row: QueueRow) => {
|
||||
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
||||
return parts.filter((p) => p && p !== '—').join(' ')
|
||||
@@ -527,73 +293,6 @@ const formatTimeHM = (time?: string) => {
|
||||
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
|
||||
}
|
||||
|
||||
const formatPeriod = (period?: string) => {
|
||||
if (period === 'morning') return '上午'
|
||||
if (period === 'afternoon') return '下午'
|
||||
return '全天'
|
||||
}
|
||||
|
||||
const formatBp = (row: any) => {
|
||||
const sys = row?.systolic_pressure
|
||||
const dia = row?.diastolic_pressure
|
||||
if (sys == null && dia == null) return '—'
|
||||
return `${sys ?? '—'} / ${dia ?? '—'}`
|
||||
}
|
||||
|
||||
const toNumeric = (value: any) => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const getBloodSugarThresholds = (ageValue: any) => {
|
||||
const age = toNumeric(ageValue)
|
||||
if (age === null) return null
|
||||
if (age < 50) return { fasting: 7, postprandial: 9 }
|
||||
if (age >= 50) return { fasting: 8, postprandial: 10 }
|
||||
return null
|
||||
}
|
||||
|
||||
const isHighFastingBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.fasting
|
||||
}
|
||||
|
||||
const isHighPostprandialBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
const isHighOtherBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
const isHighBloodPressure = (row: any) => {
|
||||
const sys = toNumeric(row?.systolic_pressure)
|
||||
const dia = toNumeric(row?.diastolic_pressure)
|
||||
return (sys !== null && sys > 140) || (dia !== null && dia > 90)
|
||||
}
|
||||
|
||||
const isHighSystolicPressure = (value: any) => {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 140
|
||||
}
|
||||
|
||||
const isHighDiastolicPressure = (value: any) => {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 90
|
||||
}
|
||||
|
||||
const joinArray = (arr: any): string => {
|
||||
if (!arr) return ''
|
||||
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
|
||||
return String(arr)
|
||||
}
|
||||
|
||||
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
||||
const today = todayStr()
|
||||
const res: any = await receptionQueue({
|
||||
|
||||
@@ -65,6 +65,7 @@ const formData = reactive({
|
||||
name: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
data_scope: 1,
|
||||
menu_id: [] as any[]
|
||||
})
|
||||
|
||||
|
||||
@@ -105,24 +105,27 @@
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>转化排行</span>
|
||||
<span class="card-hint">{{ dateRangeText }}</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="rankingChartHasData"
|
||||
class="stats-chart"
|
||||
class="stats-chart stats-chart-wide"
|
||||
:option="rankingChartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无图表数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -139,10 +142,7 @@
|
||||
<el-empty v-else description="暂无金额数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -159,35 +159,82 @@
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none stats-detail-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度</span>
|
||||
<div class="card-header-main">
|
||||
<span class="card-title">明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
|
||||
:row-key="rowKeyFn"
|
||||
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
||||
:default-expand-all="false"
|
||||
size="large"
|
||||
:indent="20"
|
||||
:row-class-name="rowClassName"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
max-height="560"
|
||||
max-height="640"
|
||||
border
|
||||
>
|
||||
<el-table-column
|
||||
:label="entityLabel"
|
||||
prop="name"
|
||||
fixed="left"
|
||||
min-width="280"
|
||||
min-width="300"
|
||||
class-name="dept-name-column"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="dept-name-cell" :title="row.name">
|
||||
{{ row.name }}
|
||||
</span>
|
||||
<div class="dept-name-cell" :title="row.name">
|
||||
<span
|
||||
v-if="isMemberRow(row)"
|
||||
class="member-icon"
|
||||
:class="{
|
||||
'is-leader': row.is_leader,
|
||||
'is-unbound': row.type === 'unbound'
|
||||
}"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</span>
|
||||
<span class="entity-name">{{ row.name }}</span>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.is_leader"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="light"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
组长
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.role"
|
||||
size="small"
|
||||
:type="row.role === '医生' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
{{ row.role }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'unbound'"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
未绑定
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -215,6 +262,7 @@
|
||||
<script setup lang="ts" name="conversionStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
|
||||
import { getConversionStatsOverview } from '@/api/stats'
|
||||
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
|
||||
return String(value ?? 0)
|
||||
}
|
||||
|
||||
const rowKeyFn = (row: Record<string, any>) => {
|
||||
if (row?.id !== undefined && row?.id !== null) {
|
||||
return String(row.id)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const isMemberRow = (row: Record<string, any>) => {
|
||||
return row?.type === 'member' || row?.type === 'unbound'
|
||||
}
|
||||
|
||||
const rowClassName = ({ row }: { row: Record<string, any> }) => {
|
||||
if (row?.type === 'unbound') {
|
||||
return 'member-row member-row--unbound'
|
||||
}
|
||||
if (row?.type === 'member') {
|
||||
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOverview()
|
||||
})
|
||||
@@ -672,6 +741,27 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
@@ -680,19 +770,93 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.dept-name-cell {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #e6f0ff;
|
||||
color: #4a78ff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-icon.is-leader {
|
||||
background: #fff4e0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.member-icon.is-unbound {
|
||||
background: #fde8e8;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.dept-name-column .cell) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.member-row) td {
|
||||
background-color: #fafbff !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader) td {
|
||||
background-color: #fff8ec !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader .entity-name) {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound) td {
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound .entity-name) {
|
||||
color: #ef4444;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-detail-card :deep(.el-card__body) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.stats-chart-wide {
|
||||
height: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
v-model="form.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
class="channel-source-select"
|
||||
@change="onChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
@@ -58,6 +59,20 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="needsChannelSourceDetail"
|
||||
label="自媒体补充:"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model="form.channel_source_detail"
|
||||
clearable
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
class="channel-source-select"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- 预约医生 -->
|
||||
<el-form-item label="预约医生:">
|
||||
<div class="doctor-list">
|
||||
@@ -233,9 +248,44 @@ const form = reactive({
|
||||
date: '',
|
||||
appointmentTime: '',
|
||||
remark: '',
|
||||
channel_source: '' as string
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
'自媒体2Q'
|
||||
])
|
||||
|
||||
function channelNameRequiresSelfMediaDetail(name: string) {
|
||||
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
|
||||
}
|
||||
|
||||
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
|
||||
const selectedChannelDictName = computed(() => {
|
||||
const v = form.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
/** 仅白名单内字典项需填写补充说明 */
|
||||
const needsChannelSourceDetail = computed(() =>
|
||||
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
|
||||
)
|
||||
|
||||
const onChannelSourceChange = (val: string) => {
|
||||
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
form.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
|
||||
const dateOptions = computed<DateOption[]>(() => {
|
||||
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
||||
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
|
||||
selectedDoctorId.value &&
|
||||
form.date &&
|
||||
form.appointmentTime &&
|
||||
!!form.channel_source
|
||||
!!form.channel_source &&
|
||||
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -344,6 +395,7 @@ const open = async (patient: any) => {
|
||||
form.appointmentTime = ''
|
||||
form.remark = ''
|
||||
form.channel_source = ''
|
||||
form.channel_source_detail = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
lastVisit.value = ''
|
||||
@@ -412,7 +464,13 @@ const loadLastVisit = async () => {
|
||||
const loadChannelOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'channels' })
|
||||
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
rows.sort((a: any, b: any) => {
|
||||
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
|
||||
if (ds !== 0) return ds
|
||||
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
|
||||
})
|
||||
channelOptions.value = rows
|
||||
} catch (e) {
|
||||
console.error('加载渠道来源失败:', e)
|
||||
channelOptions.value = []
|
||||
@@ -612,6 +670,10 @@ const handleConfirm = async () => {
|
||||
feedback.msgWarning('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
|
||||
feedback.msgWarning('请填写自媒体渠道补充信息')
|
||||
return
|
||||
}
|
||||
if (blockedByTodayDuplicate.value) {
|
||||
feedback.msgWarning(
|
||||
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
||||
@@ -631,7 +693,8 @@ const handleConfirm = async () => {
|
||||
appointment_time: form.appointmentTime,
|
||||
appointment_type: form.appointmentType,
|
||||
remark: form.remark,
|
||||
channel_source: form.channel_source
|
||||
channel_source: form.channel_source,
|
||||
channel_source_detail: form.channel_source_detail.trim()
|
||||
}
|
||||
|
||||
console.log('提交预约参数:', params)
|
||||
|
||||
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
|
||||
const raw = row.channel_source ?? row.channels ?? ''
|
||||
if (raw === '' || raw === null || raw === undefined) return '—'
|
||||
const key = String(raw)
|
||||
return channelMap.value[key] || key
|
||||
const base = channelMap.value[key] || key
|
||||
const extra = String(row.channel_source_detail ?? '').trim()
|
||||
return extra !== '' ? `${base}(${extra})` : base
|
||||
}
|
||||
|
||||
function sortRows(list: any[]) {
|
||||
|
||||
@@ -2,8 +2,35 @@
|
||||
<div class="assign-log-panel">
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
|
||||
<el-table-column label="操作时间" width="175" prop="create_time_text" />
|
||||
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
|
||||
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
|
||||
<el-table-column label="原医助" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ assistantLogCellText(row, 'from') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新医助" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ assistantLogCellText(row, 'to') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ relatedPoCreatorCell(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快照·业务单创建时间" width="190">
|
||||
<template #header>
|
||||
<span>快照·业务单创建时间</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"
|
||||
>
|
||||
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ relatedPoCreateTimeCell(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" width="110" prop="operator_name" />
|
||||
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
|
||||
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
|
||||
@@ -12,6 +39,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
|
||||
|
||||
@@ -22,12 +50,56 @@ const props = defineProps<{
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
/** 对应日志表 related_po_creator_id:触发本次释放/指派的业务订单 creator */
|
||||
function relatedPoCreatorCell(row: Record<string, unknown>): string {
|
||||
const name = row.related_po_creator_name
|
||||
if (name != null && String(name).trim() !== '') {
|
||||
return String(name)
|
||||
}
|
||||
const id = Number(row.related_po_creator_id)
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
return `ID:${id}`
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** 对应日志表 related_po_create_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 () => {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
|
||||
rows.value = Array.isArray(data) ? data : []
|
||||
const list = Array.isArray(data) ? data : []
|
||||
rows.value = list
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
rows.value = []
|
||||
@@ -46,3 +118,12 @@ watch(
|
||||
|
||||
defineExpose({ refresh: load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assign-log-col-hint {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
<template>
|
||||
<div class="call-record-panel">
|
||||
<el-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
title="视频通话与录制回放"
|
||||
>
|
||||
<!-- <p class="call-record-tip">
|
||||
下列为与本诊单关联的通话记录。医生接通并同步房间号后,服务端会尝试自动发起「云端混流录制」(需配置 CAM 密钥与云点播等);录制完成后由腾讯云回调写入回放地址。同时,管理端浏览器会进行「本地录制」,挂断后自动上传并合并到本条记录。
|
||||
</p>
|
||||
<p class="call-record-tip muted">
|
||||
回调 URL 示例:<code>{{ callbackHint }}</code>
|
||||
(若配置了 <code>trtc.recording_callback_token</code>,请在 URL 后附加
|
||||
<code>?token=你的密钥</code>)
|
||||
</p> -->
|
||||
</el-alert>
|
||||
<div v-if="!readOnly" class="call-record-toolbar">
|
||||
<upload
|
||||
ref="toolbarUploadRef"
|
||||
type="video"
|
||||
direct
|
||||
:multiple="false"
|
||||
:limit="1"
|
||||
:show-progress="true"
|
||||
@success="handleToolbarUploadSuccess"
|
||||
>
|
||||
<el-button type="primary">上传视频</el-button>
|
||||
</upload>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<template #empty>
|
||||
<div class="call-record-empty">
|
||||
<div class="call-record-empty__title">暂无通话记录</div>
|
||||
<div class="call-record-empty__desc">
|
||||
{{
|
||||
readOnly
|
||||
? '暂无录制回放数据。'
|
||||
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table-column label="录制回放" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" width="170" prop="start_time_text" />
|
||||
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
||||
<el-table-column label="通话类型" width="100">
|
||||
@@ -25,7 +39,7 @@
|
||||
{{ row.call_type === 1 ? '语音' : '视频' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间号" width="140">
|
||||
<el-table-column label="房间号" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
@@ -42,27 +56,18 @@
|
||||
{{ row.recording_status_text || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="录制回放" min-width="280">
|
||||
<el-table-column v-if="!readOnly" label="上传回放" width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
|
||||
<div
|
||||
v-for="(url, idx) in row.recording_urls_list"
|
||||
:key="idx"
|
||||
class="recording-item"
|
||||
>
|
||||
<video
|
||||
v-if="isPlayableVideo(url)"
|
||||
:src="url"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="recording-video"
|
||||
/>
|
||||
<el-link v-else :href="url" target="_blank" type="primary">
|
||||
打开链接 {{ idx + 1 }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">暂无</span>
|
||||
<upload
|
||||
type="video"
|
||||
direct
|
||||
:multiple="false"
|
||||
:limit="1"
|
||||
:show-progress="true"
|
||||
@success="(response: any) => handleRowUploadSuccess(row, response)"
|
||||
>
|
||||
<el-button type="primary" plain size="small">上传视频</el-button>
|
||||
</upload>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -70,20 +75,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { getCallRecords } from '@/api/tcm'
|
||||
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
}>()
|
||||
type CallRecordRow = {
|
||||
id: number
|
||||
call_type: number
|
||||
room_id?: string
|
||||
status: number
|
||||
recording_status_text?: string
|
||||
recording_urls_list?: string[]
|
||||
start_time_text?: string
|
||||
end_time_text?: string
|
||||
duration_text?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
diagnosisId: number
|
||||
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
|
||||
readOnly?: boolean
|
||||
}>(),
|
||||
{ readOnly: false }
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
const callbackHint = computed(() => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return `${origin}/api/trtc/recording-notify`
|
||||
})
|
||||
const rows = ref<CallRecordRow[]>([])
|
||||
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
|
||||
|
||||
const load = async () => {
|
||||
if (!props.diagnosisId) return
|
||||
@@ -101,7 +122,7 @@ const load = async () => {
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load()
|
||||
void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -118,37 +139,76 @@ function statusText(status: number) {
|
||||
return m[status] ?? '—'
|
||||
}
|
||||
|
||||
function isPlayableVideo(url: string) {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
|
||||
async function handleToolbarUploadSuccess(response: any) {
|
||||
const fileUrl = normalizeUploadUrl(response)
|
||||
if (!fileUrl) {
|
||||
feedback.msgError('上传成功但未返回视频地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await createManualCallRecord({ diagnosis_id: props.diagnosisId })
|
||||
await attachLocalCallRecording({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
call_record_id: Number(record?.id || 0),
|
||||
file_url: fileUrl
|
||||
} as any)
|
||||
feedback.msgSuccess('视频回放上传成功')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
feedback.msgError(error?.message || '写入回放失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRowUploadSuccess(row: CallRecordRow, response: any) {
|
||||
const fileUrl = normalizeUploadUrl(response)
|
||||
if (!fileUrl) {
|
||||
feedback.msgError('上传成功但未返回视频地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await attachLocalCallRecording({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
call_record_id: Number(row.id || 0),
|
||||
file_url: fileUrl
|
||||
} as any)
|
||||
feedback.msgSuccess('视频回放上传成功')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
feedback.msgError(error?.message || '写入回放失败')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUploadUrl(response: any): string {
|
||||
return String(response?.data?.uri || response?.data?.url || '').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.call-record-panel {
|
||||
.call-record-tip {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
&.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
code {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-list {
|
||||
.call-record-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.recording-video {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
|
||||
.call-record-empty {
|
||||
padding: 28px 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-record-empty__title {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.call-record-empty__desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="diagnosis-todo-list">
|
||||
<div class="toolbar">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!props.diagnosisId || !hasDailyRecordPermission"
|
||||
@click="handleAdd"
|
||||
v-perms="DAILY_RECORD_PERMS"
|
||||
>
|
||||
+ 新增待办
|
||||
</el-button>
|
||||
<el-radio-group v-model="filterStatus" class="ml-3" @change="handleFilterChange">
|
||||
<el-radio-button :value="''">全部</el-radio-button>
|
||||
<el-radio-button :value="0">待执行</el-radio-button>
|
||||
<el-radio-button :value="1">已发送</el-radio-button>
|
||||
<el-radio-button :value="3">失败</el-radio-button>
|
||||
<el-radio-button :value="2">已取消</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button class="ml-3" @click="fetchList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
border
|
||||
class="todo-table"
|
||||
empty-text="暂无待办"
|
||||
row-key="id"
|
||||
>
|
||||
<el-table-column label="提醒时间" prop="remind_time_text" width="160" />
|
||||
<el-table-column label="内容" prop="content" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" effect="light" size="small">
|
||||
{{ row.status_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator_name" width="120">
|
||||
<template #default="{ row }">{{ row.creator_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推送时间" prop="notified_at_text" width="160">
|
||||
<template #default="{ row }">{{ row.notified_at_text || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因" prop="error" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.status === 3" class="text-danger">{{ row.error || '未知错误' }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="!props.readOnly && row.status === 0 && row.can_cancel"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleCancel(row)"
|
||||
v-perms="DAILY_RECORD_PERMS"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 新增弹框 -->
|
||||
<el-dialog
|
||||
v-model="addDialogVisible"
|
||||
title="新增待办"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
|
||||
<el-form-item label="提醒时间" prop="remindAt">
|
||||
<el-date-picker
|
||||
v-model="addForm.remindAt"
|
||||
type="datetime"
|
||||
placeholder="选择提醒时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="x"
|
||||
:disabled-date="disablePastDate"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="提醒内容" prop="content">
|
||||
<el-input
|
||||
v-model="addForm.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="例:3 天后回访患者复测餐后血糖"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="提醒人">
|
||||
<span class="text-muted">企微推送给当前登录账号(创建人本人)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addSubmitting" @click="handleAddSubmit">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { diagnosisTodoLists, diagnosisTodoAdd, diagnosisTodoCancel } from '@/api/tcm'
|
||||
import { computed } from 'vue'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const DAILY_RECORD_PERMS = ['tcm.diagnosis/dailyRecord']
|
||||
|
||||
interface Props {
|
||||
diagnosisId: number | null | undefined
|
||||
patientId?: number | null
|
||||
patientName?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(DAILY_RECORD_PERMS))
|
||||
|
||||
// pager 与项目内 <pagination v-model="pager" /> 约定的字段保持一致
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
size: 15,
|
||||
count: 0,
|
||||
loading: false,
|
||||
lists: [] as any[]
|
||||
})
|
||||
|
||||
const filterStatus = ref<number | ''>('')
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!props.diagnosisId || Number(props.diagnosisId) <= 0 || !hasDailyRecordPermission.value) {
|
||||
pager.lists = []
|
||||
pager.count = 0
|
||||
return
|
||||
}
|
||||
pager.loading = true
|
||||
try {
|
||||
const params: {
|
||||
page_no: number
|
||||
page_size: number
|
||||
diagnosis_id: number
|
||||
status?: number
|
||||
} = {
|
||||
page_no: pager.page,
|
||||
page_size: pager.size,
|
||||
diagnosis_id: Number(props.diagnosisId)
|
||||
}
|
||||
if (filterStatus.value !== '' && filterStatus.value !== undefined) {
|
||||
params.status = Number(filterStatus.value)
|
||||
}
|
||||
const res: any = await diagnosisTodoLists(params)
|
||||
pager.count = Number(res?.count ?? 0)
|
||||
pager.lists = Array.isArray(res?.lists) ? res.lists : []
|
||||
} catch {
|
||||
// request 拦截器已有错误 toast
|
||||
pager.lists = []
|
||||
pager.count = 0
|
||||
} finally {
|
||||
pager.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
pager.page = 1
|
||||
fetchList()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pager.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const statusTagType = (status: number): 'info' | 'success' | 'warning' | 'danger' => {
|
||||
switch (Number(status)) {
|
||||
case 0:
|
||||
return 'info'
|
||||
case 1:
|
||||
return 'success'
|
||||
case 2:
|
||||
return 'warning'
|
||||
case 3:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 新增 ==========
|
||||
const addDialogVisible = ref(false)
|
||||
const addSubmitting = ref(false)
|
||||
const addFormRef = ref<FormInstance>()
|
||||
const addForm = reactive({
|
||||
remindAt: '' as string | number,
|
||||
content: ''
|
||||
})
|
||||
const addRules: FormRules = {
|
||||
remindAt: [{ required: true, message: '请选择提醒时间', trigger: 'change' }],
|
||||
content: [
|
||||
{ required: true, message: '请输入提醒内容', trigger: 'blur' },
|
||||
{ max: 500, message: '内容最多 500 字', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const disablePastDate = (date: Date) => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!props.diagnosisId || !hasDailyRecordPermission.value) {
|
||||
feedback.msgWarning('诊单未保存,无法添加待办')
|
||||
return
|
||||
}
|
||||
addForm.remindAt = ''
|
||||
addForm.content = ''
|
||||
addDialogVisible.value = true
|
||||
nextTick(() => addFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
const handleAddSubmit = async () => {
|
||||
if (!addFormRef.value) return
|
||||
const valid = await addFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
const remindMs = Number(addForm.remindAt)
|
||||
if (!Number.isFinite(remindMs) || remindMs <= Date.now() + 30 * 1000) {
|
||||
feedback.msgWarning('提醒时间必须晚于当前时间至少 30 秒')
|
||||
return
|
||||
}
|
||||
|
||||
addSubmitting.value = true
|
||||
try {
|
||||
await diagnosisTodoAdd({
|
||||
diagnosis_id: Number(props.diagnosisId),
|
||||
content: addForm.content.trim(),
|
||||
remind_time: Math.floor(remindMs / 1000)
|
||||
})
|
||||
addDialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// request 拦截器已有错误 toast
|
||||
} finally {
|
||||
addSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 取消 ==========
|
||||
const handleCancel = async (row: any) => {
|
||||
if (!hasDailyRecordPermission.value || props.readOnly) return
|
||||
try {
|
||||
await feedback.confirm(`确认取消「${row.remind_time_text} · ${truncate(row.content, 30)}」吗?`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await diagnosisTodoCancel({ id: Number(row.id) })
|
||||
feedback.msgSuccess('已取消')
|
||||
fetchList()
|
||||
} catch {
|
||||
// request 拦截器已有错误 toast
|
||||
}
|
||||
}
|
||||
|
||||
const truncate = (text: string, len: number) => {
|
||||
if (!text) return ''
|
||||
return text.length > len ? text.slice(0, len) + '...' : text
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchList })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diagnosis-todo-list {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.todo-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,14 +4,18 @@
|
||||
<p class="panel-tip">
|
||||
展示单聊记录:已合并患者 与
|
||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||
数据来自 IM 漫游;若未开通漫游或某账号与该患者无会话,对应侧无消息。
|
||||
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。
|
||||
</p>
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<el-button type="primary" link :loading="loading" @click="refreshLive">
|
||||
<el-button type="primary" link :loading="syncing" @click="triggerSync">
|
||||
<el-icon class="mr-1"><Promotion /></el-icon>
|
||||
同步最新(后台异步)
|
||||
</el-button>
|
||||
<el-button type="primary" link :loading="loading" @click="reloadArchived">
|
||||
<el-icon class="mr-1"><Refresh /></el-icon>
|
||||
刷新(同步云端最新)
|
||||
重新加载已归档
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -68,8 +72,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch, computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getImChatMessages } from '@/api/tcm'
|
||||
import { Refresh, Promotion } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
|
||||
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
||||
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
|
||||
|
||||
@@ -78,6 +83,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const rawRows = shallowRef<any[]>([])
|
||||
const patientImId = ref('')
|
||||
const patientName = ref('')
|
||||
@@ -139,13 +145,13 @@ function senderLabel(row: any) {
|
||||
return patientName.value ? `患者(${patientName.value})` : '患者'
|
||||
}
|
||||
|
||||
async function load(onlyArchived = false) {
|
||||
async function load() {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = (await getImChatMessages({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
only_archived: onlyArchived ? 1 : 0
|
||||
only_archived: 1
|
||||
})) as {
|
||||
lists?: any[]
|
||||
patient_im_id?: string
|
||||
@@ -162,19 +168,33 @@ async function load(onlyArchived = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLive() {
|
||||
load(false)
|
||||
function reloadArchived() {
|
||||
load()
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
if (!props.diagnosisId) return
|
||||
syncing.value = true
|
||||
try {
|
||||
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
|
||||
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('发起同步失败')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load(true)
|
||||
load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ refresh: refreshLive })
|
||||
defineExpose({ refresh: reloadArchived })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="card case-card">
|
||||
<div class="card-title">
|
||||
患者病例
|
||||
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag?.diagnosis_date || apt?.appointment_date || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">基本信息</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="诊单ID" :value="diag?.patient_id" />
|
||||
<KVItem label="姓名" :value="diag?.patient_name" />
|
||||
<KVItem label="身份证号" :value="diag?.id_card" />
|
||||
<KVItem label="手机号" :value="maskPhone(diag?.phone || apt?.patient_phone)" />
|
||||
<KVItem label="性别" :value="textOf('gender')" />
|
||||
<KVItem label="年龄" :value="diag?.age != null ? diag.age + '岁' : ''" />
|
||||
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
|
||||
<KVItem label="地区" :value="diag?.region" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生命体征 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">生命体征</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="身高" :value="diag?.height ? diag.height + ' cm' : ''" />
|
||||
<KVItem label="体重" :value="diag?.weight ? diag.weight + ' kg' : ''" />
|
||||
<MetricKVItem
|
||||
label="高压"
|
||||
:value="diag?.systolic_pressure"
|
||||
unit=" mmHg"
|
||||
:high="isHighSystolicPressure(diag?.systolic_pressure)"
|
||||
/>
|
||||
<MetricKVItem
|
||||
label="低压"
|
||||
:value="diag?.diastolic_pressure"
|
||||
unit=" mmHg"
|
||||
:high="isHighDiastolicPressure(diag?.diastolic_pressure)"
|
||||
/>
|
||||
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
|
||||
<MetricKVItem
|
||||
label="空腹血糖"
|
||||
:value="diag?.fasting_blood_sugar"
|
||||
unit=" mmol/L"
|
||||
:high="isHighFastingBloodSugar(diag?.fasting_blood_sugar, diag?.age)"
|
||||
/>
|
||||
<KVItem label="在用药物" :value="diag?.current_medications" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">主诉</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="当地医院诊断日期" :value="diag?.diagnosis_date" />
|
||||
<KVItem
|
||||
label="发现糖尿病病史"
|
||||
:value="formatDiabetesDiscoveryDisplay
|
||||
? formatDiabetesDiscoveryDisplay(diag?.diabetes_discovery_year)
|
||||
: diag?.diabetes_discovery_year"
|
||||
/>
|
||||
<KVItem label="当地就诊医院" :value="diag?.local_hospital_name" />
|
||||
<KVItem label="当地医院诊断结果" :value="joinArray(diag?.local_hospital_diagnosis)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 现病史(后端已翻译 *_text) -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">现病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="口腔感觉" :value="textOf('appetite')" />
|
||||
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
|
||||
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
|
||||
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
|
||||
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
|
||||
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
|
||||
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
|
||||
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
|
||||
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
|
||||
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
|
||||
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
|
||||
<KVItem label="小便情况" :value="textOf('urine_condition')" />
|
||||
<KVItem label="大便情况" :value="textOf('stool_condition')" />
|
||||
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
|
||||
<KVItem label="其他补充" :value="diag?.symptoms" span="3" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 既往史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">既往史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">其他病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="外伤史" :value="textOf('trauma_history')" />
|
||||
<KVItem label="手术史" :value="textOf('surgery_history')" />
|
||||
<KVItem label="过敏史" :value="textOf('allergy_history')" />
|
||||
<KVItem label="家族病史" :value="textOf('family_history')" />
|
||||
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 病史补充 -->
|
||||
<div class="grid-block" v-if="diag?.remark">
|
||||
<div class="grid-title">病史补充</div>
|
||||
<div class="kv-grid one">
|
||||
<KVItem label="" :value="diag.remark" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处方意见 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">处方意见</div>
|
||||
<div class="rx-note">
|
||||
{{ apt?.has_prescription ? '已开方。' : '当前未开方。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h } from 'vue'
|
||||
import {
|
||||
isHighSystolicPressure,
|
||||
isHighDiastolicPressure,
|
||||
isHighFastingBloodSugar
|
||||
} from '@/utils/blood-thresholds'
|
||||
import { maskPhone, joinArray, makeTextOf } from '@/utils/diag-display'
|
||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||
|
||||
interface Props {
|
||||
apt?: Record<string, any> | null
|
||||
diag?: Record<string, any> | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
apt: () => ({}),
|
||||
diag: () => ({})
|
||||
})
|
||||
|
||||
const caseTypeLabel = computed(() => {
|
||||
const d: any = props.diag || {}
|
||||
return d.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
||||
})
|
||||
|
||||
const textOf = makeTextOf(() => props.diag || {})
|
||||
|
||||
// ========== 本地函数式子组件 ==========
|
||||
|
||||
/**
|
||||
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行(与抽离前 reception 内联实现一致)
|
||||
*/
|
||||
const KVItem = (kvProps: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
|
||||
const val = kvProps.value
|
||||
const empty =
|
||||
val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', kvProps.multiline ? 'multiline' : '', empty ? 'empty' : ''],
|
||||
style: kvProps.span ? `grid-column: span ${kvProps.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, kvProps.label),
|
||||
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值型 KV:偏高时红色 + ↑ 箭头(与抽离前 reception 内联实现一致)
|
||||
*/
|
||||
const MetricKVItem = (mkvProps: {
|
||||
label: string
|
||||
value: any
|
||||
unit?: string
|
||||
span?: string | number
|
||||
high?: boolean
|
||||
}) => {
|
||||
const empty = mkvProps.value === undefined || mkvProps.value === null || mkvProps.value === ''
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
|
||||
style: mkvProps.span ? `grid-column: span ${mkvProps.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, mkvProps.label),
|
||||
h(
|
||||
'span',
|
||||
{ class: ['kv-value', 'metric-kv-value', mkvProps.high ? 'high' : ''] },
|
||||
empty
|
||||
? '—'
|
||||
: [
|
||||
`${mkvProps.value}${mkvProps.unit || ''}`,
|
||||
mkvProps.high ? h('span', { class: 'metric-up' }, '↑') : null
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background: var(--rx-surface);
|
||||
border: 1px solid var(--rx-line);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow: var(--rx-shadow-sm);
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--rx-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--rx-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 10px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.case-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--rx-soft);
|
||||
font-family: var(--rx-font-data);
|
||||
}
|
||||
|
||||
.grid-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--rx-line);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--rx-accent-strong);
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--rx-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
gap: 8px 16px;
|
||||
|
||||
&.one {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
&.two {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
&.three {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
&.four {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.kv-item) {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
min-width: 0;
|
||||
|
||||
&.multiline {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kv-label {
|
||||
color: var(--rx-soft);
|
||||
flex-shrink: 0;
|
||||
min-width: 58px;
|
||||
}
|
||||
|
||||
.kv-value {
|
||||
color: var(--rx-ink);
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.empty .kv-value {
|
||||
color: #c0c4cc;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.multiline .kv-value {
|
||||
white-space: pre-wrap;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.metric-kv-item .metric-kv-value) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&.high {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.metric-up) {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.rx-note {
|
||||
font-size: 13px;
|
||||
color: var(--rx-muted);
|
||||
line-height: 1.6;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, #fafbfd 0%, #f5f7fb 100%);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--rx-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="card patient-card">
|
||||
<div class="card-title">患者信息</div>
|
||||
<div class="patient-hero">
|
||||
<div class="patient-name">{{ apt?.patient_name || '—' }}</div>
|
||||
<div class="patient-meta">
|
||||
<div>
|
||||
{{ maskPhone(apt?.patient_phone) }} · {{ formatGender(diag?.gender) }} ·
|
||||
{{ diag?.age != null ? diag.age + '岁' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
{{ diag?.height ? diag.height + 'cm' : '—' }} /
|
||||
{{ diag?.weight ? diag.weight + 'kg' : '—' }} · {{ diag?.region || '—' }}
|
||||
</div>
|
||||
<div>
|
||||
预约:{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
|
||||
{{ formatPeriod(apt?.period) }}
|
||||
</div>
|
||||
<div>医生:{{ apt?.doctor_name || '—' }} 医助:{{ apt?.assistant_name || '—' }}</div>
|
||||
<div>
|
||||
状态:{{ apt?.status_desc || '—' }} ·
|
||||
{{ apt?.has_prescription ? '已开方' : '未开方' }}
|
||||
</div>
|
||||
<div v-if="apt?.remark">备注:{{ apt.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { maskPhone, formatGender, formatPeriod } from '@/utils/diag-display'
|
||||
|
||||
interface Props {
|
||||
apt?: Record<string, any> | null
|
||||
diag?: Record<string, any> | null
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
apt: () => ({}),
|
||||
diag: () => ({})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background: var(--rx-surface);
|
||||
border: 1px solid var(--rx-line);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow: var(--rx-shadow-sm);
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--rx-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--rx-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 10px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.patient-hero {
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
|
||||
border: 1px solid #dde7ff;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
top: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(79, 140, 255, 0.08) 0%, transparent 70%);
|
||||
}
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--rx-ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.patient-meta {
|
||||
color: var(--rx-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane>
|
||||
<!-- 血糖血压记录标签页 -->
|
||||
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
||||
<blood-record-list
|
||||
<!-- <el-tab-pane label="跟踪备注" name="trackingNote" :disabled="!formData.id" lazy>
|
||||
<div v-if="formData.id" class="p-4">
|
||||
<TrackingNoteTimeline
|
||||
:notes="trackingNotes"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:readonly="viewOnly"
|
||||
@refresh="fetchTrackingNotes"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane> -->
|
||||
<el-tab-pane
|
||||
v-if="hasDailyRecordPermission"
|
||||
label="日常记录"
|
||||
name="daily"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<daily-matrix
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
:age="formData.age"
|
||||
:patient-name="formData.patient_name"
|
||||
:read-only="viewOnly"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 饮食记录标签页 -->
|
||||
<el-tab-pane label="饮食记录" name="diet" :disabled="!formData.id">
|
||||
<diet-record-list
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
:read-only="viewOnly"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加饮食记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 运动打卡记录标签页 -->
|
||||
<el-tab-pane label="运动打卡记录" name="exercise" :disabled="!formData.id">
|
||||
<exercise-record-list
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
:read-only="viewOnly"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再添加运动打卡记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
|
||||
<!-- 病历记录标签页(开方后自动显示) -->
|
||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||
<div v-if="formData.id" class="bingli-tab-content">
|
||||
@@ -610,6 +606,22 @@
|
||||
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 业务订单:复用处方业务订单接口,按患者ID聚合 / 按当前诊单过滤 -->
|
||||
<el-tab-pane
|
||||
label="业务订单"
|
||||
name="patientOrders"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
|
||||
>
|
||||
<patient-order-list
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id) || 0"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后查看业务订单" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['tcm.diagnosis/huifang'])"
|
||||
label="视频录制回放记录"
|
||||
@@ -694,7 +706,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
|
||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
@@ -702,15 +714,15 @@ import useUserStore from '@/stores/modules/user'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import BloodRecordList from './components/BloodRecordList.vue'
|
||||
import DietRecordList from './components/DietRecordList.vue'
|
||||
import ExerciseRecordList from './components/ExerciseRecordList.vue'
|
||||
import DailyMatrix from './components/DailyMatrix.vue'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
import AssignLogPanel from './components/AssignLogPanel.vue'
|
||||
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
||||
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
|
||||
import TrackingNoteTimeline from './components/TrackingNoteTimeline.vue'
|
||||
import PatientOrderList from './components/PatientOrderList.vue'
|
||||
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||
import { getDoctorNotes } from '@/api/patient'
|
||||
|
||||
@@ -730,6 +742,7 @@ const submitting = ref(false)
|
||||
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
||||
const phoneRevealUnlocked = ref(false)
|
||||
@@ -748,10 +761,22 @@ const fetchDiagNotes = async () => {
|
||||
} catch { diagNotes.value = [] }
|
||||
}
|
||||
|
||||
const trackingNotes = ref<any[]>([])
|
||||
|
||||
const fetchTrackingNotes = async () => {
|
||||
if (!formData.value.id) return
|
||||
try {
|
||||
trackingNotes.value = await diagnosisTrackingNotes({ diagnosis_id: Number(formData.value.id) }) || []
|
||||
} catch { trackingNotes.value = [] }
|
||||
}
|
||||
|
||||
watch(() => activeTab.value, async (tab) => {
|
||||
if (tab === 'doctorNote' && formData.value.id) {
|
||||
await fetchDiagNotes()
|
||||
}
|
||||
if (tab === 'trackingNote' && formData.value.id) {
|
||||
await fetchTrackingNotes()
|
||||
}
|
||||
})
|
||||
|
||||
watch([visible, activeTab], () => {
|
||||
@@ -772,6 +797,9 @@ watch([visible, activeTab], () => {
|
||||
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (activeTab.value === 'daily' && !hasDailyRecordPermission.value) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1118,6 +1146,8 @@ const open = async (type: string, id?: number) => {
|
||||
} else if (type === 'add') {
|
||||
const userStore = useUserStore()
|
||||
formData.value.assistant_id = userStore.userInfo?.id || ''
|
||||
// 字典 diagnosis_type:复诊 → follow_up(见 server/sql/tcm_diagnosis.sql)
|
||||
formData.value.diagnosis_type = 'follow_up'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1579,6 +1609,7 @@ defineExpose({
|
||||
|
||||
.el-drawer__body {
|
||||
background: #f8fafc;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-drawer__footer {
|
||||
@@ -1587,22 +1618,48 @@ defineExpose({
|
||||
box-shadow: 0 -4px 24px rgb(15 23 42 / 7%);
|
||||
}
|
||||
|
||||
/* 多 Tab 标签:保证在窄抽屉内可横向滚动(含 flex 子项 min-width 与 EP 内层 overflow 链) */
|
||||
.tcm-diagnosis-tabs.el-tabs {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__header {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__nav-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(148 163 184 / 55%) transparent;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(148 163 184 / 55%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__nav-scroll {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__nav {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
float: none !important;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.tcm-diagnosis-tabs .el-tabs__item {
|
||||
@@ -1769,4 +1826,3 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -37,12 +37,35 @@
|
||||
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
|
||||
</span>
|
||||
<span
|
||||
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
|
||||
@click="handlePendingAssignTabClick"
|
||||
v-perms="['tcm.diagnosis/assign']"
|
||||
class="pending-assign-wrap"
|
||||
v-perms="['tcm.diagnosis/assign']"
|
||||
>
|
||||
待分配医助
|
||||
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
|
||||
<span
|
||||
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
|
||||
@click="handlePendingAssignTabClick"
|
||||
>
|
||||
待分配医助
|
||||
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
|
||||
</span>
|
||||
<el-date-picker
|
||||
v-if="formData.pending_assign === '1'"
|
||||
v-model="formData.pending_assign_order_month"
|
||||
type="month"
|
||||
placeholder="订单月份"
|
||||
value-format="YYYY-MM"
|
||||
clearable
|
||||
class="pending-assign-month"
|
||||
@change="onPendingAssignOrderMonthChange"
|
||||
/>
|
||||
<el-input
|
||||
v-if="formData.pending_assign === '1'"
|
||||
v-model="formData.pending_assign_keyword"
|
||||
placeholder="搜身份证/诊单ID/患者号/备注…"
|
||||
clearable
|
||||
class="pending-assign-search"
|
||||
@keyup.enter="doSearch"
|
||||
@clear="doSearch"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
@@ -116,22 +139,26 @@
|
||||
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
||||
</div>
|
||||
<div class="table-wrap px-4">
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
row-key="id"
|
||||
size="default"
|
||||
v-loading="pager.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="goReadonly"
|
||||
:row-class-name="getRowClassName"
|
||||
class="diagnosis-table"
|
||||
stripe
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" />
|
||||
<el-table-column label="ID" min-width="50">
|
||||
<el-table-column label="ID" min-width="70">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.id }}</div>
|
||||
<div class="patient-name">
|
||||
{{ row.id }}
|
||||
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,7 +178,7 @@
|
||||
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号" min-width="150">
|
||||
<el-table-column label="挂号" min-width="175">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
class="appointment-cell"
|
||||
@@ -168,6 +195,17 @@
|
||||
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
|
||||
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ apt.time_text || '-' }}</div>
|
||||
<div v-if="canCancelAppointmentItem(apt)" class="apt-actions">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/guahao']"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleCancelAppointmentItem(row, apt)"
|
||||
>
|
||||
取消挂号
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -215,6 +253,20 @@
|
||||
<span v-else class="status-unprescribed">未开方</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="未服务天数" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.last_blood_record_at"
|
||||
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
|
||||
placement="top"
|
||||
>
|
||||
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
|
||||
{{ row.unserved_days ?? '—' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="unserved-days unserved-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="video-watch-col">
|
||||
@@ -239,9 +291,18 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right" align="left">
|
||||
<el-table-column label="操作" width="340" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-cell">
|
||||
<div class="action-cell" @click.stop @dblclick.stop>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="goReadonly(row)"
|
||||
v-perms="['tcm.diagnosis/readonlyDetail']"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/kaifang']"
|
||||
@@ -280,7 +341,12 @@
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||
command="cancelApt"
|
||||
>
|
||||
<el-icon><CircleClose /></el-icon>取消挂号
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahaoLogList'])" command="guahaoLogs"><el-icon><List /></el-icon>挂号日志</el-dropdown-item>
|
||||
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
|
||||
@@ -371,6 +437,7 @@
|
||||
v-model="assignDialogVisible"
|
||||
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
|
||||
width="500px"
|
||||
@closed="resetAssignDialog"
|
||||
>
|
||||
<el-form :model="assignForm" label-width="100px">
|
||||
<el-form-item label="选择医助" required>
|
||||
@@ -380,7 +447,6 @@
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
value-key="id"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in assistantOptions"
|
||||
@@ -391,7 +457,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
|
||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="诊单信息">
|
||||
<div class="text-gray-500">
|
||||
@@ -616,7 +682,7 @@ import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -665,7 +731,11 @@ const formData = reactive({
|
||||
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
|
||||
pending_booking: '' as '' | '1',
|
||||
/** 顶部「已完成」Tab:至少有一条挂号记录为已完成 status=3 */
|
||||
completed_appointment: '' as '' | '1'
|
||||
completed_appointment: '' as '' | '1',
|
||||
/** 待分配医助:按业务订单创建月份筛选 YYYY-MM,空表示不限月份 */
|
||||
pending_assign_order_month: '' as string,
|
||||
/** 待分配医助 Tab:多字段检索(后端扩展至身份证/诊单ID/备注等;不额外套数据权限) */
|
||||
pending_assign_keyword: '' as string
|
||||
})
|
||||
|
||||
const activeTab = ref('all')
|
||||
@@ -700,7 +770,65 @@ const setConfirmed = (v: string) => {
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 待分配 + 宽搜时:请求只吃分页与关键词,不带其它 Tab/日期/筛选(与 buildTcmDiagnosisListRequestPayload 一致) */
|
||||
function isPendingAssignWideKeywordParams(req: Record<string, unknown>): boolean {
|
||||
const pa = req.pending_assign === '1' || req.pending_assign === 1
|
||||
if (!pa) {
|
||||
return false
|
||||
}
|
||||
const k1 = String(req.pending_assign_keyword ?? '').trim()
|
||||
const k2 = String(req.keyword ?? '').trim()
|
||||
return k1 !== '' || k2 !== ''
|
||||
}
|
||||
|
||||
/** 待分配宽搜:仅传分页 + pending_assign + 关键词,丢弃其余查询参数 */
|
||||
function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!isPendingAssignWideKeywordParams(req)) {
|
||||
return req
|
||||
}
|
||||
const k1 = String(req.pending_assign_keyword ?? '').trim()
|
||||
const k2 = String(req.keyword ?? '').trim()
|
||||
const kw = k1 !== '' ? k1 : k2
|
||||
return {
|
||||
page_no: req.page_no,
|
||||
page_size: req.page_size,
|
||||
pending_assign: 1,
|
||||
pending_assign_keyword: kw
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
|
||||
|
||||
/** 宽搜提交前清理界面上的其它检索,避免 Chip 仍显示「已选」但与实际请求不一致 */
|
||||
function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
||||
if (formData.pending_assign !== '1') {
|
||||
return
|
||||
}
|
||||
const kw1 = (formData.pending_assign_keyword ?? '').trim()
|
||||
const kw2 = (formData.keyword ?? '').trim()
|
||||
if (kw1 === '' && kw2 === '') {
|
||||
return
|
||||
}
|
||||
formData.appointment_date = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.has_appointment = ''
|
||||
formData.diagnosis_type = ''
|
||||
formData.syndrome_type = ''
|
||||
formData.assistant_id = ''
|
||||
formData.start_time = ''
|
||||
formData.end_time = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
formData.keyword = ''
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = () => {
|
||||
clearSecondaryFiltersWhenPendingAssignWideSearch()
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -723,6 +851,8 @@ const pendingAssignCount = ref<number | undefined>(undefined)
|
||||
|
||||
const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -735,6 +865,8 @@ const handleDateTabClick = async (value: string) => {
|
||||
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
|
||||
const handlePendingBookingTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = '1'
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
@@ -747,6 +879,8 @@ const handlePendingBookingTabClick = async () => {
|
||||
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
|
||||
const handleCompletedVisitTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
@@ -762,6 +896,18 @@ const handlePendingAssignTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
if (!formData.pending_assign_order_month) {
|
||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||
}
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 待分配:切换订单月份后刷新列表与角标 */
|
||||
const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
||||
formData.pending_assign_order_month = val || ''
|
||||
if (formData.pending_assign !== '1') return
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -779,7 +925,16 @@ const fetchDateCounts = async () => {
|
||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
||||
tcmDiagnosisLists(
|
||||
buildTcmDiagnosisListRequestPayload({
|
||||
pending_assign: 1,
|
||||
page_no: 1,
|
||||
page_size: 1,
|
||||
pending_assign_order_month: formData.pending_assign_order_month,
|
||||
pending_assign_keyword: formData.pending_assign_keyword,
|
||||
keyword: formData.keyword
|
||||
}) as any
|
||||
)
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -807,7 +962,7 @@ function scheduleDeferredDateCounts() {
|
||||
}
|
||||
}
|
||||
|
||||
const SILENT_LIST_POLL_MS = 10_000
|
||||
const SILENT_LIST_POLL_MS = 20_000
|
||||
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
|
||||
@@ -913,7 +1068,7 @@ const getAssistantName = (assistantId: number | string) => {
|
||||
}
|
||||
|
||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
fetchFun: tcmDiagnosisLists,
|
||||
fetchFun: fetchTcmDiagnosisListsForPaging,
|
||||
params: formData
|
||||
})
|
||||
|
||||
@@ -931,6 +1086,8 @@ const handleReset = () => {
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
pager.page = 1
|
||||
@@ -944,6 +1101,19 @@ const isDiagnosisConfirmed = (row: any) => {
|
||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「未服务天数」着色规则(计划 T3):
|
||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||
*/
|
||||
const unservedDaysClass = (n: unknown) => {
|
||||
if (n === null || n === undefined) return 'unserved-muted'
|
||||
const num = Number(n)
|
||||
if (!Number.isFinite(num)) return 'unserved-muted'
|
||||
if (num >= 7) return 'unserved-red'
|
||||
if (num >= 3) return 'unserved-orange'
|
||||
return 'unserved-green'
|
||||
}
|
||||
|
||||
// 行高亮:未确认诊单优先,其次未挂号
|
||||
const getRowClassName = ({ row }: { row: any }) => {
|
||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||
@@ -1146,6 +1316,8 @@ const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
|
||||
|
||||
// 批量指派相关
|
||||
const selectedIds = ref<number[]>([])
|
||||
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
|
||||
const batchAssignIds = ref<number[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const assignLoading = ref(false)
|
||||
const assignMode = ref<'batch' | 'single'>('batch')
|
||||
@@ -1154,6 +1326,11 @@ const assignForm = ref({
|
||||
assistant_id: null as number | null
|
||||
})
|
||||
|
||||
function resetAssignDialog() {
|
||||
batchAssignIds.value = []
|
||||
assignForm.value.assistant_id = null
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
@@ -1175,8 +1352,9 @@ const canBatchCancelAssign = computed(() => {
|
||||
const handleSingleAssign = (row: any) => {
|
||||
assignMode.value = 'single'
|
||||
currentRow.value = row
|
||||
batchAssignIds.value = []
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
|
||||
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1189,6 +1367,7 @@ const handleBatchAssign = () => {
|
||||
assignMode.value = 'batch'
|
||||
currentRow.value = null
|
||||
assignForm.value.assistant_id = null
|
||||
batchAssignIds.value = [...selectedIds.value]
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1207,15 +1386,20 @@ const handleConfirmAssign = async () => {
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
})
|
||||
} else {
|
||||
// 批量指派
|
||||
const promises = selectedIds.value.map(id =>
|
||||
tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
// 批量指派:使用打开弹窗时的 id 快照(避免弹窗内操作导致表格勾选被清空后提交为空)
|
||||
const ids = batchAssignIds.value
|
||||
if (!ids.length) {
|
||||
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
await tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id!,
|
||||
})
|
||||
)
|
||||
await Promise.all(promises)
|
||||
}
|
||||
selectedIds.value = []
|
||||
batchAssignIds.value = []
|
||||
}
|
||||
|
||||
assignDialogVisible.value = false
|
||||
@@ -1263,10 +1447,13 @@ const handleEdit = (id: number) => {
|
||||
editRef.value.open('edit', id)
|
||||
}
|
||||
|
||||
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方 */
|
||||
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
|
||||
function prescriptionActionLabel(row: any) {
|
||||
if (formData.pending_assign === '1') {
|
||||
return '开方'
|
||||
}
|
||||
if (
|
||||
row?.prescription_audit_status === 1 &&
|
||||
Number(row?.prescription_audit_status) === 1 &&
|
||||
Number(row?.prescription_void_status) !== 1
|
||||
) {
|
||||
return '查看'
|
||||
@@ -1371,13 +1558,29 @@ const appointmentCellClasses = (row: any) => {
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制) */
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
|
||||
}
|
||||
|
||||
// 取消挂号
|
||||
/** 单条挂号记录是否允许取消(已预约 1 / 已过号 4) */
|
||||
const canCancelAppointmentItem = (apt: any) => {
|
||||
const s = Number(apt?.status)
|
||||
return !!(apt?.id && (s === 1 || s === 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* 「更多」里行级取消:仅当列表只展示一条挂号时可点,避免多条时与主键 appointment_id 不对齐
|
||||
* 多条时在「挂号」列每条旁单独取消
|
||||
*/
|
||||
const canCancelAppointmentFromDropdown = (row: any) => {
|
||||
if (!canCancelAppointmentRow(row)) return false
|
||||
const rows = appointmentRows(row)
|
||||
return rows.length <= 1
|
||||
}
|
||||
|
||||
// 取消挂号(行级,唯一一条时)
|
||||
const handleCancelAppointment = async (row: any) => {
|
||||
if (!canCancelAppointmentRow(row)) {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -1388,9 +1591,39 @@ const handleCancelAppointment = async (row: any) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
const rows = appointmentRows(row)
|
||||
const target = rows.length === 1 ? rows[0] : null
|
||||
const aptId = target?.id && canCancelAppointmentItem(target) ? target.id : row.appointment_id
|
||||
try {
|
||||
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
|
||||
await cancelAppointment({ id: row.appointment_id })
|
||||
await cancelAppointment({ id: aptId })
|
||||
feedback.msgSuccess('取消挂号成功')
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
|
||||
await fetchGuahaoLogs(row.id)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消指定挂号(多条展示时用) */
|
||||
const handleCancelAppointmentItem = async (row: any, apt: any) => {
|
||||
if (!canCancelAppointmentItem(apt)) {
|
||||
const s = Number(apt?.status)
|
||||
if (s === 3) {
|
||||
feedback.msgWarning('已完成就诊,无法取消挂号')
|
||||
} else {
|
||||
feedback.msgWarning('该挂号无法取消')
|
||||
}
|
||||
return
|
||||
}
|
||||
const doc = apt.doctor_name || '—'
|
||||
const t = apt.time_text || '—'
|
||||
try {
|
||||
await feedback.confirm(`确定取消「${doc}」${t} 的挂号吗?(患者:${row.patient_name || '—'})`)
|
||||
await cancelAppointment({ id: apt.id })
|
||||
feedback.msgSuccess('取消挂号成功')
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -1706,6 +1939,17 @@ const handleDeleteRecord = async (id: number) => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
|
||||
const goReadonly = (row: any) => {
|
||||
// 没分配 readonlyDetail 权限的管理员:双击行也不跳转(按钮已用 v-perms 隐藏)
|
||||
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
|
||||
const id = Number(row?.id)
|
||||
if (!id || id <= 0) return
|
||||
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
@@ -1829,6 +2073,27 @@ onUnmounted(() => {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.pending-assign-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
|
||||
.chip-pending {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-assign-month {
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.pending-assign-search {
|
||||
width: 220px;
|
||||
max-width: 42vw;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
@@ -2013,6 +2278,11 @@ onUnmounted(() => {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.apt-actions {
|
||||
margin-top: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&.apt-item-active {
|
||||
.apt-badge {
|
||||
color: #3a4acc;
|
||||
@@ -2091,6 +2361,33 @@ onUnmounted(() => {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||
.unserved-days {
|
||||
display: inline-block;
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.unserved-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.unserved-green {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.unserved-orange {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.unserved-red {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.diagnosis-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
</div>
|
||||
<div class="dh5-card-sub">
|
||||
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
|
||||
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dh5-card-tags">
|
||||
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dh5-tag-new {
|
||||
display: inline-block;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dh5-meta-gender,
|
||||
.dh5-meta-age {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" />
|
||||
<el-table-column label="ID" min-width="50">
|
||||
<el-table-column label="ID" min-width="70">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.id }}</div>
|
||||
<div class="patient-name">
|
||||
{{ row.id }}
|
||||
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1088,7 +1090,7 @@ const handleSingleAssign = (row: any) => {
|
||||
assignMode.value = 'single'
|
||||
currentRow.value = row
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
|
||||
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+16
-2
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { fileURLToPath, URL } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
||||
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
|
||||
return stylePaths
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const rawBase = (env.VITE_APP_BASE_URL || '').trim().replace(/\/+$/, '')
|
||||
const uploadsProxyTarget =
|
||||
rawBase.startsWith('http://') || rawBase.startsWith('https://')
|
||||
? rawBase
|
||||
: 'http://127.0.0.1:8080'
|
||||
|
||||
return {
|
||||
base: '/admin/',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
hmr: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
|
||||
'/uploads': {
|
||||
target: uploadsProxyTarget,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api-proxy': {
|
||||
target: 'https://admin.zhenyangtang.com.cn',
|
||||
changeOrigin: true,
|
||||
@@ -112,4 +125,5 @@ export default defineConfig({
|
||||
__VUE_PROD_HYDRATION__: true,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
open_basedir=/www/wwwroot/zyt/server/:/tmp/
|
||||
@@ -15,6 +15,7 @@
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
|
||||
use app\common\service\DirectUploadService;
|
||||
use app\common\service\UploadService;
|
||||
use Exception;
|
||||
use think\response\Json;
|
||||
@@ -77,4 +78,41 @@ class UploadController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 浏览器直传 OSS - 签发 STS 临时凭证
|
||||
* @return Json
|
||||
*/
|
||||
public function ossCredentials()
|
||||
{
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
try {
|
||||
$result = DirectUploadService::issueCredentials($type);
|
||||
return $this->success('ok', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 浏览器直传 OSS - 上传完成回执(写 file 表 + HEAD 校验)
|
||||
* @return Json
|
||||
*/
|
||||
public function ossConfirm()
|
||||
{
|
||||
try {
|
||||
$result = DirectUploadService::confirm([
|
||||
'type' => trim((string)$this->request->post('type', 'video')),
|
||||
'key' => trim((string)$this->request->post('key', '')),
|
||||
'name' => trim((string)$this->request->post('name', '')),
|
||||
'size' => (int)$this->request->post('size', 0),
|
||||
'content_type' => trim((string)$this->request->post('content_type', '')),
|
||||
'cid' => (int)$this->request->post('cid', 0),
|
||||
'admin_id' => $this->adminId,
|
||||
]);
|
||||
return $this->success('上传成功', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -126,7 +126,11 @@ class DeptController extends BaseAdminController
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeptPerformanceTargetController extends BaseAdminController
|
||||
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
|
||||
$ym = (string) $params['year_month'];
|
||||
|
||||
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym));
|
||||
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
public function batchSave()
|
||||
@@ -37,12 +37,18 @@ class DeptPerformanceTargetController extends BaseAdminController
|
||||
$ym,
|
||||
$items,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
(string) ($this->adminInfo['name'] ?? '')
|
||||
);
|
||||
if ($result === false) {
|
||||
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
|
||||
{
|
||||
OrderActionLogLogic::record($orderId, (int) $this->adminId, $this->adminInfo, $action, $summary);
|
||||
|
||||
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
$result = ConversionLogic::overview($this->request->get());
|
||||
$result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
|
||||
|
||||
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.yeji-stats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
|
||||
* - GET stats.yeji-stats/dept-options 部门下拉
|
||||
* - GET stats.yeji-stats/leaderboard 医助排行榜(按展示部门分表)
|
||||
* - GET stats.yejiStats/overview 单区间
|
||||
* - GET stats.yejiStats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
|
||||
* - GET stats.yejiStats/deptOptions 部门下拉
|
||||
* - GET stats.yejiStats/leaderboard 医助排行榜(按展示部门分表)
|
||||
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
|
||||
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
||||
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
||||
*/
|
||||
class YejiStatsController extends BaseAdminController
|
||||
{
|
||||
@@ -23,7 +26,7 @@ class YejiStatsController extends BaseAdminController
|
||||
@set_time_limit(120);
|
||||
$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([
|
||||
'ranges' => $ranges,
|
||||
'tables' => YejiStatsLogic::overviewBatch($ranges, $base),
|
||||
'tables' => YejiStatsLogic::overviewBatch($ranges, $base, $this->adminId, $this->adminInfo),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deptOptions()
|
||||
{
|
||||
return $this->data(YejiStatsLogic::deptOptions());
|
||||
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
/** 渠道(标签)下拉,按 source_group_name 分组 */
|
||||
@@ -100,6 +103,42 @@ class YejiStatsController extends BaseAdminController
|
||||
@set_time_limit(120);
|
||||
$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\logic\order\OrderActionLogLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\WechatChatRecord;
|
||||
@@ -113,12 +114,87 @@ class DiagnosisController extends BaseAdminController
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
|
||||
|
||||
$params = (new DiagnosisValidate())->goCheck('id');
|
||||
$result = DiagnosisLogic::detail($params);
|
||||
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||
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 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
||||
*/
|
||||
@@ -332,6 +408,36 @@ class DiagnosisController extends BaseAdminController
|
||||
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 房间号到当前诊单通话记录(便于云端录制回调关联)
|
||||
* @return \think\response\Json
|
||||
@@ -396,6 +502,24 @@ class DiagnosisController extends BaseAdminController
|
||||
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 进房参数,仅拉流)
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,17 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
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' => [
|
||||
'AppInit' => [],
|
||||
'HttpRun' => [],
|
||||
'HttpEnd' => ['app\adminapi\listener\OperationLog'],
|
||||
// 关闭管理员操作日志写入 zyt_operation_log(需记录时恢复下行 listener)
|
||||
'HttpEnd' => [],
|
||||
'LogLevel' => [],
|
||||
'LogWrite' => [],
|
||||
]
|
||||
|
||||
@@ -82,7 +82,7 @@ class AuthMiddleware
|
||||
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
|
||||
$AdminUris = $this->formatUrl($AdminUris);
|
||||
|
||||
if (in_array($accessUri, $AdminUris)) {
|
||||
if (in_array($accessUri, $AdminUris) || $this->matchPermissionAlias($accessUri, $AdminUris)) {
|
||||
return $next($request);
|
||||
}
|
||||
return JsonService::fail('权限不足,无法访问或操作');
|
||||
@@ -103,6 +103,25 @@ class AuthMiddleware
|
||||
}, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord,
|
||||
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
|
||||
*/
|
||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
if (!in_array('tcm.diagnosis/dailyrecord', $adminUris, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($accessUri, [
|
||||
'tcm.diagnosistodo/lists',
|
||||
'tcm.diagnosistodo/add',
|
||||
'tcm.diagnosistodo/cancel',
|
||||
'tcm.diagnosis/trackingnotes',
|
||||
'tcm.diagnosis/addtrackingnote',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
|
||||
*/
|
||||
@@ -118,4 +137,4 @@ class AuthMiddleware
|
||||
|
||||
return in_array($c, ['auth.admin', 'doctor.appointment'], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ namespace app\adminapi\lists\tcm;
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\CallRecord;
|
||||
@@ -62,36 +64,36 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
|
||||
// 待分配医助筛选:assistant_id 为空或 0(放在最前面判断,便于跳过医助角色的自我过滤)
|
||||
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
|
||||
$pendingWideSearch = $this->isPendingAssignWideKeywordSearchActive();
|
||||
// 仅「待分配列表」收窄到未分配医助;宽搜不套此池,与「全部」一致可命中已分配医助的诊单
|
||||
$narrowPendingAssignPool = $pendingAssign && !$pendingWideSearch;
|
||||
|
||||
// 构建查询
|
||||
$query = Diagnosis::where($this->searchWhere);
|
||||
// 构建查询(待分配+关键词检索时不带诊单启用/禁用 status 条件,并弱化挂号/确认等 Tab 残留参数)
|
||||
$baseWhere = $pendingWideSearch
|
||||
? $this->searchWhereWithoutDiagnosisStatus($this->searchWhere)
|
||||
: $this->searchWhere;
|
||||
$query = Diagnosis::where($baseWhere);
|
||||
|
||||
// 如果是医助角色(role_id=2),只显示自己添加的患者;
|
||||
// 但当前在查"待分配医助"时应放开,否则 NULL 记录永远不会匹配 assistant_id=adminId
|
||||
if (in_array(2, $roleIds) && !$pendingAssign) {
|
||||
// 医助角色(role_id=2):仅在普通列表(非待分配、非待分配宽搜)下限制为本人诊单;待分配窄列表与宽搜均不设此限
|
||||
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
|
||||
$query->where('assistant_id', $this->adminId);
|
||||
}
|
||||
|
||||
if (!$pendingAssign) {
|
||||
// 数据权限:待分配(窄列表与宽搜)均不按 assistant 范围过滤;宽搜额外豁免以便全库关键词命中(与即时需求对齐)
|
||||
if (!$pendingAssign && !$pendingWideSearch) {
|
||||
$this->applyDataScopeByOwner($query, 'assistant_id');
|
||||
}
|
||||
|
||||
// 关键字搜索:支持患者姓名或手机号(模糊匹配)
|
||||
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 . '%');
|
||||
});
|
||||
}
|
||||
// 关键字:待分配 Tab 用多字段宽搜;其它 Tab 仍为姓名/手机。
|
||||
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
|
||||
|
||||
// 待分配医助:assistant_id 为 NULL 或 0(使用 whereRaw 避免闭包 whereOr 歧义)
|
||||
if ($pendingAssign) {
|
||||
// 待分配医助列表:assistant_id 为 NULL 或 0(宽搜不加此条件)
|
||||
if ($narrowPendingAssignPool) {
|
||||
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
|
||||
}
|
||||
|
||||
// 是否确认诊单:1=已确认 0=未确认
|
||||
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||
// 是否确认诊单:1=已确认 0=未确认(待分配+关键词检索时不限制确认态,避免搜不到)
|
||||
if (!$pendingWideSearch && isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
|
||||
$confirmed = (int)$this->params['diagnosis_confirmed'];
|
||||
$dvrTbl = (new DiagnosisViewRecord())->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']);
|
||||
$aptTbl = (new Appointment())->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)");
|
||||
}
|
||||
|
||||
// 挂号状态:1=已挂号(含已预约/已完成/已过号)0=未挂号
|
||||
if (isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
||||
// 挂号状态:1=已挂号 0=未挂号(待分配+关键词检索时不限制)
|
||||
if (!$pendingWideSearch && isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
|
||||
$aptTbl = (new Appointment())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
if ((int)$this->params['has_appointment'] === 1) {
|
||||
@@ -122,22 +124,24 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
}
|
||||
|
||||
// 至少有一条挂号为「已完成」(status=3),用于列表顶部「已完成」Tab
|
||||
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
||||
// 至少有一条挂号为「已完成」(status=3)(待分配+关键词检索时不限制)
|
||||
if (!$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
|
||||
$aptTbl = (new Appointment())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||
}
|
||||
|
||||
// 仅已开方(有处方)的诊单:随访列表传 only_has_prescription=1;问诊等页面不传则不过滤
|
||||
if (isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||
|
||||
// 仅已开方(待分配+关键词检索时不限制)
|
||||
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");
|
||||
}
|
||||
|
||||
// 医助所属部门(随访等页面传 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'];
|
||||
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
||||
$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();
|
||||
$aptTbl = (new Appointment())->getTable();
|
||||
$minAptDateCond = '';
|
||||
if (!empty($this->params['appointment_date'])) {
|
||||
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||
$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 . ')';
|
||||
|
||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序,最新在最前;有 appointment_date 时仍只在该日内比
|
||||
$isCompletedTab = isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
|
||||
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
||||
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 . ')';
|
||||
$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 {
|
||||
$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
|
||||
->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'])
|
||||
->orderRaw($orderRaw)
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
@@ -381,6 +385,25 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
foreach ($lists as &$item) {
|
||||
$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;
|
||||
}
|
||||
@@ -458,33 +481,29 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$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) {
|
||||
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
|
||||
$query->where('assistant_id', $this->adminId);
|
||||
}
|
||||
|
||||
if (!$pendingAssign) {
|
||||
if (!$pendingAssign && !$pendingWideSearch) {
|
||||
$this->applyDataScopeByOwner($query, 'assistant_id');
|
||||
}
|
||||
|
||||
// 关键字搜索:支持患者姓名或手机号(模糊匹配)
|
||||
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 . '%');
|
||||
});
|
||||
}
|
||||
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
|
||||
|
||||
// 待分配医助:assistant_id 为 NULL 或 0
|
||||
if ($pendingAssign) {
|
||||
if ($narrowPendingAssignPool) {
|
||||
$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'];
|
||||
$dvrTbl = (new DiagnosisViewRecord())->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']);
|
||||
$aptTbl = (new Appointment())->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();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
if ((int)$this->params['has_appointment'] === 1) {
|
||||
@@ -516,12 +535,171 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
|
||||
// 至少有一条挂号为「已完成」(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();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 待分配 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,6 +6,7 @@ namespace app\adminapi\lists\tcm;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
@@ -19,16 +20,19 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
|
||||
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
use HasDataScopeFilter;
|
||||
|
||||
private const SCENE_DIAGNOSIS_EDIT = 'diagnosis_edit';
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
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'],
|
||||
'between_time' => 'create_time',
|
||||
];
|
||||
@@ -95,13 +99,57 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
{
|
||||
$this->applyDoctorAssistantFilters($query);
|
||||
$this->applyPatientKeywordFilter($query);
|
||||
$this->applyPatientIdFilter($query);
|
||||
$this->applyExpressCompanyFilter($query);
|
||||
$this->applyExpressKeywordFilter($query);
|
||||
$this->applySupplyModeFilter($query);
|
||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||
$query->where('creator_id', $this->adminId);
|
||||
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||
$this->applyCreatorOrOwnPrescriptionVisibility($query);
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
}
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
// 业绩看板侧栏等:不展示履约已取消(4),与 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 为全部复诊分项合计)。
|
||||
*/
|
||||
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::listErCenterRevisitOrderIdsForAssistant(
|
||||
$assistantId,
|
||||
$slot,
|
||||
(int) $t0,
|
||||
(int) $t1
|
||||
);
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +220,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
|
||||
*/
|
||||
@@ -201,6 +316,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 +354,36 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
|
||||
/**
|
||||
* 统计用查询骨架:不含 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');
|
||||
$this->applyDoctorAssistantFilters($query);
|
||||
$this->applyPatientKeywordFilter($query);
|
||||
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
|
||||
$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);
|
||||
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
|
||||
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
|
||||
$this->applyAssistantDiagnosisOnlyFilter($query);
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
|
||||
return $query;
|
||||
// 与看板「合计业绩」一致:NULL 安全剔除履约已取消(4),勿用 `<>` 以免误丢 NULL 状态行
|
||||
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
|
||||
}
|
||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||
$query->where('creator_id', $this->adminId);
|
||||
if ($applyYejiDrawerChannel) {
|
||||
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
|
||||
}
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
|
||||
return $query;
|
||||
}
|
||||
@@ -365,8 +511,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
if ($diagIdsForAssist !== []) {
|
||||
$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) {
|
||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
|
||||
$pid = (int) ($item['id'] ?? 0);
|
||||
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
|
||||
'prescription_order_id',
|
||||
@@ -380,11 +531,19 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
|
||||
|
||||
// 添加创建人姓名
|
||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||
$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);
|
||||
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
|
||||
@@ -392,6 +551,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
unset($item);
|
||||
|
||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
||||
PrescriptionOrderLogic::appendLinkedAppointmentsToListRows($lists, $this->params);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
@@ -400,7 +563,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$s = $this->computeListStatsForExtend();
|
||||
$focus = $this->computeFocusCountsForExtend();
|
||||
|
||||
return [
|
||||
$base = [
|
||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||
'stats_order_amount' => $s['order_amount'],
|
||||
@@ -422,6 +585,119 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'focus_pending_ship_count' => $focus['pending_ship'],
|
||||
'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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板侧栏专用:约诊/接诊条数,与 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),不依赖 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 +710,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$query = PrescriptionOrder::where($this->searchWhereWithoutStatusFilters())->whereNull('delete_time');
|
||||
$this->applyPermissionAndExtraFilters($query);
|
||||
|
||||
$pendingRxQuery = clone $query;
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($pendingRxQuery, '');
|
||||
|
||||
return [
|
||||
// 处方审核待处理
|
||||
'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(),
|
||||
// 履约待发货
|
||||
@@ -551,6 +830,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$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)) {
|
||||
$scope = 'assistant';
|
||||
} elseif (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
|
||||
$scope = 'own_rx';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +856,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 按开方医生(关联处方 creator_id)/ 诊单医助(关联诊单 assistant_id)筛选
|
||||
* 按开方医生(关联处方 creator_id)/ 诊单医助筛选
|
||||
*
|
||||
* 医助:诊单 assistant_id 与业务订单 creator_id 并举——医助常代建单但诊单未写 assistant_id。
|
||||
*/
|
||||
private function applyDoctorAssistantFilters($query): void
|
||||
{
|
||||
@@ -590,10 +873,49 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
|
||||
$assistantId = (int) $this->params['assistant_id'];
|
||||
$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.assistant_id = {$assistantId}");
|
||||
$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 ((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
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
$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 {
|
||||
@@ -607,13 +929,41 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$inList = implode(',', $deptIds);
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$adTbl = (new AdminDept())->getTable();
|
||||
$query->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})"
|
||||
$query->whereRaw(
|
||||
YejiStatsLogic::sqlPrescriptionOrderListDeptSubtreeMatchCreatorFirst($poTbl, $diagTbl, $adTbl, $inList)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 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;
|
||||
}
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$rxTbl = (new Prescription())->getTable();
|
||||
$inList = implode(',', $adminIds);
|
||||
$query->whereRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg "
|
||||
. "LEFT JOIN `{$rxTbl}` rx ON rx.`id` = `{$poTbl}`.`prescription_id` AND rx.`delete_time` IS NULL "
|
||||
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND dg.`delete_time` IS NULL AND `{$poTbl}`.`diagnosis_id` > 0 AND "
|
||||
. '(CASE WHEN dg.`assistant_id` > 0 THEN dg.`assistant_id` '
|
||||
. "WHEN `{$poTbl}`.`creator_id` > 0 AND (rx.`id` IS NULL OR NOT (`{$poTbl}`.`creator_id` <=> rx.`creator_id`)) THEN `{$poTbl}`.`creator_id` "
|
||||
. 'ELSE 0 END) IN (' . $inList . '))'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按诊单患者姓名 / 手机号模糊检索(关联 diagnosis_id)
|
||||
*/
|
||||
@@ -636,6 +986,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}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按快递单号 / 快递公司 模糊检索
|
||||
*/
|
||||
|
||||
@@ -86,13 +86,17 @@ class RoleLogic extends BaseLogic
|
||||
try {
|
||||
$menuId = !empty($params['menu_id']) ? $params['menu_id'] : [];
|
||||
|
||||
SystemRole::update([
|
||||
$roleRow = [
|
||||
'id' => $params['id'],
|
||||
'name' => $params['name'],
|
||||
'desc' => $params['desc'] ?? '',
|
||||
'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)) {
|
||||
SystemRoleMenu::where(['role_id' => $params['id']])->delete();
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
|
||||
namespace app\adminapi\logic\dept;
|
||||
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
|
||||
/**
|
||||
@@ -64,7 +65,9 @@ class DeptLogic extends BaseLogic
|
||||
if (!empty($lists)) {
|
||||
$pid = min(array_column($lists, 'pid'));
|
||||
}
|
||||
return self::getTree($lists, $pid);
|
||||
$tree = self::getTree($lists, $pid);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +144,557 @@ class DeptLogic extends BaseLogic
|
||||
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)
|
||||
->whereRaw('NOT (o.fulfillment_status <=> ?)', [4]);
|
||||
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'])
|
||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。
|
||||
*
|
||||
* @param list<int> $subtreeDeptIds
|
||||
*
|
||||
* @return array{
|
||||
* totals: array<int, int>,
|
||||
* slots: array<int, array<int, int>>,
|
||||
* by_assistant_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_assistant_slots' => []];
|
||||
}
|
||||
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
|
||||
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
|
||||
if ($orderRows === []) {
|
||||
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []];
|
||||
}
|
||||
|
||||
$byPatient = [];
|
||||
foreach ($orderRows as $r) {
|
||||
$pid = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$byPatient[$pid][] = $r;
|
||||
}
|
||||
|
||||
$assistantNeed = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
$n = \count($orders);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
if ($aid > 0) {
|
||||
$assistantNeed[$aid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$assistantIds = array_keys($assistantNeed);
|
||||
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet);
|
||||
|
||||
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ],slot≥2 */
|
||||
$leafByDeptSlot = [];
|
||||
/** @var array<int, array<int, int>> $byAssistantSlot */
|
||||
$byAssistantSlot = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
$n = \count($orders);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$slot = $i + 1;
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
$deptId = $canonicalDept[$aid] ?? null;
|
||||
if ($deptId === null || !isset($subtreeSet[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($leafByDeptSlot[$deptId])) {
|
||||
$leafByDeptSlot[$deptId] = [];
|
||||
}
|
||||
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
|
||||
if ($aid > 0) {
|
||||
if (!isset($byAssistantSlot[$aid])) {
|
||||
$byAssistantSlot[$aid] = [];
|
||||
}
|
||||
$byAssistantSlot[$aid][$slot] = ($byAssistantSlot[$aid][$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 ($byAssistantSlot as $aid => $sm) {
|
||||
if ($sm !== []) {
|
||||
ksort($byAssistantSlot[$aid], SORT_NUMERIC);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'totals' => $rollupTotals,
|
||||
'slots' => $rollupSlots,
|
||||
'by_assistant_slots' => $byAssistantSlot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 二中心子树:各部门复诊合计与分项(第 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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* 二中心子树内:各医助成交的复诊业务单笔数(合计 + 复诊2/3/…分项),口径与部门 rollup 同源。
|
||||
*
|
||||
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
|
||||
*/
|
||||
public static function getErCenterRevisitCountsByAssistant(?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_assistant_slots'];
|
||||
$totals = [];
|
||||
$slots = [];
|
||||
foreach ($by as $aid => $slotMap) {
|
||||
$aid = (int) $aid;
|
||||
$totals[$aid] = (int) array_sum($slotMap);
|
||||
$slots[$aid] = $slotMap;
|
||||
}
|
||||
|
||||
return ['totals' => $totals, 'slots' => $slots];
|
||||
}
|
||||
|
||||
/**
|
||||
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。
|
||||
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function listErCenterRevisitOrderIdsForAssistant(
|
||||
int $assistantId,
|
||||
int $revisitSlot,
|
||||
int $orderStartTs,
|
||||
int $orderEndTs
|
||||
): array {
|
||||
if ($assistantId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
|
||||
return [];
|
||||
}
|
||||
if ($revisitSlot < 0 || $revisitSlot === 1) {
|
||||
return [];
|
||||
}
|
||||
$erRoots = self::findErCenterRootDeptIds();
|
||||
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||
if ($subtreeIds === []) {
|
||||
return [];
|
||||
}
|
||||
$subtreeSet = array_fill_keys($subtreeIds, true);
|
||||
$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;
|
||||
}
|
||||
$assistantNeed = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
$n = \count($orders);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
if ($aid > 0) {
|
||||
$assistantNeed[$aid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $subtreeSet);
|
||||
$ids = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
$n = \count($orders);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$slot = $i + 1;
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
if ($aid !== $assistantId) {
|
||||
continue;
|
||||
}
|
||||
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
|
||||
continue;
|
||||
}
|
||||
$dcan = $canonicalDept[$aid] ?? null;
|
||||
if ($dcan === null || !isset($subtreeSet[$dcan])) {
|
||||
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;
|
||||
}
|
||||
$assistantNeed = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
$n = \count($orders);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
if ($aid > 0) {
|
||||
$assistantNeed[$aid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $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;
|
||||
}
|
||||
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
|
||||
if ($aid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$canon = $canonicalDept[$aid] ?? null;
|
||||
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
|
||||
continue;
|
||||
}
|
||||
$counts[$aid] = ($counts[$aid] ?? 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 $aid => $cnt) {
|
||||
$rows[] = [
|
||||
'admin_id' => (int) $aid,
|
||||
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)),
|
||||
'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 列表树状结构
|
||||
* @param $array
|
||||
@@ -271,15 +825,74 @@ class DeptLogic extends BaseLogic
|
||||
*/
|
||||
public static function getAllData()
|
||||
{
|
||||
$data = Dept::where(['status' => YesNoEnum::YES])
|
||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
||||
// 与业绩看板等统计口径一致:含全部未软删部门(不再仅限 status=启用),
|
||||
// 避免外链 assistant_dept_id 在树下拉中不存在导致 TreeSelect 初始化异常。
|
||||
$data = Dept::order(['sort' => 'desc', 'id' => 'desc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if ($data === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pid = min(array_column($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)。
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ use app\api\logic\ChatNotifyLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\BloodRecordLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
@@ -27,6 +28,20 @@ use think\facade\Log;
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取可用时间段
|
||||
* @param array $params
|
||||
@@ -232,6 +247,21 @@ class AppointmentLogic extends BaseLogic
|
||||
Db::rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
$chSrc = trim((string) ($params['channel_source'] ?? ''));
|
||||
$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('请填写自媒体渠道补充信息');
|
||||
Db::rollback();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'patient_id' => $params['patient_id'],
|
||||
'assistant_id'=>$params['assistant_id'],
|
||||
@@ -242,7 +272,9 @@ class AppointmentLogic extends BaseLogic
|
||||
'appointment_time' => $appointmentTime,
|
||||
'appointment_type' => $params['appointment_type'] ?? 'video',
|
||||
'remark' => $params['remark'] ?? '',
|
||||
'channels' => $params['channel_source'] ?? '',
|
||||
// 表字段为 channel_source(与字典 type_value=channels 的 value 一致);勿再写入不存在的 channels 列
|
||||
'channel_source' => $chSrc,
|
||||
'channel_source_detail' => $chDetail,
|
||||
'status' => 1,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
@@ -516,10 +548,8 @@ class AppointmentLogic extends BaseLogic
|
||||
? DiagnosisLogic::detail(['id' => $diagnosisId])
|
||||
: [];
|
||||
|
||||
// 3) 血糖血压记录
|
||||
$bloodRecords = $diagnosisId > 0
|
||||
? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId])
|
||||
: [];
|
||||
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
|
||||
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
|
||||
|
||||
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
|
||||
$assistantName = '';
|
||||
@@ -551,23 +581,28 @@ class AppointmentLogic extends BaseLogic
|
||||
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
|
||||
: [];
|
||||
|
||||
// 7.1) 跟踪备注
|
||||
$trackingNotes = $diagnosisId > 0
|
||||
? TrackingNoteLogic::getByDiagnosis($diagnosisId)
|
||||
: [];
|
||||
|
||||
return [
|
||||
'appointment' => $appointment,
|
||||
'diagnosis' => $diagnosis,
|
||||
'blood_records' => $bloodRecords,
|
||||
'doctor_notes' => $doctorNotes,
|
||||
'appointment' => $appointment,
|
||||
'diagnosis' => $diagnosis,
|
||||
'doctor_notes' => $doctorNotes,
|
||||
'tracking_notes' => $trackingNotes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
|
||||
* 便于前端(接诊台等只读场景)直接渲染,无需加载字典。
|
||||
* 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。
|
||||
* 原始 code 字段保留不动,edit 场景仍可使用。
|
||||
*
|
||||
* @param array $diagnosis
|
||||
* @return array
|
||||
*/
|
||||
private static function enrichDiagnosisLabels(array $diagnosis): array
|
||||
public static function enrichDiagnosisLabels(array $diagnosis): array
|
||||
{
|
||||
if (empty($diagnosis)) {
|
||||
return $diagnosis;
|
||||
|
||||
@@ -8,6 +8,7 @@ use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\model\finance\DeptPerformanceTarget;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
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();
|
||||
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scoped = $allowed !== null;
|
||||
if ($scoped) {
|
||||
$tree = self::filterDeptTreeAllowedForest(is_array($tree) ? $tree : [], $allowed);
|
||||
}
|
||||
|
||||
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
|
||||
->select()
|
||||
@@ -33,19 +39,22 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
$total = self::sumTargetsInTree($rows);
|
||||
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'rows' => $rows,
|
||||
'total_target' => round($total, 2),
|
||||
'year_month' => $yearMonth,
|
||||
'rows' => $rows,
|
||||
'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
|
||||
*/
|
||||
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 {
|
||||
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName): void {
|
||||
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName, $allowed): void {
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
@@ -54,6 +63,10 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
if ($deptId <= 0) {
|
||||
continue;
|
||||
}
|
||||
// 仅处理 data scope 内的 dept_id(界面树与 monthMatrix 一致,不再含纯展示用父级)
|
||||
if ($allowed !== null && !isset($allowed[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
|
||||
if (!$dept) {
|
||||
continue;
|
||||
@@ -162,4 +175,63 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
|
||||
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
|
||||
->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')
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -1400,4 +1400,16 @@ class OrderLogic
|
||||
'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,497 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
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),按处方 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])
|
||||
->whereRaw('NOT (o.fulfillment_status <=> 4)')
|
||||
->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;
|
||||
|
||||
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\DiagnosisGuahaoLog;
|
||||
use app\common\model\tcm\DiagnosisAssignLog;
|
||||
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\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\AppointmentLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\FileService;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\storage\Driver as StorageDriver;
|
||||
use think\facade\Db;
|
||||
/**
|
||||
* 中医辨房病因诊单逻辑
|
||||
@@ -129,7 +142,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
private static function generatePatientId(): int
|
||||
{
|
||||
// 获取当前最大的患者ID
|
||||
$maxPatientId = Diagnosis::max('patient_id') ?? 100000;
|
||||
$maxPatientId = Diagnosis::max('id') ?? 10000000;
|
||||
|
||||
// 返回下一个患者ID
|
||||
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
|
||||
* @return bool
|
||||
*/
|
||||
@@ -480,33 +565,69 @@ class DiagnosisLogic extends BaseLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$diagnosis) {
|
||||
self::setError('诊单不存在');
|
||||
$now = time();
|
||||
$poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id);
|
||||
$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;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
return true;
|
||||
@@ -523,24 +644,32 @@ class DiagnosisLogic extends BaseLogic
|
||||
*/
|
||||
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')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$adminIds = [];
|
||||
foreach ($rows as $r) {
|
||||
if (!empty($r['from_assistant_id'])) {
|
||||
$adminIds[] = (int) $r['from_assistant_id'];
|
||||
$fid = (int) ($r['from_assistant_id'] ?? 0);
|
||||
$tid = (int) ($r['to_assistant_id'] ?? 0);
|
||||
if ($fid > 0) {
|
||||
$adminIds[] = $fid;
|
||||
}
|
||||
if (!empty($r['to_assistant_id'])) {
|
||||
$adminIds[] = (int) $r['to_assistant_id'];
|
||||
if ($tid > 0) {
|
||||
$adminIds[] = $tid;
|
||||
}
|
||||
$rcp = (int) ($r['related_po_creator_id'] ?? 0);
|
||||
if ($rcp > 0) {
|
||||
$adminIds[] = $rcp;
|
||||
}
|
||||
}
|
||||
$adminIds = array_values(array_unique(array_filter($adminIds)));
|
||||
$nameMap = [];
|
||||
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) {
|
||||
@@ -555,10 +684,18 @@ class DiagnosisLogic extends BaseLogic
|
||||
$r['create_time_text'] = !empty($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);
|
||||
|
||||
return $rows;
|
||||
return array_values($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -954,10 +1091,29 @@ class DiagnosisLogic extends BaseLogic
|
||||
if (empty($doctorAccounts)) {
|
||||
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();
|
||||
$merged = [];
|
||||
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) {
|
||||
$merged[] = $row;
|
||||
}
|
||||
@@ -1075,23 +1231,44 @@ class DiagnosisLogic extends BaseLogic
|
||||
/**
|
||||
* @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 = [];
|
||||
$lastKey = null;
|
||||
$lastTime = null;
|
||||
$guard = 0;
|
||||
$maxPages = 80;
|
||||
do {
|
||||
$res = $svc->adminGetRoamMsg($operator, $peer, 100, 0, 4294967295, $lastKey, $lastTime);
|
||||
if (!$res['success']) {
|
||||
if (!empty($res['error'])) {
|
||||
\think\facade\Log::warning('IM漫游消息拉取失败', [
|
||||
'operator' => $operator,
|
||||
'peer' => $peer,
|
||||
'error' => $res['error'],
|
||||
'code' => $res['rawErrorCode'] ?? 0,
|
||||
]);
|
||||
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
|
||||
$res = null;
|
||||
$attempt = 0;
|
||||
while ($attempt < 3) {
|
||||
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
|
||||
if (!empty($res['success'])) {
|
||||
break;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
foreach ($res['msgList'] as $raw) {
|
||||
@@ -1112,7 +1289,16 @@ class DiagnosisLogic extends BaseLogic
|
||||
break;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
} while (true);
|
||||
@@ -1711,22 +1897,14 @@ class DiagnosisLogic extends BaseLogic
|
||||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||
$adminId = (int)($params['admin_id'] ?? 0);
|
||||
$fileUrl = trim((string)($params['file_url'] ?? ''));
|
||||
$callRecordId = (int)($params['call_record_id'] ?? 0);
|
||||
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
|
||||
self::setError('参数错误');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||||
->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();
|
||||
}
|
||||
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
|
||||
if (!$record) {
|
||||
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} 账号体系一致)
|
||||
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
|
||||
@@ -3324,4 +3654,418 @@ class DiagnosisLogic extends BaseLogic
|
||||
|
||||
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\Diagnosis;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -122,6 +123,12 @@ class PrescriptionLogic
|
||||
return true;
|
||||
}
|
||||
|
||||
// 有业务订单详情权限的用户可以查看关联处方(只读)
|
||||
$perms = AuthLogic::getAuthByAdminId($adminId);
|
||||
if (in_array('tcm.prescriptionOrder/detail', $perms, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,6 +159,19 @@ class PrescriptionLogic
|
||||
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'] ?? '浓缩水丸',
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'dosage_bag_count' => self::normalizeDosageBagCount($params['dosage_bag_count'] ?? 1),
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : 1,
|
||||
'diagnosis_id' => (int)($params['diagnosis_id'] ?? 0),
|
||||
@@ -359,6 +380,7 @@ class PrescriptionLogic
|
||||
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'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),
|
||||
'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,
|
||||
@@ -811,6 +833,7 @@ class PrescriptionLogic
|
||||
'prescription_type' => '浓缩水丸',
|
||||
'dosage_amount' => 1.0,
|
||||
'dosage_unit' => 'g',
|
||||
'dosage_bag_count' => 1,
|
||||
'need_decoction' => 0,
|
||||
'bags_per_dose' => 1,
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\order\OrderLogic;
|
||||
use app\common\model\Order;
|
||||
@@ -15,6 +16,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\ExpressTrackService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\facade\Config;
|
||||
@@ -111,6 +113,55 @@ class PrescriptionOrderLogic
|
||||
return self::roleIntersect($adminInfo, is_array($roles) ? $roles : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单权限:非全量角色可额外查看「关联处方开方人为本账号」的业务订单(医助代建单)
|
||||
*
|
||||
* perms: tcm.prescriptionOrder/viewOrdersForOwnPrescription
|
||||
*/
|
||||
public static function canViewOrdersForOwnPrescription(array $adminInfo): bool
|
||||
{
|
||||
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
|
||||
return in_array('tcm.prescriptionOrder/viewOrdersForOwnPrescription', $perms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单关联处方的开方人是否为指定管理员(处方 creator_id)
|
||||
*/
|
||||
public static function isPrescriptionOrderPrescriber(int $prescriptionId, int $adminId): bool
|
||||
{
|
||||
if ($prescriptionId <= 0 || $adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$cid = (int) Prescription::where('id', $prescriptionId)->whereNull('delete_time')->value('creator_id');
|
||||
|
||||
return $cid === $adminId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 PrescriptionOrderLists 数据域一致:创建人或关联诊单医助属于当前账号可见 admin 集合
|
||||
*/
|
||||
private static function orderWithinDataScopeOwners(PrescriptionOrder $row, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if (!DataScopeService::isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($ids === null || $ids === []) {
|
||||
return false;
|
||||
}
|
||||
if (in_array((int) $row->creator_id, $ids, true)) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) $row->diagnosis_id;
|
||||
if ($did <= 0) {
|
||||
return false;
|
||||
}
|
||||
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
|
||||
|
||||
return $da > 0 && in_array($da, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PrescriptionOrder $row
|
||||
*/
|
||||
@@ -119,16 +170,40 @@ class PrescriptionOrderLogic
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
if ((int) $row->creator_id === $adminId) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) $row->diagnosis_id;
|
||||
if ($did > 0) {
|
||||
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
|
||||
if ($da === $adminId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (self::canViewOrdersForOwnPrescription($adminInfo)) {
|
||||
if (self::isPrescriptionOrderPrescriber((int) $row->prescription_id, $adminId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (self::orderWithinDataScopeOwners($row, $adminId, $adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) $row->creator_id === $adminId;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回:仅创建人或全量角色(避免开方医生仅「查看医助代建单」权限误撤他人单子)
|
||||
*
|
||||
* @param PrescriptionOrder $row
|
||||
*/
|
||||
public static function canWithdrawOrder($row, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
return self::canAccessOrder($row, $adminId, $adminInfo);
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) $row->creator_id === $adminId;
|
||||
}
|
||||
|
||||
public static function canViewInternalCost(array $adminInfo): bool
|
||||
@@ -203,6 +278,31 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单权限:查看/编辑业务订单「备注」(对内 remark_extra)
|
||||
*
|
||||
* perms: tcm.prescriptionOrder/editRemarkExtra
|
||||
*/
|
||||
public static function canEditOrderRemarkExtra(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
|
||||
return in_array('tcm.prescriptionOrder/editRemarkExtra', $perms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
public static function maskRemarkExtraIfNeeded(array &$row, array $adminInfo): void
|
||||
{
|
||||
if (!self::canEditOrderRemarkExtra($adminInfo)) {
|
||||
unset($row['remark_extra']);
|
||||
}
|
||||
}
|
||||
|
||||
/** 定金门槛(元),0 表示不校验 */
|
||||
public static function depositMinAmount(): float
|
||||
{
|
||||
@@ -224,7 +324,16 @@ class PrescriptionOrderLogic
|
||||
if ($payOrderIds === []) {
|
||||
return '未关联支付单,关联支付单总金额须大于等于定金门槛 ¥' . $min;
|
||||
}
|
||||
|
||||
|
||||
// 豁免权:关联的支付单中有豁免单则跳过金额校验
|
||||
$hasExempt = Order::whereIn('id', $payOrderIds)
|
||||
->whereNull('delete_time')
|
||||
->where('is_exempt', 1)
|
||||
->count();
|
||||
if ($hasExempt > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查关联支付单的总金额
|
||||
$rows = Order::whereIn('id', $payOrderIds)->whereNull('delete_time')->column('amount', 'id');
|
||||
$totalAmount = 0.0;
|
||||
@@ -404,7 +513,7 @@ class PrescriptionOrderLogic
|
||||
return [];
|
||||
}
|
||||
$rows = Order::whereIn('id', $ids)->whereNull('delete_time')
|
||||
->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', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -576,7 +685,10 @@ class PrescriptionOrderLogic
|
||||
$order->fee_type = (int) $params['fee_type'];
|
||||
$order->amount = round((float) $params['amount'], 2);
|
||||
$order->internal_cost = $internalCost;
|
||||
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
|
||||
$order->remark_extra = self::canEditOrderRemarkExtra($adminInfo)
|
||||
? (string) ($params['remark_extra'] ?? '')
|
||||
: '';
|
||||
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
|
||||
$order->linked_pay_order_id = $payOrderIds !== [] ? $payOrderIds[0] : null;
|
||||
$order->prescription_audit_status = 0;
|
||||
$order->prescription_audit_remark = '';
|
||||
@@ -600,6 +712,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -621,15 +734,22 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$arr = $row->toArray();
|
||||
self::maskInternalCostIfNeeded($arr, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($arr, $adminInfo);
|
||||
self::attachLinkedPayOrders($arr);
|
||||
|
||||
// 添加创建人姓名
|
||||
// 添加创建人姓名、登录账号(业务订单 creator_id;la_admin 无手机列则不发手机号)
|
||||
$creatorId = (int) ($arr['creator_id'] ?? 0);
|
||||
$arr['creator_name'] = '';
|
||||
$arr['creator_account'] = '';
|
||||
$arr['creator_mobile'] = '';
|
||||
if ($creatorId > 0) {
|
||||
$creatorName = Admin::where('id', $creatorId)->value('name');
|
||||
$arr['creator_name'] = $creatorName ? (string) $creatorName : '';
|
||||
} else {
|
||||
$arr['creator_name'] = '';
|
||||
// 须包含 id:Admin 模型 $append 在 toArray 时会依赖 data['id']
|
||||
$adm = Admin::where('id', $creatorId)->whereNull('delete_time')->field(['id', 'name', 'account'])->find();
|
||||
if ($adm !== null) {
|
||||
$a = $adm->toArray();
|
||||
$arr['creator_name'] = (string) ($a['name'] ?? '');
|
||||
$arr['creator_account'] = (string) ($a['account'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$rxId = (int) ($arr['prescription_id'] ?? 0);
|
||||
@@ -707,6 +827,8 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
$arr['diagnosis_creator_dept_path'] = $deptPath;
|
||||
/** 与 diagnosis_creator_dept_path 相同语义:业务订单创建人所属部门(多部门「;」、路径「 / 」),便于前端字段自解释 */
|
||||
$arr['order_creator_dept_path'] = $deptPath;
|
||||
|
||||
// 挂号预约摘要:优先处方 appointment_id;否则按诊单 id 匹配预约表 patient_id(诊单)
|
||||
$arr['linked_appointment'] = null;
|
||||
@@ -824,25 +946,31 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号预约简要信息(业务订单详情展示)
|
||||
* 渠道字典 value => 名称(与挂号 channel_source 存值一致)
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function buildAppointmentBrief(int $appointmentId): ?array
|
||||
private static function channelSourceDictMap(): array
|
||||
{
|
||||
if ($appointmentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$ap = Appointment::find($appointmentId);
|
||||
if ($ap === null) {
|
||||
return null;
|
||||
}
|
||||
$row = $ap->toArray();
|
||||
$doctorName = '';
|
||||
$did = (int) ($row['doctor_id'] ?? 0);
|
||||
if ($did > 0) {
|
||||
$doctorName = (string) (Admin::where('id', $did)->value('name') ?? '');
|
||||
try {
|
||||
/** @var array<string, string> $m */
|
||||
$m = Db::name('dict_data')->where('type_value', 'channels')->column('name', 'value');
|
||||
|
||||
return $m;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row doctor_appointment 行
|
||||
* @param array<string, string> $channelDict
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function formatAppointmentBriefFromRow(array $row, string $doctorName, array $channelDict = []): array
|
||||
{
|
||||
$apptId = (int) ($row['id'] ?? 0);
|
||||
$statusMap = [
|
||||
1 => '已预约',
|
||||
2 => '已取消',
|
||||
@@ -862,9 +990,33 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$atype = (string) ($row['appointment_type'] ?? '');
|
||||
$chSrc = trim((string) ($row['channel_source'] ?? ''));
|
||||
if ($chSrc === '') {
|
||||
$legacy = $row['channels'] ?? null;
|
||||
if ($legacy !== null && $legacy !== '') {
|
||||
$chSrc = is_numeric($legacy)
|
||||
? (string) (int) $legacy
|
||||
: trim((string) $legacy);
|
||||
}
|
||||
}
|
||||
$chDesc = '';
|
||||
if ($chSrc !== '') {
|
||||
$chDesc = (string) ($channelDict[$chSrc] ?? '');
|
||||
if ($chDesc === '' && is_numeric($chSrc)) {
|
||||
$chDesc = (string) ($channelDict[(string) (int) $chSrc] ?? '');
|
||||
}
|
||||
if ($chDesc === '') {
|
||||
$chDesc = $chSrc;
|
||||
}
|
||||
}
|
||||
|
||||
$channelDictValue = null;
|
||||
if ($chSrc !== '' && is_numeric($chSrc)) {
|
||||
$channelDictValue = (int) $chSrc;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $appointmentId,
|
||||
'id' => $apptId,
|
||||
'appointment_date' => (string) ($row['appointment_date'] ?? ''),
|
||||
'period' => $period,
|
||||
'appointment_time' => $timePart,
|
||||
@@ -873,11 +1025,169 @@ class PrescriptionOrderLogic
|
||||
'status' => $status,
|
||||
'status_desc' => $statusMap[$status] ?? '未知',
|
||||
'doctor_name' => $doctorName,
|
||||
'channel_source' => (string) ($row['channel_source'] ?? ''),
|
||||
'channel_source' => $chSrc,
|
||||
'channel_source_desc' => $chDesc,
|
||||
/** 字典 type_value=channels 的 value(整型);与业绩看板挂号 channels 收窄、进线统计所用字典为同套 */
|
||||
'channel_dict_value' => $channelDictValue,
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号预约简要信息(业务订单详情展示)
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function buildAppointmentBrief(int $appointmentId): ?array
|
||||
{
|
||||
if ($appointmentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$ap = Appointment::find($appointmentId);
|
||||
if ($ap === null) {
|
||||
return null;
|
||||
}
|
||||
$row = $ap->toArray();
|
||||
$doctorName = '';
|
||||
$did = (int) ($row['doctor_id'] ?? 0);
|
||||
if ($did > 0) {
|
||||
$doctorName = (string) (Admin::where('id', $did)->value('name') ?? '');
|
||||
}
|
||||
$channelDict = self::channelSourceDictMap();
|
||||
|
||||
return self::formatAppointmentBriefFromRow($row, $doctorName, $channelDict);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单列表行批量附加 linked_appointment(解析规则与详情一致:优先处方 appointment_id,否则诊单最近一条预约;
|
||||
* 业绩侧栏且带 channel_code 时优先展示与渠道收窄同源的已完成挂号,避免列表行属自媒体2Q 但展示关联为自媒体1)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $lists
|
||||
* @param array<string, mixed> $listsParams PrescriptionOrderLists 请求参数(可选)
|
||||
*/
|
||||
public static function appendLinkedAppointmentsToListRows(array &$lists, array $listsParams = []): void
|
||||
{
|
||||
if ($lists === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($lists as &$item) {
|
||||
$item['linked_appointment'] = null;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
$yejiChannelHighlightByDiag = [];
|
||||
if (
|
||||
(int) ($listsParams['yeji_order_drawer'] ?? 0) === 1
|
||||
&& trim((string) ($listsParams['channel_code'] ?? '')) !== ''
|
||||
) {
|
||||
$allDiagIdsForYeji = array_values(array_unique(array_filter(
|
||||
array_map(static fn ($x): int => (int) ($x['diagnosis_id'] ?? 0), $lists),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($allDiagIdsForYeji !== []) {
|
||||
$yejiChannelHighlightByDiag = YejiStatsLogic::batchResolveYejiChannelDrawerHighlightAppointmentIds(
|
||||
$allDiagIdsForYeji,
|
||||
$listsParams
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$rxIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($x) => (int) ($x['prescription_id'] ?? 0),
|
||||
$lists
|
||||
))));
|
||||
$rxApptByRx = [];
|
||||
if ($rxIds !== []) {
|
||||
$rxApptByRx = Prescription::whereIn('id', $rxIds)->whereNull('delete_time')->column('appointment_id', 'id');
|
||||
}
|
||||
|
||||
$diagNeedFallback = [];
|
||||
foreach ($lists as $item) {
|
||||
$rxId = (int) ($item['prescription_id'] ?? 0);
|
||||
$dgId = (int) ($item['diagnosis_id'] ?? 0);
|
||||
$apFromRx = $rxId > 0 ? (int) ($rxApptByRx[$rxId] ?? 0) : 0;
|
||||
if ($apFromRx <= 0 && $dgId > 0) {
|
||||
$diagNeedFallback[$dgId] = true;
|
||||
}
|
||||
}
|
||||
$diagIds = array_keys($diagNeedFallback);
|
||||
$latestApptByDiag = [];
|
||||
if ($diagIds !== []) {
|
||||
try {
|
||||
$agg = Db::name('doctor_appointment')
|
||||
->whereIn('patient_id', $diagIds)
|
||||
->fieldRaw('patient_id, MAX(id) as max_id')
|
||||
->group('patient_id')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($agg as $r) {
|
||||
$pid = (int) ($r['patient_id'] ?? 0);
|
||||
$mid = (int) ($r['max_id'] ?? 0);
|
||||
if ($pid > 0 && $mid > 0) {
|
||||
$latestApptByDiag[$pid] = $mid;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$latestApptByDiag = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<int, array{0: int, 1: int}> $resolvedPairs index => [ resolvedApptId, rxAppointmentId ] */
|
||||
$resolvedPairs = [];
|
||||
foreach ($lists as $idx => $item) {
|
||||
$rxId = (int) ($item['prescription_id'] ?? 0);
|
||||
$dgId = (int) ($item['diagnosis_id'] ?? 0);
|
||||
$rxApptId = $rxId > 0 ? (int) ($rxApptByRx[$rxId] ?? 0) : 0;
|
||||
$resolved = $rxApptId;
|
||||
if ($resolved <= 0 && $dgId > 0) {
|
||||
$resolved = (int) ($latestApptByDiag[$dgId] ?? 0);
|
||||
}
|
||||
$preferredYeji = $dgId > 0 ? (int) ($yejiChannelHighlightByDiag[$dgId] ?? 0) : 0;
|
||||
if ($preferredYeji > 0) {
|
||||
$resolved = $preferredYeji;
|
||||
}
|
||||
if ($resolved > 0) {
|
||||
$resolvedPairs[$idx] = [$resolved, $rxApptId];
|
||||
}
|
||||
}
|
||||
if ($resolvedPairs === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allApptIds = array_values(array_unique(array_map(static fn ($p) => $p[0], $resolvedPairs)));
|
||||
$appts = Appointment::whereIn('id', $allApptIds)->select()->toArray();
|
||||
$apptById = [];
|
||||
foreach ($appts as $a) {
|
||||
$aid = (int) ($a['id'] ?? 0);
|
||||
if ($aid > 0) {
|
||||
$apptById[$aid] = $a;
|
||||
}
|
||||
}
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($a) => (int) ($a['doctor_id'] ?? 0),
|
||||
$appts
|
||||
))));
|
||||
$doctorNames = [];
|
||||
if ($doctorIds !== []) {
|
||||
$doctorNames = Admin::whereIn('id', $doctorIds)->column('name', 'id');
|
||||
}
|
||||
$channelDict = self::channelSourceDictMap();
|
||||
|
||||
foreach ($resolvedPairs as $idx => $pair) {
|
||||
$resolvedId = $pair[0];
|
||||
$rxApptId = $pair[1];
|
||||
$row = $apptById[$resolvedId] ?? null;
|
||||
if ($row === null || !is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$did = (int) ($row['doctor_id'] ?? 0);
|
||||
$dname = $did > 0 ? (string) ($doctorNames[$did] ?? '') : '';
|
||||
$brief = self::formatAppointmentBriefFromRow($row, $dname, $channelDict);
|
||||
$brief['resolved_from'] = $rxApptId === $resolvedId ? 'prescription' : 'diagnosis';
|
||||
$lists[$idx]['linked_appointment'] = $brief;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流轨迹(顺丰/京东等,优先快递100;未配置密钥时返回官网链接)
|
||||
*
|
||||
@@ -1013,7 +1323,10 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$order->fee_type = (int) $params['fee_type'];
|
||||
$order->amount = round((float) $params['amount'], 2);
|
||||
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
|
||||
if (self::canEditOrderRemarkExtra($adminInfo)) {
|
||||
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
|
||||
}
|
||||
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
|
||||
|
||||
$diagId = (int) $order->diagnosis_id;
|
||||
|
||||
@@ -1104,6 +1417,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1182,6 +1496,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1250,6 +1565,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1328,6 +1644,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1466,6 +1783,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1552,6 +1870,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1622,6 +1941,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1738,6 +2058,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1848,6 +2169,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -1935,6 +2257,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
@@ -2040,6 +2363,12 @@ class PrescriptionOrderLogic
|
||||
if ((int) ($item['creator_id'] ?? 0) === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
self::canViewOrdersForOwnPrescription($adminInfo)
|
||||
&& self::isPrescriptionOrderPrescriber((int) ($item['prescription_id'] ?? 0), $adminId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) ($item['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($assistantByDiag[$did] ?? 0);
|
||||
|
||||
@@ -2346,6 +2675,60 @@ class PrescriptionOrderLogic
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改订单金额
|
||||
*/
|
||||
public static function updateAmount(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
$newAmount = round((float) $params['amount'], 2);
|
||||
|
||||
// 查询订单
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::setError('订单不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 数据权限检查
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::setError('无权限操作此订单');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 状态检查:已完成(3)和已取消(4)不允许修改
|
||||
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
|
||||
self::setError('已完成或已取消的订单不允许修改金额');
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldAmount = round((float) $order->amount, 2);
|
||||
$oldAmountCents = (int) round($oldAmount * 100);
|
||||
$newAmountCents = (int) round($newAmount * 100);
|
||||
|
||||
// 金额未变化
|
||||
if ($newAmountCents === $oldAmountCents) {
|
||||
self::setError('金额未发生变化');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新金额
|
||||
$order->amount = $newAmount;
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
$summary = sprintf('将订单金额从 ¥%.2f 修改为 ¥%.2f', $oldAmount, $newAmount);
|
||||
self::writeLog($id, $adminId, $adminInfo, 'update_amount', $summary);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
||||
{
|
||||
$adminName = $adminInfo['name'] ?? '';
|
||||
|
||||
@@ -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\validate\BaseValidate;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 登录验证
|
||||
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
|
||||
*/
|
||||
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 = [
|
||||
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
|
||||
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class AppointmentValidate extends BaseValidate
|
||||
'appointment_time' => 'require',
|
||||
'appointment_type' => 'in:video,text,phone',
|
||||
'channel_source' => 'require',
|
||||
'channel_source_detail' => 'max:128',
|
||||
'note_id' => 'require|integer',
|
||||
'image_type' => 'require|in:tongue_images,report_files',
|
||||
'image_path' => 'require',
|
||||
@@ -44,6 +45,7 @@ class AppointmentValidate extends BaseValidate
|
||||
'appointment_time' => '预约时间',
|
||||
'appointment_type' => '预约类型',
|
||||
'channel_source' => '渠道来源',
|
||||
'channel_source_detail' => '渠道补充说明',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -52,7 +54,7 @@ class AppointmentValidate extends BaseValidate
|
||||
*/
|
||||
public function sceneCreate()
|
||||
{
|
||||
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type', 'channel_source']);
|
||||
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type', 'channel_source', 'channel_source_detail']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?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\validate\tcm;
|
||||
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisTodo;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 诊单待办事项验证
|
||||
* Class DiagnosisTodoValidate
|
||||
* @package app\adminapi\validate\tcm
|
||||
*/
|
||||
class DiagnosisTodoValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|number',
|
||||
'diagnosis_id' => 'require|number|gt:0|checkDiagnosis',
|
||||
'content' => 'require|length:1,500',
|
||||
'remind_time' => 'require|number|checkRemindFuture',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '参数缺失',
|
||||
'diagnosis_id.require' => '诊单 id 不能为空',
|
||||
'diagnosis_id.gt' => '诊单 id 非法',
|
||||
'content.require' => '请输入提醒内容',
|
||||
'content.length' => '提醒内容长度需在 1-500 字',
|
||||
'remind_time.require' => '请选择提醒时间',
|
||||
'remind_time.number' => '提醒时间格式错误',
|
||||
];
|
||||
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'content', 'remind_time']);
|
||||
}
|
||||
|
||||
public function sceneCancel()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneDetail()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneList()
|
||||
{
|
||||
return $this->only(['diagnosis_id'])
|
||||
->append('diagnosis_id', 'require|number|gt:0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单存在校验
|
||||
*/
|
||||
protected function checkDiagnosis($value): bool|string
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty((int) $value);
|
||||
if ($diagnosis->isEmpty()) {
|
||||
return '诊单不存在';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提醒时间必须在当前时间之后(至少 30 秒),避免立刻就过期
|
||||
*/
|
||||
protected function checkRemindFuture($value): bool|string
|
||||
{
|
||||
$ts = (int) $value;
|
||||
if ($ts <= time() + 30) {
|
||||
return '提醒时间必须晚于当前时间';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ class DiagnosisValidate extends BaseValidate
|
||||
'tongue_images' => 'array',
|
||||
'report_files' => 'array',
|
||||
'diabetes_discovery_year' => 'max:50',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date|checkDateRange',
|
||||
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||
'tracking_content' => 'require|length:1,1000',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
@@ -55,11 +59,19 @@ class DiagnosisValidate extends BaseValidate
|
||||
'status.in' => '状态参数错误',
|
||||
'current_medications.max' => '在用药物最多2000个字符',
|
||||
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
|
||||
'start_date.date' => '开始日期格式不正确',
|
||||
'end_date.date' => '结束日期格式不正确',
|
||||
'diagnosis_id.require' => '诊单ID不能为空',
|
||||
'tracking_content.require' => '跟踪备注内容不能为空',
|
||||
'tracking_content.length' => '跟踪备注最多1000个字符',
|
||||
];
|
||||
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->remove('id', true);
|
||||
// 全局规则中含 tracking 相关字段,新增诊单不应校验诊单 ID
|
||||
return $this->remove('id', true)
|
||||
->remove('diagnosis_id', true)
|
||||
->remove('tracking_content', true);
|
||||
}
|
||||
|
||||
public function sceneEdit()
|
||||
@@ -71,7 +83,29 @@ class DiagnosisValidate extends BaseValidate
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
|
||||
public function sceneReadonlyDetail()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneTrackingWindow()
|
||||
{
|
||||
return $this->only(['id', 'start_date', 'end_date']);
|
||||
}
|
||||
|
||||
/** 新增跟踪备注:诊单ID + 备注内容 */
|
||||
public function sceneAddTrackingNote()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'tracking_content']);
|
||||
}
|
||||
|
||||
/** 拉取跟踪备注列表:诊单ID */
|
||||
public function sceneTrackingNotes()
|
||||
{
|
||||
return $this->only(['diagnosis_id']);
|
||||
}
|
||||
|
||||
public function sceneGenerateQrcode()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
|
||||
@@ -107,6 +141,26 @@ class DiagnosisValidate extends BaseValidate
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 校验 diagnosis_id 字段(addTrackingNote 等场景使用,避开 id 字段) */
|
||||
protected function checkDiagnosisId($value)
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||
if ($diagnosis->isEmpty()) {
|
||||
return '诊单不存在';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** end_date 不能早于 start_date */
|
||||
protected function checkDateRange($value, $rule, $data = [])
|
||||
{
|
||||
$start = $data['start_date'] ?? '';
|
||||
if ($start && $value && strtotime($value) < strtotime($start)) {
|
||||
return '结束日期不能早于开始日期';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 视频场景需 doctor_id,确认诊单需 diagnosis_id */
|
||||
protected function checkQrcodeIds($value, $rule, $data = [])
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'pay_amount' => 'require|float|gt:0',
|
||||
'pay_remark' => 'max:200',
|
||||
'remark_extra' => 'max:500',
|
||||
'remark_assistant' => 'max:500',
|
||||
'action' => 'require|in:approve,reject',
|
||||
'remark' => 'max:500',
|
||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
|
||||
@@ -55,13 +56,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'create' => [
|
||||
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
||||
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'pay_order_ids',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
],
|
||||
'detail' => ['id'],
|
||||
'edit' => [
|
||||
'id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
||||
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'pay_order_ids',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
],
|
||||
'logisticsTrace' => ['id', 'phone_tail'],
|
||||
'auditPrescription' => ['id', 'action', 'remark'],
|
||||
@@ -76,5 +77,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
];
|
||||
|
||||
public function updateAmount(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'amount'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('amount', 'require|float|gt:0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class PrescriptionValidate extends BaseValidate
|
||||
'id' => 'require',
|
||||
'diagnosis_id' => 'number',
|
||||
'appointment_id' => 'number',
|
||||
'dosage_bag_count' => 'integer|between:1,5',
|
||||
'patient_name' => 'require',
|
||||
'clinical_diagnosis' => 'require',
|
||||
'herbs' => 'require|array',
|
||||
@@ -20,6 +21,8 @@ class PrescriptionValidate extends BaseValidate
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'dosage_bag_count.integer' => '用量袋数必须为整数',
|
||||
'dosage_bag_count.between' => '用量袋数必须在1到5袋之间',
|
||||
'patient_name.require' => '患者姓名不能为空',
|
||||
'clinical_diagnosis.require' => '临床诊断不能为空',
|
||||
'herbs.require' => '请添加中药',
|
||||
@@ -28,7 +31,7 @@ class PrescriptionValidate extends BaseValidate
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->only([
|
||||
'prescription_type', 'dosage_amount', 'dosage_unit', 'need_decoction', 'bags_per_dose',
|
||||
'prescription_type', 'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'need_decoction', 'bags_per_dose',
|
||||
'patient_name', 'gender', 'age',
|
||||
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
|
||||
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
|
||||
@@ -41,7 +44,7 @@ class PrescriptionValidate extends BaseValidate
|
||||
public function sceneEdit()
|
||||
{
|
||||
return $this->only([
|
||||
'id', 'prescription_type', 'dosage_amount', 'dosage_unit', 'need_decoction', 'bags_per_dose',
|
||||
'id', 'prescription_type', 'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'need_decoction', 'bags_per_dose',
|
||||
'patient_name', 'gender', 'age',
|
||||
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
|
||||
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
|
||||
|
||||
@@ -10,7 +10,7 @@ use think\facade\Db;
|
||||
*/
|
||||
class ChatController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['notifyOpen', 'logAvPermission'];
|
||||
public array $notNeedLogin = ['notifyOpen', 'notifyClose', 'logAvPermission'];
|
||||
|
||||
/**
|
||||
* 上报音视频权限拒绝日志
|
||||
@@ -55,4 +55,21 @@ class ChatController extends BaseApiController
|
||||
ChatNotifyLogic::addNotify($doctorId, $patientId ?: '0', $patientName);
|
||||
return $this->success('已通知');
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者离开会话页时通知医生(管理后台轮询 + 可与 IM 信令配合)
|
||||
*/
|
||||
public function notifyClose()
|
||||
{
|
||||
$doctorId = (int) ($this->request->post('doctor_id') ?: $this->request->post('doctorId'));
|
||||
$patientId = trim((string) ($this->request->post('patient_id') ?: $this->request->post('patientId') ?: ''));
|
||||
$patientName = trim((string) ($this->request->post('patient_name') ?: $this->request->post('patientName') ?: '患者'));
|
||||
|
||||
if ($doctorId <= 0) {
|
||||
return $this->fail('医生ID无效');
|
||||
}
|
||||
|
||||
ChatNotifyLogic::addPatientLeftNotify($doctorId, $patientId ?: '0', $patientName);
|
||||
return $this->success('已通知');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,30 @@ class ChatNotifyLogic extends BaseLogic
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者离开会话页/诊室时通知医生端(管理后台轮询)
|
||||
*/
|
||||
public static function addPatientLeftNotify(int $doctorId, string $patientId, string $patientName): bool
|
||||
{
|
||||
$key = self::CACHE_PREFIX . $doctorId;
|
||||
$item = [
|
||||
'id' => uniqid('', true),
|
||||
'type' => 'patient_left_chat',
|
||||
'doctor_id' => $doctorId,
|
||||
'patient_id' => $patientId,
|
||||
'patient_name' => $patientName,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$list = Cache::get($key) ?: [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
array_unshift($list, $item);
|
||||
$list = array_slice($list, 0, self::MAX_PER_DOCTOR);
|
||||
Cache::set($key, $list, self::CACHE_TTL);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加面诊结束通知(医生完成接诊后通知对应医助)
|
||||
* @param int $assistantId 医助ID (admin_id)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisTodo;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 诊单待办事项 - 企业微信定时推送
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:diagnosis-todo-notify
|
||||
*
|
||||
* 推荐 cron(每分钟执行):
|
||||
* * * * * * cd /path/to/server && php think tcm:diagnosis-todo-notify >> /var/log/zyt-todo.log 2>&1
|
||||
*
|
||||
* 行为:
|
||||
* 1. 取出 status=0 且 remind_time<=now 的待办,按 remind_time asc,限 200 条
|
||||
* 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送
|
||||
* 3. 找创建人 admin.work_wechat_userid,未绑定 → status=3 + error
|
||||
* 4. 调 WechatWorkAppMessageService::sendTextToUser,失败 → status=3 + error(终态,不重试)
|
||||
* 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z
|
||||
*/
|
||||
class DiagnosisTodoNotify extends Command
|
||||
{
|
||||
/** 单次扫描最大条数 */
|
||||
private const BATCH_LIMIT = 200;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:diagnosis-todo-notify')
|
||||
->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$startTs = microtime(true);
|
||||
$now = time();
|
||||
|
||||
$output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...');
|
||||
|
||||
$totalProcessed = 0;
|
||||
$totalSent = 0;
|
||||
$totalFailed = 0;
|
||||
$totalSkipped = 0; // 被并发抢占
|
||||
$totalUnbound = 0; // 创建人未绑定企微(计入 failed)
|
||||
|
||||
try {
|
||||
$todoTable = (new DiagnosisTodo())->getTable();
|
||||
|
||||
$rows = Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||
->where('remind_time', '<=', $now)
|
||||
->whereNull('delete_time')
|
||||
->order('remind_time', 'asc')
|
||||
->limit(self::BATCH_LIMIT)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$output->writeln('待处理数量:' . count($rows));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$totalProcessed++;
|
||||
$todoId = (int) ($row['id'] ?? 0);
|
||||
if ($todoId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过
|
||||
$affected = Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('id', $todoId)
|
||||
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => DiagnosisTodo::STATUS_SENT,
|
||||
'notified_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
if ($affected <= 0) {
|
||||
$totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 取创建人 work_wechat_userid
|
||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||
$wxId = '';
|
||||
if ($creatorId > 0) {
|
||||
$wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid');
|
||||
}
|
||||
|
||||
if ($wxId === '') {
|
||||
$this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid');
|
||||
$totalUnbound++;
|
||||
$totalFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 拼推送文本
|
||||
$text = $this->buildText($row);
|
||||
|
||||
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
$errMsg = (string) ($res['message'] ?? '企业微信推送失败');
|
||||
$this->markFailed($todoTable, $todoId, $errMsg);
|
||||
$totalFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalSent++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage());
|
||||
try {
|
||||
$this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage());
|
||||
} catch (\Throwable $ee) {
|
||||
Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage());
|
||||
}
|
||||
$totalFailed++;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('诊单待办批处理异常: ' . $e->getMessage());
|
||||
$output->error('批处理异常: ' . $e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTs, 3);
|
||||
$output->writeln(sprintf(
|
||||
'处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss',
|
||||
$totalProcessed,
|
||||
$totalSent,
|
||||
$totalFailed,
|
||||
$totalUnbound,
|
||||
$totalSkipped,
|
||||
$duration
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接推送文本
|
||||
*
|
||||
* @param array<string,mixed> $row 待办记录原始行
|
||||
*/
|
||||
private function buildText(array $row): string
|
||||
{
|
||||
$patientName = '';
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name');
|
||||
}
|
||||
|
||||
$remindAt = (int) ($row['remind_time'] ?? 0);
|
||||
$content = trim((string) ($row['content'] ?? ''));
|
||||
|
||||
$lines = [
|
||||
'【患者跟踪提醒】',
|
||||
'患者:' . ($patientName !== '' ? $patientName : '-'),
|
||||
'时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'),
|
||||
'内容:' . ($content !== '' ? $content : '-'),
|
||||
'— 二中心跟踪系统',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记一条待办为「发送失败」,写入 error 字段
|
||||
*/
|
||||
private function markFailed(string $todoTable, int $todoId, string $errMsg): void
|
||||
{
|
||||
$errMsg = mb_substr($errMsg, 0, 500);
|
||||
Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('id', $todoId)
|
||||
->update([
|
||||
'status' => DiagnosisTodo::STATUS_FAILED,
|
||||
'error' => $errMsg,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Db::name() 接收的是不带前缀的表名,BloodRecord 等 Model::getTable() 返回的是带前缀的全名。
|
||||
* 这里去掉首段 `<prefix>_`。注意:项目实际前缀是 `zyt_`,但 think-orm 的 Db::name()
|
||||
* 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。
|
||||
*/
|
||||
private static function stripTablePrefix(string $tableWithPrefix): string
|
||||
{
|
||||
$prefix = (string) config('database.connections.mysql.prefix', '');
|
||||
if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) {
|
||||
return substr($tableWithPrefix, strlen($prefix));
|
||||
}
|
||||
|
||||
return $tableWithPrefix;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,11 @@ use think\console\Output;
|
||||
* 使用方法:
|
||||
* php think express:auto-update
|
||||
*
|
||||
* 配置crontab(每10分钟执行一次):
|
||||
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
|
||||
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
||||
*
|
||||
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class ExpressAutoUpdate extends Command
|
||||
{
|
||||
@@ -33,7 +36,8 @@ class ExpressAutoUpdate extends Command
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$result = ExpressTrackingService::autoUpdateBatch(200);
|
||||
$result = ExpressTrackingService::autoUpdateBatch(500);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(500);
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
@@ -41,9 +45,9 @@ class ExpressAutoUpdate extends Command
|
||||
$output->writeln("总数: {$result['total']}");
|
||||
$output->writeln("成功: {$result['success']}");
|
||||
$output->writeln("失败: {$result['failed']}");
|
||||
$output->writeln("医助已移除: " . (int) ($result['assistant_cleared'] ?? 0));
|
||||
$output->writeln("医助保留(有指派记录): " . (int) ($result['assistant_skipped_assign_log'] ?? 0));
|
||||
$lines = $result['assistant_lines'] ?? [];
|
||||
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
|
||||
$output->writeln("医助已移除(履约已发货/签收核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
|
||||
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细: (无)');
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\gancao\GancaoLogisticsRouteService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\console\Command;
|
||||
@@ -27,6 +28,9 @@ use think\console\Output;
|
||||
*
|
||||
* 建议 crontab(每 30 分钟执行一次):
|
||||
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1
|
||||
*
|
||||
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class GancaoSyncLogisticsRoute extends Command
|
||||
{
|
||||
@@ -60,6 +64,10 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
|
||||
try {
|
||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200);
|
||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||
} catch (\Throwable $e) {
|
||||
$output->error('同步异常:' . $e->getMessage());
|
||||
return 1;
|
||||
@@ -94,9 +102,9 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
$output->writeln('总数:' . $stats['total']);
|
||||
$output->writeln('成功:' . $stats['success']);
|
||||
$output->writeln('失败:' . $stats['failed']);
|
||||
$output->writeln('医助已移除:' . (int) ($stats['assistant_cleared'] ?? 0));
|
||||
$output->writeln('医助保留(有指派记录):' . (int) ($stats['assistant_skipped_assign_log'] ?? 0));
|
||||
$lines = $stats['assistant_lines'] ?? [];
|
||||
$output->writeln('医助已移除(甘草同步+指派日志):' . (int) ($stats['assistant_cleared'] ?? 0));
|
||||
$output->writeln('医助已移除(履约已发货/签收核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
|
||||
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细:(无)');
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user