Files
tiantian-system/app/pages/admin/announcements.vue
2026-04-08 17:10:58 +08:00

604 lines
18 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="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 @click="loadAnnouncements" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="8">
<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 :xs="12" :md="8">
<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 :xs="12" :md="8">
<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"
@change="handleTableChange"
size="middle"
>
<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 type="link" size="small" @click="handleView(record)">预览</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此公告?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:title="editing?.articleId ? '编辑公告' : '发布公告'"
width="760px"
:confirm-loading="saving"
@ok="handleSave"
@cancel="showFormModal = false"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="公告标题" required>
<a-input
v-model:value="formData.title"
placeholder="请输入公告标题"
:maxlength="200"
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 size="small" danger @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
accept="image/*"
:show-upload-list="false"
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
>
<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"
:rows="2"
placeholder="简短描述公告内容"
:maxlength="300"
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"
:title="previewData?.title || '公告预览'"
width="760px"
:footer="null"
>
<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 class="preview-summary" v-if="previewData.overview">{{ 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" title="封面预览" :footer="null" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script setup lang="ts">
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>