菜单添加页面模板管理页面,增加一个纯图平铺模板,优化预览手机屏效果

This commit is contained in:
2026-04-17 08:35:45 +08:00
parent 9a6418eb93
commit 052195abab
36 changed files with 1534 additions and 708 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

View File

@@ -5,6 +5,7 @@ import { CarLibrary } from "@/components/car-library/CarLibrary"
import { MiniAppContentLibrary } from "@/components/content-library/MiniAppContentLibrary"
import { UserAccessManagement } from "@/components/users/UserAccessManagement"
import { SystemSettings } from "@/components/settings/SystemSettings"
import { TemplateManagement } from "@/components/templates/TemplateManagement"
import { Separator } from "@/components/ui/separator"
import { Bell, Search, User } from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -65,6 +66,8 @@ export default function App() {
newContentRequest={null}
/>
)
case "templates":
return <TemplateManagement />
case "user-access":
return <UserAccessManagement />
case "settings":
@@ -99,6 +102,7 @@ export default function App() {
<div className="text-sm font-medium text-muted-foreground">
{activeTab === "car-library" && "官网车型库"}
{activeTab === "mini-content" && "小程序内容库"}
{activeTab === "templates" && "小程序页面模板"}
{activeTab === "user-access" && "用户与权限"}
{activeTab === "settings" && "系统设置"}
</div>

View File

@@ -21,7 +21,7 @@ type SyncStatus = "synced" | "pending" | "error"
type SourceCar = {
id: string
name: string
pageType: "车型页" | "活动页" | "专题页"
pageType: "车型页" | "活动页" | "专题页" | "纯图页"
title: string
subtitle: string
highlights: string
@@ -37,7 +37,7 @@ type SourceCar = {
}
type SourceCarDraft = {
pageType: "车型页" | "活动页" | "专题页"
pageType: "车型页" | "活动页" | "专题页" | "纯图页"
title: string
subtitle: string
highlights: string
@@ -200,10 +200,53 @@ const SOURCE_CARS: SourceCar[] = [
"/images/cars/rs7-2.jpg",
],
},
{
id: "e7x",
name: "E7X",
pageType: "纯图页",
title: "E7X详情",
subtitle: "先锋纯电旗舰",
highlights: "纯电驱动\n先锋设计\n智能座舱",
description: "E7X 纯图平铺模板内容,面向视觉导向场景。",
ctaText: "预约体验",
scheduledPublishAt: "",
officialTagline: "先锋纯电旗舰",
sourceUpdatedAt: "2026-04-16 10:30",
updatedBy: "内容运营专员",
syncStatus: "synced",
imageCount: 3,
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
{
id: "a7",
name: "A7",
pageType: "专题页",
title: "A7详情",
subtitle: "",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "A7 专题图文模板内容,聚焦视觉叙事与图文模块表达。",
ctaText: "预约试驾",
scheduledPublishAt: "",
officialTagline: "先锋设计",
sourceUpdatedAt: "2026-04-16 10:40",
updatedBy: "内容运营专员",
syncStatus: "synced",
imageCount: 4,
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
},
]
const INITIAL_LOGS: SyncLog[] = [
{ id: "l1", time: "2026-04-11 09:30", status: "success", message: "同步成功:7 个车型,素材 31 张" },
{ id: "l1", time: "2026-04-11 09:30", status: "success", message: "同步成功:9 个车型,素材 38 张" },
{ id: "l2", time: "2026-04-10 09:15", status: "failed", message: "Audi Q5L 图片拉取失败CDN 超时" },
]
@@ -245,12 +288,12 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
const [editingCarId, setEditingCarId] = React.useState<string | null>(null)
const [editorDraft, setEditorDraft] = React.useState<SourceCarDraft | null>(null)
const [newImageUrl, setNewImageUrl] = React.useState("")
const [listPage, setListPage] = React.useState(1)
const initialDraftRef = React.useRef<SourceCarDraft | null>(null)
const listPageSize = 7
const listTotalPages = 10
const listPage = 1
const filtered = sourceCars.filter((car) => car.name.toLowerCase().includes(query.toLowerCase()))
const listTotalPages = Math.max(1, Math.ceil(filtered.length / listPageSize))
const pagedCars = filtered.slice((listPage - 1) * listPageSize, listPage * listPageSize)
const previewCar = sourceCars.find((car) => car.id === previewCarId) ?? null
const previewImages = previewCar?.imageUrls ?? []
@@ -261,6 +304,16 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
return JSON.stringify(editorDraft) !== JSON.stringify(initialDraftRef.current)
}, [editorDraft])
React.useEffect(() => {
setListPage(1)
}, [query])
React.useEffect(() => {
if (listPage > listTotalPages) {
setListPage(listTotalPages)
}
}, [listPage, listTotalPages])
React.useEffect(() => {
if (!previewCarId || previewImages.length === 0) return
@@ -300,7 +353,7 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
}))
)
setSyncLogs((curr) => [
{ id: String(Date.now()), time, status: "success", message: "本周人工周更完成:7 个车型素材已核对并更新" },
{ id: String(Date.now()), time, status: "success", message: "本周人工周更完成:9 个车型素材已核对并更新" },
...curr,
])
onAddAuditLog("内容运营专员执行 JV 官网人工周更")
@@ -636,21 +689,21 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
</Table>
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-xs text-muted-foreground"> 7 10 </p>
<p className="text-xs text-muted-foreground"> 7 {listTotalPages} </p>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage <= 1} onClick={() => setListPage((prev) => Math.max(1, prev - 1))}></Button>
{Array.from({ length: listTotalPages }, (_, index) => (
<Button
key={`car-page-${index + 1}`}
size="sm"
variant={index + 1 === listPage ? "default" : "outline"}
className={index + 1 === listPage ? "bg-audi-black hover:bg-audi-dark-gray text-white" : ""}
disabled
onClick={() => setListPage(index + 1)}
>
{index + 1}
</Button>
))}
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage >= listTotalPages} onClick={() => setListPage((prev) => Math.min(listTotalPages, prev + 1))}></Button>
</div>
</div>
</CardContent>

View File

