front/src/views/order/waybillManage/index.vue
zzh bd97ed76fd feat: ?????????????????/???????
??????????????????????????????? H5/?????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:16:55 +08:00

2202 lines
72 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { IconDown } from '@arco-design/web-vue/es/icon';
import { Message, Modal } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { useI18n } from 'vue-i18n';
import {
postOrderList,
getOrderStatusCounts,
getOrderDetail,
postOrderPreDeclare,
postOrderIntercept,
postOrderCancelIntercept,
postOrderDelete,
postOrderPrint,
postOrderExport,
postOrderTrackUpsertSmallBag,
postOrderDownstreamForwardCreateOne,
postOrderBatchJob,
waitOrderBatchJob,
formatBatchJobTrackUpsertResultText,
formatBatchJobDownstreamForwardResultText,
isOrderBatchQueueDisabledError,
formatBatchJobFailureMessage,
formatBatchJobSummaryMessage,
resolveBatchJobItemRowLabel,
parseBatchJobPrintUrl,
postOrderHandleStatusUpdate,
type OrderTrackUpsertResult,
type OrderDownstreamForwardResult,
} from '@/api/order';
import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
import OrderDetailDrawer from './components/OrderDetailDrawer.vue';
import OrderPayConfirmModal from './components/OrderPayConfirmModal.vue';
import MarkProblematicModal from './components/MarkProblematicModal.vue';
import MarkClaimedModal from './components/MarkClaimedModal.vue';
import ColumnConfigModal from '../components/ColumnConfigModal.vue';
import {
buildHotColumns,
getActiveScheme,
loadColumnConfigState,
mergeDiscoveredColumns,
saveColumnConfigState,
type ColumnScheme,
type OrderListColumnDef,
} from '../myOrders/columnConfig';
import {
WAYBILL_MANAGE_COLUMN_STORAGE_KEY,
WAYBILL_MANAGE_DEFAULT_KEYS,
getWaybillBaseCatalog,
} from './columnConfig';
import OrderPrintModal from '@/components/PrintTemplate/OrderPrintModal.vue';
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import {
ORDER_STATUS_TABS,
ORDER_STATUS_ALL,
resolveOrderStatusCode,
isOrderDeletable,
getOrderStatusMeta,
} from '@/constants/orderStatus';
import {
ORDER_HANDLE_STATUS_ALL,
ORDER_HANDLE_STATUS_LIST,
isOrderHandleSubTabParent,
resolveOrderHandleStatusCode,
getOrderHandleStatusMeta,
type OrderHandleStatusCode,
} from '@/constants/orderHandleStatus';
import { hasPermission } from '@/utils/rbac-permission';
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
import { renderWarehouseActionBtn } from '@/utils/warehouseActionRenderer';
import { buildWaybillContextFromRow, openAskAdvisor } from '@/composables/useAskAdvisor';
defineOptions({
name: 'WaybillManage',
});
const { t } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const {
customerId: adminCustomerId,
customerOptions,
withCustomerId,
} = useWarehouseAdminCustomer();
// 订单详情抽屉
const detailDrawerRef = ref<InstanceType<typeof OrderDetailDrawer>>();
const currentOrder = ref<any>(null);
/** 兼容后端 camelCase / PascalCase */
function pick(raw: Record<string, any> | undefined, ...keys: string[]) {
if (!raw) return undefined;
for (const k of keys) {
const v = raw[k];
if (v !== undefined && v !== null && v !== '') return v;
}
return undefined;
}
/** 列表行与表格列对齐:接口用 serviceNo/clientNo/trackNo,表格用运单号/客户单号/跟踪号 */
function normalizeOrderRow(raw: any) {
const r = raw?.receiver || raw?.Receiver || {};
const s = raw?.sender || raw?.Sender || {};
const wl = raw?.weightList || raw?.WeightList;
const lastW =
Array.isArray(wl) && wl.length ? wl[wl.length - 1] : undefined;
const serviceNo = pick(raw, 'serviceNo', 'ServiceNo', 'waybillNo', 'WaybillNo');
const clientNo = pick(raw, 'clientNo', 'ClientNo', 'customerOrderNo', 'CustomerOrderNo');
const trackNo = pick(raw, 'trackNo', 'TrackNo', 'trackingNo', 'TrackingNo');
const downstreamServiceNo = pick(raw, 'downstreamServiceNo', 'DownstreamServiceNo');
const productCode = pick(raw, 'productCode', 'ProductCode');
const productName = pick(raw, 'productName', 'ProductName');
const channelCode = pick(raw, 'channelCode', 'ChannelCode');
const channelName = pick(raw, 'channelName', 'ChannelName');
const isPaidRaw = pick(raw, 'isPaid', 'IsPaid');
const customerId = pick(raw, 'customerId', 'CustomerId') ?? raw?.customerId;
const customerFromOptions = customerOptions.value.find(
(opt) => Number(opt.value) === Number(customerId),
);
const boolish = (v: any) => v === true || v === 1 || v === '1';
return {
...raw,
// 展平寄件人(便于列配置直接选接口字段)
senderName: pick(s, 'senderName', 'SenderName') ?? '',
senderCompany: pick(s, 'company', 'Company') ?? '',
senderPhone: pick(s, 'phone', 'Phone') ?? '',
senderTelePhone: pick(s, 'telePhone', 'TelePhone') ?? '',
senderEmail: pick(s, 'email', 'Email') ?? '',
senderCountryCode: pick(s, 'countryCode', 'CountryCode') ?? '',
senderProvince: pick(s, 'province', 'Province') ?? '',
senderCity: pick(s, 'city', 'City') ?? '',
senderZipCode: pick(s, 'zipCode', 'ZipCode') ?? '',
senderStreetAddress1: pick(s, 'streetAddress1', 'StreetAddress1') ?? '',
senderStreetAddress2: pick(s, 'streetAddress2', 'StreetAddress2') ?? '',
senderStreetAddress3: pick(s, 'streetAddress3', 'StreetAddress3') ?? '',
senderHouseNumber: pick(s, 'houseNumber', 'HouseNumber') ?? '',
// 展平收件人原始字段
receiverRecipientsName: pick(r, 'recipientsName', 'RecipientsName') ?? '',
receiverCompanyName: pick(r, 'companyName', 'CompanyName') ?? '',
receiverPhone: pick(r, 'phone', 'Phone') ?? '',
receiverTelePhone: pick(r, 'telePhone', 'TelePhone') ?? '',
receiverEmail: pick(r, 'email', 'Email') ?? '',
receiverCountryCode: pick(r, 'countryCode', 'CountryCode') ?? '',
receiverProvince: pick(r, 'province', 'Province') ?? '',
receiverCity: pick(r, 'city', 'City') ?? '',
receiverStreetNumber: pick(r, 'streetNumber', 'StreetNumber') ?? '',
receiverStreetAddress1: pick(r, 'streetAddress1', 'StreetAddress1') ?? '',
receiverStreetAddress2: pick(r, 'streetAddress2', 'StreetAddress2') ?? '',
receiverStreetAddress3: pick(r, 'streetAddress3', 'StreetAddress3') ?? '',
receiverZipCode: pick(r, 'zipCode', 'ZipCode') ?? '',
receiverTaxType: pick(r, 'taxType', 'TaxType') ?? '',
receiverTaxNo: pick(r, 'taxNo', 'TaxNo') ?? '',
receiverCertificateType: pick(r, 'certificateType', 'CertificateType') ?? '',
receiverCertificateNo: pick(r, 'certificateNo', 'CertificateNo') ?? '',
receiverEori: pick(r, 'eori', 'Eori') ?? '',
receiverVat: pick(r, 'vat', 'Vat') ?? '',
waybillNo: serviceNo ?? '',
customerOrderNo: clientNo ?? '',
trackingNo: trackNo ?? '',
downstreamServiceNo: downstreamServiceNo ?? '',
productCode: productCode ?? '',
productName: productName ?? productCode ?? '',
channelCode: channelCode ?? '',
channelName: channelName ?? channelCode ?? '',
isPaid: boolish(isPaidRaw),
isCod: boolish(pick(raw, 'isCod', 'IsCod')),
isIntercept: boolish(pick(raw, 'isIntercept', 'IsIntercept')),
isRemote: boolish(pick(raw, 'isRemote', 'IsRemote')),
consignee: pick(raw, 'consignee', 'Consignee') ?? pick(r, 'recipientsName', 'RecipientsName') ?? '',
consigneePhone: pick(raw, 'consigneePhone', 'ConsigneePhone') ?? pick(r, 'phone', 'Phone') ?? '',
consigneeAddress: pick(raw, 'consigneeAddress', 'ConsigneeAddress') ?? pick(r, 'streetAddress1', 'StreetAddress1') ?? '',
consigneePostcode: pick(raw, 'consigneePostcode', 'ConsigneePostcode') ?? pick(r, 'zipCode', 'ZipCode') ?? '',
consigneeState: pick(raw, 'consigneeState', 'ConsigneeState') ?? pick(r, 'province', 'Province') ?? '',
consigneeCity: pick(raw, 'consigneeCity', 'ConsigneeCity') ?? pick(r, 'city', 'City') ?? '',
consigneeCountry: pick(raw, 'consigneeCountry', 'ConsigneeCountry') ?? pick(r, 'country', 'Country') ?? '',
consigneeCountryName: pick(raw, 'consigneeCountryName', 'ConsigneeCountryName') ?? '',
ioss: pick(raw, 'ioss', 'Ioss', 'IOSS') ?? '',
eori: pick(raw, 'eori', 'Eori', 'EORI') ?? pick(r, 'eori', 'Eori') ?? '',
shippingMethod: productName ?? productCode ?? '',
serviceChannel: channelName ?? channelCode ?? '',
chargeableWeight:
pick(raw, 'chargeableWeight', 'ChargeableWeight') ??
pick(lastW as any, 'chargeableWeight', 'ChargeableWeight') ??
'',
receivableAmount: pick(raw, 'receivableAmount', 'ReceivableAmount') ?? '',
orderStatus: pick(raw, 'orderStatus', 'OrderStatus') ?? raw?.orderStatus,
predeclareFailReason: pick(raw, 'predeclareFailReason', 'PredeclareFailReason') ?? '',
allocationPendingReason: pick(raw, 'allocationPendingReason', 'AllocationPendingReason') ?? '',
status: resolveOrderStatusCode(raw) || pick(raw, 'status', 'Status') || raw?.status,
handleStatus: pick(raw, 'handleStatus', 'HandleStatus') ?? raw?.handleStatus,
handleStatusName: pick(raw, 'handleStatusName', 'HandleStatusName') ?? raw?.handleStatusName,
cargoAttribute: pick(raw, 'cargoAttribute', 'CargoAttribute') ?? raw?.cargoAttribute,
createTime: pick(raw, 'createTime', 'CreateTime') ?? raw?.createTime,
receiveTime: pick(raw, 'receiveTime', 'ReceiveTime') ?? raw?.receiveTime,
shipTime: pick(raw, 'shipTime', 'ShipTime') ?? raw?.shipTime,
payTime: pick(raw, 'payTime', 'PayTime') ?? raw?.payTime,
signTime: pick(raw, 'signTime', 'SignTime') ?? raw?.signTime,
warehouse: pick(raw, 'warehouse', 'Warehouse') ?? raw?.warehouse,
customerId,
customerName:
pick(raw, 'customerName', 'CustomerName') ||
customerFromOptions?.label?.replace(/\s*\([^)]*\)\s*$/, '') ||
'',
customerCode:
pick(raw, 'customerCode', 'CustomerCode') ||
(customerFromOptions?.label?.match(/\(([^)]+)\)\s*$/)?.[1] ?? '') ||
'',
};
}
async function openDetailWithOptionalFetch(
row: any,
opts?: { editBasic?: boolean; tab?: 'basic' | 'track' },
) {
let nextRow = row;
const id = row?.id;
if (id != null && id !== '') {
try {
const detail = await getOrderDetail(id);
nextRow = normalizeOrderRow({ ...row, ...detail });
} catch {
/* 保留列表行数据 */
}
}
currentOrder.value = nextRow;
detailDrawerRef.value?.openDrawer({ tab: opts?.tab || 'basic' });
if (opts?.editBasic) {
await nextTick();
detailDrawerRef.value?.startBasicEdit?.();
}
}
function handleOrderDetailUpdated(row: any) {
currentOrder.value = normalizeOrderRow(row);
const idx = orderList.value.findIndex((it) => Number(it.id) === Number(row?.id));
if (idx >= 0) {
orderList.value[idx] = normalizeOrderRow({ ...orderList.value[idx], ...row });
}
}
const handleOrderDetailClick = (row: number) => {
if (row >= 0 && row < orderList.value.length) {
openDetailWithOptionalFetch(orderList.value[row]);
}
};
// 当前激活的标签页(与后端 OrderStatusCodes 一致;支持 ?status= 从工作台跳入)
const route = useRoute();
const router = useRouter();
const validStatusSet = new Set(ORDER_STATUS_TABS.map((meta) => meta.value));
function resolveStatusFromQuery(): string {
const raw = route.query.status;
const status = Array.isArray(raw) ? raw[0] : raw;
if (typeof status === 'string' && validStatusSet.has(status as any)) {
return status;
}
return ORDER_STATUS_ALL;
}
const activeTab = ref<string>(resolveStatusFromQuery());
const activeHandleTab = ref<OrderHandleStatusCode>(ORDER_HANDLE_STATUS_ALL);
const tabCounts = ref<Record<string, number>>({});
const handleCounts = ref<Record<string, { pending: number; processed: number }>>({});
const showHandleSubTabs = computed(() => isOrderHandleSubTabParent(activeTab.value));
const showExceptionCreateActions = computed(
() => !['problematic', 'claimed', 'deleted'].includes(activeTab.value),
);
const showMarkProcessedAction = computed(
() => showHandleSubTabs.value && activeHandleTab.value === 'pending',
);
function isActiveTab(...keys: string[]) {
return keys.includes(activeTab.value);
}
/** 按当前状态收敛工具栏,避免客户在无关 Tab 看到一排无效按钮 */
const showPayAction = computed(() => isActiveTab('all', 'pendingPayment'));
const showPredeclareAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showDownstreamAction = computed(() =>
isActiveTab(
'all',
'draft',
'predeclared',
'received',
'pendingPayment',
'pendingShip',
'pendingOnline',
),
);
const showPrintAction = computed(() =>
isActiveTab(
'all',
'predeclared',
'received',
'pendingPayment',
'pendingShip',
'pendingOnline',
'shipping',
'deliverySuccess',
'deliveryFailed',
),
);
const showModifyOrderAction = computed(() =>
isActiveTab('all', 'draft', 'predeclared', 'received', 'pendingAllocation', 'pendingPayment', 'pendingShip'),
);
const showInterceptActions = computed(() =>
isActiveTab('all', 'pendingShip', 'pendingOnline', 'shipping'),
);
const showDeleteAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showCreateTrackAction = computed(() =>
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const problematicModalVisible = ref(false);
const claimedModalVisible = ref(false);
const exceptionOrderIds = ref<number[]>([]);
const payConfirmVisible = ref(false);
const payConfirmOrders = ref<Array<{ id: number | string; waybillNo?: string; serviceNo?: string }>>([]);
const handleContextLabel = computed(() => {
if (activeTab.value === 'problematic') {
return t('order.myOrders.handleStatus.context.problematic');
}
if (activeTab.value === 'claimed') {
return t('order.myOrders.handleStatus.context.claimed');
}
return '';
});
const handleSegments = computed(() => {
if (!showHandleSubTabs.value) return [];
const bucket = handleCounts.value[activeTab.value] ?? { pending: 0, processed: 0 };
const total = (tabCounts.value[activeTab.value] ?? 0) || bucket.pending + bucket.processed;
return ORDER_HANDLE_STATUS_LIST.map((meta) => {
let count = total;
if (meta.value === 'pending') count = bucket.pending;
if (meta.value === 'processed') count = bucket.processed;
return {
key: meta.value,
labelKey: meta.labelKey,
count,
tagColor: meta.tagColor,
tagBgColor: meta.tagBgColor,
};
});
});
const tabs = computed(() =>
ORDER_STATUS_TABS.map((meta) => ({
key: meta.value,
labelKey: meta.labelKey,
showCount: meta.showTabCount !== false,
count: tabCounts.value[meta.value] ?? null,
})),
);
async function loadTabCounts() {
try {
const { total, counts, handleCounts: handleCountsRaw } = await getOrderStatusCounts();
const next: Record<string, number> = {
[ORDER_STATUS_ALL]: total,
};
for (const meta of ORDER_STATUS_TABS) {
if (meta.value === ORDER_STATUS_ALL) continue;
next[meta.value] = counts[meta.value] ?? 0;
}
tabCounts.value = next;
handleCounts.value = handleCountsRaw ?? {};
} catch {
/* 角标失败不影响列表 */
}
}
async function refreshListAndTabCounts() {
await Promise.all([loadOrderList(), loadTabCounts()]);
}
// 分页相关数据
const currentPage = ref(1);
const pageSize = ref(30);
const totalCount = ref(0);
/** 列表筛选:单号关键词 + 创建时间 */
const listKeyword = ref('');
const createTimeRange = ref<(Date | string)[] | undefined>();
function buildListFilterParams() {
const keyword = listKeyword.value.trim();
let createTimeStart = '';
let createTimeEnd = '';
if (createTimeRange.value?.length === 2 && createTimeRange.value[0] && createTimeRange.value[1]) {
createTimeStart = dayjs(createTimeRange.value[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss');
createTimeEnd = dayjs(createTimeRange.value[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss');
}
return { keyword, createTimeStart, createTimeEnd };
}
const orderList = ref<any[]>([]);
const listLoading = ref(false);
let listRequestSeq = 0;
function escapeHtml(text: string) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const selectedRowCount = ref(0);
/** 表格选中行(用于批量操作) */
const selectedOrderRows = ref<any[]>([]);
/** 当前正在执行的 toolbar 操作(仅对应按钮显示 loading,避免全部按钮联动) */
const actionLoadingKey = ref<string | null>(null);
function isActionLoading(key: string) {
return actionLoadingKey.value === key;
}
function isActionBusy(exceptKey?: string) {
const current = actionLoadingKey.value;
return current != null && current !== exceptKey;
}
async function withOrderActionLoading<T>(key: string, fn: () => Promise<T>): Promise<T> {
if (actionLoadingKey.value) {
return Promise.reject(new Error('busy'));
}
actionLoadingKey.value = key;
try {
return await fn();
} finally {
if (actionLoadingKey.value === key) {
actionLoadingKey.value = null;
}
}
}
const printConnectionState = useHiprintClientState();
const singlePrintVisible = ref(false);
const batchPrintVisible = ref(false);
const singlePrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]);
const batchPrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]);
const trackUpsertModalVisible = ref(false);
const trackUpsertResultText = ref('');
const downstreamForwardModalVisible = ref(false);
const downstreamForwardResultText = ref('');
function formatTrackUpsertResult(waybillNo: string, result: OrderTrackUpsertResult): string {
const lines = [`【${waybillNo || '-'}】`];
if (result.skipped) {
lines.push(`跳过:${result.skipReason || '未知原因'}`);
return lines.join('\n');
}
lines.push(`HTTP:${result.statusCode ?? '-'}`);
lines.push(`成功:${result.success ? '是' : '否'}`);
if (result.responseBody) {
lines.push(`响应:${result.responseBody}`);
}
if (result.curlCommand) {
lines.push('curl:');
lines.push(result.curlCommand);
} else if (result.requestJson) {
lines.push(`请求:${result.requestJson}`);
}
return lines.join('\n');
}
async function runSelectedOrderBatch(options: {
action: string;
rows: any[];
payload?: Record<string, unknown>;
loadingKey: string;
syncRunner: () => Promise<void>;
successMessage?: string;
onAsyncComplete?: (ctx: {
job: { successCount: number; failCount: number };
items: Array<{ orderId: number; serviceNo?: string; status: string; failReason?: string; resultJson?: string }>;
rows: any[];
}) => void | Promise<void>;
}) {
const orderIds = options.rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({
action: options.action,
orderIds,
payloadJson: options.payload ? JSON.stringify(options.payload) : undefined,
});
const result = await waitOrderBatchJob(job.jobId);
if (result.timedOut) {
Message.warning(formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId }));
await refreshListAndTabCounts();
return;
}
if (options.onAsyncComplete) {
await options.onAsyncComplete({ ...result, rows: options.rows });
} else if (result.job.failCount > 0) {
Message.warning(
formatBatchJobFailureMessage(result.job, result.items, options.rows, (row, item) =>
resolveBatchJobItemRowLabel(row, item)
)
);
} else if (options.successMessage) {
Message.success(options.successMessage);
}
await refreshListAndTabCounts();
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
await options.syncRunner();
}
async function runTrackUpsertBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'track-upsert-small-bag', orderIds });
const result = await waitOrderBatchJob(job.jobId);
if (result.timedOut) {
trackUpsertResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobTrackUpsertResultText('创建轨迹', result.job, result.items, rows)}`;
trackUpsertModalVisible.value = true;
return;
}
const { job: finishedJob, items } = result;
trackUpsertResultText.value = formatBatchJobTrackUpsertResultText('创建轨迹', finishedJob, items, rows);
trackUpsertModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id);
try {
const result = await postOrderTrackUpsertSmallBag({ id });
resultBlocks.push(formatTrackUpsertResult(waybillNo, result));
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
trackUpsertResultText.value = resultBlocks.join('\n\n');
trackUpsertModalVisible.value = true;
}
const handleCreateTrackOrder = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('track', async () => {
await runTrackUpsertBatch(rows);
}).catch(() => undefined);
};
function formatDownstreamForwardResult(
waybillNo: string,
result: OrderDownstreamForwardResult
): string {
const lines = [
`【${waybillNo || '-'}】`,
result.success ? '成功' : '失败',
`standardCode:createOrderOne`,
];
if (result.supplierCode) {
lines.push(`supplierCode:${result.supplierCode}`);
}
if (result.downstreamServiceNo) {
lines.push(`服务商单号:${result.downstreamServiceNo}`);
}
if (result.trackNo) {
lines.push(`跟踪号:${result.trackNo}`);
}
lines.push(`请求:${result.url || '-'}`);
if (result.requestJson) {
lines.push(`请求体:\n${result.requestJson}`);
}
lines.push(`HTTP:${result.statusCode ?? '-'}`);
if (result.responseBody) {
lines.push(`响应:\n${result.responseBody}`);
}
return lines.join('\n');
}
async function runDownstreamForwardBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds });
const result = await waitOrderBatchJob(job.jobId);
if (result.timedOut) {
downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, rows)}`;
downstreamForwardModalVisible.value = true;
return;
}
const { job: finishedJob, items } = result;
downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText('预报下游', finishedJob, items, rows);
downstreamForwardModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id);
try {
const result = await postOrderDownstreamForwardCreateOne({ id });
resultBlocks.push(formatDownstreamForwardResult(waybillNo, result));
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n');
downstreamForwardModalVisible.value = true;
}
const handleForwardDownstreamCreate = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('downstream', async () => {
await runDownstreamForwardBatch(rows);
}).catch(() => undefined);
};
function getOrderRowId(row: any): number | string | undefined {
if (row == null) return undefined;
const id = row.id ?? row.Id;
if (id === undefined || id === null || id === '') return undefined;
return id;
}
function requireSelectedRows(): any[] {
const rows = selectedOrderRows.value.filter((r) => getOrderRowId(r) != null);
if (!rows.length) {
Message.warning('请先选择订单');
return [];
}
return rows;
}
const loadOrderList = async () => {
const requestSeq = ++listRequestSeq;
const status = activeTab.value === ORDER_STATUS_ALL ? ORDER_STATUS_ALL : activeTab.value;
const handleStatus =
showHandleSubTabs.value && activeHandleTab.value !== ORDER_HANDLE_STATUS_ALL
? activeHandleTab.value
: undefined;
const { keyword, createTimeStart, createTimeEnd } = buildListFilterParams();
listLoading.value = true;
try {
const { items, total } = await postOrderList(
withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
status,
handleStatus,
keyword,
createTimeStart: createTimeStart || undefined,
createTimeEnd: createTimeEnd || undefined,
}),
);
if (requestSeq !== listRequestSeq) {
return;
}
const mapped = (items || []).map((row) => normalizeOrderRow(row));
orderList.value = mapped;
totalCount.value = total;
tabCounts.value[status] = total;
syncColumnCatalogFromRows(mapped);
} catch (e: any) {
if (requestSeq !== listRequestSeq) {
return;
}
orderList.value = [];
totalCount.value = 0;
Message.error(e?.message || '加载订单列表失败');
} finally {
if (requestSeq === listRequestSeq) {
listLoading.value = false;
}
}
};
const handleSearch = () => {
currentPage.value = 1;
selectedOrderRows.value = [];
selectedRowCount.value = 0;
loadOrderList();
};
const handleResetSearch = () => {
listKeyword.value = '';
createTimeRange.value = undefined;
currentPage.value = 1;
selectedOrderRows.value = [];
selectedRowCount.value = 0;
loadOrderList();
};
// 当前页数据(HandsontablePro 用)
const processedTableData = computed(() => orderList.value);
// 标签页切换
const handleTabChange = (key: string) => {
if (activeTab.value === key) {
return;
}
activeTab.value = key;
activeHandleTab.value = ORDER_HANDLE_STATUS_ALL;
currentPage.value = 1;
orderList.value = [];
selectedOrderRows.value = [];
selectedRowCount.value = 0;
loadOrderList();
};
const handleHandleTabChange = (key: OrderHandleStatusCode) => {
if (activeHandleTab.value === key) {
return;
}
activeHandleTab.value = key;
currentPage.value = 1;
orderList.value = [];
selectedOrderRows.value = [];
selectedRowCount.value = 0;
loadOrderList();
};
// 操作按钮处理(依赖表格多选行;导出按当前 tab 条件,无需选行)
function openPayConfirm(rows: any[]) {
payConfirmOrders.value = rows
.filter((row) => getOrderRowId(row) != null)
.map((row) => ({
id: getOrderRowId(row)!,
waybillNo: row.waybillNo || row.serviceNo,
serviceNo: row.serviceNo,
}));
if (!payConfirmOrders.value.length) return;
payConfirmVisible.value = true;
}
const handlePay = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
openPayConfirm(rows);
};
const handleRowPay = (row: any) => {
if (!getOrderRowId(row)) return;
openPayConfirm([row]);
};
async function handlePayConfirmSuccess() {
notifyCustomerBalanceChanged();
await refreshListAndTabCounts();
}
const handlePreDeclare = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
try {
await withOrderActionLoading('predeclare', async () => {
await runSelectedOrderBatch({
action: 'pre-declare',
rows,
loadingKey: 'predeclare',
successMessage: '预报已提交',
syncRunner: async () => {
for (const row of rows) {
await postOrderPreDeclare({ id: getOrderRowId(row)! });
}
Message.success('预报已提交');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '操作失败');
}
}
};
const handleIntercept = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
try {
await withOrderActionLoading('intercept', async () => {
await runSelectedOrderBatch({
action: 'intercept',
rows,
loadingKey: 'intercept',
successMessage: '拦截已提交',
syncRunner: async () => {
for (const row of rows) {
await postOrderIntercept({ id: getOrderRowId(row)! });
}
Message.success('拦截已提交');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '操作失败');
}
}
};
const handleCancelIntercept = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
try {
await withOrderActionLoading('cancelIntercept', async () => {
await runSelectedOrderBatch({
action: 'cancel-intercept',
rows,
loadingKey: 'cancelIntercept',
successMessage: '已取消拦截',
syncRunner: async () => {
for (const row of rows) {
await postOrderCancelIntercept({ id: getOrderRowId(row)! });
}
Message.success('已取消拦截');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '操作失败');
}
}
};
const handleDelete = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
const blocked = rows.filter((row) => !isOrderDeletable(resolveOrderStatusCode(row)));
if (blocked.length) {
Message.warning('仅草稿或已预报订单可删除');
return;
}
Modal.confirm({
title: '确认删除',
content: `确定删除选中的 ${rows.length} 条订单吗?`,
onOk: async () => {
try {
await withOrderActionLoading('delete', async () => {
await runSelectedOrderBatch({
action: 'delete',
rows,
loadingKey: 'delete',
successMessage: '已删除',
syncRunner: async () => {
for (const row of rows) {
await postOrderDelete({ id: getOrderRowId(row)! });
}
Message.success('已删除');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '删除失败');
}
throw e;
}
},
});
};
function buildPrintItem(row: any, printUrl: string) {
const id = getOrderRowId(row)!;
const label = row?.waybillNo || row?.trackingNo || row?.customerOrderNo || String(id);
return { id, label, url: printUrl };
}
function isUsablePrintUrl(raw: unknown) {
const value = String(raw ?? '').trim();
if (!value) return false;
if (value.includes('获取中')) return false;
return true;
}
const handleSinglePrint = async () => {
singlePrintVisible.value = false;
singlePrintItems.value = [];
const rows = requireSelectedRows();
if (!rows.length) return;
if (rows.length > 1) {
Message.warning('单个打印仅支持选择 1 条订单');
return;
}
if (!printConnectionState.enabled || !printConnectionState.connected) {
Message.warning('请先在顶部打开并连接打印开关');
return;
}
try {
await withOrderActionLoading('singlePrint', async () => {
const row = rows[0];
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
if (!isUsablePrintUrl(printUrl)) {
Message.info('未获取到打印文件,已打开打印弹窗供预览布局');
singlePrintVisible.value = true;
return;
}
singlePrintItems.value = [buildPrintItem(row, printUrl)];
singlePrintVisible.value = true;
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '打印失败');
}
}
};
const handleBatchPrint = async () => {
batchPrintVisible.value = false;
batchPrintItems.value = [];
const rows = requireSelectedRows();
if (!rows.length) return;
if (!printConnectionState.enabled || !printConnectionState.connected) {
Message.warning('请先在顶部打开并连接打印开关');
return;
}
try {
await withOrderActionLoading('batchPrint', async () => {
await runSelectedOrderBatch({
action: 'print',
rows,
loadingKey: 'batchPrint',
onAsyncComplete: async ({ items, rows: selectedRows, job }) => {
const itemsByOrderId = new Map(items.map((item) => [item.orderId, item]));
const printItems: Array<{ id: number | string; label: string; url: string }> = [];
for (const row of selectedRows) {
const orderId = Number(getOrderRowId(row)!);
const item = itemsByOrderId.get(orderId);
if (item?.status !== 'success') continue;
const printUrl = parseBatchJobPrintUrl(item);
if (isUsablePrintUrl(printUrl)) {
printItems.push(buildPrintItem(row, printUrl));
}
}
if (!printItems.length) {
if (job.failCount > 0) {
Message.warning(formatBatchJobFailureMessage(job, items, selectedRows));
} else {
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
}
batchPrintVisible.value = true;
return;
}
batchPrintItems.value = printItems;
batchPrintVisible.value = true;
if (job.failCount > 0) {
Message.warning(formatBatchJobFailureMessage(job, items, selectedRows));
}
},
syncRunner: async () => {
const printItems: Array<{ id: number | string; label: string; url: string }> = [];
for (const row of rows) {
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
if (isUsablePrintUrl(printUrl)) {
printItems.push(buildPrintItem(row, printUrl));
}
}
if (!printItems.length) {
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
batchPrintVisible.value = true;
return;
}
batchPrintItems.value = printItems;
batchPrintVisible.value = true;
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '批量打印失败');
}
}
};
const handleExport = async () => {
try {
await withOrderActionLoading('export', async () => {
const status = activeTab.value === ORDER_STATUS_ALL ? ORDER_STATUS_ALL : activeTab.value;
const { keyword, createTimeStart, createTimeEnd } = buildListFilterParams();
const { downloadUrl } = await postOrderExport(
withCustomerId({
status,
keyword,
createTimeStart,
createTimeEnd,
}),
);
if (downloadUrl) window.open(downloadUrl, '_blank');
Message.success(downloadUrl ? '导出链接已打开' : '导出已提交');
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || '导出失败');
}
}
};
const handleModifyOrder = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
if (rows.length > 1) {
Message.warning(t('order.myOrders.modify.singleOnly'));
return;
}
await openDetailWithOptionalFetch(rows[0], { editBasic: true });
};
const openProblematicModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
exceptionOrderIds.value = rows.map((row) => Number(getOrderRowId(row)!));
problematicModalVisible.value = true;
};
const openClaimedModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
exceptionOrderIds.value = rows.map((row) => Number(getOrderRowId(row)!));
claimedModalVisible.value = true;
};
const handleExceptionActionSuccess = async () => {
await refreshListAndTabCounts();
};
const handleMarkProcessed = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
try {
await withOrderActionLoading('markProcessed', async () => {
await runSelectedOrderBatch({
action: 'handle-status-update',
rows,
loadingKey: 'markProcessed',
payload: { handleStatus: 'processed' },
successMessage: t('order.myOrders.handleStatus.success'),
syncRunner: async () => {
const result = await postOrderHandleStatusUpdate({
orderIds: rows.map((row) => Number(getOrderRowId(row)!)),
handleStatus: 'processed',
});
if (result.failures?.length) {
const lines = result.failures
.slice(0, 5)
.map((f) => `${f.serviceNo || f.orderId}: ${f.reason}`)
.join('\n');
if (result.successCount > 0) {
Message.warning(
t('order.myOrders.batch.partialSuccess', {
success: result.successCount,
fail: result.failures.length,
detail: lines,
}),
);
} else {
Message.error(lines || t('order.myOrders.handleStatus.failed'));
return;
}
} else {
Message.success(t('order.myOrders.handleStatus.success'));
}
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
Message.error(e?.message || t('order.myOrders.handleStatus.failed'));
}
}
};
const onMoreActionClick = (key: string) => {
if (key === 'createProblematic') return openProblematicModal();
if (key === 'createClaimed') return openClaimedModal();
if (key === 'createTrackOrder') return handleCreateTrackOrder();
if (key === 'intercept') return handleIntercept();
if (key === 'cancelIntercept') return handleCancelIntercept();
if (key === 'delete') return handleDelete();
if (key === 'export') return handleExport();
if (key === 'modifyOrder') return handleModifyOrder();
};
// 分页处理函数
const handlePageChange = (page: number) => {
currentPage.value = page;
loadOrderList();
};
const handlePageSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1;
loadOrderList();
};
// HandsontablePro 列定义(支持命名方案;目录覆盖接口全量标量字段,并随列表动态发现)
const columnCatalog = ref<OrderListColumnDef[]>(getWaybillBaseCatalog());
const columnConfigState = ref(
loadColumnConfigState(
WAYBILL_MANAGE_COLUMN_STORAGE_KEY,
columnCatalog.value,
WAYBILL_MANAGE_DEFAULT_KEYS,
t('order.myOrders.columnConfig.defaultScheme'),
),
);
const columnConfigModalRef = ref<InstanceType<typeof ColumnConfigModal>>();
const columnSchemeOptions = computed(() =>
columnConfigState.value.schemes.map((s) => ({ label: s.name, value: s.id })),
);
const activeColumnKeys = computed(() => getActiveScheme(columnConfigState.value).keys);
const hotColumns = computed(() =>
buildHotColumns(columnCatalog.value, activeColumnKeys.value, t),
);
const tableColumnsKey = computed(() => activeColumnKeys.value.join('|'));
function syncColumnCatalogFromRows(rows: Record<string, any>[]) {
columnCatalog.value = mergeDiscoveredColumns(getWaybillBaseCatalog(), rows);
}
function handleColumnSchemeChange(id: string) {
if (!columnConfigState.value.schemes.some((s) => s.id === id)) return;
columnConfigState.value = {
...columnConfigState.value,
activeId: id,
};
saveColumnConfigState(WAYBILL_MANAGE_COLUMN_STORAGE_KEY, columnConfigState.value);
}
function openColumnConfig() {
columnConfigModalRef.value?.open();
}
function handleColumnConfigApply(payload: { activeId: string; schemes: ColumnScheme[] }) {
columnConfigState.value = {
activeId: payload.activeId,
schemes: payload.schemes,
};
saveColumnConfigState(WAYBILL_MANAGE_COLUMN_STORAGE_KEY, columnConfigState.value);
Message.success(t('order.myOrders.columnConfig.applySuccess'));
}
const showRowPayAction = computed(
() => activeTab.value === 'pendingPayment' && hasPermission('Order.WaybillManage.Pay'),
);
const actionColumnWidth = computed(() => {
let width = 52 + 48; // 详情 + 轨迹
if (showRowPayAction.value) width += 48;
return width;
});
const actionRenderer = () => {
const parts = [
renderWarehouseActionBtn('view', t('common.viewDetail'), 'view'),
renderWarehouseActionBtn('track', t('order.myOrders.actions.viewTrack'), 'track'),
renderWarehouseActionBtn('advisor', t('nanaAssistant.actionAdvisor'), 'advisor'),
];
if (showRowPayAction.value) {
parts.push(renderWarehouseActionBtn('pay', t('order.myOrders.actions.pay'), 'pay'));
}
return `<div class="wh-action-group" style="display:inline-flex;flex-direction:row;flex-wrap:nowrap;align-items:center;justify-content:flex-start;gap:10px;white-space:nowrap;">${parts.join('')}</div>`;
};
const customCellRenderer = (row: number, col: number, value: any, rowData: any) => {
const colOffset = 2; // action + serialNo
const dataColIndex = col - colOffset;
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null;
const colKey = hotColumns.value[dataColIndex].key;
if (colKey === 'orderStatus') {
const code = resolveOrderStatusCode(rowData);
const meta = getOrderStatusMeta(code);
const tagColor = meta?.tagColor ?? '#86909c';
const tagBgColor = meta?.tagBgColor ?? '#f2f3f5';
const statusText = meta ? t(meta.labelKey) : (rowData?.orderStatus || code || '--');
const handleCode = resolveOrderHandleStatusCode(rowData);
const handleMeta = getOrderHandleStatusMeta(handleCode);
const handleText =
rowData?.handleStatusName ||
(handleMeta ? t(handleMeta.labelKey) : '');
const showHandleTag = isOrderHandleSubTabParent(code) && handleText;
const handleTagHtml = showHandleTag
? `<span style="display:inline-flex;align-items:center;margin-left:6px;padding:1px 6px;border-radius:10px;font-size:11px;line-height:18px;color:${handleMeta?.tagColor ?? '#4e5969'};background:${handleMeta?.tagBgColor ?? '#f2f3f5'};border:1px solid ${handleMeta?.tagBgColor ?? '#e5e6eb'};">${handleText}</span>`
: '';
return `<span style="display:inline-flex;align-items:center;flex-wrap:wrap;gap:4px;max-width:100%;"><span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;color:${tagColor};background-color:${tagBgColor};white-space:nowrap;">${statusText}</span>${handleTagHtml}</span>`;
}
if (colKey === 'predeclareFailReason') {
const reason = rowData?.predeclareFailReason || value || '';
if (!reason) return null;
const guide = buildFailGuideFromReason(String(reason));
const display = guide.reason || String(reason);
const escaped = escapeHtml(display);
const title = escapeHtml(guide.rawReason || String(reason));
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
}
if (colKey === 'allocationPendingReason') {
const reason = rowData?.allocationPendingReason || value || '';
if (!reason) return null;
const escaped = escapeHtml(String(reason));
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#ff7d00;font-size:12px;">${escaped}</span>`;
}
if (['isPaid', 'isCod', 'isIntercept', 'isRemote'].includes(colKey)) {
const rawVal = rowData?.[colKey] ?? value;
const yes = rawVal === true || rawVal === 1 || rawVal === '1';
const tagColor = yes ? '#00b42a' : '#86909c';
const tagBgColor = yes ? '#e8f8e8' : '#f2f3f5';
const text = yes ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no');
return `<span style="display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; color: ${tagColor}; background-color: ${tagBgColor}; white-space: nowrap;">${text}</span>`;
}
if (colKey === 'cargoAttribute') {
const raw = String(rowData?.cargoAttribute || value || '').trim().toLowerCase();
if (!raw) return null;
const map: Record<string, string> = {
general: 'order.myOrders.drawer.cargoAttribute.general',
electronics: 'order.myOrders.drawer.cargoAttribute.electronics',
'3c': 'order.myOrders.drawer.cargoAttribute.electronics',
sensitive: 'order.myOrders.drawer.cargoAttribute.sensitive',
};
const labelKey = map[raw];
return labelKey ? t(labelKey) : String(rowData?.cargoAttribute || value);
}
if (colKey === 'consigneeCountry') {
const code = rowData?.consigneeCountry || value || '';
const name = rowData?.consigneeCountryName || '';
if (code && name) return `${code} ${name}`;
return name || code || null;
}
if (['chargeableWeight', 'receivableAmount', 'declaredSum', 'codSum', 'claimAmount'].includes(colKey)) {
return value != null && value !== '' ? Number(value).toLocaleString() : null;
}
if (colKey === 'trackingNo') {
const no = String(value || '').trim();
if (!no) return '--';
const escaped = escapeHtml(no);
return `<span title="${t('order.myOrders.table.copyHint')}: ${escaped}" style="font-family:ui-monospace,Consolas,monospace;font-size:12px;">${escaped}</span>`;
}
// 首列添加 data-row-status 用于行背景样式
if (colKey === 'waybillNo') {
const statusCode = resolveOrderStatusCode(rowData);
const escaped = escapeHtml(String(value || '--'));
return `<span data-row-status="${statusCode}">${escaped}</span>`;
}
return null;
};
const handleActionClick = (row: any, _rowIndex: number, action?: string) => {
if (action === 'pay') {
handleRowPay(row);
return;
}
if (action === 'advisor') {
openAskAdvisor(router, buildWaybillContextFromRow(row, 'waybillManage'));
return;
}
if (action === 'track') {
void openDetailWithOptionalFetch(row, { tab: 'track' });
return;
}
openDetailWithOptionalFetch(row);
};
const handleSelectionChange = (rows: any[]) => {
selectedOrderRows.value = rows ?? [];
selectedRowCount.value = selectedOrderRows.value.length;
};
onMounted(() => {
nextTick(async () => {
await loadOrderList();
loadTabCounts();
await tryOpenOrderFromQuery();
});
});
async function tryOpenOrderFromQuery() {
const rawId = route.query.orderId;
const idStr = Array.isArray(rawId) ? rawId[0] : rawId;
const id = Number(idStr);
if (!Number.isFinite(id) || id <= 0) return;
const editRaw = route.query.edit;
const editStr = Array.isArray(editRaw) ? editRaw[0] : editRaw;
const editBasic = String(editStr || '') === '1';
try {
await openDetailWithOptionalFetch({ id }, { editBasic });
} finally {
const nextQuery = { ...route.query } as Record<string, any>;
delete nextQuery.orderId;
delete nextQuery.edit;
router.replace({ query: nextQuery });
}
}
watch(
() => route.query.status,
() => {
const next = resolveStatusFromQuery();
if (next === activeTab.value) return;
activeTab.value = next;
activeHandleTab.value = ORDER_HANDLE_STATUS_ALL;
currentPage.value = 1;
orderList.value = [];
selectedOrderRows.value = [];
selectedRowCount.value = 0;
loadOrderList();
loadTabCounts();
},
);
watch(
() => route.query.orderId,
(orderId) => {
if (!orderId) return;
void tryOpenOrderFromQuery();
},
);
</script>
<template>
<div class="waybill-manage-container">
<!-- 标签页导航和操作按钮 -->
<div class="tabs-actions-section">
<div class="tabs-container">
<div
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', { active: activeTab === tab.key }]"
@click="handleTabChange(tab.key)"
>
<span class="tab-label">{{ tab.label || $t(tab.labelKey) }}</span>
<span v-if="tab.showCount && tab.count != null" class="tab-count">({{ tab.count }})</span>
</div>
</div>
<div class="actions-right">
<a-button
v-if="showMarkProcessedAction"
type="primary"
:loading="isActionLoading('markProcessed')"
:disabled="isActionBusy('markProcessed')"
@click="handleMarkProcessed"
>{{ t('order.myOrders.actions.markProcessed') }}</a-button>
<a-button
v-if="showPayAction"
class="btn-pay"
:loading="isActionLoading('pay')"
:disabled="isActionBusy('pay')"
@click="handlePay"
>{{ t('order.myOrders.actions.pay') }}</a-button>
<a-button
v-if="showPredeclareAction"
type="primary"
:loading="isActionLoading('predeclare')"
:disabled="isActionBusy('predeclare')"
@click="handlePreDeclare"
>{{ t('order.myOrders.actions.predeclare') }}</a-button>
<a-button
v-if="showDownstreamAction"
type="primary"
:loading="isActionLoading('downstream')"
:disabled="isActionBusy('downstream')"
@click="handleForwardDownstreamCreate"
>{{ t('order.myOrders.actions.forwardDownstreamCreate') }}</a-button>
<a-button
v-if="showPrintAction"
type="primary"
:loading="isActionLoading('singlePrint')"
:disabled="isActionBusy('singlePrint')"
@click="handleSinglePrint"
>{{ t('order.myOrders.actions.singlePrint') }}</a-button>
<a-button
v-if="showPrintAction"
type="primary"
:loading="isActionLoading('batchPrint')"
:disabled="isActionBusy('batchPrint')"
@click="handleBatchPrint"
>{{ t('order.myOrders.actions.batchPrint') }}</a-button>
<a-dropdown trigger="click" position="bl" :disabled="!!actionLoadingKey">
<a-button type="text" class="btn-more" :disabled="!!actionLoadingKey">
{{ t('order.myOrders.actions.more') }}
<icon-down class="btn-more-arrow" />
</a-button>
<template #content>
<a-doption
v-if="showExceptionCreateActions"
:disabled="isActionBusy('markProblematic')"
@click="onMoreActionClick('createProblematic')"
>{{ t('order.myOrders.actions.createProblematic') }}</a-doption>
<a-doption
v-if="showExceptionCreateActions"
:disabled="isActionBusy('markClaimed')"
@click="onMoreActionClick('createClaimed')"
>{{ t('order.myOrders.actions.createClaimed') }}</a-doption>
<a-doption
v-if="showCreateTrackAction"
:disabled="isActionBusy('track')"
@click="onMoreActionClick('createTrackOrder')"
>{{ t('order.myOrders.actions.createTrackOrder') }}</a-doption>
<a-doption
v-if="showInterceptActions"
@click="onMoreActionClick('intercept')"
>{{ t('order.myOrders.actions.intercept') }}</a-doption>
<a-doption
v-if="showInterceptActions"
@click="onMoreActionClick('cancelIntercept')"
>{{ t('order.myOrders.actions.cancelIntercept') }}</a-doption>
<a-doption
v-if="showDeleteAction"
@click="onMoreActionClick('delete')"
>{{ t('order.myOrders.actions.delete') }}</a-doption>
<a-doption @click="onMoreActionClick('export')">{{ t('order.myOrders.actions.export') }}</a-doption>
<a-doption
v-if="showModifyOrderAction"
@click="onMoreActionClick('modifyOrder')"
>{{ t('order.myOrders.actions.modifyOrder') }}</a-doption>
</template>
</a-dropdown>
</div>
</div>
<div
v-if="showHandleSubTabs"
:class="['handle-status-bar', `handle-status-bar--${activeTab}`]"
>
<div class="handle-status-bar__intro">
<span class="handle-status-bar__dot" />
<span class="handle-status-bar__title">{{ handleContextLabel }}</span>
</div>
<div class="handle-status-segments" role="tablist">
<button
v-for="seg in handleSegments"
:key="seg.key"
type="button"
role="tab"
:class="['handle-status-segment', { active: activeHandleTab === seg.key }]"
:aria-selected="activeHandleTab === seg.key"
@click="handleHandleTabChange(seg.key)"
>
<span class="handle-status-segment__label">{{ t(seg.labelKey) }}</span>
<span class="handle-status-segment__count">{{ seg.count }}</span>
</button>
</div>
</div>
<!-- 订单表格区域 -->
<div class="table-section">
<!-- 表格操作按钮和分页控制 -->
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8" class="list-filters">
<a-select
v-model="adminCustomerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
style="width: 200px"
@change="handleSearch"
/>
<a-input
v-model="listKeyword"
allow-clear
:placeholder="t('order.myOrders.search.keywordPlaceholder')"
style="width: 240px"
@press-enter="handleSearch"
/>
<a-range-picker
v-model="createTimeRange"
style="width: 260px"
:placeholder="[
t('order.myOrders.search.dateStart'),
t('order.myOrders.search.dateEnd'),
]"
/>
<a-button type="primary" @click="handleSearch">
{{ t('order.myOrders.search.actions.search') }}
</a-button>
<a-button @click="handleResetSearch">
{{ t('order.myOrders.search.actions.reset') }}
</a-button>
</a-space>
<span class="selected-count">
{{ t('order.myOrders.selectedCount', { count: selectedRowCount }) }}
</span>
</div>
<div class="toolbar-right">
<div class="column-config-controls">
<a-select
:model-value="columnConfigState.activeId"
:options="columnSchemeOptions"
:placeholder="t('order.myOrders.columnConfig.scheme')"
style="width: 140px"
@change="handleColumnSchemeChange"
/>
<a-button @click="openColumnConfig">
{{ t('order.myOrders.columnConfig.open') }}
</a-button>
</div>
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div
v-if="!listLoading && totalCount === 0"
class="list-empty-hint"
>
<span>{{ t('order.waybillManage.empty.hint') }}</span>
</div>
<div ref="tableContainerRef" class="list-wrap">
<a-spin :loading="listLoading" class="table-spin">
<HandsontablePro
:key="`${activeTab}-${currentPage}-${orderList.length}-${tableColumnsKey}`"
:columns="hotColumns"
:data="processedTableData"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
:action-column-position="'start'"
:action-column-width="actionColumnWidth"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="tableHeight"
@action-click="handleActionClick"
@selection-change="handleSelectionChange"
/>
</a-spin>
</div>
</div>
<!-- 订单详情抽屉(左侧) -->
<OrderDetailDrawer
ref="detailDrawerRef"
:order="currentOrder"
@updated="handleOrderDetailUpdated"
/>
<ColumnConfigModal
ref="columnConfigModalRef"
:catalog="columnCatalog"
:schemes="columnConfigState.schemes"
:active-id="columnConfigState.activeId"
:default-keys="WAYBILL_MANAGE_DEFAULT_KEYS"
@apply="handleColumnConfigApply"
/>
<OrderPrintModal v-model:visible="singlePrintVisible" mode="single" :items="singlePrintItems" />
<OrderPrintModal v-model:visible="batchPrintVisible" mode="batch" :items="batchPrintItems" />
<a-modal
v-model:visible="trackUpsertModalVisible"
title="轨迹同步结果"
:width="720"
:footer="false"
>
<pre class="track-upsert-result">{{ trackUpsertResultText }}</pre>
</a-modal>
<a-modal
v-model:visible="downstreamForwardModalVisible"
:title="t('order.myOrders.modal.downstreamForwardTitle')"
:width="800"
:footer="false"
>
<pre class="track-upsert-result">{{ downstreamForwardResultText }}</pre>
</a-modal>
<MarkProblematicModal
v-model:visible="problematicModalVisible"
:order-ids="exceptionOrderIds"
@success="handleExceptionActionSuccess"
/>
<MarkClaimedModal
v-model:visible="claimedModalVisible"
:order-ids="exceptionOrderIds"
@success="handleExceptionActionSuccess"
/>
<OrderPayConfirmModal
v-model:visible="payConfirmVisible"
:orders="payConfirmOrders"
@success="handlePayConfirmSuccess"
/>
</div>
</template>
<style lang="less" scoped>
.waybill-manage-container {
width: 100%;
box-sizing: border-box;
padding: 16px 16px 24px;
background-color: #f0f3f4;
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
display: flex;
flex-direction: column;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
}
.tabs-actions-section {
background: #fff;
border-radius: 8px;
padding: 0 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
&:has(+ .handle-status-bar) {
margin-bottom: 0;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #f0f0f0;
}
}
.handle-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #fff;
border-radius: 0 0 8px 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
border-top: none;
flex-shrink: 0;
&--problematic {
border-left: 3px solid #f53f3f;
background: linear-gradient(90deg, #fff8f7 0%, #fff 28%);
}
&--claimed {
border-left: 3px solid #722ed1;
background: linear-gradient(90deg, #faf5ff 0%, #fff 28%);
}
&__intro {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 120px;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--app-primary-color);
flex-shrink: 0;
}
&--problematic &__dot {
background: #f53f3f;
}
&--claimed &__dot {
background: #722ed1;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
white-space: nowrap;
}
}
.handle-status-segments {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px;
background: #f2f3f5;
border-radius: 8px;
flex-wrap: wrap;
justify-content: flex-end;
flex: 1;
}
.handle-status-segment {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 14px;
border: none;
border-radius: 6px;
background: transparent;
color: #4e5969;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1d2129;
background: rgba(255, 255, 255, 0.72);
}
&.active {
color: var(--app-primary-color);
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-weight: 600;
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.06);
font-size: 12px;
line-height: 1;
color: inherit;
}
&.active &__count {
background: rgba(45, 140, 140, 0.12);
color: var(--app-primary-color);
}
}
.tabs-container {
display: flex;
align-items: center;
gap: 0;
flex: 1;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
position: relative;
&:hover {
background-color: #f8f9fa;
}
&.active {
border-bottom-color: var(--app-primary-color);
.tab-label {
color: var(--app-primary-color);
font-weight: 600;
}
}
.tab-label {
font-size: 14px;
color: #262626;
transition: color 0.3s;
}
.tab-count {
font-size: 12px;
color: #8c8c8c;
margin-left: 4px;
}
}
.actions-right {
display: flex;
flex-wrap: wrap;
gap: var(--app-button-gap, 8px);
align-items: center;
padding: 12px 0;
flex-shrink: 0;
:deep(.arco-btn) {
height: 32px;
padding: 0 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 400;
line-height: 30px;
}
:deep(.arco-btn.btn-pay) {
color: #d46b08;
background: #fffbe6;
border-color: #ffd666;
&:hover:not(:disabled) {
color: #d46b08;
background: #fff7d1;
border-color: #ffc53d;
}
}
:deep(.arco-btn.btn-track) {
color: #fff;
background: #000;
border-color: #000;
&:hover:not(:disabled) {
color: #fff;
background: #262626;
border-color: #262626;
}
&:active:not(:disabled) {
color: #fff;
background: #141414;
border-color: #141414;
}
}
:deep(.arco-btn.btn-more) {
height: 32px;
padding: 0 4px;
color: #4e5969;
background: transparent !important;
border: none !important;
box-shadow: none !important;
display: inline-flex;
align-items: center;
gap: 2px;
.btn-more-arrow {
font-size: 12px;
margin-left: 2px;
transition: transform 0.2s ease;
}
&:hover:not(:disabled) {
color: var(--app-primary-color);
background: transparent !important;
border: none !important;
}
&:active:not(:disabled) {
color: var(--app-primary-color-hover);
background: transparent !important;
}
}
}
.table-section {
background: #fff;
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
padding: 16px 16px 0;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
.toolbar-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.list-filters {
width: 100%;
}
.selected-count {
font-size: 13px;
color: #86909c;
.count-number {
color: #f53f3f;
font-weight: 600;
margin: 0 2px;
}
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
.column-config-controls {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-controls {
display: flex;
align-items: center;
}
}
}
.list-empty-hint {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin: 0 16px 12px;
padding: 10px 12px;
border-radius: 8px;
background: #f7f8fa;
color: #4e5969;
font-size: 13px;
}
.table-spin {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.arco-spin) {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.arco-spin-children) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
.list-wrap {
width: 100%;
overflow: hidden;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
:deep(.handsontable-pro-container) {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.handsontable-wrapper {
width: 100%;
:deep(.handsontable) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
border: none !important;
// 覆盖 Handsontable 默认的 table 边框
table {
border: none !important;
}
.ht_master,
.ht_clone_top,
.ht_clone_left,
.ht_clone_corner {
border: none !important;
}
.wtHolder {
border: none !important;
}
.htCore {
border-collapse: separate;
border-spacing: 0;
border: none !important;
td {
border-right: 0.5px solid #f0f0f0 !important;
border-bottom: 0.5px solid #f0f0f0 !important;
border-top: none !important;
border-left: none !important;
padding: 7px 16px;
font-size: 13px;
line-height: 1.4;
color: #262626;
transition: all 0.2s ease;
background: #fff;
text-align: center !important;
vertical-align: middle !important;
&:hover {
background-color: #fafafa;
}
&:first-child {
padding-left: 8px;
padding-right: 8px;
}
}
tr {
transition: all 0.2s ease;
&:hover {
td {
// hover时,如果单元格已经有背景色(状态背景),则稍微加深
// 否则使用默认hover颜色
&:not([style*="background-color"]) {
background-color: #fafafa;
}
}
}
}
th {
background: #f5f5f5 !important; // 表头背景颜色改为淡灰色
border-right: 0.5px solid #f0f0f0 !important;
border-bottom: 0.5px solid #f0f0f0 !important;
border-top: none !important;
border-left: none !important;
}
// 根据订单状态设置行背景
tbody tr:has([data-row-status="problematic"]) td {
background-color: #fff1f0 !important;
}
tbody tr:has([data-row-status="pendingPayment"]) td {
background-color: #fffbe6 !important;
}
tbody tr:has([data-row-status="pendingAllocation"]) td {
background-color: #fff7e6 !important;
}
:deep(.wh-action-group) {
display: inline-flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center;
justify-content: flex-start;
gap: 10px;
white-space: nowrap;
}
:deep(.wh-action-btn) {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
color: var(--color-text-3);
transition: color 0.2s ease, background-color 0.2s ease;
user-select: none;
}
:deep(.wh-action-btn--view:hover),
:deep(.wh-action-btn--pay:hover),
:deep(.wh-action-btn--track:hover) {
color: var(--app-primary-color);
background: var(--app-primary-color-light, #e6f7f7);
}
:deep(.wh-action-btn--advisor:hover) {
color: #ffb400;
background: rgba(255, 180, 0, 0.14);
}
// 隐藏筛选图标的边框
.changeType {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
// 隐藏筛选图标容器的边框
.ht_master .ht_clone_top th .changeType,
.ht_clone_top th .changeType,
.htCore th .changeType,
th .changeType {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
}
.ht_master .wtHolder {
overflow: auto;
}
.handsontableInput {
display: none;
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.tabs-container {
overflow-x: auto;
flex-wrap: nowrap;
}
.actions-right {
flex-wrap: wrap;
gap: 4px;
}
.action-btn {
font-size: 12px;
padding: 0 8px;
height: 28px;
}
}
.track-upsert-result {
margin: 0;
max-height: 480px;
overflow: auto;
padding: 12px;
background: #f7f8fa;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style>