197 行
7.3 KiB
Vue
197 行
7.3 KiB
Vue
<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 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>
|
||
<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">{{ typeLabel(item.type) }}</n-tag>
|
||
</div>
|
||
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px" translate="no">
|
||
排序 {{ 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' }, { label: '课程', value: 'course' }]" />
|
||
</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 typeLabel(t) {
|
||
return { tour: '线路', camp: '营地', course: '课程' }[t] || t
|
||
}
|
||
|
||
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>
|