Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679e61057b | ||
|
|
003810114b | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
93e6d02ce4 | ||
|
|
752eddb0ba | ||
|
|
1005859404 | ||
|
|
ea6c73c063 | ||
|
|
3e4039efb0 | ||
|
|
c9ad4ae2ed | ||
|
|
abf8026d45 | ||
|
|
26b442b5da | ||
|
|
1cb00e9fbe | ||
|
|
9208e7eaaa | ||
|
|
a1392f9491 | ||
|
|
2ea0e1d54b | ||
|
|
d96fa6f4a1 | ||
|
|
0fb51e12d1 | ||
|
|
4f9494db0b | ||
|
|
4e87b080a0 | ||
|
|
2f3038aba1 | ||
|
|
131fb7d9c7 | ||
|
|
762a724204 | ||
|
|
8090e0aab3 | ||
|
|
9df6330d41 | ||
|
|
fd63e23eaf | ||
|
|
c1143f4629 | ||
|
|
149fdffd49 | ||
|
|
439d5a3ec9 | ||
|
|
9e24d49210 | ||
|
|
90ac43fc82 | ||
|
|
8b6b709987 | ||
|
|
fb6ca9ce85 | ||
|
|
08290b329e | ||
|
|
1d66f84bd3 | ||
|
|
a2d9c1a03f | ||
|
|
d79db88349 | ||
|
|
9f5fb650af | ||
|
|
15b4339c90 | ||
|
|
5ce331394d | ||
|
|
7ba9dabdb4 | ||
|
|
611f3dcd5c | ||
|
|
d3388b160e | ||
|
|
f83d4c06cb | ||
|
|
4941ea3e21 | ||
|
|
e35696e153 | ||
|
|
800c6af7f1 | ||
|
|
e1df584ed5 | ||
|
|
876ef0f8a0 | ||
|
|
928f75e016 | ||
|
|
37fa160c24 | ||
|
|
0eaacbb4ce | ||
|
|
1e8b5c4646 | ||
|
|
fdcfa30810 | ||
|
|
fabaa84373 | ||
|
|
da017bdf20 | ||
|
|
5d94ffab3e | ||
|
|
796ca12fb8 | ||
|
|
4caa98af3a | ||
|
|
c644f2554f | ||
|
|
bb87e4fb4c | ||
|
|
e83dac756d | ||
|
|
0c1709d589 | ||
|
|
a3892001d3 | ||
|
|
039105580c | ||
|
|
f47f431bdf | ||
|
|
7add364e20 | ||
|
|
0ebea74fde | ||
|
|
1156787327 | ||
|
|
15cf6324f6 | ||
|
|
1338b8735c | ||
|
|
82f229adf2 | ||
|
|
048e43a662 | ||
|
|
d8d4da197e | ||
|
|
a0325a8fb1 | ||
|
|
091db50841 | ||
|
|
1774a87dd7 | ||
|
|
62d15efbaf | ||
|
|
c468c57cda | ||
|
|
bacdd6386f | ||
|
|
ea290c4c7b | ||
|
|
31b4afdcb7 | ||
|
|
379d2ef404 | ||
|
|
5cdb7ebb9e | ||
|
|
496a2b870b | ||
|
|
471bdf12b7 | ||
|
|
8fe4683e64 | ||
|
|
c34409edde | ||
|
|
0a37335f3c | ||
|
|
985916dea7 | ||
|
|
3330613254 | ||
|
|
3f12829204 | ||
|
|
f28500e449 | ||
|
|
fd9053a290 | ||
|
|
dc1c5d5b17 | ||
|
|
1297d9f510 | ||
|
|
daaf6c4a14 | ||
|
|
68e2e0b7d7 | ||
|
|
c78ba50331 | ||
|
|
0026a92697 | ||
|
|
ee1b140902 | ||
|
|
f81a3b5d94 | ||
|
|
8719cdd6d6 | ||
|
|
33fb055bfe | ||
|
|
14735af0b3 | ||
|
|
623bda2ecb | ||
|
|
dfbb6d82b3 | ||
|
|
7473a3e03d | ||
|
|
eb9b31f8f5 | ||
|
|
f20a45a1ad | ||
|
|
0f66d1438f | ||
|
|
43110e015d | ||
|
|
7d7a4aa4d2 | ||
|
|
8c640abb26 | ||
|
|
ffabe78942 | ||
|
|
81fcef2156 | ||
|
|
4dc9ec1878 | ||
|
|
3f05a610b3 | ||
|
|
6c997559a2 | ||
|
|
d39b1ed7ee | ||
|
|
0e81fb5037 | ||
|
|
36847ea70c | ||
|
|
9b40ebb8b7 | ||
|
|
9948bc28df | ||
|
|
a9474356ff | ||
|
|
4e23c4bf79 | ||
|
|
b6e6394e51 | ||
|
|
576ef8e033 | ||
|
|
a52380aa63 | ||
|
|
49e7169630 | ||
|
|
d53f492d2a | ||
|
|
3d3abff842 | ||
|
|
90ac63c905 | ||
|
|
320d3783ac | ||
|
|
5966049020 | ||
|
|
755491c263 | ||
|
|
79203d948e | ||
|
|
01d56f3b4f | ||
|
|
477fcf6180 | ||
|
|
3014a99db5 | ||
|
|
c4dd196e72 | ||
|
|
581c74f11a | ||
|
|
7a2f285442 | ||
|
|
375f54263a | ||
|
|
ed4a4c4960 | ||
|
|
9d405ebed9 | ||
|
|
a7fd55aa1c | ||
|
|
c389fea73c | ||
|
|
05e8f4ddd7 | ||
|
|
da9a493e8d | ||
|
|
64ab3197ec | ||
|
|
dddc4d202a | ||
|
|
71180d4fd8 | ||
|
|
1942387050 | ||
|
|
77c1fc908d | ||
|
|
ef4800e82a | ||
|
|
815ea9e025 | ||
|
|
f7ce3d18d8 | ||
|
|
de78f3b218 | ||
|
|
671c7f16bd | ||
|
|
30b5a13f3a | ||
|
|
c830008dfc | ||
|
|
919d0f856e | ||
|
|
49d0fed14e | ||
|
|
19bfeef98f | ||
|
|
cd51f55701 | ||
|
|
17cb0e8319 | ||
|
|
3ef10846b4 | ||
|
|
40eec808ef | ||
|
|
03db6d2677 | ||
|
|
c03e7051d7 | ||
|
|
d6e105f912 | ||
|
|
0da27ed745 | ||
|
|
1d6a2b9cbf | ||
|
|
28fbbd28b7 | ||
|
|
22b8b38b5d | ||
|
|
81b2808eaf | ||
|
|
25eb3340de | ||
|
|
c9150ca4b4 | ||
|
|
b9165f6b1b | ||
|
|
e0ab9ebc4b |
@@ -11,7 +11,12 @@
|
||||
"Bash(php think *)",
|
||||
"Bash(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
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/lists', params })
|
||||
}
|
||||
|
||||
/** 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) */
|
||||
export function appointmentAdminEdit(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/edit', params })
|
||||
}
|
||||
|
||||
/** 批量修改挂号渠道来源(与 edit 共用 doctor.appointment/edit 权限) */
|
||||
export function appointmentBatchEditChannel(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/batchEditChannel', params })
|
||||
}
|
||||
|
||||
// 获取挂号详情
|
||||
export function appointmentDetail(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/detail', params })
|
||||
|
||||
+85
-36
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSelfInputOverview(params: any) {
|
||||
return request.get({ url: '/stats.self_input/overview', params })
|
||||
}
|
||||
|
||||
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||
export function getSelfInputMediaSourceOptions() {
|
||||
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||
}
|
||||
|
||||
export function personalYejiLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||
}
|
||||
|
||||
export function personalYejiAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||
}
|
||||
|
||||
export function personalYejiEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||
}
|
||||
|
||||
export function personalYejiDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||
}
|
||||
|
||||
export function personalYejiDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||
}
|
||||
@@ -42,3 +42,149 @@ export function yejiStatsLeaderboard(params: {
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leaderboard', params })
|
||||
}
|
||||
|
||||
/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */
|
||||
export function yejiStatsUnassignedBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
|
||||
}
|
||||
|
||||
/** 部门行「进线数据」逐条明细(与看板同口径) */
|
||||
export function yejiStatsLeadLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leadLines', params })
|
||||
}
|
||||
|
||||
/** 医助排行榜「预约诊单」逐条挂号明细;业绩主表部门行传 dept_id(可与 assistant_id 二选一) */
|
||||
export function yejiStatsAppointmentLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/appointmentLines', params })
|
||||
}
|
||||
|
||||
/** 二中心复诊:部门行下钻医助 × 业务订单笔数 */
|
||||
export function yejiStatsRevisitBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
revisit_slot: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||
}
|
||||
|
||||
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
|
||||
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
|
||||
|
||||
/** 提成结算业绩(独立于业绩看板 yejiStats) */
|
||||
export function commissionSettlementOverview(params: {
|
||||
settlement_month: string
|
||||
/** 与 tcm.prescriptionOrder/lists 同源:create_time between */
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
/** 默认 3=履约完成,与列表 fulfillment_status 一致 */
|
||||
fulfillment_status?: number
|
||||
/** 传 1 时仅统计 is_system_auto=1;显式时段下默认不传(含手动) */
|
||||
require_system_auto_prescription?: 0 | 1
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementDeptOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
|
||||
}
|
||||
|
||||
export function commissionSettlementChannelOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
|
||||
}
|
||||
|
||||
/** 提成核对:订单明细分页 bucket: 空|current|deferred;appt_channel_value 可与 assistant_id/doctor_id 组合(0=未匹配挂号渠道) */
|
||||
export function commissionSettlementOrderLines(params: {
|
||||
settlement_month: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
fulfillment_status?: number
|
||||
require_system_auto_prescription?: 0 | 1
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
bucket?: string
|
||||
assistant_id?: number
|
||||
doctor_id?: number
|
||||
appt_channel_value?: number
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmStatus(params: {
|
||||
settlement_month: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
|
||||
return request.post(
|
||||
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
/** 撤回「确定本期业绩」:清除顺延结转,状态变为可再次核对/确定 */
|
||||
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||
}
|
||||
|
||||
+126
-6
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
||||
}
|
||||
|
||||
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
|
||||
export function diagnosisReadonlyDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
|
||||
}
|
||||
|
||||
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
|
||||
export function diagnosisTrackingWindow(params: {
|
||||
id: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
|
||||
}
|
||||
|
||||
/** 新增跟踪备注(按天合并追加,仅文字) */
|
||||
export function diagnosisAddTrackingNote(params: {
|
||||
diagnosis_id: number
|
||||
tracking_content: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
|
||||
}
|
||||
|
||||
/** 拉取跟踪备注列表(note_date DESC) */
|
||||
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
|
||||
}
|
||||
|
||||
// 医助理诊单统计(按部门、按人)
|
||||
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
|
||||
export function assistantDiagnosisStats(params?: {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
days?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
|
||||
}
|
||||
|
||||
@@ -198,14 +229,25 @@ export function startCloudRecording(params: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
|
||||
}
|
||||
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消) */
|
||||
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id) */
|
||||
export function attachLocalCallRecording(params: {
|
||||
diagnosis_id: number
|
||||
file_url: string
|
||||
call_record_id?: number
|
||||
}) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
export function createManualCallRecord(params: { diagnosis_id: number }) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/createManualCallRecord', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
|
||||
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/watchCall', params })
|
||||
@@ -230,6 +272,11 @@ export function getImChatMessages(params: { diagnosis_id: number; only_archived?
|
||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
||||
}
|
||||
|
||||
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
|
||||
export function triggerImChatSync(data: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
|
||||
}
|
||||
|
||||
// 获取企业微信聊天记录
|
||||
export function getWechatChatRecords(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
|
||||
@@ -272,6 +319,16 @@ export function prescriptionEdit(params: any) {
|
||||
return request.post({ url: '/tcm.prescription/edit', params })
|
||||
}
|
||||
|
||||
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
|
||||
export function prescriptionPatchPatient(params: {
|
||||
id: number
|
||||
patient_name: string
|
||||
phone: string
|
||||
gender: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescription/patchPatient', params })
|
||||
}
|
||||
|
||||
// 删除处方
|
||||
export function prescriptionDelete(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescription/delete', params })
|
||||
@@ -298,7 +355,11 @@ export function prescriptionVoid(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
||||
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
|
||||
export function prescriptionAudit(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescription/audit', params })
|
||||
}
|
||||
|
||||
@@ -312,6 +373,11 @@ export function prescriptionOrderLists(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
|
||||
}
|
||||
|
||||
/** 处方业务订单导出(export=1 预估条数,export=2 下载 Excel) */
|
||||
export function prescriptionOrderExport(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/export', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
|
||||
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
|
||||
@@ -396,6 +462,7 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
@@ -403,11 +470,18 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderLinkPayOrder(params: {
|
||||
id: number
|
||||
pay_order_id: number
|
||||
pay_order_id?: number
|
||||
pay_order_ids?: number[]
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
|
||||
export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
@@ -419,7 +493,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
dose_count?: number
|
||||
medication_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||
}
|
||||
|
||||
@@ -428,6 +506,16 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
}
|
||||
|
||||
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
|
||||
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
@@ -454,3 +542,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
|
||||
export function prescriptionLibraryDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
||||
}
|
||||
|
||||
// ========== 诊单待办事项(T5) ==========
|
||||
|
||||
/** 待办列表(按 diagnosis_id) */
|
||||
export function diagnosisTodoLists(params: {
|
||||
diagnosis_id: number
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
creator_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
|
||||
}
|
||||
|
||||
/** 新增待办(remind_time 为 unix 秒级时间戳) */
|
||||
export function diagnosisTodoAdd(params: {
|
||||
diagnosis_id: number
|
||||
content: string
|
||||
remind_time: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/add', params })
|
||||
}
|
||||
|
||||
/** 取消待办(仅 status=0 / 创建人或超管) */
|
||||
export function diagnosisTodoCancel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
|
||||
}
|
||||
|
||||
/** 待办详情 */
|
||||
export function diagnosisTodoDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
|
||||
}
|
||||
|
||||
@@ -9,17 +9,35 @@
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="chat-notify-card"
|
||||
:class="{ 'chat-notify-card-complete': item.type === 'consultation_complete' }"
|
||||
:class="{
|
||||
'chat-notify-card-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-card-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="chat-notify-icon" :class="{ 'chat-notify-icon-complete': item.type === 'consultation_complete' }">
|
||||
<div
|
||||
class="chat-notify-icon"
|
||||
:class="{
|
||||
'chat-notify-icon-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-icon-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
>
|
||||
<el-icon :size="22">
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
|
||||
<CircleCheck v-else />
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
|
||||
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
|
||||
<Right v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="chat-notify-body">
|
||||
<div class="chat-notify-title">{{ item.type === 'consultation_complete' ? '面诊结束' : '患者打开会话' }}</div>
|
||||
<div class="chat-notify-title">
|
||||
{{
|
||||
item.type === 'consultation_complete'
|
||||
? '面诊结束'
|
||||
: item.type === 'patient_left_chat'
|
||||
? '患者离开会话'
|
||||
: '患者打开会话'
|
||||
}}
|
||||
</div>
|
||||
<div class="chat-notify-desc">
|
||||
<template v-if="item.type === 'consultation_complete'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
@@ -27,6 +45,10 @@
|
||||
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
|
||||
完成,请及时跟进
|
||||
</template>
|
||||
<template v-else-if="item.type === 'patient_left_chat'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已离开问诊会话页面
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已打开与您的会话,请及时查看
|
||||
@@ -45,14 +67,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChatDotRound, CircleCheck, Close } from '@element-plus/icons-vue'
|
||||
import { ChatDotRound, CircleCheck, Close, Right } from '@element-plus/icons-vue'
|
||||
import { getChatNotifications } from '@/api/chat'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export interface ChatNotifyItem {
|
||||
id: string
|
||||
type?: 'patient_opened_chat' | 'consultation_complete'
|
||||
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
|
||||
doctor_id?: number
|
||||
assistant_id?: number
|
||||
patient_id?: string
|
||||
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.chat-notify-card-left {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.chat-notify-icon-left {
|
||||
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
|
||||
}
|
||||
|
||||
.chat-notify-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<el-button>导出</el-button>
|
||||
</template>
|
||||
<div>
|
||||
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
|
||||
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
|
||||
<el-form-item label="数据量:">
|
||||
预计导出{{ exportData.count }}条数据, 共{{ exportData.sum_page }}页,每页{{
|
||||
@@ -79,6 +80,11 @@ const props = defineProps({
|
||||
fetchFun: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
|
||||
exportHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<div class="guahao-list">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" :inline="true" @submit.prevent>
|
||||
<el-form-item label="预约日期">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="患者姓名">
|
||||
<el-input
|
||||
v-model="queryParams.patient_name"
|
||||
placeholder="模糊搜索"
|
||||
clearable
|
||||
class="!w-[160px]"
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医生">
|
||||
<el-input
|
||||
v-model="queryParams.doctor_name"
|
||||
placeholder="模糊搜索"
|
||||
clearable
|
||||
class="!w-[140px]"
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助">
|
||||
<el-select
|
||||
v-model="queryParams.assistant_id"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-[180px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in assistantOptions"
|
||||
:key="a.id"
|
||||
:label="a.name + (a.account ? ` (${a.account})` : '')"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
||||
<el-option label="已预约" :value="1" />
|
||||
<el-option label="已取消" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已过号" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select
|
||||
v-model="queryParams.channel_source"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-[200px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/edit']"
|
||||
type="primary"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="openBatchChannelDialog"
|
||||
>
|
||||
批量修改渠道
|
||||
</el-button>
|
||||
<span v-if="selectedRows.length > 0" class="text-sm text-gray-500">
|
||||
已选 {{ selectedRows.length }} 条
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="pager.loading"
|
||||
row-key="id"
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" reserve-selection />
|
||||
<el-table-column label="ID" prop="id" width="72" align="center" />
|
||||
<el-table-column label="诊单/患者" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ row.patient_name || '—' }}</div>
|
||||
<div class="text-gray-500">{{ maskPhone(row.patient_phone) }}</div>
|
||||
<div class="text-xs text-gray-400">诊单 #{{ row.diagnosis_id ?? row.patient_id }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医生" prop="doctor_name" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="医助" prop="assistant_name" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="预约时间" min-width="128">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.appointment_date }}</div>
|
||||
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
|
||||
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
||||
<div v-if="row.channel_source_detail" class="text-xs text-gray-400 truncate">
|
||||
{{ row.channel_source_detail }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'
|
||||
"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status_desc }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-perms="['doctor.appointment/edit']" type="primary" link @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑挂号" width="560px" destroy-on-close @closed="resetEditForm">
|
||||
<el-form :model="editForm" label-width="96px">
|
||||
<el-form-item label="预约日期" required>
|
||||
<el-date-picker
|
||||
v-model="editForm.appointment_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预约时间" required>
|
||||
<el-time-picker
|
||||
v-model="editForm.appointment_time"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
placeholder="时间"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时段" required>
|
||||
<el-radio-group v-model="editForm.period">
|
||||
<el-radio-button label="morning">上午</el-radio-button>
|
||||
<el-radio-button label="afternoon">下午</el-radio-button>
|
||||
<el-radio-button label="all">全天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="问诊类型" required>
|
||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
<el-option label="电话问诊" value="phone" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道来源" required>
|
||||
<el-select
|
||||
v-model="editForm.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
filterable
|
||||
class="!w-full"
|
||||
@change="onChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="needsChannelSourceDetail" label="自媒体补充" required>
|
||||
<el-input
|
||||
v-model="editForm.channel_source_detail"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
clearable
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" required>
|
||||
<el-select v-model="editForm.status" class="!w-full">
|
||||
<el-option label="已预约" :value="1" />
|
||||
<el-option label="已取消" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已过号" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="挂号医助">
|
||||
<el-select
|
||||
v-model="editForm.assistant_id"
|
||||
placeholder="不修改可留空"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in assistantOptions"
|
||||
:key="a.id"
|
||||
:label="a.name + (a.account ? ` (${a.account})` : '')"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="batchChannelVisible"
|
||||
title="批量修改渠道"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
@closed="resetBatchChannelForm"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-4">将对已选 {{ selectedRows.length }} 条挂号写入同一渠道来源(仅改渠道,不改其它字段)。</p>
|
||||
<el-form :model="batchChannelForm" label-width="96px">
|
||||
<el-form-item label="渠道来源" required>
|
||||
<el-select
|
||||
v-model="batchChannelForm.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
filterable
|
||||
class="!w-full"
|
||||
@change="onBatchChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="needsBatchChannelSourceDetail" label="自媒体补充" required>
|
||||
<el-input
|
||||
v-model="batchChannelForm.channel_source_detail"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
clearable
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchChannelVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchChannelSaving" @click="submitBatchChannel">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="consumerPrescriptionGuahao">
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
status: '' as number | '',
|
||||
channel_source: '' as string
|
||||
})
|
||||
|
||||
const queryInit = {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
status: '' as number | '',
|
||||
channel_source: ''
|
||||
}
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: appointmentLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const assistantOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
|
||||
|
||||
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
|
||||
const selectedRows = ref<Record<string, unknown>[]>([])
|
||||
|
||||
function onSelectionChange(rows: Record<string, unknown>[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
'自媒体2Q'
|
||||
])
|
||||
|
||||
function channelNameRequiresSelfMediaDetail(name: string) {
|
||||
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
|
||||
}
|
||||
|
||||
const channelOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
const selectedChannelDictName = computed(() => {
|
||||
const v = editForm.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
const needsChannelSourceDetail = computed(() => channelNameRequiresSelfMediaDetail(selectedChannelDictName.value))
|
||||
|
||||
const batchSelectedChannelDictName = computed(() => {
|
||||
const v = batchChannelForm.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
const needsBatchChannelSourceDetail = computed(() =>
|
||||
channelNameRequiresSelfMediaDetail(batchSelectedChannelDictName.value)
|
||||
)
|
||||
|
||||
function onBatchChannelSourceChange(val: string) {
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
const batchChannelVisible = ref(false)
|
||||
const batchChannelSaving = ref(false)
|
||||
const batchChannelForm = reactive({
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
function openBatchChannelDialog() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
feedback.msgWarning('请先勾选挂号记录')
|
||||
return
|
||||
}
|
||||
batchChannelForm.channel_source = ''
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
batchChannelVisible.value = true
|
||||
}
|
||||
|
||||
function resetBatchChannelForm() {
|
||||
batchChannelForm.channel_source = ''
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
}
|
||||
|
||||
async function submitBatchChannel() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
feedback.msgWarning('请先勾选挂号记录')
|
||||
return
|
||||
}
|
||||
if (!batchChannelForm.channel_source) {
|
||||
feedback.msgError('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsBatchChannelSourceDetail.value && !batchChannelForm.channel_source_detail.trim()) {
|
||||
feedback.msgError('请填写自媒体补充说明')
|
||||
return
|
||||
}
|
||||
batchChannelSaving.value = true
|
||||
try {
|
||||
const ids = selectedRows.value.map((r) => Number(r.id))
|
||||
await appointmentBatchEditChannel({
|
||||
ids,
|
||||
channel_source: batchChannelForm.channel_source,
|
||||
channel_source_detail: batchChannelForm.channel_source_detail.trim()
|
||||
})
|
||||
feedback.msgSuccess('批量修改成功')
|
||||
batchChannelVisible.value = false
|
||||
tableRef.value?.clearSelection?.()
|
||||
selectedRows.value = []
|
||||
getLists()
|
||||
} finally {
|
||||
batchChannelSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onChannelSourceChange(val: string) {
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
editForm.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
appointment_date: '',
|
||||
appointment_time: '' as string,
|
||||
period: 'morning' as 'morning' | 'afternoon' | 'all',
|
||||
appointment_type: 'video',
|
||||
status: 1,
|
||||
remark: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
function maskPhone(phone?: string) {
|
||||
const p = String(phone || '')
|
||||
if (p.length >= 11) {
|
||||
return p.slice(0, 3) + '****' + p.slice(-4)
|
||||
}
|
||||
return p || '—'
|
||||
}
|
||||
|
||||
function formatHm(t?: string) {
|
||||
const s = String(t || '')
|
||||
return s.length >= 5 ? s.slice(0, 5) : s || '—'
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
Object.assign(queryParams, queryInit)
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function rowPeriod(row: Record<string, unknown>): 'morning' | 'afternoon' | 'all' {
|
||||
const v = String(row.period ?? row.type ?? 'morning')
|
||||
if (v === 'afternoon' || v === 'all') {
|
||||
return v
|
||||
}
|
||||
return 'morning'
|
||||
}
|
||||
|
||||
async function loadAssistants() {
|
||||
try {
|
||||
const res: any = await getAssistants()
|
||||
assistantOptions.value = res?.lists ?? res ?? []
|
||||
} catch {
|
||||
assistantOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannelOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'channels' })
|
||||
const rows = (data?.channels || []).filter((row: { status?: number }) => row.status !== 0)
|
||||
rows.sort((a: { sort?: number; id?: number }, b: { sort?: number; id?: number }) => {
|
||||
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
|
||||
if (ds !== 0) return ds
|
||||
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
|
||||
})
|
||||
channelOptions.value = rows
|
||||
} catch {
|
||||
channelOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: Record<string, unknown>) {
|
||||
editForm.id = Number(row.id)
|
||||
editForm.appointment_date = String(row.appointment_date || '')
|
||||
editForm.appointment_time = formatHm(String(row.appointment_time || ''))
|
||||
editForm.period = rowPeriod(row)
|
||||
editForm.appointment_type = String(row.appointment_type || 'video')
|
||||
editForm.status = Number(row.status)
|
||||
editForm.remark = String(row.remark || '')
|
||||
const raw = row.appointment_assistant_id
|
||||
editForm.assistant_id =
|
||||
raw !== undefined && raw !== null && raw !== '' ? Number(raw) : undefined
|
||||
editForm.channel_source = String(row.channel_source ?? row.channels ?? '')
|
||||
editForm.channel_source_detail = String(row.channel_source_detail ?? '')
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.id = 0
|
||||
editForm.appointment_date = ''
|
||||
editForm.appointment_time = ''
|
||||
editForm.period = 'morning'
|
||||
editForm.appointment_type = 'video'
|
||||
editForm.status = 1
|
||||
editForm.remark = ''
|
||||
editForm.assistant_id = undefined
|
||||
editForm.channel_source = ''
|
||||
editForm.channel_source_detail = ''
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.appointment_date) {
|
||||
feedback.msgError('请选择预约日期')
|
||||
return
|
||||
}
|
||||
if (!editForm.appointment_time) {
|
||||
feedback.msgError('请选择预约时间')
|
||||
return
|
||||
}
|
||||
if (!editForm.channel_source) {
|
||||
feedback.msgError('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsChannelSourceDetail.value && !editForm.channel_source_detail.trim()) {
|
||||
feedback.msgError('请填写自媒体补充说明')
|
||||
return
|
||||
}
|
||||
editSaving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: editForm.id,
|
||||
appointment_date: editForm.appointment_date,
|
||||
appointment_time: editForm.appointment_time,
|
||||
period: editForm.period,
|
||||
appointment_type: editForm.appointment_type,
|
||||
status: editForm.status,
|
||||
remark: editForm.remark,
|
||||
assistant_id: editForm.assistant_id ?? '',
|
||||
channel_source: editForm.channel_source,
|
||||
channel_source_detail: editForm.channel_source_detail.trim()
|
||||
}
|
||||
await appointmentAdminEdit(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
editVisible.value = false
|
||||
getLists()
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAssistants()
|
||||
loadChannelOptions()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
getLists()
|
||||
})
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,13 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="处方类型">
|
||||
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="是否公开">
|
||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
@@ -37,6 +44,13 @@
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -114,6 +128,13 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方类型" prop="formula_type">
|
||||
<el-radio-group v-model="editForm.formula_type">
|
||||
<el-radio value="主方">主方</el-radio>
|
||||
<el-radio value="辅方">辅方</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '',
|
||||
is_public: ''
|
||||
})
|
||||
|
||||
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
})
|
||||
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
|
||||
const resetForm = () => {
|
||||
editForm.id = 0
|
||||
editForm.prescription_name = ''
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
}
|
||||
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
|
||||
editMode.value = 'view'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
|
||||
editMode.value = 'edit'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -677,7 +677,19 @@
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -819,6 +831,9 @@
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
@@ -910,11 +925,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -967,11 +983,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -1091,7 +1108,8 @@
|
||||
{{ expressCompanyLabel(detailData.express_company) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">
|
||||
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
@@ -1217,6 +1235,47 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="updateAmountVisible"
|
||||
title="修改订单金额"
|
||||
width="92%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetUpdateAmountDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="updateAmountFormRef"
|
||||
:model="updateAmountForm"
|
||||
:rules="updateAmountRules"
|
||||
label-width="84px"
|
||||
@submit.prevent="submitUpdateAmount"
|
||||
>
|
||||
<el-form-item label="当前金额">
|
||||
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
placeholder="请输入新金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="text-xs text-gray-400 pl-[84px] -mt-1 leading-relaxed">
|
||||
已完成或已取消订单不可修改。提交成功后会刷新详情与操作日志。
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="updateAmountVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑(分步向导与「创建业务订单」弹窗统一) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
@@ -1563,7 +1622,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_extra"
|
||||
type="textarea"
|
||||
@@ -1573,6 +1632,16 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_assistant"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,医助对内说明"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -2328,6 +2397,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderShip,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
@@ -2789,6 +2859,9 @@ const listStats = computed(() => {
|
||||
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
||||
} else if (scope === 'assistant') {
|
||||
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
||||
} else if (scope === 'own_rx') {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
@@ -2974,19 +3047,57 @@ function formatTime(v: unknown) {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** 与 server/config/project.php order_edit_all_roles 一致 */
|
||||
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
|
||||
|
||||
function canBypassCreatorDualAuditEditLock(): boolean {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
|
||||
}
|
||||
|
||||
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
|
||||
const uid = Number(userStore.userInfo?.id)
|
||||
return uid > 0 && Number(row.creator_id) === uid
|
||||
}
|
||||
|
||||
function isDualAuditPassed(row: {
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
}) {
|
||||
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canEditRow(row: {
|
||||
creator_id?: number
|
||||
fulfillment_status?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
// 已发货(5)、已签收(6)、已完成(3)、已取消(4) 不可编辑
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs !== 1 && fs !== 2) return false
|
||||
// 已成功提交甘草(已生成甘草处方单号 或 提交时间 > 0)不允许再编辑
|
||||
if (fs === 3 || fs === 4) return false
|
||||
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
if (gcNo !== '' || gcTime > 0) return false
|
||||
return true
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
|
||||
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
|
||||
@@ -3055,9 +3166,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
||||
}
|
||||
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
// 已发货(5) / 已签收(6) 后如需修改快递信息可单独使用此功能
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs === 5 || fs === 6
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
@@ -3116,6 +3225,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
@@ -3325,8 +3440,65 @@ const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
|
||||
let logisticsTraceRequestSeq = 0
|
||||
|
||||
type LogisticsTraceRequestContext = {
|
||||
requestSeq: number
|
||||
orderId: number
|
||||
trackingNumber: string
|
||||
}
|
||||
|
||||
function bumpLogisticsTraceRequestToken() {
|
||||
logisticsTraceRequestSeq += 1
|
||||
logisticsTracePayload.value = null
|
||||
}
|
||||
|
||||
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
|
||||
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
|
||||
if (Number(detailData.value?.id) !== ctx.orderId) return false
|
||||
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
|
||||
}
|
||||
|
||||
function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
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 +3534,7 @@ function logActionText(act: string) {
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
@@ -3395,24 +3568,33 @@ function expressCompanyLabel(v: unknown) {
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++logisticsTraceRequestSeq
|
||||
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
id: orderId,
|
||||
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>
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3446,6 +3628,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,11 +3685,42 @@ 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
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
@@ -3505,7 +3736,7 @@ async function openDetail(id: number) {
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
fetchLogs(id)
|
||||
|
||||
@@ -3572,6 +3803,7 @@ const editForm = reactive({
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
remark_extra: '',
|
||||
remark_assistant: '',
|
||||
pay_order_ids: [] as number[],
|
||||
internal_cost: undefined as number | string | undefined,
|
||||
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
||||
@@ -3734,7 +3966,25 @@ async function goEditOrderNextStep() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: { id: number }) {
|
||||
async function openEdit(row: {
|
||||
id: number
|
||||
creator_id?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
if (!canEditRow(row)) {
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
|
||||
}
|
||||
return
|
||||
}
|
||||
editOrderStep.value = 0
|
||||
editOrderPrescription.value = null
|
||||
editVisible.value = true
|
||||
@@ -3822,6 +4072,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 +4111,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 +4391,7 @@ async function submitQuickTrack() {
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
@@ -4156,8 +4409,8 @@ async function submitQuickTrack() {
|
||||
if (nd) detailData.value = nd
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
// 履约中状态且刚填单号,提示是否立即发货
|
||||
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
@@ -4492,19 +4745,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 +4852,25 @@ const rxUsageText = computed(() => {
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
||||
seg.push(`每包${amount}${unit}`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams(按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
|
||||
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||
const rxOutPelletGrams = computed(() => {
|
||||
const pill = slipPillGrams.value
|
||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,18 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
<el-date-picker
|
||||
v-model="addTimeRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
clearable
|
||||
class="!w-[280px]"
|
||||
@change="onAddTimeRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select
|
||||
v-model="queryParams.tag_ids"
|
||||
@@ -557,12 +569,34 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: []
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: ''
|
||||
})
|
||||
|
||||
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time) */
|
||||
const addTimeRange = ref<[string, string] | null>(null)
|
||||
|
||||
function onAddTimeRangeChange(val: [string, string] | null) {
|
||||
if (val && val.length === 2 && val[0] && val[1]) {
|
||||
queryParams.add_time_start = val[0]
|
||||
queryParams.add_time_end = val[1]
|
||||
} else {
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
}
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
|
||||
interface TagItem {
|
||||
tag_id: string
|
||||
@@ -793,6 +827,9 @@ function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
addTimeRange.value = null
|
||||
resetParams()
|
||||
}
|
||||
|
||||
|
||||
+3820
-355
File diff suppressed because it is too large
Load Diff
@@ -22,16 +22,25 @@
|
||||
<el-select
|
||||
v-model="formData.media_channel_code"
|
||||
placeholder="请选择自媒体渠道"
|
||||
class="w-full"
|
||||
class="account-cost-channel-select w-full"
|
||||
filterable
|
||||
:disabled="mode === 'edit'"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mediaChannelOptions"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
<el-option-group
|
||||
v-for="g in mediaChannelGroups"
|
||||
:key="g.group_name"
|
||||
:label="g.group_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="ch in g.channels"
|
||||
:key="ch.channel_code"
|
||||
:label="ch.channel_name"
|
||||
:value="ch.channel_code"
|
||||
>
|
||||
<div class="channel-opt-row">
|
||||
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" prop="dept_id">
|
||||
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
|
||||
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
interface MediaChannelOption {
|
||||
code: string
|
||||
name: string
|
||||
interface MediaChannelGroup {
|
||||
group_name: string
|
||||
channels: { channel_code: string; channel_name: string }[]
|
||||
}
|
||||
|
||||
interface DeptOption {
|
||||
@@ -90,7 +99,7 @@ interface DeptOption {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mediaChannelOptions: MediaChannelOption[]
|
||||
mediaChannelGroups: MediaChannelGroup[]
|
||||
deptOptions: DeptOption[]
|
||||
defaultMediaChannelCode: string
|
||||
}>()
|
||||
@@ -197,3 +206,21 @@ defineExpose({
|
||||
getDetail,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-cost-channel-select {
|
||||
:deep(.channel-opt-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
:deep(.opt-name) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,14 +27,25 @@
|
||||
placeholder="筛选全部渠道"
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
class="account-cost-channel-select w-[220px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mediaChannelOptions"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option-group
|
||||
v-for="g in mediaChannelGroups"
|
||||
:key="g.group_name"
|
||||
:label="g.group_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="ch in g.channels"
|
||||
:key="ch.channel_code"
|
||||
:label="ch.channel_name"
|
||||
:value="ch.channel_code"
|
||||
>
|
||||
<div class="channel-opt-row">
|
||||
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
@@ -78,7 +89,15 @@
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="getChannelGroupLabel(row.media_channel_code)">
|
||||
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
|
||||
{{ row.media_channel_name }}
|
||||
</span>
|
||||
<span v-else>{{ row.media_channel_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
@@ -117,7 +136,7 @@
|
||||
<edit-popup
|
||||
v-if="showEdit"
|
||||
ref="editRef"
|
||||
:media-channel-options="mediaChannelOptions"
|
||||
:media-channel-groups="mediaChannelGroups"
|
||||
:dept-options="deptOptions"
|
||||
:default-media-channel-code="defaultMediaChannelCode"
|
||||
@success="getLists"
|
||||
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
|
||||
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
interface MediaChannelGroup {
|
||||
group_name: string
|
||||
channels: { channel_code: string; channel_name: string }[]
|
||||
}
|
||||
|
||||
interface MediaChannelOption {
|
||||
code: string
|
||||
name: string
|
||||
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
||||
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||
const options = pager.extend.media_channel_options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
|
||||
const groups = pager.extend.media_channel_groups
|
||||
if (Array.isArray(groups) && groups.length > 0) {
|
||||
return groups as MediaChannelGroup[]
|
||||
}
|
||||
const flat = mediaChannelOptionsFlat.value
|
||||
if (!flat.length) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
group_name: '渠道',
|
||||
channels: flat.map(item => ({
|
||||
channel_code: item.code,
|
||||
channel_name: item.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const deptOptions = computed<DeptOption[]>(() => {
|
||||
const options = pager.extend.dept_options
|
||||
return Array.isArray(options) ? options : []
|
||||
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
|
||||
|
||||
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
||||
|
||||
const getChannelGroupLabel = (channelCode: string): string => {
|
||||
if (!channelCode) return ''
|
||||
for (const group of mediaChannelGroups.value) {
|
||||
const channel = group.channels.find(ch => ch.channel_code === channelCode)
|
||||
if (channel) {
|
||||
return group.group_name
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
@@ -213,3 +268,21 @@ const handleReset = () => {
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-cost-channel-select {
|
||||
:deep(.channel-opt-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
:deep(.opt-name) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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[]
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="assistant-performance-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="stats-filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
|
||||
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||
{{ formatValue(card.key, card.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">业绩趋势</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="chartHasData"
|
||||
class="stats-chart"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="assistantPerformancePage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { assistantPerformanceOverview } from '@/api/stats'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
const queryParams = reactive({
|
||||
time_type: 'month',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const overview = reactive<Record<string, any>>({
|
||||
date_range: [],
|
||||
summary: {
|
||||
total_amount: 0,
|
||||
total_count: 0
|
||||
},
|
||||
chart: {
|
||||
dates: [],
|
||||
amounts: [],
|
||||
counts: []
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards = [
|
||||
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
|
||||
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
|
||||
]
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
})
|
||||
|
||||
const chartHasData = computed(() => {
|
||||
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
|
||||
})
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0]
|
||||
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
|
||||
}
|
||||
},
|
||||
grid: { left: 60, right: 24, top: 36, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: overview.chart.dates,
|
||||
axisLabel: {
|
||||
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
|
||||
rotate: overview.chart.dates.length > 10 ? 30 : 0
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(元)',
|
||||
axisLabel: {
|
||||
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '业绩',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 3, color: '#4a78ff' },
|
||||
itemStyle: { color: '#4a78ff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
data: overview.chart.amounts
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const formatValue = (key: string, type: string) => {
|
||||
const value = overview.summary?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value).toFixed(2)}`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { time_type: queryParams.time_type }
|
||||
if (queryParams.time_type === 'custom') {
|
||||
params.start_date = dateRange.value[0] || ''
|
||||
params.end_date = dateRange.value[1] || ''
|
||||
}
|
||||
const res = await assistantPerformanceOverview(params)
|
||||
Object.assign(overview, res || {})
|
||||
} catch (error: any) {
|
||||
console.error('获取业绩数据失败:', error)
|
||||
ElMessage.error(error?.msg || '获取业绩数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomDateChange = () => {
|
||||
if (dateRange.value?.length === 2) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.time_type = 'month'
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.assistant-performance-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled {
|
||||
background: linear-gradient(145deg, #ffffff, #fff5f5);
|
||||
border-color: #ffe0e0;
|
||||
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stats-kpi-value.is-money {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled .stats-kpi-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<el-select
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:allow-create="allowCreate"
|
||||
:default-first-option="allowCreate"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:class="selectClass"
|
||||
:style="selectStyle"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@change="emit('change', $event)"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in normalizedOptions"
|
||||
:key="item.name"
|
||||
:label="item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
<!-- 旧记录可能存在不在当前字典内的值:保留显示,避免编辑时反查不到 -->
|
||||
<el-option
|
||||
v-if="modelValue && !hasCurrentValue"
|
||||
:key="`__legacy_${modelValue}`"
|
||||
:label="`${modelValue}(已停用)`"
|
||||
:value="modelValue"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface OptionItem {
|
||||
name: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
options: Array<OptionItem | string>
|
||||
loading?: boolean
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
filterable?: boolean
|
||||
allowCreate?: boolean
|
||||
disabled?: boolean
|
||||
selectClass?: string
|
||||
selectStyle?: string | Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
loading: false,
|
||||
placeholder: '请选择自媒体来源',
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
allowCreate: false,
|
||||
disabled: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
change: [value: string]
|
||||
'visible-change': [visible: boolean]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed<OptionItem[]>(() => {
|
||||
return (props.options || []).map((item) =>
|
||||
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
|
||||
)
|
||||
})
|
||||
|
||||
const hasCurrentValue = computed(() => {
|
||||
return normalizedOptions.value.some((item) => item.name === props.modelValue)
|
||||
})
|
||||
|
||||
const onVisibleChange = (visible: boolean) => {
|
||||
emit('visible-change', visible)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="personal-account-cost-page">
|
||||
<el-card class="!border-none mb-4" shadow="never">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">当前筛选天数</div>
|
||||
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">累计账户消耗 (元)</div>
|
||||
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item label="日期范围">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
:data="deptOptions"
|
||||
node-key="id"
|
||||
:props="deptTreeProps"
|
||||
:default-expand-all="true"
|
||||
check-strictly
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
@change="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源">
|
||||
<media-source-select
|
||||
v-model="queryParams.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[280px]" label="备注/录入人">
|
||||
<el-input
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.dept_path"
|
||||
:content="row.dept_path"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||
<span v-else class="text-gray-400">未分配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['stats.personal_account_cost/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['stats.personal_account_cost/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<cost-edit-popup
|
||||
v-if="showEdit"
|
||||
ref="editRef"
|
||||
:media-source-options="mediaSourceOptions"
|
||||
:media-source-loading="mediaSourceLoading"
|
||||
@success="handleCostSuccess"
|
||||
@close="showEdit = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="personalAccountCost">
|
||||
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { getRoutePath } from '@/router'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import CostEditPopup from './cost-edit.vue'
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||
|
||||
const router = useRouter()
|
||||
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||
const showEdit = ref(false)
|
||||
const deptOptions = ref<any[]>([])
|
||||
const {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
} = useMediaSourceOptions()
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
|
||||
|
||||
const goSelfInput = () => {
|
||||
if (selfInputPath.value) {
|
||||
router.push(selfInputPath.value)
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
media_source: '',
|
||||
dept_id: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: personalAccountCostLists,
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
if (visible) {
|
||||
refreshMediaSourceOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCostSuccess = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
getLists()
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('add')
|
||||
}
|
||||
|
||||
const handleEdit = async (row: Record<string, any>) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('edit')
|
||||
editRef.value?.setFormData(row)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await feedback.confirm('确定删除该账户消耗记录?')
|
||||
await personalAccountCostDelete({ id })
|
||||
getLists()
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await deptAll({ apply_data_scope: 1 })
|
||||
if (Array.isArray(res)) {
|
||||
deptOptions.value = res
|
||||
}
|
||||
} catch (e) {
|
||||
deptOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.personal-account-cost-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="520px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||
<el-form-item label="日期" prop="cost_date">
|
||||
<el-date-picker
|
||||
v-model="formData.cost_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择日期"
|
||||
:disabled="mode === 'edit'"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源" prop="media_source">
|
||||
<media-source-select
|
||||
v-model="formData.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="请选择自媒体来源"
|
||||
:allow-create="false"
|
||||
:disabled="mode === 'edit'"
|
||||
select-class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户消耗" prop="amount">
|
||||
<el-input-number
|
||||
v-model="formData.amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import {
|
||||
personalAccountCostAdd,
|
||||
personalAccountCostEdit,
|
||||
} from '@/api/self_input_stats'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mediaSourceOptions?: MediaSourceOption[]
|
||||
mediaSourceLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
mediaSourceOptions: () => [],
|
||||
mediaSourceLoading: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref<'add' | 'edit'>('add')
|
||||
|
||||
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
cost_date: '',
|
||||
media_source: '',
|
||||
amount: 0,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
|
||||
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = ''
|
||||
formData.cost_date = ''
|
||||
formData.media_source = ''
|
||||
formData.amount = 0
|
||||
formData.remark = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (mode.value === 'edit') {
|
||||
await personalAccountCostEdit(formData)
|
||||
} else {
|
||||
await personalAccountCostAdd(formData)
|
||||
}
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const open = (type: 'add' | 'edit' = 'add') => {
|
||||
mode.value = type
|
||||
resetForm()
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
const setFormData = (data: Record<string, any>) => {
|
||||
formData.id = data.id ?? ''
|
||||
formData.cost_date = data.cost_date ?? ''
|
||||
formData.media_source = data.media_source ?? ''
|
||||
formData.amount = Number(data.amount ?? 0)
|
||||
formData.remark = data.remark ?? ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="self-input-stats-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
:data="deptOptions"
|
||||
node-key="id"
|
||||
:props="deptTreeProps"
|
||||
:default-expand-all="true"
|
||||
check-strictly
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 220px"
|
||||
@change="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自媒体来源">
|
||||
<media-source-select
|
||||
v-model="queryParams.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-style="width: 220px"
|
||||
@change="handleQuery"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
|
||||
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header-main">
|
||||
<span class="card-title">业绩明细</span>
|
||||
<span class="card-hint">{{ dateRangeText }}</span>
|
||||
</div>
|
||||
<div class="card-header-actions">
|
||||
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增业绩
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="accountCostEntryPath"
|
||||
v-perms="['stats.personal_account_cost/lists']"
|
||||
@click="goAccountCostEntry"
|
||||
>
|
||||
账户消耗录入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
border
|
||||
size="large"
|
||||
max-height="640"
|
||||
>
|
||||
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
||||
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.dept_path"
|
||||
:content="row.dept_path"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||
<span v-else class="text-gray-400">未分配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="col in visibleTableColumns"
|
||||
:key="col.key"
|
||||
:label="col.label"
|
||||
:min-width="col.minWidth || 100"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ renderMetric(col.key, row, col.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['stats.personal_yeji/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEditYeji(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['stats.personal_yeji/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDeleteYeji(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="fetchOverview" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<yeji-edit-popup
|
||||
v-if="showYejiEdit"
|
||||
ref="yejiEditRef"
|
||||
:media-source-options="mediaSourceOptions"
|
||||
:media-source-loading="mediaSourceLoading"
|
||||
@success="handleYejiSuccess"
|
||||
@close="showYejiEdit = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="selfInputStats">
|
||||
import {
|
||||
getSelfInputOverview,
|
||||
personalYejiDelete,
|
||||
} from '@/api/self_input_stats'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { getRoutePath } from '@/router'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import YejiEditPopup from './yeji-edit.vue'
|
||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
||||
|
||||
interface MetricCard {
|
||||
key: string
|
||||
label: string
|
||||
type: MetricType
|
||||
}
|
||||
|
||||
interface MetricColumn {
|
||||
key: string
|
||||
label: string
|
||||
type: MetricType
|
||||
minWidth?: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
||||
const showYejiEdit = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
const deptOptions = ref<any[]>([])
|
||||
const {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
} = useMediaSourceOptions()
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
const overview = reactive<Record<string, any>>({
|
||||
date_range: [],
|
||||
summary: {},
|
||||
can_view_finance: false,
|
||||
})
|
||||
|
||||
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
|
||||
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
|
||||
|
||||
const queryParams = reactive({
|
||||
media_source: '',
|
||||
dept_id: undefined as number | undefined,
|
||||
time_type: 'today',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
|
||||
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
|
||||
|
||||
const goAccountCostEntry = () => {
|
||||
if (accountCostEntryPath.value) {
|
||||
router.push(accountCostEntryPath.value)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||
if (queryParams.time_type === 'custom') {
|
||||
params.start_date = dateRange.value[0] || ''
|
||||
params.end_date = dateRange.value[1] || ''
|
||||
}
|
||||
const res = await getSelfInputOverview(params)
|
||||
Object.assign(overview, res?.extend || {})
|
||||
return res
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await deptAll({ apply_data_scope: 1 })
|
||||
if (Array.isArray(res)) {
|
||||
deptOptions.value = res
|
||||
}
|
||||
} catch (e) {
|
||||
deptOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
if (visible) {
|
||||
refreshMediaSourceOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleYejiSuccess = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
await fetchOverview()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
})
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: fetchOverviewList,
|
||||
params: queryParams,
|
||||
firstLoading: true,
|
||||
})
|
||||
|
||||
const summaryCards: MetricCard[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
||||
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
||||
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
||||
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
||||
]
|
||||
|
||||
const tableColumns: MetricColumn[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
||||
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
||||
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
||||
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
||||
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
||||
]
|
||||
|
||||
const visibleSummaryCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? summaryCards
|
||||
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
|
||||
)
|
||||
|
||||
const visibleTableColumns = computed(() =>
|
||||
canViewFinance.value
|
||||
? tableColumns
|
||||
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
|
||||
)
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
})
|
||||
|
||||
const fetchOverview = async () => {
|
||||
try {
|
||||
await getLists()
|
||||
} catch (error: any) {
|
||||
console.error('获取自录转化统计失败:', error)
|
||||
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomDateChange = () => {
|
||||
if (dateRange.value.length === 2) {
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.time_type = 'today'
|
||||
dateRange.value = []
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
||||
const value = source?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||
return String(value ?? 0)
|
||||
}
|
||||
|
||||
const handleAddYeji = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
showYejiEdit.value = true
|
||||
await nextTick()
|
||||
yejiEditRef.value?.open('add')
|
||||
}
|
||||
|
||||
const handleEditYeji = async (row: Record<string, any>) => {
|
||||
showYejiEdit.value = true
|
||||
await nextTick()
|
||||
yejiEditRef.value?.open('edit')
|
||||
yejiEditRef.value?.setFormData(row)
|
||||
}
|
||||
|
||||
const handleDeleteYeji = async (id: number) => {
|
||||
await feedback.confirm('确定删除该业绩记录?')
|
||||
await personalYejiDelete({ id })
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOverview()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.self-input-stats-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 16px;
|
||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stats-kpi-value.is-money {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-detail-card :deep(.el-card__body) {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||
|
||||
/**
|
||||
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||
*/
|
||||
export interface MediaSourceOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useMediaSourceOptions() {
|
||||
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||
const mediaSourceLoading = ref(false)
|
||||
|
||||
const normalize = (raw: any): MediaSourceOption[] => {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const list: MediaSourceOption[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of raw) {
|
||||
if (typeof item === 'string') {
|
||||
const name = item.trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
list.push({ name, value: '' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const name = String(item.name ?? '').trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
list.push({ name, value: String(item.value ?? '') })
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
const loadMediaSourceOptions = async (force = false) => {
|
||||
if (!force && mediaSourceOptions.value.length > 0) {
|
||||
return
|
||||
}
|
||||
mediaSourceLoading.value = true
|
||||
try {
|
||||
const res = await getSelfInputMediaSourceOptions()
|
||||
mediaSourceOptions.value = normalize(res)
|
||||
} catch {
|
||||
mediaSourceOptions.value = []
|
||||
} finally {
|
||||
mediaSourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
|
||||
|
||||
return {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="640px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||
<el-form-item label="业绩日期" prop="yeji_date">
|
||||
<el-date-picker
|
||||
v-model="formData.yeji_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择日期"
|
||||
:disabled="mode === 'edit'"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源" prop="media_source">
|
||||
<media-source-select
|
||||
v-model="formData.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="请选择自媒体来源"
|
||||
:allow-create="false"
|
||||
:disabled="mode === 'edit'"
|
||||
select-class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="加粉数" prop="add_fans_count">
|
||||
<el-input-number
|
||||
v-model="formData.add_fans_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总开口" prop="total_open_count">
|
||||
<el-input-number
|
||||
v-model="formData.total_open_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="未回复" prop="unreplied_count">
|
||||
<el-input-number
|
||||
v-model="formData.unreplied_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="付费挂号" prop="paid_appointment_count">
|
||||
<el-input-number
|
||||
v-model="formData.paid_appointment_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="免费挂号" prop="free_appointment_count">
|
||||
<el-input-number
|
||||
v-model="formData.free_appointment_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="面诊" prop="interview_count">
|
||||
<el-input-number
|
||||
v-model="formData.interview_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="订单金额" prop="order_amount">
|
||||
<el-input-number
|
||||
v-model="formData.order_amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="接诊诊单" prop="completed_order_count">
|
||||
<el-input-number
|
||||
v-model="formData.completed_order_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mediaSourceOptions?: MediaSourceOption[]
|
||||
mediaSourceLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
mediaSourceOptions: () => [],
|
||||
mediaSourceLoading: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref<'add' | 'edit'>('add')
|
||||
|
||||
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
yeji_date: '',
|
||||
media_source: '',
|
||||
add_fans_count: 0,
|
||||
total_open_count: 0,
|
||||
unreplied_count: 0,
|
||||
paid_appointment_count: 0,
|
||||
free_appointment_count: 0,
|
||||
interview_count: 0,
|
||||
order_amount: 0,
|
||||
completed_order_count: 0,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
|
||||
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = ''
|
||||
formData.yeji_date = ''
|
||||
formData.media_source = ''
|
||||
formData.add_fans_count = 0
|
||||
formData.total_open_count = 0
|
||||
formData.unreplied_count = 0
|
||||
formData.paid_appointment_count = 0
|
||||
formData.free_appointment_count = 0
|
||||
formData.interview_count = 0
|
||||
formData.order_amount = 0
|
||||
formData.completed_order_count = 0
|
||||
formData.remark = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (mode.value === 'edit') {
|
||||
await personalYejiEdit(formData)
|
||||
} else {
|
||||
await personalYejiAdd(formData)
|
||||
}
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const open = (type: 'add' | 'edit' = 'add') => {
|
||||
mode.value = type
|
||||
resetForm()
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
const setFormData = (data: Record<string, any>) => {
|
||||
formData.id = data.id ?? ''
|
||||
formData.yeji_date = data.yeji_date ?? ''
|
||||
formData.media_source = data.media_source ?? ''
|
||||
formData.add_fans_count = Number(data.add_fans_count ?? 0)
|
||||
formData.total_open_count = Number(data.total_open_count ?? 0)
|
||||
formData.unreplied_count = Number(data.unreplied_count ?? 0)
|
||||
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
|
||||
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
|
||||
formData.interview_count = Number(data.interview_count ?? 0)
|
||||
formData.order_amount = Number(data.order_amount ?? 0)
|
||||
formData.completed_order_count = Number(data.completed_order_count ?? 0)
|
||||
formData.remark = data.remark ?? ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
})
|
||||
</script>
|
||||
@@ -49,15 +49,30 @@
|
||||
v-model="form.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
class="channel-source-select"
|
||||
@change="onChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="item.value"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="needsChannelSourceDetail"
|
||||
label="自媒体补充:"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model="form.channel_source_detail"
|
||||
clearable
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
class="channel-source-select"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- 预约医生 -->
|
||||
<el-form-item label="预约医生:">
|
||||
<div class="doctor-list">
|
||||
@@ -233,9 +248,44 @@ const form = reactive({
|
||||
date: '',
|
||||
appointmentTime: '',
|
||||
remark: '',
|
||||
channel_source: '' as string
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
'自媒体2Q'
|
||||
])
|
||||
|
||||
function channelNameRequiresSelfMediaDetail(name: string) {
|
||||
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
|
||||
}
|
||||
|
||||
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
|
||||
const selectedChannelDictName = computed(() => {
|
||||
const v = form.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
/** 仅白名单内字典项需填写补充说明 */
|
||||
const needsChannelSourceDetail = computed(() =>
|
||||
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
|
||||
)
|
||||
|
||||
const onChannelSourceChange = (val: string) => {
|
||||
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
form.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
|
||||
const dateOptions = computed<DateOption[]>(() => {
|
||||
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
||||
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
|
||||
selectedDoctorId.value &&
|
||||
form.date &&
|
||||
form.appointmentTime &&
|
||||
!!form.channel_source
|
||||
!!form.channel_source &&
|
||||
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -344,6 +395,7 @@ const open = async (patient: any) => {
|
||||
form.appointmentTime = ''
|
||||
form.remark = ''
|
||||
form.channel_source = ''
|
||||
form.channel_source_detail = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
lastVisit.value = ''
|
||||
@@ -412,7 +464,17 @@ const loadLastVisit = async () => {
|
||||
const loadChannelOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'channels' })
|
||||
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
rows.sort((a: any, b: any) => {
|
||||
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
|
||||
if (ds !== 0) return ds
|
||||
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
|
||||
})
|
||||
// 统一为字符串,避免字典 value 为数字时 el-select 与表单校验不一致导致看似选了但实际未绑定
|
||||
channelOptions.value = rows.map((row: any) => ({
|
||||
...row,
|
||||
value: row?.value != null && row.value !== '' ? String(row.value) : ''
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('加载渠道来源失败:', e)
|
||||
channelOptions.value = []
|
||||
@@ -612,6 +674,10 @@ const handleConfirm = async () => {
|
||||
feedback.msgWarning('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
|
||||
feedback.msgWarning('请填写自媒体渠道补充信息')
|
||||
return
|
||||
}
|
||||
if (blockedByTodayDuplicate.value) {
|
||||
feedback.msgWarning(
|
||||
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
||||
@@ -631,7 +697,8 @@ const handleConfirm = async () => {
|
||||
appointment_time: form.appointmentTime,
|
||||
appointment_type: form.appointmentType,
|
||||
remark: form.remark,
|
||||
channel_source: form.channel_source
|
||||
channel_source: String(form.channel_source ?? ''),
|
||||
channel_source_detail: form.channel_source_detail.trim()
|
||||
}
|
||||
|
||||
console.log('提交预约参数:', params)
|
||||
|
||||
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
|
||||
const raw = row.channel_source ?? row.channels ?? ''
|
||||
if (raw === '' || raw === null || raw === undefined) return '—'
|
||||
const key = String(raw)
|
||||
return channelMap.value[key] || key
|
||||
const base = channelMap.value[key] || key
|
||||
const extra = String(row.channel_source_detail ?? '').trim()
|
||||
return extra !== '' ? `${base}(${extra})` : base
|
||||
}
|
||||
|
||||
function sortRows(list: any[]) {
|
||||
@@ -141,6 +143,8 @@ const load = async () => {
|
||||
await loadChannels()
|
||||
const res = await appointmentLists({
|
||||
patient_id: props.diagnosisId,
|
||||
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
|
||||
diag_scope_relax: 1,
|
||||
page_no: 1,
|
||||
page_size: 500
|
||||
})
|
||||
|
||||
@@ -2,8 +2,35 @@
|
||||
<div class="assign-log-panel">
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
|
||||
<el-table-column label="操作时间" width="175" prop="create_time_text" />
|
||||
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
|
||||
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
|
||||
<el-table-column label="原医助" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ assistantLogCellText(row, 'from') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新医助" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ assistantLogCellText(row, 'to') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ relatedPoCreatorCell(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="快照·业务单创建时间" width="190">
|
||||
<template #header>
|
||||
<span>快照·业务单创建时间</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"
|
||||
>
|
||||
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ relatedPoCreateTimeCell(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" width="110" prop="operator_name" />
|
||||
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
|
||||
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
|
||||
@@ -12,6 +39,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
|
||||
|
||||
@@ -22,12 +50,56 @@ const props = defineProps<{
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
/** 对应日志表 related_po_creator_id:触发本次释放/指派的业务订单 creator */
|
||||
function relatedPoCreatorCell(row: Record<string, unknown>): string {
|
||||
const name = row.related_po_creator_name
|
||||
if (name != null && String(name).trim() !== '') {
|
||||
return String(name)
|
||||
}
|
||||
const id = Number(row.related_po_creator_id)
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
return `ID:${id}`
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** 对应日志表 related_po_create_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,716 @@
|
||||
<template>
|
||||
<div class="patient-order-list">
|
||||
<div v-if="!patientIdAvailable" class="po-empty-tip">
|
||||
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
border
|
||||
stripe
|
||||
empty-text="暂无业务订单"
|
||||
>
|
||||
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医生" prop="doctor_name" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.doctor_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医助" prop="assistant_name" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.assistant_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="履约状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="fulfillmentTagType(row.fulfillment_status)" size="small" effect="light">
|
||||
{{ fulfillmentText(row.fulfillment_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/detail']"
|
||||
type="primary"
|
||||
link
|
||||
@click="openDetail(row.id)"
|
||||
>查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-3">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 业务订单详情抽屉(只读,与 order_list.vue 详情保持一致) -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
size="80%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="font-semibold text-lg">业务订单详情</span>
|
||||
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
|
||||
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
|
||||
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
|
||||
|
||||
<!-- 顶部数据概览 -->
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付总额</div>
|
||||
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">需代收</div>
|
||||
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
|
||||
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 左侧:处方单(不含测试价格、改患者信息、药材明细) -->
|
||||
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
|
||||
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">处方详情</span>
|
||||
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
||||
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="detailPrescription">
|
||||
<el-descriptions :column="2" border size="small" class="bg-white">
|
||||
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方金额">
|
||||
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消费者审核">
|
||||
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">订单设置:</span>
|
||||
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<el-empty v-else description="无处方数据" :image-size="72" />
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:未关联 + 已关联支付单 -->
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<el-card shadow="never" class="border-gray-100">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-[15px]">未关联的收款记录</span>
|
||||
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="无未关联的收款单" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
|
||||
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">关联收款记录</span>
|
||||
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 履约与收货信息 -->
|
||||
<el-card shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务订单创建人" :span="2">
|
||||
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
|
||||
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
|
||||
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人所属部门" :span="3">
|
||||
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
|
||||
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
|
||||
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
|
||||
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
|
||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 物流轨迹(只读) -->
|
||||
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
|
||||
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
<el-option label="极兔速递" value="jt" />
|
||||
</el-select>
|
||||
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
|
||||
</div>
|
||||
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
|
||||
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
|
||||
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
|
||||
<span class="text-gray-800">状态:{{ logisticsTracePayload.state_text }}</span>
|
||||
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
|
||||
</div>
|
||||
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
|
||||
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
|
||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
|
||||
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
|
||||
</div>
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||
</el-card>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref, onMounted } from 'vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import {
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
patientId?: number | string
|
||||
}>()
|
||||
|
||||
const patientIdNum = computed(() => {
|
||||
const n = Number(props.patientId)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
})
|
||||
const patientIdAvailable = computed(() => patientIdNum.value > 0)
|
||||
|
||||
const queryParams = reactive<Record<string, number | string>>({})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: prescriptionOrderLists,
|
||||
params: queryParams,
|
||||
size: 10
|
||||
})
|
||||
|
||||
const buildParams = () => {
|
||||
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
||||
if (props.diagnosisId > 0) {
|
||||
queryParams.context_diagnosis_id = props.diagnosisId
|
||||
}
|
||||
if (patientIdAvailable.value) {
|
||||
queryParams.patient_id = patientIdNum.value
|
||||
}
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」相关字段
|
||||
*/
|
||||
const showPrescriptionAuditFilter = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
// ─── 详情抽屉 ───
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
const detailLogs = ref<any[]>([])
|
||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||
|
||||
const detailPrescription = computed(() => {
|
||||
const p = detailData.value?.prescription
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailLinkedPayOrders = computed(() => {
|
||||
const arr = detailData.value?.linked_pay_orders
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const detailAgencyToCollect = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return 0
|
||||
const total = Number(d.amount) || 0
|
||||
const paid = Number(d.linked_pay_paid_total) || 0
|
||||
return Math.round((total - paid) * 100) / 100
|
||||
})
|
||||
|
||||
const detailRecipientRxPhoneMismatch = computed(() => {
|
||||
const rx = normalizeBizPhone(detailPrescription.value?.phone)
|
||||
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
|
||||
if (!rx || !biz) return false
|
||||
return rx !== biz
|
||||
})
|
||||
|
||||
const detailFullAddress = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return '—'
|
||||
const parts = []
|
||||
if (d.shipping_province) parts.push(d.shipping_province)
|
||||
if (d.shipping_city) parts.push(d.shipping_city)
|
||||
if (d.shipping_district) parts.push(d.shipping_district)
|
||||
if (d.shipping_address) parts.push(d.shipping_address)
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
})
|
||||
|
||||
const detailOrderCreatorMetaLine = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return ''
|
||||
const account = String(d.creator_account ?? '').trim()
|
||||
const mobile = String(d.creator_mobile ?? '').trim()
|
||||
const parts: string[] = []
|
||||
if (account) parts.push(`登录账号:${account}`)
|
||||
if (mobile) parts.push(`手机:${mobile}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
|
||||
if (!raw) return [] as string[][]
|
||||
return raw.split(';').map((s) => s.trim()).filter(Boolean)
|
||||
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
|
||||
.filter((segs) => segs.length > 0)
|
||||
})
|
||||
|
||||
// ─── 物流轨迹 ───
|
||||
const detailLogisticsExpress = ref<string>('auto')
|
||||
const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
|
||||
const logisticsTraceList = computed(() => {
|
||||
const p = logisticsTracePayload.value
|
||||
if (!p || !Array.isArray(p.traces)) return []
|
||||
return p.traces as Array<{ time: string; context: string }>
|
||||
})
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) params.phone_tail = digits
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 打开详情 ───
|
||||
async function openDetail(id: number) {
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
|
||||
fetchLogs(id)
|
||||
if (d.diagnosis_id) {
|
||||
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '加载详情失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
|
||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
||||
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
|
||||
} catch { detailUnlinkedPayOrders.value = [] }
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
|
||||
}
|
||||
|
||||
async function fetchLogs(id: number) {
|
||||
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderLogs({ id })
|
||||
detailLogs.value = res?.data ?? res ?? []
|
||||
} catch { detailLogs.value = [] }
|
||||
}
|
||||
|
||||
// ─── 工具函数 ───
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
}
|
||||
|
||||
const formatAmount = (value: unknown) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
const formatTime = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
|
||||
const d = new Date(value * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order)
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
|
||||
servicePackageOptions.value = list.filter((item) => item.status !== 0)
|
||||
} catch {
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatServicePackage(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value.map((v) => String(v)).filter((v) => v !== '')
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
|
||||
}
|
||||
if (packages.length === 0) return '—'
|
||||
const names = packages.map((val) => {
|
||||
const opt = servicePackageOptions.value.find((o) => o.value === val)
|
||||
return opt ? opt.name : val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
})
|
||||
|
||||
function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 1) return 'success'
|
||||
if (n === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 2) return 'success'
|
||||
if (n === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentText = (s: number | undefined) => {
|
||||
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
|
||||
const n = Number(s)
|
||||
if (n === 3 || n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4 || n === 9 || n === 10) return 'danger'
|
||||
if (n === 2 || n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function logActionText(act: string) {
|
||||
const m: Record<string, string> = {
|
||||
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
|
||||
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
|
||||
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
|
||||
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额', complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.diagnosisId, patientIdNum.value] as const,
|
||||
() => {
|
||||
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
|
||||
buildParams()
|
||||
resetPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ refresh: () => getLists() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.patient-order-list {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.po-empty-tip {
|
||||
padding: 24px 0;
|
||||
}
|
||||
.audit-stamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
transform: rotate(15deg);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.stamp-inner {
|
||||
border: 3px solid currentColor;
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stamp-pass { color: #22c55e; }
|
||||
.stamp-reject { color: #ef4444; }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-if="normalized.length" class="recording-list">
|
||||
<div class="recording-item">
|
||||
<template v-if="preferred">
|
||||
<RecordingVideoPlayer
|
||||
v-if="shouldInlineRecording(preferred)"
|
||||
:key="`${recordId}-${preferred}`"
|
||||
:src="preferred"
|
||||
/>
|
||||
<el-link v-else :href="resolveRecordingLink(preferred)" target="_blank" type="primary">
|
||||
打开回放
|
||||
</el-link>
|
||||
</template>
|
||||
<div v-if="alternates.length" class="recording-alternates">
|
||||
<span class="recording-alternates__label">备用地址</span>
|
||||
<div class="recording-alternates__links">
|
||||
<el-link
|
||||
v-for="(url, linkIndex) in alternates"
|
||||
:key="`${recordId}-alt-${linkIndex}-${url.slice(-32)}`"
|
||||
:href="resolveRecordingLink(url)"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
class="recording-alternates__link"
|
||||
>
|
||||
{{ alternateLinkLabel(url, linkIndex) }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">暂无</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecordingVideoPlayer from './RecordingVideoPlayer.vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
recordId: number
|
||||
/** getCallRecords 返回的 recording_urls_list(可多来源:COS/VOD、m3u8/mp4) */
|
||||
urls?: string[]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** 去重、trim */
|
||||
const normalized = computed(() => {
|
||||
const raw = props.urls || []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const u of raw) {
|
||||
const s = String(u ?? '').trim()
|
||||
if (!s || seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** 优选一条内嵌:mp4 > 点播 m3u8 > COS m3u8 > 其它 m3u8 > 首个 */
|
||||
const preferred = computed((): string | null => {
|
||||
const list = normalized.value
|
||||
if (!list.length) return null
|
||||
|
||||
const mp4 = list.find((u) => /\.mp4(\?|#|$)/i.test(u))
|
||||
if (mp4) return mp4
|
||||
|
||||
const vodHls = list.find(
|
||||
(u) => /\.m3u8(\?|#|$)/i.test(u) && /vod-qcloud\.com/i.test(u)
|
||||
)
|
||||
if (vodHls) return vodHls
|
||||
|
||||
const cosHls = list.find(
|
||||
(u) =>
|
||||
/\.m3u8(\?|#|$)/i.test(u) && /\.cos\.[^/]+\.myqcloud\.com/i.test(u)
|
||||
)
|
||||
if (cosHls) return cosHls
|
||||
|
||||
const anyHls = list.find((u) => /\.m3u8(\?|#|$)/i.test(u))
|
||||
if (anyHls) return anyHls
|
||||
|
||||
return list[0]
|
||||
})
|
||||
|
||||
const alternates = computed(() => {
|
||||
const list = normalized.value
|
||||
const p = preferred.value
|
||||
if (!p) return list.slice(1)
|
||||
return list.filter((u) => u !== p)
|
||||
})
|
||||
|
||||
function shouldInlineRecording(url: string) {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
const u = url.trim()
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u)
|
||||
}
|
||||
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u) || u.startsWith('/uploads/')
|
||||
}
|
||||
|
||||
function resolveRecordingLink(url: string) {
|
||||
return appStore.getImageUrl(String(url || '').trim())
|
||||
}
|
||||
|
||||
function alternateLinkLabel(url: string, index: number): string {
|
||||
const u = url.trim()
|
||||
if (/\.mp4(\?|#|$)/i.test(u)) return `MP4 ${index + 1}`
|
||||
if (/vod-qcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `点播 ${index + 1}`
|
||||
if (/\.cos\.[^/]+\.myqcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `COS HLS ${index + 1}`
|
||||
if (/\.m3u8/i.test(u)) return `HLS ${index + 1}`
|
||||
return `链接 ${index + 1}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recording-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recording-alternates {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.recording-alternates__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.recording-alternates__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.recording-alternates__link {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div ref="wrapRef" class="recording-playback">
|
||||
<div class="recording-playback__shell">
|
||||
<video
|
||||
v-show="!failed"
|
||||
ref="videoRef"
|
||||
class="recording-video"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
:preload="progressivePreload"
|
||||
@error="onVideoError"
|
||||
/>
|
||||
<div
|
||||
v-if="!failed && showBusyOverlay"
|
||||
class="recording-playback__overlay"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="forceEnable"
|
||||
@keydown.enter.prevent="forceEnable"
|
||||
>
|
||||
<template v-if="!loadEnabled">
|
||||
<span class="recording-playback__overlay-title">预览待加载</span>
|
||||
<span class="recording-playback__overlay-desc">滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon class="recording-playback__spin"><Loading /></el-icon>
|
||||
<span>正在加载…</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="failed">
|
||||
<el-link :href="playbackUrl || '#'" target="_blank" type="primary">在新窗口打开</el-link>
|
||||
<div class="recording-playback__hint">
|
||||
内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const failed = ref(false)
|
||||
/** 已进入可视区域或用户点击,允许发起请求 */
|
||||
const loadEnabled = ref(false)
|
||||
/** 已开始拉流但未就绪 */
|
||||
const loadingMedia = ref(false)
|
||||
|
||||
let hlsInstance: import('hls.js').default | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
/** 防止 tearDown / 上一次 attach 的异步事件误结束 loading */
|
||||
let attachGeneration = 0
|
||||
let loadingSlowTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearLoadingSlowTimer(): void {
|
||||
if (loadingSlowTimer !== null) {
|
||||
clearTimeout(loadingSlowTimer)
|
||||
loadingSlowTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const playbackUrl = computed(() => {
|
||||
const raw = String(props.src || '').trim()
|
||||
if (!raw) return ''
|
||||
return normalizeTencentRecordingUrl(appStore.getImageUrl(raw))
|
||||
})
|
||||
|
||||
/** COS/VOD 直连 mp4:允许浏览器优先拉元数据;HLS 仍 none,交给 hls.js */
|
||||
const progressivePreload = computed(() => {
|
||||
if (!loadEnabled.value) return 'none'
|
||||
const u = playbackUrl.value
|
||||
return u && !isHlsUrl(u) ? 'metadata' : 'none'
|
||||
})
|
||||
|
||||
const showBusyOverlay = computed(() => !loadEnabled.value || loadingMedia.value)
|
||||
|
||||
/** 腾讯云录制路径里常见未编码的 “+”,部分环境下会导致视频长时间拿不到元数据 */
|
||||
function normalizeTencentRecordingUrl(url: string): string {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const h = u.hostname.toLowerCase()
|
||||
if (
|
||||
h.includes('myqcloud.com') ||
|
||||
h.includes('vod-qcloud.com') ||
|
||||
h.includes('vod-qcloud')
|
||||
) {
|
||||
if (u.pathname.includes('+')) {
|
||||
u.pathname = u.pathname.replace(/\+/g, '%2B')
|
||||
}
|
||||
return u.href
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function isHlsUrl(url: string): boolean {
|
||||
return /\.m3u8(\?|#|$)/i.test(url)
|
||||
}
|
||||
|
||||
function onVideoError(): void {
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
const el = videoRef.value
|
||||
const code = el?.error?.code
|
||||
if (code === MediaError.MEDIA_ERR_ABORTED || code === 1) {
|
||||
return
|
||||
}
|
||||
failed.value = true
|
||||
}
|
||||
|
||||
function tearDown(): void {
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy()
|
||||
hlsInstance = null
|
||||
}
|
||||
const el = videoRef.value
|
||||
if (el) {
|
||||
el.pause()
|
||||
el.removeAttribute('src')
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
|
||||
function bindProgressiveLoadingEnd(el: HTMLVideoElement, gen: number): void {
|
||||
let settled = false
|
||||
const done = () => {
|
||||
if (settled || gen !== attachGeneration) return
|
||||
settled = true
|
||||
clearLoadingSlowTimer()
|
||||
loadingMedia.value = false
|
||||
}
|
||||
el.addEventListener('loadedmetadata', () => done('loadedmetadata'), { once: true })
|
||||
el.addEventListener('loadeddata', () => done('loadeddata'), { once: true })
|
||||
el.addEventListener('canplay', () => done('canplay'), { once: true })
|
||||
el.addEventListener('playing', () => done('playing'), { once: true })
|
||||
el.addEventListener(
|
||||
'progress',
|
||||
() => {
|
||||
try {
|
||||
if (el.buffered.length > 0 && el.buffered.end(0) > 0) {
|
||||
done('progress-buffered')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
async function attachSource(): Promise<void> {
|
||||
if (!loadEnabled.value) return
|
||||
|
||||
await nextTick()
|
||||
const el = videoRef.value
|
||||
const url = playbackUrl.value
|
||||
if (!el) return
|
||||
|
||||
if (!url) {
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
return
|
||||
}
|
||||
|
||||
const gen = ++attachGeneration
|
||||
loadingMedia.value = true
|
||||
clearLoadingSlowTimer()
|
||||
tearDown()
|
||||
failed.value = false
|
||||
|
||||
if (isHlsUrl(url)) {
|
||||
try {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (Hls.isSupported()) {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// 列表里多条回放时减小初始缓冲压力,首帧稍快露出
|
||||
maxBufferLength: 20,
|
||||
maxMaxBufferLength: 120
|
||||
})
|
||||
hlsInstance.on(mod.Events.MANIFEST_PARSED, () => {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
})
|
||||
hlsInstance.on(mod.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
tearDown()
|
||||
}
|
||||
})
|
||||
hlsInstance.loadSource(url)
|
||||
hlsInstance.attachMedia(el)
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 45000)
|
||||
return
|
||||
}
|
||||
if (el.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
bindProgressiveLoadingEnd(el, gen)
|
||||
el.src = url
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 45000)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
bindProgressiveLoadingEnd(el, gen)
|
||||
el.src = url
|
||||
// moov 在尾部的大 mp4 可能迟迟不触发 loadedmetadata;超时后收起遮罩以免挡住原生控件
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 25000)
|
||||
}
|
||||
|
||||
function setupLazyLoad(): void {
|
||||
const root = wrapRef.value
|
||||
if (!root || typeof IntersectionObserver === 'undefined') {
|
||||
loadEnabled.value = true
|
||||
void attachSource()
|
||||
return
|
||||
}
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const en of entries) {
|
||||
if (en.isIntersecting) {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
loadEnabled.value = true
|
||||
void attachSource()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '240px 0px', threshold: 0 }
|
||||
)
|
||||
intersectionObserver.observe(root)
|
||||
}
|
||||
|
||||
function forceEnable(): void {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
if (!loadEnabled.value) {
|
||||
loadEnabled.value = true
|
||||
}
|
||||
void attachSource()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => {
|
||||
setupLazyLoad()
|
||||
})
|
||||
})
|
||||
|
||||
watch(playbackUrl, () => {
|
||||
if (!loadEnabled.value) return
|
||||
void attachSource()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
clearLoadingSlowTimer()
|
||||
attachGeneration++
|
||||
tearDown()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recording-playback {
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.recording-playback__shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recording-playback__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.recording-playback__overlay-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recording-playback__overlay-desc {
|
||||
opacity: 0.92;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recording-playback__spin {
|
||||
font-size: 24px;
|
||||
animation: recording-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes recording-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.recording-playback__hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div class="tracking-matrix" v-loading="loading">
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="rangeMode" @change="onRangeModeChange">
|
||||
<el-radio-button value="7">最近 7 天</el-radio-button>
|
||||
<el-radio-button value="30">最近 30 天</el-radio-button>
|
||||
<el-radio-button value="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-date-picker
|
||||
v-if="rangeMode === 'custom'"
|
||||
v-model="customRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:clearable="false"
|
||||
size="default"
|
||||
:disabled-date="disabledFutureDate"
|
||||
@change="onCustomRangeChange"
|
||||
/>
|
||||
|
||||
<span class="hint">
|
||||
超阈值红字↑(年龄分段:<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,91 @@ const setConfirmed = (v: string) => {
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 待分配 + 宽搜时:请求只吃分页与关键词,不带其它 Tab/日期/筛选(与 buildTcmDiagnosisListRequestPayload 一致) */
|
||||
function isPendingAssignWideKeywordParams(req: Record<string, unknown>): boolean {
|
||||
const pa = req.pending_assign === '1' || req.pending_assign === 1
|
||||
if (!pa) {
|
||||
return false
|
||||
}
|
||||
const k1 = String(req.pending_assign_keyword ?? '').trim()
|
||||
const k2 = String(req.keyword ?? '').trim()
|
||||
return k1 !== '' || k2 !== ''
|
||||
}
|
||||
|
||||
/** 待分配宽搜:仅传分页 + pending_assign + 关键词,丢弃其余查询参数 */
|
||||
function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!isPendingAssignWideKeywordParams(req)) {
|
||||
return req
|
||||
}
|
||||
const k1 = String(req.pending_assign_keyword ?? '').trim()
|
||||
const k2 = String(req.keyword ?? '').trim()
|
||||
const kw = k1 !== '' ? k1 : k2
|
||||
return {
|
||||
page_no: req.page_no,
|
||||
page_size: req.page_size,
|
||||
pending_assign: 1,
|
||||
pending_assign_keyword: kw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 待分配「订单月份」:未选月份时,未进入 Tab 前默认当月(与 handlePendingAssignTabClick 一致);
|
||||
* 在 Tab 内清空月份表示不限月份。
|
||||
*/
|
||||
function resolvePendingAssignOrderMonthForRequest(): string {
|
||||
const trimmed = String(formData.pending_assign_order_month ?? '').trim()
|
||||
if (trimmed !== '') return trimmed
|
||||
if (formData.pending_assign === '1') return ''
|
||||
return dayjs().format('YYYY-MM')
|
||||
}
|
||||
|
||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
return buildTcmDiagnosisListRequestPayload({
|
||||
...formData,
|
||||
page_no: 1,
|
||||
page_size: 1,
|
||||
pending_assign: 1,
|
||||
appointment_date: '',
|
||||
has_appointment: '',
|
||||
pending_booking: '',
|
||||
completed_appointment: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
||||
} as Record<string, unknown>) as Record<string, unknown>
|
||||
}
|
||||
|
||||
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
|
||||
|
||||
/** 宽搜提交前清理界面上的其它检索,避免 Chip 仍显示「已选」但与实际请求不一致 */
|
||||
function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
||||
if (formData.pending_assign !== '1') {
|
||||
return
|
||||
}
|
||||
const kw1 = (formData.pending_assign_keyword ?? '').trim()
|
||||
const kw2 = (formData.keyword ?? '').trim()
|
||||
if (kw1 === '' && kw2 === '') {
|
||||
return
|
||||
}
|
||||
formData.appointment_date = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.has_appointment = ''
|
||||
formData.diagnosis_type = ''
|
||||
formData.syndrome_type = ''
|
||||
formData.assistant_id = ''
|
||||
formData.start_time = ''
|
||||
formData.end_time = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
formData.keyword = ''
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = () => {
|
||||
clearSecondaryFiltersWhenPendingAssignWideSearch()
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -723,6 +877,8 @@ const pendingAssignCount = ref<number | undefined>(undefined)
|
||||
|
||||
const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -735,6 +891,8 @@ const handleDateTabClick = async (value: string) => {
|
||||
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
|
||||
const handlePendingBookingTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = '1'
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
@@ -747,6 +905,8 @@ const handlePendingBookingTabClick = async () => {
|
||||
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
|
||||
const handleCompletedVisitTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
@@ -762,6 +922,18 @@ const handlePendingAssignTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
if (!formData.pending_assign_order_month) {
|
||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||
}
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 待分配:切换订单月份后刷新列表与角标 */
|
||||
const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
||||
formData.pending_assign_order_month = val || ''
|
||||
if (formData.pending_assign !== '1') return
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -779,7 +951,7 @@ const fetchDateCounts = async () => {
|
||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -807,7 +979,7 @@ function scheduleDeferredDateCounts() {
|
||||
}
|
||||
}
|
||||
|
||||
const SILENT_LIST_POLL_MS = 10_000
|
||||
const SILENT_LIST_POLL_MS = 20_000
|
||||
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
|
||||
@@ -913,7 +1085,7 @@ const getAssistantName = (assistantId: number | string) => {
|
||||
}
|
||||
|
||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
fetchFun: tcmDiagnosisLists,
|
||||
fetchFun: fetchTcmDiagnosisListsForPaging,
|
||||
params: formData
|
||||
})
|
||||
|
||||
@@ -931,6 +1103,8 @@ const handleReset = () => {
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
pager.page = 1
|
||||
@@ -944,6 +1118,19 @@ const isDiagnosisConfirmed = (row: any) => {
|
||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「未服务天数」着色规则(计划 T3):
|
||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||
*/
|
||||
const unservedDaysClass = (n: unknown) => {
|
||||
if (n === null || n === undefined) return 'unserved-muted'
|
||||
const num = Number(n)
|
||||
if (!Number.isFinite(num)) return 'unserved-muted'
|
||||
if (num >= 7) return 'unserved-red'
|
||||
if (num >= 3) return 'unserved-orange'
|
||||
return 'unserved-green'
|
||||
}
|
||||
|
||||
// 行高亮:未确认诊单优先,其次未挂号
|
||||
const getRowClassName = ({ row }: { row: any }) => {
|
||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||
@@ -1146,6 +1333,8 @@ const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
|
||||
|
||||
// 批量指派相关
|
||||
const selectedIds = ref<number[]>([])
|
||||
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
|
||||
const batchAssignIds = ref<number[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const assignLoading = ref(false)
|
||||
const assignMode = ref<'batch' | 'single'>('batch')
|
||||
@@ -1154,6 +1343,11 @@ const assignForm = ref({
|
||||
assistant_id: null as number | null
|
||||
})
|
||||
|
||||
function resetAssignDialog() {
|
||||
batchAssignIds.value = []
|
||||
assignForm.value.assistant_id = null
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
@@ -1175,8 +1369,9 @@ const canBatchCancelAssign = computed(() => {
|
||||
const handleSingleAssign = (row: any) => {
|
||||
assignMode.value = 'single'
|
||||
currentRow.value = row
|
||||
batchAssignIds.value = []
|
||||
// 确保类型一致,转换为数字
|
||||
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
|
||||
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1189,6 +1384,7 @@ const handleBatchAssign = () => {
|
||||
assignMode.value = 'batch'
|
||||
currentRow.value = null
|
||||
assignForm.value.assistant_id = null
|
||||
batchAssignIds.value = [...selectedIds.value]
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1207,15 +1403,20 @@ const handleConfirmAssign = async () => {
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
})
|
||||
} else {
|
||||
// 批量指派
|
||||
const promises = selectedIds.value.map(id =>
|
||||
tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id
|
||||
// 批量指派:使用打开弹窗时的 id 快照(避免弹窗内操作导致表格勾选被清空后提交为空)
|
||||
const ids = batchAssignIds.value
|
||||
if (!ids.length) {
|
||||
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
await tcmDiagnosisAssign({
|
||||
id,
|
||||
assistant_id: assignForm.value.assistant_id!,
|
||||
})
|
||||
)
|
||||
await Promise.all(promises)
|
||||
}
|
||||
selectedIds.value = []
|
||||
batchAssignIds.value = []
|
||||
}
|
||||
|
||||
assignDialogVisible.value = false
|
||||
@@ -1263,10 +1464,13 @@ const handleEdit = (id: number) => {
|
||||
editRef.value.open('edit', id)
|
||||
}
|
||||
|
||||
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方 */
|
||||
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
|
||||
function prescriptionActionLabel(row: any) {
|
||||
if (formData.pending_assign === '1') {
|
||||
return '开方'
|
||||
}
|
||||
if (
|
||||
row?.prescription_audit_status === 1 &&
|
||||
Number(row?.prescription_audit_status) === 1 &&
|
||||
Number(row?.prescription_void_status) !== 1
|
||||
) {
|
||||
return '查看'
|
||||
@@ -1371,13 +1575,29 @@ const appointmentCellClasses = (row: any) => {
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制) */
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
|
||||
}
|
||||
|
||||
// 取消挂号
|
||||
/** 单条挂号记录是否允许取消(已预约 1 / 已过号 4) */
|
||||
const canCancelAppointmentItem = (apt: any) => {
|
||||
const s = Number(apt?.status)
|
||||
return !!(apt?.id && (s === 1 || s === 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* 「更多」里行级取消:仅当列表只展示一条挂号时可点,避免多条时与主键 appointment_id 不对齐
|
||||
* 多条时在「挂号」列每条旁单独取消
|
||||
*/
|
||||
const canCancelAppointmentFromDropdown = (row: any) => {
|
||||
if (!canCancelAppointmentRow(row)) return false
|
||||
const rows = appointmentRows(row)
|
||||
return rows.length <= 1
|
||||
}
|
||||
|
||||
// 取消挂号(行级,唯一一条时)
|
||||
const handleCancelAppointment = async (row: any) => {
|
||||
if (!canCancelAppointmentRow(row)) {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -1388,9 +1608,39 @@ const handleCancelAppointment = async (row: any) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
const rows = appointmentRows(row)
|
||||
const target = rows.length === 1 ? rows[0] : null
|
||||
const aptId = target?.id && canCancelAppointmentItem(target) ? target.id : row.appointment_id
|
||||
try {
|
||||
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
|
||||
await cancelAppointment({ id: row.appointment_id })
|
||||
await cancelAppointment({ id: aptId })
|
||||
feedback.msgSuccess('取消挂号成功')
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
|
||||
await fetchGuahaoLogs(row.id)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消指定挂号(多条展示时用) */
|
||||
const handleCancelAppointmentItem = async (row: any, apt: any) => {
|
||||
if (!canCancelAppointmentItem(apt)) {
|
||||
const s = Number(apt?.status)
|
||||
if (s === 3) {
|
||||
feedback.msgWarning('已完成就诊,无法取消挂号')
|
||||
} else {
|
||||
feedback.msgWarning('该挂号无法取消')
|
||||
}
|
||||
return
|
||||
}
|
||||
const doc = apt.doctor_name || '—'
|
||||
const t = apt.time_text || '—'
|
||||
try {
|
||||
await feedback.confirm(`确定取消「${doc}」${t} 的挂号吗?(患者:${row.patient_name || '—'})`)
|
||||
await cancelAppointment({ id: apt.id })
|
||||
feedback.msgSuccess('取消挂号成功')
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -1706,6 +1956,17 @@ const handleDeleteRecord = async (id: number) => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
|
||||
const goReadonly = (row: any) => {
|
||||
// 没分配 readonlyDetail 权限的管理员:双击行也不跳转(按钮已用 v-perms 隐藏)
|
||||
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
|
||||
const id = Number(row?.id)
|
||||
if (!id || id <= 0) return
|
||||
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
@@ -1829,6 +2090,27 @@ onUnmounted(() => {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.pending-assign-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
|
||||
.chip-pending {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-assign-month {
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.pending-assign-search {
|
||||
width: 220px;
|
||||
max-width: 42vw;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
@@ -2013,6 +2295,11 @@ onUnmounted(() => {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.apt-actions {
|
||||
margin-top: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&.apt-item-active {
|
||||
.apt-badge {
|
||||
color: #3a4acc;
|
||||
@@ -2091,6 +2378,33 @@ onUnmounted(() => {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||
.unserved-days {
|
||||
display: inline-block;
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.unserved-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.unserved-green {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.unserved-orange {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.unserved-red {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.diagnosis-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
</div>
|
||||
<div class="dh5-card-sub">
|
||||
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
|
||||
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dh5-card-tags">
|
||||
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dh5-tag-new {
|
||||
display: inline-block;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dh5-meta-gender,
|
||||
.dh5-meta-age {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1 @@
|
||||
open_basedir=/www/wwwroot/zyt/server/:/tmp/
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user