feat: 官网代码全量更新 (2026-04-18)

这个提交包含在:
wx
2026-04-18 15:13:28 +08:00
父节点 fcac8f88db
当前提交 f91ecbb093
共修改 135 个文件,包含 10307 行新增和 3349 行删除
+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">&#128269;</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>