668 行
44 KiB
Vue
668 行
44 KiB
Vue
<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-alert v-if="ver._v3Itinerary || ver._v3Pricing" type="info" style="margin-top:12px" :show-icon="true" title="此版本使用 V3 结构(分天字符串行程 + 分日期报价)">
|
||
<template #default>
|
||
行程用纯文本分天表达,报价按日期区间记录。可直接修改,不会破坏原始结构。如需深度改动(加字段/重排结构),请联系开发。
|
||
</template>
|
||
</n-alert>
|
||
<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); if(ver._v3Itinerary) ver._v3Itinerary.splice(ii,1)">删除</n-button></n-space>
|
||
<n-form-item v-if="ver._v3Itinerary" label="当日行程(纯文本)" :show-feedback="false">
|
||
<n-input v-model:value="it.title" type="textarea" :rows="2" placeholder="D1 各地→海拉尔(接机+自由活动)" />
|
||
</n-form-item>
|
||
<template v-else>
|
||
<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>
|
||
</template>
|
||
</div>
|
||
<n-button dashed block @click="addItineraryDay(ver)">添加行程日</n-button>
|
||
</n-card>
|
||
<n-card title="价格配置" style="margin-top:12px">
|
||
<template v-if="ver._v3Pricing">
|
||
<n-alert type="warning" :show-icon="false" style="margin-bottom:12px">
|
||
此产品使用 V3 分日期报价,请在下方表格直接编辑。空日期或非正数价格会保留但不会生效。
|
||
</n-alert>
|
||
<div style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead>
|
||
<tr style="background:#f5f7fa">
|
||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb;width:40%">出行日期</th>
|
||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">成人价</th>
|
||
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">儿童价</th>
|
||
<th style="padding:8px;border:1px solid #e5e7eb;width:60px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, ri) in ver._v3Pricing" :key="ri">
|
||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input v-model:value="row.date" placeholder="9月1日-9月9日" size="small" /></td>
|
||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.adult" :min="0" size="small" style="width:100%" /></td>
|
||
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.child" :min="0" size="small" style="width:100%" /></td>
|
||
<td style="padding:4px;border:1px solid #e5e7eb;text-align:center"><n-button size="tiny" type="error" quaternary @click="ver._v3Pricing.splice(ri,1)">删</n-button></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<n-button dashed block size="small" style="margin-top:8px" @click="ver._v3Pricing.push({date:'',adult:0,child:0})">添加一行</n-button>
|
||
</template>
|
||
<template v-else>
|
||
<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>
|
||
</template>
|
||
</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, NAlert,
|
||
} 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
|
||
}
|
||
|
||
// 判断是否为 V3 新结构(pricing 为数组 / itinerary 为字符串数组)
|
||
function isV3Pricing(p) { return Array.isArray(p) }
|
||
function isV3Itinerary(it) { return Array.isArray(it) && it.length > 0 && typeof it[0] === 'string' }
|
||
|
||
function normalizeVersion(v) {
|
||
const v3Pricing = isV3Pricing(v.pricing) ? v.pricing : null
|
||
const v3Itinerary = isV3Itinerary(v.itinerary) ? [...v.itinerary] : null
|
||
|
||
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 : [],
|
||
// 保留原始 V3 结构,编辑后原样回写,避免数据丢失
|
||
_v3Pricing: v3Pricing,
|
||
_v3Itinerary: v3Itinerary,
|
||
itinerary: v3Itinerary
|
||
? v3Itinerary.map((str, i) => ({ day: i + 1, title: str, summary: '', hotel: '', _v3: true }))
|
||
: normalizeItinerary(v.itinerary || []),
|
||
pricing: v3Pricing
|
||
? { _v3: true, label: '', note: '', startPrice: 0, peakPrice: 0, highStartPrice: 0, highPeakPrice: 0, tiers: [] }
|
||
: {
|
||
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 }
|
||
})
|
||
}
|
||
|
||
// 保存前把 V3 版本的 pricing/itinerary 还原为原始结构,并剥离 UI 临时字段
|
||
function serializeForSave(raw) {
|
||
const clone = JSON.parse(JSON.stringify(raw))
|
||
if (Array.isArray(clone.versions)) {
|
||
clone.versions = clone.versions.map(v => {
|
||
const out = { ...v }
|
||
// V3 行程:恢复为字符串数组(但允许用户编辑文本)
|
||
if (Array.isArray(v._v3Itinerary)) {
|
||
out.itinerary = (v.itinerary || []).map(it => it.title || '').filter(Boolean)
|
||
}
|
||
// V3 报价:整份保留,不从空对象覆盖
|
||
if (Array.isArray(v._v3Pricing)) {
|
||
out.pricing = v._v3Pricing
|
||
}
|
||
delete out._v3Pricing
|
||
delete out._v3Itinerary
|
||
if (out.itinerary && Array.isArray(out.itinerary)) {
|
||
out.itinerary = out.itinerary.map(it => {
|
||
if (it && typeof it === 'object') { const { _v3, ...rest } = it; return rest }
|
||
return it
|
||
})
|
||
}
|
||
if (out.pricing && typeof out.pricing === 'object' && !Array.isArray(out.pricing)) {
|
||
const { _v3, ...rest } = out.pricing
|
||
out.pricing = rest
|
||
}
|
||
return out
|
||
})
|
||
}
|
||
return clone
|
||
}
|
||
|
||
// ── 工具函数 ──
|
||
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: serializeForSave(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>
|