fix:
1. 优化了审方pc的效果 2. 就诊人信息优化
This commit is contained in:
@@ -20,4 +20,5 @@ VITE_INJECT_APP_LOADING=true
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# WebSocket 连接地址
|
||||
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
||||
# VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
||||
VITE_WS_URL=wss://xk.ws.nailaoyun.cn/ws
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 处方失效时间展示
|
||||
* - 剩余 ≤2 小时:标红
|
||||
* - 剩余 ≤10 分钟:额外显示倒计时(每秒刷新)
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 失效时间戳(秒)或已格式化的字符串 */
|
||||
autoExpireTime?: number | string | null;
|
||||
}>();
|
||||
|
||||
const nowTs = ref(Math.floor(Date.now() / 1000));
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** 解析为秒级时间戳 */
|
||||
function resolveExpireTs(raw: number | string | null | undefined): number {
|
||||
if (raw == null || raw === '') return 0;
|
||||
if (typeof raw === 'number') {
|
||||
return raw > 1e12 ? Math.floor(raw / 1000) : raw;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return n > 1e12 ? Math.floor(n / 1000) : n;
|
||||
}
|
||||
const parsed = Date.parse(String(raw).replace(/-/g, '/'));
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
|
||||
}
|
||||
|
||||
const expireTs = computed(() => resolveExpireTs(props.autoExpireTime));
|
||||
|
||||
const remainSec = computed(() => {
|
||||
if (!expireTs.value) return null;
|
||||
return expireTs.value - nowTs.value;
|
||||
});
|
||||
|
||||
const isUrgent = computed(() => {
|
||||
const r = remainSec.value;
|
||||
return r != null && r > 0 && r <= 2 * 3600;
|
||||
});
|
||||
|
||||
const showCountdown = computed(() => {
|
||||
const r = remainSec.value;
|
||||
return r != null && r > 0 && r <= 10 * 60;
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!expireTs.value) return '—';
|
||||
const r = remainSec.value ?? 0;
|
||||
if (r <= 0) return '已过期';
|
||||
const d = new Date(expireTs.value * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
if (showCountdown.value) {
|
||||
const m = Math.floor(r / 60);
|
||||
const s = r % 60;
|
||||
return `${base}(剩余 ${m}:${pad(s)})`;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
timer = setInterval(() => {
|
||||
nowTs.value = Math.floor(Date.now() / 1000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.autoExpireTime,
|
||||
() => {
|
||||
nowTs.value = Math.floor(Date.now() / 1000);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(startTimer);
|
||||
onBeforeUnmount(stopTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="expire-time"
|
||||
:class="{
|
||||
'expire-time--urgent': isUrgent || (remainSec != null && remainSec <= 0),
|
||||
'expire-time--countdown': showCountdown,
|
||||
}"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expire-time {
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
}
|
||||
.expire-time--urgent {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
.expire-time--countdown {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 就诊类型标签:线下就诊 / 在线问诊 / 在线复诊
|
||||
* 对应处方、订单字段 is_online
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 就诊渠道:0线下 1在线问诊 2/3在线复诊 */
|
||||
isOnline?: number | null;
|
||||
}>();
|
||||
|
||||
const label = computed(() => {
|
||||
const v = Number(props.isOnline ?? 0);
|
||||
if (v === 1) return { text: '在线问诊', color: 'blue' };
|
||||
if (v === 2 || v === 3) return { text: '在线复诊', color: 'green' };
|
||||
return { text: '线下就诊', color: 'purple' };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag :color="label.color">{{ label.text }}</Tag>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 列表/卡片内紧凑展示:小程序用户 + 就诊人
|
||||
* 点击后由父级打开 WxUserPatientDrawer
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, Button } from 'ant-design-vue';
|
||||
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 审方/订单行数据,需含 user、user_patient(或 patient) */
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [payload: { upId: number; patientName: string }];
|
||||
}>();
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const user = computed(() => props.row?.user ?? {});
|
||||
const patient = computed(
|
||||
() => props.row?.user_patient ?? props.row?.userPatient ?? {},
|
||||
);
|
||||
|
||||
const upId = computed(() =>
|
||||
Number(patient.value?.id ?? props.row?.up_id ?? 0),
|
||||
);
|
||||
|
||||
const patientName = computed(
|
||||
() => String(patient.value?.name ?? props.row?.patient ?? '') || '—',
|
||||
);
|
||||
|
||||
function userAvatarSrc() {
|
||||
const raw = String(user.value?.avatarurl ?? '').trim();
|
||||
if (!raw) return defaultAvatar;
|
||||
return resolveAvatarUrl(raw) || defaultAvatar;
|
||||
}
|
||||
|
||||
/** 打开就诊人档案抽屉 */
|
||||
function handleOpen() {
|
||||
if (!upId.value) return;
|
||||
emit('open', { upId: upId.value, patientName: patientName.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wx-user-patient-cell">
|
||||
<div class="wx-user-patient-cell__row">
|
||||
<Avatar :size="24" :src="userAvatarSrc()" class="shrink-0" />
|
||||
<div class="wx-user-patient-cell__text min-w-0">
|
||||
<span class="text-[11px] text-gray-400">微信用户:</span>
|
||||
<span class="truncate text-xs">{{ user?.nickname || '—' }}</span>
|
||||
<div class="text-[11px] text-gray-500">ID:{{ user?.id ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wx-user-patient-cell__row">
|
||||
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
|
||||
<div class="wx-user-patient-cell__text min-w-0">
|
||||
<span class="text-[11px] text-gray-400">就诊人:</span>
|
||||
<Button
|
||||
v-if="upId"
|
||||
class="!h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
@click.stop="handleOpen"
|
||||
>
|
||||
{{ patientName }}
|
||||
</Button>
|
||||
<span v-else class="text-xs">{{ patientName }}</span>
|
||||
<div
|
||||
v-if="patient?.sex || patient?.age"
|
||||
class="text-[11px] text-gray-500"
|
||||
>
|
||||
<template v-if="patient?.sex === 1">男</template>
|
||||
<template v-else-if="patient?.sex === 2">女</template>
|
||||
<template v-if="patient?.age"> · {{ patient.age }}岁</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wx-user-patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wx-user-patient-cell__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wx-user-patient-cell__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 微信用户 + 就诊人档案抽屉
|
||||
* 展示小程序用户基础信息、就诊人信息及挂号/处方/订单记录(跨医生)
|
||||
*/
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
|
||||
|
||||
import {
|
||||
getUserPatientOrderListApi,
|
||||
getUserPatientPrescriptionListApi,
|
||||
getUserPatientProfileDetailApi,
|
||||
getUserPatientRegisterListApi,
|
||||
} from './api';
|
||||
import VisitTypeTag from './VisitTypeTag.vue';
|
||||
|
||||
defineOptions({ name: 'WxUserPatientDrawer' });
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const upId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('info');
|
||||
const profile = ref<Record<string, any> | null>(null);
|
||||
|
||||
const registerList = ref<any[]>([]);
|
||||
const prescriptionList = ref<any[]>([]);
|
||||
const orderList = ref<any[]>([]);
|
||||
const registerLoading = ref(false);
|
||||
const prescriptionLoading = ref(false);
|
||||
const orderLoading = ref(false);
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
||||
connectedComponent: OrderDetail,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
footer: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
profile.value = null;
|
||||
registerList.value = [];
|
||||
prescriptionList.value = [];
|
||||
orderList.value = [];
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{ upId?: number; patientName?: string }>();
|
||||
upId.value = Number(data?.upId || 0);
|
||||
activeTab.value = 'info';
|
||||
if (upId.value > 0) {
|
||||
void loadDetail();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 解析头像 URL */
|
||||
function avatarSrc(raw?: string) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultAvatar;
|
||||
return resolveAvatarUrl(s) || defaultAvatar;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
try {
|
||||
profile.value = await getUserPatientProfileDetailApi(upId.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取用户信息失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegisterList() {
|
||||
registerLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientRegisterListApi(upId.value);
|
||||
registerList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取挂号记录失败');
|
||||
} finally {
|
||||
registerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrescriptionList() {
|
||||
prescriptionLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientPrescriptionListApi(upId.value);
|
||||
prescriptionList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取处方记录失败');
|
||||
} finally {
|
||||
prescriptionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderList() {
|
||||
orderLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientOrderListApi(upId.value);
|
||||
orderList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取订单记录失败');
|
||||
} finally {
|
||||
orderLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab 切换时懒加载对应列表 */
|
||||
function onTabChange(key: string | number) {
|
||||
const k = String(key);
|
||||
activeTab.value = k;
|
||||
if (k === 'register' && registerList.value.length === 0) {
|
||||
void loadRegisterList();
|
||||
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
|
||||
void loadPrescriptionList();
|
||||
} else if (k === 'order' && orderList.value.length === 0) {
|
||||
void loadOrderList();
|
||||
}
|
||||
}
|
||||
|
||||
function openPrescription(id: number) {
|
||||
PrescriptionDetailModalApi.setData({ values: id });
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
function openOrder(id: number) {
|
||||
OrderDetailModalApi.setData({ id });
|
||||
OrderDetailModalApi.open();
|
||||
}
|
||||
|
||||
const registerColumns = [
|
||||
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '费用',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const prescriptionColumns = [
|
||||
{
|
||||
title: '处方编号',
|
||||
dataIndex: 'prescription_no',
|
||||
key: 'prescription_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(
|
||||
Button,
|
||||
{ type: 'link', onClick: () => openPrescription(record.id) },
|
||||
() => text,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
customRender: ({ text }: { text: number }) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '未通过',
|
||||
3: '无需审核',
|
||||
};
|
||||
return map[text] || '未知';
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const orderColumns = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
key: 'order_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'total_pay_price',
|
||||
key: 'total_pay_price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-[720px]" title="微信用户 / 就诊人">
|
||||
<PrescriptionDetailModal />
|
||||
<OrderDetailModal />
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="profile">
|
||||
<!-- 小程序用户 -->
|
||||
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
|
||||
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ profile.user?.nickname || '—' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
用户 ID:{{ profile.user?.id ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||
<Tabs.TabPane key="info" tab="就诊人信息">
|
||||
<Descriptions :column="2" bordered size="small">
|
||||
<Descriptions.Item label="姓名">
|
||||
{{ profile.patient?.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
{{
|
||||
profile.patient?.sex === 1
|
||||
? '男'
|
||||
: profile.patient?.sex === 2
|
||||
? '女'
|
||||
: '—'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年龄">
|
||||
{{ profile.patient?.age ? `${profile.patient.age}岁` : '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText
|
||||
v-if="profile.patient"
|
||||
:record="profile.patient"
|
||||
field="mobile"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="身份证" :span="2">
|
||||
<SensitiveText
|
||||
v-if="profile.patient"
|
||||
:record="profile.patient"
|
||||
field="id_card"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="register" tab="挂号记录">
|
||||
<Spin :spinning="registerLoading">
|
||||
<Table
|
||||
v-if="registerList.length"
|
||||
:columns="registerColumns"
|
||||
:data-source="registerList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无挂号记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="prescription" tab="处方记录">
|
||||
<Spin :spinning="prescriptionLoading">
|
||||
<Table
|
||||
v-if="prescriptionList.length"
|
||||
:columns="prescriptionColumns"
|
||||
:data-source="prescriptionList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无处方记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="order" tab="订单记录">
|
||||
<Spin :spinning="orderLoading">
|
||||
<Table
|
||||
v-if="orderList.length"
|
||||
:columns="orderColumns"
|
||||
:data-source="orderList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无订单记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</template>
|
||||
<Empty v-else-if="!loading" description="暂无数据" />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
43
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
43
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 就诊人档案 API(跨医生视角,供药师审方等场景)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'user-patient-profile/';
|
||||
|
||||
/** 小程序用户 + 就诊人基础信息 */
|
||||
export async function getUserPatientProfileDetailApi(upId: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, {
|
||||
params: { up_id: upId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人挂号记录 */
|
||||
export async function getUserPatientRegisterListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}register-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人处方记录 */
|
||||
export async function getUserPatientPrescriptionListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}prescription-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人商品订单记录 */
|
||||
export async function getUserPatientOrderListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}order-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
8
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
8
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 微信用户/就诊人相关组件导出
|
||||
*/
|
||||
export { default as WxUserPatientCell } from './WxUserPatientCell.vue';
|
||||
export { default as WxUserPatientDrawer } from './WxUserPatientDrawer.vue';
|
||||
export { default as VisitTypeTag } from './VisitTypeTag.vue';
|
||||
export { default as PrescriptionExpireTime } from './PrescriptionExpireTime.vue';
|
||||
export * from './api';
|
||||
@@ -93,8 +93,9 @@ export async function syncLegacyShipmentApi(orderId: number) {
|
||||
*/
|
||||
export async function changeDeliveryWarehouseApi(data: {
|
||||
order_id: number;
|
||||
from_warehouse_id: number;
|
||||
to_warehouse_id: number;
|
||||
from_warehouse_id?: number;
|
||||
to_warehouse_id?: number;
|
||||
items?: Array<{ order_item_id: number; to_warehouse_id: number }>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}change-delivery-warehouse`, data);
|
||||
}
|
||||
|
||||
@@ -497,77 +497,74 @@ async function handleCancelOrder(row: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 订单页改仓弹窗状态 */
|
||||
/** 订单页改仓弹窗状态(按药分别选仓) */
|
||||
const changeWhOpen = ref(false);
|
||||
const changeWhSubmitting = ref(false);
|
||||
const changeWhLoading = ref(false);
|
||||
const changeWhOrderId = ref(0);
|
||||
const changeWhFromId = ref(0);
|
||||
const changeWhFromName = ref('');
|
||||
const changeWhToId = ref<number | undefined>(undefined);
|
||||
const changeWhOptions = ref<
|
||||
Array<{ warehouse_id: number; warehouse_name: string; quote: string }>
|
||||
/** 弹窗内药品行:每药独立选 to_warehouse_id */
|
||||
const changeWhDrugRows = ref<
|
||||
Array<{
|
||||
order_item_id: number;
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
image: string;
|
||||
specification: string;
|
||||
number: number;
|
||||
from_warehouse_id: number;
|
||||
to_warehouse_id: number;
|
||||
options: Array<{
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string | null;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const changeWhPendingCount = computed(
|
||||
() =>
|
||||
changeWhDrugRows.value.filter(
|
||||
(r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id),
|
||||
).length,
|
||||
);
|
||||
|
||||
/**
|
||||
* 改仓卡片选项:首项本仓库 + 可选配送仓
|
||||
* 当前已是本仓时本仓库卡片 disabled
|
||||
* 为单药构造仓卡片:本仓库 + 该药 options(排除不可用)
|
||||
*/
|
||||
const changeWhCardOptions = computed(() => {
|
||||
function buildDrugWarehouseCards(
|
||||
drugOptions: any[],
|
||||
_fromWarehouseId: number,
|
||||
): Array<{
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string | null;
|
||||
disabled?: boolean;
|
||||
}> {
|
||||
const local = {
|
||||
warehouse_id: 0,
|
||||
warehouse_name: '萧康医药本仓库',
|
||||
quote: null as string | null,
|
||||
disabled: changeWhFromId.value === 0,
|
||||
};
|
||||
return [
|
||||
local,
|
||||
...changeWhOptions.value.map((o) => ({
|
||||
...o,
|
||||
quote: o.quote as string | null,
|
||||
disabled: false,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* 将 options-by-drugs 按仓去重(取最低 quote),供改仓下拉使用
|
||||
*/
|
||||
function flattenWarehouseOptions(
|
||||
map: Record<string, any[]> | null | undefined,
|
||||
): Array<{ warehouse_id: number; warehouse_name: string; quote: string }> {
|
||||
const byId = new Map<
|
||||
number,
|
||||
{ warehouse_id: number; warehouse_name: string; quote: string }
|
||||
>();
|
||||
if (!map || typeof map !== 'object') {
|
||||
return [];
|
||||
const seen = new Set<number>([0]);
|
||||
const cards = [local];
|
||||
for (const opt of drugOptions || []) {
|
||||
const id = Number(opt?.warehouse_id ?? 0);
|
||||
if (id <= 0 || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
cards.push({
|
||||
warehouse_id: id,
|
||||
warehouse_name: String(opt?.warehouse_name ?? `仓库#${id}`),
|
||||
quote: String(opt?.quote ?? '0'),
|
||||
});
|
||||
}
|
||||
for (const opts of Object.values(map)) {
|
||||
if (!Array.isArray(opts)) {
|
||||
continue;
|
||||
}
|
||||
for (const opt of opts) {
|
||||
const id = Number(opt?.warehouse_id ?? 0);
|
||||
if (id <= 0) {
|
||||
continue;
|
||||
}
|
||||
const quote = String(opt?.quote ?? '0');
|
||||
const prev = byId.get(id);
|
||||
if (!prev || Number(quote) < Number(prev.quote)) {
|
||||
byId.set(id, {
|
||||
warehouse_id: id,
|
||||
warehouse_name: String(opt?.warehouse_name ?? `仓库#${id}`),
|
||||
quote,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => Number(a.quote) - Number(b.quote));
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击仓库 Tag:打开改仓弹窗(首项本仓库 + 配送仓列表)
|
||||
* 点击仓库 Tag:打开按药改仓弹窗
|
||||
*/
|
||||
async function openChangeWarehouse(
|
||||
row: Record<string, any>,
|
||||
@@ -576,39 +573,61 @@ async function openChangeWarehouse(
|
||||
changeWhOrderId.value = Number(row.id ?? 0);
|
||||
changeWhFromId.value = Number(wh?.id ?? 0);
|
||||
changeWhFromName.value = String(wh?.name ?? '');
|
||||
changeWhToId.value = undefined;
|
||||
changeWhOptions.value = [];
|
||||
changeWhDrugRows.value = [];
|
||||
changeWhOpen.value = true;
|
||||
changeWhLoading.value = true;
|
||||
try {
|
||||
const items = Array.isArray(row.product_order_items)
|
||||
? row.product_order_items
|
||||
: [];
|
||||
// 优先取当前仓名下药品;若无 delivery_warehouse_id 字段则退回整单药品
|
||||
const fromId = changeWhFromId.value;
|
||||
const scoped = items.filter((it: any) => {
|
||||
if (it?.delivery_warehouse_id === undefined || it?.delivery_warehouse_id === null) {
|
||||
if (
|
||||
it?.delivery_warehouse_id === undefined ||
|
||||
it?.delivery_warehouse_id === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const wid = Number(it.delivery_warehouse_id ?? 0);
|
||||
return fromId > 0 ? wid === fromId : wid <= 0;
|
||||
});
|
||||
const list = scoped.length ? scoped : items;
|
||||
const drugIds = [
|
||||
...new Set(
|
||||
(scoped.length ? scoped : items)
|
||||
list
|
||||
.map((it: any) => Number(it?.drug_id ?? 0))
|
||||
.filter((id: number) => id > 0),
|
||||
),
|
||||
];
|
||||
if (drugIds.length === 0) {
|
||||
if (list.length === 0) {
|
||||
message.warning('该仓库下没有可改仓的药品');
|
||||
return;
|
||||
}
|
||||
const res = await getDeliveryWarehouseOptionsByDrugs({ drug_ids: drugIds });
|
||||
const flattened = flattenWarehouseOptions(res);
|
||||
// 排除当前仓,避免无意义提交
|
||||
changeWhOptions.value = flattened.filter(
|
||||
(o) => o.warehouse_id !== changeWhFromId.value,
|
||||
);
|
||||
const res =
|
||||
drugIds.length > 0
|
||||
? await getDeliveryWarehouseOptionsByDrugs({ drug_ids: drugIds })
|
||||
: {};
|
||||
const map = (res || {}) as Record<string, any[]>;
|
||||
changeWhDrugRows.value = list.map((it: any) => {
|
||||
const drugId = Number(it?.drug_id ?? 0);
|
||||
const itemId = Number(it?.id ?? 0);
|
||||
const opts = map[String(drugId)] || map[drugId] || [];
|
||||
const fromWid = Number(it?.delivery_warehouse_id ?? fromId ?? 0);
|
||||
return {
|
||||
order_item_id: itemId,
|
||||
drug_id: drugId,
|
||||
drug_name: String(it?.drug_name || it?.name || `药品#${drugId}`),
|
||||
image: String(it?.image || it?.drug?.image || ''),
|
||||
specification: String(
|
||||
it?.specification || it?.drug?.specification || '',
|
||||
),
|
||||
number: Number(it?.number ?? it?.select_number ?? 1),
|
||||
from_warehouse_id: fromWid,
|
||||
// 默认仍为当前仓,用户需主动点选目标仓
|
||||
to_warehouse_id: fromWid,
|
||||
options: buildDrugWarehouseCards(opts, fromWid),
|
||||
};
|
||||
});
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载配送仓库失败');
|
||||
} finally {
|
||||
@@ -616,14 +635,16 @@ async function openChangeWarehouse(
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认改仓 */
|
||||
/** 确认按药改仓 */
|
||||
async function submitChangeWarehouse() {
|
||||
if (changeWhToId.value === undefined || changeWhToId.value === null) {
|
||||
message.warning('请选择目标仓库');
|
||||
return;
|
||||
}
|
||||
if (Number(changeWhToId.value) === Number(changeWhFromId.value)) {
|
||||
message.warning('目标仓库与当前仓库相同');
|
||||
const items = changeWhDrugRows.value
|
||||
.filter((r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id))
|
||||
.map((r) => ({
|
||||
order_item_id: r.order_item_id,
|
||||
to_warehouse_id: Number(r.to_warehouse_id),
|
||||
}));
|
||||
if (items.length === 0) {
|
||||
message.warning('请至少为一种药品选择新的配送仓库');
|
||||
return;
|
||||
}
|
||||
changeWhSubmitting.value = true;
|
||||
@@ -631,9 +652,9 @@ async function submitChangeWarehouse() {
|
||||
await changeDeliveryWarehouseApi({
|
||||
order_id: changeWhOrderId.value,
|
||||
from_warehouse_id: changeWhFromId.value,
|
||||
to_warehouse_id: Number(changeWhToId.value),
|
||||
items,
|
||||
});
|
||||
message.success('改仓成功');
|
||||
message.success(`改仓成功(${items.length} 种药品)`);
|
||||
changeWhOpen.value = false;
|
||||
await gridApi.query();
|
||||
} catch (error: any) {
|
||||
@@ -1349,51 +1370,83 @@ const openOrderAmountVerify = () => {
|
||||
</template>
|
||||
</Grid>
|
||||
<PercentAdjustDrawer />
|
||||
<!-- 订单改仓:卡片选择(本仓库 + 配送仓) -->
|
||||
<!-- 订单改仓:按药分别选仓 -->
|
||||
<AntdModal
|
||||
v-model:open="changeWhOpen"
|
||||
title="修改配送仓库"
|
||||
:confirm-loading="changeWhSubmitting"
|
||||
:ok-button-props="{ disabled: changeWhPendingCount <= 0 }"
|
||||
destroy-on-close
|
||||
width="720px"
|
||||
@ok="submitChangeWarehouse"
|
||||
>
|
||||
<div class="text-muted-foreground mb-3 text-sm">
|
||||
当前仓库:{{ changeWhFromName || '—' }}
|
||||
<span v-if="!changeWhLoading" class="ml-2">
|
||||
· 将改仓 {{ changeWhPendingCount }} 种药品
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="changeWhLoading" class="text-muted-foreground py-4 text-center">
|
||||
加载可选仓库…
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in changeWhCardOptions"
|
||||
:key="opt.warehouse_id"
|
||||
type="button"
|
||||
class="min-w-[140px] rounded-lg border px-3 py-2 text-left transition-colors"
|
||||
:class="[
|
||||
opt.disabled
|
||||
? 'border-border cursor-not-allowed opacity-50'
|
||||
: changeWhToId === opt.warehouse_id
|
||||
? 'border-primary bg-primary/10 ring-primary ring-1'
|
||||
: 'border-border hover:border-primary/50',
|
||||
]"
|
||||
:disabled="opt.disabled"
|
||||
@click="changeWhToId = opt.warehouse_id"
|
||||
>
|
||||
<div class="text-foreground text-sm font-medium">
|
||||
{{ opt.warehouse_name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="opt.quote != null"
|
||||
class="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
供货价 {{ opt.quote }}
|
||||
</div>
|
||||
</button>
|
||||
<div v-else class="change-wh-drug-list space-y-4">
|
||||
<div
|
||||
v-if="changeWhOptions.length === 0"
|
||||
class="text-muted-foreground w-full text-sm"
|
||||
v-for="row in changeWhDrugRows"
|
||||
:key="row.order_item_id"
|
||||
class="border-border rounded-lg border p-3"
|
||||
>
|
||||
暂无其它可用配送仓库
|
||||
<div class="mb-2 flex items-start gap-3">
|
||||
<img
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
alt=""
|
||||
class="h-12 w-12 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="bg-muted text-muted-foreground flex h-12 w-12 shrink-0 items-center justify-center rounded text-xs"
|
||||
>
|
||||
无图
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-foreground truncate font-medium">
|
||||
{{ row.drug_name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-0.5 text-xs">
|
||||
规格:{{ row.specification || '--' }} · 数量 {{ row.number }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in row.options"
|
||||
:key="`${row.order_item_id}-${opt.warehouse_id}`"
|
||||
type="button"
|
||||
class="min-w-[120px] rounded-lg border px-3 py-2 text-left transition-colors"
|
||||
:class="
|
||||
row.to_warehouse_id === opt.warehouse_id
|
||||
? 'border-primary bg-primary/10 ring-primary ring-1'
|
||||
: 'border-border hover:border-primary/50'
|
||||
"
|
||||
@click="row.to_warehouse_id = opt.warehouse_id"
|
||||
>
|
||||
<div class="text-foreground text-sm font-medium">
|
||||
{{ opt.warehouse_name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="opt.quote != null"
|
||||
class="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
供货价 {{ opt.quote }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="changeWhDrugRows.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
暂无可改仓药品
|
||||
</div>
|
||||
</div>
|
||||
</AntdModal>
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 处方详情弹窗
|
||||
* - 常规查看:仅展示/打印
|
||||
* - auditMode:底部显示「通过审方 / 拒绝」,供药师审方页使用
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Button, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
|
||||
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
|
||||
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
|
||||
import { passApi } from '#/views/pharmacist/audit-prescription/api';
|
||||
import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
|
||||
|
||||
const item = ref<Record<string, any>>({});
|
||||
const loading = ref(false);
|
||||
const passing = ref(false);
|
||||
/** 药师审方场景标识 */
|
||||
const auditMode = ref(false);
|
||||
/** 审核成功后回调(刷新列表) */
|
||||
const onAudited = ref<(() => void) | null>(null);
|
||||
|
||||
/** 顶栏诊所名:在线渠道追加(互);无诊所名时兜底空串 */
|
||||
const clinicTitleName = computed(() =>
|
||||
@@ -19,37 +32,101 @@ const clinicTitleName = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
/** 是否展示底部审方按钮:审方模式且待审核 */
|
||||
const showAuditActions = computed(
|
||||
() => auditMode.value && Number(item.value?.status) === 0,
|
||||
);
|
||||
|
||||
const [RejectionReasonModal, RejectionReasonModalApi] = useVbenModal({
|
||||
connectedComponent: RejectionReason,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
// 默认隐藏确认按钮;审方模式用自定义 footer
|
||||
showConfirmButton: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const { values } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
if (typeof values === 'number') {
|
||||
getPrescriptionInfoApi(values).then((res) => {
|
||||
item.value = res;
|
||||
if (!isOpen) {
|
||||
item.value = {};
|
||||
auditMode.value = false;
|
||||
onAudited.value = null;
|
||||
loading.value = false;
|
||||
passing.value = false;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
auditMode.value = !!data.auditMode;
|
||||
onAudited.value =
|
||||
typeof data.onAudited === 'function' ? data.onAudited : null;
|
||||
const { values } = data;
|
||||
if (values) {
|
||||
if (typeof values === 'number') {
|
||||
loading.value = true;
|
||||
getPrescriptionInfoApi(values)
|
||||
.then((res) => {
|
||||
item.value = res || {};
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
} else {
|
||||
item.value = values;
|
||||
}
|
||||
} else {
|
||||
item.value = values;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleWindowPrint(ele, fileName) {
|
||||
// 获取要打印的元素
|
||||
/**
|
||||
* 通过审方:调用审核接口,成功后关弹窗并通知父页刷新
|
||||
*/
|
||||
/**
|
||||
* 通过审方:先回调刷新列表(保留搜索/分页),再关弹窗
|
||||
* 必须先 onAudited 再 close,否则 onOpenChange(false) 会清空回调
|
||||
*/
|
||||
async function handlePass() {
|
||||
const id = item.value?.id;
|
||||
if (!id || passing.value) return;
|
||||
passing.value = true;
|
||||
try {
|
||||
await passApi({ id });
|
||||
message.success('通过成功');
|
||||
const refresh = onAudited.value;
|
||||
refresh?.();
|
||||
modalApi.close();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
passing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开拒绝原因弹窗;提交成功后先刷新列表再关闭详情
|
||||
*/
|
||||
function handleReject() {
|
||||
const id = item.value?.id;
|
||||
if (!id) return;
|
||||
RejectionReasonModalApi.setData({
|
||||
values: id,
|
||||
onSuccess: () => {
|
||||
const refresh = onAudited.value;
|
||||
refresh?.();
|
||||
modalApi.close();
|
||||
},
|
||||
});
|
||||
RejectionReasonModalApi.open();
|
||||
}
|
||||
|
||||
function handleWindowPrint(_ele, fileName) {
|
||||
const printBox = document.querySelector('.print-box');
|
||||
if (!printBox) {
|
||||
console.error('找不到具有 "print-box" 类的元素');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个隐藏的iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
@@ -58,10 +135,7 @@ function handleWindowPrint(ele, fileName) {
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
document.body.append(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
|
||||
// 写入HTML结构
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
@@ -74,41 +148,33 @@ function handleWindowPrint(ele, fileName) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// 复制原页面的所有样式
|
||||
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
|
||||
styles.forEach((style) => {
|
||||
if (style.tagName === 'LINK') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = style.href; // 使用绝对路径
|
||||
link.href = style.href;
|
||||
iframeDoc.head.append(link);
|
||||
} else {
|
||||
iframeDoc.head.append(style.cloneNode(true));
|
||||
}
|
||||
});
|
||||
|
||||
iframeDoc.close();
|
||||
|
||||
// 加载完成后触发打印
|
||||
iframe.contentWindow.addEventListener('load', () => {
|
||||
iframe.contentWindow.print();
|
||||
// 打印后移除iframe
|
||||
setTimeout(() => {
|
||||
iframe.remove();
|
||||
}, 1000); // 确保打印对话框已弹出
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// 解密方法示例(需要根据实际加密方式实现)
|
||||
const decrypt = (str: string) => str; // 简单base64解码示例
|
||||
const decrypt = (str: string) => str;
|
||||
|
||||
const getImageSource = (imageString) => {
|
||||
if (imageString && imageString.includes('http')) {
|
||||
return imageString; // 直接使用HTTP URL
|
||||
} else {
|
||||
return `data:image/jpeg;base64,${imageString}`; // 使用Base64格式
|
||||
return imageString;
|
||||
}
|
||||
return `data:image/jpeg;base64,${imageString}`;
|
||||
};
|
||||
|
||||
function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
@@ -125,192 +191,216 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[60%]" title="中药处方详情">
|
||||
<Page>
|
||||
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
|
||||
打印处方
|
||||
</Button>
|
||||
<div class="prescription-container print-box">
|
||||
<!-- 头部信息 -->
|
||||
<div class="prescription-header">
|
||||
<div class="header-top">
|
||||
<span>处方编号: {{ item.prescription_no }}</span>
|
||||
<div class="prescription-type">普通处方</div>
|
||||
</div>
|
||||
<h2 class="clinic-name">{{ clinicTitleName || '诊所' }} 处方笺</h2>
|
||||
<div class="prescription-date">开具日期: {{ item.created_at }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 患者信息 -->
|
||||
<div class="patient-info">
|
||||
<div class="info-row">
|
||||
<span>姓名: {{ decrypt(item.content?.patient.name) }}</span>
|
||||
<span>性别: {{ item.content?.patient.sex === 1 ? '男' : '女' }}</span>
|
||||
<span>年龄: {{ item.content?.patient.age }}</span>
|
||||
<span>类别: {{ item.content?.category }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>科室: {{ item.content?.doctor.depart?.name }}</span>
|
||||
<span>诊断: {{ item.content?.clinical_diagnose }}</span>
|
||||
</div>
|
||||
<template v-if="item.online_tcm_print?.show">
|
||||
<div v-if="item.online_tcm_print.tcm_syndrome" class="info-row">
|
||||
<span>中医证候: {{ item.online_tcm_print.tcm_syndrome }}</span>
|
||||
<Modal class="w-[60%]" title="处方详情">
|
||||
<RejectionReasonModal />
|
||||
<Spin :spinning="loading">
|
||||
<Page>
|
||||
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
|
||||
打印处方
|
||||
</Button>
|
||||
<div class="prescription-container print-box">
|
||||
<div class="prescription-header">
|
||||
<div class="header-top">
|
||||
<span>处方编号: {{ item.prescription_no }}</span>
|
||||
<div class="prescription-type">普通处方</div>
|
||||
</div>
|
||||
<div v-if="item.online_tcm_print.tcm_method" class="info-row">
|
||||
<span>中医治法: {{ item.online_tcm_print.tcm_method }}</span>
|
||||
<h2 class="clinic-name">{{ clinicTitleName || '诊所' }} 处方笺</h2>
|
||||
<div class="prescription-date">开具日期: {{ item.created_at }}</div>
|
||||
</div>
|
||||
<div class="patient-info">
|
||||
<div class="info-row">
|
||||
<span>姓名: {{ decrypt(item.content?.patient?.name) }}</span>
|
||||
<span>
|
||||
性别: {{ item.content?.patient?.sex === 1 ? '男' : '女' }}
|
||||
</span>
|
||||
<span>年龄: {{ item.content?.patient?.age }}</span>
|
||||
<span>类别: {{ item.content?.category }}</span>
|
||||
</div>
|
||||
<div v-if="item.online_tcm_print.tcm_disease" class="info-row">
|
||||
<span>中医疾病: {{ item.online_tcm_print.tcm_disease }}</span>
|
||||
<div class="info-row">
|
||||
<span>科室: {{ item.content?.doctor?.depart?.name }}</span>
|
||||
<span>诊断: {{ item.content?.clinical_diagnose }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 药品列表 -->
|
||||
<div class="medicine-list">
|
||||
<div class="rp-title">Rp</div>
|
||||
<div
|
||||
v-for="(recipe, index) in item.content?.repice"
|
||||
:key="index"
|
||||
class="recipe-item"
|
||||
>
|
||||
<!-- <div class="medicine-item" v-for="drug in JSON.parse(recipe.content)" :key="drug?.id">-->
|
||||
<div v-if="item.prescription_type === 1" class="w-full">
|
||||
<div
|
||||
v-for="drug in JSON.parse(recipe.content)"
|
||||
:key="drug?.id"
|
||||
class="w-1/3"
|
||||
style="display: inline-block"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<!-- {{ drug }}-->
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}</span>
|
||||
<span class="drug-quantity mr-5">{{ drug?.number }} /g</span>
|
||||
</div>
|
||||
<!-- <div class="">煎服方法: {{ drug.use_way?.name || '煎服' }}</div>-->
|
||||
<div>
|
||||
方法:
|
||||
<span class="preparation-info">{{
|
||||
drug.use_way?.name || drug.useWay || '煎服'
|
||||
}}</span>
|
||||
</div>
|
||||
<template v-if="item.online_tcm_print?.show">
|
||||
<div v-if="item.online_tcm_print.tcm_syndrome" class="info-row">
|
||||
<span>中医证候: {{ item.online_tcm_print.tcm_syndrome }}</span>
|
||||
</div>
|
||||
<div class="preparation-info">
|
||||
<p>
|
||||
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
||||
</p>
|
||||
<p>
|
||||
使用方式:{{
|
||||
`${recipe.process_rule_note},${recipe.process_rule}`
|
||||
}}
|
||||
</p>
|
||||
<div v-if="item.online_tcm_print.tcm_method" class="info-row">
|
||||
<span>中医治法: {{ item.online_tcm_print.tcm_method }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="item.prescription_type === 2 || item.prescription_type === 3 || item.prescription_type === 5 || item.prescription_type === 6 || item.prescription_type === 7">
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span></span>
|
||||
<span class="drug-quantity">{{ drug?.number
|
||||
}}{{ drug?.unit?.name }}</span>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
<div v-if="item.online_tcm_print.tcm_disease" class="info-row">
|
||||
<span>中医疾病: {{ item.online_tcm_print.tcm_disease }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="medicine-list">
|
||||
<div class="rp-title">Rp</div>
|
||||
<div
|
||||
v-for="(recipe, index) in item.content?.repice"
|
||||
:key="index"
|
||||
class="recipe-item"
|
||||
>
|
||||
<div v-if="item.prescription_type === 1" class="w-full">
|
||||
<div
|
||||
v-for="drug in JSON.parse(recipe.content)"
|
||||
:key="drug?.id"
|
||||
class="w-1/3"
|
||||
style="display: inline-block"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}</span>
|
||||
<span class="drug-quantity mr-5">{{ drug?.number }} /g</span>
|
||||
</div>
|
||||
<div>
|
||||
方法:
|
||||
<span class="preparation-info">{{
|
||||
drug.use_way?.name || drug.useWay || '煎服'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="preparation-info">
|
||||
使用方法: {{ recipe.instruction }}
|
||||
<div class="preparation-info">
|
||||
<p>
|
||||
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
||||
</p>
|
||||
<p>
|
||||
使用方式:{{
|
||||
`${recipe.process_rule_note},${recipe.process_rule}`
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
item.prescription_type === 2 ||
|
||||
item.prescription_type === 3 ||
|
||||
item.prescription_type === 5 ||
|
||||
item.prescription_type === 6 ||
|
||||
item.prescription_type === 7
|
||||
"
|
||||
>
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name"
|
||||
>{{ drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span
|
||||
></span
|
||||
>
|
||||
<span class="drug-quantity"
|
||||
>{{ drug?.number }}{{ drug?.unit?.name }}</span
|
||||
>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="preparation-info">
|
||||
使用方法: {{ recipe.instruction }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 医嘱及签名 -->
|
||||
<div class="footer-section">
|
||||
<div class="medical-advice">
|
||||
<label>医嘱:</label>
|
||||
{{ formatDoctorOrderText(item) }}
|
||||
</div>
|
||||
<div class="signature-area">
|
||||
<div class="signature">
|
||||
<label>开方医生:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block; "
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div class="footer-section">
|
||||
<div class="medical-advice">
|
||||
<label>医嘱:</label>
|
||||
{{ formatDoctorOrderText(item) }}
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="getImageSource(item.pharmacist_info.identity.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
<div class="signature-area">
|
||||
<div class="signature">
|
||||
<label>开方医生:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>调配人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>核对人:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>发药人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>调配人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div class="price-info"></div>
|
||||
<div class="price-info">总价: ¥{{ item.total_pay_price }}</div>
|
||||
<div
|
||||
v-if="item.status === 2"
|
||||
class="validity"
|
||||
style="color: red; font-weight: bold"
|
||||
>
|
||||
该处方未通过审核
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>核对人:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="getImageSource(item.pharmacist_info.identity.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
<div v-else-if="item.auto_expire_txt === ''" class="validity">
|
||||
处方有效期: {{ item.valid_hours }}小时
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>发药人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div v-else class="validity" style="color: red">
|
||||
{{ item.auro_expire_txt }}
|
||||
</div>
|
||||
<!-- <div class="signature">-->
|
||||
<!-- <label>加工费:</label>-->
|
||||
|
||||
<!-- <span v-if="item.prescription_type === 1">¥{{ item.content.repice[0].process_price }}</span>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
<div class="price-info"></div>
|
||||
<div class="price-info">总价: ¥{{ item.total_pay_price }}</div>
|
||||
<div v-if="item.status === 2" class="validity" style="color: red; font-weight: bold">
|
||||
该处方未通过审核
|
||||
</div>
|
||||
<div v-else-if="item.auto_expire_txt === ''" class="validity">
|
||||
处方有效期: {{ item.valid_hours }}小时
|
||||
</div>
|
||||
<div v-else class="validity" style="color: red">
|
||||
{{ item.auro_expire_txt }}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</Spin>
|
||||
<template v-if="showAuditActions" #footer>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<Button @click="modalApi.close()">取消</Button>
|
||||
<Button danger :disabled="passing" @click="handleReject">拒绝</Button>
|
||||
<Button type="primary" :loading="passing" @click="handlePass">
|
||||
通过审方
|
||||
</Button>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -322,111 +412,97 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
padding: 20px;
|
||||
font-family: 'SimSun', serif;
|
||||
}
|
||||
|
||||
.prescription-header {
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.prescription-type {
|
||||
border: 1px solid #666;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.patient-info .info-row {
|
||||
.prescription-date {
|
||||
text-align: right;
|
||||
}
|
||||
.patient-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.medicine-list {
|
||||
margin: 20px 0 200px 0;
|
||||
border-top: 1px solid #ccc;
|
||||
padding-top: 15px;
|
||||
min-height: 200px;
|
||||
border-top: 1px dashed #666;
|
||||
border-bottom: 1px dashed #666;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.rp-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.recipe-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.recipe-item {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.medicine-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
padding: 4px 0;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.chinese-item {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.drug-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drug-spec {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.preparation-info {
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.medical-advice {
|
||||
color: #c00;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signature-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
margin-top: 20px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.validity {
|
||||
.drug-spec {
|
||||
margin-left: 6px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
}
|
||||
.usage-info {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.preparation-info {
|
||||
margin-top: 8px;
|
||||
color: #333;
|
||||
}
|
||||
.footer-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.medical-advice {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.signature-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.price-info {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.validity {
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 审方卡片列表:结构化卡片 + 触底分页 + 紧急失效左边框
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { Button, Empty, Space, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import PrescriptionInfoCell from './PrescriptionInfoCell.vue';
|
||||
import { getPrescriptionListApi, passApi } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
formValues?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewDetail: [row: Record<string, any>];
|
||||
viewSource: [id: number];
|
||||
reject: [id: number];
|
||||
openDoctor: [row: Record<string, any>];
|
||||
openPatient: [payload: { upId: number; patientName: string }];
|
||||
}>();
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
const page = ref(1);
|
||||
const pageCount = ref(1);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const passingId = ref<number | null>(null);
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null);
|
||||
const sentinelEl = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const hasMore = computed(() => page.value < pageCount.value);
|
||||
|
||||
/** 解析失效时间戳(秒) */
|
||||
function resolveExpireTs(raw: number | string | null | undefined): number {
|
||||
if (raw == null || raw === '') return 0;
|
||||
if (typeof raw === 'number') {
|
||||
return raw > 1e12 ? Math.floor(raw / 1000) : raw;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return n > 1e12 ? Math.floor(n / 1000) : n;
|
||||
}
|
||||
const parsed = Date.parse(String(raw).replace(/-/g, '/'));
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
|
||||
}
|
||||
|
||||
/** 剩余 ≤2 小时视为紧急 */
|
||||
function isUrgent(row: Record<string, any>) {
|
||||
const ts = resolveExpireTs(row.auto_expire_time);
|
||||
if (!ts) return false;
|
||||
const remain = ts - Math.floor(Date.now() / 1000);
|
||||
return remain > 0 && remain <= 2 * 3600;
|
||||
}
|
||||
|
||||
async function fetchPage(targetPage: number, reset = false) {
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
try {
|
||||
const res = await getPrescriptionListApi({
|
||||
page: targetPage,
|
||||
pageSize: 12,
|
||||
...(props.formValues || {}),
|
||||
});
|
||||
const list = res?.items ?? [];
|
||||
page.value = res?.page ?? targetPage;
|
||||
pageCount.value = res?.page_count ?? 1;
|
||||
items.value = reset ? list : [...items.value, ...list];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载审方列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索条件变更时重置到第 1 页并重新加载 */
|
||||
async function reload() {
|
||||
page.value = 1;
|
||||
items.value = [];
|
||||
await fetchPage(1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留当前页刷新(审方成功后):同一搜索条件重拉当前页
|
||||
* 若本页已空且不在第 1 页,则回退一页
|
||||
*/
|
||||
async function refreshKeepPage() {
|
||||
const current = page.value || 1;
|
||||
await fetchPage(current, true);
|
||||
if (items.value.length === 0 && current > 1) {
|
||||
page.value = current - 1;
|
||||
await fetchPage(page.value, true);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reload, refreshKeepPage });
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore.value || loading.value || loadingMore.value) return;
|
||||
await fetchPage(page.value + 1, false);
|
||||
}
|
||||
|
||||
async function handlePass(row: Record<string, any>) {
|
||||
if (passingId.value) return;
|
||||
passingId.value = row.id;
|
||||
try {
|
||||
await passApi({ id: row.id });
|
||||
message.success('通过成功');
|
||||
await refreshKeepPage();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
passingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
observer?.disconnect();
|
||||
if (!sentinelEl.value) return;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
void loadMore();
|
||||
}
|
||||
},
|
||||
{ root: scrollEl.value, rootMargin: '100px' },
|
||||
);
|
||||
observer.observe(sentinelEl.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.formValues,
|
||||
() => {
|
||||
void reload();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await reload();
|
||||
await nextTick();
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollEl" class="audit-card-scroll">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="items.length" class="audit-card-grid">
|
||||
<div
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="audit-card"
|
||||
:class="{ 'audit-card--urgent': isUrgent(row) }"
|
||||
>
|
||||
<div class="audit-card__main">
|
||||
<PrescriptionInfoCell
|
||||
:row="row"
|
||||
:urgent="isUrgent(row)"
|
||||
@open-doctor="emit('openDoctor', $event)"
|
||||
@open-patient="emit('openPatient', $event)"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<Tag v-if="row.status === 0" color="red">待审核</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audit-card__footer">
|
||||
<Space wrap :size="8">
|
||||
<Button size="small" @click="emit('viewDetail', row)">
|
||||
查看处方
|
||||
</Button>
|
||||
<Button size="small" @click="emit('viewSource', row.id)">
|
||||
处方溯源
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="passingId === row.id"
|
||||
@click="handlePass(row)"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
@click="emit('reject', row.id)"
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else-if="!loading" description="暂无待审方处方" />
|
||||
</Spin>
|
||||
<div ref="sentinelEl" class="h-8" />
|
||||
<div v-if="loadingMore" class="py-3 text-center text-xs text-gray-400">
|
||||
加载中…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length && !hasMore"
|
||||
class="py-3 text-center text-xs text-gray-400"
|
||||
>
|
||||
没有更多了
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-card-scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 4px 2px 20px;
|
||||
}
|
||||
.audit-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.audit-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.dark .audit-card {
|
||||
border-color: #334155;
|
||||
background: #0f172a;
|
||||
}
|
||||
.audit-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.audit-card--urgent {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
.audit-card__main {
|
||||
flex: 1;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
.audit-card__footer {
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafafa;
|
||||
}
|
||||
.dark .audit-card__footer {
|
||||
border-top-color: #1e293b;
|
||||
background: #0b1220;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 开方医生单元格:头像 + 可点击姓名
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, Button } from 'ant-design-vue';
|
||||
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [row: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const doctor = computed(
|
||||
() => props.row?.doctor_info ?? props.row?.doctorInfo ?? {},
|
||||
);
|
||||
|
||||
function avatarSrc() {
|
||||
const raw = String(doctor.value?.avatar ?? '').trim();
|
||||
if (!raw) return defaultAvatar;
|
||||
return resolveAvatarUrl(raw) || defaultAvatar;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar :size="28" :src="avatarSrc()" class="shrink-0" />
|
||||
<Button
|
||||
v-if="doctor?.name"
|
||||
class="!h-auto !px-0 !py-0 truncate"
|
||||
type="link"
|
||||
@click.stop="emit('open', row)"
|
||||
>
|
||||
{{ doctor.name }}
|
||||
</Button>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 药品种类小标签(中药/西药等)
|
||||
*/
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
defineProps<{
|
||||
prescriptionType?: number | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag v-if="prescriptionType === 1" color="orange">中药</Tag>
|
||||
<Tag v-else-if="prescriptionType === 2" color="blue">西药</Tag>
|
||||
<Tag v-else-if="prescriptionType === 3" color="purple">中成药</Tag>
|
||||
<Tag v-else-if="prescriptionType === 5" color="pink">服务包</Tag>
|
||||
<Tag v-else-if="prescriptionType" color="default">类型{{ prescriptionType }}</Tag>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 审方卡片信息主体
|
||||
* 顶栏单号/标签;人物区医生/微信/就诊人同一行三列;底部时间区
|
||||
*/
|
||||
import { Avatar, Button } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
PrescriptionExpireTime,
|
||||
VisitTypeTag,
|
||||
} from '#/components/wx-user-patient';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
import DoctorAvatarCell from './DoctorAvatarCell.vue';
|
||||
import MedicineTypeTag from './MedicineTypeTag.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
/** 是否紧急(失效 ≤2h) */
|
||||
urgent?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
openDoctor: [row: Record<string, any>];
|
||||
openPatient: [payload: { upId: number; patientName: string }];
|
||||
}>();
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
function userAvatarSrc() {
|
||||
const raw = String(props.row?.user?.avatarurl ?? '').trim();
|
||||
if (!raw) return defaultAvatar;
|
||||
return resolveAvatarUrl(raw) || defaultAvatar;
|
||||
}
|
||||
|
||||
function patientName() {
|
||||
return (
|
||||
props.row?.user_patient?.name ||
|
||||
props.row?.userPatient?.name ||
|
||||
'—'
|
||||
);
|
||||
}
|
||||
|
||||
function patientUpId() {
|
||||
return Number(
|
||||
props.row?.user_patient?.id ??
|
||||
props.row?.userPatient?.id ??
|
||||
props.row?.up_id ??
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function openPatient() {
|
||||
const upId = patientUpId();
|
||||
if (!upId) return;
|
||||
emit('openPatient', { upId, patientName: patientName() });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-card-body">
|
||||
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate text-sm font-semibold text-gray-900 dark:text-slate-100"
|
||||
>
|
||||
{{ row.prescription_no || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ row.store?.name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 flex-wrap gap-1">
|
||||
<VisitTypeTag :is-online="row.is_online" />
|
||||
<MedicineTypeTag :prescription-type="row.prescription_type" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 医生 / 微信用户 / 就诊人:同一行三列 -->
|
||||
<div
|
||||
class="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-3 sm:divide-x sm:divide-gray-100 dark:sm:divide-slate-700"
|
||||
>
|
||||
<div class="min-w-0 sm:pr-3">
|
||||
<div class="mb-1 text-[11px] text-gray-400">医生</div>
|
||||
<DoctorAvatarCell :row="row" @open="emit('openDoctor', $event)" />
|
||||
</div>
|
||||
<div class="min-w-0 sm:px-3">
|
||||
<div class="mb-1 text-[11px] text-gray-400">微信用户</div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar :size="28" :src="userAvatarSrc()" class="shrink-0" />
|
||||
<div class="min-w-0 truncate text-xs">
|
||||
{{ row.user?.nickname || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 sm:pl-3">
|
||||
<div class="mb-1 text-[11px] text-gray-400">就诊人</div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar :size="28" :src="defaultAvatar" class="shrink-0" />
|
||||
<Button
|
||||
v-if="patientUpId()"
|
||||
class="!h-auto !px-0 !py-0 truncate"
|
||||
type="link"
|
||||
@click.stop="openPatient"
|
||||
>
|
||||
{{ patientName() }}
|
||||
</Button>
|
||||
<span v-else class="text-xs text-gray-400">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-md px-2.5 py-2 text-xs"
|
||||
:class="
|
||||
urgent
|
||||
? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'bg-gray-50 text-gray-600 dark:bg-slate-800/80 dark:text-slate-300'
|
||||
"
|
||||
>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-gray-400 dark:text-slate-500">创建</span>
|
||||
<span>{{ row.created_at || '—' }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between gap-2">
|
||||
<span class="text-gray-400 dark:text-slate-500">失效</span>
|
||||
<PrescriptionExpireTime :auto-expire-time="row.auto_expire_time" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 创建 / 失效时间单元格
|
||||
*/
|
||||
import { PrescriptionExpireTime } from '#/components/wx-user-patient';
|
||||
|
||||
defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1 text-xs leading-snug">
|
||||
<div>
|
||||
<span class="text-gray-400">创建 </span>
|
||||
<span>{{ row.created_at || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">失效 </span>
|
||||
<PrescriptionExpireTime :auto-expire-time="row.auto_expire_time" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 拒绝审核原因弹窗
|
||||
* 支持 gridApi.reload/query 或自定义 onSuccess 回调(详情内审方场景)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
@@ -6,13 +10,13 @@ import { useVbenModal } from '@vben/common-ui';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createAdmin, updateAdmin } from '#/views/system/admin/api';
|
||||
|
||||
import { modalFormProps } from '../config/form';
|
||||
import {rejectApi} from "#/views/pharmacist/audit-prescription/api";
|
||||
import { rejectApi } from '#/views/pharmacist/audit-prescription/api';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const onSuccess = ref<(() => void) | null>(null);
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
@@ -29,8 +33,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
rejectApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
message.success('拒绝成功');
|
||||
gridApi.value?.reload?.();
|
||||
gridApi.value?.query?.();
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -41,24 +47,27 @@ const [Modal, modalApi] = useVbenModal({
|
||||
await formApi.validateAndSubmitForm();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
formApi.setValues({
|
||||
id: values,
|
||||
});
|
||||
}
|
||||
if (!isOpen) {
|
||||
gridApi.value = null;
|
||||
onSuccess.value = null;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
gridApi.value = data.gridApi ?? null;
|
||||
onSuccess.value =
|
||||
typeof data.onSuccess === 'function' ? data.onSuccess : null;
|
||||
const { values, update } = data;
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
formApi.setValues({
|
||||
id: values,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
title="拒绝审核"
|
||||
class="w-[30%]"
|
||||
>
|
||||
<Modal title="拒绝审核" class="w-[30%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,67 +1,146 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
// import dayjs from 'dayjs';
|
||||
import {
|
||||
getDoctorOptionApi,
|
||||
getStoreOption,
|
||||
} from '#/views/system/store/api';
|
||||
|
||||
/**
|
||||
* 审方页统一搜索表单(列表 / 卡片共用)
|
||||
*/
|
||||
export const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
compact: true,
|
||||
commonConfig: {
|
||||
labelWidth: 80,
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '输入订单号',
|
||||
allowClear: true,
|
||||
placeholder: '处方订单号',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'prescription_no',
|
||||
label: '订单号',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '就诊人姓名(模糊)',
|
||||
},
|
||||
fieldName: 'patient_name',
|
||||
label: '患者',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
filterOption: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return (data || []).map((item: any) => ({
|
||||
label: `${item.name}【${item.id}】`,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getStoreOption,
|
||||
placeholder: '请选择诊所',
|
||||
},
|
||||
fieldName: 'store_id',
|
||||
label: '诊所',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
filterOption: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return (data || []).map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: () => Promise.resolve([]),
|
||||
placeholder: '请先选择诊所',
|
||||
disabled: true,
|
||||
},
|
||||
fieldName: 'su_id',
|
||||
label: '医生',
|
||||
dependencies: {
|
||||
triggerFields: ['store_id'],
|
||||
componentProps: (values: Record<string, any>) => {
|
||||
const storeId = values?.store_id;
|
||||
if (!storeId) {
|
||||
return {
|
||||
api: () => Promise.resolve([]),
|
||||
placeholder: '请先选择诊所',
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
api: () => getDoctorOptionApi(storeId),
|
||||
placeholder: '请选择医生',
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
trigger: (values: Record<string, any>, formApi: any) => {
|
||||
// 诊所变更时清空已选医生,避免跨店脏值
|
||||
formApi?.setFieldValue?.('su_id', undefined);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
showSearch: true,
|
||||
options: [
|
||||
{
|
||||
label: '中药',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '西药',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '中成药',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: '产品服务包',
|
||||
value: 5,
|
||||
},
|
||||
{ label: '线下就诊', value: 0 },
|
||||
{ label: '在线问诊', value: 1 },
|
||||
{ label: '在线复诊', value: 2 },
|
||||
],
|
||||
placeholder: '请选择',
|
||||
placeholder: '请选择来源',
|
||||
},
|
||||
fieldName: 'is_online',
|
||||
label: '处方来源',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '中药', value: 1 },
|
||||
{ label: '西药', value: 2 },
|
||||
{ label: '中成药', value: 3 },
|
||||
{ label: '产品服务包', value: 5 },
|
||||
],
|
||||
placeholder: '请选择类型',
|
||||
},
|
||||
fieldName: 'prescription_type',
|
||||
label: '订单类型',
|
||||
label: '药品类型',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
|
||||
format: 'YYYY-MM-DD',
|
||||
class: 'w-full',
|
||||
},
|
||||
// defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
],
|
||||
// 控制表单是否显示折叠按钮
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
// 是否在字段值改变时提交表单
|
||||
// submitOnChange: true,
|
||||
// submitOnChange: true,
|
||||
// 按下回车时是否提交表单
|
||||
submitOnEnter: false,
|
||||
resetButtonOptions: {
|
||||
content: '重置',
|
||||
},
|
||||
submitOnEnter: true,
|
||||
};
|
||||
|
||||
@@ -3,33 +3,79 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import { getPrescriptionListApi } from '../api';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
introduce: string;
|
||||
id: number;
|
||||
prescription_no: string;
|
||||
created_at: string;
|
||||
auto_expire_time: number;
|
||||
is_online: number;
|
||||
status: number;
|
||||
prescription_type: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审方列表列:多列拆分,避免单列堆叠
|
||||
* query 由页面注入 searchValues,此处仅作默认;index 会覆盖 proxy
|
||||
*/
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
columns: [
|
||||
// { type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID' },
|
||||
{ field: 'prescription_no', align: 'left', title: '处方订单号' },
|
||||
{ field: 'doctor_info.name', align: 'left', title: '开方医生' },
|
||||
{ field: 'user_patient.name', align: 'left', title: '就诊人名称' },
|
||||
{ field: 'store.name', align: 'left', title: '开方诊所' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
// { field: 'created_at', title: '下单时间' },
|
||||
{
|
||||
type: 'html',
|
||||
field: 'prescription_no',
|
||||
align: 'left',
|
||||
title: '处方单号',
|
||||
minWidth: 160,
|
||||
slots: { default: 'prescription_no' },
|
||||
},
|
||||
{
|
||||
field: 'store.name',
|
||||
align: 'left',
|
||||
title: '诊所',
|
||||
minWidth: 120,
|
||||
slots: { default: 'store' },
|
||||
},
|
||||
{
|
||||
field: 'doctor_info',
|
||||
align: 'left',
|
||||
title: '开方医生',
|
||||
minWidth: 140,
|
||||
slots: { default: 'doctor' },
|
||||
},
|
||||
{
|
||||
field: 'user_patient',
|
||||
align: 'left',
|
||||
title: '就诊人',
|
||||
minWidth: 200,
|
||||
slots: { default: 'patient' },
|
||||
},
|
||||
{
|
||||
field: 'is_online',
|
||||
align: 'left',
|
||||
title: '处方来源',
|
||||
width: 110,
|
||||
slots: { default: 'is_online' },
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
align: 'left',
|
||||
title: '时间',
|
||||
minWidth: 180,
|
||||
slots: { default: 'time' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
slots: { default: 'action' },
|
||||
width: 250,
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
@@ -39,14 +85,10 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
isHover: true,
|
||||
},
|
||||
// scrollY: {
|
||||
// enabled: true,
|
||||
// gt: 0,
|
||||
// },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getPrescriptionListApi({
|
||||
page: page.currentPage,
|
||||
@@ -57,26 +99,17 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
border: 'inner',
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
// @ts-ignore
|
||||
search: false,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
expandConfig: {
|
||||
// expandAll: true,
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
@@ -1,46 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 药师工作台 - 审方列表
|
||||
* 顶部统一搜索 + 列表/卡片切换(localStorage)+ 多列布局
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Page,
|
||||
useVbenModal,
|
||||
} from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {Button, message, Tag} from 'ant-design-vue';
|
||||
import { Segmented, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import {
|
||||
VisitTypeTag,
|
||||
WxUserPatientCell,
|
||||
WxUserPatientDrawer,
|
||||
} from '#/components/wx-user-patient';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
|
||||
import { getPrescriptionListApi, passApi } from './api';
|
||||
import AuditPrescriptionCardList from './components/AuditPrescriptionCardList.vue';
|
||||
import DoctorAvatarCell from './components/DoctorAvatarCell.vue';
|
||||
import MedicineTypeTag from './components/MedicineTypeTag.vue';
|
||||
import PrescriptionSource from './components/source.vue';
|
||||
import RejectionReason from './components/modal.vue';
|
||||
|
||||
import TimeCell from './components/TimeCell.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import {passApi} from "#/views/pharmacist/audit-prescription/api";
|
||||
import { gridOptions as baseGridOptions } from './config/table';
|
||||
import {
|
||||
readAuditViewMode,
|
||||
writeAuditViewMode,
|
||||
type AuditPrescriptionViewMode,
|
||||
} from './utils/viewModeStorage';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const viewMode = ref<AuditPrescriptionViewMode>(readAuditViewMode());
|
||||
const passingId = ref<number | null>(null);
|
||||
const cardListRef = ref<InstanceType<typeof AuditPrescriptionCardList> | null>(
|
||||
null,
|
||||
);
|
||||
/** 列表与卡片共用的搜索条件 */
|
||||
const searchValues = ref<Record<string, any>>({});
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
writeAuditViewMode(mode);
|
||||
});
|
||||
|
||||
/** 顶部统一搜索表单 */
|
||||
const [SearchForm] = useVbenForm({
|
||||
...formOptions,
|
||||
handleSubmit: async (values) => {
|
||||
searchValues.value = { ...(values || {}) };
|
||||
refreshCurrentView();
|
||||
},
|
||||
handleReset: async () => {
|
||||
searchValues.value = {};
|
||||
refreshCurrentView();
|
||||
},
|
||||
});
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridOptions: {
|
||||
...baseGridOptions,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
/** 始终合并顶部统一搜索条件 */
|
||||
query: async ({ page }) => {
|
||||
return await getPrescriptionListApi({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchValues.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
watch(viewMode, (mode, prev) => {
|
||||
if (mode === 'list' && prev === 'card') {
|
||||
gridApi.query();
|
||||
}
|
||||
});
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
@@ -52,153 +110,195 @@ const [RejectionReasonModal, RejectionReasonModalApi] = useVbenModal({
|
||||
const [PrescriptionSourceModal, PrescriptionSourceModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionSource,
|
||||
});
|
||||
const openPrescriptionDetail = (values) => {
|
||||
// 打开西药处方模态框逻辑
|
||||
PrescriptionDetailModalApi.setData({
|
||||
values,
|
||||
});
|
||||
PrescriptionDetailModalApi.open();
|
||||
};
|
||||
const openPrescriptionSourceModal = (id) => {
|
||||
// 打开西药处方模态框逻辑
|
||||
PrescriptionSourceModalApi.setData({
|
||||
id,
|
||||
});
|
||||
PrescriptionSourceModalApi.open();
|
||||
};
|
||||
|
||||
const pass = (id) => {
|
||||
passApi({
|
||||
id,
|
||||
}).then(() => {
|
||||
const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorCardModal,
|
||||
});
|
||||
|
||||
const [PatientDrawer, PatientDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: WxUserPatientDrawer,
|
||||
});
|
||||
|
||||
function refreshCurrentView() {
|
||||
if (viewMode.value === 'list') {
|
||||
// commitProxy('query') 会保留当前页与搜索条件
|
||||
gridApi.query();
|
||||
message.success('通过成功');
|
||||
})
|
||||
} else {
|
||||
// 卡片:保当前页重拉,不回第 1 页
|
||||
cardListRef.value?.refreshKeepPage?.();
|
||||
}
|
||||
}
|
||||
|
||||
const reject = (id) => {
|
||||
function openPrescriptionDetail(rowOrId: number | Record<string, any>) {
|
||||
const id = typeof rowOrId === 'number' ? rowOrId : rowOrId.id;
|
||||
PrescriptionDetailModalApi.setData({
|
||||
values: id,
|
||||
auditMode: true,
|
||||
onAudited: refreshCurrentView,
|
||||
});
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
function openPrescriptionSourceModal(id: number) {
|
||||
PrescriptionSourceModalApi.setData({ id });
|
||||
PrescriptionSourceModalApi.open();
|
||||
}
|
||||
|
||||
async function pass(id: number) {
|
||||
if (passingId.value) return;
|
||||
passingId.value = id;
|
||||
try {
|
||||
await passApi({ id });
|
||||
message.success('通过成功');
|
||||
refreshCurrentView();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
passingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reject(id: number) {
|
||||
RejectionReasonModalApi.setData({
|
||||
values: id,
|
||||
gridApi,
|
||||
onSuccess: refreshCurrentView,
|
||||
});
|
||||
RejectionReasonModalApi.open();
|
||||
}
|
||||
|
||||
function showDoctorCard(row: Record<string, any>) {
|
||||
const doctor = row.doctor_info ?? row.doctorInfo ?? row.doctor;
|
||||
if (!doctor?.id && !row.su_id && !doctor?.su_id) {
|
||||
message.warning('该处方无关联医生');
|
||||
return;
|
||||
}
|
||||
DoctorCardModalApi.setData({
|
||||
id: doctor?.id,
|
||||
su_id: row.su_id ?? doctor?.su_id,
|
||||
readonly: true,
|
||||
});
|
||||
DoctorCardModalApi.open();
|
||||
}
|
||||
|
||||
function openPatient(payload: { upId: number; patientName: string }) {
|
||||
if (!payload?.upId) {
|
||||
message.warning('缺少就诊人信息');
|
||||
return;
|
||||
}
|
||||
PatientDrawerApi.setData({
|
||||
upId: payload.upId,
|
||||
patientName: payload.patientName,
|
||||
});
|
||||
PatientDrawerApi.open();
|
||||
}
|
||||
|
||||
function onViewModeChange(mode: string | number) {
|
||||
viewMode.value = mode === 'card' ? 'card' : 'list';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="订单管理">
|
||||
<Page auto-content-height title="审方">
|
||||
<PrescriptionDetailModal />
|
||||
<PrescriptionSourceModal />
|
||||
<RejectionReasonModal />
|
||||
<Grid>
|
||||
<DoctorCardModals />
|
||||
<PatientDrawer />
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-gray-100 bg-white p-3 dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<SearchForm />
|
||||
</div>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<Segmented
|
||||
:value="viewMode"
|
||||
:options="[
|
||||
{ label: '列表', value: 'list' },
|
||||
{ label: '卡片', value: 'card' },
|
||||
]"
|
||||
@change="onViewModeChange"
|
||||
/>
|
||||
</div>
|
||||
<Grid v-if="viewMode === 'list'">
|
||||
<template #toolbar-buttons>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]"></TableAction>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||
</template>
|
||||
<template #prescription_no="{ row }">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium leading-tight">
|
||||
{{ row.prescription_no || '—' }}
|
||||
</div>
|
||||
<MedicineTypeTag :prescription-type="row.prescription_type" />
|
||||
</div>
|
||||
</template>
|
||||
<template #store="{ row }">
|
||||
<span>{{ row.store?.name || '—' }}</span>
|
||||
</template>
|
||||
<template #doctor="{ row }">
|
||||
<DoctorAvatarCell :row="row" @open="showDoctorCard" />
|
||||
</template>
|
||||
<template #patient="{ row }">
|
||||
<WxUserPatientCell :row="row" @open="openPatient" />
|
||||
</template>
|
||||
<template #is_online="{ row }">
|
||||
<VisitTypeTag :is-online="row.is_online" />
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
<TimeCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<div>
|
||||
<Tag v-if="row.prescription_type === 1" color="orange">中药</Tag>
|
||||
<Tag v-else-if="row.prescription_type === 2" color="blue">
|
||||
商品【西药】
|
||||
</Tag>
|
||||
<Tag v-else-if="row.prescription_type === 3" color="purple">
|
||||
商品【非处方药】
|
||||
</Tag>
|
||||
<Tag v-else-if="row.prescription_type === 4" color="green">
|
||||
已退款
|
||||
</Tag>
|
||||
<Tag v-else-if="row.prescription_type === 5" color="pink">
|
||||
产品服务包
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Tag v-if="row.status === 0" color="red">待审核</Tag>
|
||||
<Tag v-else-if="row.status === 1" color="green">通过审核</Tag>
|
||||
<Tag v-else-if="row.status === 2" color="red">拒绝审核</Tag>
|
||||
<Tag v-else-if="row.status === 3" color="purple">无需审核</Tag>
|
||||
<Tag v-else-if="row.status === 4" color="green">无需审核</Tag>
|
||||
</div>
|
||||
<Tag v-if="row.status === 0" color="red">待审核</Tag>
|
||||
<Tag v-else-if="row.status === 1" color="green">通过审核</Tag>
|
||||
<Tag v-else-if="row.status === 2" color="red">拒绝审核</Tag>
|
||||
<Tag v-else color="default">—</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看处方',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
onClick: openPrescriptionDetail.bind(null, row.id),
|
||||
onClick: openPrescriptionDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '处方溯源',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
onClick: openPrescriptionSourceModal.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '通过审核',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
loading: passingId === row.id,
|
||||
onClick: pass.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '拒绝审核',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
onClick: reject.bind(null, row.id),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
// {
|
||||
// label: '查看处方',
|
||||
// type: 'link',
|
||||
// icon: 'marketeq:eye',
|
||||
// // auth: ['超级订单', 'sys:user:save'],
|
||||
// onClick: openPrescriptionDetail.bind(null, row.id),
|
||||
// },
|
||||
// {
|
||||
// label: '处方溯源',
|
||||
// type: 'link',
|
||||
// icon: 'marketeq:eye',
|
||||
// // auth: ['超级订单', 'sys:user:save'],
|
||||
// onClick: openPrescriptionSourceModal.bind(null, row.id),
|
||||
// },
|
||||
]"
|
||||
:drop-down-actions="[]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<div v-else class="audit-card-wrap">
|
||||
<AuditPrescriptionCardList
|
||||
ref="cardListRef"
|
||||
:form-values="searchValues"
|
||||
@view-detail="openPrescriptionDetail"
|
||||
@view-source="openPrescriptionSourceModal"
|
||||
@reject="reject"
|
||||
@open-doctor="showDoctorCard"
|
||||
@open-patient="openPatient"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.custom-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-list-item {
|
||||
background-color: rgba(64, 158, 255, 0.04);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #409eff;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
<style scoped>
|
||||
.audit-card-wrap {
|
||||
height: calc(100% - 160px);
|
||||
min-height: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 审方页列表/卡片视图偏好本地存储
|
||||
*/
|
||||
export type AuditPrescriptionViewMode = 'list' | 'card';
|
||||
|
||||
const STORAGE_KEY = 'audit-prescription-view-mode';
|
||||
|
||||
/** 读取上次视图模式,默认列表 */
|
||||
export function readAuditViewMode(): AuditPrescriptionViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'list' || raw === 'card') return raw;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'list';
|
||||
}
|
||||
|
||||
/** 写入视图模式,下次打开直接进入对应视图 */
|
||||
export function writeAuditViewMode(mode: AuditPrescriptionViewMode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,13 @@ import {
|
||||
updateDeliveryWarehouseDrug,
|
||||
} from '#/views/system/delivery-warehouse-drug/api';
|
||||
import {
|
||||
applyAutoPromoFee,
|
||||
calcFeeSum,
|
||||
isFeeOverSalePrice,
|
||||
modalFormProps,
|
||||
registerFeeOverTipHandler,
|
||||
resetAutoPromoFeeKey,
|
||||
seedAutoPromoFeeKey,
|
||||
syncFeeSumDisplay,
|
||||
} from '#/views/system/delivery-warehouse-drug/config/form';
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
@@ -59,7 +62,8 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 写入售价;新增时默认平台费 = 售价 5%
|
||||
* 写入售价;新增时默认平台费 = 售价 5%,并按差额回填推广费
|
||||
* 编辑仅写售价并锁定依赖 key,不覆盖已有三费
|
||||
*/
|
||||
async function applyCentralPrice(
|
||||
price: number | string | null | undefined,
|
||||
@@ -73,8 +77,18 @@ async function applyCentralPrice(
|
||||
const sale = Number(n.toFixed(4));
|
||||
await formApi.setFieldValue('sale_price', sale);
|
||||
if (isCreate) {
|
||||
await formApi.setFieldValue('platform_fee', Number((sale * 0.05).toFixed(4)));
|
||||
const platform = Number((sale * 0.05).toFixed(4));
|
||||
await formApi.setFieldValue('platform_fee', platform);
|
||||
const values = await formApi.getValues();
|
||||
const merged = { ...values, sale_price: sale, platform_fee: platform };
|
||||
applyAutoPromoFee(merged, formApi);
|
||||
syncFeeSumDisplay(merged, formApi);
|
||||
return;
|
||||
}
|
||||
const values = await formApi.getValues();
|
||||
const merged = { ...values, sale_price: sale };
|
||||
seedAutoPromoFeeKey(merged);
|
||||
syncFeeSumDisplay(merged, formApi);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +162,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
lockDrug.value = !!data.lockDrug && !update;
|
||||
editDrugCard.value = null;
|
||||
feeOverTip.value = '';
|
||||
// 清零自动算费 key,避免沿用上一单的报价/平台费组合
|
||||
resetAutoPromoFeeKey();
|
||||
const salePrice = pickSalePrice(data);
|
||||
// 先重置,再 await 写入,避免异步 setValues 冲掉 sale_price
|
||||
await formApi.resetForm();
|
||||
|
||||
@@ -10,6 +10,18 @@ export function calcFeeSum(values: Record<string, any>) {
|
||||
return (quote + promoFee + platformFee).toFixed(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店推广费 = 售价 − (报价 + 平台费),不低于 0
|
||||
* 有售价时用于自动回填,保证三费合计贴合售价
|
||||
*/
|
||||
export function calcPromoFee(values: Record<string, any>): number {
|
||||
const sale = Number(values.sale_price) || 0;
|
||||
if (!(sale > 0)) return Number(values.promo_fee) || 0;
|
||||
const quote = Number(values.quote) || 0;
|
||||
const platformFee = Number(values.platform_fee) || 0;
|
||||
return Number(Math.max(0, sale - quote - platformFee).toFixed(4));
|
||||
}
|
||||
|
||||
/** 三费是否超过药品售价 */
|
||||
export function isFeeOverSalePrice(values: Record<string, any>) {
|
||||
const sale = Number(values.sale_price);
|
||||
@@ -21,23 +33,74 @@ export function isFeeOverSalePrice(values: Record<string, any>) {
|
||||
type FeeOverTipHandler = (tip: string) => void;
|
||||
let feeOverTipHandler: FeeOverTipHandler | null = null;
|
||||
|
||||
/**
|
||||
* 记录上次自动算费依赖(售价|报价|平台费)
|
||||
* 仅依赖变化时覆盖 promo_fee,避免手改推广费被立刻冲掉
|
||||
*/
|
||||
let lastAutoPromoKey = '';
|
||||
|
||||
/** 注册/注销弹窗内的超售价文本提示处理器 */
|
||||
export function registerFeeOverTipHandler(handler: FeeOverTipHandler | null) {
|
||||
feeOverTipHandler = handler;
|
||||
}
|
||||
|
||||
/** 弹窗打开/重置时清零,避免沿用上一次绑定的 key */
|
||||
export function resetAutoPromoFeeKey() {
|
||||
lastAutoPromoKey = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用合计联动:实时重算、三费 max、超售价 help + 文本提示
|
||||
* 为什么放在 trigger:输入任三费即触发,不必等提交
|
||||
* 编辑回填后锁定当前依赖 key,避免一打开就把已有推广费冲掉
|
||||
* 之后只有改报价/平台费/售价才会重新自动算
|
||||
*/
|
||||
export function seedAutoPromoFeeKey(values: Record<string, any>) {
|
||||
const sale = Number(values.sale_price) || 0;
|
||||
if (!(sale > 0)) {
|
||||
lastAutoPromoKey = '';
|
||||
return;
|
||||
}
|
||||
lastAutoPromoKey = `${sale}|${Number(values.quote) || 0}|${Number(values.platform_fee) || 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有售价且 quote/platform/sale 变化时,自动回填门店推广费
|
||||
* @returns 回填后的 values(含最新 promo_fee),供合计展示使用
|
||||
*/
|
||||
export function applyAutoPromoFee(
|
||||
values: Record<string, any>,
|
||||
formApi: any,
|
||||
): Record<string, any> {
|
||||
const sale = Number(values.sale_price) || 0;
|
||||
if (!(sale > 0)) {
|
||||
return values;
|
||||
}
|
||||
const key = `${sale}|${Number(values.quote) || 0}|${Number(values.platform_fee) || 0}`;
|
||||
const nextPromo = calcPromoFee(values);
|
||||
// 依赖未变:可能是手改推广费触发,不覆盖
|
||||
if (key === lastAutoPromoKey) {
|
||||
return values;
|
||||
}
|
||||
lastAutoPromoKey = key;
|
||||
const cur = Number(values.promo_fee);
|
||||
if (!(Math.abs(cur - nextPromo) < 0.00005)) {
|
||||
formApi.setFieldValue('promo_fee', nextPromo);
|
||||
}
|
||||
return { ...values, promo_fee: nextPromo };
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用合计联动:先按差额回填推广费,再刷新合计 / 超售价提示
|
||||
* 为什么放在 trigger:输入报价或平台费即触发,不必等提交
|
||||
* @returns 超售价提示文案(无则空串)
|
||||
*/
|
||||
export function syncFeeSumDisplay(
|
||||
values: Record<string, any>,
|
||||
formApi: any,
|
||||
): string {
|
||||
const sum = calcFeeSum(values);
|
||||
const merged = applyAutoPromoFee(values, formApi);
|
||||
const sum = calcFeeSum(merged);
|
||||
formApi.setFieldValue('_fee_sum', sum);
|
||||
const sale = Number(values.sale_price) || 0;
|
||||
const sale = Number(merged.sale_price) || 0;
|
||||
const over = sale > 0 && Number(sum) > sale;
|
||||
const tip = over
|
||||
? `三费合计已超过药品售价 ¥${sale.toFixed(4)},请下调报价/推广费/平台费`
|
||||
@@ -45,7 +108,7 @@ export function syncFeeSumDisplay(
|
||||
const feeHelp = over
|
||||
? tip
|
||||
: sale > 0
|
||||
? `报价+推广费+平台费之和不得超过药品售价 ¥${sale.toFixed(4)}`
|
||||
? `推广费自动=售价−(报价+平台费);合计不得超过 ¥${sale.toFixed(4)}`
|
||||
: '报价+推广费+平台费之和,保存时需大于0';
|
||||
// 单项输入上限:有售价时不得超过售价;同时刷新合计 help
|
||||
const feeInputProps = {
|
||||
@@ -64,9 +127,10 @@ export function syncFeeSumDisplay(
|
||||
},
|
||||
{
|
||||
fieldName: 'promo_fee',
|
||||
help: sale > 0 ? '自动计算:售价 − (报价 + 平台费),可微调' : undefined,
|
||||
componentProps: {
|
||||
...feeInputProps,
|
||||
placeholder: '请输入推广费',
|
||||
placeholder: sale > 0 ? '自动=售价−报价−平台费' : '请输入推广费',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -174,6 +238,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'promo_fee',
|
||||
label: '推广费',
|
||||
help: '有售价时自动=售价−(报价+平台费)',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
@@ -204,7 +269,26 @@ export const modalFormProps: VbenFormProps = {
|
||||
triggerFields: ['quote', 'promo_fee', 'platform_fee', 'sale_price'],
|
||||
},
|
||||
},
|
||||
// 绑定创建时库存固定为 0,后续在「库存管理」入库/出库,禁止表单直改数量
|
||||
// 创建时可填初始库存,提交后走入库流水(编辑时不展示)
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '选填,默认 0',
|
||||
min: 0,
|
||||
precision: 0,
|
||||
class: 'w-full',
|
||||
},
|
||||
fieldName: 'initial_stock',
|
||||
label: '初始库存',
|
||||
help: '大于 0 时绑定成功后自动入库并记流水',
|
||||
defaultValue: 0,
|
||||
dependencies: {
|
||||
show(values) {
|
||||
return !values?.id;
|
||||
},
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 出入库记录抽屉:卡片列表展示
|
||||
* 含手动入库/出库、绑定初始入库及操作人
|
||||
*/
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Empty, Spin, Table, Tag } from 'ant-design-vue';
|
||||
import { Empty, Pagination, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getDeliveryWarehouseStockLogList } from '#/views/system/delivery-warehouse-stock/api';
|
||||
|
||||
@@ -19,31 +23,27 @@ const pager = reactive({
|
||||
});
|
||||
const filter = reactive({
|
||||
warehouse_drug_id: 0 as number,
|
||||
drug_id: 0 as number,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '类型', dataIndex: 'biz_type_txt', key: 'biz_type_txt', width: 120 },
|
||||
{ title: '数量', dataIndex: 'qty', key: 'qty', width: 80 },
|
||||
{ title: '库存前→后', key: 'stock_chg', width: 120 },
|
||||
{ title: '冻结前→后', key: 'frozen_chg', width: 120 },
|
||||
{ title: '订单ID', dataIndex: 'order_id', key: 'order_id', width: 90 },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark' },
|
||||
];
|
||||
/** 类型 Tag 颜色:入绿 / 出橙 / 其它默认 */
|
||||
function bizTagColor(bizType: string) {
|
||||
if (bizType === 'manual_in' || bizType === 'refund_in') return 'success';
|
||||
if (bizType === 'manual_out' || bizType === 'ship_out') return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!filter.warehouse_drug_id && !filter.drug_id) {
|
||||
if (!filter.warehouse_drug_id) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
// 只传 warehouse_drug_id,避免与 drug_id AND 干扰
|
||||
const res = await getDeliveryWarehouseStockLogList({
|
||||
page: pager.current,
|
||||
pageSize: pager.pageSize,
|
||||
warehouse_drug_id: filter.warehouse_drug_id || undefined,
|
||||
drug_id: filter.drug_id || undefined,
|
||||
warehouse_drug_id: filter.warehouse_drug_id,
|
||||
});
|
||||
rows.value = res?.items ?? res?.list ?? [];
|
||||
pager.total = Number(res?.total ?? 0);
|
||||
@@ -63,7 +63,6 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
const row = data?.row || {};
|
||||
drugName.value = row.drug?.drug_name || '';
|
||||
filter.warehouse_drug_id = Number(row.id || 0);
|
||||
filter.drug_id = Number(row.drug_id || 0);
|
||||
pager.current = 1;
|
||||
void loadLogs();
|
||||
},
|
||||
@@ -73,38 +72,100 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
<template>
|
||||
<Drawer :title="`出入库记录${drugName ? ` - ${drugName}` : ''}`">
|
||||
<Spin :spinning="loading">
|
||||
<Table
|
||||
v-if="rows.length"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="{
|
||||
current: pager.current,
|
||||
pageSize: pager.pageSize,
|
||||
total: pager.total,
|
||||
onChange: (p: number) => {
|
||||
pager.current = p;
|
||||
void loadLogs();
|
||||
},
|
||||
}"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'biz_type_txt'">
|
||||
<Tag>{{ record.biz_type_txt || record.biz_type }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stock_chg'">
|
||||
{{ record.stock_before }} → {{ record.stock_after }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'frozen_chg'">
|
||||
{{ record.frozen_before }} → {{ record.frozen_after }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'order_id'">
|
||||
{{ record.order_id > 0 ? record.order_id : '-' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div v-if="rows.length" class="log-card-list">
|
||||
<div
|
||||
v-for="record in rows"
|
||||
:key="record.id"
|
||||
class="log-card border-border bg-card"
|
||||
>
|
||||
<div class="log-card__head">
|
||||
<Tag :color="bizTagColor(record.biz_type)">
|
||||
{{ record.biz_type_txt || record.biz_type }}
|
||||
</Tag>
|
||||
<span class="text-muted-foreground text-xs">{{
|
||||
record.created_at
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="log-card__body">
|
||||
<div class="log-card__row">
|
||||
<span class="text-muted-foreground">数量</span>
|
||||
<span class="text-foreground font-medium">{{ record.qty }}</span>
|
||||
</div>
|
||||
<div class="log-card__row">
|
||||
<span class="text-muted-foreground">库存</span>
|
||||
<span class="text-foreground"
|
||||
>{{ record.stock_before }} → {{ record.stock_after }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="log-card__row">
|
||||
<span class="text-muted-foreground">冻结</span>
|
||||
<span class="text-foreground"
|
||||
>{{ record.frozen_before }} → {{ record.frozen_after }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="log-card__row">
|
||||
<span class="text-muted-foreground">操作人</span>
|
||||
<span class="text-foreground">{{
|
||||
record.operator_name || '系统'
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="Number(record.order_id) > 0" class="log-card__row">
|
||||
<span class="text-muted-foreground">订单ID</span>
|
||||
<span class="text-foreground">{{ record.order_id }}</span>
|
||||
</div>
|
||||
<div v-if="record.remark" class="log-card__row">
|
||||
<span class="text-muted-foreground">备注</span>
|
||||
<span class="text-foreground">{{ record.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="pager.current"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
size="small"
|
||||
@change="
|
||||
(p: number) => {
|
||||
pager.current = p;
|
||||
void loadLogs();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else class="py-10" description="暂无出入库记录" />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.log-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
.log-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.log-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.log-card__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user