All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m0s
页面背景默认改为默认灰并同步启动屏;顶栏/登录头可回官网;线路卡片与话题面板顾问入口改为图标。 Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
11 KiB
Vue
331 lines
11 KiB
Vue
<template>
|
||
<aside
|
||
class="ai-routes-panel site-nav-sheet site-nav-sheet--routes"
|
||
id="ai-routes-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-label="copy.title"
|
||
>
|
||
<div class="ai-routes-panel__head">
|
||
<div class="ai-routes-panel__intro">
|
||
<h3 class="ai-routes-panel__title">{{ copy.title }}</h3>
|
||
<p class="ai-routes-panel__sub">{{ copy.sub }}</p>
|
||
</div>
|
||
<button type="button" class="ai-routes-panel__close" :aria-label="isZh ? '关闭' : 'Close'" @click="$emit('close')">
|
||
<i class="si si-x-lg" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<div class="ai-routes-panel__tools">
|
||
<div class="ai-routes-dests" role="tablist" :aria-label="copy.destLabel">
|
||
<button
|
||
v-for="dest in ROUTE_DEST_PRESETS"
|
||
:key="dest.id"
|
||
type="button"
|
||
class="ai-routes-chip"
|
||
:class="{ 'is-active': destFilter === dest.id }"
|
||
role="tab"
|
||
:aria-selected="destFilter === dest.id"
|
||
@click="setDest(dest.id)"
|
||
>
|
||
{{ isZh ? dest.label.zh : dest.label.en }}
|
||
</button>
|
||
</div>
|
||
<div class="ai-routes-toolrow">
|
||
<div class="ai-routes-groups" role="tablist">
|
||
<button
|
||
v-for="chip in groupChips"
|
||
:key="chip.code"
|
||
type="button"
|
||
class="ai-routes-chip"
|
||
:class="{ 'is-active': groupFilter === chip.code }"
|
||
role="tab"
|
||
:aria-selected="groupFilter === chip.code"
|
||
@click="setGroup(chip.code)"
|
||
>
|
||
{{ chip.name }}
|
||
</button>
|
||
</div>
|
||
<div class="ai-routes-terms" role="tablist">
|
||
<button
|
||
v-for="chip in termChips"
|
||
:key="chip.code"
|
||
type="button"
|
||
class="ai-routes-chip"
|
||
:class="{ 'is-active': termsFilter === chip.code }"
|
||
role="tab"
|
||
:aria-selected="termsFilter === chip.code"
|
||
@click="setTerms(chip.code)"
|
||
>
|
||
{{ chip.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<label class="ai-routes-search">
|
||
<i class="si si-search" aria-hidden="true"></i>
|
||
<input
|
||
v-model="search"
|
||
type="search"
|
||
class="ai-routes-search__input"
|
||
autocomplete="off"
|
||
:placeholder="copy.searchPh"
|
||
/>
|
||
</label>
|
||
<div class="ai-routes-toolbar">
|
||
<p v-if="metaText" class="ai-routes-meta">{{ metaText }}</p>
|
||
<button type="button" class="ai-routes-sticky" @click="askDirect">{{ stickyLabel }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="ai-routes-panel__body">
|
||
<div v-if="loadState === 'loading'" class="ai-routes-state">
|
||
<div class="ai-routes-skeleton"></div>
|
||
<div class="ai-routes-skeleton"></div>
|
||
<div class="ai-routes-skeleton"></div>
|
||
<p class="ai-routes-state__text">{{ copy.loading }}</p>
|
||
</div>
|
||
<div v-else-if="loadState === 'error'" class="ai-routes-state">
|
||
<p class="ai-routes-state__title">{{ copy.error }}</p>
|
||
<p class="ai-routes-state__text">{{ copy.errorHint }}</p>
|
||
<div class="ai-routes-state__actions">
|
||
<button type="button" class="ai-routes-retry" @click="reload">{{ copy.retry }}</button>
|
||
<button type="button" class="ai-routes-ghost" @click="askDirect">{{ copy.askDirect }}</button>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="!filtered.length" class="ai-routes-state">
|
||
<p class="ai-routes-state__title">{{ copy.empty }}</p>
|
||
<p class="ai-routes-state__text">{{ copy.emptyHint }}</p>
|
||
<div class="ai-routes-state__actions">
|
||
<button v-if="hasFilters" type="button" class="ai-routes-retry" @click="clearFilters">
|
||
{{ copy.clearFilters }}
|
||
</button>
|
||
<button type="button" class="ai-routes-ghost" @click="askDirect">{{ copy.askDirect }}</button>
|
||
</div>
|
||
</div>
|
||
<template v-else>
|
||
<article v-for="item in visibleItems" :key="String(item.id)" class="ai-route-card">
|
||
<div class="ai-route-card__head">
|
||
<div class="ai-route-card__titles">
|
||
<span
|
||
v-if="item.productGroupName || item.productGroup"
|
||
class="ai-route-card__group"
|
||
>{{ item.productGroupName || item.productGroup }}</span>
|
||
<h4 class="ai-route-card__name">{{ item.productName }}</h4>
|
||
</div>
|
||
<p v-if="formatRouteTimeliness(item)" class="ai-route-card__eta">
|
||
{{ formatRouteTimeliness(item) }} {{ copy.days }}
|
||
</p>
|
||
</div>
|
||
<p class="ai-route-card__meta">
|
||
<span>{{ routeCoverageLabel(item, lang) }}</span>
|
||
<span v-if="item.tradeTerms"> · {{ item.tradeTerms }}</span>
|
||
<span v-if="item.tradeTerms === 'DDP'"> · {{ copy.ddpHint }}</span>
|
||
<span v-else-if="item.tradeTerms === 'DDU'"> · {{ copy.dduHint }}</span>
|
||
</p>
|
||
<p class="ai-route-card__cargo">{{ routeCargoHint(item, catalog, lang) }}</p>
|
||
<p
|
||
v-if="item.productDescription"
|
||
class="ai-route-card__desc"
|
||
:class="{ 'is-expanded': expanded[String(item.id)] }"
|
||
>
|
||
{{ item.productDescription }}
|
||
</p>
|
||
<button
|
||
v-if="item.productDescription.length > 72"
|
||
type="button"
|
||
class="ai-route-card__more"
|
||
@click="toggleExpand(item.id)"
|
||
>
|
||
{{ expanded[String(item.id)] ? copy.collapse : copy.expand }}
|
||
</button>
|
||
<div class="ai-route-card__actions">
|
||
<a class="ai-route-card__reg" :href="officialLoginUrl(destLabel(destFilter, lang) || undefined)">
|
||
{{ copy.register }}
|
||
</a>
|
||
<button
|
||
type="button"
|
||
class="ai-route-card__ask"
|
||
:title="copy.ask"
|
||
:aria-label="copy.ask"
|
||
@click="askItem(item)"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path
|
||
d="M4 14.5V12a8 8 0 0 1 16 0v2.5"
|
||
stroke="currentColor"
|
||
stroke-width="1.8"
|
||
stroke-linecap="round"
|
||
/>
|
||
<path
|
||
d="M4 14h2.8v5.2H4A1.6 1.6 0 0 1 2.4 17.6v-2A1.6 1.6 0 0 1 4 14z"
|
||
stroke="currentColor"
|
||
stroke-width="1.8"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path
|
||
d="M20 14h-2.8v5.2H20a1.6 1.6 0 0 0 1.6-1.6v-2A1.6 1.6 0 0 0 20 14z"
|
||
stroke="currentColor"
|
||
stroke-width="1.8"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</article>
|
||
<div v-if="visibleItems.length < filtered.length" class="ai-routes-more">
|
||
<button type="button" class="ai-routes-more__btn" @click="visibleCount += ROUTE_PAGE_SIZE">
|
||
{{ copy.loadMore }}({{ filtered.length - visibleItems.length }})
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</aside>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { officialLoginUrl } from './lib/site-urls';
|
||
import {
|
||
ROUTE_DEST_PRESETS,
|
||
ROUTE_PAGE_SIZE,
|
||
ROUTES_COPY,
|
||
buildDirectAskText,
|
||
buildRouteAskText,
|
||
destLabel,
|
||
fetchRoutesCatalog,
|
||
filterRouteItems,
|
||
formatRouteTimeliness,
|
||
routeCargoHint,
|
||
routeCoverageLabel,
|
||
type RoutesCatalog,
|
||
type RouteItem,
|
||
} from './lib/portal-catalog';
|
||
|
||
const props = defineProps<{
|
||
isZh: boolean;
|
||
destId?: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
close: [];
|
||
ask: [payload: { text: string; intentId: string }];
|
||
}>();
|
||
|
||
const lang = computed(() => (props.isZh ? 'zh' : 'en') as const);
|
||
const copy = computed(() => ROUTES_COPY[lang.value]);
|
||
const catalog = ref<RoutesCatalog | null>(null);
|
||
const loadState = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||
const destFilter = ref(props.destId || 'all');
|
||
const groupFilter = ref('all');
|
||
const termsFilter = ref('all');
|
||
const search = ref('');
|
||
const visibleCount = ref(ROUTE_PAGE_SIZE);
|
||
const expanded = ref<Record<string, boolean>>({});
|
||
|
||
const groupChips = computed(() => {
|
||
const groups = (catalog.value?.groups || []).map((g) => ({ code: g.code, name: g.name || g.code }));
|
||
return [{ code: 'all', name: copy.value.all }].concat(groups);
|
||
});
|
||
|
||
const termChips = computed(() => [
|
||
{ code: 'all', name: copy.value.termsAll },
|
||
{ code: 'DDP', name: 'DDP' },
|
||
{ code: 'DDU', name: 'DDU' },
|
||
]);
|
||
|
||
const filtered = computed(() =>
|
||
filterRouteItems(catalog.value, {
|
||
dest: destFilter.value,
|
||
group: groupFilter.value,
|
||
terms: termsFilter.value,
|
||
search: search.value.trim(),
|
||
lang: lang.value,
|
||
}),
|
||
);
|
||
|
||
const visibleItems = computed(() => filtered.value.slice(0, visibleCount.value));
|
||
|
||
const hasFilters = computed(
|
||
() =>
|
||
destFilter.value !== 'all' ||
|
||
groupFilter.value !== 'all' ||
|
||
termsFilter.value !== 'all' ||
|
||
!!search.value.trim(),
|
||
);
|
||
|
||
const metaText = computed(() => {
|
||
if (loadState.value !== 'ready') return '';
|
||
const total = filtered.value.length;
|
||
const shown = visibleItems.value.length;
|
||
if (shown < total) {
|
||
return copy.value.matchShown.replace('{shown}', String(shown)).replace('{total}', String(total));
|
||
}
|
||
return copy.value.matchCount.replace('{n}', String(total));
|
||
});
|
||
|
||
const stickyLabel = computed(() => {
|
||
const dest = destLabel(destFilter.value, lang.value);
|
||
if (!dest) return copy.value.sticky;
|
||
return props.isZh ? '不确定?让顾问推荐发往' + dest + '的线路' : 'Not sure? Ask an advisor for lanes to ' + dest;
|
||
});
|
||
|
||
function resetPage() {
|
||
visibleCount.value = ROUTE_PAGE_SIZE;
|
||
}
|
||
|
||
function setDest(id: string) {
|
||
destFilter.value = id;
|
||
resetPage();
|
||
}
|
||
|
||
function setGroup(code: string) {
|
||
groupFilter.value = code;
|
||
resetPage();
|
||
}
|
||
|
||
function setTerms(code: string) {
|
||
termsFilter.value = code;
|
||
resetPage();
|
||
}
|
||
|
||
function clearFilters() {
|
||
destFilter.value = 'all';
|
||
groupFilter.value = 'all';
|
||
termsFilter.value = 'all';
|
||
search.value = '';
|
||
resetPage();
|
||
}
|
||
|
||
function toggleExpand(id: string | number) {
|
||
const key = String(id);
|
||
expanded.value = { ...expanded.value, [key]: !expanded.value[key] };
|
||
}
|
||
|
||
function askItem(item: RouteItem) {
|
||
emit('ask', { text: buildRouteAskText(item, destFilter.value, lang.value), intentId: 'quote' });
|
||
}
|
||
|
||
function askDirect() {
|
||
emit('ask', { text: buildDirectAskText(destFilter.value, lang.value), intentId: 'quote' });
|
||
}
|
||
|
||
async function reload() {
|
||
loadState.value = 'loading';
|
||
try {
|
||
catalog.value = await fetchRoutesCatalog(lang.value);
|
||
loadState.value = 'ready';
|
||
if (groupFilter.value !== 'all' && !catalog.value.groups.some((g) => g.code === groupFilter.value)) {
|
||
groupFilter.value = 'all';
|
||
}
|
||
resetPage();
|
||
} catch {
|
||
catalog.value = null;
|
||
loadState.value = 'error';
|
||
}
|
||
}
|
||
|
||
watch(search, () => resetPage());
|
||
|
||
onMounted(() => {
|
||
void reload();
|
||
});
|
||
</script>
|