feat: 新增路线详情关联管家顾问及相关功能

详细变更如下:
- 更新 .gitignore 文件,添加 pnpm-store 忽略规则
- 新增数据库迁移脚本,为 DetailRecord 添加可空的 conciergeAdvisorId 字段用于关联管家顾问
- 完善 Admin 后台玩法详情编辑器,支持选择关联的管家顾问并校验合法性
- 公共 API 支持返回已启用的管家顾问完整数据,不在详情表中冗余存储管家资料
- 小程序端新增详情页联系管家入口、个人页最近浏览历史功能
- 更新所有相关文档与测试用例,修复下拉选择框的 z-index 样式问题
This commit is contained in:
duanshuwen committed 2026-08-22 08:26:23 +08:00
1 parent e7b8c5bf43
commit b7a81964c9
34 files changed
+739 -63

No files matched your search

+8
View File
@@ -36,6 +36,14 @@ export function goWildArchives() {
go("/pages/wild-archives/index");
}
export function goPlay() {
uni.switchTab({ url: "/pages/play/index" });
}
export function goConcierge() {
uni.switchTab({ url: "/pages/concierge/index" });
}
export function goWildArchiveDetail(archiveId: string) {
go("/pages/wild-archives/detail", { id: archiveId });
}
+91
View File
@@ -0,0 +1,91 @@
export const RECENTLY_VIEWED_KEY = "miniapp:recently-viewed";
export const RECENTLY_VIEWED_LIMIT = 5;
export type RecentlyViewedType = "wanfa-route" | "team-building" | "wild-archive";
export type RecentlyViewedItem = {
type: RecentlyViewedType;
id: string;
title: string;
image: string;
visitedAt: number;
};
const recentlyViewedTypes = new Set<RecentlyViewedType>([
"wanfa-route",
"team-building",
"wild-archive",
]);
function isRecentlyViewedType(value: unknown): value is RecentlyViewedType {
return typeof value === "string" && recentlyViewedTypes.has(value as RecentlyViewedType);
}
function normalizeRecentlyViewedItem(value: unknown): RecentlyViewedItem | null {
if (!value || typeof value !== "object") return null;
const item = value as Partial<RecentlyViewedItem>;
const type = isRecentlyViewedType(item.type) ? item.type : null;
const id = typeof item.id === "string" ? item.id.trim() : "";
const title = typeof item.title === "string" ? item.title.trim() : "";
const image = typeof item.image === "string" ? item.image.trim() : "";
const visitedAt = typeof item.visitedAt === "number" && Number.isFinite(item.visitedAt)
? item.visitedAt
: null;
if (!type || !id || !title || visitedAt === null) return null;
return { type, id, title, image, visitedAt };
}
export function normalizeRecentlyViewed(value: unknown): RecentlyViewedItem[] {
if (!Array.isArray(value)) return [];
const unique = new Map<string, RecentlyViewedItem>();
for (const candidate of value) {
const item = normalizeRecentlyViewedItem(candidate);
if (!item) continue;
const existing = unique.get(item.id);
if (!existing || item.visitedAt > existing.visitedAt) {
unique.set(item.id, item);
}
}
return [...unique.values()]
.sort((left, right) => right.visitedAt - left.visitedAt)
.slice(0, RECENTLY_VIEWED_LIMIT);
}
export function upsertRecentlyViewed(
current: unknown,
item: RecentlyViewedItem,
): RecentlyViewedItem[] {
return normalizeRecentlyViewed([item, ...(Array.isArray(current) ? current : [])]);
}
export function getRecentlyViewed(): RecentlyViewedItem[] {
try {
return normalizeRecentlyViewed(uni.getStorageSync(RECENTLY_VIEWED_KEY));
} catch {
return [];
}
}
export function rememberRecentlyViewed(item: RecentlyViewedItem): RecentlyViewedItem[] {
const next = upsertRecentlyViewed(getRecentlyViewed(), item);
try {
uni.setStorageSync(RECENTLY_VIEWED_KEY, next);
} catch {
// Storage can be unavailable in strict privacy or emulator modes.
}
return next;
}
export function clearRecentlyViewed() {
try {
uni.removeStorageSync(RECENTLY_VIEWED_KEY);
} catch {
// Storage can be unavailable in strict privacy or emulator modes.
}
}
+1
View File
@@ -135,6 +135,7 @@ export type PublicDetail = {
excluded: string[];
notes: string[];
gallery: string[];
conciergeAdvisor: PublicConciergeAdvisor | null;
};
export type PublicConciergeDetail = {
@@ -25,6 +25,14 @@ export function saveWechatProfile(profile: WechatProfile) {
uni.setStorageSync(WECHAT_PROFILE_KEY, profile);
}
export function clearWechatProfile() {
try {
uni.removeStorageSync(WECHAT_PROFILE_KEY);
} catch {
// Storage can be unavailable in strict privacy or emulator modes.
}
}
export function requestWechatProfile(): Promise<WechatProfile | null> {
return new Promise((resolve) => {
if (typeof uni.getUserProfile !== "function") {
@@ -19,27 +19,31 @@ function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeConciergeAdvisor(value: unknown): ConciergeAdvisor | null {
if (!isRecord(value)) return null;
const avatar = normalizeText(value.avatar);
const name = normalizeText(value.name);
const role = normalizeText(value.role);
const qrImage = normalizeText(value.qrImage);
if (!avatar || !name || !role || !qrImage) return null;
const details = Array.isArray(value.details)
? value.details.flatMap((detail) => {
if (!isRecord(detail)) return [];
const icon = normalizeText(detail.icon);
const label = normalizeText(detail.label);
return icon && label ? [{ icon, label }] : [];
})
: [];
return { avatar, name, role, details, qrImage };
}
export function normalizeConciergeAdvisors(value: unknown): ConciergeAdvisor[] {
if (!isRecord(value) || !Array.isArray(value.advisors)) return [];
return value.advisors.flatMap((item) => {
if (!isRecord(item)) return [];
const avatar = normalizeText(item.avatar);
const name = normalizeText(item.name);
const role = normalizeText(item.role);
const qrImage = normalizeText(item.qrImage);
if (!avatar || !name || !role || !qrImage) return [];
const details = Array.isArray(item.details)
? item.details.flatMap((detail) => {
if (!isRecord(detail)) return [];
const icon = normalizeText(detail.icon);
const label = normalizeText(detail.label);
return icon && label ? [{ icon, label }] : [];
})
: [];
return [{ avatar, name, role, details, qrImage }];
const advisor = normalizeConciergeAdvisor(item);
return advisor ? [advisor] : [];
});
}
@@ -28,6 +28,7 @@
price }}万</text>
</view>
<view
v-if="showConcierge"
class="tap-feedback flex items-center justify-center m-0 h-11 rounded-full bg-[#e96635] px-4 text-[13px] font-semibold text-white shadow-[0_6px_16px_rgba(233,102,53,0.22)]"
@click="$emit('concierge')">联系管家</view>
</view>
@@ -38,10 +39,13 @@
<script setup lang="ts">
import { formatWan } from "@/lib/data";
defineProps<{
withDefaults(defineProps<{
price: number;
favorite: boolean;
}>();
showConcierge?: boolean;
}>(), {
showConcierge: true,
});
defineEmits<{
call: [];
@@ -1,5 +1,6 @@
import { playCategories, type PlayRoute } from "../../play/components/playData";
import type { PublicDetail } from "@/lib/types";
import { normalizeConciergeAdvisor } from "../../concierge/components/conciergeTypes";
export type DetailPresentation = PublicDetail;
@@ -30,6 +31,7 @@ function createRouteFallback(route: PlayRoute): DetailPresentation {
"最终行程以出行日期、人数和资源确认结果为准。",
],
gallery: Array.from(new Set([route.image, ...sharedGallery])).slice(0, 5),
conciergeAdvisor: null,
};
}
@@ -75,6 +77,7 @@ export function normalizeDetailPresentation(
const subtitle = cleanText(source.subtitle) || fallback?.subtitle || "";
const intro = cleanText(source.intro) || fallback?.intro || "";
const gallery = cleanGallery(source.gallery);
const conciergeAdvisor = normalizeConciergeAdvisor(source.conciergeAdvisor);
if (!key || !eyebrow || !duration || !title || !subtitle || !intro) {
return fallback;
@@ -92,5 +95,6 @@ export function normalizeDetailPresentation(
excluded: cleanList(source.excluded).length ? cleanList(source.excluded) : fallback?.excluded ?? [],
notes: cleanList(source.notes).length ? cleanList(source.notes) : fallback?.notes ?? [],
gallery: gallery.length ? gallery : fallback?.gallery ?? [],
conciergeAdvisor,
};
}
+24 -13
View File
@@ -42,7 +42,13 @@
</view>
</scroll-view>
<DetailActionBar :price="1.68" :favorite="false" @call="goCall" @concierge="goConcierge" />
<DetailActionBar
:price="1.68"
:favorite="false"
:show-concierge="Boolean(presentation?.conciergeAdvisor)"
@call="goCall"
@concierge="goConcierge"
/>
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor" @close="handleConciergeClosed" />
</view>
@@ -54,6 +60,7 @@ import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
import { fetchPublicDetail } from "@/lib/api";
import { goBack } from "@/lib/navigation";
import { rememberRecentlyViewed } from "@/lib/recentlyViewed";
import { SUPPORT_PHONE } from "@/lib/types";
import type { ConciergeAdvisor } from "../concierge/components/conciergeTypes";
import DetailActionBar from "./components/DetailActionBar.vue";
@@ -74,17 +81,6 @@ const usingFallback = ref(false);
const errorMessage = ref("");
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
const conciergeAdvisor: ConciergeAdvisor = {
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&fm=jpg&q=80&w=240&h=240",
name: "Amanda",
role: "SENIOR TRAVEL ADVISOR",
qrImage: "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=WonderQ%20Concierge",
details: [
{ icon: "calendar", label: "服务经验:8年" },
{ icon: "navigate", label: "擅长领域:瀑降 / 探洞" },
],
};
const heroImage = computed(() => presentation.value?.gallery[0] ?? "");
const priceParagraphs = computed(() => ["最终价格会根据出行日期、人数和资源确认结果生成。"]);
@@ -125,16 +121,31 @@ async function loadDetail() {
usingFallback.value = Boolean(fallback);
errorMessage.value = cause instanceof Error ? cause.message : "路线详情接口暂时不可用。";
} finally {
rememberCurrentDetail();
loading.value = false;
}
}
function rememberCurrentDetail() {
const current = presentation.value;
if (!current || !routeId.value) return;
rememberRecentlyViewed({
type: "wanfa-route",
id: routeId.value,
title: current.title,
image: current.gallery[0] ?? "",
visitedAt: Date.now(),
});
}
function goCall() {
uni.makePhoneCall({ phoneNumber: SUPPORT_PHONE });
}
function goConcierge() {
selectedAdvisor.value = conciergeAdvisor;
const advisor = presentation.value?.conciergeAdvisor;
if (advisor) selectedAdvisor.value = advisor;
}
function handleConciergeClosed() {
@@ -1,6 +1,6 @@
<template>
<view class="flex h-screen flex-col overflow-hidden bg-[#f5f2ed]">
<view class="relative h-[52%] min-h-[300px] overflow-hidden rounded-b-[24px] bg-[#211e1c]">
<view class="min-h-screen bg-[#f5f2ed]">
<view class="relative h-[32vh] min-h-[230px] max-h-[320px] overflow-hidden rounded-b-[24px] bg-[#211e1c]">
<image class="absolute inset-0 h-full w-full opacity-75"
src="https://images.unsplash.com/photo-1774623703220-b3b5e6d3cedf?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=1800"
mode="aspectFill" />
@@ -17,7 +17,7 @@
</view>
</view>
<view
class="flex min-h-0 flex-1 flex-col items-stretch px-6 pb-[calc(12px+env(safe-area-inset-bottom))] pt-6">
class="px-6 pb-[calc(28px+env(safe-area-inset-bottom))] pt-6">
<view
class="w-full rounded-[22px] border border-[#ded6cd] bg-white/95 px-4 py-4 shadow-[0_12px_30px_rgba(80,58,45,0.08)]">
<view class="flex items-center gap-3">
@@ -48,33 +48,83 @@
<text v-else-if="avatarUrl" class="mt-3 block text-[11px] leading-5 text-[#9a9189]">头像仅保存在当前设备</text>
<text v-else class="mt-3 block text-[11px] leading-5 text-[#9a9189]">微信头像可在授权后显示</text>
</view>
<button
class="m-0 mt-5 flex min-h-[58px] w-full items-center gap-3 rounded-[16px] bg-[#a45a3d] px-4 text-left text-white shadow-[0_8px_20px_rgba(164,90,61,0.16)]"
@click="emit('open-concierge')"
>
<view class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
<uni-icons type="headphones" :size="18" color="#fffaf5" />
</view>
<view class="min-w-0 flex-1">
<text class="block text-[14px] font-semibold">联系管家</text>
<text class="mt-1 block text-[10px] leading-4 text-white/75">获取适合你的出行建议</text>
</view>
<uni-icons type="right" :size="16" color="#fffaf5" />
</button>
<MineRecentViews
:items="props.recentViews"
@select="(item) => emit('select-recent', item)"
@explore="emit('open-play')"
/>
<view class="mt-6 rounded-[16px] border border-[#e2d9d0] bg-white px-4 py-4 shadow-[0_8px_20px_rgba(80,58,45,0.04)]">
<text class="block text-[14px] font-semibold text-[#2b2724]">账号操作</text>
<text class="mt-1 block text-[11px] leading-5 text-[#9a9189]">退出登录会清除本机最近浏览记录</text>
<button
class="m-0 mt-4 flex min-h-[42px] w-full items-center justify-center rounded-full border border-[#d9c7bb] bg-[#fffaf5] px-4 text-[12px] font-medium tracking-[0.04em] text-[#8c4f39]"
@click="emit('logout')"
>
<uni-icons type="forward" :size="16" color="#8c4f39" />
<text class="ml-1">退出登录</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import type { RecentlyViewedItem } from "@/lib/recentlyViewed";
import MineRecentViews from "./MineRecentViews.vue";
withDefaults(
const props = withDefaults(
defineProps<{
customerLine: string;
customerName?: string;
avatarUrl?: string;
showAvatarAction?: boolean;
recentViews?: RecentlyViewedItem[];
}>(),
{
customerName: "万趣会员",
avatarUrl: "",
showAvatarAction: false,
recentViews: () => [],
},
);
const emit = defineEmits<{
"request-avatar": [];
"open-concierge": [];
"open-play": [];
"select-recent": [item: RecentlyViewedItem];
logout: [];
}>();
const menuButtonTop = ref<number | null>(null);
const brandPositionStyle = computed(() =>
menuButtonTop.value === null ? {} : { top: `${menuButtonTop.value}px` },
);
function syncMenuButtonPosition() {
try {
menuButtonTop.value = uni.getMenuButtonBoundingClientRect().top;
} catch {
menuButtonTop.value = null;
}
}
onMounted(syncMenuButtonPosition);
</script>
@@ -0,0 +1,66 @@
<template>
<view class="mt-6">
<view class="mb-3 flex items-end justify-between">
<view>
<text class="block text-[18px] font-semibold tracking-[0.02em] text-[#24211f]">继续探索</text>
<text class="mt-1 block text-[11px] leading-5 text-[#9a9189]">最近浏览的内容</text>
</view>
<text v-if="items.length" class="text-[11px] text-[#b18a76]">{{ items.length }} 条记录</text>
</view>
<view v-if="items.length" class="overflow-hidden rounded-[16px] border border-[#e2d9d0] bg-white shadow-[0_8px_20px_rgba(80,58,45,0.05)]">
<button
v-for="(item, index) in items"
:key="`${item.type}-${item.id}`"
class="m-0 flex w-full items-center gap-3 px-3 py-3 text-left"
:class="index < items.length - 1 ? 'border-b border-[#eee7e0]' : ''"
@click="emit('select', item)"
>
<view class="h-14 w-16 shrink-0 overflow-hidden rounded-[10px] bg-[#f1ece6]">
<image v-if="item.image" class="h-full w-full" :src="item.image" mode="aspectFill" />
<view v-else class="flex h-full w-full items-center justify-center">
<uni-icons type="image" :size="20" color="#b8aaa0" />
</view>
</view>
<view class="min-w-0 flex-1">
<text class="block truncate text-[14px] font-medium text-[#2b2724]">{{ item.title }}</text>
<text class="mt-1 block text-[10px] leading-4 text-[#a19890]">{{ typeLabel(item.type) }}</text>
</view>
<uni-icons type="right" :size="15" color="#b3a79e" />
</button>
</view>
<view v-else class="rounded-[16px] border border-dashed border-[#d8cec5] bg-white/70 px-5 py-6 text-center">
<view class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-[#f4eee8]">
<uni-icons type="eye" :size="20" color="#a45a3d" />
</view>
<text class="mt-3 block text-[14px] font-medium text-[#4a413a]">暂无浏览记录</text>
<text class="mt-1 block text-[11px] leading-5 text-[#9a9189]">去看看玩法,收藏下一段旅程</text>
<button
class="m-0 mx-auto mt-4 flex min-h-[36px] items-center justify-center rounded-full bg-[#a45a3d] px-5 text-[12px] font-medium text-white"
@click="emit('explore')"
>
去看看玩法
</button>
</view>
</view>
</template>
<script setup lang="ts">
import type { RecentlyViewedItem, RecentlyViewedType } from "@/lib/recentlyViewed";
defineProps<{
items: RecentlyViewedItem[];
}>();
const emit = defineEmits<{
select: [item: RecentlyViewedItem];
explore: [];
}>();
function typeLabel(type: RecentlyViewedType) {
if (type === "wanfa-route") return "玩法路线";
if (type === "team-building") return "团队共创";
return "客片案例";
}
</script>
+45 -2
View File
@@ -8,7 +8,12 @@
:customer-name="wechatProfile?.nickName || '万趣会员'"
:avatar-url="wechatProfile?.avatarUrl"
:show-avatar-action="isWechatMiniProgram"
@request-avatar="loadWechatProfile" />
:recent-views="recentViews"
@request-avatar="loadWechatProfile"
@open-concierge="goConcierge"
@open-play="goPlay"
@select-recent="openRecent"
@logout="logout" />
</scroll-view>
</view>
</view>
@@ -17,9 +22,22 @@
<script setup lang="ts">
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getAuthSession, loginWithPhoneCode } from "@/lib/auth";
import { clearAuthSession, getAuthSession, loginWithPhoneCode } from "@/lib/auth";
import {
clearRecentlyViewed,
getRecentlyViewed,
type RecentlyViewedItem,
} from "@/lib/recentlyViewed";
import {
goConcierge,
goPlay,
goTeamBuildingDetail,
goWanfaRouteDetail,
goWildArchiveDetail,
} from "@/lib/navigation";
import { loadAppData } from "@/lib/store";
import {
clearWechatProfile,
getStoredWechatProfile,
requestWechatProfile,
saveWechatProfile,
@@ -34,9 +52,11 @@ const wechatProfile = ref(getStoredWechatProfile());
const isWechatMiniProgram = ref(false);
const isLoggingIn = ref(false);
const isLoadingWechatProfile = ref(false);
const recentViews = ref<RecentlyViewedItem[]>([]);
onShow(() => {
session.value = getAuthSession();
wechatProfile.value = getStoredWechatProfile();
recentViews.value = getRecentlyViewed();
try {
isWechatMiniProgram.value = uni.getSystemInfoSync().uniPlatform === "mp-weixin";
} catch {
@@ -101,4 +121,27 @@ async function loadWechatProfile() {
uni.hideLoading();
}
}
function openRecent(item: RecentlyViewedItem) {
if (item.type === "wanfa-route") {
goWanfaRouteDetail(item.id);
return;
}
if (item.type === "team-building") {
goTeamBuildingDetail(item.id);
return;
}
goWildArchiveDetail(item.id);
}
function logout() {
clearAuthSession();
clearWechatProfile();
clearRecentlyViewed();
session.value = null;
wechatProfile.value = null;
recentViews.value = [];
customerLine.value = "登录后管理头像与手机号,继续探索旅程。";
uni.showToast({ title: "已退出登录", icon: "none" });
}
</script>
@@ -42,6 +42,7 @@ import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { fetchPublicTeamBuildingDetail } from "@/lib/api";
import { goBack } from "@/lib/navigation";
import { rememberRecentlyViewed } from "@/lib/recentlyViewed";
import {
homeTeamBuildingFallback,
normalizeHomeTeamBuildingDetail,
@@ -63,10 +64,24 @@ async function loadDetail(teamBuildingId: string) {
detail.value = fallback ? { ...fallback, detailParagraphs: [...fallback.detailParagraphs] } : null;
if (detail.value) fallbackNotice.value = "接口暂不可用,当前展示模拟数据";
} finally {
rememberCurrentDetail(teamBuildingId);
loading.value = false;
}
}
function rememberCurrentDetail(teamBuildingId: string) {
const current = detail.value;
if (!current || !teamBuildingId) return;
rememberRecentlyViewed({
type: "team-building",
id: teamBuildingId,
title: current.title,
image: current.image,
visitedAt: Date.now(),
});
}
onLoad((query) => {
const teamBuildingId = typeof query?.id === "string" ? query.id : "";
if (!teamBuildingId) {
@@ -33,6 +33,7 @@ import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { fetchPublicWildArchiveDetail } from "@/lib/api";
import { goBack } from "@/lib/navigation";
import { rememberRecentlyViewed } from "@/lib/recentlyViewed";
import { homeWildArchiveFallback, normalizeHomeWildArchiveDetail, type HomeWildArchive } from "@/pages/home/components/homeWildArchivesData";
const detail = ref<HomeWildArchive | null>(null);
@@ -50,10 +51,24 @@ async function loadDetail(archiveId: string) {
detail.value = fallback ? { ...fallback, images: [...fallback.images] } : null;
if (detail.value) fallbackNotice.value = "接口暂不可用,当前展示模拟数据";
} finally {
rememberCurrentDetail(archiveId);
loading.value = false;
}
}
function rememberCurrentDetail(archiveId: string) {
const current = detail.value;
if (!current || !archiveId) return;
rememberRecentlyViewed({
type: "wild-archive",
id: archiveId,
title: current.title,
image: current.image,
visitedAt: Date.now(),
});
}
onLoad((query) => {
const archiveId = typeof query?.id === "string" ? query.id : "";
if (!archiveId) {
+20 -1
View File
@@ -1,7 +1,26 @@
import { describe, expect, it } from "vitest";
import { normalizeConciergeAdvisors } from "@/pages/concierge/components/conciergeTypes";
import { normalizeConciergeAdvisor, normalizeConciergeAdvisors } from "@/pages/concierge/components/conciergeTypes";
describe("normalizeConciergeAdvisors", () => {
it("normalizes a single valid advisor and rejects incomplete data", () => {
expect(
normalizeConciergeAdvisor({
avatar: " https://example.test/amanda.jpg ",
name: " Amanda ",
role: " ADVISOR ",
details: [{ icon: " calendar ", label: " 服务经验:8年 " }],
qrImage: " https://example.test/amanda-qr.png ",
}),
).toEqual({
avatar: "https://example.test/amanda.jpg",
name: "Amanda",
role: "ADVISOR",
details: [{ icon: "calendar", label: "服务经验:8年" }],
qrImage: "https://example.test/amanda-qr.png",
});
expect(normalizeConciergeAdvisor({ name: "缺少头像" })).toBeNull();
});
it("normalizes public advisor data and filters incomplete records", () => {
expect(
normalizeConciergeAdvisors({
+35
View File
@@ -20,6 +20,7 @@ describe("wanfa route detail presentation", () => {
excluded: [" 往返大交通 "],
notes: [" 请准备雨具。 "],
gallery: [" https://example.test/cover.jpg "],
conciergeAdvisor: null,
}),
).toEqual({
key: "family-water",
@@ -33,6 +34,40 @@ describe("wanfa route detail presentation", () => {
excluded: ["往返大交通"],
notes: ["请准备雨具。"],
gallery: ["https://example.test/cover.jpg"],
conciergeAdvisor: null,
});
});
it("normalizes a valid concierge advisor without inventing fallback data", () => {
expect(
normalizeDetailPresentation({
key: "family-water",
eyebrow: "玩法推荐",
duration: "5天4晚",
title: "亲子玩水",
subtitle: "贵州路线",
intro: "介绍",
highlights: [],
included: [],
excluded: [],
notes: [],
gallery: ["https://example.test/cover.jpg"],
conciergeAdvisor: {
avatar: " https://example.test/amanda.jpg ",
name: " Amanda ",
role: " SENIOR TRAVEL ADVISOR ",
details: [{ icon: " calendar ", label: " 服务经验:8年 " }],
qrImage: " https://example.test/amanda-qr.png ",
},
}),
).toMatchObject({
conciergeAdvisor: {
avatar: "https://example.test/amanda.jpg",
name: "Amanda",
role: "SENIOR TRAVEL ADVISOR",
details: [{ icon: "calendar", label: "服务经验:8年" }],
qrImage: "https://example.test/amanda-qr.png",
},
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
normalizeRecentlyViewed,
upsertRecentlyViewed,
type RecentlyViewedItem,
} from "@/lib/recentlyViewed";
const baseItem: RecentlyViewedItem = {
type: "wanfa-route",
id: "route-1",
title: "亲子玩水",
image: "https://example.test/route-1.jpg",
visitedAt: 100,
};
describe("recently viewed data", () => {
it("filters invalid records and sorts valid records by latest visit", () => {
expect(
normalizeRecentlyViewed([
{ ...baseItem, visitedAt: 100 },
{ ...baseItem, id: "route-2", visitedAt: 300 },
{ id: "missing-title", type: "wanfa-route", visitedAt: 200 },
"invalid",
]),
).toEqual([
{ ...baseItem, id: "route-2", visitedAt: 300 },
baseItem,
]);
});
it("updates an existing ID, puts it first, and keeps at most five records", () => {
const existing = Array.from({ length: 5 }, (_, index) => ({
...baseItem,
id: `route-${index}`,
visitedAt: index,
}));
expect(
upsertRecentlyViewed(existing, {
...baseItem,
id: "route-3",
title: "更新后的路线",
visitedAt: 10,
}),
).toEqual([
{ ...baseItem, id: "route-3", title: "更新后的路线", visitedAt: 10 },
{ ...baseItem, id: "route-4", visitedAt: 4 },
{ ...baseItem, id: "route-2", visitedAt: 2 },
{ ...baseItem, id: "route-1", visitedAt: 1 },
{ ...baseItem, id: "route-0", visitedAt: 0 },
]);
});
});