feat: 接口参数复制带注释 JSON,下单自寄到仓展示仓库地址
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s

便于调试时一键复制 Body/Response 参数树,并让客户在自寄到仓时快速查看与复制仓库收货信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-16 15:57:07 +08:00
parent a5ef7e542d
commit 2b3797d548
8 changed files with 716 additions and 15 deletions

View File

@ -42,6 +42,8 @@
} from '@/utils/interfaceParameterModel';
import {
applyJsonCommentsToExistingRows,
buildCommentedJsonFromKvRows,
buildCommentedJsonFromParameterFlatList,
defaultResolveParameterType,
extractCommentsFromJson,
flatParameterListToTableRows,
@ -2691,16 +2693,11 @@
dataList1.value = undefined;
}
// 复制 cURL tab 输入框内容到剪贴板
async function copyRequestExampleContent() {
const content = requestExample.value || '';
if (!content.trim()) {
Message.warning('暂无内容可复制');
return;
}
async function writeTextToClipboard(content: string) {
try {
await navigator.clipboard.writeText(content);
Message.success('已复制到剪贴板');
return true;
} catch {
const textArea = document.createElement('textarea');
textArea.value = content;
@ -2711,12 +2708,66 @@
try {
document.execCommand('copy');
Message.success('已复制到剪贴板');
return true;
} catch {
Message.error('复制失败');
}
return false;
} finally {
document.body.removeChild(textArea);
}
}
}
// 复制 cURL tab 输入框内容到剪贴板
async function copyRequestExampleContent() {
const content = requestExample.value || '';
if (!content.trim()) {
Message.warning('暂无内容可复制');
return;
}
await writeTextToClipboard(content);
}
/** 复制当前 Body 参数列表为带 // 描述的格式化 JSON */
async function copyBodyParamsAsCommentedJson() {
if (isNoneBodyContentType.value) {
Message.warning('当前请求类型为 none,无 Body 参数可复制');
return;
}
let content = '';
if (isBodyKvMode.value) {
const rows = getTableFullData(bodyFormTableRef, bodyFormTableProps.data);
content = buildCommentedJsonFromKvRows(rows);
} else {
const rows = getRequestBodyFlatParams();
content = buildCommentedJsonFromParameterFlatList(rows, {
isRootRow: isBodyRootRow,
});
}
const compact = content.replace(/\s+/g, '');
if (!compact || compact === '{}' || compact === '[]') {
Message.warning('暂无参数可复制');
return;
}
await writeTextToClipboard(content);
}
/** 复制当前 Response Body 参数列表为带 // 描述的格式化 JSON */
async function copyResponseParamsAsCommentedJson() {
const fromGrid =
responseTableRef.value?.getTableData?.()?.fullData
|| responseTableRef.value?.xGrid?.getTableData()?.fullData;
const rows = getTreeTableFlatRows(fromGrid, responseTableProps.data);
const content = buildCommentedJsonFromParameterFlatList(rows);
const compact = content.replace(/\s+/g, '');
if (!compact || compact === '{}' || compact === '[]') {
Message.warning('暂无参数可复制');
return;
}
await writeTextToClipboard(content);
}
// 取消 Request 参数解析
function cancelClick() {
@ -3656,6 +3707,14 @@
aria-label="Request content type"
@change="updateContentType"
/>
<a-button
size="mini"
class="content-type-copy-btn"
title="复制当前参数为带描述的 JSON"
@click="copyBodyParamsAsCommentedJson"
>
复制
</a-button>
</div>
<!-- Query tab 内容 -->
<div v-show="requestTab === 'query'" class="query-tab-pane">
@ -4428,6 +4487,14 @@
aria-label="Response content type"
@change="updateResponseContentType"
/>
<a-button
size="mini"
class="content-type-copy-btn"
title="复制当前参数为带描述的 JSON"
@click="copyResponseParamsAsCommentedJson"
>
复制
</a-button>
</div>
<!-- Response body tab 内容 -->
<div v-show="responseTab === 'body'" class="table-content">
@ -5149,7 +5216,7 @@
display: flex;
align-items: center;
gap: var(--app-button-gap, 8px);
padding: 0 0 10px var(--vxe-table-cell-padding-left, 10px);
padding: 0 var(--vxe-table-cell-padding-left, 10px) 10px;
min-height: 28px;
box-sizing: border-box;
background: transparent;
@ -5159,6 +5226,14 @@
.content-type-tabs {
flex-shrink: 0;
}
.content-type-copy-btn {
margin-left: auto;
flex-shrink: 0;
height: 24px;
padding: 0 10px;
border-radius: 4px;
}
}
/* Apifox 式虚拟根节点(仅 Json/Xml 展示,导出剥离) */

