front/src/views/h5/customer-center/price/index.vue
2026-07-29 12:47:32 +08:00

1779 lines
46 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.

<template>
<div class="h5-price-page">
<!-- 查价卡片:手机原生交互 -->
<section class="quote-form h5-enter">
<div class="route-row">
<button type="button" class="route-cell" @click="openPicker('warehouse')">
<span class="route-cell__label">{{ t('h5.price.origin') }}</span>
<span class="route-cell__value">
<span>{{ warehouseLabel || t('h5.price.originAny') }}</span>
<icon-down />
</span>
</button>
<div class="route-mid" aria-hidden="true">
<span class="route-mid__icon">
<svg class="route-mid__plane" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M30 15.2c0-.7-.5-1.2-1.2-1.2h-7.1L13.2 4.5c-.3-.4-.8-.5-1.2-.3-.4.2-.6.6-.5 1.1l1.6 7.7H6.3L3.8 9.7c-.2-.4-.6-.6-1-.5-.5.1-.8.5-.8 1v4.5c0 .1 0 .2.1.3H1.2c-.7 0-1.2.5-1.2 1.2S.5 17.4 1.2 17.4h.9c0 .1-.1.2-.1.3v4.5c0 .5.3.9.8 1 .4.1.8-.1 1-.5l2.5-3.3h6.8l-1.6 7.7c-.1.5.1.9.5 1.1.1.1.3.1.4.1.3 0 .6-.1.8-.4l8.5-9.5h7.1c.7 0 1.2-.5 1.2-1.2z"
/>
</svg>
</span>
</div>
<button type="button" class="route-cell" @click="openPicker('country')">
<span class="route-cell__label">{{ t('h5.price.destination') }}</span>
<span class="route-cell__value" :class="{ 'is-placeholder': !destinationCode }">
<span class="route-cell__dest">
<template v-if="destinationCode">
<b>{{ destinationName || destinationCode }}</b>
<em v-if="destinationName">{{ destinationCode }}</em>
</template>
<template v-else>{{ t('h5.price.countryPlaceholder') }}</template>
</span>
<icon-down />
</span>
</button>
</div>
<div class="line-field">
<span class="line-field__label">{{ t('h5.price.postalCode') }}</span>
<input
v-model="form.postalCode"
class="line-field__input"
type="text"
inputmode="text"
:placeholder="t('h5.price.postalCodePh')"
autocomplete="postal-code"
/>
</div>
<div class="pkg-row">
<label class="pkg-cell">
<span class="pkg-cell__label">{{ t('h5.price.packageWeight') }}</span>
<span class="pkg-cell__value">
<input
v-model="form.cargoWeight"
type="text"
inputmode="decimal"
:placeholder="t('h5.price.weightPlaceholder')"
@keyup.enter="handleQuery"
/>
<em>KG</em>
</span>
</label>
<button type="button" class="pkg-cell pkg-cell--tap" @click="openPicker('cargo')">
<span class="pkg-cell__label">{{ t('h5.price.cargoAttr') }}</span>
<span class="pkg-cell__value">
<strong :class="{ 'is-placeholder': !cargoLabel }">
{{ cargoLabel || t('h5.price.cargoPick') }}
</strong>
<icon-down />
</span>
</button>
</div>
<button
type="button"
class="quote-cta"
:disabled="queryLoading"
@click="handleQuery"
>
<span v-if="queryLoading" class="quote-cta__loading" aria-hidden="true" />
<template v-else>{{ t('h5.price.query') }}</template>
<template v-if="queryLoading">{{ t('h5.price.querying') }}</template>
</button>
<div class="quote-foot">
<span v-if="summaryText" class="quote-summary">{{ summaryText }}</span>
<p class="quote-ref">{{ t('h5.price.referenceTip') }}</p>
</div>
</section>
<section v-if="queried" class="h5-price-results">
<div v-if="sortedResults.length" class="h5-results-bar">
<p class="h5-disclaimer">{{ t('h5.price.referenceBanner') }}</p>
<div class="h5-sort">
<button
v-for="mode in sortModes"
:key="mode"
type="button"
class="h5-sort__btn"
:class="{ 'is-active': sortMode === mode }"
@click="sortMode = mode"
>
{{ t(`h5.price.sort.${mode}`) }}
</button>
</div>
</div>
<a-spin :loading="queryLoading" class="h5-price-spin">
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty">
<div class="h5-empty__icon" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28" r="14" fill="none" stroke="currentColor" stroke-width="3" />
<path d="M38 38 50 50" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<path d="M22 28h12M28 22v12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
</div>
<p>{{ queryFailureText || t('h5.price.noResult') }}</p>
<a-button type="outline" size="small" @click="h5Navigate(router, '/h5/assistant')">
{{ t('h5.price.askAdvisor') }}
</a-button>
</div>
<article
v-for="(item, index) in sortedResults"
:key="item.rowKey"
class="h5-quote"
:class="[
`h5-quote--tone-${quoteTone(index)}`,
{ 'h5-quote--top': index === 0 && sortMode === 'recommend' },
]"
@click="openDetail(item)"
>
<span class="h5-quote__blob" aria-hidden="true" />
<span class="h5-quote__shine" aria-hidden="true" />
<div class="h5-quote__main">
<div class="h5-quote__head">
<div class="h5-quote__title">
<h3>
<span v-if="index === 0 && sortMode === 'recommend'" class="h5-quote__tag">
{{ t('h5.price.recommendTag') }}
</span>
{{ item.shippingMethod || item.productCode || '-' }}
</h3>
<p class="h5-quote__sub">
<span class="h5-quote__chip h5-quote__chip--cargo">
{{ item.cargoTypeName || item.cargoType || '-' }}
</span>
<span class="h5-quote__chip h5-quote__chip--wh">
{{ item.warehouse || '-' }}
</span>
<span class="h5-quote__meta">
{{ t('h5.price.delivery') }} {{ item.deliveryTime || '-' }}
<template v-if="item.chargeableWeight">
· {{ item.chargeableWeight }}kg
</template>
</span>
</p>
</div>
<div class="h5-quote__price-block">
<div class="h5-quote__price">
<small>¥</small>{{ formatMoney(item.totalCost) }}
</div>
<span class="ref-label">{{ t('h5.price.referencePrice') }}</span>
</div>
</div>
<div class="h5-quote__actions" @click.stop>
<button type="button" class="link-btn" @click="openDetail(item)">
{{ t('h5.price.viewDetail') }}
</button>
<a-dropdown trigger="click" position="top">
<button type="button" class="link-btn">
{{ t('h5.price.askAdvisor') }}
</button>
<template #content>
<a-doption @click="askAdvisor(item, 'canShip')">
{{ t('h5.price.askAiCanShip') }}
</a-doption>
<a-doption @click="askAdvisor(item, 'cheaper')">
{{ t('h5.price.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button type="button" class="order-btn" @click="goOrder(item)">
{{ t('h5.price.goOrder') }}
</button>
</div>
</div>
</article>
</a-spin>
</section>
<div v-else class="h5-empty h5-price-empty">
<div class="h5-empty__icon" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="22" width="44" height="28" rx="6" fill="currentColor" opacity="0.12" />
<path
d="M16 30h20M16 38h12"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
<path
fill="currentColor"
d="M46 18.5 52 21l-6 2.5V18.5Zm-18 4.5 20-6.5 1.5 4.5-20 6.5-1.5-4.5Z"
/>
</svg>
</div>
<p>{{ t('h5.price.empty') }}</p>
</div>
<!-- 选择器:国家 / 仓库 / 属性 -->
<a-drawer
v-model:visible="pickerVisible"
:title="pickerTitle"
placement="bottom"
:height="pickerHeight"
unmount-on-close
:footer="false"
>
<div v-if="pickerType === 'country'" class="picker">
<input
v-model="countryKeyword"
class="picker-search"
type="search"
:placeholder="t('h5.price.countrySearchPh')"
/>
<div class="picker-shortcuts">
<button
v-for="item in shortcutCountries"
:key="item.value"
type="button"
class="quick-chip"
:class="{ 'is-active': destinationCode === item.value }"
@click="pickCountry(item.value); pickerVisible = false"
>
<span>{{ item.name }}</span>
<em>{{ item.value }}</em>
</button>
</div>
<button
v-for="opt in filteredCountries"
:key="opt.value"
type="button"
class="picker-item"
:class="{ 'is-active': destinationCode === opt.value }"
@click="pickCountry(opt.value); pickerVisible = false"
>
<span class="picker-item__name">{{ opt.name || opt.value }}</span>
<span class="picker-item__code">{{ opt.value }}</span>
</button>
</div>
<div v-else-if="pickerType === 'warehouse'" class="picker">
<button
type="button"
class="picker-item"
:class="{ 'is-active': !form.warehouseId }"
@click="pickWarehouse(undefined); pickerVisible = false"
>
{{ t('h5.price.originAny') }}
</button>
<button
v-for="opt in warehouseOptions"
:key="opt.value"
type="button"
class="picker-item"
:class="{ 'is-active': form.warehouseId === opt.value }"
@click="pickWarehouse(opt.value); pickerVisible = false"
>
{{ opt.label }}
</button>
</div>
<div v-else class="picker">
<button
type="button"
class="picker-item"
:class="{ 'is-active': !form.cargoType }"
@click="pickCargo(''); pickerVisible = false"
>
{{ t('h5.price.cargoAny') }}
</button>
<button
v-for="opt in cargoOptions"
:key="opt.value"
type="button"
class="picker-item"
:class="{ 'is-active': form.cargoType === opt.value }"
@click="pickCargo(opt.value); pickerVisible = false"
>
{{ opt.label }}
</button>
</div>
</a-drawer>
<!-- 线路详情 -->
<a-drawer
v-model:visible="detailVisible"
:title="detailTitle"
placement="bottom"
:height="'78%'"
unmount-on-close
:footer="false"
>
<a-spin :loading="detailLoading" class="h5-detail-spin">
<div v-if="detailItem" class="h5-detail">
<section class="h5-detail__summary">
<div class="h5-detail__price">
<span>{{ t('h5.price.referencePrice') }}</span>
<strong>¥{{ formatMoney(detailItem.totalCost) }}</strong>
</div>
<p class="h5-detail__ref">{{ t('h5.price.referenceTip') }}</p>
<div class="h5-detail__grid">
<div>
<label>{{ t('h5.price.cargo') }}</label>
<p>{{ detailItem.cargoTypeName || detailItem.cargoType || '-' }}</p>
</div>
<div>
<label>{{ t('h5.price.warehouse') }}</label>
<p>{{ detailItem.warehouse || '-' }}</p>
</div>
<div>
<label>{{ t('h5.price.delivery') }}</label>
<p>{{ detailItem.deliveryTime || '-' }}</p>
</div>
<div>
<label>{{ t('h5.price.chargeableWeight') }}</label>
<p>{{ detailItem.chargeableWeight || '-' }}</p>
</div>
<div v-if="detailItem.freight !== '' && detailItem.freight != null">
<label>{{ t('h5.price.freight') }}</label>
<p>¥{{ formatMoney(detailItem.freight) }}</p>
</div>
<div v-if="detailItem.otherFees !== '' && detailItem.otherFees != null">
<label>{{ t('h5.price.otherFees') }}</label>
<p>¥{{ formatMoney(detailItem.otherFees) }}</p>
</div>
</div>
</section>
<section class="h5-detail__guide">
<h4>{{ t('h5.price.routeGuide') }}</h4>
<div v-if="guideHtml" class="h5-md" v-html="guideHtml" />
<a-empty v-else-if="!detailLoading" :description="t('h5.price.guideEmpty')" />
</section>
<div class="h5-detail__footer">
<a-dropdown trigger="click" position="top" class="h5-detail__ask-drop">
<a-button long>{{ t('h5.price.askAdvisor') }}</a-button>
<template #content>
<a-doption @click="askAdvisor(detailItem, 'canShip')">
{{ t('h5.price.askAiCanShip') }}
</a-doption>
<a-doption @click="askAdvisor(detailItem, 'cheaper')">
{{ t('h5.price.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<a-button type="primary" long @click="goOrder(detailItem)">
{{ t('h5.price.goOrder') }}
</a-button>
</div>
</div>
</a-spin>
</a-drawer>
<H5PlaceOrderSheet
v-model:visible="placeOrderVisible"
:draft="placeOrderDraft"
/>
<H5CompleteContactSheet
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactSuccess"
@cancel="onContactCancel"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconDown } from '@arco-design/web-vue/es/icon';
import type { CustomerProfileCompleteness } from '@/api/customer';
import { postOrderPriceCheck } from '@/api/order';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { getDictListAll } from '@/api/system/dict';
import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { h5Navigate } from '@/utils/device';
import { checkCustomerContactRequired } from '@/utils/customerContactGate';
import {
getPriceAskLocation,
type PriceAskIntent,
} from '@/composables/priceAskAdvisor';
import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue';
import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue';
defineOptions({ name: 'H5Price' });
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
const countryShortcutCodes = ['US', 'CA', 'MX', 'BR', 'NZ', 'GB', 'DE', 'IT', 'FR', 'NL'];
const { t } = useI18n();
const router = useRouter();
const placeOrderVisible = ref(false);
const placeOrderDraft = ref<Record<string, any> | null>(null);
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
let pendingOpenOrder = false;
const form = reactive({
destinationCountry: '',
cargoWeight: '1',
postalCode: '',
cargoType: '',
warehouseId: undefined as number | undefined,
});
type Opt = { value: string; label: string; name?: string };
type WhOpt = { value: number; label: string };
const countryOptions = ref<Opt[]>([]);
const warehouseOptions = ref<WhOpt[]>([]);
const cargoOptions = ref<Opt[]>([]);
const countryKeyword = ref('');
const optionsLoading = ref(false);
const queryLoading = ref(false);
const queried = ref(false);
const queryFailureText = ref('');
const results = ref<any[]>([]);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
const sortModes = ['recommend', 'price', 'time'] as const;
const pickerVisible = ref(false);
const pickerType = ref<'country' | 'warehouse' | 'cargo'>('country');
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailItem = ref<any>(null);
const guideHtml = ref('');
function resolveCountryName(code: string, opt?: Opt | null) {
const iso = String(code || opt?.value || '').trim().toUpperCase();
const fromOpt = String(opt?.name || '').trim();
// 忽略「名称=二字码」的无效值,继续走中文兜底
if (fromOpt && fromOpt.toUpperCase() !== iso) return fromOpt;
const key = `h5.price.shortcut.${iso}`;
const fromLocale = t(key);
if (fromLocale && fromLocale !== key && fromLocale.toUpperCase() !== iso) return fromLocale;
return '';
}
const selectedCountry = computed(() => {
const code = String(form.destinationCountry || '').trim().toUpperCase();
if (!code) return undefined;
return countryOptions.value.find((c) => c.value.toUpperCase() === code);
});
const destinationCode = computed(() =>
String(form.destinationCountry || selectedCountry.value?.value || '')
.trim()
.toUpperCase(),
);
const destinationName = computed(() =>
resolveCountryName(destinationCode.value, selectedCountry.value),
);
const destinationDisplay = computed(() => {
if (!destinationCode.value) return '';
if (destinationName.value) return `${destinationName.value} ${destinationCode.value}`;
return destinationCode.value;
});
const warehouseLabel = computed(() => {
if (!form.warehouseId) return '';
return warehouseOptions.value.find((w) => w.value === form.warehouseId)?.label || '';
});
const cargoLabel = computed(() => {
if (!form.cargoType) return '';
return cargoOptions.value.find((c) => c.value === form.cargoType)?.label || form.cargoType;
});
const summaryText = computed(() => {
const dest = destinationDisplay.value;
if (!dest && !form.cargoWeight) return '';
const origin = warehouseLabel.value || t('h5.price.originAny');
const zip = form.postalCode.trim();
const destPart = zip ? `${dest}(${zip})` : dest || '-';
const weight = form.cargoWeight ? `${form.cargoWeight}KG` : '-';
const cargo = cargoLabel.value || t('h5.price.cargoAny');
return `${origin}-${destPart} | ${weight} | ${cargo}`;
});
const shortcutCountries = computed(() =>
countryShortcutCodes
.map((code) => {
const hit = countryOptions.value.find((c) => c.value.toUpperCase() === code);
return {
value: code,
name: resolveCountryName(code, hit) || code,
};
})
.filter((item) => item.value),
);
function quoteTone(index: number) {
if (index === 0 && sortMode.value === 'recommend') return 'primary';
const tones = ['primary', 'warning', 'success', 'neutral'] as const;
return tones[index % tones.length];
}
const filteredCountries = computed(() => {
const q = countryKeyword.value.trim().toLowerCase();
if (!q) return countryOptions.value;
return countryOptions.value.filter(
(c) =>
c.value.toLowerCase().includes(q) ||
(c.name || '').toLowerCase().includes(q) ||
c.label.toLowerCase().includes(q),
);
});
const pickerTitle = computed(() => {
if (pickerType.value === 'warehouse') return t('h5.price.origin');
if (pickerType.value === 'cargo') return t('h5.price.cargoAttr');
return t('h5.price.destination');
});
const pickerHeight = computed(() => (pickerType.value === 'country' ? '72%' : '50%'));
const detailTitle = computed(() => {
const name = detailItem.value?.shippingMethod || detailItem.value?.productCode || '';
return name ? t('h5.price.detailTitleNamed', { name }) : t('h5.price.detailTitle');
});
function formatMoney(val: unknown) {
const n = Number(String(val ?? '').replace(/[^\d.]/g, ''));
if (!Number.isFinite(n)) return String(val ?? '-');
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function parseTotalCost(cost: unknown) {
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 escapeHtml(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
async function markdownToHtml(md: string) {
const text = String(md || '').trim();
if (!text) return '';
try {
const { marked } = await import('marked');
marked.setOptions({ gfm: true, breaks: true });
return String(marked.parse(text));
} catch {
return `<pre>${escapeHtml(text)}</pre>`;
}
}
function normalizeRow(raw: any, index: number) {
const deliveryTime = String(raw.deliveryTime ?? raw.DeliveryTime ?? '');
const match = deliveryTime.match(/(\d+)-(\d+)/);
const maxDeliveryDays = match ? parseInt(match[2], 10) : 0;
const totalCost = raw.totalCost ?? raw.TotalCost ?? '';
const cargoType = raw.cargoType ?? raw.CargoType ?? '';
const cargoTypeName = raw.cargoTypeName ?? raw.CargoTypeName ?? cargoType ?? '';
return {
...raw,
productId: raw.id ?? raw.Id ?? raw.productId ?? raw.ProductId,
productCode: raw.productCode ?? raw.ProductCode ?? '',
shippingMethod: raw.shippingMethod ?? raw.ShippingMethod ?? '',
cargoType,
cargoTypeName: String(cargoTypeName || '').trim(),
warehouseId: raw.warehouseId ?? raw.WarehouseId,
warehouse: String(raw.warehouse ?? raw.Warehouse ?? '').trim(),
deliveryTime,
chargeableWeight: raw.chargeableWeight ?? raw.ChargeableWeight ?? '',
freight: raw.freight ?? raw.Freight ?? '',
otherFees: raw.otherFees ?? raw.OtherFees ?? '',
totalCost,
totalCostNum: parseTotalCost(totalCost),
maxDeliveryDays,
rowKey: [
raw.id ?? raw.productCode ?? 'p',
cargoType || 'c',
raw.warehouseId ?? raw.warehouse ?? 'w',
index,
].join('-'),
};
}
const sortedResults = computed(() => {
const list = [...results.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.sort((a, b) => a.totalCostNum - b.totalCostNum);
});
function openPicker(type: 'country' | 'warehouse' | 'cargo') {
pickerType.value = type;
if (type === 'country') countryKeyword.value = '';
pickerVisible.value = true;
}
function pickCountry(code: string) {
form.destinationCountry = String(code || '').trim().toUpperCase();
}
function pickWarehouse(id?: number) {
form.warehouseId = id;
}
function pickCargo(code: string) {
form.cargoType = code;
}
async function loadOptions() {
optionsLoading.value = true;
try {
const [countryRes, warehouseRes, dictRows] = await Promise.all([
getCountryList(),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'cargo_attribute' }),
]);
countryOptions.value = (countryRes.items ?? [])
.map((row: any) => {
const code = String(row?.countryCode ?? row?.CountryCode ?? row?.code ?? '')
.trim()
.toUpperCase();
const name = String(
row?.chineseName ??
row?.ChineseName ??
row?.countryNameCn ??
row?.countryName ??
row?.CountryName ??
row?.name ??
'',
).trim();
return {
value: code,
name,
label: name ? `${name} ${code}` : code,
};
})
.filter((c) => c.value);
warehouseOptions.value = (warehouseRes.items ?? [])
.map((row: any) => {
const id = Number(row?.id ?? row?.Id);
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { value: id, label: name || String(id) };
})
.filter((w) => Number.isFinite(w.value) && w.value > 0);
if (!form.warehouseId && warehouseOptions.value.length) {
form.warehouseId = warehouseOptions.value[0].value;
}
cargoOptions.value = (dictRows ?? [])
.map((row: any) => {
const code = String(row?.dictCode ?? row?.DictCode ?? row?.code ?? '').trim();
const name = String(row?.dictName ?? row?.DictName ?? row?.name ?? '').trim();
return { value: code, label: name || code };
})
.filter((c) => c.value);
// 默认普货:名称含「普」或首项
if (!form.cargoType && cargoOptions.value.length) {
const general = cargoOptions.value.find((c) => /普货|general/i.test(c.label));
form.cargoType = (general || cargoOptions.value[0]).value;
}
} catch {
countryOptions.value = [];
warehouseOptions.value = [];
cargoOptions.value = [];
} finally {
optionsLoading.value = false;
}
}
async function handleQuery() {
if (!form.destinationCountry || !String(form.cargoWeight).trim()) {
Message.warning(t('h5.price.required'));
return;
}
queryLoading.value = true;
queried.value = true;
queryFailureText.value = '';
try {
const { items, failures } = await postOrderPriceCheck({
destinationCountry: form.destinationCountry,
shippingMethod: '',
cargoWeight: String(form.cargoWeight).trim(),
destinationProvince: '',
postalCode: form.postalCode.trim() || '',
cargoTypes: form.cargoType ? [form.cargoType] : undefined,
warehouseIds: form.warehouseId ? [form.warehouseId] : undefined,
});
results.value = (items || []).map((item, index) => normalizeRow(item, index));
if (!results.value.length) {
queryFailureText.value =
(failures || [])
.map((f: any) => f.reason || f.Reason || '')
.filter(Boolean)
.join(';') || t('h5.price.noResult');
}
sortMode.value = 'recommend';
} catch (e: any) {
results.value = [];
queryFailureText.value = e?.message || t('h5.price.noResult');
} finally {
queryLoading.value = false;
}
}
async function openDetail(item: any) {
detailItem.value = item;
detailVisible.value = true;
guideHtml.value = '';
const productId = item?.productId ?? item?.id;
if (productId == null || productId === '') {
Message.warning(t('h5.price.guideMissing'));
return;
}
detailLoading.value = true;
try {
const detail = await getSalesProductDetail(productId);
const md = String(
detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '',
).trim();
guideHtml.value = await markdownToHtml(md);
} catch (e: any) {
Message.error(e?.message || t('h5.price.guideFailed'));
} finally {
detailLoading.value = false;
}
}
function askAdvisor(item: any, intent: PriceAskIntent = 'general') {
const ctx = {
destinationCountry: String(form.destinationCountry || '').trim(),
cargoWeight: form.cargoWeight,
postalCode: form.postalCode,
productCode: String(item?.productCode ?? '').trim(),
shippingMethod: String(item?.shippingMethod ?? '').trim(),
cargoType: String(item?.cargoType ?? form.cargoType ?? '').trim(),
cargoTypeName: String(item?.cargoTypeName ?? item?.cargoType ?? '').trim(),
warehouse: String(item?.warehouse ?? '').trim(),
totalCost: item?.totalCost,
deliveryTime: item?.deliveryTime,
quoteLevel: String(item?.quoteLevel ?? item?.QuoteLevel ?? '').trim(),
};
detailVisible.value = false;
h5Navigate(router, getPriceAskLocation(intent, ctx, { h5: true }));
}
function buildOrderDraft(item: any) {
return {
productCode: String(item?.productCode ?? '').trim(),
productId: item?.productId ?? item?.id ?? null,
shippingMethod: String(item?.shippingMethod ?? '').trim(),
cargoAttribute: String(item?.cargoType ?? form.cargoType ?? '').trim(),
warehouseId:
item?.warehouseId != null && item?.warehouseId !== ''
? String(item.warehouseId)
: form.warehouseId
? String(form.warehouseId)
: '',
countryCode: String(form.destinationCountry || '').trim(),
destinationCountry: String(form.destinationCountry || '').trim(),
actualWeight: String(form.cargoWeight || '').trim(),
cargoWeight: String(form.cargoWeight || '').trim(),
zipCode: form.postalCode.trim(),
length: '',
width: '',
height: '',
totalCost: item?.totalCost ?? null,
};
}
function openPlaceOrderWithDraft(draft: Record<string, any>) {
try {
sessionStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft));
localStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft));
} catch {
// ignore
}
detailVisible.value = false;
placeOrderDraft.value = draft;
placeOrderVisible.value = true;
}
async function goOrder(item: any) {
const draft = buildOrderDraft(item);
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
placeOrderDraft.value = draft;
pendingOpenOrder = true;
contactProfile.value = profile;
contactVisible.value = true;
return;
}
openPlaceOrderWithDraft(draft);
}
function onContactSuccess() {
if (!pendingOpenOrder) return;
pendingOpenOrder = false;
const draft = placeOrderDraft.value || {};
openPlaceOrderWithDraft(draft);
}
function onContactCancel() {
pendingOpenOrder = false;
}
onMounted(() => {
void loadOptions();
});
</script>
<style lang="less" scoped>
.h5-price-page {
padding: 12px 16px 24px;
min-height: 100%;
box-sizing: border-box;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--app-primary-color) 10%, #f5f7f8) 0%,
#f5f7f8 140px
);
}
.quote-form {
background: #fff;
border-radius: 18px;
padding: 18px 16px 14px;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
text-align: left;
}
.route-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 48px minmax(0, 1fr);
align-items: center;
column-gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid #eef0f3;
}
.route-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 2px 4px;
border: none;
background: transparent;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active .route-cell__value {
opacity: 0.72;
}
}
.route-cell__label {
font-size: 12px;
color: #86909c;
line-height: 1.2;
width: 100%;
text-align: center;
}
.route-cell__value {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
max-width: 100%;
font-size: 18px;
font-weight: 700;
color: #1d2129;
line-height: 1.25;
min-height: 26px;
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-placeholder > span {
color: #c9cdd4;
font-weight: 600;
font-size: 16px;
}
:deep(.arco-icon) {
flex-shrink: 0;
font-size: 12px;
color: #c9cdd4;
}
}
.route-cell__dest {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 5px;
min-width: 0;
max-width: 100%;
b {
font-weight: 700;
font-size: 18px;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
flex-shrink: 0;
font-style: normal;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
border-radius: 4px;
padding: 1px 5px;
line-height: 1.4;
}
}
.route-mid {
display: flex;
align-items: center;
justify-content: center;
}
.route-mid__icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(
145deg,
color-mix(in srgb, var(--app-primary-color) 88%, #5eead4) 0%,
var(--app-primary-color) 48%,
color-mix(in srgb, var(--app-primary-color) 75%, #0f766e) 100%
);
box-shadow:
0 4px 12px color-mix(in srgb, var(--app-primary-color) 32%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.route-mid__plane {
width: 22px;
height: 22px;
display: block;
}
.picker-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.quick-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #e5e6eb;
background: #fff;
border-radius: 999px;
padding: 6px 10px;
font-size: 13px;
color: #1d2129;
cursor: pointer;
em {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: #86909c;
}
&.is-active {
border-color: color-mix(in srgb, var(--app-primary-color) 40%, #e5e6eb);
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
font-weight: 600;
em {
color: var(--app-primary-color);
}
}
}
.line-field {
padding: 12px 0 10px;
border-bottom: 1px solid #eef0f3;
}
.line-field__label {
display: block;
font-size: 12px;
color: #86909c;
margin-bottom: 4px;
}
.line-field__input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 18px;
font-weight: 600;
color: #1d2129;
padding: 0;
line-height: 1.4;
&::placeholder {
color: #c9cdd4;
font-weight: 500;
font-size: 15px;
}
}
.pkg-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
margin: 0 0 16px;
border-bottom: 1px solid #eef0f3;
}
.pkg-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 12px 0 14px;
border: none;
background: transparent;
text-align: left;
font: inherit;
color: inherit;
&:first-child {
padding-right: 14px;
border-right: 1px solid #eef0f3;
}
&:last-child {
padding-left: 14px;
}
&--tap {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active .pkg-cell__value {
opacity: 0.72;
}
}
}
.pkg-cell__label {
font-size: 12px;
color: #86909c;
line-height: 1.2;
}
.pkg-cell__value {
display: flex;
align-items: baseline;
gap: 4px;
min-width: 0;
min-height: 32px;
input {
flex: 1;
min-width: 0;
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 24px;
font-weight: 700;
color: #1d2129;
padding: 0;
line-height: 1.2;
font-variant-numeric: tabular-nums;
&::placeholder {
color: #c9cdd4;
font-weight: 600;
}
}
em {
flex-shrink: 0;
font-style: normal;
font-size: 12px;
font-weight: 600;
color: #86909c;
line-height: 1.2;
}
strong {
flex: 1;
min-width: 0;
font-size: 18px;
font-weight: 700;
color: #1d2129;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-placeholder {
color: #c9cdd4;
font-weight: 600;
}
}
:deep(.arco-icon) {
flex-shrink: 0;
align-self: center;
font-size: 12px;
color: #c9cdd4;
}
}
.quote-cta {
width: 100%;
height: 48px;
border: none;
border-radius: 12px;
background: var(--app-primary-color);
color: #fff;
font-size: 17px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 8px 20px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
&:active {
transform: scale(0.985);
}
&:disabled {
opacity: 0.8;
}
}
.quote-cta__loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.quote-foot {
margin-top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.quote-summary {
display: inline-flex;
max-width: 100%;
padding: 6px 12px;
border-radius: 999px;
background: #f2f3f5;
color: #86909c;
font-size: 12px;
line-height: 1.3;
word-break: break-all;
text-align: center;
}
.quote-ref {
margin: 0;
font-size: 12px;
color: #86909c;
text-align: center;
}
.picker {
padding: 0 4px 16px;
}
.picker-search {
width: 100%;
height: 40px;
margin-bottom: 12px;
padding: 0 12px;
border: none;
border-radius: 10px;
background: #f2f3f5;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 4px;
border: none;
border-bottom: 1px solid #f2f3f5;
background: transparent;
font-size: 16px;
color: #1d2129;
text-align: left;
cursor: pointer;
&__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__code {
flex-shrink: 0;
min-width: 28px;
padding: 2px 6px;
border-radius: 6px;
background: #f2f3f5;
color: #4e5969;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-align: center;
}
&.is-active {
color: var(--app-primary-color);
font-weight: 600;
.picker-item__code {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
}
}
}
.h5-price-results {
margin-top: 10px;
}
.h5-results-bar {
margin-bottom: 8px;
}
.h5-disclaimer {
margin: 0 0 8px;
padding: 6px 10px;
border-radius: 8px;
background: #fff7e8;
color: #ff7d00;
font-size: 11px;
line-height: 1.4;
text-align: left;
}
.h5-sort {
display: flex;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: transparent;
}
.h5-sort__btn {
flex: 1;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #86909c;
cursor: pointer;
&.is-active {
color: #4e5969;
background: rgba(15, 23, 42, 0.04);
font-weight: 600;
}
}
.h5-price-spin {
width: 100%;
display: block;
}
.h5-quote {
--liquid-a: rgba(0, 121, 121, 0.12);
--liquid-b: rgba(64, 128, 255, 0.08);
--liquid-edge: rgba(255, 255, 255, 0.78);
--liquid-glow: rgba(0, 121, 121, 0.06);
position: relative;
z-index: 0;
overflow: hidden;
isolation: isolate;
margin-bottom: 10px;
padding: 12px 14px 10px;
border-radius: 16px;
border: 1px solid var(--liquid-edge);
background:
linear-gradient(
155deg,
rgba(255, 255, 255, 0.88) 0%,
rgba(255, 255, 255, 0.58) 48%,
rgba(255, 255, 255, 0.78) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 6px 18px var(--liquid-glow),
0 1px 2px rgba(15, 23, 42, 0.03);
backdrop-filter: blur(12px) saturate(1.15);
-webkit-backdrop-filter: blur(12px) saturate(1.15);
cursor: pointer;
text-align: left;
transition:
border-color 0.3s ease,
box-shadow 0.3s ease,
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
&--tone-primary {
--liquid-a: rgba(0, 121, 121, 0.14);
--liquid-b: rgba(64, 128, 255, 0.1);
--liquid-glow: rgba(0, 121, 121, 0.08);
}
&--tone-warning {
--liquid-a: rgba(255, 125, 0, 0.14);
--liquid-b: rgba(255, 182, 93, 0.12);
--liquid-glow: rgba(255, 125, 0, 0.07);
}
&--tone-success {
--liquid-a: rgba(0, 180, 42, 0.12);
--liquid-b: rgba(76, 210, 99, 0.1);
--liquid-glow: rgba(0, 180, 42, 0.06);
}
&--tone-neutral {
--liquid-a: rgba(45, 140, 140, 0.1);
--liquid-b: rgba(125, 162, 255, 0.08);
--liquid-glow: rgba(45, 140, 140, 0.05);
}
&--top {
border-color: color-mix(in srgb, var(--app-primary-color) 32%, #fff);
}
&:active {
transform: scale(0.992);
}
}
.h5-quote__blob,
.h5-quote__shine,
.h5-quote__main {
position: relative;
z-index: 1;
}
.h5-quote__blob {
position: absolute;
z-index: 0;
inset: -42% -18% auto auto;
width: 78%;
height: 125%;
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.55) 0%, transparent 42%),
radial-gradient(circle at 70% 65%, var(--liquid-a) 0%, transparent 58%),
radial-gradient(circle at 20% 80%, var(--liquid-b) 0%, transparent 55%);
filter: blur(12px);
opacity: 0.68;
pointer-events: none;
transform-origin: 60% 40%;
animation: h5-liquid-drift 9s ease-in-out infinite;
}
.h5-quote__shine {
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background: linear-gradient(
115deg,
transparent 28%,
rgba(255, 255, 255, 0.05) 42%,
rgba(255, 255, 255, 0.24) 50%,
rgba(255, 255, 255, 0.05) 58%,
transparent 72%
);
background-size: 220% 100%;
background-position: 110% 0;
opacity: 0.4;
pointer-events: none;
}
.h5-quote__main {
display: flex;
flex-direction: column;
min-width: 0;
}
.h5-quote__head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.h5-quote__title {
min-width: 0;
flex: 1;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #1d2129;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.h5-quote__tag {
display: inline-block;
margin-right: 4px;
padding: 0 5px;
border-radius: 4px;
background: var(--app-primary-color);
color: #fff;
font-size: 10px;
font-weight: 600;
vertical-align: 1px;
}
.h5-quote__sub {
margin: 5px 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
}
.h5-quote__chip {
display: inline-flex;
align-items: center;
max-width: 100%;
height: 20px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&--cargo {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
}
&--wh {
color: #ff7d00;
background: rgba(255, 125, 0, 0.1);
}
}
.h5-quote__meta {
font-size: 11px;
color: #86909c;
line-height: 1.4;
white-space: nowrap;
}
.h5-quote__price-block {
text-align: right;
flex-shrink: 0;
}
.ref-label {
display: block;
margin-top: 1px;
font-size: 10px;
color: #c9cdd4;
}
.h5-quote__price {
font-size: 18px;
font-weight: 700;
color: #f53f3f;
white-space: nowrap;
font-variant-numeric: tabular-nums;
line-height: 1.1;
small {
font-size: 11px;
margin-right: 1px;
}
}
.h5-quote__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(242, 243, 245, 0.85);
}
.link-btn {
border: none;
background: transparent;
padding: 0 4px;
font-size: 12px;
color: #86909c;
cursor: pointer;
}
.order-btn {
border: none;
height: 28px;
padding: 0 12px;
border-radius: 14px;
background: var(--app-primary-color);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 10px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
}
@keyframes h5-liquid-drift {
0%,
100% {
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
transform: translate(0, 0) rotate(0deg) scale(1);
}
33% {
border-radius: 58% 42% 55% 45% / 42% 58% 42% 58%;
transform: translate(-8%, 6%) rotate(8deg) scale(1.06);
}
66% {
border-radius: 48% 52% 40% 60% / 55% 45% 55% 45%;
transform: translate(6%, -4%) rotate(-6deg) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.h5-quote__blob {
animation: none;
}
}
@media (max-width: 720px) {
.h5-quote__blob {
animation: none;
opacity: 0.72;
}
}
.h5-empty {
padding: 20px 12px 4px;
text-align: center;
p {
margin: 0 0 10px;
color: #86909c;
font-size: 13px;
line-height: 1.5;
}
}
.h5-empty__icon {
width: 40px;
height: 40px;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: center;
color: #c9cdd4;
background: transparent;
svg {
width: 36px;
height: 36px;
display: block;
}
}
.h5-price-empty {
margin-top: 12px;
}
.h5-detail-spin {
width: 100%;
min-height: 200px;
display: block;
}
.h5-detail {
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
text-align: left;
}
.h5-detail__summary {
padding: 4px 0 16px;
border-bottom: 1px solid #f2f3f5;
}
.h5-detail__price {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
span {
font-size: 13px;
color: #86909c;
}
strong {
font-size: 26px;
color: #f53f3f;
font-variant-numeric: tabular-nums;
}
}
.h5-detail__ref {
margin: 6px 0 12px;
font-size: 12px;
color: #ff7d00;
}
.h5-detail__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
label {
display: block;
font-size: 11px;
color: #86909c;
}
p {
margin: 2px 0 0;
font-size: 14px;
font-weight: 600;
color: #1d2129;
word-break: break-all;
}
}
.h5-detail__guide {
padding: 16px 0;
h4 {
margin: 0 0 10px;
font-size: 15px;
color: #1d2129;
}
}
.h5-md {
font-size: 14px;
line-height: 1.65;
color: #4e5969;
word-break: break-word;
:deep(h1),
:deep(h2),
:deep(h3) {
color: #1d2129;
margin: 12px 0 8px;
}
:deep(p) {
margin: 0 0 8px;
}
:deep(ul),
:deep(ol) {
padding-left: 1.2em;
margin: 0 0 8px;
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
}
}
.h5-detail__footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: grid;
grid-template-columns: 0.9fr 1.3fr;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #e5e6eb;
}
</style>