front/src/views/site/SiteRoutesPanel.vue
zzh 07b71ca565
Some checks failed
Build and Deploy Front / deploy (push) Has been cancelled
feat: 官网纸面对话与登录翻页过渡,波浪更稳,并同步面单打印优化
欢迎区/侧栏/聊天槽统一暖纸拟物;跟进改仓储代发;波浪降扰动关视差减少眩晕。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 13:33:00 +08:00

365 lines
12 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">
<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
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>
</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 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 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 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());
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count;
});
onMounted(() => {
void reload();
});
</script>