front/src/views/order/placeOrder/index.vue
2026-09-23 11:29:53 +08:00

7066 lines
231 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts" setup>
import { ref, computed, onMounted, reactive, nextTick, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
IconExport,
IconDelete,
IconPlus,
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';
import CustomTemplateModal from './components/CustomTemplateModal.vue';
import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vue';
import FormLabelWithPreview from '@/views/channel/salesProducts/components/FormLabelWithPreview.vue';
import CustomerGuideDrawer from '@/views/order/priceCheck/components/CustomerGuideDrawer.vue';
import ExportCustoms9610GuideModal from './components/ExportCustoms9610GuideModal.vue';
import { Message, Modal } from '@arco-design/web-vue';
import { useI18n } from 'vue-i18n';
import * as XLSX from 'xlsx';
import {
getOrderTemplateList,
postOrderParcelCreate,
postOrderDelete,
downloadOrderTemplate,
deleteOrderTemplateCustom,
postOrderUpload,
getOrderBatchJob,
postOrderReceiverParseAddress,
} from '@/api/order';
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
import {
providerModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { mapWarehouseSkuToDeclarationFields, normalizeListRow, normalizeSkuImageForStorage, pickField } from '@/utils/warehouseListMapper';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel';
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate';
import {
buildParcelCreateFailGuide,
type ParcelCreateFailGuide,
} from '@/utils/parcelCreateFailGuide';
import { openAskAdvisor } from '@/composables/useAskAdvisor';
import CompleteContactModal from '@/components/CompleteContactModal/index.vue';
import type { CustomerProfileCompleteness } from '@/api/customer';
defineOptions({
name: 'PlaceOrder',
});
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const rbacUserStore = useRbacUserStore();
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
customerLoading,
withCustomerId,
ensureMutationCustomerId,
resolveSelectedCustomerId,
} = useWarehouseAdminCustomer();
const adminCustomerSelected = computed(
() => !isAdmin.value || resolveSelectedCustomerId() != null,
);
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const contactBlocked = ref(false);
const activeOrderMode = ref<'batch' | 'single'>('single');
const orderModeTabItems = computed(() => [
{ key: 'single', label: t('order.placeOrder.singleOrder') },
{ key: 'batch', label: t('order.placeOrder.batchOrder') },
]);
const uploadDragActive = ref(false);
const priceCheckHandoff = ref<{
visible: boolean;
filled: string[];
pending: string[];
quoteLevel?: string;
disclaimers?: string[];
} | null>(null);
const showDeclarationExtra = ref(false);
const declarationSkuModalVisible = ref(false);
/** 提交失败后展示字段级红框;修正后实时清除对应错误 */
const singleShowFieldErrors = ref(false);
/** 收件人选填字段默认折叠 */
const showRecipientOptional = ref(false);
const savingReceiverAddress = ref(false);
/** 粘贴地址 → AI 解析 */
const RECEIVER_AI_PREFS_KEY = 'oms.placeOrder.receiverAi';
const RECEIVER_AI_SCENE = 'receiverAddressParse';
const RECEIVER_AI_DEFAULT_SKILL_KEY = 'oms-receiver-address-parse';
const receiverPasteText = ref('');
const receiverParseLoading = ref(false);
const receiverPasteFocused = ref(false);
const exportCustomsGuideVisible = ref(false);
const receiverPasteDragging = ref(false);
const receiverParseTip = ref<{
tone: 'ok' | 'warn' | 'danger';
text: string;
} | null>(null);
const receiverAiProviders = ref<AiProviderItem[]>([]);
const receiverAiSkills = ref<AiSkillItem[]>([]);
const receiverAiProviderKey = ref('');
const receiverAiModelId = ref('');
const receiverAiSkillKey = ref(RECEIVER_AI_DEFAULT_SKILL_KEY);
const receiverAiProvidersLoading = ref(false);
const receiverAiSkillsLoading = ref(false);
const receiverAiEnabledProviders = computed(() =>
receiverAiProviders.value.filter((p) => p.enabled && Boolean(p.apiKey)),
);
const receiverAiSelectedProvider = computed(
() =>
receiverAiEnabledProviders.value.find(
(p) => p.providerKey === receiverAiProviderKey.value,
) ?? null,
);
const receiverAiModelOptions = computed(() => {
const p = receiverAiSelectedProvider.value;
if (!p) return [];
const tags = providerModelTags(p.providerType);
const extra = String(p.apiSecret || '').trim();
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }];
}
return tags;
});
/** 申报表固定高度(框内滚动,不支持拖拽调高) */
const DECLARATION_PANEL_HEIGHT = 280;
interface TemplateRow {
id: number;
name: string;
updateTime: string;
type?: string;
templateKey?: string;
mappingStatus?: string;
sourceFileName?: string;
}
const templateModalVisible = ref(false);
const templateModalTemplateId = ref(0);
const standardTemplates = ref<TemplateRow[]>([]);
const customTemplates = ref<TemplateRow[]>([]);
const templatePanelLoading = ref(false);
const selectedTemplate = ref<number | undefined>(undefined);
const selectedTemplateRow = computed(() => {
const id = selectedTemplate.value;
if (id == null) return null;
return [...standardTemplates.value, ...customTemplates.value].find((x) => x.id === id) ?? null;
});
const displayedStandardTemplates = computed<TemplateRow[]>(() => standardTemplates.value);
const DEDICATED_SIMPLE_TEMPLATE_KEY = 'dedicated_small_parcel_simple_v1';
const DEDICATED_STANDARD_TEMPLATE_KEY = 'dedicated_small_parcel_v1';
function sortStandardTemplates(list: TemplateRow[]) {
return [...list].sort((a, b) => {
const aKey = String(a.templateKey ?? '').trim().toLowerCase();
const bKey = String(b.templateKey ?? '').trim().toLowerCase();
const aSimple = aKey === DEDICATED_SIMPLE_TEMPLATE_KEY ? 0 : 1;
const bSimple = bKey === DEDICATED_SIMPLE_TEMPLATE_KEY ? 0 : 1;
if (aSimple !== bSimple) return aSimple - bSimple;
return String(a.name ?? '').localeCompare(String(b.name ?? ''));
});
}
function pickDefaultStandardTemplateId(list: TemplateRow[]) {
const simple = list.find(
(x) => String(x.templateKey ?? '').trim().toLowerCase() === DEDICATED_SIMPLE_TEMPLATE_KEY,
);
return simple?.id ?? list[0]?.id;
}
const standardTemplateMappingOptions = computed(() => {
const list = standardTemplates.value.map((item) => ({
templateKey: item.templateKey || DEDICATED_STANDARD_TEMPLATE_KEY,
label: item.name || item.templateKey || '标准模板',
id: item.id,
}));
return list.length
? list
: [
{ templateKey: DEDICATED_SIMPLE_TEMPLATE_KEY, label: '专线小包模板(精简版)', id: 0 },
{ templateKey: DEDICATED_STANDARD_TEMPLATE_KEY, label: '专线小包模板(标准版)', id: 0 },
];
});
const isSelectedStandardBackendTemplate = computed(() => selectedTemplateRow.value?.type === 'standard');
function normalizeTemplateType(rawType: unknown): 'standard' | 'custom' | '' {
const text = String(rawType ?? '').trim().toLowerCase();
if (!text) return '';
if (text === 'standard' || text === '0' || text === 'system') return 'standard';
if (text === 'custom' || text === '1' || text === 'user') return 'custom';
if (text.includes('standard') || text.includes('std') || text.includes('标准')) return 'standard';
if (text.includes('custom') || text.includes('自定义')) return 'custom';
return '';
}
function resolveTemplateType(raw: any): 'standard' | 'custom' {
const explicit = normalizeTemplateType(raw.type ?? raw.Type);
if (explicit === 'standard' || explicit === 'custom') {
return explicit;
}
const templateKey = String(raw.templateKey ?? raw.TemplateKey ?? '').trim().toLowerCase();
if (templateKey.startsWith('custom_')) {
return 'custom';
}
if (templateKey === DEDICATED_SIMPLE_TEMPLATE_KEY
|| templateKey === DEDICATED_STANDARD_TEMPLATE_KEY
|| templateKey.startsWith('std_')) {
return 'standard';
}
// 自定义模板可能带 standardTemplateKey,不能据此判为标准模板
return 'custom';
}
function mapTemplateItem(raw: any): TemplateRow {
const type = resolveTemplateType(raw);
const rawId = raw.id ?? raw.Id ?? raw.templateId ?? raw.TemplateId;
const id = Number(rawId);
return {
id,
name: String(raw.name ?? raw.Name ?? raw.templateKey ?? raw.TemplateKey ?? ''),
updateTime: String(raw.updateTime ?? raw.UpdateTime ?? raw.createTime ?? ''),
type,
templateKey: raw.templateKey ?? raw.TemplateKey,
mappingStatus: String(raw.mappingStatus ?? raw.MappingStatus ?? (type === 'standard' ? 'ready' : 'draft')),
sourceFileName: raw.sourceFileName ?? raw.SourceFileName,
};
}
function dedupeTemplates(list: TemplateRow[]) {
const map = new Map<number, TemplateRow>();
list.forEach((item) => {
if (!Number.isFinite(item.id)) return;
map.set(item.id, item);
});
return Array.from(map.values());
}
/** 标准模板按 templateKey 去重,避免种子+自动补齐重复展示 */
function dedupeStandardTemplates(list: TemplateRow[]) {
const byKey = new Map<string, TemplateRow>();
list.forEach((item) => {
const key = String(item.templateKey ?? `id:${item.id}`).trim().toLowerCase();
const prev = byKey.get(key);
if (!prev || item.id > prev.id) {
byKey.set(key, item);
}
});
return Array.from(byKey.values());
}
async function loadTemplates() {
templatePanelLoading.value = true;
try {
const listRes = await getOrderTemplateList({ page: 1, pageSize: 200, type: '' });
const allList = (listRes.items || []).map(mapTemplateItem).filter((item) => Number.isFinite(item.id));
const stdList = allList.filter((item) => item.type === 'standard');
const custList = allList.filter((item) => item.type === 'custom');
standardTemplates.value = sortStandardTemplates(dedupeStandardTemplates(stdList));
customTemplates.value = dedupeTemplates(custList);
const merged = [...standardTemplates.value, ...customTemplates.value];
if (merged.length && (selectedTemplate.value == null || !merged.some((x) => x.id === selectedTemplate.value))) {
selectedTemplate.value = pickDefaultStandardTemplateId(standardTemplates.value) ?? merged[0].id;
}
} catch (e: any) {
Message.error(e?.message || '加载模板列表失败');
} finally {
templatePanelLoading.value = false;
}
}
type SingleSelectOption = { value: string; label: string };
type SingleProductOption = SingleSelectOption & {
productId?: number;
productName?: string;
additionalServices?: string[];
};
/** 9610简易申报默认字典编码(字典 export_customs_mode) */
const SAMPLE_EXPORT_CUSTOMS_MODE_CODE = '9610Simple';
const singleReferenceLoading = ref(false);
const singleSalesProductOptions = ref<SingleProductOption[]>([]);
const singleDomesticPickupProductOptions = ref<Array<{ value: string; label: string }>>([]);
const singleCargoAttributeOptions = ref<SingleSelectOption[]>([]);
const singleExportCustomsModeOptions = ref<SingleSelectOption[]>([]);
type WarehouseSelectOption = SingleSelectOption & {
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
};
const singleWarehouseOptions = ref<WarehouseSelectOption[]>([]);
const singleCurrencyOptions = ref<SingleSelectOption[]>([]);
const singleCountryOptions = ref<SingleSelectOption[]>([]);
function normalizeProductRow(row: any) {
const code = String(row?.productCode ?? row?.ProductCode ?? '').trim();
const name = String(row?.productName ?? row?.ProductName ?? '').trim();
const id = Number(row?.id ?? row?.Id ?? 0);
const additionalServices = parseAdditionalServiceCodes(
row?.additionalServices ?? row?.AdditionalServices,
);
return {
code,
name,
productId: id > 0 ? id : undefined,
additionalServices,
};
}
function normalizeCargoDictRow(row: any) {
const code = String(row?.dictCode ?? row?.DictCode ?? '').trim();
const name = String(row?.dictName ?? row?.DictName ?? '').trim();
const sort = Number(row?.dictSort ?? row?.DictSort ?? 0);
return { code, name, sort: Number.isFinite(sort) ? sort : 0 };
}
function normalizeCurrencyDictRow(row: any) {
const code = String(row?.dictCode ?? row?.DictCode ?? '').trim();
const sort = Number(row?.dictSort ?? row?.DictSort ?? 0);
return { code, sort: Number.isFinite(sort) ? sort : 0 };
}
function normalizeWarehouseRow(row: any) {
const id = row?.id ?? row?.Id;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
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) {
const code = String(row?.countryCode ?? row?.CountryCode ?? '').trim().toUpperCase();
const name = String(row?.chineseName ?? row?.ChineseName ?? '').trim();
return { code, name };
}
onMounted(() => {
loadTemplates();
});
const pendingUploadFile = ref<File | null>(null);
const nativeFileInputRef = ref<HTMLInputElement | null>(null);
const orderTemplateFields: Array<{ key: string; desc: string; example: string }> = [
{ key: 'requestId', desc: '幂等请求ID(可选)', example: '' },
{ key: 'clientNo', desc: '客户单号', example: 'C202604230001' },
{ key: 'productCode', desc: '运输方式(产品代码)', example: 'PROD001' },
{ key: 'cargoAttribute', desc: '货物属性(字典cargo_attribute)', example: 'general' },
{
key: 'exportCustomsMode',
desc: '出口报关方式(字典export_customs_mode,不填默认9610简易申报)',
example: '9610Simple',
},
{ key: 'warehouseId', desc: '仓库', example: '1' },
{ key: 'length', desc: '长(cm)', example: '10' },
{ key: 'width', desc: '宽(cm)', example: '10' },
{ key: 'height', desc: '高(cm)', example: '10' },
{ key: 'actualWeight', desc: '实重(kg)', example: '0.5' },
{ key: 'isCod', desc: '是否COD(true/false)', example: 'false' },
{ key: 'codCurrency', desc: 'COD币种', example: 'USD' },
{ key: 'codSum', desc: 'COD金额', example: '0' },
{ key: 'quantity', desc: '包裹数量', example: '1' },
{ key: 'receiver.recipientsName', desc: '收件人名称', example: 'Tom' },
{ key: 'receiver.companyName', desc: '收件人公司', example: '' },
{ key: 'receiver.phone', desc: '收件人手机', example: '13800138000' },
{ key: 'receiver.telePhone', desc: '收件人电话', example: '' },
{ key: 'receiver.email', desc: '收件人邮箱', example: 'tom@example.com' },
{ key: 'receiver.countryCode', desc: '收件国家代码', example: 'CN' },
{ key: 'receiver.province', desc: '收件省/州', example: 'Guangdong' },
{ key: 'receiver.city', desc: '收件城市', example: 'Shenzhen' },
{ key: 'receiver.streetNumber', desc: '收件人门牌号', example: '1001' },
{ key: 'receiver.streetAddress1', desc: '收件地址1', example: 'Nanshan District' },
{ key: 'receiver.streetAddress2', desc: '收件地址2', example: '' },
{ key: 'receiver.streetAddress3', desc: '收件地址3', example: '' },
{ key: 'receiver.zipCode', desc: '收件邮编', example: '518000' },
{ key: 'receiver.taxType', desc: '税号类型', example: '0' },
{ key: 'receiver.taxNo', desc: '税号', example: '' },
{ key: 'receiver.certificateType', desc: '证件类型', example: '0' },
{ key: 'receiver.certificateNo', desc: '证件号码', example: '' },
{ key: 'receiver.eori', desc: 'EORI', example: '' },
{ key: 'receiver.vat', desc: 'VAT', example: '' },
{ key: 'sender.senderName', desc: '发件人姓名', example: 'SenderName' },
{ key: 'sender.company', desc: '发件人公司', example: 'Sender Company' },
{ key: 'sender.phone', desc: '发件人手机', example: '13900139000' },
{ key: 'sender.telePhone', desc: '发件人电话', example: '' },
{ key: 'sender.email', desc: '发件人邮箱', example: 'sender@example.com' },
{ key: 'sender.countryCode', desc: '发件国家代码', example: 'CN' },
{ key: 'sender.province', desc: '发件省/州', example: 'Guangdong' },
{ key: 'sender.city', desc: '发件城市', example: 'Shenzhen' },
{ key: 'sender.zipCode', desc: '发件邮编', example: '518000' },
{ key: 'sender.streetAddress1', desc: '发件地址1', example: 'Baoan District' },
{ key: 'sender.streetAddress2', desc: '发件地址2', example: '' },
{ key: 'sender.streetAddress3', desc: '发件地址3', example: '' },
{ key: 'sender.houseNumber', desc: '发件门牌号', example: '88' },
{ key: 'declaredCurrency', desc: '申报币种(字典currency,默认USD)', example: 'USD' },
{ key: 'declaredSum', desc: '申报总金额(可选,不填自动汇总各行总金额)', example: '' },
{ key: 'items[0].cnName', desc: '申报品名(中)', example: '手机壳' },
{ key: 'items[0].enName', desc: '申报品名(英)', example: 'Phone Case' },
{ key: 'items[0].sku', desc: 'SKU', example: 'SKU001' },
{ key: 'items[0].unit', desc: '单位', example: 'pcs' },
{ key: 'items[0].unitPrice', desc: '单价(元)', example: '2.5' },
{ key: 'items[0].unitWeight', desc: '单重(kg)', example: '0.1' },
{ key: 'items[0].quantity', desc: '申报数量', example: '2' },
{ key: 'items[0].amount', desc: '总金额(元,可选,不填自动计算=单价×数量)', example: '5' },
{ key: 'items[0].weight', desc: '总重量(kg,可选,不填自动计算=单重×数量)', example: '0.2' },
{ key: 'items[0].imHsCode', desc: '进口HsCode', example: '' },
{ key: 'items[0].exHsCode', desc: '出口HsCode', example: '' },
{ key: 'items[0].declareElement', desc: '出口申报要素', example: '' },
{ key: 'items[0].skuImage', desc: '商品图片URL', example: '' },
{ key: 'items[0].category', desc: '类别', example: '' },
{ key: 'items[0].brand', desc: '品牌', example: '' },
{ key: 'items[0].brandType', desc: '品牌类型', example: '' },
{ key: 'items[0].isMagnetic', desc: '是否带磁带电(true/false)', example: 'false' },
{ key: 'items[0].distribution', desc: '配货信息', example: '' },
{ key: 'items[0].material', desc: '材质', example: 'plastic' },
{ key: 'items[0].enMaterial', desc: '英文材质', example: 'plastic' },
{ key: 'items[0].purpose', desc: '用途', example: 'protection' },
{ key: 'items[0].attributes', desc: '属性', example: '' },
{ key: 'items[0].originCountry', desc: '原产国', example: 'CN' },
{ key: 'items[0].salesLink', desc: '销售链接', example: '' },
{ key: 'items[0].model', desc: '型号', example: '' },
{ key: 'items[0].specifications', desc: '规格', example: '' },
{ key: 'items[0].comment', desc: '备注', example: '' },
];
const excelHeaderAliasMap: Record<string, string> = {
请求ID: 'requestId',
幂等ID: 'requestId',
幂等请求ID: 'requestId',
requestid: 'requestId',
客户单号: 'clientNo',
外部单号: 'clientNo',
客户订单号: 'clientNo',
产品代码: 'productCode',
产品编码: 'productCode',
运输方式: 'productCode',
货物属性: 'cargoAttribute',
货物类型: 'cargoAttribute',
出口报关方式: 'exportCustomsMode',
exportcustomsmode: 'exportCustomsMode',
报关方式: 'exportCustomsMode',
国家二字码: 'receiver.countryCode',
仓库ID: 'warehouseId',
仓库: 'warehouseId',
长: 'length',
长cm: 'length',
长度: 'length',
宽: 'width',
宽cm: 'width',
宽度: 'width',
高: 'height',
高cm: 'height',
高度: 'height',
实重: 'actualWeight',
实重kg: 'actualWeight',
是否COD: 'isCod',
COD币种: 'codCurrency',
COD金额: 'codSum',
申报币种: 'declaredCurrency',
申报金额: 'declaredSum',
申报总金额: 'declaredSum',
包裹数量: 'quantity',
收件人名称: 'receiver.recipientsName',
收件姓名: 'receiver.recipientsName',
收件人公司: 'receiver.companyName',
收件人公司名称: 'receiver.companyName',
收件人手机: 'receiver.phone',
收件人电话: 'receiver.telePhone',
收件人邮箱: 'receiver.email',
收件国家代码: 'receiver.countryCode',
收件国家: 'receiver.countryCode',
收件国家二字码: 'receiver.countryCode',
收件省: 'receiver.province',
收件州: 'receiver.province',
收件城市: 'receiver.city',
收件人门牌号: 'receiver.streetNumber',
收件地址1: 'receiver.streetAddress1',
收件地址2: 'receiver.streetAddress2',
收件地址3: 'receiver.streetAddress3',
收件邮编: 'receiver.zipCode',
税号类型: 'receiver.taxType',
税号: 'receiver.taxNo',
证件类型: 'receiver.certificateType',
证件号码: 'receiver.certificateNo',
EORI: 'receiver.eori',
VAT: 'receiver.vat',
发件人姓名: 'sender.senderName',
发件人公司: 'sender.company',
发件人手机: 'sender.phone',
发件人电话: 'sender.telePhone',
发件人邮箱: 'sender.email',
发件国家代码: 'sender.countryCode',
发件国家: 'sender.countryCode',
发件国家二字码: 'sender.countryCode',
发件省: 'sender.province',
发件州: 'sender.province',
发件城市: 'sender.city',
发件邮编: 'sender.zipCode',
发件地址1: 'sender.streetAddress1',
发件地址2: 'sender.streetAddress2',
发件地址3: 'sender.streetAddress3',
发件门牌号: 'sender.houseNumber',
申报品名中: 'items[0].cnName',
申报品名英: 'items[0].enName',
品名中: 'items[0].cnName',
品名英: 'items[0].enName',
中文品名: 'items[0].cnName',
英文品名: 'items[0].enName',
SKU: 'items[0].sku',
单位: 'items[0].unit',
单价: 'items[0].unitPrice',
单价元: 'items[0].unitPrice',
单重: 'items[0].unitWeight',
单重kg: 'items[0].unitWeight',
申报数量: 'items[0].quantity',
数量: 'items[0].quantity',
总金额: 'items[0].amount',
总金额元: 'items[0].amount',
总重量: 'items[0].weight',
总重量kg: 'items[0].weight',
进口HsCode: 'items[0].imHsCode',
出口HsCode: 'items[0].exHsCode',
进口HS编码: 'items[0].imHsCode',
出口HS编码: 'items[0].exHsCode',
进口海关编码: 'items[0].imHsCode',
出口海关编码: 'items[0].exHsCode',
出口申报要素: 'items[0].declareElement',
申报要素: 'items[0].declareElement',
商品图片URL: 'items[0].skuImage',
图片: 'items[0].skuImage',
类别: 'items[0].category',
品牌: 'items[0].brand',
品牌类型: 'items[0].brandType',
是否带磁带电: 'items[0].isMagnetic',
配货信息: 'items[0].distribution',
材质: 'items[0].material',
英文材质: 'items[0].enMaterial',
用途: 'items[0].purpose',
属性: 'items[0].attributes',
原产国: 'items[0].originCountry',
销售链接: 'items[0].salesLink',
型号: 'items[0].model',
规格: 'items[0].specifications',
备注: 'items[0].comment',
};
const forceStringFieldSet = new Set([
'requestId',
'clientNo',
'productCode',
'cargoAttribute',
'exportCustomsMode',
'codCurrency',
'codSum',
'declaredCurrency',
'declaredSum',
'quantity',
'receiver.recipientsName',
'receiver.companyName',
'receiver.phone',
'receiver.telePhone',
'receiver.email',
'receiver.province',
'receiver.city',
'sender.phone',
'sender.telePhone',
'receiver.streetAddress1',
'receiver.streetAddress2',
'receiver.streetAddress3',
'receiver.zipCode',
'receiver.taxNo',
'receiver.certificateNo',
'receiver.eori',
'receiver.vat',
'receiver.streetNumber',
'sender.senderName',
'sender.company',
'sender.email',
'sender.province',
'sender.city',
'sender.zipCode',
'sender.streetAddress1',
'sender.streetAddress2',
'sender.streetAddress3',
'sender.houseNumber',
'items[0].cnName',
'items[0].enName',
'items[0].sku',
'items[0].unit',
'items[0].imHsCode',
'items[0].exHsCode',
'items[0].declareElement',
'items[0].skuImage',
'items[0].category',
'items[0].brand',
'items[0].brandType',
'items[0].distribution',
'items[0].material',
'items[0].enMaterial',
'items[0].purpose',
'items[0].attributes',
'items[0].originCountry',
'items[0].salesLink',
'items[0].model',
'items[0].specifications',
'items[0].comment',
]);
const forceNumberFieldSet = new Set([
'warehouseId',
'length',
'width',
'height',
'actualWeight',
'receiver.countryCode',
'receiver.taxType',
'receiver.certificateType',
'sender.countryCode',
'items[0].unitPrice',
'items[0].unitWeight',
'items[0].quantity',
'items[0].amount',
'items[0].weight',
]);
const forceBooleanFieldSet = new Set([
'isCod',
'items[0].isMagnetic',
]);
function triggerFilePicker() {
// 关键:每次打开前清空 input value,确保重复选择同一文件也会触发 change
if (nativeFileInputRef.value) {
nativeFileInputRef.value.value = '';
}
nativeFileInputRef.value?.click();
}
function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
applyUploadFile(input?.files?.[0] ?? null);
}
function applyUploadFile(file: File | null) {
if (!file) return;
const name = file.name.toLowerCase();
if (!name.endsWith('.xlsx') && !name.endsWith('.xls')) {
Message.warning(t('order.placeOrder.upload.invalidType'));
return;
}
pendingUploadFile.value = file;
}
function handleUploadDragOver(event: DragEvent) {
event.preventDefault();
uploadDragActive.value = true;
}
function handleUploadDragLeave(event: DragEvent) {
event.preventDefault();
uploadDragActive.value = false;
}
function handleUploadDrop(event: DragEvent) {
event.preventDefault();
uploadDragActive.value = false;
const file = event.dataTransfer?.files?.[0] ?? null;
applyUploadFile(file);
}
function clearSelectedFile() {
pendingUploadFile.value = null;
if (nativeFileInputRef.value) {
nativeFileInputRef.value.value = '';
}
}
function setByPath(target: Record<string, any>, path: string, value: any) {
if (!path) return;
const normalized = path.replace(/\[(\d+)\]/g, '.$1');
const parts = normalized.split('.').filter(Boolean);
let cursor: any = target;
for (let i = 0; i < parts.length; i += 1) {
const key = parts[i];
const isLast = i === parts.length - 1;
const nextKey = parts[i + 1];
const nextIsIndex = /^\d+$/.test(nextKey || '');
if (isLast) {
cursor[key] = value;
} else {
if (cursor[key] == null) {
cursor[key] = nextIsIndex ? [] : {};
}
cursor = cursor[key];
}
}
}
function parseCellValue(raw: any) {
if (raw == null) return undefined;
if (typeof raw === 'number' || typeof raw === 'boolean') return raw;
const text = String(raw).trim();
if (!text) return undefined;
const lowered = text.toLowerCase();
if (lowered === 'true') return true;
if (lowered === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
return text;
}
function parseBooleanValue(raw: any): boolean | undefined {
if (raw == null) return undefined;
if (typeof raw === 'boolean') return raw;
const text = String(raw).trim().toLowerCase();
if (!text) return undefined;
if (['true', '1', 'y', 'yes', '是'].includes(text)) return true;
if (['false', '0', 'n', 'no', '否'].includes(text)) return false;
return undefined;
}
function parseNumberValue(raw: any): number | undefined {
if (raw == null) return undefined;
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
const text = String(raw).trim().replace(/,/g, '');
if (!text) return undefined;
const num = Number(text);
return Number.isFinite(num) ? num : undefined;
}
function coerceValueByFieldType(key: string, rawValue: any) {
if (forceStringFieldSet.has(key)) {
const text = rawValue == null ? '' : String(rawValue).trim();
return text === '' ? undefined : text;
}
if (forceNumberFieldSet.has(key)) {
return parseNumberValue(rawValue);
}
if (forceBooleanFieldSet.has(key)) {
return parseBooleanValue(rawValue);
}
return parseCellValue(rawValue);
}
function normalizeHeaderText(text: string) {
return String(text || '')
.trim()
.replace(/[((].*?[))]/g, '')
.replace(/\s+/g, '')
.replace(/[._\-:/\\]/g, '')
.toLowerCase();
}
function resolveHeaderToField(header: string) {
const raw = String(header || '').trim();
if (!raw) return '';
if (raw.includes('.') || raw.includes('[')) return raw;
const direct = orderTemplateFields.find((x) => x.key === raw)?.key;
if (direct) return direct;
const normalized = normalizeHeaderText(raw);
const aliasDirect = excelHeaderAliasMap[raw] || excelHeaderAliasMap[normalized];
if (aliasDirect) return aliasDirect;
// 使用模板字段中文说明做兜底匹配
const byDesc = orderTemplateFields.find((x) => normalizeHeaderText(x.desc) === normalized);
if (byDesc) return byDesc.key;
return '';
}
function applyBatchDeclarationDerivedFields(payload: Record<string, any>) {
if (!String(payload.declaredCurrency ?? '').trim()) {
payload.declaredCurrency = 'USD';
}
const items = Array.isArray(payload.items) ? payload.items : [];
let declaredTotal = 0;
let hasDeclaredTotal = false;
items.forEach((item: any) => {
const unitPrice = parseNumberValue(item?.unitPrice);
const unitWeight = parseNumberValue(item?.unitWeight);
const quantity = parseNumberValue(item?.quantity);
if (parseNumberValue(item?.amount) === undefined && unitPrice !== undefined && quantity !== undefined) {
item.amount = unitPrice * quantity;
}
if (parseNumberValue(item?.weight) === undefined && unitWeight !== undefined && quantity !== undefined) {
item.weight = unitWeight * quantity;
}
const amount = parseNumberValue(item?.amount);
if (amount !== undefined) {
declaredTotal += amount;
hasDeclaredTotal = true;
}
});
if (parseNumberValue(payload.declaredSum) === undefined && hasDeclaredTotal) {
payload.declaredSum = declaredTotal;
}
}
function buildOrderPayloadFromRow(row: Record<string, any>) {
const payload: Record<string, any> = {};
Object.entries(row).forEach(([key, rawValue]) => {
const value = coerceValueByFieldType(key, rawValue);
if (value === undefined) return;
setByPath(payload, key, value);
});
if (!payload.receiver) payload.receiver = {};
if (!payload.sender) payload.sender = {};
if (!Array.isArray(payload.items)) payload.items = [];
if (!payload.items.length) payload.items = [{}];
applyBatchDeclarationDerivedFields(payload);
return omitEmptyOptionalOrderFields(payload);
}
async function parseExcelToOrderPayloads(file: File): Promise<Record<string, any>[]> {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) return [];
const worksheet = workbook.Sheets[firstSheetName];
const table = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }) as any[][];
if (!table.length) return [];
const headers = (table[0] || []).map((x) => resolveHeaderToField(String(x || '').trim()));
const rows = table.slice(1).filter((line) => line.some((cell) => String(cell ?? '').trim() !== ''));
const dataRows = rows;
const parsedRows = dataRows.map((line) => {
const rowObj: Record<string, any> = {};
headers.forEach((header, idx) => {
if (!header) return;
rowObj[header] = line[idx];
});
return rowObj;
});
return parsedRows.map(buildOrderPayloadFromRow);
}
const handleDownloadTemplate = async (template: TemplateRow) => {
if (template.id <= 0) {
Message.warning('模板不可用,请先执行数据库种子脚本');
return;
}
try {
const defaultName = template.type === 'custom' ? template.name : (template.name || '专线小包模板');
const rawName = template.sourceFileName || template.name || defaultName;
const safeName = rawName.replace(/[\\/:*?"<>|]/g, '_');
await downloadOrderTemplate(template.id, safeName);
Message.success('模板下载成功');
} catch (e: any) {
Message.error(e?.message || '下载模板失败');
}
};
function openTemplateMapping(template: TemplateRow) {
templateModalTemplateId.value = template.id;
templateModalVisible.value = true;
}
async function handleDeleteCustomTemplate(template: TemplateRow) {
Modal.confirm({
title: t('order.placeOrder.template.deleteConfirmTitle'),
content: t('order.placeOrder.template.deleteConfirmContent', { name: template.name }),
okText: t('order.placeOrder.template.delete'),
cancelText: t('order.placeOrder.template.cancel'),
onOk: async () => {
try {
await deleteOrderTemplateCustom(template.id);
Message.success(t('order.placeOrder.template.deleteSuccess'));
if (selectedTemplate.value === template.id) {
selectedTemplate.value = standardTemplates.value[0]?.id;
}
await loadTemplates();
} catch (e: any) {
Message.error(e?.message || '删除失败');
throw e;
}
},
});
}
const handleAddCustomTemplate = () => {
templateModalTemplateId.value = 0;
templateModalVisible.value = true;
};
async function onTemplateModalSuccess(payload?: { templateId: number }) {
await loadTemplates();
if (payload?.templateId) {
selectedTemplate.value = payload.templateId;
}
}
const batchSubmitting = ref(false);
const batchResultVisible = ref(false);
const batchResultRows = ref<Array<{ rowNo: number; clientNo: string; message: string }>>([]);
const singleSubmitting = ref(false);
const batchAgreementChecked = ref(false);
const singleAgreementChecked = ref(false);
const selectedTemplateReady = computed(() => {
const row = selectedTemplateRow.value;
if (!row) return false;
if (row.type !== 'custom') return true;
return row.mappingStatus === 'ready';
});
const canSubmitBatch = computed(
() =>
adminCustomerSelected.value &&
Boolean(pendingUploadFile.value) &&
batchAgreementChecked.value &&
selectedTemplate.value != null &&
selectedTemplateReady.value &&
Boolean(selectedSenderAddressId.value),
);
const batchSubmitBlockReason = computed(() => {
if (canSubmitBatch.value) return '';
if (!adminCustomerSelected.value) return t('order.placeOrder.selectCustomerRequired');
if (selectedTemplate.value == null) return t('order.placeOrder.batch.block.noTemplate');
if (!selectedTemplateReady.value) return t('order.placeOrder.batch.block.templateDraft');
if (!selectedSenderAddressId.value) return t('order.placeOrder.batch.block.noSender');
if (!pendingUploadFile.value) return t('order.placeOrder.batch.block.noFile');
if (!batchAgreementChecked.value) return t('order.placeOrder.batch.block.noAgreement');
return '';
});
const formTrim = (value: unknown) => String(value ?? '').trim();
type SingleFormSection = 'basic' | 'sender' | 'recipient' | 'declaration';
function isSingleBasicComplete() {
if (!formTrim(singleForm.clientNo)) return false;
if (!formTrim(singleForm.productCode)) return false;
if (!formTrim(singleForm.cargoAttribute)) return false;
if (formTrim(singleForm.warehouseId) === '' || parseNumberValue(singleForm.warehouseId) === undefined) {
return false;
}
if (
formTrim(String(singleForm.actualWeight ?? '')) === '' ||
parseNumberValue(singleForm.actualWeight) === undefined
) {
return false;
}
const length = parseNumberValue(singleForm.length);
const width = parseNumberValue(singleForm.width);
const height = parseNumberValue(singleForm.height);
if (length == null || length <= 0) return false;
if (width == null || width <= 0) return false;
if (height == null || height <= 0) return false;
if (!formTrim(String(singleForm.quantity ?? ''))) return false;
return true;
}
function isSingleSenderFilled() {
return Boolean(formTrim(singleForm.sender?.senderName));
}
function isSingleRecipientComplete() {
const receiver = singleForm.receiver;
if (!formTrim(receiver.recipientsName)) return false;
if (!formTrim(receiver.phone)) return false;
if (formTrim(receiver.countryCode) === '') return false;
if (!formTrim(receiver.streetAddress1)) return false;
if (!formTrim(receiver.zipCode)) return false;
return true;
}
async function saveReceiverAddress() {
const receiver = singleForm.receiver;
if (!formTrim(receiver.recipientsName) || !formTrim(receiver.streetAddress1)) {
Message.warning(t('order.placeOrder.single.saveReceiverRequired'));
return;
}
if (isAdmin.value && ensureMutationCustomerId() == null) return;
savingReceiverAddress.value = true;
try {
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined;
await createSenderAddress({
addressType: 'receiver',
addressName: formTrim(receiver.recipientsName),
senderName: formTrim(receiver.recipientsName),
company: formTrim(receiver.companyName) || undefined,
phone: formTrim(receiver.phone) || undefined,
telePhone: formTrim(receiver.telePhone) || undefined,
email: formTrim(receiver.email) || undefined,
countryCode: formTrim(receiver.countryCode) || undefined,
province: formTrim(receiver.province) || undefined,
city: formTrim(receiver.city) || undefined,
zipCode: formTrim(receiver.zipCode) || undefined,
streetAddress1: formTrim(receiver.streetAddress1),
streetAddress2: formTrim(receiver.streetAddress2) || undefined,
streetAddress3: formTrim(receiver.streetAddress3) || undefined,
houseNumber: formTrim(receiver.streetNumber) || undefined,
isDefault: false,
customerId: mutationCustomerId ?? undefined,
});
Message.success(t('order.placeOrder.single.saveReceiverSuccess'));
await loadReceiverAddressOptions({ applyDefault: false });
} catch (e: any) {
Message.error(e?.message || t('order.placeOrder.single.saveReceiverFailed'));
} finally {
savingReceiverAddress.value = false;
}
}
function applyParsedReceiver(parsed: Record<string, string | undefined>) {
const assignKeys = [
'recipientsName',
'companyName',
'phone',
'telePhone',
'email',
'countryCode',
'province',
'city',
'streetNumber',
'streetAddress1',
'streetAddress2',
'streetAddress3',
'zipCode',
] as const;
for (const key of assignKeys) {
const val = formTrim(parsed[key] || '');
if (val) {
;(singleForm.receiver as Record<string, string>)[key] = val;
}
}
const needOptional = Boolean(
formTrim(parsed.companyName || '') ||
formTrim(parsed.telePhone || '') ||
formTrim(parsed.streetAddress2 || '') ||
formTrim(parsed.streetAddress3 || '') ||
formTrim(singleForm.receiver.taxNo) ||
formTrim(singleForm.receiver.certificateNo) ||
formTrim(singleForm.receiver.eori) ||
formTrim(singleForm.receiver.vat),
);
if (needOptional) {
showRecipientOptional.value = true;
}
}
function buildReceiverParseTip(result: {
isComplete: boolean;
missingRequired: string[];
missingRecommended: string[];
warnings: string[];
}) {
const parts: string[] = [];
if (result.missingRequired.length) {
parts.push(`仍缺必填:${result.missingRequired.join('、')}`);
}
if (result.missingRecommended.length) {
parts.push(`建议补全:${result.missingRecommended.join('、')}`);
}
if (result.warnings.length) {
parts.push(result.warnings.slice(0, 2).join(';'));
}
if (result.isComplete && !result.missingRecommended.length && !result.warnings.length) {
receiverParseTip.value = { tone: 'ok', text: '地址信息完整,已填入下方字段,请确认后下单' };
return;
}
if (result.isComplete) {
receiverParseTip.value = {
tone: 'warn',
text: parts.length ? `必填已齐。${parts.join('。')}` : '必填已齐,请确认地址细节',
};
return;
}
receiverParseTip.value = {
tone: 'danger',
text: parts.length ? parts.join('。') : '解析后仍不完整,请手动补全',
};
}
function loadReceiverAiPrefs() {
try {
const raw = localStorage.getItem(RECEIVER_AI_PREFS_KEY);
if (!raw) return;
const data = JSON.parse(raw) as {
providerKey?: string;
modelId?: string;
skillKey?: string;
};
if (data.providerKey) receiverAiProviderKey.value = data.providerKey;
if (data.modelId) receiverAiModelId.value = data.modelId;
if (data.skillKey) receiverAiSkillKey.value = data.skillKey;
} catch {
/* ignore */
}
}
function saveReceiverAiPrefs() {
try {
localStorage.setItem(
RECEIVER_AI_PREFS_KEY,
JSON.stringify({
providerKey: receiverAiProviderKey.value,
modelId: receiverAiModelId.value,
skillKey: receiverAiSkillKey.value,
}),
);
} catch {
/* ignore */
}
}
function onReceiverAiProviderChange() {
const opts = receiverAiModelOptions.value;
if (!opts.some((m) => m.modelId === receiverAiModelId.value)) {
receiverAiModelId.value = opts[0]?.modelId ?? '';
}
saveReceiverAiPrefs();
}
async function loadReceiverAiProviders() {
receiverAiProvidersLoading.value = true;
try {
const items = await listAiProviders();
receiverAiProviders.value = (items ?? []) as AiProviderItem[];
if (!receiverAiProviderKey.value) {
const def =
receiverAiEnabledProviders.value.find((p) => p.isDefault) ??
receiverAiEnabledProviders.value[0];
if (def) receiverAiProviderKey.value = def.providerKey;
}
if (
receiverAiProviderKey.value &&
!receiverAiEnabledProviders.value.some(
(p) => p.providerKey === receiverAiProviderKey.value,
)
) {
receiverAiProviderKey.value =
receiverAiEnabledProviders.value[0]?.providerKey ?? '';
}
onReceiverAiProviderChange();
} catch (err: unknown) {
Message.error(err instanceof Error ? err.message : '加载 AI 提供商失败');
} finally {
receiverAiProvidersLoading.value = false;
}
}
async function loadReceiverAiSkills() {
receiverAiSkillsLoading.value = true;
try {
receiverAiSkills.value = await listAiSkills({
scene: RECEIVER_AI_SCENE,
includeContent: false,
enabledOnly: true,
});
if (
!receiverAiSkills.value.some((s) => s.skillKey === receiverAiSkillKey.value) &&
receiverAiSkills.value[0]
) {
receiverAiSkillKey.value = receiverAiSkills.value[0].skillKey;
} else if (!receiverAiSkillKey.value) {
receiverAiSkillKey.value = RECEIVER_AI_DEFAULT_SKILL_KEY;
}
saveReceiverAiPrefs();
} catch (err: unknown) {
receiverAiSkills.value = [];
Message.error(err instanceof Error ? err.message : '加载 AI Skills 失败');
} finally {
receiverAiSkillsLoading.value = false;
}
}
function ensureReceiverAiReady() {
if (!receiverAiProviderKey.value) {
Message.warning('请选择 AI 提供商');
return false;
}
if (!receiverAiModelId.value) {
Message.warning('请选择模型');
return false;
}
if (!receiverAiSkillKey.value) {
Message.warning('请选择技能卡片');
return false;
}
return true;
}
async function handleReceiverAiParse() {
const raw = receiverPasteText.value.trim();
if (!raw) {
Message.warning('请先粘贴收件人地址文本');
return;
}
if (!ensureReceiverAiReady()) return;
if (receiverParseLoading.value) return;
saveReceiverAiPrefs();
receiverParseLoading.value = true;
receiverParseTip.value = null;
try {
const result = await postOrderReceiverParseAddress({
rawText: raw,
providerKey: receiverAiProviderKey.value,
model: receiverAiModelId.value,
skillKey: receiverAiSkillKey.value,
});
applyParsedReceiver(result.receiver);
buildReceiverParseTip(result);
Message.success('已分析并填入收件人信息');
} catch (e: any) {
receiverParseTip.value = {
tone: 'danger',
text: e?.message || 'AI 分析失败,请稍后重试',
};
Message.error(e?.message || 'AI 分析失败');
} finally {
receiverParseLoading.value = false;
}
}
function clearReceiverPaste() {
receiverPasteText.value = '';
receiverParseTip.value = null;
}
function onReceiverPasteKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void handleReceiverAiParse();
}
}
function onReceiverTextPaste() {
nextTick(() => {
if (receiverPasteText.value.trim()) {
void handleReceiverAiParse();
}
});
}
function onReceiverPasteDragLeave(e: DragEvent) {
const current = e.currentTarget as HTMLElement | null;
const related = e.relatedTarget as Node | null;
if (current && related && current.contains(related)) return;
receiverPasteDragging.value = false;
}
function onReceiverPasteDrop(e: DragEvent) {
receiverPasteDragging.value = false;
const text = e.dataTransfer?.getData('text/plain') || '';
if (!text.trim()) return;
receiverPasteText.value = text;
void handleReceiverAiParse();
}
function isSingleDeclarationComplete() {
const lines = singleForm.items || [];
if (!lines.length) return false;
for (const row of lines) {
if (!formTrim(row?.enName)) return false;
if (parseNumberValue(row?.unitPrice) === undefined) return false;
if (parseNumberValue(row?.unitWeight) === undefined) return false;
if (parseNumberValue(row?.quantity) === undefined) return false;
}
return true;
}
const singleProgressStep = computed(() => {
if (!isSingleBasicComplete()) return 1;
if (!isSingleSenderFilled()) return 2;
if (!isSingleRecipientComplete()) return 3;
if (!isSingleDeclarationComplete() || !formTrim(singleForm.declaredCurrency)) return 4;
return 4;
});
const canSubmitSingle = computed(
() =>
adminCustomerSelected.value &&
singleAgreementChecked.value &&
isSingleDeclarationComplete() &&
isSingleRecipientComplete() &&
isSingleSenderFilled() &&
isSingleBasicComplete() &&
!!formTrim(singleForm.declaredCurrency),
);
const singleSubmitBlockReason = computed(() => {
if (canSubmitSingle.value) return '';
if (!adminCustomerSelected.value) return t('order.placeOrder.selectCustomerRequired');
const err = getSingleFormValidationError();
if (err) return err.message;
if (!singleAgreementChecked.value) return t('order.placeOrder.agreement.required');
return t('order.placeOrder.single.submitHint');
});
const singleSubmitHint = computed(() =>
canSubmitSingle.value
? t('order.placeOrder.single.submitReady')
: singleSubmitBlockReason.value || t('order.placeOrder.single.submitHint'),
);
const batchStep = computed(() => {
if (pendingUploadFile.value) return 4;
if (selectedSenderAddressId.value) return 3;
if (selectedTemplate.value != null) return 2;
return 1;
});
const batchFocusTarget = ref<'template' | 'upload' | 'submit' | null>(null);
const batchTemplatePanelRef = ref<HTMLElement | null>(null);
const batchUploadCardRef = ref<HTMLElement | null>(null);
const batchSubmitFooterRef = ref<HTMLElement | null>(null);
let batchFocusTimer: number | undefined;
const batchFlowSteps = computed(() => [
{ key: 'template', label: t('order.placeOrder.batch.stepTemplate') },
{ key: 'sender', label: t('order.placeOrder.single.stepSender') },
{ key: 'upload', label: t('order.placeOrder.batch.stepUpload') },
{ key: 'submit', label: t('order.placeOrder.batch.stepSubmit') },
]);
function isBatchStepDone(stepKey: string) {
if (stepKey === 'template') return selectedTemplate.value != null && selectedTemplateReady.value;
if (stepKey === 'sender') return Boolean(selectedSenderAddressId.value);
if (stepKey === 'upload') return Boolean(pendingUploadFile.value);
if (stepKey === 'submit') return canSubmitBatch.value;
return false;
}
/** 第一个未完成步骤 = 当前待办 */
const batchCurrentStepKey = computed(() => {
for (const step of batchFlowSteps.value) {
if (!isBatchStepDone(step.key)) return step.key;
}
return 'submit';
});
function handleBatchFlowClick(stepKey: string) {
if (stepKey === 'sender') {
void openSenderModal();
return;
}
const target =
stepKey === 'template' ? 'template' : stepKey === 'upload' ? 'upload' : 'submit';
batchFocusTarget.value = target;
if (batchFocusTimer) window.clearTimeout(batchFocusTimer);
nextTick(() => {
const el =
target === 'template'
? batchTemplatePanelRef.value
: target === 'upload'
? batchUploadCardRef.value
: batchSubmitFooterRef.value;
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
batchFocusTimer = window.setTimeout(() => {
if (batchFocusTarget.value === target) batchFocusTarget.value = null;
}, 1400);
});
}
const agreementModalVisible = ref(false);
const agreementBodyRef = ref<HTMLElement | null>(null);
const agreementText = `物流下单用户协议及赔偿协议
提示:请您仔细阅读并充分理解本协议全部条款(包括用户协议和赔偿协议),您在物流下单页面勾选“我已阅读并同意本协议全部条款”并提交订单,即视为您已完全接受本协议所有约定,自愿与我方(物流提供方)建立物流服务关系,严格遵守本协议各项条款。若您不同意本协议任何条款,请立即停止下单操作,切勿勾选同意及提交订单。
一、物流下单用户协议
(一)个人信息授权使用条款
1. 您确认并同意,为顺利完成物流服务(包括但不限于揽收、运输、中转、派送、清关、售后跟进等),授权我方及我方合作的物流服务商、清关机构、投递机构等相关主体,收集、使用、存储您在下单时提供的个人信息,包括但不限于寄件人/收件人姓名、联系电话、身份证号(如需清关)、收货地址、下单账号信息、交易信息等。
2. 我方及相关合作主体仅会在提供物流服务的必要范围内使用您的个人信息,严格遵守《中华人民共和国个人信息保护法》等相关法律法规,采取合理安全措施保护您的个人信息,防止信息泄露、篡改、丢失,不会超出服务范围滥用个人信息,也不会未经您允许向无关第三方披露(法律法规要求、政府部门调取除外)。
3. 物流服务终止后,我方将在法律法规规定的期限内妥善留存您的个人信息,留存期满后将依法进行删除或匿名化处理,不再用于任何与物流服务无关的用途。
4. 您保证所提供的个人信息真实、准确、完整、有效,若因您提供的个人信息虚假、有误、不完整导致物流服务无法正常进行(如揽收失败、派送延误、无法清关等),或造成我方及第三方损失的,全部责任由您自行承担,我方不承担任何责任,且有权终止本次物流服务,已收取的运费不予退还。
(二)包裹申报条款
1. 您确认并保证,在下单时如实、准确申报包裹相关信息,包括但不限于包裹内件品名、数量、规格、重量、实际货值、物品性质(是否为易碎品、液体、危险品等),严禁虚报、瞒报、漏报包裹信息,严禁交寄法律法规及我方规定禁止或限制寄递的物品(包括但不限于易燃易爆、有毒有害、管制物品、违禁品等)。
2. 您知晓并同意,包裹申报信息将作为物流揽收、运输、清关、理赔的重要依据,若您申报的信息与包裹实际内容不符(包括但不限于品名不符、货值虚报、隐瞒物品性质等),由此产生的一切后果及损失均由您自行承担,我方不承担任何责任,具体包括但不限于:
(1)包裹被海关扣留、没收、罚款,或被我方及合作机构拒收、退回,产生的罚款、退件运费、仓储费等全部费用由您承担,已收取的运费不予退还;
(2)包裹在运输过程中出现损坏、丢失、延误等情况,我方不承担任何赔偿责任;
(3)因虚报、瞒报物品信息导致我方及第三方(包括但不限于其他客户、物流服务商、监管部门)遭受人身、财产损失的,您需全额赔偿我方及第三方的全部损失,同时承担相应的法律责任。
(三)免责条款
1. 因您自身原因(包括但不限于个人信息虚假、包裹申报不实、包装不当、收件人无法联系、拒收等)导致的物流服务异常及相关损失,我方不承担任何责任。
2. 因不可抗力(包括但不限于地震、洪水、台风等自然灾害,战争、罢工、政府管制、疫情等不可预见、不可避免且无法克服的事件)导致物流服务延误、中断、包裹损坏或丢失的,我方不承担任何赔偿责任,已收取的运费可根据实际服务进度协商退还(若未提供任何服务,全额退还;若已部分提供服务,扣除已产生的费用后退还剩余部分)。
3. 包裹内件为易碎品、易损品、易变质物品,或因物品自身性质、合理损耗导致的损坏、变质,我方不承担赔偿责任(若您已提前声明并办理相关增值防护服务,按增值服务约定执行)。
4. 因海关查验、清关政策调整、航班延误、中转延误等非我方主观原因导致的物流服务延误,我方不承担赔偿责任,仅可协助您跟进相关进度。
5. 您未按约定支付运费、保价费等相关费用,我方有权暂停或终止物流服务,由此产生的损失由您自行承担。
(四)其他补充条款
1. 我方有权根据法律法规规定、行业规范及实际运营情况,对本协议条款进行修改和更新,修改后的协议将在下单页面公示,您再次下单勾选同意,即视为接受修改后的条款。
2. 您在使用我方物流服务过程中,应遵守国家相关法律法规及我方的服务规则,不得利用物流服务从事任何违法违规活动,否则我方有权终止服务,追究您的法律责任。
3. 本协议自您勾选同意并提交订单之日起生效,有效期至本次物流服务完成(包裹妥投、拒收或退回完成)之日止。
二、物流服务赔偿协议
(一)赔偿适用前提
1. 本赔偿协议仅适用于因我方主观过错(包括但不限于揽收失误、运输过程中保管不当、派送错误等)导致包裹损坏、丢失、无法送达(非不可抗力、您自身原因及第三方原因导致)的情形。
2. 您需在知晓包裹异常(损坏、丢失、无法送达)之日起48小时内,向我方提交书面理赔申请,并提供相关证明材料(包括但不限于订单截图、包裹照片、签收记录、货值证明等),逾期未提交申请或未提供完整证明材料的,视为自动放弃理赔权利,我方不再受理。
3. 若包裹存在申报不实、包装不当、内件为禁限寄物品等情形,无论是否因我方过错,均不适用本赔偿协议,我方不承担任何赔偿责任。
(二)退运费约定
1. 因我方主观过错导致包裹无法揽收、无法送达(收件人地址无误且可正常接收)、包裹丢失或全部损坏,无法完成物流服务的,我方将全额退还您本次支付的运费。
2. 因我方主观过错导致包裹部分损坏、派送延误,但仍可完成送达的,根据实际情况协商退还部分运费(具体退还比例由双方协商确定)。
3. 非我方主观过错导致的物流服务异常(包括但不限于您自身原因、不可抗力、海关原因等),已收取的运费不予退还。
(三)货值赔偿约定
1. 包裹损坏、丢失的,赔偿金额按照您下单时申报的实际货值计算,若您未申报货值或申报货值低于物品实际价值,按申报货值计算赔偿;若您申报货值高于物品实际价值,按实际货值计算赔偿(需提供有效货值证明,如发票、采购凭证等)。
2. 单票包裹赔偿金额最高不超过100CNY(人民币壹佰元整),无论包裹实际货值或申报货值多少,均按此限额执行,超出部分我方不承担赔偿责任。
3. 包裹部分损坏的,赔偿金额按损坏部分的实际价值(或申报价值)计算,同样受单票最高100CNY赔偿限额约束。
4. 赔偿金额仅包括包裹内件的实际价值,不包括运费、包装费、税费、预期收益、商业机会等间接损失,我方不承担任何间接损失赔偿责任。
(四)赔偿流程及其他约定
1. 我方在收到您的理赔申请及完整证明材料后,将在3-5个工作日内完成审核,审核通过后,将赔偿款项(退运费+货值赔偿)一次性支付至您下单时预留的收款账户。
2. 若您对理赔结果有异议,可在收到理赔结果之日起3个工作日内与我方沟通协商,协商不成的,双方可按相关法律法规处理。
3. 您确认并同意,本协议约定的赔偿标准为我方承担的全部赔偿责任,您获得赔偿后,不得就本次物流服务相关的损失再向我方提出任何其他赔偿要求,双方就此了结本次纠纷。
三、勾选确认条款
本人已仔细阅读并充分理解《物流下单用户协议》及《物流服务赔偿协议》全部条款,明确知晓自身权利和义务,自愿接受本协议所有约定,自愿承担因违反本协议条款产生的一切后果及损失,同意我方按本协议约定提供物流服务及处理相关理赔事宜。`;
function openAgreementViewModal() {
agreementModalVisible.value = true;
nextTick(() => {
if (agreementBodyRef.value) {
agreementBodyRef.value.scrollTop = 0;
}
});
}
function ensureAgreementChecked(mode: 'batch' | 'single' = activeOrderMode.value) {
const checked = mode === 'batch' ? batchAgreementChecked.value : singleAgreementChecked.value;
if (!checked) {
Message.warning(t('order.placeOrder.agreement.required'));
return false;
}
return true;
}
async function handleBatchOrderClick() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
if (!canSubmitBatch.value) {
Message.warning(batchSubmitBlockReason.value || t('order.placeOrder.batch.block.generic'));
return;
}
if (!ensureAgreementChecked('batch')) return;
await handleBatchOrder();
}
async function handleSingleOrderClick() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
if (!canSubmitSingle.value) {
singleShowFieldErrors.value = true;
const err = getSingleFormValidationError();
if (err) {
Message.warning(err.message);
focusSingleSection(err.section);
return;
}
Message.warning(singleSubmitBlockReason.value);
return;
}
if (!ensureAgreementChecked('single')) return;
await handleSingleOrderSubmit();
}
function resolveExportCustomsMode9610Simple() {
const opts = singleExportCustomsModeOptions.value;
const byCode = opts.find(
(o) => o.value === SAMPLE_EXPORT_CUSTOMS_MODE_CODE || o.value.toLowerCase() === '9610simple'
);
if (byCode) return byCode.value;
const byLabel = opts.find((o) => /9610.*简易|简易.*9610/i.test(o.label));
if (byLabel) return byLabel.value;
return SAMPLE_EXPORT_CUSTOMS_MODE_CODE;
}
const singleFormScrollRef = ref<HTMLElement | null>(null);
const customerGuideDrawerRef = ref<InstanceType<typeof CustomerGuideDrawer> | null>(null);
const singlePanelRefs = reactive<Record<string, HTMLElement | null>>({
basic: null,
sender: null,
recipient: null,
declaration: null,
});
type SenderAddressOption = {
id: number;
label: string;
isDefault: boolean;
data: Record<string, string>;
};
const senderModalVisible = ref(false);
const senderAddressLoading = ref(false);
const senderAddressOptions = ref<SenderAddressOption[]>([]);
const selectedSenderAddressId = ref<number | undefined>(undefined);
const receiverModalVisible = ref(false);
const receiverAddressLoading = ref(false);
const receiverAddressOptions = ref<SenderAddressOption[]>([]);
const selectedReceiverAddressId = ref<number | undefined>(undefined);
const singleFlowSteps = computed(() => [
{ key: 'basic', label: t('order.placeOrder.single.stepBasic') },
{ key: 'sender', label: t('order.placeOrder.single.stepSender') },
{ key: 'recipient', label: t('order.placeOrder.single.stepRecipient') },
{ key: 'declaration', label: t('order.placeOrder.single.stepDeclaration') },
]);
function isSingleStepDone(stepKey: string) {
if (stepKey === 'basic') return isSingleBasicComplete();
if (stepKey === 'sender') return isSingleSenderFilled();
if (stepKey === 'recipient') return isSingleRecipientComplete();
if (stepKey === 'declaration') {
return isSingleDeclarationComplete() && !!formTrim(singleForm.declaredCurrency);
}
return false;
}
/** 第一个未完成步骤 = 当前待办 */
const singleCurrentStepKey = computed(() => {
for (const step of singleFlowSteps.value) {
if (!isSingleStepDone(step.key)) return step.key;
}
return 'declaration';
});
const selectedSenderSummary = computed(() => {
const s = singleForm.sender;
const name = formTrim(s.senderName);
const phone = formTrim(s.phone);
if (!name && !phone) return '';
const place = [formTrim(s.province), formTrim(s.city)].filter(Boolean).join(' ');
return [name, phone, place].filter(Boolean).join(' · ');
});
const selectedReceiverSummary = computed(() => {
const r = singleForm.receiver;
const name = formTrim(r.recipientsName);
const phone = formTrim(r.phone);
if (!name && !phone) return '';
const place = [formTrim(r.province), formTrim(r.city)].filter(Boolean).join(' ');
return [name, phone, place].filter(Boolean).join(' · ');
});
const senderSelectOptions = computed(() =>
senderAddressOptions.value.map((opt) => ({ label: opt.label, value: opt.id })),
);
const receiverSelectOptions = computed(() =>
receiverAddressOptions.value.map((opt) => ({ label: opt.label, value: opt.id })),
);
function emptySenderForm() {
return {
senderName: '',
company: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
houseNumber: '',
};
}
function mapSenderAddressOption(raw: Record<string, unknown>): SenderAddressOption | null {
const row = normalizeListRow(raw);
const id = Number(pickField(row, 'id', 'Id') || 0);
if (!id) return null;
const addressName = String(pickField(row, 'addressName', 'AddressName') || '');
const senderName = String(pickField(row, 'senderName', 'SenderName') || '');
const phone = String(pickField(row, 'phone', 'Phone') || '');
const isDefault = Boolean(pickField(row, 'isDefault', 'IsDefault'));
const defaultTag = isDefault ? t('order.placeOrder.single.senderDefaultTag') : '';
const title = addressName || senderName || String(id);
return {
id,
isDefault,
label: `${title}${defaultTag ? ` ${defaultTag}` : ''}${phone ? ` · ${phone}` : ''}`,
data: {
senderName,
company: String(pickField(row, 'company', 'Company') || ''),
phone,
telePhone: String(pickField(row, 'telePhone', 'TelePhone') || ''),
email: String(pickField(row, 'email', 'Email') || ''),
countryCode: String(pickField(row, 'countryCode', 'CountryCode') || ''),
province: String(pickField(row, 'province', 'Province') || ''),
city: String(pickField(row, 'city', 'City') || ''),
zipCode: String(pickField(row, 'zipCode', 'ZipCode') || ''),
streetAddress1: String(pickField(row, 'streetAddress1', 'StreetAddress1') || ''),
streetAddress2: String(pickField(row, 'streetAddress2', 'StreetAddress2') || ''),
streetAddress3: String(pickField(row, 'streetAddress3', 'StreetAddress3') || ''),
houseNumber: String(pickField(row, 'houseNumber', 'HouseNumber') || ''),
},
};
}
function applySenderAddressById(id: number | undefined, { silentEmpty = false } = {}) {
if (id == null) {
if (!silentEmpty) {
Object.assign(singleForm.sender, emptySenderForm());
}
selectedSenderAddressId.value = undefined;
return;
}
const hit = senderAddressOptions.value.find((opt) => opt.id === id);
if (!hit) return;
Object.assign(singleForm.sender, { ...hit.data });
selectedSenderAddressId.value = id;
}
function onPickupSenderAddressChange(id: number | undefined) {
singleForm.senderAddressId = id ?? '';
}
const pickupAddressOptions = ref<SenderAddressOption[]>([]);
const pickupAddressLoading = ref(false);
const pickupSelectOptions = computed(() =>
pickupAddressOptions.value.map((opt) => ({ label: opt.label, value: opt.id })),
);
async function loadPickupAddressOptions() {
if (isAdmin.value && resolveSelectedCustomerId() == null) {
pickupAddressOptions.value = [];
return;
}
pickupAddressLoading.value = true;
try {
const res = await getSenderAddressList(
withCustomerId({
page: 1,
pageSize: 200,
addressType: 'pickup',
}),
);
pickupAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapSenderAddressOption(item))
.filter((x): x is SenderAddressOption => !!x);
} catch (e: any) {
pickupAddressOptions.value = [];
Message.warning(e?.message || t('order.placeOrder.single.senderLoadFailed'));
} finally {
pickupAddressLoading.value = false;
}
}
async function loadSenderAddressOptions({ applyDefault = true } = {}) {
if (isAdmin.value && resolveSelectedCustomerId() == null) {
senderAddressOptions.value = [];
if (applyDefault) {
applySenderAddressById(undefined, { silentEmpty: true });
}
return;
}
senderAddressLoading.value = true;
try {
const res = await getSenderAddressList(
withCustomerId({
page: 1,
pageSize: 200,
addressType: 'sender',
}),
);
senderAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapSenderAddressOption(item))
.filter((x): x is SenderAddressOption => !!x);
if (applyDefault && !isSingleSenderFilled()) {
const defaultAddr =
senderAddressOptions.value.find((x) => x.isDefault) || senderAddressOptions.value[0];
if (defaultAddr) {
applySenderAddressById(defaultAddr.id);
}
} else if (selectedSenderAddressId.value) {
const still = senderAddressOptions.value.some((x) => x.id === selectedSenderAddressId.value);
if (!still) selectedSenderAddressId.value = undefined;
}
} catch (e: any) {
senderAddressOptions.value = [];
Message.warning(e?.message || t('order.placeOrder.single.senderLoadFailed'));
} finally {
senderAddressLoading.value = false;
}
}
async function openSenderModal() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
senderModalVisible.value = true;
await loadSenderAddressOptions({ applyDefault: !isSingleSenderFilled() });
}
function handleSenderAddressSelect(id: number | undefined) {
if (id == null) return;
applySenderAddressById(id);
}
function goToSenderAddressManage() {
router.push({ path: '/user/senderAddress' });
}
function emptyReceiverForm() {
return {
recipientsName: '',
companyName: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
streetNumber: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
zipCode: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: '',
vat: '',
};
}
function mapReceiverAddressOption(raw: Record<string, unknown>): SenderAddressOption | null {
const row = normalizeListRow(raw);
const id = Number(pickField(row, 'id', 'Id') || 0);
if (!id) return null;
const addressName = String(pickField(row, 'addressName', 'AddressName') || '');
const senderName = String(pickField(row, 'senderName', 'SenderName') || '');
const phone = String(pickField(row, 'phone', 'Phone') || '');
const isDefault = Boolean(pickField(row, 'isDefault', 'IsDefault'));
const defaultTag = isDefault ? t('order.placeOrder.single.senderDefaultTag') : '';
const title = addressName || senderName || String(id);
return {
id,
isDefault,
label: `${title}${defaultTag ? ` ${defaultTag}` : ''}${phone ? ` · ${phone}` : ''}`,
data: {
recipientsName: senderName,
companyName: String(pickField(row, 'company', 'Company') || ''),
phone,
telePhone: String(pickField(row, 'telePhone', 'TelePhone') || ''),
email: String(pickField(row, 'email', 'Email') || ''),
countryCode: String(pickField(row, 'countryCode', 'CountryCode') || ''),
province: String(pickField(row, 'province', 'Province') || ''),
city: String(pickField(row, 'city', 'City') || ''),
zipCode: String(pickField(row, 'zipCode', 'ZipCode') || ''),
streetAddress1: String(pickField(row, 'streetAddress1', 'StreetAddress1') || ''),
streetAddress2: String(pickField(row, 'streetAddress2', 'StreetAddress2') || ''),
streetAddress3: String(pickField(row, 'streetAddress3', 'StreetAddress3') || ''),
streetNumber: String(pickField(row, 'houseNumber', 'HouseNumber') || ''),
},
};
}
function applyReceiverAddressById(id: number | undefined, { silentEmpty = false } = {}) {
if (id == null) {
if (!silentEmpty) {
Object.assign(singleForm.receiver, emptyReceiverForm());
}
selectedReceiverAddressId.value = undefined;
return;
}
const hit = receiverAddressOptions.value.find((opt) => opt.id === id);
if (!hit) return;
Object.assign(singleForm.receiver, {
...emptyReceiverForm(),
...hit.data,
});
selectedReceiverAddressId.value = id;
}
async function loadReceiverAddressOptions({ applyDefault = false } = {}) {
if (isAdmin.value && resolveSelectedCustomerId() == null) {
receiverAddressOptions.value = [];
if (applyDefault) {
applyReceiverAddressById(undefined, { silentEmpty: true });
}
return;
}
receiverAddressLoading.value = true;
try {
const res = await getSenderAddressList(
withCustomerId({
page: 1,
pageSize: 200,
addressType: 'receiver',
}),
);
receiverAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapReceiverAddressOption(item))
.filter((x): x is SenderAddressOption => !!x);
if (applyDefault && !isSingleRecipientComplete()) {
const defaultAddr =
receiverAddressOptions.value.find((x) => x.isDefault) || receiverAddressOptions.value[0];
if (defaultAddr) {
applyReceiverAddressById(defaultAddr.id);
}
} else if (selectedReceiverAddressId.value) {
const still = receiverAddressOptions.value.some(
(x) => x.id === selectedReceiverAddressId.value,
);
if (!still) selectedReceiverAddressId.value = undefined;
}
} catch (e: any) {
receiverAddressOptions.value = [];
Message.warning(e?.message || t('order.placeOrder.single.receiverLoadFailed'));
} finally {
receiverAddressLoading.value = false;
}
}
async function openReceiverModal() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
receiverModalVisible.value = true;
await loadReceiverAddressOptions({ applyDefault: false });
}
function handleReceiverAddressSelect(id: number | undefined) {
if (id == null) return;
applyReceiverAddressById(id);
}
function goToReceiverAddressManage() {
router.push({ path: '/user/senderAddress' });
}
const productAdditionalServiceOptions = ref<Array<{ value: string; label: string }>>([]);
const additionalServiceDictRows = ref<Array<{ dictCode: string; dictName: string }>>([]);
const selectedAdditionalServices = ref<string[]>([]);
function parseAdditionalServiceCodes(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((x) => String(x ?? '').trim()).filter(Boolean);
}
if (raw == null || raw === '') return [];
const s = String(raw).trim();
if (!s) return [];
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) {
return parsed.map((x) => String(x ?? '').trim()).filter(Boolean);
}
} catch {
/* ignore */
}
return s
.split(/[,,]/)
.map((x) => x.trim())
.filter(Boolean);
}
function labelAdditionalService(code: string) {
const hit = additionalServiceDictRows.value.find((r) => r.dictCode === code);
return hit?.dictName || code;
}
function applyProductAdditionalServiceCodes(codes: string[]) {
productAdditionalServiceOptions.value = codes.map((c) => ({
value: c,
label: labelAdditionalService(c),
}));
selectedAdditionalServices.value = selectedAdditionalServices.value.filter((c) =>
codes.includes(c),
);
}
async function loadProductAdditionalServices(productCode: string) {
const code = formTrim(productCode);
if (!code) {
productAdditionalServiceOptions.value = [];
selectedAdditionalServices.value = [];
return;
}
const selected = singleSalesProductOptions.value.find((opt) => opt.value === code);
if (!selected) {
productAdditionalServiceOptions.value = [];
selectedAdditionalServices.value = [];
return;
}
if (Array.isArray(selected.additionalServices)) {
applyProductAdditionalServiceCodes(selected.additionalServices);
return;
}
const productId = selected.productId;
if (!productId) {
applyProductAdditionalServiceCodes([]);
return;
}
try {
const detail = await getSalesProductDetail(productId);
const codes = parseAdditionalServiceCodes(
detail?.additionalServices ?? detail?.AdditionalServices,
);
selected.additionalServices = codes;
applyProductAdditionalServiceCodes(codes);
} catch {
applyProductAdditionalServiceCodes([]);
}
}
const singleForm = reactive<any>({
clientNo: '',
productCode: '',
cargoAttribute: '',
exportCustomsMode: '',
warehouseId: '',
inboundMethod: 'selfShip',
senderAddressId: '',
expectPickupTime: '',
carrierService: '',
length: '10',
width: '10',
height: '10',
actualWeight: '',
isCod: false,
codCurrency: '',
codSum: '',
declaredCurrency: 'USD',
quantity: '1',
additionalServices: [] as string[],
receiver: {
recipientsName: '',
companyName: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
streetNumber: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
zipCode: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: '',
vat: '',
},
sender: {
senderName: '',
company: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
houseNumber: '',
},
items: [
{
cnName: '',
enName: '',
sku: '',
unit: '',
unitPrice: '',
unitWeight: '',
quantity: '',
amount: '',
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',
brandType: '',
isMagnetic: false,
distribution: '',
material: '',
enMaterial: '',
purpose: '',
attributes: '',
originCountry: '',
salesLink: '',
model: '',
specifications: '',
comment: '',
},
],
});
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');
if (usd) return usd.value;
return opts[0]?.value ?? 'USD';
}
function formatDerivedDeclarationNumber(value: number) {
if (Number.isInteger(value)) return String(value);
return value.toFixed(4).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
}
function syncDeclarationRowDerivedFields(item: any) {
const unitPrice = parseNumberValue(item.unitPrice);
const unitWeight = parseNumberValue(item.unitWeight);
const quantity = parseNumberValue(item.quantity);
if (unitPrice !== undefined && quantity !== undefined) {
item.amount = formatDerivedDeclarationNumber(unitPrice * quantity);
} else {
item.amount = '';
}
if (unitWeight !== undefined && quantity !== undefined) {
item.weight = formatDerivedDeclarationNumber(unitWeight * quantity);
} else {
item.weight = '';
}
}
function syncAllDeclarationRowDerivedFields() {
(singleForm.items || []).forEach((item: any) => syncDeclarationRowDerivedFields(item));
}
function calcSingleDeclaredTotalAmount() {
const items = singleForm.items || [];
let total = 0;
let hasValue = false;
for (const item of items) {
const amount = parseNumberValue(item.amount);
if (amount !== undefined) {
total += amount;
hasValue = true;
}
}
return hasValue ? total : undefined;
}
function formatDeclaredTotalDisplay(total: number | undefined) {
if (total === undefined) return '';
return formatDerivedDeclarationNumber(total);
}
/** 申报总金额输入框(可编辑;失焦时反算单价) */
const declaredTotalInput = ref('');
const declaredTotalInputFocused = ref(false);
function syncDeclaredTotalInputFromRows() {
if (declaredTotalInputFocused.value) return;
declaredTotalInput.value = formatDeclaredTotalDisplay(calcSingleDeclaredTotalAmount());
}
/** 填写申报总金额 → 反算单价(默认作用在第一行;多行时扣掉其余行金额) */
function applyDeclaredTotalReverse(raw: string) {
const total = parseNumberValue(raw);
if (total === undefined || total < 0) {
syncDeclaredTotalInputFromRows();
return;
}
const items = singleForm.items || [];
if (!items.length) return;
const othersSum = items.slice(1).reduce((sum, item) => {
const amount = parseNumberValue(item.amount);
return sum + (amount ?? 0);
}, 0);
let firstAmount = items.length === 1 ? total : total - othersSum;
if (firstAmount < 0) firstAmount = 0;
const row = items[0];
let qty = parseNumberValue(row.quantity);
if (qty === undefined || qty <= 0) {
row.quantity = '1';
qty = 1;
}
row.amount = formatDerivedDeclarationNumber(firstAmount);
row.unitPrice = formatDerivedDeclarationNumber(firstAmount / qty);
declaredTotalInput.value = formatDeclaredTotalDisplay(calcSingleDeclaredTotalAmount());
}
function handleDeclaredTotalFocus() {
declaredTotalInputFocused.value = true;
}
function handleDeclaredTotalBlur() {
declaredTotalInputFocused.value = false;
applyDeclaredTotalReverse(declaredTotalInput.value);
}
function handleDeclaredTotalChange(val: string | number) {
declaredTotalInput.value = String(val ?? '');
if (!declaredTotalInputFocused.value) {
applyDeclaredTotalReverse(declaredTotalInput.value);
}
}
const singleDeclarationColumns: Array<{
key: string
label: string
type?: 'text' | 'number' | 'boolean'
/** 由单价/单重/数量自动计算,只读 */
derived?: boolean
/** 仅与 /order/parcel/create 请求体 JSON 行内「//必填」一致 */
required?: boolean
/** 默认折叠的扩展字段 */
extra?: boolean
}> = [
{ key: 'enName', label: '品名(英)', required: true },
{ key: 'unitPrice', label: '单价', type: 'number', required: true },
{ key: 'unitWeight', label: '单重(kg)', type: 'number', required: true },
{ key: 'quantity', label: '数量', type: 'number', required: true },
{ key: 'amount', label: '总金额', type: 'number', derived: true },
{ key: 'weight', label: '总重量(kg)', type: 'number', derived: true },
{ key: 'cnName', label: '品名(中)' },
{ key: 'sku', label: 'SKU' },
{ key: 'unit', label: '单位' },
{ key: 'imHsCode', label: '进口HsCode', extra: true },
{ key: 'exHsCode', label: '出口HsCode', extra: true },
{ key: 'declareElement', label: '出口申报要素', extra: true },
{ key: 'skuImage', label: '图片', extra: true },
{ key: 'category', label: '类别', extra: true },
{ key: 'brand', label: '品牌', extra: true },
{ key: 'brandType', label: '品牌类型', extra: true },
{ key: 'isMagnetic', label: '是否带磁带电', type: 'boolean', extra: true },
{ key: 'distribution', label: '配货信息', extra: true },
{ key: 'material', label: '材质', extra: true },
{ key: 'enMaterial', label: '英文材质', extra: true },
{ key: 'purpose', label: '用途', extra: true },
{ key: 'attributes', label: '属性', extra: true },
{ key: 'originCountry', label: '原产国', extra: true },
{ key: 'salesLink', label: '销售链接', extra: true },
{ key: 'model', label: '型号', extra: true },
{ key: 'specifications', label: '规格', extra: true },
{ key: 'comment', label: '备注', extra: true },
];
const visibleDeclarationColumns = computed(() =>
singleDeclarationColumns.filter((col) => showDeclarationExtra.value || !(col as any).extra),
);
function handleViewShippingCustomerGuide() {
const productCode = formTrim(singleForm.productCode);
if (!productCode) return;
const selected = singleSalesProductOptions.value.find((opt) => opt.value === productCode);
if (!selected?.productId) {
Message.warning(t('order.priceCheck.customerGuide.missingProduct'));
return;
}
customerGuideDrawerRef.value?.open({
productId: selected.productId,
shippingMethod: selected.productName || selected.label,
});
}
async function loadSingleFormReferenceOptions() {
singleReferenceLoading.value = true;
try {
const [
productRes,
cargoDictRows,
exportCustomsDictRows,
warehouseRes,
currencyDictRows,
countryRes,
salesProductGroupDictRows,
additionalServiceRows,
] = await Promise.all([
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'cargo_attribute' }),
getDictListAll({ dictType: 'export_customs_mode' }),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'currency' }),
getCountryList(),
getDictListAll({ dictType: 'sales_product_group' }),
getDictListAll({ dictType: 'additional_service' }),
]);
additionalServiceDictRows.value = (additionalServiceRows ?? [])
.map((r: any) => ({
dictCode: String(r?.dictCode ?? r?.DictCode ?? '').trim(),
dictName: String(r?.dictName ?? r?.DictName ?? '').trim(),
}))
.filter((r) => r.dictCode);
const domesticPickupGroupCodes = new Set(
(salesProductGroupDictRows ?? [])
.filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收'))
.map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim())
.filter((code: string) => Boolean(code)),
);
singleSalesProductOptions.value = (productRes.items ?? [])
.filter((row: any) => {
const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim();
return domesticPickupGroupCodes.size ? !domesticPickupGroupCodes.has(groupCode) : true;
})
.map(normalizeProductRow)
.filter((p) => p.code)
.map((p) => ({
value: p.code,
label: p.name ? `${p.name} ${p.code}` : p.code,
productId: p.productId,
productName: p.name || p.code,
additionalServices: p.additionalServices ?? [],
}));
singleDomesticPickupProductOptions.value = (productRes.items ?? [])
.filter((row: any) => {
const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim();
return domesticPickupGroupCodes.size > 0 && domesticPickupGroupCodes.has(groupCode);
})
.map(normalizeProductRow)
.filter((p) => p.code)
.map((p) => ({
value: p.code,
label: p.name ? `${p.name} (${p.code})` : p.code,
}));
const cargoOpts = (cargoDictRows ?? [])
.map(normalizeCargoDictRow)
.filter((d) => d.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
.map((d) => ({ value: d.code, label: d.name || d.code }));
singleCargoAttributeOptions.value = cargoOpts;
if (cargoOpts.length && !String(singleForm.cargoAttribute ?? '').trim()) {
singleForm.cargoAttribute = cargoOpts[0].value;
}
const exportOpts = (exportCustomsDictRows ?? [])
.map(normalizeCargoDictRow)
.filter((d) => d.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
.map((d) => ({ value: d.code, label: d.name || d.code }));
singleExportCustomsModeOptions.value = exportOpts;
const defaultExportMode = resolveExportCustomsMode9610Simple();
const currentExportMode = String(singleForm.exportCustomsMode ?? '').trim();
if (!currentExportMode || currentExportMode === '1') {
singleForm.exportCustomsMode = defaultExportMode;
}
const whOpts = (warehouseRes.items ?? [])
.map(normalizeWarehouseRow)
.filter((w) => 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;
}
const curOpts = (currencyDictRows ?? [])
.map(normalizeCurrencyDictRow)
.filter((c) => c.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
.map((c) => ({ value: c.code, label: c.code }));
singleCurrencyOptions.value = curOpts;
if (!String(singleForm.declaredCurrency ?? '').trim()) {
singleForm.declaredCurrency = resolveDefaultDeclaredCurrency();
}
singleCountryOptions.value = (countryRes.items ?? [])
.map(normalizeCountryRow)
.filter((c) => c.code)
.map((c) => ({
value: c.code,
label: c.name ? `${c.code} ${c.name}` : c.code,
}));
applyPriceCheckDraft();
await loadProductAdditionalServices(singleForm.productCode);
await loadSenderAddressOptions({ applyDefault: true });
await loadPickupAddressOptions();
} catch (e: any) {
Message.error(e?.message || '加载下拉选项失败');
singleSalesProductOptions.value = [];
singleDomesticPickupProductOptions.value = [];
singleCargoAttributeOptions.value = [];
singleExportCustomsModeOptions.value = [];
singleWarehouseOptions.value = [];
singleCurrencyOptions.value = [];
singleCountryOptions.value = [];
productAdditionalServiceOptions.value = [];
} finally {
singleReferenceLoading.value = false;
}
}
function applyPriceCheckDraft() {
const fromPriceCheck = String(route.query.from || '') === 'priceCheck';
let draft: Record<string, any> | null = null;
try {
const raw = sessionStorage.getItem(PRICE_CHECK_ORDER_DRAFT_KEY);
draft = raw ? JSON.parse(raw) : null;
} catch {
draft = null;
}
if (!fromPriceCheck && !draft) return;
activeOrderMode.value = 'single';
const productCode = String(route.query.productCode || draft?.productCode || '').trim();
const countryCode = String(route.query.countryCode || draft?.countryCode || '')
.trim()
.toUpperCase();
const cargoAttribute = normalizeCargoAttributeCode(draft?.cargoAttribute || '');
const warehouseId = String(draft?.warehouseId || '').trim();
const actualWeight = String(draft?.actualWeight || '').trim();
const length = String(draft?.length || '').trim();
const width = String(draft?.width || '').trim();
const height = String(draft?.height || '').trim();
const zipCode = String(draft?.zipCode || '').trim();
const quoteLevel = String(draft?.quoteLevel || '').trim().toLowerCase();
const disclaimers = Array.isArray(draft?.disclaimers)
? draft.disclaimers.map((x: unknown) => String(x ?? '').trim()).filter(Boolean)
: [];
const filled: string[] = [];
if (productCode) {
const hit = singleSalesProductOptions.value.find((opt) => opt.value === productCode);
singleForm.productCode = hit?.value || productCode;
filled.push(t('order.placeOrder.fromPriceCheck.item.product'));
}
const draftServices = parseAdditionalServiceCodes(draft?.additionalServices);
if (draftServices.length) {
selectedAdditionalServices.value = draftServices;
filled.push(t('order.placeOrder.form.additionalServices'));
}
if (cargoAttribute) {
const hit = singleCargoAttributeOptions.value.find((opt) => opt.value === cargoAttribute);
singleForm.cargoAttribute = hit?.value || cargoAttribute;
filled.push(t('order.placeOrder.fromPriceCheck.item.cargo'));
}
if (warehouseId) {
const hit = singleWarehouseOptions.value.find((opt) => String(opt.value) === warehouseId);
singleForm.warehouseId = hit?.value || warehouseId;
filled.push(t('order.placeOrder.fromPriceCheck.item.warehouse'));
}
if (actualWeight) {
singleForm.actualWeight = actualWeight;
filled.push(t('order.placeOrder.fromPriceCheck.item.weight'));
}
if (length && width && height) {
singleForm.length = length;
singleForm.width = width;
singleForm.height = height;
filled.push(t('order.placeOrder.fromPriceCheck.item.dimensions'));
}
if (countryCode) {
singleForm.receiver.countryCode = countryCode;
filled.push(t('order.placeOrder.fromPriceCheck.item.country'));
}
if (zipCode) {
singleForm.receiver.zipCode = zipCode;
filled.push(t('order.placeOrder.fromPriceCheck.item.zip'));
}
const pending = [
t('order.placeOrder.fromPriceCheck.item.clientNo'),
t('order.placeOrder.fromPriceCheck.item.receiverName'),
t('order.placeOrder.fromPriceCheck.item.receiverPhone'),
t('order.placeOrder.fromPriceCheck.item.receiverAddress'),
t('order.placeOrder.fromPriceCheck.item.declaration'),
];
if (!(length && width && height)) {
pending.unshift(t('order.placeOrder.fromPriceCheck.item.dimensions'));
}
priceCheckHandoff.value = {
visible: true,
filled,
pending,
quoteLevel: quoteLevel || undefined,
disclaimers,
};
nextTick(() => {
focusSingleSection('recipient');
});
try {
sessionStorage.removeItem(PRICE_CHECK_ORDER_DRAFT_KEY);
} catch {
// ignore
}
}
onMounted(() => {
loadSingleFormReferenceOptions();
loadReceiverAiPrefs();
void Promise.all([loadReceiverAiProviders(), loadReceiverAiSkills()]);
void (async () => {
if (isAdmin.value) return;
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactBlocked.value = true;
contactProfile.value = profile;
contactVisible.value = true;
}
})();
});
function onContactCompleted() {
contactBlocked.value = false;
}
function onContactCancelled() {
if (contactBlocked.value) {
Message.warning(t('h5.contact.requiredToast'));
router.replace('/home/workplace').catch(() => undefined);
}
}
function clearUploadedFile() {
pendingUploadFile.value = null;
if (nativeFileInputRef.value) {
nativeFileInputRef.value.value = '';
}
}
const nanaBatchPanel = useNanaBatchPanel();
function navigateToBatchJob(jobId: number) {
void nanaBatchPanel.open(jobId, { attention: true, delayMs: 120 });
}
function goToBatchJobs() {
void nanaBatchPanel.open(null, { attention: true });
}
function handleSelectTemplate(template: TemplateRow) {
if (template.id > 0) {
selectedTemplate.value = template.id;
}
}
const handleBatchOrder = async () => {
if (!pendingUploadFile.value) {
Message.warning('请先选择 Excel 文件');
return;
}
if (!selectedTemplate.value) {
Message.warning(t('order.placeOrder.upload.selectTemplateFirst'));
return;
}
if (!selectedTemplateReady.value) {
Message.warning('自定义模板需先完成字段映射');
return;
}
const uploadFile = pendingUploadFile.value;
let previewTotal = 0;
try {
previewTotal = (await parseExcelToOrderPayloads(uploadFile)).length;
} catch {
previewTotal = 0;
}
batchSubmitting.value = true;
try {
const uploadResult = await postOrderUpload(
uploadFile,
selectedTemplate.value,
selectedSenderAddressId.value,
resolveSelectedCustomerId(),
);
clearUploadedFile();
if (uploadResult.jobId > 0) {
let total: number | string = previewTotal > 0 ? previewTotal : '-';
try {
const job = await getOrderBatchJob(uploadResult.jobId);
if (job.total > 0) total = job.total;
} catch {
// 任务可能尚未落库,沿用前端预读条数
}
Message.success(
uploadResult.async
? t('order.placeOrder.batch.submitted', {
jobId: uploadResult.jobId,
total,
})
: t('order.placeOrder.batch.syncCompleted', {
jobId: uploadResult.jobId,
total,
}),
);
navigateToBatchJob(uploadResult.jobId);
return;
}
Message.success('文件已提交处理');
} catch (e: any) {
Message.error(e?.message || '上传失败');
} finally {
batchSubmitting.value = false;
}
};
type SingleFieldError = { message: string; section: SingleFormSection; field: string };
/** 收集全部必填错误,供字段红框一次性标出 */
function collectSingleFieldErrors(): SingleFieldError[] {
const errors: SingleFieldError[] = [];
if (!formTrim(singleForm.clientNo)) {
errors.push({ message: '请填写客户单号', section: 'basic', field: 'clientNo' });
}
if (!formTrim(singleForm.productCode)) {
errors.push({ message: '请选择运输方式', section: 'basic', field: 'productCode' });
}
if (!formTrim(singleForm.cargoAttribute)) {
errors.push({ message: '请选择货物属性', section: 'basic', field: 'cargoAttribute' });
}
if (formTrim(singleForm.warehouseId) === '' || parseNumberValue(singleForm.warehouseId) === undefined) {
errors.push({ message: '请选择仓库', section: 'basic', field: 'warehouseId' });
}
if (String(singleForm.inboundMethod || '') === 'doorPickup') {
if (!parseNumberValue(singleForm.senderAddressId)) {
errors.push({ message: '上门揽收请选择国内揽收地址', section: 'basic', field: 'senderAddressId' });
}
if (!formTrim(singleForm.expectPickupTime)) {
errors.push({ message: '上门揽收请填写期望揽收时间', section: 'basic', field: 'expectPickupTime' });
}
}
if (
formTrim(String(singleForm.actualWeight ?? '')) === '' ||
parseNumberValue(singleForm.actualWeight) === undefined
) {
errors.push({ message: '请填写实重(kg)', section: 'basic', field: 'actualWeight' });
}
const length = parseNumberValue(singleForm.length);
const width = parseNumberValue(singleForm.width);
const height = parseNumberValue(singleForm.height);
if (length == null || length <= 0) {
errors.push({
message: t('order.placeOrder.validate.lengthRequired'),
section: 'basic',
field: 'length',
});
}
if (width == null || width <= 0) {
errors.push({
message: t('order.placeOrder.validate.widthRequired'),
section: 'basic',
field: 'width',
});
}
if (height == null || height <= 0) {
errors.push({
message: t('order.placeOrder.validate.heightRequired'),
section: 'basic',
field: 'height',
});
}
if (!formTrim(String(singleForm.quantity ?? ''))) {
errors.push({ message: '请填写包裹数量', section: 'basic', field: 'quantity' });
}
if (!formTrim(singleForm.sender?.senderName)) {
errors.push({
message: senderAddressOptions.value.length
? t('order.placeOrder.single.senderRequired')
: t('order.placeOrder.single.senderEmpty'),
section: 'sender',
field: 'sender.senderName',
});
}
const receiver = singleForm.receiver;
if (!formTrim(receiver.recipientsName)) {
errors.push({ message: '请填写收件人名称', section: 'recipient', field: 'receiver.recipientsName' });
}
if (!formTrim(receiver.phone)) {
errors.push({ message: '请填写收件人手机', section: 'recipient', field: 'receiver.phone' });
}
if (formTrim(receiver.countryCode) === '') {
errors.push({ message: '请选择收件国家', section: 'recipient', field: 'receiver.countryCode' });
}
if (!formTrim(receiver.streetAddress1)) {
errors.push({ message: '请填写收件人地址1', section: 'recipient', field: 'receiver.streetAddress1' });
}
if (!formTrim(receiver.zipCode)) {
errors.push({ message: '请填写收件人邮编', section: 'recipient', field: 'receiver.zipCode' });
}
if (!formTrim(singleForm.declaredCurrency)) {
errors.push({ message: '请选择申报币种', section: 'declaration', field: 'declaredCurrency' });
}
const lines = singleForm.items || [];
for (let i = 0; i < lines.length; i += 1) {
const row = lines[i];
if (!formTrim(row?.enName)) {
errors.push({
message: `申报明细第 ${i + 1} 行:请填写品名(英)`,
section: 'declaration',
field: `items.${i}.enName`,
});
}
if (parseNumberValue(row?.unitPrice) === undefined) {
errors.push({
message: `申报明细第 ${i + 1} 行:请填写单价`,
section: 'declaration',
field: `items.${i}.unitPrice`,
});
}
if (parseNumberValue(row?.unitWeight) === undefined) {
errors.push({
message: `申报明细第 ${i + 1} 行:请填写单重`,
section: 'declaration',
field: `items.${i}.unitWeight`,
});
}
if (parseNumberValue(row?.quantity) === undefined) {
errors.push({
message: `申报明细第 ${i + 1} 行:请填写数量`,
section: 'declaration',
field: `items.${i}.quantity`,
});
}
}
return errors;
}
/** 仅按 oms 文档 POST /order/parcel/create 请求体 JSON 行内「//必填」 */
function getSingleFormValidationError(): SingleFieldError | null {
return collectSingleFieldErrors()[0] ?? null;
}
function isDeclarationRowStarted(row: any) {
return Boolean(
formTrim(row?.enName) ||
formTrim(row?.cnName) ||
formTrim(String(row?.unitPrice ?? '')) ||
formTrim(String(row?.unitWeight ?? '')) ||
formTrim(String(row?.quantity ?? '')),
);
}
const singleFieldErrorMap = computed(() => {
const map: Record<string, string> = {};
const liveDeclaration =
isSingleBasicComplete() && isSingleSenderFilled() && isSingleRecipientComplete();
collectSingleFieldErrors().forEach((err) => {
if (singleShowFieldErrors.value) {
if (!map[err.field]) map[err.field] = err.message;
return;
}
if (!(liveDeclaration && err.section === 'declaration')) return;
// 币种始终即时提示;明细行仅在开始填写后标出缺项
if (err.field === 'declaredCurrency') {
if (!map[err.field]) map[err.field] = err.message;
return;
}
const match = /^items\.(\d+)\./.exec(err.field);
if (!match) return;
const row = singleForm.items?.[Number(match[1])];
if (!isDeclarationRowStarted(row)) return;
if (!map[err.field]) map[err.field] = err.message;
});
return map;
});
const declarationLiveHint = computed(() => {
if (!(isSingleBasicComplete() && isSingleSenderFilled() && isSingleRecipientComplete())) {
return '';
}
if (isSingleDeclarationComplete() && formTrim(singleForm.declaredCurrency)) return '';
const first = collectSingleFieldErrors().find((err) => err.section === 'declaration');
return first?.message || '';
});
function fieldValidateStatus(field: string) {
return singleFieldErrorMap.value[field] ? 'error' : undefined;
}
function fieldHelp(field: string) {
return singleFieldErrorMap.value[field];
}
function isDeclarationCellError(rowIndex: number, colKey: string) {
return Boolean(singleFieldErrorMap.value[`items.${rowIndex}.${colKey}`]);
}
function validateSingleParcelForm(): string | null {
return getSingleFormValidationError()?.message ?? null;
}
function omitEmptyOptionalOrderFields(payload: Record<string, any>) {
delete payload.trackNo;
delete payload.serviceNo;
return payload;
}
function normalizeCountryCode(value: unknown) {
return String(value ?? '').trim().toUpperCase();
}
function buildSinglePayload() {
const exportMode = String(singleForm.exportCustomsMode ?? '').trim();
const length = parseNumberValue(singleForm.length);
const width = parseNumberValue(singleForm.width);
const height = parseNumberValue(singleForm.height);
const payload: Record<string, any> = {
...singleForm,
cargoAttribute: normalizeCargoAttributeCode(singleForm.cargoAttribute),
warehouseId: parseNumberValue(singleForm.warehouseId) ?? 0,
length,
width,
height,
actualWeight: parseNumberValue(singleForm.actualWeight) ?? 0,
isCod: Boolean(singleForm.isCod),
additionalServices: [...selectedAdditionalServices.value],
receiver: {
...singleForm.receiver,
countryCode: normalizeCountryCode(singleForm.receiver.countryCode),
taxType: parseNumberValue(singleForm.receiver.taxType) ?? 0,
certificateType: parseNumberValue(singleForm.receiver.certificateType) ?? 0,
},
sender: {
...singleForm.sender,
countryCode: normalizeCountryCode(singleForm.sender.countryCode) || undefined,
},
items: (singleForm.items || []).map((item: any) => ({
...item,
unitPrice: parseNumberValue(item.unitPrice) ?? 0,
unitWeight: parseNumberValue(item.unitWeight) ?? 0,
quantity: parseNumberValue(item.quantity) ?? 0,
amount: parseNumberValue(item.amount) ?? 0,
weight: parseNumberValue(item.weight) ?? 0,
isMagnetic: Boolean(item.isMagnetic),
skuImage: normalizeSkuImageForStorage(item.skuImage),
})),
};
payload.exportCustomsMode = exportMode || resolveExportCustomsMode9610Simple();
const declaredTotal = calcSingleDeclaredTotalAmount();
if (declaredTotal !== undefined) {
payload.declaredSum = declaredTotal;
}
const inbound = String(singleForm.inboundMethod || 'selfShip').trim() || 'selfShip';
payload.inboundMethod = inbound;
const mutationCustomerId = resolveSelectedCustomerId();
if (mutationCustomerId != null) {
payload.customerId = mutationCustomerId;
}
if (inbound === 'doorPickup') {
payload.senderAddressId = parseNumberValue(singleForm.senderAddressId);
payload.expectPickupTime = formTrim(singleForm.expectPickupTime) || undefined;
payload.carrierService = formTrim(singleForm.carrierService) || undefined;
delete payload.carrierCode;
} else {
delete payload.senderAddressId;
delete payload.expectPickupTime;
delete payload.carrierCode;
delete payload.carrierService;
}
return omitEmptyOptionalOrderFields(payload);
}
function handleAddDeclarationRow() {
singleForm.items.push({
cnName: '',
enName: '',
sku: '',
unit: '',
unitPrice: '',
unitWeight: '',
quantity: '',
amount: '',
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',
brandType: '',
isMagnetic: false,
distribution: '',
material: '',
enMaterial: '',
purpose: '',
attributes: '',
originCountry: '',
salesLink: '',
model: '',
specifications: '',
comment: '',
});
}
function handleRemoveDeclarationRow(index: number) {
if (singleForm.items.length <= 1) return;
singleForm.items.splice(index, 1);
}
function mapSkuToDeclarationFields(raw: Record<string, unknown>) {
return mapWarehouseSkuToDeclarationFields(raw);
}
function resolveDeclarationSkuTargetIndex() {
const items = singleForm.items || [];
const emptyIdx = items.findIndex((row) => !isDeclarationRowStarted(row));
if (emptyIdx >= 0) return emptyIdx;
return -1;
}
function openDeclarationSkuModal() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
declarationSkuModalVisible.value = true;
}
function handleDeclarationSkuSelect(raw: Record<string, unknown>) {
let targetIdx = resolveDeclarationSkuTargetIndex();
if (targetIdx < 0) {
handleAddDeclarationRow();
targetIdx = singleForm.items.length - 1;
}
const mapped = mapSkuToDeclarationFields(raw);
Object.assign(singleForm.items[targetIdx], {
...singleForm.items[targetIdx],
...mapped,
});
if (!formTrim(singleForm.items[targetIdx].quantity)) {
singleForm.items[targetIdx].quantity = '1';
}
syncDeclarationRowDerivedFields(singleForm.items[targetIdx]);
if (
formTrim(mapped.exHsCode)
|| formTrim(mapped.imHsCode)
|| formTrim(mapped.declareElement)
|| formTrim(mapped.skuImage)
|| formTrim(mapped.comment)
) {
showDeclarationExtra.value = true;
}
Message.success(t('order.placeOrder.single.declarationSkuFilled'));
}
function randomOrderNo(prefix: string) {
const now = new Date();
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const suffix = Math.random().toString().slice(2, 8);
return `${prefix}${date}${suffix}`;
}
function handleGenerateSampleOrder() {
Modal.confirm({
title: t('order.placeOrder.single.sampleConfirmTitle'),
content: t('order.placeOrder.single.sampleConfirmContent'),
okText: t('order.placeOrder.single.sampleConfirmOk'),
cancelText: t('order.placeOrder.template.cancel'),
onOk: () => {
fillSampleOrder();
},
});
}
function fillSampleOrder() {
singleForm.clientNo = randomOrderNo('C');
singleForm.productCode = singleSalesProductOptions.value[0]?.value ?? 'PROD001';
singleForm.cargoAttribute = singleCargoAttributeOptions.value[0]?.value ?? 'general';
singleForm.exportCustomsMode = resolveExportCustomsMode9610Simple();
singleForm.warehouseId = singleWarehouseOptions.value[0]?.value ?? '';
singleForm.length = '10';
singleForm.width = '10';
singleForm.height = '10';
singleForm.actualWeight = '1.2';
singleForm.isCod = false;
singleForm.codCurrency = 'USD';
singleForm.codSum = '0';
singleForm.declaredCurrency = resolveDefaultDeclaredCurrency();
singleForm.quantity = '1';
selectedAdditionalServices.value = [];
void loadProductAdditionalServices(singleForm.productCode);
singleForm.sender.senderName = 'Demo Sender';
singleForm.sender.company = 'Demo Sender Co.,Ltd';
singleForm.sender.phone = '13800138000';
singleForm.sender.telePhone = '';
singleForm.sender.email = 'sender@example.com';
singleForm.sender.countryCode = 'CN';
singleForm.sender.province = 'Guangdong';
singleForm.sender.city = 'Shenzhen';
singleForm.sender.zipCode = '518000';
singleForm.sender.streetAddress1 = 'Nanshan District';
singleForm.sender.streetAddress2 = '';
singleForm.sender.streetAddress3 = '';
singleForm.sender.houseNumber = '88';
selectedSenderAddressId.value = undefined;
selectedReceiverAddressId.value = undefined;
singleForm.receiver.recipientsName = 'Demo Receiver';
singleForm.receiver.companyName = 'Demo Receiver LLC';
singleForm.receiver.phone = '13900139000';
singleForm.receiver.telePhone = '';
singleForm.receiver.email = 'receiver@example.com';
singleForm.receiver.countryCode = 'US';
singleForm.receiver.province = 'California';
singleForm.receiver.city = 'Los Angeles';
singleForm.receiver.streetNumber = '1001';
singleForm.receiver.streetAddress1 = 'Sunset Blvd';
singleForm.receiver.streetAddress2 = '';
singleForm.receiver.streetAddress3 = '';
singleForm.receiver.zipCode = '90001';
singleForm.receiver.taxType = '0';
singleForm.receiver.taxNo = '';
singleForm.receiver.certificateType = '0';
singleForm.receiver.certificateNo = '';
singleForm.receiver.eori = '';
singleForm.receiver.vat = '';
singleForm.items = [
{
cnName: '手机壳',
enName: 'Phone Case',
sku: 'SKU001',
unit: 'pcs',
unitPrice: '12.5',
unitWeight: '0.3',
quantity: '1',
amount: '12.5',
weight: '0.3',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: 'Accessories',
brand: 'DemoBrand',
brandType: '',
isMagnetic: false,
distribution: '',
material: 'plastic',
enMaterial: 'plastic',
purpose: 'protection',
attributes: '',
originCountry: 'CN',
salesLink: '',
model: 'Model-X',
specifications: 'Standard',
comment: '示例订单数据',
},
];
Message.success('Mock 订单已填入');
}
function scrollSectionIntoView(section: string) {
const scrollEl = singleFormScrollRef.value;
const panelEl = singlePanelRefs[section];
if (!scrollEl || !panelEl) return;
const nextTop =
panelEl.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop -
8;
scrollEl.scrollTo({
top: Math.max(0, nextTop),
behavior: 'smooth',
});
}
function focusSingleSection(section: string) {
if (section === 'sender') {
void openSenderModal();
return;
}
if (section === 'recipient') {
void openReceiverModal();
nextTick(() => {
scrollSectionIntoView('recipient');
});
return;
}
nextTick(() => {
scrollSectionIntoView(section);
});
}
const createFailVisible = ref(false);
const createFailCancelling = ref(false);
const createFailSavingDraft = ref(false);
const createFailInfo = ref<ParcelCreateFailGuide | null>(null);
function emptyDeclarationItem() {
return {
cnName: '',
enName: '',
sku: '',
unit: '',
unitPrice: '',
unitWeight: '',
quantity: '',
amount: '',
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',
brandType: '',
isMagnetic: false,
distribution: '',
material: '',
enMaterial: '',
purpose: '',
attributes: '',
originCountry: '',
salesLink: '',
model: '',
specifications: '',
comment: '',
};
}
function resetSingleForm() {
selectedAdditionalServices.value = [];
productAdditionalServiceOptions.value = [];
Object.assign(singleForm, {
clientNo: '',
productCode: '',
cargoAttribute: '',
exportCustomsMode: '',
warehouseId: '',
length: '10',
width: '10',
height: '10',
actualWeight: '',
inboundMethod: 'selfShip',
senderAddressId: '',
expectPickupTime: '',
carrierService: '',
isCod: false,
codCurrency: '',
codSum: '',
declaredCurrency: resolveDefaultDeclaredCurrency(),
quantity: '1',
additionalServices: [],
});
Object.assign(singleForm.receiver, {
recipientsName: '',
companyName: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
streetNumber: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
zipCode: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: '',
vat: '',
});
Object.assign(singleForm.sender, emptySenderForm());
singleForm.items = [emptyDeclarationItem()];
selectedSenderAddressId.value = undefined;
selectedReceiverAddressId.value = undefined;
singleShowFieldErrors.value = false;
singleAgreementChecked.value = false;
priceCheckHandoff.value = null;
}
function openCreateFailModal(error: unknown) {
createFailInfo.value = buildParcelCreateFailGuide(error);
createFailVisible.value = true;
}
function closeCreateFailModal() {
createFailVisible.value = false;
}
function handleCreateFailContinueEdit() {
const info = createFailInfo.value;
closeCreateFailModal();
if (!info) return;
if (info.draftSaved && info.orderId) {
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: { status: 'draft', orderId: String(info.orderId), edit: '1' },
});
return;
}
singleShowFieldErrors.value = true;
focusSingleSection(info.section);
}
async function handleCreateFailSaveAsDraft() {
const info = createFailInfo.value;
if (!info || info.draftSaved) return;
if (isAdmin.value && ensureMutationCustomerId() == null) return;
createFailSavingDraft.value = true;
try {
const created = await postOrderParcelCreate({
...buildSinglePayload(),
saveAsDraft: true,
});
const orderId = Number(created?.orderId ?? created?.OrderId ?? created?.id ?? created?.Id ?? 0) || undefined;
closeCreateFailModal();
priceCheckHandoff.value = null;
singleShowFieldErrors.value = false;
Modal.confirm({
title: t('order.placeOrder.single.failSaveAsDraftOk'),
content: t('order.placeOrder.single.failDraftSavedNote'),
okText: t('order.placeOrder.single.failSaveAsDraftGo'),
cancelText: t('order.placeOrder.single.failSaveAsDraftStay'),
onOk: () => {
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: {
status: 'draft',
...(orderId ? { orderId: String(orderId), edit: '1' } : {}),
},
});
},
onCancel: () => {
resetSingleForm();
},
});
} catch (e: any) {
Message.error(e?.message || t('order.placeOrder.single.failSaveAsDraftFailed'));
} finally {
createFailSavingDraft.value = false;
}
}
function handleCreateFailAskSupport() {
const info = createFailInfo.value;
openAskAdvisor(router, {
orderId: info?.orderId ? String(info.orderId) : undefined,
waybillNo: info?.serviceNo,
from: rbacUserStore.isCustomerPortal() ? 'myOrders' : 'waybillManage',
orderStatus: info?.draftSaved ? 'draft' : undefined,
});
}
async function handleCreateFailCopyServiceNo() {
const no = createFailInfo.value?.serviceNo;
if (!no) return;
try {
await navigator.clipboard.writeText(no);
Message.success(t('order.placeOrder.single.failCopied'));
} catch {
Message.warning(t('order.placeOrder.single.failCopyFailed'));
}
}
function handleCreateFailGoDrafts() {
const info = createFailInfo.value;
closeCreateFailModal();
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: {
status: 'draft',
...(info?.orderId ? { orderId: String(info.orderId) } : {}),
},
});
}
function handleCreateFailCancelSubmit() {
const info = createFailInfo.value;
if (!info) {
closeCreateFailModal();
return;
}
if (info.draftSaved && info.orderId) {
Modal.confirm({
title: t('order.placeOrder.single.failCancelDraftTitle'),
content: t('order.placeOrder.single.failCancelDraftContent'),
okText: t('order.placeOrder.single.failCancelDraftOk'),
cancelText: t('order.placeOrder.single.failCancelKeep'),
okButtonProps: { status: 'danger' },
onOk: async () => {
createFailCancelling.value = true;
try {
await postOrderDelete({ id: info.orderId! });
Message.success(t('order.placeOrder.single.failCancelDraftDone'));
closeCreateFailModal();
resetSingleForm();
} catch (e: any) {
Message.error(e?.message || t('order.placeOrder.single.failCancelDraftFailed'));
} finally {
createFailCancelling.value = false;
}
},
});
return;
}
Modal.confirm({
title: t('order.placeOrder.single.failAbandonTitle'),
content: t('order.placeOrder.single.failAbandonContent'),
okText: t('order.placeOrder.single.failAbandonOk'),
cancelText: t('order.placeOrder.single.failCancelKeep'),
okButtonProps: { status: 'danger' },
onOk: () => {
closeCreateFailModal();
resetSingleForm();
Message.success(t('order.placeOrder.single.failAbandonDone'));
},
});
}
async function handleSingleOrderSubmit() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
const validationError = getSingleFormValidationError();
if (validationError) {
singleShowFieldErrors.value = true;
Message.warning(validationError.message);
focusSingleSection(validationError.section);
return;
}
singleSubmitting.value = true;
try {
const created: any = await postOrderParcelCreate(buildSinglePayload());
priceCheckHandoff.value = null;
singleShowFieldErrors.value = false;
const pickupNo = created?.pickupNo || created?.PickupNo;
const pickupErr = created?.pickupCreateError || created?.PickupCreateError;
let content = t('order.placeOrder.single.successContent');
if (pickupNo) {
content = `${content}\n揽收单号:${pickupNo}`;
} else if (pickupErr) {
content = `${content}\n揽收未创建成功:${pickupErr}(可到我的订单补建)`;
}
Modal.confirm({
title: t('order.placeOrder.single.successTitle'),
content,
okText: t('order.placeOrder.single.successGoOrders'),
cancelText: t('order.placeOrder.single.successStay'),
onOk: () => {
router.push({
path: rbacUserStore.isCustomerPortal()
? '/order/myOrders'
: '/order/waybillManage',
});
},
});
} catch (e: any) {
if (isCustomerContactRequiredError(e)) {
if (isAdmin.value) {
Message.warning(t('order.placeOrder.contact.adminCustomerIncomplete'));
} else {
contactBlocked.value = true;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
}
} else if (
String(e?.message || '').includes('发件人姓名不能为空') ||
/sender\s*name/i.test(String(e?.message || ''))
) {
Message.warning(
senderAddressOptions.value.length
? t('order.placeOrder.single.senderRequired')
: t('order.placeOrder.single.senderEmpty'),
);
void openSenderModal();
} else {
openCreateFailModal(e);
}
} finally {
singleSubmitting.value = false;
}
}
watch(
() => (singleForm.items || []).map(
(item: any) => `${item.unitPrice ?? ''}|${item.unitWeight ?? ''}|${item.quantity ?? ''}|${item.amount ?? ''}`,
),
() => {
syncAllDeclarationRowDerivedFields();
syncDeclaredTotalInputFromRows();
},
);
watch(
() => singleForm.productCode,
(code) => {
void loadProductAdditionalServices(String(code ?? ''));
},
);
watch(activeOrderMode, (mode) => {
if (mode === 'batch') {
void loadSenderAddressOptions({ applyDefault: !selectedSenderAddressId.value });
}
});
watch(adminCustomerId, () => {
if (!isAdmin.value) return;
applySenderAddressById(undefined);
applyReceiverAddressById(undefined, { silentEmpty: true });
singleForm.senderAddressId = '';
void loadSenderAddressOptions({ applyDefault: true });
void loadReceiverAddressOptions({ applyDefault: false });
void loadPickupAddressOptions();
});
function handleSingleFlowClick(stepKey: string) {
if (stepKey === 'sender') {
void openSenderModal();
return;
}
if (stepKey === 'recipient') {
void openReceiverModal();
nextTick(() => {
scrollSectionIntoView('recipient');
});
return;
}
focusSingleSection(stepKey);
}
</script>
<template>
<div class="place-order-container" :class="{ 'is-contact-blocked': contactBlocked }">
<CompleteContactModal
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactCompleted"
@cancel="onContactCancelled"
/>
<div v-if="contactBlocked" class="contact-block-banner">
<div class="contact-block-banner__text">
<strong>{{ t('order.placeOrder.contact.blockTitle') }}</strong>
<span>{{ t('order.placeOrder.contact.blockDesc') }}</span>
</div>
<a-button type="primary" size="small" @click="contactVisible = true">
{{ t('order.placeOrder.contact.blockAction') }}
</a-button>
</div>
<div class="place-order-tabs">
<div class="place-order-tabs-header">
<div class="tabs-slot">
<SlidingTabs v-model="activeOrderMode" :items="orderModeTabItems" />
</div>
</div>
<div v-show="activeOrderMode === 'single'" class="place-order-pane">
<div class="single-tab">
<a-alert
v-if="priceCheckHandoff?.visible"
type="info"
class="price-check-handoff-alert"
closable
@close="priceCheckHandoff = null"
>
<template #title>{{ t('order.placeOrder.fromPriceCheck.bannerTitle') }}</template>
<div class="handoff-body">
<p v-if="priceCheckHandoff.filled.length" class="handoff-line">
<span class="handoff-label">{{ t('order.placeOrder.fromPriceCheck.filledLabel') }}</span>
{{ priceCheckHandoff.filled.join('、') }}
</p>
<p class="handoff-line">
<span class="handoff-label">{{ t('order.placeOrder.fromPriceCheck.pendingLabel') }}</span>
{{ priceCheckHandoff.pending.join('、') }}
</p>
<p
v-if="priceCheckHandoff.quoteLevel === 'estimated' || priceCheckHandoff.disclaimers?.length"
class="handoff-line handoff-quote-warning"
>
{{ t('order.placeOrder.fromPriceCheck.quoteWarning') }}
<template v-if="priceCheckHandoff.disclaimers?.length">
{{ priceCheckHandoff.disclaimers.join(';') }}
</template>
</p>
</div>
</a-alert>
<div class="single-toolbar table-toolbar liquid-card">
<div class="toolbar-left batch-steps">
<template v-for="(step, idx) in singleFlowSteps" :key="step.key">
<button
type="button"
class="batch-step flow-step-btn"
:class="{
active: singleProgressStep >= idx + 1,
done: isSingleStepDone(step.key),
current: singleCurrentStepKey === step.key,
'is-sender': step.key === 'sender',
'is-sender-empty': step.key === 'sender' && !selectedSenderSummary,
'is-recipient': step.key === 'recipient',
'is-recipient-empty': step.key === 'recipient' && !selectedReceiverSummary,
}"
:title="
step.key === 'sender'
? selectedSenderSummary
? t('order.placeOrder.single.senderEditHint')
: t('order.placeOrder.single.senderEmpty')
: step.key === 'recipient'
? selectedReceiverSummary
? t('order.placeOrder.single.receiverEditHint')
: t('order.placeOrder.single.receiverEmpty')
: undefined
"
@click="handleSingleFlowClick(step.key)"
>
<span class="batch-step-index">
<icon-check v-if="isSingleStepDone(step.key)" class="batch-step-check" />
<template v-else>{{ idx + 1 }}</template>
</span>
<span class="batch-step-main">
<span class="batch-step-label">{{ step.label }}</span>
<span
v-if="step.key === 'sender'"
class="batch-step-sender-summary"
>
{{ selectedSenderSummary || t('order.placeOrder.single.senderPickHint') }}
</span>
<span
v-else-if="step.key === 'recipient'"
class="batch-step-sender-summary"
>
{{ selectedReceiverSummary || t('order.placeOrder.single.receiverPickHint') }}
</span>
</span>
</button>
<span
v-if="idx < singleFlowSteps.length - 1"
class="batch-step-sep"
:class="{ 'is-done': isSingleStepDone(step.key) }"
>→</span>
</template>
</div>
<div class="toolbar-right single-toolbar-right">
<a-select
v-if="isAdmin"
v-model="adminCustomerId"
allow-search
allow-clear
size="small"
:placeholder="t('order.placeOrder.selectCustomer')"
:loading="customerLoading"
:options="customerOptions"
class="place-order-customer-select"
/>
<a-button type="secondary" size="small" @click="handleGenerateSampleOrder">
{{ t('order.placeOrder.single.sampleOrder') }}
</a-button>
</div>
</div>
<div class="single-form-body">
<div ref="singleFormScrollRef" class="single-form-scroll">
<div class="single-cards-row">
<section
class="single-info-card liquid-card"
:ref="(el) => (singlePanelRefs.basic = (el as HTMLElement | null))"
>
<header class="single-info-card__header">
<span class="single-section-index">1</span>
<span class="single-section-title">{{ t('order.placeOrder.single.stepBasic') }}</span>
</header>
<div class="single-form-grid single-form-grid--compact">
<a-form-item :validate-status="fieldValidateStatus('productCode')" :help="fieldHelp('productCode')">
<template #label>
<FormLabelWithPreview
label="运输方式"
required
:has-value="!!formTrim(singleForm.productCode)"
:tooltip="t('order.placeOrder.single.viewCustomerGuide')"
@preview="handleViewShippingCustomerGuide"
/>
</template>
<a-select
v-model="singleForm.productCode"
allow-search
placeholder="请选择"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleSalesProductOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
label="客户单号"
required
:validate-status="fieldValidateStatus('clientNo')"
:help="fieldHelp('clientNo')"
>
<a-input v-model="singleForm.clientNo" />
</a-form-item>
<a-form-item
label="货物属性"
required
:validate-status="fieldValidateStatus('cargoAttribute')"
:help="fieldHelp('cargoAttribute')"
>
<a-select
v-model="singleForm.cargoAttribute"
allow-search
placeholder="请选择"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleCargoAttributeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
label="仓库"
required
:validate-status="fieldValidateStatus('warehouseId')"
:help="fieldHelp('warehouseId')"
>
<a-select
v-model="singleForm.warehouseId"
allow-search
placeholder="请选择"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleWarehouseOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
label="实重(kg)"
required
:validate-status="fieldValidateStatus('actualWeight')"
:help="fieldHelp('actualWeight')"
>
<a-input v-model="singleForm.actualWeight" />
</a-form-item>
<a-form-item
label="包裹数量"
required
:validate-status="fieldValidateStatus('quantity')"
:help="fieldHelp('quantity')"
>
<a-input v-model="singleForm.quantity" />
</a-form-item>
<a-form-item class="export-customs-mode-item">
<template #label>
<span class="export-customs-mode-label">
出口报关方式
<IconQuestionCircle
class="export-customs-help-icon"
@click.stop.prevent="exportCustomsGuideVisible = true"
/>
</span>
</template>
<a-select
v-model="singleForm.exportCustomsMode"
allow-clear
allow-search
:placeholder="t('order.placeOrder.single.exportCustomsPlaceholder')"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleExportCustomsModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
class="dims-form-item"
label="长宽高(cm)"
:validate-status="
fieldValidateStatus('length') ||
fieldValidateStatus('width') ||
fieldValidateStatus('height') ||
undefined
"
:help="fieldHelp('length') || fieldHelp('width') || fieldHelp('height')"
>
<div class="dims-group">
<a-input
v-model="singleForm.length"
placeholder="长"
:status="fieldValidateStatus('length')"
/>
<span class="dims-sep">×</span>
<a-input
v-model="singleForm.width"
placeholder="宽"
:status="fieldValidateStatus('width')"
/>
<span class="dims-sep">×</span>
<a-input
v-model="singleForm.height"
placeholder="高"
:status="fieldValidateStatus('height')"
/>
</div>
</a-form-item>
<a-form-item label="到仓方式" required class="inbound-method-item">
<a-radio-group
v-model="singleForm.inboundMethod"
type="button"
size="small"
class="inbound-method-radios"
>
<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">
上门揽收
<a-tooltip
content="上门揽收为付费服务,费用通常低于市场价。"
position="top"
>
<IconQuestionCircle
class="inbound-method-help"
@click.stop.prevent
/>
</a-tooltip>
</span>
</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="singleForm.inboundMethod === 'doorPickup'">
<a-form-item label="揽收地址" required>
<a-select
v-model="singleForm.senderAddressId"
allow-search
allow-clear
placeholder="请选择国内揽收地址"
:loading="pickupAddressLoading"
@change="onPickupSenderAddressChange"
@focus="loadPickupAddressOptions"
>
<a-option
v-for="opt in pickupSelectOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="期望揽收时间" required>
<a-date-picker
v-model="singleForm.expectPickupTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="承运服务">
<a-select
v-model="singleForm.carrierService"
allow-search
allow-clear
placeholder="选择国内揽收销售产品(可后补)"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleDomesticPickupProductOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<p class="inbound-pickup-batch-hint">
{{
rbacUserStore.isCustomerPortal()
? t('order.placeOrder.single.doorPickupBatchHintCustomer')
: t('order.placeOrder.single.doorPickupBatchHint')
}}
</p>
</template>
</div>
</section>
<section
class="single-info-card liquid-card single-info-card--recipient"
:ref="(el) => (singlePanelRefs.recipient = (el as HTMLElement | null))"
>
<header class="single-info-card__header">
<span class="single-section-index">3</span>
<span class="single-section-title">{{ t('order.placeOrder.single.stepRecipient') }}</span>
</header>
<div class="receiver-split">
<div class="receiver-split__form">
<div class="single-form-grid single-form-grid--compact">
<a-form-item
label="收件人名称"
required
:validate-status="fieldValidateStatus('receiver.recipientsName')"
:help="fieldHelp('receiver.recipientsName')"
>
<a-input v-model="singleForm.receiver.recipientsName" />
</a-form-item>
<a-form-item
label="收件人手机"
required
:validate-status="fieldValidateStatus('receiver.phone')"
:help="fieldHelp('receiver.phone')"
>
<a-input v-model="singleForm.receiver.phone" />
</a-form-item>
<a-form-item
label="国家"
required
:validate-status="fieldValidateStatus('receiver.countryCode')"
:help="fieldHelp('receiver.countryCode')"
>
<a-select
v-model="singleForm.receiver.countryCode"
allow-search
placeholder="请选择"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleCountryOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
label="邮编"
required
:validate-status="fieldValidateStatus('receiver.zipCode')"
:help="fieldHelp('receiver.zipCode')"
>
<a-input v-model="singleForm.receiver.zipCode" />
</a-form-item>
<a-form-item
class="span-2"
label="地址1"
required
:validate-status="fieldValidateStatus('receiver.streetAddress1')"
:help="fieldHelp('receiver.streetAddress1')"
>
<a-input v-model="singleForm.receiver.streetAddress1" />
</a-form-item>
<a-form-item label="省/州">
<a-input v-model="singleForm.receiver.province" />
</a-form-item>
<a-form-item label="城市">
<a-input v-model="singleForm.receiver.city" />
</a-form-item>
<a-form-item label="门牌号">
<a-input v-model="singleForm.receiver.streetNumber" />
</a-form-item>
<a-form-item label="收件人邮箱" class="street-number-with-optional">
<div class="street-number-row">
<a-input v-model="singleForm.receiver.email" />
<a-button
type="secondary"
size="small"
:loading="savingReceiverAddress"
@click="saveReceiverAddress"
>
{{ t('order.placeOrder.single.saveReceiver') }}
</a-button>
<button
type="button"
class="place-order-text-link"
@click="showRecipientOptional = !showRecipientOptional"
>
{{
showRecipientOptional
? t('order.placeOrder.single.hideRecipientOptional')
: t('order.placeOrder.single.showRecipientOptional')
}}
</button>
</div>
</a-form-item>
</div>
</div>
<aside
v-show="!showRecipientOptional"
class="receiver-ai-paste"
:class="{
'is-focused': receiverPasteFocused,
'is-dragging': receiverPasteDragging,
'is-loading': receiverParseLoading,
}"
@dragenter.prevent="receiverPasteDragging = true"
@dragover.prevent="receiverPasteDragging = true"
@dragleave="onReceiverPasteDragLeave"
@drop.prevent="onReceiverPasteDrop"
>
<div class="receiver-ai-paste__body">
<a-textarea
v-model="receiverPasteText"
class="receiver-ai-paste__textarea"
:disabled="receiverParseLoading"
:auto-size="{ minRows: 8, maxRows: 14 }"
:placeholder="`粘贴姓名、电话、地址、邮编、国家…\n国外派送失败60%的原因是因为地址信息不全`"
@focus="receiverPasteFocused = true"
@blur="receiverPasteFocused = false"
@paste="onReceiverTextPaste"
@keydown="onReceiverPasteKeydown"
/>
<button
v-if="receiverPasteText"
type="button"
class="receiver-ai-paste__clear"
:disabled="receiverParseLoading"
@click="clearReceiverPaste"
>
清空
</button>
<div v-if="receiverParseLoading" class="receiver-ai-paste__loading">
AI 分析中…
</div>
</div>
<div class="receiver-ai-paste__footer">
<p
v-if="receiverParseTip"
class="receiver-ai-paste__tip"
:class="`is-${receiverParseTip.tone}`"
>
{{ receiverParseTip.text }}
</p>
<a-button
type="primary"
size="small"
class="receiver-ai-paste__submit"
:loading="receiverParseLoading"
:disabled="!receiverPasteText.trim()"
@click="handleReceiverAiParse"
>
AI分析并填入
</a-button>
</div>
</aside>
<div
v-show="showRecipientOptional"
class="recipient-optional-panel"
>
<div class="single-form-grid single-form-grid--compact recipient-optional-grid">
<a-form-item label="收件人公司"><a-input v-model="singleForm.receiver.companyName" /></a-form-item>
<a-form-item label="收件人电话"><a-input v-model="singleForm.receiver.telePhone" /></a-form-item>
<a-form-item label="地址2"><a-input v-model="singleForm.receiver.streetAddress2" /></a-form-item>
<a-form-item label="地址3"><a-input v-model="singleForm.receiver.streetAddress3" /></a-form-item>
<a-form-item label="税号类型"><a-input v-model="singleForm.receiver.taxType" placeholder="数字,如 0" /></a-form-item>
<a-form-item label="税号"><a-input v-model="singleForm.receiver.taxNo" /></a-form-item>
<a-form-item label="证件类型"><a-input v-model="singleForm.receiver.certificateType" placeholder="数字,如 0" /></a-form-item>
<a-form-item label="证件号码"><a-input v-model="singleForm.receiver.certificateNo" /></a-form-item>
<a-form-item label="EORI"><a-input v-model="singleForm.receiver.eori" /></a-form-item>
<a-form-item label="VAT"><a-input v-model="singleForm.receiver.vat" /></a-form-item>
</div>
</div>
</div>
</section>
</div>
<section
class="single-info-card liquid-card declaration-card"
:ref="(el) => (singlePanelRefs.declaration = (el as HTMLElement | null))"
>
<header class="single-info-card__header declaration-card__header">
<div class="single-section-header-left">
<span class="single-section-index">4</span>
<span class="single-section-title">{{ t('order.placeOrder.single.stepDeclaration') }}</span>
<a-button type="secondary" size="small" shape="square" @click="openDeclarationSkuModal">
{{ t('order.placeOrder.single.declarationPickSku') }}
</a-button>
<button
type="button"
class="place-order-text-link"
@click="showDeclarationExtra = !showDeclarationExtra"
>
{{
showDeclarationExtra
? t('order.placeOrder.single.hideExtraCols')
: t('order.placeOrder.single.showExtraCols')
}}
</button>
</div>
<div class="single-section-header-right">
<div class="declaration-header-field">
<span class="declaration-header-label">申报总金额</span>
<a-input
v-model="declaredTotalInput"
class="declaration-header-input"
placeholder="可填,反算单价"
allow-clear
@focus="handleDeclaredTotalFocus"
@blur="handleDeclaredTotalBlur"
@change="handleDeclaredTotalChange"
@press-enter="handleDeclaredTotalBlur"
/>
</div>
<div class="declaration-header-field declaration-header-currency-row">
<span class="declaration-header-label required">申报币种</span>
<a-select
v-model="singleForm.declaredCurrency"
class="declaration-header-select"
:class="{ 'is-error': !!fieldHelp('declaredCurrency') }"
placeholder="请选择"
:status="fieldValidateStatus('declaredCurrency')"
:loading="singleReferenceLoading"
>
<a-option
v-for="opt in singleCurrencyOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
<span
v-if="fieldHelp('declaredCurrency')"
class="declaration-header-hint is-error-hint"
>
{{ fieldHelp('declaredCurrency') }}
</span>
<a-tooltip
v-else
:content="t('order.placeOrder.single.declaredCurrencyHint')"
position="tr"
:mouse-enter-delay="0"
:popup-translate="[0, -4]"
>
<span class="declaration-header-help" @click.stop.prevent>
<IconQuestionCircle />
</span>
</a-tooltip>
</div>
</div>
</header>
<div
class="single-declaration-table-wrap"
:style="{ height: `${DECLARATION_PANEL_HEIGHT}px` }"
>
<table class="single-declaration-table">
<thead>
<tr>
<th class="op-col">操作</th>
<th v-for="col in visibleDeclarationColumns" :key="col.key">
<span v-if="col.required" class="single-th-required">*</span>{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in singleForm.items" :key="index">
<td class="op-col">
<a-button
type="text"
size="mini"
title="新增"
@click="handleAddDeclarationRow"
>
<template #icon><icon-plus /></template>
</a-button>
<a-button
type="text"
size="mini"
status="danger"
title="删除"
:disabled="singleForm.items.length <= 1"
@click="handleRemoveDeclarationRow(index)"
>
<template #icon><icon-delete /></template>
</a-button>
</td>
<td v-for="col in visibleDeclarationColumns" :key="`${index}-${col.key}`">
<a-switch
v-if="col.type === 'boolean'"
v-model="item[col.key]"
size="small"
/>
<a-input
v-else-if="col.derived"
:model-value="item[col.key]"
readonly
size="small"
class="declaration-derived-input"
/>
<a-input
v-else
v-model="item[col.key]"
:type="col.type === 'number' ? 'number' : 'text'"
size="small"
:class="{ 'declaration-cell-error': isDeclarationCellError(index, col.key) }"
:status="isDeclarationCellError(index, col.key) ? 'error' : undefined"
:placeholder="
isDeclarationCellError(index, col.key)
? singleFieldErrorMap[`items.${index}.${col.key}`]
: undefined
"
/>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="declarationLiveHint" class="declaration-live-hint">{{ declarationLiveHint }}</p>
</section>
<div class="single-form-footer liquid-card">
<div
class="single-submit-hint"
:class="canSubmitSingle ? 'is-ready' : 'is-pending'"
>
{{ singleSubmitHint }}
</div>
<div
v-if="productAdditionalServiceOptions.length"
class="footer-additional-services"
>
<span class="footer-additional-services__label">
{{ t('order.placeOrder.form.additionalServices') }}
</span>
<a-checkbox-group v-model="selectedAdditionalServices">
<a-checkbox
v-for="opt in productAdditionalServiceOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
</div>
<div class="order-submit-actions">
<div class="order-agreement-bar">
<a-checkbox v-model="singleAgreementChecked" />
<span class="agreement-link" @click="openAgreementViewModal">
{{ t('order.placeOrder.agreement.label') }}
</span>
</div>
<a-button
type="primary"
class="single-order-btn"
:loading="singleSubmitting"
:disabled="!canSubmitSingle"
@click="handleSingleOrderClick"
>
{{ t('order.placeOrder.confirmOrder') }}
</a-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-show="activeOrderMode === 'batch'" class="place-order-pane">
<div class="batch-tab">
<div class="batch-toolbar table-toolbar liquid-card">
<div class="toolbar-left batch-steps">
<template v-for="(step, idx) in batchFlowSteps" :key="step.key">
<button
type="button"
class="batch-step flow-step-btn"
:class="{
active: batchStep >= idx + 1,
done: isBatchStepDone(step.key),
current:
batchCurrentStepKey === step.key ||
(step.key === 'template' && batchFocusTarget === 'template') ||
(step.key === 'upload' && batchFocusTarget === 'upload') ||
(step.key === 'submit' && batchFocusTarget === 'submit'),
'is-sender': step.key === 'sender',
'is-sender-empty': step.key === 'sender' && !selectedSenderSummary,
}"
:title="
step.key === 'sender'
? selectedSenderSummary
? t('order.placeOrder.single.senderEditHint')
: t('order.placeOrder.single.senderEmpty')
: undefined
"
@click="handleBatchFlowClick(step.key)"
>
<span class="batch-step-index">
<icon-check v-if="isBatchStepDone(step.key)" class="batch-step-check" />
<template v-else>{{ idx + 1 }}</template>
</span>
<span class="batch-step-main">
<span class="batch-step-label">{{ step.label }}</span>
<span
v-if="step.key === 'sender'"
class="batch-step-sender-summary"
>
{{ selectedSenderSummary || t('order.placeOrder.single.senderPickHint') }}
</span>
</span>
</button>
<span
v-if="idx < batchFlowSteps.length - 1"
class="batch-step-sep"
:class="{ 'is-done': isBatchStepDone(step.key) }"
>→</span>
</template>
<span class="batch-guide-hint">{{ t('order.placeOrder.batch.guideHint') }}</span>
</div>
<div class="toolbar-right">
<a-select
v-if="isAdmin"
v-model="adminCustomerId"
allow-search
allow-clear
size="small"
:placeholder="t('order.placeOrder.selectCustomer')"
:loading="customerLoading"
:options="customerOptions"
class="place-order-customer-select"
/>
<a-button size="small" @click="goToBatchJobs">
{{ t('order.placeOrder.batch.viewJobs') }}
</a-button>
</div>
</div>
<div class="main-content">
<div class="left-column">
<div class="upload-section">
<div
ref="batchUploadCardRef"
class="upload-card liquid-card"
:class="{ 'batch-focus-pulse': batchFocusTarget === 'upload' }"
>
<div class="upload-card-title">
<span class="upload-card-title__main">{{ t('order.placeOrder.batch.uploadTitle') }}</span>
<span v-if="selectedTemplateRow" class="upload-card-title__template">
{{ selectedTemplateRow.name }}
</span>
</div>
<div class="upload-card-main">
<div
class="upload-area"
:class="{ 'is-dragover': uploadDragActive, 'has-file': !!pendingUploadFile }"
@dragover="handleUploadDragOver"
@dragleave="handleUploadDragLeave"
@drop="handleUploadDrop"
>
<div class="upload-dragger" @click="triggerFilePicker">
<div class="upload-content">
<ExcelFileIcon class="upload-excel-icon" :size="56" />
<div v-if="!pendingUploadFile" class="upload-text">
{{ t('order.placeOrder.upload.dropText') }}
</div>
<div v-else class="upload-file-ready">
<div class="upload-file-name">{{ pendingUploadFile.name }}</div>
<div class="upload-file-hint">{{ t('order.placeOrder.upload.fileReady') }}</div>
</div>
<div v-if="!pendingUploadFile" class="upload-inline-tips">
<p class="upload-inline-tip">{{ t('order.placeOrder.batch.templateTip') }}</p>
</div>
<div v-if="pendingUploadFile" class="upload-file-actions" @click.stop>
<a-button size="small" @click="triggerFilePicker">
{{ t('order.placeOrder.upload.reselect') }}
</a-button>
<a-button size="small" type="text" status="danger" @click="clearSelectedFile">
{{ t('order.placeOrder.upload.clearFile') }}
</a-button>
</div>
</div>
<input
ref="nativeFileInputRef"
type="file"
accept=".xlsx,.xls"
class="native-file-input"
@change="handleNativeFileChange"
/>
</div>
</div>
</div>
<div
ref="batchSubmitFooterRef"
class="upload-card-footer"
:class="{ 'batch-focus-pulse': batchFocusTarget === 'submit' }"
>
<p
v-if="batchSubmitBlockReason && pendingUploadFile"
class="batch-block-reason"
>{{ batchSubmitBlockReason }}</p>
<div class="order-submit-actions">
<div class="order-agreement-bar">
<a-checkbox v-model="batchAgreementChecked" />
<span class="agreement-link" @click="openAgreementViewModal">
{{ t('order.placeOrder.agreement.label') }}
</span>
</div>
<a-button
type="primary"
class="batch-order-btn"
:loading="batchSubmitting"
:disabled="!canSubmitBatch"
@click="handleBatchOrderClick"
>
{{ t('order.placeOrder.batchOrder') }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<div
ref="batchTemplatePanelRef"
class="template-section"
:class="{ 'batch-focus-pulse': batchFocusTarget === 'template' }"
>
<div class="template-card liquid-card">
<div class="template-card-header">
<h3>{{ t('order.placeOrder.templatePanel.title') }}</h3>
</div>
<a-spin :loading="templatePanelLoading" class="template-panel-spin">
<div class="template-scroll">
<div class="card-header">
<h4>{{ t('order.placeOrder.standardTemplates') }}</h4>
</div>
<div class="template-list template-list--standard-row">
<div v-if="!displayedStandardTemplates.length" class="template-empty">
{{ t('order.placeOrder.templatePanel.emptyStandard') }}
</div>
<div
v-for="template in displayedStandardTemplates"
:key="'std-' + template.id"
class="template-item standard-template-item"
:class="{ 'is-selected': selectedTemplate === template.id }"
@click="handleSelectTemplate(template)"
>
<ExcelFileIcon class="template-excel-icon" :size="44" />
<div class="template-main">
<div class="template-name">{{ template.name }}</div>
<div class="template-update">
{{ t('order.placeOrder.template.updatedAt', { time: template.updateTime || '--' }) }}
</div>
</div>
<span
v-if="selectedTemplate === template.id"
class="template-selected-badge"
aria-hidden="true"
>
<icon-check />
</span>
<div class="template-actions template-actions-inline" @click.stop>
<a-button
type="text"
size="mini"
class="template-action-btn"
:title="t('order.placeOrder.template.download')"
@click="handleDownloadTemplate(template)"
>
<template #icon><icon-export /></template>
</a-button>
</div>
</div>
</div>
<div class="custom-template-section">
<div class="card-header">
<h4>{{ t('order.placeOrder.customTemplates') }}</h4>
</div>
<div class="template-list">
<div v-if="!customTemplates.length" class="template-empty">
{{ t('order.placeOrder.templatePanel.emptyCustom') }}
</div>
<div
v-for="template in customTemplates"
:key="'cst-' + template.id"
class="template-item standard-template-item"
:class="{ 'is-selected': selectedTemplate === template.id }"
@click="handleSelectTemplate(template)"
>
<ExcelFileIcon class="template-excel-icon" :size="44" />
<div class="template-main">
<div class="template-name">
{{ template.name }}
<a-tag
v-if="template.mappingStatus !== 'ready'"
size="small"
color="orangered"
class="template-status-tag"
>
待编辑
</a-tag>
</div>
<div class="template-update">
{{ t('order.placeOrder.template.updatedAt', { time: template.updateTime || '--' }) }}
</div>
</div>
<span
v-if="selectedTemplate === template.id"
class="template-selected-badge"
aria-hidden="true"
>
<icon-check />
</span>
<div class="template-actions template-actions-inline" @click.stop>
<a-button
type="text"
size="mini"
class="template-action-btn"
:title="t('order.placeOrder.template.download')"
@click="handleDownloadTemplate(template)"
>
<template #icon><icon-export /></template>
</a-button>
<a-button
type="text"
size="mini"
class="template-action-btn"
:title="t('order.placeOrder.template.edit')"
@click="openTemplateMapping(template)"
>
<template #icon><icon-edit /></template>
</a-button>
<a-button
type="text"
size="mini"
status="danger"
class="template-action-btn"
:title="t('order.placeOrder.template.delete')"
@click="handleDeleteCustomTemplate(template)"
>
<template #icon><icon-delete /></template>
</a-button>
</div>
</div>
<div class="add-template-item" @click="handleAddCustomTemplate">
<icon-plus class="add-icon" />
<span>{{ t('order.placeOrder.customTemplates.add') }}</span>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</div>
</div>
</div>
</div>
</div>
<a-modal
v-model:visible="batchResultVisible"
title="批量下单失败明细"
:footer="false"
width="780px"
unmount-on-close
>
<div class="batch-result-wrap">
<div class="batch-result-tip">以下为创建失败的行,请修正后重新上传。</div>
<a-table :data="batchResultRows" :pagination="false" size="small">
<template #columns>
<a-table-column title="Excel行号" data-index="rowNo" :width="120" />
<a-table-column title="客户单号" data-index="clientNo" :width="180" />
<a-table-column title="失败原因" data-index="message" />
</template>
</a-table>
</div>
</a-modal>
<a-modal
v-model:visible="createFailVisible"
:title="
createFailInfo?.draftSaved
? t('order.placeOrder.single.failTitleDraft')
: t('order.placeOrder.single.failTitle')
"
:footer="false"
width="520px"
unmount-on-close
:mask-closable="!createFailCancelling && !createFailSavingDraft"
:esc-to-close="!createFailCancelling && !createFailSavingDraft"
>
<div v-if="createFailInfo" class="create-fail-wrap">
<div class="create-fail-hero">
<p class="create-fail-reason">{{ createFailInfo.reason }}</p>
<p v-if="createFailInfo.tips?.[0]" class="create-fail-tip">
<span class="create-fail-tip-label">{{ t('order.placeOrder.single.failHowToFix') }}</span>
{{ createFailInfo.tips[0] }}
</p>
</div>
<div
v-if="createFailInfo.draftSaved"
class="create-fail-status is-draft"
>
<span>{{ t('order.placeOrder.single.failDraftSavedNote') }}</span>
<button
v-if="createFailInfo.serviceNo"
type="button"
class="create-fail-copy"
@click="handleCreateFailCopyServiceNo"
>
{{ createFailInfo.serviceNo }}
</button>
</div>
<details
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
class="create-fail-details"
>
<summary>{{ t('order.placeOrder.single.failRawReason') }}</summary>
<p class="create-fail-raw">{{ createFailInfo.rawReason }}</p>
</details>
<div class="create-fail-actions">
<a-button
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailCancelSubmit"
>
{{
createFailInfo.draftSaved
? t('order.placeOrder.single.failCancelSubmit')
: t('order.placeOrder.single.failAbandon')
}}
</a-button>
<a-button
v-if="createFailInfo.suggestSupport || createFailInfo.draftSaved"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailAskSupport"
>
{{ t('order.placeOrder.single.failAskSupport') }}
</a-button>
<a-button
v-if="createFailInfo.draftSaved"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailGoDrafts"
>
{{ t('order.placeOrder.single.failGoDrafts') }}
</a-button>
<a-button
v-if="!createFailInfo.draftSaved"
:disabled="createFailCancelling"
:loading="createFailSavingDraft"
@click="handleCreateFailSaveAsDraft"
>
{{ t('order.placeOrder.single.failSaveAsDraft') }}
</a-button>
<a-button
type="primary"
:disabled="createFailSavingDraft"
:loading="createFailCancelling"
@click="handleCreateFailContinueEdit"
>
{{
createFailInfo.draftSaved
? t('order.placeOrder.single.failEditDraft')
: t('order.placeOrder.single.failContinueEdit')
}}
</a-button>
</div>
</div>
</a-modal>
<a-modal
v-model:visible="senderModalVisible"
:title="t('order.placeOrder.single.senderModalTitle')"
:mask-closable="true"
:esc-to-close="true"
width="600px"
unmount-on-close
>
<a-spin :loading="senderAddressLoading" class="sender-modal-spin">
<div class="sender-modal-body">
<a-form layout="vertical">
<a-form-item :label="t('order.placeOrder.single.senderSelectLabel')" required>
<a-select
:model-value="selectedSenderAddressId"
allow-search
:placeholder="t('order.placeOrder.single.senderSelectPlaceholder')"
:options="senderSelectOptions"
@change="handleSenderAddressSelect"
/>
</a-form-item>
</a-form>
<div v-if="selectedSenderSummary" class="sender-preview">
<div class="sender-preview__title">{{ t('order.placeOrder.single.senderPreviewTitle') }}</div>
<p class="sender-preview__line">{{ selectedSenderSummary }}</p>
<p v-if="formTrim(singleForm.sender.streetAddress1)" class="sender-preview__line muted">
{{
[
formTrim(singleForm.sender.countryCode),
formTrim(singleForm.sender.streetAddress1),
formTrim(singleForm.sender.houseNumber),
formTrim(singleForm.sender.zipCode),
]
.filter(Boolean)
.join(' · ')
}}
</p>
</div>
<a-empty
v-else-if="!senderAddressOptions.length"
:description="t('order.placeOrder.single.senderEmpty')"
/>
</div>
</a-spin>
<template #footer>
<div class="sender-modal-footer">
<a-button class="app-btn-toolbar" @click="goToSenderAddressManage">
{{ t('order.placeOrder.single.senderManage') }}
</a-button>
<div class="sender-modal-footer__right">
<a-button class="app-btn-toolbar" @click="senderModalVisible = false">
{{ t('order.placeOrder.template.cancel') }}
</a-button>
<a-button
type="primary"
:disabled="!selectedSenderAddressId"
@click="senderModalVisible = false"
>
{{ t('order.placeOrder.single.senderModalOk') }}
</a-button>
</div>
</div>
</template>
</a-modal>
<a-modal
v-model:visible="receiverModalVisible"
:title="t('order.placeOrder.single.receiverModalTitle')"
:mask-closable="true"
:esc-to-close="true"
width="600px"
unmount-on-close
>
<a-spin :loading="receiverAddressLoading" class="sender-modal-spin">
<div class="sender-modal-body">
<a-form layout="vertical">
<a-form-item :label="t('order.placeOrder.single.receiverSelectLabel')" required>
<a-select
:model-value="selectedReceiverAddressId"
allow-search
:placeholder="t('order.placeOrder.single.receiverSelectPlaceholder')"
:options="receiverSelectOptions"
@change="handleReceiverAddressSelect"
/>
</a-form-item>
</a-form>
<div v-if="selectedReceiverSummary" class="sender-preview">
<div class="sender-preview__title">{{ t('order.placeOrder.single.receiverPreviewTitle') }}</div>
<p class="sender-preview__line">{{ selectedReceiverSummary }}</p>
<p v-if="formTrim(singleForm.receiver.streetAddress1)" class="sender-preview__line muted">
{{
[
formTrim(singleForm.receiver.countryCode),
formTrim(singleForm.receiver.streetAddress1),
formTrim(singleForm.receiver.streetNumber),
formTrim(singleForm.receiver.zipCode),
]
.filter(Boolean)
.join(' · ')
}}
</p>
</div>
<a-empty
v-else-if="!receiverAddressOptions.length"
:description="t('order.placeOrder.single.receiverEmpty')"
/>
</div>
</a-spin>
<template #footer>
<div class="sender-modal-footer">
<a-button class="app-btn-toolbar" @click="goToReceiverAddressManage">
{{ t('order.placeOrder.single.senderManage') }}
</a-button>
<div class="sender-modal-footer__right">
<a-button class="app-btn-toolbar" @click="receiverModalVisible = false">
{{ t('order.placeOrder.template.cancel') }}
</a-button>
<a-button
type="primary"
:disabled="!selectedReceiverAddressId && !selectedReceiverSummary"
@click="receiverModalVisible = false"
>
{{ t('order.placeOrder.single.senderModalOk') }}
</a-button>
</div>
</div>
</template>
</a-modal>
<a-modal
v-model:visible="agreementModalVisible"
title="物流下单用户协议及赔偿协议"
:mask-closable="true"
:esc-to-close="true"
width="860px"
unmount-on-close
@ok="agreementModalVisible = false"
>
<div ref="agreementBodyRef" class="agreement-content">
<pre>{{ agreementText }}</pre>
</div>
</a-modal>
<CustomTemplateModal
v-model:visible="templateModalVisible"
:template-id="templateModalTemplateId"
:standard-template-options="standardTemplateMappingOptions"
@success="onTemplateModalSuccess"
/>
<SelectDeclarationSkuModal
v-model:visible="declarationSkuModalVisible"
:customer-id="resolveSelectedCustomerId()"
@select="handleDeclarationSkuSelect"
/>
<CustomerGuideDrawer ref="customerGuideDrawerRef" />
<ExportCustoms9610GuideModal v-model:visible="exportCustomsGuideVisible" />
</div>
</template>
<style lang="less" scoped>
.place-order-container {
padding: 16px;
background-color: var(--app-page-bg);
flex: 1;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
&.is-contact-blocked {
.place-order-tabs {
pointer-events: none;
user-select: none;
opacity: 0.42;
filter: grayscale(0.2);
}
}
}
.contact-block-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #ffcf8b;
background: #fff7e8;
flex-shrink: 0;
z-index: 2;
}
.contact-block-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
color: #ad6800;
font-size: 14px;
font-weight: 600;
}
span {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
}
}
.declaration-live-hint {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: #fff7e8;
color: #ad6800;
font-size: 12px;
line-height: 1.4;
}
.liquid-card {
position: relative;
z-index: 0;
overflow: hidden;
isolation: isolate;
border: 1px solid var(--app-card-border);
border-radius: 16px !important;
background: var(--app-card-bg);
box-shadow:
0 8px 24px rgba(15, 23, 42, 0.05),
0 1px 2px rgba(15, 23, 42, 0.03);
> * {
position: relative;
z-index: 1;
}
}
.price-check-handoff-alert {
margin-bottom: 12px;
border-radius: 12px;
flex-shrink: 0;
.handoff-body {
margin-top: 4px;
}
.handoff-line {
margin: 0 0 4px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
}
.handoff-label {
color: var(--color-text-1);
font-weight: 600;
}
.handoff-quote-warning {
color: #d48806;
}
}
.single-toolbar-right {
flex-wrap: nowrap;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
}
.place-order-customer-select {
width: 180px;
}
.single-tips {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
max-width: 320px;
}
.single-tip-text {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
text-align: right;
&.tip2 {
color: #a9aeb8;
}
}
.optional-tag {
margin-left: 8px;
}
.selected-template-chip {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 6px 12px;
border-radius: 10px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-2);
&.empty {
color: var(--color-text-3);
border-style: dashed;
}
.chip-name {
font-weight: 600;
color: var(--color-text-1);
}
.chip-placeholder {
font-size: 13px;
}
}
.batch-block-reason {
margin: 0;
color: #ff7d00;
font-size: 12px;
line-height: 1.4;
text-align: right;
align-self: center;
flex-shrink: 0;
}
.recipient-optional-panel {
min-width: 0;
height: 100%;
box-sizing: border-box;
overflow: auto;
}
.recipient-optional-grid {
margin-top: 0;
padding-top: 0;
border-top: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.street-number-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
:deep(.arco-input-wrapper) {
flex: 1;
min-width: 0;
}
}
.place-order-text-link {
flex-shrink: 0;
white-space: nowrap;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-3);
font-size: 13px;
font-weight: 400;
line-height: 30px;
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--app-primary-color, rgb(var(--primary-6)));
}
&:active {
color: var(--app-primary-color, rgb(var(--primary-6)));
opacity: 0.85;
}
}
.dims-group {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
:deep(.arco-input-wrapper) {
flex: 1;
min-width: 0;
}
}
.dims-sep {
flex-shrink: 0;
color: var(--color-text-3);
font-size: 13px;
line-height: 1;
}
.receiver-ai-paste {
min-width: 0;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.2s;
&.is-dragging .receiver-ai-paste__textarea :deep(.arco-textarea-wrapper) {
border-color: var(--app-primary-color, rgb(var(--primary-6)));
border-style: dashed;
background: color-mix(in srgb, var(--app-primary-color) 6%, var(--app-card-bg));
}
&.is-focused .receiver-ai-paste__textarea :deep(.arco-textarea-wrapper) {
border-color: var(--app-primary-color, rgb(var(--primary-6)));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-primary-color) 16%, transparent);
}
&.is-loading {
pointer-events: none;
}
}
.receiver-ai-paste__body {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.receiver-ai-paste__clear {
position: absolute;
top: 8px;
right: 10px;
z-index: 1;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.3;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--app-primary-color, rgb(var(--primary-6)));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.receiver-ai-paste__textarea {
flex: 1;
:deep(.arco-textarea-wrapper) {
height: 100%;
background: var(--app-card-bg);
border-color: var(--color-border-2);
border-radius: 8px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
:deep(.arco-textarea-wrapper:hover) {
border-color: var(--color-border-3);
}
:deep(.arco-textarea) {
height: 100%;
background: transparent;
}
:deep(textarea) {
min-height: 180px;
padding-right: 40px;
font-size: 13px;
line-height: 1.55;
background: transparent;
resize: none;
}
}
.receiver-ai-paste__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: color-mix(in srgb, var(--app-card-bg) 72%, transparent);
color: var(--color-text-2);
font-size: 13px;
backdrop-filter: blur(1px);
}
.receiver-ai-paste__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
min-width: 0;
}
.receiver-ai-paste__tip {
flex: 1;
min-width: 0;
margin: 0;
margin-right: auto;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
&.is-ok {
color: var(--color-success-6, #00a870);
}
&.is-warn {
color: var(--color-warning-6, #d48806);
}
&.is-danger {
color: var(--color-danger-6, #f53f3f);
}
}
.receiver-ai-paste__submit {
flex-shrink: 0;
}
.declaration-card {
min-width: 0;
overflow: hidden;
}
.single-declaration-table-wrap {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border: 1px solid #e8eaed;
border-radius: 8px;
background: var(--app-card-bg);
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.batch-guide-hint {
margin-left: 8px;
padding-left: 10px;
border-left: 1px solid var(--color-border-2);
color: var(--color-text-3);
font-size: 12px;
line-height: 1.3;
white-space: nowrap;
@media (max-width: 1100px) {
display: none;
}
}
.batch-focus-pulse {
animation: place-order-focus-pulse 1.2s ease;
}
@keyframes place-order-focus-pulse {
0% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--app-primary-color) 28%, transparent),
0 6px 18px rgba(15, 23, 42, 0.04);
}
40% {
box-shadow:
0 0 0 4px color-mix(in srgb, var(--app-primary-color) 18%, transparent),
0 6px 18px rgba(15, 23, 42, 0.04);
}
100% {
box-shadow:
0 6px 18px rgba(15, 23, 42, 0.04),
0 1px 2px rgba(15, 23, 42, 0.03);
}
}
.place-order-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
.place-order-tabs-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 20;
background: var(--app-page-bg);
padding: 0 0 12px;
min-width: 0;
}
.tabs-slot {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
}
:deep(.t-tabs) {
flex: 0 1 auto;
min-width: 0;
width: max-content;
max-width: 100%;
}
.place-order-pane {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.batch-tab {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 12px;
}
.batch-toolbar {
flex-shrink: 0;
margin-bottom: 0;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
.toolbar-left,
.toolbar-right {
align-items: center;
}
.batch-step {
align-items: center;
}
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.batch-steps {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.batch-step {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-3);
&.active {
color: var(--color-text-1);
font-weight: 500;
}
&.done {
color: var(--color-text-1);
.batch-step-index {
background: #00b42a;
color: #fff;
}
.batch-step-label {
color: var(--color-text-2);
font-weight: 500;
}
}
&.current:not(.done) .batch-step-index {
background: var(--app-primary-color);
color: #fff;
box-shadow: 0 0 0 3px rgba(var(--primary-6), 0.18);
}
&.is-sender,
&.is-recipient {
max-width: min(360px, 46vw);
padding: 4px 10px;
margin: -4px -4px;
border: 1px solid rgba(45, 140, 140, 0.22);
background: rgba(45, 140, 140, 0.06);
border-radius: 999px;
&:hover {
border-color: var(--app-primary-color);
background: rgba(45, 140, 140, 0.1);
}
}
&.is-sender-empty:not(.done),
&.is-recipient-empty:not(.done) {
border-color: #ffcf8b;
background: #fff7e8;
.batch-step-label,
.batch-step-sender-summary {
color: #ad6800;
}
.batch-step-index {
background: #ffe7ba;
color: #ad6800;
box-shadow: none;
}
}
}
.batch-step-main {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.batch-step-label {
flex-shrink: 0;
}
.batch-step-sender-summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 400;
color: var(--color-text-2);
}
.batch-step.is-sender .batch-step-label,
.batch-step.is-recipient .batch-step-label {
color: var(--app-primary-color);
font-weight: 600;
}
.batch-step-index {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
background: var(--color-fill-2);
color: var(--color-text-3);
flex-shrink: 0;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
}
.batch-step-check {
font-size: 12px;
animation: batch-step-check-in 0.28s ease-out;
}
@keyframes batch-step-check-in {
0% {
transform: scale(0.4);
opacity: 0;
}
70% {
transform: scale(1.15);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.batch-step-sep {
color: var(--color-border-3);
font-size: 12px;
transition: color 0.2s;
&.is-done {
color: #00b42a;
opacity: 0.65;
}
}
.main-content {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
align-items: stretch;
}
.left-column {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
order: 2;
max-height: min(520px, 52vh);
}
.upload-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.upload-card {
background: var(--app-card-bg);
border-radius: 4px;
padding: 16px 20px;
flex: 1;
height: 100%;
min-height: 0;
max-height: min(520px, 52vh);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.upload-card-title {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin: 0 0 12px;
min-height: 22px;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
.upload-card-title__main {
justify-self: start;
}
.upload-card-title__template {
justify-self: center;
max-width: min(420px, 100%);
padding: 2px 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--app-primary-color) 10%, transparent);
color: var(--app-primary-color, rgb(var(--primary-6)));
font-size: 13px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-card-main {
display: flex;
flex-direction: column;
flex: 1;
min-height: 160px;
margin-bottom: 12px;
}
.upload-area {
margin-bottom: 0;
flex: 1;
display: flex;
min-height: 0;
height: 100%;
border-radius: 4px;
transition: box-shadow 0.2s;
&.is-dragover .upload-dragger {
border-color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.06);
}
&.has-file .upload-dragger {
border-style: solid;
border-color: rgba(var(--primary-6), 0.45);
background: rgba(var(--primary-6), 0.04);
}
.upload-dragger {
width: 100%;
flex: 1;
min-height: 140px;
border: 1px dashed var(--color-border-3);
border-radius: 10px;
background: var(--color-fill-2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
box-sizing: border-box;
&:hover {
border-color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.04);
}
.upload-content {
text-align: center;
padding: 16px;
.upload-excel-icon {
display: block;
margin: 0 auto 14px;
filter: drop-shadow(0 4px 10px rgba(22, 142, 45, 0.18));
}
.upload-icon {
font-size: 40px;
color: var(--color-text-3);
margin-bottom: 12px;
}
.upload-text {
font-size: 15px;
font-weight: 500;
color: var(--color-text-1);
}
.upload-inline-tips {
margin-top: 14px;
max-width: 320px;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.upload-inline-tip {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
&.primary {
color: #ff7d00;
font-weight: 500;
}
&:last-child {
margin-bottom: 0;
}
}
.upload-file-ready {
max-width: 360px;
margin: 0 auto;
}
.upload-file-name {
font-size: 14px;
color: var(--color-text-1);
font-weight: 600;
word-break: break-all;
}
.upload-file-hint {
margin-top: 6px;
font-size: 12px;
color: #00b42a;
}
.upload-file-actions {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
}
}
.native-file-input {
display: none;
}
}
.upload-card-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--color-border-2);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.order-submit-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.single-order-btn,
.batch-order-btn {
min-width: 120px;
height: 32px;
border-radius: 4px;
flex-shrink: 0;
}
.order-agreement-bar {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-2);
flex-shrink: 0;
}
.agreement-link {
color: var(--app-primary-color);
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
.single-action-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-left: auto;
}
.template-section {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
order: 1;
max-height: min(520px, 52vh);
align-self: stretch;
}
.template-panel-spin {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
max-height: 100%;
:deep(.arco-spin) {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.arco-spin-children) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
}
.template-card {
background: var(--app-card-bg);
border-radius: 4px;
padding: 16px;
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
max-height: min(520px, 52vh);
box-sizing: border-box;
}
.template-card-header {
margin-bottom: 12px;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
}
.template-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.template-empty {
grid-column: 1 / -1;
color: var(--color-text-3);
font-size: 13px;
padding: 8px 0;
}
.card-header {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h3,
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
}
.template-list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
}
.template-list--standard-row {
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
gap: 12px;
padding-bottom: 4px;
.template-item,
.standard-template-item {
flex: 0 0 auto;
width: 280px;
max-width: none;
}
.template-empty {
flex: 1 1 auto;
min-width: 0;
}
}
.template-item {
width: 280px;
max-width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border-2);
border-radius: 10px;
padding: 10px 12px;
background: var(--app-card-bg);
position: relative;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
.template-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1;
.delete-btn,
.download-btn,
.edit-btn {
color: var(--color-text-2) !important;
padding: 0;
min-width: 20px;
min-height: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
display: inline-flex !important;
align-items: center;
justify-content: center;
opacity: 1 !important;
visibility: visible !important;
cursor: pointer;
font-size: 16px;
line-height: 1;
:deep(svg),
:deep(i) {
color: inherit !important;
fill: currentColor !important;
stroke: currentColor !important;
opacity: 1 !important;
font-size: 16px;
}
&:hover {
color: var(--app-primary-color) !important;
}
}
.delete-btn:hover {
color: #f53f3f !important;
}
}
.template-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
padding-right: 56px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.template-status-tag {
flex-shrink: 0;
}
.template-update {
font-size: 10px;
color: var(--color-text-4, #c9cdd4);
}
}
.standard-template-item {
min-height: 64px;
width: 280px;
max-width: 100%;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
&:hover {
border-color: rgba(var(--primary-6), 0.35);
background: rgba(var(--primary-6), 0.03);
}
&.is-selected {
border: 1px solid rgba(0, 180, 42, 0.45);
background: #f3fbf4;
box-shadow: 0 0 0 2px rgba(0, 180, 42, 0.1);
padding: 10px 12px;
.template-name {
color: #2a7a3a;
font-weight: 600;
}
.template-update {
color: rgba(42, 122, 58, 0.45);
}
}
.template-selected-badge {
position: absolute;
top: 0;
left: 0;
width: 18px;
height: 18px;
border-radius: 10px 0 8px 0;
background: rgba(0, 180, 42, 0.85);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
z-index: 2;
animation: batch-step-check-in 0.28s ease-out;
}
.template-excel-icon {
flex-shrink: 0;
}
.template-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.template-name {
margin-bottom: 0;
padding-right: 0;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.template-update {
font-size: 10px;
color: var(--color-text-4, #c9cdd4);
}
.template-actions-inline {
position: static;
display: inline-flex;
align-items: center;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
.template-action-btn {
width: 28px;
height: 28px;
padding: 0;
color: var(--color-text-3);
&:hover {
color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.08);
}
&.arco-btn-status-danger:hover {
color: #f53f3f;
background: rgba(245, 63, 63, 0.08);
}
}
.close-btn {
color: var(--color-text-3);
font-size: 14px;
line-height: 1;
cursor: pointer;
user-select: none;
&:hover {
color: #f53f3f;
}
}
}
.add-template-item {
width: 280px;
max-width: 100%;
box-sizing: border-box;
border: 1px dashed var(--color-border-3);
border-radius: 10px;
padding: 12px;
background: var(--color-fill-2);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
color: var(--color-text-2);
font-size: 13px;
transition: all 0.2s;
&:hover {
border-color: var(--app-primary-color);
color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.04);
}
.add-icon {
font-size: 16px;
}
}
.custom-template-section {
.card-header {
margin-top: 8px;
}
}
.batch-result-wrap {
.batch-result-tip {
margin-bottom: 10px;
color: var(--color-text-2);
font-size: 13px;
}
}
.create-fail-wrap {
display: flex;
flex-direction: column;
gap: 14px;
}
.create-fail-hero {
padding: 14px 16px;
border-radius: 6px;
border-left: 3px solid #f53f3f;
background: #fff7f7;
}
.create-fail-reason {
margin: 0;
color: var(--color-text-1);
font-size: 16px;
font-weight: 600;
line-height: 1.45;
word-break: break-word;
}
.create-fail-tip {
margin: 8px 0 0;
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
}
.create-fail-tip-label {
margin-right: 6px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
.create-fail-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--color-text-2);
background: var(--color-fill-2);
&.is-draft {
color: #0fc6c2;
background: rgba(15, 198, 194, 0.08);
}
}
.create-fail-copy {
border: none;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.create-fail-details {
font-size: 12px;
color: var(--color-text-3);
summary {
cursor: pointer;
user-select: none;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::before {
content: '› ';
display: inline-block;
transition: transform 0.15s;
}
}
&[open] summary::before {
transform: rotate(90deg);
}
}
.create-fail-raw {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: var(--color-fill-2);
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
.create-fail-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.single-tab {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
gap: 12px;
}
.single-toolbar {
flex-shrink: 0;
margin-bottom: 0;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
.toolbar-left,
.toolbar-right {
align-items: center;
}
.batch-step,
.flow-step-btn {
align-items: center;
}
}
.flow-step-btn {
border: none;
background: transparent;
padding: 4px 6px;
margin: -4px -6px;
border-radius: 8px;
cursor: pointer;
font: inherit;
color: inherit;
transition: background 0.15s, color 0.15s;
&:hover {
color: var(--app-primary-color);
background: rgba(45, 140, 140, 0.06);
}
&.current {
color: var(--app-primary-color);
background: rgba(45, 140, 140, 0.1);
}
}
.single-form-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.single-form-scroll {
flex: 1;
min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9dce0;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-border-3);
}
}
.single-cards-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 12px;
align-items: stretch;
}
.single-info-card {
padding: 14px 16px 12px;
box-sizing: border-box;
min-width: 0;
}
.single-info-card--recipient {
margin-top: 0;
}
.receiver-split {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(180px, 0.9fr);
gap: 12px;
align-items: stretch;
}
.receiver-split__form {
min-width: 0;
}
.receiver-split__form {
display: flex;
flex-direction: column;
}
.single-info-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.declaration-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.sender-modal-spin {
width: 100%;
}
.sender-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
:deep(.arco-form-item) {
margin-bottom: 0;
}
}
.sender-preview {
padding: 12px;
border-radius: 10px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
}
.sender-preview__title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-3);
}
.sender-preview__line {
margin: 0 0 4px;
font-size: 13px;
color: var(--color-text-1);
line-height: 1.45;
&.muted {
color: var(--color-text-2);
}
&:last-child {
margin-bottom: 0;
}
}
.sender-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.sender-modal-footer__right {
display: flex;
align-items: center;
gap: 8px;
}
.single-form-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
flex-wrap: wrap;
flex-shrink: 0;
padding: 12px 16px;
position: sticky;
bottom: 0;
z-index: 5;
}
.footer-additional-services {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 4px 0;
font-size: 13px;
color: var(--color-text-1);
&__label {
flex-shrink: 0;
color: var(--color-text-2);
font-weight: 600;
}
:deep(.arco-checkbox-group) {
display: inline-flex;
flex-wrap: wrap;
gap: 8px 16px;
}
}
.single-submit-hint {
margin-right: auto;
max-width: min(420px, 52%);
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
&.is-pending {
color: #f53f3f;
background: #ffece8;
}
&.is-ready {
color: #00b42a;
background: #e8ffea;
}
}
.agreement-content {
max-height: 60vh;
overflow: auto;
margin: 0;
background: var(--color-fill-2);
border-radius: 6px;
padding: 12px;
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-size: 13px;
color: var(--color-text-1);
font-family: inherit;
}
}
.single-section-card {
margin-bottom: 0;
}
.single-section-header {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.declaration-section-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.single-section-header-left {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-shrink: 0;
flex-wrap: wrap;
}
.single-section-header-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
margin-left: auto;
}
.declaration-header-currency-row {
flex-wrap: nowrap;
}
.declaration-header-hint {
font-size: 12px;
line-height: 1.4;
color: var(--color-text-3);
white-space: nowrap;
margin-left: 0;
&.is-error-hint {
color: #f53f3f;
}
}
.declaration-header-help {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 14px;
color: var(--color-text-3);
cursor: help;
line-height: 1;
transition: color 0.15s ease;
&:hover {
color: var(--app-primary-color, rgb(var(--primary-6)));
}
}
.declaration-header-field {
display: flex;
align-items: center;
gap: 8px;
}
.declaration-header-label {
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
&.required::before {
content: '*';
color: #f53f3f;
margin-right: 4px;
}
}
.declaration-header-input {
width: 168px;
}
.declaration-header-select {
width: 80px;
min-width: 80px;
max-width: 80px;
flex: 0 0 80px;
:deep(.arco-select-view) {
width: 80px !important;
min-width: 80px !important;
max-width: 80px !important;
box-sizing: border-box;
}
}
.declaration-header-input,
.declaration-header-select {
:deep(.arco-input-wrapper),
:deep(.arco-select-view) {
min-height: 28px;
background: var(--app-card-bg);
}
}
.single-section-index {
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
background: var(--color-fill-2);
flex-shrink: 0;
transition: all 0.2s ease;
}
.single-section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.4;
}
.single-collapse {
background: transparent;
:deep(.arco-collapse-item) {
margin-bottom: 12px;
border: 1px solid #e8eaed;
border-radius: 8px;
overflow: hidden;
background: var(--app-card-bg);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:last-child {
margin-bottom: 0;
}
}
:deep(.arco-collapse-item-active) {
border-color: rgba(var(--primary-6), 0.28);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
:deep(.arco-collapse-item-header) {
padding: 12px 16px;
background: var(--color-fill-2);
border-bottom: 1px solid transparent;
transition: background 0.2s ease;
}
:deep(.arco-collapse-item-active > .arco-collapse-item-header) {
background: linear-gradient(90deg, rgba(var(--primary-6), 0.06) 0%, var(--color-fill-2) 48%);
border-bottom-color: var(--color-border-2);
}
:deep(.arco-collapse-item-active .single-section-index) {
background: var(--app-primary-color);
color: #fff;
}
:deep(.arco-collapse-item-icon-hover) {
width: 24px;
height: 24px;
border-radius: 4px;
color: var(--color-text-3);
&:hover {
background: rgba(var(--primary-6), 0.08);
color: var(--app-primary-color);
}
}
:deep(.arco-collapse-item-content) {
background: var(--app-card-bg);
}
:deep(.arco-collapse-item-content-box) {
padding: 16px;
}
}
.single-form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 16px;
&--compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
.span-2 {
grid-column: span 2;
}
}
:deep(.arco-form-item) {
margin-bottom: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
:deep(.arco-form-item-label-col) {
width: 100%;
flex: none;
margin-bottom: 6px;
text-align: left !important;
}
:deep(.arco-form-item-wrapper-col) {
width: 100%;
flex: none;
}
:deep(.arco-form-item-label) {
line-height: 1.4;
color: var(--color-text-2);
font-size: 13px;
font-weight: 500;
justify-content: flex-start !important;
text-align: left !important;
width: 100%;
}
:deep(.arco-form-item-label-required-symbol) {
margin-right: 2px;
margin-left: 0;
line-height: 1;
vertical-align: middle;
}
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-select-view-multiple) {
min-height: 32px;
border-radius: 4px;
background-color: var(--app-card-bg);
border: 1px solid var(--color-border-2);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
:deep(.arco-input-wrapper:hover),
:deep(.arco-select-view-single:hover),
:deep(.arco-select-view-multiple:hover) {
border-color: rgba(var(--primary-6), 0.45);
}
:deep(.arco-input-wrapper.arco-input-focus),
:deep(.arco-select-view-single.arco-select-view-focus),
:deep(.arco-select-view-multiple.arco-select-view-focus) {
border-color: var(--app-primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.12);
}
:deep(.arco-input),
:deep(.arco-select-view-value) {
font-size: 13px;
}
:deep(.arco-select) {
width: 100%;
}
}
.inbound-method-item {
grid-column: 1 / -1;
}
.inbound-method-radios {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
:deep(.arco-radio-group-button) {
background: var(--color-fill-2);
}
:deep(.arco-radio-button) {
color: var(--color-text-2);
&:hover {
color: var(--app-primary-color, rgb(var(--primary-6)));
}
&.arco-radio-checked {
color: #fff;
background-color: var(--app-primary-color, rgb(var(--primary-6)));
border-color: var(--app-primary-color, rgb(var(--primary-6)));
&:hover {
color: #fff;
background-color: var(--app-primary-color-hover, var(--app-primary-color));
border-color: var(--app-primary-color-hover, var(--app-primary-color));
}
}
}
:deep(.arco-radio-checked .arco-radio-button-content) {
color: #fff;
}
}
.inbound-method-pickup-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.inbound-method-help {
color: inherit;
opacity: 0.75;
font-size: 13px;
cursor: help;
vertical-align: middle;
&:hover {
opacity: 1;
color: inherit;
}
}
.export-customs-mode-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.export-customs-help-icon {
color: var(--color-text-3);
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: var(--app-primary-color);
}
}
.inbound-pickup-batch-hint {
margin: -4px 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--color-text-3, #86909c);
}
.single-declaration-table {
border-collapse: separate;
border-spacing: 0;
width: max-content;
min-width: 100%;
table-layout: fixed;
th,
td {
border-right: 1px solid var(--color-border-2);
border-bottom: 1px solid var(--color-border-2);
padding: 6px 8px;
white-space: nowrap;
vertical-align: middle;
min-width: 140px;
width: 140px;
box-sizing: border-box;
}
.op-col {
position: sticky;
left: 0;
z-index: 2;
min-width: 72px;
width: 72px;
max-width: 72px;
padding: 6px 4px;
text-align: center;
background: var(--app-card-bg);
}
.op-col :deep(.arco-btn) {
width: 22px;
height: 22px;
padding: 0;
margin: 0 1px;
vertical-align: middle;
color: var(--color-text-3);
background: transparent;
&:hover,
&:focus {
color: var(--color-text-2);
background: transparent;
}
&.arco-btn-status-danger {
color: var(--color-text-3);
&:hover,
&:focus {
color: var(--color-text-2);
background: transparent;
}
}
&:disabled {
color: var(--color-text-4);
}
}
.op-col :deep(.arco-icon) {
font-size: 14px;
}
.single-th-required {
display: inline-block;
color: #f53f3f;
margin-right: 2px;
font-weight: 600;
line-height: 1;
vertical-align: middle;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-fill-2);
color: var(--color-text-3);
font-size: 12px;
font-weight: 600;
}
thead th.op-col {
z-index: 3;
background: var(--color-fill-2);
}
tbody tr:hover td {
background: rgba(var(--primary-6), 0.03);
}
td {
background: var(--app-card-bg);
}
th:last-child,
td:last-child {
border-right: none;
}
:deep(.arco-input-wrapper) {
min-width: 0;
width: 100%;
min-height: 28px;
border-radius: 4px;
background-color: var(--app-card-bg);
border: 1px solid var(--color-border-2);
}
:deep(.arco-input-wrapper:hover) {
background-color: var(--app-card-bg);
border-color: var(--color-border-3);
}
:deep(.arco-input-wrapper.arco-input-focus) {
background-color: var(--app-card-bg);
border-color: var(--app-primary-color);
box-shadow: none;
}
:deep(.arco-input) {
font-size: 12px;
}
}
.declaration-derived-input {
:deep(.arco-input-wrapper) {
background: var(--color-fill-2);
color: var(--color-text-2);
}
}
.declaration-cell-error {
:deep(.arco-input) {
border-color: #f53f3f;
}
}
@media (max-width: 1200px) {
.single-form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
&--compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
}
@media (max-width: 992px) {
.main-content {
flex-direction: column;
}
.left-column,
.template-section {
flex: none;
width: 100%;
min-width: 0;
max-width: none;
}
.template-section {
min-height: 320px;
}
.single-cards-row {
grid-template-columns: 1fr;
}
.receiver-split {
grid-template-columns: 1fr;
}
.single-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.receiver-split {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.single-form-grid {
grid-template-columns: 1fr;
}
}
</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>