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

1.03
This commit is contained in:
zzh 2026-10-10 17:06:02 +08:00
commit bf5be87823
27 changed files with 1905 additions and 856 deletions

View File

@ -1200,6 +1200,8 @@ export interface OrderOperationLogItem {
source?: string
changes: OrderOperationLogChangeItem[]
remark?: string
/** 列表「内容」列:备注 + 字段变更摘要 */
summary?: string
createTime?: string
}
@ -1217,6 +1219,12 @@ function mapOperationLogItem(raw: any): OrderOperationLogItem {
after: c?.after ?? c?.After ?? null,
}))
: []
const remark = raw?.remark ?? raw?.Remark
const summaryRaw = raw?.summary ?? raw?.Summary
const summary =
summaryRaw != null && String(summaryRaw).trim()
? String(summaryRaw).trim()
: buildOperationLogSummaryFallback(changes, remark)
return {
id: Number(raw?.id ?? raw?.Id ?? 0),
orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0),
@ -1228,11 +1236,33 @@ function mapOperationLogItem(raw: any): OrderOperationLogItem {
operatorType: raw?.operatorType ?? raw?.OperatorType,
source: raw?.source ?? raw?.Source,
changes,
remark: raw?.remark ?? raw?.Remark,
remark,
summary,
createTime: raw?.createTime ?? raw?.CreateTime,
}
}
function buildOperationLogSummaryFallback(
changes: OrderOperationLogChangeItem[],
remark?: string | null,
): string {
const parts: string[] = []
if (remark != null && String(remark).trim()) {
parts.push(String(remark).trim())
}
const maxItems = 5
for (const change of changes.slice(0, maxItems)) {
const label = change.label || change.path || '字段'
const before = change.before?.trim() ? change.before : '空'
const after = change.after?.trim() ? change.after : '空'
parts.push(`${label}:${before}→${after}`)
}
if (changes.length > maxItems) {
parts.push(`等${changes.length}项变更`)
}
return parts.length ? parts.join(';') : '—'
}
/** GET /order/operation-log/list */
export async function getOrderOperationLogList(
orderId: number | string,

View File

@ -287,6 +287,8 @@
const formDirtyBaseline = ref('');
const exampleDirtyBaseline = ref({ request: '', response: '' });
/** 参数表内容快照:比 VXE recordset / sticky manualDirty 更抗误报 */
const tablesDirtyBaseline = ref('');
const manualDirty = ref(false);
const isDirty = ref(false);
/** 接口明细加载/回填中:不算未保存,避免误报 */
@ -296,23 +298,88 @@
function pickFormDirtySnapshot() {
const d: any = props.basicFormData || {};
// 字典/SelectPro 常把 number 回写成 string,快照统一成字符串避免误报脏
const norm = (v: unknown) => (v == null || v === '' ? '' : String(v));
return JSON.stringify({
name: d.name,
abbreviation: d.abbreviation,
code: d.code,
description: d.description,
category: d.category,
requestMethod: d.requestMethod,
requestAddress: d.requestAddress,
requestProtocol: d.requestProtocol,
contentType: d.contentType,
responseContentType: d.responseContentType,
mappedParamType: d.mappedParamType,
qps: d.qps,
isBatch: d.isBatch,
name: norm(d.name),
abbreviation: norm(d.abbreviation),
code: norm(d.code),
description: norm(d.description),
category: norm(d.category),
requestMethod: norm(d.requestMethod),
requestAddress: norm(d.requestAddress),
requestProtocol: norm(d.requestProtocol),
contentType: norm(d.contentType),
responseContentType: norm(d.responseContentType),
mappedParamType: norm(d.mappedParamType),
qps: norm(d.qps),
isBatch: !!d.isBatch,
});
}
function stabilizeDirtyRows(rows: any[]): any[] {
return flattenTreeTableRows(Array.isArray(rows) ? rows : [])
.filter((r) => r && !r.__isBodyRoot && r.id !== '__oms_body_root__')
.map((r) => ({
id: r.id ?? r.Id ?? null,
pId: r.pId ?? r.PId ?? 0,
name: String(r.name ?? r.Name ?? ''),
type: r.type ?? r.Type ?? null,
isRequired: !!(r.isRequired ?? r.IsRequired),
description: String(r.description ?? r.Description ?? ''),
cnName: String(r.cnName ?? r.CnName ?? ''),
alias: String(r.alias ?? r.Alias ?? ''),
value: String(r.value ?? r.Value ?? ''),
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id), 'en'));
}
function pickTablesDirtySnapshot() {
try {
const mappingFromGrid =
mappingBodyTableRef.value?.getTableData?.()?.fullData
|| mappingBodyTableRef.value?.xGrid?.getTableData?.()?.fullData;
const responseFromGrid =
responseTableRef.value?.getTableData?.()?.fullData
|| responseTableRef.value?.xGrid?.getTableData?.()?.fullData;
return JSON.stringify({
query: stabilizeDirtyRows(
stripCommonRows(
queryTableRef.value?.getTableData?.()?.fullData
|| queryTableRef.value?.xGrid?.getTableData?.()?.fullData
|| queryTableProps.data
|| [],
),
),
header: stabilizeDirtyRows(
stripCommonRows(
headerTableRef.value?.getTableData?.()?.fullData
|| headerTableRef.value?.xGrid?.getTableData?.()?.fullData
|| headerTableProps.data
|| [],
),
),
bodyForm: stabilizeDirtyRows(
stripCommonRows(
bodyFormTableRef.value?.getTableData?.()?.fullData
|| bodyFormTableRef.value?.xGrid?.getTableData?.()?.fullData
|| bodyFormTableProps.data
|| [],
),
),
request: stabilizeDirtyRows(getRequestBodyFlatParams()),
mappingBody: stabilizeDirtyRows(
getTreeTableFlatRows(mappingFromGrid, mappingBodyTableProps.data),
),
response: stabilizeDirtyRows(
getTreeTableFlatRows(responseFromGrid, responseTableProps.data),
),
});
} catch {
return '';
}
}
function actionRowsDirty(getAction?: () => any) {
const a = getAction?.() || {};
return (
@ -325,31 +392,36 @@
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(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))) {
return true;
}
if (
actionRowsDirty(() =>
// 以参数表内容快照为准(切 tab / SelectPro 回填导致的假 VXE insert、sticky markDirty 不再误报)
if (tablesDirtyBaseline.value) {
const currentTables = pickTablesDirtySnapshot();
if (currentTables && currentTables !== tablesDirtyBaseline.value) return true;
} else if (
// 基线尚未采到表快照时,回退 VXE recordset
actionRowsDirty(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))
|| actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))
|| actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))
|| actionRowsDirty(() =>
stripBodyRootFromActionRows(requestTableRef.value?.getActionRows?.() || {}),
)
|| actionRowsDirty(() => mappingBodyTableRef.value?.getActionRows?.())
|| actionRowsDirty(() => responseTableRef.value?.getActionRows?.())
) {
return true;
}
if (actionRowsDirty(() => mappingBodyTableRef.value?.getActionRows?.())) return true;
if (actionRowsDirty(() => responseTableRef.value?.getActionRows?.())) return true;
// sticky manualDirty:仅在内容快照不可用时作补充;有快照且内容一致则清掉误标
if (manualDirty.value) {
if (tablesDirtyBaseline.value) {
manualDirty.value = false;
return false;
}
return true;
}
return false;
}
@ -360,7 +432,8 @@
function markDirty() {
if (detailHydrating.value) return;
manualDirty.value = true;
isDirty.value = true;
// 立刻按内容快照重算,避免「点一下必填又点回去」仍显示未保存
refreshDirtyState();
}
function captureDirtyBaseline() {
@ -370,6 +443,7 @@
request: requestExample.value || '',
response: responseExample.value || '',
};
tablesDirtyBaseline.value = pickTablesDirtySnapshot();
isDirty.value = false;
dirtyBaselineReady.value = true;
}
@ -1164,6 +1238,7 @@
if (!row || row.children?.length) return;
row.type = typeKey;
paramTypePopoverRowId.value = null;
markDirty();
}
// Query 表格配置(使用下游接口的配置方式)
@ -2359,8 +2434,19 @@
async function deleteRequestRow(row: any) {
if (isBodyRootRow(row)) return;
markDirty();
const ref = requestTab.value === 'mappingBody' ? mappingBodyTableRef : requestTableRef;
const isMapping = requestTab.value === 'mappingBody';
const ref = isMapping ? mappingBodyTableRef : requestTableRef;
await ref.value?.removeRow(row);
const fromGrid =
ref.value?.getTableData?.()?.fullData
|| ref.value?.xGrid?.getTableData?.()?.fullData
|| [];
if (isMapping) {
mappingBodyTableProps.data = Array.isArray(fromGrid) ? [...fromGrid] : [];
} else {
const next = Array.isArray(fromGrid) ? [...fromGrid] : [];
requestTableProps.data = next.length ? next : ensureBodyRootInRows([]);
}
refreshTabCounts();
}
@ -2523,6 +2609,12 @@
async function deleteResponseRow(row: any) {
markDirty();
await responseTableRef.value?.removeRow(row);
// 与表格同步,避免 props 残留旧树被校验/保存误读
const fromGrid =
responseTableRef.value?.getTableData?.()?.fullData
|| responseTableRef.value?.xGrid?.getTableData?.()?.fullData
|| [];
responseTableProps.data = Array.isArray(fromGrid) ? [...fromGrid] : [];
refreshTabCounts();
}
@ -2807,15 +2899,19 @@
await writeTextToClipboard(content);
}
/** 复制当前 Body 参数列表为带 // 描述的格式化 JSON */
/** 复制当前 Body / Mapping Body 参数为带 // 描述的格式化 JSON */
async function copyBodyParamsAsCommentedJson() {
if (isNoneBodyContentType.value) {
let content = '';
if (requestTab.value === 'mappingBody') {
const fromGrid =
mappingBodyTableRef.value?.getTableData?.()?.fullData
|| mappingBodyTableRef.value?.xGrid?.getTableData()?.fullData;
const rows = getTreeTableFlatRows(fromGrid, mappingBodyTableProps.data);
content = buildCommentedJsonFromParameterFlatList(rows);
} else if (isNoneBodyContentType.value) {
Message.warning('当前请求类型为 none,无 Body 参数可复制');
return;
}
let content = '';
if (isBodyKvMode.value) {
} else if (isBodyKvMode.value) {
const rows = getTableFullData(bodyFormTableRef, bodyFormTableProps.data);
content = buildCommentedJsonFromKvRows(rows);
} else {
@ -3107,10 +3203,7 @@
await postParameterValidateParameter(arr);
const sortedData = [...dataList1.value].sort((a: any, b: any) => a.id - b.id);
sortedData.forEach((node: any) => {
responseTableRef.value?.addRowsAt(node, -1);
});
await loadFlatListIntoTreeTable(arr, responseTableProps, responseTableRef);
refreshTabCounts();
$baseMessage('导入成功', 'success');
@ -3215,13 +3308,16 @@
});
await postParameterValidateParameter(arr);
$baseMessage('导入成功', 'success');
dataList1.value.sort((a: any, b: any) => a.id - b.id);
dataList1.value.forEach((node: any) => {
responseTableRef.value?.addRowsAt(node, -1);
});
const rows = (dataList1.value || []).map((i: any) => ({
...i,
cnName: i.cnName ?? '',
optionType: 1,
alias: i?.alias || i?.name,
}));
await loadFlatListIntoTreeTable(rows, responseTableProps, responseTableRef);
refreshTabCounts();
$baseMessage('导入成功', 'success');
drawerVisible1.value = false;
clearResponseImportEditors();
@ -3244,8 +3340,9 @@
async function loadFlatListIntoTreeTable(
flatList: any[],
tableProps: { data: any[] },
tableRef: { value?: { reloadData?: (rows: any[]) => void; xGrid?: any } },
tableRef: { value?: { reloadData?: (rows: any[]) => void; xGrid?: any; addRowsAt?: (row: any, index: number) => any } },
) {
// 全量覆盖:必须 reloadData 整表替换;禁止 addRowsAt 追加(否则肉眼像全量、保存仍是增量)
let rows = flatParameterListToTableRows(sortFlatParameterListParentFirst(flatList));
if (tableProps === requestTableProps) {
rows = ensureBodyRootInRows(rows);
@ -3255,18 +3352,18 @@
if (tableRef.value?.reloadData) {
await tableRef.value.reloadData(rows);
} else {
const ordered = sortFlatParameterListParentFirst(
tableProps === requestTableProps ? rows : flatList,
);
for (const node of ordered) {
await tableRef.value?.addRowsAt?.(node, -1);
}
// 无 reloadData 时先清空再写入,避免残留旧行
tableProps.data = [];
await nextTick();
tableProps.data = rows;
}
await nextTick();
const xGrid = tableRef.value?.xGrid;
if (xGrid?.setAllTreeExpand) {
xGrid.setAllTreeExpand(true);
}
// reloadData 会清空 VXE recordset,必须手动标脏,否则保存按钮不出现
markDirty();
}
/** Json/Xml → Body 树;FormUrlEncoded / None → Mapping Body 树 */
@ -3701,11 +3798,27 @@
<a-button size="small" @click="saveFixedParams">保存固定配置</a-button>
</template>
<template v-else-if="requestTab === 'body' && isBodyKvMode">
<a-button
size="small"
class="section-icon-btn"
title="复制参数为带 // 描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
<IconCopy />
</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewBodyFormParam">
<IconPlus />
</a-button>
</template>
<template v-else-if="requestTab === 'body' && !isNoneBodyContentType">
<a-button
size="small"
class="section-icon-btn"
title="复制参数为带 // 描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
<IconCopy />
</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus />
</a-button>
@ -3714,6 +3827,14 @@
</a-button>
</template>
<template v-else-if="requestTab === 'mappingBody'">
<a-button
size="small"
class="section-icon-btn"
title="复制参数为带 // 描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
<IconCopy />
</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus />
</a-button>
@ -3787,14 +3908,6 @@
aria-label="Request content type"
@change="updateContentType"
/>
<a-button
size="mini"
class="content-type-copy-btn section-icon-btn"
title="复制当前参数为带描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
<IconCopy />
</a-button>
</div>
<!-- Query tab 内容 -->
<div v-show="requestTab === 'query'" class="query-tab-pane">
@ -4785,7 +4898,7 @@
>
<div class="analysis-content">
<div class="analysis-tip">
粘贴 JSON 或 XML 内容后导入参数树。JSON 可用「前端解析」(支持 <code>//</code> 备注写入描述)、「仅导入注释」(只更新已有字段描述,不新增),或使用「解析并导入」走后端解析。
粘贴 JSON 或 XML 内容后<strong>全量替换</strong>当前参数树(先清空再导入,保存时会删除库中旧节点)。JSON 可用「前端解析」(支持 <code>//</code> 备注写入描述)、「仅导入注释」(只更新已有字段描述,不新增/不删树),或使用「解析并导入」走后端解析。
</div>
<div class="analysis-tabs">
<SlidingTabs v-model="tabs" size="mini" :items="analysisTypeItems" aria-label="Import format" />
@ -4878,7 +4991,7 @@
>
<div class="analysis-content">
<div class="analysis-tip">
粘贴 JSON 或 XML 响应示例,点击「解析并导入」生成响应参数树。
粘贴 JSON 或 XML 响应示例,点击「解析并导入」将<strong>全量替换</strong>当前响应参数树(先清空再导入)。
</div>
<div class="analysis-tabs">
<SlidingTabs v-model="tabs1" size="mini" :items="analysisTypeItems" aria-label="Import format" />

View File

@ -2152,6 +2152,9 @@ export default {
'order.myOrders.drawer.operationLog.after': 'After',
'order.myOrders.drawer.operationLog.noChanges': 'No field changes',
'order.myOrders.drawer.operationLog.operator': 'Operator',
'order.myOrders.drawer.operationLog.time': 'Time',
'order.myOrders.drawer.operationLog.action': 'Action',
'order.myOrders.drawer.operationLog.content': 'Content',
'order.myOrders.drawer.tabs.issueHandling': 'Issue handling',
'order.myOrders.drawer.issueHandling.pendingCount': 'Pending {count}',
'order.myOrders.drawer.issueHandling.processedCount': 'Processed {count}',
@ -2384,6 +2387,8 @@ export default {
'order.myOrders.drawer.actions.edit': 'Edit',
'order.myOrders.drawer.actions.save': 'Save',
'order.myOrders.drawer.actions.cancel': 'Cancel',
'order.myOrders.drawer.editAfterInboundForbidden':
'Order details cannot be edited after the parcel is received',
'order.myOrders.drawer.actions.recalculateReceivable': 'Recalc Receivable',
'order.myOrders.drawer.actions.recalculatePayable': 'Recalc Payable',
'order.myOrders.drawer.actions.bindSnapshotVersions': 'Switch Versions',
@ -2406,7 +2411,7 @@ export default {
'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.actions.additionalServices': 'Add service',
'order.myOrders.drawer.editBasicTitle': 'Edit Order Details',
'order.myOrders.drawer.editWeightTitle': 'Edit Weight & Dimensions',
'order.myOrders.drawer.message.saveSuccess': 'Saved successfully',
@ -3956,9 +3961,9 @@ export default {
'operation.inbound.pageTitle': 'Waybill Information',
'operation.inbound.broadcastSettings': 'Broadcast Settings',
'operation.inbound.scan.title': 'Scan inbound',
'operation.inbound.tip': 'Scan to verify product and destCountry, then enter received weight and dimensions before inbound.',
'operation.inbound.tip': 'Scan to load order info, enter weight and dimensions, then inbound (Enter submits when metrics are filled).',
'operation.inbound.preview.title': 'Verify',
'operation.inbound.preview.empty': 'Scan a number to show order info and enter weight / dimensions',
'operation.inbound.preview.empty': 'Scan or enter a number and press Enter to load order info',
'operation.inbound.form.scanNumber': 'Scan Number:',
'operation.inbound.form.receivedWeight': 'Received Weight',
'operation.inbound.form.dimensions': 'L × W × H',
@ -4008,9 +4013,9 @@ export default {
'operation.outbound.pageTitle': 'Waybill Information',
'operation.outbound.broadcastSettings': 'Broadcast Settings',
'operation.outbound.scan.title': 'Scan outbound',
'operation.outbound.tip': 'Scan waybill to verify product, destCountry and channel. Weight and dimensions are optional.',
'operation.outbound.tip': 'Scan to load product, destCountry and channel, enter weight and dimensions, then outbound (Enter submits when metrics are filled).',
'operation.outbound.preview.title': 'Verify',
'operation.outbound.preview.empty': 'Scan a number to show order info; weight / dimensions are optional',
'operation.outbound.preview.empty': 'Scan or enter a number and press Enter to load order info',
'operation.outbound.form.scanNumber': 'Scan Number:',
'operation.outbound.form.shipWeight': 'Shipped weight',
'operation.outbound.form.dimensions': 'L × W × H',
@ -4042,6 +4047,7 @@ export default {
'operation.outbound.table.operateTime': 'Operate Time',
'operation.outbound.table.operator': 'Operator',
'operation.outbound.message.waybillNumberRequired': 'Please enter or scan waybill number',
'operation.outbound.message.metricsRequired': 'Please enter ship weight and dimensions',
'operation.outbound.message.outboundSuccess': 'Outbound successful',
'operation.outbound.message.outboundFailed': 'Outbound failed',
'operation.outbound.message.loadRecentFailed': 'Failed to load recent outbound records',
@ -5040,6 +5046,8 @@ export default {
'channel.labelDesign.actions.search': 'Search',
'channel.labelDesign.actions.create': 'New template',
'channel.labelDesign.actions.edit': 'Design',
'channel.labelDesign.actions.rename': 'Rename',
'channel.labelDesign.actions.editTemplate': 'Edit',
'channel.labelDesign.actions.delete': 'Delete',
'channel.labelDesign.actions.refresh': 'Refresh',
'channel.labelDesign.actions.back': 'Back',
@ -5049,6 +5057,7 @@ export default {
'channel.labelDesign.actions.openTemplate': 'Open template',
'channel.labelDesign.actions.open': 'Open',
'channel.labelDesign.actions.startDesign': 'Start design',
'channel.labelDesign.actions.confirm': 'OK',
'channel.labelDesign.actions.cancel': 'Cancel',
'channel.labelDesign.loadingList': 'Loading templates…',
'channel.labelDesign.emptySelect': 'Select a template on the left, or create one',
@ -5059,7 +5068,12 @@ export default {
'channel.labelDesign.message.saveSuccess': 'Template saved',
'channel.labelDesign.message.saveFailed': 'Save failed',
'channel.labelDesign.message.deleteSuccess': 'Deleted',
'channel.labelDesign.message.renameSuccess': 'Name updated',
'channel.labelDesign.message.renameFailed': 'Failed to rename',
'channel.labelDesign.message.editSuccess': 'Updated',
'channel.labelDesign.message.editFailed': 'Update failed',
'channel.labelDesign.message.nameRequired': 'Template name is required',
'channel.labelDesign.message.typeRequired': 'Please select a template type',
'channel.labelDesign.message.alreadyOpen': 'This template is already open',
'channel.labelDesign.message.openSuccess': 'Opened: {name}',
'channel.labelDesign.fieldDictionary': 'Order fields',

View File

@ -2013,6 +2013,9 @@ export default {
'order.myOrders.drawer.operationLog.after': '修改后',
'order.myOrders.drawer.operationLog.noChanges': '无字段变更',
'order.myOrders.drawer.operationLog.operator': '操作人',
'order.myOrders.drawer.operationLog.time': '时间',
'order.myOrders.drawer.operationLog.action': '操作动作',
'order.myOrders.drawer.operationLog.content': '内容',
'order.myOrders.drawer.tabs.issueHandling': '问题处理',
'order.myOrders.drawer.issueHandling.pendingCount': '待处理 {count}',
'order.myOrders.drawer.issueHandling.processedCount': '已处理 {count}',
@ -2245,11 +2248,12 @@ export default {
'order.myOrders.drawer.actions.edit': '编辑',
'order.myOrders.drawer.actions.save': '保存',
'order.myOrders.drawer.actions.cancel': '取消',
'order.myOrders.drawer.editAfterInboundForbidden': '包裹入库后不可再编辑订单详情',
'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.actions.additionalServices': '添加服务',
'order.myOrders.drawer.fee.rulesVersion': '规则版本',
'order.myOrders.drawer.fee.priceVersion': '价格版本',
'order.myOrders.drawer.bindSnapshot.title': '切换价格/规则版本',
@ -3985,9 +3989,9 @@ export default {
'operation.inbound.pageTitle': '运单信息',
'operation.inbound.broadcastSettings': '播报设置',
'operation.inbound.scan.title': '扫码入库',
'operation.inbound.tip': '扫描单号核对产品与国家后,填写收货实重与尺寸再入库,避免录错。',
'operation.inbound.tip': '扫描单号自动带出订单信息,填写收货实重与尺寸后点入库(件重尺已填时可回车直接入库)。',
'operation.inbound.preview.title': '核对信息',
'operation.inbound.preview.empty': '扫描单号后显示订单信息,并填写收货实重与尺寸',
'operation.inbound.preview.empty': '扫描或输入单号并回车,自动带出订单信息',
'operation.inbound.form.scanNumber': '扫描单号:',
'operation.inbound.form.receivedWeight': '收货实重',
'operation.inbound.form.dimensions': '长*宽*高',
@ -4037,9 +4041,9 @@ export default {
'operation.outbound.pageTitle': '运单信息',
'operation.outbound.broadcastSettings': '播报设置',
'operation.outbound.scan.title': '扫码出库',
'operation.outbound.tip': '扫描运单号核对产品、国家与渠道后,可按需补录发货实重尺寸再出库。',
'operation.outbound.tip': '扫描单号自动带出产品、国家与渠道,填写发货实重与尺寸后点出库(件重尺已填时可回车直接出库)。',
'operation.outbound.preview.title': '核对信息',
'operation.outbound.preview.empty': '扫描单号后显示订单信息,可填写发货实重与尺寸',
'operation.outbound.preview.empty': '扫描或输入单号并回车,自动带出订单信息',
'operation.outbound.form.scanNumber': '扫描单号:',
'operation.outbound.form.shipWeight': '发货实重',
'operation.outbound.form.dimensions': '长*宽*高',
@ -4071,6 +4075,7 @@ export default {
'operation.outbound.table.operateTime': '操作时间',
'operation.outbound.table.operator': '操作人',
'operation.outbound.message.waybillNumberRequired': '请输入或扫描运单号',
'operation.outbound.message.metricsRequired': '请填写发货实重与长宽高',
'operation.outbound.message.outboundSuccess': '出库成功',
'operation.outbound.message.outboundFailed': '出库失败',
'operation.outbound.message.loadRecentFailed': '加载最近出库记录失败',
@ -5068,6 +5073,8 @@ export default {
'channel.labelDesign.actions.search': '查询',
'channel.labelDesign.actions.create': '新建模板',
'channel.labelDesign.actions.edit': '设计',
'channel.labelDesign.actions.rename': '编辑名称',
'channel.labelDesign.actions.editTemplate': '编辑',
'channel.labelDesign.actions.delete': '删除',
'channel.labelDesign.actions.refresh': '刷新列表',
'channel.labelDesign.actions.back': '返回列表',
@ -5077,6 +5084,7 @@ export default {
'channel.labelDesign.actions.openTemplate': '打开模板',
'channel.labelDesign.actions.open': '打开',
'channel.labelDesign.actions.startDesign': '开始设计',
'channel.labelDesign.actions.confirm': '确定',
'channel.labelDesign.actions.cancel': '取消',
'channel.labelDesign.loadingList': '正在加载模板…',
'channel.labelDesign.emptySelect': '请在左侧选择模板,或新建一个',
@ -5087,7 +5095,12 @@ export default {
'channel.labelDesign.message.saveSuccess': '模板已保存',
'channel.labelDesign.message.saveFailed': '保存失败',
'channel.labelDesign.message.deleteSuccess': '已删除',
'channel.labelDesign.message.renameSuccess': '名称已更新',
'channel.labelDesign.message.renameFailed': '修改名称失败',
'channel.labelDesign.message.editSuccess': '已更新',
'channel.labelDesign.message.editFailed': '修改失败',
'channel.labelDesign.message.nameRequired': '请填写模板名称',
'channel.labelDesign.message.typeRequired': '请选择标签类型',
'channel.labelDesign.message.alreadyOpen': '该模板已在设计器中',
'channel.labelDesign.message.openSuccess': '已打开:{name}',
'channel.labelDesign.fieldDictionary': '订单字段',

View File

@ -3,11 +3,15 @@ import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
// eslint-disable-next-line import/no-unresolved
import 'uno.css';
import { installGlobalMessageDefaults } from '@/utils/message';
import router from './router';
import store, { useAppStore } from './store';
import i18n, { initPreferredLocale, ensureFullLocaleMessages } from './locale';
import directive from './directive';
import globalProperties from './plugins/globalProperties';
// 全局 Message:可手动关闭;错误/警告默认 8s
installGlobalMessageDefaults();
import {
bindHeavyPluginsContext,
ensureHeavyPlugins,

View File

@ -41,10 +41,7 @@ export const initDynamicRouter = async (options?: {
/** 路由初始化错误 */
const routerError = (isNotice: boolean = true, title: string = '无权限访问', message: string = '当前账号无任何菜单权限,请联系系统管理员!') => {
if (isNotice) {
Message.warning({
content: message,
duration: 3000
});
Message.warning(message);
}
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
const currentPath = router.currentRoute.value.fullPath;

View File

@ -70,14 +70,16 @@ function flattenTree(arr: any[]): any[] {
return flattenTreeTableRows(arr);
}
/** 读取树表完整扁平数据(避免 fullData 仅返回根节点导致子级丢失) */
/**
* 读取树表完整扁平数据。
* 已传入表格 fullData(含清空后的 [])时必须以表格为准;
* 旧逻辑在 grid 为空时回退 props,会把已删除行「复活」,导致清空后保存仍报重名。
*/
export function getTreeTableFlatRows(fromGrid?: any[], propsData?: any[]): any[] {
const flatFromGrid = flattenTreeTableRows(fromGrid || []);
const propsFlat = Array.isArray(propsData) ? propsData : [];
if (flatFromGrid.length >= propsFlat.length) {
return flatFromGrid.length ? flatFromGrid : [...propsFlat];
if (Array.isArray(fromGrid)) {
return flattenTreeTableRows(fromGrid);
}
return propsFlat.length ? [...propsFlat] : flatFromGrid;
return flattenTreeTableRows(Array.isArray(propsData) ? propsData : []);
}
function getRowSeq(row: any, tableRef: any): string {
@ -278,11 +280,33 @@ function mapTreeChangeRows(
return [...install, ...remove, ...update];
}
function mapTreeDeleteFromBaseline(
serverId: number,
baseline: NormalizedParamRow,
paraKind: number,
) {
return {
optionType: 2,
id: serverId,
pId: baseline.pId,
alias: baseline.alias || baseline.name,
name: baseline.name,
cnName: baseline.cnName,
type: baseline.type,
isRequired: baseline.isRequired,
sort: baseline.sort || '0',
description: baseline.description,
paraKind,
};
}
/**
* 编辑保存时对齐后端 MaintainParametersAsync:
* - 仅提交增/删/改增量
* - 未变更的已有参数不传(后端会自动补全 DB 节点)
* - 假 id + 已存在路径 → 按服务端 id 走修改,避免与 DB 补全节点重复
* - 解析全量导入会 reloadData(清空 removeRecords)且行 id 全是新假 id:走「删光快照 + 整树新增」
* - VXE removeRecords 丢失时,按「快照有、当前表无」补删(与 FormKV 一致)
*/
function buildTreeParameterList(
actionRows: { insertRecords?: any[]; removeRecords?: any[]; updateRecords?: any[] },
@ -298,55 +322,90 @@ function buildTreeParameterList(
changes.filter((c) => c.optionType === 2).map((c) => Number(c.id)),
);
const flatCurrent = flattenTree(treeData).filter(
(item) => item?.name && String(item.name).trim() !== '',
);
if (!isEditMode) {
if (changes.length > 0) return changes;
return flattenTree(treeData)
.filter((item) => item?.name && String(item.name).trim() !== '')
.map((item) => mapTreeRow(item, 1, tableRef, paraKind));
return flatCurrent.map((item) => mapTreeRow(item, 1, tableRef, paraKind));
}
// 全量替换:当前行没有任何加载时的服务端 id(解析导入假 id / 或已清空整表)
const preservesAnyServerId = flatCurrent.some((item) =>
original.ids.has(normalizeRowId(item)),
);
if (original.ids.size > 0 && !preservesAnyServerId) {
const result: any[] = [];
original.ids.forEach((serverId) => {
const baseline = original.rowById.get(serverId);
if (!baseline?.name) return;
result.push(mapTreeDeleteFromBaseline(serverId, baseline, paraKind));
});
flatCurrent.forEach((item) => {
result.push(mapTreeRow(item, 1, tableRef, paraKind));
});
return result;
}
const result: any[] = [];
changes.filter((c) => c.optionType === 2).forEach((c) => result.push(c));
const presentServerIds = new Set<number>();
flattenTree(treeData)
.filter((item) => item?.name && String(item.name).trim() !== '')
.forEach((item) => {
const id = normalizeRowId(item);
const pId = Number(item?.pId ?? item?.PId ?? 0);
const name = String(item?.name ?? item?.Name ?? '').trim();
const pathKey = buildParamPathKey(pId, name);
flatCurrent.forEach((item) => {
const id = normalizeRowId(item);
const pId = Number(item?.pId ?? item?.PId ?? 0);
const name = String(item?.name ?? item?.Name ?? '').trim();
const pathKey = buildParamPathKey(pId, name);
if (removedIds.has(id)) return;
if (removedIds.has(id)) return;
const tracked = changeById.get(id);
if (tracked && tracked.optionType !== 2) {
result.push(tracked);
return;
const tracked = changeById.get(id);
if (tracked && tracked.optionType !== 2) {
result.push(tracked);
const trackedServerId = Number(tracked.id);
if (trackedServerId > 0 && original.ids.has(trackedServerId)) {
presentServerIds.add(trackedServerId);
}
return;
}
const serverIdByPath = original.pathToId.get(pathKey);
const serverId = original.ids.has(id) ? id : serverIdByPath;
const baseline = serverId ? original.rowById.get(serverId) : undefined;
const serverIdByPath = original.pathToId.get(pathKey);
const serverId = original.ids.has(id) ? id : serverIdByPath;
const baseline = serverId ? original.rowById.get(serverId) : undefined;
if (serverId && baseline) {
if (!isSameTreeParamRow(item, baseline)) {
result.push(mapTreeRow(item, 3, tableRef, paraKind, serverId));
}
return;
if (serverId && baseline) {
presentServerIds.add(serverId);
if (!isSameTreeParamRow(item, baseline)) {
result.push(mapTreeRow(item, 3, tableRef, paraKind, serverId));
}
return;
}
if (!serverId) {
result.push(mapTreeRow(item, 1, tableRef, paraKind));
}
});
if (!serverId) {
result.push(mapTreeRow(item, 1, tableRef, paraKind));
}
});
// 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(mapTreeDeleteFromBaseline(serverId, baseline, paraKind));
});
return result;
}
/** 客户端校验:同一 pId 下参数名不可重复(对齐后端 ValidateParameter) */
/** 客户端校验:同一 pId 下参数名不可重复(对齐后端 ValidateParameter,跳过删除项) */
export function validateTreeParameterPaths(rows: any[]): string | null {
const seen = new Set<string>();
for (const item of flattenTree(rows)) {
// 后端 ValidateParameter 会先排除 OptionType.删除;全量替换时「删旧 + 增同名」不能当重复
const optionType = Number(item?.optionType ?? item?.OptionType ?? 0);
if (optionType === 2) continue;
const name = String(item?.name ?? item?.Name ?? '').trim();
if (!name) continue;
const pId = Number(item?.pId ?? item?.PId ?? 0);

View File

@ -1,18 +1,94 @@
import { Message } from '@arco-design/web-vue';
export type BaseMessageType = 'success' | 'error' | 'warning' | 'info';
export type BaseMessageOptions = {
/** 显示时长 ms */
duration?: number;
/** 是否显示关闭按钮 */
closable?: boolean;
/** 悬停时重置倒计时 */
resetOnHover?: boolean;
};
type MessageConfigLike = {
content: any;
duration?: number;
closable?: boolean;
resetOnHover?: boolean;
[key: string]: any;
};
/** 全局默认:可手动关闭;错误/警告多停留约 5s(合计 8s) */
const GLOBAL_MESSAGE_DEFAULTS: Record<
'info' | 'success' | 'warning' | 'error' | 'normal',
{ duration: number; closable: boolean; resetOnHover: boolean }
> = {
info: { duration: 3000, closable: true, resetOnHover: true },
success: { duration: 3000, closable: true, resetOnHover: true },
warning: { duration: 8000, closable: true, resetOnHover: true },
error: { duration: 8000, closable: true, resetOnHover: true },
normal: { duration: 3000, closable: true, resetOnHover: true },
};
type PatchableType = keyof typeof GLOBAL_MESSAGE_DEFAULTS;
let messageDefaultsInstalled = false;
function mergeMessageConfig(
type: PatchableType,
config: string | MessageConfigLike,
): MessageConfigLike {
const defaults = GLOBAL_MESSAGE_DEFAULTS[type];
if (typeof config === 'string') {
return {
content: config,
...defaults,
};
}
return {
...defaults,
...config,
duration: config.duration ?? defaults.duration,
closable: config.closable ?? defaults.closable,
resetOnHover: config.resetOnHover ?? defaults.resetOnHover,
};
}
/** 安装全局 Message 默认行为(幂等)。覆盖 Message.* 与 $baseMessage。 */
export function installGlobalMessageDefaults() {
if (messageDefaultsInstalled) return;
messageDefaultsInstalled = true;
(Object.keys(GLOBAL_MESSAGE_DEFAULTS) as PatchableType[]).forEach((type) => {
const original = (Message[type] as any).bind(Message);
(Message as any)[type] = (config: string | MessageConfigLike, appContext?: any) => {
return original(mergeMessageConfig(type, config), appContext);
};
});
}
// 被任何路径 import 时即安装,保证 request 拦截器等直接 Message.error 也生效
installGlobalMessageDefaults();
/**
* 全局消息提示工具函数
* 全局消息提示
* @param message 消息内容
* @param type 消息类型 'success' | 'error' | 'warning' | 'info'
* @param type 消息类型
* @param options 可覆盖全局默认(duration / closable / resetOnHover)
*/
export const baseMessage = (
message: string,
type: 'success' | 'error' | 'warning' | 'info' = 'info'
type: BaseMessageType = 'info',
options?: BaseMessageOptions,
) => {
Message[type](message);
Message[type]({
content: message,
...options,
});
};
// Export as a global function for direct use
export const $baseMessage = baseMessage;
export default baseMessage;

View File

@ -12,6 +12,8 @@ import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { resolvePostLogoutLogin } from '@/utils/login-portal';
import { rewriteBrowserApiPath } from '@/utils/rewriteBrowserApiPath';
// 安装全局 Message 默认(可关闭 / 错误加长),须在本文件 Message.* 调用前生效
import '@/utils/message';
const service: AxiosInstance = axios.create({
baseURL: '/',
@ -149,10 +151,7 @@ service.interceptors.response.use(
// 根据不同的状态码处理不同的错误
switch (status) {
case 400:
Message.error({
content: `请求参数错误: ${data?.message || statusText}`,
duration: 3000,
});
Message.error(`请求参数错误: ${data?.message || statusText}`);
break;
case 401:
// 401 未授权,登录过期
@ -162,10 +161,7 @@ service.interceptors.response.use(
if (isRbacApi) {
// RBAC API的401,说明token过期,需要重新登录
Message.error({
content: errorMessage,
duration: 3000,
});
Message.error(errorMessage);
const rbacUserStore = useRbacUserStore();
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
const currentPath = router.currentRoute.value.fullPath;
@ -184,10 +180,7 @@ service.interceptors.response.use(
}
} else {
// 非RBAC API的401,统一处理为登录失效,跳转到登录页
Message.error({
content: errorMessage || '登录已过期,请重新登录',
duration: 3000,
});
Message.error(errorMessage || '登录已过期,请重新登录');
const rbacUserStore = useRbacUserStore();
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
const currentPath = router.currentRoute.value.fullPath;
@ -202,41 +195,23 @@ service.interceptors.response.use(
}
break;
case 403:
Message.error({
content: `禁止访问: ${data?.message || statusText}`,
duration: 3000,
});
Message.error(`禁止访问: ${data?.message || statusText}`);
break;
case 404:
Message.error({
content: `请求的资源不存在: ${data?.message || statusText}`,
duration: 3000,
});
Message.error(`请求的资源不存在: ${data?.message || statusText}`);
break;
case 500:
Message.error({
content: `服务器内部错误: ${data?.message || statusText}`,
duration: 3000,
});
Message.error(`服务器内部错误: ${data?.message || statusText}`);
break;
default:
Message.error({
content: `请求失败 (${status}): ${data?.message || statusText}`,
duration: 3000,
});
Message.error(`请求失败 (${status}): ${data?.message || statusText}`);
}
} else if (error.request) {
// 请求已经发出,但没有收到响应
Message.error({
content: '网络连接失败,请检查网络设置',
duration: 3000,
});
Message.error('网络连接失败,请检查网络设置');
} else {
// 请求配置出错
Message.error({
content: `请求配置错误: ${error.message}`,
duration: 3000,
});
Message.error(`请求配置错误: ${error.message}`);
}
return Promise.reject(error);

View File

@ -230,6 +230,10 @@
if (!forceRefresh && isInitializing.value && lastInitId.value === currentId) {
return;
}
// 已加载同一接口且非强制刷新(来回切 tab):跳过重拉,避免误报未保存
if (!forceRefresh && lastInitId.value === currentId && shouldRenderForm.value && !isInitializing.value) {
return;
}
// 标记正在初始化和当前ID
isInitializing.value = true;

View File

@ -53,17 +53,29 @@
return row;
}
/** SelectPro / 字典 key 常把 number 写成 string,脏检查必须宽松比较 */
function sameScalar(a: unknown, b: unknown) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return String(a) === String(b);
}
function rowSourceId(row: any): number | null {
const id = Number(row?.id);
return Number.isFinite(id) && id > 0 ? id : null;
}
function snapshotSource(rows: any[]) {
const map = new Map<number | string, any>();
rows.forEach((row) => {
if (row.id != null) {
map.set(row.id, {
id: row.id,
configType: row.configType,
parameterId: row.parameterId,
value: row.value == null ? '' : String(row.value),
});
}
const id = rowSourceId(row);
if (id == null) return;
map.set(id, {
id,
configType: row.configType == null ? '' : String(row.configType),
parameterId: row.parameterId == null ? '' : String(row.parameterId),
value: row.value == null ? '' : String(row.value),
});
});
sourceMap.value = map;
removedRecords.value = [];
@ -74,19 +86,20 @@
const updateRecords: any[] = [];
listData.value.forEach((row) => {
if (row._isNew || row.id == null) {
const id = rowSourceId(row);
if (row._isNew || id == null) {
insertRecords.push(row);
return;
}
const src = sourceMap.value.get(row.id);
const src = sourceMap.value.get(id);
if (!src) {
insertRecords.push(row);
return;
}
const currentValue = row.value == null ? '' : String(row.value);
if (
src.configType !== row.configType ||
src.parameterId !== row.parameterId ||
!sameScalar(src.configType, row.configType) ||
!sameScalar(src.parameterId, row.parameterId) ||
src.value !== currentValue
) {
updateRecords.push(row);
@ -141,12 +154,27 @@
});
const tableData = [...addData, ...removeData, ...updataData];
// map 里若因类型空而 push 了 Message 返回值,拦截掉
const items = tableData.filter((i) => i && typeof i === 'object' && i.optionType != null);
if (items.length !== tableData.length) {
return false;
}
await postInterfaceReturnConfigSave({
interfaceId: addId.value,
items: tableData,
items,
});
// 保存成功后以当前表为准重新采基线,避免仍按旧快照误报脏
listData.value.forEach((row) => {
if (row._isNew) delete row._isNew;
});
snapshotSource(listData.value);
// 保存后需重新拉 id(新增行),否则下次会当 insert
if (resolvedInterfaceId.value) {
await loadData(resolvedInterfaceId.value);
}
if (!options?.skipSuccessToast) {
$baseMessage('保存成功,如需生效请去发布', 'success');
}
@ -171,8 +199,9 @@
if (idx >= 0) {
listData.value.splice(idx, 1);
}
if (currRow?.id != null && !currRow._isNew) {
if (!removedRecords.value.some((r) => r.id === currRow.id)) {
const removedId = rowSourceId(currRow);
if (removedId != null && !currRow._isNew) {
if (!removedRecords.value.some((r) => rowSourceId(r) === removedId)) {
removedRecords.value.push(currRow);
}
}
@ -181,12 +210,15 @@
function handleTypeChange(e: any, row: any) {
row.parameterId = undefined;
const fullData = getFullData();
const nextType = Number(e?.value ?? e);
if (e.value === 2 || e.value === 3) {
const flag = fullData?.filter((item: any) => item.configType === e.value);
if (nextType === 2 || nextType === 3) {
const flag = fullData?.filter(
(item: any) => Number(item.configType) === nextType,
);
if (flag && flag?.length >= 2) {
const msg =
e.value === 2
nextType === 2
? '标签上传只能保存一条数据'
: '错误返回只能保存一条数据';
row.configType = undefined;
@ -232,6 +264,9 @@
);
snapshotSource(listData.value);
await nextTick();
// SelectPro 异步回写 key 类型后再采一次基线,避免刚打开就误报脏
await nextTick();
snapshotSource(listData.value);
}
watch(
@ -244,9 +279,11 @@
{ immediate: false }
);
/** 与后端 onlyLeafs 一致:排除 Object/Array。错误返回(3)需要整树,其余只要叶子 */
function formatNameType(row: any) {
if (row.configType === 1 || row.configType === 2) return true;
if (row.configType === 3) return false;
const type = Number(row?.configType);
if (type === 3) return false;
return true;
}
const valueList = ref<API.EnumCacheEntry[]>([]);
@ -325,7 +362,7 @@
<template #value="{ record }">
<SelectPro
v-if="record.configType === 2"
v-if="Number(record.configType) === 2"
v-model="record.value"
:api="getDictionaryGetList"
:extra="{
@ -338,7 +375,7 @@
placeholder="请选择值"
/>
<a-input
v-else-if="record.configType === 1"
v-else-if="Number(record.configType) === 1"
v-model="record.value"
placeholder="请输入值"
/>

View File

@ -1437,10 +1437,20 @@
addClick();
}
/** 刷新左侧服务商列表:清空当前选中 */
function handleRefresh() {
selectedProvider.value = null;
selectedInterface.value = null;
interfaceList.value = [];
loadServiceProviderList();
}
/** 刷新中间接口列表:清空当前接口选中 */
function handleRefreshInterfaceList() {
selectedInterface.value = null;
loadInterfaceList();
}
// 接口弹窗关闭
function handleInterfaceModalClose() {
interfaceModalVisible.value = false;
@ -1787,7 +1797,7 @@
actions-title="操作"
delete-label="删除"
@search="handleInterfaceSearch"
@refresh="loadInterfaceList"
@refresh="handleRefreshInterfaceList"
@add="addInterface"
@select="handleInterfaceSelectFromTree"
@node-click="handleInterfaceNodeClick"

View File

@ -7,6 +7,7 @@
getEditMapDetails,
getGetMapParameterCascadedList,
} from '@/api/InterfaceMappingServices/interfaceMap';
import { ParameterType } from '@/constants/itfxEnums';
import AddParameter from './AddParameter.vue';
import MapAiAssistModal from './MapAiAssistModal.vue';
@ -290,11 +291,36 @@
);
};
}
/** 与 GetParameterList?onlyLeafs=true 一致:排除 Object/Array */
function isLeafParamType(type: unknown) {
const t = Number(type);
return t !== ParameterType.Object && t !== ParameterType.Array;
}
/** 接口根保留,参数列表只留叶子(服务商出参接口原先未过滤) */
function prepareCascaderRoots(roots: any[]) {
return (roots || []).map((root) => ({
...root,
parameters: (Array.isArray(root.parameters) ? root.parameters : []).filter(
(p: any) => isLeafParamType(p?.type),
),
}));
}
function setlabel(data: any) {
data.forEach((item: any) => {
if (item.parameters) item.parameters = setlabel(item.parameters);
const children = Array.isArray(item.parameters) ? item.parameters : null;
if (children?.length) {
item.parameters = setlabel(children);
} else if (item.interfaceId != null && item.alias == null) {
// 接口根无参数:保留空 children,避免被当成可选叶子
item.parameters = [];
} else {
// 参数叶子:去掉空 parameters,否则 Arco 会当成不可选的非叶子
delete item.parameters;
}
item.getlabel = item.parameters
item.getlabel = children?.length
? item.name
: item.description
? `${item.alias}-${item.description}`
@ -304,6 +330,13 @@
});
return data;
}
/** Arco Cascader 无 show-all-levels,用 formatLabel 只展示叶子文案 */
function formatCascaderLeafLabel(options: any[]) {
if (!options?.length) return '';
const leaf = options[options.length - 1];
return leaf?.getlabel ?? leaf?.alias ?? leaf?.name ?? '';
}
const mapAliasEditRef = ref();
/** 级联 options 按步骤缓存,避免每行渲染都深拷贝/打标 */
const cascaderOptionsCache = ref<Map<string, any[]>>(new Map());
@ -321,7 +354,9 @@
let result: any[];
if (isReverse) {
result = setlabel(cascadedList.value.slice(1, cascadedList.value.length));
result = setlabel(
prepareCascaderRoots(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);
@ -335,7 +370,7 @@
}),
).filter(Boolean);
}
result = setlabel(data);
result = setlabel(prepareCascaderRoots(data));
}
cascaderOptionsCache.value.set(cacheKey, result);
return result;
@ -347,16 +382,45 @@
return getOptions(activeInParaItem.value, false);
});
const outputCascaderOptions = computed(() => getOptions({ interfaceId: 0 }, true));
function findCascadedParamById(id: number): any | null {
let found: any = null;
const walk = (nodes: any[] | undefined) => {
if (!nodes?.length || found) return;
for (const node of nodes) {
if (Number(node.id) === Number(id)) {
found = node;
return;
}
if (Array.isArray(node.parameters) && node.parameters.length) {
walk(node.parameters);
}
}
};
for (const root of cascadedList.value) {
walk(root.parameters);
if (found) break;
}
return found;
}
function mapAliasEditCheng(
e: any,
row: API.InterfaceParameterMapOutputValueObject
) {
if (e) {
let arr: any[] = [];
cascadedList.value.forEach((t: any) => {
if (t.parameters) arr = arr.concat(t.parameters);
});
const item = arr.find((t: any) => t.id === e);
const item = findCascadedParamById(e);
// 仅允许叶子:Object/Array 或仍带子节点的不算有效映射
if (
!item ||
!isLeafParamType(item.type) ||
(Array.isArray(item.parameters) && item.parameters.length)
) {
row.mapAlias = '';
row.mapId = 0;
row.mapCnName = '';
row.mapDescription = '';
return;
}
row.mapAlias = item?.alias;
row.mapId = item?.id;
row.mapCnName = item?.cnName;
@ -740,6 +804,8 @@
v-model="row.mapId"
:options="activeInputCascaderOptions"
:filter-option="filterMethod"
:format-label="formatCascaderLeafLabel"
search-option-only-label
allow-clear
allow-search
:field-names="{
@ -747,17 +813,12 @@
label: 'getlabel',
children: 'parameters',
}"
:show-all-levels="false"
@change="
(value) => {
mapAliasEditCheng(value, row);
}
"
>
<template #label="{ data: _data }">
{{ _data.label }}
</template>
</a-cascader>
/>
</template>
<!-- 参数类型(请求侧)在必填时加红色星号 -->
@ -798,6 +859,8 @@
v-model="row.mapId"
:options="outputCascaderOptions"
:filter-option="filterMethod"
:format-label="formatCascaderLeafLabel"
search-option-only-label
allow-search
allow-clear
:field-names="{
@ -805,17 +868,12 @@
label: 'getlabel',
children: 'parameters',
}"
:show-all-levels="false"
@change="
(value) => {
mapAliasEditCheng(value, row);
}
"
>
<template #label="{ data: _data }">
{{ _data.label }}
</template>
</a-cascader>
/>
</template>
<!-- 参数类型(响应侧)在必填时加红色星号 -->
<template #typeResp="{ row }">

View File

@ -100,8 +100,12 @@
// 上游接口的验证规则(使用 form-config.ts 中的规则)
const upstreamFormRules = formRules;
// 初始化函数
async function init(data?: { id?: number | string; interfaceId?: number | string }) {
// 初始化函数;forceRefresh 用于保存后强制重拉,切 tab 回来不重复 init
async function init(data?: {
id?: number | string;
interfaceId?: number | string;
forceRefresh?: boolean;
}) {
// 优先使用传入的参数
let idRaw: any = null;
@ -118,6 +122,7 @@
tableForm.isUpStream = true; // 上游接口默认设置为 true
tableForm.requestMethod = 2; // 默认 POST
tableForm.qps = 1;
lastInitId.value = null;
await nextTick();
if (interfaceDetailRef.value) {
interfaceDetailRef.value.setBasicFormValues({ ...tableForm });
@ -127,11 +132,16 @@
}
const id = Number(idRaw);
const forceRefresh = !!data?.forceRefresh;
// 防止重复调用:如果正在加载相同的接口,直接返回
// 正在加载同一接口:忽略重复调用
if (isInitializing.value && lastInitId.value === id) {
return;
}
// 已加载同一接口且非强制刷新(例如明细 ↔ 调试来回切):跳过重拉,避免误报未保存
if (!forceRefresh && lastInitId.value === id && shouldRenderForm.value) {
return;
}
// 标记正在初始化和当前ID
isInitializing.value = true;

View File

@ -125,7 +125,10 @@
async function loadList(options?: { keepSelection?: boolean }) {
if (!props.id) {
list.value = [];
selectedId.value = null;
if (selectedId.value != null) {
selectedId.value = null;
emit('row-click', null);
}
return;
}
try {
@ -142,8 +145,10 @@
const data: any = raw.items ?? raw.data ?? raw.list ?? raw.records ?? raw;
list.value = Array.isArray(data) ? data : [];
if (!options?.keepSelection) {
// 切换上游接口时不默认选中第一条映射
// 刷新 / 切换上游接口:清空选中
const hadSelection = selectedId.value != null;
selectedId.value = null;
if (hadSelection) emit('row-click', null);
} else if (
selectedId.value !== null &&
!list.value.some(
@ -151,10 +156,12 @@
)
) {
selectedId.value = null;
emit('row-click', null);
}
} catch (e) {
list.value = [];
selectedId.value = null;
emit('row-click', null);
} finally {
loading.value = false;
}
@ -398,7 +405,7 @@
:show-node-index="true"
:show-node-code="true"
@search="handleSearch"
@refresh="() => loadList({ keepSelection: true })"
@refresh="() => loadList({ keepSelection: false })"
@add="handleAdd"
@select="handleMenuSelect"
@node-click="handleNodeClick"

View File

@ -998,7 +998,17 @@
}
function rowClick(row: any) {
MapServiceProviderROW.value = row;
MapServiceProviderROW.value = row || null;
if (!row) {
serviceProviderId.value = 0;
serviceProviderName.value = '';
mappingPanelReady.value = false;
if (activeContentTab.value === 'mapping' || activeContentTab.value === 'debugger') {
activeContentTab.value = 'detail';
currentActiveTab.value = 'detail';
}
return;
}
// 联动右侧映射明细:根据左侧选中映射行初始化 EnteredMap
serviceProviderId.value = row.serviceProviderId;
serviceProviderName.value = row.serviceProviderName;
@ -1462,6 +1472,22 @@
loadInterfaceTree();
}
/** 刷新左侧列表:清空当前选中,回到未选中状态 */
async function handleRefreshInterfaceTree() {
selectedKeys.value = [];
selectedInterface.value = null;
upStreamIdRef.value = undefined;
serviceProviderId.value = 0;
serviceProviderName.value = '';
MapServiceProviderROW.value = null;
mappingPanelReady.value = false;
if (activeContentTab.value === 'mapping' || activeContentTab.value === 'debugger') {
activeContentTab.value = 'detail';
currentActiveTab.value = 'detail';
}
await loadInterfaceTree();
}
// 处理下拉菜单选择
function handleActionSelect(value: string, option: any) {
try {
@ -1519,7 +1545,7 @@
:actions-title="$t('system.interface.table.actions')"
method-label="POST"
@search="handleSearch"
@refresh="loadInterfaceTree"
@refresh="handleRefreshInterfaceTree"
@add="interfaceClick"
@select="handleInterfaceSelect"
@expand="handleTreeExpand"

View File

@ -494,8 +494,138 @@ onBeforeUnmount(() => {
flex: 1;
min-height: 0;
display: flex;
--vg-header-bg: var(--app-primary-color, rgb(var(--arcoblue-6)));
/* vg-print 大量写死 #580df8;同步系统主色 + Element Plus 主色 */
--vg-primary: var(--app-primary-color, rgb(var(--arcoblue-6)));
--vg-primary-soft: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
--vg-header-bg: var(--vg-primary);
--vg-header-text: #fff;
--el-color-primary: var(--vg-primary);
--el-color-primary-light-3: color-mix(in srgb, var(--app-primary-color) 70%, #fff);
--el-color-primary-light-5: color-mix(in srgb, var(--app-primary-color) 50%, #fff);
--el-color-primary-light-7: color-mix(in srgb, var(--app-primary-color) 30%, #fff);
--el-color-primary-light-8: color-mix(in srgb, var(--app-primary-color) 20%, #fff);
--el-color-primary-light-9: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
--el-color-primary-dark-2: var(--app-primary-color-hover, var(--app-primary-color));
}
/* 顶栏 / 左栏标题 / 属性头 / 结构树头 / 历史头 */
.ld-designer__body :deep(.primary-toolbar),
.ld-designer__body :deep(.left-sider-header),
.ld-designer__body :deep(.prop-header),
.ld-designer__body :deep(.hip-struct-header),
.ld-designer__body :deep(.hip-history-header) {
background-color: var(--vg-primary) !important;
background: var(--vg-primary) !important;
}
.ld-designer__body :deep(.primary-toolbar) {
border-bottom-color: color-mix(in srgb, var(--app-primary-color) 40%, #dcdfe6) !important;
}
/* 顶栏按钮:主色底上用浅色按钮,避免与主色 primary 按钮糊在一起 */
.ld-designer__body :deep(.primary-toolbar .el-button) {
--el-button-bg-color: rgba(255, 255, 255, 0.95);
--el-button-border-color: rgba(255, 255, 255, 0.85);
--el-button-text-color: var(--vg-primary);
--el-button-hover-bg-color: #fff;
--el-button-hover-border-color: #fff;
--el-button-hover-text-color: var(--app-primary-color-hover, var(--vg-primary));
--el-button-active-bg-color: #fff;
--el-button-active-border-color: #fff;
--el-button-active-text-color: var(--app-primary-color-hover, var(--vg-primary));
}
.ld-designer__body :deep(.primary-toolbar .el-button--primary) {
--el-button-bg-color: #fff;
--el-button-border-color: #fff;
--el-button-text-color: var(--vg-primary);
--el-button-hover-bg-color: rgba(255, 255, 255, 0.92);
--el-button-hover-border-color: #fff;
--el-button-hover-text-color: var(--app-primary-color-hover, var(--vg-primary));
}
.ld-designer__body :deep(.secondary-toolbar) {
background-color: color-mix(in srgb, var(--app-primary-color) 6%, #fff) !important;
}
.ld-designer__body :deep(.secondary-toolbar .scrollbar-track) {
background: color-mix(in srgb, var(--app-primary-color) 12%, transparent) !important;
}
.ld-designer__body :deep(.secondary-toolbar .scrollbar-thumb) {
background-color: var(--vg-primary) !important;
}
/* 文字 / 勾选强调色 */
.ld-designer__body :deep(.template-name),
.ld-designer__body :deep(.hiprint-option-item-iconBtn),
.ld-designer__body :deep(.template-item.is-active .template-check) {
color: var(--vg-primary) !important;
}
/* 左侧模板选中态 */
.ld-designer__body :deep(.template-item.is-active) {
background: linear-gradient(
90deg,
var(--vg-primary-soft),
#fff 60%
) !important;
border-color: var(--vg-primary) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--app-primary-color) 12%, transparent) !important;
}
/* 画布缩放把手 / 表格把手 */
.ld-designer__body :deep(.resize-handle:hover),
.ld-designer__body :deep(.hiprint-printElement-table-handle),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .n),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .s),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .e),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .w),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .ne),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .nw),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .se),
.ld-designer__body :deep(.hiprint-printElement-table .resize-panel .sw) {
background: var(--vg-primary) !important;
background-color: var(--vg-primary) !important;
}
/* 左侧组件拖拽项(库内边框写死 #590df8) */
.ld-designer__body :deep(.ep-draggable-item),
.ld-designer__body :deep(.rect-printElement-types .hiprint-printElement-type > li > ul > li > a),
.ld-designer__body :deep(.small-printElement-types .hiprint-printElement-type > li > ul > li > a) {
border-color: var(--vg-primary) !important;
color: var(--color-text-1, #303133) !important;
}
.ld-designer__body :deep(.ep-draggable-item:hover),
.ld-designer__body :deep(.rect-printElement-types .hiprint-printElement-type > li > ul > li > a:hover),
.ld-designer__body :deep(.small-printElement-types .hiprint-printElement-type > li > ul > li > a:hover) {
background: var(--vg-primary-soft) !important;
border-color: var(--vg-primary) !important;
color: var(--vg-primary) !important;
}
/* 左栏折叠钮(库内写死 #590df8) */
.ld-designer__body :deep(.left-sider-toggle) {
background: var(--vg-primary) !important;
background-color: var(--vg-primary) !important;
color: #fff !important;
}
/* 属性 Tab / 分页选中(库内 #2196f3) */
.ld-designer__body :deep(.prop-tabs .prop-tab-items li.active) {
border-bottom-color: var(--vg-primary) !important;
color: var(--vg-primary) !important;
}
.ld-designer__body :deep(.hiprint-pagination .selected) {
border-color: var(--vg-primary) !important;
}
/* 移动端 sheet tab(库内 #409eff) */
.ld-designer__body :deep(.mobile-bottom-sheet .mobile-sheet-tabs .tab-item.active) {
color: var(--vg-primary) !important;
border-bottom-color: var(--vg-primary) !important;
}
.ld-spin {

View File

@ -25,7 +25,18 @@
@add="openCreateModal"
@select="handleMenuSelect"
@delete-node="handleDeleteNode"
/>
>
<template #node-menu="{ nodeData }">
<a-doption @click="openRenameModal(nodeData)">
<icon-edit />
{{ $t('channel.labelDesign.actions.editTemplate') }}
</a-doption>
<a-doption @click="handleDeleteNode(nodeData)">
<icon-delete />
{{ $t('channel.labelDesign.actions.delete') }}
</a-doption>
</template>
</CustomLeftMenu>
</div>
<div class="right-panel">
@ -82,6 +93,45 @@
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="renameModalVisible"
:title="$t('channel.labelDesign.actions.editTemplate')"
:ok-text="$t('channel.labelDesign.actions.confirm')"
:cancel-text="$t('channel.labelDesign.actions.cancel')"
unmount-on-close
:on-before-ok="confirmRename"
>
<a-form
:model="renameForm"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item
field="name"
:label="$t('channel.labelDesign.table.name')"
:rules="[{ required: true, message: $t('channel.labelDesign.message.nameRequired') }]"
>
<a-input
v-model="renameForm.name"
allow-clear
:placeholder="$t('channel.labelDesign.namePlaceholder')"
/>
</a-form-item>
<a-form-item
field="templateType"
:label="$t('channel.labelDesign.table.type')"
:rules="[{ required: true, message: $t('channel.labelDesign.message.typeRequired') }]"
>
<a-select v-model="renameForm.templateType">
<a-option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@ -90,12 +140,14 @@ import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal, Message, Spin } from '@arco-design/web-vue'
import { IconDelete, IconEdit } from '@arco-design/web-vue/es/icon'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
import {
deleteLabelTemplate,
getLabelTemplate,
getLabelTemplateList,
saveLabelTemplate,
type LabelTemplateListItem,
} from '@/api/channel/labelTemplate'
@ -126,6 +178,12 @@ const createForm = reactive({
name: '',
templateType: 'lastMile',
})
const renameModalVisible = ref(false)
const renameForm = reactive({
id: 0,
name: '',
templateType: 'lastMile',
})
const editing = reactive<{
id?: number
name?: string
@ -261,6 +319,49 @@ function handleMenuSelect(keys: string[]) {
openTemplate(id)
}
function openRenameModal(nodeData: any) {
const id = Number(nodeData?.id)
if (!id) return
renameForm.id = id
renameForm.name = String(nodeData?.name || nodeData?.title || '')
renameForm.templateType = String(nodeData?.templateType || 'lastMile')
renameModalVisible.value = true
}
async function confirmRename() {
const id = renameForm.id
const name = renameForm.name?.trim()
const templateType = renameForm.templateType?.trim() || 'lastMile'
if (!id) return false
if (!name) {
Message.warning(t('channel.labelDesign.message.nameRequired'))
return false
}
if (!templateType) {
Message.warning(t('channel.labelDesign.message.typeRequired'))
return false
}
try {
const detail = await getLabelTemplate(id)
await saveLabelTemplate({
id,
name,
templateType,
templateJson: detail.templateJson,
})
Message.success(t('channel.labelDesign.message.editSuccess'))
if (editing.id === id) {
editing.name = name
editing.templateType = templateType
}
await loadList()
return true
} catch (e: any) {
Message.error(e?.message || t('channel.labelDesign.message.editFailed'))
return false
}
}
function handleDeleteNode(nodeData: any) {
const id = Number(nodeData?.id)
const name = String(nodeData?.name || nodeData?.title || '')

View File

@ -367,9 +367,27 @@
enterBeginsEditing: true,
fragmentSelection: false,
selectionMode: 'multiple',
// 点表格外的工具栏/弹窗按钮时不要清选中,否则「批量填价」等会在 mousedown 时先丢选中再点不到
outsideClickDeselects(event: MouseEvent) {
const target = event.target as HTMLElement | null;
return !target?.closest('.sales-price-config-modal, .arco-modal, .arco-trigger-popup');
if (!target) return true;
if (
target.closest(
[
'.price-list-actions',
'.price-list-header',
'.price-list-content',
'.sales-price-config-modal',
'.arco-modal',
'.arco-modal-wrapper',
'.arco-trigger-popup',
'.arco-btn',
].join(','),
)
) {
return false;
}
return true;
},
allowInsertRow: false,
allowInsertColumn: false,

View File

@ -3243,10 +3243,12 @@
/** 当前价格表行对应的客户组编码顺序(用于同步时按编码保留价格) */
const priceListCustomerCodesSnapshot = ref<string[]>([]);
const priceListSelectedIds = ref<number[]>([]);
/** 上次非空选中(点工具栏时 HOT 可能先清空当前选中) */
const lastPriceListSelectedIds = ref<number[]>([]);
const batchFillTargetIds = ref<number[]>([]);
const priceListHotRef = ref<{ selectAll: () => void; clearSelection: () => void } | null>(null);
const batchFillModalVisible = ref(false);
const batchFillValue = ref<number | undefined>(undefined);
const batchFillOnlyEmpty = ref(false);
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001');
@ -3841,11 +3843,20 @@
);
function onPriceListSelectionChange(ids: Array<number | string>) {
priceListSelectedIds.value = ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
const next = ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
priceListSelectedIds.value = next;
if (next.length) lastPriceListSelectedIds.value = next;
}
function resolvePriceListSelectedIds() {
if (priceListSelectedIds.value.length) return [...priceListSelectedIds.value];
return [...lastPriceListSelectedIds.value];
}
function clearPriceListChecked() {
priceListSelectedIds.value = [];
lastPriceListSelectedIds.value = [];
batchFillTargetIds.value = [];
try {
priceListHotRef.value?.clearSelection?.();
} catch {
@ -3869,12 +3880,13 @@
}
}
const openBatchFillModal = (onlyEmpty = false) => {
if (!priceListSelectedIds.value.length) {
const openBatchFillModal = () => {
const ids = resolvePriceListSelectedIds();
if (!ids.length) {
Message.warning('请先选中要填价的行');
return;
}
batchFillOnlyEmpty.value = onlyEmpty;
batchFillTargetIds.value = ids;
batchFillValue.value = undefined;
batchFillModalVisible.value = true;
};
@ -3885,7 +3897,11 @@
Message.warning('请输入有效价格(≥0)');
return;
}
const selected = new Set(priceListSelectedIds.value);
const selected = new Set(
batchFillTargetIds.value.length
? batchFillTargetIds.value
: resolvePriceListSelectedIds(),
);
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
if (!customerKeys.length) {
Message.warning('当前没有客户组价格列');
@ -3895,41 +3911,17 @@
for (const row of priceListData.value) {
if (!selected.has(row.id)) continue;
for (const key of customerKeys) {
const cur = Number((row as any)[key]);
const isEmpty = !Number.isFinite(cur) || cur === 0;
if (batchFillOnlyEmpty.value && !isEmpty) continue;
(row as any)[key] = val;
touched += 1;
}
}
batchFillModalVisible.value = false;
batchFillTargetIds.value = [];
// 触发 Handsontable 刷新可视区(数值已写回源对象)
touchPriceListData();
Message.success(touched ? `已填充 ${touched} 个价格格` : '没有需要填充的格子');
};
/** 将上一行客户组价格复制到选中行 */
const handleCopyPrevRowPrices = () => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先选中目标行');
return;
}
const selected = new Set(priceListSelectedIds.value);
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
let copied = 0;
for (let i = 0; i < priceListData.value.length; i++) {
const row = priceListData.value[i];
if (!selected.has(row.id) || i === 0) continue;
const prev = priceListData.value[i - 1];
for (const key of customerKeys) {
(row as any)[key] = Number((prev as any)[key]) || 0;
}
copied += 1;
}
if (copied) touchPriceListData();
Message.success(copied ? `已将上一行价格复制到 ${copied} 行` : '没有可复制的行');
};
// 取消
const handleCancelPriceDrawer = () => {
priceModalVisible.value = false;
@ -5519,25 +5511,11 @@
<span class="price-list-actions__sep" aria-hidden="true"></span>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(false)"
:disabled="!priceListSelectedIds.length && !lastPriceListSelectedIds.length"
@mousedown.prevent="openBatchFillModal"
>
{{ t('channel.salesProducts.drawer.priceList.batchFill') }}
</a-button>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(true)"
>
{{ t('channel.salesProducts.drawer.priceList.batchFillEmpty') }}
</a-button>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="handleCopyPrevRowPrices"
>
{{ t('channel.salesProducts.drawer.priceList.copyPrev') }}
</a-button>
<span class="price-list-actions__sep" aria-hidden="true"></span>
<a-button size="small" @click="handleExportPriceList">导出</a-button>
<a-button size="small" @click="handleImportPriceList">导入</a-button>
@ -5593,19 +5571,19 @@
</template>
</a-modal>
<!-- 批量填价 -->
<!-- 批量填价:须高于价格配置大弹窗,否则会被盖住看起来像没弹窗 -->
<a-modal
v-model:visible="batchFillModalVisible"
:title="batchFillOnlyEmpty
? t('channel.salesProducts.drawer.priceList.batchFillEmpty')
: t('channel.salesProducts.drawer.priceList.batchFill')"
:title="t('channel.salesProducts.drawer.priceList.batchFill')"
width="420px"
:z-index="3200"
unmount-on-close
@ok="handleConfirmBatchFill"
@cancel="batchFillModalVisible = false"
>
<div class="batch-fill-form">
<div class="batch-fill-meta">
{{ t('channel.salesProducts.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
{{ t('channel.salesProducts.drawer.priceList.batchFillSelected', { count: batchFillTargetIds.length || priceListSelectedIds.length }) }}
</div>
<a-form-item :label="t('channel.salesProducts.drawer.priceList.batchFillValue')" required>
<a-input-number

View File

@ -787,25 +787,11 @@
<span class="price-list-actions__sep" aria-hidden="true"></span>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(false)"
:disabled="!priceListSelectedIds.length && !lastPriceListSelectedIds.length"
@mousedown.prevent="openBatchFillModal"
>
{{ t('channel.serviceChannels.drawer.priceList.batchFill') }}
</a-button>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(true)"
>
{{ t('channel.serviceChannels.drawer.priceList.batchFillEmpty') }}
</a-button>
<a-button
size="small"
:disabled="!priceListSelectedIds.length"
@click="handleCopyPrevRowPrices"
>
{{ t('channel.serviceChannels.drawer.priceList.copyPrev') }}
</a-button>
<span class="price-list-actions__sep" aria-hidden="true"></span>
<a-button size="small" @click="handleExportPriceList">导出</a-button>
<a-button size="small" @click="handleImportPriceList">导入</a-button>
@ -863,19 +849,19 @@
</template>
</a-modal>
<!-- 批量填价 -->
<!-- 批量填价:须高于价格配置大弹窗,否则会被盖住看起来像没弹窗 -->
<a-modal
v-model:visible="batchFillModalVisible"
:title="batchFillOnlyEmpty
? t('channel.serviceChannels.drawer.priceList.batchFillEmpty')
: t('channel.serviceChannels.drawer.priceList.batchFill')"
:title="t('channel.serviceChannels.drawer.priceList.batchFill')"
width="420px"
:z-index="3200"
unmount-on-close
@ok="handleConfirmBatchFill"
@cancel="batchFillModalVisible = false"
>
<div class="batch-fill-form">
<div class="batch-fill-meta">
{{ t('channel.serviceChannels.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
{{ t('channel.serviceChannels.drawer.priceList.batchFillSelected', { count: batchFillTargetIds.length || priceListSelectedIds.length }) }}
</div>
<a-form-item :label="t('channel.serviceChannels.drawer.priceList.batchFillValue')" required>
<a-input-number
@ -2753,10 +2739,12 @@ function buildServiceChannelDetailsPayload(): any[] {
// 价格表数据:shallowRef + 普通对象,避免上千行 deep 响应式拖垮 Handsontable
const priceListData = shallowRef<any[]>([])
const priceListSelectedIds = ref<number[]>([])
/** 上次非空选中(点工具栏时 HOT 可能先清空当前选中) */
const lastPriceListSelectedIds = ref<number[]>([])
const batchFillTargetIds = ref<number[]>([])
const priceListHotRef = ref<{ selectAll: () => void; clearSelection: () => void } | null>(null)
const batchFillModalVisible = ref(false)
const batchFillValue = ref<number | undefined>(undefined)
const batchFillOnlyEmpty = ref(false)
function replacePriceListData(rows: any[]) {
priceListData.value = (rows || []).map((row) => ({
@ -2772,11 +2760,20 @@ function touchPriceListData() {
function clearPriceListChecked() {
priceListSelectedIds.value = []
lastPriceListSelectedIds.value = []
batchFillTargetIds.value = []
priceListHotRef.value?.clearSelection()
}
function onPriceListSelectionChange(ids: Array<number | string>) {
priceListSelectedIds.value = ids.map((id) => Number(id)).filter((id) => Number.isFinite(id))
const next = ids.map((id) => Number(id)).filter((id) => Number.isFinite(id))
priceListSelectedIds.value = next
if (next.length) lastPriceListSelectedIds.value = next
}
function resolvePriceListSelectedIds() {
if (priceListSelectedIds.value.length) return [...priceListSelectedIds.value]
return [...lastPriceListSelectedIds.value]
}
function selectAllPriceListRows() {
@ -3395,12 +3392,13 @@ const handleGeneratePriceList = async () => {
applySyncedPriceList(preview)
}
const openBatchFillModal = (onlyEmpty = false) => {
if (!priceListSelectedIds.value.length) {
const openBatchFillModal = () => {
const ids = resolvePriceListSelectedIds()
if (!ids.length) {
Message.warning('请先选中要填价的行')
return
}
batchFillOnlyEmpty.value = onlyEmpty
batchFillTargetIds.value = ids
batchFillValue.value = undefined
batchFillModalVisible.value = true
}
@ -3411,40 +3409,23 @@ const handleConfirmBatchFill = () => {
Message.warning('请输入有效价格(≥0)')
return
}
const selected = new Set(priceListSelectedIds.value)
const selected = new Set(
batchFillTargetIds.value.length
? batchFillTargetIds.value
: resolvePriceListSelectedIds(),
)
let touched = 0
for (const row of priceListData.value) {
if (!selected.has(row.id)) continue
const cur = Number(row.calculatedValue)
const isEmpty = !Number.isFinite(cur) || cur === 0
if (batchFillOnlyEmpty.value && !isEmpty) continue
row.calculatedValue = val
touched += 1
}
batchFillModalVisible.value = false
batchFillTargetIds.value = []
touchPriceListData()
Message.success(touched ? `已填充 ${touched} 行单价` : '没有需要填充的行')
}
/** 将上一行单价复制到勾选行 */
const handleCopyPrevRowPrices = () => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先选中目标行')
return
}
const selected = new Set(priceListSelectedIds.value)
let copied = 0
for (let i = 0; i < priceListData.value.length; i++) {
const row = priceListData.value[i]
if (!selected.has(row.id) || i === 0) continue
const prev = priceListData.value[i - 1]
row.calculatedValue = Number(prev.calculatedValue) || 0
copied += 1
}
touchPriceListData()
Message.success(copied ? `已将上一行单价复制到 ${copied} 行` : '没有可复制的行')
}
const savingPriceDrawer = ref(false)
function nextServiceChannelPriceSerialNumber(): number {

View File

@ -31,13 +31,9 @@
type="primary"
class="ops-btn ops-btn--primary"
:loading="submitting || lookingUp"
@click="formData.orderId ? handleSubmit() : handleWaybillLookup()"
@click="handleSubmit()"
>
{{
formData.orderId
? $t('operation.inbound.actions.inbound')
: $t('operation.inbound.actions.lookup')
}}
{{ $t('operation.inbound.actions.inbound') }}
</a-button>
</div>
</div>
@ -149,7 +145,7 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
@ -341,6 +337,8 @@ function handlePageSizeChange(size: number) {
currentPage.value = 1
}
const lookedUpWaybillNo = ref('')
function clearLookupFields() {
formData.salesProductName = ''
formData.destinationCountryName = ''
@ -349,6 +347,7 @@ function clearLookupFields() {
formData.length = ''
formData.width = ''
formData.height = ''
lookedUpWaybillNo.value = ''
}
function clearLookup() {
@ -356,11 +355,19 @@ function clearLookup() {
clearFeedback()
}
async function handleWaybillLookup() {
function hasMetrics() {
return !!(formData.weight && formData.length && formData.width && formData.height)
}
/** 扫码后静默查单;不弹「匹配成功」,失败才提示 */
async function ensureOrderLookup(): Promise<boolean> {
const no = formData.waybillNumber?.trim()
if (!no) {
clearLookup()
return
return false
}
if (formData.orderId && lookedUpWaybillNo.value === no) {
return true
}
lookingUp.value = true
try {
@ -372,24 +379,29 @@ async function handleWaybillLookup() {
formData.salesProductName = info.salesProductName || info.productCode || ''
formData.destinationCountryName =
info.destinationCountryName || info.destinationCountry || ''
showSuccess(t('operation.common.message.lookupSuccess'))
lookedUpWaybillNo.value = no
return true
} catch (e: any) {
formData.salesProductName = ''
formData.destinationCountryName = ''
formData.orderId = undefined
lookedUpWaybillNo.value = ''
showError(e?.message || t('operation.common.message.orderNotFound'))
maybeRefocusScan()
return false
} finally {
lookingUp.value = false
}
}
async function onScanEnter() {
if (formData.orderId && formData.waybillNumber?.trim()) {
const ok = await ensureOrderLookup()
if (!ok) return
if (hasMetrics()) {
await handleSubmit()
return
}
await handleWaybillLookup()
clearFeedback()
}
function buildSuccessMessage(result: any) {
@ -410,11 +422,9 @@ async function handleSubmit() {
maybeRefocusScan()
return
}
if (!formData.orderId) {
await handleWaybillLookup()
if (!formData.orderId) return
}
if (!formData.weight || !formData.length || !formData.width || !formData.height) {
const ok = await ensureOrderLookup()
if (!ok) return
if (!hasMetrics()) {
showWarning(t('operation.inbound.message.metricsRequired'))
return
}
@ -443,6 +453,19 @@ async function handleSubmit() {
}
}
watch(
() => formData.waybillNumber,
(value) => {
const no = String(value || '').trim()
if (!no || no !== lookedUpWaybillNo.value) {
formData.orderId = undefined
formData.salesProductName = ''
formData.destinationCountryName = ''
lookedUpWaybillNo.value = ''
}
},
)
function handleReset(clearStatus = true) {
formData.waybillNumber = ''
clearLookupFields()

View File

@ -31,13 +31,9 @@
type="primary"
class="ops-btn ops-btn--primary"
:loading="submitting || lookingUp"
@click="formData.orderId ? handleSubmit() : handleWaybillLookup()"
@click="handleSubmit()"
>
{{
formData.orderId
? $t('operation.outbound.actions.outbound')
: $t('operation.outbound.actions.lookup')
}}
{{ $t('operation.outbound.actions.outbound') }}
</a-button>
</div>
</div>
@ -150,7 +146,7 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
@ -272,6 +268,8 @@ function handlePageSizeChange(size: number) {
currentPage.value = 1
}
const lookedUpWaybillNo = ref('')
function clearLookupFields() {
formData.salesProductName = ''
formData.destinationCountryName = ''
@ -281,6 +279,7 @@ function clearLookupFields() {
formData.length = ''
formData.width = ''
formData.height = ''
lookedUpWaybillNo.value = ''
}
function clearLookup() {
@ -288,11 +287,19 @@ function clearLookup() {
clearFeedback()
}
async function handleWaybillLookup() {
function hasMetrics() {
return !!(formData.weight && formData.length && formData.width && formData.height)
}
/** 扫码后静默查单;不弹「匹配成功」,失败才提示 */
async function ensureOrderLookup(): Promise<boolean> {
const no = formData.waybillNumber?.trim()
if (!no) {
clearLookup()
return
return false
}
if (formData.orderId && lookedUpWaybillNo.value === no) {
return true
}
lookingUp.value = true
try {
@ -305,25 +312,30 @@ async function handleWaybillLookup() {
formData.destinationCountryName =
info.destinationCountryName || info.destinationCountry || ''
formData.serviceChannelName = info.serviceChannelName || info.channelCode || ''
showSuccess(t('operation.common.message.lookupSuccess'))
lookedUpWaybillNo.value = no
return true
} catch (e: any) {
formData.salesProductName = ''
formData.destinationCountryName = ''
formData.serviceChannelName = ''
formData.orderId = undefined
lookedUpWaybillNo.value = ''
showError(e?.message || t('operation.common.message.orderNotFound'))
maybeRefocusScan()
return false
} finally {
lookingUp.value = false
}
}
async function onScanEnter() {
if (formData.orderId && formData.waybillNumber?.trim()) {
const ok = await ensureOrderLookup()
if (!ok) return
if (hasMetrics()) {
await handleSubmit()
return
}
await handleWaybillLookup()
clearFeedback()
}
async function handleSubmit() {
@ -332,9 +344,11 @@ async function handleSubmit() {
maybeRefocusScan()
return
}
if (!formData.orderId) {
await handleWaybillLookup()
if (!formData.orderId) return
const ok = await ensureOrderLookup()
if (!ok) return
if (!hasMetrics()) {
showWarning(t('operation.outbound.message.metricsRequired'))
return
}
submitting.value = true
try {
@ -357,6 +371,20 @@ async function handleSubmit() {
}
}
watch(
() => formData.waybillNumber,
(value) => {
const no = String(value || '').trim()
if (!no || no !== lookedUpWaybillNo.value) {
formData.orderId = undefined
formData.salesProductName = ''
formData.destinationCountryName = ''
formData.serviceChannelName = ''
lookedUpWaybillNo.value = ''
}
},
)
function handleReset(clearStatus = true) {
formData.waybillNumber = ''
clearLookupFields()

View File

@ -1,159 +1,419 @@
<template>
<div class="order-declaration-hot-table">
<HandsontablePro
:key="tableKey"
:columns="hotColumns"
:data="data"
:total="data.length"
:current-page="1"
:page-size="Math.max(data.length, 1)"
:height="tableHeight"
:read-only="!editing"
:show-action-column="editing"
:action-column-header="actionHeader"
:action-renderer="actionRenderer"
:serial-no-header="serialHeader"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:enable-header-search="false"
:auto-hybrid-header="false"
:enable-header-sort="false"
:enable-header-filter="false"
:show-active-filter-bar="false"
:enable-column-filter="false"
@action-click="handleActionClick"
@cell-click="handleCellClick"
<div
ref="wrapRef"
class="order-declaration-hot-wrap"
:style="{ height: `${tableHeight}px` }"
>
<div
ref="hostRef"
class="order-declaration-hot"
:class="{ 'is-ready': ready }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import type { ColumnDef } from '@/components/HandsontablePro/types'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { ORDER_DETAIL_DECLARATION_COLUMNS } from '@/views/order/components/orderDetailFieldConfig'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Handsontable from 'handsontable';
import { registerAllModules } from 'handsontable/registry';
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-classic.min.css';
import { ORDER_DETAIL_DECLARATION_COLUMNS } from '@/views/order/components/orderDetailFieldConfig';
const NUMBER_KEYS = new Set(['unitPrice', 'unitWeight', 'quantity', 'amount', 'weight'])
registerAllModules();
registerLanguageDictionary(zhCN);
const NUMBER_KEYS = new Set(['unitPrice', 'unitWeight', 'quantity', 'amount', 'weight']);
const props = withDefaults(
defineProps<{
data: any[]
editing?: boolean
data: any[];
editing?: boolean;
}>(),
{
editing: false,
},
)
);
const emit = defineEmits<{
'remove-row': [rowIndex: number]
'preview-sku-image': [url: string]
}>()
'remove-row': [rowIndex: number];
'preview-sku-image': [url: string];
}>();
const { t } = useI18n()
const { t } = useI18n();
const tableKey = computed(() => `decl-${props.editing ? 'edit' : 'view'}-${props.data.length}`)
const serialHeader = computed(() => t('order.myOrders.table.serialNo'))
const actionHeader = computed(() => t('order.myOrders.drawer.declaration.action'))
const hotColumns = computed<ColumnDef[]>(() =>
ORDER_DETAIL_DECLARATION_COLUMNS.map((col) => {
const base: ColumnDef = {
key: col.key,
label: t(col.labelKey),
width: col.key === 'skuImage' ? (props.editing ? 160 : 72) : col.width,
searchable: false,
filterable: false,
sortable: false,
}
if (col.key === 'isMagnetic') {
base.type = 'checkbox'
} else if (NUMBER_KEYS.has(col.key)) {
base.type = 'numeric'
}
if (col.key === 'skuImage') {
base.className = 'htCenter declaration-sku-cell'
// 查看态只读,避免把长 URL 当文本画进单元格再被 ellipsis 裁成 ...
if (!props.editing) base.readOnly = true
}
return base
}),
)
const wrapRef = ref<HTMLElement | null>(null);
const hostRef = ref<HTMLElement | null>(null);
const ready = ref(false);
let hot: Handsontable | null = null;
let ignoreChange = false;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
let resizeRaf = 0;
let mountTimer: ReturnType<typeof setTimeout> | null = null;
const tableHeight = computed(() => {
const rows = Math.max(props.data.length, 1)
return Math.min(280, Math.max(120, 40 + rows * 32 + 16))
})
const rows = Math.max(props.data?.length || 0, 1);
return Math.min(280, Math.max(120, 40 + rows * 32 + 16));
});
function hasSkuImage(raw: unknown) {
const value = String(raw ?? '').trim()
return !!value && value !== '0' && value !== '--'
const value = String(raw ?? '').trim();
return !!value && value !== '0' && value !== '--';
}
function escapeAttr(text: string) {
return String(text).replace(/"/g, '&quot;')
return String(text).replace(/"/g, '&quot;');
}
function eyeSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;display:block;pointer-events:none"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;display:block;pointer-events:none"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
}
/**
* 查看态图片列:只渲染眼睛,不写入长 URL 文本。
* HandsontablePro 只读态全局带 text-overflow:ellipsis,长链会被裁成「...」叠在眼睛旁。
*/
function customCellRenderer(
_row: number,
col: number,
_value: any,
rowData: any,
): string | null {
if (props.editing) return null
// 布局:操作(可选) → 序号 → 数据列
const dataColStart = props.editing ? 2 : 1
const colDef = hotColumns.value[col - dataColStart]
if (colDef?.key !== 'skuImage') return null
if (!hasSkuImage(rowData?.skuImage)) return '--'
const title = t('order.myOrders.drawer.declarationSkuImageView')
return `<span class="declaration-sku-eye" title="${escapeAttr(title)}" role="button">${eyeSvg()}</span>`
function decorateRows(rows: any[]): any[] {
return (rows || []).map((row, idx) => {
if (row && typeof row === 'object') {
row._serial = idx + 1;
}
return row;
});
}
function actionRenderer(_row: any, rowIndex: number) {
return wrapWarehouseActions(
[renderWarehouseActionBtn('delete', t('order.myOrders.drawer.declaration.delete'))],
rowIndex,
)
function readWrapWidth() {
const el = wrapRef.value;
if (!el) return 0;
const w = Math.floor(el.clientWidth || el.getBoundingClientRect().width);
return w > 0 ? w : 0;
}
function handleActionClick(_row: any, rowIndex: number, action?: string) {
if (action === 'delete' || !action) {
emit('remove-row', rowIndex)
function buildColHeaders(): string[] {
const headers = [t('order.myOrders.table.serialNo')];
if (props.editing) {
headers.push(t('order.myOrders.drawer.declaration.action'));
}
headers.push(...ORDER_DETAIL_DECLARATION_COLUMNS.map((col) => t(col.labelKey)));
return headers;
}
function buildColumns(): any[] {
const cols: any[] = [
{
data: '_serial',
readOnly: true,
width: 50,
className: 'htCenter',
},
];
if (props.editing) {
cols.push({
data: '_action',
readOnly: true,
width: 64,
className: 'htCenter declaration-action-cell',
renderer(
_instance: Handsontable,
td: HTMLTableCellElement,
row: number,
) {
td.className = 'htCenter declaration-action-cell';
td.innerHTML = `<button type="button" class="declaration-del-btn" data-row="${row}">${t(
'order.myOrders.drawer.declaration.delete',
)}</button>`;
return td;
},
});
}
for (const col of ORDER_DETAIL_DECLARATION_COLUMNS) {
const conf: any = {
data: col.key,
readOnly: !props.editing,
width: col.key === 'skuImage' ? (props.editing ? 120 : 72) : col.width || 100,
className: 'htLeft',
};
if (col.key === 'isMagnetic') {
conf.type = 'checkbox';
conf.className = 'htCenter';
} else if (NUMBER_KEYS.has(col.key)) {
conf.type = 'numeric';
conf.numericFormat = { pattern: '0.[000]' };
conf.className = 'htRight';
} else if (col.key === 'skuImage') {
conf.className = 'htCenter declaration-sku-cell';
conf.renderer = (
_instance: Handsontable,
td: HTMLTableCellElement,
_row: number,
_col: number,
_prop: any,
value: any,
) => {
td.className = 'htCenter declaration-sku-cell';
if (!props.editing) {
if (!hasSkuImage(value)) {
td.textContent = '--';
return td;
}
const title = t('order.myOrders.drawer.declarationSkuImageView');
td.innerHTML = `<span class="declaration-sku-eye" title="${escapeAttr(
title,
)}" role="button">${eyeSvg()}</span>`;
return td;
}
td.textContent = value == null || value === '' ? '' : String(value);
return td;
};
} else {
conf.type = 'text';
}
cols.push(conf);
}
return cols;
}
function beforeStretchingColumnWidth(stretchedWidth: number, column: number) {
// 序号、操作列不拉伸
if (column === 0) return 50;
if (props.editing && column === 1) return 64;
return stretchedWidth;
}
function destroyHot() {
if (mountTimer) {
clearTimeout(mountTimer);
mountTimer = null;
}
if (hot) {
hot.destroy();
hot = null;
}
ready.value = false;
}
function mountHot() {
if (!hostRef.value || !wrapRef.value) return;
destroyHot();
let width = readWrapWidth();
if (width < 200) {
const parent = wrapRef.value.parentElement;
width = Math.max(Math.floor(parent?.clientWidth || 0), 600);
}
lastWidth = width;
hot = new Handsontable(hostRef.value, {
data: decorateRows(props.data),
columns: buildColumns(),
colHeaders: buildColHeaders(),
rowHeaders: false,
width,
height: tableHeight.value,
licenseKey: 'non-commercial-and-evaluation',
themeName: 'ht-theme-classic',
language: 'zh-CN',
stretchH: 'all',
beforeStretchingColumnWidth,
autoRowSize: false,
autoColumnSize: false,
rowHeights: 32,
columnHeaderHeight: 36,
renderAllRows: false,
viewportRowRenderingOffset: 20,
viewportColumnRenderingOffset: 6,
enterBeginsEditing: true,
fragmentSelection: false,
selectionMode: 'range',
readOnly: !props.editing,
allowInsertRow: false,
allowInsertColumn: false,
allowRemoveRow: false,
allowRemoveColumn: false,
contextMenu: false,
manualColumnResize: true,
afterGetColHeader(_col, th) {
th.style.verticalAlign = 'middle';
th.style.textAlign = 'center';
},
afterChange(changes, source) {
if (!changes || source === 'loadData' || ignoreChange) return;
for (const [rowIndex, prop, _oldVal, newVal] of changes) {
const row = hot?.getSourceDataAtRow(rowIndex as number) as any;
if (!row || typeof prop !== 'string') continue;
if (prop === '_serial' || prop === '_action') continue;
row[prop] = newVal;
}
},
afterOnCellMouseDown(_event, coords, td) {
if (coords.row < 0) return;
const target = _event.target as HTMLElement | null;
if (props.editing && target?.closest?.('.declaration-del-btn')) {
const row = Number(target.getAttribute('data-row'));
if (Number.isFinite(row)) emit('remove-row', row);
return;
}
if (!props.editing && td?.querySelector?.('.declaration-sku-eye')) {
const row = hot?.getSourceDataAtRow(coords.row) as any;
const url = String(row?.skuImage ?? '').trim();
if (hasSkuImage(url)) emit('preview-sku-image', url);
}
},
});
hot.render();
ready.value = true;
requestAnimationFrame(() => syncWidth(true));
}
function reloadData() {
if (!hot) {
mountHot();
return;
}
ignoreChange = true;
try {
hot.loadData(decorateRows(props.data));
} finally {
requestAnimationFrame(() => {
ignoreChange = false;
});
}
}
function handleCellClick(payload: {
row: any
rowIndex: number
columnKey: string
event: MouseEvent
}) {
if (props.editing) return
if (payload.columnKey !== 'skuImage') return
const url = String(payload.row?.skuImage ?? '').trim()
if (!hasSkuImage(url)) return
emit('preview-sku-image', url)
function syncWidth(force = false) {
if (!hot) return;
const width = readWrapWidth();
if (width < 200) return;
if (!force && Math.abs(width - lastWidth) < 2) return;
lastWidth = width;
hot.updateSettings(
{
width,
height: tableHeight.value,
stretchH: 'all',
},
false,
);
hot.refreshDimensions();
}
function setupResizeObserver() {
if (!wrapRef.value) return;
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
resizeRaf = 0;
syncWidth();
});
});
resizeObserver.observe(wrapRef.value);
}
onMounted(() => {
void nextTick(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
await new Promise<void>((resolve) => {
mountTimer = setTimeout(() => {
mountTimer = null;
resolve();
}, 40);
});
if (!wrapRef.value || !hostRef.value) return;
mountHot();
setupResizeObserver();
});
});
onBeforeUnmount(() => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
if (mountTimer) {
clearTimeout(mountTimer);
mountTimer = null;
}
resizeObserver?.disconnect();
resizeObserver = null;
destroyHot();
});
watch(
() => tableHeight.value,
(h) => {
if (!hot || !h) return;
hot.updateSettings({ height: h }, false);
hot.refreshDimensions();
},
);
watch(
() => props.editing,
() => {
void nextTick(() => mountHot());
},
);
watch(
() => [props.data, props.data?.length] as const,
() => {
reloadData();
},
);
</script>
<style lang="less" scoped>
.order-declaration-hot-table {
<style scoped lang="less">
.order-declaration-hot-wrap {
box-sizing: border-box;
width: 100%;
min-height: 120px;
min-width: 0;
max-height: 280px;
}
.order-declaration-hot {
width: 100%;
height: 100%;
overflow: hidden;
border: none;
border-radius: 6px;
opacity: 0;
transition: opacity 0.12s ease;
&.is-ready {
opacity: 1;
}
:deep(.handsontable) {
font-size: 13px;
color: var(--color-text-1);
}
:deep(.htCore th) {
background: var(--color-fill-1) !important;
color: var(--color-text-2) !important;
font-weight: 600;
border-color: var(--color-border-2) !important;
vertical-align: middle !important;
}
:deep(.htCore td) {
border-color: var(--color-border-2) !important;
}
:deep(.htCore tr:hover td) {
background: color-mix(in srgb, var(--app-primary-color) 5%, #fff) !important;
}
:deep(.ht-wrapper),
:deep(.handsontable) {
width: 100% !important;
max-width: 100%;
}
:deep(td.declaration-sku-cell) {
overflow: visible !important;
@ -179,8 +439,20 @@ function handleCellClick(payload: {
}
}
:deep(.ht-action-cell) {
overflow: visible;
:deep(.declaration-del-btn) {
appearance: none;
border: none;
background: transparent;
color: var(--color-danger-6, #f53f3f);
font-size: 12px;
line-height: 20px;
padding: 0 4px;
cursor: pointer;
border-radius: 4px;
&:hover {
background: color-mix(in srgb, var(--color-danger-6, #f53f3f) 10%, #fff);
}
}
}
</style>

View File

@ -5,7 +5,10 @@
@register="register"
@cancel="handleCancel"
>
<div class="order-detail-drawer order-detail-drawer-shell">
<div
class="order-detail-drawer order-detail-drawer-shell"
:class="{ 'has-action-bar': showActionBar }"
>
<div class="drawer-tabs">
<div class="tabs-slot">
<SlidingTabs
@ -16,69 +19,6 @@
</div>
</div>
<div
v-if="activeTab === 'basic' || activeTab === 'track' || activeTab === 'stockBind' || activeTab === 'operationLog'"
class="drawer-tab-toolbar"
>
<div class="toolbar-left">
<template v-if="activeTab === 'basic'">
<template v-if="basicEditing">
<a-button class="app-btn-search" @click="cancelBasicEdit">{{ $t('order.myOrders.drawer.actions.cancel') }}</a-button>
<a-button class="app-btn-search" :loading="basicSaving" @click="handleBasicEditSave">
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<a-button v-else class="app-btn-search" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
<a-button class="app-btn-search" @click="handleAskAdvisor">
{{ $t('nanaAssistant.askAboutWaybill') }}
</a-button>
</template>
<template v-else-if="activeTab === 'track'">
<span class="toolbar-meta">
<span class="toolbar-meta-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="toolbar-meta-value">{{ trackOrderNumber || '--' }}</span>
</span>
<a-tag
v-if="currentOrderStatusMeta"
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ $t(currentOrderStatusMeta.labelKey) }}
</a-tag>
<a-tag v-if="trackTimeline.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.track.count', { count: trackTimeline.length }) }}
</a-tag>
<a-button
class="app-btn-search"
:loading="trackLoading"
@click="loadTrackTimeline"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'stockBind'">
<a-button
class="app-btn-search"
:loading="stockBindLoading"
@click="reloadStockBind"
>{{ $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>
<div class="drawer-tab-content">
<!-- 基本信息 -->
<div v-if="activeTab === 'basic'" class="tab-panel basic-info">
@ -97,7 +37,7 @@
<summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary>
<p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p>
</details>
<div class="predeclare-fail-guide__actions">
<div v-if="canEditOrderDetail" class="predeclare-fail-guide__actions">
<a-button class="app-btn-search" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.failGuideEdit') }}
</a-button>
@ -134,6 +74,7 @@
<div
v-if="section.key === 'declaration'"
class="declaration-header-sum"
:class="{ 'is-editable': basicEditing }"
@click.stop
>
<template v-if="basicEditing">
@ -196,6 +137,7 @@
v-for="fieldKey in group.fields"
:key="fieldKey"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isOrderFieldEditable(fieldKey) }"
>
<span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span>
<div class="detail-field-value">
@ -281,6 +223,7 @@
v-for="field in senderFields"
:key="field.key"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isSenderFieldEditable(field.key) }"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
@ -312,6 +255,7 @@
v-for="field in recipientFields"
:key="field.key"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isRecipientFieldEditable(field.key) }"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
@ -635,6 +579,85 @@
</a-spin>
</div>
</div>
<div v-if="showActionBar" class="drawer-action-bar">
<div class="drawer-action-bar__meta">
<template v-if="activeTab === 'track' && !showEditFooter">
<span class="action-bar-meta">
<span class="action-bar-meta-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="action-bar-meta-value">{{ trackOrderNumber || '--' }}</span>
</span>
<a-tag
v-if="currentOrderStatusMeta"
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ $t(currentOrderStatusMeta.labelKey) }}
</a-tag>
<a-tag v-if="trackTimeline.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.track.count', { count: trackTimeline.length }) }}
</a-tag>
</template>
<a-tag
v-else-if="activeTab === 'operationLog' && !showEditFooter && operationLogList.length"
size="small"
color="arcoblue"
>
{{ $t('order.myOrders.drawer.operationLog.count', { count: operationLogList.length }) }}
</a-tag>
</div>
<div class="drawer-action-bar__actions">
<template v-if="showEditFooter">
<a-button @click="cancelBasicEdit">
{{ $t('order.myOrders.drawer.actions.cancel') }}
</a-button>
<a-button
type="primary"
class="app-btn-primary-soft"
:loading="basicSaving"
@click="handleBasicEditSave"
>
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<template v-else-if="activeTab === 'basic'">
<a-button
v-if="canEditOrderDetail"
class="app-btn-search"
@click="startBasicEdit"
>
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
<a-button class="app-btn-search" @click="handleAskAdvisor">
{{ $t('nanaAssistant.askAboutWaybill') }}
</a-button>
</template>
<template v-else-if="activeTab === 'track'">
<a-button
class="app-btn-search"
:loading="trackLoading"
@click="loadTrackTimeline"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'stockBind'">
<a-button
class="app-btn-search"
:loading="stockBindLoading"
@click="reloadStockBind"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'operationLog'">
<a-button
class="app-btn-search"
:loading="operationLogLoading"
@click="loadOperationLogs"
>{{ $t('order.myOrders.drawer.operationLog.refresh') }}
</a-button>
</template>
</div>
</div>
</div>
</DrawerPro>
@ -664,7 +687,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue';
import { ref, computed, watch, reactive, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
@ -823,9 +846,10 @@ interface FormFieldDef {
options?: Array<{ value: string; labelKey: string }>;
}
/** 我的订单可编辑字段:跟踪号不开放;入库后整单不可编辑 */
const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{
key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -841,6 +865,9 @@ const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'codSum', labelKey: 'order.myOrders.drawer.codSum', inputType: 'number' },
];
/** 入库前(草稿/已预报)才允许客户编辑 */
const MY_ORDERS_EDITABLE_STATUSES = new Set(['draft', 'predeclared']);
const orderEditMetaMap = Object.fromEntries(
orderEditFieldDefs.map((def) => [def.key, def])
) as Record<string, FormFieldDef>;
@ -875,7 +902,7 @@ function getRecipientFormKey(key: string) {
const basicEditForm = reactive({
clientNo: '',
trackNo: '',
downstreamServiceNo: '',
cargoAttribute: 'general',
isCod: false,
codSum: undefined as number | undefined,
@ -1045,6 +1072,19 @@ function resetEditState() {
const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const activeTab = ref('basic');
const canEditOrderDetail = computed(() => {
const code = resolveOrderStatusCode(detail.value);
return !!code && MY_ORDERS_EDITABLE_STATUSES.has(code);
});
const showEditFooter = computed(
() => activeTab.value === 'basic' && basicEditing.value,
);
const showActionBar = computed(() =>
['basic', 'track', 'stockBind', 'operationLog'].includes(activeTab.value),
);
const CUSTOMER_WEIGHT_TYPE_KEYS = ['forecast', 'receive'] as const;
const tabList = computed(() => [
@ -1863,7 +1903,7 @@ function fillBasicEditForm() {
const o = detail.value;
if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.trackNo = String(o.trackNo ?? o.trackingNo ?? '');
basicEditForm.downstreamServiceNo = String(o.downstreamServiceNo ?? o.DownstreamServiceNo ?? '');
basicEditForm.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -1915,10 +1955,26 @@ function fillBasicEditForm() {
declarationEditList.value = cloneDeclarationRows(declarationList.value);
}
async function focusFirstEditableField(rootSelector: string) {
await nextTick();
await nextTick();
const root = document.querySelector(rootSelector);
if (!root) return;
const el = root.querySelector(
'.is-editable input:not([disabled]), .is-editable textarea:not([disabled]), .is-editable .arco-select-view',
) as HTMLElement | null;
el?.focus?.();
}
function startBasicEdit() {
if (!canEditOrderDetail.value) {
Message.warning(t('order.myOrders.drawer.editAfterInboundForbidden'));
return;
}
fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency);
basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
}
function cancelBasicEdit() {
@ -1951,7 +2007,7 @@ async function handleBasicEditSave() {
const result = await postOrderUpdate({
id,
clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo,
downstreamServiceNo: basicEditForm.downstreamServiceNo,
cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum,
@ -2192,43 +2248,81 @@ defineExpose({
min-width: 0;
.tabs-slot {
flex: 1;
min-width: 0;
overflow: hidden;
}
}
.drawer-tab-toolbar {
.drawer-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
background: #fff;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.drawer-action-bar__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
flex: 1;
}
.drawer-action-bar__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.action-bar-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.action-bar-meta-label {
font-size: 13px;
color: var(--color-text-3);
}
.action-bar-meta-value {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
/* 编辑态:仅高亮输入控件本身 */
.detail-field-item.is-editable,
.declaration-header-sum.is-editable {
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-input-number),
:deep(.arco-textarea-wrapper) {
background: #fff;
border-color: var(--app-primary-color, #1d7281);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 25%, transparent);
}
.toolbar-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
margin-right: 4px;
}
.toolbar-meta-label {
font-size: 13px;
color: var(--color-text-3);
}
.toolbar-meta-value {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
:deep(.arco-input-wrapper:hover),
:deep(.arco-select-view-single:hover),
:deep(.arco-input-number:hover),
:deep(.arco-textarea-wrapper:hover),
:deep(.arco-input-wrapper.arco-input-focus),
:deep(.arco-select-view-single.arco-select-view-focus),
:deep(.arco-input-number.arco-input-number-focus),
:deep(.arco-textarea-wrapper.arco-input-focus) {
border-color: var(--app-primary-color, #1d7281);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-primary-color, #1d7281) 18%, transparent);
}
}

View File

@ -3506,9 +3506,18 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
singleShowFieldErrors.value = false;
const pickupNo = created?.pickupNo || created?.PickupNo;
const pickupErr = created?.pickupCreateError || created?.PickupCreateError;
const pickupQueued = !!(created?.pickupQueued || created?.PickupQueued);
const forecastQueued = !!(
created?.forecastBillingQueued || created?.ForecastBillingQueued
);
let content = t('order.placeOrder.single.successContent');
if (forecastQueued) {
content = `${content}\n预报计费/排货处理中,费用与渠道稍后可在订单列表查看`;
}
if (pickupNo) {
content = `${content}\n揽收单号:${pickupNo}`;
} else if (pickupQueued) {
content = `${content}\n揽收单后台创建中,请稍后在揽收单列表查看`;
} else if (pickupErr) {
content = `${content}\n揽收未创建成功:${pickupErr}(可到我的订单补建)`;
}

View File

@ -5,7 +5,10 @@
@register="register"
@cancel="handleCancel"
>
<div class="order-detail-drawer order-detail-drawer-shell">
<div
class="order-detail-drawer order-detail-drawer-shell"
:class="{ 'has-action-bar': showActionBar }"
>
<div class="drawer-tabs">
<div class="tabs-slot">
<SlidingTabs
@ -16,112 +19,6 @@
</div>
</div>
<div
v-if="activeTab === 'basic' || activeTab === 'weight' || activeTab === 'fee' || activeTab === 'track' || activeTab === 'stockBind' || activeTab === 'operationLog'"
class="drawer-tab-toolbar"
>
<div class="toolbar-left">
<template v-if="activeTab === 'basic'">
<template v-if="basicEditing">
<a-button class="app-btn-search" @click="cancelBasicEdit">{{ $t('order.myOrders.drawer.actions.cancel') }}</a-button>
<a-button class="app-btn-search" :loading="basicSaving" @click="handleBasicEditSave">
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<a-button v-else class="app-btn-search" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
<a-button class="app-btn-search" @click="handleAskAdvisor">
{{ $t('nanaAssistant.askAboutWaybill') }}
</a-button>
</template>
<template v-else-if="activeTab === 'weight'">
<template v-if="weightEditing">
<a-button class="app-btn-search" @click="cancelWeightEdit">{{ $t('order.myOrders.drawer.actions.cancel') }}</a-button>
<a-button class="app-btn-search" :loading="weightSaving" @click="handleWeightEditSave">
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<a-button v-else class="app-btn-search" @click="startWeightEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
</template>
<template v-else-if="activeTab === 'fee'">
<a-button
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') }}
</a-button>
<a-button
class="app-btn-search"
:loading="recalcReceivableLoading"
@click="handleRecalculateReceivable"
>
{{ $t('order.myOrders.drawer.actions.recalculateReceivable') }}
</a-button>
<a-button
class="app-btn-search"
:loading="recalcPayableLoading"
@click="handleRecalculatePayable"
>
{{ $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">
<span class="toolbar-meta-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="toolbar-meta-value">{{ trackOrderNumber || '--' }}</span>
</span>
<a-tag
v-if="currentOrderStatusMeta"
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ $t(currentOrderStatusMeta.labelKey) }}
</a-tag>
<a-tag v-if="trackTimeline.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.track.count', { count: trackTimeline.length }) }}
</a-tag>
<a-button
class="app-btn-search"
:loading="trackLoading"
@click="loadTrackTimeline"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'stockBind'">
<a-button
class="app-btn-search"
:loading="stockBindLoading"
@click="reloadStockBind"
>{{ $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>
<div class="drawer-tab-content">
<!-- 基本信息 -->
<div v-if="activeTab === 'basic'" class="tab-panel basic-info">
@ -173,6 +70,7 @@
<div
v-if="section.key === 'declaration'"
class="declaration-header-sum"
:class="{ 'is-editable': basicEditing }"
@click.stop
>
<template v-if="basicEditing">
@ -230,6 +128,7 @@
v-for="fieldKey in group.fields"
:key="fieldKey"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isOrderFieldEditable(fieldKey) }"
>
<span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span>
<div class="detail-field-value">
@ -331,6 +230,7 @@
v-for="field in senderFields"
:key="field.key"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isSenderFieldEditable(field.key) }"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
@ -362,6 +262,7 @@
v-for="field in recipientFields"
:key="field.key"
class="detail-field-item"
:class="{ 'is-editable': basicEditing && isRecipientFieldEditable(field.key) }"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
@ -458,7 +359,10 @@
v-for="field in weightMetricFields"
:key="field.key"
class="weight-metric-item"
:class="{ highlight: field.highlight }"
:class="{
highlight: field.highlight && !weightEditing,
'is-editable': weightEditing && field.editable && hasWeightRow(section.key),
}"
>
<span class="metric-label">{{ $t(field.labelKey) }}</span>
<div class="metric-value-wrap">
@ -473,7 +377,7 @@
<span
v-else
class="metric-value"
:class="{ 'metric-value-highlight': field.highlight }"
:class="{ 'metric-value-highlight': field.highlight && !weightEditing }"
>
{{ formatWeightCellValue(section.row, field.key) }}
</span>
@ -524,18 +428,6 @@
<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
@ -805,52 +697,16 @@
<!-- 操作日志 -->
<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-table
v-if="operationLogList.length"
class="operation-log-table"
row-key="id"
size="small"
:pagination="false"
:bordered="{ cell: true }"
:columns="operationLogColumns"
:data="operationLogTableData"
/>
<a-empty
v-else-if="!operationLogLoading"
:description="$t('order.myOrders.drawer.operationLog.empty')"
@ -859,6 +715,120 @@
</a-spin>
</div>
</div>
<div v-if="showActionBar" class="drawer-action-bar">
<div class="drawer-action-bar__meta">
<template v-if="activeTab === 'track' && !showEditFooter">
<span class="action-bar-meta">
<span class="action-bar-meta-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="action-bar-meta-value">{{ trackOrderNumber || '--' }}</span>
</span>
<a-tag
v-if="currentOrderStatusMeta"
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ $t(currentOrderStatusMeta.labelKey) }}
</a-tag>
<a-tag v-if="trackTimeline.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.track.count', { count: trackTimeline.length }) }}
</a-tag>
</template>
<a-tag
v-else-if="activeTab === 'operationLog' && !showEditFooter && operationLogList.length"
size="small"
color="arcoblue"
>
{{ $t('order.myOrders.drawer.operationLog.count', { count: operationLogList.length }) }}
</a-tag>
</div>
<div class="drawer-action-bar__actions">
<template v-if="showEditFooter">
<a-button
@click="activeTab === 'weight' ? cancelWeightEdit() : cancelBasicEdit()"
>
{{ $t('order.myOrders.drawer.actions.cancel') }}
</a-button>
<a-button
type="primary"
class="app-btn-primary-soft"
:loading="activeTab === 'weight' ? weightSaving : basicSaving"
@click="activeTab === 'weight' ? handleWeightEditSave() : handleBasicEditSave()"
>
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<template v-else-if="activeTab === 'basic'">
<a-button class="app-btn-search" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
<a-button class="app-btn-search" @click="handleAskAdvisor">
{{ $t('nanaAssistant.askAboutWaybill') }}
</a-button>
</template>
<template v-else-if="activeTab === 'weight'">
<a-button class="app-btn-search" @click="startWeightEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
</template>
<template v-else-if="activeTab === 'fee'">
<a-button
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') }}
</a-button>
<a-button
class="app-btn-search"
:loading="recalcReceivableLoading"
@click="handleRecalculateReceivable"
>
{{ $t('order.myOrders.drawer.actions.recalculateReceivable') }}
</a-button>
<a-button
class="app-btn-search"
:loading="recalcPayableLoading"
@click="handleRecalculatePayable"
>
{{ $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'">
<a-button
class="app-btn-search"
:loading="trackLoading"
@click="loadTrackTimeline"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'stockBind'">
<a-button
class="app-btn-search"
:loading="stockBindLoading"
@click="reloadStockBind"
>{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</template>
<template v-else-if="activeTab === 'operationLog'">
<a-button
class="app-btn-search"
:loading="operationLogLoading"
@click="loadOperationLogs"
>{{ $t('order.myOrders.drawer.operationLog.refresh') }}
</a-button>
</template>
</div>
</div>
</div>
</DrawerPro>
@ -991,11 +961,12 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue';
import { ref, computed, watch, reactive, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconRefresh, IconRobot, IconEye, IconDown, IconUp } from '@arco-design/web-vue/es/icon';
import { IconRefresh, IconRobot, IconEye } from '@arco-design/web-vue/es/icon';
import type { TableColumnData } from '@arco-design/web-vue';
import useDrawer from '@/components/DrawerPro/useDrawer';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import {
@ -1137,7 +1108,7 @@ interface FormFieldDef {
const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' },
{ key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{
key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -1188,7 +1159,7 @@ function getRecipientFormKey(key: string) {
const basicEditForm = reactive({
clientNo: '',
trackNo: '',
channelCode: '',
downstreamServiceNo: '',
cargoAttribute: 'general',
isCod: false,
codSum: undefined as number | undefined,
@ -1376,6 +1347,17 @@ function resetEditState() {
const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const activeTab = ref('basic');
const showEditFooter = computed(
() =>
(activeTab.value === 'basic' && basicEditing.value) ||
(activeTab.value === 'weight' && weightEditing.value),
);
const showActionBar = computed(() =>
['basic', 'weight', 'fee', 'track', 'stockBind', 'operationLog'].includes(activeTab.value),
);
const tabList = computed(() => [
{ key: 'basic', label: t('order.myOrders.drawer.tabs.basic') },
{ key: 'weight', label: t('order.myOrders.drawer.tabs.weight') },
@ -1389,7 +1371,40 @@ const tabList = computed(() => [
const operationLogList = ref<OrderOperationLogItem[]>([]);
const operationLogLoading = ref(false);
const operationLogExpandedIds = ref<Set<number>>(new Set());
const operationLogColumns = computed<TableColumnData[]>(() => [
{
title: t('order.myOrders.drawer.operationLog.time'),
dataIndex: 'createTime',
width: 170,
},
{
title: t('order.myOrders.drawer.operationLog.action'),
dataIndex: 'actionName',
width: 140,
},
{
title: t('order.myOrders.drawer.operationLog.content'),
dataIndex: 'summary',
ellipsis: true,
tooltip: true,
},
{
title: t('order.myOrders.drawer.operationLog.operator'),
dataIndex: 'operatorName',
width: 120,
},
]);
const operationLogTableData = computed(() =>
operationLogList.value.map((item) => ({
id: item.id,
createTime: formatOperationLogTime(item.createTime),
actionName: item.actionName || item.action || '--',
summary: item.summary || item.remark || '—',
operatorName: item.operatorName || '--',
})),
);
const slidingTabItems = computed(() =>
tabList.value.map((tab) => ({ key: tab.key, label: tab.label })),
@ -2376,7 +2391,7 @@ function fillBasicEditForm() {
if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.trackNo = String(o.trackNo ?? o.trackingNo ?? '');
basicEditForm.channelCode = String(o.channelCode ?? '');
basicEditForm.downstreamServiceNo = String(o.downstreamServiceNo ?? o.DownstreamServiceNo ?? '');
basicEditForm.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -2428,10 +2443,22 @@ function fillBasicEditForm() {
declarationEditList.value = cloneDeclarationRows(declarationList.value);
}
async function focusFirstEditableField(rootSelector: string) {
await nextTick();
await nextTick();
const root = document.querySelector(rootSelector);
if (!root) return;
const el = root.querySelector(
'.is-editable input:not([disabled]), .is-editable textarea:not([disabled]), .is-editable .arco-select-view',
) as HTMLElement | null;
el?.focus?.();
}
function startBasicEdit() {
fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency);
basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
}
function cancelBasicEdit() {
@ -2449,6 +2476,7 @@ function startWeightEdit() {
actualWeight: row.actualWeight != null ? Number(row.actualWeight) : undefined,
}));
weightEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .weight-info');
}
function cancelWeightEdit() {
@ -2465,7 +2493,7 @@ async function handleBasicEditSave() {
id,
clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo,
channelCode: basicEditForm.channelCode,
downstreamServiceNo: basicEditForm.downstreamServiceNo,
cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum,
@ -2617,9 +2645,6 @@ async function loadOperationLogs() {
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 {
@ -2627,17 +2652,6 @@ async function loadOperationLogs() {
}
}
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);
@ -2740,43 +2754,82 @@ defineExpose({
min-width: 0;
.tabs-slot {
flex: 1;
min-width: 0;
overflow: hidden;
}
}
.drawer-tab-toolbar {
.drawer-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
background: #fff;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.drawer-action-bar__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
flex: 1;
}
.drawer-action-bar__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.action-bar-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.action-bar-meta-label {
font-size: 13px;
color: var(--color-text-3);
}
.action-bar-meta-value {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
/* 编辑态:仅高亮输入控件本身 */
.detail-field-item.is-editable,
.weight-metric-item.is-editable,
.declaration-header-sum.is-editable {
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-input-number),
:deep(.arco-textarea-wrapper) {
background: #fff;
border-color: var(--app-primary-color, #1d7281);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 25%, transparent);
}
.toolbar-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
margin-right: 4px;
}
.toolbar-meta-label {
font-size: 13px;
color: var(--color-text-3);
}
.toolbar-meta-value {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
:deep(.arco-input-wrapper:hover),
:deep(.arco-select-view-single:hover),
:deep(.arco-input-number:hover),
:deep(.arco-textarea-wrapper:hover),
:deep(.arco-input-wrapper.arco-input-focus),
:deep(.arco-select-view-single.arco-select-view-focus),
:deep(.arco-input-number.arco-input-number-focus),
:deep(.arco-textarea-wrapper.arco-input-focus) {
border-color: var(--app-primary-color, #1d7281);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-primary-color, #1d7281) 18%, transparent);
}
}
@ -3338,10 +3391,6 @@ defineExpose({
color: var(--color-text-3);
}
.fee-additional-services__edit {
margin-left: auto;
}
.fee-snapshot-versions {
display: flex;
flex-wrap: wrap;
@ -3849,102 +3898,35 @@ defineExpose({
}
}
.operation-log-spin {
width: 100%;
}
.operation-log-list {
.operation-log-info {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
}
.operation-log-item {
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.operation-log-spin {
width: 100%;
flex: 1;
min-height: 0;
.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);
:deep(.arco-spin-children) {
height: 100%;
}
}
.operation-log-head-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.operation-log-table {
width: 100%;
.operation-log-action {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
:deep(.arco-table-th) {
background: var(--color-fill-2);
font-weight: 500;
}
.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;
:deep(.arco-table-td) {
font-size: 13px;
vertical-align: top;
}
}
.additional-services-hint {