合作页面的内容

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

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