You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
6.5 KiB
276 lines
6.5 KiB
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { articleApi, articleAdminApi } from '@/api/client'
|
|
import { ArticleStatus, type ArticleListItem } from '@/generated/api'
|
|
import Pagination from '@/components/Pagination.vue'
|
|
|
|
const router = useRouter()
|
|
|
|
const articles = ref<ArticleListItem[]>([])
|
|
const statusFilter = ref<ArticleStatus>(ArticleStatus.Published)
|
|
const page = ref(1)
|
|
const size = 10
|
|
const totalPages = ref(0)
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
const statusOptions = [
|
|
{ label: '草稿', value: ArticleStatus.Draft },
|
|
{ label: '已发布', value: ArticleStatus.Published },
|
|
{ label: '已归档', value: ArticleStatus.Archived }
|
|
]
|
|
|
|
function statusLabel(s: ArticleStatus): string {
|
|
switch (s) {
|
|
case ArticleStatus.Draft:
|
|
return '草稿'
|
|
case ArticleStatus.Published:
|
|
return '已发布'
|
|
case ArticleStatus.Archived:
|
|
return '已归档'
|
|
}
|
|
}
|
|
|
|
function formatDate(iso?: string): string {
|
|
if (!iso) return '-'
|
|
const d = new Date(iso)
|
|
if (isNaN(d.getTime())) return iso
|
|
return d.toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const resp = await articleApi.apiArticlesGet(page.value, size, statusFilter.value)
|
|
const data = resp.data.data
|
|
articles.value = (data?.list as ArticleListItem[]) ?? []
|
|
totalPages.value = data?.totalPages ?? 0
|
|
} catch (e) {
|
|
console.error(e)
|
|
error.value = '文章列表加载失败'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onFilterChange() {
|
|
page.value = 1
|
|
load()
|
|
}
|
|
|
|
function onPageChange(p: number) {
|
|
page.value = p
|
|
load()
|
|
}
|
|
|
|
function onEdit(article: ArticleListItem) {
|
|
router.push({
|
|
name: 'AdminArticleEdit',
|
|
params: { id: String(article.id) },
|
|
query: { slug: article.slug }
|
|
})
|
|
}
|
|
|
|
function onDelete(article: ArticleListItem) {
|
|
if (!window.confirm(`确定删除文章「${article.title}」?`)) return
|
|
articleAdminApi
|
|
.apiAdminArticlesIdDelete(article.id)
|
|
.then(() => {
|
|
load()
|
|
})
|
|
.catch((e) => {
|
|
console.error(e)
|
|
window.alert('删除失败')
|
|
})
|
|
}
|
|
|
|
function onPublish(article: ArticleListItem) {
|
|
articleAdminApi
|
|
.apiAdminArticlesIdPublishPatch(article.id)
|
|
.then(() => {
|
|
load()
|
|
})
|
|
.catch((e) => {
|
|
console.error(e)
|
|
window.alert('发布失败')
|
|
})
|
|
}
|
|
|
|
function onArchive(article: ArticleListItem) {
|
|
articleAdminApi
|
|
.apiAdminArticlesIdArchivePatch(article.id)
|
|
.then(() => {
|
|
load()
|
|
})
|
|
.catch((e) => {
|
|
console.error(e)
|
|
window.alert('归档失败')
|
|
})
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="toolbar">
|
|
<select v-model="statusFilter" class="filter-select" @change="onFilterChange">
|
|
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
|
{{ opt.label }}
|
|
</option>
|
|
</select>
|
|
<router-link to="/admin/articles/new" class="btn-primary">新建文章</router-link>
|
|
</div>
|
|
|
|
<p v-if="error" class="error-text">{{ error }}</p>
|
|
<p v-else-if="loading" class="hint-text">加载中...</p>
|
|
<p v-else-if="articles.length === 0" class="hint-text">暂无文章</p>
|
|
|
|
<table v-else class="article-table">
|
|
<thead>
|
|
<tr>
|
|
<th>标题</th>
|
|
<th>状态</th>
|
|
<th>发布时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="article in articles" :key="article.id">
|
|
<td>{{ article.title }}</td>
|
|
<td>
|
|
<span
|
|
class="status-badge"
|
|
:class="{
|
|
draft: article.status === ArticleStatus.Draft,
|
|
published: article.status === ArticleStatus.Published,
|
|
archived: article.status === ArticleStatus.Archived
|
|
}"
|
|
>
|
|
{{ statusLabel(article.status) }}
|
|
</span>
|
|
</td>
|
|
<td>{{ formatDate(article.publishedAt || article.createdAt) }}</td>
|
|
<td>
|
|
<div class="actions">
|
|
<button class="btn-link" @click="onEdit(article)">编辑</button>
|
|
<button v-if="article.status === ArticleStatus.Draft" class="btn-link" @click="onPublish(article)">
|
|
发布
|
|
</button>
|
|
<button v-if="article.status === ArticleStatus.Published" class="btn-link" @click="onArchive(article)">
|
|
归档
|
|
</button>
|
|
<button class="btn-link danger" @click="onDelete(article)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<Pagination v-if="totalPages > 1" :page="page" :total-pages="totalPages" @change="onPageChange" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--ac-input-border);
|
|
border-radius: var(--song-radius-l);
|
|
background: var(--ac-bg-card);
|
|
color: var(--ac-text);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn-primary {
|
|
display: inline-block;
|
|
padding: 9px 16px;
|
|
background: var(--ac-primary-strong);
|
|
color: var(--ac-primary-text);
|
|
border-radius: var(--song-radius-l);
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
transition: opacity var(--song-dur-1) var(--song-ease);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.article-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: var(--ac-bg-card);
|
|
border: 1px solid var(--ac-border);
|
|
border-radius: var(--song-radius-l);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.article-table th,
|
|
.article-table td {
|
|
padding: 10px 14px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--ac-border);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.article-table th {
|
|
background: var(--ac-surface);
|
|
color: var(--ac-text-secondary);
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 2px 10px;
|
|
border-radius: var(--song-radius-m);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.status-badge.draft {
|
|
background: var(--ac-surface);
|
|
color: var(--ac-text-secondary);
|
|
}
|
|
|
|
.status-badge.published {
|
|
background: var(--ac-success-bg);
|
|
color: var(--ac-success);
|
|
}
|
|
|
|
.status-badge.archived {
|
|
background: var(--ac-warning-bg);
|
|
color: var(--ac-warning);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.btn-link {
|
|
border: none;
|
|
background: none;
|
|
color: var(--ac-primary);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
padding: 0;
|
|
}
|
|
|
|
.btn-link.danger {
|
|
color: var(--ac-danger);
|
|
}
|
|
|
|
.error-text {
|
|
color: var(--ac-danger);
|
|
}
|
|
|
|
.hint-text {
|
|
color: var(--ac-text-disabled);
|
|
}
|
|
</style>
|