chore: initialize WanderQ MiniAPP 2.0 repository

This commit is contained in:
inman
2026-07-23 15:48:34 +08:00
commit b4c0a1c516
93 changed files with 53525 additions and 0 deletions

381
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,381 @@
import bcrypt from "bcryptjs";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Prisma, PrismaClient } from "@prisma/client";
import { bottomCtas, destinations, heroSlides, themeCards } from "../../../src/content.ts";
import { DESTINATION_PAGE_CONFIG_ID, DESTINATION_RECOMMENDATION_LIMIT } from "../src/destination-page.ts";
import { createHomeModuleSnapshot, DEFAULT_HOME_MODULES } from "../src/home-module-defaults.ts";
import { collectImageUrls, materializeImageUrls } from "../src/media-migration.ts";
import { mediaStorage } from "../src/media-storage.ts";
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
import { getWecomContactWayConfigDefaults } from "../src/wecom.ts";
type SourceProduct = {
id: number;
title: string;
price: string;
tags: string[];
image: string;
summary?: string;
destinationName?: string;
priceUnit?: string;
pricingTiers?: Array<{
groupSize: number;
adultPrice: number;
child6PlusPrice: number;
childUnder6Price: number;
}>;
images?: { url: string; alt?: string | null; sortOrder?: number }[];
durationDays?: number | null;
durationNights?: number | null;
departureDates?: string[];
remainingSpots?: number | null;
recommendation?: string | null;
keyFacts?: Array<{ label: string; value: string }>;
contentBlocks?: Array<{ type: "title"; text: string } | { type: "image"; url: string; alt?: string | null }>;
detailSections?: Array<{ key: string; label: string; title?: string | null; blocks: Array<{ type: "text"; text: string } | { type: "image"; url: string; alt?: string | null }> }>;
};
const prisma = new PrismaClient();
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
function slugify(input: string) {
return encodeURIComponent(input).replace(/%/g, "").toLowerCase();
}
async function createMedia(url: string, group: string, name?: string) {
if (!url) return;
await prisma.mediaAsset.upsert({
where: { url },
update: { group, name },
create: { url, group, name },
});
}
function defaultDetailSections(summary: string, location: string) {
return [
{
key: "overview",
label: "行程概述",
title: "小包团专属概览",
blocks: [{ type: "text", text: `${summary}。万趣会按同行人、预算、酒店偏好和体力强度重排细节,保留小车小团、错峰入园与在地向导服务。` }],
},
{
key: "itinerary",
label: "每日行程",
title: `${location} 弹性安排`,
blocks: [{ type: "text", text: "默认按抵达接站、核心景点游览、特色体验、酒店休整和返程送站安排每日节奏;具体天数、停留时长和餐食可在行前由服务管家二次确认。" }],
},
{
key: "service",
label: "包含/不含服务",
title: "费用边界清晰",
blocks: [{ type: "text", text: "通常包含当地用车、行程内住宿、列明门票/体验、必要讲解和服务管家跟进;大交通、个人消费、未列明餐食和自选项目以最终方案为准。" }],
},
{
key: "notice",
label: "出行须知",
title: "贵州山地旅行提示",
blocks: [{ type: "text", text: "贵州多山多雨,建议准备防滑鞋、轻便雨具和薄外套;溶洞、漂流、徒步等体验会按天气和同行人体力调整。" }],
},
{
key: "price",
label: "价格区间",
title: "按人数、酒店和季节报价",
blocks: [{ type: "text", text: "页面价格为参考起价,节假日、旺季房态、用车车型和体验资源会影响最终报价;提交需求后由服务管家给出可执行方案。" }],
},
{
key: "manager",
label: "服务管家",
title: "直接添加服务管家",
blocks: [{ type: "text", text: "点击底部“服务管家”或拨打 18786174929可直接添加服务管家沟通出行人数、日期、酒店偏好和预算。" }],
},
];
}
function defaultKeyFacts(product: SourceProduct) {
const duration = product.title.match(/\d+天\d+晚/)?.[0];
return [
duration ? { label: "行程时长", value: duration } : null,
product.tags[0] ? { label: "线路类型", value: product.tags[0] } : null,
{ label: "成团方式", value: "专属小包团" },
].filter((fact): fact is { label: string; value: string } => Boolean(fact));
}
async function main() {
await mediaStorage.ensureReady();
const productFile = await readFile(resolve(rootDir, "src/generated-products.json"), "utf8");
const products = await materializeImageUrls(JSON.parse(productFile) as SourceProduct[]);
const seededHomeModules = await materializeImageUrls(DEFAULT_HOME_MODULES);
const seededHeroSlides = await materializeImageUrls(heroSlides);
const seededDestinations = await materializeImageUrls(destinations);
const seededThemeCards = await materializeImageUrls(themeCards);
const seededCtas = await materializeImageUrls(bottomCtas);
const seededSearchPage = await materializeImageUrls(DEFAULT_SEARCH_PAGE_CONFIG);
await prisma.auditLog.deleteMany();
await prisma.wecomExternalContact.deleteMany();
await prisma.siteVersion.deleteMany();
await prisma.homeModule.deleteMany();
await prisma.searchPageConfig.deleteMany();
await prisma.miniProgramHistory.deleteMany();
await prisma.miniProgramFavorite.deleteMany();
await prisma.miniProgramUser.deleteMany();
await prisma.leadFollowup.deleteMany();
await prisma.lead.deleteMany();
await prisma.order.deleteMany();
await prisma.customer.deleteMany();
await prisma.campaignProduct.deleteMany();
await prisma.campaign.deleteMany();
await prisma.productImage.deleteMany();
await prisma.product.deleteMany();
await prisma.ctaBanner.deleteMany();
await prisma.themeCard.deleteMany();
await prisma.heroSlide.deleteMany();
await prisma.destinationAlias.deleteMany();
await prisma.destination.deleteMany();
await prisma.mediaAsset.deleteMany();
const wecomDefaults = getWecomContactWayConfigDefaults();
await prisma.wecomContactWay.upsert({
where: { configId: wecomDefaults.configId },
update: {},
create: wecomDefaults,
});
await prisma.homeModule.createMany({
data: seededHomeModules.map((module) => ({
...module,
content: module.content as Prisma.InputJsonValue,
publishedConfig: createHomeModuleSnapshot(module) as Prisma.InputJsonValue,
})),
});
for (const url of collectImageUrls(seededHomeModules)) await createMedia(url, "home-module");
for (const url of collectImageUrls(seededSearchPage)) await createMedia(url, "search-page");
const passwordHash = await bcrypt.hash("ChangeMe123!", 12);
await prisma.adminUser.upsert({
where: { email: "admin@example.com" },
update: { passwordHash, isActive: true },
create: {
email: "admin@example.com",
name: "后台管理员",
passwordHash,
role: "super_admin",
},
});
for (const [index, slide] of seededHeroSlides.entries()) {
await createMedia(slide.image, "hero", slide.title);
await prisma.heroSlide.create({
data: {
title: slide.title,
kicker: slide.kicker,
actionLabel: slide.action,
image: slide.image,
targetType: slide.targetType,
targetValue: slide.targetValue,
sortOrder: index,
},
});
}
const destinationMap = new Map<string, string>();
for (const [index, item] of seededDestinations.entries()) {
if (item.image) await createMedia(item.image, "destination", item.label);
const destination = await prisma.destination.create({
data: {
name: item.label,
slug: slugify(item.label),
image: item.image,
isHot: index < 8,
sortOrder: index,
},
});
destinationMap.set(item.label, destination.id);
}
const aliases: Record<string, string[]> = {
: ["贵阳", "青岩", "花溪", "高坡", "天河潭"],
: ["黄果树", "安顺", "坝陵河", "瀑布"],
: ["荔波", "小七孔", "茂兰", "水上森林"],
西: ["西江", "苗寨", "郎德", "雷山", "苗岭"],
: ["梵净山", "铜仁", "云舍", "寨沙"],
: ["镇远", "青龙洞", "古城"],
: ["肇兴", "侗寨", "堂安", "加榜", "黎平"],
: ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"],
: ["织金洞", "织金", "洞穴", "喀斯特"],
: ["赤水", "丹霞", "竹海", "丙安"],
: ["青岩", "古镇", "屯堡"],
: ["百里杜鹃", "毕节", "花季"],
: ["乌蒙", "六盘水", "草原", "避暑"],
: ["中国天眼", "平塘", "观星", "科普"],
: ["遵义", "黔北", "红色文化"],
: ["茅台", "酱香", "酒旅"],
};
for (const [label, values] of Object.entries(aliases)) {
const destinationId = destinationMap.get(label);
if (!destinationId) continue;
for (const alias of values) {
await prisma.destinationAlias.create({ data: { destinationId, alias } });
}
}
for (const [index, card] of seededThemeCards.entries()) {
await createMedia(card.image, "theme", card.label);
await prisma.themeCard.create({
data: {
label: card.label,
image: card.image,
targetType: card.targetType,
targetValue: card.targetValue,
sortOrder: index,
},
});
}
for (const [index, cta] of seededCtas.entries()) {
await createMedia(cta.image, "cta", cta.alt);
await prisma.ctaBanner.create({
data: {
alt: cta.alt,
image: cta.image,
targetType: cta.targetType,
targetValue: cta.targetValue,
sortOrder: index,
},
});
}
for (const product of products) {
await createMedia(product.image, "product", product.title);
for (const image of product.images ?? []) await createMedia(image.url, "product-gallery", image.alt ?? product.title);
for (const block of product.contentBlocks ?? []) {
if (block.type === "image") await createMedia(block.url, "product-content", block.alt ?? product.title);
}
const matchedDestination = product.destinationName && destinationMap.has(product.destinationName)
? product.destinationName
: [...destinationMap.keys()].find((label) => product.title.includes(label) || product.tags.includes(label));
const summary = product.summary ?? product.tags.join(" · ");
const created = await prisma.product.create({
data: {
sourceId: product.id,
title: product.title,
subtitle: product.title.replace(/^【.*?】\s*/, "").split("·")[0],
destinationId: matchedDestination ? destinationMap.get(matchedDestination) : undefined,
priceAmount: product.price ? Number(product.price) : null,
priceUnit: product.priceUnit ?? "咨询价",
pricingTiers: product.pricingTiers?.length ? (product.pricingTiers as Prisma.InputJsonValue) : undefined,
tags: product.tags,
coverImage: product.image,
summary,
durationDays: product.durationDays ?? null,
durationNights: product.durationNights ?? null,
departureDates: product.departureDates ?? [],
remainingSpots: product.remainingSpots ?? null,
recommendation: product.recommendation ?? null,
keyFacts: (product.keyFacts?.length ? product.keyFacts : defaultKeyFacts(product)) as Prisma.InputJsonValue,
contentBlocks: product.contentBlocks?.length ? (product.contentBlocks as Prisma.InputJsonValue) : undefined,
status: "published",
sortWeight: product.id,
publishedAt: new Date(),
},
});
const gallery = product.images?.length ? product.images : [{ url: product.image, alt: product.title, sortOrder: 0 }];
await prisma.productImage.createMany({
data: gallery.map((image, index) => ({ productId: created.id, url: image.url, alt: image.alt ?? product.title, sortOrder: image.sortOrder ?? index })),
});
}
const defaultRecommendedProducts = await prisma.product.findMany({
where: { status: "published" },
orderBy: [{ sortWeight: "asc" }, { createdAt: "asc" }],
take: DESTINATION_RECOMMENDATION_LIMIT,
select: { id: true },
});
await prisma.destinationPageConfig.upsert({
where: { id: DESTINATION_PAGE_CONFIG_ID },
update: { recommendedProductIds: defaultRecommendedProducts.map((product) => product.id) as Prisma.InputJsonValue },
create: { id: DESTINATION_PAGE_CONFIG_ID, recommendedProductIds: defaultRecommendedProducts.map((product) => product.id) as Prisma.InputJsonValue },
});
await prisma.searchPageConfig.upsert({
where: { id: SEARCH_PAGE_CONFIG_ID },
update: {
title: seededSearchPage.modules.seasonalInspiration.title,
subtitle: seededSearchPage.modules.seasonalInspiration.subtitle,
placeholder: seededSearchPage.placeholder,
modules: seededSearchPage.modules as Prisma.InputJsonValue,
popularKeywords: seededSearchPage.popularKeywords as Prisma.InputJsonValue,
groups: seededSearchPage.groups as Prisma.InputJsonValue,
},
create: {
id: SEARCH_PAGE_CONFIG_ID,
title: seededSearchPage.modules.seasonalInspiration.title,
subtitle: seededSearchPage.modules.seasonalInspiration.subtitle,
placeholder: seededSearchPage.placeholder,
modules: seededSearchPage.modules as Prisma.InputJsonValue,
popularKeywords: seededSearchPage.popularKeywords as Prisma.InputJsonValue,
groups: seededSearchPage.groups as Prisma.InputJsonValue,
},
});
const campaignSeeds = [
{ slug: "classic-deal", title: "经典打卡特惠", start: 0, end: 8, coverImage: seededHeroSlides[0]?.image },
{ slug: "outdoor-deal", title: "山野野咖特惠", start: 8, end: 16, coverImage: seededHeroSlides[1]?.image },
{ slug: "mixed-route", title: "人文户外混搭", start: 16, end: 24, coverImage: seededHeroSlides[2]?.image },
];
for (const seed of campaignSeeds) {
const campaign = await prisma.campaign.create({
data: {
slug: seed.slug,
title: seed.title,
description: "由当前 H5 万趣贵州小包团内容导入的活动专题。",
coverImage: seed.coverImage,
status: "published",
},
});
const linkedProducts = await prisma.product.findMany({
where: { sourceId: { gte: seed.start + 1, lte: seed.end } },
orderBy: { sourceId: "asc" },
});
for (const [index, product] of linkedProducts.entries()) {
await prisma.campaignProduct.create({
data: { campaignId: campaign.id, productId: product.id, sortOrder: index },
});
}
}
const snapshot = await prisma.siteVersion.create({
data: {
title: "seed-initial",
status: "published",
publishedAt: new Date(),
snapshot: {
homeModules: seededHomeModules.length,
heroSlides: seededHeroSlides.length,
destinations: seededDestinations.length,
themeCards: seededThemeCards.length,
products: products.length,
},
},
});
console.log(`Seed complete. Admin login: admin@example.com / ChangeMe123!`);
console.log(`Initial site version: ${snapshot.id}`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});