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(); 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 = { 贵阳: ["贵阳", "青岩", "花溪", "高坡", "天河潭"], 黄果树: ["黄果树", "安顺", "坝陵河", "瀑布"], 荔波小七孔: ["荔波", "小七孔", "茂兰", "水上森林"], 西江苗寨: ["西江", "苗寨", "郎德", "雷山", "苗岭"], 梵净山: ["梵净山", "铜仁", "云舍", "寨沙"], 镇远: ["镇远", "青龙洞", "古城"], 肇兴: ["肇兴", "侗寨", "堂安", "加榜", "黎平"], 万峰林: ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"], 织金洞: ["织金洞", "织金", "洞穴", "喀斯特"], 赤水丹霞: ["赤水", "丹霞", "竹海", "丙安"], 青岩古镇: ["青岩", "古镇", "屯堡"], 百里杜鹃: ["百里杜鹃", "毕节", "花季"], 乌蒙草原: ["乌蒙", "六盘水", "草原", "避暑"], 中国天眼: ["中国天眼", "平塘", "观星", "科普"], 遵义: ["遵义", "黔北", "红色文化"], 茅台镇: ["茅台", "酱香", "酒旅"], }; 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(); });