在线复诊挂号和订单校验
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-05-06 15:53:45 +08:00
parent 014bf97121
commit 01f0a6b0f9
5 changed files with 403 additions and 41 deletions

View File

@@ -491,7 +491,13 @@ export const usePrescriptionStore = defineStore('prescription', () => {
image: data.drug.image,
instruction: data.drug.instruction,
type: data.drug.type,
select_number: 1,
select_number: (() => {
const raw = Number(
data.select_number ?? data.purchase_quantity ?? data.purchase_number,
);
if (!Number.isFinite(raw) || raw < 1) return 1;
return Math.min(9999, Math.max(1, Math.floor(raw)));
})(),
};
const newDrugs = [...currentDrugs.value, newProduct];

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
content: {
type: Object,
required: true,
},
});
const data = computed(() =>
typeof props.content === 'string'
? (() => {
try {
return JSON.parse(props.content);
} catch {
return {};
}
})()
: props.content || {},
);
const answeredLabel = computed(() => {
const st = data.value.answer_status;
if (st === 'yes' || st === '1') return '是,曾使用过';
if (st === 'no' || st === '0') return '否,未使用过';
return '';
});
const drugs = computed(() =>
Array.isArray(data.value.drugs) ? data.value.drugs : [],
);
</script>
<template>
<div class="follow-up-drug-card">
<div class="card-head">
<div class="head-icon">
<i class="fas fa-user-md"></i>
</div>
<div class="head-text">
<div class="title">医生助理 · 用药确认</div>
<div v-if="data.flow === 'follow_up_drug'" class="sub">复诊用药</div>
</div>
</div>
<div v-if="data.question" class="question-block">
{{ data.question }}
</div>
<div v-if="drugs.length" class="drug-list">
<div class="drug-list-title">涉及药品</div>
<ul>
<li v-for="(row, idx) in drugs" :key="idx">
<span class="name">{{ row.name || '药品' }}</span>
<span class="qty">×{{ row.quantity != null ? row.quantity : 1 }}</span>
</li>
</ul>
</div>
<div v-if="answeredLabel" class="answer-row">
<span class="label">患者回复</span>
<span class="value">{{ answeredLabel }}</span>
</div>
<div
v-else-if="data.answer_status === 'pending'"
class="pending-tip"
>
待患者在小程序端填写用药信息
</div>
</div>
</template>
<style scoped>
.follow-up-drug-card {
@apply w-full max-w-md rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-600 dark:bg-slate-800;
}
.card-head {
@apply mb-3 flex items-center gap-3;
}
.head-icon {
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300;
}
.head-text .title {
@apply text-base font-semibold text-slate-800 dark:text-slate-100;
}
.head-text .sub {
@apply mt-0.5 text-xs text-slate-500 dark:text-slate-400;
}
.question-block {
@apply mb-3 rounded-lg bg-slate-50 p-3 text-sm leading-relaxed text-slate-700 dark:bg-slate-900/60 dark:text-slate-200;
}
.drug-list {
@apply mb-3 border-t border-slate-100 pt-3 dark:border-slate-600;
}
.drug-list-title {
@apply mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400;
}
.drug-list ul {
@apply m-0 list-none space-y-2 p-0;
}
.drug-list li {
@apply flex items-center justify-between text-sm text-slate-800 dark:text-slate-100;
}
.drug-list .name {
@apply min-w-0 flex-1 truncate pr-2;
}
.drug-list .qty {
@apply flex-shrink-0 font-medium text-slate-600 dark:text-slate-300;
}
.answer-row {
@apply flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
}
.answer-row .label {
@apply text-slate-600 dark:text-slate-400;
}
.answer-row .value {
@apply font-medium text-emerald-800 dark:text-emerald-300;
}
.pending-tip {
@apply rounded-lg border border-dashed border-amber-200 bg-amber-50 px-3 py-2 text-center text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
@@ -55,6 +55,7 @@ import { useThemeStore } from '../stores/theme.ts';
import { useUserStore as chatUseUserStore } from '../stores/user';
import AudioMessage from './AudioMessage.vue';
import PatientExperienceCard from '#/views/doctor/online-consultation/components/PatientExperienceCard.vue';
import FollowUpDrugAssistantCard from './FollowUpDrugAssistantCard.vue';
import ProductCard from '#/views/doctor/online-consultation/components/ProductCard.vue';
import EndConsultationCard from '#/views/doctor/online-consultation/components/EndConsultationCard.vue';
import TransferPrescriptionMessageCard from '#/views/doctor/online-consultation/components/TransferPrescriptionMessageCard.vue';
@@ -154,15 +155,76 @@ const messageTypeName = computed(() => {
return getMessageTypeName(props.message.message_type);
});
const getRegisterCardInfo = () => {
const content = getParsedContent(props.message.message_type, props.message.message_content);
getChatMessageRegisterInfoApi({ id: content.id }).then(
(res) => {
// eslint-disable-next-line vue/no-mutating-props
props.message.message_content = JSON.stringify(res);
},
/** type10接口补全的挂号卡字段不写回 props避免破坏响应式 */
const registerCardPayload = ref(null);
const registerCardDisplay = computed(() => {
if (props.message.message_type !== 10) {
return {};
}
let base = {};
const raw = props.message.message_content || props.message.content || '';
try {
base =
typeof raw === 'string' && raw
? JSON.parse(raw)
: typeof raw === 'object' && raw
? { ...raw }
: {};
} catch {
base = {};
}
const enrich = registerCardPayload.value;
if (enrich && typeof enrich === 'object' && !Array.isArray(enrich)) {
return { ...base, ...enrich };
}
return base;
});
const registerDrugNamesLine = computed(() => {
const pc = registerCardDisplay.value;
if (!pc || typeof pc !== 'object') return '';
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
return arr.map((d) => d?.name).filter(Boolean).join('、');
}
if (pc.drug?.name) return pc.drug.name;
return '';
});
const isFollowUpDrugAssistant = computed(() => {
if (props.message.message_type !== 11) return false;
const pc = parsedContent.value;
return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug');
});
function fetchRegisterCard() {
if (props.message.message_type !== 10) return;
const content = getParsedContent(
props.message.message_type,
props.message.message_content,
);
};
const id = content && typeof content === 'object' ? content.id : undefined;
if (!id) return;
getChatMessageRegisterInfoApi({ id }).then((res) => {
const body = res?.data?.data ?? res?.data ?? res;
registerCardPayload.value =
body && typeof body === 'object' && !Array.isArray(body) ? body : null;
});
}
watch(
() => [
props.message.message_type,
props.message.id,
props.message.message_content,
],
() => {
registerCardPayload.value = null;
fetchRegisterCard();
},
{ immediate: true },
);
// 接诊操作
const acceptRegister = async (registerId, orderNo) => {
@@ -176,7 +238,7 @@ const acceptRegister = async (registerId, orderNo) => {
receptionApi(registerId).then(async (value) => {
// message.success('接诊成功');
// 更新消息状态
getRegisterCardInfo();
fetchRegisterCard();
const messageObj = {
message_type: 0,
@@ -188,7 +250,7 @@ const acceptRegister = async (registerId, orderNo) => {
read: true,
duration: 0,
};
localStorage.setItem('currentRegisterId', reregisterId);
localStorage.setItem('currentRegisterId', String(registerId));
// chatStore.currentFriend?.register_id = reregisterId;
chatStore.addMessage(messageObj, chatUserStore.currentUser.doctor_id);
await sendMessage({
@@ -336,9 +398,6 @@ const getSexText = (sex) => {
return sex === 1 ? '男' : '女';
};
if (props.message.message_type === 10) {
getRegisterCardInfo();
}
</script>
<template>
@@ -482,20 +541,20 @@ if (props.message.message_type === 10) {
<div
:class="{
'bg-yellow-100 text-yellow-800':
parsedContent.status === 0,
'bg-blue-100 text-blue-800': parsedContent.status === 1,
'bg-red-100 text-red-800': parsedContent.status === 2,
'bg-red-100 text-red-800': parsedContent.status === 3,
'bg-gray-100 text-gray-800': parsedContent.status === 4,
registerCardDisplay.status === 0,
'bg-blue-100 text-blue-800': registerCardDisplay.status === 1,
'bg-red-100 text-red-800': registerCardDisplay.status === 2,
'bg-red-100 text-red-800': registerCardDisplay.status === 3,
'bg-gray-100 text-gray-800': registerCardDisplay.status === 4,
'bg-purple-100 text-purple-800':
parsedContent.status === 5,
registerCardDisplay.status === 5,
'bg-indigo-100 text-indigo-800':
parsedContent.status === 6,
'bg-pink-100 text-pink-800': parsedContent.status === 7,
registerCardDisplay.status === 6,
'bg-pink-100 text-pink-800': registerCardDisplay.status === 7,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{ registerStatusMap[parsedContent.status] || '未知状态' }}
{{ registerStatusMap[registerCardDisplay.status] || '未知状态' }}
</div>
</div>
@@ -505,13 +564,13 @@ if (props.message.message_type === 10) {
<div class="flex items-center gap-1">
<i class="fas fa-hashtag text-xs"></i>
<span class="max-w-[120px] truncate">{{
parsedContent.order_no
registerCardDisplay.order_no
}}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-list-ol text-xs"></i>
<span>{{ parsedContent.order_number }}</span>
<span>{{ registerCardDisplay.order_number }}</span>
</div>
</div>
</div>
@@ -525,12 +584,12 @@ if (props.message.message_type === 10) {
</div>
<div
:class="{
'bg-red-100 text-red-800': parsedContent.is_pay === 0,
'bg-green-100 text-green-800': parsedContent.is_pay === 1,
'bg-red-100 text-red-800': registerCardDisplay.is_pay === 0,
'bg-green-100 text-green-800': registerCardDisplay.is_pay === 1,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{ payStatusMap[parsedContent.is_pay] || '未知' }}
{{ payStatusMap[registerCardDisplay.is_pay] || '未知' }}
</div>
</div>
@@ -539,43 +598,70 @@ if (props.message.message_type === 10) {
>
<div class="flex items-center gap-1">
<i class="fas fa-user text-xs"></i>
<span>{{ parsedContent.user_patient?.name || '未知' }}</span>
<span>{{ registerCardDisplay.user_patient?.name || '未知' }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-birthday-cake text-xs"></i>
<span>{{ parsedContent.user_patient?.age || 0 }}</span>
<span>{{ registerCardDisplay.user_patient?.age || 0 }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-venus-mars text-xs"></i>
<span>{{ getSexText(parsedContent.user_patient?.sex) }}</span>
<span>{{ getSexText(registerCardDisplay.user_patient?.sex) }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-phone text-xs"></i>
<span>{{
parsedContent.user_patient?.mobile ? '已绑定' : '未绑定'
registerCardDisplay.user_patient?.mobile ? '已绑定' : '未绑定'
}}</span>
</div>
</div>
</div>
<!-- 主诉 / 所选药品 / 数量与小程序挂号卡对齐 -->
<div
v-if="registerCardDisplay.chief_complaint || registerDrugNamesLine || (registerCardDisplay.number != null && registerCardDisplay.number !== '')"
class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600"
>
<div v-if="registerCardDisplay.chief_complaint">
<span class="font-medium text-gray-600 dark:text-gray-300">主诉</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerCardDisplay.chief_complaint
}}</span>
</div>
<div v-if="registerDrugNamesLine">
<span class="font-medium text-gray-600 dark:text-gray-300">所选药品</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerDrugNamesLine
}}</span>
</div>
<div
v-if="registerCardDisplay.number != null && registerCardDisplay.number !== ''"
>
<span class="font-medium text-gray-600 dark:text-gray-300">数量</span>
<span class="text-gray-800 dark:text-gray-100"
> {{ registerCardDisplay.number }} </span
>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-medium text-gray-700 dark:text-gray-200">
挂号费用:
<span class="text-green-500 dark:text-green-400">¥{{ parsedContent.price }}</span>
<span class="text-green-500 dark:text-green-400">¥{{ registerCardDisplay.price }}</span>
</div>
</div>
<!-- 接诊/拒诊按钮 - 只在待就诊状态显示 -->
<div v-if="parsedContent.status === 1" class="mt-2 flex gap-2">
<div v-if="registerCardDisplay.status === 1" class="mt-2 flex gap-2">
<Button
:loading="registerOperating"
class="flex-1"
size="small"
type="primary"
@click="
acceptRegister(parsedContent.id, parsedContent.order_no)
acceptRegister(registerCardDisplay.id, registerCardDisplay.order_no)
"
>
<i class="fas fa-check mr-1"></i>
@@ -587,7 +673,7 @@ if (props.message.message_type === 10) {
danger
size="small"
@click="
rejectRegister(parsedContent.id, parsedContent.order_no)
rejectRegister(registerCardDisplay.id, registerCardDisplay.order_no)
"
>
<i class="fas fa-times mr-1"></i>
@@ -606,9 +692,9 @@ if (props.message.message_type === 10) {
<!-- 时间信息 -->
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
创建时间: {{ parsedContent.created_at }}
<span v-if="parsedContent.pay_time" class="ml-2">
支付时间: {{ formatDateTime(parsedContent.pay_time) }}
创建时间: {{ registerCardDisplay.created_at }}
<span v-if="registerCardDisplay.pay_time" class="ml-2">
支付时间: {{ formatDateTime(registerCardDisplay.pay_time) }}
</span>
</div>
</div>
@@ -824,6 +910,12 @@ if (props.message.message_type === 10) {
</div>
</div>
<!-- type11复诊助理用药确认与药店患者就诊经历卡片分流 -->
<FollowUpDrugAssistantCard
v-else-if="message.message_type === 11 && isFollowUpDrugAssistant"
:content="parsedContent"
/>
<!-- 患者就诊经历卡片 (type=11) -->
<PatientExperienceCard
v-else-if="message.message_type === 11"

View File

@@ -93,3 +93,30 @@ export async function exportOrderApi() {
export async function updateFreeShipping(data: { id: number; is_free_shipping: number }) {
return requestClient.post<any>(`${prefix}update-free-shipping`, data);
}
/**
* 近 N 分钟订单:表内金额与按 OrderItems 重算是否一致(调价/分账同口径)
*/
export async function getVerifyRecentOrderAmounts(params?: {
minutes?: number;
mismatch_only?: number;
}) {
return requestClient.get<{
count: number;
minutes: number;
orders: Array<{
expected_items_price: number;
expected_total_pay_price: number;
items_diff: number;
items_match: boolean;
items_price: number;
ok: boolean;
order_id: number;
order_no: string;
total_diff: number;
total_match: boolean;
total_pay_price: number;
}>;
since: number;
}>(`${prefix}verify-recent-order-amounts`, { params });
}

View File

@@ -11,7 +11,7 @@ import {
} from '@vben/common-ui';
import { SvgCakeIcon } from '@vben/icons';
import { Button, Image, message, Switch, Tag } from 'ant-design-vue';
import { Button, Image, message, Modal as AntdModal, Space, Switch, Table, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
@@ -19,6 +19,7 @@ import { downloadByData } from '#/util/tool';
import {
exportOrderApi,
getOrderList,
getVerifyRecentOrderAmounts,
saleAmountApi,
updateFreeShipping,
} from '#/views/business/order/product-order/api';
@@ -178,12 +179,103 @@ const toggleFreeShipping = async (row: any, checked: boolean) => {
message.error(error.message || '更新失败');
}
};
const verifyOpen = ref(false);
const verifyLoading = ref(false);
const verifyRows = ref<any[]>([]);
const verifyMeta = ref({ minutes: 10, count: 0, since: 0 });
const verifyMismatchOnly = ref(false);
const verifyColumns = [
{ title: '订单ID', dataIndex: 'order_id', key: 'order_id', width: 90 },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', width: 200, ellipsis: true },
{ title: '药品价(表)', dataIndex: 'items_price', key: 'items_price', width: 100 },
{ title: '药品价(算)', dataIndex: 'expected_items_price', key: 'exp_items', width: 100 },
{ title: '药品', key: 'items_match', width: 80 },
{ title: '应付(表)', dataIndex: 'total_pay_price', key: 'total_pay', width: 100 },
{ title: '应付(算)', dataIndex: 'expected_total_pay_price', key: 'exp_total', width: 100 },
{ title: '应付', key: 'total_match', width: 80 },
{ title: '全OK', key: 'ok', width: 70 },
{ title: '药品差', dataIndex: 'items_diff', key: 'items_diff', width: 80 },
{ title: '应付差', dataIndex: 'total_diff', key: 'total_diff', width: 80 },
];
const fetchOrderAmountVerify = async () => {
verifyLoading.value = true;
try {
const res = await getVerifyRecentOrderAmounts({
minutes: 10,
mismatch_only: verifyMismatchOnly.value ? 1 : 0,
});
verifyMeta.value = {
minutes: res.minutes,
count: res.count,
since: res.since,
};
verifyRows.value = res.orders ?? [];
verifyOpen.value = true;
} finally {
verifyLoading.value = false;
}
};
const openOrderAmountVerify = () => {
verifyMismatchOnly.value = false;
fetchOrderAmountVerify();
};
</script>
<template>
<Page auto-content-height title="订单管理">
<FormModal />
<Modal />
<AntdModal
v-model:open="verifyOpen"
title="近10分钟订单金额校验"
width="min(1100px, 96vw)"
:footer="null"
destroy-on-close
>
<Space direction="vertical" style="width: 100%; margin-bottom: 12px">
<div>
与明细重算口径同调价/分账已检 {{ verifyMeta.count }} since={{ verifyMeta.since }}Unix
</div>
<Space wrap>
<span>仅显示不一致</span>
<Switch v-model:checked="verifyMismatchOnly" @update:checked="fetchOrderAmountVerify" />
<Button :loading="verifyLoading" type="primary" @click="fetchOrderAmountVerify">
重新检测
</Button>
</Space>
</Space>
<Table
:columns="verifyColumns"
:data-source="verifyRows"
:loading="verifyLoading"
:pagination="false"
:scroll="{ x: 1200 }"
size="small"
row-key="order_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'items_match'">
<Tag :color="record.items_match ? 'success' : 'error'">
{{ record.items_match ? '一致' : '否' }}
</Tag>
</template>
<template v-else-if="column.key === 'total_match'">
<Tag :color="record.total_match ? 'success' : 'error'">
{{ record.total_match ? '一致' : '否' }}
</Tag>
</template>
<template v-else-if="column.key === 'ok'">
<Tag :color="record.ok ? 'success' : 'error'">
{{ record.ok ? '是' : '否' }}
</Tag>
</template>
</template>
</Table>
</AntdModal>
<RefundModal />
<PrescriptionDetailModal />
<AnalysisOverview :items="overviewItems" :my-card="false" />
@@ -192,6 +284,12 @@ const toggleFreeShipping = async (row: any, checked: boolean) => {
<TableAction
:flex="false"
:actions="[
// {
// label: '校验近10分钟金额',
// type: 'default',
// icon: 'ix:search-check',
// onClick: openOrderAmountVerify.bind(null),
// },
{
label: '导出',
type: 'primary',