数据结构优化

This commit is contained in:
李琦
2026-01-23 16:05:23 +08:00
parent 889247b33a
commit 9d39b7c22b
11 changed files with 881 additions and 87 deletions

View File

@@ -96,7 +96,16 @@
<!-- Category Field -->
<div class="space-y-2">
<label for="category" class="block text-xs font-medium text-art-muted uppercase tracking-wider">分类</label>
<div class="flex items-center justify-between">
<label for="category" class="block text-xs font-medium text-art-muted uppercase tracking-wider">分类</label>
<button
type="button"
@click="showCreateCategory = true"
class="text-xs text-art-accent hover:text-white transition-colors"
>
+ 新建分类
</button>
</div>
<CustomSelect
v-model="form.categoryId"
:options="categories"
@@ -105,21 +114,79 @@
<div class="text-art-error text-xs mt-1" v-if="errors.categoryId">
{{ errors.categoryId }}
</div>
<!-- Quick Create Category -->
<div v-if="showCreateCategory" class="admin-card p-3 space-y-2 mt-2">
<input
v-model="newCategory.name"
placeholder="分类名称"
class="admin-input text-sm"
@keyup.enter="createCategory"
/>
<div class="flex gap-2">
<button type="button" @click="createCategory" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button type="button" @click="showCreateCategory = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</div>
</div>
<!-- Column Field -->
<div class="space-y-2">
<label for="column" class="block text-xs font-medium text-art-muted uppercase tracking-wider">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<div class="flex items-center justify-between">
<label for="column" class="block text-xs font-medium text-art-muted uppercase tracking-wider">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<button
type="button"
@click="showCreateColumn = true"
class="text-xs text-art-accent hover:text-white transition-colors"
>
+ 新建专栏
</button>
</div>
<CustomSelect
v-model="form.columnId"
:options="columns"
placeholder="选择专栏(可选)"
/>
<!-- Quick Create Column -->
<div v-if="showCreateColumn" class="admin-card p-3 space-y-2 mt-2">
<input
v-model="newColumn.name"
placeholder="专栏名称"
class="admin-input text-sm"
/>
<div>
<label class="block text-xs text-art-muted mb-2">封面图片</label>
<ImageUpload
v-model="newColumn.cover"
:categoryId="1"
/>
</div>
<textarea
v-model="newColumn.description"
placeholder="描述"
rows="2"
class="admin-input text-sm"
/>
<div class="flex gap-2">
<button type="button" @click="createColumn" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button type="button" @click="showCreateColumn = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</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 items-center justify-between">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">标签</label>
<button
type="button"
@click="showCreateTag = true"
class="text-xs text-art-accent hover:text-white transition-colors"
>
+ 新建标签
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
@@ -135,6 +202,20 @@
暂无可用标签
</div>
</div>
<!-- Quick Create Tag -->
<div v-if="showCreateTag" class="admin-card p-3 space-y-2 mt-2">
<input
v-model="newTag.name"
placeholder="标签名称"
class="admin-input text-sm"
@keyup.enter="createTag"
/>
<div class="flex gap-2">
<button type="button" @click="createTag" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button type="button" @click="showCreateTag = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</div>
</div>
<!-- Date Field -->
@@ -187,9 +268,21 @@ import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useToast } from '../../composables/useToast'
import { createPost, updatePost, fetchPost, fetchCategories, fetchColumns, fetchTags, Tag } from '../../services/api'
import {
createPost,
updatePost,
fetchPost,
fetchCategories,
fetchColumns,
fetchTags,
createCategory as createCategoryApi,
createColumn as createColumnApi,
createTag as createTagApi,
Tag
} from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()
@@ -217,6 +310,15 @@ const categories = ref<{value: number, label: string}[]>([])
const columns = ref<{value: number, label: string}[]>([])
const availableTags = ref<Tag[]>([])
// Create form states
const showCreateCategory = ref(false)
const showCreateColumn = ref(false)
const showCreateTag = ref(false)
const newCategory = ref({ name: '', slug: '', description: '' })
const newColumn = ref({ name: '', cover: '', description: '', isActive: 1, sortOrder: 0 })
const newTag = ref({ name: '', slug: '' })
// Form data
const form = reactive({
title: '',
@@ -283,6 +385,86 @@ const toggleTag = (tagId: number) => {
}
}
// Create category
const createCategory = async () => {
if (!newCategory.value.name) {
toast.showToast('请输入分类名称', 'error')
return
}
try {
await createCategoryApi({
name: newCategory.value.name,
slug: newCategory.value.slug || newCategory.value.name,
description: newCategory.value.description || '',
sortOrder: 0
})
toast.showToast('分类创建成功', 'success')
const createdName = newCategory.value.name
newCategory.value = { name: '', slug: '', description: '' }
showCreateCategory.value = false
await loadData()
// 自动选择新创建的分类
const newCat = categories.value.find(c => c.label === createdName)
if (newCat) {
form.categoryId = newCat.value
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
// Create column
const createColumn = async () => {
if (!newColumn.value.name) {
toast.showToast('请输入专栏名称', 'error')
return
}
try {
await createColumnApi(newColumn.value)
toast.showToast('专栏创建成功', 'success')
const createdName = newColumn.value.name
newColumn.value = { name: '', cover: '', description: '', isActive: 1, sortOrder: 0 }
showCreateColumn.value = false
await loadData()
// 自动选择新创建的专栏
const newCol = columns.value.find(c => c.label === createdName)
if (newCol) {
form.columnId = newCol.value
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
// Create tag
const createTag = async () => {
if (!newTag.value.name) {
toast.showToast('请输入标签名称', 'error')
return
}
try {
await createTagApi({
name: newTag.value.name,
slug: newTag.value.slug || newTag.value.name
})
toast.showToast('标签创建成功', 'success')
const createdName = newTag.value.name
newTag.value = { name: '', slug: '' }
showCreateTag.value = false
await loadData()
// 自动选择新创建的标签
const newTagItem = availableTags.value.find(t => t.name === createdName)
if (newTagItem && !form.tagIds.includes(newTagItem.id)) {
form.tagIds.push(newTagItem.id)
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {

View File

@@ -8,6 +8,38 @@
</router-link>
</div>
<!-- 搜索和筛选区域 -->
<div class="admin-card mb-6">
<div class="flex items-center gap-4">
<div class="flex-1 relative">
<input
type="text"
v-model="searchQuery"
@keyup.enter="handleSearch"
placeholder="搜索文章标题或内容..."
class="admin-input w-full pl-10"
/>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute left-3 top-1/2 -translate-y-1/2 text-art-muted">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-3 top-1/2 -translate-y-1/2 text-art-muted hover:text-white transition-colors"
>
<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="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button @click="handleSearch" class="admin-btn-secondary">
搜索
</button>
</div>
</div>
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
<table class="admin-table">
@@ -85,14 +117,52 @@
</table>
</div>
<div v-if="posts.length === 0" class="p-16 text-center">
<div v-if="posts.length === 0 && !loading" 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/posts/create" class="admin-btn-secondary inline-flex items-center gap-2">
<p class="text-art-muted text-sm mb-6">{{ searchQuery ? '没有找到匹配的文章' : '开始您的创作之旅吧' }}</p>
<router-link v-if="!searchQuery" to="/admin/posts/create" class="admin-btn-secondary inline-flex items-center gap-2">
+ 新增文章
</router-link>
</div>
<!-- 分页组件 -->
<div v-if="total > 0" class="border-t border-white/10 px-6 py-4 flex items-center justify-between">
<div class="text-sm text-art-muted">
共 {{ total }} 条,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="flex items-center gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="admin-btn-secondary px-3 py-1 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<div class="flex items-center gap-1">
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-1 text-sm rounded transition-colors',
page === currentPage
? 'bg-art-accent text-white'
: 'admin-btn-secondary'
]"
>
{{ page }}
</button>
</div>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="admin-btn-secondary px-3 py-1 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
<!-- Post Relation Modal -->
@@ -112,8 +182,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
import { ref, onMounted, computed } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus, PaginationResponse } from '../../services/api'
import { useToast } from '../../composables/useToast'
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
@@ -126,6 +196,36 @@ const isAccessLogModalOpen = ref(false)
const selectedPostId = ref<number | null>(null)
const selectedPostTitle = ref<string>('')
// 搜索和分页状态
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
// 计算总页数
const totalPages = computed(() => {
return Math.ceil(total.value / pageSize.value)
})
// 计算可见的页码
const visiblePages = computed(() => {
const pages: number[] = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const openRelationModal = (post: Post) => {
editingPost.value = post
isRelationModalOpen.value = true
@@ -138,18 +238,43 @@ const openAccessLogModal = (post: Post) => {
}
const fetchPosts = async () => {
loading.value = true
try {
const response = await getAdminPosts()
const response = await getAdminPosts(currentPage.value, pageSize.value, searchQuery.value)
// Handle PaginationResponse structure
if ('list' in response) {
posts.value = response.list
total.value = response.total
} else {
// Fallback if API returns array directly (legacy)
posts.value = response as any
total.value = (response as any).length || 0
}
} catch (error) {
console.error('Error fetching posts:', error)
toast.showToast('获取文章列表失败', 'error')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchPosts()
}
const clearSearch = () => {
searchQuery.value = ''
currentPage.value = 1
fetchPosts()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchPosts()
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}