新版官网模板

This commit is contained in:
2026-04-29 01:33:33 +08:00
commit 0d82386f8f
341 changed files with 64526 additions and 0 deletions

View File

@@ -0,0 +1,859 @@
<template>
<div class="articles-page">
<div class="page-header">
<div>
<h2 class="page-title">📝 文章管理</h2>
<p class="page-desc">管理平台文章内容支持分类封面推荐与状态流转</p>
</div>
<a-space>
<a-button @click="navigateTo('/admin/article-categories')">
分类管理
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新增文章
</a-button>
<a-button :loading="loading" @click="loadArticles">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :md="6" :xs="12">
<div
:class="[
stat.color,
{
active:
(filterStatus === undefined && stat.key === -1) ||
filterStatus === stat.key,
},
]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 文章列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
<a-select
v-model:value="filterCategoryId"
allow-clear
placeholder="全部分类"
style="width: 180px"
@change="handleSearch"
>
<a-select-option
v-for="item in categoryOptions"
:key="item.categoryId"
:value="item.categoryId"
>
{{ item.title }}
</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 摘要 / 作者"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedArticles"
:loading="loading"
:pagination="tablePagination"
row-key="articleId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="article-info-cell">
<img v-if="record.image" :src="record.image" class="article-thumb" />
<div v-else class="article-thumb-empty">📄</div>
<div class="article-info-text">
<div class="article-title">{{ record.title }}</div>
<div class="article-meta">
<span v-if="record.author"> {{ record.author }}</span>
<span v-if="resolveCategoryName(record)" class="meta-item">📁 {{ resolveCategoryName(record) }}</span>
</div>
<div class="article-overview">{{ record.overview || '暂无摘要' }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'metrics'">
<div class="metrics-cell">
<div>👁 {{ record.actualViews || 0 }} 次阅读</div>
<div> {{ record.likes || 0 }} 点赞</div>
</div>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="!!record.recommend"
size="small"
@change="(val: boolean) => handleToggleRecommend(record, val)"
/>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="文章标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入文章标题"
show-count
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="作者">
<a-input v-model:value="formData.author" placeholder="文章作者" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="来源">
<a-input v-model:value="formData.source" placeholder="文章来源" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="文章分类">
<a-select
v-model:value="formData.categoryId"
:options="categorySelectOptions"
allow-clear
option-filter-prop="label"
placeholder="请选择文章分类"
show-search
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="封面图">
<div class="cover-upload-wrap">
<div v-if="formData.image" class="cover-preview-card">
<img :src="formData.image" class="cover-preview-image" />
<div class="cover-preview-actions">
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
<a-button danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<a-button :loading="imageUploading">上传封面</a-button>
</a-upload>
<div class="field-hint">支持 jpg/png/webp建议横版封面单张不超过 5MB</div>
</div>
</a-form-item>
<a-form-item label="文章摘要">
<a-textarea
v-model:value="formData.overview"
:maxlength="500"
:rows="3"
placeholder="文章简短描述"
show-count
/>
</a-form-item>
<a-form-item label="文章内容" required>
<div class="content-editor-wrap">
<div class="editor-tabs">
<a-radio-group v-model:value="editorMode" button-style="solid" size="small">
<a-radio-button value="edit">编辑</a-radio-button>
<a-radio-button value="preview">预览</a-radio-button>
</a-radio-group>
</div>
<div v-show="editorMode === 'edit'">
<MarkdownEditor
v-model="formData.content"
:show-preview="false"
min-height="320px"
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、![图片](url)、代码块等语法"
/>
</div>
<div v-show="editorMode === 'preview'" class="preview-only-mode">
<MarkdownRenderer v-if="formData.content" :content="formData.content" />
<div v-else class="empty-preview">暂无内容</div>
</div>
</div>
</a-form-item>
<a-form-item label="内容格式">
<a-tag :color="isMarkdown ? 'blue' : 'default'">
{{ isMarkdown ? 'Markdown' : '纯文本/HTML' }}
</a-tag>
<span class="format-hint">
当前编辑器支持 Markdown 语法编写
</span>
</a-form-item>
<a-form-item label="是否推荐">
<a-switch v-model:checked="formRecommend" />
<span class="switch-tip">推荐文章将优先出现在列表与前台推荐位</span>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="showPreviewModal"
:footer="null"
:title="previewData?.title || '文章预览'"
width="760px"
>
<template v-if="previewData">
<div class="preview-meta">
<a-tag :color="statusColor(previewData.status)">{{ statusText(previewData.status) }}</a-tag>
<a-tag v-if="previewData.recommend" color="gold">推荐</a-tag>
<a-tag v-if="previewData.categoryName" color="blue">{{ previewData.categoryName }}</a-tag>
<span class="preview-meta-text">{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
</div>
<div v-if="previewData.image" class="preview-cover-wrap">
<img :src="previewData.image" class="preview-cover" />
</div>
<div v-if="previewData.overview" class="preview-summary">{{ previewData.overview }}</div>
<a-divider />
<div class="preview-content">
<MarkdownRenderer v-if="isPreviewMarkdown" :content="previewData.content" />
<div v-else v-html="previewData.content || previewData.overview || '暂无内容'"></div>
</div>
</template>
</a-modal>
<a-modal v-model:open="showImagePreview" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
listAppArticle as listCmsArticle,
addAppArticle as addCmsArticle,
updateAppArticle as updateCmsArticle,
removeAppArticle as removeCmsArticle,
} from '@/api/app/article'
import { listAppArticleCategory as listCmsArticleCategory } from '@/api/app/articleCategory'
import { uploadFile } from '@/api/system/file'
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
import MarkdownEditor from '@/components/admin/MarkdownEditor.vue'
import MarkdownRenderer from '@/components/admin/MarkdownRenderer.vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '文章管理 - 平台管理' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const ANNOUNCE_MODEL = 'announcement'
const loading = ref(false)
const imageUploading = ref(false)
const allArticles = ref<CmsArticle[]>([])
const categoryOptions = ref<CmsArticleCategory[]>([])
const filterStatus = ref<number | undefined>(undefined)
const filterCategoryId = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '✅', label: '已发布', value: 0, color: 'green' },
{ key: 1, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 2, icon: '❌', label: '已驳回', value: 0, color: 'red' },
{ key: -1, icon: '📝', label: '全部文章', value: 0, color: 'blue' },
])
const columns = [
{ title: '文章信息', key: 'info', width: 360 },
{ title: '状态', key: 'status', width: 110 },
{ title: '数据', key: 'metrics', width: 120 },
{ title: '推荐', key: 'recommend', width: 80 },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 170 },
]
const showFormModal = ref(false)
const saving = ref(false)
const editingArticle = ref<CmsArticle | null>(null)
const formData = reactive<CmsArticle>({
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
const formRecommend = ref(false)
const editorMode = ref<'edit' | 'preview'>('edit')
const isMarkdown = computed(() => {
return formData.content && (
/[#*`_\[\]()!>-]/.test(formData.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(formData.content)
)
})
const isPreviewMarkdown = computed(() => {
if (!previewData.value?.content) return false
return (
/[#*`_\[\]()!>-]/.test(previewData.value.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(previewData.value.content)
)
})
const showPreviewModal = ref(false)
const previewData = ref<CmsArticle | null>(null)
const showImagePreview = ref(false)
const previewImageUrl = ref('')
const categorySelectOptions = computed(() =>
categoryOptions.value.map(item => ({
value: item.categoryId,
label: item.title || `分类 ${item.categoryId}`,
}))
)
const standardArticles = computed(() => allArticles.value.filter(isStandardArticle))
const filteredArticles = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return [...standardArticles.value]
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => filterCategoryId.value === undefined || item.categoryId === filterCategoryId.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.overview, item.author, item.source]
.some(value => String(value || '').toLowerCase().includes(keyword))
})
.sort((a, b) => {
const timeA = a.createTime || ''
const timeB = b.createTime || ''
if (timeA && timeB && timeA !== timeB) {
return timeB.localeCompare(timeA)
}
return (b.articleId || 0) - (a.articleId || 0)
})
})
const pagedArticles = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredArticles.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredArticles.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
async function loadCategories(silent = false) {
try {
const list = await listCmsArticleCategory({ status: 0 })
categoryOptions.value = (list || [])
.filter(item => item.categoryId)
.sort((a, b) => (a.sortNumber || 0) - (b.sortNumber || 0))
} catch (e: any) {
if (!silent) {
message.error(e?.message || '加载文章分类失败')
}
}
}
async function loadArticles() {
loading.value = true
try {
const list = await listCmsArticle()
allArticles.value = list || []
ensurePaginationInRange()
updateStats()
} catch (e: any) {
message.error(e?.message || '加载文章列表失败')
} finally {
loading.value = false
}
}
function updateStats() {
const list = standardArticles.value
statCards[0].value = list.filter(item => item.status === 0).length
statCards[1].value = list.filter(item => item.status === 1).length
statCards[2].value = list.filter(item => item.status === 2).length
statCards[3].value = list.length
}
function ensurePaginationInRange() {
const total = filteredArticles.value.length
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
function isStandardArticle(item: CmsArticle) {
return (item.model || '').trim() !== ANNOUNCE_MODEL
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
ensurePaginationInRange()
}
function resetForm() {
Object.assign(formData, {
articleId: undefined,
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
formRecommend.value = false
}
function handleCreate() {
editingArticle.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticle) {
editingArticle.value = record
Object.assign(formData, {
articleId: record.articleId,
title: record.title || '',
author: record.author || '',
source: record.source || '',
overview: record.overview || '',
content: record.content || '',
status: record.status ?? 0,
categoryId: record.categoryId,
image: record.image || '',
})
formRecommend.value = !!record.recommend
showFormModal.value = true
}
function handleView(record: CmsArticle) {
previewData.value = {
...record,
categoryName: resolveCategoryName(record),
}
showPreviewModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入文章标题')
return
}
if (!formData.content?.trim()) {
message.warning('请输入文章内容')
return
}
saving.value = true
try {
const data: CmsArticle = {
...formData,
model: undefined,
categoryName: resolveCategoryNameById(formData.categoryId),
recommend: formRecommend.value ? 1 : 0,
}
if (editingArticle.value?.articleId) {
await updateCmsArticle(data)
message.success('文章已更新')
} else {
await addCmsArticle(data)
message.success('文章已创建')
}
showFormModal.value = false
await loadArticles()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticle) {
try {
await removeCmsArticle(record.articleId)
message.success('文章已删除')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleToggleRecommend(record: CmsArticle, val: boolean) {
try {
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
message.success(val ? '已加入推荐' : '已取消推荐')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return false
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
async function handleCoverUpload(option: UploadRequestOption) {
const rawFile = option.file
if (!rawFile) return
imageUploading.value = true
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回图片地址')
formData.image = url
option.onSuccess?.(record, rawFile)
message.success('封面上传成功')
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '封面上传失败')
} finally {
imageUploading.value = false
}
}
function handleRemoveCover() {
formData.image = ''
}
function handlePreviewImage(url?: string) {
if (!url) return
previewImageUrl.value = url
showImagePreview.value = true
}
function resolveCategoryName(record: CmsArticle) {
return record.categoryName || resolveCategoryNameById(record.categoryId)
}
function resolveCategoryNameById(categoryId?: number) {
if (!categoryId) return ''
return categoryOptions.value.find(item => item.categoryId === categoryId)?.title || ''
}
function statusText(status?: number) {
const map: Record<number, string> = {
0: '已发布',
1: '待审核',
2: '已驳回',
3: '违规',
}
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = {
0: 'success',
1: 'orange',
2: 'error',
3: 'volcano',
}
return map[status ?? -1] || 'default'
}
watch([filteredArticles, () => pagination.pageSize], () => {
ensurePaginationInRange()
})
onMounted(async () => {
await loadCategories(true)
await loadArticles()
})
</script>
<style scoped>
.articles-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.article-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.article-thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
.article-thumb-empty {
width: 72px;
height: 48px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.article-info-text { flex: 1; min-width: 0; }
.article-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
.meta-item { margin-left: 8px; }
.article-overview {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.metrics-cell { font-size: 12px; color: rgba(0, 0, 0, 0.45); line-height: 1.7; }
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.cover-preview-card {
width: 220px;
padding: 8px;
border: 1px dashed #d9d9d9;
border-radius: 10px;
background: #fafafa;
}
.cover-preview-image {
width: 100%;
height: 124px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.content-editor-wrap {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
.editor-tabs {
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.preview-only-mode {
padding: 16px;
min-height: 320px;
background: #fff;
}
.empty-preview {
color: rgba(0, 0, 0, 0.25);
font-style: italic;
text-align: center;
padding: 60px 0;
}
.format-hint {
margin-left: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.preview-meta-text { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.preview-cover-wrap { margin: 16px 0 12px; }
.preview-cover {
width: 100%;
max-height: 320px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #f0f0f0;
}
.preview-summary {
margin-top: 12px;
padding: 12px 14px;
background: #fafafa;
border-radius: 10px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.preview-content {
font-size: 15px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
}
.image-preview-modal {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
</style>