合作页面的内容

This commit is contained in:
李琦
2026-01-16 08:29:57 +08:00
parent df10ec9678
commit e99560ba87
14 changed files with 1474 additions and 57 deletions

View File

@@ -6,7 +6,7 @@
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
<div class="relative z-10">
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
Let's build<br>something<br>unreal.
共创<br>无限<br>可能
</h3>
<p class="font-mono text-xs text-white/40 leading-relaxed">
告诉我你的构想<br>
@@ -36,7 +36,10 @@
placeholder="Name"
v-model="formData.name"
>
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">您的称呼</label>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.name ? '-top-4 text-white/50' : ''"
>您的称呼</label>
</div>
<div class="relative group/input">
<input
@@ -46,7 +49,10 @@
placeholder="Company"
v-model="formData.company"
>
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">公司 / 组织</label>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.company ? '-top-4 text-white/50' : ''"
>公司 / 组织</label>
</div>
</div>
<div class="relative group/input">
@@ -57,7 +63,10 @@
placeholder="Contact"
v-model="formData.contact"
>
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">联系方式 (Email / WeChat)</label>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.contact ? '-top-4 text-white/50' : ''"
>联系方式 (Email / WeChat)</label>
</div>
</div>
<div class="space-y-6">
@@ -73,7 +82,7 @@
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">10k - 50k</span>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">1w - 5w</span>
</label>
<label class="cursor-pointer group">
<input
@@ -83,7 +92,7 @@
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">50k - 200k</span>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">5w - 20w</span>
</label>
<label class="cursor-pointer group">
<input
@@ -93,7 +102,7 @@
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">200k +</span>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">20w +</span>
</label>
</div>
</div>
@@ -106,7 +115,10 @@
v-model="formData.description"
@input="autoResizeTextarea"
></textarea>
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">一句话描述需求</label>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.description ? '-top-4 text-white/50' : ''"
>一句话描述需求</label>
</div>
</div>
<div class="pt-6">

View File

@@ -45,10 +45,22 @@
</li>
<li>
<router-link to="/admin/about" class="nav-link" active-class="active">
<span class="nav-icon"><EFBFBD></span>
<span class="nav-icon">👤</span>
<span class="nav-text">关于页面</span>
</router-link>
</li>
<li>
<router-link to="/admin/testimonials" class="nav-link" active-class="active">
<span class="nav-icon">💬</span>
<span class="nav-text">客户评价</span>
</router-link>
</li>
<li>
<router-link to="/admin/partners" class="nav-link" active-class="active">
<span class="nav-icon">🤝</span>
<span class="nav-text">合作伙伴</span>
</router-link>
</li>
<li>
<router-link to="/admin/snippets" class="nav-link" active-class="active">
<span class="nav-icon">💻</span>

View File

@@ -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)

View 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>

View 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>

View 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>

View 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>

View File

@@ -58,6 +58,16 @@ const routes = [
{ path: 'about/create', name: 'admin-about-create', component: () => import('./pages/admin/AboutForm.vue') },
{ path: 'about/edit/:id', name: 'admin-about-edit', component: () => import('./pages/admin/AboutForm.vue') },
// 客户评价管理
{ path: 'testimonials', name: 'admin-testimonials', component: () => import('./pages/admin/Testimonials.vue') },
{ path: 'testimonials/create', name: 'admin-testimonials-create', component: () => import('./pages/admin/TestimonialForm.vue') },
{ path: 'testimonials/:id/edit', name: 'admin-testimonials-edit', component: () => import('./pages/admin/TestimonialForm.vue') },
// 合作伙伴管理
{ path: 'partners', name: 'admin-partners', component: () => import('./pages/admin/Partners.vue') },
{ path: 'partners/create', name: 'admin-partners-create', component: () => import('./pages/admin/PartnerForm.vue') },
{ path: 'partners/:id/edit', name: 'admin-partners-edit', component: () => import('./pages/admin/PartnerForm.vue') },
// 系统配置
{ path: 'settings', name: 'admin-settings', component: () => import('./pages/admin/Settings.vue') },

View File

@@ -961,3 +961,156 @@ export const deleteAboutProfile = async (id: number): Promise<void> => {
throw error
}
}
// 客户评价相关类型
export interface Testimonial {
id: number
content: string
author: string
role: string
avatar: string
rating: number
createdAt: string
updatedAt: string
}
// 合作伙伴相关类型
export interface Partner {
id: number
name: string
logo: string
description: string
website: string
createdAt: string
updatedAt: string
}
// 客户评价API
export const fetchTestimonials = async (): Promise<Testimonial[]> => {
try {
const response = await fetch(`${API_BASE}/testimonials`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取客户评价失败')
}
return await response.json()
} catch (error) {
console.error('Fetch testimonials error:', error)
throw error
}
}
export const createTestimonial = async (data: Omit<Testimonial, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/testimonials`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建客户评价失败')
}
} catch (error) {
console.error('Create testimonial error:', error)
throw error
}
}
export const updateTestimonial = async (id: number, data: Omit<Testimonial, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/testimonials/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新客户评价失败')
}
} catch (error) {
console.error('Update testimonial error:', error)
throw error
}
}
export const deleteTestimonial = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/testimonials/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除客户评价失败')
}
} catch (error) {
console.error('Delete testimonial error:', error)
throw error
}
}
// 合作伙伴API
export const fetchPartners = async (): Promise<Partner[]> => {
try {
const response = await fetch(`${API_BASE}/partners`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取合作伙伴失败')
}
return await response.json()
} catch (error) {
console.error('Fetch partners error:', error)
throw error
}
}
export const createPartner = async (data: Omit<Partner, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/partners`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建合作伙伴失败')
}
} catch (error) {
console.error('Create partner error:', error)
throw error
}
}
export const updatePartner = async (id: number, data: Omit<Partner, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/partners/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新合作伙伴失败')
}
} catch (error) {
console.error('Update partner error:', error)
throw error
}
}
export const deletePartner = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/partners/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除合作伙伴失败')
}
} catch (error) {
console.error('Delete partner error:', error)
throw error
}
}

