新版官网模板

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,603 @@
<template>
<div class="announcements-page">
<div class="page-header">
<div>
<h2 class="page-title">📢 公告管理</h2>
<p class="page-desc">发布和管理平台公告支持草稿置顶封面和预览</p>
</div>
<a-space>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
发布公告
</a-button>
<a-button :loading="loading" @click="loadAnnouncements">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :md="8" :xs="12">
<div class="stat-card blue">
<div class="stat-icon">📢</div>
<div class="stat-info">
<div class="stat-value">{{ totalCount }}</div>
<div class="stat-label">全部公告</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card green">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ publishedCount }}</div>
<div class="stat-label">已发布</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card orange">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ recommendCount }}</div>
<div class="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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索公告标题"
style="width: 220px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="announcements"
:loading="loading"
:pagination="pagination"
row-key="articleId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="ann-info-cell">
<img v-if="record.image" :src="record.image" class="ann-thumb" />
<div v-else class="ann-thumb-empty">📢</div>
<div class="ann-info-text">
<div class="ann-title">
<span v-if="record.recommend" class="pin-badge">📌 置顶</span>
{{ record.title }}
</div>
<div class="ann-overview">{{ record.overview || '暂无摘要' }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 0 ? 'success' : 'default'">
{{ record.status === 0 ? '已发布' : '草稿' }}
</a-tag>
</template>
<template v-if="column.key === 'views'">
<span class="text-sm text-gray">👁 {{ record.actualViews || 0 }}</span>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="!!record.recommend"
size="small"
@change="(val: boolean) => handleTogglePin(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="editing?.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-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适合公告 banner 场景单张不超过 5MB</div>
</div>
</a-form-item>
<a-form-item label="公告摘要">
<a-textarea
v-model:value="formData.overview"
:maxlength="300"
:rows="2"
placeholder="简短描述公告内容"
show-count
/>
</a-form-item>
<a-form-item label="公告内容" required>
<a-textarea v-model:value="formData.content" :rows="10" placeholder="公告正文内容..." />
</a-form-item>
<a-row :gutter="16">
<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>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否置顶">
<a-switch v-model:checked="formPin" />
<span class="switch-tip">置顶公告将优先展示在列表顶部</span>
</a-form-item>
</a-col>
</a-row>
</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">
<span class="text-sm text-gray">发布时间{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
<a-tag v-if="previewData.recommend" color="orange">置顶</a-tag>
<a-tag :color="previewData.status === 0 ? 'success' : 'default'">
{{ previewData.status === 0 ? '已发布' : '草稿' }}
</a-tag>
</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" v-html="previewData.content || previewData.overview || '暂无内容'"></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 {
pageAppArticle as pageCmsArticle,
addAppArticle as addCmsArticle,
updateAppArticle as updateCmsArticle,
removeAppArticle as removeCmsArticle,
} from '@/api/app/article'
import { uploadFile } from '@/api/system/file'
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '公告管理 - 平台管理' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const loading = ref(false)
const imageUploading = ref(false)
const announcements = ref<CmsArticle[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const totalCount = ref(0)
const publishedCount = ref(0)
const recommendCount = ref(0)
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
const columns = [
{ title: '公告信息', key: 'info', width: 420 },
{ title: '状态', key: 'status', width: 100 },
{ title: '阅读量', key: 'views', width: 100 },
{ title: '置顶', key: 'recommend', width: 80 },
{ title: '发布时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 170 },
]
const showFormModal = ref(false)
const saving = ref(false)
const editing = ref<CmsArticle | null>(null)
const formData = reactive<CmsArticle>({ title: '', overview: '', content: '', status: 0, image: '' })
const formPin = ref(false)
const showPreviewModal = ref(false)
const previewData = ref<CmsArticle | null>(null)
const showImagePreview = ref(false)
const previewImageUrl = ref('')
const ANNOUNCE_MODEL = 'announcement'
async function loadAnnouncements() {
loading.value = true
try {
const res = await pageCmsArticle({
page: pagination.current,
limit: pagination.pageSize,
model: ANNOUNCE_MODEL,
status: filterStatus.value,
keywords: searchKeyword.value || undefined,
})
announcements.value = res?.list || []
pagination.total = res?.count || 0
loadStats()
} catch {
message.error('加载公告列表失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const [allRes, pubRes, pinRes] = await Promise.allSettled([
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL }),
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, status: 0 }),
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, recommend: 1 }),
])
totalCount.value = allRes.status === 'fulfilled' ? allRes.value?.count || 0 : 0
publishedCount.value = pubRes.status === 'fulfilled' ? pubRes.value?.count || 0 : 0
recommendCount.value = pinRes.status === 'fulfilled' ? pinRes.value?.count || 0 : 0
} catch {
// ignore
}
}
function handleSearch() {
pagination.current = 1
loadAnnouncements()
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadAnnouncements()
}
function resetForm() {
Object.assign(formData, {
articleId: undefined,
title: '',
overview: '',
content: '',
status: 0,
image: '',
})
formPin.value = false
}
function handleCreate() {
editing.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticle) {
editing.value = record
Object.assign(formData, {
articleId: record.articleId,
title: record.title || '',
overview: record.overview || '',
content: record.content || '',
status: record.status ?? 0,
image: record.image || '',
})
formPin.value = !!record.recommend
showFormModal.value = true
}
function handleView(record: CmsArticle) {
previewData.value = 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: ANNOUNCE_MODEL,
recommend: formPin.value ? 1 : 0,
}
if (editing.value?.articleId) {
await updateCmsArticle(data)
message.success('公告已更新')
} else {
await addCmsArticle(data)
message.success('公告已发布')
}
showFormModal.value = false
loadAnnouncements()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticle) {
try {
await removeCmsArticle(record.articleId)
message.success('公告已删除')
loadAnnouncements()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleTogglePin(record: CmsArticle, val: boolean) {
try {
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
message.success(val ? '已置顶' : '已取消置顶')
loadAnnouncements()
} 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
}
onMounted(() => loadAnnouncements())
</script>
<style scoped>
.announcements-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;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.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-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); }
.ann-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.ann-thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
.ann-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;
}
.ann-info-text { flex: 1; min-width: 0; }
.ann-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
line-height: 1.6;
}
.ann-overview {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.pin-badge {
font-size: 11px;
color: #f97316;
background: #fff7ed;
padding: 1px 6px;
border-radius: 4px;
margin-right: 6px;
border: 1px solid #fed7aa;
}
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.cover-preview-card {
width: 240px;
padding: 8px;
border: 1px dashed #d9d9d9;
border-radius: 10px;
background: #fafafa;
}
.cover-preview-image {
width: 100%;
height: 132px;
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); }
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.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);
white-space: pre-wrap;
word-break: break-word;
}
.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>