1. 优化了审方pc的效果
2. 就诊人信息优化
This commit is contained in:
李琦
2026-07-18 14:16:25 +08:00
parent 47a829c1b1
commit 92149f43a6
23 changed files with 2299 additions and 624 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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 },
});
}

View 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';

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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>