AI-News/frontend/app/pages/admin/home-featured.vue
2025-12-04 10:04:21 +08:00

588 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>