Files
nl-blogs/client/src/pages/admin/Testimonials.vue
2026-06-26 17:00:02 +08:00

277 lines
6.8 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="testimonials-container">
<h1 class="page-title">客户评价管理</h1>
<!-- Action Buttons -->
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/testimonials/create')">
<span class="btn-icon"></span>
新建评价
</button>
</div>
<AdminTableFilter
v-model:keyword="keyword"
keyword-placeholder="搜索作者或内容..."
@search="applyFilter"
@reset="resetFilters"
>
<template #filters>
<div class="filter-field">
<label>评分</label>
<CustomSelect v-model="filterRating" :options="ratingOptions" style="width: 100px;" />
</div>
</template>
</AdminTableFilter>
<!-- Testimonials Table -->
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>头像</th>
<th>作者</th>
<th>角色</th>
<th>内容</th>
<th>评分</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTestimonials" :key="t.id">
<td class="table-cell">{{ t.id }}</td>
<td class="table-cell">
<img
v-if="t.avatar"
:src="t.avatar"
alt=""
class="w-8 h-8 rounded-full object-cover bg-white/5"
/>
<span v-else class="text-art-muted text-xs"></span>
</td>
<td class="table-cell">{{ t.name }}</td>
<td class="table-cell">{{ t.role }}</td>
<td class="table-cell" :title="t.content">
{{ t.content.length > 30 ? t.content.substring(0, 30) + '...' : t.content }}
</td>
<td class="table-cell">{{ t.rating }}</td>
<td class="table-cell">{{ new Date(t.createdAt).toLocaleDateString() }}</td>
<td class="table-cell actions">
<button class="btn-edit" @click="router.push(`/admin/testimonials/${t.id}/edit`)" title="编辑">
</button>
<button class="btn-delete" @click="handleDelete(t.id)" title="删除">
🗑
</button>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div v-if="filteredTestimonials.length === 0" class="empty-state">
<p>暂无客户评价数据</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchTestimonials, deleteTestimonial, type Testimonial } from '../../services/api'
import { useToast } from '../../composables/useToast'
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
const toast = useToast()
const testimonials = ref<Testimonial[]>([])
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
const filterRating = ref<number | string>('')
const appliedRating = ref<number | string>('')
const ratingOptions = [
{ value: '', label: '全部评分' },
...([5, 4, 3, 2, 1].map(n => ({ value: n, label: `${n}` })))
]
const filteredTestimonials = computed(() => testimonials.value.filter(t => {
if (!matchKeywordFn(t, appliedKeyword.value, item => `${item.name} ${item.content}`)) return false
if (appliedRating.value !== '' && t.rating !== Number(appliedRating.value)) return false
return true
}))
const applyFilter = () => {
apply()
appliedRating.value = filterRating.value
}
const resetFilters = () => {
reset()
filterRating.value = ''
appliedRating.value = ''
}
const loadData = async () => {
try {
testimonials.value = await fetchTestimonials()
} catch (error) {
console.error('Failed to fetch testimonials:', error)
toast.error('获取评价列表失败')
}
}
const handleDelete = async (id: number) => {
if (confirm('确定要删除这条评价吗?')) {
try {
await deleteTestimonial(id)
toast.success('删除成功')
loadData()
} catch (error) {
console.error('Failed to delete testimonial:', error)
toast.error('删除失败')
}
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.testimonials-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.action-bar {
display: flex;
justify-content: flex-start;
margin-bottom: 1rem;
}
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: rgba(212, 179, 131, 0.1);
border: 1px solid #d4b383;
color: #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.btn-primary:hover {
background: rgba(212, 179, 131, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(212, 179, 131, 0.15);
}
.btn-icon {
font-size: 1rem;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-family: 'Inter', sans-serif;
}
.admin-table thead {
background: rgba(255, 255, 255, 0.08);
}
.admin-table th {
padding: 1rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: #d4b383;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table td {
padding: 1rem;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table tr:last-child td {
border-bottom: none;
}
.admin-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-edit, .btn-delete {
padding: 0.5rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-edit {
background: rgba(59, 130, 246, 0.1);
color: rgba(59, 130, 246, 0.8);
}
.btn-edit:hover {
background: rgba(59, 130, 246, 0.2);
transform: translateY(-1px);
}
.btn-delete {
background: rgba(239, 68, 68, 0.1);
color: rgba(239, 68, 68, 0.8);
}
.btn-delete:hover {
background: rgba(239, 68, 68, 0.2);
transform: translateY(-1px);
}
.empty-state {
padding: 3rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 1rem;
}
</style>