fix: 订单详情编辑体验、接口参数与价格表批量填价
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m47s

优化运单详情抽屉操作栏与申报表编辑观感,限制我的订单入库后不可编辑;完善接口参数全量替换/脏检查与全局 Message;修复价格表批量填价选中被清与弹窗被遮挡。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-10 16:27:22 +08:00
parent 2612a225ad
commit b9702cd0bc
27 changed files with 1905 additions and 856 deletions

View File

@ -1200,6 +1200,8 @@ export interface OrderOperationLogItem {
source?: string source?: string
changes: OrderOperationLogChangeItem[] changes: OrderOperationLogChangeItem[]
remark?: string remark?: string
/** 列表「内容」列:备注 + 字段变更摘要 */
summary?: string
createTime?: string createTime?: string
} }
@ -1217,6 +1219,12 @@ function mapOperationLogItem(raw: any): OrderOperationLogItem {
after: c?.after ?? c?.After ?? null, 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 { return {
id: Number(raw?.id ?? raw?.Id ?? 0), id: Number(raw?.id ?? raw?.Id ?? 0),
orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0), orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0),
@ -1228,11 +1236,33 @@ function mapOperationLogItem(raw: any): OrderOperationLogItem {
operatorType: raw?.operatorType ?? raw?.OperatorType, operatorType: raw?.operatorType ?? raw?.OperatorType,
source: raw?.source ?? raw?.Source, source: raw?.source ?? raw?.Source,
changes, changes,
remark: raw?.remark ?? raw?.Remark, remark,
summary,
createTime: raw?.createTime ?? raw?.CreateTime, 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 */ /** GET /order/operation-log/list */
export async function getOrderOperationLogList( export async function getOrderOperationLogList(
orderId: number | string, orderId: number | string,

View File

@ -287,6 +287,8 @@
const formDirtyBaseline = ref(''); const formDirtyBaseline = ref('');
const exampleDirtyBaseline = ref({ request: '', response: '' }); const exampleDirtyBaseline = ref({ request: '', response: '' });
/** 参数表内容快照:比 VXE recordset / sticky manualDirty 更抗误报 */
const tablesDirtyBaseline = ref('');
const manualDirty = ref(false); const manualDirty = ref(false);
const isDirty = ref(false); const isDirty = ref(false);
/** 接口明细加载/回填中:不算未保存,避免误报 */ /** 接口明细加载/回填中:不算未保存,避免误报 */
@ -296,23 +298,88 @@
function pickFormDirtySnapshot() { function pickFormDirtySnapshot() {
const d: any = props.basicFormData || {}; const d: any = props.basicFormData || {};
// 字典/SelectPro 常把 number 回写成 string,快照统一成字符串避免误报脏
const norm = (v: unknown) => (v == null || v === '' ? '' : String(v));
return JSON.stringify({ return JSON.stringify({
name: d.name, name: norm(d.name),
abbreviation: d.abbreviation, abbreviation: norm(d.abbreviation),
code: d.code, code: norm(d.code),
description: d.description, description: norm(d.description),
category: d.category, category: norm(d.category),
requestMethod: d.requestMethod, requestMethod: norm(d.requestMethod),
requestAddress: d.requestAddress, requestAddress: norm(d.requestAddress),
requestProtocol: d.requestProtocol, requestProtocol: norm(d.requestProtocol),
contentType: d.contentType, contentType: norm(d.contentType),
responseContentType: d.responseContentType, responseContentType: norm(d.responseContentType),
mappedParamType: d.mappedParamType, mappedParamType: norm(d.mappedParamType),
qps: d.qps, qps: norm(d.qps),
isBatch: d.isBatch, 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) { function actionRowsDirty(getAction?: () => any) {
const a = getAction?.() || {}; const a = getAction?.() || {};
return ( return (
@ -325,31 +392,36 @@
function hasUnsavedChanges() { function hasUnsavedChanges() {
// 加载未完成时不提示「有未保存更改」 // 加载未完成时不提示「有未保存更改」
if (detailHydrating.value || !dirtyBaselineReady.value) return false; if (detailHydrating.value || !dirtyBaselineReady.value) return false;
if (manualDirty.value) return true;
if (fixedParamsHasChanges.value) return true; if (fixedParamsHasChanges.value) return true;
if (returnConfigHasChanges.value) return true; if (returnConfigHasChanges.value) return true;
if (formDirtyBaseline.value && pickFormDirtySnapshot() !== formDirtyBaseline.value) return true; if (formDirtyBaseline.value && pickFormDirtySnapshot() !== formDirtyBaseline.value) return true;
if ((requestExample.value || '') !== (exampleDirtyBaseline.value.request || '')) return true; if ((requestExample.value || '') !== (exampleDirtyBaseline.value.request || '')) return true;
if ((responseExample.value || '') !== (exampleDirtyBaseline.value.response || '')) return true; if ((responseExample.value || '') !== (exampleDirtyBaseline.value.response || '')) return true;
// 公共参数行不参与脏检查 // 以参数表内容快照为准(切 tab / SelectPro 回填导致的假 VXE insert、sticky markDirty 不再误报)
if (actionRowsDirty(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))) { if (tablesDirtyBaseline.value) {
return true; const currentTables = pickTablesDirtySnapshot();
} if (currentTables && currentTables !== tablesDirtyBaseline.value) return true;
if (actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))) { } else if (
return true; // 基线尚未采到表快照时,回退 VXE recordset
} actionRowsDirty(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))
if (actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))) { || actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))
return true; || actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))
} || actionRowsDirty(() =>
if (
actionRowsDirty(() =>
stripBodyRootFromActionRows(requestTableRef.value?.getActionRows?.() || {}), stripBodyRootFromActionRows(requestTableRef.value?.getActionRows?.() || {}),
) )
|| actionRowsDirty(() => mappingBodyTableRef.value?.getActionRows?.())
|| actionRowsDirty(() => responseTableRef.value?.getActionRows?.())
) { ) {
return true; return true;
} }
if (actionRowsDirty(() => mappingBodyTableRef.value?.getActionRows?.())) return true; // sticky manualDirty:仅在内容快照不可用时作补充;有快照且内容一致则清掉误标
if (actionRowsDirty(() => responseTableRef.value?.getActionRows?.())) return true; if (manualDirty.value) {
if (tablesDirtyBaseline.value) {
manualDirty.value = false;
return false;
}
return true;
}
return false; return false;
} }
@ -360,7 +432,8 @@
function markDirty() { function markDirty() {
if (detailHydrating.value) return; if (detailHydrating.value) return;
manualDirty.value = true; manualDirty.value = true;
isDirty.value = true; // 立刻按内容快照重算,避免「点一下必填又点回去」仍显示未保存
refreshDirtyState();
} }
function captureDirtyBaseline() { function captureDirtyBaseline() {
@ -370,6 +443,7 @@
request: requestExample.value || '', request: requestExample.value || '',
response: responseExample.value || '', response: responseExample.value || '',
}; };
tablesDirtyBaseline.value = pickTablesDirtySnapshot();
isDirty.value = false; isDirty.value = false;
dirtyBaselineReady.value = true; dirtyBaselineReady.value = true;
} }
@ -1164,6 +1238,7 @@
if (!row || row.children?.length) return; if (!row || row.children?.length) return;
row.type = typeKey; row.type = typeKey;
paramTypePopoverRowId.value = null; paramTypePopoverRowId.value = null;
markDirty();
} }
// Query 表格配置(使用下游接口的配置方式) // Query 表格配置(使用下游接口的配置方式)
@ -2359,8 +2434,19 @@
async function deleteRequestRow(row: any) { async function deleteRequestRow(row: any) {
if (isBodyRootRow(row)) return; if (isBodyRootRow(row)) return;
markDirty(); markDirty();
const ref = requestTab.value === 'mappingBody' ? mappingBodyTableRef : requestTableRef; const isMapping = requestTab.value === 'mappingBody';
const ref = isMapping ? mappingBodyTableRef : requestTableRef;
await ref.value?.removeRow(row); 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(); refreshTabCounts();
} }
@ -2523,6 +2609,12 @@
async function deleteResponseRow(row: any) { async function deleteResponseRow(row: any) {
markDirty(); markDirty();
await responseTableRef.value?.removeRow(row); await responseTableRef.value?.removeRow(row);
// 与表格同步,避免 props 残留旧树被校验/保存误读
const fromGrid =
responseTableRef.value?.getTableData?.()?.fullData
|| responseTableRef.value?.xGrid?.getTableData?.()?.fullData
|| [];
responseTableProps.data = Array.isArray(fromGrid) ? [...fromGrid] : [];
refreshTabCounts(); refreshTabCounts();
} }
@ -2807,15 +2899,19 @@
await writeTextToClipboard(content); await writeTextToClipboard(content);
} }
/** 复制当前 Body 参数列表为带 // 描述的格式化 JSON */ /** 复制当前 Body / Mapping Body 参数为带 // 描述的格式化 JSON */
async function copyBodyParamsAsCommentedJson() { 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 参数可复制'); Message.warning('当前请求类型为 none,无 Body 参数可复制');
return; return;
} } else if (isBodyKvMode.value) {
let content = '';
if (isBodyKvMode.value) {
const rows = getTableFullData(bodyFormTableRef, bodyFormTableProps.data); const rows = getTableFullData(bodyFormTableRef, bodyFormTableProps.data);
content = buildCommentedJsonFromKvRows(rows); content = buildCommentedJsonFromKvRows(rows);
} else { } else {
@ -3107,10 +3203,7 @@
await postParameterValidateParameter(arr); await postParameterValidateParameter(arr);
const sortedData = [...dataList1.value].sort((a: any, b: any) => a.id - b.id); await loadFlatListIntoTreeTable(arr, responseTableProps, responseTableRef);
sortedData.forEach((node: any) => {
responseTableRef.value?.addRowsAt(node, -1);
});
refreshTabCounts(); refreshTabCounts();
$baseMessage('导入成功', 'success'); $baseMessage('导入成功', 'success');
@ -3215,13 +3308,16 @@
}); });
await postParameterValidateParameter(arr); await postParameterValidateParameter(arr);
$baseMessage('导入成功', 'success');
dataList1.value.sort((a: any, b: any) => a.id - b.id); const rows = (dataList1.value || []).map((i: any) => ({
dataList1.value.forEach((node: any) => { ...i,
responseTableRef.value?.addRowsAt(node, -1); cnName: i.cnName ?? '',
}); optionType: 1,
alias: i?.alias || i?.name,
}));
await loadFlatListIntoTreeTable(rows, responseTableProps, responseTableRef);
refreshTabCounts(); refreshTabCounts();
$baseMessage('导入成功', 'success');
drawerVisible1.value = false; drawerVisible1.value = false;
clearResponseImportEditors(); clearResponseImportEditors();
@ -3244,8 +3340,9 @@
async function loadFlatListIntoTreeTable( async function loadFlatListIntoTreeTable(
flatList: any[], flatList: any[],
tableProps: { data: 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)); let rows = flatParameterListToTableRows(sortFlatParameterListParentFirst(flatList));
if (tableProps === requestTableProps) { if (tableProps === requestTableProps) {
rows = ensureBodyRootInRows(rows); rows = ensureBodyRootInRows(rows);
@ -3255,18 +3352,18 @@
if (tableRef.value?.reloadData) { if (tableRef.value?.reloadData) {
await tableRef.value.reloadData(rows); await tableRef.value.reloadData(rows);
} else { } else {
const ordered = sortFlatParameterListParentFirst( // 无 reloadData 时先清空再写入,避免残留旧行
tableProps === requestTableProps ? rows : flatList, tableProps.data = [];
); await nextTick();
for (const node of ordered) { tableProps.data = rows;
await tableRef.value?.addRowsAt?.(node, -1);
}
} }
await nextTick(); await nextTick();
const xGrid = tableRef.value?.xGrid; const xGrid = tableRef.value?.xGrid;
if (xGrid?.setAllTreeExpand) { if (xGrid?.setAllTreeExpand) {
xGrid.setAllTreeExpand(true); xGrid.setAllTreeExpand(true);
} }
// reloadData 会清空 VXE recordset,必须手动标脏,否则保存按钮不出现
markDirty();
} }
/** Json/Xml → Body 树;FormUrlEncoded / None → Mapping Body 树 */ /** Json/Xml → Body 树;FormUrlEncoded / None → Mapping Body 树 */
@ -3701,11 +3798,27 @@
<a-button size="small" @click="saveFixedParams">保存固定配置</a-button> <a-button size="small" @click="saveFixedParams">保存固定配置</a-button>
</template> </template>
<template v-else-if="requestTab === 'body' && isBodyKvMode"> <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"> <a-button size="small" class="section-icon-btn" title="新增" @click="addNewBodyFormParam">
<IconPlus /> <IconPlus />
</a-button> </a-button>
</template> </template>
<template v-else-if="requestTab === 'body' && !isNoneBodyContentType"> <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"> <a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus /> <IconPlus />
</a-button> </a-button>
@ -3714,6 +3827,14 @@
</a-button> </a-button>
</template> </template>
<template v-else-if="requestTab === 'mappingBody'"> <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"> <a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus /> <IconPlus />
</a-button> </a-button>
@ -3787,14 +3908,6 @@
aria-label="Request content type" aria-label="Request content type"
@change="updateContentType" @change="updateContentType"
/> />
<a-button
size="mini"
class="content-type-copy-btn section-icon-btn"
title="复制当前参数为带描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
<IconCopy />
</a-button>
</div> </div>
<!-- Query tab 内容 --> <!-- Query tab 内容 -->
<div v-show="requestTab === 'query'" class="query-tab-pane"> <div v-show="requestTab === 'query'" class="query-tab-pane">
@ -4785,7 +4898,7 @@
> >
<div class="analysis-content"> <div class="analysis-content">
<div class="analysis-tip"> <div class="analysis-tip">
粘贴 JSON 或 XML 内容后导入参数树。JSON 可用「前端解析」(支持 <code>//</code> 备注写入描述)、「仅导入注释」(只更新已有字段描述,不新增),或使用「解析并导入」走后端解析。 粘贴 JSON 或 XML 内容后<strong>全量替换</strong>当前参数树(先清空再导入,保存时会删除库中旧节点)。JSON 可用「前端解析」(支持 <code>//</code> 备注写入描述)、「仅导入注释」(只更新已有字段描述,不新增/不删树),或使用「解析并导入」走后端解析。
</div> </div>
<div class="analysis-tabs"> <div class="analysis-tabs">
<SlidingTabs v-model="tabs" size="mini" :items="analysisTypeItems" aria-label="Import format" /> <SlidingTabs v-model="tabs" size="mini" :items="analysisTypeItems" aria-label="Import format" />
@ -4878,7 +4991,7 @@
> >
<div class="analysis-content"> <div class="analysis-content">
<div class="analysis-tip"> <div class="analysis-tip">
粘贴 JSON 或 XML 响应示例,点击「解析并导入」生成响应参数树。 粘贴 JSON 或 XML 响应示例,点击「解析并导入」将<strong>全量替换</strong>当前响应参数树(先清空再导入)。
</div> </div>
<div class="analysis-tabs"> <div class="analysis-tabs">
<SlidingTabs v-model="tabs1" size="mini" :items="analysisTypeItems" aria-label="Import format" /> <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.after': 'After',
'order.myOrders.drawer.operationLog.noChanges': 'No field changes', 'order.myOrders.drawer.operationLog.noChanges': 'No field changes',
'order.myOrders.drawer.operationLog.operator': 'Operator', '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.tabs.issueHandling': 'Issue handling',
'order.myOrders.drawer.issueHandling.pendingCount': 'Pending {count}', 'order.myOrders.drawer.issueHandling.pendingCount': 'Pending {count}',
'order.myOrders.drawer.issueHandling.processedCount': 'Processed {count}', 'order.myOrders.drawer.issueHandling.processedCount': 'Processed {count}',
@ -2384,6 +2387,8 @@ export default {
'order.myOrders.drawer.actions.edit': 'Edit', 'order.myOrders.drawer.actions.edit': 'Edit',
'order.myOrders.drawer.actions.save': 'Save', 'order.myOrders.drawer.actions.save': 'Save',
'order.myOrders.drawer.actions.cancel': 'Cancel', '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.recalculateReceivable': 'Recalc Receivable',
'order.myOrders.drawer.actions.recalculatePayable': 'Recalc Payable', 'order.myOrders.drawer.actions.recalculatePayable': 'Recalc Payable',
'order.myOrders.drawer.actions.bindSnapshotVersions': 'Switch Versions', '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.failed': 'Failed to switch versions',
'order.myOrders.drawer.bindSnapshot.loadFailed': 'Failed to load publish versions', 'order.myOrders.drawer.bindSnapshot.loadFailed': 'Failed to load publish versions',
'order.myOrders.drawer.actions.addFee': 'Add Fee', '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.editBasicTitle': 'Edit Order Details',
'order.myOrders.drawer.editWeightTitle': 'Edit Weight & Dimensions', 'order.myOrders.drawer.editWeightTitle': 'Edit Weight & Dimensions',
'order.myOrders.drawer.message.saveSuccess': 'Saved successfully', 'order.myOrders.drawer.message.saveSuccess': 'Saved successfully',
@ -3956,9 +3961,9 @@ export default {
'operation.inbound.pageTitle': 'Waybill Information', 'operation.inbound.pageTitle': 'Waybill Information',
'operation.inbound.broadcastSettings': 'Broadcast Settings', 'operation.inbound.broadcastSettings': 'Broadcast Settings',
'operation.inbound.scan.title': 'Scan inbound', '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.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.scanNumber': 'Scan Number:',
'operation.inbound.form.receivedWeight': 'Received Weight', 'operation.inbound.form.receivedWeight': 'Received Weight',
'operation.inbound.form.dimensions': 'L × W × H', 'operation.inbound.form.dimensions': 'L × W × H',
@ -4008,9 +4013,9 @@ export default {
'operation.outbound.pageTitle': 'Waybill Information', 'operation.outbound.pageTitle': 'Waybill Information',
'operation.outbound.broadcastSettings': 'Broadcast Settings', 'operation.outbound.broadcastSettings': 'Broadcast Settings',
'operation.outbound.scan.title': 'Scan outbound', '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.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.scanNumber': 'Scan Number:',
'operation.outbound.form.shipWeight': 'Shipped weight', 'operation.outbound.form.shipWeight': 'Shipped weight',
'operation.outbound.form.dimensions': 'L × W × H', 'operation.outbound.form.dimensions': 'L × W × H',
@ -4042,6 +4047,7 @@ export default {
'operation.outbound.table.operateTime': 'Operate Time', 'operation.outbound.table.operateTime': 'Operate Time',
'operation.outbound.table.operator': 'Operator', 'operation.outbound.table.operator': 'Operator',
'operation.outbound.message.waybillNumberRequired': 'Please enter or scan waybill number', '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.outboundSuccess': 'Outbound successful',
'operation.outbound.message.outboundFailed': 'Outbound failed', 'operation.outbound.message.outboundFailed': 'Outbound failed',
'operation.outbound.message.loadRecentFailed': 'Failed to load recent outbound records', 'operation.outbound.message.loadRecentFailed': 'Failed to load recent outbound records',
@ -5040,6 +5046,8 @@ export default {
'channel.labelDesign.actions.search': 'Search', 'channel.labelDesign.actions.search': 'Search',
'channel.labelDesign.actions.create': 'New template', 'channel.labelDesign.actions.create': 'New template',
'channel.labelDesign.actions.edit': 'Design', 'channel.labelDesign.actions.edit': 'Design',
'channel.labelDesign.actions.rename': 'Rename',
'channel.labelDesign.actions.editTemplate': 'Edit',
'channel.labelDesign.actions.delete': 'Delete', 'channel.labelDesign.actions.delete': 'Delete',
'channel.labelDesign.actions.refresh': 'Refresh', 'channel.labelDesign.actions.refresh': 'Refresh',
'channel.labelDesign.actions.back': 'Back', 'channel.labelDesign.actions.back': 'Back',
@ -5049,6 +5057,7 @@ export default {
'channel.labelDesign.actions.openTemplate': 'Open template', 'channel.labelDesign.actions.openTemplate': 'Open template',
'channel.labelDesign.actions.open': 'Open', 'channel.labelDesign.actions.open': 'Open',
'channel.labelDesign.actions.startDesign': 'Start design', 'channel.labelDesign.actions.startDesign': 'Start design',
'channel.labelDesign.actions.confirm': 'OK',
'channel.labelDesign.actions.cancel': 'Cancel', 'channel.labelDesign.actions.cancel': 'Cancel',
'channel.labelDesign.loadingList': 'Loading templates…', 'channel.labelDesign.loadingList': 'Loading templates…',
'channel.labelDesign.emptySelect': 'Select a template on the left, or create one', '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.saveSuccess': 'Template saved',
'channel.labelDesign.message.saveFailed': 'Save failed', 'channel.labelDesign.message.saveFailed': 'Save failed',
'channel.labelDesign.message.deleteSuccess': 'Deleted', '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.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.alreadyOpen': 'This template is already open',
'channel.labelDesign.message.openSuccess': 'Opened: {name}', 'channel.labelDesign.message.openSuccess': 'Opened: {name}',
'channel.labelDesign.fieldDictionary': 'Order fields', 'channel.labelDesign.fieldDictionary': 'Order fields',

View File

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

View File

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

View File

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

View File

@ -1,18 +1,94 @@
import { Message } from '@arco-design/web-vue'; 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 message 消息内容
* @param type 消息类型 'success' | 'error' | 'warning' | 'info' * @param type 消息类型
* @param options 可覆盖全局默认(duration / closable / resetOnHover)
*/ */
export const baseMessage = ( export const baseMessage = (
message: string, 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 const $baseMessage = baseMessage;
export default baseMessage; export default baseMessage;

View File

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

View File

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

View File

@ -53,17 +53,29 @@
return row; 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[]) { function snapshotSource(rows: any[]) {
const map = new Map<number | string, any>(); const map = new Map<number | string, any>();
rows.forEach((row) => { rows.forEach((row) => {
if (row.id != null) { const id = rowSourceId(row);
map.set(row.id, { if (id == null) return;
id: row.id, map.set(id, {
configType: row.configType, id,
parameterId: row.parameterId, configType: row.configType == null ? '' : String(row.configType),
parameterId: row.parameterId == null ? '' : String(row.parameterId),
value: row.value == null ? '' : String(row.value), value: row.value == null ? '' : String(row.value),
}); });
}
}); });
sourceMap.value = map; sourceMap.value = map;
removedRecords.value = []; removedRecords.value = [];
@ -74,19 +86,20 @@
const updateRecords: any[] = []; const updateRecords: any[] = [];
listData.value.forEach((row) => { listData.value.forEach((row) => {
if (row._isNew || row.id == null) { const id = rowSourceId(row);
if (row._isNew || id == null) {
insertRecords.push(row); insertRecords.push(row);
return; return;
} }
const src = sourceMap.value.get(row.id); const src = sourceMap.value.get(id);
if (!src) { if (!src) {
insertRecords.push(row); insertRecords.push(row);
return; return;
} }
const currentValue = row.value == null ? '' : String(row.value); const currentValue = row.value == null ? '' : String(row.value);
if ( if (
src.configType !== row.configType || !sameScalar(src.configType, row.configType) ||
src.parameterId !== row.parameterId || !sameScalar(src.parameterId, row.parameterId) ||
src.value !== currentValue src.value !== currentValue
) { ) {
updateRecords.push(row); updateRecords.push(row);
@ -141,12 +154,27 @@
}); });
const tableData = [...addData, ...removeData, ...updataData]; 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({ await postInterfaceReturnConfigSave({
interfaceId: addId.value, 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) { if (!options?.skipSuccessToast) {
$baseMessage('保存成功,如需生效请去发布', 'success'); $baseMessage('保存成功,如需生效请去发布', 'success');
} }
@ -171,8 +199,9 @@
if (idx >= 0) { if (idx >= 0) {
listData.value.splice(idx, 1); listData.value.splice(idx, 1);
} }
if (currRow?.id != null && !currRow._isNew) { const removedId = rowSourceId(currRow);
if (!removedRecords.value.some((r) => r.id === currRow.id)) { if (removedId != null && !currRow._isNew) {
if (!removedRecords.value.some((r) => rowSourceId(r) === removedId)) {
removedRecords.value.push(currRow); removedRecords.value.push(currRow);
} }
} }
@ -181,12 +210,15 @@
function handleTypeChange(e: any, row: any) { function handleTypeChange(e: any, row: any) {
row.parameterId = undefined; row.parameterId = undefined;
const fullData = getFullData(); const fullData = getFullData();
const nextType = Number(e?.value ?? e);
if (e.value === 2 || e.value === 3) { if (nextType === 2 || nextType === 3) {
const flag = fullData?.filter((item: any) => item.configType === e.value); const flag = fullData?.filter(
(item: any) => Number(item.configType) === nextType,
);
if (flag && flag?.length >= 2) { if (flag && flag?.length >= 2) {
const msg = const msg =
e.value === 2 nextType === 2
? '标签上传只能保存一条数据' ? '标签上传只能保存一条数据'
: '错误返回只能保存一条数据'; : '错误返回只能保存一条数据';
row.configType = undefined; row.configType = undefined;
@ -232,6 +264,9 @@
); );
snapshotSource(listData.value); snapshotSource(listData.value);
await nextTick(); await nextTick();
// SelectPro 异步回写 key 类型后再采一次基线,避免刚打开就误报脏
await nextTick();
snapshotSource(listData.value);
} }
watch( watch(
@ -244,9 +279,11 @@
{ immediate: false } { immediate: false }
); );
/** 与后端 onlyLeafs 一致:排除 Object/Array。错误返回(3)需要整树,其余只要叶子 */
function formatNameType(row: any) { function formatNameType(row: any) {
if (row.configType === 1 || row.configType === 2) return true; const type = Number(row?.configType);
if (row.configType === 3) return false; if (type === 3) return false;
return true;
} }
const valueList = ref<API.EnumCacheEntry[]>([]); const valueList = ref<API.EnumCacheEntry[]>([]);
@ -325,7 +362,7 @@
<template #value="{ record }"> <template #value="{ record }">
<SelectPro <SelectPro
v-if="record.configType === 2" v-if="Number(record.configType) === 2"
v-model="record.value" v-model="record.value"
:api="getDictionaryGetList" :api="getDictionaryGetList"
:extra="{ :extra="{
@ -338,7 +375,7 @@
placeholder="请选择值" placeholder="请选择值"
/> />
<a-input <a-input
v-else-if="record.configType === 1" v-else-if="Number(record.configType) === 1"
v-model="record.value" v-model="record.value"
placeholder="请输入值" placeholder="请输入值"
/> />

View File

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

View File

@ -7,6 +7,7 @@
getEditMapDetails, getEditMapDetails,
getGetMapParameterCascadedList, getGetMapParameterCascadedList,
} from '@/api/InterfaceMappingServices/interfaceMap'; } from '@/api/InterfaceMappingServices/interfaceMap';
import { ParameterType } from '@/constants/itfxEnums';
import AddParameter from './AddParameter.vue'; import AddParameter from './AddParameter.vue';
import MapAiAssistModal from './MapAiAssistModal.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) { function setlabel(data: any) {
data.forEach((item: 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.name
: item.description : item.description
? `${item.alias}-${item.description}` ? `${item.alias}-${item.description}`
@ -304,6 +330,13 @@
}); });
return data; 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(); const mapAliasEditRef = ref();
/** 级联 options 按步骤缓存,避免每行渲染都深拷贝/打标 */ /** 级联 options 按步骤缓存,避免每行渲染都深拷贝/打标 */
const cascaderOptionsCache = ref<Map<string, any[]>>(new Map()); const cascaderOptionsCache = ref<Map<string, any[]>>(new Map());
@ -321,7 +354,9 @@
let result: any[]; let result: any[];
if (isReverse) { if (isReverse) {
result = setlabel(cascadedList.value.slice(1, cascadedList.value.length)); result = setlabel(
prepareCascaderRoots(cascadedList.value.slice(1, cascadedList.value.length)),
);
} else { } else {
const ids: number[] = inParaMapList.value.map((t) => t.interfaceId!); const ids: number[] = inParaMapList.value.map((t) => t.interfaceId!);
const findI = ids.findIndex((i) => i === item.interfaceId); const findI = ids.findIndex((i) => i === item.interfaceId);
@ -335,7 +370,7 @@
}), }),
).filter(Boolean); ).filter(Boolean);
} }
result = setlabel(data); result = setlabel(prepareCascaderRoots(data));
} }
cascaderOptionsCache.value.set(cacheKey, result); cascaderOptionsCache.value.set(cacheKey, result);
return result; return result;
@ -347,16 +382,45 @@
return getOptions(activeInParaItem.value, false); return getOptions(activeInParaItem.value, false);
}); });
const outputCascaderOptions = computed(() => getOptions({ interfaceId: 0 }, true)); 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( function mapAliasEditCheng(
e: any, e: any,
row: API.InterfaceParameterMapOutputValueObject row: API.InterfaceParameterMapOutputValueObject
) { ) {
if (e) { if (e) {
let arr: any[] = []; const item = findCascadedParamById(e);
cascadedList.value.forEach((t: any) => { // 仅允许叶子:Object/Array 或仍带子节点的不算有效映射
if (t.parameters) arr = arr.concat(t.parameters); if (
}); !item ||
const item = arr.find((t: any) => t.id === e); !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.mapAlias = item?.alias;
row.mapId = item?.id; row.mapId = item?.id;
row.mapCnName = item?.cnName; row.mapCnName = item?.cnName;
@ -740,6 +804,8 @@
v-model="row.mapId" v-model="row.mapId"
:options="activeInputCascaderOptions" :options="activeInputCascaderOptions"
:filter-option="filterMethod" :filter-option="filterMethod"
:format-label="formatCascaderLeafLabel"
search-option-only-label
allow-clear allow-clear
allow-search allow-search
:field-names="{ :field-names="{
@ -747,17 +813,12 @@
label: 'getlabel', label: 'getlabel',
children: 'parameters', children: 'parameters',
}" }"
:show-all-levels="false"
@change=" @change="
(value) => { (value) => {
mapAliasEditCheng(value, row); mapAliasEditCheng(value, row);
} }
" "
> />
<template #label="{ data: _data }">
{{ _data.label }}
</template>
</a-cascader>
</template> </template>
<!-- 参数类型(请求侧)在必填时加红色星号 --> <!-- 参数类型(请求侧)在必填时加红色星号 -->
@ -798,6 +859,8 @@
v-model="row.mapId" v-model="row.mapId"
:options="outputCascaderOptions" :options="outputCascaderOptions"
:filter-option="filterMethod" :filter-option="filterMethod"
:format-label="formatCascaderLeafLabel"
search-option-only-label
allow-search allow-search
allow-clear allow-clear
:field-names="{ :field-names="{
@ -805,17 +868,12 @@
label: 'getlabel', label: 'getlabel',
children: 'parameters', children: 'parameters',
}" }"
:show-all-levels="false"
@change=" @change="
(value) => { (value) => {
mapAliasEditCheng(value, row); mapAliasEditCheng(value, row);
} }
" "
> />
<template #label="{ data: _data }">
{{ _data.label }}
</template>
</a-cascader>
</template> </template>
<!-- 参数类型(响应侧)在必填时加红色星号 --> <!-- 参数类型(响应侧)在必填时加红色星号 -->
<template #typeResp="{ row }"> <template #typeResp="{ row }">

View File

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

View File

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

View File

@ -998,7 +998,17 @@
} }
function rowClick(row: any) { 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 // 联动右侧映射明细:根据左侧选中映射行初始化 EnteredMap
serviceProviderId.value = row.serviceProviderId; serviceProviderId.value = row.serviceProviderId;
serviceProviderName.value = row.serviceProviderName; serviceProviderName.value = row.serviceProviderName;
@ -1462,6 +1472,22 @@
loadInterfaceTree(); 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) { function handleActionSelect(value: string, option: any) {
try { try {
@ -1519,7 +1545,7 @@
:actions-title="$t('system.interface.table.actions')" :actions-title="$t('system.interface.table.actions')"
method-label="POST" method-label="POST"
@search="handleSearch" @search="handleSearch"
@refresh="loadInterfaceTree" @refresh="handleRefreshInterfaceTree"
@add="interfaceClick" @add="interfaceClick"
@select="handleInterfaceSelect" @select="handleInterfaceSelect"
@expand="handleTreeExpand" @expand="handleTreeExpand"

View File

@ -494,8 +494,138 @@ onBeforeUnmount(() => {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; 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; --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 { .ld-spin {

View File

@ -25,7 +25,18 @@
@add="openCreateModal" @add="openCreateModal"
@select="handleMenuSelect" @select="handleMenuSelect"
@delete-node="handleDeleteNode" @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>
<div class="right-panel"> <div class="right-panel">
@ -82,6 +93,45 @@
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </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> </div>
</template> </template>
@ -90,12 +140,14 @@ import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { h } from 'vue' import { h } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Modal, Message, Spin } from '@arco-design/web-vue' 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 CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types' import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
import { import {
deleteLabelTemplate, deleteLabelTemplate,
getLabelTemplate, getLabelTemplate,
getLabelTemplateList, getLabelTemplateList,
saveLabelTemplate,
type LabelTemplateListItem, type LabelTemplateListItem,
} from '@/api/channel/labelTemplate' } from '@/api/channel/labelTemplate'
@ -126,6 +178,12 @@ const createForm = reactive({
name: '', name: '',
templateType: 'lastMile', templateType: 'lastMile',
}) })
const renameModalVisible = ref(false)
const renameForm = reactive({
id: 0,
name: '',
templateType: 'lastMile',
})
const editing = reactive<{ const editing = reactive<{
id?: number id?: number
name?: string name?: string
@ -261,6 +319,49 @@ function handleMenuSelect(keys: string[]) {
openTemplate(id) 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) { function handleDeleteNode(nodeData: any) {
const id = Number(nodeData?.id) const id = Number(nodeData?.id)
const name = String(nodeData?.name || nodeData?.title || '') const name = String(nodeData?.name || nodeData?.title || '')

View File

@ -367,9 +367,27 @@
enterBeginsEditing: true, enterBeginsEditing: true,
fragmentSelection: false, fragmentSelection: false,
selectionMode: 'multiple', selectionMode: 'multiple',
// 点表格外的工具栏/弹窗按钮时不要清选中,否则「批量填价」等会在 mousedown 时先丢选中再点不到
outsideClickDeselects(event: MouseEvent) { outsideClickDeselects(event: MouseEvent) {
const target = event.target as HTMLElement | null; 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, allowInsertRow: false,
allowInsertColumn: false, allowInsertColumn: false,

View File

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

View File

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

View File

@ -31,13 +31,9 @@
type="primary" type="primary"
class="ops-btn ops-btn--primary" class="ops-btn ops-btn--primary"
:loading="submitting || lookingUp" :loading="submitting || lookingUp"
@click="formData.orderId ? handleSubmit() : handleWaybillLookup()" @click="handleSubmit()"
> >
{{ {{ $t('operation.inbound.actions.inbound') }}
formData.orderId
? $t('operation.inbound.actions.inbound')
: $t('operation.inbound.actions.lookup')
}}
</a-button> </a-button>
</div> </div>
</div> </div>
@ -149,7 +145,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from 'vue-i18n' 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 HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
@ -341,6 +337,8 @@ function handlePageSizeChange(size: number) {
currentPage.value = 1 currentPage.value = 1
} }
const lookedUpWaybillNo = ref('')
function clearLookupFields() { function clearLookupFields() {
formData.salesProductName = '' formData.salesProductName = ''
formData.destinationCountryName = '' formData.destinationCountryName = ''
@ -349,6 +347,7 @@ function clearLookupFields() {
formData.length = '' formData.length = ''
formData.width = '' formData.width = ''
formData.height = '' formData.height = ''
lookedUpWaybillNo.value = ''
} }
function clearLookup() { function clearLookup() {
@ -356,11 +355,19 @@ function clearLookup() {
clearFeedback() 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() const no = formData.waybillNumber?.trim()
if (!no) { if (!no) {
clearLookup() clearLookup()
return return false
}
if (formData.orderId && lookedUpWaybillNo.value === no) {
return true
} }
lookingUp.value = true lookingUp.value = true
try { try {
@ -372,24 +379,29 @@ async function handleWaybillLookup() {
formData.salesProductName = info.salesProductName || info.productCode || '' formData.salesProductName = info.salesProductName || info.productCode || ''
formData.destinationCountryName = formData.destinationCountryName =
info.destinationCountryName || info.destinationCountry || '' info.destinationCountryName || info.destinationCountry || ''
showSuccess(t('operation.common.message.lookupSuccess')) lookedUpWaybillNo.value = no
return true
} catch (e: any) { } catch (e: any) {
formData.salesProductName = '' formData.salesProductName = ''
formData.destinationCountryName = '' formData.destinationCountryName = ''
formData.orderId = undefined formData.orderId = undefined
lookedUpWaybillNo.value = ''
showError(e?.message || t('operation.common.message.orderNotFound')) showError(e?.message || t('operation.common.message.orderNotFound'))
maybeRefocusScan() maybeRefocusScan()
return false
} finally { } finally {
lookingUp.value = false lookingUp.value = false
} }
} }
async function onScanEnter() { async function onScanEnter() {
if (formData.orderId && formData.waybillNumber?.trim()) { const ok = await ensureOrderLookup()
if (!ok) return
if (hasMetrics()) {
await handleSubmit() await handleSubmit()
return return
} }
await handleWaybillLookup() clearFeedback()
} }
function buildSuccessMessage(result: any) { function buildSuccessMessage(result: any) {
@ -410,11 +422,9 @@ async function handleSubmit() {
maybeRefocusScan() maybeRefocusScan()
return return
} }
if (!formData.orderId) { const ok = await ensureOrderLookup()
await handleWaybillLookup() if (!ok) return
if (!formData.orderId) return if (!hasMetrics()) {
}
if (!formData.weight || !formData.length || !formData.width || !formData.height) {
showWarning(t('operation.inbound.message.metricsRequired')) showWarning(t('operation.inbound.message.metricsRequired'))
return 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) { function handleReset(clearStatus = true) {
formData.waybillNumber = '' formData.waybillNumber = ''
clearLookupFields() clearLookupFields()

View File

@ -31,13 +31,9 @@
type="primary" type="primary"
class="ops-btn ops-btn--primary" class="ops-btn ops-btn--primary"
:loading="submitting || lookingUp" :loading="submitting || lookingUp"
@click="formData.orderId ? handleSubmit() : handleWaybillLookup()" @click="handleSubmit()"
> >
{{ {{ $t('operation.outbound.actions.outbound') }}
formData.orderId
? $t('operation.outbound.actions.outbound')
: $t('operation.outbound.actions.lookup')
}}
</a-button> </a-button>
</div> </div>
</div> </div>
@ -150,7 +146,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from 'vue-i18n' 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 HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
@ -272,6 +268,8 @@ function handlePageSizeChange(size: number) {
currentPage.value = 1 currentPage.value = 1
} }
const lookedUpWaybillNo = ref('')
function clearLookupFields() { function clearLookupFields() {
formData.salesProductName = '' formData.salesProductName = ''
formData.destinationCountryName = '' formData.destinationCountryName = ''
@ -281,6 +279,7 @@ function clearLookupFields() {
formData.length = '' formData.length = ''
formData.width = '' formData.width = ''
formData.height = '' formData.height = ''
lookedUpWaybillNo.value = ''
} }
function clearLookup() { function clearLookup() {
@ -288,11 +287,19 @@ function clearLookup() {
clearFeedback() 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() const no = formData.waybillNumber?.trim()
if (!no) { if (!no) {
clearLookup() clearLookup()
return return false
}
if (formData.orderId && lookedUpWaybillNo.value === no) {
return true
} }
lookingUp.value = true lookingUp.value = true
try { try {
@ -305,25 +312,30 @@ async function handleWaybillLookup() {
formData.destinationCountryName = formData.destinationCountryName =
info.destinationCountryName || info.destinationCountry || '' info.destinationCountryName || info.destinationCountry || ''
formData.serviceChannelName = info.serviceChannelName || info.channelCode || '' formData.serviceChannelName = info.serviceChannelName || info.channelCode || ''
showSuccess(t('operation.common.message.lookupSuccess')) lookedUpWaybillNo.value = no
return true
} catch (e: any) { } catch (e: any) {
formData.salesProductName = '' formData.salesProductName = ''
formData.destinationCountryName = '' formData.destinationCountryName = ''
formData.serviceChannelName = '' formData.serviceChannelName = ''
formData.orderId = undefined formData.orderId = undefined
lookedUpWaybillNo.value = ''
showError(e?.message || t('operation.common.message.orderNotFound')) showError(e?.message || t('operation.common.message.orderNotFound'))
maybeRefocusScan() maybeRefocusScan()
return false
} finally { } finally {
lookingUp.value = false lookingUp.value = false
} }
} }
async function onScanEnter() { async function onScanEnter() {
if (formData.orderId && formData.waybillNumber?.trim()) { const ok = await ensureOrderLookup()
if (!ok) return
if (hasMetrics()) {
await handleSubmit() await handleSubmit()
return return
} }
await handleWaybillLookup() clearFeedback()
} }
async function handleSubmit() { async function handleSubmit() {
@ -332,9 +344,11 @@ async function handleSubmit() {
maybeRefocusScan() maybeRefocusScan()
return return
} }
if (!formData.orderId) { const ok = await ensureOrderLookup()
await handleWaybillLookup() if (!ok) return
if (!formData.orderId) return if (!hasMetrics()) {
showWarning(t('operation.outbound.message.metricsRequired'))
return
} }
submitting.value = true submitting.value = true
try { 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) { function handleReset(clearStatus = true) {
formData.waybillNumber = '' formData.waybillNumber = ''
clearLookupFields() clearLookupFields()

View File

@ -1,159 +1,419 @@
<template> <template>
<div class="order-declaration-hot-table"> <div
<HandsontablePro ref="wrapRef"
:key="tableKey" class="order-declaration-hot-wrap"
:columns="hotColumns" :style="{ height: `${tableHeight}px` }"
:data="data" >
:total="data.length" <div
:current-page="1" ref="hostRef"
:page-size="Math.max(data.length, 1)" class="order-declaration-hot"
:height="tableHeight" :class="{ 'is-ready': ready }"
: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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
import HandsontablePro from '@/components/HandsontablePro/index.vue' import Handsontable from 'handsontable';
import type { ColumnDef } from '@/components/HandsontablePro/types' import { registerAllModules } from 'handsontable/registry';
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer' import { registerLanguageDictionary, zhCN } from 'handsontable/i18n';
import { ORDER_DETAIL_DECLARATION_COLUMNS } from '@/views/order/components/orderDetailFieldConfig' 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( const props = withDefaults(
defineProps<{ defineProps<{
data: any[] data: any[];
editing?: boolean editing?: boolean;
}>(), }>(),
{ {
editing: false, editing: false,
}, },
) );
const emit = defineEmits<{ const emit = defineEmits<{
'remove-row': [rowIndex: number] 'remove-row': [rowIndex: number];
'preview-sku-image': [url: string] 'preview-sku-image': [url: string];
}>() }>();
const { t } = useI18n() const { t } = useI18n();
const tableKey = computed(() => `decl-${props.editing ? 'edit' : 'view'}-${props.data.length}`) const wrapRef = ref<HTMLElement | null>(null);
const hostRef = ref<HTMLElement | null>(null);
const serialHeader = computed(() => t('order.myOrders.table.serialNo')) const ready = ref(false);
const actionHeader = computed(() => t('order.myOrders.drawer.declaration.action')) let hot: Handsontable | null = null;
let ignoreChange = false;
const hotColumns = computed<ColumnDef[]>(() => let resizeObserver: ResizeObserver | null = null;
ORDER_DETAIL_DECLARATION_COLUMNS.map((col) => { let lastWidth = 0;
const base: ColumnDef = { let resizeRaf = 0;
key: col.key, let mountTimer: ReturnType<typeof setTimeout> | null = null;
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 tableHeight = computed(() => { const tableHeight = computed(() => {
const rows = Math.max(props.data.length, 1) const rows = Math.max(props.data?.length || 0, 1);
return Math.min(280, Math.max(120, 40 + rows * 32 + 16)) return Math.min(280, Math.max(120, 40 + rows * 32 + 16));
}) });
function hasSkuImage(raw: unknown) { function hasSkuImage(raw: unknown) {
const value = String(raw ?? '').trim() const value = String(raw ?? '').trim();
return !!value && value !== '0' && value !== '--' return !!value && value !== '0' && value !== '--';
} }
function escapeAttr(text: string) { function escapeAttr(text: string) {
return String(text).replace(/"/g, '&quot;') return String(text).replace(/"/g, '&quot;');
} }
function eyeSvg() { 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>`;
} }
/** function decorateRows(rows: any[]): any[] {
* 查看态图片列:只渲染眼睛,不写入长 URL 文本。 return (rows || []).map((row, idx) => {
* HandsontablePro 只读态全局带 text-overflow:ellipsis,长链会被裁成「...」叠在眼睛旁。 if (row && typeof row === 'object') {
*/ row._serial = idx + 1;
function customCellRenderer( }
return row;
});
}
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 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, _row: number,
col: number, _col: number,
_value: any, _prop: any,
rowData: any, value: any,
): string | null { ) => {
if (props.editing) return null td.className = 'htCenter declaration-sku-cell';
// 布局:操作(可选) → 序号 → 数据列 if (!props.editing) {
const dataColStart = props.editing ? 2 : 1 if (!hasSkuImage(value)) {
const colDef = hotColumns.value[col - dataColStart] td.textContent = '--';
if (colDef?.key !== 'skuImage') return null return td;
if (!hasSkuImage(rowData?.skuImage)) return '--' }
const title = t('order.myOrders.drawer.declarationSkuImageView') const title = t('order.myOrders.drawer.declarationSkuImageView');
return `<span class="declaration-sku-eye" title="${escapeAttr(title)}" role="button">${eyeSvg()}</span>` 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 actionRenderer(_row: any, rowIndex: number) { function beforeStretchingColumnWidth(stretchedWidth: number, column: number) {
return wrapWarehouseActions( // 序号、操作列不拉伸
[renderWarehouseActionBtn('delete', t('order.myOrders.drawer.declaration.delete'))], if (column === 0) return 50;
rowIndex, if (props.editing && column === 1) return 64;
) return stretchedWidth;
} }
function handleActionClick(_row: any, rowIndex: number, action?: string) { function destroyHot() {
if (action === 'delete' || !action) { if (mountTimer) {
emit('remove-row', rowIndex) 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: { function syncWidth(force = false) {
row: any if (!hot) return;
rowIndex: number const width = readWrapWidth();
columnKey: string if (width < 200) return;
event: MouseEvent if (!force && Math.abs(width - lastWidth) < 2) return;
}) { lastWidth = width;
if (props.editing) return hot.updateSettings(
if (payload.columnKey !== 'skuImage') return {
const url = String(payload.row?.skuImage ?? '').trim() width,
if (!hasSkuImage(url)) return height: tableHeight.value,
emit('preview-sku-image', url) 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> </script>
<style lang="less" scoped> <style scoped lang="less">
.order-declaration-hot-table { .order-declaration-hot-wrap {
box-sizing: border-box;
width: 100%; width: 100%;
min-height: 120px; min-width: 0;
max-height: 280px; max-height: 280px;
}
.order-declaration-hot {
width: 100%;
height: 100%;
overflow: hidden; 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) { :deep(td.declaration-sku-cell) {
overflow: visible !important; overflow: visible !important;
@ -179,8 +439,20 @@ function handleCellClick(payload: {
} }
} }
:deep(.ht-action-cell) { :deep(.declaration-del-btn) {
overflow: visible; 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> </style>

View File

@ -5,7 +5,10 @@
@register="register" @register="register"
@cancel="handleCancel" @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="drawer-tabs">
<div class="tabs-slot"> <div class="tabs-slot">
<SlidingTabs <SlidingTabs
@ -16,69 +19,6 @@
</div> </div>
</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 class="drawer-tab-content">
<!-- 基本信息 --> <!-- 基本信息 -->
<div v-if="activeTab === 'basic'" class="tab-panel basic-info"> <div v-if="activeTab === 'basic'" class="tab-panel basic-info">
@ -97,7 +37,7 @@
<summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary> <summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary>
<p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p> <p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p>
</details> </details>
<div class="predeclare-fail-guide__actions"> <div v-if="canEditOrderDetail" class="predeclare-fail-guide__actions">
<a-button class="app-btn-search" @click="startBasicEdit"> <a-button class="app-btn-search" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.failGuideEdit') }} {{ $t('order.myOrders.drawer.failGuideEdit') }}
</a-button> </a-button>
@ -134,6 +74,7 @@
<div <div
v-if="section.key === 'declaration'" v-if="section.key === 'declaration'"
class="declaration-header-sum" class="declaration-header-sum"
:class="{ 'is-editable': basicEditing }"
@click.stop @click.stop
> >
<template v-if="basicEditing"> <template v-if="basicEditing">
@ -196,6 +137,7 @@
v-for="fieldKey in group.fields" v-for="fieldKey in group.fields"
:key="fieldKey" :key="fieldKey"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isOrderFieldEditable(fieldKey) }"
> >
<span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span> <span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -281,6 +223,7 @@
v-for="field in senderFields" v-for="field in senderFields"
:key="field.key" :key="field.key"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isSenderFieldEditable(field.key) }"
> >
<span class="detail-field-label">{{ $t(field.labelKey) }}</span> <span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -312,6 +255,7 @@
v-for="field in recipientFields" v-for="field in recipientFields"
:key="field.key" :key="field.key"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isRecipientFieldEditable(field.key) }"
> >
<span class="detail-field-label">{{ $t(field.labelKey) }}</span> <span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -635,6 +579,85 @@
</a-spin> </a-spin>
</div> </div>
</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> </div>
</DrawerPro> </DrawerPro>
@ -664,7 +687,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
@ -823,9 +846,10 @@ interface FormFieldDef {
options?: Array<{ value: string; labelKey: string }>; options?: Array<{ value: string; labelKey: string }>;
} }
/** 我的订单可编辑字段:跟踪号不开放;入库后整单不可编辑 */
const orderEditFieldDefs: FormFieldDef[] = [ const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' }, { key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' }, { key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{ {
key: 'cargoAttribute', key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute', labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -841,6 +865,9 @@ const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'codSum', labelKey: 'order.myOrders.drawer.codSum', inputType: 'number' }, { key: 'codSum', labelKey: 'order.myOrders.drawer.codSum', inputType: 'number' },
]; ];
/** 入库前(草稿/已预报)才允许客户编辑 */
const MY_ORDERS_EDITABLE_STATUSES = new Set(['draft', 'predeclared']);
const orderEditMetaMap = Object.fromEntries( const orderEditMetaMap = Object.fromEntries(
orderEditFieldDefs.map((def) => [def.key, def]) orderEditFieldDefs.map((def) => [def.key, def])
) as Record<string, FormFieldDef>; ) as Record<string, FormFieldDef>;
@ -875,7 +902,7 @@ function getRecipientFormKey(key: string) {
const basicEditForm = reactive({ const basicEditForm = reactive({
clientNo: '', clientNo: '',
trackNo: '', downstreamServiceNo: '',
cargoAttribute: 'general', cargoAttribute: 'general',
isCod: false, isCod: false,
codSum: undefined as number | undefined, codSum: undefined as number | undefined,
@ -1045,6 +1072,19 @@ function resetEditState() {
const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer(); const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const activeTab = ref('basic'); 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 CUSTOMER_WEIGHT_TYPE_KEYS = ['forecast', 'receive'] as const;
const tabList = computed(() => [ const tabList = computed(() => [
@ -1863,7 +1903,7 @@ function fillBasicEditForm() {
const o = detail.value; const o = detail.value;
if (!o?.id) return; if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? ''); 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.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod); basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined; basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -1915,10 +1955,26 @@ function fillBasicEditForm() {
declarationEditList.value = cloneDeclarationRows(declarationList.value); 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() { function startBasicEdit() {
if (!canEditOrderDetail.value) {
Message.warning(t('order.myOrders.drawer.editAfterInboundForbidden'));
return;
}
fillBasicEditForm(); fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency); void ensureCurrencyOptions(basicEditForm.declaredCurrency);
basicEditing.value = true; basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
} }
function cancelBasicEdit() { function cancelBasicEdit() {
@ -1951,7 +2007,7 @@ async function handleBasicEditSave() {
const result = await postOrderUpdate({ const result = await postOrderUpdate({
id, id,
clientNo: basicEditForm.clientNo, clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo, downstreamServiceNo: basicEditForm.downstreamServiceNo,
cargoAttribute: basicEditForm.cargoAttribute, cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod, isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum, codSum: basicEditForm.codSum,
@ -2192,43 +2248,81 @@ defineExpose({
min-width: 0; min-width: 0;
.tabs-slot { .tabs-slot {
flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
} }
.drawer-tab-toolbar { .drawer-action-bar {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px;
flex-shrink: 0; flex-shrink: 0;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
background: #fff;
}
.toolbar-left { .drawer-action-bar__meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} gap: 8px;
min-width: 0;
flex: 1;
}
.toolbar-meta { .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; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 6px;
margin-right: 4px; }
}
.toolbar-meta-label { .action-bar-meta-label {
font-size: 13px; font-size: 13px;
color: var(--color-text-3); color: var(--color-text-3);
} }
.toolbar-meta-value { .action-bar-meta-value {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--color-text-1); 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);
}
: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; singleShowFieldErrors.value = false;
const pickupNo = created?.pickupNo || created?.PickupNo; const pickupNo = created?.pickupNo || created?.PickupNo;
const pickupErr = created?.pickupCreateError || created?.PickupCreateError; 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'); let content = t('order.placeOrder.single.successContent');
if (forecastQueued) {
content = `${content}\n预报计费/排货处理中,费用与渠道稍后可在订单列表查看`;
}
if (pickupNo) { if (pickupNo) {
content = `${content}\n揽收单号:${pickupNo}`; content = `${content}\n揽收单号:${pickupNo}`;
} else if (pickupQueued) {
content = `${content}\n揽收单后台创建中,请稍后在揽收单列表查看`;
} else if (pickupErr) { } else if (pickupErr) {
content = `${content}\n揽收未创建成功:${pickupErr}(可到我的订单补建)`; content = `${content}\n揽收未创建成功:${pickupErr}(可到我的订单补建)`;
} }

View File

@ -5,7 +5,10 @@
@register="register" @register="register"
@cancel="handleCancel" @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="drawer-tabs">
<div class="tabs-slot"> <div class="tabs-slot">
<SlidingTabs <SlidingTabs
@ -16,112 +19,6 @@
</div> </div>
</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 class="drawer-tab-content">
<!-- 基本信息 --> <!-- 基本信息 -->
<div v-if="activeTab === 'basic'" class="tab-panel basic-info"> <div v-if="activeTab === 'basic'" class="tab-panel basic-info">
@ -173,6 +70,7 @@
<div <div
v-if="section.key === 'declaration'" v-if="section.key === 'declaration'"
class="declaration-header-sum" class="declaration-header-sum"
:class="{ 'is-editable': basicEditing }"
@click.stop @click.stop
> >
<template v-if="basicEditing"> <template v-if="basicEditing">
@ -230,6 +128,7 @@
v-for="fieldKey in group.fields" v-for="fieldKey in group.fields"
:key="fieldKey" :key="fieldKey"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isOrderFieldEditable(fieldKey) }"
> >
<span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span> <span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -331,6 +230,7 @@
v-for="field in senderFields" v-for="field in senderFields"
:key="field.key" :key="field.key"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isSenderFieldEditable(field.key) }"
> >
<span class="detail-field-label">{{ $t(field.labelKey) }}</span> <span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -362,6 +262,7 @@
v-for="field in recipientFields" v-for="field in recipientFields"
:key="field.key" :key="field.key"
class="detail-field-item" class="detail-field-item"
:class="{ 'is-editable': basicEditing && isRecipientFieldEditable(field.key) }"
> >
<span class="detail-field-label">{{ $t(field.labelKey) }}</span> <span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value"> <div class="detail-field-value">
@ -458,7 +359,10 @@
v-for="field in weightMetricFields" v-for="field in weightMetricFields"
:key="field.key" :key="field.key"
class="weight-metric-item" 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> <span class="metric-label">{{ $t(field.labelKey) }}</span>
<div class="metric-value-wrap"> <div class="metric-value-wrap">
@ -473,7 +377,7 @@
<span <span
v-else v-else
class="metric-value" class="metric-value"
:class="{ 'metric-value-highlight': field.highlight }" :class="{ 'metric-value-highlight': field.highlight && !weightEditing }"
> >
{{ formatWeightCellValue(section.row, field.key) }} {{ formatWeightCellValue(section.row, field.key) }}
</span> </span>
@ -524,18 +428,6 @@
<span v-else class="fee-additional-services__empty"> <span v-else class="fee-additional-services__empty">
{{ $t('order.myOrders.drawer.fee.additionalServicesNone') }} {{ $t('order.myOrders.drawer.fee.additionalServicesNone') }}
</span> </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>
<div class="fee-summary"> <div class="fee-summary">
<button <button
@ -805,52 +697,16 @@
<!-- 操作日志 --> <!-- 操作日志 -->
<div v-if="activeTab === 'operationLog'" class="tab-panel operation-log-info"> <div v-if="activeTab === 'operationLog'" class="tab-panel operation-log-info">
<a-spin :loading="operationLogLoading" class="operation-log-spin"> <a-spin :loading="operationLogLoading" class="operation-log-spin">
<div v-if="operationLogList.length" class="operation-log-list"> <a-table
<div v-if="operationLogList.length"
v-for="item in operationLogList" class="operation-log-table"
:key="item.id" row-key="id"
class="operation-log-item" size="small"
> :pagination="false"
<div class="operation-log-head" @click="toggleOperationLogExpand(item.id)"> :bordered="{ cell: true }"
<div class="operation-log-head-main"> :columns="operationLogColumns"
<span class="operation-log-action">{{ item.actionName || item.action }}</span> :data="operationLogTableData"
<a-tag v-if="item.changes?.length" size="small" color="orangered">
{{ item.changes.length }}
</a-tag>
</div>
<div class="operation-log-head-meta">
<span>{{ item.operatorName || $t('order.myOrders.drawer.operationLog.operator') }}</span>
<span>{{ formatOperationLogTime(item.createTime) }}</span>
<IconDown v-if="!isOperationLogExpanded(item.id)" />
<IconUp v-else />
</div>
</div>
<div v-if="isOperationLogExpanded(item.id)" class="operation-log-body">
<p v-if="item.remark" class="operation-log-remark">{{ item.remark }}</p>
<div v-if="item.changes?.length" class="operation-log-changes">
<div class="operation-log-changes-header">
<span>{{ $t('order.myOrders.drawer.operationLog.field') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.before') }}</span>
<span>{{ $t('order.myOrders.drawer.operationLog.after') }}</span>
</div>
<div
v-for="(change, idx) in item.changes"
:key="`${item.id}-${change.path}-${idx}`"
class="operation-log-changes-row"
>
<span>{{ change.label || change.path }}</span>
<span>{{ change.before ?? '--' }}</span>
<span class="operation-log-after">{{ change.after ?? '--' }}</span>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.operationLog.noChanges')"
:image-style="{ height: '40px' }"
/> />
</div>
</div>
</div>
<a-empty <a-empty
v-else-if="!operationLogLoading" v-else-if="!operationLogLoading"
:description="$t('order.myOrders.drawer.operationLog.empty')" :description="$t('order.myOrders.drawer.operationLog.empty')"
@ -859,6 +715,120 @@
</a-spin> </a-spin>
</div> </div>
</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> </div>
</DrawerPro> </DrawerPro>
@ -991,11 +961,12 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; 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 useDrawer from '@/components/DrawerPro/useDrawer';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder'; import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { import {
@ -1137,7 +1108,7 @@ interface FormFieldDef {
const orderEditFieldDefs: FormFieldDef[] = [ const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' }, { key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' }, { key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' }, { key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{ {
key: 'cargoAttribute', key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute', labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -1188,7 +1159,7 @@ function getRecipientFormKey(key: string) {
const basicEditForm = reactive({ const basicEditForm = reactive({
clientNo: '', clientNo: '',
trackNo: '', trackNo: '',
channelCode: '', downstreamServiceNo: '',
cargoAttribute: 'general', cargoAttribute: 'general',
isCod: false, isCod: false,
codSum: undefined as number | undefined, codSum: undefined as number | undefined,
@ -1376,6 +1347,17 @@ function resetEditState() {
const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer(); const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const activeTab = ref('basic'); 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(() => [ const tabList = computed(() => [
{ key: 'basic', label: t('order.myOrders.drawer.tabs.basic') }, { key: 'basic', label: t('order.myOrders.drawer.tabs.basic') },
{ key: 'weight', label: t('order.myOrders.drawer.tabs.weight') }, { key: 'weight', label: t('order.myOrders.drawer.tabs.weight') },
@ -1389,7 +1371,40 @@ const tabList = computed(() => [
const operationLogList = ref<OrderOperationLogItem[]>([]); const operationLogList = ref<OrderOperationLogItem[]>([]);
const operationLogLoading = ref(false); 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(() => const slidingTabItems = computed(() =>
tabList.value.map((tab) => ({ key: tab.key, label: tab.label })), tabList.value.map((tab) => ({ key: tab.key, label: tab.label })),
@ -2376,7 +2391,7 @@ function fillBasicEditForm() {
if (!o?.id) return; if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? ''); basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.trackNo = String(o.trackNo ?? o.trackingNo ?? ''); 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.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod); basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined; basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -2428,10 +2443,22 @@ function fillBasicEditForm() {
declarationEditList.value = cloneDeclarationRows(declarationList.value); 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() { function startBasicEdit() {
fillBasicEditForm(); fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency); void ensureCurrencyOptions(basicEditForm.declaredCurrency);
basicEditing.value = true; basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
} }
function cancelBasicEdit() { function cancelBasicEdit() {
@ -2449,6 +2476,7 @@ function startWeightEdit() {
actualWeight: row.actualWeight != null ? Number(row.actualWeight) : undefined, actualWeight: row.actualWeight != null ? Number(row.actualWeight) : undefined,
})); }));
weightEditing.value = true; weightEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .weight-info');
} }
function cancelWeightEdit() { function cancelWeightEdit() {
@ -2465,7 +2493,7 @@ async function handleBasicEditSave() {
id, id,
clientNo: basicEditForm.clientNo, clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo, trackNo: basicEditForm.trackNo,
channelCode: basicEditForm.channelCode, downstreamServiceNo: basicEditForm.downstreamServiceNo,
cargoAttribute: basicEditForm.cargoAttribute, cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod, isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum, codSum: basicEditForm.codSum,
@ -2617,9 +2645,6 @@ async function loadOperationLogs() {
try { try {
const res = await getOrderOperationLogList(id); const res = await getOrderOperationLogList(id);
operationLogList.value = res.items || []; operationLogList.value = res.items || [];
operationLogExpandedIds.value = new Set(
operationLogList.value.slice(0, 3).map((x) => x.id),
);
} catch { } catch {
operationLogList.value = []; operationLogList.value = [];
} finally { } 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) { function formatOperationLogTime(value?: string) {
if (!value) return '--'; if (!value) return '--';
const d = new Date(value); const d = new Date(value);
@ -2740,43 +2754,82 @@ defineExpose({
min-width: 0; min-width: 0;
.tabs-slot { .tabs-slot {
flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
} }
.drawer-tab-toolbar { .drawer-action-bar {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px;
flex-shrink: 0; flex-shrink: 0;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
background: #fff;
}
.toolbar-left { .drawer-action-bar__meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} gap: 8px;
min-width: 0;
flex: 1;
}
.toolbar-meta { .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; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 6px;
margin-right: 4px; }
}
.toolbar-meta-label { .action-bar-meta-label {
font-size: 13px; font-size: 13px;
color: var(--color-text-3); color: var(--color-text-3);
} }
.toolbar-meta-value { .action-bar-meta-value {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--color-text-1); 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);
}
: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); color: var(--color-text-3);
} }
.fee-additional-services__edit {
margin-left: auto;
}
.fee-snapshot-versions { .fee-snapshot-versions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -3849,102 +3898,35 @@ defineExpose({
} }
} }
.operation-log-spin { .operation-log-info {
width: 100%;
}
.operation-log-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; min-height: 0;
height: 100%;
} }
.operation-log-item { .operation-log-spin {
border: 1px solid var(--color-border-2); width: 100%;
border-radius: 4px; flex: 1;
background: #fff; min-height: 0;
overflow: hidden;
}
.operation-log-head { :deep(.arco-spin-children) {
display: flex; height: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
user-select: none;
&:hover {
background: var(--color-fill-1);
} }
} }
.operation-log-head-main { .operation-log-table {
display: flex; width: 100%;
align-items: center;
gap: 8px;
min-width: 0;
}
.operation-log-action { :deep(.arco-table-th) {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.operation-log-head-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
}
.operation-log-body {
padding: 0 12px 12px;
border-top: 1px solid var(--color-border-2);
}
.operation-log-remark {
margin: 10px 0 0;
font-size: 12px;
color: var(--color-text-3);
word-break: break-word;
}
.operation-log-changes {
margin-top: 10px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
}
.operation-log-changes-header,
.operation-log-changes-row {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.operation-log-changes-header {
background: var(--color-fill-2); background: var(--color-fill-2);
color: var(--color-text-3);
font-weight: 500; font-weight: 500;
} }
.operation-log-changes-row { :deep(.arco-table-td) {
color: var(--color-text-2); font-size: 13px;
border-top: 1px solid var(--color-border-2); vertical-align: top;
word-break: break-all; }
}
.operation-log-after {
color: var(--app-primary-color);
font-weight: 500;
} }
.additional-services-hint { .additional-services-hint {