144
server/handlers/services.go Normal file
View File

@@ -0,0 +1,144 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/repositories"
)
// GetTestimonials 获取客户评价
func GetTestimonials(c *gin.Context) {
testimonials, err := repositories.GetTestimonials()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch testimonials"})
return
}
c.JSON(http.StatusOK, testimonials)
}
// GetPartners 获取合作伙伴
func GetPartners(c *gin.Context) {
partners, err := repositories.GetPartners()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch partners"})
return
}
c.JSON(http.StatusOK, partners)
}
// AdminCreateTestimonial 创建客户评价
func AdminCreateTestimonial(c *gin.Context) {
var t models.Testimonial
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := repositories.CreateTestimonial(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create testimonial"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Testimonial created successfully"})
}
// AdminUpdateTestimonial 更新客户评价
func AdminUpdateTestimonial(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var t models.Testimonial
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
t.ID = id
if err := repositories.UpdateTestimonial(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update testimonial"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Testimonial updated successfully"})
}
// AdminDeleteTestimonial 删除客户评价
func AdminDeleteTestimonial(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := repositories.DeleteTestimonial(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete testimonial"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Testimonial deleted successfully"})
}
// AdminCreatePartner 创建合作伙伴
func AdminCreatePartner(c *gin.Context) {
var p models.Partner
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := repositories.CreatePartner(&p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create partner"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Partner created successfully"})
}
// AdminUpdatePartner 更新合作伙伴
func AdminUpdatePartner(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var p models.Partner
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
p.ID = id
if err := repositories.UpdatePartner(&p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update partner"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Partner updated successfully"})
}
// AdminDeletePartner 删除合作伙伴
func AdminDeletePartner(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := repositories.DeletePartner(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete partner"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Partner deleted successfully"})
}

View File

