front/src/views/order/priceCheck/index.vue
2026-10-06 15:37:02 +08:00

1819 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { IconSearch, IconDown, IconUp, IconInfoCircle, IconQuestionCircle, IconClose } from '@arco-design/web-vue/es/icon';
import { Message } from '@arco-design/web-vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { postOrderPriceCheck, getOrderPriceCheckShippingOptions, type PriceCheckQueryHint, type PriceCheckShippingGroup, type CouponAvailableOutput } from '@/api/order';
import { getCountryList } from '@/api/system/country';
import { getDictListAll } from '@/api/system/dict';
import { getWarehouseList } from '@/api/system/warehouse';
import { useCurrentWarehouseStore } from '@/store';
import ChannelGuidePanel from './components/ChannelGuidePanel.vue';
import ShippingMethodTabSelect from './components/ShippingMethodTabSelect.vue';
import AiRecommendCard from './components/AiRecommendCard.vue';
import QuoteResultCard from './components/QuoteResultCard.vue';
import SlidingTabs from '@/components/SlidingTabs/index.vue';
import { recommendWithAi, type QuoteCandidate, type RecommendAlternative, type ShipVerdict } from './recommendChannel';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { openPriceAskAdvisor, type PriceAskIntent } from '@/composables/priceAskAdvisor';
import {
formatPriceCheckFailures,
normalizePriceCheckFailures,
type PriceCheckFailureItem,
} from './priceCheckFailures';
defineOptions({
name: 'PriceCheck',
});
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
const { t } = useI18n();
const router = useRouter();
const currentWarehouseStore = useCurrentWarehouseStore();
/** 首屏常用国精简,避免芯片墙;其余靠搜索 */
const countryShortcutCodes: string[] = ['US', 'GB', 'DE', 'CA', 'FR', 'ES'];
const formData = reactive({
destinationCountry: '',
shippingMethod: '',
cargoWeight: '1',
length: '',
width: '',
height: '',
/** 货物属性(单选;空表示全部) */
cargoType: '' as string,
/** 仓库(单选;空表示全部) */
warehouseId: undefined as number | undefined,
postalCode: '',
destinationProvince: '',
destinationCity: '',
});
/** 是否已发起过查询(用于空态 / 结果区切换) */
const hasQueried = ref(false);
const postalInputRef = ref<HTMLElement | null>(null);
function parseOptionalDimension(value: unknown) {
const n = parseFloat(String(value ?? '').trim());
return Number.isFinite(n) && n > 0 ? n : undefined;
}
function hasFullDimensions() {
return [formData.length, formData.width, formData.height].every((v) => parseOptionalDimension(v) != null);
}
function hasPartialDimensions() {
const filled = [formData.length, formData.width, formData.height].filter((v) => String(v ?? '').trim() !== '').length;
return filled > 0 && !hasFullDimensions();
}
/** 更多条件默认折叠,各项空值表示「全部」 */
const showAdvanced = ref(false);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
const sortTabItems = computed(() => [
{ key: 'recommend', label: t('order.priceCheck.sort.recommend') },
{ key: 'price', label: t('order.priceCheck.sort.price') },
{ key: 'time', label: t('order.priceCheck.sort.time') },
]);
const selectedGuideRecord = ref<Record<string, any> | null>(null);
const recommendedRowKey = ref('');
const recommendReason = ref('');
const recommendVerdict = ref<ShipVerdict | ''>('');
const recommendEvidence = ref<string[]>([]);
const recommendCargoName = ref('');
const recommendAlternatives = ref<RecommendAlternative[]>([]);
const aiRecommendLoading = ref(false);
const queryHint = ref<PriceCheckQueryHint | null>(null);
const hintBannerDismissed = ref(false);
const couponInstanceId = ref<number | undefined>(undefined);
const availableCoupons = ref<CouponAvailableOutput[]>([]);
const needPostalHint = computed(() => {
if (!hasQueried.value) return false;
if (String(formData.postalCode || '').trim()) return false;
const missing = queryHint.value?.missingFieldCodes ?? [];
return (
queryHint.value?.quoteLevel === 'estimated' ||
missing.includes('postalCode') ||
missing.includes('PostalCode')
);
});
/** 仅「更多条件」里的筛选项摘要(主表单字段不再重复展示) */
type AdvancedConditionChip = {
key: string;
label: string;
kind:
| 'dimensions'
| 'destinationProvince'
| 'destinationCity'
| 'cargoType'
| 'warehouse'
| 'coupon';
value?: string | number;
};
const advancedConditionChips = computed((): AdvancedConditionChip[] => {
const chips: AdvancedConditionChip[] = [];
if (hasFullDimensions()) {
chips.push({
key: 'dimensions',
kind: 'dimensions',
label: `${formData.length}×${formData.width}×${formData.height}cm`,
});
}
const province = String(formData.destinationProvince || '').trim();
if (province) {
chips.push({
key: 'destinationProvince',
kind: 'destinationProvince',
value: province,
label: `${t('order.priceCheck.form.destinationProvince')}: ${province}`,
});
}
const city = String(formData.destinationCity || '').trim();
if (city) {
chips.push({
key: 'destinationCity',
kind: 'destinationCity',
value: city,
label: `${t('order.priceCheck.form.destinationCity')}: ${city}`,
});
}
if (formData.cargoType) {
const hit = cargoTypeOptions.value.find((c) => c.value === formData.cargoType);
chips.push({
key: `cargoType:${formData.cargoType}`,
kind: 'cargoType',
value: formData.cargoType,
label: hit?.label || formData.cargoType,
});
}
if (formData.warehouseId != null) {
const hit = warehouseOptions.value.find((w) => w.value === formData.warehouseId);
chips.push({
key: `warehouse:${formData.warehouseId}`,
kind: 'warehouse',
value: formData.warehouseId,
label: hit?.label || String(formData.warehouseId),
});
}
if (couponInstanceId.value) {
const c = availableCoupons.value.find((x) => (x.id ?? x.Id) === couponInstanceId.value);
const name = String(c?.templateName ?? c?.TemplateName ?? '').trim();
chips.push({
key: 'coupon',
kind: 'coupon',
value: couponInstanceId.value,
label: name || t('marketing.priceCheck.couponLabel'),
});
}
return chips;
});
function removeAdvancedChip(chip: AdvancedConditionChip) {
if (chip.kind === 'dimensions') {
formData.length = '';
formData.width = '';
formData.height = '';
return;
}
if (chip.kind === 'destinationProvince') {
formData.destinationProvince = '';
return;
}
if (chip.kind === 'destinationCity') {
formData.destinationCity = '';
return;
}
if (chip.kind === 'cargoType') {
formData.cargoType = '';
return;
}
if (chip.kind === 'warehouse') {
formData.warehouseId = undefined;
return;
}
if (chip.kind === 'coupon') {
couponInstanceId.value = undefined;
}
}
function clearAdvancedConditions() {
formData.length = '';
formData.width = '';
formData.height = '';
formData.destinationProvince = '';
formData.destinationCity = '';
formData.cargoType = '';
formData.warehouseId = undefined;
couponInstanceId.value = undefined;
}
function openAdvancedFilters() {
showAdvanced.value = true;
}
/** 仓库默认取顶栏当前仓;货物属性默认普货 */
function applyDefaultFilters() {
const id = currentWarehouseStore.resolveDefaultWarehouseId();
if (!id) {
formData.warehouseId = undefined;
} else {
const hit = warehouseOptions.value.find((w) => w.value === id);
formData.warehouseId = hit?.value;
}
if (!formData.cargoType && cargoTypeOptions.value.length) {
const general = cargoTypeOptions.value.find(
(c) => /普货|general/i.test(c.label) || /^(general|general_cargo)$/i.test(c.value),
);
formData.cargoType = (general || cargoTypeOptions.value[0]).value;
}
}
function focusPostalAndHint() {
hintBannerDismissed.value = false;
postalInputRef.value?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
const el = postalInputRef.value?.querySelector?.('input') as HTMLInputElement | null;
el?.focus?.();
}
const showQueryHintBanner = computed(
() =>
Boolean(queryHint.value?.disclaimers?.length) ||
queryHint.value?.quoteLevel === 'estimated',
);
const queryHintDisclaimers = computed(() => {
const fromApi = queryHint.value?.disclaimers ?? [];
if (fromApi.length) return fromApi;
const codes = queryHint.value?.missingFieldCodes ?? [];
return codes.map((code) => t(`order.priceCheck.hint.missing.${code}`)).filter(Boolean);
});
const queryHintSummary = computed(() => {
if (!showQueryHintBanner.value) return '';
return t('order.priceCheck.hint.summaryInline');
});
function couponOptionLabel(c: CouponAvailableOutput) {
const id = c.id ?? c.Id;
const name = c.templateName ?? c.TemplateName ?? `#${id}`;
const face = c.faceValue ?? c.FaceValue;
const reason = c.unavailableReason ?? c.UnavailableReason;
const available = c.available ?? c.Available ?? !reason;
const suffix = available ? (face != null ? ` (-¥${face})` : '') : ` (${reason || t('marketing.priceCheck.couponUnavailable')})`;
return `${name}${suffix}`;
}
async function handleCouponChange(val: number | undefined) {
couponInstanceId.value = val;
if (formData.destinationCountry && String(formData.cargoWeight ?? '').trim()) {
await handleQuery();
if (val) Message.info(t('marketing.priceCheck.couponChanged'));
}
}
function normalizeStringList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((x) => String(x ?? '').trim()).filter(Boolean);
}
/** 兼容查价接口 camelCase / PascalCase */
type PriceCheckOtherFee = {
costItem: string;
name: string;
amount: number;
formulaPart?: string;
};
function formatMoneyAmount(value: string | number | null | undefined) {
if (value == null || value === '') return '';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (Number.isNaN(num)) return String(value);
if (Number.isInteger(num)) return String(num);
return num.toFixed(4).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
}
function resolveCargoTypeLabel(code?: string, fallbackName?: string) {
const normalizedCode = String(code ?? '').trim();
const fromDict = cargoTypeOptions.value.find(
(option) => option.value.toLowerCase() === normalizedCode.toLowerCase()
)?.label;
if (fromDict) return fromDict;
const fallback = String(fallbackName ?? '').trim();
if (fallback) return fallback;
return normalizedCode;
}
function normalizeOtherFeeList(raw: any): PriceCheckOtherFee[] {
const list = raw?.otherFeeList ?? raw?.OtherFeeList ?? [];
if (!Array.isArray(list)) return [];
return list
.map((item) => ({
costItem: String(item?.costItem ?? item?.CostItem ?? '').trim(),
name: String(item?.name ?? item?.Name ?? item?.costItem ?? item?.CostItem ?? '').trim(),
amount: Number(item?.amount ?? item?.Amount ?? 0),
formulaPart: String(item?.formulaPart ?? item?.FormulaPart ?? '').trim(),
}))
.filter((item) => item.name || item.costItem);
}
function buildOtherFeesTooltip(fees: PriceCheckOtherFee[]) {
if (!fees.length) return '';
return fees
.map((fee) => `${fee.name || fee.costItem}:¥${formatMoneyAmount(fee.amount)}`)
.join('\n');
}
function normalizePriceRow(raw: any) {
const cargoType = raw.cargoType ?? raw.CargoType ?? '';
const cargoTypeNameRaw = raw.cargoTypeName ?? raw.CargoTypeName ?? raw.cargoType ?? raw.CargoType ?? '';
const otherFeeList = normalizeOtherFeeList(raw);
return {
...raw,
productId: raw.id ?? raw.Id ?? raw.productId ?? raw.ProductId,
productCode: raw.productCode ?? raw.ProductCode ?? '',
cargoType,
cargoTypeName: resolveCargoTypeLabel(String(cargoType), String(cargoTypeNameRaw)),
shippingMethod: raw.shippingMethod ?? raw.ShippingMethod ?? '',
warehouseId: raw.warehouseId ?? raw.WarehouseId,
warehouse: raw.warehouse ?? raw.Warehouse ?? '',
deliveryTime: raw.deliveryTime ?? raw.DeliveryTime ?? '',
tradeTerms: String(raw.tradeTerms ?? raw.TradeTerms ?? '')
.trim()
.toUpperCase(),
productGroup: String(raw.productGroup ?? raw.ProductGroup ?? '').trim(),
productGroupName: String(
raw.productGroupName ?? raw.ProductGroupName ?? raw.productGroup ?? raw.ProductGroup ?? '',
).trim(),
productDescription: String(
raw.productDescription ?? raw.ProductDescription ?? '',
).trim(),
volumetricWeight: raw.volumetricWeight ?? raw.VolumetricWeight ?? '',
chargeableWeight: raw.chargeableWeight ?? raw.ChargeableWeight ?? '',
costFormula: raw.costFormula ?? raw.CostFormula ?? '',
freight: raw.freight ?? raw.Freight ?? '',
otherFees: raw.otherFees ?? raw.OtherFees ?? '',
otherFeeList,
otherFeesTooltip: buildOtherFeesTooltip(otherFeeList),
totalCost: raw.totalCost ?? raw.TotalCost ?? '',
quoteLevel: String(raw.quoteLevel ?? raw.QuoteLevel ?? 'exact').toLowerCase(),
missingFieldCodes: normalizeStringList(raw.missingFieldCodes ?? raw.MissingFieldCodes),
disclaimers: normalizeStringList(raw.disclaimers ?? raw.Disclaimers),
zoneName: raw.zoneName ?? raw.ZoneName ?? '',
};
}
const parseDeliveryTime = (timeStr: string) => {
const match = String(timeStr || '').match(/(\d+)-(\d+)/);
return match ? parseInt(match[2], 10) : 0;
};
const parseTotalCost = (cost: string | number | undefined | null) => {
if (cost == null || cost === '') return Infinity;
if (typeof cost === 'number') return cost;
const num = parseFloat(String(cost).replace(/[^\d.]/g, ''));
return Number.isNaN(num) ? Infinity : num;
};
function applyOptimalFlags(list: any[]) {
if (!list.length) return list;
const minCost = Math.min(...list.map((item) => item.totalCostNum));
const minDays = Math.min(...list.map((item) => item.maxDeliveryDays));
list.forEach((item) => {
item.isPriceOptimal = item.totalCostNum === minCost;
item.isTimeOptimal = item.maxDeliveryDays === minDays;
});
return list;
}
const processQueryResults = (results: any[]) => {
if (!results?.length) return [];
const processed = results.map((item, index) => {
const row = normalizePriceRow(item);
const cardKey = [
row.productId ?? row.id ?? 'p',
row.productCode || 'code',
row.cargoType ?? 'c',
row.warehouseId ?? row.warehouse ?? 'w',
index,
].join('-');
return {
...row,
maxDeliveryDays: parseDeliveryTime(row.deliveryTime || '0-0'),
totalCostNum: parseTotalCost(row.totalCost as any),
cardKey,
rowKey: cardKey,
};
});
applyOptimalFlags(processed);
return processed.sort((a, b) => {
if (a.isPriceOptimal && !b.isPriceOptimal) return -1;
if (!a.isPriceOptimal && b.isPriceOptimal) return 1;
if (a.isTimeOptimal && !b.isTimeOptimal) return -1;
if (!a.isTimeOptimal && b.isTimeOptimal) return 1;
return 0;
});
};
const queryResults = ref<any[]>([]);
const queryLoading = ref(false);
const queryFailureText = ref('');
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
/** 单卡切换货物属性 / 仓库时的加载态 */
const recalcLoadingKeys = ref<Record<string, boolean>>({});
function failureStepLabel(step: string) {
const key = `order.priceCheck.guide.failureStep.${step}`;
const label = t(key);
return label === key ? step : label;
}
function applyPriceCheckFailures(failures: unknown[]) {
queryFailureItems.value = normalizePriceCheckFailures((failures ?? []) as any);
queryFailureText.value =
formatPriceCheckFailures((failures ?? []) as any, failureStepLabel) ||
t('order.priceCheck.table.empty');
}
function clearPriceCheckFailures() {
queryFailureItems.value = [];
queryFailureText.value = '';
}
const currentPage = ref(1);
const pageSize = ref(10);
const totalCount = computed(() => queryResults.value.length);
const sortedResults = computed(() => {
const list = [...queryResults.value];
if (sortMode.value === 'price') {
return list.sort((a, b) => a.totalCostNum - b.totalCostNum);
}
if (sortMode.value === 'time') {
return list.sort((a, b) => a.maxDeliveryDays - b.maxDeliveryDays || a.totalCostNum - b.totalCostNum);
}
return list;
});
const processedTableData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return sortedResults.value.slice(start, start + pageSize.value);
});
type SelectOption = { value: string; label: string };
const countryOptions = ref<SelectOption[]>([]);
const shippingMethodGroups = ref<PriceCheckShippingGroup[]>([]);
const cargoTypeOptions = ref<SelectOption[]>([]);
const warehouseOptions = ref<Array<{ value: number; label: string }>>([]);
const optionsLoading = ref(false);
function normalizeCountryRow(row: any) {
const code = String(row?.countryCode ?? row?.CountryCode ?? '').trim();
const name = String(row?.chineseName ?? row?.ChineseName ?? '').trim();
return { code, name };
}
function normalizeDictRow(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 normalizeWarehouseRow(row: any) {
const id = Number(row?.id ?? row?.Id);
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { id, name };
}
async function loadPriceCheckOptions() {
optionsLoading.value = true;
try {
const [countryRes, shippingGroups, dictRows, warehouseRes] = await Promise.all([
getCountryList(),
getOrderPriceCheckShippingOptions(),
getDictListAll({ dictType: 'cargo_attribute' }),
getWarehouseList({ page: 1, pageSize: 500 }),
]);
const countries = (countryRes.items ?? [])
.map(normalizeCountryRow)
.filter((c) => c.code)
.map((c) => ({
value: c.code,
label: c.name ? `${c.name} ${c.code}` : c.code,
}));
countryOptions.value = countries;
shippingMethodGroups.value = shippingGroups ?? [];
const cargoOpts = (dictRows ?? [])
.map(normalizeDictRow)
.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,
}));
cargoTypeOptions.value = cargoOpts;
warehouseOptions.value = (warehouseRes.items ?? [])
.map(normalizeWarehouseRow)
.filter((w) => Number.isFinite(w.id) && w.id > 0)
.map((w) => ({
value: w.id,
label: w.name || String(w.id),
}));
applyDefaultFilters();
} catch (e: any) {
Message.error(e?.message || '加载筛选项失败');
countryOptions.value = [];
shippingMethodGroups.value = [];
cargoTypeOptions.value = [];
warehouseOptions.value = [];
} finally {
optionsLoading.value = false;
}
}
onMounted(() => {
loadPriceCheckOptions();
});
const handleQuery = async (e?: Event) => {
e?.preventDefault?.();
e?.stopPropagation?.();
if (!formData.destinationCountry || String(formData.cargoWeight ?? '').trim() === '') {
Message.warning(t('order.priceCheck.validate.requiredBasic'));
return;
}
if (hasPartialDimensions()) {
Message.warning(t('order.priceCheck.validate.partialDimensions'));
return;
}
hasQueried.value = true;
queryLoading.value = true;
clearPriceCheckFailures();
queryHint.value = null;
hintBannerDismissed.value = false;
try {
const length = parseOptionalDimension(formData.length);
const width = parseOptionalDimension(formData.width);
const height = parseOptionalDimension(formData.height);
const { items, failures, queryHint: hint, availableCoupons: coupons } = await postOrderPriceCheck({
destinationCountry: formData.destinationCountry,
shippingMethod: formData.shippingMethod || '',
cargoWeight: String(formData.cargoWeight).trim(),
...(length != null && width != null && height != null
? { length: String(length), width: String(width), height: String(height) }
: {}),
cargoTypes: formData.cargoType ? [formData.cargoType] : undefined,
warehouseIds: formData.warehouseId != null ? [formData.warehouseId] : undefined,
destinationProvince: formData.destinationProvince?.trim() || '',
destinationCity: formData.destinationCity?.trim() || '',
postalCode: formData.postalCode?.trim() || '',
couponInstanceId: couponInstanceId.value,
});
queryHint.value = hint;
availableCoupons.value = coupons ?? [];
hintBannerDismissed.value = false;
queryResults.value = processQueryResults(items || []);
resetRecommendState();
sortMode.value = 'recommend';
currentPage.value = 1;
recalcLoadingKeys.value = {};
if (!queryResults.value.length) {
selectedGuideRecord.value = null;
applyPriceCheckFailures(failures ?? []);
} else {
selectedGuideRecord.value = sortedResults.value[0] ?? queryResults.value[0];
clearPriceCheckFailures();
Message.success(t('order.priceCheck.message.querySuccess', { count: queryResults.value.length }));
}
} catch (e: any) {
const errText = e?.message || t('order.priceCheck.message.queryFailed');
queryFailureItems.value = [];
queryFailureText.value = errText;
queryResults.value = [];
selectedGuideRecord.value = null;
resetRecommendState();
} finally {
queryLoading.value = false;
}
};
const handleReset = () => {
Object.assign(formData, {
destinationCountry: '',
shippingMethod: '',
cargoWeight: '1',
length: '',
width: '',
height: '',
cargoType: '',
warehouseId: undefined as number | undefined,
postalCode: '',
destinationProvince: '',
destinationCity: '',
});
applyDefaultFilters();
hasQueried.value = false;
queryResults.value = [];
queryHint.value = null;
hintBannerDismissed.value = false;
selectedGuideRecord.value = null;
resetRecommendState();
currentPage.value = 1;
clearPriceCheckFailures();
sortMode.value = 'recommend';
couponInstanceId.value = undefined;
availableCoupons.value = [];
recalcLoadingKeys.value = {};
};
/** 单卡切换货物属性 / 仓库后,仅重算该销售产品报价 */
async function handleCardRecalc(
record: Record<string, any>,
payload: { cargoType: string; warehouseId: number },
) {
if (!formData.destinationCountry || String(formData.cargoWeight ?? '').trim() === '') {
Message.warning(t('order.priceCheck.validate.requiredBasic'));
return;
}
if (hasPartialDimensions()) {
Message.warning(t('order.priceCheck.validate.partialDimensions'));
return;
}
const cardKey = String(record.cardKey || record.rowKey || '');
const productCode = String(record.productCode || '').trim();
if (!cardKey || !productCode) {
Message.warning(t('order.priceCheck.message.recalcFailed'));
return;
}
if (recalcLoadingKeys.value[cardKey]) return;
recalcLoadingKeys.value = { ...recalcLoadingKeys.value, [cardKey]: true };
try {
const length = parseOptionalDimension(formData.length);
const width = parseOptionalDimension(formData.width);
const height = parseOptionalDimension(formData.height);
const { items, failures } = await postOrderPriceCheck({
destinationCountry: formData.destinationCountry,
shippingMethod: productCode,
cargoWeight: String(formData.cargoWeight).trim(),
...(length != null && width != null && height != null
? { length: String(length), width: String(width), height: String(height) }
: {}),
cargoTypes: [payload.cargoType],
warehouseIds: [payload.warehouseId],
destinationProvince: formData.destinationProvince?.trim() || '',
destinationCity: formData.destinationCity?.trim() || '',
postalCode: formData.postalCode?.trim() || '',
couponInstanceId: couponInstanceId.value,
});
const nextRows = processQueryResults(items || []);
const replacement =
nextRows.find((row) => String(row.productCode || '').trim() === productCode) || nextRows[0];
if (!replacement) {
const failureText = formatPriceCheckFailures(failures ?? [], failureStepLabel);
Message.warning(failureText || t('order.priceCheck.message.recalcNoQuote'));
return;
}
replacement.cardKey = cardKey;
replacement.rowKey = cardKey;
const idx = queryResults.value.findIndex(
(item) => String(item.cardKey || item.rowKey) === cardKey,
);
if (idx < 0) return;
const updated = [...queryResults.value];
updated[idx] = replacement;
applyOptimalFlags(updated);
queryResults.value = updated;
if (
selectedGuideRecord.value &&
String(selectedGuideRecord.value.cardKey || selectedGuideRecord.value.rowKey) === cardKey
) {
selectedGuideRecord.value = replacement;
}
if (recommendedRowKey.value === cardKey || recommendedRowKey.value === record.rowKey) {
recommendedRowKey.value = cardKey;
}
Message.success(t('order.priceCheck.message.recalcSuccess'));
} catch (e: any) {
Message.error(e?.message || t('order.priceCheck.message.recalcFailed'));
} finally {
const next = { ...recalcLoadingKeys.value };
delete next[cardKey];
recalcLoadingKeys.value = next;
}
}
function buildAskContext(item: any) {
const cargoTypeName =
cargoTypeOptions.value.find((o) => o.value === String(item?.cargoType ?? ''))?.label ||
String(item?.cargoTypeName ?? item?.cargoType ?? '').trim();
return {
destinationCountry: String(formData.destinationCountry || '').trim(),
cargoWeight: formData.cargoWeight,
postalCode: formData.postalCode,
length: formData.length,
width: formData.width,
height: formData.height,
productCode: String(item?.productCode ?? '').trim(),
shippingMethod: String(item?.shippingMethod ?? '').trim(),
cargoType: String(item?.cargoType ?? '').trim(),
cargoTypeName,
warehouse: String(item?.warehouse ?? '').trim(),
totalCost: item?.totalCost,
deliveryTime: item?.deliveryTime,
quoteLevel: String(item?.quoteLevel ?? item?.QuoteLevel ?? queryHint.value?.quoteLevel ?? '').trim(),
};
}
function handleAskAi(item: any, intent: PriceAskIntent) {
openPriceAskAdvisor(router, intent, buildAskContext(item));
}
const handleOrder = (item: any) => {
const rowDisclaimers = normalizeStringList(item?.disclaimers ?? item?.Disclaimers);
const hintDisclaimers = normalizeStringList(queryHint.value?.disclaimers);
const disclaimers = [...new Set([...rowDisclaimers, ...hintDisclaimers])];
const quoteLevel = String(item?.quoteLevel ?? item?.QuoteLevel ?? queryHint.value?.quoteLevel ?? '')
.trim()
.toLowerCase();
const draft = {
productCode: String(item?.productCode ?? '').trim(),
productId: item?.productId ?? item?.id ?? null,
shippingMethod: String(item?.shippingMethod ?? '').trim(),
cargoAttribute: normalizeCargoAttributeCode(item?.cargoType ?? item?.cargoAttribute),
warehouseId: item?.warehouseId != null && item?.warehouseId !== '' ? String(item.warehouseId) : '',
countryCode: String(formData.destinationCountry || '').trim(),
actualWeight: String(formData.cargoWeight || '').trim(),
length: String(formData.length || '').trim(),
width: String(formData.width || '').trim(),
height: String(formData.height || '').trim(),
zipCode: String(formData.postalCode || '').trim(),
totalCost: item?.totalCost ?? null,
quoteLevel: quoteLevel || undefined,
disclaimers,
};
try {
sessionStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft));
} catch {
// ignore quota
}
router
.push({
name: 'PlaceOrder',
query: {
from: 'priceCheck',
mode: 'single',
productCode: draft.productCode || undefined,
countryCode: draft.countryCode || undefined,
},
})
.catch(() => undefined);
};
function clearRecommendInsight() {
recommendReason.value = '';
recommendVerdict.value = '';
recommendEvidence.value = [];
recommendCargoName.value = '';
recommendAlternatives.value = [];
}
function resetRecommendState() {
recommendedRowKey.value = '';
clearRecommendInsight();
}
function applyRecommendInsight(result: {
rowKey?: string;
reason: string;
verdict: ShipVerdict;
evidence: string[];
cargoName: string;
alternatives: RecommendAlternative[];
}) {
recommendedRowKey.value = result.rowKey || '';
recommendReason.value = result.reason;
recommendVerdict.value = result.verdict;
recommendEvidence.value = result.evidence || [];
recommendCargoName.value = result.cargoName || '';
recommendAlternatives.value = result.alternatives || [];
}
function isRecommendStamp(record: Record<string, any>) {
if (recommendedRowKey.value) return record.rowKey === recommendedRowKey.value;
return sortMode.value === 'recommend' && !!record.isPriceOptimal;
}
const handleSelectGuide = (record: any) => {
if (!record) return;
selectedGuideRecord.value = record;
clearRecommendInsight();
};
const handleCloseGuide = () => {
selectedGuideRecord.value = null;
clearRecommendInsight();
};
const focusQuoteRow = (rowKey: string) => {
const hit = queryResults.value.find((item) => item.rowKey === rowKey);
if (!hit) return null;
selectedGuideRecord.value = hit;
const sorted = sortedResults.value;
const idx = sorted.findIndex((item) => item.rowKey === rowKey);
if (idx >= 0) {
currentPage.value = Math.floor(idx / pageSize.value) + 1;
}
return hit;
};
const handleSelectAlternative = (rowKey: string) => {
const hit = focusQuoteRow(rowKey);
if (!hit) {
Message.warning(t('order.priceCheck.ai.noMatch', { name: recommendCargoName.value || '' }));
return;
}
const alt = recommendAlternatives.value.find((a) => a.rowKey === rowKey);
recommendedRowKey.value = rowKey;
recommendVerdict.value = alt?.verdict || recommendVerdict.value || 'unknown';
recommendReason.value = alt?.blurb
? t('order.priceCheck.ai.altSelected', { blurb: alt.blurb })
: recommendReason.value;
recommendAlternatives.value = recommendAlternatives.value.filter((a) => a.rowKey !== rowKey);
Message.success(t('order.priceCheck.ai.altSwitched'));
};
const handleRecommendPlaceOrder = () => {
if (!selectedGuideRecord.value) return;
handleOrder(selectedGuideRecord.value);
};
const handleRecommendAskAdvisor = () => {
if (!selectedGuideRecord.value) return;
const cargoName = recommendCargoName.value.trim();
openPriceAskAdvisor(
router,
'canShip',
{
...buildAskContext(selectedGuideRecord.value),
cargoName,
},
{ autoAsk: !!cargoName },
);
};
const handleAiRecommend = async (productName: string) => {
const name = String(productName || '').trim();
if (!name) {
Message.warning(t('order.priceCheck.ai.validate.productName'));
return;
}
if (!queryResults.value.length) {
Message.warning(t('order.priceCheck.ai.validate.needQuotes'));
return;
}
aiRecommendLoading.value = true;
queryFailureText.value = '';
try {
const candidates: QuoteCandidate[] = queryResults.value.map((item) => ({
rowKey: String(item.rowKey),
productId: item.productId,
productCode: String(item.productCode || ''),
shippingMethod: String(item.shippingMethod || ''),
cargoTypeName: String(item.cargoTypeName || ''),
warehouse: String(item.warehouse || ''),
deliveryTime: String(item.deliveryTime || ''),
totalCostNum: Number(item.totalCostNum),
maxDeliveryDays: Number(item.maxDeliveryDays),
isPriceOptimal: !!item.isPriceOptimal,
isTimeOptimal: !!item.isTimeOptimal,
}));
const result = await recommendWithAi(name, candidates);
if (!result) {
resetRecommendState();
selectedGuideRecord.value = null;
queryFailureText.value = t('order.priceCheck.ai.noMatch', { name });
Message.warning(t('order.priceCheck.ai.noMatch', { name }));
return;
}
const hit = focusQuoteRow(result.rowKey);
if (!hit) {
Message.warning(t('order.priceCheck.ai.noMatch', { name }));
return;
}
applyRecommendInsight(result);
Message.success(
result.source === 'ai'
? t('order.priceCheck.ai.successAi')
: result.source === 'mcp'
? t('order.priceCheck.ai.successMcp')
: t('order.priceCheck.ai.successLocal'),
);
} catch (e: any) {
Message.error(e?.message || t('order.priceCheck.ai.failed'));
} finally {
aiRecommendLoading.value = false;
}
};
const handleSortChange = (mode: string) => {
if (mode !== 'recommend' && mode !== 'price' && mode !== 'time') return;
sortMode.value = mode;
currentPage.value = 1;
};
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handlePageSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1;
};
</script>
<template>
<div class="price-check-container">
<div class="price-check-layout">
<div class="price-check-main">
<section class="liquid-panel query-form-section">
<div class="query-form-layout liquid-panel__content">
<div class="form-container">
<div class="form-head">
<h2 class="form-head__title">{{ $t('order.priceCheck.form.title') }}</h2>
<a-tooltip :content="$t('order.priceCheck.form.subtitle')" position="right">
<button type="button" class="form-head__help" :aria-label="$t('order.priceCheck.form.subtitle')">
<icon-question-circle />
</button>
</a-tooltip>
</div>
<div class="form-row form-row-primary">
<div class="form-item form-item-channel">
<label class="form-label">{{ $t('order.priceCheck.form.channel') }}</label>
<ShippingMethodTabSelect
v-model="formData.shippingMethod"
:groups="shippingMethodGroups"
:loading="optionsLoading"
:placeholder="$t('order.priceCheck.form.channel.placeholder')"
class="form-select"
/>
</div>
<div class="form-item form-item-country">
<label class="form-label required">{{ $t('order.priceCheck.form.destinationCountry') }}</label>
<a-select
v-model="formData.destinationCountry"
:placeholder="$t('order.priceCheck.form.destinationCountry.placeholder')"
class="form-select"
allow-search
:loading="optionsLoading"
>
<template #header>
<div
class="country-shortcuts"
role="group"
:aria-label="$t('order.priceCheck.shortcuts.destCountryAria')"
>
<button
v-for="code in countryShortcutCodes"
:key="code"
type="button"
class="country-chip"
:class="{ 'is-active': formData.destinationCountry === code }"
@mousedown.prevent
@click="formData.destinationCountry = code"
>
{{ t(`order.priceCheck.shortcut.${code}`) }}
</button>
</div>
</template>
<a-option v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<div ref="postalInputRef" class="form-item form-item-compact">
<label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label>
<a-input
v-model="formData.postalCode"
:placeholder="$t('order.priceCheck.form.postalCode.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div class="form-item form-item-compact">
<label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label>
<a-input
v-model="formData.cargoWeight"
:placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div class="form-item button-group">
<label class="form-label form-label-spacer" aria-hidden="true">&nbsp;</label>
<div class="button-group-row">
<a-button
html-type="button"
type="primary"
class="query-btn"
:loading="queryLoading"
@click="handleQuery"
>
{{ $t('order.priceCheck.actions.query') }}
</a-button>
<a-button html-type="button" class="reset-btn" @click="handleReset">
{{ $t('order.priceCheck.actions.reset') }}
</a-button>
<button type="button" class="advanced-toggle-btn" @click="showAdvanced = !showAdvanced">
<span>{{ $t('order.priceCheck.form.moreFilters') }}</span>
<icon-up v-if="showAdvanced" />
<icon-down v-else />
</button>
</div>
</div>
</div>
<div
v-if="advancedConditionChips.length && !showAdvanced"
class="filter-summary-bar"
>
<span class="filter-summary-bar__label">
{{ $t('order.priceCheck.form.activeFilters', { count: advancedConditionChips.length }) }}
</span>
<div class="filter-summary-bar__chips">
<button
v-for="chip in advancedConditionChips"
:key="chip.key"
type="button"
class="filter-chip"
:title="chip.label"
@click="openAdvancedFilters"
>
<span class="filter-chip__text">{{ chip.label }}</span>
<span
class="filter-chip__remove"
role="button"
tabindex="0"
:aria-label="$t('order.priceCheck.form.removeFilter')"
@click.stop="removeAdvancedChip(chip)"
@keydown.enter.prevent.stop="removeAdvancedChip(chip)"
>
<icon-close />
</span>
</button>
<button type="button" class="filter-summary-bar__clear" @click="clearAdvancedConditions">
{{ $t('order.priceCheck.form.clearFilters') }}
</button>
</div>
</div>
<div v-show="showAdvanced" class="form-row form-row-advanced">
<div class="form-item dimensions-group">
<label class="form-label" :title="$t('order.priceCheck.form.dimensionsHintOptional')">
{{ $t('order.priceCheck.form.dimensions') }}
</label>
<div class="dimensions-inputs">
<a-input
v-model="formData.length"
:placeholder="$t('order.priceCheck.form.dimensions.length')"
class="dimension-input"
/>
<a-input
v-model="formData.width"
:placeholder="$t('order.priceCheck.form.dimensions.width')"
class="dimension-input"
/>
<a-input
v-model="formData.height"
:placeholder="$t('order.priceCheck.form.dimensions.height')"
class="dimension-input"
/>
</div>
<p v-if="hasPartialDimensions()" class="form-inline-error">
{{ $t('order.priceCheck.validate.partialDimensions') }}
</p>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.destinationProvince') }}</label>
<a-input
v-model="formData.destinationProvince"
:placeholder="$t('order.priceCheck.form.destinationProvince.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.destinationCity') }}</label>
<a-input
v-model="formData.destinationCity"
:placeholder="$t('order.priceCheck.form.destinationCity.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div v-if="availableCoupons.length" class="form-item form-item-coupon">
<label class="form-label">{{ $t('marketing.priceCheck.couponLabel') }}</label>
<a-select
:model-value="couponInstanceId"
allow-clear
:placeholder="$t('marketing.priceCheck.couponPlaceholder')"
class="form-select"
@change="handleCouponChange"
>
<a-option
v-for="c in availableCoupons"
:key="c.id ?? c.Id"
:value="(c.id ?? c.Id) as number"
:disabled="!(c.available ?? c.Available ?? !(c.unavailableReason ?? c.UnavailableReason))"
>
{{ couponOptionLabel(c) }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.cargoType') }}</label>
<a-select
v-model="formData.cargoType"
class="form-select"
allow-search
allow-clear
:placeholder="$t('order.priceCheck.form.cargoType.placeholder')"
:loading="optionsLoading"
>
<a-option v-for="option in cargoTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.warehouse') }}</label>
<a-select
v-model="formData.warehouseId"
class="form-select"
allow-search
allow-clear
:placeholder="$t('order.priceCheck.form.warehouse.placeholder')"
:loading="optionsLoading"
>
<a-option v-for="option in warehouseOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
</div>
</div>
</section>
<section class="liquid-panel table-section">
<div class="table-section__inner liquid-panel__content">
<AiRecommendCard
v-if="queryResults.length"
variant="bar"
:loading="aiRecommendLoading"
:show-postal-hint="needPostalHint && !hintBannerDismissed"
@recommend="handleAiRecommend"
@dismiss-hint="hintBannerDismissed = true"
/>
<div class="table-toolbar">
<div class="toolbar-left">
<SlidingTabs
:model-value="sortMode"
:items="sortTabItems"
aria-label="Sort tabs"
@change="handleSortChange"
/>
<template v-if="showQueryHintBanner && !needPostalHint && !hintBannerDismissed">
<a-popover
v-if="queryHintDisclaimers.length"
position="bl"
trigger="hover"
:content-style="{ maxWidth: '320px', padding: '8px 12px' }"
>
<button type="button" class="hint-inline-chip">
<icon-info-circle />
<span>{{ queryHintSummary }}</span>
</button>
<template #content>
<ul class="hint-popover-list">
<li v-for="(line, idx) in queryHintDisclaimers" :key="idx">{{ line }}</li>
</ul>
</template>
</a-popover>
<span v-else class="hint-inline-chip hint-inline-chip--static">
<icon-info-circle />
<span>{{ queryHintSummary }}</span>
</span>
<button
type="button"
class="hint-inline-dismiss"
:title="$t('order.priceCheck.hint.dismiss')"
@click="hintBannerDismissed = true"
>
×
</button>
</template>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div class="list-wrap">
<a-spin :loading="queryLoading" class="price-check-spin">
<div v-if="processedTableData.length" class="quote-card-list">
<QuoteResultCard
v-for="record in processedTableData"
:key="record.cardKey || record.rowKey"
:record="record"
:selected="selectedGuideRecord?.rowKey === record.rowKey"
:recommended="isRecommendStamp(record)"
:cargo-options="cargoTypeOptions"
:warehouse-options="warehouseOptions"
:lock-cargo-type="!!formData.cargoType"
:lock-warehouse="formData.warehouseId != null"
:recalc-loading="!!recalcLoadingKeys[record.cardKey || record.rowKey]"
@select="handleSelectGuide(record)"
@order="handleOrder(record)"
@ask="(intent) => handleAskAi(record, intent)"
@recalc="(payload) => handleCardRecalc(record, payload)"
/>
</div>
<div v-else-if="!queryLoading && hasQueried" class="quote-empty">
<a-empty
:description="
queryFailureItems.length
? t('order.priceCheck.table.emptyWithFailures', { count: queryFailureItems.length })
: queryFailureText || t('order.priceCheck.table.empty')
"
>
<template #image />
<p class="empty-hint">{{ t('order.priceCheck.table.emptyHint') }}</p>
</a-empty>
</div>
</a-spin>
</div>
</div>
</section>
</div>
<div class="price-check-aside">
<ChannelGuidePanel
:record="selectedGuideRecord"
:failure-items="!queryResults.length ? queryFailureItems : []"
:failure-text="!queryResults.length ? queryFailureText : ''"
:recommend-reason="recommendReason"
:recommend-verdict="recommendVerdict"
:recommend-evidence="recommendEvidence"
:recommend-cargo-name="recommendCargoName"
:alternatives="recommendAlternatives"
@close="handleCloseGuide"
@select-alternative="handleSelectAlternative"
@place-order="handleRecommendPlaceOrder"
@ask-advisor="handleRecommendAskAdvisor"
/>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.price-check-container {
width: 100%;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: var(--app-page-bg);
}
.price-check-layout { display: flex; gap: 12px; flex: 1; min-height: 0; min-width: 0; }
.price-check-main { flex: 5 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.price-check-aside { flex: 3 1 0; min-width: 340px; min-height: 0; display: flex; flex-direction: column; > * { flex: 1; min-height: 0; } }
.liquid-panel {
position: relative;
z-index: 0;
overflow: hidden;
border: 1px solid var(--app-card-border);
border-radius: 12px;
background: var(--app-card-bg);
}
.liquid-panel__content {
position: relative;
z-index: 1;
}
.query-form-section { padding: 20px 24px 16px; flex-shrink: 0; }
.query-form-layout {
display: flex;
align-items: stretch;
gap: 14px;
min-width: 0;
}
.form-container {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.form-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.form-head__title {
margin: 0;
color: var(--color-text-1);
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
}
.form-head__help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-3);
font-size: 14px;
cursor: help;
transition: color 0.15s ease;
&:hover {
color: var(--app-primary-color, #007979);
}
}
.form-label-tip {
margin-left: 6px;
color: var(--color-text-3);
font-size: 11px;
font-weight: 400;
}
.form-inline-error {
margin: 0;
color: #f53f3f;
font-size: 12px;
line-height: 1.4;
}
.form-row { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.form-row-primary, .form-row-advanced { overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
.form-row-primary { flex-wrap: nowrap; }
.form-row-advanced {
margin-top: 12px;
padding-top: 16px;
border-top: 1px solid var(--color-border-2);
}
.filter-summary-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
min-width: 0;
}
.filter-summary-bar__label {
flex-shrink: 0;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
.filter-summary-bar__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 180px;
height: 24px;
padding: 0 4px 0 8px;
border: none;
border-radius: 999px;
background: var(--color-fill-2);
color: var(--color-text-2);
font-size: 12px;
line-height: 22px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #007979) 10%, var(--color-fill-2));
color: var(--app-primary-color, #007979);
}
}
.filter-chip__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
color: var(--color-text-3);
font-size: 10px;
flex-shrink: 0;
&:hover {
background: var(--color-fill-2);
color: var(--color-text-1);
}
}
.filter-summary-bar__clear {
flex-shrink: 0;
height: 24px;
padding: 0 6px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-3);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--app-primary-color, #007979);
}
}
.advanced-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
height: 32px;
padding: 0 4px;
border: none;
border-radius: 0;
background: transparent;
color: var(--color-text-2);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
&:hover { color: var(--app-primary-color, #007979); }
}
.form-hint { margin: 0; color: var(--color-text-3); font-size: 12px; line-height: 1.3; }
.form-item {
display: flex; flex-direction: column; gap: 8px; flex: 0 0 auto; width: 148px; min-width: 132px;
&.dimensions-group { width: 260px; min-width: 220px; }
&.button-group { width: auto; min-width: 0; flex-shrink: 0; }
.button-group-row {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
justify-content: flex-start;
flex-wrap: nowrap;
}
&.form-item-country { width: 240px; min-width: 200px; flex-shrink: 0; }
&.form-item-channel { width: 220px; min-width: 180px; flex-shrink: 0; }
&.form-item-compact { width: 112px; min-width: 100px; }
}
.country-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 4px;
max-width: 100%;
}
.country-chip {
height: 24px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 4px;
background: var(--color-fill-2);
color: var(--color-text-2);
font-size: 12px;
line-height: 22px;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
&:hover { color: var(--app-primary-color, #007979); background: rgba(0, 121, 121, 0.06); }
&.is-active {
background: rgba(0, 121, 121, 0.08);
border-color: color-mix(in srgb, var(--app-primary-color, #007979) 35%, transparent);
color: var(--app-primary-color, #007979);
font-weight: 600;
}
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-2);
min-height: 18px;
line-height: 18px;
flex-shrink: 0;
letter-spacing: 0.01em;
&.required::after { content: '*'; color: #f53f3f; margin-left: 4px; }
&.form-label-spacer { visibility: hidden; user-select: none; pointer-events: none; }
}
.form-select, .form-input { width: 100%; }
.form-row-primary :deep(.arco-input-wrapper),
.form-row-advanced :deep(.arco-input-wrapper),
.form-row-primary :deep(.arco-select-view),
.form-row-advanced :deep(.arco-select-view) {
border-radius: 6px;
background: var(--app-card-bg);
border-color: var(--color-border-2);
}
.form-row-primary :deep(.arco-input-wrapper .arco-input),
.form-row-advanced :deep(.arco-input-wrapper .arco-input) { min-height: 32px; }
.form-row-primary :deep(.arco-select .arco-select-view),
.form-row-advanced :deep(.arco-select .arco-select-view) { min-height: 32px; }
.dimensions-inputs { display: flex; gap: 8px; .dimension-input { flex: 1; } }
.query-btn,
.reset-btn {
height: 32px;
border-radius: 4px;
}
.query-btn {
min-width: 88px;
}
.table-section { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
.table-section__inner { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; gap: 10px; }
.table-section__inner > .ai-rec {
margin: 12px 16px 0;
flex-shrink: 0;
}
.quote-card-list {
display: flex;
flex-direction: column;
gap: 12px;
/* 列表本身可滚动 */
padding: 14px 16px 16px 18px;
box-sizing: border-box;
}
.quote-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 24px;
}
.table-toolbar {
padding: 4px 16px 8px;
margin-bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
min-width: 0;
}
.hint-inline-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 220px;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 4px;
background: rgba(255, 125, 0, 0.08);
color: #d48806;
font-size: 12px;
line-height: 1;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
:deep(svg) { flex-shrink: 0; font-size: 14px; }
&--static { cursor: default; }
}
.hint-inline-dismiss {
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-3);
font-size: 16px;
line-height: 1;
cursor: pointer;
&:hover { color: var(--color-text-2); background: var(--color-fill-2); }
}
.hint-popover-list {
margin: 0;
padding-left: 16px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-2);
li + li { margin-top: 4px; }
}
.quote-disclaimer-popover {
max-width: 320px;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.5;
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.price-check-spin {
width: 100%;
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
:deep(.arco-spin) {
width: 100%;
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
}
:deep(.arco-spin-children) {
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
overflow: visible;
}
}
.price-check-table {
--row-hover-bg: var(--color-fill-2);
--row-selected-bg: color-mix(in srgb, var(--app-primary-color, #2d8c8c) 10%, var(--app-card-bg));
:deep(.arco-table-container),
:deep(.arco-table-content) {
background: transparent;
}
:deep(.arco-table-th) {
background: var(--color-fill-2);
font-weight: 600;
color: var(--color-text-1);
}
/* 普通列 + 固定列统一底色,避免 Arco 固定列 ::before 造成色差 */
:deep(.arco-table-td),
:deep(.arco-table-td.arco-table-col-fixed-left),
:deep(.arco-table-td.arco-table-col-fixed-right) {
background-color: var(--app-card-bg) !important;
}
:deep(.arco-table-td.arco-table-col-fixed-left::before),
:deep(.arco-table-td.arco-table-col-fixed-right::before) {
display: none !important;
content: none !important;
background: transparent !important;
}
:deep(.arco-table-tr) {
cursor: pointer;
}
:deep(.arco-table-tr:hover .arco-table-td),
:deep(.arco-table-tr:hover .arco-table-td.arco-table-col-fixed-left),
:deep(.arco-table-tr:hover .arco-table-td.arco-table-col-fixed-right) {
background-color: var(--row-hover-bg) !important;
}
:deep(.price-check-row-selected .arco-table-td),
:deep(.price-check-row-selected .arco-table-td.arco-table-col-fixed-left),
:deep(.price-check-row-selected .arco-table-td.arco-table-col-fixed-right),
:deep(.price-check-row-selected:hover .arco-table-td),
:deep(.price-check-row-selected:hover .arco-table-td.arco-table-col-fixed-left),
:deep(.price-check-row-selected:hover .arco-table-td.arco-table-col-fixed-right) {
background-color: var(--row-selected-bg) !important;
}
}
.shipping-method-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shipping-method-name {
font-weight: 650;
font-size: 14px;
color: var(--color-text-1);
line-height: 1.4;
}
.optimal-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.delivery-time-cell { font-weight: 600; color: var(--color-text-1); }
.delivery-time-unit { margin-left: 4px; color: var(--color-text-3); font-size: 12px; }
.weight-cell { font-size: 13px; line-height: 1.5; color: var(--color-text-2); }
.weight-label { color: var(--color-text-3); margin-right: 4px; }
.weight-sub { margin-top: 2px; font-size: 12px; }
.fee-detail-cell { display: flex; flex-direction: column; gap: 2px; font-size: 12px; line-height: 1.5; }
.fee-label { margin-right: 4px; color: var(--color-text-3); }
.money-freight { color: var(--color-text-2); font-weight: 500; }
.money-other { color: var(--app-primary-color); font-weight: 500; }
.total-cost-cell { display: flex; flex-direction: column; gap: 2px; }
.money-total { color: #f53f3f; font-weight: 700; font-size: 20px; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1.2; }
.money-total-hover { cursor: help; }
.empty-hint { margin: 8px 0 0; color: var(--color-text-3); font-size: 12px; }
.ops-actions { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.ops-btn {
display: inline-flex; align-items: center; justify-content: center; height: 30px; padding: 0 14px;
border-radius: 8px; font-size: 13px; font-weight: 500; line-height: 1; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
&--ghost {
border: 1px solid var(--color-border-2); background: var(--app-elevated-bg); color: var(--color-text-2);
&:hover { border-color: var(--app-primary-color, #2d8c8c); color: var(--app-primary-color, #2d8c8c); background: rgba(45, 140, 140, 0.06); }
}
&--primary {
border: 1px solid var(--app-primary-color, #2d8c8c); background: var(--app-primary-color, #2d8c8c); color: #fff;
font-weight: 600; box-shadow: 0 3px 10px rgba(45, 140, 140, 0.22);
&:hover { filter: brightness(1.06); box-shadow: 0 4px 14px rgba(45, 140, 140, 0.3); }
&:active { filter: brightness(0.96); box-shadow: none; }
}
}
.cost-formula-popover {
max-width: 320px;
.cost-formula-label { margin-bottom: 6px; font-size: 12px; font-weight: 600; color: var(--color-text-3); }
.cost-formula-text { font-size: 13px; line-height: 1.6; color: var(--color-text-1); white-space: pre-wrap; word-break: break-word; }
}
.money-empty { color: var(--color-text-4); }
.other-fee-popover {
min-width: 180px;
.other-fee-row {
display: flex; justify-content: space-between; gap: 16px; padding: 4px 0; font-size: 13px; line-height: 1.5;
&:not(:last-child) { border-bottom: 1px dashed var(--color-border-2); }
}
}
</style>