BUG修复,返回结构统一

This commit is contained in:
李琦
2026-01-16 17:03:34 +08:00
parent 87ed11db7a
commit e6edf7210b
57 changed files with 3642 additions and 1074 deletions

View File

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

View File

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

View File

@@ -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: '🏷️' }

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []
}
}

View File

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