初始化
This commit is contained in:
159
client/src/pages/About.vue
Normal file
159
client/src/pages/About.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<section id="about" class="page-section block animate-slide-down">
|
||||
<div class="max-w-6xl mx-auto pt-32 px-6 pb-20">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchProfileData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Profile content -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<span class="text-art-accent font-mono text-sm tracking-widest uppercase">个人档案</span>
|
||||
<h2 class="font-serif text-5xl italic text-white mt-2 mb-6">关于我</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden border-2 border-art-accent/50 shadow-2xl">
|
||||
<img
|
||||
:src="profile.avatar"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-bold text-white">{{ profile.name }}</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-art-muted">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-art-accent"></i>
|
||||
<span>{{ profile.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose prose-invert text-art-muted font-light leading-relaxed">
|
||||
<p v-html="profile.bio"></p>
|
||||
</div>
|
||||
<div class="bg-white/5 border border-white/5 p-6 rounded-xl backdrop-blur-sm mt-6">
|
||||
<h4 class="text-white font-bold mb-4 flex items-center gap-2">
|
||||
<i data-lucide="contact" class="w-4 h-4 text-art-accent"></i> 联系方式
|
||||
</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<a :href="'mailto:' + profile.contact.email" class="flex items-center gap-2 text-art-muted hover:text-white transition-colors">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-art-accent"></i>
|
||||
<span>{{ profile.contact.email }}</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-sm text-art-muted">
|
||||
<i data-lucide="message-circle" class="w-4 h-4 text-art-accent"></i>
|
||||
<span>WeChat: {{ profile.contact.wechat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-8">
|
||||
<div class="bg-white/5 border border-white/5 p-8 rounded-2xl backdrop-blur-sm">
|
||||
<h3 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<i data-lucide="cpu" class="w-5 h-5 text-art-accent"></i> 技术栈
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-10">
|
||||
<span
|
||||
v-for="(tech, index) in profile.techStack"
|
||||
:key="index"
|
||||
class="px-3 py-1 bg-white/5 rounded-full text-xs text-white/80 border border-white/10"
|
||||
>
|
||||
{{ tech }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
// 定义类型
|
||||
interface Profile {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
location: string
|
||||
bio: string
|
||||
contact: {
|
||||
email: string
|
||||
wechat: string
|
||||
}
|
||||
techStack: string[]
|
||||
}
|
||||
|
||||
const profile = ref<Profile>({
|
||||
id: '',
|
||||
name: '年糕崽崽',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4',
|
||||
location: '中国 · 浙江杭州',
|
||||
bio: '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。目前,我专注于高性能 B 端应用的体验升级。',
|
||||
contact: {
|
||||
email: 'hello@niangao.dev',
|
||||
wechat: 'Niangao_Dev'
|
||||
},
|
||||
techStack: ['Vue 3', 'React', 'TypeScript', 'Three.js', 'Golang', 'Tailwind CSS', 'Vite', 'Gin']
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 模拟API调用,实际项目中应替换为真实API
|
||||
const fetchProfileData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 实际项目中应调用真实API
|
||||
// const response = await fetch(`${API_BASE}/profile`)
|
||||
// const data = await response.json()
|
||||
// profile.value = data
|
||||
|
||||
// 使用模拟数据作为备选
|
||||
profile.value = {
|
||||
id: '1',
|
||||
name: '年糕崽崽',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4',
|
||||
location: '中国 · 浙江杭州',
|
||||
bio: '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。目前,我专注于高性能 B 端应用的体验升级。',
|
||||
contact: {
|
||||
email: 'hello@niangao.dev',
|
||||
wechat: 'Niangao_Dev'
|
||||
},
|
||||
techStack: ['Vue 3', 'React', 'TypeScript', 'Three.js', 'Golang', 'Tailwind CSS', 'Vite', 'Gin']
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching profile data:', err)
|
||||
error.value = '获取个人资料失败,请稍后重试'
|
||||
// 保持原有静态数据
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchProfileData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
82
client/src/pages/Blog.vue
Normal file
82
client/src/pages/Blog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<section id="blog" class="page-section block animate-slide-down">
|
||||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
|
||||
<div class="mb-16 text-center">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">深度思考</h2>
|
||||
<p class="text-art-muted max-w-lg mx-auto">关于前端技术、交互设计以及数字艺术的深度思考。</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchBlogPosts" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog posts list -->
|
||||
<div v-else class="space-y-12" id="blog-list-container">
|
||||
<!-- Blog posts will be rendered here -->
|
||||
<div
|
||||
v-for="post in blogPosts"
|
||||
:key="post.id"
|
||||
class="art-card rounded-2xl p-8 group cursor-pointer transition-all"
|
||||
@click="$router.push('/blog/' + post.id)"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ post.category }}</span>
|
||||
<span class="text-xs font-mono text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/50 transform group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl md:text-3xl font-serif text-white mb-4 group-hover:text-art-accent transition-colors">{{ post.title }}</h3>
|
||||
<p class="text-art-muted leading-relaxed">{{ post.excerpt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { fetchPosts, Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const blogPosts = ref<Post[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchBlogPosts = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const posts = await fetchPosts()
|
||||
blogPosts.value = posts
|
||||
} catch (err) {
|
||||
console.error('Error fetching blog posts:', err)
|
||||
error.value = '获取博客文章失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchBlogPosts()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
115
client/src/pages/BlogDetail.vue
Normal file
115
client/src/pages/BlogDetail.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section id="blog-detail" class="page-section block animate-slide-down">
|
||||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
@click="$router.push('/blog')"
|
||||
class="group flex items-center gap-2 text-art-muted hover:text-white transition-colors mb-12"
|
||||
>
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 transform group-hover:-translate-x-1 transition-transform"></i>
|
||||
<span class="text-sm font-medium tracking-wide">返回文章列表</span>
|
||||
</button>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchBlogDetail" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
<button @click="$router.push('/blog')" class="mt-4 px-4 py-2 border border-white/30 text-white rounded hover:bg-white/5 transition-colors">
|
||||
返回文章列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog detail content -->
|
||||
<div v-else class="space-y-12">
|
||||
<!-- 文章信息 -->
|
||||
<div class="border-b border-white/5 pb-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.category }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">年糕崽崽</div>
|
||||
<div class="text-xs text-art-muted">前端架构师</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<article class="blog-content prose prose-invert prose-lg max-w-none text-art-muted leading-relaxed">
|
||||
<div v-html="post.content"></div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchPost, Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postId = route.params.id as string
|
||||
|
||||
const post = ref<Post>({
|
||||
id: postId,
|
||||
title: '',
|
||||
category: '',
|
||||
date: '',
|
||||
excerpt: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchBlogDetail = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const postData = await fetchPost(postId)
|
||||
if (postData) {
|
||||
post.value = postData
|
||||
} else {
|
||||
error.value = '未找到该文章'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching blog detail:', err)
|
||||
error.value = '获取文章详情失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchBlogDetail()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
167
client/src/pages/Home.vue
Normal file
167
client/src/pages/Home.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<section id="home" class="page-section block">
|
||||
<!-- Hero -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-24 items-center animate-reveal">
|
||||
<div class="lg:col-span-7 space-y-8">
|
||||
<p class="text-art-accent font-mono text-sm tracking-widest uppercase mb-4">前端架构师 & 创意开发者</p>
|
||||
<h1 class="font-serif text-5xl md:text-7xl lg:text-8xl leading-[1.1] text-white text-glow">
|
||||
<span class="block italic opacity-80">设计</span>
|
||||
<span class="block font-bold">铸造数字</span>
|
||||
<span class="block font-bold pl-12 md:pl-24">灵魂</span>
|
||||
</h1>
|
||||
<p class="text-art-muted text-lg md:text-xl max-w-xl leading-relaxed mt-8 font-light">
|
||||
在代码的逻辑与设计的感性之间寻找平衡。我是<span class="text-white font-bold">年糕崽崽</span>,不仅仅构建页面,更在构建<span class="text-white border-b border-white/20 pb-0.5">沉浸式体验</span>。
|
||||
</p>
|
||||
<div class="pt-8 flex items-center gap-6">
|
||||
<button @click="$router.push('/works')" class="group flex items-center gap-2 text-white border-b border-white pb-1 hover:text-art-accent hover:border-art-accent transition-all">
|
||||
<span>浏览作品集</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 transform group-hover:translate-x-1 transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-5 relative h-[400px] lg:h-[600px] w-full flex items-center justify-center">
|
||||
<div class="relative w-full h-full">
|
||||
<div class="absolute inset-0 border border-white/10 rounded-full rotate-12 scale-90"></div>
|
||||
<div class="absolute inset-0 border border-white/5 rounded-full -rotate-6 scale-75"></div>
|
||||
<div
|
||||
id="home-hero-work"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-80 bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 rounded-sm shadow-2xl rotate-6 hover:rotate-0 transition-transform duration-700 z-10 flex flex-col p-6 justify-between cursor-pointer"
|
||||
@click="$router.push('/works/' + latestWork.id)"
|
||||
>
|
||||
<div class="text-white/50 text-xs">最新作品</div>
|
||||
<div class="font-serif text-3xl italic text-white">{{ latestWork.title }}</div>
|
||||
<div class="flex justify-end"><i data-lucide="arrow-up-right" class="text-white"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bento Grid -->
|
||||
<div class="space-y-6 animate-reveal" style="animation-delay: 0.2s;">
|
||||
<div class="flex items-end justify-between border-b border-white/10 pb-4 mb-8">
|
||||
<h2 class="font-serif text-3xl italic text-white">精选内容</h2>
|
||||
<span class="font-mono text-xs text-art-muted">下滑探索更多</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 auto-rows-[300px]" id="bento-grid">
|
||||
<!-- Bento items will be rendered here -->
|
||||
<div
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 flex flex-col justify-end group cursor-pointer"
|
||||
@click="$router.push('/blog/' + featuredPost.id)"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent z-10"></div>
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
|
||||
<div class="relative z-20 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.category }}</span>
|
||||
<span class="text-xs text-white/60">{{ featuredPost.date }}</span>
|
||||
</div>
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">当极简主义遇见复杂数据:Dashboard 设计哲学</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="art-card rounded-2xl p-8 flex flex-col justify-between group"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<i data-lucide="code-2" class="w-8 h-8 text-white/40 group-hover:text-art-accent transition-colors"></i>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/20"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-4xl font-mono font-bold text-white mb-2">120+</div>
|
||||
<div class="text-sm text-art-muted">开源提交 (Commits)</div>
|
||||
<div class="text-xs text-art-muted mt-2 opacity-60">Vue, React, Three.js</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="art-card rounded-2xl p-6 md:p-8 flex flex-col justify-between group cursor-pointer bg-[#050505]"
|
||||
@click="$router.push('/snippets')"
|
||||
>
|
||||
<div class="font-mono text-xs text-art-muted mb-4">// React Hook: useArt</div>
|
||||
<div class="font-mono text-sm text-gray-400 overflow-hidden opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<span class="code-keyword">const</span> <span class="code-func">useCreative</span> = () => {<br>
|
||||
<span class="code-keyword">return</span> <span class="code-string">"Innovation"</span>;<br>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<span>访问代码实验室</span>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-art-accent"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between gap-8 group cursor-pointer bg-gradient-to-r from-art-surface to-transparent"
|
||||
@click="$router.push('/about')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-serif text-3xl text-white">需要独特的前端架构?</h3>
|
||||
<p class="text-art-muted max-w-sm">不论是 WebGL 3D 交互网站,还是高性能的 SaaS 管理系统,我都能提供专业的解决方案。</p>
|
||||
</div>
|
||||
<div class="h-16 w-16 rounded-full border border-white/20 flex items-center justify-center group-hover:bg-white group-hover:text-black transition-all duration-300">
|
||||
<i data-lucide="arrow-right" class="w-6 h-6"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { fetchWorks, fetchPosts, Work, Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const latestWork = ref<Work>({
|
||||
id: 'nova',
|
||||
title: 'Nova 交易平台',
|
||||
category: '金融科技',
|
||||
year: '2023',
|
||||
heroImg: '',
|
||||
desc: '',
|
||||
techStack: [],
|
||||
gallery: [],
|
||||
links: { live: '#' },
|
||||
next: ''
|
||||
})
|
||||
|
||||
const featuredPost = ref<Post>({
|
||||
id: 'refactor',
|
||||
title: '当极简主义遇见复杂数据',
|
||||
category: '设计思维',
|
||||
date: '2025-10-24',
|
||||
excerpt: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchHomeData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 获取最新作品
|
||||
const works = await fetchWorks()
|
||||
if (works.length > 0) {
|
||||
latestWork.value = works[0]
|
||||
}
|
||||
|
||||
// 获取精选文章
|
||||
const posts = await fetchPosts()
|
||||
if (posts.length > 0) {
|
||||
featuredPost.value = posts[0]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching home data:', err)
|
||||
error.value = '获取首页数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchHomeData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
})
|
||||
</script>
|
||||
96
client/src/pages/Login.vue
Normal file
96
client/src/pages/Login.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="font-serif text-3xl italic text-white mb-2">后台管理</h2>
|
||||
<p class="text-art-muted text-sm">请输入您的管理员账号</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div class="form-group">
|
||||
<label for="username" class="block text-sm font-medium text-art-muted mb-2">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-art-accent focus:ring-1 focus:ring-art-accent transition-all"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" class="block text-sm font-medium text-art-muted mb-2">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-art-accent focus:ring-1 focus:ring-art-accent transition-all"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-white text-black font-bold py-3 rounded-lg hover:bg-art-accent hover:text-white transition-all transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
登 录
|
||||
</button>
|
||||
<div v-if="error" class="text-red-500 text-sm text-center mt-4 bg-red-500/10 py-2 rounded border border-red-500/20">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { login } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const response = await login(form.value)
|
||||
// 保存token到本地存储
|
||||
localStorage.setItem('token', response.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.user))
|
||||
toast.showToast('登录成功', 'success')
|
||||
// 登录成功后重定向到管理后台
|
||||
router.push('/admin')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || '登录失败,请检查用户名和密码'
|
||||
toast.showToast('登录失败', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
background-image: radial-gradient(circle at 50% 50%, rgba(212, 179, 131, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding: 3rem;
|
||||
border-radius: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
283
client/src/pages/Services.vue
Normal file
283
client/src/pages/Services.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<section id="services" class="page-section block animate-slide-down">
|
||||
<div class="py-16 text-center">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">共创数字未来</h2>
|
||||
<p class="text-art-muted text-lg max-w-2xl mx-auto">用代码构建骨架,用设计触动灵魂。</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-32 px-4" id="services-grid">
|
||||
<div class="tilt-card group h-96">
|
||||
<div class="tilt-inner flex flex-col justify-between">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400 mb-6 border border-blue-500/20 group-hover:scale-110 transition-transform duration-500">
|
||||
<i data-lucide="layers" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">前端架构设计</h3>
|
||||
<p class="text-art-muted text-sm leading-relaxed">为复杂的大型应用提供可扩展的架构方案。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tilt-card group h-96">
|
||||
<div class="tilt-inner flex flex-col justify-between border-art-accent/20 bg-white/5">
|
||||
<div class="w-16 h-16 rounded-2xl bg-art-accent/10 flex items-center justify-center text-art-accent mb-6 border border-art-accent/20 group-hover:scale-110 transition-transform duration-500 shadow-[0_0_30px_-10px_rgba(212,179,131,0.3)]">
|
||||
<i data-lucide="wand-2" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">创意交互开发</h3>
|
||||
<p class="text-art-muted text-sm leading-relaxed">利用 WebGL (Three.js) 和 GSAP 打造令人过目难忘的着陆页。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tilt-card group h-96">
|
||||
<div class="tilt-inner flex flex-col justify-between">
|
||||
<div class="w-16 h-16 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400 mb-6 border border-purple-500/20 group-hover:scale-110 transition-transform duration-500">
|
||||
<i data-lucide="smartphone" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">跨平台应用</h3>
|
||||
<p class="text-art-muted text-sm leading-relaxed">使用 UniApp 或 React Native 开发高质量的移动端应用。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process Accordion -->
|
||||
<div class="mb-32 px-4">
|
||||
<h3 class="font-serif text-3xl text-white text-center mb-12 italic">创作流程</h3>
|
||||
<div class="process-accordion">
|
||||
<div class="process-step">
|
||||
<img src="https://images.unsplash.com/photo-1531403009284-440f080d1e12?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Discovery">
|
||||
<div class="step-content">
|
||||
<div class="step-number">01</div>
|
||||
<h4 class="text-xl font-bold text-white mb-2">灵感 & 探索</h4>
|
||||
<p class="step-desc">深入理解业务需求,进行竞品分析,寻找视觉灵感,确定设计方向。这是地基。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<img src="https://images.unsplash.com/photo-1581291518633-83b4ebd1d83e?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Design">
|
||||
<div class="step-content">
|
||||
<div class="step-number">02</div>
|
||||
<h4 class="text-xl font-bold text-white mb-2">架构 & 设计</h4>
|
||||
<p class="step-desc">设计高保真原型,规划技术架构,确定数据流向。将抽象的想法具象化。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<img src="https://images.unsplash.com/photo-1555099962-4199c345e5dd?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Code">
|
||||
<div class="step-content">
|
||||
<div class="step-number">03</div>
|
||||
<h4 class="text-xl font-bold text-white mb-2">编码 & 雕琢</h4>
|
||||
<p class="step-desc">编写干净、可维护的代码。添加微交互和动画,让页面"活"起来。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<img src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=2426&auto=format&fit=crop" class="step-bg" alt="Launch">
|
||||
<div class="step-content">
|
||||
<div class="step-number">04</div>
|
||||
<h4 class="text-xl font-bold text-white mb-2">测试 & 交付</h4>
|
||||
<p class="step-desc">多设备测试,性能优化,SEO 配置。确保最终交付物完美无瑕。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danmaku & Partners -->
|
||||
<div class="py-20 mb-32 overflow-hidden relative">
|
||||
<div class="absolute left-0 top-0 w-20 h-full bg-gradient-to-r from-art-bg to-transparent z-10"></div>
|
||||
<div class="absolute right-0 top-0 w-20 h-full bg-gradient-to-l from-art-bg to-transparent z-10"></div>
|
||||
<h3 class="text-3xl font-serif text-white text-center mb-12 italic">客户原声</h3>
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchServicesData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-6" id="danmaku-container">
|
||||
<!-- Danmaku items will be rendered here -->
|
||||
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
|
||||
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id" class="danmaku-item">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
|
||||
<span>"{{ testimonial.content }}"</span>
|
||||
</div>
|
||||
<!-- 复制一份数据以实现无缝滚动 -->
|
||||
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
|
||||
<span>"{{ testimonial.content }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="danmaku-row animate-marquee-reverse hover:[animation-play-state:paused]">
|
||||
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id" class="danmaku-item">
|
||||
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
|
||||
<span>"{{ testimonial.content }}"</span>
|
||||
</div>
|
||||
<!-- 复制一份数据以实现无缝滚动 -->
|
||||
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
|
||||
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
|
||||
<span>"{{ testimonial.content }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CTA -->
|
||||
<div class="max-w-4xl mx-auto text-center px-6 mb-20">
|
||||
<div class="p-12 rounded-3xl bg-gradient-to-b from-white/10 to-transparent border border-white/10 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-64 h-64 bg-art-accent/20 blur-[100px] -z-10"></div>
|
||||
<h2 class="text-4xl md:text-5xl font-serif text-white mb-6">准备好开始了吗?</h2>
|
||||
<p class="text-art-muted mb-8 max-w-xl mx-auto">无论是一个疯狂的想法,还是一个具体的业务需求,我都乐意倾听。</p>
|
||||
<button @click="openInquiry" class="inline-flex items-center gap-2 px-8 py-4 bg-white text-black rounded-full font-bold hover:scale-105 transition-transform"><span>发起合作咨询</span><i data-lucide="arrow-right" class="w-5 h-5"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Partners -->
|
||||
<div class="max-w-4xl mx-auto px-6 mb-32 border-t border-white/5 pt-12">
|
||||
<p class="text-center text-xs font-mono text-art-muted tracking-widest uppercase mb-8">Trusted By</p>
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchServicesData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap justify-center gap-8 md:gap-16 opacity-50">
|
||||
<span
|
||||
v-for="partner in partners"
|
||||
:key="partner.id"
|
||||
class="partner-logo text-xl font-bold text-white cursor-pointer"
|
||||
:class="getPartnerFontClass(partner.name)"
|
||||
>
|
||||
{{ partner.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
// 定义类型
|
||||
interface Testimonial {
|
||||
id: string
|
||||
content: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface Partner {
|
||||
id: string
|
||||
name: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
const partners = ref<Partner[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 模拟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' }
|
||||
]
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
// 获取合作伙伴字体类
|
||||
const getPartnerFontClass = (name: string): string => {
|
||||
if (name === 'VOGUE' || name === 'Monocle') {
|
||||
return 'font-serif italic'
|
||||
} else if (name === 'WIRED') {
|
||||
return 'font-mono'
|
||||
} else if (name === 'stripe') {
|
||||
return 'font-sans'
|
||||
}
|
||||
return 'font-bold'
|
||||
}
|
||||
|
||||
const openInquiry = () => {
|
||||
// 使用全局函数打开咨询模态框
|
||||
if (window.openInquiry) {
|
||||
window.openInquiry()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchServicesData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// 添加鼠标倾斜效果
|
||||
const tiltCards = document.querySelectorAll('.tilt-card')
|
||||
tiltCards.forEach(card => {
|
||||
card.addEventListener('mousemove', (e) => {
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const x = e.clientX - cardRect.left
|
||||
const y = e.clientY - cardRect.top
|
||||
|
||||
const xPercent = (x / cardRect.width) * 100
|
||||
const yPercent = (y / cardRect.height) * 100
|
||||
|
||||
card.style.setProperty('--mouse-x', `${xPercent}%`)
|
||||
card.style.setProperty('--mouse-y', `${yPercent}%`)
|
||||
|
||||
// 3D Rotation
|
||||
const rotateX = (y / cardRect.height - 0.5) * 20
|
||||
const rotateY = (x / cardRect.width - 0.5) * -20
|
||||
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
|
||||
})
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0)'
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
152
client/src/pages/Snippets.vue
Normal file
152
client/src/pages/Snippets.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<section id="snippets" class="page-section block animate-slide-down">
|
||||
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20">
|
||||
<div class="mb-12 text-center">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">代码实验室</h2>
|
||||
<p class="text-art-muted">点击下方卡片查看代码与实时效果。</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||||
<p class="mb-4">{{ error }}</p>
|
||||
<button @click="fetchSnippetsData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Snippets list -->
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8" id="snippets-grid">
|
||||
<!-- Snippets will be rendered here -->
|
||||
<div
|
||||
v-for="snippet in snippets"
|
||||
:key="snippet.id"
|
||||
class="art-card rounded-2xl p-8 group cursor-pointer transition-all hover:translate-y-[-5px]"
|
||||
@click="openSnippet(snippet)"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i data-lucide="code-2" class="w-6 h-6 text-art-accent"></i>
|
||||
<span class="font-mono text-sm text-art-muted">{{ getSnippetType(snippet.type) }}</span>
|
||||
</div>
|
||||
<h3 class="text-2xl font-serif text-white mb-4 group-hover:text-art-accent transition-colors">{{ snippet.title }}</h3>
|
||||
<pre class="font-mono text-sm text-art-muted leading-relaxed overflow-hidden text-ellipsis line-clamp-4">{{ snippet.code }}</pre>
|
||||
<div class="flex items-center justify-end mt-6">
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/50 transform group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeMount } from 'vue'
|
||||
import { fetchSnippets, Snippet } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const snippets = ref<Snippet[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchSnippetsData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const snippetsData = await fetchSnippets()
|
||||
snippets.value = snippetsData
|
||||
} catch (err) {
|
||||
console.error('Error fetching snippets:', err)
|
||||
error.value = '获取代码片段失败,请稍后重试'
|
||||
// 如果API调用失败,使用静态数据作为备选
|
||||
snippets.value = [
|
||||
{
|
||||
id: 'mouse',
|
||||
title: 'React 鼠标追踪 Hook',
|
||||
code: `import { useState, useEffect } from 'react';
|
||||
|
||||
export const useMousePosition = () => {
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => {
|
||||
const update = (e) => setPos({ x: e.clientX, y: e.clientY });
|
||||
window.addEventListener('mousemove', update);
|
||||
return () => window.removeEventListener('mousemove', update);
|
||||
}, []);
|
||||
return pos;
|
||||
};`,
|
||||
type: 'mouse'
|
||||
},
|
||||
{
|
||||
id: 'glass',
|
||||
title: 'CSS 极致毛玻璃效果',
|
||||
code: `.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
}`,
|
||||
type: 'glass'
|
||||
},
|
||||
{
|
||||
id: 'noise',
|
||||
title: 'SVG 噪点纹理滤镜',
|
||||
code: `<filter id="noise">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" /
|
||||
</filter>`,
|
||||
type: 'noise'
|
||||
},
|
||||
{
|
||||
id: 'animate',
|
||||
title: 'CSS 流畅动画',
|
||||
code: `.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}`,
|
||||
type: 'animate'
|
||||
}
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSnippetType = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
javascript: 'JavaScript',
|
||||
css: 'CSS',
|
||||
html: 'HTML',
|
||||
mouse: 'React Hook',
|
||||
glass: 'CSS',
|
||||
noise: 'SVG',
|
||||
animate: 'CSS Animation'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
const openSnippet = (snippet: any) => {
|
||||
// 使用全局函数打开代码片段模态框
|
||||
if (window.showSnippet) {
|
||||
window.showSnippet(snippet.title, snippet.code)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchSnippetsData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
173
client/src/pages/WorkDetail.vue
Normal file
173
client/src/pages/WorkDetail.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<section id="work-detail" class="work-detail-page page-section block">
|
||||
<button @click="$router.push('/works')" class="fixed top-8 right-8 z-[60] mix-blend-difference text-white hover:scale-110 transition-transform">
|
||||
<div class="rounded-full border border-white/20 p-4 backdrop-blur-md bg-white/5"><i data-lucide="x" class="w-6 h-6"></i></div>
|
||||
</button>
|
||||
<div class="fixed top-0 left-0 h-1 bg-art-accent z-[60] w-0 transition-all duration-100" id="scroll-progress"></div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="fixed inset-0 bg-black flex items-center justify-center z-[99]">
|
||||
<div class="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="fixed inset-0 bg-black flex flex-col items-center justify-center z-[99] p-8">
|
||||
<p class="text-red-500 text-xl mb-4">{{ error }}</p>
|
||||
<button @click="fetchWorkDetails" class="px-6 py-3 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
<button @click="$router.push('/works')" class="mt-4 px-6 py-3 border border-white/30 text-white rounded hover:bg-white/5 transition-colors">
|
||||
返回作品列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Work content -->
|
||||
<div v-else class="relative w-full" id="work-detail-content">
|
||||
<div class="relative h-[85vh] w-full overflow-hidden group">
|
||||
<img
|
||||
id="work-hero-img"
|
||||
:src="work.heroImg"
|
||||
class="absolute inset-0 w-full h-full object-cover filter grayscale group-hover:grayscale-0 transition-all duration-1000 scale-105"
|
||||
:alt="work.title"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<div class="absolute bottom-0 left-0 p-8 md:p-20 w-full">
|
||||
<div class="flex items-end justify-between border-t border-white/30 pt-8 animate-slide-down">
|
||||
<div>
|
||||
<span id="work-category" class="font-mono text-art-accent text-sm tracking-[0.2em] uppercase mb-2 block">{{ work.category }}</span>
|
||||
<h1 id="work-title" class="font-serif text-5xl md:text-8xl text-white leading-[0.9] mix-blend-overlay">{{ work.title }}</h1>
|
||||
</div>
|
||||
<span id="work-year" class="hidden md:block font-mono text-white/50 text-xl">{{ work.year }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row max-w-[1920px] mx-auto min-h-screen">
|
||||
<div class="md:w-1/3 p-8 md:p-16 md:sticky md:top-0 md:h-screen md:max-h-screen overflow-y-auto custom-scrollbar flex flex-col justify-between border-r border-white/5 bg-[#050505]">
|
||||
<div class="space-y-12">
|
||||
<div>
|
||||
<h3 class="font-mono text-xs text-art-muted uppercase tracking-widest mb-4">关于项目</h3>
|
||||
<div id="work-desc" class="text-white/80 font-light leading-relaxed text-lg font-serif" v-html="work.desc"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-mono text-xs text-art-muted uppercase tracking-widest mb-6">技术全景</h3>
|
||||
<div id="work-tech-stack" class="space-y-6">
|
||||
<div v-for="(tech, index) in work.techStack" :key="index">
|
||||
<div class="tech-category-title">{{ tech.category }}</div>
|
||||
<div class="tech-grid">
|
||||
<span v-for="(item, itemIndex) in tech.items" :key="itemIndex" class="tech-item">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<a
|
||||
id="work-link-live"
|
||||
:href="work.links.live"
|
||||
target="_blank"
|
||||
class="group flex items-center justify-between w-full py-6 border-t border-white/10 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span class="font-serif text-2xl italic text-white group-hover:text-art-accent transition-colors">访问线上项目</span>
|
||||
<i data-lucide="arrow-up-right" class="w-6 h-6 text-white group-hover:rotate-45 transition-transform"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-2/3 bg-[#080808] flex flex-col">
|
||||
<div id="work-gallery" class="flex flex-col flex-grow">
|
||||
<img
|
||||
v-for="(img, index) in work.gallery"
|
||||
:key="index"
|
||||
:src="img"
|
||||
:alt="`${work.title} - 图片 ${index + 1}`"
|
||||
class="gallery-image"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-[40vh] flex items-center justify-center border-t border-white/5 bg-[#050505] cursor-pointer group hover:bg-white/5 transition-colors"
|
||||
@click="$router.push('/works')"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="font-mono text-xs text-art-muted mb-4 tracking-widest">下一个作品</p>
|
||||
<h2 class="font-serif text-5xl text-white group-hover:italic transition-all">返回作品列表</h2>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">© 2024 年糕崽崽. 保留所有权利.</p></footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchWork, Work } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = route.params.id as string
|
||||
|
||||
const work = ref<Work>({
|
||||
id: workId,
|
||||
title: '加载中...',
|
||||
category: '',
|
||||
year: '',
|
||||
heroImg: 'https://via.placeholder.com/1600x900',
|
||||
desc: '<p>加载中...</p>',
|
||||
techStack: [],
|
||||
gallery: [],
|
||||
links: { live: '#' },
|
||||
next: ''
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const updateScrollProgress = () => {
|
||||
const scrollTop = window.scrollY
|
||||
const docHeight = document.documentElement.scrollHeight
|
||||
const winHeight = window.innerHeight
|
||||
const scrollPercent = scrollTop / (docHeight - winHeight)
|
||||
const progressBar = document.getElementById('scroll-progress')
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${scrollPercent * 100}%`
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkDetails = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const workData = await fetchWork(workId)
|
||||
if (workData) {
|
||||
work.value = workData
|
||||
} else {
|
||||
error.value = '未找到该作品'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '获取作品详情失败,请稍后重试'
|
||||
console.error('Error fetching work details:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
fetchWorkDetails()
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener('scroll', updateScrollProgress)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 移除滚动事件监听
|
||||
window.removeEventListener('scroll', updateScrollProgress)
|
||||
})
|
||||
</script>
|
||||
116
client/src/pages/Works.vue
Normal file
116
client/src/pages/Works.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<section id="works" class="page-section block animate-slide-down">
|
||||
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20">
|
||||
<div class="mb-32">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">精选作品</h2>
|
||||
<p class="text-art-muted">这里展示了我参与设计和开发的核心项目。</p>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center text-red-500">
|
||||
<p>{{ error }}</p>
|
||||
<button @click="fetchWorksData" class="mt-4 px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<!-- Works list -->
|
||||
<div v-else class="grid grid-cols-1 gap-20" id="works-list-container">
|
||||
<div
|
||||
v-for="(work, index) in works"
|
||||
:key="work.id"
|
||||
class="group grid grid-cols-1 md:grid-cols-2 gap-10 items-center cursor-pointer"
|
||||
@click="$router.push('/works/' + work.id)"
|
||||
>
|
||||
<!-- 作品图片 -->
|
||||
<div
|
||||
:class="['md:order-1', index % 2 === 1 ? 'md:order-2' : 'md:order-1']"
|
||||
class="relative aspect-[4/3] overflow-hidden rounded-sm bg-gray-900 border border-white/5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 z-10"
|
||||
:class="[
|
||||
index % 2 === 0 ? 'bg-gradient-to-tr from-purple-900/40 to-blue-900/40' : 'bg-gradient-to-tr from-orange-900/40 to-red-900/40',
|
||||
'mix-blend-color-burn'
|
||||
]"
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-black/30 group-hover:bg-transparent transition-colors z-20"></div>
|
||||
<img
|
||||
:src="work.heroImg"
|
||||
:alt="work.title"
|
||||
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 opacity-80"
|
||||
>
|
||||
</div>
|
||||
<!-- 作品信息 -->
|
||||
<div
|
||||
:class="['md:order-2', index % 2 === 1 ? 'md:order-1' : 'md:order-2']"
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- 年份和分类 -->
|
||||
<div class="font-mono text-xs text-art-accent">{{ work.year }} · {{ work.category }}</div>
|
||||
<!-- 作品标题 -->
|
||||
<h3
|
||||
class="text-4xl font-serif text-white group-hover:text-art-accent transition-colors"
|
||||
>
|
||||
{{ work.title }}
|
||||
</h3>
|
||||
<!-- 作品描述 -->
|
||||
<p class="text-art-muted font-light leading-relaxed" v-html="work.desc"></p>
|
||||
<!-- 技术栈 -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<span
|
||||
v-for="(stack, stackIndex) in work.techStack.flatMap(tech => tech.items)"
|
||||
:key="stackIndex"
|
||||
class="px-3 py-1 border border-white/10 rounded-full text-xs text-white/70"
|
||||
>
|
||||
{{ stack }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 查看详情按钮 -->
|
||||
<div class="pt-4">
|
||||
<span class="inline-flex items-center gap-2 text-sm font-medium text-white border-b border-transparent hover:border-art-accent hover:text-art-accent transition-all">
|
||||
查看项目详情 <i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchWorks, Work } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
const works = ref<Work[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchWorksData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
works.value = await fetchWorks()
|
||||
} catch (err) {
|
||||
error.value = '获取作品失败,请稍后重试'
|
||||
console.error('Error fetching works:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 启用滚动动画
|
||||
useScrollAnimation()
|
||||
|
||||
fetchWorksData()
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
362
client/src/pages/admin/Dashboard.vue
Normal file
362
client/src/pages/admin/Dashboard.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users">👥</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-title">用户总数</h3>
|
||||
<p class="stat-value">{{ stats.users }}</p>
|
||||
<span class="stat-change positive">+2.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon posts">📝</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-title">文章总数</h3>
|
||||
<p class="stat-value">{{ stats.posts }}</p>
|
||||
<span class="stat-change positive">+5.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon works">🎨</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-title">作品总数</h3>
|
||||
<p class="stat-value">{{ stats.works }}</p>
|
||||
<span class="stat-change positive">+3.1%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon snippets">💻</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-title">代码片段</h3>
|
||||
<p class="stat-value">{{ stats.snippets }}</p>
|
||||
<span class="stat-change negative">-1.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activities -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">最近操作</h2>
|
||||
<div class="activity-list">
|
||||
<div class="activity-item" v-for="activity in recentActivities" :key="activity.id">
|
||||
<div class="activity-icon">
|
||||
{{ activity.icon }}
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<p class="activity-text">{{ activity.text }}</p>
|
||||
<span class="activity-time">{{ activity.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">快速操作</h2>
|
||||
<div class="quick-actions">
|
||||
<button class="action-btn" @click="router.push('/admin/posts/create')">
|
||||
<span class="action-icon">📝</span>
|
||||
<span class="action-text">新建文章</span>
|
||||
</button>
|
||||
<button class="action-btn" @click="router.push('/admin/works/create')">
|
||||
<span class="action-icon">🎨</span>
|
||||
<span class="action-text">新建作品</span>
|
||||
</button>
|
||||
<button class="action-btn" @click="router.push('/admin/snippets/create')">
|
||||
<span class="action-icon">💻</span>
|
||||
<span class="action-text">新建代码片段</span>
|
||||
</button>
|
||||
<button class="action-btn" @click="router.push('/admin/users/create')">
|
||||
<span class="action-icon">👥</span>
|
||||
<span class="action-text">新建用户</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getDashboardStats, getRecentActivities } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Stats data
|
||||
const stats = ref({
|
||||
users: 0,
|
||||
posts: 0,
|
||||
works: 0,
|
||||
snippets: 0
|
||||
})
|
||||
|
||||
// Recent activities data
|
||||
const recentActivities = ref([])
|
||||
|
||||
// Fetch dashboard data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Get stats
|
||||
const statsData = await getDashboardStats()
|
||||
stats.value = statsData
|
||||
|
||||
// Get recent activities
|
||||
const activitiesData = await getRecentActivities()
|
||||
recentActivities.value = activitiesData
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-right: 1rem;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.stat-icon.users {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
|
||||
.stat-icon.posts {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: rgba(16, 185, 129, 0.8);
|
||||
}
|
||||
|
||||
.stat-icon.works {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
color: rgba(251, 191, 36, 0.8);
|
||||
}
|
||||
|
||||
.stat-icon.snippets {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: rgba(139, 92, 246, 0.8);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Dashboard Grid */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Panel Styles */
|
||||
.panel {
|
||||
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: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin: 0 0 1rem 0;
|
||||
font-family: 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
/* Activity List */
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-align: left;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 5px 15px rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 1.25rem;
|
||||
background: transparent;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
378
client/src/pages/admin/Logs.vue
Normal file
378
client/src/pages/admin/Logs.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="admin-logs">
|
||||
<h1 class="page-title">操作日志管理</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label for="pageSize">每页显示:</label>
|
||||
<CustomSelect
|
||||
v-model.number="pageSize"
|
||||
:options="pageSizeOptions"
|
||||
@update:modelValue="fetchLogs"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>操作人</th>
|
||||
<th>IP地址</th>
|
||||
<th>路径</th>
|
||||
<th>方法</th>
|
||||
<th>状态</th>
|
||||
<th>耗时(ms)</th>
|
||||
<th>操作时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs.list" :key="log.id">
|
||||
<td>{{ log.id }}</td>
|
||||
<td>{{ log.username }}</td>
|
||||
<td>{{ log.ip }}</td>
|
||||
<td class="log-path">{{ log.path }}</td>
|
||||
<td>
|
||||
<span :class="['method-badge', `method-${log.method.toLowerCase()}`]">
|
||||
{{ log.method }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', getStatusClass(log.status)]">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ log.duration }}</td>
|
||||
<td>{{ formatDate(log.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.list.length === 0" class="empty-state">
|
||||
<p>暂无操作日志</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="logs.list.length > 0" class="pagination">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
:disabled="currentPage === 1"
|
||||
@click="changePage(currentPage - 1)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span class="page-info">
|
||||
第 {{ currentPage }} 页,共 {{ totalPages }} 页,总计 {{ logs.total }} 条记录
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="changePage(currentPage + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getOperationLogs, PaginationResponse, OperationLog } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const logs = ref<PaginationResponse<OperationLog>>({
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 10
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// Select options
|
||||
const pageSizeOptions = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' }
|
||||
]
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(logs.value.total / pageSize.value)
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
logs.value = await getOperationLogs(currentPage.value, pageSize.value)
|
||||
} catch (error) {
|
||||
console.error('Error fetching operation logs:', error)
|
||||
toast.error('获取操作日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return 'success'
|
||||
} else if (status >= 400 && status < 500) {
|
||||
return 'warning'
|
||||
} else if (status >= 500) {
|
||||
return 'error'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-logs {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
.log-path {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.method-get {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.method-post {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.method-put {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
.method-delete {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.method-patch {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.method-options {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
368
client/src/pages/admin/PostForm.vue
Normal file
368
client/src/pages/admin/PostForm.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="post-form-container">
|
||||
<h1 class="page-title">{{ isEditing ? '编辑文章' : '新建文章' }}</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form @submit.prevent="handleSubmit" class="post-form">
|
||||
<!-- Title Field -->
|
||||
<div class="form-group">
|
||||
<label for="title">文章标题</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
placeholder="请输入文章标题"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.title">
|
||||
{{ errors.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Field -->
|
||||
<div class="form-group">
|
||||
<label for="category">分类</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
placeholder="请输入文章分类"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.category">
|
||||
{{ errors.category }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Field -->
|
||||
<div class="form-group">
|
||||
<label for="date">发布日期</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
v-model="form.date"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.date">
|
||||
{{ errors.date }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excerpt Field -->
|
||||
<div class="form-group">
|
||||
<label for="excerpt">文章摘要</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
v-model="form.excerpt"
|
||||
placeholder="请输入文章摘要"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.excerpt">
|
||||
{{ errors.excerpt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Field -->
|
||||
<div class="form-group">
|
||||
<label for="content">文章内容</label>
|
||||
<textarea
|
||||
id="content"
|
||||
v-model="form.content"
|
||||
placeholder="请输入文章内容"
|
||||
rows="10"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.content">
|
||||
{{ errors.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Is Published Field -->
|
||||
<div class="form-group">
|
||||
<label for="isPublished">发布状态</label>
|
||||
<CustomSelect
|
||||
v-model="form.isPublished"
|
||||
:options="publishStatusOptions"
|
||||
placeholder="请选择发布状态"
|
||||
/>
|
||||
<div class="error-message" v-if="errors.isPublished">
|
||||
{{ errors.isPublished }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '更新文章' : '创建文章') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createPost, updatePost, fetchPost } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// Form state
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
excerpt: '',
|
||||
content: '',
|
||||
isPublished: 1
|
||||
})
|
||||
|
||||
// Select options
|
||||
const publishStatusOptions = [
|
||||
{ value: 1, label: '已发布' },
|
||||
{ value: 0, label: '草稿' }
|
||||
]
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => delete errors[key])
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Validate title
|
||||
if (!form.title.trim()) {
|
||||
errors.title = '文章标题不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate category
|
||||
if (!form.category.trim()) {
|
||||
errors.category = '文章分类不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate date
|
||||
if (!form.date) {
|
||||
errors.date = '发布日期不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate content
|
||||
if (!form.content.trim()) {
|
||||
errors.content = '文章内容不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// Update existing post
|
||||
await updatePost(route.params.id as string, form)
|
||||
toast.success('文章更新成功')
|
||||
} else {
|
||||
// Create new post
|
||||
await createPost(form)
|
||||
toast.success('文章创建成功')
|
||||
}
|
||||
|
||||
// Redirect to posts list
|
||||
router.push('/admin/posts')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.error(error.message || (isEditing.value ? '更新文章失败' : '创建文章失败'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handler
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/posts')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// If editing, load post data from API
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const postId = route.params.id as string
|
||||
const post = await fetchPost(postId)
|
||||
|
||||
// Populate form with post data
|
||||
form.title = post.title
|
||||
form.category = post.category
|
||||
form.date = post.date
|
||||
form.excerpt = post.excerpt || ''
|
||||
form.content = post.content || ''
|
||||
form.isPublished = post.isPublished === 1 ? 1 : 0
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch post data:', error)
|
||||
toast.error('加载文章数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-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-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.post-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder,
|
||||
.form-group select::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
client/src/pages/admin/Posts.vue
Normal file
234
client/src/pages/admin/Posts.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="admin-posts">
|
||||
<h1 class="page-title">文章管理</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<router-link to="/admin/posts/create" class="btn btn-primary">
|
||||
+ 新增文章
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>发布日期</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="post in posts" :key="post.id">
|
||||
<td>{{ post.id }}</td>
|
||||
<td>{{ post.title }}</td>
|
||||
<td>{{ post.category }}</td>
|
||||
<td>{{ post.date }}</td>
|
||||
<td>
|
||||
<span :class="['status-badge', post.isPublished === 1 ? 'published' : 'draft']">
|
||||
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/admin/posts/${post.id}/edit`" class="btn btn-sm btn-secondary">
|
||||
编辑
|
||||
</router-link>
|
||||
<button @click="deletePost(post.id)" class="btn btn-sm btn-danger">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="posts.length === 0" class="empty-state">
|
||||
<p>暂无文章,请点击上方按钮新增</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminPosts, deletePost as deletePostApi, Post } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const posts = ref<Post[]>([])
|
||||
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
posts.value = await getAdminPosts()
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error)
|
||||
toast.error('获取文章列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deletePost = async (id: string) => {
|
||||
if (confirm('确定要删除这篇文章吗?')) {
|
||||
try {
|
||||
await deletePostApi(id)
|
||||
toast.success('文章删除成功')
|
||||
fetchPosts()
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error)
|
||||
toast.error('删除文章失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPosts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-posts {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.published {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.draft {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
273
client/src/pages/admin/RoleForm.vue
Normal file
273
client/src/pages/admin/RoleForm.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="role-form-container">
|
||||
<h1 class="page-title">{{ isEditing ? '编辑角色' : '新建角色' }}</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form @submit.prevent="handleSubmit" class="role-form">
|
||||
<!-- Role Name Field -->
|
||||
<div class="form-group">
|
||||
<label for="name">角色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="请输入角色名称"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.name">
|
||||
{{ errors.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="请输入角色描述"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.description">
|
||||
{{ errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '更新角色' : '创建角色') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createRole, updateRole, fetchRole } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// Form state
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => delete errors[key])
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Validate name
|
||||
if (!form.name.trim()) {
|
||||
errors.name = '角色名称不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// Update existing role
|
||||
await updateRole(parseInt(route.params.id as string), form)
|
||||
toast.success('角色更新成功')
|
||||
} else {
|
||||
// Create new role
|
||||
await createRole(form)
|
||||
toast.success('角色创建成功')
|
||||
}
|
||||
|
||||
// Redirect to roles list
|
||||
router.push('/admin/roles')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.error(error.message || (isEditing.value ? '更新角色失败' : '创建角色失败'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handler
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/roles')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const roleId = parseInt(route.params.id as string)
|
||||
const role = await fetchRole(roleId)
|
||||
// Populate form with role data
|
||||
form.name = role.name
|
||||
form.description = role.description || ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch role data:', error)
|
||||
toast.error('加载角色数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-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-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.role-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
417
client/src/pages/admin/Roles.vue
Normal file
417
client/src/pages/admin/Roles.vue
Normal file
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="roles-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">角色管理</h1>
|
||||
<button class="create-btn" @click="router.push('/admin/roles/create')">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text">新建角色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色名称"
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
|
||||
<!-- Roles Table -->
|
||||
<div class="table-container">
|
||||
<table class="roles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>角色名称</th>
|
||||
<th>描述</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in filteredRoles" :key="role.id">
|
||||
<td>{{ role.id }}</td>
|
||||
<td>{{ role.name }}</td>
|
||||
<td>{{ role.description || '无描述' }}</td>
|
||||
<td>{{ formatDate(role.createdAt) }}</td>
|
||||
<td class="actions">
|
||||
<button class="action-btn edit" @click="router.push(`/admin/roles/${role.id}/edit`)" title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button class="action-btn delete" @click="handleDelete(role.id)" title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" v-if="filteredRoles.length === 0">
|
||||
<div class="empty-icon">🔒</div>
|
||||
<h3>暂无角色数据</h3>
|
||||
<p>点击右上角按钮创建新角色</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="filteredRoles.length > 0">
|
||||
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
|
||||
上一页
|
||||
</button>
|
||||
<span class="page-info">
|
||||
第 {{ currentPage }} / {{ totalPages }} 页
|
||||
</span>
|
||||
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { getRoles, deleteRole, Role } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const roles = ref<Role[]>([])
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const filteredRoles = computed(() => {
|
||||
let result = roles.value
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(role =>
|
||||
role.name.toLowerCase().includes(query) ||
|
||||
(role.description && role.description.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredRoles.value.length / pageSize.value)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchRoles = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await getRoles()
|
||||
roles.value = data
|
||||
} catch (error) {
|
||||
toast.error('获取角色列表失败')
|
||||
console.error('Error fetching roles:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个角色吗?')) {
|
||||
try {
|
||||
await deleteRole(id)
|
||||
toast.success('角色删除成功')
|
||||
fetchRoles() // Refresh the list
|
||||
} catch (error) {
|
||||
toast.error('删除角色失败')
|
||||
console.error('Error deleting role:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.roles-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background-color: transparent;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.roles-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.roles-table th,
|
||||
.roles-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.roles-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.roles-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
background-color: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
border-color: rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
background-color: rgba(212, 179, 131, 0.3);
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 10px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.search-box {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.roles-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.roles-table th,
|
||||
.roles-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
412
client/src/pages/admin/Settings.vue
Normal file
412
client/src/pages/admin/Settings.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="admin-settings">
|
||||
<h1 class="page-title">系统配置管理</h1>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>键名</th>
|
||||
<th>值</th>
|
||||
<th>描述</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="setting in settings" :key="setting.id">
|
||||
<td>{{ setting.keyName }}</td>
|
||||
<td class="setting-value">{{ setting.value }}</td>
|
||||
<td>{{ setting.description }}</td>
|
||||
<td class="actions">
|
||||
<button @click="editSetting(setting)" class="btn btn-sm btn-secondary">
|
||||
编辑
|
||||
</button>
|
||||
<button @click="deleteSetting(setting.keyName)" class="btn btn-sm btn-danger">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.length === 0" class="empty-state">
|
||||
<p>暂无系统配置</p>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingSetting ? '编辑配置' : '新增配置' }}</h2>
|
||||
<button @click="closeModal" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="saveSetting">
|
||||
<div class="form-group">
|
||||
<label for="keyName">键名</label>
|
||||
<input
|
||||
type="text"
|
||||
id="keyName"
|
||||
v-model="form.keyName"
|
||||
:disabled="editingSetting"
|
||||
required
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">值</label>
|
||||
<input
|
||||
type="text"
|
||||
id="value"
|
||||
v-model="form.value"
|
||||
required
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-control"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="closeModal" class="btn btn-secondary">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getSettings, createSetting, updateSetting, deleteSetting as deleteSettingApi, Setting } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
const settings = ref<Setting[]>([])
|
||||
const showModal = ref(false)
|
||||
const editingSetting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
id: 0,
|
||||
keyName: '',
|
||||
value: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error)
|
||||
toast.error('获取系统配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editSetting = (setting: Setting) => {
|
||||
editingSetting.value = true
|
||||
form.value = {
|
||||
id: setting.id,
|
||||
keyName: setting.keyName,
|
||||
value: setting.value,
|
||||
description: setting.description
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const saveSetting = async () => {
|
||||
try {
|
||||
if (editingSetting.value) {
|
||||
await updateSetting(form.value)
|
||||
toast.success('配置更新成功')
|
||||
} else {
|
||||
await createSetting({
|
||||
keyName: form.value.keyName,
|
||||
value: form.value.value,
|
||||
description: form.value.description
|
||||
})
|
||||
toast.success('配置创建成功')
|
||||
}
|
||||
closeModal()
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error saving setting:', error)
|
||||
toast.error(editingSetting.value ? '更新配置失败' : '创建配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSetting = async (keyName: string) => {
|
||||
if (confirm(`确定要删除配置项 "${keyName}" 吗?`)) {
|
||||
try {
|
||||
await deleteSettingApi(keyName)
|
||||
toast.success('配置删除成功')
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error deleting setting:', error)
|
||||
toast.error('删除配置失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
editingSetting.value = false
|
||||
form.value = {
|
||||
id: 0,
|
||||
keyName: '',
|
||||
value: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-settings {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #d4b383;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
322
client/src/pages/admin/SnippetForm.vue
Normal file
322
client/src/pages/admin/SnippetForm.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="snippet-form-container">
|
||||
<h1 class="page-title">{{ isEditing ? '编辑代码片段' : '新建代码片段' }}</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form @submit.prevent="handleSubmit" class="snippet-form">
|
||||
<!-- Title Field -->
|
||||
<div class="form-group">
|
||||
<label for="title">标题</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
placeholder="请输入代码片段标题"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.title">
|
||||
{{ errors.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Field -->
|
||||
<div class="form-group">
|
||||
<label for="type">类型</label>
|
||||
<input
|
||||
type="text"
|
||||
id="type"
|
||||
v-model="form.type"
|
||||
placeholder="请输入代码类型(如:js、css、html等)"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.type">
|
||||
{{ errors.type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Field -->
|
||||
<div class="form-group">
|
||||
<label for="code">代码内容</label>
|
||||
<textarea
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
placeholder="请输入代码内容"
|
||||
rows="10"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.code">
|
||||
{{ errors.code }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="请输入代码片段描述"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.description">
|
||||
{{ errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '更新代码片段' : '创建代码片段') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createSnippet, updateSnippet, fetchSnippet } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// Form state
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
title: '',
|
||||
code: '',
|
||||
type: 'js',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => delete errors[key])
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Validate title
|
||||
if (!form.title.trim()) {
|
||||
errors.title = '代码片段标题不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!form.type.trim()) {
|
||||
errors.type = '代码类型不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate code
|
||||
if (!form.code.trim()) {
|
||||
errors.code = '代码内容不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// Update existing snippet
|
||||
await updateSnippet(route.params.id as string, form)
|
||||
toast.success('代码片段更新成功')
|
||||
} else {
|
||||
// Create new snippet
|
||||
await createSnippet(form)
|
||||
toast.success('代码片段创建成功')
|
||||
}
|
||||
|
||||
// Redirect to snippets list
|
||||
router.push('/admin/snippets')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.error(error.message || (isEditing.value ? '更新代码片段失败' : '创建代码片段失败'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handler
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/snippets')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const snippetId = route.params.id as string
|
||||
const snippet = await fetchSnippet(snippetId)
|
||||
// Populate form with snippet data
|
||||
form.title = snippet.title
|
||||
form.code = snippet.code
|
||||
form.type = snippet.type
|
||||
form.description = snippet.description || ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch snippet data:', error)
|
||||
toast.error('加载代码片段数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.snippet-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-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.snippet-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder,
|
||||
.form-group select::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
client/src/pages/admin/Snippets.vue
Normal file
218
client/src/pages/admin/Snippets.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="admin-snippets">
|
||||
<h1 class="page-title">代码片段管理</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<router-link to="/admin/snippets/create" class="btn btn-primary">
|
||||
+ 新增代码片段
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>类型</th>
|
||||
<th>查看次数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="snippet in snippets" :key="snippet.id">
|
||||
<td>{{ snippet.id }}</td>
|
||||
<td>{{ snippet.title }}</td>
|
||||
<td>
|
||||
<span class="type-badge">{{ snippet.type }}</span>
|
||||
</td>
|
||||
<td>{{ snippet.viewCount || 0 }}</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/admin/snippets/${snippet.id}/edit`" class="btn btn-sm btn-secondary">
|
||||
编辑
|
||||
</router-link>
|
||||
<button @click="deleteSnippet(snippet.id)" class="btn btn-sm btn-danger">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="snippets.length === 0" class="empty-state">
|
||||
<p>暂无代码片段,请点击上方按钮新增</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAdminSnippets, deleteSnippet as deleteSnippetApi, Snippet } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
const snippets = ref<Snippet[]>([])
|
||||
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
snippets.value = await getAdminSnippets()
|
||||
} catch (error) {
|
||||
console.error('Error fetching snippets:', error)
|
||||
toast.error('获取代码片段列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSnippet = async (id: string) => {
|
||||
if (confirm('确定要删除这个代码片段吗?')) {
|
||||
try {
|
||||
await deleteSnippetApi(id)
|
||||
toast.success('代码片段删除成功')
|
||||
fetchSnippets()
|
||||
} catch (error) {
|
||||
console.error('Error deleting snippet:', error)
|
||||
toast.error('删除代码片段失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSnippets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-snippets {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background-color: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
border: 1px solid rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
219
client/src/pages/admin/TagForm.vue
Normal file
219
client/src/pages/admin/TagForm.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="tag-form-container">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑标签' : '新建标签' }}</h1>
|
||||
|
||||
<div class="form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<!-- 名称字段 -->
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="tagForm.name"
|
||||
class="form-input"
|
||||
placeholder="请输入标签名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 描述字段 -->
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="tagForm.description"
|
||||
class="form-input"
|
||||
placeholder="请输入标签描述"
|
||||
rows="3"
|
||||
></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 { createTag, updateTag, adminGetTag } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// 判断是编辑还是创建
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// 标签表单数据
|
||||
const tagForm = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 获取标签详情
|
||||
const fetchTagDetail = async () => {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const tagId = parseInt(route.params.id as string, 10)
|
||||
const tag = await adminGetTag(tagId)
|
||||
tagForm.value = {
|
||||
name: tag.name,
|
||||
description: tag.description
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch tag detail:', error)
|
||||
toast.error('加载标签数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const tagId = parseInt(route.params.id as string, 10)
|
||||
await updateTag(tagId, tagForm.value)
|
||||
toast.success('标签更新成功')
|
||||
} else {
|
||||
await createTag(tagForm.value)
|
||||
toast.success('标签创建成功')
|
||||
}
|
||||
router.push('/admin/tags')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to submit tag form:', error)
|
||||
toast.error(error.message || (isEdit.value ? '更新标签失败' : '创建标签失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTagDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-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-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-form-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
221
client/src/pages/admin/Tags.vue
Normal file
221
client/src/pages/admin/Tags.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="tags-container">
|
||||
<h1 class="page-title">标签管理</h1>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-bar">
|
||||
<button class="btn-primary" @click="router.push('/admin/tags/create')">
|
||||
<span class="btn-icon">➕</span>
|
||||
新建标签
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tags Table -->
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>描述</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tag in tags" :key="tag.id">
|
||||
<td class="table-cell">{{ tag.id }}</td>
|
||||
<td class="table-cell">{{ tag.name }}</td>
|
||||
<td class="table-cell">{{ tag.description || '-' }}</td>
|
||||
<td class="table-cell">{{ tag.createdAt }}</td>
|
||||
<td class="table-cell">{{ tag.updatedAt }}</td>
|
||||
<td class="table-cell actions">
|
||||
<button class="btn-edit" @click="router.push(`/admin/tags/${tag.id}/edit`)" title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-delete" @click="handleDeleteTag(tag.id)" title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="tags.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 { adminGetTags, deleteTag } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 标签数据
|
||||
const tags = ref([])
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const data = await adminGetTags()
|
||||
tags.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除标签
|
||||
const handleDeleteTag = async (id: number) => {
|
||||
if (confirm('确定要删除这个标签吗?')) {
|
||||
try {
|
||||
await deleteTag(id)
|
||||
fetchTags() // 重新获取标签列表
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tag:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags-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>
|
||||
364
client/src/pages/admin/UserForm.vue
Normal file
364
client/src/pages/admin/UserForm.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="user-form-container">
|
||||
<h1 class="page-title">{{ isEditing ? '编辑用户' : '新建用户' }}</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form @submit.prevent="handleSubmit" class="user-form">
|
||||
<!-- Username Field -->
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.username">
|
||||
{{ errors.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.email">
|
||||
{{ errors.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Field -->
|
||||
<div class="form-group">
|
||||
<label for="role">角色</label>
|
||||
<CustomSelect
|
||||
v-model="form.role"
|
||||
:options="roleOptions"
|
||||
placeholder="请选择角色"
|
||||
/>
|
||||
<div class="error-message" v-if="errors.role">
|
||||
{{ errors.role }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Field -->
|
||||
<div class="form-group">
|
||||
<label for="isActive">状态</label>
|
||||
<CustomSelect
|
||||
v-model="form.isActive"
|
||||
:options="statusOptions"
|
||||
placeholder="请选择状态"
|
||||
/>
|
||||
<div class="error-message" v-if="errors.isActive">
|
||||
{{ errors.isActive }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '更新用户' : '创建用户') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createUser, updateUser, fetchUser, User, API_BASE, getAuthHeaders } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// Form state
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
isActive: 1
|
||||
})
|
||||
|
||||
// Select options
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'editor', label: '编辑' },
|
||||
{ value: 'viewer', label: '查看者' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 1, label: '激活' },
|
||||
{ value: 0, label: '禁用' }
|
||||
]
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => delete errors[key])
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Validate username
|
||||
if (!form.username.trim()) {
|
||||
errors.username = '用户名不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!form.email.trim()) {
|
||||
errors.email = '邮箱不能为空'
|
||||
isValid = false
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
errors.email = '请输入有效的邮箱地址'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if (!form.role) {
|
||||
errors.role = '请选择角色'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// Update existing user
|
||||
await updateUser(parseInt(route.params.id as string), form)
|
||||
toast.success('用户更新成功')
|
||||
} else {
|
||||
// Create new user
|
||||
await createUser(form)
|
||||
toast.success('用户创建成功')
|
||||
}
|
||||
|
||||
// Redirect to users list
|
||||
router.push('/admin/users')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.error(error.message || (isEditing.value ? '更新用户失败' : '创建用户失败'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handler
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/users')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const userId = parseInt(route.params.id as string)
|
||||
console.log(`Fetching user data for ID: ${userId}`)
|
||||
|
||||
// Direct fetch to debug
|
||||
const response = await fetch(`${API_BASE}/admin/users/${userId}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
console.log(`Response status: ${response.status}`)
|
||||
|
||||
// Check response headers
|
||||
const contentType = response.headers.get('content-type')
|
||||
console.log(`Response content-type: ${contentType}`)
|
||||
|
||||
// Read response as text first to debug
|
||||
const responseText = await response.text()
|
||||
console.log(`Response text: ${responseText}`)
|
||||
|
||||
// Then try to parse as JSON
|
||||
if (!response.ok) {
|
||||
// If response is not ok, still try to parse as JSON
|
||||
let errorData
|
||||
try {
|
||||
errorData = JSON.parse(responseText)
|
||||
throw new Error(errorData.error || '获取用户详情失败')
|
||||
} catch (parseError) {
|
||||
throw new Error(`获取用户详情失败,响应格式错误: ${parseError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
const user = JSON.parse(responseText)
|
||||
console.log('Parsed user data:', user)
|
||||
|
||||
// Populate form with user data
|
||||
form.username = user.username
|
||||
form.email = user.email
|
||||
form.role = user.role
|
||||
form.isActive = user.isActive
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch user data:', error)
|
||||
console.error('Error stack:', error.stack)
|
||||
toast.error('加载用户数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-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-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group select::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
574
client/src/pages/admin/Users.vue
Normal file
574
client/src/pages/admin/Users.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div class="users-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">用户管理</h1>
|
||||
<button class="create-btn" @click="router.push('/admin/users/create')">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text">新建用户</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="search-filter">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名或邮箱"
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
<div class="filter-options">
|
||||
<CustomSelect
|
||||
v-model="roleFilter"
|
||||
:options="roleFilterOptions"
|
||||
@update:modelValue="handleFilter"
|
||||
style="width: 120px; margin-right: 10px;"
|
||||
/>
|
||||
<CustomSelect
|
||||
v-model="statusFilter"
|
||||
:options="statusFilterOptions"
|
||||
@update:modelValue="handleFilter"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="user.role">
|
||||
{{ getUserRoleText(user.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="user.isActive ? 'active' : 'inactive'">
|
||||
{{ user.isActive ? '激活' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(user.createdAt) }}</td>
|
||||
<td class="actions">
|
||||
<button class="action-btn edit" @click="router.push(`/admin/users/${user.id}/edit`)" title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button class="action-btn delete" @click="handleDelete(user.id)" title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" v-if="filteredUsers.length === 0">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>暂无用户数据</h3>
|
||||
<p>点击右上角按钮创建新用户</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="filteredUsers.length > 0">
|
||||
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
|
||||
上一页
|
||||
</button>
|
||||
<span class="page-info">
|
||||
第 {{ currentPage }} / {{ totalPages }} 页
|
||||
</span>
|
||||
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { getUsers, deleteUser, User } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const users = ref<User[]>([])
|
||||
const searchQuery = ref('')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Select options
|
||||
const roleFilterOptions = [
|
||||
{ value: '', label: '全部角色' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'editor', label: '编辑' },
|
||||
{ value: 'viewer', label: '查看者' }
|
||||
]
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: '1', label: '激活' },
|
||||
{ value: '0', label: '禁用' }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const filteredUsers = computed(() => {
|
||||
let result = users.value
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(user =>
|
||||
user.username.toLowerCase().includes(query) ||
|
||||
user.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply role filter
|
||||
if (roleFilter.value) {
|
||||
result = result.filter(user => user.role === roleFilter.value)
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter.value !== '') {
|
||||
result = result.filter(user => user.isActive === parseInt(statusFilter.value))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredUsers.value.length / pageSize.value)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchUsers = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await getUsers()
|
||||
users.value = data
|
||||
} catch (error) {
|
||||
toast.error('获取用户列表失败')
|
||||
console.error('Error fetching users:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// Debounce search if needed
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个用户吗?')) {
|
||||
try {
|
||||
await deleteUser(id)
|
||||
toast.success('用户删除成功')
|
||||
fetchUsers() // Refresh the list
|
||||
} catch (error) {
|
||||
toast.error('删除用户失败')
|
||||
console.error('Error deleting user:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getUserRoleText = (role: string): string => {
|
||||
const roleMap: Record<string, string> = {
|
||||
'admin': '管理员',
|
||||
'editor': '编辑',
|
||||
'viewer': '查看者'
|
||||
}
|
||||
return roleMap[role] || role
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Search and Filter */
|
||||
.search-filter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
width: 300px;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-options select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.filter-options select:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.role-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.role-badge.editor {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.role-badge.viewer {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #f59e0b;
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
background-color: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
border-color: rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
background-color: rgba(212, 179, 131, 0.3);
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 10px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.search-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
465
client/src/pages/admin/WorkForm.vue
Normal file
465
client/src/pages/admin/WorkForm.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="work-form-container">
|
||||
<h1 class="page-title">{{ isEditing ? '编辑作品' : '新建作品' }}</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form @submit.prevent="handleSubmit" class="work-form">
|
||||
<!-- Title Field -->
|
||||
<div class="form-group">
|
||||
<label for="title">作品标题</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
placeholder="请输入作品标题"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.title">
|
||||
{{ errors.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Field -->
|
||||
<div class="form-group">
|
||||
<label for="category">分类</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
placeholder="请输入作品分类"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.category">
|
||||
{{ errors.category }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year Field -->
|
||||
<div class="form-group">
|
||||
<label for="year">创作年份</label>
|
||||
<input
|
||||
type="text"
|
||||
id="year"
|
||||
v-model="form.year"
|
||||
placeholder="请输入创作年份"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.year">
|
||||
{{ errors.year }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Image Field -->
|
||||
<div class="form-group">
|
||||
<label for="heroImg">作品主图 URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="heroImg"
|
||||
v-model="form.heroImg"
|
||||
placeholder="请输入作品主图 URL"
|
||||
required
|
||||
/>
|
||||
<div class="error-message" v-if="errors.heroImg">
|
||||
{{ errors.heroImg }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-group">
|
||||
<label for="desc">作品描述</label>
|
||||
<textarea
|
||||
id="desc"
|
||||
v-model="form.desc"
|
||||
placeholder="请输入作品描述"
|
||||
rows="5"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.desc">
|
||||
{{ errors.desc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tech Stack Field (Simplified for now) -->
|
||||
<div class="form-group">
|
||||
<label for="techStack">技术栈(JSON格式)</label>
|
||||
<textarea
|
||||
id="techStack"
|
||||
v-model="techStackJson"
|
||||
placeholder='请输入技术栈 JSON,例如:[{"category": "前端", "items": ["Vue 3", "TypeScript"]}]'
|
||||
rows="3"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.techStack">
|
||||
{{ errors.techStack }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery Field (Simplified for now) -->
|
||||
<div class="form-group">
|
||||
<label for="gallery">作品图库(JSON格式)</label>
|
||||
<textarea
|
||||
id="gallery"
|
||||
v-model="galleryJson"
|
||||
placeholder='请输入图库 JSON,例如:["image1.jpg", "image2.jpg"]'
|
||||
rows="3"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.gallery">
|
||||
{{ errors.gallery }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Field (Simplified for now) -->
|
||||
<div class="form-group">
|
||||
<label for="links">链接(JSON格式)</label>
|
||||
<textarea
|
||||
id="links"
|
||||
v-model="linksJson"
|
||||
placeholder='请输入链接 JSON,例如:{"live": "https://example.com"}'
|
||||
rows="3"
|
||||
required
|
||||
></textarea>
|
||||
<div class="error-message" v-if="errors.links">
|
||||
{{ errors.links }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '更新作品' : '创建作品') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createWork, updateWork, fetchWork } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
// Form state
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
year: '',
|
||||
heroImg: '',
|
||||
desc: '',
|
||||
techStack: [] as { category: string; items: string[] }[],
|
||||
gallery: [] as string[],
|
||||
links: { live: '' }
|
||||
})
|
||||
|
||||
// JSON string representations for easy editing
|
||||
const techStackJson = ref('[]')
|
||||
const galleryJson = ref('[]')
|
||||
const linksJson = ref('{"live": ""}')
|
||||
|
||||
// Watch JSON strings and update form data
|
||||
watch(techStackJson, (newVal) => {
|
||||
try {
|
||||
form.techStack = JSON.parse(newVal)
|
||||
delete errors.techStack
|
||||
} catch (e) {
|
||||
// Validation will catch this
|
||||
}
|
||||
})
|
||||
|
||||
watch(galleryJson, (newVal) => {
|
||||
try {
|
||||
form.gallery = JSON.parse(newVal)
|
||||
delete errors.gallery
|
||||
} catch (e) {
|
||||
// Validation will catch this
|
||||
}
|
||||
})
|
||||
|
||||
watch(linksJson, (newVal) => {
|
||||
try {
|
||||
form.links = JSON.parse(newVal)
|
||||
delete errors.links
|
||||
} catch (e) {
|
||||
// Validation will catch this
|
||||
}
|
||||
})
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => delete errors[key])
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Validate title
|
||||
if (!form.title.trim()) {
|
||||
errors.title = '作品标题不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate category
|
||||
if (!form.category.trim()) {
|
||||
errors.category = '作品分类不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate year
|
||||
if (!form.year.trim()) {
|
||||
errors.year = '创作年份不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate hero image
|
||||
if (!form.heroImg.trim()) {
|
||||
errors.heroImg = '作品主图 URL 不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate description
|
||||
if (!form.desc.trim()) {
|
||||
errors.desc = '作品描述不能为空'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate tech stack JSON
|
||||
try {
|
||||
JSON.parse(techStackJson.value)
|
||||
} catch (e) {
|
||||
errors.techStack = '技术栈 JSON 格式无效'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate gallery JSON
|
||||
try {
|
||||
JSON.parse(galleryJson.value)
|
||||
} catch (e) {
|
||||
errors.gallery = '图库 JSON 格式无效'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Validate links JSON
|
||||
try {
|
||||
JSON.parse(linksJson.value)
|
||||
} catch (e) {
|
||||
errors.links = '链接 JSON 格式无效'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// Update existing work
|
||||
await updateWork(route.params.id as string, form)
|
||||
toast.success('作品更新成功')
|
||||
} else {
|
||||
// Create new work
|
||||
await createWork(form)
|
||||
toast.success('作品创建成功')
|
||||
}
|
||||
|
||||
// Redirect to works list
|
||||
router.push('/admin/works')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.error(error.message || (isEditing.value ? '更新作品失败' : '创建作品失败'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handler
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/works')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const workId = route.params.id as string
|
||||
const work = await fetchWork(workId)
|
||||
// Populate form with work data
|
||||
form.title = work.title
|
||||
form.category = work.category
|
||||
form.year = work.year
|
||||
form.heroImg = work.heroImg
|
||||
form.desc = work.desc
|
||||
form.techStack = work.techStack
|
||||
form.gallery = work.gallery
|
||||
form.links = work.links
|
||||
|
||||
// Update JSON string representations
|
||||
techStackJson.value = JSON.stringify(work.techStack, null, 2)
|
||||
galleryJson.value = JSON.stringify(work.gallery, null, 2)
|
||||
linksJson.value = JSON.stringify(work.links, null, 2)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch work data:', error)
|
||||
toast.error('加载作品数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.work-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-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.work-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder,
|
||||
.form-group select::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border: 1px solid #d4b383;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
205
client/src/pages/admin/Works.vue
Normal file
205
client/src/pages/admin/Works.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="admin-works">
|
||||
<h1 class="page-title">作品管理</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<router-link to="/admin/works/create" class="btn btn-primary">
|
||||
+ 新增作品
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>年份</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="work in works" :key="work.id">
|
||||
<td>{{ work.id }}</td>
|
||||
<td>{{ work.title }}</td>
|
||||
<td>{{ work.category }}</td>
|
||||
<td>{{ work.year }}</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/admin/works/${work.id}/edit`" class="btn btn-sm btn-secondary">
|
||||
编辑
|
||||
</router-link>
|
||||
<button @click="deleteWork(work.id)" class="btn btn-sm btn-danger">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="works.length === 0" class="empty-state">
|
||||
<p>暂无作品,请点击上方按钮新增</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAdminWorks, deleteWork as deleteWorkApi, Work } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
const works = ref<Work[]>([])
|
||||
|
||||
const fetchWorks = async () => {
|
||||
try {
|
||||
works.value = await getAdminWorks()
|
||||
} catch (error) {
|
||||
console.error('Error fetching works:', error)
|
||||
toast.error('获取作品列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWork = async (id: string) => {
|
||||
if (confirm('确定要删除这个作品吗?')) {
|
||||
try {
|
||||
await deleteWorkApi(id)
|
||||
toast.success('作品删除成功')
|
||||
fetchWorks()
|
||||
} catch (error) {
|
||||
console.error('Error deleting work:', error)
|
||||
toast.error('删除作品失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-works {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user