View File

@ -2425,6 +2425,19 @@ export default {
'For multiple shipments, select waybills in Waybill Manage to create pickups in bulk',
'order.placeOrder.single.doorPickupBatchHintCustomer':
'For multiple shipments, select orders in My Orders to create pickups in bulk',
'order.placeOrder.single.selfShipAddressTitle': 'Ship to warehouse',
'order.placeOrder.single.selfShipRecipient': 'Recipient',
'order.placeOrder.single.selfShipPhone': 'Phone',
'order.placeOrder.single.selfShipAddress': 'Address',
'order.placeOrder.single.selfShipClientNo': 'Client No.',
'order.placeOrder.single.selfShipCopy': 'Copy',
'order.placeOrder.single.selfShipCopySuccess': 'Warehouse shipping info copied',
'order.placeOrder.single.selfShipCopyFailed': 'Copy failed',
'order.placeOrder.single.selfShipSelectWarehouse': 'Select a warehouse above first',
'order.placeOrder.single.selfShipAddressEmpty':
'This warehouse has no shipping address yet. Contact support or choose another warehouse.',
'order.placeOrder.single.selfShipAddressHint':
'Ship to this address yourself. Put the client number on the courier label so the warehouse can match it.',
'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit',
'order.placeOrder.single.submitReady': 'Ready to submit',
'order.placeOrder.contact.blockTitle': 'Complete your contact info',

View File

@ -2318,6 +2318,18 @@ export default {
'多票上门揽收可在「运单管理」中勾选运单后批量创建揽收单',
'order.placeOrder.single.doorPickupBatchHintCustomer':
'多票上门揽收可在「我的订单」中勾选运单后批量创建揽收单',
'order.placeOrder.single.selfShipAddressTitle': '寄往仓库',
'order.placeOrder.single.selfShipRecipient': '收件人',
'order.placeOrder.single.selfShipPhone': '电话',
'order.placeOrder.single.selfShipAddress': '地址',
'order.placeOrder.single.selfShipClientNo': '客户单号',
'order.placeOrder.single.selfShipCopy': '复制',
'order.placeOrder.single.selfShipCopySuccess': '仓库寄件信息已复制',
'order.placeOrder.single.selfShipCopyFailed': '复制失败',
'order.placeOrder.single.selfShipSelectWarehouse': '请先选择上方仓库',
'order.placeOrder.single.selfShipAddressEmpty': '该仓库暂未配置收货地址,请联系客服或更换仓库',
'order.placeOrder.single.selfShipAddressHint':
'按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交',
'order.placeOrder.single.submitReady': '信息已完善,可提交下单',
'order.placeOrder.contact.blockTitle': '请先完善联系方式',

View File

@ -586,3 +586,233 @@ export function reorderJsonBodiesForMapping(
if (!best) return candidates;
return [best, ...candidates.filter((c) => c !== best)];
}
function escapeJsonComment(text: string): string {
return String(text || '')
.replace(/\r?\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function sampleLiteralByParameterType(type: unknown): string {
const t = Number(type);
if (t === ParameterType.Integer || t === ParameterType.Float) return '0';
if (t === ParameterType.Boolean) return 'false';
if (t === ParameterType.Object) return '{}';
if (t === ParameterType.Array) return '[]';
return '""';
}
function isTopLevelParameterPid(pId: unknown): boolean {
return pId == null || pId === '' || pId === 0 || pId === '0';
}
export interface BuildCommentedJsonOptions {
isRootRow?: (row: any) => boolean;
indentSize?: number;
}
/**
* 将参数树扁平列表导出为带 // 参数描述的 JSON(JSON5 风格,可再导入注释)。
* 叶子用类型占位值;Object/Array 按子节点展开;跳过虚拟根与 obj/val/array 占位名。
*/
export function buildCommentedJsonFromParameterFlatList(
rows: any[],
options?: BuildCommentedJsonOptions,
): string {
const list = (Array.isArray(rows) ? rows : []).filter(Boolean);
const isRootRow = options?.isRootRow ?? (() => false);
const indentSize = options?.indentSize ?? 2;
const pad = (depth: number) => ' '.repeat(Math.max(0, depth) * indentSize);
const byId = new Map<any, any>();
list.forEach((r) => {
if (r?.id != null) byId.set(r.id, r);
});
const childrenOf = new Map<any, any[]>();
const ensureBucket = (key: any) => {
if (!childrenOf.has(key)) childrenOf.set(key, []);
return childrenOf.get(key)!;
};
list.forEach((row) => {
if (isRootRow(row)) return;
const pId = row.pId ?? row.PId;
const parent = byId.get(pId);
const bucketKey =
isTopLevelParameterPid(pId) || (parent && isRootRow(parent)) ? 0 : pId;
ensureBucket(bucketKey).push(row);
});
const getChildren = (id: any) => childrenOf.get(id) || [];
function formatComment(desc: unknown): string {
const text = escapeJsonComment(String(desc ?? ''));
return text ? ` // ${text}` : '';
}
function renderObjectProps(nodes: any[], depth: number): string {
const props = nodes.filter((n) => {
const name = String(n?.name ?? '').trim();
return name && !SYNTHETIC_PARAM_NAMES.has(name);
});
if (!props.length) return `${pad(depth)}{}`;
const lines: string[] = [`${pad(depth)}{`];
props.forEach((node, index) => {
const comma = index < props.length - 1 ? ',' : '';
lines.push(...renderPropertyLines(node, depth + 1, comma));
});
lines.push(`${pad(depth)}}`);
return lines.join('\n');
}
function renderPropertyLines(node: any, depth: number, comma: string): string[] {
const name = String(node?.name ?? '').trim();
const key = JSON.stringify(name);
const comment = formatComment(node?.description);
const type = Number(node?.type);
const kids = getChildren(node.id);
const indent = pad(depth);
if (type === ParameterType.Object || (kids.length > 0 && type !== ParameterType.Array)) {
const innerProps = kids.filter((k) => {
const n = String(k?.name ?? '').trim();
return n && !SYNTHETIC_PARAM_NAMES.has(n);
});
if (!innerProps.length && type === ParameterType.Object) {
return [`${indent}${key}: {}${comma}${comment}`];
}
const lines: string[] = [`${indent}${key}: {${comment}`];
innerProps.forEach((child, index) => {
const childComma = index < innerProps.length - 1 ? ',' : '';
lines.push(...renderPropertyLines(child, depth + 1, childComma));
});
lines.push(`${indent}}${comma}`);
return lines;
}
if (type === ParameterType.Array) {
return renderArrayPropertyLines(node, depth, comma, key, comment, kids);
}
return [`${indent}${key}: ${sampleLiteralByParameterType(type)}${comma}${comment}`];
}
function renderArrayPropertyLines(
_node: any,
depth: number,
comma: string,
key: string,
comment: string,
kids: any[],
): string[] {
const indent = pad(depth);
const objChild = kids.find((k) => {
const n = String(k?.name ?? '').trim().toLowerCase();
return n === 'obj' || Number(k?.type) === ParameterType.Object;
});
const valChild = kids.find((k) => {
const n = String(k?.name ?? '').trim().toLowerCase();
return n === 'val';
});
if (objChild) {
const fields = getChildren(objChild.id).filter((k) => {
const n = String(k?.name ?? '').trim();
return n && !SYNTHETIC_PARAM_NAMES.has(n);
});
if (!fields.length) {
return [`${indent}${key}: [{}]${comma}${comment}`];
}
const lines: string[] = [`${indent}${key}: [${comment}`, `${pad(depth + 1)}{`];
fields.forEach((child, index) => {
const childComma = index < fields.length - 1 ? ',' : '';
lines.push(...renderPropertyLines(child, depth + 2, childComma));
});
lines.push(`${pad(depth + 1)}}`, `${indent}]${comma}`);
return lines;
}
if (valChild) {
const sample = sampleLiteralByParameterType(valChild.type);
return [`${indent}${key}: [${sample}]${comma}${comment}`];
}
const namedKids = kids.filter((k) => {
const n = String(k?.name ?? '').trim();
return n && !SYNTHETIC_PARAM_NAMES.has(n);
});
if (namedKids.length) {
const lines: string[] = [`${indent}${key}: [${comment}`, `${pad(depth + 1)}{`];
namedKids.forEach((child, index) => {
const childComma = index < namedKids.length - 1 ? ',' : '';
lines.push(...renderPropertyLines(child, depth + 2, childComma));
});
lines.push(`${pad(depth + 1)}}`, `${indent}]${comma}`);
return lines;
}
return [`${indent}${key}: []${comma}${comment}`];
}
const tops = getChildren(0);
if (!tops.length) return '{\n}\n';
if (tops.length === 1 && isSyntheticArrayWrapper(tops[0])) {
const wrapper = tops[0];
const kids = getChildren(wrapper.id);
const objChild = kids.find((k) => String(k?.name ?? '').trim().toLowerCase() === 'obj');
const valChild = kids.find((k) => String(k?.name ?? '').trim().toLowerCase() === 'val');
if (objChild) {
const fields = getChildren(objChild.id).filter((k) => {
const n = String(k?.name ?? '').trim();
return n && !SYNTHETIC_PARAM_NAMES.has(n);
});
if (!fields.length) return '[{}]\n';
return `[\n${renderObjectProps(fields, 1)}\n]\n`;
}
if (valChild) {
return `[${sampleLiteralByParameterType(valChild.type)}]\n`;
}
const namedKids = kids.filter((k) => {
const n = String(k?.name ?? '').trim();
return n && !SYNTHETIC_PARAM_NAMES.has(n);
});
if (namedKids.length) {
return `[\n${renderObjectProps(namedKids, 1)}\n]\n`;
}
return '[]\n';
}
return `${renderObjectProps(tops, 0)}\n`;
}
/** FormUrlEncoded / 扁平 KV 列表 → 带 // 描述的 JSON 对象 */
export function buildCommentedJsonFromKvRows(rows: any[]): string {
const items = (Array.isArray(rows) ? rows : []).filter((r) => {
const name = String(r?.name ?? '').trim();
return !!name;
});
if (!items.length) return '{\n}\n';
const lines: string[] = ['{'];
items.forEach((row, index) => {
const name = String(row.name).trim();
const rawValue = row.value;
const value =
rawValue == null || rawValue === ''
? '""'
: JSON.stringify(String(rawValue));
const desc = escapeJsonComment(String(row.description ?? ''));
const comma = index < items.length - 1 ? ',' : '';
const comment = desc ? ` // ${desc}` : '';
lines.push(` ${JSON.stringify(name)}: ${value}${comma}${comment}`);
});
lines.push('}');
return `${lines.join('\n')}\n`;
}

View File

@ -231,6 +231,45 @@
<p v-if="form.inboundMethod === 'doorPickup'" class="h5-po__hint">
{{ t('h5.placeOrder.inboundDoorPickupHint') }}
</p>
<div v-if="form.inboundMethod === 'selfShip'" class="h5-po__wh-card">
<div class="h5-po__wh-head">
<strong>
{{ t('h5.placeOrder.selfShipAddressTitle') }}
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
</strong>
<button
v-if="selectedWarehouseHasAddress"
type="button"
class="h5-po__wh-copy"
@click="copyWarehouseShipAddress"
>
{{ t('h5.placeOrder.selfShipCopy') }}
</button>
</div>
<p v-if="!selectedWarehouseOption" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipSelectWarehouse') }}
</p>
<template v-else-if="!selectedWarehouseHasAddress">
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressEmpty') }}</p>
</template>
<template v-else>
<p>
<span>{{ t('h5.placeOrder.selfShipRecipient') }}</span>
{{ selectedWarehouseOption.recipient || '-' }}
</p>
<p>
<span>{{ t('h5.placeOrder.selfShipPhone') }}</span>
{{ selectedWarehouseOption.contactPhone || '-' }}
</p>
<p class="is-addr">
<span>{{ t('h5.placeOrder.selfShipAddress') }}</span>
{{ selectedWarehouseOption.shippingAddress || '-' }}
</p>
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressHint') }}</p>
</template>
</div>
</div>
<template v-if="form.inboundMethod === 'doorPickup'">
@ -1062,7 +1101,15 @@ const steps = computed(() => [
const productOptions = ref<{ value: string; label: string; productId?: number }[]>([]);
const cargoOptions = ref<{ value: string; label: string }[]>([]);
const warehouseOptions = ref<{ value: string; label: string }[]>([]);
const warehouseOptions = ref<
{
value: string;
label: string;
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
}[]
>([]);
const countryOptions = ref<{ value: string; label: string }[]>([]);
const currencyOptions = ref<{ value: string; label: string }[]>([]);
const senderOptions = ref<{ id: number; label: string; data: Record<string, string> }[]>([]);
@ -1242,6 +1289,36 @@ const form = reactive({
},
});
const selectedWarehouseOption = computed(() => {
const id = String(form.warehouseId ?? '').trim();
if (!id) return undefined;
return warehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
async function copyWarehouseShipAddress() {
const opt = selectedWarehouseOption.value;
if (!opt) return;
const text = [
opt.label || '',
`${t('h5.placeOrder.selfShipRecipient')}:${opt.recipient || '-'}`,
`${t('h5.placeOrder.selfShipPhone')}:${opt.contactPhone || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${opt.shippingAddress || '-'}`,
]
.filter(Boolean)
.join('\n');
try {
await navigator.clipboard.writeText(text);
Message.success(t('h5.placeOrder.selfShipCopySuccess'));
} catch {
Message.error(t('h5.orders.copyFailed'));
}
}
function trim(v: unknown) {
return String(v ?? '').trim();
}
@ -2556,7 +2633,20 @@ async function loadOptions(
.map((row: any) => {
const id = row?.id ?? row?.Id;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { value: String(id ?? ''), label: name || String(id) };
const pick = (...keys: string[]) => {
for (const k of keys) {
const v = row?.[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
return {
value: String(id ?? ''),
label: name || String(id),
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
};
})
.filter((x) => x.value);
if (!opts?.skipDefaults && warehouseOptions.value.length && !form.warehouseId) {
@ -3030,6 +3120,50 @@ watch(
color: #86909c;
}
.h5-po__wh-card {
margin-top: 8px;
padding: 12px;
border-radius: 12px;
background: #fff;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 16%, #e5e6eb);
}
.h5-po__wh-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
font-size: 14px;
font-weight: 600;
}
}
.h5-po__wh-copy {
border: none;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 12px;
font-weight: 600;
height: 28px;
padding: 0 10px;
border-radius: 14px;
}
.h5-po__wh-card p {
margin: 8px 0 0;
font-size: 13px;
color: #1d2129;
line-height: 1.45;
span {
color: #86909c;
margin-right: 6px;
}
&.is-addr {
word-break: break-all;
}
}
.h5-po__more-panel {
display: flex;
flex-direction: column;

View File

@ -393,6 +393,17 @@ export default {
'h5.placeOrder.inboundDoorPickup': 'Door pickup',
'h5.placeOrder.inboundDoorPickupHint':
'Door pickup is a paid service. Please fill in the pickup address and expected time.',
'h5.placeOrder.selfShipAddressTitle': 'Ship to warehouse',
'h5.placeOrder.selfShipRecipient': 'Recipient',
'h5.placeOrder.selfShipPhone': 'Phone',
'h5.placeOrder.selfShipAddress': 'Address',
'h5.placeOrder.selfShipCopy': 'Copy',
'h5.placeOrder.selfShipCopySuccess': 'Warehouse shipping info copied',
'h5.placeOrder.selfShipSelectWarehouse': 'Select a warehouse first',
'h5.placeOrder.selfShipAddressEmpty':
'This warehouse has no shipping address yet. Contact support or choose another warehouse.',
'h5.placeOrder.selfShipAddressHint':
'Ship to this address yourself. Put the client number on the courier label so the warehouse can match it.',
'h5.placeOrder.pickupAddress': 'Pickup address',
'h5.placeOrder.pickupAddressPh': 'Select a saved sender address',
'h5.placeOrder.expectPickupTime': 'Expected pickup time',

View File

@ -391,6 +391,15 @@ export default {
'h5.placeOrder.inboundSelfShip': '自寄到仓',
'h5.placeOrder.inboundDoorPickup': '上门揽收',
'h5.placeOrder.inboundDoorPickupHint': '上门揽收为付费服务,请填写取件地址与期望揽收时间。',
'h5.placeOrder.selfShipAddressTitle': '寄往仓库',
'h5.placeOrder.selfShipRecipient': '收件人',
'h5.placeOrder.selfShipPhone': '电话',
'h5.placeOrder.selfShipAddress': '地址',
'h5.placeOrder.selfShipCopy': '复制',
'h5.placeOrder.selfShipCopySuccess': '仓库寄件信息已复制',
'h5.placeOrder.selfShipSelectWarehouse': '请先选择仓库',
'h5.placeOrder.selfShipAddressEmpty': '该仓库暂未配置收货地址,请联系客服或更换仓库',
'h5.placeOrder.selfShipAddressHint': '按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'h5.placeOrder.pickupAddress': '取件地址',
'h5.placeOrder.pickupAddressPh': '请选择发件地址簿',
'h5.placeOrder.expectPickupTime': '期望揽收时间',

View File

@ -8,6 +8,7 @@
IconEdit,
IconQuestionCircle,
IconCheck,
IconCopy,
} from '@arco-design/web-vue/es/icon';
import SlidingTabs from '@/components/SlidingTabs/index.vue';
import ExcelFileIcon from '@/components/icons/ExcelFileIcon.vue';
@ -311,7 +312,12 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const singleDomesticPickupProductOptions = ref<Array<{ value: string; label: string }>>([]);
const singleCargoAttributeOptions = ref<SingleSelectOption[]>([]);
const singleExportCustomsModeOptions = ref<SingleSelectOption[]>([]);
const singleWarehouseOptions = ref<SingleSelectOption[]>([]);
type WarehouseSelectOption = SingleSelectOption & {
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
};
const singleWarehouseOptions = ref<WarehouseSelectOption[]>([]);
const singleCurrencyOptions = ref<SingleSelectOption[]>([]);
const singleCountryOptions = ref<SingleSelectOption[]>([]);
@ -346,7 +352,20 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
function normalizeWarehouseRow(row: any) {
const id = row?.id ?? row?.Id;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { id: id != null && id !== '' ? String(id) : '', name };
const pick = (...keys: string[]) => {
for (const k of keys) {
const v = row?.[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
return {
id: id != null && id !== '' ? String(id) : '',
name,
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
};
}
function normalizeCountryRow(row: any) {
@ -1999,6 +2018,60 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
],
});
const selectedWarehouseOption = computed(() => {
const id = String(singleForm.warehouseId ?? '').trim();
if (!id) return undefined;
return singleWarehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
function buildWarehouseShipCopyText() {
const opt = selectedWarehouseOption.value;
if (!opt) return '';
const lines = [
opt.label || '',
`收件人:${opt.recipient || '-'}`,
`电话:${opt.contactPhone || '-'}`,
`地址:${opt.shippingAddress || '-'}`,
];
const clientNo = String(singleForm.clientNo ?? '').trim();
if (clientNo) {
lines.push(`客户单号:${clientNo}`);
}
return lines.filter(Boolean).join('\n');
}
async function copyWarehouseShipAddress() {
const text = buildWarehouseShipCopyText();
if (!text.trim()) {
Message.warning('该仓库暂未配置收货地址');
return;
}
try {
await navigator.clipboard.writeText(text);
Message.success('仓库寄件信息已复制');
} catch {
const area = document.createElement('textarea');
area.value = text;
area.style.position = 'fixed';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
try {
document.execCommand('copy');
Message.success('仓库寄件信息已复制');
} catch {
Message.error('复制失败');
}
document.body.removeChild(area);
}
}
function resolveDefaultDeclaredCurrency() {
const opts = singleCurrencyOptions.value;
const usd = opts.find((o) => String(o.value).trim().toUpperCase() === 'USD');
@ -2247,7 +2320,13 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const whOpts = (warehouseRes.items ?? [])
.map(normalizeWarehouseRow)
.filter((w) => w.id)
.map((w) => ({ value: w.id, label: w.name || w.id }));
.map((w) => ({
value: w.id,
label: w.name || w.id,
recipient: w.recipient,
contactPhone: w.contactPhone,
shippingAddress: w.shippingAddress,
}));
singleWarehouseOptions.value = whOpts;
if (whOpts.length && !String(singleForm.warehouseId ?? '').trim()) {
singleForm.warehouseId = whOpts[0].value;
@ -3616,7 +3695,71 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
size="small"
class="inbound-method-radios"
>
<a-radio value="selfShip">自寄到仓</a-radio>
<a-radio value="selfShip">
<span class="inbound-method-pickup-label">
自寄到仓
<a-popover
position="bl"
trigger="hover"
content-class="warehouse-ship-popover"
>
<IconQuestionCircle
class="inbound-method-help"
@click.stop
/>
<template #content>
<div class="warehouse-ship-popover__body">
<div class="warehouse-ship-popover__head">
<strong>
寄往仓库
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
</strong>
<a-button
v-if="selectedWarehouseHasAddress"
type="text"
size="mini"
class="warehouse-ship-popover__copy"
@click="copyWarehouseShipAddress"
>
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<p v-if="!selectedWarehouseOption" class="warehouse-ship-popover__empty">
请先选择上方仓库
</p>
<p
v-else-if="!selectedWarehouseHasAddress"
class="warehouse-ship-popover__empty"
>
该仓库暂未配置收货地址,请联系客服或更换仓库
</p>
<template v-else>
<dl class="warehouse-ship-popover__rows">
<div>
<dt>收件人</dt>
<dd>{{ selectedWarehouseOption.recipient || '-' }}</dd>
</div>
<div>
<dt>电话</dt>
<dd>{{ selectedWarehouseOption.contactPhone || '-' }}</dd>
</div>
<div class="is-addr">
<dt>地址</dt>
<dd>{{ selectedWarehouseOption.shippingAddress || '-' }}</dd>
</div>
</dl>
<p class="warehouse-ship-popover__hint">
按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。
</p>
</template>
</div>
</template>
</a-popover>
</span>
</a-radio>
<a-radio value="doorPickup">
<span class="inbound-method-pickup-label">
上门揽收
@ -6836,3 +6979,77 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
}
</style>
<style lang="less">
.warehouse-ship-popover {
max-width: 360px;
padding: 0;
.arco-popover-content {
padding: 12px;
}
}
.warehouse-ship-popover__body {
min-width: 240px;
max-width: 320px;
}
.warehouse-ship-popover__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
line-height: 22px;
}
}
.warehouse-ship-popover__copy {
height: 24px;
padding: 0 8px;
color: var(--app-primary-color);
flex-shrink: 0;
}
.warehouse-ship-popover__rows {
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
> div {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
dt {
margin: 0;
font-size: 12px;
color: var(--color-text-3);
line-height: 20px;
}
dd {
margin: 0;
font-size: 13px;
color: var(--color-text-1);
line-height: 20px;
word-break: break-all;
}
}
.warehouse-ship-popover__hint,
.warehouse-ship-popover__empty {
margin: 8px 0 0;
font-size: 12px;
line-height: 18px;
color: var(--color-text-3);
}
</style>