Add product/channel billing condition editors, place-order additional services, billing simulator, forecast-to-inbound fee delta and margin warnings, insurance-aware claim tips, OMS preset dict seeds, and related AI skills/system pages. Co-authored-by: Cursor <cursoragent@cursor.com>
5712 lines
184 KiB
Vue
5712 lines
184 KiB
Vue
<script lang="ts" setup>
|
||
import { ref, computed, onMounted, reactive, nextTick, watch } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { IconDownload, IconDelete, IconPlus, IconEdit } from '@arco-design/web-vue/es/icon';
|
||
import ExcelFileIcon from '@/components/icons/ExcelFileIcon.vue';
|
||
import CustomTemplateModal from './components/CustomTemplateModal.vue';
|
||
import FormLabelWithPreview from '@/views/channel/salesProducts/components/FormLabelWithPreview.vue';
|
||
import CustomerGuideDrawer from '@/views/order/priceCheck/components/CustomerGuideDrawer.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 { 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 { normalizeListRow, pickField } from '@/utils/warehouseListMapper';
|
||
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
|
||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||
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 contactVisible = ref(false);
|
||
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
|
||
const contactBlocked = ref(false);
|
||
const activeOrderMode = ref<'batch' | 'single'>('single');
|
||
const uploadDragActive = ref(false);
|
||
const priceCheckHandoff = ref<{
|
||
visible: boolean;
|
||
filled: string[];
|
||
pending: string[];
|
||
quoteLevel?: string;
|
||
disclaimers?: string[];
|
||
} | null>(null);
|
||
const showDeclarationExtra = ref(false);
|
||
/** 提交失败后展示字段级红框;修正后实时清除对应错误 */
|
||
const singleShowFieldErrors = ref(false);
|
||
/** 收件人选填字段默认折叠 */
|
||
const showRecipientOptional = ref(false);
|
||
/** 粘贴地址 → AI 解析 */
|
||
const receiverPasteText = ref('');
|
||
const receiverParseLoading = ref(false);
|
||
const receiverParseTip = ref<{
|
||
tone: 'ok' | 'warn' | 'danger';
|
||
text: string;
|
||
} | null>(null);
|
||
/** 申报表滚动面板高度(可拖拽调节) */
|
||
const declarationPanelHeight = ref(280);
|
||
const DECLARATION_PANEL_MIN = 160;
|
||
const DECLARATION_PANEL_MAX = 640;
|
||
|
||
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 templateOptions = computed(() => {
|
||
const list = [...standardTemplates.value, ...customTemplates.value];
|
||
return list.map((x) => ({ label: x.name || `模板 #${x.id}`, value: x.id }));
|
||
});
|
||
|
||
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 };
|
||
|
||
/** 9610简易申报默认字典编码(字典 export_customs_mode) */
|
||
const SAMPLE_EXPORT_CUSTOMS_MODE_CODE = '9610Simple';
|
||
|
||
const singleReferenceLoading = ref(false);
|
||
const singleSalesProductOptions = ref<SingleProductOption[]>([]);
|
||
const singleCargoAttributeOptions = ref<SingleSelectOption[]>([]);
|
||
const singleExportCustomsModeOptions = ref<SingleSelectOption[]>([]);
|
||
const singleWarehouseOptions = ref<SingleSelectOption[]>([]);
|
||
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);
|
||
return { code, name, productId: id > 0 ? id : undefined };
|
||
}
|
||
|
||
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();
|
||
return { id: id != null && id !== '' ? String(id) : '', name };
|
||
}
|
||
|
||
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].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',
|
||
商品图片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].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(
|
||
() =>
|
||
Boolean(pendingUploadFile.value) &&
|
||
batchAgreementChecked.value &&
|
||
selectedTemplate.value != null &&
|
||
selectedTemplateReady.value &&
|
||
Boolean(selectedSenderAddressId.value),
|
||
);
|
||
|
||
const batchSubmitBlockReason = computed(() => {
|
||
if (canSubmitBatch.value) return '';
|
||
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;
|
||
}
|
||
|
||
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.email || '') ||
|
||
formTrim(parsed.province || '') ||
|
||
formTrim(parsed.city || '') ||
|
||
formTrim(parsed.streetNumber || '') ||
|
||
formTrim(parsed.streetAddress2 || '') ||
|
||
formTrim(parsed.streetAddress3 || ''),
|
||
);
|
||
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('。') : '解析后仍不完整,请手动补全',
|
||
};
|
||
}
|
||
|
||
async function handleReceiverAiParse() {
|
||
const raw = receiverPasteText.value.trim();
|
||
if (!raw) {
|
||
Message.warning('请先粘贴收件人地址文本');
|
||
return;
|
||
}
|
||
if (receiverParseLoading.value) return;
|
||
receiverParseLoading.value = true;
|
||
receiverParseTip.value = null;
|
||
try {
|
||
const result = await postOrderReceiverParseAddress({ rawText: raw });
|
||
applyParsedReceiver(result.receiver);
|
||
buildReceiverParseTip(result);
|
||
Message.success('已解析并填入收件人信息');
|
||
} catch (e: any) {
|
||
receiverParseTip.value = {
|
||
tone: 'danger',
|
||
text: e?.message || '地址解析失败,请稍后重试',
|
||
};
|
||
Message.error(e?.message || '地址解析失败');
|
||
} finally {
|
||
receiverParseLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onReceiverPasteKeydown(e: KeyboardEvent) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
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(
|
||
() =>
|
||
singleAgreementChecked.value &&
|
||
isSingleDeclarationComplete() &&
|
||
isSingleRecipientComplete() &&
|
||
isSingleSenderFilled() &&
|
||
isSingleBasicComplete() &&
|
||
!!formTrim(singleForm.declaredCurrency),
|
||
);
|
||
|
||
const singleSubmitBlockReason = computed(() => {
|
||
if (canSubmitSingle.value) return '';
|
||
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 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;
|
||
|
||
function handleBatchFlowClick(step: 1 | 2 | 3) {
|
||
const target = step === 1 ? 'template' : step === 2 ? '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 (!canSubmitBatch.value) {
|
||
Message.warning(batchSubmitBlockReason.value || t('order.placeOrder.batch.block.generic'));
|
||
return;
|
||
}
|
||
if (!ensureAgreementChecked('batch')) return;
|
||
await handleBatchOrder();
|
||
}
|
||
|
||
async function handleSingleOrderClick() {
|
||
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 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 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 senderSelectOptions = computed(() =>
|
||
senderAddressOptions.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;
|
||
}
|
||
|
||
async function loadSenderAddressOptions({ applyDefault = true } = {}) {
|
||
senderAddressLoading.value = true;
|
||
try {
|
||
const res = await getSenderAddressList({
|
||
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() {
|
||
senderModalVisible.value = true;
|
||
await loadSenderAddressOptions({ applyDefault: !isSingleSenderFilled() });
|
||
}
|
||
|
||
function handleSenderAddressSelect(id: number | undefined) {
|
||
applySenderAddressById(id);
|
||
}
|
||
|
||
function clearSelectedSender() {
|
||
applySenderAddressById(undefined);
|
||
}
|
||
|
||
function goToSenderAddressManage() {
|
||
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;
|
||
}
|
||
|
||
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);
|
||
const productId = selected?.productId;
|
||
if (!productId) {
|
||
productAdditionalServiceOptions.value = [];
|
||
selectedAdditionalServices.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const detail = await getSalesProductDetail(productId);
|
||
const codes = parseAdditionalServiceCodes(
|
||
detail?.additionalServices ?? detail?.AdditionalServices
|
||
);
|
||
productAdditionalServiceOptions.value = codes.map((c) => ({
|
||
value: c,
|
||
label: labelAdditionalService(c),
|
||
}));
|
||
selectedAdditionalServices.value = selectedAdditionalServices.value.filter((c) =>
|
||
codes.includes(c)
|
||
);
|
||
} catch {
|
||
productAdditionalServiceOptions.value = [];
|
||
selectedAdditionalServices.value = [];
|
||
}
|
||
}
|
||
|
||
const singleForm = reactive<any>({
|
||
clientNo: '',
|
||
productCode: '',
|
||
cargoAttribute: '',
|
||
exportCustomsMode: '',
|
||
warehouseId: '',
|
||
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: '',
|
||
skuImage: '',
|
||
category: '',
|
||
brand: '',
|
||
brandType: '',
|
||
isMagnetic: false,
|
||
distribution: '',
|
||
material: '',
|
||
enMaterial: '',
|
||
purpose: '',
|
||
attributes: '',
|
||
originCountry: '',
|
||
salesLink: '',
|
||
model: '',
|
||
specifications: '',
|
||
comment: '',
|
||
},
|
||
],
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
const singleDeclaredTotalAmount = computed(() => {
|
||
const total = calcSingleDeclaredTotalAmount();
|
||
if (total === undefined) return '';
|
||
if (Number.isInteger(total)) return String(total);
|
||
return total.toFixed(4).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
|
||
});
|
||
|
||
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: '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 onDeclarationResizeStart(e: MouseEvent) {
|
||
e.preventDefault();
|
||
const startY = e.clientY;
|
||
const startH = declarationPanelHeight.value;
|
||
const onMove = (ev: MouseEvent) => {
|
||
const next = startH + (ev.clientY - startY);
|
||
declarationPanelHeight.value = Math.min(
|
||
DECLARATION_PANEL_MAX,
|
||
Math.max(DECLARATION_PANEL_MIN, next),
|
||
);
|
||
};
|
||
const onUp = () => {
|
||
window.removeEventListener('mousemove', onMove);
|
||
window.removeEventListener('mouseup', onUp);
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
};
|
||
document.body.style.cursor = 'ns-resize';
|
||
document.body.style.userSelect = 'none';
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
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,
|
||
}));
|
||
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 }));
|
||
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 });
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载下拉选项失败');
|
||
singleSalesProductOptions.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();
|
||
void (async () => {
|
||
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 = '';
|
||
}
|
||
}
|
||
|
||
function navigateToBatchJob(jobId: number) {
|
||
router.push({ path: '/order/batchJobs', query: { jobId: String(jobId) } });
|
||
}
|
||
|
||
function goToBatchJobs() {
|
||
router.push({ path: '/order/batchJobs' });
|
||
}
|
||
|
||
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,
|
||
);
|
||
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 (
|
||
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),
|
||
})),
|
||
};
|
||
payload.exportCustomsMode = exportMode || resolveExportCustomsMode9610Simple();
|
||
const declaredTotal = calcSingleDeclaredTotalAmount();
|
||
if (declaredTotal !== undefined) {
|
||
payload.declaredSum = declaredTotal;
|
||
}
|
||
return omitEmptyOptionalOrderFields(payload);
|
||
}
|
||
|
||
function handleAddDeclarationRow() {
|
||
singleForm.items.push({
|
||
cnName: '',
|
||
enName: '',
|
||
sku: '',
|
||
unit: '',
|
||
unitPrice: '',
|
||
unitWeight: '',
|
||
quantity: '',
|
||
amount: '',
|
||
weight: '',
|
||
imHsCode: '',
|
||
exHsCode: '',
|
||
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 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;
|
||
|
||
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: '',
|
||
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('示例订单已生成');
|
||
}
|
||
|
||
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;
|
||
}
|
||
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: '',
|
||
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: '',
|
||
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;
|
||
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;
|
||
|
||
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() {
|
||
const validationError = getSingleFormValidationError();
|
||
if (validationError) {
|
||
singleShowFieldErrors.value = true;
|
||
Message.warning(validationError.message);
|
||
focusSingleSection(validationError.section);
|
||
return;
|
||
}
|
||
singleSubmitting.value = true;
|
||
try {
|
||
await postOrderParcelCreate(buildSinglePayload());
|
||
priceCheckHandoff.value = null;
|
||
singleShowFieldErrors.value = false;
|
||
Modal.confirm({
|
||
title: t('order.placeOrder.single.successTitle'),
|
||
content: t('order.placeOrder.single.successContent'),
|
||
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)) {
|
||
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 ?? ''}`,
|
||
),
|
||
() => syncAllDeclarationRowDerivedFields(),
|
||
);
|
||
|
||
watch(
|
||
() => singleForm.productCode,
|
||
(code) => {
|
||
void loadProductAdditionalServices(String(code ?? ''));
|
||
},
|
||
);
|
||
|
||
watch(activeOrderMode, (mode) => {
|
||
if (mode === 'batch') {
|
||
void loadSenderAddressOptions({ applyDefault: !selectedSenderAddressId.value });
|
||
}
|
||
});
|
||
|
||
function handleSingleFlowClick(stepKey: string) {
|
||
if (stepKey === 'sender') {
|
||
void openSenderModal();
|
||
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>
|
||
<a-tabs v-model:active-key="activeOrderMode" type="line" class="place-order-tabs">
|
||
<a-tab-pane key="single" :title="t('order.placeOrder.singleOrder')">
|
||
<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),
|
||
}"
|
||
@click="handleSingleFlowClick(step.key)"
|
||
>
|
||
<span class="batch-step-index">{{ idx + 1 }}</span>
|
||
<span>{{ step.label }}</span>
|
||
</button>
|
||
<span v-if="idx < singleFlowSteps.length - 1" class="batch-step-sep">→</span>
|
||
</template>
|
||
</div>
|
||
<div class="toolbar-right single-toolbar-right">
|
||
<button
|
||
type="button"
|
||
class="sender-summary-chip"
|
||
:class="{ 'is-empty': !selectedSenderSummary }"
|
||
:title="
|
||
selectedSenderSummary
|
||
? t('order.placeOrder.single.senderEditHint')
|
||
: t('order.placeOrder.single.senderEmpty')
|
||
"
|
||
@click="openSenderModal"
|
||
>
|
||
<span class="sender-summary-label">{{ t('order.placeOrder.single.stepSender') }}</span>
|
||
<span class="sender-summary-text">
|
||
{{ selectedSenderSummary || t('order.placeOrder.single.senderPickHint') }}
|
||
</span>
|
||
</button>
|
||
<div class="single-tips">
|
||
<span class="single-tip-text">{{ t('order.placeOrder.single.tip1') }}</span>
|
||
<span class="single-tip-text tip2">{{ t('order.placeOrder.single.tip2') }}</span>
|
||
</div>
|
||
<a-button size="small" type="outline" @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
|
||
v-if="productAdditionalServiceOptions.length"
|
||
:label="t('order.placeOrder.form.additionalServices')"
|
||
class="additional-services-item"
|
||
>
|
||
<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>
|
||
</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 label="出口报关方式">
|
||
<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 label="长(cm)" :validate-status="fieldValidateStatus('length')" :help="fieldHelp('length')">
|
||
<a-input v-model="singleForm.length" />
|
||
</a-form-item>
|
||
<a-form-item label="宽(cm)" :validate-status="fieldValidateStatus('width')" :help="fieldHelp('width')">
|
||
<a-input v-model="singleForm.width" />
|
||
</a-form-item>
|
||
<a-form-item label="高(cm)" :validate-status="fieldValidateStatus('height')" :help="fieldHelp('height')">
|
||
<a-input v-model="singleForm.height" />
|
||
</a-form-item>
|
||
</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">
|
||
<aside class="receiver-split__paste">
|
||
<div class="receiver-ai-paste">
|
||
<div class="receiver-ai-paste__head">
|
||
<span class="receiver-ai-paste__title">粘贴地址 · AI 解析</span>
|
||
<span class="receiver-ai-paste__hint">中/英整段粘贴,Ctrl+Enter 解析</span>
|
||
</div>
|
||
<a-textarea
|
||
v-model="receiverPasteText"
|
||
class="receiver-ai-paste__textarea"
|
||
:auto-size="{ minRows: 8, maxRows: 16 }"
|
||
placeholder="例:John Smith, +1 310-555-0199, 1001 Sunset Blvd, Los Angeles, CA 90001, USA 或:张三 13800138000 广东省深圳市南山区科技园南路 XX 号 518000"
|
||
@keydown="onReceiverPasteKeydown"
|
||
/>
|
||
<div class="receiver-ai-paste__actions">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="receiverParseLoading"
|
||
@click="handleReceiverAiParse"
|
||
>
|
||
AI解析
|
||
</a-button>
|
||
<a-button
|
||
v-if="receiverPasteText"
|
||
size="small"
|
||
@click="receiverPasteText = ''; receiverParseTip = null"
|
||
>
|
||
清空
|
||
</a-button>
|
||
</div>
|
||
<p
|
||
v-if="receiverParseTip"
|
||
class="receiver-ai-paste__tip"
|
||
:class="`is-${receiverParseTip.tone}`"
|
||
>
|
||
{{ receiverParseTip.text }}
|
||
</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<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>
|
||
</div>
|
||
<div class="recipient-optional-toggle">
|
||
<button
|
||
type="button"
|
||
class="declaration-extra-btn"
|
||
@click="showRecipientOptional = !showRecipientOptional"
|
||
>
|
||
{{
|
||
showRecipientOptional
|
||
? t('order.placeOrder.single.hideRecipientOptional')
|
||
: t('order.placeOrder.single.showRecipientOptional')
|
||
}}
|
||
</button>
|
||
</div>
|
||
<div
|
||
v-show="showRecipientOptional"
|
||
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="收件人邮箱"><a-input v-model="singleForm.receiver.email" /></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="地址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>
|
||
</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>
|
||
</div>
|
||
<div class="single-section-header-right">
|
||
<div class="declaration-header-field">
|
||
<span class="declaration-header-label">申报总金额</span>
|
||
<a-input
|
||
class="declaration-header-input"
|
||
:model-value="singleDeclaredTotalAmount"
|
||
readonly
|
||
placeholder="自动汇总"
|
||
/>
|
||
</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') }"
|
||
allow-search
|
||
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
|
||
class="declaration-header-hint"
|
||
:class="{ 'is-error-hint': !!fieldHelp('declaredCurrency') }"
|
||
>
|
||
{{ fieldHelp('declaredCurrency') || t('order.placeOrder.single.declaredCurrencyHint') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div class="declaration-extra-toggle">
|
||
<button type="button" class="declaration-extra-btn" @click="showDeclarationExtra = !showDeclarationExtra">
|
||
{{ showDeclarationExtra ? t('order.placeOrder.single.hideExtraCols') : t('order.placeOrder.single.showExtraCols') }}
|
||
</button>
|
||
<span class="declaration-resize-hint">{{ t('order.placeOrder.single.declarationResizeHint') }}</span>
|
||
</div>
|
||
<div
|
||
class="single-declaration-table-wrap"
|
||
:style="{ height: `${declarationPanelHeight}px` }"
|
||
>
|
||
<table class="single-declaration-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="op-col">操作</th>
|
||
<th v-for="col in visibleDeclarationColumns" :key="col.key">
|
||
{{ col.label }}<span v-if="col.required" class="single-th-required">*</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(item, index) in singleForm.items" :key="index">
|
||
<td class="op-col">
|
||
<div class="op-col-actions">
|
||
<a-button
|
||
type="text"
|
||
size="mini"
|
||
class="declaration-row-add-btn"
|
||
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>
|
||
</div>
|
||
</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
|
||
:type="col.type === 'number' ? 'number' : 'text'"
|
||
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>
|
||
<div
|
||
class="declaration-resize-handle"
|
||
title="拖拽调节高度"
|
||
@mousedown="onDeclarationResizeStart"
|
||
>
|
||
<span class="declaration-resize-bar" />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="single-form-footer liquid-card">
|
||
<div
|
||
class="single-submit-hint"
|
||
:class="canSubmitSingle ? 'is-ready' : 'is-pending'"
|
||
>
|
||
{{ singleSubmitHint }}
|
||
</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.singleOrder') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="batch" :title="t('order.placeOrder.batchOrder')">
|
||
<div class="batch-tab">
|
||
<div class="batch-toolbar table-toolbar liquid-card">
|
||
<div class="toolbar-left batch-steps">
|
||
<button
|
||
type="button"
|
||
class="batch-step flow-step-btn"
|
||
:class="{ active: batchStep >= 1, done: batchStep > 1, current: batchFocusTarget === 'template' }"
|
||
@click="handleBatchFlowClick(1)"
|
||
>
|
||
<span class="batch-step-index">1</span>
|
||
<span>{{ t('order.placeOrder.batch.stepTemplate') }}</span>
|
||
</button>
|
||
<span class="batch-step-sep">→</span>
|
||
<button
|
||
type="button"
|
||
class="batch-step flow-step-btn"
|
||
:class="{ active: batchStep >= 2, done: batchStep > 2, current: batchFocusTarget === 'upload' }"
|
||
@click="handleBatchFlowClick(2)"
|
||
>
|
||
<span class="batch-step-index">2</span>
|
||
<span>{{ t('order.placeOrder.batch.stepUpload') }}</span>
|
||
</button>
|
||
<span class="batch-step-sep">→</span>
|
||
<button
|
||
type="button"
|
||
class="batch-step flow-step-btn"
|
||
:class="{ active: batchStep >= 3, current: batchFocusTarget === 'submit' }"
|
||
@click="handleBatchFlowClick(3)"
|
||
>
|
||
<span class="batch-step-index">3</span>
|
||
<span>{{ t('order.placeOrder.batch.stepSubmit') }}</span>
|
||
</button>
|
||
<span class="batch-guide-hint">{{ t('order.placeOrder.batch.guideHint') }}</span>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<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">{{ t('order.placeOrder.batch.uploadTitle') }}</div>
|
||
|
||
<div class="upload-card-main">
|
||
<div class="upload-card-main__left">
|
||
<div class="template-selector">
|
||
<label class="selector-label">{{ t('order.placeOrder.template.label') }}</label>
|
||
<a-select
|
||
v-model="selectedTemplate"
|
||
class="template-select"
|
||
allow-search
|
||
:placeholder="t('order.placeOrder.batch.pickTemplateHint')"
|
||
:options="templateOptions"
|
||
:loading="templatePanelLoading"
|
||
/>
|
||
<div
|
||
v-if="selectedTemplateRow?.type === 'custom' && selectedTemplateRow.mappingStatus !== 'ready'"
|
||
class="template-draft-tip"
|
||
>
|
||
<a-tag size="small" color="orangered">待编辑</a-tag>
|
||
<span>{{ t('order.placeOrder.batch.block.templateDraft') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="batch-sender-block">
|
||
<label class="selector-label">{{ t('order.placeOrder.batch.senderLabel') }}</label>
|
||
<a-select
|
||
:model-value="selectedSenderAddressId"
|
||
allow-search
|
||
class="template-select"
|
||
:loading="senderAddressLoading"
|
||
:placeholder="t('order.placeOrder.single.senderSelectPlaceholder')"
|
||
:options="senderSelectOptions"
|
||
@change="handleSenderAddressSelect"
|
||
/>
|
||
<p v-if="selectedSenderSummary" class="batch-sender-summary">{{ selectedSenderSummary }}</p>
|
||
<p v-else-if="!senderAddressLoading && !senderAddressOptions.length" class="batch-sender-empty">
|
||
{{ t('order.placeOrder.single.senderEmpty') }}
|
||
<a-link @click="goToSenderAddressManage">{{ t('order.placeOrder.single.senderManage') }}</a-link>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="upload-left-tips">
|
||
<p>{{ t('order.placeOrder.upload.tip1') }}</p>
|
||
<p>{{ t('order.placeOrder.batch.templateTip') }}</p>
|
||
<p>{{ t('order.placeOrder.upload.tip2') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="upload-card-main__right">
|
||
<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 primary">{{ t('order.placeOrder.batch.block.noFile') }}</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>
|
||
|
||
<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>
|
||
<span class="template-card-hint">{{ t('order.placeOrder.templatePanel.hint') }}</span>
|
||
</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>
|
||
<div class="template-actions template-actions-inline" @click.stop>
|
||
<span
|
||
class="download-btn"
|
||
:title="t('order.placeOrder.template.download')"
|
||
@click="handleDownloadTemplate(template)"
|
||
>
|
||
<icon-download />
|
||
</span>
|
||
</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>
|
||
<div class="template-actions template-actions-inline" @click.stop>
|
||
<span
|
||
class="download-btn"
|
||
:title="t('order.placeOrder.template.download')"
|
||
@click="handleDownloadTemplate(template)"
|
||
>
|
||
<icon-download />
|
||
</span>
|
||
<span
|
||
class="edit-btn"
|
||
:title="t('order.placeOrder.template.edit')"
|
||
@click="openTemplateMapping(template)"
|
||
>
|
||
<icon-edit />
|
||
</span>
|
||
<span
|
||
class="delete-btn"
|
||
:title="t('order.placeOrder.template.delete')"
|
||
@click="handleDeleteCustomTemplate(template)"
|
||
>
|
||
<icon-delete />
|
||
</span>
|
||
</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>
|
||
</a-tab-pane>
|
||
|
||
</a-tabs>
|
||
|
||
<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="560px"
|
||
unmount-on-close
|
||
:ok-text="t('order.placeOrder.single.senderModalOk')"
|
||
@ok="senderModalVisible = false"
|
||
>
|
||
<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
|
||
allow-clear
|
||
: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 class="sender-modal-actions">
|
||
<a-button size="small" type="text" status="danger" :disabled="!selectedSenderSummary" @click="clearSelectedSender">
|
||
{{ t('order.placeOrder.single.senderClear') }}
|
||
</a-button>
|
||
<a-button size="small" type="text" @click="goToSenderAddressManage">
|
||
{{ t('order.placeOrder.single.senderManage') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</a-spin>
|
||
</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"
|
||
/>
|
||
|
||
<CustomerGuideDrawer ref="customerGuideDrawerRef" />
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.place-order-container {
|
||
padding: 16px;
|
||
background-color: #f0f3f4;
|
||
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: #86909c;
|
||
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 rgba(255, 255, 255, 0.92);
|
||
border-radius: 16px !important;
|
||
background:
|
||
linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 48%, rgba(255, 255, 255, 0.94) 100%);
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.98),
|
||
inset 0 -1px 0 rgba(255, 255, 255, 0.28),
|
||
0 8px 24px rgba(15, 23, 42, 0.05),
|
||
0 1px 2px rgba(15, 23, 42, 0.03);
|
||
backdrop-filter: blur(14px) saturate(1.08);
|
||
-webkit-backdrop-filter: blur(14px) saturate(1.08);
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
z-index: 0;
|
||
inset: -28% -12% auto auto;
|
||
width: 62%;
|
||
height: 100%;
|
||
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
|
||
background:
|
||
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.9) 0%, transparent 44%),
|
||
radial-gradient(circle at 68% 70%, rgba(240, 243, 244, 0.7) 0%, transparent 58%);
|
||
filter: blur(16px);
|
||
opacity: 0.75;
|
||
pointer-events: none;
|
||
animation: place-liquid-drift 14s ease-in-out infinite;
|
||
}
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
z-index: 0;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
background: linear-gradient(
|
||
115deg,
|
||
transparent 30%,
|
||
rgba(255, 255, 255, 0.06) 44%,
|
||
rgba(255, 255, 255, 0.32) 50%,
|
||
rgba(255, 255, 255, 0.06) 56%,
|
||
transparent 70%
|
||
);
|
||
background-size: 220% 100%;
|
||
background-position: 120% 0;
|
||
opacity: 0.42;
|
||
pointer-events: none;
|
||
}
|
||
|
||
> * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes place-liquid-drift {
|
||
0%,
|
||
100% {
|
||
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
|
||
transform: translate(0, 0) scale(1);
|
||
}
|
||
50% {
|
||
border-radius: 54% 46% 52% 48% / 44% 56% 44% 56%;
|
||
transform: translate(-4%, 3%) scale(1.04);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.liquid-card::before {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
.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: #4e5969;
|
||
}
|
||
|
||
.handoff-label {
|
||
color: #1d2129;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.handoff-quote-warning {
|
||
color: #d48806;
|
||
}
|
||
}
|
||
|
||
.single-toolbar-right {
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.single-tips {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.single-tip-text {
|
||
color: #86909c;
|
||
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 #e5e6eb;
|
||
background: #f7f8fa;
|
||
|
||
&.empty {
|
||
color: #86909c;
|
||
border-style: dashed;
|
||
}
|
||
|
||
.chip-name {
|
||
font-weight: 600;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.chip-placeholder {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.batch-block-reason {
|
||
margin: 0 0 8px;
|
||
color: #ff7d00;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.declaration-extra-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.declaration-resize-hint {
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.recipient-optional-toggle {
|
||
margin: 8px 0 4px;
|
||
}
|
||
|
||
.recipient-optional-grid {
|
||
margin-top: 8px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed rgba(45, 140, 140, 0.18);
|
||
}
|
||
|
||
.receiver-ai-paste {
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
border: 1px dashed color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
|
||
background: color-mix(in srgb, var(--app-primary-color) 6%, #fff);
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.receiver-ai-paste__head {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.receiver-ai-paste__title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.receiver-ai-paste__hint {
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
}
|
||
|
||
.receiver-ai-paste__textarea {
|
||
flex: 1;
|
||
|
||
:deep(textarea) {
|
||
min-height: 160px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
background: #fff;
|
||
}
|
||
}
|
||
|
||
.receiver-ai-paste__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.receiver-ai-paste__tip {
|
||
margin: 8px 0 0;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
|
||
&.is-ok {
|
||
color: #00a870;
|
||
}
|
||
|
||
&.is-warn {
|
||
color: #d48806;
|
||
}
|
||
|
||
&.is-danger {
|
||
color: #f53f3f;
|
||
}
|
||
}
|
||
|
||
.declaration-card {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.single-declaration-table-wrap {
|
||
overflow-x: auto;
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
-webkit-overflow-scrolling: touch;
|
||
border: 1px solid #e8eaed;
|
||
border-radius: 8px 8px 0 0;
|
||
background: #fff;
|
||
min-height: 160px;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.declaration-resize-handle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 14px;
|
||
border: 1px solid #e8eaed;
|
||
border-top: none;
|
||
border-radius: 0 0 8px 8px;
|
||
background: #f7f8fa;
|
||
cursor: ns-resize;
|
||
user-select: none;
|
||
|
||
&:hover .declaration-resize-bar,
|
||
&:active .declaration-resize-bar {
|
||
background: var(--app-primary-color, #2d8c8c);
|
||
}
|
||
}
|
||
|
||
.declaration-resize-bar {
|
||
width: 40px;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
background: #c9cdd4;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.declaration-extra-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
border: 1px solid rgba(22, 93, 255, 0.35);
|
||
border-radius: 6px;
|
||
background: rgba(22, 93, 255, 0.06);
|
||
color: #165dff;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||
|
||
&:hover {
|
||
color: #0e42d2;
|
||
background: rgba(22, 93, 255, 0.12);
|
||
border-color: rgba(22, 93, 255, 0.55);
|
||
}
|
||
|
||
&:active {
|
||
color: #0e42d2;
|
||
background: rgba(22, 93, 255, 0.16);
|
||
}
|
||
}
|
||
|
||
.declaration-cell-error {
|
||
:deep(.arco-input) {
|
||
border-color: #f53f3f;
|
||
}
|
||
}
|
||
|
||
.batch-guide-hint {
|
||
margin-left: 8px;
|
||
padding-left: 10px;
|
||
border-left: 1px solid #e5e6eb;
|
||
color: #86909c;
|
||
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 rgba(45, 140, 140, 0.28),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||
0 6px 18px rgba(15, 23, 42, 0.04);
|
||
}
|
||
40% {
|
||
box-shadow:
|
||
0 0 0 4px rgba(45, 140, 140, 0.18),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||
0 6px 18px rgba(15, 23, 42, 0.04);
|
||
}
|
||
100% {
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||
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;
|
||
|
||
:deep(.arco-tabs-nav) {
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
background: #f0f3f4;
|
||
}
|
||
|
||
:deep(.arco-tabs-content) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: auto;
|
||
}
|
||
:deep(.arco-tabs-content-item) {
|
||
height: 100%;
|
||
}
|
||
:deep(.arco-tabs-pane) {
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.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: #86909c;
|
||
|
||
&.active {
|
||
color: #1d2129;
|
||
font-weight: 500;
|
||
}
|
||
|
||
&.done .batch-step-index {
|
||
background: rgba(var(--primary-6), 0.12);
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
|
||
.batch-step-index {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
background: #f2f3f5;
|
||
color: #86909c;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.batch-step.active .batch-step-index {
|
||
background: var(--app-primary-color);
|
||
color: #fff;
|
||
}
|
||
|
||
.batch-step-sep {
|
||
color: #c9cdd4;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.main-content {
|
||
display: flex;
|
||
gap: 16px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.left-column {
|
||
flex: 1 1 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
}
|
||
|
||
.upload-section {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.upload-card {
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
padding: 20px;
|
||
height: 100%;
|
||
min-height: 520px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.upload-card-title {
|
||
margin: 0 0 16px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.upload-card-main {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||
gap: 16px;
|
||
flex: 1;
|
||
min-height: 360px;
|
||
margin-bottom: 12px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.upload-card-main__left,
|
||
.upload-card-main__right {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.upload-card-main__left {
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border-radius: 10px;
|
||
border: 1px solid #eef0f3;
|
||
background: #fafbfc;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.upload-card-main__right {
|
||
min-height: 0;
|
||
}
|
||
|
||
.template-selector {
|
||
margin-bottom: 0;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
|
||
.selector-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.selector-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.template-select {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.batch-sender-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
|
||
.selector-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.template-select {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.batch-sender-summary {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: #4e5969;
|
||
}
|
||
|
||
.batch-sender-empty {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: #86909c;
|
||
}
|
||
|
||
.template-draft-tip {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
color: #ff7d00;
|
||
}
|
||
|
||
.upload-left-tips {
|
||
margin-top: auto;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed #e5e6eb;
|
||
|
||
p {
|
||
margin: 0 0 4px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: #4e5969;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.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: 280px;
|
||
border: 1px dashed #c9cdd4;
|
||
border-radius: 10px;
|
||
background: #f7f8fa;
|
||
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: #86909c;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.upload-text {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #1d2129;
|
||
}
|
||
|
||
.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: #86909c;
|
||
|
||
&.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: #1d2129;
|
||
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: #4e5969;
|
||
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;
|
||
}
|
||
|
||
.template-panel-spin {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
:deep(.arco-spin) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
.template-card {
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
padding: 16px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.template-card-header {
|
||
margin-bottom: 12px;
|
||
flex-shrink: 0;
|
||
|
||
h3 {
|
||
margin: 0 0 4px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1d2129;
|
||
}
|
||
}
|
||
|
||
.template-card-hint {
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
}
|
||
|
||
.template-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.template-empty {
|
||
grid-column: 1 / -1;
|
||
color: #86909c;
|
||
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: #1d2129;
|
||
}
|
||
}
|
||
|
||
.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: 240px;
|
||
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: #fff;
|
||
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: #4e5969 !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: #262626;
|
||
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: 12px;
|
||
color: #8c8c8c;
|
||
}
|
||
}
|
||
|
||
.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-color: var(--app-primary-color);
|
||
background: rgba(var(--primary-6), 0.06);
|
||
box-shadow: 0 0 0 1px rgba(45, 140, 140, 0.2);
|
||
}
|
||
|
||
.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: #1d2129;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.template-update {
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
}
|
||
|
||
.template-actions-inline {
|
||
position: static;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
|
||
}
|
||
|
||
.delete-btn,
|
||
.download-btn,
|
||
.edit-btn {
|
||
color: #4e5969 !important;
|
||
width: 16px;
|
||
height: 16px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
opacity: 1 !important;
|
||
visibility: visible !important;
|
||
|
||
: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;
|
||
}
|
||
|
||
.close-btn {
|
||
color: #86909c;
|
||
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 #c9cdd4;
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
background: #f7f8fa;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
color: #4e5969;
|
||
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: #4e5969;
|
||
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: #1d2129;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
line-height: 1.45;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.create-fail-tip {
|
||
margin: 8px 0 0;
|
||
color: #4e5969;
|
||
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: #4e5969;
|
||
background: #f7f8fa;
|
||
|
||
&.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: #86909c;
|
||
|
||
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: #f7f8fa;
|
||
color: #86909c;
|
||
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;
|
||
gap: 12px;
|
||
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: #c9cdd4;
|
||
}
|
||
}
|
||
|
||
.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(180px, 0.9fr) minmax(0, 1.2fr);
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.receiver-split__paste,
|
||
.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-summary-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-width: 280px;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(45, 140, 140, 0.25);
|
||
background: rgba(45, 140, 140, 0.06);
|
||
color: #1d2129;
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
|
||
&:hover {
|
||
border-color: var(--app-primary-color);
|
||
background: rgba(45, 140, 140, 0.1);
|
||
}
|
||
|
||
&.is-empty {
|
||
border-color: #ffcf8b;
|
||
background: #fff7e8;
|
||
|
||
.sender-summary-label,
|
||
.sender-summary-text {
|
||
color: #ad6800;
|
||
}
|
||
}
|
||
}
|
||
|
||
.sender-summary-label {
|
||
flex-shrink: 0;
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sender-summary-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #4e5969;
|
||
}
|
||
|
||
.sender-modal-spin {
|
||
width: 100%;
|
||
}
|
||
|
||
.sender-modal-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.sender-preview {
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
background: #f7f8fa;
|
||
border: 1px solid #eef0f3;
|
||
}
|
||
|
||
.sender-preview__title {
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #86909c;
|
||
}
|
||
|
||
.sender-preview__line {
|
||
margin: 0 0 4px;
|
||
font-size: 13px;
|
||
color: #1d2129;
|
||
line-height: 1.45;
|
||
|
||
&.muted {
|
||
color: #4e5969;
|
||
}
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.sender-modal-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.single-form-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 0;
|
||
padding: 12px 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: #f7f8fa;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
line-height: 1.6;
|
||
font-size: 13px;
|
||
color: #1d2129;
|
||
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;
|
||
}
|
||
|
||
.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: #86909c;
|
||
white-space: nowrap;
|
||
margin-left: 4px;
|
||
|
||
&.is-error-hint {
|
||
color: #f53f3f;
|
||
}
|
||
}
|
||
|
||
.declaration-header-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.declaration-header-label {
|
||
font-size: 13px;
|
||
color: #4e5969;
|
||
white-space: nowrap;
|
||
|
||
&.required::before {
|
||
content: '*';
|
||
color: #f53f3f;
|
||
margin-right: 4px;
|
||
}
|
||
}
|
||
|
||
.declaration-header-input {
|
||
width: 120px;
|
||
}
|
||
|
||
.declaration-header-select {
|
||
width: 100px;
|
||
}
|
||
|
||
.declaration-header-input,
|
||
.declaration-header-select {
|
||
:deep(.arco-input-wrapper),
|
||
:deep(.arco-select-view) {
|
||
min-height: 28px;
|
||
background: #fff;
|
||
}
|
||
}
|
||
|
||
.declaration-derived-input {
|
||
:deep(.arco-input-wrapper) {
|
||
background: #f7f8fa;
|
||
color: #4e5969;
|
||
}
|
||
}
|
||
|
||
.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: #4e5969;
|
||
background: #f2f3f5;
|
||
flex-shrink: 0;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.single-section-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1d2129;
|
||
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: #fff;
|
||
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: #fafbfc;
|
||
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%, #fafbfc 48%);
|
||
border-bottom-color: #eef0f3;
|
||
}
|
||
|
||
: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: #86909c;
|
||
|
||
&:hover {
|
||
background: rgba(var(--primary-6), 0.08);
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
|
||
:deep(.arco-collapse-item-content) {
|
||
background: #fff;
|
||
}
|
||
|
||
: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: #4e5969;
|
||
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-left: 2px;
|
||
}
|
||
|
||
:deep(.arco-input-wrapper),
|
||
:deep(.arco-select-view-single),
|
||
:deep(.arco-select-view-multiple) {
|
||
min-height: 32px;
|
||
border-radius: 4px;
|
||
background-color: #fff;
|
||
border: 1px solid #e5e6eb;
|
||
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%;
|
||
}
|
||
}
|
||
|
||
.single-declaration-table {
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
width: max-content;
|
||
min-width: 100%;
|
||
table-layout: fixed;
|
||
|
||
th,
|
||
td {
|
||
border-right: 1px solid #eef0f3;
|
||
border-bottom: 1px solid #eef0f3;
|
||
padding: 8px 10px;
|
||
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: 88px;
|
||
width: 88px;
|
||
max-width: 88px;
|
||
background: #fff;
|
||
box-shadow: 2px 0 4px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.single-th-required {
|
||
color: #f53f3f;
|
||
margin-left: 2px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
background: #f7f8fa;
|
||
color: #1d2129;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
box-shadow: inset 0 -1px 0 #eef0f3;
|
||
}
|
||
|
||
thead th.op-col {
|
||
z-index: 3;
|
||
background: #f7f8fa;
|
||
}
|
||
|
||
tbody tr {
|
||
transition: background 0.15s ease;
|
||
|
||
&:hover td {
|
||
background: rgba(var(--primary-6), 0.03);
|
||
}
|
||
|
||
&:hover td.op-col {
|
||
background: #f3fbfb;
|
||
}
|
||
}
|
||
|
||
td {
|
||
background: #fff;
|
||
}
|
||
|
||
tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
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: #fff;
|
||
border: 1px solid #e5e6eb;
|
||
}
|
||
|
||
:deep(.arco-input-wrapper:hover) {
|
||
background-color: #fff;
|
||
border-color: #c9cdd4;
|
||
}
|
||
|
||
:deep(.arco-input-wrapper.arco-input-focus) {
|
||
background-color: #fff;
|
||
border-color: var(--app-primary-color);
|
||
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.1);
|
||
}
|
||
|
||
:deep(.arco-input) {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.op-col {
|
||
min-width: 88px !important;
|
||
width: 88px;
|
||
text-align: center;
|
||
}
|
||
|
||
.op-col-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.op-col-actions :deep(.arco-btn) {
|
||
width: 24px;
|
||
height: 24px;
|
||
padding: 0;
|
||
}
|
||
|
||
.op-col-actions :deep(.arco-icon) {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.declaration-row-add-btn {
|
||
color: var(--app-primary-color);
|
||
}
|
||
|
||
@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;
|
||
}
|
||
|
||
.upload-card-main {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.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>
|