@@ -25,47 +25,10 @@ import {
TableRow,
} from "@/components/ui/table"
type WorkflowStatus = "draft" | "prepublished" | "published"
type TopicSection = {
id: string
title: string
imageUrl: string
specLeftLabel: string
specRightLabel: string
}
type SourceSnapshot = {
title: string
subtitle: string
highlights: string
description: string
ctaText: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
}
type MiniAppContent = {
id: string
sourceCarId: string
sourceCarName: string
pageType: "车型页" | "活动页" | "专题页"
title: string
subtitle: string
highlights: string
description: string
ctaText: string
scheduledPublishAt: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
workflowStatus: WorkflowStatus
updatedBy: string
updatedAt: string
}
import type { WorkflowStatus, TopicSection, SourceSnapshot, MiniAppContent } from "./types"
import { getTemplate, getAllTemplates } from "./template-registry"
import "./register-templates"
import { createTopicSectionsFromImages, buildTopicPatch } from "./register-templates"
type MiniAppContentLibraryProps = {
roleView: RoleView
@@ -227,8 +190,83 @@ const CONTENTS: MiniAppContent[] = [
updatedBy: "内容运营专员",
updatedAt: "2026-04-12 10:20",
},
{
id: "c8",
sourceCarId: "e7x",
sourceCarName: "E7X",
pageType: "纯图页",
title: "E7X详情",
subtitle: "",
highlights: "",
description: "",
ctaText: "预约体验",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: "2026-04-16 10:30",
},
{
id: "c9",
sourceCarId: "a7",
sourceCarName: "A7",
pageType: "专题页",
title: "A7详情",
subtitle: "",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "",
ctaText: "预约试驾",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
topicNavTitle: "A7详情",
topicHeroTitle: "先锋设计",
topicSections: [
{
id: "a7-topic-1",
title: "RS竞速套件",
imageUrl: "/images/cars/a7-1.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-2",
title: "宽体低趴 超长轴距",
imageUrl: "/images/cars/a7-2.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-3",
title: "高性能美学,锋芒尽释",
imageUrl: "/images/cars/a7-3.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-4",
title: "懂你,更懂未来",
imageUrl: "/images/cars/a7-4.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: "2026-04-16 10:40",
},
]
const INITIAL_CONTENT_IDS = new Set(CONTENTS.map((item) => item.id))
const WORKFLOW_LABEL: Record<WorkflowStatus, string> = {
draft: "草稿",
prepublished: "预发布",
@@ -240,7 +278,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A3 Sportback",
subtitle: "进取,不负期待",
highlights: "数字座舱\n城市通勤\n智能互联",
description: "来自官网车型库的车型基线内容。",
description: "适合城市用户的豪华紧凑车型,兼顾效率与智能体验。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/a3-1.jpg",
@@ -251,7 +289,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A4L",
subtitle: "做更强大的自己",
highlights: "quattro\n商务舒适\n智能互联",
description: "来自官网车型库的车型基线内容。",
description: "面向商务人群的主推内容版本,突出舒适与操控。",
ctaText: "了解更多",
imageUrls: [
"/images/cars/a4-1.jpg",
@@ -261,7 +299,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A6L",
subtitle: "懂你,更懂未来",
highlights: "行政旗舰\n长轴空间\n智能辅助",
description: "来自官网车型库的车型基线内容。",
description: "正在进行文案优化,待业务审核。",
ctaText: "预约顾问回电",
imageUrls: [
"/images/cars/a6-1.jpg",
@@ -272,7 +310,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi Q3",
subtitle: "活出生命的辽阔",
highlights: "紧凑SUV\n智能互联\n都市通勤",
description: "来自官网车型库的车型基线内容。",
description: "新建草稿,待进一步补充图文和活动权益信息。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/q3-1.jpg",
@@ -283,7 +321,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi Q5L",
subtitle: "自由,由我定义",
highlights: "四驱性能\n家庭空间\n全场景出行",
description: "来自官网车型库的车型基线内容。",
description: "因权益文案与活动规则不一致,被驳回待修订。",
ctaText: "预约试驾",
imageUrls: [
"/images/cars/q5-1.jpg",
@@ -294,7 +332,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A8L",
subtitle: "旗舰格局,沉稳之选",
highlights: "旗舰行政\n豪华座舱\n专属服务",
description: "来自官网车型库的车型基线内容。",
description: "已发布后因活动档期调整撤回,待重新排期。",
ctaText: "预约专属顾问",
imageUrls: [
"/images/cars/a8-1.jpg",
@@ -305,13 +343,70 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi RS7 Performance",
subtitle: "高性能美学,锋芒尽释",
highlights: "V8双涡轮\nquattro四驱\nRS专属运动套件",
description: "来自官网车型库的车型基线内容。",
description: "聚焦高性能驾驶体验与豪华运动设计,适配高意向用户内容触达场景。",
ctaText: "预约性能试驾",
imageUrls: [
"/images/cars/rs7-1.jpg",
"/images/cars/rs7-2.jpg",
],
},
e7x: {
title: "E7X详情",
subtitle: "先锋纯电旗舰",
highlights: "纯电驱动\n先锋设计\n智能座舱",
description: "",
ctaText: "预约体验",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
a7: {
title: "A7详情",
subtitle: "先锋设计",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "",
ctaText: "预约试驾",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
topicNavTitle: "A7详情",
topicHeroTitle: "先锋设计",
topicSections: [
{
id: "a7-topic-1",
title: "RS竞速套件",
imageUrl: "/images/cars/a7-1.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-2",
title: "宽体低趴 超长轴距",
imageUrl: "/images/cars/a7-2.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-3",
title: "高性能美学,锋芒尽释",
imageUrl: "/images/cars/a7-3.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-4",
title: "懂你,更懂未来",
imageUrl: "/images/cars/a7-4.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
],
},
}
const SOURCE_CAR_OPTIONS = [
@@ -322,31 +417,10 @@ const SOURCE_CAR_OPTIONS = [
{ id: "q5l", name: "Audi Q5L" },
{ id: "a8l", name: "Audi A8L" },
{ id: "rs7", name: "Audi RS7" },
{ id: "e7x", name: "E7X" },
{ id: "a7", name: "A7" },
] as const
function createTopicSectionsFromImages(images: string[]) {
const picks = images.slice(0, 2)
const fallback = ["/images/cars/a7-1.jpg", "/images/cars/a7-2.jpg"]
const selected = picks.length > 0 ? picks : fallback
return selected.map((imageUrl, index) => ({
id: `topic-section-${index + 1}`,
title: index === 0 ? "RS竞速套件" : "宽体低趴 超长轴距",
imageUrl,
specLeftLabel: "长",
specRightLabel: "宽",
}))
}
function buildTopicPatch(sourceCarName: string, snapshot?: SourceSnapshot): Pick<MiniAppContent, "topicNavTitle" | "topicHeroTitle" | "topicSections"> {
const hero = snapshot?.topicHeroTitle ?? snapshot?.subtitle ?? "先锋设计"
return {
topicNavTitle: snapshot?.topicNavTitle ?? `${sourceCarName || "车型"}详情`,
topicHeroTitle: hero,
topicSections: snapshot?.topicSections ?? createTopicSectionsFromImages(snapshot?.imageUrls ?? []),
}
}
function workflowBadgeClass(status: WorkflowStatus) {
switch (status) {
case "draft":
@@ -369,9 +443,8 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const [query, setQuery] = React.useState("")
const [newImageUrl, setNewImageUrl] = React.useState("")
const [pendingResetPatch, setPendingResetPatch] = React.useState<Record<string, Partial<MiniAppContent>>>({})
const [listPage, setListPage] = React.useState(1)
const listPageSize = 7
const listTotalPages = 10
const listPage = 1
React.useEffect(() => {
if (!newContentRequest) return
@@ -379,16 +452,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const id = `c${newContentRequest.requestId}`
const existing = contents.find((item) => item.id === id)
if (!existing) {
const defaultPageType = getAllTemplates()[0]?.pageTypes[0] ?? "车型页"
const tmpl = getTemplate(defaultPageType)
const draft: MiniAppContent = {
id,
sourceCarId: newContentRequest.sourceCarId,
sourceCarName: newContentRequest.sourceCarName,
pageType: "车型页",
pageType: defaultPageType,
title: newContentRequest.sourceCarName,
subtitle: "",
highlights: "",
description: "",
ctaText: "立即预约试驾",
ctaText: "",
scheduledPublishAt: "",
imageUrls: [],
topicNavTitle: `${newContentRequest.sourceCarName}详情`,
@@ -397,6 +472,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: nowString(),
...tmpl?.defaultValues,
}
setContents((prev) => [draft, ...prev])
}
@@ -412,8 +488,19 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const key = `${item.sourceCarName} ${item.sourceCarId} ${item.pageType} ${item.title}`.toLowerCase()
return key.includes(query.toLowerCase())
})
const listTotalPages = Math.max(1, Math.ceil(filteredContents.length / listPageSize))
const pagedContents = filteredContents.slice((listPage - 1) * listPageSize, listPage * listPageSize)
React.useEffect(() => {
setListPage(1)
}, [query])
React.useEffect(() => {
if (listPage > listTotalPages) {
setListPage(listTotalPages)
}
}, [listPage, listTotalPages])
const updateContent = (id: string, patch: Partial<MiniAppContent>) => {
setContents((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch, updatedAt: nowString() } : item))
@@ -430,7 +517,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
return next
})
if (Object.keys(stagedPatch).length > 0) {
onAddAuditLog(`内容运营专员保存并重置 ${item.sourceCarName}官网源数据(回草稿)`)
onAddAuditLog(`内容运营专员保存并重置 ${item.sourceCarName}来源车型同步内容(回草稿)`)
} else {
onAddAuditLog(`内容运营专员保存 ${item.sourceCarName} 内容草稿`)
}
@@ -439,19 +526,16 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const stageResetFromSource = (item: MiniAppContent) => {
const source = SOURCE_SNAPSHOTS[item.sourceCarId]
if (!source) return
if (!window.confirm("确认重置当前内容吗?此操作将删除当前编辑内容,并重新与所选“来源车型”进行同步。")) {
return
}
const tmpl = getTemplate(item.pageType)
const templatePatch = tmpl?.onSourceCarSelected?.(source, item) ?? {}
setPendingResetPatch((prev) => ({
...prev,
[item.id]: {
title: source.title,
subtitle: source.subtitle,
highlights: source.highlights,
description: source.description,
ctaText: item.pageType === "专题页" ? "预约体验" : source.ctaText,
imageUrls: item.pageType === "专题页" ? [] : source.imageUrls,
...buildTopicPatch(item.sourceCarName, source),
},
[item.id]: templatePatch,
}))
onAddAuditLog(`内容运营专员发起 ${item.sourceCarName} 源数据重置(待保存生效)`)
onAddAuditLog(`内容运营专员发起 ${item.sourceCarName} 内容重置(待保存生效)`)
}
const submitForReview = (item: MiniAppContent) => {
@@ -476,16 +560,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const createNewContent = () => {
const id = `c${Date.now()}`
const defaultPageType = ""
const tmpl = defaultPageType ? getTemplate(defaultPageType) : undefined
const draft: MiniAppContent = {
id,
sourceCarId: "",
sourceCarName: "",
pageType: "车型页",
pageType: defaultPageType,
title: "",
subtitle: "",
highlights: "",
description: "",
ctaText: "立即预约试驾",
ctaText: "",
scheduledPublishAt: "",
imageUrls: [],
topicNavTitle: "车型详情",
@@ -494,6 +580,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: nowString(),
...tmpl?.defaultValues,
}
setContents((prev) => [draft, ...prev])
setSelectedId(id)
@@ -527,14 +614,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
}
const updateTopicSection = (item: MiniAppContent, sectionId: string, patch: Partial<TopicSection>) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
updateContent(item.id, {
topicSections: sections.map((section) => (section.id === sectionId ? { ...section, ...patch } : section)),
})
}
const addTopicSection = (item: MiniAppContent) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
if (sections.length >= 6) return
updateContent(item.id, {
topicSections: [
@@ -551,7 +642,9 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
}
const removeTopicSection = (item: MiniAppContent, sectionId: string) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
if (sections.length <= 1) return
updateContent(item.id, {
topicSections: sections.filter((section) => section.id !== sectionId),
@@ -607,21 +700,38 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
if (viewMode === "editor" && selected) {
const isNewContent = !selected.sourceCarId
const canResetFromSource = Boolean(selected.sourceCarId && SOURCE_SNAPSHOTS[selected.sourceCarId])
const isTopicPage = selected.pageType === "专题页"
const currentTemplate = getTemplate(selected.pageType)
const selectedTemplateId = currentTemplate?.templateId ?? ""
const topicSections = (selected.topicSections && selected.topicSections.length > 0)
? selected.topicSections
: createTopicSectionsFromImages(selected.imageUrls)
const previewHeroImage = selected.imageUrls[0]
const highlights = selected.highlights
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
const PreviewComp = currentTemplate?.PreviewComponent
const EditorComp = currentTemplate?.EditorComponent
const handleBackToList = () => {
setPendingResetPatch((prev) => {
if (!prev[selected.id]) return prev
const next = { ...prev }
delete next[selected.id]
return next
})
if (!INITIAL_CONTENT_IDS.has(selected.id)) {
setContents((prev) => {
const next = prev.filter((item) => item.id !== selected.id)
if (next.length > 0) {
setSelectedId(next[0].id)
}
return next
})
}
setViewMode("list")
}
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={() => setViewMode("list")}>
<Button variant="outline" size="sm" onClick={handleBackToList}>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
</Button>
<div>
@@ -642,31 +752,32 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
{isNewContent && canEdit ? (
<select
className="h-10 rounded-lg border border-input bg-background px-3 text-sm"
value={selected.pageType}
value={selectedTemplateId}
onChange={(e) => {
const nextPageType = e.target.value as MiniAppContent["pageType"]
const nextTemplateId = e.target.value
const nextTemplate = getAllTemplates().find((tmpl) => tmpl.templateId === nextTemplateId)
const nextPageType = nextTemplate?.pageTypes[0] ?? ""
const snapshot = SOURCE_SNAPSHOTS[selected.sourceCarId]
const sourceName = selected.sourceCarName || "车型"
const templateDefaults = nextTemplate?.defaultValues ?? {}
const templatePatch = nextTemplate?.onSourceCarSelected?.(snapshot, { ...selected, pageType: nextPageType }) ?? {}
updateContent(selected.id, {
pageType: nextPageType,
...(nextPageType === "专题页" ? {
...buildTopicPatch(sourceName, snapshot),
imageUrls: [],
ctaText: "预约体验",
} : {}),
...templateDefaults,
...templatePatch,
})
}}
>
<option value="车型页"></option>
<option value="活动页"></option>
<option value="专题页"></option>
<option value=""></option>
{getAllTemplates().map((tmpl) => (
<option key={tmpl.templateId} value={tmpl.templateId}>{tmpl.displayName}</option>
))}
</select>
) : (
<Input value={selected.pageType} disabled />
<Input value={getTemplate(selected.pageType)?.displayName ?? selected.pageType} disabled />
)}
</div>
@@ -681,16 +792,11 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const option = SOURCE_CAR_OPTIONS.find((item) => item.id === nextId)
const snapshot = SOURCE_SNAPSHOTS[nextId]
const sourceName = option?.name ?? ""
const templatePatch = currentTemplate?.onSourceCarSelected?.(snapshot, { ...selected, sourceCarId: nextId, sourceCarName: sourceName }) ?? {}
updateContent(selected.id, {
sourceCarId: nextId,
sourceCarName: sourceName,
title: snapshot?.title ?? selected.title,
subtitle: snapshot?.subtitle ?? selected.subtitle,
highlights: snapshot?.highlights ?? selected.highlights,
description: snapshot?.description ?? selected.description,
ctaText: selected.pageType === "专题页" ? "预约体验" : (snapshot?.ctaText ?? selected.ctaText),
imageUrls: selected.pageType === "专题页" ? [] : (snapshot?.imageUrls ?? selected.imageUrls),
...buildTopicPatch(sourceName, snapshot),
...templatePatch,
})
if (snapshot) {
onAddAuditLog(`内容运营专员选择来源车型 ${option?.name ?? nextId} 并载入官网基线内容`)
@@ -707,140 +813,28 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
)}
</div>
{isTopicPage ? (
{selected.pageType ? (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={selected.topicNavTitle ?? `${selected.sourceCarName || "车型"}详情`}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { topicNavTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={selected.topicHeroTitle ?? "先锋设计"}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { topicHeroTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.ctaText} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { ctaText: e.target.value })} />
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">1-6</label>
<Button size="sm" variant="outline" disabled={!canEdit || topicSections.length >= 6} onClick={() => addTopicSection(selected)}>
</Button>
</div>
<div className="space-y-3">
{topicSections.map((section, index) => (
<div key={section.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground"> {index + 1}</p>
<Button size="sm" variant="ghost" disabled={!canEdit || topicSections.length <= 1} onClick={() => removeTopicSection(selected, section.id)}>
</Button>
</div>
<Input
value={section.title}
disabled={!canEdit}
onChange={(e) => updateTopicSection(selected, section.id, { title: e.target.value })}
placeholder="模块标题"
/>
<Input
value={section.imageUrl}
disabled={!canEdit}
onChange={(e) => updateTopicSection(selected, section.id, { imageUrl: e.target.value })}
placeholder="模块图片 URL"
/>
</div>
))}
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.title} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.subtitle} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { subtitle: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-24 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={selected.highlights}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { highlights: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-28 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={selected.description}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { description: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.ctaText} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { ctaText: e.target.value })} />
</div>
</>
)}
{!isTopicPage && workflowConfig.publishStrategy === "scheduled" && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
type="datetime-local"
disabled={!canEdit}
value={selected.scheduledPublishAt}
onChange={(e) => updateContent(selected.id, { scheduledPublishAt: e.target.value })}
/>
</div>
)}
{!isTopicPage && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input value={newImageUrl} disabled={!canEdit} onChange={(e) => setNewImageUrl(e.target.value)} placeholder="粘贴图片 URL" />
<Button variant="outline" disabled={!canEdit} onClick={() => addImage(selected)}></Button>
</div>
<div className="grid grid-cols-2 gap-2">
{selected.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${selected.sourceCarName}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex justify-end">
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => removeImage(selected, idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
{EditorComp && (
<EditorComp
content={selected}
canEdit={canEdit}
onUpdate={(patch) => updateContent(selected.id, patch)}
workflowConfig={workflowConfig}
newImageUrl={newImageUrl}
onNewImageUrlChange={setNewImageUrl}
onAddImage={() => addImage(selected)}
onRemoveImage={(idx) => removeImage(selected, idx)}
topicSections={topicSections}
onUpdateTopicSection={(sectionId, patch) => updateTopicSection(selected, sectionId, patch)}
onAddTopicSection={() => addTopicSection(selected)}
onRemoveTopicSection={(sectionId) => removeTopicSection(selected, sectionId)}
/>
)}
{pendingResetPatch[selected.id] && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
稿稿
稿
</div>
)}
@@ -862,6 +856,10 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</Button>
</div>
</div>
</>
) : (
<div className="text-sm text-muted-foreground py-8 text-center"></div>
)}
</CardContent>
</Card>
@@ -869,150 +867,14 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>
{isTopicPage
? "专题页预览:按来源车型模板自动同步,可对模块图文做手工编辑。"
: "按钮上方展示预约试驾表单视觉区(仅演示,不提交;真实留资将直接进入企业 CRM。"}
{currentTemplate?.previewDescription ?? "页面预览"}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{isTopicPage ? (
<div className="min-h-screen w-[360px] bg-white text-[#1a1a1a] flex flex-col relative overflow-hidden shadow-2xl rounded-[28px] border border-gray-200">
<div className="px-8 pt-4 pb-2 flex justify-between items-center text-sm font-semibold">
<span>9:41</span>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rotate-45 border border-current inline-block" />
<span className="h-2.5 w-2.5 rotate-45 border border-current inline-block" />
<div className="w-6 h-3 border border-current rounded-sm relative">
<div className="absolute inset-0.5 bg-current rounded-[2px] w-3" />
</div>
</div>
</div>
<header className="px-4 py-2 flex items-center justify-between sticky top-0 bg-white z-10">
<button className="p-2 rounded-full">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-[17px] font-medium">{selected.topicNavTitle || `${selected.sourceCarName || "车型"}详情`}</h1>
<div className="flex items-center bg-gray-50 border border-gray-200 rounded-full px-3 py-1.5 gap-3">
<span className="text-xs text-gray-500">···</span>
<div className="w-px h-4 bg-gray-300" />
<span className="text-xs text-gray-500">x</span>
</div>
</header>
<main className="flex-1 overflow-y-auto pb-32">
<section className="pt-8 pb-6 text-center">
<h2 className="text-[32px] font-bold tracking-tight mb-2">{selected.topicHeroTitle || "先锋设计"}</h2>
<div className="w-12 h-[3px] bg-[#d51c2a] mx-auto" />
</section>
<div className="space-y-4 px-0 max-h-[520px] overflow-y-auto">
{topicSections.map((section) => (
<section key={section.id} className="bg-[#f8f9fb] rounded-t-[32px] overflow-hidden">
<div className="aspect-[16/9] relative overflow-hidden">
{section.imageUrl ? (
<img
src={section.imageUrl}
alt={section.title}
className="w-full h-full object-cover mix-blend-multiply"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-gray-500 bg-gray-100"></div>
)}
</div>
<div className="px-6 py-2">
<h3 className="text-[22px] font-normal text-gray-700">{section.title}</h3>
</div>
</section>
))}
</div>
</main>
<footer className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-md px-4 pt-2 pb-8 border-t border-gray-100">
<button className="w-full bg-[#1c1f23] text-white py-4 rounded-md text-[16px] font-medium">
{selected.ctaText || "预约体验"}
</button>
<div className="mt-6 w-32 h-1.5 bg-black/80 mx-auto rounded-full" />
</footer>
</div>
{selected.pageType && PreviewComp ? (
<PreviewComp content={selected} workflowConfig={workflowConfig} />
) : (
<div className="w-[360px] rounded-[28px] border border-gray-200 bg-white shadow-2xl overflow-hidden">
<div className="h-9 bg-black text-white px-4 flex items-center text-[10px] tracking-[0.18em] uppercase">Audi</div>
<div className="relative">
{previewHeroImage ? (
<img src={previewHeroImage} alt={selected.title} className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-gray-100 text-xs text-gray-500"></div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent" />
<div className="absolute left-4 right-4 bottom-3 text-white">
<p className="text-lg font-bold tracking-tight">{selected.title}</p>
<p className="text-xs opacity-85">{selected.subtitle}</p>
</div>
</div>
<div className="p-4">
<div className="mb-3 grid grid-cols-3 gap-2">
{selected.imageUrls.slice(0, 3).map((url, idx) => (
<img key={`${url}-${idx}`} src={url} alt={`thumb-${idx}`} className="h-16 w-full rounded object-cover" />
))}
</div>
<div className="space-y-2">
{highlights.map((item) => (
<div key={item} className="inline-flex mr-2 mb-1 rounded-full border px-2 py-0.5 text-[10px] text-gray-600">
{item}
</div>
))}
</div>
<p className="mt-3 text-sm leading-6 text-gray-600">{selected.description}</p>
{workflowConfig.publishStrategy === "scheduled" && selected.scheduledPublishAt && (
<div className="mt-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
{selected.scheduledPublishAt.replace("T", " ")}
</div>
)}
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入您的姓名" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入手机号" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<div className="flex gap-2">
<select className="h-8 flex-1 rounded-lg border border-input bg-white px-2 text-sm text-gray-500" disabled>
<option></option>
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => alert("获取位置功能为原型占位提示")}
>
</Button>
</div>
</div>
<label className="flex items-start gap-2 text-[11px] leading-5 text-gray-600">
<input type="checkbox" className="mt-0.5" />
<span>
CRM
</span>
</label>
</div>
<Button className="mt-4 w-full bg-audi-black hover:bg-audi-dark-gray text-white" disabled>
{selected.ctaText || "立即预约试驾"}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground py-8"></div>
)}
</CardContent>
</Card>
@@ -1056,7 +918,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-gray-100">
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
@@ -1071,7 +933,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<p className="text-xs text-muted-foreground">{item.sourceCarId}</p>
</TableCell>
<TableCell>
<Badge variant="outline">{item.pageType}</Badge>
<Badge variant="outline">{getTemplate(item.pageType)?.displayName ?? item.pageType}</Badge>
</TableCell>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
@@ -1092,21 +954,21 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</Table>
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-xs text-muted-foreground"> 7 10 </p>
<p className="text-xs text-muted-foreground"> 7 {listTotalPages} </p>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage <= 1} onClick={() => setListPage((prev) => Math.max(1, prev - 1))}></Button>
{Array.from({ length: listTotalPages }, (_, index) => (
<Button
key={`mini-page-${index + 1}`}
size="sm"
variant={index + 1 === listPage ? "default" : "outline"}
className={index + 1 === listPage ? "bg-audi-black hover:bg-audi-dark-gray text-white" : ""}
disabled
onClick={() => setListPage(index + 1)}
>
{index + 1}
</Button>
))}
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage >= listTotalPages} onClick={() => setListPage((prev) => Math.min(listTotalPages, prev + 1))}></Button>
</div>
</div>
</CardContent>

View File

@@ -0,0 +1,95 @@
import { registerTemplate } from "./template-registry"
import type { PageTemplate } from "./types"
import { CarPagePreview } from "./templates/car-page/CarPagePreview"
import { CarPageEditor } from "./templates/car-page/CarPageEditor"
import { PureImagePagePreview } from "./templates/pure-image-page/PureImagePagePreview"
import { PureImagePageEditor } from "./templates/pure-image-page/PureImagePageEditor"
import { TopicPagePreview } from "./templates/topic-page/TopicPagePreview"
import { TopicPageEditor } from "./templates/topic-page/TopicPageEditor"
import { buildTopicPatch, createTopicSectionsFromImages } from "./templates/topic-page/helpers"
const carPageTemplate: PageTemplate = {
templateId: "car-page",
displayName: "车型营销模板",
pageTypes: ["车型页", "活动页"],
defaultValues: {
ctaText: "立即预约试驾",
imageUrls: [],
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: snapshot?.ctaText ?? current.ctaText,
imageUrls: snapshot?.imageUrls ?? current.imageUrls,
}
},
PreviewComponent: CarPagePreview,
EditorComponent: CarPageEditor,
previewDescription: "按钮上方展示预约试驾表单视觉区(仅演示,不提交;真实留资将直接进入企业 CRM。",
}
const topicPageTemplate: PageTemplate = {
templateId: "topic-page",
displayName: "专题图文模板",
pageTypes: ["专题页"],
defaultValues: {
ctaText: "预约体验",
imageUrls: [],
topicNavTitle: "车型详情",
topicHeroTitle: "先锋设计",
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: "预约体验",
imageUrls: [],
...buildTopicPatch(current.sourceCarName, snapshot),
}
},
PreviewComponent: TopicPagePreview,
EditorComponent: TopicPageEditor,
previewDescription: "专题页预览:按来源车型模板自动同步,可对模块图文做手工编辑。",
}
const pureImagePageTemplate: PageTemplate = {
templateId: "pure-image-page",
displayName: "纯图平铺模板",
pageTypes: ["纯图页"],
defaultValues: {
title: "E7X详情",
ctaText: "预约体验",
subtitle: "",
highlights: "",
description: "",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: snapshot?.ctaText ?? current.ctaText,
imageUrls: snapshot?.imageUrls ?? current.imageUrls,
}
},
PreviewComponent: PureImagePagePreview,
EditorComponent: PureImagePageEditor,
previewDescription: "页面体仅保留纯图平铺内容,无文案、无圆角、无间距。",
}
registerTemplate(carPageTemplate)
registerTemplate(topicPageTemplate)
registerTemplate(pureImagePageTemplate)
export { createTopicSectionsFromImages, buildTopicPatch }

View File

@@ -0,0 +1,23 @@
import type { PageTemplate } from "./types"
const templates: PageTemplate[] = []
const pageTypeMap: Map<string, PageTemplate> = new Map()
export function registerTemplate(template: PageTemplate) {
templates.push(template)
for (const pt of template.pageTypes) {
pageTypeMap.set(pt, template)
}
}
export function getTemplate(pageType: string): PageTemplate | undefined {
return pageTypeMap.get(pageType)
}
export function getAllPageTypes(): string[] {
return Array.from(pageTypeMap.keys())
}
export function getAllTemplates(): PageTemplate[] {
return [...templates]
}

View File

@@ -0,0 +1,86 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
export function CarPageEditor({
content,
canEdit,
onUpdate,
workflowConfig,
newImageUrl,
onNewImageUrlChange,
onAddImage,
onRemoveImage,
}: EditorProps) {
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.title} disabled={!canEdit} onChange={(e) => onUpdate({ title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.subtitle} disabled={!canEdit} onChange={(e) => onUpdate({ subtitle: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-24 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={content.highlights}
disabled={!canEdit}
onChange={(e) => onUpdate({ highlights: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-28 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={content.description}
disabled={!canEdit}
onChange={(e) => onUpdate({ description: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
{workflowConfig.publishStrategy === "scheduled" && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
type="datetime-local"
disabled={!canEdit}
value={content.scheduledPublishAt}
onChange={(e) => onUpdate({ scheduledPublishAt: e.target.value })}
/>
</div>
)}
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input value={newImageUrl ?? ""} disabled={!canEdit} onChange={(e) => onNewImageUrlChange?.(e.target.value)} placeholder="粘贴图片 URL" />
<Button variant="outline" disabled={!canEdit} onClick={() => onAddImage?.()}></Button>
</div>
<div className="grid grid-cols-2 gap-2">
{content.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${content.sourceCarName}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex items-center justify-between gap-2">
<span className="truncate text-[11px] text-muted-foreground">{url}</span>
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => onRemoveImage?.(idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,124 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { PreviewProps } from "../../types"
export function CarPagePreview({ content, workflowConfig }: PreviewProps) {
const previewHeroImage = content.imageUrls[0]
const highlights = content.highlights
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
return (
<div className="flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-2xl">
<div className="flex h-11 items-end justify-between bg-black px-8 pb-2 text-[15px] font-semibold tracking-tight text-white">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="white" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="white" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-white/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-white" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-white/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-black/85 px-4 backdrop-blur-md">
<button className="p-2 text-white/90" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<div className="h-7" />
<div className="flex h-8 items-center rounded-full border border-white/10 bg-black/40 px-1">
<button className="border-r border-white/10 px-2 text-white/90" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-white/90" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="relative">
{previewHeroImage ? (
<img src={previewHeroImage} alt={content.title} className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-gray-100 text-xs text-gray-500"></div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent" />
<div className="absolute left-4 right-4 bottom-3 text-white">
<p className="text-lg font-bold tracking-tight">{content.title}</p>
<p className="text-xs opacity-85">{content.subtitle}</p>
</div>
</div>
<div className="p-4">
<div className="mb-3 grid grid-cols-3 gap-2">
{content.imageUrls.slice(0, 3).map((url, idx) => (
<img key={`${url}-${idx}`} src={url} alt={`thumb-${idx}`} className="h-16 w-full rounded object-cover" />
))}
</div>
<div className="space-y-2">
{highlights.map((item) => (
<div key={item} className="inline-flex mr-2 mb-1 rounded-full border px-2 py-0.5 text-[10px] text-gray-600">
{item}
</div>
))}
</div>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-gray-600">{content.description}</p>
{workflowConfig.publishStrategy === "scheduled" && content.scheduledPublishAt && (
<div className="mt-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
{content.scheduledPublishAt.replace("T", " ")}
</div>
)}
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入您的姓名" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入手机号" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<div className="flex gap-2">
<select className="h-8 flex-1 rounded-lg border border-input bg-white px-2 text-sm text-gray-500" disabled>
<option></option>
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => alert("获取位置功能为原型占位提示")}
>
</Button>
</div>
</div>
<label className="flex items-start gap-2 text-[11px] leading-5 text-gray-600">
<input type="checkbox" className="mt-0.5" />
<span>
CRM
</span>
</label>
</div>
<Button className="mt-4 w-full bg-audi-black hover:bg-audi-dark-gray text-white" disabled>
{content.ctaText || "立即预约试驾"}
</Button>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
export function PureImagePageEditor({
content,
canEdit,
onUpdate,
newImageUrl,
onNewImageUrlChange,
onAddImage,
onRemoveImage,
}: EditorProps) {
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.title} disabled={!canEdit} onChange={(e) => onUpdate({ title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input
value={newImageUrl ?? ""}
disabled={!canEdit}
onChange={(e) => onNewImageUrlChange?.(e.target.value)}
placeholder="粘贴图片 URL"
/>
<Button variant="outline" disabled={!canEdit} onClick={() => onAddImage?.()}>
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{content.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${content.title}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex items-center justify-between gap-2">
<span className="truncate text-[11px] text-muted-foreground">{url}</span>
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => onRemoveImage?.(idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import type { PreviewProps } from "../../types"
export function PureImagePagePreview({ content }: PreviewProps) {
const imageUrls = content.imageUrls.filter(Boolean)
return (
<div className="flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-black text-white shadow-2xl">
<div className="flex h-11 items-end justify-between px-8 pb-2 text-[15px] font-semibold tracking-tight">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="white" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="white" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-white/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-white" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-white/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-black/85 px-4 backdrop-blur-md">
<button className="p-2 text-white/90" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<h1 className="text-lg font-medium tracking-wide">{content.title || "E7X详情"}</h1>
<div className="flex h-8 items-center rounded-full border border-white/10 bg-black/40 px-1">
<button className="border-r border-white/10 px-2 text-white/90" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-white/90" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto bg-black [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{imageUrls.length > 0 ? (
imageUrls.map((url, idx) => (
<img
key={`${url}-${idx}`}
src={url}
alt={`${content.title || "E7X详情"}-${idx + 1}`}
className="block w-full"
/>
))
) : (
<div className="flex min-h-[480px] items-center justify-center bg-neutral-900 text-sm text-white/60">
</div>
)}
</main>
<footer className="shrink-0 bg-black px-5 py-3.5">
<button
type="button"
className="w-full border border-white bg-transparent py-2.5 text-sm font-medium text-white"
>
{content.ctaText || "预约体验"}
</button>
<div className="mt-3 flex justify-center">
<div className="h-1 w-24 rounded-full bg-white/20" />
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
import { createTopicSectionsFromImages } from "./helpers"
export function TopicPageEditor({
content,
canEdit,
onUpdate,
topicSections: topicSectionsProp,
onUpdateTopicSection,
onAddTopicSection,
onRemoveTopicSection,
}: EditorProps) {
const topicSections = topicSectionsProp
?? ((content.topicSections && content.topicSections.length > 0)
? content.topicSections
: createTopicSectionsFromImages(content.imageUrls))
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={content.topicNavTitle ?? `${content.sourceCarName || "车型"}详情`}
disabled={!canEdit}
onChange={(e) => onUpdate({ topicNavTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={content.topicHeroTitle ?? "先锋设计"}
disabled={!canEdit}
onChange={(e) => onUpdate({ topicHeroTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Button size="sm" variant="outline" disabled={!canEdit || topicSections.length >= 6} onClick={() => onAddTopicSection?.()}>
</Button>
</div>
<div className="space-y-3">
{topicSections.map((section, index) => (
<div key={section.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground"> {index + 1}</p>
<Button size="sm" variant="ghost" disabled={!canEdit || topicSections.length <= 1} onClick={() => onRemoveTopicSection?.(section.id)}>
</Button>
</div>
<Input
value={section.title}
disabled={!canEdit}
onChange={(e) => onUpdateTopicSection?.(section.id, { title: e.target.value })}
placeholder="模块标题"
/>
<Input
value={section.imageUrl}
disabled={!canEdit}
onChange={(e) => onUpdateTopicSection?.(section.id, { imageUrl: e.target.value })}
placeholder="模块图片 URL"
/>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,79 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import type { PreviewProps } from "../../types"
import { createTopicSectionsFromImages } from "./helpers"
export function TopicPagePreview({ content }: PreviewProps) {
const topicSections = (content.topicSections && content.topicSections.length > 0)
? content.topicSections
: createTopicSectionsFromImages(content.imageUrls)
return (
<div className="relative flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white text-[#1a1a1a] shadow-2xl">
<div className="flex h-11 items-end justify-between bg-white px-8 pb-2 text-[15px] font-semibold tracking-tight text-[#1a1a1a]">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="#1a1a1a" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="#1a1a1a" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-black/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-[#1a1a1a]" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-black/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-white px-4">
<button className="p-2 text-[#1a1a1a]" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<h1 className="text-lg font-medium tracking-wide">{content.topicNavTitle || `${content.sourceCarName || "车型"}详情`}</h1>
<div className="flex h-8 items-center rounded-full border border-gray-200 bg-gray-50 px-1">
<button className="border-r border-gray-200 px-2 text-gray-500" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-gray-500" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<section className="pt-4 pb-3 text-center">
<h2 className="mb-1 text-[28px] font-bold tracking-tight">{content.topicHeroTitle || "先锋设计"}</h2>
<div className="w-12 h-[3px] bg-[#d51c2a] mx-auto" />
</section>
<div className="space-y-2 px-0">
{topicSections.map((section) => (
<section key={section.id} className="bg-[#f8f9fb] rounded-t-[32px] overflow-hidden">
<div className="aspect-[16/9] relative overflow-hidden">
{section.imageUrl ? (
<img
src={section.imageUrl}
alt={section.title}
className="w-full h-full object-cover mix-blend-multiply"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-gray-500 bg-gray-100"></div>
)}
</div>
<div className="px-5 py-1.5">
<h3 className="text-[19px] font-normal text-gray-700">{section.title}</h3>
</div>
</section>
))}
</div>
</main>
<footer className="shrink-0 border-t border-gray-100 bg-white/80 px-4 pt-2 pb-4 backdrop-blur-md">
<button className="w-full rounded-md bg-[#1c1f23] py-3 text-[15px] font-medium text-white">
{content.ctaText || "预约体验"}
</button>
<div className="mx-auto mt-3 h-1 w-24 rounded-full bg-black/80" />
</footer>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import type { MiniAppContent, SourceSnapshot, TopicSection } from "../../types"
export function createTopicSectionsFromImages(images: string[]): TopicSection[] {
const picks = images.slice(0, 2)
const fallback = ["/images/cars/a7-1.jpg", "/images/cars/a7-2.jpg"]
const selected = picks.length > 0 ? picks : fallback
return selected.map((imageUrl, index) => ({
id: `topic-section-${index + 1}`,
title: index === 0 ? "RS竞速套件" : "宽体低趴 超长轴距",
imageUrl,
specLeftLabel: "长",
specRightLabel: "宽",
}))
}
export function buildTopicPatch(sourceCarName: string, snapshot?: SourceSnapshot): Pick<MiniAppContent, "topicNavTitle" | "topicHeroTitle" | "topicSections"> {
const hero = snapshot?.topicHeroTitle ?? snapshot?.subtitle ?? "先锋设计"
return {
topicNavTitle: snapshot?.topicNavTitle ?? `${sourceCarName || "车型"}详情`,
topicHeroTitle: hero,
topicSections: snapshot?.topicSections ?? createTopicSectionsFromImages(snapshot?.imageUrls ?? []),
}
}

View File

@@ -0,0 +1,85 @@
import type * as React from "react"
import type { WorkflowConfig } from "@/App"
export type WorkflowStatus = "draft" | "prepublished" | "published"
export type TopicSection = {
id: string
title: string
imageUrl: string
specLeftLabel: string
specRightLabel: string
}
export type SourceSnapshot = {
title: string
subtitle: string
highlights: string
description: string
ctaText: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
}
export type MiniAppContent = {
id: string
sourceCarId: string
sourceCarName: string
pageType: string
title: string
subtitle: string
highlights: string
description: string
ctaText: string
scheduledPublishAt: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
workflowStatus: WorkflowStatus
updatedBy: string
updatedAt: string
}
export type PreviewProps = {
content: MiniAppContent
workflowConfig: WorkflowConfig
}
export type EditorProps = {
content: MiniAppContent
canEdit: boolean
onUpdate: (patch: Partial<MiniAppContent>) => void
workflowConfig: WorkflowConfig
/** For car-page image management */
newImageUrl?: string
onNewImageUrlChange?: (url: string) => void
onAddImage?: () => void
onRemoveImage?: (idx: number) => void
/** For topic-page section management */
onUpdateTopicSection?: (sectionId: string, patch: Partial<TopicSection>) => void
onAddTopicSection?: () => void
onRemoveTopicSection?: (sectionId: string) => void
topicSections?: TopicSection[]
}
export type PageTemplate = {
/** Unique template identifier (kebab-case) */
templateId: string
/** Human-friendly template name for UI display */
displayName: string
/** Page type labels that map to this template */
pageTypes: string[]
/** Default values when creating content with this template */
defaultValues: Partial<MiniAppContent>
/** Called when a source car is selected */
onSourceCarSelected?: (snapshot: SourceSnapshot | undefined, current: MiniAppContent) => Partial<MiniAppContent>
/** Preview component for the phone mockup */
PreviewComponent: React.ComponentType<PreviewProps>
/** Editor fields component */
EditorComponent: React.ComponentType<EditorProps>
/** Description shown below the preview card */
previewDescription?: string
}

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import {
Car,
NotebookPen,
LayoutTemplate,
UserCog,
Settings,
LogOut,
@@ -34,6 +35,11 @@ const navItems = [
icon: NotebookPen,
id: "mini-content",
},
{
title: "小程序页面模板",
icon: LayoutTemplate,
id: "templates",
},
{
title: "用户与权限",
icon: UserCog,

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
import { getAllTemplates } from "@/components/content-library/template-registry"
import "@/components/content-library/register-templates"
import type { MiniAppContent } from "@/components/content-library/types"
import type { WorkflowConfig } from "@/App"
const defaultWorkflowConfig: WorkflowConfig = {
enablePrePublish: true,
allowRecall: true,
publishStrategy: "manual",
defaultPublishTime: "10:00",
syncFrequency: "weekly",
syncExecutionTime: "02:00",
retryCount: 2,
manualConfirmSync: true,
}
function makeSampleContent(overrides: Partial<MiniAppContent> = {}): MiniAppContent {
return {
id: "sample",
sourceCarId: "AUDI_A7SPB",
sourceCarName: "Audi A7 Sportback",
pageType: "车型页",
title: "A7 Sportback",
subtitle: "优雅轿跑",
highlights: "340 马力 · quattro 四驱 · 5.3s 破百",
description: "以优雅之姿,释放澎湃动力。",
ctaText: "立即预约试驾",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
],
workflowStatus: "draft",
updatedBy: "系统",
updatedAt: "2026-04-16",
...overrides,
}
}
function getTemplateSampleContent(templateId: string, defaults: Partial<MiniAppContent>): MiniAppContent {
if (templateId === "car-page") {
return makeSampleContent({
sourceCarId: "a3",
sourceCarName: "Audi A3 Sportback",
pageType: "车型页",
title: "Audi A3 Sportback",
subtitle: "进取,不负期待",
highlights: "数字座舱\n城市通勤\n智能互联",
description: "适合城市用户的豪华紧凑车型,兼顾效率与智能体验。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/a3-1.jpg",
"/images/cars/a3-2.jpg",
],
})
}
return makeSampleContent({
pageType: defaults.pageType as string,
title: (defaults.title as string) ?? "A7 Sportback",
ctaText: (defaults.ctaText as string) ?? "了解更多",
imageUrls: (defaults.imageUrls as string[]) ?? [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
],
topicNavTitle: defaults.topicNavTitle as string | undefined,
topicHeroTitle: defaults.topicHeroTitle as string | undefined,
topicSections: defaults.topicSections as MiniAppContent["topicSections"],
})
}
export function TemplateManagement() {
const templates = getAllTemplates()
const [expandedTemplateId, setExpandedTemplateId] = React.useState<string | null>(null)
const expandedTemplate = expandedTemplateId
? templates.find((t) => t.templateId === expandedTemplateId)
: null
return (
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-bold"></h2>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
{/* Preview cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{templates.map((t) => {
const Preview = t.PreviewComponent
const sampleContent = getTemplateSampleContent(t.templateId, {
...t.defaultValues,
pageType: t.pageTypes[0],
})
return (
<Card key={t.templateId} className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t.displayName}</CardTitle>
{t.previewDescription && (
<p className="text-xs text-muted-foreground mt-1">{t.previewDescription}</p>
)}
</CardHeader>
<CardContent className="flex justify-center">
<button
type="button"
className="cursor-pointer rounded-xl border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow w-[200px] h-[350px]"
onClick={() => setExpandedTemplateId(t.templateId)}
title="点击放大预览"
>
<div className="origin-top-left scale-[0.555] w-[360px] pointer-events-none">
<Preview content={sampleContent} workflowConfig={defaultWorkflowConfig} />
</div>
</button>
</CardContent>
</Card>
)
})}
</div>
{/* Fullscreen modal */}
{expandedTemplate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={() => setExpandedTemplateId(null)}
>
<div
className="relative bg-white rounded-2xl shadow-2xl p-6 max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="font-semibold">{expandedTemplate.displayName}</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setExpandedTemplateId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex justify-center">
<expandedTemplate.PreviewComponent
content={getTemplateSampleContent(expandedTemplate.templateId, {
...expandedTemplate.defaultValues,
pageType: expandedTemplate.pageTypes[0],
})}
workflowConfig={defaultWorkflowConfig}
/>
</div>
</div>
</div>
)}
</div>
)
}