合作页面的内容
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
|
||||
@@ -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
144
server/handlers/services.go
Normal 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"})
|
||||
}
|
||||
@@ -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
30
server/models/services.go
Normal 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"`
|
||||
}
|
||||
183
server/repositories/services_repository.go
Normal file
183
server/repositories/services_repository.go
Normal 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
|
||||
}
|
||||
35
server/scripts/run_services_migration.go
Normal file
35
server/scripts/run_services_migration.go
Normal 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.")
|
||||
}
|
||||
Reference in New Issue
Block a user