mach-cms的前台,用vue写的
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

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