合作页面的内容
This commit is contained in:
@@ -157,19 +157,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount, onUpdated, nextTick } from 'vue'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
// 定义类型
|
||||
interface Testimonial {
|
||||
id: string
|
||||
content: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface Partner {
|
||||
id: string
|
||||
name: string
|
||||
logo?: string
|
||||
}
|
||||
import {
|
||||
fetchTestimonials,
|
||||
fetchPartners,
|
||||
type Testimonial,
|
||||
type Partner
|
||||
} from '../services/api'
|
||||
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
const partners = ref<Partner[]>([])
|
||||
@@ -182,48 +175,20 @@ const refreshIcons = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟API调用,实际项目中应替换为真实API
|
||||
// 获取服务数据
|
||||
const fetchServicesData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 实际项目中应调用真实API
|
||||
// const testimonialsRes = await fetch(`${API_BASE}/testimonials`)
|
||||
// const partnersRes = await fetch(`${API_BASE}/partners`)
|
||||
// testimonials.value = await testimonialsRes.json()
|
||||
// partners.value = await partnersRes.json()
|
||||
|
||||
// 使用模拟数据作为备选
|
||||
testimonials.value = [
|
||||
{ id: '1', content: '从未见过如此丝滑的 WebGL 体验!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1' },
|
||||
{ id: '2', content: '代码质量非常高,易于维护。', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2' },
|
||||
{ id: '3', content: '细节把控令人惊叹,强烈推荐!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3' },
|
||||
{ id: '4', content: '交付速度快,超出预期!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test4' }
|
||||
]
|
||||
|
||||
partners.value = [
|
||||
{ id: '1', name: 'VOGUE' },
|
||||
{ id: '2', name: 'WIRED' },
|
||||
{ id: '3', name: 'stripe' },
|
||||
{ id: '4', name: 'Monocle' }
|
||||
]
|
||||
const [testimonialsData, partnersData] = await Promise.all([
|
||||
fetchTestimonials(),
|
||||
fetchPartners()
|
||||
])
|
||||
testimonials.value = testimonialsData
|
||||
partners.value = partnersData
|
||||
} catch (err) {
|
||||
console.error('Error fetching services data:', err)
|
||||
error.value = '获取服务数据失败,请稍后重试'
|
||||
// 使用静态数据作为备选
|
||||
testimonials.value = [
|
||||
{ id: '1', content: '从未见过如此丝滑的 WebGL 体验!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1' },
|
||||
{ id: '2', content: '代码质量非常高,易于维护。', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2' },
|
||||
{ id: '3', content: '细节把控令人惊叹,强烈推荐!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3' },
|
||||
{ id: '4', content: '交付速度快,超出预期!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test4' }
|
||||
]
|
||||
|
||||
partners.value = [
|
||||
{ id: '1', name: 'VOGUE' },
|
||||
{ id: '2', name: 'WIRED' },
|
||||
{ id: '3', name: 'stripe' },
|
||||
{ id: '4', name: 'Monocle' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
nextTick(refreshIcons)
|
||||
|
||||
196
client/src/pages/admin/PartnerForm.vue
Normal file
196
client/src/pages/admin/PartnerForm.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑合作伙伴' : '新建合作伙伴' }}</h1>
|
||||
|
||||
<div class="form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">名称</label>
|
||||
<input type="text" v-model="formData.name" class="form-input" required placeholder="请输入合作伙伴名称">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Logo URL</label>
|
||||
<input type="text" v-model="formData.logo" class="form-input" required placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">官网链接</label>
|
||||
<input type="text" v-model="formData.website" class="form-input" placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea v-model="formData.description" class="form-input" rows="4" placeholder="请输入描述"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="router.back()">取消</button>
|
||||
<button type="submit" class="btn-primary">{{ isEdit ? '保存修改' : '创建合作伙伴' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createPartner, updatePartner, fetchPartners } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
logo: '',
|
||||
website: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
// Same as testimonials, fetching list to find item
|
||||
const list = await fetchPartners()
|
||||
const item = list.find(p => p.id === id)
|
||||
if (item) {
|
||||
formData.value = {
|
||||
name: item.name,
|
||||
logo: item.logo,
|
||||
website: item.website,
|
||||
description: item.description
|
||||
}
|
||||
} else {
|
||||
toast.error('未找到该合作伙伴')
|
||||
router.push('/admin/partners')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load partner:', error)
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updatePartner(parseInt(route.params.id as string), formData.value)
|
||||
toast.success('更新成功')
|
||||
} else {
|
||||
await createPartner(formData.value)
|
||||
toast.success('创建成功')
|
||||
}
|
||||
router.push('/admin/partners')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to submit form:', error)
|
||||
toast.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
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);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #d4b383;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
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 {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border: 1px solid #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.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-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
232
client/src/pages/admin/Partners.vue
Normal file
232
client/src/pages/admin/Partners.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="partners-container">
|
||||
<h1 class="page-title">合作伙伴管理</h1>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-bar">
|
||||
<button class="btn-primary" @click="router.push('/admin/partners/create')">
|
||||
<span class="btn-icon">➕</span>
|
||||
新建合作伙伴
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Partners Table -->
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Logo</th>
|
||||
<th>名称</th>
|
||||
<th>网站</th>
|
||||
<th>描述</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in partners" :key="p.id">
|
||||
<td class="table-cell">{{ p.id }}</td>
|
||||
<td class="table-cell">
|
||||
<img v-if="p.logo" :src="p.logo" alt="Logo" class="w-8 h-8 object-contain bg-white/10 rounded">
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="table-cell">{{ p.name }}</td>
|
||||
<td class="table-cell">
|
||||
<a v-if="p.website" :href="p.website" target="_blank" class="text-blue-400 hover:underline">链接</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="table-cell" :title="p.description">
|
||||
{{ p.description && p.description.length > 20 ? p.description.substring(0, 20) + '...' : (p.description || '-') }}
|
||||
</td>
|
||||
<td class="table-cell">{{ new Date(p.createdAt).toLocaleDateString() }}</td>
|
||||
<td class="table-cell actions">
|
||||
<button class="btn-edit" @click="router.push(`/admin/partners/${p.id}/edit`)" title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-delete" @click="handleDelete(p.id)" title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="partners.length === 0" class="empty-state">
|
||||
<p>暂无合作伙伴数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchPartners, deletePartner, type Partner } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const partners = ref<Partner[]>([])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
partners.value = await fetchPartners()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch partners:', error)
|
||||
toast.error('获取合作伙伴列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个合作伙伴吗?')) {
|
||||
try {
|
||||
await deletePartner(id)
|
||||
toast.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete partner:', error)
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.partners-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>
|
||||
205
client/src/pages/admin/TestimonialForm.vue
Normal file
205
client/src/pages/admin/TestimonialForm.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑评价' : '新建评价' }}</h1>
|
||||
|
||||
<div class="form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">作者</label>
|
||||
<input type="text" v-model="formData.author" class="form-input" required placeholder="请输入作者姓名">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">角色/职位</label>
|
||||
<input type="text" v-model="formData.role" class="form-input" placeholder="例如:CEO, Tech Lead">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">头像 URL</label>
|
||||
<input type="text" v-model="formData.avatar" class="form-input" placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">评分 (1-5)</label>
|
||||
<input type="number" v-model.number="formData.rating" class="form-input" min="1" max="5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">评价内容</label>
|
||||
<textarea v-model="formData.content" class="form-input" rows="4" required placeholder="请输入评价内容"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="router.back()">取消</button>
|
||||
<button type="submit" class="btn-primary">{{ isEdit ? '保存修改' : '创建评价' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createTestimonial, updateTestimonial, fetchTestimonials } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const formData = ref({
|
||||
author: '',
|
||||
role: '',
|
||||
avatar: '',
|
||||
rating: 5,
|
||||
content: ''
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
// Since we don't have a single fetch API for admin yet (or we can use the list),
|
||||
// we'll fetch all and find one. In a real app, we should have getTestimonial(id).
|
||||
// But for now, let's fetch list and find.
|
||||
const list = await fetchTestimonials()
|
||||
const item = list.find(t => t.id === id)
|
||||
if (item) {
|
||||
formData.value = {
|
||||
author: item.author,
|
||||
role: item.role,
|
||||
avatar: item.avatar,
|
||||
rating: item.rating,
|
||||
content: item.content
|
||||
}
|
||||
} else {
|
||||
toast.error('未找到该评价')
|
||||
router.push('/admin/testimonials')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load testimonial:', error)
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateTestimonial(parseInt(route.params.id as string), formData.value)
|
||||
toast.success('更新成功')
|
||||
} else {
|
||||
await createTestimonial(formData.value)
|
||||
toast.success('创建成功')
|
||||
}
|
||||
router.push('/admin/testimonials')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to submit form:', error)
|
||||
toast.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
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);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #d4b383;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
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 {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border: 1px solid #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.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-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
226
client/src/pages/admin/Testimonials.vue
Normal file
226
client/src/pages/admin/Testimonials.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in testimonials" :key="t.id">
|
||||
<td class="table-cell">{{ t.id }}</td>
|
||||
<td class="table-cell">{{ t.author }}</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="testimonials.length === 0" class="empty-state">
|
||||
<p>暂无客户评价数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchTestimonials, deleteTestimonial, type Testimonial } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user