373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
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();
|
||
});
|