初始化

This commit is contained in:
李琦
2026-01-15 13:51:44 +08:00
commit b7b6d3e39e
156 changed files with 38913 additions and 0 deletions

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

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

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

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

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

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

View 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">&times;</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>

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

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

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

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

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

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

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

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