feat: 运单操作日志 Tab,并完善揽收下单与详情编辑体验
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m37s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-09 17:32:10 +08:00
parent 640562654d
commit 5b6cb43457
12 changed files with 1185 additions and 263 deletions

View File

@ -1181,6 +1181,71 @@ function mapExceptionEventItem(raw: any): OrderExceptionEventItem {
}
}
export interface OrderOperationLogChangeItem {
path: string
label: string
before?: string | null
after?: string | null
}
export interface OrderOperationLogItem {
id: number
orderId: number
serviceNo?: string
action: string
actionName: string
operatorId: number
operatorName?: string
operatorType?: string
source?: string
changes: OrderOperationLogChangeItem[]
remark?: string
createTime?: string
}
export interface OrderOperationLogListResult {
items: OrderOperationLogItem[]
}
function mapOperationLogItem(raw: any): OrderOperationLogItem {
const changesRaw = raw?.changes ?? raw?.Changes ?? []
const changes = Array.isArray(changesRaw)
? changesRaw.map((c: any) => ({
path: String(c?.path ?? c?.Path ?? ''),
label: String(c?.label ?? c?.Label ?? c?.path ?? c?.Path ?? ''),
before: c?.before ?? c?.Before ?? null,
after: c?.after ?? c?.After ?? null,
}))
: []
return {
id: Number(raw?.id ?? raw?.Id ?? 0),
orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0),
serviceNo: raw?.serviceNo ?? raw?.ServiceNo,
action: String(raw?.action ?? raw?.Action ?? ''),
actionName: String(raw?.actionName ?? raw?.ActionName ?? ''),
operatorId: Number(raw?.operatorId ?? raw?.OperatorId ?? 0),
operatorName: raw?.operatorName ?? raw?.OperatorName,
operatorType: raw?.operatorType ?? raw?.OperatorType,
source: raw?.source ?? raw?.Source,
changes,
remark: raw?.remark ?? raw?.Remark,
createTime: raw?.createTime ?? raw?.CreateTime,
}
}
/** GET /order/operation-log/list */
export async function getOrderOperationLogList(
orderId: number | string,
): Promise<OrderOperationLogListResult> {
const res = await axiosInstance.get(orderUrl('/order/operation-log/list'), {
params: { orderId: Number(orderId) },
})
const data = unwrapData<any>(res.data) ?? {}
const itemsRaw = data?.items ?? data?.Items ?? []
const items = Array.isArray(itemsRaw) ? itemsRaw.map(mapOperationLogItem) : []
return { items }
}
/** GET /order/exception-events/list */
export async function getOrderExceptionEventList(orderId: number | string): Promise<OrderExceptionEventListResult> {
const res = await axiosInstance.get(orderUrl('/order/exception-events/list'), {

View File

@ -73,6 +73,25 @@ export const schedulePickupOrder = (body: {
export const reschedulePickupOrder = (body: { id: number | string; expectPickupTime: string }) =>
postOk('/order/pickup-order/reschedule', body as unknown as Record<string, unknown>)
export const updatePickupOrderInfo = (body: {
id: number | string
warehouseId?: number
senderAddressId?: number
expectPickupTime?: string
carrierService?: string
carrierWaybillNo?: string
remark?: string
}) => postOk('/order/pickup-order/update-info', body as unknown as Record<string, unknown>)
export const updatePickupOrderMeasure = (body: {
id: number | string
totalActualWeight: number
packageSizes: Array<{ length: number; width: number; height: number; count: number }>
}) => postOk('/order/pickup-order/update-measure', body as unknown as Record<string, unknown>)
export const recalcPickupOrderFee = (id: number | string) =>
postOk('/order/pickup-order/recalc-fee', { id })
export const markPickupPicked = (id: number | string) =>
postOk('/order/pickup-order/mark-picked', { id })

View File

@ -896,6 +896,26 @@ export default {
'order.pickupOrder.settlementStatus.pending': 'Pending',
'order.pickupOrder.settlementStatus.settled': 'Settled',
'order.pickupOrder.actions.reschedule': 'Reschedule',
'order.pickupOrder.actions.editInfo': 'Edit pickup info',
'order.pickupOrder.actions.saveInfo': 'Save',
'order.pickupOrder.actions.cancelEdit': 'Cancel',
'order.pickupOrder.actions.editMeasure': 'Edit dimensions',
'order.pickupOrder.actions.saveMeasure': 'Save dimensions',
'order.pickupOrder.actions.recalcFee': 'Recalculate fee',
'order.pickupOrder.actions.addSize': 'Add size',
'order.pickupOrder.actions.removeSize': 'Remove',
'order.pickupOrder.form.carrierServicePlaceholder': 'Select carrier service',
'order.pickupOrder.form.sizeLength': 'L',
'order.pickupOrder.form.sizeWidth': 'W',
'order.pickupOrder.form.sizeHeight': 'H',
'order.pickupOrder.form.sizeCount': 'Qty',
'order.pickupOrder.message.infoSaveOk': 'Pickup info saved',
'order.pickupOrder.message.measureSaveOk': 'Dimensions saved',
'order.pickupOrder.message.recalcOk': 'Fee recalculated',
'order.pickupOrder.message.recalcNeedMeasure': 'Save weight and sizes before recalculating',
'order.pickupOrder.message.packageSizeRequired': 'Enter at least one valid size',
'order.pickupOrder.message.expectTimeRequired': 'Enter expected pickup time',
'order.pickupOrder.message.optionsLoadFailed': 'Failed to load options',
'order.pickupOrder.actions.batchConfirmFreight': 'Batch Confirm Freight',
'order.pickupOrder.actions.batchSchedule': 'Batch Draft Forecast',
'order.pickupOrder.actions.copyServiceNos': 'Copy all service Nos.',
@ -1799,7 +1819,7 @@ export default {
'order.myOrders.actions.markProcessed': 'Mark processed',
'order.myOrders.reallocate.modalTitle': 'Reallocate ({count} selected)',
'order.myOrders.reallocate.hint':
'Only recalculates payable and updates the service channel. Receivable and dimensions are unchanged. Paid orders are skipped. Changing channel clears provider service no. / tracking no.; re-forward downstream if needed.',
'Only recalculates payable and updates the service channel. Receivable and dimensions are unchanged. Available for predeclared / pending allocation / payment / ship (including paid). Changing channel clears provider service no. / tracking no.; re-forward downstream if needed.',
'order.myOrders.reallocate.section.scheme': 'Allocation scheme',
'order.myOrders.reallocate.section.channel': 'Designated channel',
'order.myOrders.reallocate.schemeLabel': 'Allocation scheme',
@ -2110,6 +2130,15 @@ export default {
'order.myOrders.drawer.tabs.track': 'Tracking',
'order.myOrders.drawer.tabs.attachments': 'Attachments',
'order.myOrders.drawer.tabs.stockBind': 'Bound stock',
'order.myOrders.drawer.tabs.operationLog': 'Operation log',
'order.myOrders.drawer.operationLog.empty': 'No operation logs',
'order.myOrders.drawer.operationLog.refresh': 'Refresh',
'order.myOrders.drawer.operationLog.count': '{count} items',
'order.myOrders.drawer.operationLog.field': 'Field',
'order.myOrders.drawer.operationLog.before': 'Before',
'order.myOrders.drawer.operationLog.after': 'After',
'order.myOrders.drawer.operationLog.noChanges': 'No field changes',
'order.myOrders.drawer.operationLog.operator': 'Operator',
'order.myOrders.drawer.tabs.issueHandling': 'Issue handling',
'order.myOrders.drawer.issueHandling.pendingCount': 'Pending {count}',
'order.myOrders.drawer.issueHandling.processedCount': 'Processed {count}',

View File

@ -885,6 +885,26 @@ export default {
'order.pickupOrder.settlementStatus.pending': '待结算',
'order.pickupOrder.settlementStatus.settled': '已结算',
'order.pickupOrder.actions.reschedule': '改约',
'order.pickupOrder.actions.editInfo': '编辑揽收信息',
'order.pickupOrder.actions.saveInfo': '保存',
'order.pickupOrder.actions.cancelEdit': '取消',
'order.pickupOrder.actions.editMeasure': '编辑件重尺',
'order.pickupOrder.actions.saveMeasure': '保存件重尺',
'order.pickupOrder.actions.recalcFee': '重算费用',
'order.pickupOrder.actions.addSize': '添加尺寸',
'order.pickupOrder.actions.removeSize': '删除',
'order.pickupOrder.form.carrierServicePlaceholder': '请选择承运服务',
'order.pickupOrder.form.sizeLength': '长',
'order.pickupOrder.form.sizeWidth': '宽',
'order.pickupOrder.form.sizeHeight': '高',
'order.pickupOrder.form.sizeCount': '件数',
'order.pickupOrder.message.infoSaveOk': '揽收信息已保存',
'order.pickupOrder.message.measureSaveOk': '件重尺已保存',
'order.pickupOrder.message.recalcOk': '费用已重算',
'order.pickupOrder.message.recalcNeedMeasure': '请先保存实重与尺寸后再重算',
'order.pickupOrder.message.packageSizeRequired': '请至少填写一条有效尺寸',
'order.pickupOrder.message.expectTimeRequired': '请填写期望揽收时间',
'order.pickupOrder.message.optionsLoadFailed': '加载选项失败',
'order.pickupOrder.actions.batchConfirmFreight': '批量确认运费',
'order.pickupOrder.actions.batchSchedule': '批量草稿预报',
'order.pickupOrder.actions.copyServiceNos': '复制全部运单号',
@ -1626,7 +1646,7 @@ export default {
'order.waybillManage.importDownstream.failRow': '第 {row} 行({waybill}):{reason}',
'order.myOrders.reallocate.modalTitle': '重新排货(已选 {count} 单)',
'order.myOrders.reallocate.hint':
'仅重算应付并更新服务渠道,不改应收与件重尺;已付款订单会自动跳过。换渠道会清空服务商单号/跟踪号,需自行重新预报下游。',
'仅重算应付并更新服务渠道,不改应收与件重尺;已预报/待排货/待支付/待发货可用(含已付款)。换渠道会清空服务商单号/跟踪号,需自行重新预报下游。',
'order.myOrders.reallocate.section.scheme': '排货方案',
'order.myOrders.reallocate.section.channel': '指定渠道',
'order.myOrders.reallocate.schemeLabel': '排货方案',
@ -1971,6 +1991,15 @@ export default {
'order.myOrders.drawer.tabs.track': '轨迹',
'order.myOrders.drawer.tabs.attachments': '附件',
'order.myOrders.drawer.tabs.stockBind': '绑定包裹/库存',
'order.myOrders.drawer.tabs.operationLog': '操作日志',
'order.myOrders.drawer.operationLog.empty': '暂无操作日志',
'order.myOrders.drawer.operationLog.refresh': '刷新',
'order.myOrders.drawer.operationLog.count': '{count} 条',
'order.myOrders.drawer.operationLog.field': '字段',
'order.myOrders.drawer.operationLog.before': '修改前',
'order.myOrders.drawer.operationLog.after': '修改后',
'order.myOrders.drawer.operationLog.noChanges': '无字段变更',
'order.myOrders.drawer.operationLog.operator': '操作人',
'order.myOrders.drawer.tabs.issueHandling': '问题处理',
'order.myOrders.drawer.issueHandling.pendingCount': '待处理 {count}',
'order.myOrders.drawer.issueHandling.processedCount': '已处理 {count}',

View File

@ -38,11 +38,10 @@
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('order.myOrders.createPickup.carrierService')">
<a-form-item :label="t('order.myOrders.createPickup.carrierService')" required>
<a-select
v-model="form.carrierService"
allow-search
allow-clear
:loading="loadingRefs"
:placeholder="t('order.myOrders.createPickup.carrierServicePh')"
>
@ -178,7 +177,7 @@ async function open(rows: Array<{ id: number | string; serviceNo?: string; wareh
}))
form.warehouseId = whIds[0]
form.expectPickupTime = defaultPickupTime()
form.carrierService = ''
form.carrierService = carrierServiceOptions.value[0]?.value || ''
form.remark = ''
form.senderAddressId = undefined
@ -200,6 +199,10 @@ async function handleOk() {
$baseMessage(t('order.myOrders.createPickup.needTime'), 'warning')
return
}
if (!form.carrierService?.trim()) {
$baseMessage(t('order.pickupOrder.message.carrierServiceRequired'), 'warning')
return
}
submitting.value = true
try {
@ -216,7 +219,7 @@ async function handleOk() {
senderAddressId: form.senderAddressId,
direction: 'forward',
warehouseId: form.warehouseId,
carrierService: form.carrierService || undefined,
carrierService: form.carrierService.trim(),
expectPickupTime: form.expectPickupTime,
orderIds: orderIds.value,
remark: form.remark || undefined,

View File

@ -17,7 +17,7 @@
</div>
<div
v-if="activeTab === 'basic' || activeTab === 'track' || activeTab === 'stockBind'"
v-if="activeTab === 'basic' || activeTab === 'track' || activeTab === 'stockBind' || activeTab === 'operationLog'"
class="drawer-tab-toolbar"
>
<div class="toolbar-left">
@ -65,6 +65,17 @@
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'operationLog'">
<a-tag v-if="operationLogList.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.operationLog.count', { count: operationLogList.length }) }}
</a-tag>
<a-button
class="app-btn-search"
:loading="operationLogLoading"
@click="loadOperationLogs"
>{{ $t('order.myOrders.drawer.operationLog.refresh') }}
</a-button>
</template>
</div>
</div>
@ -566,6 +577,63 @@
hide-refresh
/>
</div>
<!-- 操作日志 -->
<div v-if="activeTab === 'operationLog'" class="tab-panel operation-log-info">
<a-spin :loading="operationLogLoading" class="operation-log-spin">
<div v-if="operationLogList.length" class="operation-log-list">
<div
v-for="item in operationLogList"
:key="item.id"
class="operation-log-item"
>
<div class="operation-log-head" @click="toggleOperationLogExpand(item.id)">
<div class="operation-log-head-main">
<span class="operation-log-action">{{ item.actionName || item.action }}</span>
<a-tag v-if="item.changes?.length" size="small" color="orangered">
{{ item.changes.length }}
</a-tag>
</div>
<div class="operation-log-head-meta">
<span>{{ item.operatorName || $t('order.myOrders.drawer.operationLog.operator') }}</span>
<span>{{ formatOperationLogTime(item.createTime) }}</span>
<IconDown v-if="!isOperationLogExpanded(item.id)" />
<IconUp v-else />
</div>
</div>
<div v-if="isOperationLogExpanded(item.id)" class="operation-log-body">
<p v-if="item.remark" class="operation-log-remark">{{ item.remark }}</p>
<div v-if="item.changes?.length" class="operation-log-changes">
<div class="operation-log-changes-header">
<span>{{ $t('order.myOrders.drawer.operationLog.field') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.before') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.after') }}</span>
</div>
<div
v-for="(change, idx) in item.changes"
:key="`${item.id}-${change.path}-${idx}`"
class="operation-log-changes-row"
>
<span>{{ change.label || change.path }}</span>
<span>{{ change.before ?? '--' }}</span>
<span class="operation-log-after">{{ change.after ?? '--' }}</span>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.operationLog.noChanges')"
:image-style="{ height: '40px' }"
/>
</div>
</div>
</div>
<a-empty
v-else-if="!operationLogLoading"
:description="$t('order.myOrders.drawer.operationLog.empty')"
:image-style="{ height: '60px' }"
/>
</a-spin>
</div>
</div>
</div>
</DrawerPro>
@ -608,6 +676,8 @@ import {
postOrderWeightUpdate,
postOrderRecalculateReceivable,
postOrderRecalculatePayable,
getOrderOperationLogList,
type OrderOperationLogItem,
} from '@/api/order';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getOrderStatusMeta, getOrderStatusTagStyle, resolveOrderStatusCode } from '@/constants/orderStatus';
@ -983,8 +1053,13 @@ const tabList = computed(() => [
{ key: 'track', label: t('order.myOrders.drawer.tabs.track') },
{ key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') },
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
{ key: 'operationLog', label: t('order.myOrders.drawer.tabs.operationLog') },
]);
const operationLogList = ref<OrderOperationLogItem[]>([]);
const operationLogLoading = ref(false);
const operationLogExpandedIds = ref<Set<number>>(new Set());
const slidingTabItems = computed(() =>
tabList.value.map((tab) => ({ key: tab.key, label: tab.label })),
);
@ -1987,6 +2062,45 @@ async function handleRecalculatePayable() {
}
}
async function loadOperationLogs() {
const id = orderId.value;
if (!id) {
operationLogList.value = [];
return;
}
operationLogLoading.value = true;
try {
const res = await getOrderOperationLogList(id);
operationLogList.value = res.items || [];
operationLogExpandedIds.value = new Set(
operationLogList.value.slice(0, 3).map((x) => x.id),
);
} catch {
operationLogList.value = [];
} finally {
operationLogLoading.value = false;
}
}
function isOperationLogExpanded(id: number) {
return operationLogExpandedIds.value.has(id);
}
function toggleOperationLogExpand(id: number) {
const next = new Set(operationLogExpandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
operationLogExpandedIds.value = next;
}
function formatOperationLogTime(value?: string) {
if (!value) return '--';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function handleTabChange(tabKey: string) {
const next = resolveDrawerTab(tabKey);
if (next !== tabKey) {
@ -2003,6 +2117,9 @@ function handleTabChange(tabKey: string) {
weightViewMode.value = 'all';
void ensureFeeItemDictLoaded();
}
if (next === 'operationLog') {
void loadOperationLogs();
}
}
function handleCancel() {
@ -2024,13 +2141,16 @@ watch(
if (activeTab.value === 'weightFee') {
void ensureFeeItemDictLoaded();
}
if (activeTab.value === 'operationLog') {
void loadOperationLogs();
}
}
},
{ immediate: true, deep: true }
);
defineExpose({
openDrawer: (opts?: { tab?: 'basic' | 'weightFee' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => {
openDrawer: (opts?: { tab?: 'basic' | 'weightFee' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' | 'operationLog' }) => {
const tab = resolveDrawerTab(opts?.tab);
activeTab.value = tab;
basicViewMode.value = 'all';
@ -3117,4 +3237,102 @@ defineExpose({
padding-bottom: 0;
}
}
.operation-log-spin {
width: 100%;
}
.operation-log-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.operation-log-item {
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.operation-log-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
user-select: none;
&:hover {
background: var(--color-fill-1);
}
}
.operation-log-head-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.operation-log-action {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.operation-log-head-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
}
.operation-log-body {
padding: 0 12px 12px;
border-top: 1px solid var(--color-border-2);
}
.operation-log-remark {
margin: 10px 0 0;
font-size: 12px;
color: var(--color-text-3);
word-break: break-word;
}
.operation-log-changes {
margin-top: 10px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
}
.operation-log-changes-header,
.operation-log-changes-row {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.operation-log-changes-header {
background: var(--color-fill-2);
color: var(--color-text-3);
font-weight: 500;
}
.operation-log-changes-row {
color: var(--color-text-2);
border-top: 1px solid var(--color-border-2);
word-break: break-all;
}
.operation-log-after {
color: var(--app-primary-color);
font-weight: 500;
}
</style>

View File

@ -1,13 +1,26 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import dayjs from 'dayjs'
import DrawerPro from '@/components/DrawerPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import useDrawer from '@/components/DrawerPro/useDrawer'
import { formatDateTime } from '@/utils/warehouseListMapper'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import { getSenderAddressList } from '@/api/order/senderAddress'
import { getWarehouseList } from '@/api/system/warehouse'
import {
recalcPickupOrderFee,
updatePickupOrderInfo,
updatePickupOrderMeasure,
} from '@/api/order/pickupOrder'
import { loadDomesticPickupProductOptions } from '@/utils/domesticPickupProducts'
defineOptions({ name: 'PickupOrderDetailDrawer' })
type SelectOpt = { value: number | string; label: string }
type SizeRow = { length?: number; width?: number; height?: number; count?: number }
const props = defineProps<{
loading: boolean
detail: any
@ -25,17 +38,58 @@ const emit = defineEmits<{
close: []
copyServiceNos: []
openOrder: [orderId: number]
updated: [detail: any]
}>()
const { t } = useI18n()
const [register, { openDrawer, closeDrawer, setDrawerProps }] = useDrawer() as any
const detailTab = ref('info')
const infoEditing = ref(false)
const measureEditing = ref(false)
const infoSaving = ref(false)
const measureSaving = ref(false)
const recalcLoading = ref(false)
const optionsLoading = ref(false)
const warehouseOptions = ref<SelectOpt[]>([])
const senderOptions = ref<SelectOpt[]>([])
const carrierServiceOptions = ref<SelectOpt[]>([])
const infoForm = reactive({
warehouseId: undefined as number | undefined,
senderAddressId: undefined as number | undefined,
expectPickupTime: '' as string,
carrierService: '' as string,
carrierWaybillNo: '' as string,
})
const measureForm = reactive({
totalActualWeight: undefined as number | undefined,
packageSizes: [] as SizeRow[],
})
const detailPayStatus = computed(() =>
String(props.detail?.payStatus ?? props.detail?.PayStatus ?? ''),
)
const detailStatus = computed(() => String(props.detail?.status ?? props.detail?.Status ?? ''))
const canEditBase = computed(() => {
const pay = detailPayStatus.value.toLowerCase()
const status = detailStatus.value.toLowerCase()
if (pay === 'paid') return false
if (status === 'cancelled' || status === 'failed') return false
return Boolean(props.detail)
})
const canEditInfo = computed(() => {
if (!canEditBase.value) return false
const status = detailStatus.value.toLowerCase()
return status === 'draft' || status === 'scheduled'
})
const canEditMeasure = computed(() => canEditBase.value)
const orderColumns = computed(() => [
{
title: t('order.pickupOrder.table.serviceNo'),
@ -69,6 +123,7 @@ watch(
() => props.detail?.id ?? props.detail?.Id,
() => {
detailTab.value = 'info'
resetEditState()
},
)
@ -78,6 +133,10 @@ watch(tabItems, (items) => {
}
})
watch(detailTab, () => {
resetEditState()
})
function pick(d: any, a: string, b: string) {
return d?.[a] ?? d?.[b]
}
@ -94,18 +153,257 @@ function fmtMoney(v: unknown) {
return Number.isFinite(n) ? n.toFixed(2) : String(v)
}
function formatPackageSizes(raw: unknown) {
const text = String(raw ?? '').trim()
if (!text) return '-'
return text
.split(';')
function normalizePackageSizes(raw: unknown): SizeRow[] {
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
length: Number(x?.length ?? x?.Length) || undefined,
width: Number(x?.width ?? x?.Width) || undefined,
height: Number(x?.height ?? x?.Height) || undefined,
count: Number(x?.count ?? x?.Count) || 1,
}))
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(/[;;]/)
.map((p) => p.trim())
.filter(Boolean)
.join(';')
.map((p) => {
const m = p.match(/([\d.]+)\s*[x×*]\s*([\d.]+)\s*[x×*]\s*([\d.]+)(?:\s*[x×*]\s*(\d+))?/i)
if (!m) return { length: undefined, width: undefined, height: undefined, count: 1 }
return {
length: Number(m[1]) || undefined,
width: Number(m[2]) || undefined,
height: Number(m[3]) || undefined,
count: Number(m[4]) || 1,
}
})
}
return []
}
function formatPackageSizes(raw: unknown) {
const rows = normalizePackageSizes(raw)
if (!rows.length) {
const text = typeof raw === 'string' ? raw.trim() : ''
return text || '-'
}
return rows
.map((r) => {
const dims = [r.length, r.width, r.height].filter((n) => n != null && Number(n) > 0)
if (dims.length < 3) return ''
const base = `${r.length}×${r.width}×${r.height}`
return r.count && r.count > 1 ? `${base}×${r.count}` : base
})
.filter(Boolean)
.join(';') || '-'
}
function resetEditState() {
infoEditing.value = false
measureEditing.value = false
infoSaving.value = false
measureSaving.value = false
recalcLoading.value = false
}
async function ensureEditOptions() {
if (warehouseOptions.value.length && senderOptions.value.length && carrierServiceOptions.value.length) {
return
}
optionsLoading.value = true
try {
const customerId = Number(pick(props.detail, 'customerId', 'CustomerId') || 0)
const senderBody: Record<string, unknown> = {
page: 1,
pageSize: 200,
addressType: 'pickup',
}
if (props.isAdmin && customerId > 0) {
senderBody.customerId = customerId
}
const [senderRes, warehouseRes, pickupProducts] = await Promise.all([
getSenderAddressList(senderBody),
getWarehouseList({ page: 1, pageSize: 500 }),
loadDomesticPickupProductOptions(),
])
senderOptions.value = (senderRes.items || [])
.map((item: any) => {
const row = normalizeListRow(item)
const id = Number(pickField(row, 'id', 'Id') || 0)
const name = String(
pickField(row, 'addressName', 'AddressName') ||
pickField(row, 'senderName', 'SenderName') ||
id,
)
const phone = String(pickField(row, 'phone', 'Phone') || '')
return { value: id, label: phone ? `${name} · ${phone}` : name }
})
.filter((x) => Number(x.value) > 0)
warehouseOptions.value = (warehouseRes.items || [])
.map((item: any) => {
const id = Number(item?.id ?? item?.Id ?? 0)
const name = String(item?.warehouseName ?? item?.WarehouseName ?? id)
return { value: id, label: name }
})
.filter((x) => Number(x.value) > 0)
carrierServiceOptions.value = pickupProducts.map((p) => ({ value: p.value, label: p.label }))
} catch (e: any) {
Message.warning(e?.message || t('order.pickupOrder.message.optionsLoadFailed'))
} finally {
optionsLoading.value = false
}
}
async function startInfoEdit() {
if (!canEditInfo.value || !props.detail) return
await ensureEditOptions()
const d = props.detail
infoForm.warehouseId = Number(pick(d, 'warehouseId', 'WarehouseId') || 0) || undefined
infoForm.senderAddressId = Number(pick(d, 'senderAddressId', 'SenderAddressId') || 0) || undefined
const t0 = pick(d, 'expectPickupTime', 'ExpectPickupTime')
infoForm.expectPickupTime = t0 ? dayjs(t0).format('YYYY-MM-DD HH:mm:ss') : ''
infoForm.carrierService = String(pick(d, 'carrierService', 'CarrierService') || '')
infoForm.carrierWaybillNo = String(pick(d, 'carrierWaybillNo', 'CarrierWaybillNo') || '')
infoEditing.value = true
}
function cancelInfoEdit() {
infoEditing.value = false
}
async function saveInfoEdit() {
if (!props.detail) return
if (!infoForm.warehouseId) {
Message.warning(t('order.pickupOrder.message.warehouseRequired'))
return
}
if (!infoForm.senderAddressId) {
Message.warning(t('order.pickupOrder.message.senderRequired'))
return
}
if (!infoForm.expectPickupTime) {
Message.warning(t('order.pickupOrder.message.expectTimeRequired'))
return
}
infoSaving.value = true
try {
const id = pick(props.detail, 'id', 'Id')
const res = await updatePickupOrderInfo({
id,
warehouseId: infoForm.warehouseId,
senderAddressId: infoForm.senderAddressId,
expectPickupTime: infoForm.expectPickupTime,
carrierService: infoForm.carrierService || undefined,
carrierWaybillNo: infoForm.carrierWaybillNo,
})
infoEditing.value = false
emit('updated', res)
Message.success(t('order.pickupOrder.message.infoSaveOk'))
} catch (e: any) {
Message.error(e?.message || t('order.pickupOrder.message.actionFailed'))
} finally {
infoSaving.value = false
}
}
function startMeasureEdit() {
if (!canEditMeasure.value || !props.detail) return
const d = props.detail
const weight = Number(pick(d, 'totalActualWeight', 'TotalActualWeight'))
measureForm.totalActualWeight = Number.isFinite(weight) && weight > 0 ? weight : undefined
const sizes = normalizePackageSizes(pick(d, 'packageSizes', 'PackageSizes'))
measureForm.packageSizes = sizes.length
? sizes.map((x) => ({ ...x }))
: [{ length: undefined, width: undefined, height: undefined, count: 1 }]
measureEditing.value = true
}
function cancelMeasureEdit() {
measureEditing.value = false
}
function addSizeRow() {
measureForm.packageSizes.push({ length: undefined, width: undefined, height: undefined, count: 1 })
}
function removeSizeRow(index: number) {
if (measureForm.packageSizes.length <= 1) return
measureForm.packageSizes.splice(index, 1)
}
async function saveMeasureEdit() {
if (!props.detail) return
const weight = Number(measureForm.totalActualWeight)
if (!(weight > 0)) {
Message.warning(t('order.pickupOrder.message.weightRequired'))
return
}
const sizes = measureForm.packageSizes
.map((r) => ({
length: Number(r.length) || 0,
width: Number(r.width) || 0,
height: Number(r.height) || 0,
count: Number(r.count) > 0 ? Number(r.count) : 1,
}))
.filter((r) => r.length > 0 && r.width > 0 && r.height > 0)
if (!sizes.length) {
Message.warning(t('order.pickupOrder.message.packageSizeRequired'))
return
}
measureSaving.value = true
try {
const id = pick(props.detail, 'id', 'Id')
const res = await updatePickupOrderMeasure({
id,
totalActualWeight: weight,
packageSizes: sizes,
})
measureEditing.value = false
emit('updated', res)
Message.success(t('order.pickupOrder.message.measureSaveOk'))
} catch (e: any) {
Message.error(e?.message || t('order.pickupOrder.message.actionFailed'))
} finally {
measureSaving.value = false
}
}
async function handleRecalcFee() {
if (!props.detail || !canEditMeasure.value) return
const weight = Number(pick(props.detail, 'totalActualWeight', 'TotalActualWeight'))
const sizes = normalizePackageSizes(pick(props.detail, 'packageSizes', 'PackageSizes'))
const carrier = String(pick(props.detail, 'carrierService', 'CarrierService') || '').trim()
if (!(weight > 0) || !sizes.length) {
Message.warning(t('order.pickupOrder.message.recalcNeedMeasure'))
return
}
if (!carrier) {
Message.warning(t('order.pickupOrder.message.carrierServiceRequired'))
return
}
recalcLoading.value = true
try {
const id = pick(props.detail, 'id', 'Id')
const res = await recalcPickupOrderFee(id)
emit('updated', res)
Message.success(t('order.pickupOrder.message.recalcOk'))
if (props.isAdmin) {
detailTab.value = 'fee'
}
} catch (e: any) {
Message.error(e?.message || t('order.pickupOrder.message.actionFailed'))
} finally {
recalcLoading.value = false
}
}
function open() {
detailTab.value = 'info'
resetEditState()
setDrawerProps({
title: t('order.pickupOrder.detail.title'),
width: 720,
@ -117,6 +415,7 @@ function open() {
}
function close() {
resetEditState()
closeDrawer()
}
@ -157,7 +456,24 @@ defineExpose({ open, close })
<SlidingTabs v-model="detailTab" :items="tabItems" size="mini" class="pk-detail-tabs" />
<div class="pk-detail-body">
<div v-show="detailTab === 'info'" class="pk-detail-grid">
<div v-show="detailTab === 'info'">
<div class="pk-detail-toolbar">
<template v-if="canEditInfo && !infoEditing">
<a-button type="primary" size="mini" @click="startInfoEdit">
{{ t('order.pickupOrder.actions.editInfo') }}
</a-button>
</template>
<template v-else-if="infoEditing">
<a-button size="mini" :disabled="infoSaving" @click="cancelInfoEdit">
{{ t('order.pickupOrder.actions.cancelEdit') }}
</a-button>
<a-button type="primary" size="mini" :loading="infoSaving" @click="saveInfoEdit">
{{ t('order.pickupOrder.actions.saveInfo') }}
</a-button>
</template>
</div>
<div v-if="!infoEditing" class="pk-detail-grid">
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.table.carrierWaybillNo') }}</span>
<b>{{ detail.carrierWaybillNo || detail.CarrierWaybillNo || '-' }}</b>
@ -184,18 +500,112 @@ defineExpose({ open, close })
</div>
</div>
<div v-show="detailTab === 'measure'" class="pk-detail-grid">
<a-spin v-else :loading="optionsLoading">
<a-form layout="vertical" class="pk-edit-form">
<a-form-item :label="t('order.pickupOrder.table.warehouse')" required>
<a-select
v-model="infoForm.warehouseId"
allow-search
:placeholder="t('order.pickupOrder.form.warehousePlaceholder')"
>
<a-option
v-for="opt in warehouseOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.form.senderAddress')" required>
<a-select
v-model="infoForm.senderAddressId"
allow-search
:placeholder="t('order.pickupOrder.form.senderAddressPlaceholder')"
>
<a-option v-for="opt in senderOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.table.expectTime')" required>
<a-date-picker
v-model="infoForm.expectPickupTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.table.carrierService')">
<a-select
v-model="infoForm.carrierService"
allow-search
allow-clear
:placeholder="t('order.pickupOrder.form.carrierServicePlaceholder')"
>
<a-option
v-for="opt in carrierServiceOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.table.carrierWaybillNo')">
<a-input
v-model="infoForm.carrierWaybillNo"
:placeholder="t('order.pickupOrder.form.carrierWaybillNoPh')"
allow-clear
/>
</a-form-item>
</a-form>
</a-spin>
</div>
<div v-show="detailTab === 'measure'">
<div class="pk-detail-toolbar">
<template v-if="canEditMeasure && !measureEditing">
<a-button type="primary" size="mini" @click="startMeasureEdit">
{{ t('order.pickupOrder.actions.editMeasure') }}
</a-button>
<a-button
size="mini"
class="app-btn-primary-soft"
:loading="recalcLoading"
@click="handleRecalcFee"
>
{{ t('order.pickupOrder.actions.recalcFee') }}
</a-button>
</template>
<template v-else-if="measureEditing">
<a-button size="mini" :disabled="measureSaving" @click="cancelMeasureEdit">
{{ t('order.pickupOrder.actions.cancelEdit') }}
</a-button>
<a-button
type="primary"
size="mini"
:loading="measureSaving"
@click="saveMeasureEdit"
>
{{ t('order.pickupOrder.actions.saveMeasure') }}
</a-button>
</template>
</div>
<div v-if="!measureEditing" class="pk-detail-grid">
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.table.actualWeightKg') }}</span>
<b>{{ fmtNum(pick(detail, 'actualWeightKg', 'ActualWeightKg')) }}</b>
<b>{{ fmtNum(pick(detail, 'totalActualWeight', 'TotalActualWeight')) }}</b>
</div>
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.table.chargeableWeight') }}</span>
<b>{{ fmtNum(pick(detail, 'chargeableWeightKg', 'ChargeableWeightKg')) }}</b>
<b>{{ fmtNum(pick(detail, 'chargeableWeight', 'ChargeableWeight')) }}</b>
</div>
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.table.volumetricWeight') }}</span>
<b>{{ fmtNum(pick(detail, 'volumetricWeightKg', 'VolumetricWeightKg')) }}</b>
<b>{{ fmtNum(pick(detail, 'volumetricWeight', 'VolumetricWeight')) }}</b>
</div>
<div class="pk-detail-field pk-detail-grid-span">
<span>{{ t('order.pickupOrder.detail.packageSizes') }}</span>
@ -203,6 +613,69 @@ defineExpose({ open, close })
</div>
</div>
<div v-else class="pk-edit-form">
<a-form layout="vertical">
<a-form-item :label="t('order.pickupOrder.table.actualWeightKg')" required>
<a-input-number
v-model="measureForm.totalActualWeight"
:min="0.001"
:precision="3"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.detail.packageSizes')" required>
<div class="pk-size-list">
<div
v-for="(row, idx) in measureForm.packageSizes"
:key="idx"
class="pk-size-row"
>
<a-input-number
v-model="row.length"
:min="0.1"
:precision="1"
:placeholder="t('order.pickupOrder.form.sizeLength')"
/>
<span class="pk-size-sep">×</span>
<a-input-number
v-model="row.width"
:min="0.1"
:precision="1"
:placeholder="t('order.pickupOrder.form.sizeWidth')"
/>
<span class="pk-size-sep">×</span>
<a-input-number
v-model="row.height"
:min="0.1"
:precision="1"
:placeholder="t('order.pickupOrder.form.sizeHeight')"
/>
<span class="pk-size-sep">×</span>
<a-input-number
v-model="row.count"
:min="1"
:precision="0"
:placeholder="t('order.pickupOrder.form.sizeCount')"
/>
<a-button
type="text"
size="mini"
status="danger"
:disabled="measureForm.packageSizes.length <= 1"
@click="removeSizeRow(idx)"
>
{{ t('order.pickupOrder.actions.removeSize') }}
</a-button>
</div>
<a-button size="mini" @click="addSizeRow">
{{ t('order.pickupOrder.actions.addSize') }}
</a-button>
</div>
</a-form-item>
</a-form>
</div>
</div>
<div v-show="detailTab === 'fee' && isAdmin">
<div class="pk-detail-grid">
<div class="pk-detail-field">
@ -211,7 +684,7 @@ defineExpose({ open, close })
</div>
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.detail.omsPayable') }}</span>
<b>¥ {{ fmtMoney(pick(detail, 'payableAmount', 'PayableAmount')) }}</b>
<b>¥ {{ fmtMoney(pick(detail, 'payableAmount', 'PayableAmount') ?? pick(detail, 'carrierPayableAmount', 'CarrierPayableAmount')) }}</b>
</div>
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.form.freight') }}</span>
@ -232,15 +705,15 @@ defineExpose({ open, close })
<div class="pk-detail-grid">
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.detail.carrierFreightWeight') }}</span>
<b>{{ fmtNum(pick(detail, 'carrierFreightWeightKg', 'CarrierFreightWeightKg')) }}</b>
<b>{{ fmtNum(pick(detail, 'carrierFreightWeight', 'CarrierFreightWeight')) }}</b>
</div>
<div class="pk-detail-field">
<span>{{ t('order.pickupOrder.detail.carrierQuotedFreight') }}</span>
<b>¥ {{ fmtMoney(pick(detail, 'carrierQuotedFreight', 'CarrierQuotedFreight')) }}</b>
<b>¥ {{ fmtMoney(pick(detail, 'carrierQuotedFreightAmount', 'CarrierQuotedFreightAmount')) }}</b>
</div>
<div class="pk-detail-field pk-detail-grid-span">
<span>{{ t('order.pickupOrder.detail.carrierQuotedReceivable') }}</span>
<b>¥ {{ fmtMoney(pick(detail, 'carrierQuotedReceivable', 'CarrierQuotedReceivable')) }}</b>
<b>¥ {{ fmtMoney(pick(detail, 'carrierQuotedReceivableAmount', 'CarrierQuotedReceivableAmount')) }}</b>
</div>
</div>
</div>
@ -312,6 +785,13 @@ defineExpose({ open, close })
min-height: 280px;
}
.pk-detail-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.pk-detail-header {
display: flex;
align-items: flex-start;
@ -443,6 +923,33 @@ defineExpose({ open, close })
grid-column: 1 / -1;
}
.pk-edit-form {
padding: 4px 0;
}
.pk-size-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pk-size-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
:deep(.arco-input-number) {
width: 88px;
}
}
.pk-size-sep {
color: var(--color-text-3);
flex-shrink: 0;
}
.pk-detail-empty {
padding: 24px 0;
text-align: center;

View File

@ -153,12 +153,11 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('order.myOrders.createPickup.carrierService')">
<a-form-item :label="t('order.myOrders.createPickup.carrierService')" required>
<a-select
v-model="bindForm.carrierService"
:options="carrierServiceOptions"
allow-search
allow-clear
:loading="refLoading"
:placeholder="t('order.myOrders.createPickup.carrierServicePh')"
/>
@ -622,7 +621,7 @@ async function loadBindableOrders() {
function resetBindForm() {
bindForm.senderAddressId = senderOptions.value[0]?.value
bindForm.expectPickupTime = defaultPickupTime()
bindForm.carrierService = ''
bindForm.carrierService = carrierServiceOptions.value[0]?.value || ''
bindForm.remark = ''
orderKeyword.value = ''
orderRows.value = []
@ -723,6 +722,10 @@ function validateBind() {
$baseMessage(t('order.pickupOrder.message.pickupTimeRequired'), 'warning')
return false
}
if (!bindForm.carrierService?.trim()) {
$baseMessage(t('order.pickupOrder.message.carrierServiceRequired'), 'warning')
return false
}
return true
}
@ -767,7 +770,7 @@ async function handleCreate() {
senderAddressId: bindForm.senderAddressId!,
direction: createDirection.value,
warehouseId: selectedWarehouseId.value!,
carrierService: bindForm.carrierService || undefined,
carrierService: bindForm.carrierService.trim(),
expectPickupTime: bindForm.expectPickupTime,
orderIds: selectedOrders.value.map((x) => x.id),
remark: bindForm.remark || undefined,

View File

@ -27,7 +27,6 @@ import {
postPickupOrderTrackUpsertBigBag,
removePickupOrderOrders,
reschedulePickupOrder,
schedulePickupOrder,
type PickupBigBagTrackSyncResult,
type PickupBigBagTrackStepResult,
} from '@/api/order/pickupOrder'
@ -115,19 +114,12 @@ const detailLoading = ref(false)
const detail = ref<any>(null)
const detailDrawerRef = ref<{ open: () => void; close: () => void } | null>(null)
const [registerFreightModal, { openModal: openFreightModal, closeModal: closeFreightModal }] = useModal() as any
const [registerScheduleModal, { openModal: openScheduleModal, closeModal: closeScheduleModal }] = useModal() as any
const [registerRescheduleModal, { openModal: openRescheduleModal, closeModal: closeRescheduleModal }] = useModal() as any
const [registerRebindModal, { openModal: openRebindModal, closeModal: closeRebindModal }] = useModal() as any
const [registerBatchFreightModal, { openModal: openBatchFreightModal, closeModal: closeBatchFreightModal }] =
useModal() as any
const freightForm = reactive({ id: 0, freightAmount: undefined as number | undefined, otherFeeAmount: undefined as number | undefined })
const scheduleForm = reactive({
id: 0,
carrierService: '',
carrierWaybillNo: '',
expectPickupTime: '',
})
const rescheduleForm = reactive({
id: 0,
expectPickupTime: '',
@ -148,15 +140,12 @@ const actionLoading = ref(false)
const selectedRows = ref<any[]>([])
const batchPaying = ref(false)
const batchFreighting = ref(false)
const batchScheduling = ref(false)
const batchDownstreamForwarding = ref(false)
const downstreamForwardModalVisible = ref(false)
const downstreamForwardResultText = ref('')
const batchTrackUpserting = ref(false)
const trackUpsertModalVisible = ref(false)
const trackUpsertResultText = ref('')
/** batch schedule uses same schedule modal with multiple ids */
const batchScheduleIds = ref<number[]>([])
const canForwardDownstream = computed(
() =>
@ -187,9 +176,6 @@ const batchFreightCandidates = computed(() =>
return payStatus !== 'paid' && status !== 'cancelled' && status !== 'failed'
}),
)
const batchScheduleCandidates = computed(() =>
selectedRows.value.filter((row) => canShowSchedule(String(row?.status || ''))),
)
const statusTabs = [
{ value: STATUS_TAB_ALL, labelKey: 'common.all' },
@ -249,10 +235,6 @@ function canShowPay(row: { status?: string; payStatus?: string; receivableAmount
return Number.isFinite(amount) && amount > 0
}
function canShowSchedule(status: string) {
return status === 'draft' || status === 'scheduled'
}
function canShowRebind(status: string) {
return status === 'draft' || status === 'scheduled'
}
@ -362,9 +344,6 @@ function actionRenderer(rowData: any) {
if (canShowPay(rowData)) {
parts.push(renderWarehouseActionBtn('pay', t('order.pickupOrder.actions.pay'), 'pay'))
}
if (canShowSchedule(status)) {
parts.push(renderWarehouseActionBtn('schedule', t('order.pickupOrder.actions.draftForecast'), 'publish'))
}
if (canShowRebind(status)) {
parts.push(renderWarehouseActionBtn('rebind', t('order.pickupOrder.actions.rebind'), 'rebind'))
}
@ -458,7 +437,6 @@ function showCreateNextSteps(payload: CreatePickupSuccessPayload) {
const status = String(payload.status || 'draft')
const payStatus = String(payload.payStatus || '')
const amount = Number(payload.receivableAmount)
const canSchedule = canShowSchedule(status)
const canPay =
payStatus !== 'paid' &&
status !== 'cancelled' &&
@ -481,20 +459,6 @@ function showCreateNextSteps(payload: CreatePickupSuccessPayload) {
'div',
{ style: 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end' },
[
canSchedule
? h(
'button',
{
class: 'arco-btn arco-btn-secondary arco-btn-size-medium',
style: 'height:32px',
onClick: () => {
Modal.destroyAll()
if (id) void openScheduleById(id)
},
},
t('order.pickupOrder.createNext.goSchedule'),
)
: null,
canPay
? h(
'button',
@ -836,26 +800,6 @@ async function submitBatchFreight() {
}
}
async function handleBatchSchedule() {
if (!hasPermission('Order.Pickup.Create')) {
$baseMessage(t('order.pickupOrder.message.noPermission'), 'warning')
return
}
if (!batchScheduleCandidates.value.length) {
$baseMessage(t('order.pickupOrder.message.batchScheduleNoCandidates'), 'warning')
return
}
await ensureCarrierServiceOptions()
batchScheduleIds.value = batchScheduleCandidates.value
.map((r) => Number(r.id))
.filter((id) => id > 0)
scheduleForm.id = 0
scheduleForm.carrierService = ''
scheduleForm.carrierWaybillNo = ''
scheduleForm.expectPickupTime = dayjs().add(1, 'day').hour(10).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss')
openScheduleModal(true)
}
function handlePageChange(page: number) {
currentPage.value = page
void loadList()
@ -874,10 +818,6 @@ function handleRowAction(row: any, _rowIndex: number, action?: string) {
void openDetail(row.id)
return
}
if (key === 'schedule') {
void openScheduleById(row.id)
return
}
if (key === 'rebind') {
void openRebind(row.id)
return
@ -918,6 +858,13 @@ async function openDetail(id: number | string) {
}
}
function onDetailUpdated(res: any) {
if (res) {
detail.value = res
}
void loadList()
}
function openConfirmFreight() {
if (!detail.value) return
freightForm.id = Number(detail.value.id ?? detail.value.Id)
@ -1003,93 +950,6 @@ async function doPayById(id: number | string) {
}
}
function fillScheduleForm(raw: any) {
scheduleForm.id = Number(raw.id ?? raw.Id)
scheduleForm.carrierService = String(raw.carrierService ?? raw.CarrierService ?? '')
scheduleForm.carrierWaybillNo = String(raw.carrierWaybillNo ?? raw.CarrierWaybillNo ?? '')
const t0 = raw.expectPickupTime ?? raw.ExpectPickupTime
scheduleForm.expectPickupTime = t0
? dayjs(t0).format('YYYY-MM-DD HH:mm:ss')
: dayjs().add(1, 'day').hour(10).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss')
}
async function openSchedule() {
if (!detail.value) return
batchScheduleIds.value = []
await ensureCarrierServiceOptions()
fillScheduleForm(detail.value)
openScheduleModal(true)
}
async function openScheduleById(id: number | string) {
actionLoading.value = true
batchScheduleIds.value = []
try {
await ensureCarrierServiceOptions()
const raw = await getPickupOrderDetail(id)
fillScheduleForm(raw)
openScheduleModal(true)
} catch (e: any) {
$baseMessage(e?.message || t('order.pickupOrder.message.detailFailed'), 'error')
} finally {
actionLoading.value = false
}
}
async function submitSchedule() {
if (!scheduleForm.carrierService.trim()) {
$baseMessage(t('order.pickupOrder.message.carrierServiceRequired'), 'warning')
return
}
const ids =
batchScheduleIds.value.length > 0
? [...batchScheduleIds.value]
: scheduleForm.id > 0
? [scheduleForm.id]
: []
if (!ids.length) return
actionLoading.value = true
batchScheduling.value = batchScheduleIds.value.length > 0
let success = 0
let fail = 0
try {
for (const id of ids) {
try {
const res = await schedulePickupOrder({
id,
carrierService: scheduleForm.carrierService.trim(),
carrierWaybillNo: scheduleForm.carrierWaybillNo.trim() || undefined,
expectPickupTime: scheduleForm.expectPickupTime || undefined,
})
if (detail.value && Number(detail.value.id ?? detail.value.Id) === id) {
detail.value = res
}
success += 1
} catch {
fail += 1
}
}
closeScheduleModal()
batchScheduleIds.value = []
if (ids.length > 1) {
$baseMessage(
t('order.pickupOrder.message.batchScheduleResult', { success, fail }),
fail > 0 ? 'warning' : 'success',
)
} else if (fail > 0) {
$baseMessage(t('order.pickupOrder.message.actionFailed'), 'error')
} else {
$baseMessage(t('order.pickupOrder.message.scheduleOk'), 'success')
}
selectedRows.value = []
void loadList()
} finally {
actionLoading.value = false
batchScheduling.value = false
}
}
function openReschedule() {
if (!detail.value) return
rescheduleForm.id = Number(detail.value.id ?? detail.value.Id)
@ -1360,12 +1220,6 @@ const detailSettlementText = computed(() => {
return settlementStatusLabel(String(detail.value.settlementStatus || detail.value.SettlementStatus || 'none'))
})
const scheduleModalTitle = computed(() =>
batchScheduleIds.value.length > 1
? t('order.pickupOrder.actions.batchSchedule')
: t('order.pickupOrder.actions.draftForecast'),
)
watch(
() => route.query.status,
() => {
@ -1544,6 +1398,7 @@ onMounted(() => {
:pay-status-text="payStatusLabel(detailPayStatus)"
@copy-service-nos="copyAllServiceNos"
@open-order="goLinkedOrder"
@updated="onDetailUpdated"
/>
<ModalPro
@ -1585,51 +1440,6 @@ onMounted(() => {
</a-form>
</ModalPro>
<ModalPro
@register="registerScheduleModal"
:title="scheduleModalTitle"
:confirm-loading="actionLoading || batchScheduling"
@ok="submitSchedule"
>
<a-alert
v-if="batchScheduleIds.length > 1"
type="info"
show-icon
style="margin-bottom: 12px"
>
{{ t('order.pickupOrder.message.batchScheduleHint', { count: batchScheduleIds.length }) }}
</a-alert>
<a-form layout="vertical">
<a-form-item :label="t('order.myOrders.createPickup.carrierService')" required>
<a-select
v-model="scheduleForm.carrierService"
allow-search
allow-clear
:placeholder="t('order.myOrders.createPickup.carrierServicePh')"
>
<a-option v-for="opt in carrierServiceOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('order.pickupOrder.table.carrierWaybillNo')">
<a-input
v-model="scheduleForm.carrierWaybillNo"
:placeholder="t('order.pickupOrder.form.carrierWaybillNoPh')"
/>
</a-form-item>
<a-form-item :label="t('order.myOrders.createPickup.expectTime')">
<a-date-picker
v-model="scheduleForm.expectPickupTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
</a-form>
</ModalPro>
<ModalPro
@register="registerRescheduleModal"
:title="t('order.pickupOrder.actions.reschedule')"

View File

@ -4081,6 +4081,27 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
</a-radio-group>
</a-form-item>
<template v-if="singleForm.inboundMethod === 'doorPickup'">
<a-form-item
label="仓库地址"
required
:validate-status="fieldValidateStatus('warehouseId')"
:help="fieldHelp('warehouseId')"
>
<a-select
v-model="singleForm.warehouseId"
allow-search
placeholder="请选择仓库"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleWarehouseOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="揽收地址" required>
<a-select
v-model="singleForm.senderAddressId"

View File

@ -17,7 +17,7 @@
</div>
<div
v-if="activeTab === 'basic' || activeTab === 'weight' || activeTab === 'fee' || activeTab === 'track' || activeTab === 'stockBind'"
v-if="activeTab === 'basic' || activeTab === 'weight' || activeTab === 'fee' || activeTab === 'track' || activeTab === 'stockBind' || activeTab === 'operationLog'"
class="drawer-tab-toolbar"
>
<div class="toolbar-left">
@ -103,6 +103,17 @@
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'operationLog'">
<a-tag v-if="operationLogList.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.operationLog.count', { count: operationLogList.length }) }}
</a-tag>
<a-button
class="app-btn-search"
:loading="operationLogLoading"
@click="loadOperationLogs"
>{{ $t('order.myOrders.drawer.operationLog.refresh') }}
</a-button>
</template>
</div>
</div>
@ -747,6 +758,63 @@
@changed="handleIssueChanged"
/>
</div>
<!-- 操作日志 -->
<div v-if="activeTab === 'operationLog'" class="tab-panel operation-log-info">
<a-spin :loading="operationLogLoading" class="operation-log-spin">
<div v-if="operationLogList.length" class="operation-log-list">
<div
v-for="item in operationLogList"
:key="item.id"
class="operation-log-item"
>
<div class="operation-log-head" @click="toggleOperationLogExpand(item.id)">
<div class="operation-log-head-main">
<span class="operation-log-action">{{ item.actionName || item.action }}</span>
<a-tag v-if="item.changes?.length" size="small" color="orangered">
{{ item.changes.length }}
</a-tag>
</div>
<div class="operation-log-head-meta">
<span>{{ item.operatorName || $t('order.myOrders.drawer.operationLog.operator') }}</span>
<span>{{ formatOperationLogTime(item.createTime) }}</span>
<IconDown v-if="!isOperationLogExpanded(item.id)" />
<IconUp v-else />
</div>
</div>
<div v-if="isOperationLogExpanded(item.id)" class="operation-log-body">
<p v-if="item.remark" class="operation-log-remark">{{ item.remark }}</p>
<div v-if="item.changes?.length" class="operation-log-changes">
<div class="operation-log-changes-header">
<span>{{ $t('order.myOrders.drawer.operationLog.field') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.before') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.after') }}</span>
</div>
<div
v-for="(change, idx) in item.changes"
:key="`${item.id}-${change.path}-${idx}`"
class="operation-log-changes-row"
>
<span>{{ change.label || change.path }}</span>
<span>{{ change.before ?? '--' }}</span>
<span class="operation-log-after">{{ change.after ?? '--' }}</span>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.operationLog.noChanges')"
:image-style="{ height: '40px' }"
/>
</div>
</div>
</div>
<a-empty
v-else-if="!operationLogLoading"
:description="$t('order.myOrders.drawer.operationLog.empty')"
:image-style="{ height: '60px' }"
/>
</a-spin>
</div>
</div>
</div>
</DrawerPro>
@ -813,7 +881,7 @@ import { ref, computed, watch, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconRefresh, IconRobot, IconEye } from '@arco-design/web-vue/es/icon';
import { IconRefresh, IconRobot, IconEye, IconDown, IconUp } from '@arco-design/web-vue/es/icon';
import useDrawer from '@/components/DrawerPro/useDrawer';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import {
@ -822,6 +890,8 @@ import {
postOrderRecalculateReceivable,
postOrderRecalculatePayable,
postOrderAdditionalServicesUpdate,
getOrderOperationLogList,
type OrderOperationLogItem,
} from '@/api/order';
import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll, type DictDto } from '@/api/system/dict';
@ -1197,8 +1267,13 @@ const tabList = computed(() => [
{ key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') },
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
{ key: 'issueHandling', label: t('order.myOrders.drawer.tabs.issueHandling') },
{ key: 'operationLog', label: t('order.myOrders.drawer.tabs.operationLog') },
]);
const operationLogList = ref<OrderOperationLogItem[]>([]);
const operationLogLoading = ref(false);
const operationLogExpandedIds = ref<Set<number>>(new Set());
const slidingTabItems = computed(() =>
tabList.value.map((tab) => ({ key: tab.key, label: tab.label })),
);
@ -2362,6 +2437,45 @@ async function handleRecalculatePayable() {
}
}
async function loadOperationLogs() {
const id = orderId.value;
if (!id) {
operationLogList.value = [];
return;
}
operationLogLoading.value = true;
try {
const res = await getOrderOperationLogList(id);
operationLogList.value = res.items || [];
operationLogExpandedIds.value = new Set(
operationLogList.value.slice(0, 3).map((x) => x.id),
);
} catch {
operationLogList.value = [];
} finally {
operationLogLoading.value = false;
}
}
function isOperationLogExpanded(id: number) {
return operationLogExpandedIds.value.has(id);
}
function toggleOperationLogExpand(id: number) {
const next = new Set(operationLogExpandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
operationLogExpandedIds.value = next;
}
function formatOperationLogTime(value?: string) {
if (!value) return '--';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function handleTabChange(tabKey: string) {
resetEditState();
if (tabKey === 'basic') {
@ -2378,6 +2492,9 @@ function handleTabChange(tabKey: string) {
if (tabKey === 'track') {
loadTrackTimeline();
}
if (tabKey === 'operationLog') {
void loadOperationLogs();
}
}
function handleCancel() {
@ -2400,13 +2517,16 @@ watch(
if (activeTab.value === 'track') {
loadTrackTimeline();
}
if (activeTab.value === 'operationLog') {
void loadOperationLogs();
}
}
},
{ immediate: true, deep: true }
);
defineExpose({
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' | 'issueHandling' }) => {
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' | 'issueHandling' | 'operationLog' }) => {
activeTab.value = opts?.tab || 'basic';
basicViewMode.value = 'all';
feeViewMode.value = 'all';
@ -3515,4 +3635,102 @@ defineExpose({
padding-bottom: 0;
}
}
.operation-log-spin {
width: 100%;
}
.operation-log-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.operation-log-item {
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.operation-log-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
user-select: none;
&:hover {
background: var(--color-fill-1);
}
}
.operation-log-head-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.operation-log-action {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.operation-log-head-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
}
.operation-log-body {
padding: 0 12px 12px;
border-top: 1px solid var(--color-border-2);
}
.operation-log-remark {
margin: 10px 0 0;
font-size: 12px;
color: var(--color-text-3);
word-break: break-word;
}
.operation-log-changes {
margin-top: 10px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
}
.operation-log-changes-header,
.operation-log-changes-row {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.operation-log-changes-header {
background: var(--color-fill-2);
color: var(--color-text-3);
font-weight: 500;
}
.operation-log-changes-row {
color: var(--color-text-2);
border-top: 1px solid var(--color-border-2);
word-break: break-all;
}
.operation-log-after {
color: var(--app-primary-color);
font-weight: 500;
}
</style>

View File

@ -423,7 +423,7 @@
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const showReallocateAction = computed(() =>
isActiveTab('all', 'pendingAllocation', 'pendingPayment'),
isActiveTab('all', 'predeclared', 'pendingAllocation', 'pendingPayment', 'pendingShip'),
);
const problematicModalVisible = ref(false);