front/src/views/site/SiteRoutesPanel.vue
zzh 9922d20141
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m0s
feat: 默认页面背景联动加载页,官网入口与线路面板体验优化
页面背景默认改为默认灰并同步启动屏;顶栏/登录头可回官网;线路卡片与话题面板顾问入口改为图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:08:57 +08:00

331 lines
11 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>
<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>