Mimingguang 810ae2724d fix: 后台编辑器字段全覆盖 + 新建 about/versions/pricing 专用编辑器
- tour 编辑器补全:selectionGuide 3维度、isMainProduct、timeline 正确结构
- camp 编辑器补全:name、campEssentials
- calendar 补全:quickRecommendations、travelAdvice、recommendedProducts
- guides 补全:穿搭章节 months[] 每月数据
- brand 补全:ctaButtons、conversionPath、icpUrl、eContract
- qualifications 补全:enterpriseMatrix
- youji 补全:notes[].tags
- gallery 补全:页面配置(intro/stats/categories)
- 新建 about.vue 专用编辑器(14个分区)
- 新建 versions.vue 专用编辑器(8个分区)
- 新建 pricing.vue 专用编辑器(5个分区)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:06:50 +08:00

584 行
39 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<div style="max-width: 1200px">
<n-breadcrumb style="margin-bottom: 16px">
<n-breadcrumb-item @click="navigateTo('/admin/products')">产品线管理</n-breadcrumb-item>
<n-breadcrumb-item>{{ meta?.name || route.params.key }}</n-breadcrumb-item>
</n-breadcrumb>
<n-space justify="space-between" align="center" style="margin-bottom: 24px">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #111827">
{{ meta?.name || route.params.key }}
<n-tag v-if="meta?.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ meta.version }}</n-tag>
<n-tag v-if="meta?.type" size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel }}</n-tag>
</h1>
<n-space>
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</n-space>
</n-space>
<n-spin :show="loading">
<template v-if="data">
<!-- ============ TOUR 类型编辑器(夏/秋/冬季线路产品)============ -->
<n-tabs v-if="editorType === 'tour'" type="line" animated>
<n-tab-pane name="basic" tab="基本信息">
<n-card>
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="品牌"><n-input v-model:value="data.brand" placeholder="额吉的故乡" /></n-form-item></n-gi>
<n-gi><n-form-item label="版本号"><n-input v-model:value="data.version" placeholder="V9" /></n-form-item></n-gi>
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" placeholder="summer/autumn/winter" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="产品介绍"><n-input v-model:value="data.narrative" type="textarea" :rows="4" /></n-form-item>
<n-form-item label="产品风格"><n-input v-model:value="data.style" placeholder="产品风格描述" /></n-form-item>
<n-form-item label="价格哲学"><n-input v-model:value="data.pricingPhilosophy" type="textarea" :rows="3" placeholder="价格透明说明" /></n-form-item>
</n-form>
</n-card>
<!-- 产品亮点 -->
<n-card title="产品亮点" style="margin-top: 16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'highlights', {title:'',description:''})">添加</n-button></template>
<div v-for="(h, i) in data.highlights" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px;">
<n-space justify="space-between" style="margin-bottom: 8px"><span style="font-weight:500">亮点 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.highlights.splice(i,1)">删除</n-button></n-space>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="h.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="描述" :show-feedback="false"><n-input v-model:value="h.description" /></n-form-item></n-gi>
</n-grid>
</div>
</n-card>
<!-- 南线/北线特色(秋冬) -->
<template v-if="data.southHighlights !== undefined">
<n-card title="南线特色" style="margin-top: 16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'southHighlights', {title:'',description:''})">添加</n-button></template>
<div v-for="(h, i) in data.southHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.southHighlights.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card title="北线特色" style="margin-top: 16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'northHighlights', {title:'',description:''})">添加</n-button></template>
<div v-for="(h, i) in data.northHighlights" :key="i" style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center;">
<n-input v-model:value="h.title" placeholder="标题" style="width: 200px" /><n-input v-model:value="h.description" placeholder="描述" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.northHighlights.splice(i,1)">删</n-button>
</div>
</n-card>
</template>
</n-tab-pane>
<!-- 版本管理 -->
<n-tab-pane name="versions" tab="版本管理">
<n-space style="margin-bottom: 16px"><n-button type="primary" @click="addVersion">新增版本</n-button></n-space>
<n-collapse>
<n-collapse-item v-for="(ver, vi) in data.versions" :key="vi" :name="vi">
<template #header><n-space align="center"><strong>{{ ver.name || '版本 '+(vi+1) }}</strong><n-tag v-if="ver.tag" size="small" :bordered="false">{{ ver.tag }}</n-tag><span style="color:#9ca3af;font-size:12px">{{ ver.days }}天{{ ver.nights }}晚</span></n-space></template>
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.versions.splice(vi,1)">删除</n-button></template>
<n-card>
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="版本ID"><n-input v-model:value="ver.id" /></n-form-item></n-gi>
<n-gi><n-form-item label="名称"><n-input v-model:value="ver.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="标签"><n-input v-model:value="ver.tag" /></n-form-item></n-gi>
</n-grid>
<n-grid :cols="5" :x-gap="12">
<n-gi><n-form-item label="天数"><n-input-number v-model:value="ver.days" :min="1" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="ver.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="线路"><n-input v-model:value="ver.line" placeholder="南线/北线" /></n-form-item></n-gi>
<n-gi><n-form-item label="受众"><n-input v-model:value="ver.audience" /></n-form-item></n-gi>
<n-gi><n-form-item label="主推产品"><n-switch v-model:value="ver.isMainProduct" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="路线"><n-input v-model:value="ver.route" /></n-form-item>
<n-form-item label="描述"><n-input v-model:value="ver.description" type="textarea" :rows="3" /></n-form-item>
<n-form-item label="亮点"><n-dynamic-input v-model:value="ver.highlights" placeholder="输入亮点" /></n-form-item>
</n-form>
</n-card>
<n-card title="行程安排" style="margin-top:12px">
<div v-for="(it, ii) in ver.itinerary" :key="ii" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1)">删除</n-button></n-space>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
</div>
<n-button dashed block @click="addItineraryDay(ver)">添加行程日</n-button>
</n-card>
<n-card title="价格配置" style="margin-top:12px">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
</n-grid>
<n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
</n-grid>
</n-card>
<n-card title="费用说明" style="margin-top:12px">
<n-form-item label="住宿"><n-input v-model:value="ver.included.accommodation" /></n-form-item>
<n-form-item label="交通"><n-input v-model:value="ver.included.transport" /></n-form-item>
<n-form-item label="门票"><n-input v-model:value="ver.included.tickets" /></n-form-item>
<n-form-item label="活动"><n-input v-model:value="ver.included.activities" /></n-form-item>
</n-card>
</n-collapse-item>
</n-collapse>
<n-empty v-if="!data.versions?.length" description="暂无版本" style="padding:40px 0" />
</n-tab-pane>
<!-- 选择指南 -->
<n-tab-pane name="guide" tab="选择指南">
<n-card title="按假期长度推荐">
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byVacationLength'); data.selectionGuide.byVacationLength.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
<div v-for="(g, i) in data.selectionGuide?.byVacationLength || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byVacationLength.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card title="按孩子年龄推荐" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byChildAge'); data.selectionGuide.byChildAge.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
<div v-for="(g, i) in data.selectionGuide?.byChildAge || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byChildAge.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card title="按偏好推荐" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="ensureArray(data, 'selectionGuide.byPreference'); data.selectionGuide.byPreference.push({condition:'',recommendation:'',reason:''})">添加</n-button></template>
<div v-for="(g, i) in data.selectionGuide?.byPreference || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="g.condition" placeholder="条件" style="width:120px" />
<n-input v-model:value="g.recommendation" placeholder="推荐产品ID" style="width:160px" />
<n-input v-model:value="g.reason" placeholder="原因" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.selectionGuide.byPreference.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card title="版本迭代时间线" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="addToArray(data, 'timeline', {version:'',date:'',reason:'',changes:[]})">添加</n-button></template>
<n-collapse>
<n-collapse-item v-for="(t, i) in data.timeline || []" :key="i" :name="i">
<template #header><strong>{{ t.version || '版本 '+(i+1) }}</strong> <span style="color:#9ca3af;font-size:12px;margin-left:8px">{{ t.date }}</span></template>
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.timeline.splice(i,1)">删除</n-button></template>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="版本号"><n-input v-model:value="t.version" /></n-form-item></n-gi>
<n-gi><n-form-item label="日期"><n-input v-model:value="t.date" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="更新原因"><n-input v-model:value="t.reason" /></n-form-item>
<n-form-item label="变更列表">
<div v-for="(c, ci) in t.changes || []" :key="ci" style="display:flex;gap:8px;margin-bottom:6px;">
<n-input v-model:value="c.type" placeholder="类型" style="width:100px" />
<n-input v-model:value="c.text" placeholder="变更内容" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="t.changes.splice(ci,1)">删</n-button>
</div>
<n-button size="tiny" dashed @click="if(!t.changes) t.changes=[]; t.changes.push({type:'',text:''})">添加变更</n-button>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane>
<n-tab-pane name="faq" tab="常见问答">
<faq-editor :faq="data.faq" @update="data.faq = $event" />
</n-tab-pane>
</n-tabs>
<!-- ============ CAMP 类型编辑器(夏令营/冬令营)============ -->
<n-tabs v-else-if="editorType === 'camp'" type="line" animated>
<n-tab-pane name="basic" tab="基本信息">
<n-card>
<n-form label-placement="top">
<n-form-item label="营地名称"><n-input v-model:value="data.name" /></n-form-item>
<n-form-item label="营地定位"><n-input v-model:value="data.positioning" type="textarea" :rows="3" /></n-form-item>
<n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="天数"><n-input-number v-model:value="data.days" :min="1" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="晚数"><n-input-number v-model:value="data.nights" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="每期家庭数"><n-input-number v-model:value="data.maxFamilies" :min="1" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="总期数"><n-input-number v-model:value="data.totalSessions" :min="1" style="width:100%" /></n-form-item></n-gi>
</n-grid>
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="适龄范围"><n-input v-model:value="data.ageRange" /></n-form-item></n-gi>
<n-gi><n-form-item label="订金"><n-input v-model:value="data.deposit" /></n-form-item></n-gi>
<n-gi><n-form-item label="季节"><n-input v-model:value="data.season" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="路线"><n-input v-model:value="data.route" /></n-form-item>
<n-form-item v-if="data.whyHulunbuir !== undefined" label="为什么选呼伦贝尔"><n-input v-model:value="data.whyHulunbuir" type="textarea" :rows="3" /></n-form-item>
<n-form-item v-if="data.differenceFromV9 !== undefined" label="与V9系列的区别"><n-input v-model:value="data.differenceFromV9" type="textarea" :rows="3" /></n-form-item>
<n-form-item v-if="data.closingNote !== undefined" label="结语"><n-input v-model:value="data.closingNote" type="textarea" :rows="3" /></n-form-item>
</n-form>
</n-card>
<!-- 营地优势/原则 -->
<n-card v-if="data.campAdvantages !== undefined" title="亲子营优势" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.campAdvantages.push('')">添加</n-button></template>
<div v-for="(_, i) in data.campAdvantages" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.campAdvantages[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.campAdvantages.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card v-if="data.principles !== undefined" title="四大原则" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.principles.push('')">添加</n-button></template>
<div v-for="(_, i) in data.principles" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.principles[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.principles.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card v-if="data.sessions !== undefined" title="期数统计" style="margin-top:16px">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="2025年期数"><n-input-number v-model:value="data.sessions['2025']" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="2026年计划"><n-input-number v-model:value="data.sessions['2026plan']" style="width:100%" /></n-form-item></n-gi>
</n-grid>
</n-card>
</n-tab-pane>
<n-tab-pane name="itinerary" tab="行程安排">
<n-card>
<template #header-extra><n-button size="small" type="primary" @click="addCampItinerary">添加行程日</n-button></template>
<div v-for="(it, i) in data.itinerary || []" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day || i+1 }}</n-tag><n-button size="tiny" type="error" quaternary @click="data.itinerary.splice(i,1)">删除</n-button></n-space>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
<n-form-item v-if="it.highlights" label="亮点" :show-feedback="false" style="margin-top:8px"><n-dynamic-input v-model:value="it.highlights" placeholder="行程亮点" /></n-form-item>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane v-if="data.hotels !== undefined" name="hotels" tab="住宿安排">
<n-card>
<template #header-extra><n-button size="small" @click="data.hotels.push({name:'',star:'',nights:'',description:''})">添加酒店</n-button></template>
<div v-for="(h, i) in data.hotels" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<n-space justify="space-between" style="margin-bottom:8px"><span style="font-weight:500">酒店 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.hotels.splice(i,1)">删除</n-button></n-space>
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="名称"><n-input v-model:value="h.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="星级"><n-input v-model:value="h.star" /></n-form-item></n-gi>
<n-gi><n-form-item label="住宿晚数"><n-input v-model:value="h.nights" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="描述"><n-input v-model:value="h.description" type="textarea" :rows="2" /></n-form-item>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane v-if="data.photographer !== undefined || data.winterClothing !== undefined || data.serviceConfig !== undefined" name="service" tab="服务配置">
<n-card v-if="data.photographer" title="摄影师">
<n-form-item label="姓名"><n-input v-model:value="data.photographer.name" /></n-form-item>
<n-form-item label="介绍"><n-input v-model:value="data.photographer.description" type="textarea" :rows="2" /></n-form-item>
<n-form-item label="特别日程"><n-input v-model:value="data.photographer.specialDay2" /></n-form-item>
</n-card>
<n-card v-if="data.winterClothing" title="穿衣指南" style="margin-top:16px">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="上身"><n-input v-model:value="data.winterClothing.upper" /></n-form-item></n-gi>
<n-gi><n-form-item label="下身"><n-input v-model:value="data.winterClothing.lower" /></n-form-item></n-gi>
<n-gi><n-form-item label="鞋子"><n-input v-model:value="data.winterClothing.shoes" /></n-form-item></n-gi>
<n-gi><n-form-item label="配件"><n-input v-model:value="data.winterClothing.accessories" /></n-form-item></n-gi>
</n-grid>
</n-card>
<n-card v-if="data.serviceConfig" title="随行服务" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.serviceConfig.push('')">添加</n-button></template>
<div v-for="(_, i) in data.serviceConfig" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.serviceConfig[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.serviceConfig.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card v-if="data.campEssentials !== undefined" title="营地必备清单" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.campEssentials.push('')">添加</n-button></template>
<div v-for="(_, i) in data.campEssentials" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.campEssentials[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.campEssentials.splice(i,1)">删</n-button>
</div>
</n-card>
<n-card v-if="data.coreActivities !== undefined" title="核心体验" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.coreActivities.push('')">添加</n-button></template>
<div v-for="(_, i) in data.coreActivities" :key="i" style="margin-bottom:8px;display:flex;gap:8px;">
<n-input v-model:value="data.coreActivities[i]" style="flex:1" /><n-button size="tiny" type="error" quaternary @click="data.coreActivities.splice(i,1)">删</n-button>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane name="faq" tab="常见问答">
<faq-editor :faq="data.faq" @update="data.faq = $event" />
</n-tab-pane>
</n-tabs>
<!-- ============ COURSE 类型编辑器(研学课程)============ -->
<n-tabs v-else-if="editorType === 'course'" type="line" animated>
<n-tab-pane name="philosophy" tab="教育理念">
<n-card>
<n-form label-placement="top">
<n-form-item label="标题"><n-input v-model:value="data.philosophy.headline" /></n-form-item>
<n-form-item label="副标题"><n-input v-model:value="data.philosophy.tagline" /></n-form-item>
<n-form-item label="描述"><n-input v-model:value="data.philosophy.description" type="textarea" :rows="3" /></n-form-item>
</n-form>
</n-card>
<n-card title="价值观" style="margin-top:16px">
<template #header-extra><n-button size="small" @click="data.philosophy.values.push({label:'',desc:''})">添加</n-button></template>
<div v-for="(v, i) in data.philosophy.values" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
<n-input v-model:value="v.label" placeholder="标签" style="width:120px" /><n-input v-model:value="v.desc" placeholder="描述" style="flex:1" />
<n-button size="tiny" type="error" quaternary @click="data.philosophy.values.splice(i,1)">删</n-button>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane name="ageGroups" tab="年龄分组">
<n-card>
<template #header-extra><n-button size="small" type="primary" @click="data.ageGroups.push({id:'',label:'',headline:'',focus:'',description:'',highlights:[],recommendedModules:[]})">添加分组</n-button></template>
<n-collapse>
<n-collapse-item v-for="(g, gi) in data.ageGroups" :key="gi" :name="gi">
<template #header><strong>{{ g.label || '分组 '+(gi+1) }}</strong></template>
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.ageGroups.splice(gi,1)">删除</n-button></template>
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="ID"><n-input v-model:value="g.id" /></n-form-item></n-gi>
<n-gi><n-form-item label="标签"><n-input v-model:value="g.label" /></n-form-item></n-gi>
<n-gi><n-form-item label="Emoji"><n-input v-model:value="g.emoji" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="标题"><n-input v-model:value="g.headline" /></n-form-item>
<n-form-item label="焦点"><n-input v-model:value="g.focus" /></n-form-item>
<n-form-item label="描述"><n-input v-model:value="g.description" type="textarea" :rows="3" /></n-form-item>
<n-form-item label="能力清单(每行一项)"><n-input v-model:value="highlightsText[gi]" type="textarea" :rows="4" @blur="g.highlights = highlightsText[gi].split('\n').filter(Boolean)" /></n-form-item>
<n-form-item label="推荐模块ID逗号分隔"><n-input :value="g.recommendedModules?.join(',')" @update:value="v => g.recommendedModules = v.split(',').map(s=>s.trim()).filter(Boolean)" /></n-form-item>
</n-form>
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane>
<n-tab-pane name="modules" tab="课程模块">
<n-card>
<template #header-extra><n-button size="small" type="primary" @click="data.modules.push({id:'',icon:'',name:'',ageRange:'',duration:'',shortDesc:'',objectives:[],activities:[],philosophy:''})">添加模块</n-button></template>
<n-collapse>
<n-collapse-item v-for="(m, mi) in data.modules" :key="mi" :name="mi">
<template #header><strong>{{ m.name || '模块 '+(mi+1) }}</strong> <span style="color:#9ca3af;font-size:12px;margin-left:8px">{{ m.ageRange }}</span></template>
<template #header-extra><n-button size="tiny" type="error" quaternary @click.stop="data.modules.splice(mi,1)">删除</n-button></template>
<n-form label-placement="top">
<n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="ID"><n-input v-model:value="m.id" /></n-form-item></n-gi>
<n-gi><n-form-item label="图标"><n-input v-model:value="m.icon" /></n-form-item></n-gi>
<n-gi><n-form-item label="适龄"><n-input v-model:value="m.ageRange" /></n-form-item></n-gi>
<n-gi><n-form-item label="时长"><n-input v-model:value="m.duration" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="名称"><n-input v-model:value="m.name" /></n-form-item>
<n-form-item label="简述"><n-input v-model:value="m.shortDesc" type="textarea" :rows="2" /></n-form-item>
<n-form-item label="学习目标(每行一项)"><n-input :value="m.objectives?.join('\n')" @update:value="v => m.objectives = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
<n-form-item label="活动内容(每行一项)"><n-input :value="m.activities?.join('\n')" @update:value="v => m.activities = v.split('\n').filter(Boolean)" type="textarea" :rows="3" /></n-form-item>
<n-form-item label="教育理念引言"><n-input v-model:value="m.philosophy" type="textarea" :rows="2" /></n-form-item>
</n-form>
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane>
<n-tab-pane name="instructors" tab="导师团队">
<n-card>
<template #header-extra><n-button size="small" @click="ensureObj(data,'instructors',{placeholder:[]}); data.instructors.placeholder.push({name:'',title:'',credentials:'',bio:''})">添加导师</n-button></template>
<div v-for="(inst, i) in data.instructors?.placeholder || []" :key="i" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<n-space justify="space-between" style="margin-bottom:8px"><span style="font-weight:500">导师 {{ i+1 }}</span><n-button size="tiny" type="error" quaternary @click="data.instructors.placeholder.splice(i,1)">删除</n-button></n-space>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="姓名"><n-input v-model:value="inst.name" /></n-form-item></n-gi>
<n-gi><n-form-item label="头衔"><n-input v-model:value="inst.title" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="资质"><n-input v-model:value="inst.credentials" /></n-form-item>
<n-form-item label="简介"><n-input v-model:value="inst.bio" type="textarea" :rows="2" /></n-form-item>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane name="faq" tab="常见问答">
<faq-editor :faq="data.faq" @update="data.faq = $event" />
</n-tab-pane>
</n-tabs>
</template>
</n-spin>
<div class="save-bar" v-if="data">
<n-button secondary @click="navigateTo('/admin/products')">返回</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</div>
</template>
<script setup>
import { h } from 'vue'
import {
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch,
} from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const route = useRoute()
const { adminFetch } = useAdmin()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const data = ref(null)
const meta = ref(null)
const highlightsText = ref([])
const editorType = computed(() => {
const type = meta.value?.type
if (type === 'camp') return 'camp'
if (type === 'course') return 'course'
return 'tour'
})
const typeLabel = computed(() => ({ tour: '线路', camp: '营地', course: '课程' }[editorType.value] || ''))
// ── FAQ 子组件(内联)──
const FaqEditor = {
props: ['faq'],
emits: ['update'],
setup(props, { emit }) {
const items = computed({
get: () => props.faq || [],
set: (v) => emit('update', v),
})
return () => h(NCard, null, {
default: () => [
...items.value.map((f, i) =>
h('div', { style: 'margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;', key: i }, [
h(NSpace, { justify: 'space-between', style: 'margin-bottom:8px' }, () => [
h('span', { style: 'font-weight:500' }, `问答 ${i + 1}`),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => { const arr = [...items.value]; arr.splice(i, 1); emit('update', arr) } }, () => '删除'),
]),
h(NFormItem, { label: '问题', showFeedback: false, style: 'margin-bottom:8px' }, () => h(NInput, { value: f.question, 'onUpdate:value': v => { f.question = v } })),
h(NFormItem, { label: '答案', showFeedback: false }, () => h(NInput, { value: f.answer, 'onUpdate:value': v => { f.answer = v }, type: 'textarea', rows: 3 })),
])
),
h(NButton, { dashed: true, block: true, onClick: () => emit('update', [...items.value, { question: '', answer: '' }]) }, () => '添加问答'),
],
})
},
}
// ── 数据加载 ──
async function loadData() {
loading.value = true
try {
const plRes = await adminFetch('/api/admin/site-content?key=product-lines')
const productLines = typeof plRes.data === 'string' ? JSON.parse(plRes.data) : plRes.data
meta.value = productLines?.find(p => p.key === route.params.key)
const res = await adminFetch(`/api/admin/site-content?key=${route.params.key}`)
const raw = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
data.value = normalizeData(raw)
// Init highlights text for course ageGroups
if (data.value.ageGroups) {
highlightsText.value = data.value.ageGroups.map(g => (g.highlights || []).join('\n'))
}
} catch (e) {
message.error('加载失败: ' + (e.data?.message || e.message))
} finally {
loading.value = false
}
}
function normalizeData(raw) {
const d = { ...raw }
// Normalize FAQ
if (Array.isArray(d.faq)) {
d.faq = d.faq.map(f => ({ question: f.question || f.q || '', answer: f.answer || f.a || '' }))
} else {
d.faq = []
}
// Normalize highlights
if (Array.isArray(d.highlights)) {
d.highlights = d.highlights.map(h => typeof h === 'string' ? { title: h, description: '' } : h)
}
// Normalize versions (tour type)
if (Array.isArray(d.versions)) {
d.versions = d.versions.map(v => normalizeVersion(v))
}
// Ensure objects
if (!d.selectionGuide) d.selectionGuide = {}
if (!d.philosophy && editorType.value === 'course') d.philosophy = { headline: '', tagline: '', description: '', values: [] }
if (!d.ageGroups && editorType.value === 'course') d.ageGroups = []
if (!d.modules && editorType.value === 'course') d.modules = []
return d
}
function normalizeVersion(v) {
return {
id: v.id || '', name: v.name || '', tag: v.tag || '',
days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '',
audience: v.audience || '', description: v.description || '', isMainProduct: v.isMainProduct || false,
highlights: Array.isArray(v.highlights) ? v.highlights : [],
itinerary: normalizeItinerary(v.itinerary || []),
pricing: {
label: v.pricing?.label || '', note: v.pricing?.note || '',
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
tiers: v.pricing?.tiers || [],
},
included: {
accommodation: v.included?.accommodation || '', transport: v.included?.transport || '',
tickets: v.included?.tickets || '', activities: v.included?.activities || '',
},
}
}
function normalizeItinerary(items) {
return items.map((item, i) => {
if (typeof item === 'string') {
const match = item.match(/^(?:D|Day)\s*(\d+)\s*(.*)$/i)
const day = match ? parseInt(match[1]) : i + 1
const rest = match ? match[2].trim() : item
return { day, title: rest, summary: '', hotel: '' }
}
return { day: item.day || i + 1, title: item.title || '', summary: item.summary || '', hotel: item.hotel || '', highlights: item.highlights }
})
}
// ── 工具函数 ──
function addVersion() {
if (!data.value.versions) data.value.versions = []
data.value.versions.push(normalizeVersion({ id: '', name: '', days: 5, nights: 4 }))
}
function addItineraryDay(ver) {
if (!ver.itinerary) ver.itinerary = []
ver.itinerary.push({ day: ver.itinerary.length + 1, title: '', summary: '', hotel: '' })
}
function addCampItinerary() {
if (!data.value.itinerary) data.value.itinerary = []
data.value.itinerary.push({ day: data.value.itinerary.length + 1, title: '', summary: '', hotel: '', highlights: [] })
}
function addToArray(obj, key, item) {
if (!obj[key]) obj[key] = []
obj[key].push(typeof item === 'object' ? { ...item } : item)
}
function ensureArray(obj, path) {
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]]) cur[parts[i]] = {}
cur = cur[parts[i]]
}
if (!Array.isArray(cur[parts[parts.length - 1]])) cur[parts[parts.length - 1]] = []
}
function ensureObj(obj, key, defaultVal) {
if (!obj[key]) obj[key] = defaultVal
}
// ── 保存 ──
async function handleSave() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: route.params.key, data: data.value },
})
message.success('保存成功')
} catch (e) {
message.error('保存失败: ' + (e.data?.message || e.message))
} finally {
saving.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.save-bar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 16px 0; margin-top: 24px; background: #f0f2f5; border-top: 1px solid #e5e7eb; }
</style>