Files
2026-07-23 18:45:48 +08:00

373 lines
15 KiB
TypeScript
Raw Permalink 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.

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";
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.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();
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();
});