数据结构优化
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<!-- Main Content -->
|
||||
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
|
||||
<main :class="[
|
||||
isAdminOrLogin ? 'w-full h-full' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
|
||||
isAdminOrLoginOrBlog ? 'w-full h-full' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
|
||||
]">
|
||||
<router-view />
|
||||
</main>
|
||||
@@ -58,6 +58,12 @@ const isAdminOrLogin = computed(() => {
|
||||
return path.startsWith('/admin') || path === '/login'
|
||||
})
|
||||
|
||||
// Check if current route is Admin or Login page
|
||||
const isAdminOrLoginOrBlog = computed(() => {
|
||||
const path = route.path
|
||||
return path.startsWith('/admin') || path === '/login' || path.startsWith('/blog/')
|
||||
})
|
||||
|
||||
// 应用网站配置到页面标题和meta标签
|
||||
const applySiteSettings = async () => {
|
||||
try {
|
||||
|
||||
@@ -65,11 +65,13 @@
|
||||
class="admin-input text-sm"
|
||||
@keyup.enter="createColumn"
|
||||
/>
|
||||
<input
|
||||
v-model="newColumn.cover"
|
||||
placeholder="封面图片URL"
|
||||
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="描述"
|
||||
@@ -144,7 +146,7 @@ import {
|
||||
createCategory as createCategoryApi,
|
||||
createColumn as createColumnApi,
|
||||
createTag as createTagApi,
|
||||
updatePost,
|
||||
updatePostRelations,
|
||||
Category,
|
||||
Column,
|
||||
Tag,
|
||||
@@ -152,6 +154,7 @@ import {
|
||||
} from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import CustomSelect from '../CustomSelect.vue'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -320,23 +323,13 @@ const save = async () => {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
title: props.post.title,
|
||||
// 只更新关联关系,不更新内容
|
||||
await updatePostRelations(props.post.id, {
|
||||
categoryId: selectedCategoryId.value,
|
||||
date: props.post.date,
|
||||
excerpt: props.post.excerpt || '',
|
||||
content: props.post.content || '',
|
||||
isPublished: props.post.isPublished || 1,
|
||||
tags: selectedTagIds.value.map(id => ({ id }))
|
||||
}
|
||||
columnId: selectedColumnId.value > 0 ? selectedColumnId.value : null,
|
||||
tagIds: selectedTagIds.value
|
||||
})
|
||||
|
||||
if (selectedColumnId.value > 0) {
|
||||
payload.columnId = selectedColumnId.value
|
||||
} else {
|
||||
payload.columnId = null
|
||||
}
|
||||
|
||||
await updatePost(props.post.id, payload)
|
||||
toast.showToast('保存成功', 'success')
|
||||
emit('saved')
|
||||
close()
|
||||
|
||||
@@ -112,14 +112,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Loading more indicator -->
|
||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
|
||||
<!-- No more posts indicator -->
|
||||
<div v-else-if="!hasMore && blogPosts.length > 0" class="text-center py-8 text-art-muted text-sm">
|
||||
没有更多文章了
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed } from 'vue'
|
||||
import { fetchPosts, fetchCategories, fetchTags, Post, Category, Tag } from '../services/api'
|
||||
import { ref, onMounted, nextTick, computed, onBeforeUnmount } from 'vue'
|
||||
import { fetchPosts, fetchCategories, fetchTags, getPublicSettings, Post, Category, Tag, PaginationResponse } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import CustomSelect from '../components/CustomSelect.vue'
|
||||
|
||||
@@ -133,8 +143,18 @@ const selectedTagId = ref(0)
|
||||
const categories = ref<Category[]>([])
|
||||
const tags = ref<Tag[]>([])
|
||||
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
// 滚动监听器
|
||||
let scrollHandler: (() => void) | null = null
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return selectedCategoryId.value > 0 || selectedTagId.value > 0
|
||||
})
|
||||
@@ -161,16 +181,39 @@ const refreshIcons = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBlogPosts = async () => {
|
||||
loading.value = true
|
||||
const fetchBlogPosts = async (reset: boolean = true) => {
|
||||
if (reset) {
|
||||
loading.value = true
|
||||
currentPage.value = 1
|
||||
blogPosts.value = []
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
try {
|
||||
const query = searchQuery.value.trim() || undefined
|
||||
const categoryId = selectedCategoryId.value > 0 ? selectedCategoryId.value : undefined
|
||||
const tagId = selectedTagId.value > 0 ? selectedTagId.value : undefined
|
||||
|
||||
const posts = await fetchPosts(query, categoryId, tagId)
|
||||
blogPosts.value = posts
|
||||
const response: PaginationResponse<Post> = await fetchPosts(
|
||||
query,
|
||||
categoryId,
|
||||
tagId,
|
||||
undefined,
|
||||
currentPage.value,
|
||||
pageSize.value
|
||||
)
|
||||
|
||||
if (reset) {
|
||||
blogPosts.value = response.list
|
||||
} else {
|
||||
blogPosts.value = [...blogPosts.value, ...response.list]
|
||||
}
|
||||
|
||||
total.value = response.total
|
||||
hasMore.value = blogPosts.value.length < total.value
|
||||
|
||||
nextTick(() => {
|
||||
initObserver()
|
||||
refreshIcons()
|
||||
@@ -180,29 +223,62 @@ const fetchBlogPosts = async () => {
|
||||
error.value = '获取博客文章失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore.value || !hasMore.value) return
|
||||
|
||||
currentPage.value++
|
||||
await fetchBlogPosts(false)
|
||||
}
|
||||
|
||||
// 触底加载
|
||||
const setupScrollListener = () => {
|
||||
scrollHandler = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// 当距离底部 200px 时触发加载
|
||||
if (scrollTop + windowHeight >= documentHeight - 200) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', scrollHandler, { passive: true })
|
||||
}
|
||||
|
||||
// 移除滚动监听
|
||||
const removeScrollListener = () => {
|
||||
if (scrollHandler) {
|
||||
window.removeEventListener('scroll', scrollHandler)
|
||||
scrollHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!searchQuery.value.trim() && !isSearching.value) return
|
||||
isSearching.value = !!searchQuery.value.trim()
|
||||
fetchBlogPosts()
|
||||
fetchBlogPosts(true)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
isSearching.value = false
|
||||
fetchBlogPosts()
|
||||
fetchBlogPosts(true)
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
fetchBlogPosts()
|
||||
fetchBlogPosts(true)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategoryId.value = 0
|
||||
selectedTagId.value = 0
|
||||
fetchBlogPosts()
|
||||
fetchBlogPosts(true)
|
||||
}
|
||||
|
||||
const loadFilters = async () => {
|
||||
@@ -227,6 +303,20 @@ const highlightText = (text: string | undefined) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 从配置读取每页数量
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
const postsPerPage = settings.posts_per_page
|
||||
if (postsPerPage) {
|
||||
const size = parseInt(postsPerPage, 10)
|
||||
if (size > 0) {
|
||||
pageSize.value = size
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts_per_page setting:', err)
|
||||
}
|
||||
|
||||
// 先加载筛选选项
|
||||
await loadFilters()
|
||||
|
||||
@@ -254,7 +344,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 应用筛选并获取文章列表
|
||||
fetchBlogPosts()
|
||||
await fetchBlogPosts(true)
|
||||
refreshIcons()
|
||||
|
||||
// 设置滚动监听
|
||||
setupScrollListener()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeScrollListener()
|
||||
})
|
||||
</script>
|
||||
@@ -38,10 +38,12 @@
|
||||
<Icon name="book-open" :size="14" class="text-art-accent" />
|
||||
所属专栏
|
||||
</h3>
|
||||
|
||||
<!-- 专栏名称和跳转 -->
|
||||
<a
|
||||
@click.prevent="router.push(`/columns/${post.columnId}`)"
|
||||
href="#"
|
||||
class="block p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
class="block p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group mb-4"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.columnName }}
|
||||
@@ -51,6 +53,52 @@
|
||||
查看专栏
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 专栏文章目录 -->
|
||||
<div v-if="columnPosts.length > 0" class="space-y-2">
|
||||
<!-- 展开/收起按钮 -->
|
||||
<button
|
||||
@click="isColumnExpanded = !isColumnExpanded"
|
||||
class="w-full flex items-center justify-between text-xs text-art-muted hover:text-white transition-colors py-1 px-2 rounded hover:bg-white/5"
|
||||
>
|
||||
<span class="font-medium">文章目录</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ isColumnExpanded ? '收起' : '展开' }}</span>
|
||||
<Icon
|
||||
:name="isColumnExpanded ? 'chevron-up' : 'chevron-down'"
|
||||
:size="14"
|
||||
class="transition-transform"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<nav class="space-y-0.5 border-t border-white/10 pt-2">
|
||||
<a
|
||||
v-for="columnPost in displayedColumnPosts"
|
||||
:key="columnPost.id"
|
||||
:href="`/blog/${columnPost.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block text-xs py-2 px-3 rounded transition-all duration-200 relative group"
|
||||
:class="[
|
||||
columnPost.id === post.id
|
||||
? 'text-white pl-4'
|
||||
: 'text-art-muted hover:text-white hover:bg-white/5'
|
||||
]"
|
||||
>
|
||||
<div class="line-clamp-2 leading-relaxed">
|
||||
{{ columnPost.title }}
|
||||
</div>
|
||||
<div v-if="columnPost.id === post.id" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-3 bg-art-accent/40 rounded-full"></div>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="loadingColumnPosts" class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类搜索卡片 -->
|
||||
@@ -292,7 +340,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchPost, getRecommendedPosts, Post, getPublicSettings } from '../services/api'
|
||||
import { fetchPost, getRecommendedPosts, fetchColumnPosts, Post, getPublicSettings } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
@@ -341,6 +389,11 @@ const error = ref('')
|
||||
const recommendedPosts = ref<Post[]>([])
|
||||
const loadingRecommendations = ref(false)
|
||||
|
||||
// 专栏文章目录相关状态
|
||||
const columnPosts = ref<Post[]>([])
|
||||
const isColumnExpanded = ref(false)
|
||||
const loadingColumnPosts = ref(false)
|
||||
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
const md = new MarkdownIt({
|
||||
@@ -492,6 +545,54 @@ const activeIndicatorStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 计算当前文章在专栏文章列表中的索引(根据文章ID查找)
|
||||
const currentPostIndex = computed(() => {
|
||||
if (!post.value.id || columnPosts.value.length === 0) return -1
|
||||
// 根据当前文章ID在专栏文章列表中查找索引
|
||||
const index = columnPosts.value.findIndex(p => p.id === post.value.id)
|
||||
return index
|
||||
})
|
||||
|
||||
// 计算显示的文章列表(默认显示上一个、当前、下一个,展开后显示全部)
|
||||
const displayedColumnPosts = computed(() => {
|
||||
if (columnPosts.value.length === 0) return []
|
||||
|
||||
if (isColumnExpanded.value) {
|
||||
// 展开状态:显示全部文章
|
||||
return columnPosts.value
|
||||
}
|
||||
|
||||
// 收起状态:根据当前文章ID找到位置,显示上一个、当前、下一个(共3篇)
|
||||
const currentIndex = currentPostIndex.value
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// 如果当前文章不在专栏文章列表中,显示前3篇
|
||||
return columnPosts.value.slice(0, Math.min(3, columnPosts.value.length))
|
||||
}
|
||||
|
||||
// 根据当前文章的位置,计算要显示的范围
|
||||
// 目标:显示上一个、当前、下一个(共3篇)
|
||||
const total = columnPosts.value.length
|
||||
|
||||
// 计算起始索引:尽量显示上一个(如果存在)
|
||||
let start = Math.max(0, currentIndex - 1)
|
||||
|
||||
// 计算结束索引:尽量显示下一个(如果存在)
|
||||
let end = Math.min(total, currentIndex + 2)
|
||||
|
||||
// 如果当前文章在开头,确保至少显示当前和下一个
|
||||
if (currentIndex === 0 && total > 1) {
|
||||
end = Math.min(3, total)
|
||||
}
|
||||
|
||||
// 如果当前文章在末尾,确保至少显示上一个和当前
|
||||
if (currentIndex === total - 1 && total > 1) {
|
||||
start = Math.max(0, total - 2)
|
||||
}
|
||||
|
||||
return columnPosts.value.slice(start, end)
|
||||
})
|
||||
|
||||
const addCopyListeners = () => {
|
||||
if (!articleRef.value) return
|
||||
const buttons = articleRef.value.querySelectorAll('.copy-btn')
|
||||
@@ -541,6 +642,10 @@ const fetchBlogDetail = async () => {
|
||||
await updatePageTitle(postData.title)
|
||||
}
|
||||
fetchRecommendations()
|
||||
// 如果有专栏,加载专栏文章列表
|
||||
if (postData.columnId) {
|
||||
await fetchColumnPostsList(postData.columnId)
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initObserver()
|
||||
@@ -573,6 +678,27 @@ const fetchRecommendations = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载专栏文章列表
|
||||
const fetchColumnPostsList = async (columnId?: number) => {
|
||||
const id = columnId || post.value.columnId
|
||||
if (!id) {
|
||||
columnPosts.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loadingColumnPosts.value = true
|
||||
try {
|
||||
// 获取专栏的所有文章(无limit限制)
|
||||
const posts = await fetchColumnPosts(id)
|
||||
columnPosts.value = posts || []
|
||||
} catch (err) {
|
||||
console.error('Error fetching column posts:', err)
|
||||
columnPosts.value = []
|
||||
} finally {
|
||||
loadingColumnPosts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBlogDetail()
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -502,9 +502,20 @@ export const deleteWork = async (id: string): Promise<void> => {
|
||||
}
|
||||
|
||||
// 文章管理API
|
||||
export const getAdminPosts = async (): Promise<PaginationResponse<Post>> => {
|
||||
export const getAdminPosts = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
keyword?: string
|
||||
): Promise<PaginationResponse<Post>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/posts?pageSize=1000`, {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', page.toString())
|
||||
params.append('pageSize', pageSize.toString())
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/admin/posts?${params.toString()}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (!response.ok) {
|
||||
@@ -519,7 +530,14 @@ export const getAdminPosts = async (): Promise<PaginationResponse<Post>> => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchPosts = async (query?: string, categoryId?: number, tagId?: number, columnId?: number): Promise<Post[]> => {
|
||||
export const fetchPosts = async (
|
||||
query?: string,
|
||||
categoryId?: number,
|
||||
tagId?: number,
|
||||
columnId?: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<PaginationResponse<Post>> => {
|
||||
try {
|
||||
let url = `${API_BASE}/posts`
|
||||
const params = new URLSearchParams()
|
||||
@@ -527,6 +545,8 @@ export const fetchPosts = async (query?: string, categoryId?: number, tagId?: nu
|
||||
if (categoryId) params.append('category', categoryId.toString())
|
||||
if (tagId) params.append('tag', tagId.toString())
|
||||
if (columnId) params.append('column', columnId.toString())
|
||||
if (page) params.append('page', page.toString())
|
||||
if (pageSize) params.append('pageSize', pageSize.toString())
|
||||
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`
|
||||
@@ -535,10 +555,28 @@ export const fetchPosts = async (query?: string, categoryId?: number, tagId?: nu
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Failed to fetch posts')
|
||||
const data = await response.json()
|
||||
return data.result || []
|
||||
|
||||
// 返回分页格式的响应
|
||||
if (data.result && typeof data.result === 'object' && 'list' in data.result) {
|
||||
return data.result as PaginationResponse<Post>
|
||||
}
|
||||
|
||||
// 兼容旧格式(直接返回数组)
|
||||
const list = Array.isArray(data.result) ? data.result : []
|
||||
return {
|
||||
list,
|
||||
total: list.length,
|
||||
page: page || 1,
|
||||
size: pageSize || list.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error)
|
||||
return []
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: page || 1,
|
||||
size: pageSize || 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +645,38 @@ export const updatePost = async (id: number | string, postData: Omit<Post, 'id'>
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文章关联关系(只更新分类、专栏、标签,不更新内容)
|
||||
export const updatePostRelations = async (
|
||||
id: number | string,
|
||||
relations: { categoryId: number; columnId: number | null; tagIds: number[] }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const payload: any = {
|
||||
categoryId: relations.categoryId,
|
||||
tagIds: relations.tagIds
|
||||
}
|
||||
|
||||
if (relations.columnId !== null && relations.columnId > 0) {
|
||||
payload.columnId = relations.columnId
|
||||
} else {
|
||||
payload.columnId = null
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/admin/posts/${id}/relations`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || '更新文章关联失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update post relations error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const togglePostStatus = async (id: number, isPublished: number): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/posts/${id}/status`, {
|
||||
@@ -1104,6 +1174,7 @@ export interface PublicSettings {
|
||||
site_author?: string
|
||||
site_keywords?: string
|
||||
visible_menus?: string
|
||||
posts_per_page?: string
|
||||
}
|
||||
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
|
||||
@@ -40,8 +40,30 @@ func GetPosts(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库获取所有博客文章
|
||||
posts, err := repositories.GetPosts(keyword, categoryID, tagID, columnID)
|
||||
// 获取分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// 从数据库读取 posts_per_page 配置作为默认值
|
||||
pageSize := 10 // 默认值
|
||||
setting, err := repositories.GetSettingByKey("posts_per_page")
|
||||
if err == nil && setting != nil {
|
||||
if ps, err := strconv.Atoi(setting.Value); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求中指定了 pageSize,则使用请求的值
|
||||
if pageSizeStr := c.Query("pageSize"); pageSizeStr != "" {
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库获取博客文章(支持分页)
|
||||
posts, total, err := repositories.GetPosts(keyword, categoryID, tagID, columnID, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
@@ -64,15 +86,21 @@ func GetPosts(c *gin.Context) {
|
||||
LogSearch(strconv.FormatUint(uint64(columnID), 10), "column", userIP, userLocation)
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
// 构建响应(返回分页格式)
|
||||
responses := repositories.BuildPostsResponse(posts)
|
||||
// Ensure not nil
|
||||
if responses == nil {
|
||||
// We need to return []
|
||||
utils.Success(c, []interface{}{})
|
||||
} else {
|
||||
utils.Success(c, responses)
|
||||
responses = []models.PostResponse{}
|
||||
}
|
||||
|
||||
// 返回分页格式的响应
|
||||
res := gin.H{
|
||||
"list": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
}
|
||||
|
||||
utils.Success(c, res)
|
||||
}
|
||||
|
||||
func GetPost(c *gin.Context) {
|
||||
@@ -169,9 +197,22 @@ func GetPost(c *gin.Context) {
|
||||
// 获取所有文章(包括未发布的,后台用)
|
||||
func AdminGetPosts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "1000")) // Default to 1000 to mimic "all" for now
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
posts, total, err := repositories.GetAllPosts(page, pageSize)
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) // 默认20条
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 获取搜索关键词
|
||||
keyword := c.Query("keyword")
|
||||
if keyword == "" {
|
||||
keyword = c.Query("q") // 兼容 q 参数
|
||||
}
|
||||
|
||||
posts, total, err := repositories.GetAllPosts(page, pageSize, keyword)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
@@ -247,6 +288,34 @@ func AdminUpdatePost(c *gin.Context) {
|
||||
utils.SuccessWithMsg(c, "Post updated successfully", nil)
|
||||
}
|
||||
|
||||
// 更新文章关联关系(只更新分类、专栏、标签,不更新内容)
|
||||
func AdminUpdatePostRelations(c *gin.Context) {
|
||||
postIDStr := c.Param("id")
|
||||
var postID uint
|
||||
if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil {
|
||||
utils.Error(c, 400, "Invalid post ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CategoryID uint `json:"categoryId"`
|
||||
ColumnID *uint `json:"columnId"`
|
||||
TagIDs []uint `json:"tagIds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Error(c, 400, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新关联关系
|
||||
if err := repositories.UpdatePostRelations(postID, req.CategoryID, req.ColumnID, req.TagIDs); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessWithMsg(c, "Post relations updated successfully", nil)
|
||||
}
|
||||
|
||||
// 切换文章发布状态
|
||||
func AdminTogglePostStatus(c *gin.Context) {
|
||||
postIDStr := c.Param("id")
|
||||
@@ -355,18 +424,49 @@ func GetPostsByTagID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
posts, err := repositories.GetPosts("", 0, tagID, 0)
|
||||
// 获取分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// 从数据库读取 posts_per_page 配置作为默认值
|
||||
pageSize := 10 // 默认值
|
||||
setting, err := repositories.GetSettingByKey("posts_per_page")
|
||||
if err == nil && setting != nil {
|
||||
if ps, err := strconv.Atoi(setting.Value); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求中指定了 pageSize,则使用请求的值
|
||||
if pageSizeStr := c.Query("pageSize"); pageSizeStr != "" {
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
posts, total, err := repositories.GetPosts("", 0, tagID, 0, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := repositories.BuildPostsResponse(posts)
|
||||
if res == nil {
|
||||
utils.Success(c, []interface{}{})
|
||||
} else {
|
||||
utils.Success(c, res)
|
||||
// 构建响应(返回分页格式)
|
||||
responses := repositories.BuildPostsResponse(posts)
|
||||
if responses == nil {
|
||||
responses = []models.PostResponse{}
|
||||
}
|
||||
|
||||
// 返回分页格式的响应
|
||||
res := gin.H{
|
||||
"list": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
}
|
||||
|
||||
utils.Success(c, res)
|
||||
}
|
||||
|
||||
// GetRecommendedPosts 获取推荐文章(基于IP的协同过滤)
|
||||
|
||||
@@ -20,7 +20,7 @@ func GetSettings(c *gin.Context) {
|
||||
|
||||
// 只返回前端需要的公开配置项
|
||||
publicSettings := make(map[string]string)
|
||||
publicKeys := []string{"site_title", "site_description", "site_author", "site_keywords", "visible_menus"}
|
||||
publicKeys := []string{"site_title", "site_description", "site_author", "site_keywords", "visible_menus", "posts_per_page"}
|
||||
|
||||
for _, key := range publicKeys {
|
||||
if value, exists := settingsMap[key]; exists {
|
||||
|
||||
@@ -148,7 +148,8 @@ func main() {
|
||||
authAdmin.GET("/posts", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPosts)
|
||||
authAdmin.POST("/posts", middleware.PermissionMiddleware("posts", "create"), handlers.AdminCreatePost)
|
||||
authAdmin.PUT("/posts/:id", middleware.PermissionMiddleware("posts", "update"), handlers.AdminUpdatePost)
|
||||
authAdmin.PATCH("/posts/:id/status", middleware.PermissionMiddleware("posts", "update"), handlers.AdminTogglePostStatus) // 新增状态切换
|
||||
authAdmin.PATCH("/posts/:id/relations", middleware.PermissionMiddleware("posts", "update"), handlers.AdminUpdatePostRelations) // 更新文章关联关系
|
||||
authAdmin.PATCH("/posts/:id/status", middleware.PermissionMiddleware("posts", "update"), handlers.AdminTogglePostStatus) // 新增状态切换
|
||||
authAdmin.DELETE("/posts/:id", middleware.PermissionMiddleware("posts", "delete"), handlers.AdminDeletePost)
|
||||
|
||||
// 分类管理 (复用 posts 权限)
|
||||
|
||||
@@ -17,13 +17,13 @@ type TrendData struct {
|
||||
MoM float64 `json:"mom"`
|
||||
}
|
||||
|
||||
// GetPosts 获取所有博客文章(支持搜索、分类、标签筛选)
|
||||
func GetPosts(keyword string, categoryID uint, tagID uint, columnID uint) ([]models.Post, error) {
|
||||
// GetPosts 获取所有博客文章(支持搜索、分类、标签筛选,支持分页)
|
||||
func GetPosts(keyword string, categoryID uint, tagID uint, columnID uint, page int, pageSize int) ([]models.Post, int64, error) {
|
||||
var posts []models.Post
|
||||
var total int64
|
||||
|
||||
// 构建基础查询
|
||||
query := config.DB.Model(&models.Post{}).
|
||||
Preload("Category").
|
||||
Preload("Column").
|
||||
Preload("Tags").
|
||||
Where("is_published = ? AND deleted_at = ?", 1, 0)
|
||||
|
||||
if tagID > 0 {
|
||||
@@ -45,13 +45,30 @@ func GetPosts(keyword string, categoryID uint, tagID uint, columnID uint) ([]mod
|
||||
keyword, likeKeyword, likeKeyword)
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").Find(&posts).Error
|
||||
// 计算总数
|
||||
countQuery := query
|
||||
err := countQuery.Count(&total).Error
|
||||
if err != nil {
|
||||
log.Printf("Error querying posts: %v", err)
|
||||
return nil, err
|
||||
log.Printf("Error counting posts: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
// 应用分页
|
||||
offset := (page - 1) * pageSize
|
||||
err = query.
|
||||
Preload("Category").
|
||||
Preload("Column").
|
||||
Preload("Tags").
|
||||
Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&posts).Error
|
||||
if err != nil {
|
||||
log.Printf("Error querying posts: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// GetPostByID 根据ID获取博客文章
|
||||
@@ -82,28 +99,36 @@ func GetPostByID(id uint) (*models.Post, error) {
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// GetAllPosts 获取所有博客文章(包括未发布的,后台用)
|
||||
func GetAllPosts(page, pageSize int) ([]models.Post, int64, error) {
|
||||
// GetAllPosts 获取所有博客文章(包括未发布的,后台用,支持搜索)
|
||||
func GetAllPosts(page, pageSize int, keyword string) ([]models.Post, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var posts []models.Post
|
||||
var total int64
|
||||
|
||||
// 构建查询
|
||||
query := config.DB.Model(&models.Post{}).
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
// 如果有关键词,添加搜索条件
|
||||
if keyword != "" {
|
||||
likeKeyword := "%" + keyword + "%"
|
||||
query = query.Where("(MATCH(title, content) AGAINST(? IN BOOLEAN MODE) OR title LIKE ? OR content LIKE ?)",
|
||||
keyword, likeKeyword, likeKeyword)
|
||||
}
|
||||
|
||||
// Count total
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
Count(&total).Error
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
log.Printf("Error counting posts: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get posts
|
||||
err = config.DB.Model(&models.Post{}).
|
||||
err = query.
|
||||
Preload("Category").
|
||||
Preload("Column").
|
||||
Preload("Tags").
|
||||
Where("deleted_at = ?", 0).
|
||||
Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
@@ -149,11 +174,12 @@ func CreatePost(post *models.Post) error {
|
||||
|
||||
// UpdatePost 更新博客文章
|
||||
func UpdatePost(post *models.Post) error {
|
||||
// 使用 map 更新,并明确指定要更新的字段,确保即使字段是空字符串也会被更新
|
||||
updateData := map[string]interface{}{
|
||||
"title": post.Title,
|
||||
"category_id": post.CategoryID,
|
||||
"excerpt": post.Excerpt,
|
||||
"content": post.Content,
|
||||
"content": post.Content, // 明确包含 content,即使为空字符串也会更新
|
||||
"is_published": post.IsPublished,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}
|
||||
@@ -166,8 +192,10 @@ func UpdatePost(post *models.Post) error {
|
||||
updateData["column_id"] = nil
|
||||
}
|
||||
|
||||
// 使用 Select 明确指定要更新的字段,确保所有字段都被更新
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
Where("id = ? AND deleted_at = ?", post.ID, 0).
|
||||
Select("title", "category_id", "column_id", "excerpt", "content", "is_published", "updated_at").
|
||||
Updates(updateData).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -212,6 +240,71 @@ func UpdatePost(post *models.Post) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePostRelations 只更新文章的关联关系(分类、专栏、标签),不更新内容
|
||||
func UpdatePostRelations(postID uint, categoryID uint, columnID *uint, tagIDs []uint) error {
|
||||
// 更新分类和专栏
|
||||
updateData := map[string]interface{}{
|
||||
"category_id": categoryID,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if columnID != nil {
|
||||
updateData["column_id"] = columnID
|
||||
} else {
|
||||
updateData["column_id"] = nil
|
||||
}
|
||||
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
Where("id = ? AND deleted_at = ?", postID, 0).
|
||||
Updates(updateData).Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error updating post relations: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新标签关联
|
||||
post := &models.Post{ID: postID}
|
||||
if len(tagIDs) > 0 {
|
||||
// 构建标签对象
|
||||
tags := make([]models.Tag, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
tags = append(tags, models.Tag{ID: tagID})
|
||||
}
|
||||
err = config.DB.Model(post).Association("Tags").Replace(tags)
|
||||
if err != nil {
|
||||
log.Printf("Error updating tags: %v", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 清除所有标签
|
||||
err = config.DB.Model(post).Association("Tags").Clear()
|
||||
if err != nil {
|
||||
log.Printf("Error clearing tags: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 处理专栏关联
|
||||
// 先移除所有专栏关联
|
||||
err = config.DB.Where("post_id = ?", postID).Delete(&models.ColumnPost{}).Error
|
||||
if err != nil {
|
||||
log.Printf("Error removing post from columns: %v", err)
|
||||
// Don't fail the whole operation
|
||||
}
|
||||
|
||||
// 然后添加新的专栏关联
|
||||
if columnID != nil && *columnID > 0 {
|
||||
err = AddPostToColumn(*columnID, postID, 0)
|
||||
if err != nil {
|
||||
log.Printf("Error adding post to column: %v", err)
|
||||
// Don't fail the whole operation
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePostStatus 更新文章状态
|
||||
func UpdatePostStatus(id uint, status int) error {
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
|
||||
Reference in New Issue
Block a user