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

@@ -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) {