refactor: 清理废弃模块并重构管理端与全端代码

- 移除所有废弃的旧首页配置模块,包括destinationHero、demand相关、HomeExperience、vehicleService等表结构与代码,新增数据库迁移删除遗留表
- 重构管理端后台布局为标准RuoYi风格,新增Sidebar、Navbar、SettingsDrawer等组件,替换旧的过渡菜单实现
- 调整移动端响应断点为768px,更新布局相关测试用例,新增布局组件测试
- 删除OperationsTools发布/重置页面,移除/tools菜单入口,清理对应的API调用与测试代码
- 重构玩法模块编辑器:替换旧的图片手动输入框为图片上传组件,移除冗余的取消按钮
- 新增线索查询offset参数支持,完善查询参数处理逻辑
- 优化rbac菜单构建逻辑,新增可见性控制参数
- 修正管家编辑器提示文案,优化系统资源编辑器表单初始化逻辑
- 清理小程序端废弃的体验推荐组件与相关测试代码
- 更新文档与种子脚本,匹配新的API契约与使用说明
This commit is contained in:
duanshuwen committed 2026-08-26 23:24:28 +08:00
1 parent f6c5ad589f
commit fbf2c772a8
63 files changed
+2272 -1991

No files matched your search

+17 -114
View File
@@ -4,15 +4,9 @@ import {
} from "@/content";
import type {
AppData,
DemandFeatureCard,
DemandFormConfig,
DemandHero,
DestinationHero,
PublicHomeCard,
PublicSiteConfig,
PublicVehicleService,
PublicVehicleServiceSection,
PublicVehicleServiceStep,
VehicleOption,
} from "./types";
@@ -37,131 +31,40 @@ export function normalizeHomeCards<T extends { id?: string; title: string; image
});
}
function normalizeDestinationHero(items: PublicSiteConfig["destinationHero"], fallback: DestinationHero): DestinationHero {
const item = normalizeActiveItems(items).find((entry) => entry.title?.trim());
return item
? { id: item.id, title: item.title.trim(), kicker: item.kicker?.trim() || fallback.kicker, image: item.image?.trim() || fallback.image }
: fallback;
}
function normalizeDemandHero(items: PublicSiteConfig["demandHero"], fallback: DemandHero): DemandHero {
const item = normalizeActiveItems(items).find((entry) => entry.title?.trim());
if (!item) return fallback;
const steps = (item.steps ?? []).map((step) => step.trim()).filter(Boolean);
return { id: item.id, kicker: item.kicker?.trim() || fallback.kicker, title: item.title.trim(), description: item.description?.trim() || fallback.description, steps: steps.length ? steps : fallback.steps };
}
function normalizeDemandFeatureCards(items: PublicSiteConfig["demandFeatureCards"], fallback: DemandFeatureCard[]): DemandFeatureCard[] {
const activeItems = normalizeActiveItems(items).filter((item) => item.title?.trim());
return activeItems.length
? activeItems.map((item, index) => ({ id: item.id, title: item.title.trim(), description: item.description?.trim() || fallback[index]?.description || "" }))
: fallback;
}
function normalizeDemandForm(items: PublicSiteConfig["demandForm"], fallback: DemandFormConfig): DemandFormConfig {
const item = (items ?? []).find((entry) => entry.isActive !== false);
if (!item) return fallback;
const chips = (item.chips ?? []).map((chip) => chip.trim()).filter(Boolean);
return {
id: item.id,
destinationLabel: item.destinationLabel?.trim() || fallback.destinationLabel,
destinationPlaceholder: item.destinationPlaceholder?.trim() || fallback.destinationPlaceholder,
phoneLabel: item.phoneLabel?.trim() || fallback.phoneLabel,
phonePlaceholder: item.phonePlaceholder?.trim() || fallback.phonePlaceholder,
noteLabel: item.noteLabel?.trim() || fallback.noteLabel,
notePlaceholder: item.notePlaceholder?.trim() || fallback.notePlaceholder,
submitLabel: item.submitLabel?.trim() || fallback.submitLabel,
chips: chips.length ? chips : fallback.chips,
};
}
function normalizeVehicleServiceSection(item: unknown): PublicVehicleServiceSection | null {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
return title && description ? { title, description } : null;
}
function normalizeVehicleServiceStep(item: unknown): PublicVehicleServiceStep | null {
return normalizeVehicleServiceSection(item);
}
function normalizeVehicleService(item: PublicSiteConfig["vehicleService"], fallback: PublicVehicleService): PublicVehicleService {
if (!item || item.isActive === false) return fallback;
const serviceSections = (item.serviceSections ?? []).map(normalizeVehicleServiceSection).filter(Boolean) as PublicVehicleServiceSection[];
const processSteps = (item.processSteps ?? []).map(normalizeVehicleServiceStep).filter(Boolean) as PublicVehicleServiceStep[];
const advantages = (item.advantages ?? []).filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean);
return {
id: item.id,
introTitle: item.intro?.trim() && item.introTitle?.trim() ? item.introTitle.trim() : fallback.introTitle,
intro: item.intro?.trim() || fallback.intro,
serviceSections: serviceSections.length ? serviceSections : fallback.serviceSections,
advantages: advantages.length ? advantages : fallback.advantages,
processSteps: processSteps.length ? processSteps : fallback.processSteps,
};
}
export function normalizeSiteConfig(siteConfig: PublicSiteConfig, fallback: AppData): AppData {
return {
heroSlides: siteConfig.heroSlides?.length ? siteConfig.heroSlides.map((slide) => ({ id: slide.id, image: slide.image, kicker: slide.kicker, title: slide.title, targetType: slide.targetType, targetValue: slide.targetValue })) : fallback.heroSlides,
destinationHero: normalizeDestinationHero(siteConfig.destinationHero, fallback.destinationHero),
demandHero: normalizeDemandHero(siteConfig.demandHero, fallback.demandHero),
demandFeatureCards: normalizeDemandFeatureCards(siteConfig.demandFeatureCards, fallback.demandFeatureCards),
demandForm: normalizeDemandForm(siteConfig.demandForm, fallback.demandForm),
heroSlides: siteConfig.heroSlides?.length
? siteConfig.heroSlides.map((slide) => ({
id: slide.id,
image: slide.image,
kicker: slide.kicker,
title: slide.title,
targetType: slide.targetType,
targetValue: slide.targetValue,
}))
: fallback.heroSlides,
vehicleOptions: normalizeHomeCards<VehicleOption>(siteConfig.vehicleOptions, fallback.vehicleOptions),
vehicleService: normalizeVehicleService(siteConfig.vehicleService, fallback.vehicleService),
vehicleService: fallback.vehicleService,
};
}
const fallbackDestinationHero: DestinationHero = {
image: "/assets/guizhou/huangguoshu-waterfall.jpg",
kicker: "贵州小包团目的地",
title: "山水、苗寨、古城与野咖同程安排",
};
const fallbackDemandHero: DemandHero = { kicker: "3步定制", title: "告诉我们日期、人数和想法", description: "管家会按同行人、预算和体力强度,重新组合酒店、用车和景点节奏。", steps: ["提交需求", "管家沟通", "确认方案"] };
const fallbackDemandFeatureCards: DemandFeatureCard[] = [
{ title: "动线", description: "按天数顺路排" },
{ title: "住宿", description: "城市与山野组合" },
{ title: "用车", description: "人数行李匹配" },
];
const fallbackDemandForm: DemandFormConfig = {
destinationLabel: "目的地/玩法",
destinationPlaceholder: "例如:贵州、黄果树、西江苗寨",
phoneLabel: "联系方式",
phonePlaceholder: "手机号 / 微信号",
noteLabel: "补充说明",
notePlaceholder: "出行日期、人数、酒店偏好、预算范围",
submitLabel: "提交出行需求",
chips: ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"],
};
const fallbackVehicleService: PublicVehicleService = {
introTitle: "旅游出行专属用车服务",
intro: "覆盖景区接送、短途包车、旅途代步和多人出行场景。提交需求后由管家联系确认车型与服务安排。",
intro: "提交用车需求后由管家联系确认车型与服务安排。",
serviceSections: [
{ title: "旅游专属包车", description: "适配家庭出游、朋友结伴和小团体旅行,支持半天或全天包车,行程可按需求沟通调整。" },
{ title: "多车型灵活选择", description: "舒适轿车、SUV、商务车等车型按人数和行李情况匹配,让不同规模的旅行都能舒适出行。" },
],
advantages: [
"专为旅游定制:贴合出游场景,行程灵活",
"车型齐全:按人数和行李匹配出行方案",
"省心便捷:线上提交需求,管家快速跟进",
"出行安心:服务安排清晰,行程有人跟进",
{ title: "旅游专属包车", description: "适配家庭出游、朋友结伴和小团体旅行,行程可按需求沟通调整。" },
{ title: "多车型灵活选择", description: "按人数和行李情况匹配舒适轿车、SUV 或商务车。" },
],
advantages: ["专为旅游定制", "按人数和行李匹配", "管家快速跟进"],
processSteps: [
{ title: "提交需求", description: "填写日期、地点、人数和车型偏好。" },
{ title: "管家联系", description: "管家根据需求沟通车辆和服务安排。" },
{ title: "确认服务", description: "确认行程细节与报价后按约定出行。" },
{ title: "安心出行", description: "按确认方案完成接送或包车服务。" },
{ title: "管家联系", description: "沟通车辆和服务安排。" },
{ title: "确认服务", description: "确认行程细节后按约定出行。" },
],
};
export const fallbackData: AppData = {
heroSlides: fallbackHeroSlides,
destinationHero: fallbackDestinationHero,
demandHero: fallbackDemandHero,
demandFeatureCards: fallbackDemandFeatureCards,
demandForm: fallbackDemandForm,
vehicleOptions: fallbackVehicleOptions.map((option) => ({ ...option })),
vehicleService: fallbackVehicleService,
};
+1 -38
View File
@@ -32,23 +32,9 @@ export type PublicHeroSlide = {
targetType?: string | null;
targetValue?: string | null;
isActive?: boolean;
sortOrder?: number;
};
export type PublicDestinationHero = { id: string; title: string; kicker?: string | null; image?: string | null; isActive?: boolean; sortOrder?: number };
export type PublicDemandHero = { id: string; title: string; kicker?: string | null; description?: string | null; steps?: string[]; isActive?: boolean; sortOrder?: number };
export type PublicDemandFeatureCard = { id: string; title: string; description?: string | null; isActive?: boolean; sortOrder?: number };
export type PublicDemandForm = {
id: string;
destinationLabel?: string | null;
destinationPlaceholder?: string | null;
phoneLabel?: string | null;
phonePlaceholder?: string | null;
noteLabel?: string | null;
notePlaceholder?: string | null;
submitLabel?: string | null;
chips?: string[];
isActive?: boolean;
};
export type PublicHomeCard = { id: string; title: string; description?: string | null; image?: string | null; isActive?: boolean; sortOrder?: number };
export type PublicVehicleServiceSection = { title: string; description: string };
@@ -63,16 +49,6 @@ export type PublicVehicleService = {
isActive?: boolean;
};
export type PublicHomeExperience = {
id: string;
badge: string;
category: string;
title: string;
englishTitle: string;
image: string;
demandKeyword: string;
};
export type PublicHomeTeamBuilding = {
id: string;
tag: string;
@@ -169,12 +145,7 @@ export type PublicConciergeResponse = {
export type PublicSiteConfig = {
heroSlides?: PublicHeroSlide[];
destinationHero?: PublicDestinationHero[];
demandHero?: PublicDemandHero[];
demandFeatureCards?: PublicDemandFeatureCard[];
demandForm?: PublicDemandForm[];
vehicleOptions?: PublicHomeCard[];
vehicleService?: PublicVehicleService | null;
};
export type PublicLeadPayload = {
@@ -211,18 +182,10 @@ export type VehicleLeadPayload = Omit<PublicLeadPayload, "leadType" | "vehicleDe
};
export type HeroSlide = { id?: string; image: string; kicker?: string | null; title: string; action?: string | null; targetType?: string | null; targetValue?: string | null };
export type DestinationHero = { id?: string; image: string; kicker?: string | null; title: string };
export type DemandHero = { id?: string; kicker: string; title: string; description: string; steps: string[] };
export type DemandFeatureCard = { id?: string; title: string; description: string };
export type DemandFormConfig = { id?: string; destinationLabel: string; destinationPlaceholder: string; phoneLabel: string; phonePlaceholder: string; noteLabel: string; notePlaceholder: string; submitLabel: string; chips: string[] };
export type VehicleOption = { id?: string; title: string; image: string; description: string };
export type AppData = {
heroSlides: HeroSlide[];
destinationHero: DestinationHero;
demandHero: DemandHero;
demandFeatureCards: DemandFeatureCard[];
demandForm: DemandFormConfig;
vehicleOptions: VehicleOption[];
vehicleService: PublicVehicleService;
};
@@ -1,35 +0,0 @@
<template>
<view
class="w-full bg-[#fffdf9] pt-[18px] tap-feedback min-[431px]:pt-[22px]"
:aria-label="`${item.title},${item.category}`"
@click="$emit('select', item)"
>
<view class="flex items-start justify-between gap-4 pr-[18px] min-[431px]:pr-[22px]">
<view class="min-w-0 flex-1">
<view class="flex min-h-5 items-center gap-[10px]">
<text class="inline-flex min-h-5 items-center rounded-[12px] border border-[#eadfce] bg-[#f8f1e8] px-[6px] text-[10px] leading-[18px] tracking-[0.06em] text-[#98744b]">{{ item.badge }}</text>
<text class="text-[13px] font-semibold leading-5 text-[#283b4d]">{{ item.category }}</text>
</view>
<text class="mt-[10px] block whitespace-pre-line font-serif text-[25px] font-medium leading-8 tracking-[0.04em] text-[#18212a]">{{ item.title }}</text>
<text class="mt-0.5 block font-sans text-[11px] font-medium leading-[18px] tracking-[0.22em] text-[#a7a0a0]">{{ item.englishTitle }}</text>
</view>
<view class="mt-8 flex h-10 w-6 shrink-0 items-center justify-center" aria-hidden="true">
<uni-icons type="right" :size="24" color="#c8c8c8" />
</view>
</view>
<image class="mt-[17px] block h-[174px] w-full rounded-[12px] bg-[#eae7e1] min-[431px]:h-[190px]" :src="item.image" mode="aspectFill" />
</view>
</template>
<script setup lang="ts">
import type { HomeExperience } from "./homeExperienceData";
defineProps<{
item: HomeExperience;
}>();
defineEmits<{
select: [item: HomeExperience];
}>();
</script>
@@ -1,23 +0,0 @@
<template>
<view class="bg-[#fffdf9] px-[8.5%] pb-[26px]">
<HomeExperienceCard
v-for="item in items"
:key="item.id"
:item="item"
@select="$emit('select', $event)"
/>
</view>
</template>
<script setup lang="ts">
import HomeExperienceCard from "./HomeExperienceCard.vue";
import type { HomeExperience } from "./homeExperienceData";
defineProps<{
items: HomeExperience[];
}>();
defineEmits<{
select: [item: HomeExperience];
}>();
</script>
@@ -1,78 +0,0 @@
export type HomeExperience = {
id: string;
badge: string;
category: string;
title: string;
englishTitle: string;
image: string;
demandKeyword: string;
};
export type HomeExperienceApiItem = {
id?: string | null;
badge?: string | null;
category?: string | null;
title?: string | null;
englishTitle?: string | null;
image?: string | null;
demandKeyword?: string | null;
isActive?: boolean | null;
sortOrder?: number | null;
};
export type HomeExperiences = HomeExperienceApiItem[];
export const homeExperienceMocks: HomeExperience[] = [
{
id: "waterfall-descent",
badge: "玩过推荐",
category: "瀑降体验",
title: "悬崖瀑降",
englishTitle: "WATERFALL DESCENT",
image: "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
demandKeyword: "悬崖瀑降",
},
{
id: "cave-exploration",
badge: "玩过推荐",
category: "地心探险",
title: "森林&探洞",
englishTitle: "CAVE EXPLORATION",
image: "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
demandKeyword: "地心探险",
},
];
export const homeExperienceFallback = homeExperienceMocks;
export function normalizeHomeExperiences(
items: HomeExperiences | null | undefined,
): HomeExperience[] {
const fallback = homeExperienceFallback.map((item) => ({ ...item }));
const activeItems = (items ?? [])
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.isActive !== false && Boolean(item.title?.trim()))
.sort(
(left, right) =>
(left.item.sortOrder ?? left.index) - (right.item.sortOrder ?? right.index),
);
if (activeItems.length === 0) {
return fallback;
}
return activeItems.map(({ item }, index) => {
const fallbackItem = homeExperienceFallback[index] ?? homeExperienceFallback[0];
const title = item.title?.trim() || fallbackItem.title;
return {
id: item.id?.trim() || fallbackItem.id,
badge: item.badge?.trim() || fallbackItem.badge,
category: item.category?.trim() || fallbackItem.category,
title,
englishTitle: item.englishTitle?.trim() || fallbackItem.englishTitle,
image: item.image?.trim() || fallbackItem.image,
demandKeyword: item.demandKeyword?.trim() || title,
};
});
}
+24 -47
View File
@@ -7,42 +7,47 @@ describe("WonderQ shared data helpers", () => {
expect(normalizeSiteConfig({}, fallbackData)).toEqual(fallbackData);
});
it("normalizes active site modules and ignores incomplete records", () => {
it("normalizes the supported home modules and keeps the local vehicle fallback", () => {
const config: PublicSiteConfig = {
destinationHero: [
heroSlides: [
{
id: "hero-1",
title: "后台主视觉标题",
title: "后台首页主视觉",
kicker: "后台小标题",
image: "https://cdn.example.test/hero.jpg",
isActive: true,
sortOrder: 0,
},
],
demandForm: [
vehicleOptions: [
{
id: "form-1",
destinationLabel: "想去哪里",
submitLabel: "提交给管家",
chips: [" 贵州 ", "", "黄果树"],
id: "vehicle-1",
title: "舒适型用车",
description: "适合家庭出行",
image: "https://cdn.example.test/vehicle.jpg",
isActive: true,
sortOrder: 0,
},
],
};
const data = normalizeSiteConfig(config, fallbackData);
expect(data.destinationHero).toMatchObject({
id: "hero-1",
title: "后台主视觉标题",
image: "https://cdn.example.test/hero.jpg",
});
expect(data.demandForm).toMatchObject({
id: "form-1",
destinationLabel: "想去哪里",
submitLabel: "提交给管家",
chips: ["贵州", "黄果树"],
});
expect(data.heroSlides).toMatchObject([
{
id: "hero-1",
title: "后台首页主视觉",
image: "https://cdn.example.test/hero.jpg",
},
]);
expect(data.vehicleOptions).toMatchObject([
{
id: "vehicle-1",
title: "舒适型用车",
description: "适合家庭出行",
},
]);
expect(data.vehicleService.introTitle).toBe("旅游出行专属用车服务");
});
it("returns a stable submit error message", () => {
@@ -50,32 +55,4 @@ describe("WonderQ shared data helpers", () => {
expect(getSubmitErrorMessage("unknown")).toBe("提交失败,请稍后再试。");
});
it("normalizes the vehicle service module and falls back incomplete sections", () => {
const config: PublicSiteConfig = {
vehicleService: {
id: "vehicle-service-1",
introTitle: "旅游专属用车",
intro: " 提交需求后由管家跟进。 ",
serviceSections: [{ title: " 包车服务 ", description: " 灵活安排 " }],
advantages: [" 车型齐全 ", ""],
processSteps: [{ title: "提交需求", description: " 填写出行信息 " }],
isActive: true,
},
};
const data = normalizeSiteConfig(config, fallbackData);
expect(data.vehicleService).toMatchObject({
id: "vehicle-service-1",
introTitle: "旅游专属用车",
intro: "提交需求后由管家跟进。",
advantages: ["车型齐全"],
});
expect(data.vehicleService.serviceSections).toEqual([
{ title: "包车服务", description: "灵活安排" },
]);
expect(data.vehicleService.processSteps).toEqual([
{ title: "提交需求", description: "填写出行信息" },
]);
});
});
-22
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { homeExperienceMocks, normalizeHomeExperiences } from "@/pages/home/components/homeExperienceData";
import {
homeTeamBuildingMocks,
normalizeHomeTeamBuildingDetail,
@@ -14,27 +13,6 @@ import {
import { normalizeHomeWanfaRecommendations } from "@/pages/home/components/homeWanfaRecommendationData";
describe("home content normalizers", () => {
it("normalizes experiences and keeps the existing fallback for missing fields", () => {
expect(
normalizeHomeExperiences([
{
id: "api-experience",
title: " API 瀑降 ",
sortOrder: 0,
isActive: true,
},
{ id: "disabled", title: "不展示", isActive: false },
]),
).toEqual([
{
...homeExperienceMocks[0],
id: "api-experience",
title: "API 瀑降",
demandKeyword: "API 瀑降",
},
]);
});
it("normalizes team building and wild archive lists", () => {
expect(normalizeHomeTeamBuildings(null)).toEqual(homeTeamBuildingMocks);
expect(normalizeHomeWildArchives(undefined)).toEqual(homeWildArchiveMocks);