feat: 官网代码全量更新 (2026-04-18)
这个提交包含在:
+104
-6
@@ -44,6 +44,20 @@
|
||||
<NFormItem label="出行日期">
|
||||
<NInput v-model:value="form.date" placeholder="如:2026年3月" />
|
||||
</NFormItem>
|
||||
<NFormItem label="评价截图">
|
||||
<div class="screenshot-wrapper">
|
||||
<AdminImageUpload v-model="form.screenshot" />
|
||||
<div v-if="form.screenshot" class="screenshot-actions">
|
||||
<NButton size="tiny" @click="previewOpen = true">🔍 查看大图</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
:loading="ocrLoading"
|
||||
:disabled="ocrLoading"
|
||||
@click="handleOcr"
|
||||
>✨ 从截图识别文字</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="内容" required>
|
||||
<NInput
|
||||
v-model:value="form.content"
|
||||
@@ -54,6 +68,31 @@
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<NCard title="分类标签" style="margin-top: 16px;">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="出行场景(多选)">
|
||||
<NSelect
|
||||
v-model:value="form.scenes"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
:options="SCENE_OPTIONS"
|
||||
placeholder="选择或输入场景"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="关心维度(多选)">
|
||||
<NSelect
|
||||
v-model:value="form.concerns"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
:options="CONCERN_OPTIONS"
|
||||
placeholder="选择或输入关心点"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
|
||||
<!-- 侧栏(1列) -->
|
||||
@@ -61,14 +100,17 @@
|
||||
<NCard title="发布设置">
|
||||
<NForm label-placement="top" :show-feedback="false">
|
||||
<NFormItem label="状态">
|
||||
<NSelect
|
||||
v-model:value="form.published"
|
||||
:options="publishedOptions"
|
||||
/>
|
||||
<NSelect v-model:value="form.published" :options="publishedOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="已验证">
|
||||
<NSwitch v-model:value="form.verified" />
|
||||
</NFormItem>
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" style="width:100%" />
|
||||
<div style="font-size: 11px; color: #9ca3af; margin-top: 4px;">
|
||||
数字越小越靠前(0 为默认)
|
||||
</div>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</NGi>
|
||||
@@ -81,6 +123,11 @@
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- 截图 Lightbox -->
|
||||
<NModal v-model:show="previewOpen" preset="card" style="max-width: 640px;" :bordered="false" title="评价截图">
|
||||
<img v-if="form.screenshot" :src="form.screenshot" style="width:100%;display:block;" />
|
||||
</NModal>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,7 +135,8 @@
|
||||
<script setup>
|
||||
import {
|
||||
NButton, NCard, NForm, NFormItem, NInput, NSelect, NSwitch, NRate,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, useMessage,
|
||||
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, NInputNumber, NModal,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
|
||||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||||
@@ -101,13 +149,19 @@ const isNew = computed(() => route.params.id === 'new')
|
||||
const pageLoading = ref(!isNew.value)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const ocrLoading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
author: '',
|
||||
title: '',
|
||||
date: '',
|
||||
content: '',
|
||||
screenshot: '',
|
||||
scenes: [],
|
||||
concerns: [],
|
||||
rating: 5,
|
||||
sortOrder: 0,
|
||||
published: true,
|
||||
verified: false,
|
||||
})
|
||||
@@ -117,14 +171,30 @@ const publishedOptions = [
|
||||
{ label: '草稿', value: false },
|
||||
]
|
||||
|
||||
const SCENE_OPTIONS = [
|
||||
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
|
||||
const CONCERN_OPTIONS = [
|
||||
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
|
||||
'性价比', '安全放心', '特色体验', '饮食',
|
||||
].map(v => ({ label: v, value: v }))
|
||||
|
||||
async function loadItem() {
|
||||
if (isNew.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await adminFetch(`/api/admin/reviews/${route.params.id}`)
|
||||
form.value = {
|
||||
...data,
|
||||
author: data.author || '',
|
||||
title: data.title || '',
|
||||
date: data.date || '',
|
||||
content: data.content || '',
|
||||
screenshot: data.screenshot || '',
|
||||
scenes: Array.isArray(data.scenes) ? data.scenes : [],
|
||||
concerns: Array.isArray(data.concerns) ? data.concerns : [],
|
||||
rating: data.rating ?? 5,
|
||||
sortOrder: data.sortOrder || 0,
|
||||
published: !!data.published,
|
||||
verified: !!data.verified,
|
||||
}
|
||||
@@ -156,6 +226,24 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOcr() {
|
||||
if (!form.value.screenshot) { message.warning('请先上传截图'); return }
|
||||
ocrLoading.value = true
|
||||
try {
|
||||
const res = await adminFetch('/api/admin/ocr', { method: 'POST', body: { image: form.value.screenshot } })
|
||||
if (res?.text) {
|
||||
form.value.content = res.text.trim()
|
||||
message.success('已识别,可在内容区继续调整')
|
||||
} else {
|
||||
message.warning('未识别到文字')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.data?.message || 'OCR 失败')
|
||||
} finally {
|
||||
ocrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadItem)
|
||||
</script>
|
||||
|
||||
@@ -191,6 +279,16 @@ onMounted(loadItem)
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.screenshot-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screenshot-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
+212
-18
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div style="max-width: 1200px">
|
||||
<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>
|
||||
@@ -12,17 +12,81 @@
|
||||
</n-space>
|
||||
|
||||
<n-card>
|
||||
<n-space style="margin-bottom: 16px">
|
||||
<n-input v-model:value="search" placeholder="搜索客户、内容..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
|
||||
<!-- 筛选栏 -->
|
||||
<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-button @click="page = 1; loadData()">搜索</n-button>
|
||||
</n-space>
|
||||
<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>
|
||||
|
||||
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
|
||||
<!-- 批量操作工具栏 -->
|
||||
<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-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
|
||||
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
|
||||
<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>
|
||||
@@ -30,39 +94,111 @@
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm, NRate } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
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: 'author',
|
||||
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: 120,
|
||||
title: '评分', key: 'rating', width: 110,
|
||||
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
|
||||
},
|
||||
{ title: '产品版本', key: 'title', width: 100 },
|
||||
{ title: '产品版本', key: 'title', width: 160, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '内容摘要', key: 'content',
|
||||
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.content?.slice(0, 50)),
|
||||
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, {
|
||||
@@ -72,7 +208,7 @@ const columns = [
|
||||
}, () => row.published ? '已发布' : '草稿'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 120,
|
||||
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) }, {
|
||||
@@ -86,8 +222,19 @@ const columns = [
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await adminFetch(`/api/admin/reviews?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
|
||||
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -98,6 +245,14 @@ async function togglePublish(item) {
|
||||
} 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' })
|
||||
@@ -106,5 +261,44 @@ async function handleDelete(item) {
|
||||
} 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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户