1.02
All checks were successful
Build and Deploy Front / deploy (push) Successful in 1m51s

1.02
This commit is contained in:
zzh 2026-10-09 18:55:53 +08:00
commit 61df911a5c
37 changed files with 3021 additions and 591 deletions

View File

@ -229,8 +229,10 @@ flowchart TD
| `POST /operation/outbound/create` | 出库:不写费用 |
| `GET /order/weight/downstream-import-template` | 对账件重尺导入模板 |
| `POST /order/weight/downstream-import` | 导入 downstream 重尺并锁定渠道重算应付 |
| `POST /order/fee/recalculate-receivable` | 重算应收(已支付拒绝) |
| `POST /order/fee/recalculate-payable` | 重算应付(已有渠道时锁定渠道,不换渠道) |
| `POST /order/fee/recalculate-receivable` | 重算应收(已支付拒绝;沿用订单绑定价格/规则版本) |
| `POST /order/fee/recalculate-payable` | 重算应付(已有渠道时锁定渠道;允许渠道不在旧规则排货池,只重算买价) |
| `POST /order/fee/bind-snapshot-versions` | 显式切换订单绑定的规则/价格版本(可选随后重算应收/应付) |
| `POST /order/fee/reallocate` | 重新排货(取当前有效规则;成功后写回订单规则版本) |
| `POST /order/fee/rerun-inbound-billing` | 重跑入库计费(已支付拒绝,应收已锁) |
| `POST /order/fee/add` | 手工加费(应收已支付拒绝) |
| `POST /order/additional-services/update` | 未支付改附加服务并重算 |

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'), {
@ -2087,6 +2152,25 @@ export async function postOrderRecalculatePayable(body: OrderIdRequest): Promise
return res.data?.data ?? res.data?.Data ?? res.data
}
/** POST /order/fee/bind-snapshot-versions — 切换订单绑定的规则/价格版本 */
export async function postOrderBindSnapshotVersions(body: {
id: number | string
rulesVersion?: string
priceVersion?: string
recalculateReceivable?: boolean
recalculatePayable?: boolean
}): Promise<any> {
const res = await axiosInstance.post(orderUrl('/order/fee/bind-snapshot-versions'), {
id: Number(body.id),
rulesVersion: body.rulesVersion || undefined,
priceVersion: body.priceVersion || undefined,
recalculateReceivable: !!body.recalculateReceivable,
recalculatePayable: !!body.recalculatePayable,
})
assertBusinessOk(res.data)
return res.data?.data ?? res.data?.Data ?? res.data
}
/** POST /order/fee/rerun-inbound-billing — 重跑入库计费(应收+排货应付) */
export async function postOrderRerunInboundBilling(body: OrderIdRequest): Promise<any> {
const res = await axiosInstance.post(orderUrl('/order/fee/rerun-inbound-billing'), { id: body.id })

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

@ -9,6 +9,10 @@ export interface WarehouseDto {
recipient?: string
contactPhone?: string
shippingAddress?: string
/** 展示排序,越小越靠前;可不填 */
sort?: number | null
/** 备注(选填) */
remark?: string
createTime?: string
updateTime?: string
createBy?: string

View File

@ -220,7 +220,8 @@
originalParamIds.value = source
? buildOriginalParamIdsFromHydrated(source)
: createEmptyOriginalParamIds();
nextTick(() => captureDirtyBaseline());
// 等表格/公共参数/表单回填落稳后再采基线,避免「刚打开就提示未保存」
void settleDirtyBaseline();
}
watch(
@ -288,6 +289,10 @@
const exampleDirtyBaseline = ref({ request: '', response: '' });
const manualDirty = ref(false);
const isDirty = ref(false);
/** 接口明细加载/回填中:不算未保存,避免误报 */
const detailHydrating = ref(false);
let baselineSettleSeq = 0;
const dirtyBaselineReady = ref(false);
function pickFormDirtySnapshot() {
const d: any = props.basicFormData || {};
@ -318,15 +323,24 @@
}
function hasUnsavedChanges() {
// 加载未完成时不提示「有未保存更改」
if (detailHydrating.value || !dirtyBaselineReady.value) return false;
if (manualDirty.value) return true;
if (fixedParamsHasChanges.value) return true;
if (returnConfigHasChanges.value) return true;
if (formDirtyBaseline.value && pickFormDirtySnapshot() !== formDirtyBaseline.value) return true;
if ((requestExample.value || '') !== (exampleDirtyBaseline.value.request || '')) return true;
if ((responseExample.value || '') !== (exampleDirtyBaseline.value.response || '')) return true;
if (actionRowsDirty(() => queryTableRef.value?.getActionRows?.())) return true;
if (actionRowsDirty(() => headerTableRef.value?.getActionRows?.())) return true;
if (actionRowsDirty(() => bodyFormTableRef.value?.getActionRows?.())) return true;
// 公共参数行不参与脏检查
if (actionRowsDirty(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))) {
return true;
}
if (
actionRowsDirty(() =>
stripBodyRootFromActionRows(requestTableRef.value?.getActionRows?.() || {}),
@ -344,6 +358,7 @@
}
function markDirty() {
if (detailHydrating.value) return;
manualDirty.value = true;
isDirty.value = true;
}
@ -356,6 +371,60 @@
response: responseExample.value || '',
};
isDirty.value = false;
dirtyBaselineReady.value = true;
}
/** 用当前 props.data 再 reload 一次,清掉 VXE keep-source 在回填过程中留下的假 insert/update */
function reseatParamTables() {
const reload = (refLike: { value?: { reloadData?: (rows: any[]) => void } }, rows: any[]) => {
if (!refLike?.value?.reloadData) return;
refLike.value.reloadData(Array.isArray(rows) ? [...rows] : []);
};
reload(queryTableRef, queryTableProps.data);
reload(headerTableRef, headerTableProps.data);
reload(bodyFormTableRef, bodyFormTableProps.data);
reload(mappingBodyTableRef, mappingBodyTableProps.data);
reload(requestTableRef, requestTableProps.data);
reload(responseTableRef, responseTableProps.data);
}
/**
* 等待公共参数合并、表格挂载、FormPro/SelectPro 回填后再采脏检查基线。
* 解决:选中接口后立刻切 API 调试误报「有未保存的更改」。
*/
async function settleDirtyBaseline() {
const seq = ++baselineSettleSeq;
detailHydrating.value = true;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
await nextTick();
await nextTick();
if (seq !== baselineSettleSeq) return;
// 等服务商公共参数拉取并合并进 Query/Header/Body
const waitStart = Date.now();
while (inheritedCommonParamsLoading.value && Date.now() - waitStart < 4000) {
await new Promise((r) => setTimeout(r, 40));
if (seq !== baselineSettleSeq) return;
}
await nextTick();
reseatParamTables();
await nextTick();
if (seq !== baselineSettleSeq) return;
captureDirtyBaseline();
detailHydrating.value = false;
// SelectPro/字典异步回写可能再改一帧表单值,二次采基线(仅无手动编辑时)
await new Promise((r) => setTimeout(r, 120));
if (seq !== baselineSettleSeq || manualDirty.value) return;
reseatParamTables();
await nextTick();
if (seq !== baselineSettleSeq || manualDirty.value) return;
captureDirtyBaseline();
}
/** 浏览器关闭/刷新:有未保存更改时提示 */
@ -370,6 +439,8 @@
* resolve true = 可离开(丢弃更改),false = 留下继续编辑。
*/
async function confirmLeaveUnsaved(options?: { content?: string }): Promise<boolean> {
// 仍在回填:用户不可能已编辑,直接放行
if (detailHydrating.value || !dirtyBaselineReady.value) return true;
refreshDirtyState();
if (!hasUnsavedChanges()) return true;
return new Promise((resolve) => {
@ -562,11 +633,19 @@
(id, oldId) => {
if (oldId) saveTabMemory(oldId);
if (id) {
// 切换接口:进入回填态,避免沿用上一接口的脏标记
detailHydrating.value = true;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
nextTick(() => {
restoreTabMemory(id);
// 加载完成后由父组件 hydrate 再 capture;此处先清 manual
manualDirty.value = false;
});
} else {
detailHydrating.value = false;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
}
},
);
@ -3283,7 +3362,8 @@
}
} else {
fixedParams.value = payload.fixedParams;
fixedParamsHasChanges.value = payload.fixedParams.length > 0;
// 有固定配置数据 ≠ 未保存更改;加载后应视为干净
fixedParamsHasChanges.value = false;
await nextTick();
queryFixedTableRef.value?.reloadData?.(payload.fixedParams);
}
@ -3825,7 +3905,7 @@
<li><strong>参数名</strong>:与服务商文档、curl、Mapping Body / Body 参数树、前置脚本写入字段名保持一致(如 <code>_aop_signature</code>、<code>notifyBizEventDTO</code>)。</li>
<li><strong>参数值</strong>:使用<strong>占位符</strong>或留空,不要填写 curl 中的真实签名/业务样例;转发前由 Like API 替换。</li>
<li><code>$FromMappingBody</code>(FormUrlEncoded):Query 上承载业务 JSON 的字段(如 <code>*DTO</code>)填此占位符,映射结果来自 <strong>Mapping Body</strong> 参数树;POST 参数仅在 URL、无 Body 时,DTO 应配置在 Query 而非 Body 表单。</li>
<li><code>$FromAccount</code>:按参数名从<strong>服务商账户</strong>取值;<code>$TimeStamp</code> / <code>$DateTime</code>:时间;<code>$token</code>:令牌;<code>$Url</code>:当前请求地址。</li>
<li><code>$FromAccount</code>:按参数名从<strong>服务商账户</strong>取值;<code>$TimeStamp</code> / <code>$DateTime</code>:时间;<code>$Token</code>:令牌;<code>$Url</code>:当前请求地址。</li>
<li><strong>留空</strong>:签名类参数(如 <code>sign</code>、<code>_aop_signature</code>)由<strong>前置脚本</strong>写入。</li>
<li><strong>参数描述</strong>:可记录备注或 curl 片段,便于对照映射,不参与转发。</li>
</ul>

View File

@ -100,7 +100,7 @@ export const ParameterPlaceholderText: Record<number, string> = {
[ParameterPlaceholder.TimeStamp]: '$TimeStamp',
[ParameterPlaceholder.DateTime]: '$DateTime',
[ParameterPlaceholder.Url]: '$Url',
[ParameterPlaceholder.Token]: '$token',
[ParameterPlaceholder.Token]: '$Token',
[ParameterPlaceholder.FromMappingBody]: '$FromMappingBody',
};

View File

@ -175,6 +175,7 @@ export const PERMISSION_ACTION_LABELS: Record<string, string> = {
AddFee: '添加费用',
RecalculateReceivable: '重算应收',
RecalculatePayable: '重算应付',
BindSnapshotVersions: '切换版本',
ImportDownstreamWeight: '导入对账件重尺',
AddDeclarationRow: '添加申报行',
DownstreamForecastResult: '下游预报结果',
@ -537,6 +538,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'AddFee', name: '添加费用' },
{ code: 'RecalculateReceivable', name: '重算应收' },
{ code: 'RecalculatePayable', name: '重算应付' },
{ code: 'BindSnapshotVersions', name: '切换版本' },
{ code: 'AddDeclarationRow', name: '添加申报行' },
]),
B('Order.FulfillOrders', [
@ -573,6 +575,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'AddFee', name: '添加费用' },
{ code: 'RecalculateReceivable', name: '重算应收' },
{ code: 'RecalculatePayable', name: '重算应付' },
{ code: 'BindSnapshotVersions', name: '切换版本' },
{ code: 'ImportDownstreamWeight', name: '导入对账件重尺' },
{ code: 'AddDeclarationRow', name: '添加申报行' },
]),

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',
@ -1856,7 +1876,20 @@ export default {
'order.waybillManage.table.id': 'Order ID',
'order.waybillManage.table.productCode': 'Product code',
'order.waybillManage.table.channelCode': 'Channel code',
'order.waybillManage.table.downstreamSupplierName': 'Service provider',
'order.waybillManage.table.downstreamSupplierCode': 'Downstream supplier',
'order.waybillManage.table.maxTimeliness': 'Max transit days',
'order.waybillManage.table.salesProductRulesVersion': 'Rules version',
'order.waybillManage.table.salesProductPriceVersion': 'Price version',
'order.waybillManage.table.forecastReceivableAmount': 'Forecast receivable',
'order.waybillManage.table.forecastPayableAmount': 'Forecast payable',
'order.waybillManage.table.hasInsurance': 'Insured',
'order.waybillManage.table.hasAllocationTrace': 'Has allocation trace',
'order.waybillManage.table.allocationScheme': 'Allocation scheme',
'order.waybillManage.table.exportQualificationId': 'Export qualification ID',
'order.waybillManage.table.pickupOrderId': 'Pickup order ID',
'order.waybillManage.table.stockBindLineCount': 'Bind line count',
'order.waybillManage.table.primarySkuCodes': 'Bound SKUs',
'order.waybillManage.table.downstreamAppKey': 'Downstream AppKey',
'order.waybillManage.table.downstreamAppId': 'Downstream AppId',
'order.waybillManage.table.channelForecastCode': 'Forecast code',
@ -2110,6 +2143,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}',
@ -2176,6 +2218,8 @@ export default {
'order.myOrders.drawer.productName': 'Sales Product',
'order.myOrders.drawer.channelCode': 'Channel Code',
'order.myOrders.drawer.channelName': 'Service Channel',
'order.myOrders.drawer.downstreamSupplierName': 'Service provider',
'order.myOrders.drawer.downstreamSupplierCode': 'Provider code',
'order.myOrders.allocationTrace.view': 'View allocation process',
'order.myOrders.allocationTrace.title': 'Allocation process',
'order.myOrders.allocationTrace.fromSnapshot': 'Historical snapshot (at allocation time)',
@ -2342,6 +2386,25 @@ export default {
'order.myOrders.drawer.actions.cancel': 'Cancel',
'order.myOrders.drawer.actions.recalculateReceivable': 'Recalc Receivable',
'order.myOrders.drawer.actions.recalculatePayable': 'Recalc Payable',
'order.myOrders.drawer.actions.bindSnapshotVersions': 'Switch Versions',
'order.myOrders.drawer.fee.rulesVersion': 'Rules version',
'order.myOrders.drawer.fee.priceVersion': 'Price version',
'order.myOrders.drawer.bindSnapshot.title': 'Switch price/rules versions',
'order.myOrders.drawer.bindSnapshot.hint':
'Explicitly switch the publish versions bound to this order. Recalculation follows these bindings.',
'order.myOrders.drawer.bindSnapshot.rulesVersion': 'Rules version',
'order.myOrders.drawer.bindSnapshot.priceVersion': 'Price version',
'order.myOrders.drawer.bindSnapshot.boundAs': 'Bound: {version}',
'order.myOrders.drawer.bindSnapshot.keepUnchanged': 'Keep current',
'order.myOrders.drawer.bindSnapshot.useCurrent': 'Current effective publish',
'order.myOrders.drawer.bindSnapshot.currentBound': 'current binding',
'order.myOrders.drawer.bindSnapshot.recalcReceivable': 'Recalc receivable after bind',
'order.myOrders.drawer.bindSnapshot.recalcPayable': 'Recalc payable after bind',
'order.myOrders.drawer.bindSnapshot.needVersion': 'Select at least a rules or price version',
'order.myOrders.drawer.bindSnapshot.noProduct': 'Order has no sales product',
'order.myOrders.drawer.bindSnapshot.success': 'Versions updated',
'order.myOrders.drawer.bindSnapshot.failed': 'Failed to switch versions',
'order.myOrders.drawer.bindSnapshot.loadFailed': 'Failed to load publish versions',
'order.myOrders.drawer.actions.addFee': 'Add Fee',
'order.myOrders.drawer.actions.additionalServices': 'Additional services',
'order.myOrders.drawer.editBasicTitle': 'Edit Order Details',
@ -2373,7 +2436,24 @@ export default {
'order.myOrders.drawer.fee.diff': 'Delta',
'order.myOrders.drawer.fee.negativeGrossProfitWarn': 'Negative gross profit detected — review cost and pricing',
'order.myOrders.drawer.fee.additionalServicesTitle': 'Additional services',
'order.myOrders.drawer.fee.additionalServicesManageTitle': 'Manage additional services',
'order.myOrders.drawer.fee.additionalServicesViewTitle': 'View additional services',
'order.myOrders.drawer.fee.additionalServicesEmpty': 'No additional services for this product',
'order.myOrders.drawer.fee.additionalServicesNone': 'None',
'order.myOrders.drawer.fee.additionalServicesManage': 'Manage',
'order.myOrders.drawer.fee.additionalServicesView': 'View',
'order.myOrders.drawer.fee.additionalServicesSave': 'Save & recalc',
'order.myOrders.drawer.fee.additionalServicesClose': 'Close',
'order.myOrders.drawer.fee.additionalServicesHint':
'Check or uncheck product-allowed services. Saving recalculates receivable (unpaid only).',
'order.myOrders.drawer.fee.additionalServicesPaidHint':
'Order is paid; additional services cannot be changed.',
'order.myOrders.drawer.fee.additionalServicesCurrent': 'Currently bound',
'order.myOrders.drawer.fee.additionalServicesPick': 'Available services',
'order.myOrders.drawer.fee.additionalServicesOrphan':
'These are no longer on the product and will be removed on save',
'order.myOrders.drawer.fee.additionalServicesOrphanCleared':
'Removed out-of-scope services: {names}',
'order.myOrders.drawer.fee.additionalServicesSuccess': 'Additional services updated',
'order.myOrders.drawer.fee.additionalServicesFailed': 'Failed to update additional services',
'order.myOrders.drawer.fee.addTitle': 'Add Fee',
@ -3046,6 +3126,8 @@ export default {
'system.interface.mapping.table.providerParamName': 'Provider parameter name',
'system.interface.mapping.table.providerParamNameCn': 'Provider parameter description',
'system.interface.mapping.table.paramValue': 'Parameter value',
'system.interface.mapping.table.paramValueTip':
'If a value looks like both a number and a string (e.g. phone 075525131031), wrap it in single quotes as \'075525131031\' to mark it as a string. Entering it without quotes treats it as a number and may drop leading zeros or cause type errors.',
'system.interface.mapping.table.inputParam': 'Input parameter',
'system.interface.mapping.table.inputParamCn': 'Input parameter description',
'system.interface.mapping.actions.prefixScript': 'Prefix script',
@ -4085,8 +4167,12 @@ export default {
'system.warehouseAddress.fields.recipient': 'Recipient',
'system.warehouseAddress.fields.contactPhone': 'Contact Phone',
'system.warehouseAddress.fields.shippingAddress': 'Shipping Address',
'system.warehouseAddress.fields.sort': 'Sort',
'system.warehouseAddress.fields.remark': 'Remark',
'system.warehouseAddress.fields.servicePhone': 'Service Phone',
'system.warehouseAddress.fields.serviceWechat': 'Service WeChat',
'system.warehouseAddress.placeholder.sort': 'Smaller first (optional)',
'system.warehouseAddress.placeholder.remark': 'Optional note shown to customers',
'system.warehouseAddress.actions.add': 'Add',
'system.warehouseAddress.actions.edit': 'Edit',
'system.warehouseAddress.actions.copy': 'Copy',

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': '排货方案',
@ -1788,10 +1808,23 @@ export default {
'order.waybillManage.table.id': '订单ID',
'order.waybillManage.table.productCode': '产品编码',
'order.waybillManage.table.channelCode': '渠道编码',
'order.waybillManage.table.downstreamSupplierName': '服务商名称',
'order.waybillManage.table.downstreamSupplierCode': '下游服务商编码',
'order.waybillManage.table.downstreamAppKey': '下游AppKey',
'order.waybillManage.table.downstreamAppId': '下游AppId',
'order.waybillManage.table.channelForecastCode': '渠道预报编码',
'order.waybillManage.table.maxTimeliness': '最大时效(天)',
'order.waybillManage.table.salesProductRulesVersion': '规则版本',
'order.waybillManage.table.salesProductPriceVersion': '价格版本',
'order.waybillManage.table.forecastReceivableAmount': '预报应收',
'order.waybillManage.table.forecastPayableAmount': '预报应付',
'order.waybillManage.table.hasInsurance': '是否投保',
'order.waybillManage.table.hasAllocationTrace': '有排货快照',
'order.waybillManage.table.allocationScheme': '排货方案',
'order.waybillManage.table.exportQualificationId': '出口资质ID',
'order.waybillManage.table.pickupOrderId': '揽收单ID',
'order.waybillManage.table.stockBindLineCount': '绑定行数',
'order.waybillManage.table.primarySkuCodes': '绑定SKU',
'order.waybillManage.table.consigneeCountryName': '收件国家名称',
'order.waybillManage.table.warehouseId': '仓库ID',
'order.waybillManage.table.customerId': '客户ID',
@ -1971,6 +2004,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}',
@ -2037,6 +2079,8 @@ export default {
'order.myOrders.drawer.productName': '销售产品',
'order.myOrders.drawer.channelCode': '渠道code',
'order.myOrders.drawer.channelName': '服务渠道',
'order.myOrders.drawer.downstreamSupplierName': '服务商名称',
'order.myOrders.drawer.downstreamSupplierCode': '服务商编码',
'order.myOrders.allocationTrace.view': '查看排货过程',
'order.myOrders.allocationTrace.title': '排货过程',
'order.myOrders.allocationTrace.fromSnapshot': '历史快照(排货当时结果)',
@ -2203,8 +2247,27 @@ export default {
'order.myOrders.drawer.actions.cancel': '取消',
'order.myOrders.drawer.actions.recalculateReceivable': '重算应收',
'order.myOrders.drawer.actions.recalculatePayable': '重算应付',
'order.myOrders.drawer.actions.bindSnapshotVersions': '切换版本',
'order.myOrders.drawer.actions.addFee': '添加费用',
'order.myOrders.drawer.actions.additionalServices': '附加服务',
'order.myOrders.drawer.fee.rulesVersion': '规则版本',
'order.myOrders.drawer.fee.priceVersion': '价格版本',
'order.myOrders.drawer.bindSnapshot.title': '切换价格/规则版本',
'order.myOrders.drawer.bindSnapshot.hint':
'显式切换订单绑定的发布版本;默认仍保护下单契约。重算应收/应付会沿用此处绑定。',
'order.myOrders.drawer.bindSnapshot.rulesVersion': '规则版本',
'order.myOrders.drawer.bindSnapshot.priceVersion': '价格版本',
'order.myOrders.drawer.bindSnapshot.boundAs': '当前绑定:{version}',
'order.myOrders.drawer.bindSnapshot.keepUnchanged': '不修改(保持当前)',
'order.myOrders.drawer.bindSnapshot.useCurrent': '当前有效发布',
'order.myOrders.drawer.bindSnapshot.currentBound': '当前绑定',
'order.myOrders.drawer.bindSnapshot.recalcReceivable': '绑定后重算应收',
'order.myOrders.drawer.bindSnapshot.recalcPayable': '绑定后重算应付',
'order.myOrders.drawer.bindSnapshot.needVersion': '请至少选择规则版本或价格版本',
'order.myOrders.drawer.bindSnapshot.noProduct': '订单未绑定销售产品',
'order.myOrders.drawer.bindSnapshot.success': '版本已切换',
'order.myOrders.drawer.bindSnapshot.failed': '切换版本失败',
'order.myOrders.drawer.bindSnapshot.loadFailed': '加载发布版本失败',
'order.myOrders.drawer.editBasicTitle': '编辑订单详情',
'order.myOrders.drawer.editWeightTitle': '编辑件重尺',
'order.myOrders.drawer.message.saveSuccess': '保存成功',
@ -2234,7 +2297,23 @@ export default {
'order.myOrders.drawer.fee.diff': '差额',
'order.myOrders.drawer.fee.negativeGrossProfitWarn': '存在负毛利,请关注成本与报价',
'order.myOrders.drawer.fee.additionalServicesTitle': '附加服务',
'order.myOrders.drawer.fee.additionalServicesManageTitle': '管理附加服务',
'order.myOrders.drawer.fee.additionalServicesViewTitle': '查看附加服务',
'order.myOrders.drawer.fee.additionalServicesEmpty': '该产品暂无可选附加服务',
'order.myOrders.drawer.fee.additionalServicesNone': '未绑定',
'order.myOrders.drawer.fee.additionalServicesManage': '管理',
'order.myOrders.drawer.fee.additionalServicesView': '查看',
'order.myOrders.drawer.fee.additionalServicesSave': '保存并重算',
'order.myOrders.drawer.fee.additionalServicesClose': '关闭',
'order.myOrders.drawer.fee.additionalServicesHint':
'勾选增删产品允许的附加服务;保存后按当前阶段重算应收(未支付可改)。',
'order.myOrders.drawer.fee.additionalServicesPaidHint': '订单已支付,附加服务不可修改。',
'order.myOrders.drawer.fee.additionalServicesCurrent': '订单当前绑定',
'order.myOrders.drawer.fee.additionalServicesPick': '可选附加服务',
'order.myOrders.drawer.fee.additionalServicesOrphan':
'以下服务已不在产品可选范围,保存后将自动移除',
'order.myOrders.drawer.fee.additionalServicesOrphanCleared':
'已移除不在产品范围的附加服务:{names}',
'order.myOrders.drawer.fee.additionalServicesSuccess': '附加服务已更新',
'order.myOrders.drawer.fee.additionalServicesFailed': '附加服务更新失败',
'order.myOrders.drawer.fee.addTitle': '添加费用',
@ -2908,6 +2987,8 @@ export default {
'system.interface.mapping.table.providerParamName': '服务商参数名',
'system.interface.mapping.table.providerParamNameCn': '服务商参数描述',
'system.interface.mapping.table.paramValue': '参数值',
'system.interface.mapping.table.paramValueTip':
'若填写的值既像数字又像字符串(例如电话号 075525131031),请用单引号包起来写成 \'075525131031\',明确为字符串。直接填写会被当成数字解析,可能导致前导零丢失或类型错误。',
'system.interface.mapping.table.inputParam': '入参',
'system.interface.mapping.table.inputParamCn': '入参描述',
'system.interface.mapping.actions.prefixScript': '前置脚本',
@ -4113,8 +4194,12 @@ export default {
'system.warehouseAddress.fields.recipient': '收件人',
'system.warehouseAddress.fields.contactPhone': '联系电话',
'system.warehouseAddress.fields.shippingAddress': '收货地址',
'system.warehouseAddress.fields.sort': '排序',
'system.warehouseAddress.fields.remark': '备注',
'system.warehouseAddress.fields.servicePhone': '客服电话',
'system.warehouseAddress.fields.serviceWechat': '客服微信',
'system.warehouseAddress.placeholder.sort': '越小越靠前,可不填',
'system.warehouseAddress.placeholder.remark': '选填,展示给客户的说明',
'system.warehouseAddress.actions.add': '新增',
'system.warehouseAddress.actions.edit': '编辑',
'system.warehouseAddress.actions.copy': '复制',

View File

@ -399,6 +399,14 @@ function isSameFormKvRow(row: any, baseline: NormalizedParamRow): boolean {
);
}
function isPersistableFormKvRow(p: any) {
// 删除只依赖 id;参数值允许留空(签名类由前置脚本写入)
if (Number(p?.optionType) === 2) {
return Number(p?.id) > 0;
}
return !!(p.name && String(p.name).trim() !== '');
}
function buildFormKvParameterList(
changes: any[],
tableData: any[],
@ -406,24 +414,23 @@ function buildFormKvParameterList(
isEditMode: boolean,
original: OriginalParamSnapshot,
) {
const isValid = (p: any) =>
p.name && String(p.name).trim() !== '' && p.value && String(p.value).trim() !== '';
const changeById = new Map(changes.map((c) => [Number(c.id), c]));
const removedIds = new Set(
changes.filter((c) => c.optionType === 2).map((c) => Number(c.id)),
);
if (!isEditMode) {
if (changes.length) return changes;
if (changes.length) return changes.filter(isPersistableFormKvRow);
return (tableData || [])
.map((item) => mapFormKvRow(stripFormKvMeta(item), 1, fieldPosition))
.filter(isValid);
.filter(isPersistableFormKvRow);
}
const result: any[] = [];
changes.filter((c) => c.optionType === 2).forEach((c) => result.push(c));
const presentServerIds = new Set<number>();
(tableData || []).forEach((item) => {
const rest = stripFormKvMeta(item);
const id = normalizeRowId(rest);
@ -435,6 +442,8 @@ function buildFormKvParameterList(
const tracked = changeById.get(id);
if (tracked && tracked.optionType !== 2) {
result.push(tracked);
const trackedServerId = Number(tracked.id);
if (trackedServerId > 0) presentServerIds.add(trackedServerId);
return;
}
@ -443,6 +452,7 @@ function buildFormKvParameterList(
const baseline = serverId ? original.rowById.get(serverId) : undefined;
if (serverId && baseline) {
presentServerIds.add(serverId);
if (!isSameFormKvRow(rest, baseline)) {
result.push(mapFormKvRow(rest, 3, fieldPosition, serverId));
}
@ -454,7 +464,22 @@ function buildFormKvParameterList(
}
});
return result.filter(isValid);
// VXE removeRecords 丢失时,仍按「原始快照有、当前表无」补删,避免库里残留
original.ids.forEach((serverId) => {
if (removedIds.has(serverId) || presentServerIds.has(serverId)) return;
const baseline = original.rowById.get(serverId);
if (!baseline?.name) return;
result.push({
id: serverId,
name: baseline.name,
value: baseline.alias || '',
description: baseline.description || '',
optionType: 2,
fieldPosition,
});
});
return result.filter(isPersistableFormKvRow);
}
function mapFormKvChanges(
@ -476,9 +501,7 @@ function mapFormKvChanges(
...(actionRows.updateRecords || []).map((r) => mapRow(r, 3)),
];
return list.filter(
(p) => p.name && String(p.name).trim() !== '' && p.value && String(p.value).trim() !== '',
);
return list.filter(isPersistableFormKvRow);
}
export interface InterfaceDetailSnapshot {

View File

@ -0,0 +1,49 @@
/**
* 客户登录时展示/复制仓库寄件信息:
* - 收件人后拼接客户编号(空格分隔)
* - 地址后拼接客户编号 +「号」(无空格)
* 例:张志坚 AI0A1 / …二楼AI0A1号
*/
/** 有 Sort 的升序靠前,无 Sort 的靠后,再按 id */
export function compareWarehouseSort(
a: { sort?: number | null; id?: number | string | null; value?: string | number | null },
b: { sort?: number | null; id?: number | string | null; value?: string | number | null },
): number {
const aHas = a.sort != null && Number.isFinite(Number(a.sort));
const bHas = b.sort != null && Number.isFinite(Number(b.sort));
if (aHas !== bHas) return aHas ? -1 : 1;
if (aHas && bHas) {
const diff = Number(a.sort) - Number(b.sort);
if (diff !== 0) return diff;
}
const aId = Number(a.id ?? a.value ?? 0);
const bId = Number(b.id ?? b.value ?? 0);
return aId - bId;
}
export function formatWarehouseShipRecipient(
recipient?: string | null,
customerCode?: string | null,
): string {
const base = String(recipient ?? '').trim();
const code = String(customerCode ?? '').trim();
if (!code) return base || '-';
if (!base) return code;
if (base === code || base.endsWith(` ${code}`) || base.endsWith(code)) return base;
return `${base} ${code}`;
}
export function formatWarehouseShipAddress(
address?: string | null,
customerCode?: string | null,
): string {
const base = String(address ?? '').trim();
const code = String(customerCode ?? '').trim();
if (!code) return base || '-';
const withHao = `${code}号`;
if (!base) return withHao;
if (base.endsWith(withHao)) return base;
if (base.endsWith(code)) return `${base}号`;
return `${base}${withHao}`;
}

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
getServiceProviderAccountGetAccountPageList,
getServiceProviderAccountGetFieldList,
@ -18,6 +19,8 @@
name: 'AccountList',
});
const { t } = useI18n();
const emit = defineEmits<{
(e: 'saved', payload: { id: number; name?: string; code?: string }): void;
}>();
@ -487,7 +490,8 @@
>
<div class="account-list-body">
<a-alert type="info" :show-icon="false" class="modal-form-tip">
配置服务商账户凭证;可先自定义表头再录入。修改后点确定保存,发布后才生效。
<p>配置服务商账户凭证;可先自定义表头再录入。修改后点确定保存,发布后才生效。</p>
<p>{{ t('system.interface.mapping.table.paramValueTip') }}</p>
</a-alert>
<div class="form-section-title">账户凭证</div>
@ -670,6 +674,14 @@
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
p {
margin: 0;
}
p + p {
margin-top: 6px;
}
}
.form-section-title {

View File

@ -8,7 +8,6 @@
getGetMapParameterCascadedList,
} from '@/api/InterfaceMappingServices/interfaceMap';
import { getParameterGetParameterList } from '@/api/ParameterServices/parameter';
import AddParameter from './AddParameter.vue';
import MapAiAssistModal from './MapAiAssistModal.vue';
@ -41,52 +40,21 @@
const tableRef1 = ref();
const active = ref(0);
const AddParameterRef = ref();
const formsTypeList_new = ref<any[]>([]);
const maxCompletedIndex = ref<number>(-1);
const outputAttention = ref(false);
const outputStepRef = ref<HTMLElement | null>(null);
let outputAttentionTimer: ReturnType<typeof setTimeout> | null = null;
/** 当前已加载的映射上下文,避免切 tab 重复拉取 */
const loadedContextKey = ref('');
let initSeq = 0;
let inflightInit: Promise<void> | null = null;
let inflightInitKey = '';
const detailLoading = ref(false);
interface Indexable {
[key: string]: any;
}
// 请求去重:记录正在请求的接口ID
const loadingParameterId = ref<number | null>(null);
async function formsValueInfoGetList() {
// 如果正在加载相同的接口,直接返回
if (loadingParameterId.value === props.interfaceId) {
return;
}
// 如果没有接口ID,直接返回
if (!props.interfaceId) {
return;
}
loadingParameterId.value = props.interfaceId;
try {
const { data } = await getParameterGetParameterList({
isInPara: true,
id: Number(props.interfaceId),
onlyLeafs: true,
});
formsTypeList_new.value = data;
formsTypeList.value = data;
} catch (error) {
} finally {
loadingParameterId.value = null;
}
}
// 监听 interfaceId 变化,只在变化时请求
watch(() => props.interfaceId, (newId) => {
if (newId) {
formsValueInfoGetList();
}
}, { immediate: true });
const tableProps1 = computed(() => {
return {
columnsKey: 'mapDetails111',
@ -168,6 +136,7 @@
title: t('system.interface.mapping.table.paramValue'),
minWidth: 120,
slots: {
header: 'fixedValueHeader',
edit: 'fixedValueEdit',
},
editRender: {},
@ -285,9 +254,17 @@
id: e.interfaceId,
};
});
invalidateCascaderOptionsCache();
};
onMounted(() => {
restaurants.value = loadAll();
// 面板首次挂载时自行拉数,避免依赖父级 setTimeout
if (props.serviceProviderId && props.interfaceId) {
void initData({
serviceProviderId: props.serviceProviderId,
interfaceId: props.interfaceId,
});
}
});
type RestaurantItem = {
value: string;
@ -328,24 +305,48 @@
return data;
}
const mapAliasEditRef = ref();
function getOptions(item: any, isReverse = false) {
if (isReverse)
return setlabel(cascadedList.value.slice(1, cascadedList.value.length));
/** 级联 options 按步骤缓存,避免每行渲染都深拷贝/打标 */
const cascaderOptionsCache = ref<Map<string, any[]>>(new Map());
function invalidateCascaderOptionsCache() {
cascaderOptionsCache.value = new Map();
}
function getOptions(item: any, isReverse = false) {
const cacheKey = isReverse
? 'reverse'
: `fwd-${item?.interfaceId ?? 'unknown'}`;
const cached = cascaderOptionsCache.value.get(cacheKey);
if (cached) return cached;
let result: any[];
if (isReverse) {
result = setlabel(cascadedList.value.slice(1, cascadedList.value.length));
} else {
const ids: number[] = inParaMapList.value.map((t) => t.interfaceId!);
const findI = ids.findIndex((i) => i === item.interfaceId);
let data: any[] = [];
if (findI === -1 || findI === 0) {
data = [cascadedList.value[0]];
data = cascadedList.value[0] ? [cascadedList.value[0]] : [];
} else {
data = [cascadedList.value[0]].concat(
cascadedList.value.filter((t) => {
return ids.slice(0, findI).includes(t.interfaceId!);
})
);
}),
).filter(Boolean);
}
return setlabel(data);
result = setlabel(data);
}
cascaderOptionsCache.value.set(cacheKey, result);
return result;
}
/** 当前入参步骤 / 返参步骤共用一份 options,模板里不要对每行再算 */
const activeInputCascaderOptions = computed(() => {
if (!activeInParaItem.value) return [];
return getOptions(activeInParaItem.value, false);
});
const outputCascaderOptions = computed(() => getOptions({ interfaceId: 0 }, true));
function mapAliasEditCheng(
e: any,
row: API.InterfaceParameterMapOutputValueObject
@ -424,7 +425,6 @@
temporaryItem.value = undefined;
emits('save-model-enter');
}
const formsTypeList = ref<any[]>([]);
// 支持多关键词(空格分隔)、大小写不敏感,匹配别名/中文名/名称/代码等字段
function filterMethod(val: string, node: any) {
const query = (val ?? '').toString().trim().toLowerCase();
@ -471,7 +471,9 @@
}
function getVali(row: Indexable) {
tableRef.value?.forEach((element: any) => {
const refs = tableRef.value;
const tables = Array.isArray(refs) ? refs : refs ? [refs] : [];
tables.forEach((element: any) => {
setTimeout(() => {
element?.editRowCellEvent(row, 'mapAlias');
element?.xGrid.scrollToRow(row, 'mapAlias');
@ -485,36 +487,77 @@
}, 300);
}
function resultsFn() {
formsTypeList_new.value = [];
formsTypeList.value = [];
mapDetails.value = [];
active.value = 0;
maxCompletedIndex.value = -1;
inParaMapList.value = [];
}
async function initData(data: any, options?: { activeIndex?: number }) {
const nextActive = options?.activeIndex;
resultsFn();
if (typeof nextActive === 'number') {
active.value = nextActive;
function buildContextKey(data: { serviceProviderId?: number; interfaceId?: number }) {
return `${data?.serviceProviderId ?? 0}:${data?.interfaceId ?? 0}`;
}
getEditMapDetails(data).then((resp) => {
const detail = (resp as any)?.data || resp || {};
function hasLoadedData() {
return !!(inParaMapList.value.length || mapDetails.value.length);
}
async function initData(
data: any,
options?: { activeIndex?: number; force?: boolean },
) {
const ctx = buildContextKey(data);
const nextActive = options?.activeIndex;
const force = !!options?.force || typeof nextActive === 'number';
// 同服务商/接口已加载过:切回映射明细 tab 时跳过重复请求
if (!force && loadedContextKey.value === ctx && hasLoadedData()) {
return;
}
// 挂载与父级同时触发时合并为一次请求
if (!force && inflightInit && inflightInitKey === ctx) {
await inflightInit;
return;
}
const seq = ++initSeq;
detailLoading.value = true;
const run = (async () => {
try {
const [detailResp] = await Promise.all([
getEditMapDetails(data),
getCascadedList(data),
]);
if (seq !== initSeq) return;
const detail = (detailResp as any)?.data || detailResp || {};
inParaMapList.value = detail.inParaMapList || [];
mapDetails.value = detail.mapDetails || [];
});
loadedContextKey.value = ctx;
invalidateCascaderOptionsCache();
getCascadedList(data);
if (typeof nextActive === 'number') {
active.value = nextActive;
} else {
active.value = 0;
maxCompletedIndex.value = -1;
}
} finally {
if (seq === initSeq) detailLoading.value = false;
}
})();
setActive(active.value);
inflightInit = run;
inflightInitKey = ctx;
try {
await run;
} finally {
if (inflightInit === run) {
inflightInit = null;
inflightInitKey = '';
}
}
}
defineExpose({
tableRef,
getdata,
initData,
hasLoadedData,
getVali,
getReturnVali,
advanceAfterSave,
@ -640,20 +683,38 @@
<span class="output-hint-action">{{ t('system.interface.mapping.outputMappingGoFill') }}</span>
</button>
<div class="content-pane">
<a-spin :loading="detailLoading" class="map-detail-spin">
<div class="content-scroll">
<div class="content-body">
<template v-for="(item, index) in inParaMapList" :key="index">
<div v-show="active === index" class="map-table-wrapper">
<!-- 只挂载当前步骤表格,避免多步骤×多参数同时渲染 -->
<div
v-if="activeInParaItem && active >= 0"
:key="`in-${activeInParaItem.interfaceMapId || active}`"
class="map-table-wrapper"
>
<div class="map-scroll">
<TablePro
ref="tableRef"
v-bind="tableProps"
class="map-table"
:table-config="{ data: item?.mapDetails || [] }"
:table-config="{ data: activeInParaItem?.mapDetails || [] }"
>
<template #toolbar_right>
<slot name="toolbar_right" />
</template>
<template #fixedValueHeader>
<span class="param-value-header">
{{ t('system.interface.mapping.table.paramValue') }}
<a-tooltip position="top" :content-style="{ maxWidth: '320px' }">
<template #content>
<div class="param-value-help">
{{ t('system.interface.mapping.table.paramValueTip') }}
</div>
</template>
<IconQuestionCircle class="param-value-help-icon" @click.stop />
</a-tooltip>
</span>
</template>
<template #fixedValueEdit="{ row }">
<a-auto-complete
v-model="row.fixedValue"
@ -677,7 +738,7 @@
<a-cascader
ref="mapAliasEditRef"
v-model="row.mapId"
:options="getOptions(item, false)"
:options="activeInputCascaderOptions"
:filter-option="filterMethod"
allow-clear
allow-search
@ -712,9 +773,7 @@
</TablePro>
</div>
</div>
</template>
<template v-if="active === -1">
<div class="map-table-wrapper">
<div v-else-if="active === -1" class="map-table-wrapper">
<a-alert
v-if="outputNeedsAttention"
type="warning"
@ -734,11 +793,10 @@
</span>
</template>
<template #mapAlias="{ row }">
<a-cascader
ref="mapAliasEditRef"
v-model="row.mapId"
:options="getOptions({ interfaceId: 0 }, true)"
:options="outputCascaderOptions"
:filter-option="filterMethod"
allow-search
allow-clear
@ -766,9 +824,9 @@
</TablePro>
</div>
</div>
</template>
</div>
</div>
</a-spin>
</div>
<AddParameter ref="AddParameterRef" @seva="save" />
<MapAiAssistModal
@ -783,6 +841,13 @@
</template>
<style scoped>
.entered-map { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; }
.map-detail-spin { display: block; width: 100%; height: 100%; min-height: 0; }
.map-detail-spin :deep(.arco-spin-children) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.steps-bar {
display: flex;
align-items: center;
@ -821,13 +886,24 @@
white-space: nowrap;
}
.unequal-help-icon,
.script-help-icon {
.script-help-icon,
.param-value-help-icon {
color: var(--color-text-3);
font-size: 14px;
cursor: help;
}
.param-value-header {
display: inline-flex;
align-items: center;
gap: 4px;
}
.param-value-help {
font-size: 12px;
line-height: 1.5;
}
.unequal-help-icon:hover,
.script-help-icon:hover {
.script-help-icon:hover,
.param-value-help-icon:hover {
color: var(--app-primary-color);
}
.step-item { position: relative; display: flex; align-items: center; color: var(--color-text-3); cursor: pointer; flex-shrink: 0; }

View File

@ -119,10 +119,10 @@
}
function handleSearch() {
loadList();
loadList({ keepSelection: true });
}
async function loadList() {
async function loadList(options?: { keepSelection?: boolean }) {
if (!props.id) {
list.value = [];
selectedId.value = null;
@ -141,10 +141,14 @@
const raw: any = response || {};
const data: any = raw.items ?? raw.data ?? raw.list ?? raw.records ?? raw;
list.value = Array.isArray(data) ? data : [];
// 仅当当前选中项在新列表中不存在时才清空,避免“点击一次又掉选中”
if (
if (!options?.keepSelection) {
// 切换上游接口时不默认选中第一条映射
selectedId.value = null;
} else if (
selectedId.value !== null &&
!list.value.some((row: any, index: number) => getRowKey(row, index) === selectedId.value)
!list.value.some(
(row: any, index: number) => String(getRowKey(row, index)) === String(selectedId.value),
)
) {
selectedId.value = null;
}
@ -157,19 +161,21 @@
}
watch(() => props.id, (id) => {
if (id) loadList();
else {
list.value = [];
// 换接口先清选中,避免沿用上一次的服务商高亮
selectedId.value = null;
}
if (id) loadList({ keepSelection: false });
else list.value = [];
}, { immediate: true });
// 暴露与旧用法兼容的 reload 接口
tableRef.value = { reload: loadList };
// 暴露与旧用法兼容的 reload:默认保留当前选中(发布/保存后刷新不丢高亮)
tableRef.value = {
reload: (options?: { keepSelection?: boolean }) =>
loadList({ keepSelection: options?.keepSelection !== false }),
};
function setSearchKeyword(val: string) {
searchKeyword.value = val || '';
loadList();
loadList({ keepSelection: true });
}
// 右侧内容
@ -191,7 +197,7 @@
serviceProviderId: row.serviceProviderId,
}).then(() => {
$baseMessage(t('system.interface.message.deleteSuccess'), 'success');
loadList();
loadList({ keepSelection: false });
});
},
});
@ -350,6 +356,8 @@
);
registerReleaseMethods.closeModal();
// 发布后刷新列表,保留当前服务商选中
void loadList({ keepSelection: true });
},
});
@ -390,7 +398,7 @@
:show-node-index="true"
:show-node-code="true"
@search="handleSearch"
@refresh="loadList"
@refresh="() => loadList({ keepSelection: true })"
@add="handleAdd"
@select="handleMenuSelect"
@node-click="handleNodeClick"

View File

@ -53,6 +53,8 @@
const active = ref(0);
const activeContentTab = ref('detail'); // 接口明细、API调试、映射明细
const currentActiveTab = ref('detail'); // 用于保存按钮判断的当前tab
/** 映射明细首次打开后保持挂载,避免反复销毁重建 */
const mappingPanelReady = ref(false);
const apiDebuggerRef = ref<any>();
const apiDebuggerRefreshKey = ref(0);
@ -1147,10 +1149,11 @@
// 将上游接口ID同步给右侧明细区域
upStreamIdRef.value = Number(nodeData.id);
// 重置映射明细区域,确保EnteredMap组件重新渲染
// 重置映射明细区域
serviceProviderId.value = 0;
serviceProviderName.value = '';
MapServiceProviderROW.value = null;
mappingPanelReady.value = false;
// 未选服务商时映射/调试不可用,回到接口明细
if (activeContentTab.value === 'mapping' || activeContentTab.value === 'debugger') {
activeContentTab.value = 'detail';
@ -1159,7 +1162,8 @@
// 刷新右侧映射服务商列表
nextTick(() => {
if (MapServiceProviderRef.value && MapServiceProviderRef.value.tableRef) {
MapServiceProviderRef.value.tableRef.reload();
// 切换上游接口:清空中间映射选中,不默认第一条
MapServiceProviderRef.value.tableRef.reload({ keepSelection: false });
}
});
@ -1262,17 +1266,22 @@
initInterfaceConfig();
}, 100);
} else if (newTab === 'mapping') {
// 切换回映射明细tab时,重新初始化EnteredMap组件
mappingPanelReady.value = true;
await nextTick();
await nextTick();
setTimeout(() => {
if (enteredMapRef.value && selectedInterface.value && upStreamIdRef.value) {
(unref(enteredMapRef) as any)?.initData?.({
const map = unref(enteredMapRef) as any;
// 已缓存同一服务商映射时不再重复拉数;首次挂载或未加载时再 init
if (
map &&
selectedInterface.value &&
upStreamIdRef.value &&
serviceProviderId.value &&
!map.hasLoadedData?.()
) {
await map.initData?.({
serviceProviderId: serviceProviderId.value,
interfaceId: upStreamIdRef.value,
});
}
}, 100);
}
});
@ -1566,10 +1575,14 @@
<div class="interface-detail-tab-content">
<template v-if="selectedInterface">
<!-- 映射明细内容 -->
<div v-if="activeContentTab === 'mapping'" class="mapping-content">
<!-- 映射明细:首次打开后 v-show 保活,避免切 tab 整表重挂 -->
<div
v-if="mappingPanelReady && serviceProviderId"
v-show="activeContentTab === 'mapping'"
class="mapping-content"
>
<EnteredMap
:key="`entered-map-${selectedInterface?.id}-${serviceProviderId}-${activeContentTab}`"
:key="`entered-map-${selectedInterface?.id}-${serviceProviderId}`"
ref="enteredMapRef"
class="h-full w-full"
:service-provider-id="serviceProviderId"

View File

@ -325,40 +325,67 @@
</p>
<div v-if="form.inboundMethod === 'selfShip'" class="h5-po__wh-card">
<div class="h5-po__wh-head">
<strong>{{ t('h5.placeOrder.selfShipAddressTitle') }}</strong>
</div>
<p v-if="!warehouseOptions.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoWarehouse') }}
</p>
<p v-else-if="!warehousesWithShipAddress.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoAddressConfigured') }}
</p>
<template v-else>
<div class="h5-po__wh-list">
<div
v-for="opt in warehouseOptions"
:key="opt.value"
class="h5-po__wh-item"
:class="{
'is-selected': String(opt.value) === String(form.warehouseId ?? ''),
'is-empty': !warehouseHasAddress(opt),
}"
>
<div class="h5-po__wh-item-head">
<strong>
{{ t('h5.placeOrder.selfShipAddressTitle') }}
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
{{ opt.label || opt.value }}
<span
v-if="String(opt.value) === String(form.warehouseId ?? '')"
class="h5-po__wh-badge"
>
{{ t('h5.placeOrder.selfShipCurrent') }}
</span>
</strong>
<button
v-if="selectedWarehouseHasAddress"
v-if="warehouseHasAddress(opt)"
type="button"
class="h5-po__wh-copy"
@click="copyWarehouseShipAddress"
@click="copyWarehouseShipAddress(opt)"
>
{{ t('h5.placeOrder.selfShipCopy') }}
</button>
</div>
<p v-if="!selectedWarehouseOption" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipSelectWarehouse') }}
<p v-if="!warehouseHasAddress(opt)" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipAddressEmpty') }}
</p>
<template v-else-if="!selectedWarehouseHasAddress">
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressEmpty') }}</p>
</template>
<template v-else>
<p>
<span>{{ t('h5.placeOrder.selfShipRecipient') }}</span>
{{ selectedWarehouseOption.recipient || '-' }}
{{ displayWarehouseRecipient(opt) }}
</p>
<p>
<span>{{ t('h5.placeOrder.selfShipPhone') }}</span>
{{ selectedWarehouseOption.contactPhone || '-' }}
{{ opt.contactPhone || '-' }}
</p>
<p class="is-addr">
<span>{{ t('h5.placeOrder.selfShipAddress') }}</span>
{{ selectedWarehouseOption.shippingAddress || '-' }}
{{ displayWarehouseShippingAddress(opt) }}
</p>
<p v-if="opt.remark">
<span>{{ t('h5.placeOrder.selfShipRemark') }}</span>
{{ opt.remark }}
</p>
</template>
</div>
</div>
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressHint') }}</p>
</template>
</div>
@ -1153,6 +1180,7 @@ import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesP
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { resolveOmsAssetUrl } from '@/api/oms/common';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import {
@ -1160,6 +1188,13 @@ import {
normalizeSkuImageForStorage,
pickField,
} from '@/utils/warehouseListMapper';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { h5Navigate } from '@/utils/device';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
import {
@ -1306,6 +1341,8 @@ const warehouseOptions = ref<
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
sort?: number | null;
remark?: string;
}[]
>([]);
const countryOptions = ref<{ value: string; label: string }[]>([]);
@ -1526,25 +1563,49 @@ function skuImageSrc(url?: string | null) {
return resolveOmsAssetUrl(url || '') || '';
}
const selectedWarehouseOption = computed(() => {
const id = String(form.warehouseId ?? '').trim();
if (!id) return undefined;
return warehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
type WarehouseOption = (typeof warehouseOptions.value)[number];
const rbacUserStore = useRbacUserStore();
const currentCustomerCode = ref('');
async function loadCurrentCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
function displayWarehouseRecipient(opt?: WarehouseOption) {
return formatWarehouseShipRecipient(opt?.recipient, currentCustomerCode.value);
}
function displayWarehouseShippingAddress(opt?: WarehouseOption) {
return formatWarehouseShipAddress(opt?.shippingAddress, currentCustomerCode.value);
}
function warehouseHasAddress(opt?: WarehouseOption) {
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
}
async function copyWarehouseShipAddress() {
const opt = selectedWarehouseOption.value;
if (!opt) return;
const warehousesWithShipAddress = computed(() =>
warehouseOptions.value.filter((opt) => warehouseHasAddress(opt)),
);
async function copyWarehouseShipAddress(opt?: WarehouseOption) {
if (!opt || !warehouseHasAddress(opt)) {
Message.warning(t('h5.placeOrder.selfShipAddressEmpty'));
return;
}
const text = [
opt.label || '',
`${t('h5.placeOrder.selfShipRecipient')}:${opt.recipient || '-'}`,
`${t('h5.placeOrder.selfShipRecipient')}:${displayWarehouseRecipient(opt)}`,
`${t('h5.placeOrder.selfShipPhone')}:${opt.contactPhone || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${opt.shippingAddress || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${displayWarehouseShippingAddress(opt)}`,
]
.filter(Boolean)
.join('\n');
@ -3172,15 +3233,20 @@ async function loadOptions(
}
return '';
};
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
value: String(id ?? ''),
label: name || String(id),
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick('remark', 'Remark'),
};
})
.filter((x) => x.value);
.filter((x) => x.value)
.sort(compareWarehouseSort);
if (!opts?.skipDefaults && warehouseOptions.value.length && !form.warehouseId) {
form.warehouseId = warehouseOptions.value[0].value;
}
@ -3342,6 +3408,7 @@ watch(
return;
}
resetForm();
void loadCurrentCustomerCode();
if (isEditMode.value) {
await loadOptions(null, { skipDefaults: true, skipEnsureClientNo: true });
await loadSenders({ skipAutoSelect: true });
@ -3838,6 +3905,61 @@ watch(
}
}
.h5-po__wh-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.h5-po__wh-item {
padding: 10px;
border-radius: 10px;
border: 1px solid #e5e6eb;
background: #f7f8fa;
&.is-selected {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
&.is-empty {
opacity: 0.72;
}
}
.h5-po__wh-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1d2129;
line-height: 20px;
}
}
.h5-po__wh-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.h5-po__wh-copy {
border: none;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
@ -3847,9 +3969,11 @@ watch(
height: 28px;
padding: 0 10px;
border-radius: 14px;
flex-shrink: 0;
}
.h5-po__wh-card p {
.h5-po__wh-card p,
.h5-po__wh-item p {
margin: 8px 0 0;
font-size: 13px;
color: #1d2129;
@ -3863,6 +3987,10 @@ watch(
}
}
.h5-po__wh-item .h5-po__hint {
margin-top: 4px;
}
.h5-po__more-panel {
display: flex;
flex-direction: column;

View File

@ -35,11 +35,15 @@
{{ t('h5.journey.warehouse.copy') }}
</button>
</div>
<p><span>{{ t('h5.journey.warehouse.recipient') }}</span>{{ item.recipient || '-' }}</p>
<p><span>{{ t('h5.journey.warehouse.recipient') }}</span>{{ displayRecipient(item) }}</p>
<p><span>{{ t('h5.journey.warehouse.phone') }}</span>{{ item.contactPhone || '-' }}</p>
<p class="is-addr">
<span>{{ t('h5.journey.warehouse.address') }}</span>
{{ item.shippingAddress || '-' }}
{{ displayShippingAddress(item) }}
</p>
<p v-if="item.remark">
<span>{{ t('h5.journey.warehouse.remark') }}</span>
{{ item.remark }}
</p>
</article>
</a-spin>
@ -53,6 +57,14 @@ import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose, IconHome } from '@arco-design/web-vue/es/icon';
import { getWarehouseList, type WarehouseDto } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5WarehouseAddressSheet' });
@ -61,8 +73,10 @@ const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{ 'update:visible': [boolean] }>();
const { t } = useI18n();
const rbacUserStore = useRbacUserStore();
const loading = ref(false);
const list = ref<WarehouseDto[]>([]);
const currentCustomerCode = ref('');
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
@ -82,20 +96,46 @@ function pick(raw: any, ...keys: string[]) {
}
function normalize(row: any): WarehouseDto {
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
id: row?.id ?? row?.Id,
warehouseName: pick(row, 'warehouseName', 'WarehouseName'),
recipient: pick(row, 'recipient', 'Recipient'),
contactPhone: pick(row, 'contactPhone', 'ContactPhone'),
shippingAddress: pick(row, 'shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick(row, 'remark', 'Remark') || undefined,
};
}
function displayRecipient(item: WarehouseDto) {
return formatWarehouseShipRecipient(item.recipient, currentCustomerCode.value);
}
function displayShippingAddress(item: WarehouseDto) {
return formatWarehouseShipAddress(item.shippingAddress, currentCustomerCode.value);
}
async function loadCurrentCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
async function loadList() {
loading.value = true;
try {
const res = await getWarehouseList({ page: 1, pageSize: 100 });
list.value = (res.items ?? []).map(normalize);
const [res] = await Promise.all([
getWarehouseList({ page: 1, pageSize: 100 }),
loadCurrentCustomerCode(),
]);
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort);
} catch {
list.value = [];
} finally {
@ -106,9 +146,9 @@ async function loadList() {
async function copyItem(item: WarehouseDto) {
const text = [
item.warehouseName,
`${t('h5.journey.warehouse.recipient')}${item.recipient || '-'}`,
`${t('h5.journey.warehouse.recipient')}${displayRecipient(item)}`,
`${t('h5.journey.warehouse.phone')}${item.contactPhone || '-'}`,
`${t('h5.journey.warehouse.address')}${item.shippingAddress || '-'}`,
`${t('h5.journey.warehouse.address')}${displayShippingAddress(item)}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);

View File

@ -406,13 +406,17 @@ export default {
'h5.placeOrder.selfShipRecipient': 'Recipient',
'h5.placeOrder.selfShipPhone': 'Phone',
'h5.placeOrder.selfShipAddress': 'Address',
'h5.placeOrder.selfShipRemark': 'Remark',
'h5.placeOrder.selfShipCopy': 'Copy',
'h5.placeOrder.selfShipCopySuccess': 'Warehouse shipping info copied',
'h5.placeOrder.selfShipSelectWarehouse': 'Select a warehouse first',
'h5.placeOrder.selfShipAddressEmpty':
'This warehouse has no shipping address yet. Contact support or choose another warehouse.',
'h5.placeOrder.selfShipCurrent': 'Current',
'h5.placeOrder.selfShipNoWarehouse': 'No warehouse info yet. Retry later or contact support.',
'h5.placeOrder.selfShipNoAddressConfigured':
'No warehouse has a shipping address configured. Contact support.',
'h5.placeOrder.selfShipAddressEmpty': 'No shipping address configured',
'h5.placeOrder.selfShipAddressHint':
'Ship to this address yourself. Put the client number on the courier label so the warehouse can match it.',
'Ship to the selected warehouse address yourself. Put the client number on the courier label so the warehouse can match it.',
'h5.placeOrder.pickupAddress': 'Pickup address',
'h5.placeOrder.pickupAddressPh': 'Select a saved sender address',
'h5.placeOrder.expectPickupTime': 'Expected pickup time',
@ -792,6 +796,7 @@ export default {
'h5.journey.warehouse.recipient': 'Recipient: ',
'h5.journey.warehouse.phone': 'Phone: ',
'h5.journey.warehouse.address': 'Address: ',
'h5.journey.warehouse.remark': 'Remark: ',
'h5.journey.ship.title': 'Pickup / ship to warehouse',
'h5.journey.ship.lead': 'Create a logistics order first. Then either self-ship to the warehouse, or add a sender address and request pickup.',

View File

@ -404,11 +404,15 @@ export default {
'h5.placeOrder.selfShipRecipient': '收件人',
'h5.placeOrder.selfShipPhone': '电话',
'h5.placeOrder.selfShipAddress': '地址',
'h5.placeOrder.selfShipRemark': '备注',
'h5.placeOrder.selfShipCopy': '复制',
'h5.placeOrder.selfShipCopySuccess': '仓库寄件信息已复制',
'h5.placeOrder.selfShipSelectWarehouse': '请先选择仓库',
'h5.placeOrder.selfShipAddressEmpty': '该仓库暂未配置收货地址,请联系客服或更换仓库',
'h5.placeOrder.selfShipAddressHint': '按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'h5.placeOrder.selfShipCurrent': '当前',
'h5.placeOrder.selfShipNoWarehouse': '暂无仓库信息,请稍后重试或联系客服',
'h5.placeOrder.selfShipNoAddressConfigured': '暂无已配置收货地址的仓库,请联系客服',
'h5.placeOrder.selfShipAddressEmpty': '暂未配置收货地址',
'h5.placeOrder.selfShipAddressHint': '按所选仓库地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'h5.placeOrder.pickupAddress': '取件地址',
'h5.placeOrder.pickupAddressPh': '请选择发件地址簿',
'h5.placeOrder.expectPickupTime': '期望揽收时间',
@ -778,6 +782,7 @@ export default {
'h5.journey.warehouse.recipient': '收件人:',
'h5.journey.warehouse.phone': '电话:',
'h5.journey.warehouse.address': '地址:',
'h5.journey.warehouse.remark': '备注:',
'h5.journey.ship.title': '揽收寄仓',
'h5.journey.ship.lead': '请先创建物流订单。两种方式任选:自己快递寄到仓库,或填写寄件地址后预约上门揽收。',

View File

@ -36,7 +36,7 @@
'login.hero.title': 'IDE',
'login.hero.tagline': 'Faster · Simpler · Professional',
'login.hero.desc.customer':
'查价说得清、下单走得顺、轨迹跟得上。AI 帮你看风险、提建议、少踩坑——从揽收到签收,IDE 即配陪你把每一票发好、盯紧、交代清楚。',
'查价说得清、下单走得顺、轨迹跟得上。AI 帮你看风险、提建议、少踩坑——从揽收到签收,IDE 陪你把每一票发好、盯紧、交代清楚。',
'login.hero.admin.brief': '开工前对齐三件事,并让 AI 成为搭档',
'login.hero.admin.pillar.care.title': '更专业',
'login.hero.admin.pillar.care.desc': '线路选对 · 异常不丢 · 全程说得清',

View File

@ -145,7 +145,7 @@
apply: [payload: { activeId: string; schemes: ColumnScheme[] }];
}>();
const { t } = useI18n();
const { t, te } = useI18n();
const [registerModal, { openModal, closeModal, setModalProps }] = useModal();
const editingSchemeId = ref('');
@ -173,8 +173,9 @@
selectedKeys.value
.map((key) => {
const col = catalogMap.value.get(key);
if (!col) return null;
return { key: col.key, label: resolveColumnLabel(col, t) };
// 方案里可能有目录尚未收录的 key:仍用中文兜底,避免表头/配置显示英文字段名
const def = col || { key };
return { key, label: resolveColumnLabel(def, t, te) };
})
.filter(Boolean) as { key: string; label: string }[],
);
@ -183,7 +184,7 @@
const selected = new Set(selectedKeys.value);
return props.catalog
.filter((col) => !selected.has(col.key))
.map((col) => ({ key: col.key, label: resolveColumnLabel(col, t) }));
.map((col) => ({ key: col.key, label: resolveColumnLabel(col, t, te) }));
});
function readSelectedKeysFromDom(): string[] {

View File

@ -32,6 +32,8 @@ export const ORDER_DETAIL_VALUE_FIELD_MAP: Record<string, string[]> = {
declaredCurrency: ['declaredCurrency', 'DeclaredCurrency'],
declaredSum: ['declaredSum', 'DeclaredSum'],
additionalServices: ['additionalServices', 'AdditionalServices'],
downstreamSupplierName: ['downstreamSupplierName', 'DownstreamSupplierName'],
downstreamSupplierCode: ['downstreamSupplierCode', 'DownstreamSupplierCode'],
};
export interface OrderDetailFieldGroup {
@ -58,7 +60,9 @@ export function patchOrderBasicFieldGroups(
const fields = [
'productCode',
'productName',
...(includeChannel ? (['channelCode', 'channelName'] as const) : []),
...(includeChannel
? (['channelCode', 'channelName', 'downstreamSupplierName', 'downstreamSupplierCode'] as const)
: []),
'warehouse',
'consigneeCountry',
'consigneeCountryName',

View File

@ -1,6 +1,7 @@
/** 我的订单列表字段配置(本地命名方案,最多显示 30 列) */
import { filterMyOrdersFieldKeys, isMyOrdersHiddenField, MY_ORDERS_HIDDEN_FIELD_KEYS } from './customerFieldMask';
import { getOrderColumnLabelZh } from '../orderColumnLabelsZh';
export const MAX_VISIBLE_COLUMNS = 30;
@ -182,29 +183,67 @@ export function getActiveScheme(state: ColumnConfigState): ColumnScheme {
);
}
function isUsableLabel(text: string | undefined | null, fieldKey: string): text is string {
if (!text) return false;
const s = String(text).trim();
if (!s) return false;
// 翻译失败时 vue-i18n 常回传 i18n key 或字段 key
if (s === fieldKey) return false;
if (s.startsWith('order.') && s.includes('.')) return false;
return true;
}
export function resolveColumnLabel(
col: OrderListColumnDef,
t: (key: string) => string,
t: (key: string, ...args: any[]) => string,
_te?: (key: string) => boolean,
): string {
if (col.labelKey) return t(col.labelKey);
if (col.label) return col.label;
return col.key;
const fieldKey = col.key;
const tryI18n = (i18nKey: string) => {
try {
const text = t(i18nKey);
return isUsableLabel(text, fieldKey) ? text : null;
} catch {
return null;
}
};
if (col.labelKey) {
const byLabelKey = tryI18n(col.labelKey);
if (byLabelKey) return byLabelKey;
}
if (isUsableLabel(col.label, fieldKey)) return col.label!.trim();
const autoKeys = [
`order.waybillManage.table.${fieldKey}`,
`order.myOrders.table.${fieldKey}`,
`order.myOrders.drawer.${fieldKey}`,
];
for (const k of autoKeys) {
const hit = tryI18n(k);
if (hit) return hit;
}
// 最终兜底:中文列名表,绝不直接返回 camelCase key
return getOrderColumnLabelZh(fieldKey);
}
export function buildHotColumns(
catalog: OrderListColumnDef[],
keys: string[],
t: (key: string) => string,
t: (key: string, ...args: any[]) => string,
te?: (key: string) => boolean,
) {
const map = new Map(catalog.map((c) => [c.key, c]));
return keys
.map((key) => map.get(key))
.filter(Boolean)
.map((col) => ({
key: col!.key,
label: resolveColumnLabel(col!, t),
width: col!.width ?? 120,
}));
.map((key) => {
const col = map.get(key) || { key, width: 120 };
return {
key,
label: resolveColumnLabel(col, t, te),
width: col.width ?? 120,
};
})
.filter((col) => !!col.key);
}
/** 将接口行中的标量字段并入可选列目录(跳过对象/数组与内部字段) */
@ -228,10 +267,19 @@ export function mergeDiscoveredColumns(
for (const [key, value] of Object.entries(row)) {
if (!key || skip.has(key) || map.has(key)) continue;
if (value != null && typeof value === 'object') continue;
map.set(key, { key, label: key, width: 120 });
map.set(key, {
key,
label: getOrderColumnLabelZh(key),
labelKey: `order.waybillManage.table.${key}`,
width: 120,
});
}
}
return Array.from(map.values());
return Array.from(map.values()).map((col) =>
isUsableLabel(col.label, col.key)
? col
: { ...col, label: getOrderColumnLabelZh(col.key) },
);
}
export function createSchemeId() {

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';
@ -985,6 +1055,10 @@ const tabList = computed(() => [
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
]);
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 })),
);
@ -992,6 +1066,8 @@ const slidingTabItems = computed(() =>
function resolveDrawerTab(tab?: string) {
const next = tab || 'basic';
if (next === 'weight' || next === 'fee') return 'weightFee';
// 我的运单不展示操作日志(管理端运单管理保留)
if (next === 'operationLog') return 'basic';
return next;
}
@ -1987,6 +2063,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 +2118,9 @@ function handleTabChange(tabKey: string) {
weightViewMode.value = 'all';
void ensureFeeItemDictLoaded();
}
if (next === 'operationLog') {
void loadOperationLogs();
}
}
function handleCancel() {
@ -2024,6 +2142,9 @@ watch(
if (activeTab.value === 'weightFee') {
void ensureFeeItemDictLoaded();
}
if (activeTab.value === 'operationLog') {
void loadOperationLogs();
}
}
},
{ immediate: true, deep: true }
@ -3117,4 +3238,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

@ -7,6 +7,7 @@ export const MY_ORDERS_HIDDEN_FIELD_KEYS = new Set([
'channelName',
'downstreamProvider',
'downstreamSupplierCode',
'downstreamSupplierName',
'downstreamAppKey',
'downstreamAppId',
'channelForecastCode',

View File

@ -122,7 +122,7 @@
name: 'MyOrders',
});
const { t } = useI18n();
const { t, te } = useI18n();
const route = useRoute();
const router = useRouter();
const isFulfillList = computed(() => route.meta.listMode === 'fulfill');
@ -1623,7 +1623,7 @@
'trackNo',
]);
const base = withServerFilterColumns(
buildHotColumns(listColumnCatalog.value, activeColumnKeys.value, t),
buildHotColumns(listColumnCatalog.value, activeColumnKeys.value, t, te),
ORDER_SERVER_FILTER_KEYS,
(col) => {
if (numberKeys.has(col.key)) {

View File

@ -0,0 +1,161 @@
/**
* 运单/订单列表列中文名(表头 + 字段配置兜底)。
* 优先保证中文展示,避免出现 maxTimeliness 等英文字段名。
*/
export const ORDER_COLUMN_LABEL_ZH: Record<string, string> = {
// 常用别名
customerName: '客户名称',
customerCode: '客户编码',
waybillNo: '运单号',
customerOrderNo: '客户单号',
trackingNo: '跟踪号',
downstreamServiceNo: '服务商单号',
shippingMethod: '销售产品',
serviceChannel: '服务渠道',
consignee: '收件人',
consigneePhone: '收件电话',
consigneeCity: '收件城市',
consigneeAddress: '收件地址',
consigneePostcode: '收件邮编',
consigneeState: '收件省州',
ioss: 'IOSS',
eori: 'EORI',
// OrderOutput 标量
id: '订单ID',
clientNo: '客户单号',
trackNo: '跟踪号',
serviceNo: '运单号',
productCode: '产品编码',
productName: '销售产品',
maxTimeliness: '最大时效(天)',
channelCode: '渠道编码',
channelName: '服务渠道',
downstreamSupplierName: '服务商名称',
downstreamSupplierCode: '服务商编码',
downstreamAppKey: '下游AppKey',
downstreamAppId: '下游AppId',
channelForecastCode: '渠道预报编码',
consigneeCountry: '收件国家',
consigneeCountryName: '收件国家名称',
cargoAttribute: '货物属性',
warehouseId: '仓库ID',
warehouse: '仓库',
customerId: '客户ID',
orderSource: '订单来源',
pickupTime: '期望揽收时间',
carrierWaybillNo: '承运商单号',
pickupNo: '揽收单号',
inboundMethod: '到仓方式',
fulfillmentType: '履约类型',
fulfillProgress: '代发进度',
stockBindStatus: '绑定状态',
domesticTrackingNos: '国内快递单号',
stockBindQtyTotal: '绑定件数',
stockBindLineCount: '绑定行数',
primaryBinCode: '主库位',
primarySkuCodes: '绑定SKU',
fulfillmentSource: '代发来源',
customerSenderAddressId: '发件地址簿ID',
requestId: '请求标识',
exportCustomsMode: '出口报关方式',
exportQualificationId: '出口资质ID',
predeclareFailReason: '预报失败原因',
allocationPendingReason: '待排货原因',
allocationScheme: '排货方案',
hasAllocationTrace: '有排货快照',
declaredCurrency: '申报币种',
declaredSum: '申报总金额',
quantity: '包裹数量',
isCod: '是否COD',
codCurrency: 'COD币种',
codSum: 'COD金额',
orderStatus: '订单状态',
status: '状态码',
handleStatus: '处理状态码',
handleStatusName: '处理状态',
issueType: '问题类型码',
issueTypeName: '问题类型',
issueTitle: '问题标题',
issueContent: '问题内容',
issueTemplateId: '问题模板ID',
issueRemark: '问题备注',
issueTime: '问题时间',
claimType: '理赔类型码',
claimTypeName: '理赔类型',
claimAmount: '理赔金额',
claimCurrency: '理赔币种',
claimReason: '理赔原因',
claimTime: '理赔时间',
previousOrderStatus: '原订单状态',
receiveTime: '收货时间',
shipTime: '发货时间',
payTime: '支付时间',
isPaid: '是否已支付',
signTime: '签收时间',
isIntercept: '是否拦截',
isRemote: '是否偏远',
hasInsurance: '是否投保',
forecastReceivableAmount: '预报应收',
forecastPayableAmount: '预报应付',
salesProductRulesVersion: '规则版本',
salesProductPriceVersion: '价格版本',
createTime: '创建时间',
createBy: '创建人',
updateTime: '更新时间',
updateBy: '更新人',
chargeableWeight: '计费重',
receivableAmount: '应收金额',
inboundTime: '入库时间',
outboundTime: '出库时间',
pickupOrderId: '揽收单ID',
// 寄件人展平
senderName: '寄件人',
senderCompany: '寄件公司',
senderPhone: '寄件手机',
senderTelePhone: '寄件电话',
senderEmail: '寄件邮箱',
senderCountryCode: '寄件国家代码',
senderProvince: '寄件省州',
senderCity: '寄件城市',
senderZipCode: '寄件邮编',
senderStreetAddress1: '寄件地址1',
senderStreetAddress2: '寄件地址2',
senderStreetAddress3: '寄件地址3',
senderHouseNumber: '寄件门牌号',
// 收件人展平
receiverRecipientsName: '收件人',
receiverCompanyName: '收件公司',
receiverPhone: '收件手机',
receiverTelePhone: '收件电话',
receiverEmail: '收件邮箱',
receiverCountryCode: '收件国家代码',
receiverProvince: '收件省州',
receiverCity: '收件城市',
receiverStreetNumber: '收件门牌号',
receiverStreetAddress1: '收件地址1',
receiverStreetAddress2: '收件地址2',
receiverStreetAddress3: '收件地址3',
receiverZipCode: '收件邮编',
receiverTaxType: '税号类型',
receiverTaxNo: '税号',
receiverCertificateType: '证件类型',
receiverCertificateNo: '证件号码',
receiverEori: '收件EORI',
receiverVat: '收件VAT',
};
/** 按字段 key 取中文列名;未知字段做可读化,避免直接甩 camelCase。 */
export function getOrderColumnLabelZh(key: string): string {
const k = String(key || '').trim();
if (!k) return '';
if (ORDER_COLUMN_LABEL_ZH[k]) return ORDER_COLUMN_LABEL_ZH[k];
// fooBarBaz → Foo Bar Baz,再包一层中文前缀,避免纯英文 key 当表头
const spaced = k
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return spaced || k;
}

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

@ -41,8 +41,15 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { mapWarehouseSkuToDeclarationFields, normalizeListRow, normalizeSkuImageForStorage, pickField } from '@/utils/warehouseListMapper';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel';
@ -319,6 +326,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
sort?: number | null;
remark?: string;
};
const singleWarehouseOptions = ref<WarehouseSelectOption[]>([]);
const singleCurrencyOptions = ref<SingleSelectOption[]>([]);
@ -362,12 +371,16 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
return '';
};
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
id: id != null && id !== '' ? String(id) : '',
name,
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick('remark', 'Remark'),
};
}
@ -2135,20 +2148,51 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
return singleWarehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
/** 客户登录时拼接到仓库收件人/地址后的客户编号 */
const currentCustomerCode = ref('');
async function loadCurrentCustomerCode() {
if (!rbacUserStore.isCustomerPortal()) {
currentCustomerCode.value = '';
return;
}
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
function displayWarehouseRecipient(opt?: WarehouseSelectOption) {
return formatWarehouseShipRecipient(opt?.recipient, currentCustomerCode.value);
}
function displayWarehouseShippingAddress(opt?: WarehouseSelectOption) {
return formatWarehouseShipAddress(opt?.shippingAddress, currentCustomerCode.value);
}
function warehouseHasAddress(opt?: WarehouseSelectOption) {
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
}
function buildWarehouseShipCopyText() {
const opt = selectedWarehouseOption.value;
if (!opt) return '';
const warehouseShipAddressList = computed(() => singleWarehouseOptions.value);
const warehousesWithShipAddress = computed(() =>
singleWarehouseOptions.value.filter((opt) => warehouseHasAddress(opt)),
);
function buildWarehouseShipCopyText(opt?: WarehouseSelectOption) {
const target = opt ?? selectedWarehouseOption.value;
if (!target) return '';
const lines = [
opt.label || '',
`收件人:${opt.recipient || '-'}`,
`电话:${opt.contactPhone || '-'}`,
`地址:${opt.shippingAddress || '-'}`,
target.label || '',
`收件人:${displayWarehouseRecipient(target)}`,
`电话:${target.contactPhone || '-'}`,
`地址:${displayWarehouseShippingAddress(target)}`,
];
const clientNo = String(singleForm.clientNo ?? '').trim();
if (clientNo) {
@ -2157,15 +2201,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
return lines.filter(Boolean).join('\n');
}
async function copyWarehouseShipAddress() {
const text = buildWarehouseShipCopyText();
if (!text.trim()) {
Message.warning('该仓库暂未配置收货地址');
return;
}
async function writeClipboardText(text: string, successMsg: string) {
try {
await navigator.clipboard.writeText(text);
Message.success('仓库寄件信息已复制');
Message.success(successMsg);
} catch {
const area = document.createElement('textarea');
area.value = text;
@ -2175,7 +2214,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
area.select();
try {
document.execCommand('copy');
Message.success('仓库寄件信息已复制');
Message.success(successMsg);
} catch {
Message.error('复制失败');
}
@ -2183,6 +2222,15 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
}
async function copyWarehouseShipAddress(opt?: WarehouseSelectOption) {
const text = buildWarehouseShipCopyText(opt);
if (!text.trim()) {
Message.warning('该仓库暂未配置收货地址');
return;
}
await writeClipboardText(text, '仓库寄件信息已复制');
}
function resolveDefaultDeclaredCurrency() {
const opts = singleCurrencyOptions.value;
const usd = opts.find((o) => String(o.value).trim().toUpperCase() === 'USD');
@ -2446,7 +2494,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
recipient: w.recipient,
contactPhone: w.contactPhone,
shippingAddress: w.shippingAddress,
}));
sort: w.sort,
remark: w.remark,
}))
.sort(compareWarehouseSort);
singleWarehouseOptions.value = whOpts;
if (whOpts.length && !String(singleForm.warehouseId ?? '').trim()) {
singleForm.warehouseId = whOpts[0].value;
@ -2592,6 +2643,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
onMounted(() => {
loadSingleFormReferenceOptions();
void loadCurrentCustomerCode();
loadReceiverAiPrefs();
void Promise.all([loadReceiverAiProviders(), loadReceiverAiSkills()]);
void (async () => {
@ -3926,49 +3978,85 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<template #content>
<div class="warehouse-ship-popover__body">
<div class="warehouse-ship-popover__head">
<strong>寄往仓库</strong>
</div>
<p
v-if="!warehouseShipAddressList.length"
class="warehouse-ship-popover__empty"
>
暂无仓库信息,请稍后重试或联系客服
</p>
<p
v-else-if="!warehousesWithShipAddress.length"
class="warehouse-ship-popover__empty"
>
暂无已配置收货地址的仓库,请联系客服
</p>
<template v-else>
<div class="warehouse-ship-popover__list">
<div
v-for="opt in warehouseShipAddressList"
:key="opt.value"
class="warehouse-ship-popover__item"
:class="{
'is-selected':
String(opt.value) ===
String(singleForm.warehouseId ?? ''),
'is-empty': !warehouseHasAddress(opt),
}"
>
<div class="warehouse-ship-popover__item-head">
<strong>
寄往仓库
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
{{ opt.label || opt.value }}
<span
v-if="
String(opt.value) ===
String(singleForm.warehouseId ?? '')
"
class="warehouse-ship-popover__badge"
>
当前
</span>
</strong>
<a-button
v-if="selectedWarehouseHasAddress"
v-if="warehouseHasAddress(opt)"
type="text"
size="mini"
class="warehouse-ship-popover__copy"
@click="copyWarehouseShipAddress"
@click="copyWarehouseShipAddress(opt)"
>
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<p v-if="!selectedWarehouseOption" class="warehouse-ship-popover__empty">
请先选择上方仓库
</p>
<p
v-else-if="!selectedWarehouseHasAddress"
v-if="!warehouseHasAddress(opt)"
class="warehouse-ship-popover__empty"
>
该仓库暂未配置收货地址,请联系客服或更换仓库
暂未配置收货地址
</p>
<template v-else>
<dl class="warehouse-ship-popover__rows">
<dl v-else class="warehouse-ship-popover__rows">
<div>
<dt>收件人</dt>
<dd>{{ selectedWarehouseOption.recipient || '-' }}</dd>
<dd>{{ displayWarehouseRecipient(opt) }}</dd>
</div>
<div>
<dt>电话</dt>
<dd>{{ selectedWarehouseOption.contactPhone || '-' }}</dd>
<dd>{{ opt.contactPhone || '-' }}</dd>
</div>
<div class="is-addr">
<dt>地址</dt>
<dd>{{ selectedWarehouseOption.shippingAddress || '-' }}</dd>
<dd>{{ displayWarehouseShippingAddress(opt) }}</dd>
</div>
<div v-if="opt.remark">
<dt>备注</dt>
<dd>{{ opt.remark }}</dd>
</div>
</dl>
</div>
</div>
<p class="warehouse-ship-popover__hint">
按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。
按所选仓库地址自行寄出。快递面单建议备注客户单号,方便仓库认领。
</p>
</template>
</div>
@ -3993,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"
@ -7210,7 +7319,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<style lang="less">
.warehouse-ship-popover {
max-width: 360px;
max-width: 400px;
padding: 0;
.arco-popover-content {
@ -7219,8 +7328,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
.warehouse-ship-popover__body {
min-width: 240px;
max-width: 320px;
min-width: 260px;
max-width: 360px;
}
.warehouse-ship-popover__head {
@ -7244,8 +7353,62 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
flex-shrink: 0;
}
.warehouse-ship-popover__list {
margin-top: 8px;
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.warehouse-ship-popover__item {
padding: 8px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-fill-1);
&.is-selected {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
&.is-empty {
opacity: 0.72;
}
}
.warehouse-ship-popover__item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
line-height: 22px;
}
}
.warehouse-ship-popover__badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 2px;
font-size: 11px;
font-weight: 500;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.warehouse-ship-popover__rows {
margin: 8px 0 0;
margin: 6px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
@ -7280,4 +7443,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
line-height: 18px;
color: var(--color-text-3);
}
.warehouse-ship-popover__item .warehouse-ship-popover__empty {
margin-top: 4px;
}
</style>

View File

@ -4,6 +4,7 @@
*/
import type { OrderListColumnDef } from '../myOrders/columnConfig';
import { MY_ORDERS_COLUMN_CATALOG } from '../myOrders/columnConfig';
import { getOrderColumnLabelZh } from '../orderColumnLabelsZh';
export const WAYBILL_MANAGE_COLUMN_STORAGE_KEY = 'oms.order.waybillManage.columnSchemes';
@ -40,10 +41,23 @@ export const WAYBILL_MANAGE_COLUMN_CATALOG: OrderListColumnDef[] = [
{ key: 'productName', labelKey: M('shippingMethod'), width: 120 },
{ key: 'channelCode', labelKey: L('channelCode'), width: 110 },
{ key: 'channelName', labelKey: M('serviceChannel'), width: 120 },
{ key: 'downstreamSupplierName', labelKey: L('downstreamSupplierName'), width: 140 },
{ key: 'downstreamSupplierCode', labelKey: L('downstreamSupplierCode'), width: 140 },
{ key: 'downstreamAppKey', labelKey: L('downstreamAppKey'), width: 140 },
{ key: 'downstreamAppId', labelKey: L('downstreamAppId'), width: 110 },
{ key: 'channelForecastCode', labelKey: L('channelForecastCode'), width: 130 },
{ key: 'maxTimeliness', labelKey: L('maxTimeliness'), width: 100 },
{ key: 'salesProductRulesVersion', labelKey: L('salesProductRulesVersion'), width: 150 },
{ key: 'salesProductPriceVersion', labelKey: L('salesProductPriceVersion'), width: 150 },
{ key: 'forecastReceivableAmount', labelKey: L('forecastReceivableAmount'), width: 120 },
{ key: 'forecastPayableAmount', labelKey: L('forecastPayableAmount'), width: 120 },
{ key: 'hasInsurance', labelKey: L('hasInsurance'), width: 90 },
{ key: 'hasAllocationTrace', labelKey: L('hasAllocationTrace'), width: 110 },
{ key: 'allocationScheme', labelKey: L('allocationScheme'), width: 120 },
{ key: 'exportQualificationId', labelKey: L('exportQualificationId'), width: 120 },
{ key: 'pickupOrderId', labelKey: L('pickupOrderId'), width: 110 },
{ key: 'stockBindLineCount', labelKey: L('stockBindLineCount'), width: 100 },
{ key: 'primarySkuCodes', labelKey: L('primarySkuCodes'), width: 140 },
{ key: 'consigneeCountry', labelKey: M('consigneeCountry'), width: 110 },
{ key: 'consigneeCountryName', labelKey: L('consigneeCountryName'), width: 120 },
{ key: 'cargoAttribute', labelKey: M('cargoAttribute'), width: 100 },
@ -182,7 +196,16 @@ export function dedupeCatalog(list: OrderListColumnDef[]): OrderListColumnDef[]
return Array.from(map.values());
}
/** 基础目录 = 运单全量 + 我的订单目录中可能遗漏的项 */
export function getWaybillBaseCatalog(): OrderListColumnDef[] {
return dedupeCatalog([...WAYBILL_MANAGE_COLUMN_CATALOG, ...MY_ORDERS_COLUMN_CATALOG]);
function withZhLabels(list: OrderListColumnDef[]): OrderListColumnDef[] {
return list.map((col) => ({
...col,
label: col.label && col.label !== col.key ? col.label : getOrderColumnLabelZh(col.key),
}));
}
/** 基础目录 = 运单全量 + 我的订单目录中可能遗漏的项(带中文 label) */
export function getWaybillBaseCatalog(): OrderListColumnDef[] {
return withZhLabels(
dedupeCatalog([...WAYBILL_MANAGE_COLUMN_CATALOG, ...MY_ORDERS_COLUMN_CATALOG]),
);
}

View File

@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import {
getSalesProductPublishPriceList,
getSalesProductPublishRulesList,
} from '@/api/channel/salesProduct'
import { postOrderBindSnapshotVersions } from '@/api/order'
const props = defineProps<{
visible: boolean
orderId: number
salesProductId: number | string | null
currentRulesVersion?: string | null
currentPriceVersion?: string | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [order: any]
}>()
const { t } = useI18n()
const [registerModal] = useModal()
const submitting = ref(false)
const loading = ref(false)
const formState = reactive({
rulesVersion: undefined as string | undefined,
priceVersion: undefined as string | undefined,
recalculateReceivable: true,
recalculatePayable: true,
})
const rulesOptions = ref<Array<{ label: string; value: string }>>([])
const priceOptions = ref<Array<{ label: string; value: string }>>([])
const title = computed(() => t('order.myOrders.drawer.bindSnapshot.title'))
function mapVersionOptions(items: any[], current?: string | null) {
const rows = (items || []).map((row) => {
const version = String(row.version ?? row.Version ?? '').trim()
const publishTime = row.publishTime ?? row.PublishTime ?? ''
const desc = String(row.description ?? row.Description ?? '').trim()
const parts = [version]
if (publishTime) parts.push(String(publishTime))
if (desc) parts.push(desc)
const isCurrent = current && version === current
return {
value: version,
label: isCurrent
? `${parts.join(' · ')}(${t('order.myOrders.drawer.bindSnapshot.currentBound')})`
: parts.join(' · '),
}
}).filter((x) => x.value)
return [
{
value: 'CURRENT',
label: t('order.myOrders.drawer.bindSnapshot.useCurrent'),
},
...rows,
]
}
async function loadVersionOptions() {
rulesOptions.value = [
{ value: 'CURRENT', label: t('order.myOrders.drawer.bindSnapshot.useCurrent') },
]
priceOptions.value = [
{ value: 'CURRENT', label: t('order.myOrders.drawer.bindSnapshot.useCurrent') },
]
if (props.salesProductId == null || props.salesProductId === '') return
loading.value = true
try {
const [rules, price] = await Promise.all([
getSalesProductPublishRulesList({
salesProductId: props.salesProductId,
page: 1,
pageSize: 50,
includeContent: false,
}),
getSalesProductPublishPriceList({
salesProductId: props.salesProductId,
page: 1,
pageSize: 50,
includeContent: false,
}),
])
rulesOptions.value = mapVersionOptions(rules.items, props.currentRulesVersion)
priceOptions.value = mapVersionOptions(price.items, props.currentPriceVersion)
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.drawer.bindSnapshot.loadFailed'))
} finally {
loading.value = false
}
}
function resetForm() {
formState.rulesVersion = undefined
formState.priceVersion = undefined
formState.recalculateReceivable = true
formState.recalculatePayable = true
}
watch(
() => props.visible,
async (v) => {
if (!v) return
resetForm()
await loadVersionOptions()
},
{ immediate: true },
)
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
async function handleOk() {
if (!formState.rulesVersion && !formState.priceVersion) {
Message.warning(t('order.myOrders.drawer.bindSnapshot.needVersion'))
return
}
submitting.value = true
try {
const result = await postOrderBindSnapshotVersions({
id: props.orderId,
rulesVersion: formState.rulesVersion,
priceVersion: formState.priceVersion,
recalculateReceivable: formState.recalculateReceivable,
recalculatePayable: formState.recalculatePayable,
})
Message.success(t('order.myOrders.drawer.bindSnapshot.success'))
emit('success', result)
emit('update:visible', false)
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.drawer.bindSnapshot.failed'))
} finally {
submitting.value = false
}
}
function handleCancel() {
emit('update:visible', false)
}
</script>
<template>
<ModalPro
:visible="visible"
:title="title"
:width="520"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal"
@update:visible="onUpdateVisible"
@ok="handleOk"
@cancel="handleCancel"
>
<p class="bind-snapshot-hint">{{ t('order.myOrders.drawer.bindSnapshot.hint') }}</p>
<a-spin :loading="loading" style="width: 100%">
<div class="bind-snapshot-modal">
<div class="bind-snapshot-modal__field">
<div class="bind-snapshot-modal__label">
{{ t('order.myOrders.drawer.bindSnapshot.rulesVersion') }}
</div>
<div class="bind-snapshot-modal__current">
{{ t('order.myOrders.drawer.bindSnapshot.boundAs', {
version: currentRulesVersion || '--',
}) }}
</div>
<a-select
v-model="formState.rulesVersion"
allow-clear
allow-search
:options="rulesOptions"
:placeholder="t('order.myOrders.drawer.bindSnapshot.keepUnchanged')"
/>
</div>
<div class="bind-snapshot-modal__field">
<div class="bind-snapshot-modal__label">
{{ t('order.myOrders.drawer.bindSnapshot.priceVersion') }}
</div>
<div class="bind-snapshot-modal__current">
{{ t('order.myOrders.drawer.bindSnapshot.boundAs', {
version: currentPriceVersion || '--',
}) }}
</div>
<a-select
v-model="formState.priceVersion"
allow-clear
allow-search
:options="priceOptions"
:placeholder="t('order.myOrders.drawer.bindSnapshot.keepUnchanged')"
/>
</div>
<div class="bind-snapshot-modal__checks">
<a-checkbox v-model="formState.recalculateReceivable">
{{ t('order.myOrders.drawer.bindSnapshot.recalcReceivable') }}
</a-checkbox>
<a-checkbox v-model="formState.recalculatePayable">
{{ t('order.myOrders.drawer.bindSnapshot.recalcPayable') }}
</a-checkbox>
</div>
</div>
</a-spin>
</ModalPro>
</template>
<style scoped lang="less">
.bind-snapshot-hint {
margin: 0 0 12px;
color: var(--color-text-3);
font-size: 13px;
line-height: 1.5;
}
.bind-snapshot-modal__field {
margin-bottom: 16px;
}
.bind-snapshot-modal__label {
margin-bottom: 4px;
font-weight: 500;
color: var(--color-text-1);
}
.bind-snapshot-modal__current {
margin-bottom: 8px;
font-size: 12px;
color: var(--color-text-3);
}
.bind-snapshot-modal__checks {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>

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">
@ -48,12 +48,14 @@
</template>
<template v-else-if="activeTab === 'fee'">
<a-button
v-if="canEditAdditionalServices"
class="app-btn-search"
:loading="additionalServicesLoading"
@click="openAdditionalServicesModal"
>
{{ $t('order.myOrders.drawer.actions.additionalServices') }}
<template v-if="orderAdditionalServices.length">
({{ orderAdditionalServices.length }})
</template>
</a-button>
<a-button class="app-btn-search" @click="openAddFeeModal">
{{ $t('order.myOrders.drawer.actions.addFee') }}
@ -72,6 +74,9 @@
>
{{ $t('order.myOrders.drawer.actions.recalculatePayable') }}
</a-button>
<a-button class="app-btn-search" @click="openBindSnapshotVersionsModal">
{{ $t('order.myOrders.drawer.actions.bindSnapshotVersions') }}
</a-button>
</template>
<template v-else-if="activeTab === 'track'">
<span class="toolbar-meta">
@ -103,6 +108,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>
@ -483,6 +499,44 @@
<!-- 应收应付 -->
<div v-if="activeTab === 'fee'" class="tab-panel fee-info">
<div v-if="boundRulesVersion || boundPriceVersion" class="fee-snapshot-versions">
<span class="fee-snapshot-versions__item">
{{ $t('order.myOrders.drawer.fee.rulesVersion') }}:{{ boundRulesVersion || '--' }}
</span>
<span class="fee-snapshot-versions__item">
{{ $t('order.myOrders.drawer.fee.priceVersion') }}:{{ boundPriceVersion || '--' }}
</span>
</div>
<div class="fee-additional-services">
<span class="fee-additional-services__label">
{{ $t('order.myOrders.drawer.additionalServices') }}:
</span>
<a-space v-if="orderAdditionalServices.length" wrap :size="4">
<a-tag
v-for="code in orderAdditionalServices"
:key="code"
size="small"
color="arcoblue"
>
{{ labelAdditionalService(code) }}
</a-tag>
</a-space>
<span v-else class="fee-additional-services__empty">
{{ $t('order.myOrders.drawer.fee.additionalServicesNone') }}
</span>
<a-button
type="text"
size="mini"
class="fee-additional-services__edit"
@click="openAdditionalServicesModal"
>
{{
canEditAdditionalServices
? $t('order.myOrders.drawer.fee.additionalServicesManage')
: $t('order.myOrders.drawer.fee.additionalServicesView')
}}
</a-button>
</div>
<div class="fee-summary">
<button
type="button"
@ -747,6 +801,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>
@ -758,6 +869,16 @@
@success="handleAddFeeSuccess"
/>
<BindSnapshotVersionsModal
v-if="bindSnapshotVersionsVisible && orderId != null"
v-model:visible="bindSnapshotVersionsVisible"
:order-id="Number(orderId)"
:sales-product-id="bindSnapshotSalesProductId"
:current-rules-version="boundRulesVersion"
:current-price-version="boundPriceVersion"
@success="handleBindSnapshotVersionsSuccess"
/>
<a-modal
v-model:visible="declarationSkuPreviewVisible"
:title="$t('order.myOrders.drawer.declarationSkuImage')"
@ -782,15 +903,75 @@
<a-modal
v-model:visible="additionalServicesModalVisible"
:title="$t('order.myOrders.drawer.fee.additionalServicesTitle')"
:title="
canEditAdditionalServices
? $t('order.myOrders.drawer.fee.additionalServicesManageTitle')
: $t('order.myOrders.drawer.fee.additionalServicesViewTitle')
"
:ok-loading="additionalServicesSaving"
:ok-text="
canEditAdditionalServices
? $t('order.myOrders.drawer.fee.additionalServicesSave')
: $t('order.myOrders.drawer.fee.additionalServicesClose')
"
:hide-cancel="!canEditAdditionalServices"
unmount-on-close
@ok="handleAdditionalServicesSubmit"
>
<a-spin :loading="additionalServicesLoading">
<p class="additional-services-hint">
{{
canEditAdditionalServices
? $t('order.myOrders.drawer.fee.additionalServicesHint')
: $t('order.myOrders.drawer.fee.additionalServicesPaidHint')
}}
</p>
<div class="additional-services-current">
<div class="additional-services-current__label">
{{ $t('order.myOrders.drawer.fee.additionalServicesCurrent') }}
</div>
<a-space v-if="orderAdditionalServices.length" wrap :size="4">
<a-tag
v-for="code in orderAdditionalServices"
:key="`cur-${code}`"
size="small"
color="arcoblue"
>
{{ labelAdditionalService(code) }}
</a-tag>
</a-space>
<span v-else class="additional-services-current__empty">
{{ $t('order.myOrders.drawer.fee.additionalServicesNone') }}
</span>
</div>
<template v-if="canEditAdditionalServices">
<div
v-if="visibleOrphanAdditionalServices.length"
class="additional-services-orphan"
>
<div class="additional-services-orphan__label">
{{ $t('order.myOrders.drawer.fee.additionalServicesOrphan') }}
</div>
<a-space wrap :size="4">
<a-tag
v-for="code in visibleOrphanAdditionalServices"
:key="`orphan-${code}`"
size="small"
color="orangered"
closable
@close="dismissOrphanAdditionalService(code)"
>
{{ labelAdditionalService(code) }}
</a-tag>
</a-space>
</div>
<div class="additional-services-options-label">
{{ $t('order.myOrders.drawer.fee.additionalServicesPick') }}
</div>
<a-checkbox-group
v-if="productAdditionalServiceOptions.length"
v-model="selectedAdditionalServices"
class="additional-services-checkbox-group"
>
<a-checkbox
v-for="opt in productAdditionalServiceOptions"
@ -804,6 +985,7 @@
v-else
:description="$t('order.myOrders.drawer.fee.additionalServicesEmpty')"
/>
</template>
</a-spin>
</a-modal>
</template>
@ -813,7 +995,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 +1004,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';
@ -830,6 +1014,7 @@ import { getBooleanTagStyle } from '@/utils/statusTag';
import { resolveOmsAssetUrl } from '@/api/oms/common';
import SlidingTabs from '@/components/SlidingTabs/index.vue';
import AddOrderFeeModal from './AddOrderFeeModal.vue';
import BindSnapshotVersionsModal from './BindSnapshotVersionsModal.vue';
import AllocationTraceModal from './AllocationTraceModal.vue';
import OrderStockBindTab from '../../myOrders/components/OrderStockBindTab.vue';
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
@ -879,6 +1064,8 @@ const weightSaving = ref(false);
const recalcReceivableLoading = ref(false);
const recalcPayableLoading = ref(false);
const addFeeModalVisible = ref(false);
const bindSnapshotVersionsVisible = ref(false);
const bindSnapshotSalesProductId = ref<number | string | null>(null);
const allocationTraceVisible = ref(false);
const addFeeDefaultType = ref<'receivable' | 'payable'>('receivable');
const additionalServicesModalVisible = ref(false);
@ -1197,8 +1384,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 })),
);
@ -1213,6 +1405,8 @@ const orderBasicFields = mergeOrderBasicFields([
{ key: 'productName', labelKey: 'order.myOrders.drawer.productName' },
{ key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' },
{ key: 'channelName', labelKey: 'order.myOrders.drawer.channelName' },
{ key: 'downstreamSupplierName', labelKey: 'order.myOrders.drawer.downstreamSupplierName' },
{ key: 'downstreamSupplierCode', labelKey: 'order.myOrders.drawer.downstreamSupplierCode' },
{ key: 'consigneeCountry', labelKey: 'order.myOrders.drawer.consigneeCountry' },
{ key: 'consigneeCountryName', labelKey: 'order.myOrders.drawer.consigneeCountryName' },
{ key: 'cargoAttribute', labelKey: 'order.myOrders.drawer.cargoAttribute' },
@ -1774,26 +1968,27 @@ const orderAdditionalServices = computed(() =>
)
);
const orderHasAdditionalServicesField = computed(() => {
const o = detail.value;
if (!o) return false;
return (
Object.prototype.hasOwnProperty.call(o, 'additionalServices') ||
Object.prototype.hasOwnProperty.call(o, 'AdditionalServices') ||
o.additionalServices != null ||
o.AdditionalServices != null
);
});
const isOrderUnpaid = computed(() => {
const v = detail.value?.isPaid ?? detail.value?.IsPaid;
return !(v === true || v === 1 || v === '1');
});
const canEditAdditionalServices = computed(
() =>
isOrderUnpaid.value &&
(productHasAdditionalServices.value || orderHasAdditionalServicesField.value)
/** 未支付可勾选/取消附加服务;已支付仅可查看。 */
const canEditAdditionalServices = computed(() => isOrderUnpaid.value);
/** 订单上有、但当前产品配置已不包含的附加服务(保存时只能去掉,不能再写回)。 */
const orphanAdditionalServices = computed(() => {
const allowed = new Set(productAdditionalServiceOptions.value.map((o) => o.value));
if (!allowed.size) {
return [...orderAdditionalServices.value];
}
return orderAdditionalServices.value.filter((code) => !allowed.has(code));
});
const dismissedOrphanServices = ref<string[]>([]);
const visibleOrphanAdditionalServices = computed(() =>
orphanAdditionalServices.value.filter((code) => !dismissedOrphanServices.value.includes(code))
);
const grossProfitMarginText = computed(() => {
@ -1982,25 +2177,45 @@ async function refreshProductAdditionalServicesCapability() {
async function openAdditionalServicesModal() {
additionalServicesModalVisible.value = true;
additionalServicesLoading.value = true;
dismissedOrphanServices.value = [];
try {
await refreshProductAdditionalServicesCapability();
const allowed = new Set(productAdditionalServiceOptions.value.map((o) => o.value));
selectedAdditionalServices.value = orderAdditionalServices.value.filter((c) =>
allowed.size ? allowed.has(c) : true
allowed.size ? allowed.has(c) : false
);
} finally {
additionalServicesLoading.value = false;
}
}
function dismissOrphanAdditionalService(code: string) {
if (!dismissedOrphanServices.value.includes(code)) {
dismissedOrphanServices.value = [...dismissedOrphanServices.value, code];
}
}
async function handleAdditionalServicesSubmit() {
if (!canEditAdditionalServices.value) {
additionalServicesModalVisible.value = false;
return;
}
const id = orderId.value;
if (id == null) return Promise.reject(new Error('missing order'));
additionalServicesSaving.value = true;
try {
const allowed = new Set(productAdditionalServiceOptions.value.map((o) => o.value));
const next = selectedAdditionalServices.value.filter((c) => allowed.has(c));
if (orphanAdditionalServices.value.length) {
Message.info(
t('order.myOrders.drawer.fee.additionalServicesOrphanCleared', {
names: orphanAdditionalServices.value.map((c) => labelAdditionalService(c)).join('、'),
})
);
}
const result = await postOrderAdditionalServicesUpdate({
id,
additionalServices: [...selectedAdditionalServices.value],
additionalServices: next,
});
applyDetailFromApi(result);
additionalServicesModalVisible.value = false;
@ -2362,6 +2577,75 @@ async function handleRecalculatePayable() {
}
}
const boundRulesVersion = computed(() => {
const o = detail.value;
if (!o) return '';
return String(o.salesProductRulesVersion ?? o.SalesProductRulesVersion ?? '').trim();
});
const boundPriceVersion = computed(() => {
const o = detail.value;
if (!o) return '';
return String(o.salesProductPriceVersion ?? o.SalesProductPriceVersion ?? '').trim();
});
async function openBindSnapshotVersionsModal() {
const code = String(detail.value?.productCode ?? detail.value?.ProductCode ?? '').trim();
if (!code) {
Message.warning(t('order.myOrders.drawer.bindSnapshot.noProduct'));
return;
}
bindSnapshotSalesProductId.value = await resolveProductIdByCode(code);
if (bindSnapshotSalesProductId.value == null) {
Message.warning(t('order.myOrders.drawer.bindSnapshot.noProduct'));
return;
}
bindSnapshotVersionsVisible.value = true;
}
function handleBindSnapshotVersionsSuccess(result: any) {
applyDetailFromApi(result);
}
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 +2662,9 @@ function handleTabChange(tabKey: string) {
if (tabKey === 'track') {
loadTrackTimeline();
}
if (tabKey === 'operationLog') {
void loadOperationLogs();
}
}
function handleCancel() {
@ -2400,13 +2687,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';
@ -3026,6 +3316,49 @@ defineExpose({
gap: 12px;
}
.fee-additional-services {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 4px;
background: var(--color-fill-1);
font-size: 12px;
line-height: 20px;
}
.fee-additional-services__label {
color: var(--color-text-2);
flex-shrink: 0;
}
.fee-additional-services__empty {
color: var(--color-text-3);
}
.fee-additional-services__edit {
margin-left: auto;
}
.fee-snapshot-versions {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 4px;
background: var(--color-fill-1);
font-size: 12px;
color: var(--color-text-2);
line-height: 20px;
}
.fee-snapshot-versions__item {
word-break: break-all;
}
.fee-summary {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@ -3515,4 +3848,143 @@ 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;
}
.additional-services-hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-3);
}
.additional-services-current {
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 4px;
background: var(--color-fill-1);
}
.additional-services-current__label,
.additional-services-orphan__label,
.additional-services-options-label {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
.additional-services-current__empty {
font-size: 13px;
color: var(--color-text-3);
}
.additional-services-orphan {
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 125, 0, 0.08);
}
.additional-services-checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>

View File

@ -131,7 +131,7 @@
name: 'WaybillManage',
});
const { t } = useI18n();
const { t, te } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const { customerOptions, withCustomerId } = useWarehouseAdminCustomer();
@ -248,6 +248,10 @@
productName: productName ?? productCode ?? '',
channelCode: channelCode ?? '',
channelName: channelName ?? channelCode ?? '',
downstreamSupplierName:
pick(raw, 'downstreamSupplierName', 'DownstreamSupplierName') ?? '',
downstreamSupplierCode:
pick(raw, 'downstreamSupplierCode', 'DownstreamSupplierCode') ?? '',
isPaid: boolish(isPaidRaw),
isCod: boolish(pick(raw, 'isCod', 'IsCod')),
isIntercept: boolish(pick(raw, 'isIntercept', 'IsIntercept')),
@ -423,7 +427,7 @@
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const showReallocateAction = computed(() =>
isActiveTab('all', 'pendingAllocation', 'pendingPayment'),
isActiveTab('all', 'predeclared', 'pendingAllocation', 'pendingPayment', 'pendingShip'),
);
const problematicModalVisible = ref(false);
@ -1567,7 +1571,7 @@
]);
return withSortableColumns(
withServerFilterColumns(
buildHotColumns(columnCatalog.value, activeColumnKeys.value, t),
buildHotColumns(columnCatalog.value, activeColumnKeys.value, t, te),
ORDER_SERVER_FILTER_KEYS,
(col) => {
if (numberKeys.has(col.key)) {

View File

@ -18,6 +18,13 @@
<icon-home />
</span>
<span class="warehouse-card-title-text" :title="item.warehouseName">{{ item.warehouseName }}</span>
<a-tag
v-if="item.sort != null && item.sort !== ''"
size="small"
class="warehouse-card-sort-tag"
>
{{ $t('system.warehouseAddress.fields.sort') }} {{ item.sort }}
</a-tag>
</div>
</template>
<template #extra>
@ -58,6 +65,10 @@
<span class="label">{{ $t('system.warehouseAddress.fields.shippingAddress') }}:</span>
<span class="value value-address" :title="displayShippingAddress(item)">{{ displayShippingAddress(item) }}</span>
</div>
<div v-if="item.remark" class="card-row card-row--remark">
<span class="label">{{ $t('system.warehouseAddress.fields.remark') }}:</span>
<span class="value" :title="item.remark">{{ item.remark }}</span>
</div>
</div>
</div>
</a-card>
@ -105,6 +116,18 @@
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('system.warehouseAddress.fields.sort')">
<a-input-number
v-model="formData.sort"
:placeholder="$t('system.warehouseAddress.placeholder.sort')"
:precision="0"
allow-clear
hide-button
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<div class="form-section-title">{{ $t('system.warehouseAddress.section.contact') }}</div>
@ -134,6 +157,17 @@
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item :label="$t('system.warehouseAddress.fields.remark')">
<a-textarea
v-model="formData.remark"
:placeholder="$t('system.warehouseAddress.placeholder.remark')"
:max-length="512"
show-word-limit
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
@ -156,6 +190,11 @@ import {
} from '@/api/system/warehouse';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
const { t } = useI18n();
const rbacUserStore = useRbacUserStore();
@ -172,22 +211,12 @@ const editingId = ref<string | number | null>(null);
const warehouseTypeOptions = ref<Array<{ value: string; label: string }>>([]);
const currentCustomerCode = ref('');
function appendCustomerCode(value?: string, codeSuffix = ''): string {
const base = value?.trim() || '';
const code = currentCustomerCode.value.trim();
if (!code) return base || '-';
const tail = `${code}${codeSuffix}`;
if (!base) return tail;
if (base.endsWith(tail)) return base;
return `${base} ${tail}`;
}
function displayRecipient(item: { recipient?: string }) {
return appendCustomerCode(item.recipient);
return formatWarehouseShipRecipient(item.recipient, currentCustomerCode.value);
}
function displayShippingAddress(item: { shippingAddress?: string }) {
return appendCustomerCode(item.shippingAddress, '号');
return formatWarehouseShipAddress(item.shippingAddress, currentCustomerCode.value);
}
async function loadCurrentCustomerCode() {
@ -242,9 +271,11 @@ function getWarehouseTypeDisplay(item: {
const formData = reactive({
warehouseName: '',
warehouseType: undefined as string | undefined,
sort: undefined as number | undefined,
recipient: '',
contactPhone: '',
shippingAddress: '',
remark: '',
});
function handleDelete(item: any) {
@ -268,7 +299,7 @@ function handleDelete(item: any) {
async function loadList() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 });
list.value = items || [];
list.value = [...(items || [])].sort(compareWarehouseSort);
} catch (e: any) {
$baseMessage(e?.message || '加载仓库列表失败', 'error');
list.value = [];
@ -281,9 +312,11 @@ function handleAdd() {
Object.assign(formData, {
warehouseName: '',
warehouseType: undefined,
sort: undefined,
recipient: '',
contactPhone: '',
shippingAddress: '',
remark: '',
});
modalVisible.value = true;
}
@ -304,12 +337,15 @@ function handleCopy(item: any) {
function handleEdit(item: any) {
isEdit.value = true;
editingId.value = item.id ?? null;
const sortRaw = item.sort ?? item.Sort;
Object.assign(formData, {
warehouseName: item.warehouseName,
warehouseType: item.warehouseType,
sort: sortRaw == null || sortRaw === '' ? undefined : Number(sortRaw),
recipient: item.recipient,
contactPhone: item.contactPhone,
shippingAddress: item.shippingAddress,
remark: item.remark || '',
});
modalVisible.value = true;
}
@ -322,9 +358,11 @@ async function handleModalOk() {
const payload = {
warehouseName: formData.warehouseName.trim(),
warehouseType: formData.warehouseType,
sort: formData.sort == null || Number.isNaN(Number(formData.sort)) ? null : Number(formData.sort),
recipient: formData.recipient,
contactPhone: formData.contactPhone,
shippingAddress: formData.shippingAddress,
remark: formData.remark?.trim() || null,
};
try {
if (isEdit.value && editingId.value != null) {
@ -505,6 +543,10 @@ onMounted(() => {
color: var(--color-text-1);
}
.warehouse-card-sort-tag {
flex-shrink: 0;
}
.card-extra {
display: flex;
align-items: center;