front/src/views/site/SiteRoutesPanel.vue
zzh eb12f9fb9e
Some checks failed
Build and Deploy Front / deploy (push) Has been cancelled
feat: 官网手机端适配、EventBus 菜单与图标扩展
同步 H5/登录回官网、仓库 PeekModal、运单状态展示与菜单图标目录。

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

347 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>
<nav
v-if="loadState === 'ready' && filtered.length > 0 && pageCount > 1"
class="ai-routes-pager"
:aria-label="isZh ? '线路分页' : 'Route pagination'"
>
<button
type="button"
class="ai-routes-pager__btn"
:disabled="currentPage <= 1"
:aria-label="isZh ? '上一页' : 'Previous page'"
@click="goPage(currentPage - 1)"
>
‹
</button>
<span class="ai-routes-pager__label">{{ currentPage }} / {{ pageCount }}</span>
<button
type="button"
class="ai-routes-pager__btn"
:disabled="currentPage >= pageCount"
:aria-label="isZh ? '下一页' : 'Next page'"
@click="goPage(currentPage + 1)"
>
›
</button>
</nav>
<button type="button" class="ai-routes-sticky" @click="askDirect">{{ stickyLabel }}</button>
</div>
</div>
<div ref="bodyRef" 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 pageItems" :key="String(item.id)" class="ai-route-card">
<div class="ai-route-card__head">
<div class="ai-route-card__titles">
<h4 class="ai-route-card__name">{{ item.productName }}</h4>
<span
v-if="item.productGroupName || item.productGroup"
class="ai-route-card__group"
>{{ item.productGroupName || item.productGroup }}</span>
</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 v-if="item.productDescription" class="ai-route-card__desc">
{{ item.productDescription }}
</p>
<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>
</template>
</div>
</aside>
</template>
<script lang="ts" setup>
import { computed, nextTick, 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,
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 currentPage = ref(1);
const bodyRef = ref<HTMLElement | null>(null);
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 pageCount = computed(() =>
Math.max(1, Math.ceil(filtered.value.length / ROUTE_PAGE_SIZE) || 1),
);
const pageItems = computed(() => {
const start = (currentPage.value - 1) * ROUTE_PAGE_SIZE;
return filtered.value.slice(start, start + ROUTE_PAGE_SIZE);
});
const hasFilters = computed(
() =>
destFilter.value !== 'all' ||
groupFilter.value !== 'all' ||
termsFilter.value !== 'all' ||
!!search.value.trim(),
);
const metaText = computed(() => {
if (loadState.value !== 'ready') return '';
return copy.value.matchCount.replace('{n}', String(filtered.value.length));
});
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() {
currentPage.value = 1;
}
function goPage(page: number) {
const next = Math.min(pageCount.value, Math.max(1, page));
if (next === currentPage.value) return;
currentPage.value = next;
void nextTick(() => {
if (bodyRef.value) bodyRef.value.scrollTop = 0;
});
}
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 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());
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count;
});
onMounted(() => {
void reload();
});
</script>