@@ -77,6 +77,10 @@ func main() {
// 关于页面路由
api.GET("/about", handlers.GetAboutProfile)
// 合作页面路由
api.GET("/testimonials", handlers.GetTestimonials)
api.GET("/partners", handlers.GetPartners)
}
// 管理员API路由组
@@ -152,6 +156,16 @@ func main() {
authAdmin.POST("/about", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreateAboutProfile)
authAdmin.PUT("/about/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateAboutProfile)
authAdmin.DELETE("/about/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteAboutProfile)
// 客户评价管理 (复用 settings 权限)
authAdmin.POST("/testimonials", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreateTestimonial)
authAdmin.PUT("/testimonials/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateTestimonial)
authAdmin.DELETE("/testimonials/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteTestimonial)
// 合作伙伴管理 (复用 settings 权限)
authAdmin.POST("/partners", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreatePartner)
authAdmin.PUT("/partners/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdatePartner)
authAdmin.DELETE("/partners/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeletePartner)
}
}

30
server/models/services.go Normal file
View File

@@ -0,0 +1,30 @@
package models
import (
"time"
)
// Testimonial 客户评价模型
type Testimonial struct {
ID uint `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Content string `json:"content"`
Avatar string `json:"avatar"`
Rating uint8 `json:"rating"`
SortOrder uint `json:"sortOrder"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Partner 合作伙伴模型
type Partner struct {
ID uint `json:"id"`
Name string `json:"name"`
Logo string `json:"logo"`
Description string `json:"description"`
URL string `json:"url"`
SortOrder uint `json:"sortOrder"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,183 @@
package repositories
import (
"log"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
)
// GetTestimonials 获取所有客户评价
func GetTestimonials() ([]models.Testimonial, error) {
query := "SELECT id, name, role, content, avatar, rating, sort_order, created_at, updated_at FROM testimonials ORDER BY sort_order ASC, created_at DESC"
rows, err := config.DB.Query(query)
if err != nil {
log.Printf("Error querying testimonials: %v", err)
return nil, err
}
defer rows.Close()
var testimonials []models.Testimonial
for rows.Next() {
var t models.Testimonial
if err := rows.Scan(
&t.ID,
&t.Name,
&t.Role,
&t.Content,
&t.Avatar,
&t.Rating,
&t.SortOrder,
&t.CreatedAt,
&t.UpdatedAt,
); err != nil {
log.Printf("Error scanning testimonial: %v", err)
continue
}
testimonials = append(testimonials, t)
}
return testimonials, nil
}
// CreateTestimonial 创建客户评价
func CreateTestimonial(t *models.Testimonial) error {
query := `
INSERT INTO testimonials (name, role, content, avatar, rating, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
`
_, err := config.DB.Exec(
query,
t.Name,
t.Role,
t.Content,
t.Avatar,
t.Rating,
t.SortOrder,
)
if err != nil {
log.Printf("Error creating testimonial: %v", err)
return err
}
return nil
}
// UpdateTestimonial 更新客户评价
func UpdateTestimonial(t *models.Testimonial) error {
query := `
UPDATE testimonials SET name = ?, role = ?, content = ?, avatar = ?, rating = ?, sort_order = ?, updated_at = NOW()
WHERE id = ?
`
_, err := config.DB.Exec(
query,
t.Name,
t.Role,
t.Content,
t.Avatar,
t.Rating,
t.SortOrder,
t.ID,
)
if err != nil {
log.Printf("Error updating testimonial: %v", err)
return err
}
return nil
}
// DeleteTestimonial 删除客户评价
func DeleteTestimonial(id uint) error {
query := "DELETE FROM testimonials WHERE id = ?"
_, err := config.DB.Exec(query, id)
if err != nil {
log.Printf("Error deleting testimonial: %v", err)
return err
}
return nil
}
// GetPartners 获取所有合作伙伴
func GetPartners() ([]models.Partner, error) {
query := "SELECT id, name, logo, description, url, sort_order, created_at, updated_at FROM partners ORDER BY sort_order ASC, created_at DESC"
rows, err := config.DB.Query(query)
if err != nil {
log.Printf("Error querying partners: %v", err)
return nil, err
}
defer rows.Close()
var partners []models.Partner
for rows.Next() {
var p models.Partner
if err := rows.Scan(
&p.ID,
&p.Name,
&p.Logo,
&p.Description,
&p.URL,
&p.SortOrder,
&p.CreatedAt,
&p.UpdatedAt,
); err != nil {
log.Printf("Error scanning partner: %v", err)
continue
}
partners = append(partners, p)
}
return partners, nil
}
// CreatePartner 创建合作伙伴
func CreatePartner(p *models.Partner) error {
query := `
INSERT INTO partners (name, logo, description, url, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
`
_, err := config.DB.Exec(
query,
p.Name,
p.Logo,
p.Description,
p.URL,
p.SortOrder,
)
if err != nil {
log.Printf("Error creating partner: %v", err)
return err
}
return nil
}
// UpdatePartner 更新合作伙伴
func UpdatePartner(p *models.Partner) error {
query := `
UPDATE partners SET name = ?, logo = ?, description = ?, url = ?, sort_order = ?, updated_at = NOW()
WHERE id = ?
`
_, err := config.DB.Exec(
query,
p.Name,
p.Logo,
p.Description,
p.URL,
p.SortOrder,
p.ID,
)
if err != nil {
log.Printf("Error updating partner: %v", err)
return err
}
return nil
}
// DeletePartner 删除合作伙伴
func DeletePartner(id uint) error {
query := "DELETE FROM partners WHERE id = ?"
_, err := config.DB.Exec(query, id)
if err != nil {
log.Printf("Error deleting partner: %v", err)
return err
}
return nil
}

View File

@@ -0,0 +1,35 @@
package main
import (
"io/ioutil"
"log"
"strings"
"github.com/niangaodev/art-code/config"
)
func main() {
config.InitDB()
defer config.CloseDB()
// Read SQL file
content, err := ioutil.ReadFile("scripts/create_services_tables.sql")
if err != nil {
log.Fatalf("Failed to read SQL file: %v", err)
}
requests := strings.Split(string(content), ";")
for _, request := range requests {
request = strings.TrimSpace(request)
if request == "" {
continue
}
_, err := config.DB.Exec(request)
if err != nil {
log.Printf("Error executing query: %s\nError: %v", request, err)
} else {
log.Println("Executed query successfully")
}
}
log.Println("Services tables migration completed.")
}