AI-News/frontend/app/pages/admin/home-featured.vue

588 lines
14 KiB
Vue
Raw Normal View History

2025-12-04 10:04:21 +08:00
<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>