数据结构优化
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user