2025-12-04 10:04:21 +08:00

783 lines
19 KiB
Vue
Raw Permalink 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">
<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>