初始化2
This commit is contained in:
603
app/pages/admin/announcements.vue
Normal file
603
app/pages/admin/announcements.vue
Normal 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 @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>
|
||||
Reference in New Issue
Block a user