feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范 - 前台 usePublicApi 切换到 API 调用(GET /api/content/[key]) - 前后台数据源统一(site_content 表) - 产品线统一管理(tour/camp/course 三套编辑器) - 产品数据归一化(itinerary/faq/pricing 格式统一) - 表单提交 API 改写入 submissions 表 - 新增 submissions 标记已读 API - site-content PUT 支持 upsert - 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb) - 消除 PageHero 类型警告(useSEO reactive 修复) - 25 个前台页面全部 HTTP 200,展示效果不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,544 @@
|
||||
<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="4" :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-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="addToArray(data, 'timeline', {year:'',title:'',description:''})">添加</n-button></template>
|
||||
<div v-for="(t, i) in data.timeline || []" :key="i" style="margin-bottom:8px;display:flex;gap:8px;align-items:center;">
|
||||
<n-input v-model:value="t.year" placeholder="年份" style="width:80px" />
|
||||
<n-input v-model:value="t.title" placeholder="标题" style="width:160px" />
|
||||
<n-input v-model:value="t.description" placeholder="描述" style="flex:1" />
|
||||
<n-button size="tiny" type="error" quaternary @click="data.timeline.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>
|
||||
|
||||
<!-- ============ 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.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.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,
|
||||
} 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 || '',
|
||||
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>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">产品线管理</h1>
|
||||
<p style="margin: 0; font-size: 13px; color: #9ca3af">管理所有产品线 · 支持动态增删</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="showAddModal = true">新增产品线</n-button>
|
||||
</n-space>
|
||||
|
||||
<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-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>
|
||||
<div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #111827">
|
||||
{{ 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>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
|
||||
key: {{ item.key }} · 排序: {{ item.sortOrder }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button size="small" @click="moveUp(index)" :disabled="index === 0">上移</n-button>
|
||||
<n-button size="small" @click="moveDown(index)" :disabled="index === productLines.length - 1">下移</n-button>
|
||||
<n-button size="small" type="primary" @click="navigateTo(`/admin/products/${item.key}`)">编辑内容</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete(index)">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" quaternary>删除</n-button>
|
||||
</template>
|
||||
确定删除「{{ item.name }}」产品线?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && productLines.length === 0" description="暂无产品线" style="padding: 60px 0" />
|
||||
</n-spin>
|
||||
|
||||
<!-- 新增弹窗 -->
|
||||
<n-modal v-model:show="showAddModal" preset="dialog" title="新增产品线" positive-text="确定" negative-text="取消" @positive-click="handleAdd">
|
||||
<n-form label-placement="top" style="margin-top: 16px">
|
||||
<n-form-item label="产品线名称" required>
|
||||
<n-input v-model:value="newItem.name" placeholder="如:额吉的故乡" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="Key(唯一标识)" required>
|
||||
<n-input v-model:value="newItem.key" placeholder="如:spring-products" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="版本号">
|
||||
<n-input v-model:value="newItem.version" placeholder="如:V1" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="季节">
|
||||
<n-select v-model:value="newItem.season" :options="seasonOptions" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="类型">
|
||||
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NPopconfirm } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
|
||||
const { adminFetch } = useAdmin()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const productLines = ref([])
|
||||
const showAddModal = ref(false)
|
||||
|
||||
const seasonOptions = [
|
||||
{ label: '春季', value: 'spring' },
|
||||
{ label: '夏季', value: 'summer' },
|
||||
{ label: '秋季', value: 'autumn' },
|
||||
{ label: '冬季', value: 'winter' },
|
||||
{ label: '全年', value: 'all' },
|
||||
]
|
||||
|
||||
const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 'tour' })
|
||||
|
||||
function seasonLabel(s) {
|
||||
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
|
||||
}
|
||||
|
||||
function seasonType(s) {
|
||||
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/site-content?key=product-lines')
|
||||
productLines.value = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || [])
|
||||
} catch { message.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function saveList() {
|
||||
try {
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: { key: 'product-lines', data: productLines.value },
|
||||
})
|
||||
} catch { message.error('保存失败') }
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newItem.value.name?.trim() || !newItem.value.key?.trim()) {
|
||||
message.warning('名称和 Key 不能为空')
|
||||
return false
|
||||
}
|
||||
if (productLines.value.some(p => p.key === newItem.value.key)) {
|
||||
message.warning('Key 已存在')
|
||||
return false
|
||||
}
|
||||
|
||||
// Add to list
|
||||
productLines.value.push({
|
||||
...newItem.value,
|
||||
sortOrder: productLines.value.length + 1,
|
||||
})
|
||||
await saveList()
|
||||
|
||||
// Create empty product data entry in site_content
|
||||
await adminFetch('/api/admin/site-content', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
key: newItem.value.key,
|
||||
label: newItem.value.name,
|
||||
data: {
|
||||
narrative: '',
|
||||
highlights: [],
|
||||
versions: [],
|
||||
faq: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
message.success('已添加')
|
||||
newItem.value = { name: '', key: '', version: '', season: 'summer', type: 'tour', icon: '🌿' }
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const arr = productLines.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= productLines.value.length - 1) return
|
||||
const arr = productLines.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
arr.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
saveList()
|
||||
}
|
||||
|
||||
async function handleDelete(index) {
|
||||
productLines.value.splice(index, 1)
|
||||
productLines.value.forEach((item, i) => { item.sortOrder = i + 1 })
|
||||
await saveList()
|
||||
message.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
在新工单中引用
屏蔽一个用户