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">&#128269;</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>