front/src/views/h5/customer-center/price/index.vue
zzh c6a4484448 feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验
销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:48:36 +08:00

2368 lines
61 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">
<img :src="iconRoutePlane" alt="" class="route-mid__plane" draggable="false" />
</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 ref="postalFieldRef" 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="dim-toggle" @click="showDimensions = !showDimensions">
<span>{{ t('h5.price.dimensionsToggle') }}</span>
<em>{{ showDimensions ? '−' : '+' }}</em>
</button>
<div v-if="showDimensions" class="dim-row">
<label class="dim-cell">
<span>{{ t('h5.price.length') }}</span>
<input v-model="form.length" type="text" inputmode="decimal" placeholder="cm" />
</label>
<label class="dim-cell">
<span>{{ t('h5.price.width') }}</span>
<input v-model="form.width" type="text" inputmode="decimal" placeholder="cm" />
</label>
<label class="dim-cell">
<span>{{ t('h5.price.height') }}</span>
<input v-model="form.height" type="text" inputmode="decimal" placeholder="cm" />
</label>
</div>
<p v-if="showDimensions" class="dim-hint">{{ t('h5.price.dimensionsHint') }}</p>
<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-ai-bar">
<div class="h5-ai-bar__head">
<strong>{{ t('h5.price.ai.title') }}</strong>
</div>
<div class="h5-ai-bar__row">
<input
v-model="aiProductName"
class="h5-ai-bar__input"
type="text"
:placeholder="t('h5.price.ai.placeholder')"
maxlength="80"
@keyup.enter="handleAiRecommend"
/>
<button
type="button"
class="h5-ai-bar__btn"
:disabled="aiRecommendLoading || !aiProductName.trim()"
@click="handleAiRecommend"
>
{{ aiRecommendLoading ? '…' : t('h5.price.ai.action') }}
</button>
</div>
</div>
<div
v-if="recommendVerdict"
class="h5-ai-insight"
:class="`is-${recommendVerdict}`"
>
<div class="h5-ai-insight__top">
<span class="h5-ai-insight__verdict">
{{ t(`order.priceCheck.ai.verdict.${recommendVerdict}`) }}
</span>
<span v-if="recommendCargoName" class="h5-ai-insight__cargo">
{{ t('order.priceCheck.ai.cargoLabel', { name: recommendCargoName }) }}
</span>
</div>
<p v-if="recommendReason" class="h5-ai-insight__reason">{{ recommendReason }}</p>
<ul v-if="recommendEvidence.length" class="h5-ai-insight__evidence">
<li v-for="(line, idx) in recommendEvidence" :key="idx">{{ line }}</li>
</ul>
<div v-if="recommendAlternatives.length" class="h5-ai-insight__alts">
<button
v-for="alt in recommendAlternatives"
:key="alt.rowKey"
type="button"
class="h5-ai-alt"
:class="`is-${alt.verdict}`"
@click="selectAiAlternative(alt.rowKey)"
>
{{ t(`order.priceCheck.ai.verdict.${alt.verdict}`) }} · {{ alt.blurb }}
</button>
</div>
</div>
<div
v-if="needPostalHint && !hintBannerDismissed"
class="h5-accuracy"
>
<span>{{ t('h5.price.accuracy.postalCta') }}</span>
<div class="h5-accuracy__actions">
<button type="button" class="h5-accuracy__fill" @click="focusPostal">
{{ t('h5.price.accuracy.fillPostal') }}
</button>
<button
type="button"
class="h5-accuracy__dismiss"
:aria-label="t('h5.price.accuracy.dismiss')"
@click="hintBannerDismissed = true"
>
×
</button>
</div>
</div>
<div v-if="sortedResults.length" class="h5-results-bar">
<p v-if="showQueryHintBanner && !needPostalHint" class="h5-disclaimer">
{{ queryHintSummary }}
</p>
<p v-else-if="!needPostalHint" 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="askAdvisorEmpty">
{{ t('h5.price.askAdvisor') }}
</a-button>
</div>
<article
v-for="item in sortedResults"
:key="item.rowKey"
:ref="(el) => setQuoteRef(item.rowKey, el)"
class="h5-quote"
:class="{
'h5-quote--top': isRecommendStamp(item),
'h5-quote--ai': item.rowKey === recommendedRowKey,
}"
@click="openDetail(item)"
>
<span
v-if="isRecommendStamp(item)"
class="h5-quote__stamp"
aria-hidden="true"
>
{{ t('h5.price.recommendTag') }}
</span>
<div class="h5-quote__main">
<div class="h5-quote__head">
<div class="h5-quote__title">
<h3>{{ item.shippingMethod || item.productCode || '-' }}</h3>
<p class="h5-quote__sub">
<span
v-if="item.productGroupName"
class="h5-quote__chip h5-quote__chip--group"
>
{{ item.productGroupName }}
</span>
<span class="h5-quote__chip h5-quote__chip--cargo">
{{ item.cargoTypeName || item.cargoType || '-' }}
</span>
<span
v-if="item.tradeTerms === 'DDP' || item.tradeTerms === 'DDU'"
class="h5-quote__chip h5-quote__chip--trade"
>
{{ item.tradeTerms }}
</span>
<span v-if="item.isPriceOptimal" class="h5-quote__tag is-price">
{{ t('h5.price.tag.priceOptimal') }}
</span>
<span v-if="item.isTimeOptimal" class="h5-quote__tag is-time">
{{ t('h5.price.tag.timeOptimal') }}
</span>
<span v-if="item.chargeableWeight" class="h5-quote__weight">
{{ item.chargeableWeight }}kg
</span>
</p>
</div>
<div class="h5-quote__eta">
<strong class="h5-quote__eta-value">{{ item.deliveryTime || '-' }}</strong>
<span class="h5-quote__eta-label">{{ t('h5.price.workingDays') }}</span>
</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>
<span class="h5-quote__chip h5-quote__chip--wh">
{{ item.warehouse || '-' }}
</span>
<div class="h5-quote__action-btns">
<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>
</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="onPickCountry(item.value)"
>
<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="onPickCountry(opt.value)"
>
<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="onPickWarehouse(undefined)"
>
{{ 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="onPickWarehouse(opt.value)"
>
{{ opt.label }}
</button>
</div>
<div v-else class="picker">
<button
type="button"
class="picker-item"
:class="{ 'is-active': !form.cargoType }"
@click="onPickCargo('')"
>
{{ 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="onPickCargo(opt.value)"
>
{{ 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 v-if="detailItem.productGroupName">
<label>{{ t('h5.price.productGroup') }}</label>
<p>{{ detailItem.productGroupName }}</p>
</div>
<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 v-if="detailItem.tradeTerms === 'DDP' || detailItem.tradeTerms === 'DDU'">
<label>{{ t('h5.price.tradeTerms') }}</label>
<p>{{ detailItem.tradeTerms }}</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, nextTick, onMounted, reactive, ref, type ComponentPublicInstance } 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, type PriceCheckQueryHint } 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 iconRoutePlane from '@/assets/icons/h5/route-plane.svg?url';
import {
getPriceAskLocation,
type PriceAskIntent,
} from '@/composables/priceAskAdvisor';
import {
recommendWithAi,
type QuoteCandidate,
type RecommendAlternative,
type ShipVerdict,
} from '@/views/order/priceCheck/recommendChannel';
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', 'GB', 'DE', 'CA', 'FR', 'ES'];
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,
length: '',
width: '',
height: '',
});
const showDimensions = ref(false);
const postalFieldRef = ref<HTMLElement | null>(null);
const quoteElMap = new Map<string, HTMLElement>();
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 queryHint = ref<PriceCheckQueryHint | null>(null);
const hintBannerDismissed = ref(false);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
const sortModes = ['recommend', 'price', 'time'] as const;
const aiProductName = ref('');
const aiRecommendLoading = ref(false);
const recommendedRowKey = ref('');
const recommendReason = ref('');
const recommendVerdict = ref<ShipVerdict | ''>('');
const recommendEvidence = ref<string[]>([]);
const recommendCargoName = ref('');
const recommendAlternatives = ref<RecommendAlternative[]>([]);
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 parseOptionalDimension(value: unknown) {
const n = parseFloat(String(value ?? '').trim());
return Number.isFinite(n) && n > 0 ? n : undefined;
}
function clearRecommendInsight() {
recommendedRowKey.value = '';
recommendReason.value = '';
recommendVerdict.value = '';
recommendEvidence.value = [];
recommendCargoName.value = '';
recommendAlternatives.value = [];
}
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 setQuoteRef(rowKey: string, el: Element | ComponentPublicInstance | null) {
if (!el) {
quoteElMap.delete(rowKey);
return;
}
const node = (el as ComponentPublicInstance).$el
? ((el as ComponentPublicInstance).$el as HTMLElement)
: (el as HTMLElement);
if (node) quoteElMap.set(rowKey, node);
}
function scrollToQuote(rowKey: string) {
nextTick(() => {
quoteElMap.get(rowKey)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
});
}
function isRecommendStamp(item: any) {
if (recommendedRowKey.value) return item.rowKey === recommendedRowKey.value;
return sortMode.value === 'recommend' && !!item.isPriceOptimal;
}
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),
);
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');
});
const needPostalHint = computed(() => {
if (!queried.value) return false;
if (String(form.postalCode || '').trim()) return false;
const missing = queryHint.value?.missingFieldCodes ?? [];
return (
queryHint.value?.quoteLevel === 'estimated' ||
missing.includes('postalCode') ||
missing.includes('PostalCode')
);
});
const showQueryHintBanner = computed(
() =>
Boolean(queryHint.value?.disclaimers?.length) ||
queryHint.value?.quoteLevel === 'estimated',
);
const queryHintSummary = computed(() => {
if (!showQueryHintBanner.value) return '';
return t('h5.price.hint.estimated');
});
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(),
productGroup: String(raw.productGroup ?? raw.ProductGroup ?? '').trim(),
productGroupName: String(
raw.productGroupName ?? raw.ProductGroupName ?? raw.productGroup ?? raw.ProductGroup ?? '',
).trim(),
tradeTerms: (() => {
const rawTerms = String(raw.tradeTerms ?? raw.TradeTerms ?? '')
.trim()
.toUpperCase();
return rawTerms === 'DDP' || rawTerms === 'DDU' ? rawTerms : '';
})(),
deliveryTime,
chargeableWeight: raw.chargeableWeight ?? raw.ChargeableWeight ?? '',
freight: raw.freight ?? raw.Freight ?? '',
otherFees: raw.otherFees ?? raw.OtherFees ?? '',
totalCost,
totalCostNum: parseTotalCost(totalCost),
maxDeliveryDays,
quoteLevel: String(raw.quoteLevel ?? raw.QuoteLevel ?? '').toLowerCase(),
rowKey: [
raw.id ?? raw.productCode ?? 'p',
cargoType || 'c',
raw.warehouseId ?? raw.warehouse ?? 'w',
index,
].join('-'),
};
}
function processQueryResults(items: any[]) {
if (!items?.length) return [];
const processed = items.map((item, index) => normalizeRow(item, index));
const minCost = Math.min(...processed.map((item) => item.totalCostNum));
const minDays = Math.min(...processed.map((item) => item.maxDeliveryDays));
processed.forEach((item) => {
item.isPriceOptimal = item.totalCostNum === minCost;
item.isTimeOptimal = item.maxDeliveryDays === minDays;
});
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 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,
);
}
// recommend:保持 processQueryResults 的综合序
return list;
});
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;
}
function onPickCountry(code: string) {
pickCountry(code);
pickerVisible.value = false;
}
function onPickWarehouse(id?: number) {
pickWarehouse(id);
pickerVisible.value = false;
}
function onPickCargo(code: string) {
pickCargo(code);
pickerVisible.value = false;
}
function focusPostal() {
hintBannerDismissed.value = false;
postalFieldRef.value?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
const input = postalFieldRef.value?.querySelector?.('input') as HTMLInputElement | null;
input?.focus?.();
}
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 = '';
hintBannerDismissed.value = false;
clearRecommendInsight();
try {
const length = parseOptionalDimension(form.length);
const width = parseOptionalDimension(form.width);
const height = parseOptionalDimension(form.height);
const { items, failures, queryHint: hint } = await postOrderPriceCheck({
destinationCountry: form.destinationCountry,
shippingMethod: '',
cargoWeight: String(form.cargoWeight).trim(),
...(length != null && width != null && height != null
? { length: String(length), width: String(width), height: String(height) }
: {}),
destinationProvince: '',
postalCode: form.postalCode.trim() || '',
cargoTypes: form.cargoType ? [form.cargoType] : undefined,
warehouseIds: form.warehouseId ? [form.warehouseId] : undefined,
});
queryHint.value = hint;
results.value = processQueryResults(items || []);
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 = [];
queryHint.value = null;
queryFailureText.value = e?.message || t('h5.price.noResult');
} finally {
queryLoading.value = false;
}
}
async function handleAiRecommend() {
const name = aiProductName.value.trim();
if (!name) {
Message.warning(t('h5.price.ai.needName'));
return;
}
if (!results.value.length) {
Message.warning(t('h5.price.ai.needQuotes'));
return;
}
aiRecommendLoading.value = true;
try {
const candidates: QuoteCandidate[] = results.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) {
clearRecommendInsight();
Message.warning(t('order.priceCheck.ai.noMatch', { name }));
return;
}
const hit = results.value.find((item) => item.rowKey === result.rowKey);
if (!hit) {
Message.warning(t('order.priceCheck.ai.noMatch', { name }));
return;
}
applyRecommendInsight(result);
sortMode.value = 'recommend';
scrollToQuote(result.rowKey);
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;
}
}
function selectAiAlternative(rowKey: string) {
const hit = results.value.find((item) => item.rowKey === 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';
if (alt?.blurb) recommendReason.value = alt.blurb;
scrollToQuote(rowKey);
}
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 ?? queryHint.value?.quoteLevel ?? '').trim(),
length: form.length,
width: form.width,
height: form.height,
};
detailVisible.value = false;
h5Navigate(router, getPriceAskLocation(intent, ctx, { h5: true }));
}
function askAdvisorEmpty() {
h5Navigate(router, '/h5/assistant');
}
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: String(form.length || '').trim(),
width: String(form.width || '').trim(),
height: String(form.height || '').trim(),
totalCost: item?.totalCost ?? null,
quoteLevel: String(item?.quoteLevel ?? queryHint.value?.quoteLevel ?? '').trim() || undefined,
};
}
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;
min-width: 40px;
}
.route-mid__icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
box-shadow: none;
border-radius: 0;
}
.route-mid__plane {
width: 39px;
height: 39px;
display: block;
object-fit: contain;
pointer-events: none;
-webkit-user-drag: none;
}
.dim-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0 0 8px;
padding: 8px 0 0;
border: none;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #4e5969;
cursor: pointer;
em {
font-style: normal;
font-size: 16px;
color: #86909c;
}
}
.dim-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 4px;
}
.dim-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
span {
font-size: 11px;
color: #86909c;
}
input {
width: 100%;
height: 36px;
border: 1px solid #e5e6eb;
border-radius: 8px;
padding: 0 8px;
font-size: 15px;
font-weight: 600;
color: #1d2129;
box-sizing: border-box;
outline: none;
&:focus {
border-color: color-mix(in srgb, var(--app-primary-color) 50%, #e5e6eb);
}
}
}
.dim-hint {
margin: 0 0 12px;
font-size: 11px;
color: #86909c;
line-height: 1.4;
}
.h5-ai-bar {
margin-bottom: 10px;
padding: 12px;
border-radius: 14px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.h5-ai-bar__head {
margin-bottom: 8px;
strong {
font-size: 13px;
font-weight: 700;
color: #1d2129;
}
}
.h5-ai-bar__row {
display: flex;
gap: 8px;
}
.h5-ai-bar__input {
flex: 1;
min-width: 0;
height: 40px;
border: 1px solid #e5e6eb;
border-radius: 10px;
padding: 0 12px;
font-size: 14px;
outline: none;
background: #fafbfc;
&:focus {
border-color: color-mix(in srgb, var(--app-primary-color) 45%, #e5e6eb);
background: #fff;
}
}
.h5-ai-bar__btn {
flex-shrink: 0;
height: 40px;
padding: 0 14px;
border: none;
border-radius: 10px;
background: var(--app-primary-color);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
&:disabled {
opacity: 0.55;
}
}
.h5-ai-insight {
margin-bottom: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid #e8edf2;
background: #fafbfc;
text-align: left;
&.is-ok {
border-color: color-mix(in srgb, #00b42a 28%, #e8edf2);
background: color-mix(in srgb, #00b42a 6%, #fff);
}
&.is-caution {
border-color: color-mix(in srgb, #ff7d00 28%, #e8edf2);
background: color-mix(in srgb, #ff7d00 6%, #fff);
}
&.is-forbid {
border-color: color-mix(in srgb, #f53f3f 28%, #e8edf2);
background: color-mix(in srgb, #f53f3f 6%, #fff);
}
}
.h5-ai-insight__top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.h5-ai-insight__verdict {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: #4e5969;
background: #fff;
.is-ok & {
color: #00b42a;
}
.is-caution & {
color: #ff7d00;
}
.is-forbid & {
color: #f53f3f;
}
}
.h5-ai-insight__cargo {
font-size: 12px;
color: #86909c;
}
.h5-ai-insight__reason {
margin: 0 0 6px;
font-size: 13px;
color: #1d2129;
line-height: 1.45;
}
.h5-ai-insight__evidence {
margin: 0 0 8px;
padding-left: 16px;
font-size: 12px;
color: #4e5969;
line-height: 1.45;
}
.h5-ai-insight__alts {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.h5-ai-alt {
border: 1px solid #e5e6eb;
background: #fff;
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
color: #4e5969;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h5-accuracy {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #fff7e8;
color: #ad6800;
font-size: 12px;
line-height: 1.45;
text-align: left;
}
.h5-accuracy__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.h5-accuracy__fill {
border: none;
border-radius: 8px;
padding: 6px 10px;
background: var(--app-primary-color);
color: #fff;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.h5-accuracy__dismiss {
border: none;
background: transparent;
color: #86909c;
font-size: 18px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.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;
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 {
position: relative;
z-index: 0;
overflow: hidden;
margin-bottom: 10px;
padding: 14px 14px 10px;
border-radius: 16px;
border: none;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
text-align: left;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
/* 推荐卡:允许盖章探出,整块内容统一左缩进对齐 */
&--top {
overflow: visible;
z-index: 1;
margin-top: 4px;
.h5-quote__main {
padding-left: 22px;
}
}
&--ai {
box-shadow:
0 0 0 1.5px color-mix(in srgb, var(--app-primary-color) 45%, transparent),
0 4px 14px color-mix(in srgb, var(--app-primary-color) 12%, transparent);
}
&:active {
transform: scale(0.992);
}
}
/* 推荐盖章:倾斜压在左上角 */
.h5-quote__stamp {
position: absolute;
z-index: 3;
top: -6px;
left: -8px;
padding: 5px 10px;
border: 2px solid var(--app-primary-color);
border-radius: 4px;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
line-height: 1;
white-space: nowrap;
transform: rotate(-18deg);
transform-origin: center center;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-primary-color) 35%, transparent) inset,
0 3px 8px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
pointer-events: none;
opacity: 0.92;
&::after {
content: '';
position: absolute;
inset: 2px;
border: 1px dashed color-mix(in srgb, var(--app-primary-color) 55%, transparent);
border-radius: 2px;
pointer-events: none;
}
}
.h5-quote__main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.h5-quote__head {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.h5-quote__title {
min-width: 0;
justify-self: start;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1d2129;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.h5-quote__sub {
margin: 6px 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__weight {
font-size: 11px;
color: #86909c;
line-height: 1.3;
white-space: nowrap;
}
.h5-quote__tag {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
&.is-price {
color: #f53f3f;
background: rgba(245, 63, 63, 0.1);
}
&.is-time {
color: #165dff;
background: rgba(22, 93, 255, 0.1);
}
}
.h5-quote__eta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 56px;
padding: 0 6px;
text-align: center;
justify-self: center;
}
.h5-quote__eta-value {
font-size: 18px;
font-weight: 800;
color: var(--app-primary-color);
line-height: 1.1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.h5-quote__eta-label {
font-size: 10px;
font-weight: 600;
color: #86909c;
line-height: 1.2;
}
.h5-quote__price-block {
text-align: right;
justify-self: end;
min-width: 0;
}
.ref-label {
display: block;
margin-top: 2px;
font-size: 10px;
color: #c9cdd4;
}
.h5-quote__price {
font-size: 20px;
font-weight: 700;
color: #f53f3f;
white-space: nowrap;
font-variant-numeric: tabular-nums;
line-height: 1.1;
small {
font-size: 12px;
margin-right: 1px;
}
}
.h5-quote__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(242, 243, 245, 0.85);
.h5-quote__chip--wh {
flex-shrink: 1;
min-width: 0;
}
}
.h5-quote__action-btns {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.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);
}
.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>