BUG修复,返回结构统一
This commit is contained in:
@@ -358,7 +358,13 @@ const runCode = async () => {
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || '代码执行失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const result = data.result // 从统一响应格式中提取 result
|
||||
|
||||
let outputHtml = ''
|
||||
if (result.error) {
|
||||
|
||||
@@ -93,7 +93,9 @@ const navigate = (target: string) => {
|
||||
const updateActiveNav = () => {
|
||||
const path = route.path
|
||||
if (path === '/') activeNav.value = 'home'
|
||||
else if (path === '/blog') activeNav.value = 'blog'
|
||||
else if (path === '/blog' || path.startsWith('/blog/')) activeNav.value = 'blog'
|
||||
else if (path === '/columns' || path.startsWith('/columns/')) activeNav.value = 'columns'
|
||||
else if (path === '/categories' || path.startsWith('/categories/')) activeNav.value = 'categories'
|
||||
else if (path === '/works' || path.startsWith('/works/')) activeNav.value = 'works'
|
||||
else if (path === '/snippets') activeNav.value = 'snippets'
|
||||
else if (path === '/services') activeNav.value = 'services'
|
||||
|
||||
@@ -199,6 +199,8 @@ const menuItems = ref<MenuItem[]>([
|
||||
isOpen: true,
|
||||
children: [
|
||||
{ title: '文章管理', path: '/admin/posts', icon: '📝' },
|
||||
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
|
||||
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
|
||||
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
|
||||
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
|
||||
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
|
||||
|
||||
@@ -155,8 +155,9 @@ const fetchProfileData = async () => {
|
||||
email: data.email,
|
||||
wechat: data.wechat
|
||||
},
|
||||
techStack: data.techStack,
|
||||
experiences: data.experiences || []
|
||||
// 确保 techStack 和 experiences 始终是数组
|
||||
techStack: Array.isArray(data.techStack) ? data.techStack : [],
|
||||
experiences: Array.isArray(data.experiences) ? data.experiences : []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching profile data:', err)
|
||||
|
||||
@@ -62,8 +62,11 @@
|
||||
>
|
||||
<div class="flex flex-col md:flex-row gap-8 items-start">
|
||||
<div class="md:w-1/4 pt-2">
|
||||
<span class="font-mono text-xs text-art-accent block mb-1">{{ post.category }}</span>
|
||||
<span class="font-mono text-xs text-art-accent block mb-1">{{ post.categoryName || post.category?.name || '未分类' }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-for="tag in post.tags" :key="tag.id" class="text-xs text-white/40 bg-white/5 px-1 rounded">#{{ tag.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-3/4 space-y-4">
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors leading-tight" v-html="highlightText(post.title)">
|
||||
|
||||
56
client/src/pages/Categories.vue
Normal file
56
client/src/pages/Categories.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<section class="page-section block animate-slide-down">
|
||||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
|
||||
<div class="mb-16 text-center">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">分类</h2>
|
||||
<p class="text-art-muted max-w-lg mx-auto mb-8">按照主题浏览文章。</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Categories Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="group p-6 rounded-lg border border-white/5 bg-white/5 hover:bg-white/10 hover:border-art-accent/30 transition-all cursor-pointer"
|
||||
@click="router.push(`/blog?category=${category.id}`)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<span class="text-3xl">📂</span>
|
||||
<span class="font-mono text-xs text-art-muted group-hover:text-art-accent transition-colors">#{{ category.slug }}</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-serif text-white mb-2 group-hover:text-art-accent transition-colors">{{ category.name }}</h3>
|
||||
<p class="text-sm text-art-muted leading-relaxed line-clamp-3">{{ category.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && categories.length === 0" class="text-center py-20 text-art-muted">
|
||||
暂无分类
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchCategories, Category } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
categories.value = await fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
61
client/src/pages/ColumnDetail.vue
Normal file
61
client/src/pages/ColumnDetail.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<section class="page-section block animate-slide-down">
|
||||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="column">
|
||||
<!-- Header -->
|
||||
<div class="mb-16 text-center">
|
||||
<div class="inline-block mb-4 px-3 py-1 rounded-full border border-art-accent/30 text-art-accent text-xs font-mono">专栏</div>
|
||||
<h2 class="font-serif text-4xl italic text-white mb-6">{{ column.name }}</h2>
|
||||
<p class="text-art-muted max-w-lg mx-auto leading-relaxed">{{ column.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Posts List (Placeholder for now as we don't have column_posts API integrated fully yet) -->
|
||||
<!-- Since we don't have GetPostsByColumn API yet, we might need to fetch all posts and filter,
|
||||
or update backend to support fetching posts by column.
|
||||
The SQL has `column_posts` table. I need to check if backend `GetColumn` returns posts or if I need a separate endpoint.
|
||||
-->
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="text-center py-10 border border-dashed border-white/10 rounded-lg">
|
||||
<p class="text-art-muted">专栏文章列表功能开发中...</p>
|
||||
<!--
|
||||
TODO: Fetch posts associated with this column.
|
||||
Backend needs to expose this.
|
||||
For now, I'll just show the column info.
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-20 text-art-muted">
|
||||
未找到专栏
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { fetchColumn, Column } from '../services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const column = ref<Column | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
column.value = await fetchColumn(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch column:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
70
client/src/pages/Columns.vue
Normal file
70
client/src/pages/Columns.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<section class="page-section block animate-slide-down">
|
||||
<div class="max-w-6xl mx-auto pt-32 px-6 pb-20">
|
||||
<div class="mb-16 text-center">
|
||||
<h2 class="font-serif text-5xl italic text-white mb-6">专栏</h2>
|
||||
<p class="text-art-muted max-w-lg mx-auto mb-8">系列文章集合,系统性地学习。</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Columns Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
class="group relative overflow-hidden rounded-xl border border-white/5 bg-white/5 aspect-[16/9] cursor-pointer"
|
||||
@click="router.push(`/columns/${column.id}`)"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<div
|
||||
v-if="column.cover"
|
||||
class="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
||||
:style="{ backgroundImage: `url(${column.cover})` }"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-500"></div>
|
||||
</div>
|
||||
<div v-else class="absolute inset-0 bg-gradient-to-br from-white/5 to-white/10 group-hover:from-white/10 group-hover:to-white/20 transition-colors"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="absolute inset-0 p-8 flex flex-col justify-end">
|
||||
<h3 class="text-3xl font-serif text-white mb-3 group-hover:text-art-accent transition-colors">{{ column.name }}</h3>
|
||||
<p class="text-art-muted leading-relaxed line-clamp-2">{{ column.description || '暂无描述' }}</p>
|
||||
<div class="mt-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform translate-y-2 group-hover:translate-y-0">
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-white border-b border-art-accent pb-1">查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && columns.length === 0" class="text-center py-20 text-art-muted">
|
||||
暂无专栏
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchColumns, Column } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const columns = ref<Column[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await fetchColumns()
|
||||
// Filter active columns
|
||||
columns.value = data.filter(c => c.isActive === 1)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch columns:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
95
client/src/pages/admin/Categories.vue
Normal file
95
client/src/pages/admin/Categories.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="w-full animate-reveal">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-serif italic text-white">分类管理</h1>
|
||||
<button @click="router.push('/admin/categories/create')" class="admin-btn-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-20">ID</th>
|
||||
<th>名称</th>
|
||||
<th>Slug</th>
|
||||
<th>描述</th>
|
||||
<th>排序</th>
|
||||
<th>创建时间</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="category in categories" :key="category.id" class="group transition-colors duration-200">
|
||||
<td class="font-mono text-xs text-white/40">#{{ category.id }}</td>
|
||||
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ category.name }}</td>
|
||||
<td class="text-xs text-art-muted font-mono">{{ category.slug }}</td>
|
||||
<td class="text-sm text-white/70 max-w-xs truncate">{{ category.description || '-' }}</td>
|
||||
<td class="text-xs text-art-muted">{{ category.sortOrder }}</td>
|
||||
<td class="text-art-muted text-xs">{{ category.createdAt }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<router-link :to="`/admin/categories/${category.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
|
||||
编辑
|
||||
</router-link>
|
||||
<button @click="handleDelete(category.id)" class="admin-btn-danger py-1 px-3 text-xs">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="categories.length === 0" class="p-16 text-center">
|
||||
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📂</div>
|
||||
<h3 class="text-white font-medium mb-2">暂无分类</h3>
|
||||
<p class="text-art-muted text-sm mb-6">创建分类来整理您的文章</p>
|
||||
<router-link to="/admin/categories/create" class="admin-btn-secondary inline-flex items-center gap-2">
|
||||
+ 新建分类
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminCategories, deleteCategory, Category } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const categories = ref<Category[]>([])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
categories.value = await getAdminCategories()
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
toast.showToast('获取分类列表失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个分类吗?')) {
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
toast.showToast('分类删除成功', 'success')
|
||||
fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error)
|
||||
toast.showToast('删除分类失败', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
148
client/src/pages/admin/CategoryForm.vue
Normal file
148
client/src/pages/admin/CategoryForm.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="w-full animate-reveal h-[calc(100vh-8rem)] flex flex-col">
|
||||
<!-- Top Bar -->
|
||||
<div class="flex items-center justify-between mb-4 shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-2xl font-serif italic text-white">{{ isEditing ? '编辑分类' : '新建分类' }}</h1>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="admin-btn-secondary" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="admin-btn-primary" @click="handleSubmit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '保存修改' : '创建分类') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card p-6 max-w-2xl mx-auto w-full">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Name Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="name" class="block text-xs font-medium text-art-muted uppercase tracking-wider">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="admin-input"
|
||||
placeholder="请输入分类名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slug Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="slug" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Slug (URL标识)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
class="admin-input font-mono"
|
||||
placeholder="category-slug"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="sortOrder" class="block text-xs font-medium text-art-muted uppercase tracking-wider">排序 (越小越靠前)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sortOrder"
|
||||
v-model="form.sortOrder"
|
||||
class="admin-input"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="description" class="block text-xs font-medium text-art-muted uppercase tracking-wider">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
class="admin-input resize-none"
|
||||
placeholder="简短的分类描述..."
|
||||
></textarea>
|
||||
</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 { createCategory, updateCategory, getAdminCategories } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sortOrder: 0
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.slug.trim()) {
|
||||
toast.showToast('名称和Slug不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
const id = parseInt(route.params.id as string)
|
||||
await updateCategory(id, form)
|
||||
toast.showToast('分类更新成功', 'success')
|
||||
} else {
|
||||
await createCategory(form)
|
||||
toast.showToast('分类创建成功', 'success')
|
||||
}
|
||||
router.push('/admin/categories')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.showToast(error.message || (isEditing.value ? '更新分类失败' : '创建分类失败'), 'error')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
// Note: We don't have getCategoryById in API for single category yet,
|
||||
// but we can fetch all and find one, or update API.
|
||||
// For now, let's fetch all (simple enough for admin)
|
||||
const categories = await getAdminCategories()
|
||||
const category = categories.find(c => c.id === id)
|
||||
if (category) {
|
||||
form.name = category.name
|
||||
form.slug = category.slug
|
||||
form.description = category.description
|
||||
form.sortOrder = category.sortOrder
|
||||
} else {
|
||||
toast.showToast('未找到该分类', 'error')
|
||||
router.push('/admin/categories')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load category:', error)
|
||||
toast.showToast('加载数据失败', 'error')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
191
client/src/pages/admin/ColumnForm.vue
Normal file
191
client/src/pages/admin/ColumnForm.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="w-full animate-reveal h-[calc(100vh-8rem)] flex flex-col">
|
||||
<!-- Top Bar -->
|
||||
<div class="flex items-center justify-between mb-4 shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-2xl font-serif italic text-white">{{ isEditing ? '编辑专栏' : '新建专栏' }}</h1>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="admin-btn-secondary" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="admin-btn-primary" @click="handleSubmit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '提交中...' : (isEditing ? '保存修改' : '创建专栏') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card p-6 max-w-2xl mx-auto w-full">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Name Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="name" class="block text-xs font-medium text-art-muted uppercase tracking-wider">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="admin-input"
|
||||
placeholder="请输入专栏名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cover Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="cover" class="block text-xs font-medium text-art-muted uppercase tracking-wider">封面图片 URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cover"
|
||||
v-model="form.cover"
|
||||
class="admin-input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status Field -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">状态</label>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" v-model="form.isActive" :value="1" class="mr-2">
|
||||
<span class="text-sm text-white">启用</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" v-model="form.isActive" :value="0" class="mr-2">
|
||||
<span class="text-sm text-white">禁用</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="sortOrder" class="block text-xs font-medium text-art-muted uppercase tracking-wider">排序 (越小越靠前)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sortOrder"
|
||||
v-model="form.sortOrder"
|
||||
class="admin-input"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="description" class="block text-xs font-medium text-art-muted uppercase tracking-wider">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
class="admin-input resize-none"
|
||||
placeholder="简短的专栏描述..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Post Management Section (Only in Edit Mode) -->
|
||||
<div v-if="isEditing" class="pt-6 border-t border-white/10">
|
||||
<h3 class="text-lg font-medium text-white mb-4">文章管理</h3>
|
||||
|
||||
<!-- Add Post -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<select v-model="selectedPostId" class="admin-input flex-1">
|
||||
<option value="">选择文章添加到专栏...</option>
|
||||
<option v-for="post in allPosts" :key="post.id" :value="post.id">
|
||||
{{ post.title }} ({{ post.isPublished ? '已发布' : '草稿' }})
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" @click="handleAddPost" class="admin-btn-secondary whitespace-nowrap" :disabled="!selectedPostId">
|
||||
添加文章
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post List -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">已关联文章 ({{ columnPosts.length }})</label>
|
||||
<div v-if="columnPosts.length > 0" class="border border-white/10 rounded-lg divide-y divide-white/5 bg-white/5">
|
||||
<div v-for="post in columnPosts" :key="post.id" class="flex items-center justify-between p-3">
|
||||
<span class="text-sm text-white">{{ post.title }}</span>
|
||||
<button type="button" @click="handleRemovePost(post.id)" class="text-red-400 hover:text-red-300 text-xs px-2 py-1">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-art-muted text-sm border border-dashed border-white/10 rounded-lg">
|
||||
暂无关联文章
|
||||
</div>
|
||||
</div>
|
||||
</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 { createColumn, updateColumn, fetchColumn } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
isActive: 1,
|
||||
sortOrder: 0
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.showToast('名称不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
const id = parseInt(route.params.id as string)
|
||||
await updateColumn(id, form)
|
||||
toast.showToast('专栏更新成功', 'success')
|
||||
} else {
|
||||
await createColumn(form)
|
||||
toast.showToast('专栏创建成功', 'success')
|
||||
}
|
||||
router.push('/admin/columns')
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting form:', error)
|
||||
toast.showToast(error.message || (isEditing.value ? '更新专栏失败' : '创建专栏失败'), 'error')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
const column = await fetchColumn(id)
|
||||
if (column) {
|
||||
form.name = column.name
|
||||
form.cover = column.cover
|
||||
form.description = column.description
|
||||
form.isActive = column.isActive
|
||||
form.sortOrder = column.sortOrder
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load column:', error)
|
||||
toast.showToast('加载数据失败', 'error')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
104
client/src/pages/admin/Columns.vue
Normal file
104
client/src/pages/admin/Columns.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="w-full animate-reveal">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-serif italic text-white">专栏管理</h1>
|
||||
<button @click="router.push('/admin/columns/create')" class="admin-btn-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
新建专栏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-20">ID</th>
|
||||
<th>封面</th>
|
||||
<th>名称</th>
|
||||
<th>描述</th>
|
||||
<th>状态</th>
|
||||
<th>排序</th>
|
||||
<th>创建时间</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="column in columns" :key="column.id" class="group transition-colors duration-200">
|
||||
<td class="font-mono text-xs text-white/40">#{{ column.id }}</td>
|
||||
<td>
|
||||
<div v-if="column.cover" class="w-10 h-10 rounded bg-cover bg-center border border-white/10" :style="{ backgroundImage: `url(${column.cover})` }"></div>
|
||||
<div v-else class="w-10 h-10 rounded bg-white/5 border border-white/10 flex items-center justify-center text-xs text-white/30">无</div>
|
||||
</td>
|
||||
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ column.name }}</td>
|
||||
<td class="text-sm text-white/70 max-w-xs truncate">{{ column.description || '-' }}</td>
|
||||
<td>
|
||||
<span :class="['px-2 py-1 rounded-full text-xs font-medium border', column.isActive === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-gray-500/10 text-gray-400 border-gray-500/20']">
|
||||
{{ column.isActive === 1 ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-xs text-art-muted">{{ column.sortOrder }}</td>
|
||||
<td class="text-art-muted text-xs">{{ column.createdAt }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<router-link :to="`/admin/columns/${column.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
|
||||
编辑
|
||||
</router-link>
|
||||
<button @click="handleDelete(column.id)" class="admin-btn-danger py-1 px-3 text-xs">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="columns.length === 0" class="p-16 text-center">
|
||||
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📚</div>
|
||||
<h3 class="text-white font-medium mb-2">暂无专栏</h3>
|
||||
<p class="text-art-muted text-sm mb-6">创建专栏来组织系列文章</p>
|
||||
<router-link to="/admin/columns/create" class="admin-btn-secondary inline-flex items-center gap-2">
|
||||
+ 新建专栏
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminColumns, deleteColumn, Column } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const columns = ref<Column[]>([])
|
||||
|
||||
const fetchColumns = async () => {
|
||||
try {
|
||||
columns.value = await getAdminColumns()
|
||||
} catch (error) {
|
||||
console.error('Error fetching columns:', error)
|
||||
toast.showToast('获取专栏列表失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个专栏吗?')) {
|
||||
try {
|
||||
await deleteColumn(id)
|
||||
toast.showToast('专栏删除成功', 'success')
|
||||
fetchColumns()
|
||||
} catch (error) {
|
||||
console.error('Error deleting column:', error)
|
||||
toast.showToast('删除专栏失败', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchColumns()
|
||||
})
|
||||
</script>
|
||||
@@ -78,7 +78,8 @@ const fetchSuffixes = async () => {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (response.ok) {
|
||||
suffixes.value = await response.json()
|
||||
const data = await response.json()
|
||||
suffixes.value = data.result || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch suffixes:', error)
|
||||
@@ -108,7 +109,8 @@ const handleAdd = async () => {
|
||||
newSortOrder.value = 0
|
||||
fetchSuffixes()
|
||||
} else {
|
||||
toast.error('添加失败')
|
||||
const errorData = await response.json()
|
||||
toast.error(errorData.message || '添加失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -129,7 +131,8 @@ const handleDelete = async (id: number) => {
|
||||
toast.success('删除成功')
|
||||
fetchSuffixes()
|
||||
} else {
|
||||
toast.error('删除失败')
|
||||
const errorData = await response.json()
|
||||
toast.error(errorData.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -86,7 +86,8 @@ const fetchInquiries = async () => {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (response.ok) {
|
||||
inquiries.value = await response.json()
|
||||
const data = await response.json()
|
||||
inquiries.value = data.result || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error)
|
||||
@@ -106,7 +107,8 @@ const updateStatus = async (id: number, status: number) => {
|
||||
toast.success('状态更新成功')
|
||||
fetchInquiries()
|
||||
} else {
|
||||
toast.error('更新失败')
|
||||
const errorData = await response.json()
|
||||
toast.error(errorData.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -85,16 +85,33 @@
|
||||
<!-- Category Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="category" class="block text-xs font-medium text-art-muted uppercase tracking-wider">分类</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
class="admin-input"
|
||||
placeholder="文章分类"
|
||||
required
|
||||
<CustomSelect
|
||||
v-model="form.categoryId"
|
||||
:options="categories"
|
||||
placeholder="选择文章分类"
|
||||
/>
|
||||
<div class="text-art-error text-xs mt-1" v-if="errors.category">
|
||||
{{ errors.category }}
|
||||
<div class="text-art-error text-xs mt-1" v-if="errors.categoryId">
|
||||
{{ errors.categoryId }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Field -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">标签</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
v-for="tag in availableTags"
|
||||
:key="tag.id"
|
||||
@click="toggleTag(tag.id)"
|
||||
class="px-2 py-1 text-xs rounded border transition-colors"
|
||||
:class="form.tagIds.includes(tag.id) ? 'bg-art-accent/20 border-art-accent text-art-accent' : 'bg-white/5 border-white/10 text-art-muted hover:border-white/30'"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<div v-if="availableTags.length === 0" class="text-xs text-art-muted italic">
|
||||
暂无可用标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +157,7 @@ import { MdEditor } from 'md-editor-v3'
|
||||
import 'md-editor-v3/lib/style.css'
|
||||
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createPost, updatePost, fetchPost } from '../../services/api'
|
||||
import { createPost, updatePost, fetchPost, fetchCategories, fetchTags, Category, Tag } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -158,14 +175,19 @@ const isSubmitting = ref(false)
|
||||
const isEditing = computed(() => !!route.params.id)
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Data sources
|
||||
const categories = ref<{value: number, label: string}[]>([])
|
||||
const availableTags = ref<Tag[]>([])
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
categoryId: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
excerpt: '',
|
||||
content: '',
|
||||
isPublished: 1
|
||||
isPublished: 1,
|
||||
tagIds: [] as number[]
|
||||
})
|
||||
|
||||
// Select options
|
||||
@@ -190,8 +212,8 @@ const validateForm = (): boolean => {
|
||||
}
|
||||
|
||||
// Validate category
|
||||
if (!form.category.trim()) {
|
||||
errors.category = '文章分类不能为空'
|
||||
if (!form.categoryId) {
|
||||
errors.categoryId = '请选择文章分类'
|
||||
isValid = false
|
||||
isSidebarOpen.value = true
|
||||
}
|
||||
@@ -212,6 +234,16 @@ const validateForm = (): boolean => {
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Toggle tag selection
|
||||
const toggleTag = (tagId: number) => {
|
||||
const index = form.tagIds.indexOf(tagId)
|
||||
if (index === -1) {
|
||||
form.tagIds.push(tagId)
|
||||
} else {
|
||||
form.tagIds.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
@@ -221,13 +253,24 @@ const handleSubmit = async () => {
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
// Construct payload
|
||||
const payload = {
|
||||
title: form.title,
|
||||
categoryId: form.categoryId,
|
||||
date: form.date,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
isPublished: form.isPublished,
|
||||
tags: form.tagIds.map(id => ({ id } as any)) // Backend expects objects with ID
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
// Update existing post
|
||||
await updatePost(route.params.id as string, form)
|
||||
await updatePost(route.params.id as string, payload)
|
||||
toast.showToast('文章更新成功', 'success')
|
||||
} else {
|
||||
// Create new post
|
||||
await createPost(form)
|
||||
await createPost(payload)
|
||||
toast.showToast('文章创建成功', 'success')
|
||||
}
|
||||
|
||||
@@ -246,26 +289,49 @@ const handleCancel = () => {
|
||||
router.push('/admin/posts')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// If editing, load post data from API
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
// Load initial data
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Fetch categories and tags in parallel
|
||||
const [cats, tags] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchTags()
|
||||
])
|
||||
|
||||
categories.value = cats.map(c => ({
|
||||
value: c.id,
|
||||
label: c.name
|
||||
}))
|
||||
|
||||
availableTags.value = tags
|
||||
|
||||
// If editing, load post data
|
||||
if (isEditing.value) {
|
||||
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.categoryId = post.categoryId
|
||||
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.showToast('加载文章数据失败: ' + (error.message || '未知错误'), 'error')
|
||||
|
||||
// Map tags to tagIds
|
||||
if (post.tags && post.tags.length > 0) {
|
||||
form.tagIds = post.tags.map(t => t.id)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load data:', error)
|
||||
toast.showToast('加载数据失败: ' + (error.message || '未知错误'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,12 +25,16 @@
|
||||
<tr v-for="post in posts" :key="post.id" class="group transition-colors duration-200">
|
||||
<td class="font-mono text-xs text-white/40">#{{ post.id }}</td>
|
||||
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</td>
|
||||
<td><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ post.category }}</span></td>
|
||||
<td><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ post.categoryName || post.category?.name || '未分类' }}</span></td>
|
||||
<td class="text-art-muted text-xs">{{ post.date }}</td>
|
||||
<td>
|
||||
<span :class="['px-2 py-1 rounded-full text-xs font-medium border', post.isPublished === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20']">
|
||||
<button
|
||||
@click="handleToggleStatus(post)"
|
||||
:class="['px-2 py-1 rounded-full text-xs font-medium border transition-all hover:opacity-80', post.isPublished === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20']"
|
||||
title="点击切换状态"
|
||||
>
|
||||
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
@@ -56,13 +60,41 @@
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Modal -->
|
||||
<div v-if="isCategoryModalOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="closeCategoryModal">
|
||||
<div class="bg-[#1a1a1a] border border-white/10 rounded-lg shadow-xl w-full max-w-md overflow-hidden animate-reveal">
|
||||
<div class="p-4 border-b border-white/10 flex justify-between items-center">
|
||||
<h3 class="text-lg font-serif italic text-white">修改文章分类</h3>
|
||||
<button @click="closeCategoryModal" class="text-white/50 hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-sm text-art-muted">正在修改文章: <span class="text-white">{{ editingPost?.title }}</span></p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">选择分类</label>
|
||||
<CustomSelect
|
||||
v-model="selectedCategoryId"
|
||||
:options="categories"
|
||||
placeholder="请选择分类"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-white/10 flex justify-end gap-3 bg-white/5">
|
||||
<button @click="closeCategoryModal" class="px-4 py-2 text-sm text-white/70 hover:text-white transition-colors">取消</button>
|
||||
<button @click="saveCategory" class="admin-btn-primary py-1.5 px-4 text-sm">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -71,13 +103,32 @@ const posts = ref<Post[]>([])
|
||||
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
posts.value = await getAdminPosts()
|
||||
const response = await getAdminPosts()
|
||||
// Handle PaginationResponse structure
|
||||
if ('list' in response) {
|
||||
posts.value = response.list
|
||||
} else {
|
||||
// Fallback if API returns array directly (legacy)
|
||||
posts.value = response as any
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error)
|
||||
toast.showToast('获取文章列表失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (post: Post) => {
|
||||
const newStatus = post.isPublished === 1 ? 0 : 1
|
||||
try {
|
||||
await togglePostStatus(post.id, newStatus)
|
||||
post.isPublished = newStatus
|
||||
toast.showToast(newStatus === 1 ? '文章已发布' : '文章已转为草稿', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error toggling status:', error)
|
||||
toast.showToast('更新状态失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const deletePost = async (id: number) => {
|
||||
if (confirm('确定要删除这篇文章吗?')) {
|
||||
try {
|
||||
|
||||
@@ -206,14 +206,4 @@ textarea.form-input {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-form-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -203,14 +203,15 @@ onMounted(async () => {
|
||||
let errorData
|
||||
try {
|
||||
errorData = JSON.parse(responseText)
|
||||
throw new Error(errorData.error || '获取用户详情失败')
|
||||
throw new Error(errorData.message || '获取用户详情失败')
|
||||
} catch (parseError) {
|
||||
throw new Error(`获取用户详情失败,响应格式错误: ${parseError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
const user = JSON.parse(responseText)
|
||||
const data = JSON.parse(responseText)
|
||||
const user = data.result // 从统一响应格式中提取 result
|
||||
console.log('Parsed user data:', user)
|
||||
|
||||
// Populate form with user data
|
||||
|
||||
@@ -63,10 +63,14 @@ const works = ref<Work[]>([])
|
||||
|
||||
const fetchWorks = async () => {
|
||||
try {
|
||||
works.value = await getAdminWorks()
|
||||
const result = await getAdminWorks()
|
||||
// 确保 works.value 始终是数组
|
||||
works.value = Array.isArray(result) ? result : []
|
||||
} catch (error) {
|
||||
console.error('Error fetching works:', error)
|
||||
toast.showToast('获取作品列表失败', 'error')
|
||||
// 确保错误时也保持为空数组
|
||||
works.value = []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ const routes = [
|
||||
{ path: 'posts/create', name: 'admin-posts-create', component: () => import('./pages/admin/PostForm.vue') },
|
||||
{ path: 'posts/:id/edit', name: 'admin-posts-edit', component: () => import('./pages/admin/PostForm.vue') },
|
||||
|
||||
// 分类管理
|
||||
{ path: 'categories', name: 'admin-categories', component: () => import('./pages/admin/Categories.vue') },
|
||||
{ path: 'categories/create', name: 'admin-categories-create', component: () => import('./pages/admin/CategoryForm.vue') },
|
||||
{ path: 'categories/:id/edit', name: 'admin-categories-edit', component: () => import('./pages/admin/CategoryForm.vue') },
|
||||
|
||||
// 专栏管理
|
||||
{ path: 'columns', name: 'admin-columns', component: () => import('./pages/admin/Columns.vue') },
|
||||
{ path: 'columns/create', name: 'admin-columns-create', component: () => import('./pages/admin/ColumnForm.vue') },
|
||||
{ path: 'columns/:id/edit', name: 'admin-columns-edit', component: () => import('./pages/admin/ColumnForm.vue') },
|
||||
|
||||
// 作品管理
|
||||
{ path: 'works', name: 'admin-works', component: () => import('./pages/admin/Works.vue') },
|
||||
{ path: 'works/create', name: 'admin-works-create', component: () => import('./pages/admin/WorkForm.vue') },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user