588 lines
14 KiB
Vue
588 lines
14 KiB
Vue
<template>
|
||
<div class="admin-page home-featured-page">
|
||
<div class="page-grid card">
|
||
<!-- 左侧:候选文章列表(GET /api/articles) -->
|
||
<section class="column">
|
||
<div class="section-header">
|
||
<div>
|
||
<div class="section-title">候选文章</div>
|
||
<p class="section-sub">搜索 / 分页选择文章加入首页推送</p>
|
||
</div>
|
||
<div class="section-actions">
|
||
<input
|
||
v-model="searchKeyword"
|
||
class="input"
|
||
type="text"
|
||
placeholder="搜索标题 / 描述 / 标签"
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
<button class="btn" type="button" @click="handleSearch" :disabled="loadingCandidates">
|
||
{{ loadingCandidates ? '查询中...' : '搜索' }}
|
||
</button>
|
||
<button class="btn ghost" type="button" @click="refreshCandidates" :disabled="loadingCandidates">
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="candidate-list">
|
||
<div v-if="loadingCandidates" class="empty">文章列表加载中...</div>
|
||
<div v-else-if="candidates.length === 0" class="empty">暂无匹配的文章</div>
|
||
<div v-else class="candidate-cards">
|
||
<article v-for="article in candidates" :key="article.slug" class="candidate-card">
|
||
<div class="card-main">
|
||
<div class="card-title">{{ article.title }}</div>
|
||
<p class="card-desc">
|
||
{{ article.description || '暂无描述' }}
|
||
</p>
|
||
<div class="card-tags" v-if="article.tagList?.length">
|
||
<span v-for="tag in article.tagList" :key="`${article.slug}-${tag}`" class="tag-chip">
|
||
{{ tag }}
|
||
</span>
|
||
</div>
|
||
<div class="card-meta">
|
||
<span>浏览:{{ article.views ?? 0 }}</span>
|
||
<span class="dot">·</span>
|
||
<span>创建:{{ formatDate(article.createdAt) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
<button
|
||
class="btn primary"
|
||
type="button"
|
||
:disabled="isInSlots(article.slug) || slotsFull"
|
||
@click="addToSlots(article)"
|
||
>
|
||
{{ isInSlots(article.slug) ? '已加入' : '加入槽位' }}
|
||
</button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pagination" v-if="totalPages > 1">
|
||
<button class="btn ghost" type="button" :disabled="page === 1" @click="prevPage">
|
||
上一页
|
||
</button>
|
||
<span class="page-info">第 {{ page }} / {{ totalPages }} 页</span>
|
||
<button class="btn ghost" type="button" :disabled="page >= totalPages" @click="nextPage">
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 右侧:首页推送槽位(GET/PUT /admin/home-featured-articles) -->
|
||
<section class="column">
|
||
<div class="section-header">
|
||
<div>
|
||
<div class="section-title">首页推送槽位</div>
|
||
<p class="section-sub">最多 {{ maxSlots }} 条,保存后前台 /api/home-featured-articles 可见</p>
|
||
</div>
|
||
<div class="section-actions">
|
||
<button class="btn ghost" type="button" @click="refreshSlots" :disabled="loadingSlots">
|
||
{{ loadingSlots ? '读取中...' : '刷新配置' }}
|
||
</button>
|
||
<button class="btn primary" type="button" @click="saveSlots" :disabled="saving || !slots.length">
|
||
{{ saving ? '保存中...' : '保存配置' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="slots-list">
|
||
<div v-if="loadingSlots" class="empty">正在读取首页推送配置...</div>
|
||
<div v-else-if="slots.length === 0" class="empty">尚未添加首页推送文章</div>
|
||
<div v-else class="slot-items">
|
||
<div v-for="(article, index) in slots" :key="article.slug" class="slot-item">
|
||
<div class="slot-index">#{{ index + 1 }}</div>
|
||
<div class="slot-body">
|
||
<div class="card-title">{{ article.title }}</div>
|
||
<p class="card-desc">
|
||
{{ article.description || '暂无描述' }}
|
||
</p>
|
||
<div class="card-tags" v-if="article.tagList?.length">
|
||
<span v-for="tag in article.tagList" :key="`${article.slug}-${tag}`" class="tag-chip">
|
||
{{ tag }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="slot-actions">
|
||
<button class="btn ghost small" type="button" :disabled="index === 0" @click="moveSlot(index, -1)">
|
||
上移
|
||
</button>
|
||
<button
|
||
class="btn ghost small"
|
||
type="button"
|
||
:disabled="index === slots.length - 1"
|
||
@click="moveSlot(index, 1)"
|
||
>
|
||
下移
|
||
</button>
|
||
<button class="btn danger small" type="button" @click="removeSlot(index)">移除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="slots-footer">
|
||
<span>已选择 {{ slots.length }} / {{ maxSlots }} 条</span>
|
||
<span class="muted">保存后前台会实时展示,建议保持 10 条满配</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { navigateTo, useAsyncData, useRuntimeConfig } from '#app'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useAuthToken } from '@/composables/useApi'
|
||
|
||
definePageMeta({
|
||
layout: 'admin',
|
||
adminTitle: '首页推送',
|
||
adminSub: '配置首页推送文章,控制前台首页推荐顺序',
|
||
})
|
||
|
||
interface ArticleItem {
|
||
slug: string
|
||
title: string
|
||
description?: string | null
|
||
tagList?: string[]
|
||
views?: number
|
||
createdAt?: string
|
||
}
|
||
|
||
interface ArticleListResponse {
|
||
articles?: ArticleItem[]
|
||
articles_count?: number
|
||
articlesCount?: number
|
||
}
|
||
|
||
interface FeaturedResponse {
|
||
articles?: ArticleItem[]
|
||
}
|
||
|
||
const pageSize = 8 // 候选列表分页大小,可根据后台能力调整
|
||
const maxSlots = 10 // 首页推送最大槽位数,保存时会截断到该数量
|
||
|
||
const searchKeyword = ref('')
|
||
const page = ref(1)
|
||
const total = ref(0)
|
||
const candidates = ref<ArticleItem[]>([])
|
||
const slots = ref<ArticleItem[]>([])
|
||
const saving = ref(false)
|
||
const loadingCandidates = ref(false)
|
||
const loadingSlots = ref(false)
|
||
const hasAccess = ref(false)
|
||
|
||
const { token } = useAuthToken()
|
||
const { user: authUser, fetchMe } = useAuth()
|
||
const { public: config } = useRuntimeConfig()
|
||
|
||
const authHeaders = computed(() => {
|
||
return token.value ? { Authorization: `Token ${token.value}` } : {}
|
||
})
|
||
|
||
const isAdmin = computed(() => {
|
||
const roles = authUser.value?.roles
|
||
return Array.isArray(roles) && roles.includes('admin')
|
||
})
|
||
|
||
function formatDate(value?: string): string {
|
||
if (!value) return '--'
|
||
try {
|
||
return new Date(value).toLocaleDateString()
|
||
} catch {
|
||
return '--'
|
||
}
|
||
}
|
||
|
||
async function ensureAccess(redirect = true): Promise<boolean> {
|
||
if (!token.value) {
|
||
hasAccess.value = false
|
||
if (redirect) await navigateTo('/login')
|
||
return false
|
||
}
|
||
if (!authUser.value) {
|
||
try {
|
||
await fetchMe()
|
||
} catch (err) {
|
||
console.error('[Admin] fetch user failed', err)
|
||
}
|
||
}
|
||
const ok = isAdmin.value
|
||
hasAccess.value = ok
|
||
if (!ok && redirect) await navigateTo('/')
|
||
return ok
|
||
}
|
||
|
||
// GET /api/articles(搜索 + 分页)
|
||
const { data: candidateData, refresh: refreshCandidatesData } = useAsyncData(
|
||
'admin-home-candidates',
|
||
() =>
|
||
$fetch<ArticleListResponse>(`${config.apiBase}/articles`, {
|
||
headers: authHeaders.value,
|
||
params: {
|
||
limit: pageSize,
|
||
offset: (page.value - 1) * pageSize,
|
||
search: searchKeyword.value || undefined,
|
||
},
|
||
}),
|
||
{ server: false, immediate: false },
|
||
)
|
||
|
||
watch(candidateData, (res) => {
|
||
const list = Array.isArray(res?.articles) ? res?.articles : []
|
||
candidates.value = list
|
||
const totalCount = Number(res?.articles_count ?? res?.articlesCount ?? list.length)
|
||
total.value = Number.isNaN(totalCount) ? list.length : totalCount
|
||
})
|
||
|
||
async function handleSearch(): Promise<void> {
|
||
page.value = 1
|
||
await refreshCandidates()
|
||
}
|
||
|
||
async function refreshCandidates(): Promise<void> {
|
||
if (!hasAccess.value) return
|
||
loadingCandidates.value = true
|
||
try {
|
||
await refreshCandidatesData()
|
||
} finally {
|
||
loadingCandidates.value = false
|
||
}
|
||
}
|
||
|
||
const totalPages = computed(() =>
|
||
Math.max(1, Math.ceil(total.value / pageSize)),
|
||
)
|
||
|
||
function prevPage(): void {
|
||
if (page.value <= 1) return
|
||
page.value -= 1
|
||
refreshCandidates()
|
||
}
|
||
|
||
function nextPage(): void {
|
||
if (page.value >= totalPages.value) return
|
||
page.value += 1
|
||
refreshCandidates()
|
||
}
|
||
|
||
// GET /admin/home-featured-articles(加载已有配置)
|
||
const { data: slotData, refresh: refreshSlotData } = useAsyncData(
|
||
'admin-home-featured',
|
||
() =>
|
||
$fetch<FeaturedResponse>(`${config.apiBase}/admin/home-featured-articles`, {
|
||
headers: authHeaders.value,
|
||
}),
|
||
{ server: false, immediate: false },
|
||
)
|
||
|
||
watch(slotData, (res) => {
|
||
const list = Array.isArray(res?.articles) ? res.articles : []
|
||
slots.value = list.slice(0, maxSlots)
|
||
})
|
||
|
||
async function refreshSlots(): Promise<void> {
|
||
if (!hasAccess.value) return
|
||
loadingSlots.value = true
|
||
try {
|
||
await refreshSlotData()
|
||
} finally {
|
||
loadingSlots.value = false
|
||
}
|
||
}
|
||
|
||
const slotsFull = computed(() => slots.value.length >= maxSlots)
|
||
|
||
function isInSlots(slug?: string): boolean {
|
||
if (!slug) return false
|
||
return slots.value.some((item) => item.slug === slug)
|
||
}
|
||
|
||
function addToSlots(article: ArticleItem): void {
|
||
if (slotsFull.value) {
|
||
alert(`最多只能选择 ${maxSlots} 条首页推送`)
|
||
return
|
||
}
|
||
if (isInSlots(article.slug)) return
|
||
slots.value = [...slots.value, article].slice(0, maxSlots)
|
||
}
|
||
|
||
function removeSlot(index: number): void {
|
||
slots.value.splice(index, 1)
|
||
}
|
||
|
||
function moveSlot(index: number, delta: number): void {
|
||
const newIndex = index + delta
|
||
if (newIndex < 0 || newIndex >= slots.value.length) return
|
||
const newList = [...slots.value]
|
||
const [moved] = newList.splice(index, 1)
|
||
newList.splice(newIndex, 0, moved)
|
||
slots.value = newList
|
||
}
|
||
|
||
// PUT /admin/home-featured-articles(保存配置)
|
||
async function saveSlots(): Promise<void> {
|
||
if (!hasAccess.value || saving.value || !slots.value.length) return
|
||
saving.value = true
|
||
try {
|
||
await $fetch(`${config.apiBase}/admin/home-featured-articles`, {
|
||
method: 'PUT',
|
||
headers: authHeaders.value,
|
||
body: {
|
||
articles: slots.value.map((item) => ({ slug: item.slug })),
|
||
},
|
||
})
|
||
await refreshSlots()
|
||
alert('首页推送配置已保存')
|
||
} catch (err: any) {
|
||
console.error('[Admin] save home featured failed', err)
|
||
alert(err?.statusMessage || '保存失败,请稍后重试')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function initPage(): Promise<void> {
|
||
if (await ensureAccess(true)) {
|
||
await Promise.all([refreshCandidates(), refreshSlots()])
|
||
}
|
||
}
|
||
|
||
onMounted(initPage)
|
||
|
||
watch(
|
||
() => token.value,
|
||
async () => {
|
||
if (await ensureAccess(false)) {
|
||
await Promise.all([refreshCandidates(), refreshSlots()])
|
||
} else {
|
||
candidates.value = []
|
||
slots.value = []
|
||
}
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.admin-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.card {
|
||
background: #ffffff;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||
padding: 16px;
|
||
}
|
||
|
||
.page-grid {
|
||
display: grid;
|
||
grid-template-columns: 1.1fr 0.9fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.column {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.section-title {
|
||
font-weight: 800;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.section-sub {
|
||
margin-top: 4px;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.section-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.input {
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid #d1d5db;
|
||
min-width: 220px;
|
||
font-size: 14px;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn {
|
||
padding: 8px 14px;
|
||
border-radius: 10px;
|
||
border: 1px solid #d1d5db;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.18s ease;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn.primary {
|
||
background: linear-gradient(120deg, #22d3ee, #6366f1);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.btn.ghost {
|
||
border-color: #e5e7eb;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn.danger {
|
||
color: #b91c1c;
|
||
border-color: #fecdd3;
|
||
background: #fff5f5;
|
||
}
|
||
|
||
.btn.small {
|
||
padding: 6px 10px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.candidate-list,
|
||
.slots-list {
|
||
background: #f8fafc;
|
||
border-radius: 12px;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 12px;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.candidate-cards,
|
||
.slot-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.candidate-card,
|
||
.slot-item {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 10px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.slot-item {
|
||
grid-template-columns: 60px 1fr auto;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-main,
|
||
.slot-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.card-title {
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.card-desc {
|
||
margin: 0;
|
||
color: #4b5563;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.card-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tag-chip {
|
||
background: #eef2ff;
|
||
color: #4f46e5;
|
||
padding: 4px 8px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.card-meta {
|
||
display: flex;
|
||
gap: 6px;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.dot {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.card-actions,
|
||
.slot-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.slot-index {
|
||
font-weight: 800;
|
||
color: #6366f1;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.slots-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.muted {
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.page-info {
|
||
color: #4b5563;
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
padding: 18px 10px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.page-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|