feat: 官网代码全量更新 (2026-04-18)
这个提交包含在:
+109
-25
@@ -90,28 +90,66 @@
|
||||
<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)">删除</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-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">
|
||||
<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 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>
|
||||
@@ -398,7 +436,7 @@ 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,
|
||||
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch, NAlert,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
@@ -497,19 +535,33 @@ function normalizeData(raw) {
|
||||
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 : [],
|
||||
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 || [],
|
||||
},
|
||||
// 保留原始 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 || '',
|
||||
@@ -529,6 +581,38 @@ function normalizeItinerary(items) {
|
||||
})
|
||||
}
|
||||
|
||||
// 保存前把 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 = []
|
||||
@@ -565,7 +649,7 @@ async function handleSave() {
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: route.params.key, data: data.value },
|
||||
body: { key: route.params.key, data: serializeForSave(data.value) },
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="1" :y-gap="12">
|
||||
<n-gi v-for="(item, index) in productLines" :key="item.key">
|
||||
<n-card hoverable>
|
||||
<n-card hoverable translate="no">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-space align="center" :size="16">
|
||||
<span style="font-size: 14px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #f0f7f1; border-radius: 8px; font-weight: 600; color: #3a7d44">{{ item.name?.charAt(0) || 'P' }}</span>
|
||||
@@ -20,10 +20,10 @@
|
||||
{{ item.name }}
|
||||
<n-tag v-if="item.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ item.version }}</n-tag>
|
||||
<n-tag size="small" :type="seasonType(item.season)" :bordered="false" style="margin-left: 4px">{{ seasonLabel(item.season) }}</n-tag>
|
||||
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ item.type === 'camp' ? '营地' : '线路' }}</n-tag>
|
||||
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel(item.type) }}</n-tag>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
|
||||
key: {{ item.key }} · 排序: {{ item.sortOrder }}
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px" translate="no">
|
||||
排序 {{ item.sortOrder }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
@@ -72,7 +72,7 @@
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }, { label: '课程', value: 'course' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -106,6 +106,9 @@ const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 't
|
||||
function seasonLabel(s) {
|
||||
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
|
||||
}
|
||||
function typeLabel(t) {
|
||||
return { tour: '线路', camp: '营地', course: '课程' }[t] || t
|
||||
}
|
||||
|
||||
function seasonType(s) {
|
||||
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
|
||||
|
||||
在新工单中引用
屏蔽一个用户