1. 配送仓库

This commit is contained in:
李琦
2026-07-18 10:22:36 +08:00
parent 5ff6f0d837
commit 47a829c1b1
17 changed files with 1208 additions and 200 deletions

View File

@@ -172,6 +172,8 @@ const searchDrugs = debounce(async (keyword: string) => {
_specification: drug.specification || item.specification || '',
_supplier: drug.supplier?.name || item.supplier?.name || '',
_price: Number(item.price ?? 0),
// 已绑定配送仓:列表展示「多仓」标识
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
_timeId: drug.time_id || 0,
_typeId: drug.type_id || 0,
_frequencyId: drug.frequency_id || 0,
@@ -423,9 +425,12 @@ watch(
<div v-else class="drug-item__img-placeholder">无图</div>
</div>
<!-- 药品信息药名 / ID / 规格 / 供应商 -->
<!-- 药品信息药名 / 多仓 / ID / 规格 / 供应商 -->
<div class="drug-item__info">
<div class="drug-item__name">{{ item._drugName }}</div>
<div class="drug-item__name-row">
<div class="drug-item__name">{{ item._drugName }}</div>
<span v-if="item._hasDeliveryWarehouse" class="drug-item__multi-wh">多仓</span>
</div>
<div class="drug-item__meta">
<span class="drug-item__id">ID{{ item._drugId }}</span>
<span v-if="item._specification" class="drug-item__spec">
@@ -565,6 +570,14 @@ watch(
overflow: hidden;
}
&__name-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 500;
@@ -572,7 +585,20 @@ watch(
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
min-width: 0;
}
/* 配送仓绑定药品标识:小号胶囊,不抢主信息 */
&__multi-wh {
flex-shrink: 0;
padding: 0 6px;
height: 18px;
line-height: 16px;
font-size: 11px;
color: #2b6de5;
background: rgba(43, 109, 229, 0.08);
border: 1px solid rgba(43, 109, 229, 0.45);
border-radius: 999px;
}
&__meta {

View File

@@ -571,6 +571,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
frequency_id: data.drug.frequency_id,
unit_id: data.drug.unit_id,
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug?.specification || data.specification || '',
instruction: data.drug.instruction,
type: data.drug.type,
select_number: (() => {
@@ -701,6 +703,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
frequency_id: data.drug.frequency_id,
unit_id: data.drug.unit_id,
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug?.specification || data.specification || '',
instruction: data.drug.instruction,
type: data.drug.type,
};
@@ -987,8 +991,13 @@ export const usePrescriptionStore = defineStore('prescription', () => {
// 诊所选择参数转诊挂号时自动使用承接方诊所ID
send_mode: finalSendMode,
custom_store_id: finalSendMode === 1 ? finalStoreId : null,
// 药店按药选配送仓映射 drug_id -> warehouse_id
warehouse_map: warehouseMap || undefined,
// 在线复诊手选仓:列表格式避免数字键对象序列化丢失
warehouse_map: warehouseMap
? Object.entries(warehouseMap).map(([drug_id, warehouse_id]) => ({
drug_id: Number(drug_id),
warehouse_id: Number(warehouse_id),
}))
: undefined,
});
const res = await response;
@@ -1019,7 +1028,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
userStore.currentUser.doctor_id,
);
resetForm();
// 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory
clearLocalPrescriptionCache();
// 返回包含转诊信息的响应数据
return {
success: true,
@@ -1034,6 +1045,33 @@ export const usePrescriptionStore = defineStore('prescription', () => {
}
};
/**
* 清除当前挂号会话的处方 localStorage 草稿
* 原因resetForm 只会把当前分类写成空数组,另一分类与 activeCategory 会残留,再次打开仍会回填
*/
const clearLocalPrescriptionCache = () => {
const registerId = currentRegisterId.value;
if (!registerId) return;
try {
// 与小程序 PrescriptionStorage.clearAllPrescriptionData 对齐的分类集合
const categories = [1, 2, 3, 5, 6, 7];
categories.forEach((cat) => {
localStorage.removeItem(
`${storagePrefix.value}prescriptionData_${cat}_${registerId}`,
);
// 清理历史双横线孤儿 key旧版弹窗 `${prefix}-prescriptionData_`
localStorage.removeItem(
`${storagePrefix.value}-prescriptionData_${cat}_${registerId}`,
);
});
localStorage.removeItem(
`${storagePrefix.value}activeCategory${registerId}`,
);
} catch (error) {
console.error('清除处方本地缓存失败:', error);
}
};
const resetForm = () => {
updateCurrentDrugs([]);
diagnosis.value = '';
@@ -1168,6 +1206,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
checkChineseMedicineConflict,
sendPrescription,
resetForm,
clearLocalPrescriptionCache,
changeCategory,
getProcessRuleListData,
splitString,

View File

@@ -0,0 +1,15 @@
/**
* 在线处方诊所名追加「is_online 为 1/2/3问诊/复诊等),线下 0 不加
*/
export function formatStoreNameWithHu(
storeName: string | null | undefined,
isOnline: number | string | null | undefined,
): string {
const name = String(storeName ?? '').trim();
if (!name) return '';
const online = Number(isOnline);
if (online === 1 || online === 2 || online === 3) {
return name.endsWith('(互)') ? name : `${name}(互)`;
}
return name;
}

View File

@@ -224,50 +224,103 @@ const [WarehouseSelectModals, warehouseSelectModalApi] = useVbenModal({
});
/**
* 药店在线问诊:若药品有配送仓绑定,则先弹选仓再发送
* 在线复诊发送前:有仓药绑定时弹卡片选仓(默认最低价)
* @returns true=已拦截发送等待选仓false=无需选仓可继续发送
*/
const tryPharmacyWarehouseSelect = async (
doctorSecondSignValue: number,
sendMode: number,
customStoreId: number | null,
): Promise<boolean> => {
const storagePrefix =
modalData.value?.storagePrefix || 'onlineConsultation-';
// 诊所在线复诊前缀不走手动选仓(后端自动按报价)
if (storagePrefix === 'onlineConsultationClinic-') {
return false;
}
const drugs = prescriptionStore.currentDrugs || [];
const drugIds = drugs.map((d: any) => Number(d.id)).filter((id: number) => id > 0);
const drugIds: number[] = [];
const needQtyMap: Record<number, number> = {};
for (const d of drugs) {
const id = Number(d?.id ?? 0);
const qty = Number(d?.select_number ?? d?.number ?? 0);
if (id <= 0 || qty <= 0) {
continue;
}
drugIds.push(id);
needQtyMap[id] = (needQtyMap[id] || 0) + qty;
}
if (drugIds.length === 0) {
return false;
}
const needQtyMap: Record<number, number> = {};
drugs.forEach((d: any) => {
needQtyMap[Number(d.id)] = Number(d.select_number || d.number || 1);
});
const optionsMap = await getDeliveryWarehouseOptionsByDrugs({
drug_ids: drugIds.join(','),
need_qty_map: needQtyMap,
});
const rows: any[] = [];
Object.keys(optionsMap || {}).forEach((drugId) => {
const list = optionsMap[drugId] || [];
if (!list.length) return;
const drug = drugs.find((d: any) => Number(d.id) === Number(drugId));
rows.push({
drug_id: Number(drugId),
drug_name: drug?.drug_name || drug?.name || `药品#${drugId}`,
options: list,
try {
const res = await getDeliveryWarehouseOptionsByDrugs({
drug_ids: drugIds,
need_qty_map: needQtyMap,
});
});
if (rows.length === 0) {
return false;
const map = (res?.result ?? res ?? {}) as Record<string, any[]>;
const rows: Array<{
drug_id: number;
drug_name: string;
options: Array<{
warehouse_id: number;
warehouse_name: string;
quote: string;
available_stock: number;
}>;
}> = [];
for (const d of drugs) {
const id = Number(d?.id ?? 0);
if (id <= 0) {
continue;
}
const options = map[String(id)] || map[id] || [];
if (!Array.isArray(options) || options.length === 0) {
continue;
}
rows.push({
drug_id: id,
// 优先用仓选项接口带回的药品信息,避免处方草稿未存规格/图
drug_name: String(
options[0]?.drug_name ||
d?.drug_name ||
d?.name ||
`药品#${id}`,
),
image: String(
options[0]?.image ||
d?.image ||
d?._image ||
d?.drug?.image ||
'',
),
specification: String(
options[0]?.specification ||
d?.specification ||
d?.drug?.specification ||
'',
),
options: options.map((o) => ({
warehouse_id: Number(o.warehouse_id),
warehouse_name: String(o.warehouse_name || ''),
quote: String(o.quote ?? '0'),
available_stock: Number(o.available_stock ?? 0),
// 保留药品展示字段,供弹窗从 options[0] 回退读取
drug_name: String(o.drug_name || ''),
image: String(o.image || ''),
specification: String(o.specification || ''),
})),
});
}
if (rows.length === 0) {
return false;
}
pendingSendParams.value = {
doctorSecondSignValue,
sendMode,
customStoreId,
};
warehouseSelectModalApi.setData({ rows });
warehouseSelectModalApi.open();
return true;
} catch (error: any) {
message.error(error?.message || '加载配送仓库失败');
return true;
}
pendingSendParams.value = { doctorSecondSignValue, sendMode, customStoreId };
warehouseSelectModalApi.setData({ rows });
warehouseSelectModalApi.open();
return true;
};
const onWarehouseSelected = async (warehouseMap: Record<number, number>) => {
@@ -301,6 +354,7 @@ const handleSendPrescription = async (doctorSecondSignValue = 0) => {
sendMode = 1;
customStoreId = storeInfo.store_id;
}
// 有绑仓药品时先卡片选仓,再发送
const needSelect = await tryPharmacyWarehouseSelect(
doctorSecondSignValue,
sendMode,
@@ -552,6 +606,7 @@ async function handleSelectCommonPrescription(data: any, type: number) {
frequency_id: recipe.frequency_id,
unit_id: recipe.unit_id,
image: recipe.image,
specification: recipe.specification || recipe.drug?.specification || '',
instruction: recipe.instruction,
type: recipe.type,
select_number: recipe.select_number || 1,
@@ -575,6 +630,7 @@ async function handleSelectCommonPrescription(data: any, type: number) {
price: recipe.price || 0,
buy_price: recipe.buy_price,
way_id: recipe.way_id || 0,
specification: recipe.specification || recipe.drug?.specification || '',
select_number: 1,
};
// 检查是否已存在
@@ -666,6 +722,12 @@ function handleSimpleProductSelect(drug: any) {
number: 1,
price: drug._price || drug.price,
image: drug._image || drug.drug?.image || drug.image,
// 规格(选仓弹窗等处展示)
specification:
drug._specification ||
drug.drug?.specification ||
drug.specification ||
'',
instruction: drug.drug?.instruction || drug.instruction || '',
type: drug.drug?.type || drug.type || prescriptionStore.activeCategory,
select_number: 1,

View File

@@ -1,92 +1,210 @@
<script lang="ts" setup>
/**
* 药店开方:按药品选配送仓库
* 交互尽量简单:每个药品一个下拉,确认后回传 warehouse_map
* 在线复诊开方:按药品分步卡片选配送仓库
* 默认选中 options[0](与后端 auto 一致quote ASC 最低价)
* 样式使用主题 token适配亮/暗色
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { Button, Image, message } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'confirm', map: Record<number, number>): void;
}>();
const drugRows = ref<
Array<{
drug_id: number;
drug_name: string;
options: Array<{
warehouse_id: number;
warehouse_name: string;
quote: string;
available_stock: number;
}>;
warehouse_id?: number;
}>
>([]);
type WarehouseOption = {
warehouse_id: number;
warehouse_name: string;
quote: string;
available_stock: number;
/** 接口可能在 option 上带药品展示字段,供 row 回退 */
drug_name?: string;
image?: string;
specification?: string;
};
type DrugRow = {
drug_id: number;
drug_name: string;
image?: string;
specification?: string;
options: WarehouseOption[];
warehouse_id?: number;
};
/**
* 从 row 或 options[0] 取药品展示字段(接口规格在 option 上)
*/
function resolveDrugMeta(row: DrugRow) {
const first = row.options?.[0];
return {
drug_name: row.drug_name || first?.drug_name || '',
image: row.image || first?.image || '',
specification: row.specification || first?.specification || '',
};
}
const drugRows = ref<DrugRow[]>([]);
/** 当前步骤下标0-based */
const stepIndex = ref(0);
const totalSteps = computed(() => drugRows.value.length);
const currentRow = computed(() => drugRows.value[stepIndex.value] || null);
/** 当前步药品展示:名/图/规格,含 option 回退 */
const currentDrugMeta = computed(() =>
currentRow.value
? resolveDrugMeta(currentRow.value)
: { drug_name: '', image: '', specification: '' },
);
const isFirstStep = computed(() => stepIndex.value <= 0);
const isLastStep = computed(
() => stepIndex.value >= Math.max(totalSteps.value - 1, 0),
);
const canGoNext = computed(() => !!currentRow.value?.warehouse_id);
/** 点击卡片选中当前步骤药品的配送仓 */
function selectWarehouse(warehouseId: number) {
const row = currentRow.value;
if (!row) return;
row.warehouse_id = warehouseId;
}
function goPrev() {
if (!isFirstStep.value) {
stepIndex.value -= 1;
}
}
function goNext() {
if (!canGoNext.value) {
message.error('请先选择配送仓库');
return;
}
if (!isLastStep.value) {
stepIndex.value += 1;
}
}
/** 汇总 warehouse_map 并确认 */
function confirmAll() {
if (!canGoNext.value) {
message.error('请先选择配送仓库');
return;
}
const map: Record<number, number> = {};
for (const row of drugRows.value) {
if (!row.warehouse_id) {
message.error(`请为【${row.drug_name}】选择配送仓库`);
return;
}
map[row.drug_id] = row.warehouse_id;
}
emit('confirm', map);
modalApi.close();
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
footer: false,
onCancel() {
modalApi.close();
},
onConfirm() {
const map: Record<number, number> = {};
for (const row of drugRows.value) {
if (!row.warehouse_id) {
message.error(`请为【${row.drug_name}】选择配送仓库`);
return;
}
map[row.drug_id] = row.warehouse_id;
}
emit('confirm', map);
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ rows: typeof drugRows.value }>();
drugRows.value = (data?.rows || []).map((row) => ({
...row,
warehouse_id: row.options?.[0]?.warehouse_id,
}));
const data = modalApi.getData<{ rows: DrugRow[] }>();
// 默认选中每药 options[0](最低 quote规格/图/名可从 option 回退
drugRows.value = (data?.rows || []).map((row) => {
const meta = resolveDrugMeta(row);
return {
...row,
warehouse_id: row.options?.[0]?.warehouse_id,
drug_name: meta.drug_name || `药品#${row.drug_id}`,
image: meta.image,
specification: meta.specification,
};
});
stepIndex.value = 0;
}
},
});
const canConfirm = computed(() =>
drugRows.value.every((row) => !!row.warehouse_id),
);
</script>
<template>
<Modal title="选择配送仓库" class="w-[520px]" :confirm-disabled="!canConfirm">
<div class="space-y-3 py-2">
<Modal title="选择配送仓库" class="w-[640px]">
<div v-if="currentRow" class="space-y-4 py-2">
<div class="text-muted-foreground text-sm">
{{ stepIndex + 1 }} / {{ totalSteps }} 个药品
</div>
<!-- 当前药品信息 / / 规格 -->
<div
v-for="row in drugRows"
:key="row.drug_id"
class="flex items-center gap-3"
class="border-border bg-muted/30 flex items-start gap-3 rounded-lg border p-3"
>
<div class="w-40 shrink-0 truncate" :title="row.drug_name">
{{ row.drug_name }}
</div>
<Select
v-model:value="row.warehouse_id"
class="flex-1"
placeholder="请选择配送仓库"
:options="
row.options.map((o) => ({
label: `${o.warehouse_name}(报价${o.quote},库存${o.available_stock}`,
value: o.warehouse_id,
}))
"
<Image
v-if="currentDrugMeta.image"
:src="currentDrugMeta.image"
:width="64"
:height="64"
class="shrink-0 rounded object-cover"
/>
<div
v-else
class="bg-accent text-muted-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded text-xs"
>
暂无图
</div>
<div class="min-w-0 flex-1">
<div
class="text-foreground truncate text-base font-medium"
:title="currentDrugMeta.drug_name"
>
{{ currentDrugMeta.drug_name }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
规格{{ currentDrugMeta.specification || '--' }}
</div>
</div>
</div>
<div v-if="drugRows.length === 0" class="text-gray-400">
当前药品无需选择配送仓库
<!-- 仓卡片 -->
<div class="flex flex-wrap gap-2">
<button
v-for="opt in currentRow.options"
:key="opt.warehouse_id"
type="button"
class="min-w-[140px] rounded-lg border px-3 py-2 text-left transition-colors"
:class="
currentRow.warehouse_id === opt.warehouse_id
? 'border-primary bg-primary/10 ring-primary ring-1'
: 'border-border hover:border-primary/50'
"
@click="selectWarehouse(opt.warehouse_id)"
>
<div class="text-foreground text-sm font-medium">
{{ opt.warehouse_name }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
供货价 {{ opt.quote }} · 库存 {{ opt.available_stock }}
</div>
</button>
</div>
</div>
<div v-else class="text-muted-foreground py-6">当前药品无需选择配送仓库</div>
<!-- 自定义底栏上一步 / 下一步 / 确认 -->
<div class="border-border mt-4 flex justify-end gap-2 border-t pt-3">
<Button v-if="!isFirstStep" @click="goPrev">上一步</Button>
<Button
v-if="!isLastStep"
type="primary"
:disabled="!canGoNext"
@click="goNext"
>
下一步
</Button>
<Button v-else type="primary" :disabled="!canGoNext" @click="confirmAll">
确认
</Button>
</div>
</Modal>
</template>

View File

@@ -79,6 +79,26 @@ export async function expressDetailByOrderId(data: Record<string, any>) {
return requestClient.post<any>(`express-detail/detail-by-order`, data);
}
/**
* 超管:将历史订单级运单同步到分包裹 shipment
*/
export async function syncLegacyShipmentApi(orderId: number) {
return requestClient.post<any>(`${prefix}sync-legacy-shipment`, {
order_id: orderId,
});
}
/**
* 订单页改仓:将 from 仓名下未出库明细切到 to 仓0=萧康医药本仓库)
*/
export async function changeDeliveryWarehouseApi(data: {
order_id: number;
from_warehouse_id: number;
to_warehouse_id: number;
}) {
return requestClient.post<any>(`${prefix}change-delivery-warehouse`, data);
}
/**
* 导出订单数据(后端 Excel旧接口保留
*/

View File

@@ -2,8 +2,9 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Card, Descriptions, Image, Space, Tag, Timeline } from 'ant-design-vue';
import { Button, Card, Descriptions, Image, Space, Tabs, Tag, message } from 'ant-design-vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
@@ -11,15 +12,16 @@ import OrderPricePercentAdjustDrawer from '#/views/business/order/components/Ord
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust';
import { expressDetailByOrderId, getOrderInfo } from '../api';
import { expressDetailByOrderId, getOrderInfo, syncLegacyShipmentApi } from '../api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import LogisticsModal from './logistics-modal.vue';
defineOptions({
name: 'DetailModal',
});
const userStore = useUserStore();
const gridApi = ref();
// 订单信息
const data = ref();
@@ -27,6 +29,20 @@ const data = ref();
const expressDetail = ref({});
// 订单发货方式
const deliveryMethod = ref(-1);
const syncingLegacy = ref(false);
/**
* 超管可见:上门快递且订单有运单时,可手动把历史运单同步到 shipment
* 与后端一致:仅 role_id=1SUPPER_ADMIN
*/
const canSyncLegacyShipment = computed(() => {
const roleId = Number(
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
);
if (roleId !== 1) return false;
if (Number(data.value?.delivery_method) !== 0) return false;
return Number(data.value?.express_no_id) > 0;
});
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
connectedComponent: OrderTraceDrawer,
@@ -36,6 +52,11 @@ const [PercentAdjustDrawer, percentAdjustDrawerApi] = useVbenDrawer({
connectedComponent: OrderPricePercentAdjustDrawer,
});
/** 详情内「查看物流动态」:独立弹窗只拉物流接口 */
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
const priceAdjustMeta = ref({
scope: 'sale_only' as 'both' | 'sale_only',
quickOptions: [] as QuickDiscountOption[],
@@ -114,7 +135,10 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, id } = modalApi.getData<Record<string, any>>();
const modalData = modalApi.getData<Record<string, any>>() || {};
const { values, id, onShipPackage: shipFn } = modalData;
// 列表传入:按 warehouse_id 打开发货弹窗(平台包=0
onShipPackage.value = typeof shipFn === 'function' ? shipFn : null;
if (id) {
getOrderInfo(id).then((res) => {
data.value = res;
@@ -127,6 +151,8 @@ const [Modal, modalApi] = useVbenModal({
deliveryMethod.value = data.value.delivery_method;
getExpressDetail();
}
} else {
onShipPackage.value = null;
}
},
});
@@ -138,26 +164,53 @@ async function getExpressDetail() {
}
/**
* 获取物流信息标签颜色
* @param status
* 多包裹:优先物流接口 packages门店角色已按开关抹仓名
* 避免订单详情未抹名数据盖过物流结果。
*/
function getColor(status: any) {
switch (status) {
case '在途': {
return '';
}
case '揽收': {
return 'orange';
}
case '派件': {
return 'blue';
}
case '签收': {
return 'green';
}
default: {
return '';
}
const packageTabs = computed(() => {
const fromExpress = Array.isArray((expressDetail.value as any)?.packages)
? (expressDetail.value as any).packages
: [];
if (fromExpress.length) return fromExpress;
const fromOrder = Array.isArray(data.value?.packages) ? data.value.packages : [];
return fromOrder;
});
const activePkg = ref('0');
/** 详情内「发本包」回调(由列表传入时仅平台包裹可发) */
const onShipPackage = ref<null | ((warehouseId: number) => void)>(null);
/**
* Tab 文案:有仓名才拼括号;空串不兜底「平台/仓」
*/
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
const no = pkg.package_no || idx + 1;
const name = String(pkg.warehouse_name || '').trim();
return name ? `包裹${no}${name}` : `包裹${no}`;
}
/**
* 打开独立物流动态弹窗(完整轨迹)
*/
function openLogisticsModal() {
if (!data.value?.id) return;
logisticsModalApi.setData({ order_id: data.value.id });
logisticsModalApi.open();
}
/**
* 超管手动同步历史运单到分包裹表,成功后刷新详情与物流
*/
async function syncLegacyShipment() {
if (!data.value?.id || syncingLegacy.value) return;
syncingLegacy.value = true;
try {
const res = await syncLegacyShipmentApi(Number(data.value.id));
message.success(res?.message || '同步成功');
await reloadOrder();
deliveryMethod.value = data.value?.delivery_method;
await getExpressDetail();
} finally {
syncingLegacy.value = false;
}
}
@@ -428,45 +481,94 @@ function prescriptionStatusColor() {
</Descriptions.Item>
</Descriptions>
</div>
<div v-if="deliveryMethod === 0">
<div class="mt-4">
<h3>物流信息</h3>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
class="mt-4"
>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ expressDetail.express_no }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt }}
</Descriptions.Item>
</Descriptions>
</div>
<div class="mt-4">
<h3>物流追踪</h3>
<Timeline class="mt-4">
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"
:key="index"
<div v-if="deliveryMethod === 0" class="mt-4">
<div class="flex items-center justify-between">
<h3 class="mb-0">物流信息</h3>
<Space>
<Button
v-if="canSyncLegacyShipment"
type="link"
size="small"
:loading="syncingLegacy"
@click="syncLegacyShipment"
>
<Tag :color="getColor(detail.status)">
{{ detail.status }}
</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
同步物流包裹
</Button>
<Button type="link" size="small" @click="openLogisticsModal">
查看物流动态
</Button>
</Space>
</div>
<!-- 详情内仅保留包裹发货状态完整轨迹进独立物流动态弹窗 -->
<Tabs
v-if="packageTabs.length"
v-model:active-key="activePkg"
type="card"
class="mt-4"
>
<Tabs.TabPane
v-for="(pkg, idx) in packageTabs"
:key="String(idx)"
:tab="packageTabLabel(pkg, idx)"
>
<div
class="mb-3 flex justify-end"
v-if="Number(pkg.is_send) === 0 && Number(pkg.warehouse_id) === 0 && onShipPackage"
>
<Button
type="primary"
size="small"
@click="onShipPackage(Number(pkg.warehouse_id) || 0)"
>
发本包裹
</Button>
</div>
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ pkg.express.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ pkg.express.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
</template>
<div v-else class="text-gray-400 py-2">
本包裹尚未发货
<span v-if="Array.isArray(pkg.items) && pkg.items.length">
{{ pkg.items.map((p: any) => p.drug_name || p.name).filter(Boolean).join('、') }}
</span>
</div>
</Tabs.TabPane>
</Tabs>
<Descriptions
v-else
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
class="mt-4"
>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ expressDetail.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
</div>
</div>
</Modal>
<TraceDrawer />
<PercentAdjustDrawer />
<LogisticsModalComp />
</template>
<style scoped>
@@ -494,6 +596,14 @@ function prescriptionStatusColor() {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.mb-0 {
margin-bottom: 0;
}
.space-x-4 > * + * {
margin-left: 1rem;
}

View File

@@ -0,0 +1,196 @@
<script lang="ts" setup>
/**
* 独立「物流动态」弹窗:只请求 express-detail不展示整单其它信息。
* 仓名为空时 Tab 仅显示「包裹N」与后端诊所开关抹名约定一致。
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Spin, Tabs, Tag, Timeline } from 'ant-design-vue';
import { expressDetailByOrderId } from '../api';
defineOptions({
name: 'ProductOrderLogisticsModal',
});
const loading = ref(false);
const expressDetail = ref<Record<string, any>>({});
const activePkg = ref('0');
const orderId = ref<number | null>(null);
/**
* 多包裹列表:以物流接口 packages 为准(门店角色已按开关抹仓名)
*/
const packageTabs = computed(() => {
const pkgs = expressDetail.value?.packages;
return Array.isArray(pkgs) ? pkgs : [];
});
/**
* Tab 文案:有仓名才拼括号,空串不兜底「平台/仓」
*/
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
const no = pkg.package_no || idx + 1;
const name = String(pkg.warehouse_name || '').trim();
return name ? `包裹${no}${name}` : `包裹${no}`;
}
/**
* 物流状态 Tag 颜色
*/
function getColor(status: any) {
switch (status) {
case '在途': {
return '';
}
case '揽收': {
return 'orange';
}
case '派件': {
return 'blue';
}
case '签收': {
return 'green';
}
default: {
return '';
}
}
}
async function loadExpress(id: number) {
loading.value = true;
try {
expressDetail.value = (await expressDetailByOrderId({ order_id: id })) || {};
activePkg.value = '0';
} finally {
loading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const payload = modalApi.getData<Record<string, any>>() || {};
const id = Number(payload.order_id || payload.id || 0);
orderId.value = id || null;
expressDetail.value = {};
if (id) {
loadExpress(id);
}
} else {
orderId.value = null;
expressDetail.value = {};
activePkg.value = '0';
}
},
});
</script>
<template>
<Modal class="w-[640px]" title="物流动态">
<Spin :spinning="loading">
<div v-if="!orderId" class="text-gray-400 py-6 text-center">
缺少订单信息
</div>
<template v-else>
<Tabs
v-if="packageTabs.length"
v-model:active-key="activePkg"
type="card"
>
<Tabs.TabPane
v-for="(pkg, idx) in packageTabs"
:key="String(idx)"
:tab="packageTabLabel(pkg, idx)"
>
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ pkg.express.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ pkg.express.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(pkg.express.detail) && pkg.express.detail.length"
>
<Timeline.Item
v-for="(detail, dIdx) in pkg.express.detail"
:key="dIdx"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
</div>
</template>
<div v-else class="text-gray-400 py-2">本包裹尚未发货</div>
</Tabs.TabPane>
</Tabs>
<template v-else>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ expressDetail.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length"
>
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"
:key="index"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
</div>
</template>
</template>
</Spin>
</Modal>
</template>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
</style>

View File

@@ -27,9 +27,16 @@ const [Modal, modalApi] = useVbenModal({
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 仅提交合法非负整数 warehouse_id脏值不传由后端按角色默认平台包 0
const wid = Number(values.warehouse_id);
const payload: Record<string, any> = { ...values };
if (Number.isFinite(wid) && wid >= 0 && String(values.warehouse_id) !== '[object Object]') {
payload.warehouse_id = wid;
} else {
delete payload.warehouse_id;
}
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = sendOrder;
submitApi(values)
sendOrder(payload)
.then(() => {
message.success('发货成功');
gridApi.value?.reload();
@@ -45,7 +52,9 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
const { values, update } = modalApi.getData<Record<string, any>>() || {};
// 先重置,避免上次发货残留的 warehouse_id
formApi.resetForm();
if (values) {
orderNo.value = values.order_no;
isUpdate.value = update;

View File

@@ -22,6 +22,17 @@ export const modalFormProps: VbenFormProps = {
triggerFields: ['oreder_id'],
},
},
{
// 分包裹发货0=平台包;>0=配送仓(默认 0
component: 'VbenInput',
fieldName: 'warehouse_id',
label: '发货方',
defaultValue: 0,
dependencies: {
show: false,
triggerFields: ['warehouse_id'],
},
},
{
component: 'VbenInput',
componentProps: {

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -15,7 +15,18 @@ import {
import { useUserStore } from '@vben/stores';
import { SvgCakeIcon } from '@vben/icons';
import { Button, Image, message, Modal as AntdModal, Popconfirm, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
import {
Button,
Image,
message,
Modal as AntdModal,
Popconfirm,
Popover,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
@@ -24,12 +35,14 @@ import { TableAction } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
cancelOrderApi,
changeDeliveryWarehouseApi,
getOrderInfo,
getOrderList,
getVerifyRecentOrderAmounts,
saleAmountApi,
updateFreeShipping,
} from '#/views/business/order/product-order/api';
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
import ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
@@ -42,6 +55,7 @@ import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vu
import DetailModal from './components/detail.vue';
import OrderUserInfoCell from './components/cells/OrderUserInfoCell.vue';
import LogisticsModal from './components/logistics-modal.vue';
import FormModalDemo from './components/modal.vue';
import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
import Refund from './components/refund.vue';
@@ -167,6 +181,10 @@ const [FormModal, formModalApi] = useVbenModal({
const [Modal, modalApi] = useVbenModal({
connectedComponent: DetailModal,
});
/** 独立物流动态弹窗:只拉 express-detail与订单详情解耦 */
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund,
});
@@ -175,22 +193,51 @@ const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ProductOrderExportModal,
});
const infoModal = (data = {}) => {
const infoModal = (data: Record<string, any> = {}) => {
modalApi.setData({
// 表单值
// 带 id 走详情接口拿到 packagesvalues 作首屏兜底
id: data?.id,
values: data,
gridApi,
// 详情内「发本包裹」:关详情后打开发货弹窗(仅平台包会传 0
onShipPackage: (warehouseId: number) => {
modalApi.close();
wareSend(data, warehouseId);
},
});
modalApi.open();
};
const wareSend = (data = {}) => {
/**
* 打开物流动态:仅传 order_id弹窗内单独请求物流接口
*/
const openLogisticsModal = (data: Record<string, any> = {}) => {
logisticsModalApi.setData({
order_id: data?.id,
});
logisticsModalApi.open();
};
/**
* 打开发货弹窗。
* 列表不传 warehouse_id后端平台默认 0详情按包发时仅传合法非负整数。
* 注意:勿用 bind(null,row)table-action 的 click 会把事件当成第 2 参污染 warehouse_id。
*/
const wareSend = (data: Record<string, any> = {}, warehouseId?: number) => {
const values: Record<string, any> = {
order_id: data?.id,
order_no: data?.order_no,
};
if (
warehouseId !== undefined &&
warehouseId !== null &&
Number.isFinite(Number(warehouseId)) &&
Number(warehouseId) >= 0
) {
values.warehouse_id = Number(warehouseId);
}
formModalApi.setData({
// 表单值
values: {
order_id: data?.id,
order_no: data?.order_no,
},
values,
gridApi,
});
formModalApi.open();
@@ -450,6 +497,152 @@ 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 }>
>([]);
/**
* 改仓卡片选项:首项本仓库 + 可选配送仓
* 当前已是本仓时本仓库卡片 disabled
*/
const changeWhCardOptions = computed(() => {
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 [];
}
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));
}
/**
* 点击仓库 Tag打开改仓弹窗首项本仓库 + 配送仓列表)
*/
async function openChangeWarehouse(
row: Record<string, any>,
wh: { id: number; name: string },
) {
changeWhOrderId.value = Number(row.id ?? 0);
changeWhFromId.value = Number(wh?.id ?? 0);
changeWhFromName.value = String(wh?.name ?? '');
changeWhToId.value = undefined;
changeWhOptions.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) {
return true;
}
const wid = Number(it.delivery_warehouse_id ?? 0);
return fromId > 0 ? wid === fromId : wid <= 0;
});
const drugIds = [
...new Set(
(scoped.length ? scoped : items)
.map((it: any) => Number(it?.drug_id ?? 0))
.filter((id: number) => id > 0),
),
];
if (drugIds.length === 0) {
return;
}
const res = await getDeliveryWarehouseOptionsByDrugs({ drug_ids: drugIds });
const flattened = flattenWarehouseOptions(res);
// 排除当前仓,避免无意义提交
changeWhOptions.value = flattened.filter(
(o) => o.warehouse_id !== changeWhFromId.value,
);
} catch (error: any) {
message.error(error?.message || '加载配送仓库失败');
} finally {
changeWhLoading.value = false;
}
}
/** 确认改仓 */
async function submitChangeWarehouse() {
if (changeWhToId.value === undefined || changeWhToId.value === null) {
message.warning('请选择目标仓库');
return;
}
if (Number(changeWhToId.value) === Number(changeWhFromId.value)) {
message.warning('目标仓库与当前仓库相同');
return;
}
changeWhSubmitting.value = true;
try {
await changeDeliveryWarehouseApi({
order_id: changeWhOrderId.value,
from_warehouse_id: changeWhFromId.value,
to_warehouse_id: Number(changeWhToId.value),
});
message.success('改仓成功');
changeWhOpen.value = false;
await gridApi.query();
} catch (error: any) {
message.error(error?.message || '改仓失败');
} finally {
changeWhSubmitting.value = false;
}
}
const toggleFreeShipping = async (row: any, checked: boolean) => {
try {
const res = await updateFreeShipping({ id: row.id, is_free_shipping: checked ? 1 : 0 });
@@ -511,6 +704,7 @@ const openOrderAmountVerify = () => {
<Page auto-content-height title="订单管理">
<FormModal />
<Modal />
<LogisticsModalComp />
<AntdModal
v-model:open="verifyOpen"
title="近10分钟订单金额校验"
@@ -643,7 +837,7 @@ const openOrderAmountVerify = () => {
<div class="text-xs text-gray-500">{{ formatExpressAddress(row) }}</div>
</div>
</template>
<!-- 患者配送仓库一单多仓用 Tag 展示 -->
<!-- 患者配送仓库点击 Tag 可改仓本仓库 / 配送仓 -->
<template #delivery-warehouses="{ row }">
<div
v-if="Array.isArray(row.delivery_warehouses) && row.delivery_warehouses.length"
@@ -653,6 +847,8 @@ const openOrderAmountVerify = () => {
v-for="wh in row.delivery_warehouses"
:key="wh.id"
color="processing"
class="cursor-pointer"
@click="openChangeWarehouse(row, wh)"
>
{{ wh.name }}
</Tag>
@@ -974,6 +1170,19 @@ const openOrderAmountVerify = () => {
// auth: ['order', 'sys:role:detail'],
onClick: infoModal.bind(null, row),
},
{
label: '物流动态',
type: 'link',
icon: 'mdi:truck-delivery-outline',
size: 'small',
// 上门快递且已支付、非取消/待支付:可单独查看物流
ifShow:
row.delivery_method === 0 &&
row.is_pay === 1 &&
row.status !== 0 &&
row.status !== 9,
onClick: openLogisticsModal.bind(null, row),
},
{
label: '溯源',
type: 'link',
@@ -1002,11 +1211,7 @@ const openOrderAmountVerify = () => {
type: 'link',
icon: 'ri:send-plane-fill',
auth: ['Super Admin','Admin'],
onClick: wareSend.bind(null, row),
// popConfirm: {
// title: '确定发货吗?',
// confirm: wareSend.bind(null, row),
// },
onClick: () => wareSend(row),
},
]"
:drop-down-actions="[
@@ -1144,6 +1349,54 @@ const openOrderAmountVerify = () => {
</template>
</Grid>
<PercentAdjustDrawer />
<!-- 订单改仓:卡片选择(本仓库 + 配送仓) -->
<AntdModal
v-model:open="changeWhOpen"
title="修改配送仓库"
:confirm-loading="changeWhSubmitting"
destroy-on-close
@ok="submitChangeWarehouse"
>
<div class="text-muted-foreground mb-3 text-sm">
当前仓库:{{ changeWhFromName || '—' }}
</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-if="changeWhOptions.length === 0"
class="text-muted-foreground w-full text-sm"
>
暂无其它可用配送仓库
</div>
</div>
</AntdModal>
</Page>
</template>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
@@ -7,9 +7,18 @@ import { Button } from 'ant-design-vue';
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
const item = ref<Record<string, any>>({});
/** 顶栏诊所名:在线渠道追加(互);无诊所名时兜底空串 */
const clinicTitleName = computed(() =>
formatStoreNameWithHu(
item.value?.store?.name || item.value?.content?.store?.name || '',
item.value?.is_online,
),
);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
@@ -128,7 +137,7 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
<span>处方编号: {{ item.prescription_no }}</span>
<div class="prescription-type">普通处方</div>
</div>
<h2 class="clinic-name">{{ item.content?.patient.name }} 处方笺</h2>
<h2 class="clinic-name">{{ clinicTitleName || '诊所' }} 处方笺</h2>
<div class="prescription-date">开具日期: {{ item.created_at }}</div>
</div>

View File

@@ -71,16 +71,22 @@ const setVisible = (value: boolean, instruction = '') => {
previewImage.value = instruction;
};
// 本地存储键名(支持前缀区分不同场景
// 本地存储键名:与 prescription store 一致prefix 已含尾部 `-`,勿再拼 `-`
const storageKey = computed(() => {
const prefix = storagePrefix.value ? `${storagePrefix.value}-` : '';
return `${prefix}prescriptionData_${type.value}_${activePatientId.value}`;
return `${storagePrefix.value}prescriptionData_${type.value}_${activePatientId.value}`;
});
// 获取当前患者的药品数据
/**
* 同步已选药品到 selectList
* chat 模式以 Pinia currentDrugs 为准,避免双横线孤儿缓存回填
*/
const getCurrentDrugs = () => {
try {
// 从localStorage获取数据并解析
if (isChatMode.value) {
const drugs = prescriptionStore.currentDrugs;
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
return;
}
const storedData = localStorage.getItem(storageKey.value);
selectList.value = storedData ? JSON.parse(storedData) : [];
} catch (error) {
@@ -167,6 +173,8 @@ function addProducts(data: any) {
number: 1,
price: data.price,
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug.specification || data.specification || '',
instruction: data.drug.instruction,
type: data.drug.type,
select_number: 1,
@@ -316,7 +324,7 @@ const [Modal, modalApi] = useVbenModal({
// 提示成功
message.success('保存成功');
},
onOpenChange(isOpen: boolean) {
onOpenChange: async (isOpen: boolean) => {
if (isOpen) {
// 获取回调函数
const data = modalApi.getData();
@@ -334,8 +342,15 @@ const [Modal, modalApi] = useVbenModal({
activePatientId.value = activePatient_id;
if (isChat === 'chat') {
isChatMode.value = true;
// 使用轻量级初始化,不获取患者信息
initializeForModal(activePatient_id);
await initializeForModal(
activePatient_id,
prefix || 'onlineConsultation-',
);
// 弹窗类型与 store tab 对齐,避免读到其它分类的已选
prescriptionStore.activeCategory = type.value;
prescriptionStore.loadFromLocalStorage();
const drugs = prescriptionStore.currentDrugs;
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
} else {
isChatMode.value = false;
}

View File

@@ -77,16 +77,22 @@ const setVisible = (value, instruction = '') => {
previewImage.value = instruction;
};
// 本地存储键名(支持前缀区分不同场景
// 本地存储键名:与 prescription store 一致prefix 已含尾部 `-`,勿再拼 `-`
const storageKey = computed(() => {
const prefix = storagePrefix.value ? `${storagePrefix.value}-` : '';
return `${prefix}prescriptionData_${type.value}_${activePatientId.value}`;
return `${storagePrefix.value}prescriptionData_${type.value}_${activePatientId.value}`;
});
// 获取当前患者的药品数据
/**
* 同步已选药品到 selectList
* chat 模式以 Pinia currentDrugs 为准(发送后已清空),避免孤儿 localStorage 回填
*/
const getCurrentDrugs = () => {
try {
// 从localStorage获取数据并解析
if (ChatTypeCheck.value === 'chat') {
const drugs = prescriptionStore.currentDrugs;
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
return;
}
const storedData = localStorage.getItem(storageKey.value);
selectList.value = storedData ? JSON.parse(storedData) : [];
} catch (error) {
@@ -202,6 +208,8 @@ function addProducts(data) {
unit_id: data.drug.unit_id,
// 药品图片
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug.specification || data.specification || '',
// 药品说明书
instruction: data.drug.instruction,
// 药品类型
@@ -376,7 +384,7 @@ const [Modal, modalApi] = useVbenModal({
// 提示成功
message.success('保存成功');
},
onOpenChange(isOpen: boolean) {
onOpenChange: async (isOpen: boolean) => {
if (isOpen) {
// 获取回调函数
const data = modalApi.getData();
@@ -394,8 +402,18 @@ const [Modal, modalApi] = useVbenModal({
activePatientId.value = activePatient_id;
if (isChat === 'chat') {
ChatTypeCheck.value = isChat;
// 使用轻量级初始化,不获取患者信息
initializeForModal(activePatient_id);
// 传入 prefix与 store 读写同一套 key完成后用 store 回填已选
await initializeForModal(
activePatient_id,
prefix || 'onlineConsultation-',
);
// 弹窗类型可能与 initialize 恢复的 tab 不一致,强制对齐后再取已选
prescriptionStore.activeCategory = type.value;
prescriptionStore.loadFromLocalStorage();
const drugs = prescriptionStore.currentDrugs;
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
} else {
ChatTypeCheck.value = '';
}
// 获取药品列表
getDrugListByWesternModal();

View File

@@ -3,7 +3,7 @@ import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, Image, Tag, Timeline } from 'ant-design-vue';
import { Button, Descriptions, Image, Tabs, Tag, Timeline } from 'ant-design-vue';
import {
expressDetailByOrderId,
@@ -21,9 +21,23 @@ const logisticsAnchorRef = ref<HTMLElement | null>(null);
/** 待发货时由列表传入,点击后关详情并打开发货弹窗 */
const onShipFn = ref<null | (() => void)>(null);
const canShip = computed(
() => Number(data.value.status) === 1 && typeof onShipFn.value === 'function',
);
const canShip = computed(() => {
const packages = Array.isArray(data.value.packages) ? data.value.packages : [];
const mineUnsent = packages.some((p: any) => Number(p.is_send) === 0);
const legacy =
Number(data.value.warehouse_is_send ?? 0) === 0 &&
Number(data.value.status) === 1;
return (mineUnsent || legacy) && typeof onShipFn.value === 'function';
});
const packageTabs = computed(() => {
const packages = Array.isArray(data.value.packages)
? data.value.packages
: Array.isArray(expressDetail.value.packages)
? expressDetail.value.packages
: [];
return packages;
});
const activePkg = ref('0');
const statusMap: Record<number, { text: string; color: string }> = {
0: { text: '待支付', color: 'default' },
@@ -219,8 +233,50 @@ const [Modal, modalApi] = useVbenModal({
<section ref="logisticsAnchorRef">
<h3 class="section-title">物流信息</h3>
<Tabs v-if="packageTabs.length" v-model:active-key="activePkg" type="card">
<Tabs.TabPane
v-for="(pkg, idx) in packageTabs"
:key="String(idx)"
:tab="`包裹${pkg.package_no || idx + 1}${pkg.warehouse_name || '本仓'}`"
>
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ pkg.express.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ pkg.express.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
<div
v-if="Array.isArray(pkg.express.detail) && pkg.express.detail.length"
class="mt-4"
>
<Timeline>
<Timeline.Item
v-for="(detail, dIdx) in pkg.express.detail"
:key="dIdx"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
</div>
<div v-else class="empty-tip mt-3">暂无物流轨迹</div>
</template>
<div v-else class="empty-tip">本包裹尚未发货</div>
</Tabs.TabPane>
</Tabs>
<template
v-if="
v-else-if="
Number(data.is_send) === 1 ||
Number(data.status) >= 2 ||
expressNoRow().express_no ||
@@ -249,21 +305,17 @@ const [Modal, modalApi] = useVbenModal({
</Descriptions.Item>
</Descriptions>
<div v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length" class="mt-4">
<h4 class="mb-2 text-sm font-medium">物流追踪</h4>
<Timeline>
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"
:key="index"
>
<Tag :color="getColor(detail.status)">
{{ detail.status }}
</Tag>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
</div>
<div v-else class="empty-tip mt-3">暂无物流轨迹</div>
</template>
<div v-else class="empty-tip">暂无物流</div>
</section>

View File

@@ -120,7 +120,7 @@ function openDetail(row: Record<string, any>, scrollToLogistics = false) {
id: row?.id,
scrollToLogistics,
onShip:
Number(row?.status) === 1
Number(row?.warehouse_is_send ?? 0) === 0 || Number(row?.status) === 1
? () => {
detailModalApi.close();
wareSend(row);
@@ -284,7 +284,7 @@ onMounted(() => {
物流
</Button>
<Button
v-if="Number(row.status) === 1"
v-if="Number(row.warehouse_is_send ?? 0) === 0"
size="small"
type="link"
@click="wareSend(row)"

View File

@@ -31,14 +31,28 @@ const newQuickName = ref('');
const newQuickValue = ref<number | null>(null);
const inputAutoAuditPass = ref(true);
const miniprogramEmptyImage = ref('');
/** 患者端物流是否显示配送仓名(默认否) */
const logisticsShowWhNameUser = ref(false);
/** 诊所端物流是否显示配送仓名(默认是) */
const logisticsShowWhNameClinic = ref(true);
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored === 'price_adjust' || stored === 'input_audit' || stored === 'miniprogram') {
if (
stored === 'price_adjust' ||
stored === 'input_audit' ||
stored === 'miniprogram' ||
stored === 'logistics'
) {
activeKey.value = stored;
}
}
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
if (val === undefined || val === null || val === '') return defaultVal;
return val === '1' || val === 1 || val === true || val === 'true';
}
function handleTabChange(key: string | number) {
activeKey.value = String(key);
localStorage.setItem(TAB_STORAGE_KEY, String(key));
@@ -84,6 +98,12 @@ async function load() {
if (row.config_key === 'miniprogram_empty_image') {
miniprogramEmptyImage.value = String(row.config_value || '');
}
if (row.config_key === 'logistics_show_warehouse_name_user') {
logisticsShowWhNameUser.value = parseBoolConfig(row.config_value, false);
}
if (row.config_key === 'logistics_show_warehouse_name_clinic') {
logisticsShowWhNameClinic.value = parseBoolConfig(row.config_value, true);
}
}
} finally {
loading.value = false;
@@ -125,6 +145,14 @@ async function handleSave() {
config_key: 'miniprogram_empty_image',
config_value: miniprogramEmptyImage.value,
},
{
config_key: 'logistics_show_warehouse_name_user',
config_value: logisticsShowWhNameUser.value ? '1' : '0',
},
{
config_key: 'logistics_show_warehouse_name_clinic',
config_value: logisticsShowWhNameClinic.value ? '1' : '0',
},
]);
message.success('保存成功');
} finally {
@@ -188,6 +216,33 @@ onMounted(() => {
<FormAvatar v-model:value="miniprogramEmptyImage" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="logistics" tab="物流展示">
<div class="py-4 space-y-6">
<div>
<div class="mb-2 font-medium">患者端物流多包裹是否显示配送仓名称</div>
<div class="mb-2 text-sm text-gray-500">
关闭后患者端仅显示包裹1包裹2不展示仓名
</div>
<Switch
v-model:checked="logisticsShowWhNameUser"
checked-children="显示"
un-checked-children="隐藏"
/>
</div>
<div>
<div class="mb-2 font-medium">诊所端物流多包裹是否显示配送仓名称</div>
<div class="mb-2 text-sm text-gray-500">
关闭后诊所/商家小程序订单物流仅显示包裹序号平台后台产品订单不受影响
</div>
<Switch
v-model:checked="logisticsShowWhNameClinic"
checked-children="显示"
un-checked-children="隐藏"
/>
</div>
</div>
</Tabs.TabPane>
</Tabs>
<div class="mt-4">