305 行
10 KiB
Vue
305 行
10 KiB
Vue
<template>
|
|
<div style="max-width: 1400px">
|
|
<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-space>
|
|
<n-button tag="a" href="/reviews" target="_blank" secondary>查看前台</n-button>
|
|
<n-button type="primary" @click="navigateTo('/admin/reviews/new')">新增评价</n-button>
|
|
</n-space>
|
|
</n-space>
|
|
|
|
<n-card>
|
|
<!-- 筛选栏 -->
|
|
<div class="filter-row">
|
|
<n-input
|
|
v-model:value="search"
|
|
placeholder="搜索客户、内容..."
|
|
clearable
|
|
style="width: 220px"
|
|
@keyup.enter="resetAndLoad"
|
|
>
|
|
<template #prefix><span style="color: #9ca3af">🔍</span></template>
|
|
</n-input>
|
|
<n-select
|
|
v-model:value="filters.published"
|
|
:options="publishedFilterOptions"
|
|
placeholder="发布状态"
|
|
clearable
|
|
style="width: 130px"
|
|
@update:value="resetAndLoad"
|
|
/>
|
|
<n-select
|
|
v-model:value="filters.hasScreenshot"
|
|
:options="screenshotFilterOptions"
|
|
placeholder="截图"
|
|
clearable
|
|
style="width: 130px"
|
|
@update:value="resetAndLoad"
|
|
/>
|
|
<n-select
|
|
v-model:value="filters.scene"
|
|
:options="sceneOptions"
|
|
placeholder="场景"
|
|
clearable
|
|
style="width: 150px"
|
|
@update:value="resetAndLoad"
|
|
/>
|
|
<n-select
|
|
v-model:value="filters.concern"
|
|
:options="concernOptions"
|
|
placeholder="关心维度"
|
|
clearable
|
|
style="width: 150px"
|
|
@update:value="resetAndLoad"
|
|
/>
|
|
<n-button @click="resetAndLoad">搜索</n-button>
|
|
<n-button quaternary @click="clearFilters">重置</n-button>
|
|
</div>
|
|
|
|
<!-- 批量操作工具栏 -->
|
|
<div v-if="checkedIds.length" class="bulk-toolbar">
|
|
<span class="bulk-count">已选 {{ checkedIds.length }} 条</span>
|
|
<n-button size="small" @click="bulk('publish')">批量发布</n-button>
|
|
<n-button size="small" @click="bulk('unpublish')">批量下线</n-button>
|
|
<n-popconfirm @positive-click="bulk('delete')">
|
|
<template #trigger>
|
|
<n-button size="small" type="error">批量删除</n-button>
|
|
</template>
|
|
确定删除选中的 {{ checkedIds.length }} 条评价?
|
|
</n-popconfirm>
|
|
<n-button size="tiny" quaternary @click="checkedIds = []">清空选择</n-button>
|
|
</div>
|
|
|
|
<n-data-table
|
|
:columns="columns"
|
|
:data="list"
|
|
:loading="loading"
|
|
:row-key="(row) => row.id"
|
|
v-model:checked-row-keys="checkedIds"
|
|
:pagination="false"
|
|
size="small"
|
|
striped
|
|
/>
|
|
|
|
<n-space justify="space-between" align="center" style="margin-top: 16px" v-if="total">
|
|
<span style="color: #9ca3af; font-size: 12px;">共 {{ total }} 条</span>
|
|
<n-pagination v-if="totalPages > 1" v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
|
</n-space>
|
|
</n-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { h } from 'vue'
|
|
import {
|
|
NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm,
|
|
NRate, NSelect, NInputNumber, useMessage,
|
|
} from 'naive-ui'
|
|
|
|
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
|
|
|
const { adminFetch } = useAdmin()
|
|
const message = useMessage()
|
|
const { moveUp, moveDown } = useAdminSort('/api/admin/reviews')
|
|
const list = ref([])
|
|
const total = ref(0)
|
|
const page = ref(1)
|
|
const totalPages = ref(1)
|
|
const loading = ref(false)
|
|
const search = ref('')
|
|
const checkedIds = ref([])
|
|
|
|
const filters = reactive({
|
|
published: null,
|
|
hasScreenshot: null,
|
|
scene: null,
|
|
concern: null,
|
|
})
|
|
|
|
const publishedFilterOptions = [
|
|
{ label: '已发布', value: 'true' },
|
|
{ label: '草稿', value: 'false' },
|
|
]
|
|
const screenshotFilterOptions = [
|
|
{ label: '有截图', value: 'true' },
|
|
{ label: '无截图', value: 'false' },
|
|
]
|
|
const sceneOptions = [
|
|
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
|
|
].map(v => ({ label: v, value: v }))
|
|
const concernOptions = [
|
|
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
|
|
'性价比', '安全放心', '特色体验', '饮食',
|
|
].map(v => ({ label: v, value: v }))
|
|
|
|
function clearFilters() {
|
|
search.value = ''
|
|
filters.published = null
|
|
filters.hasScreenshot = null
|
|
filters.scene = null
|
|
filters.concern = null
|
|
resetAndLoad()
|
|
}
|
|
|
|
function resetAndLoad() {
|
|
page.value = 1
|
|
loadData()
|
|
}
|
|
|
|
const columns = [
|
|
{ type: 'selection', width: 40 },
|
|
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
|
|
{
|
|
title: '截图', key: 'screenshot', width: 72,
|
|
render: (row) => row.screenshot
|
|
? h('img', {
|
|
src: row.screenshot,
|
|
style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e5e7eb;',
|
|
referrerpolicy: 'no-referrer',
|
|
onClick: () => window.open(row.screenshot, '_blank'),
|
|
})
|
|
: h('span', { style: 'color: #d1d5db; font-size: 11px;' }, '无图'),
|
|
},
|
|
{
|
|
title: '客户', key: 'author', width: 100,
|
|
render: (row) => h('a', {
|
|
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
|
|
onClick: () => navigateTo(`/admin/reviews/${row.id}`),
|
|
}, row.author),
|
|
},
|
|
{
|
|
title: '评分', key: 'rating', width: 110,
|
|
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
|
|
},
|
|
{ title: '产品版本', key: 'title', width: 160, ellipsis: { tooltip: true } },
|
|
{
|
|
title: '内容摘要', key: 'content', minWidth: 200,
|
|
render: (row) => h('span', { style: 'color: #6b7280; font-size: 12px' }, row.content?.slice(0, 60)),
|
|
},
|
|
{
|
|
title: '标签', key: 'tags', width: 220,
|
|
render: (row) => h('div', { style: 'display: flex; flex-wrap: wrap; gap: 4px;' }, [
|
|
...(row.scenes || []).map(s => h(NTag, { size: 'small', type: 'info', bordered: false }, () => s)),
|
|
...(row.concerns || []).map(c => h(NTag, { size: 'small', type: 'success', bordered: false }, () => c)),
|
|
]),
|
|
},
|
|
{ title: '出行日期', key: 'date', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.date) },
|
|
{
|
|
title: '排序', key: 'sortOrder', width: 140,
|
|
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
|
|
h(NInputNumber, {
|
|
value: row.sortOrder || 0, size: 'tiny', min: 0, showButton: false,
|
|
style: 'width: 52px;',
|
|
'onUpdate:value': (v) => updateSortOrder(row, v),
|
|
}),
|
|
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
|
|
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
|
|
]),
|
|
},
|
|
{
|
|
title: '状态', key: 'published', width: 70,
|
|
render: (row) => h(NTag, {
|
|
size: 'small', type: row.published ? 'success' : 'default', bordered: false,
|
|
style: 'cursor: pointer',
|
|
onClick: () => togglePublish(row),
|
|
}, () => row.published ? '已发布' : '草稿'),
|
|
},
|
|
{
|
|
title: '操作', key: 'actions', width: 110, fixed: 'right',
|
|
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/reviews/${row.id}`) }, () => '编辑'),
|
|
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
|
|
trigger: () => h(NButton, { size: 'tiny', text: true, type: 'error' }, () => '删除'),
|
|
default: () => `确定删除「${row.author}」的评价?`,
|
|
}),
|
|
]),
|
|
},
|
|
]
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: page.value,
|
|
limit: 20,
|
|
search: search.value,
|
|
})
|
|
if (filters.published) params.set('published', filters.published)
|
|
if (filters.hasScreenshot) params.set('hasScreenshot', filters.hasScreenshot)
|
|
if (filters.scene) params.set('scene', filters.scene)
|
|
if (filters.concern) params.set('concern', filters.concern)
|
|
const d = await adminFetch(`/api/admin/reviews?${params}`)
|
|
list.value = d.items
|
|
total.value = d.total
|
|
totalPages.value = d.totalPages
|
|
} catch { message.error('加载失败') } finally { loading.value = false }
|
|
}
|
|
|
|
async function togglePublish(item) {
|
|
try {
|
|
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { published: !item.published } })
|
|
item.published = !item.published
|
|
} catch { message.error('操作失败') }
|
|
}
|
|
|
|
async function updateSortOrder(item, value) {
|
|
const v = Number(value) || 0
|
|
item.sortOrder = v
|
|
try {
|
|
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { sortOrder: v } })
|
|
} catch { message.error('排序更新失败') }
|
|
}
|
|
|
|
async function handleDelete(item) {
|
|
try {
|
|
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'DELETE' })
|
|
message.success('已删除')
|
|
loadData()
|
|
} catch { message.error('删除失败') }
|
|
}
|
|
|
|
async function bulk(action) {
|
|
if (!checkedIds.value.length) return
|
|
try {
|
|
const res = await adminFetch('/api/admin/reviews/bulk', {
|
|
method: 'POST',
|
|
body: { action, ids: checkedIds.value },
|
|
})
|
|
message.success(`已${ {publish:'发布',unpublish:'下线',delete:'删除'}[action] } ${res.affected} 条`)
|
|
checkedIds.value = []
|
|
loadData()
|
|
} catch { message.error('操作失败') }
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.bulk-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 12px;
|
|
background: #f0fdf4;
|
|
border: 1px solid #bbf7d0;
|
|
border-radius: 6px;
|
|
}
|
|
.bulk-count {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #065f46;
|
|
margin-right: 4px;
|
|
}
|
|
</style>
|