783 lines
19 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<template>
<div class="admin-page">
<section class="card">
<div class="filter-row">
<input
v-model="filters.search"
class="input"
type="text"
placeholder="搜索标题 / 描述"
/>
<input v-model="filters.author" class="input" type="text" placeholder="作者用户名" />
<button class="btn" type="button" @click="loadArticles">查询</button>
<button class="btn ghost" type="button" @click="loadArticles" :disabled="loading">
{{ loading ? '刷新中...' : '刷新列表' }}
</button>
</div>
</section>
<section class="card table-card">
<div v-if="loading" class="empty">文章列表加载中...</div>
<div v-else-if="articles.length === 0" class="empty">暂无文章</div>
<div v-else class="table-wrapper">
<table class="table">
<thead>
<tr>
<th>标题 / 描述</th>
<th style="width: 160px">标签</th>
<th>置顶</th>
<th>推荐</th>
<th style="width: 90px">权重</th>
<th>作者</th>
<th>浏览</th>
<th style="width: 220px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="article in articles" :key="article.slug">
<td>
<div class="article-title">{{ article.title }}</div>
<div class="user-meta">Slug{{ article.slug }}</div>
<div v-if="article.description" class="article-desc">
{{ article.description }}
</div>
</td>
<td>
<div v-if="article.tagList?.length" class="tag-list">
<span
v-for="tag in article.tagList"
:key="`${article.slug}-${tag}`"
class="tag-chip"
>
{{ tag }}
</span>
</div>
<span v-else class="muted">--</span>
</td>
<td class="flag-cell">
<button
class="btn small"
:class="{ primary: article.isTop }"
type="button"
:disabled="savingFlags[article.slug]"
@click="toggleTop(article)"
>
{{ article.isTop ? '已置顶' : '置顶' }}
</button>
</td>
<td class="flag-cell">
<button
class="btn small"
:class="{ primary: article.isFeatured }"
type="button"
:disabled="savingFlags[article.slug]"
@click="toggleFeatured(article)"
>
{{ article.isFeatured ? '已推荐' : '推荐' }}
</button>
</td>
<td>
<div class="weight-cell">
<span class="weight-value">{{ article.sortWeight ?? 0 }}</span>
<button
class="btn small ghost"
type="button"
:disabled="savingFlags[article.slug]"
@click="setSortWeight(article)"
>
调整
</button>
</div>
</td>
<td>{{ article.author?.username || '未知' }}</td>
<td>{{ article.views ?? 0 }}</td>
<td class="actions">
<button class="btn small primary" type="button" @click="openEdit(article)">
编辑
</button>
<button class="btn small danger" type="button" @click="deleteArticle(article)">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- <section class="card">
<div class="menu-header">
<div>
<div class="menu-title">菜单标签配置</div>
<div class="menu-sub">资讯广场使用教程分配标签多标签 AND 匹配</div>
</div>
<button class="btn ghost" type="button" @click="loadMenuSlots" :disabled="menuLoading">
{{ menuLoading ? '刷新中...' : '刷新配置' }}
</button>
</div>
<div v-if="tagsLoading" class="empty">加载标签...</div>
<div v-else class="menu-slots">
<div v-for="slot in menuSlots" :key="slot.slotKey" class="menu-slot">
<div class="menu-slot-title">{{ slot.label }}</div>
<div class="menu-slot-sub">
选择文章需要同时命中的标签空为全部
</div>
<select v-model="slot.tags" class="input" multiple>
<option v-for="tag in allTags" :key="tag" :value="tag">
{{ tag }}
</option>
</select>
<div class="menu-slot-actions">
<button
class="btn primary small"
type="button"
:disabled="menuSaving[slot.slotKey]"
@click="saveMenuSlot(slot)"
>
{{ menuSaving[slot.slotKey] ? '保存中...' : '保存标签' }}
</button>
<button class="btn small ghost" type="button" @click="clearSlotTags(slot)">
清空
</button>
</div>
</div>
</div>
</section> -->
<!-- 编辑文章 -->
<div v-if="showDialog && editing" class="modal-mask">
<div class="modal">
<div class="modal-header">
<h3>编辑文章</h3>
<button class="modal-close" type="button" @click="closeDialog">×</button>
</div>
<form class="form" @submit.prevent="submitEdit">
<label>标题</label>
<input v-model="editing.title" class="input" type="text" required />
<label>描述</label>
<textarea v-model="editing.description" class="input" rows="3" />
<div class="inline-flags">
<label class="checkbox-row">
<input v-model="editing.isTop" type="checkbox" />
<span>置顶</span>
</label>
<label class="checkbox-row">
<input v-model="editing.isFeatured" type="checkbox" />
<span>推荐</span>
</label>
</div>
<label>权重数字越大越靠前</label>
<input v-model.number="editing.sortWeight" class="input" type="number" />
<div class="modal-footer">
<button class="btn" type="button" @click="closeDialog" :disabled="saving">
取消
</button>
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch, type Ref } from 'vue'
import { navigateTo } from '#app'
import { useAuth } from '@/composables/useAuth'
import { useAuthToken, useApi } from '@/composables/useApi'
definePageMeta({
layout: 'admin',
adminTitle: '文章管理',
adminSub: '按标题、描述或作者过滤,支持置顶、推荐与菜单标签配置',
})
interface ArticleItem {
slug: string
title: string
description: string | null
body?: string
tagList?: string[]
isTop?: boolean
isFeatured?: boolean
sortWeight?: number
createdAt?: string
updatedAt?: string
author: {
username: string
image?: string | null
}
views?: number
}
interface AdminArticlesResponse {
articles?: ArticleItem[]
}
interface TagsResponse {
tags?: string[]
}
interface MenuSlot {
slotKey: string
label: string
tags: string[]
}
interface MenuSlotsResponse {
slots?: MenuSlot[]
}
const menuDefaults: MenuSlot[] = [
{ slotKey: 'news', label: '资讯广场', tags: [] },
{ slotKey: 'tutorial', label: '使用教程', tags: [] },
]
const { token } = useAuthToken()
const { user: authUser, fetchMe } = useAuth() as {
user: Ref<{ roles?: string[] } | null>
fetchMe: () => Promise<unknown>
}
const api = useApi()
const articles = ref<ArticleItem[]>([])
const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const hasAccess = ref(false)
const savingFlags = reactive<Record<string, boolean>>({})
const filters = reactive({
search: '',
author: '',
})
const editing = ref<{
slug: string
title: string
description: string | null
isTop: boolean
isFeatured: boolean
sortWeight: number
} | null>(null)
const menuSlots = ref<MenuSlot[]>([...menuDefaults])
const menuSaving = reactive<Record<string, boolean>>({})
const menuLoading = ref(false)
const tagsLoading = ref(false)
const allTags = ref<string[]>([])
const isAdmin = computed(() => {
const rs = authUser.value?.roles
return Array.isArray(rs) && rs.includes('admin')
})
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
}
function normalizeArticle(item: ArticleItem): ArticleItem {
return {
...item,
isTop: Boolean(item.isTop),
isFeatured: Boolean(item.isFeatured),
sortWeight: Number(item.sortWeight ?? 0),
}
}
async function loadArticles(): Promise<void> {
if (!hasAccess.value) return
loading.value = true
try {
const query: Record<string, any> = { limit: 50, offset: 0 }
const kw = filters.search.trim()
if (kw) query.search = kw
const author = filters.author.trim()
if (author) query.author = author
const res = (await api.get('/admin/articles', query)) as AdminArticlesResponse
const list = Array.isArray(res.articles) ? res.articles : []
articles.value = list.map((a) => normalizeArticle(a))
} catch (err) {
console.error('[Admin] load articles failed', err)
alert('加载文章列表失败')
} finally {
loading.value = false
}
}
function openEdit(article: ArticleItem): void {
editing.value = {
slug: article.slug,
title: article.title,
description: article.description || '',
isTop: Boolean(article.isTop),
isFeatured: Boolean(article.isFeatured),
sortWeight: Number(article.sortWeight ?? 0),
}
showDialog.value = true
}
function closeDialog(): void {
editing.value = null
showDialog.value = false
}
async function submitEdit(): Promise<void> {
if (!hasAccess.value || !editing.value) return
saving.value = true
try {
await api.put(`/admin/articles/${editing.value.slug}`, {
article: {
title: editing.value.title,
description: editing.value.description,
isTop: editing.value.isTop,
isFeatured: editing.value.isFeatured,
sortWeight: editing.value.sortWeight,
},
})
closeDialog()
await loadArticles()
} catch (err: any) {
console.error('[Admin] save article failed', err)
alert(err?.statusMessage || '保存文章失败')
} finally {
saving.value = false
}
}
async function deleteArticle(article: ArticleItem): Promise<void> {
if (!hasAccess.value || !article?.slug) return
if (!confirm(`确定删除文章「${article.title}」吗?`)) return
try {
await api.del(`/admin/articles/${article.slug}`)
await loadArticles()
} catch (err: any) {
console.error('[Admin] delete article failed', err)
alert(err?.statusMessage || '删除文章失败')
}
}
async function updateArticleFlags(article: ArticleItem, patch: Partial<ArticleItem>): Promise<void> {
if (!hasAccess.value || !article?.slug) return
if (savingFlags[article.slug]) return
savingFlags[article.slug] = true
try {
await api.put(`/admin/articles/${article.slug}`, { article: patch })
Object.assign(article, normalizeArticle({ ...article, ...patch }))
} catch (err: any) {
console.error('[Admin] update flags failed', err)
alert(err?.statusMessage || '更新失败')
} finally {
delete savingFlags[article.slug]
}
}
async function toggleTop(article: ArticleItem): Promise<void> {
await updateArticleFlags(article, { isTop: !article.isTop })
}
async function toggleFeatured(article: ArticleItem): Promise<void> {
await updateArticleFlags(article, { isFeatured: !article.isFeatured })
}
async function setSortWeight(article: ArticleItem): Promise<void> {
const current = Number(article.sortWeight ?? 0)
const input = prompt('设置权重(整数,越大越靠前)', String(current))
if (input === null) return
const num = Number(input)
if (Number.isNaN(num)) {
alert('请输入数字')
return
}
await updateArticleFlags(article, { sortWeight: num })
}
async function loadAllTags(): Promise<void> {
tagsLoading.value = true
try {
const res = (await api.get('/tags')) as TagsResponse
allTags.value = Array.isArray(res.tags) ? res.tags : []
} catch (err) {
console.error('[Admin] load tags failed', err)
allTags.value = []
} finally {
tagsLoading.value = false
}
}
function mergeMenuSlots(serverSlots: MenuSlot[] | undefined): MenuSlot[] {
const serverMap = new Map<string, MenuSlot>()
for (const s of serverSlots || []) {
serverMap.set(s.slotKey, {
slotKey: s.slotKey,
label: s.label || s.slotKey,
tags: Array.isArray(s.tags) ? [...s.tags] : [],
})
}
return menuDefaults.map((d) => {
const matched = serverMap.get(d.slotKey)
return {
slotKey: d.slotKey,
label: matched?.label || d.label,
tags: matched?.tags || [],
}
})
}
async function loadMenuSlots(): Promise<void> {
if (!hasAccess.value) return
menuLoading.value = true
try {
const res = (await api.get('/admin/menu-slots')) as MenuSlotsResponse
menuSlots.value = mergeMenuSlots(res.slots)
} catch (err) {
console.error('[Admin] load menu slots failed', err)
alert('加载菜单标签配置失败')
} finally {
menuLoading.value = false
}
}
async function saveMenuSlot(slot: MenuSlot): Promise<void> {
if (!hasAccess.value) return
if (menuSaving[slot.slotKey]) return
menuSaving[slot.slotKey] = true
try {
const res = (await api.put(`/admin/menu-slots/${slot.slotKey}`, {
slot: { tags: slot.tags, label: slot.label },
})) as { slot?: MenuSlot }
if (res?.slot) {
Object.assign(slot, {
tags: Array.isArray(res.slot.tags) ? res.slot.tags : [],
label: res.slot.label || slot.label,
})
}
} catch (err: any) {
console.error('[Admin] save menu slot failed', err)
alert(err?.statusMessage || '保存菜单标签失败')
} finally {
delete menuSaving[slot.slotKey]
}
}
function clearSlotTags(slot: MenuSlot): void {
slot.tags = []
}
onMounted(async () => {
if (await ensureAccess(true)) {
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
}
})
watch(
() => authUser.value?.roles,
async () => {
if (await ensureAccess(false)) {
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
}
},
)
watch(
() => token.value,
async () => {
if (await ensureAccess(false)) {
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
}
},
)
</script>
<style scoped>
.admin-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #ffffff;
border-radius: 16px;
border: 1px solid #e5e7eb;
padding: 14px 16px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.input {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #d1d5db;
min-width: 180px;
font-size: 14px;
background: #fff;
}
.btn {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
transition: all 0.18s ease;
}
.btn.primary {
background: #111827;
color: #fff;
border-color: #111827;
}
.btn.ghost {
border-color: #e5e7eb;
background: #fff;
}
.btn.danger {
color: #b91c1c;
border-color: #fecdd3;
background: #fff5f5;
}
.btn.small {
padding: 6px 10px;
font-size: 13px;
}
.table-card {
padding: 10px 12px;
}
.table-wrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th,
.table td {
padding: 9px 10px;
border-bottom: 1px solid #f3f4f6;
text-align: left;
}
.table th {
color: #6b7280;
font-size: 13px;
font-weight: 500;
}
.article-title {
font-weight: 600;
color: #0f172a;
}
.article-desc {
margin-top: 4px;
color: #4b5563;
font-size: 13px;
}
.user-meta {
margin-top: 2px;
color: #9ca3af;
font-size: 12px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
background: #f3f4f6;
color: #4b5563;
font-size: 12px;
}
.flag-cell {
min-width: 80px;
}
.weight-cell {
display: flex;
align-items: center;
gap: 6px;
}
.weight-value {
font-weight: 600;
color: #111827;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.empty {
padding: 18px 6px;
text-align: center;
color: #9ca3af;
}
.muted {
color: #9ca3af;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
}
.modal {
width: min(520px, 94vw);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.45);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #6b7280;
}
.form {
display: flex;
flex-direction: column;
gap: 8px;
}
.form label {
font-size: 13px;
color: #6b7280;
}
.inline-flags {
display: flex;
gap: 12px;
margin-top: 4px;
}
.checkbox-row {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #374151;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.menu-title {
font-weight: 600;
font-size: 16px;
}
.menu-sub {
color: #6b7280;
font-size: 13px;
}
.menu-slots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
margin-top: 12px;
}
.menu-slot {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 12px;
background: #fafafa;
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-slot-title {
font-weight: 600;
}
.menu-slot-sub {
font-size: 13px;
color: #6b7280;
}
.menu-slot-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>