feat: 官网代码全量更新 (2026-04-18)

这个提交包含在:
wx
2026-04-18 15:13:28 +08:00
父节点 fcac8f88db
当前提交 f91ecbb093
共修改 135 个文件,包含 10307 行新增和 3349 行删除
+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) {
+8 -5
查看文件
@@ -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'