更新website

This commit is contained in:
2026-09-17 23:42:41 +08:00
parent 67e819c629
commit 1a4471e34d
18 changed files with 1922 additions and 187 deletions
+105
View File
@@ -0,0 +1,105 @@
import request from "@/utils/request";
// ==================== 官网管理 - 站点公告(平台端) ====================
// 数据表:yz_platform_website_notice
// 建表 SQL:go/docs/sql/create_platform_website_notice.sql
//
// 状态约定:0 草稿 / 1 已发布 / 2 已下线
// 类型约定:1 通知公告 / 2 活动公告 / 3 系统维护
// 公告列表(分页):keyword / type / status / is_popup / page / pageSize
export function listNotices(params) {
return request({
url: "/platform/website/notice/list",
method: "get",
params,
});
}
// 公告详情(含富文本正文,供编辑回显)
export function getNotice(id) {
return request({
url: `/platform/website/notice/${id}`,
method: "get",
});
}
// 新增公告
export function createNotice(data) {
return request({
url: "/platform/website/notice",
method: "post",
data,
});
}
// 编辑公告
export function editNotice(id, data) {
return request({
url: `/platform/website/notice/${id}`,
method: "post",
data,
});
}
// 删除公告(软删除)
export function deleteNotice(id) {
return request({
url: `/platform/website/notice/${id}`,
method: "delete",
});
}
// 批量删除公告
export function batchDeleteNotices(ids) {
return request({
url: "/platform/website/notice/batchdelete",
method: "post",
data: { ids },
});
}
// 发布 / 下线(status 1 / 2)
export function publishNotice(id) {
return request({
url: `/platform/website/notice/${id}/publish`,
method: "post",
});
}
export function unpublishNotice(id) {
return request({
url: `/platform/website/notice/${id}/unpublish`,
method: "post",
});
}
// 置顶 / 取消置顶
export function topNotice(id) {
return request({
url: `/platform/website/notice/${id}/top`,
method: "post",
});
}
export function untopNotice(id) {
return request({
url: `/platform/website/notice/${id}/untop`,
method: "post",
});
}
// 标记为弹窗公告 / 取消弹窗
export function popupNotice(id) {
return request({
url: `/platform/website/notice/${id}/popup`,
method: "post",
});
}
export function unpopupNotice(id) {
return request({
url: `/platform/website/notice/${id}/unpopup`,
method: "post",
});
}
@@ -0,0 +1,291 @@
<template>
<el-drawer
v-model="visible"
:title="isEdit ? '编辑公告' : '新增公告'"
size="780px"
:close-on-click-modal="false"
destroy-on-close
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入公告标题"
maxlength="255"
show-word-limit
/>
</el-form-item>
<el-form-item label="公告类型" prop="type">
<el-select v-model="form.type" style="width: 200px">
<el-option
v-for="item in TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="公告摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="列表卡片展示的摘要,留空则前台展示正文开头"
/>
</el-form-item>
<el-form-item label="公告正文">
<div class="editor-container">
<UmoEditor v-model="form.content" :cate="materialCateId" />
</div>
</el-form-item>
<el-form-item label="跳转地址" prop="link_url">
<el-input
v-model="form.link_url"
placeholder="点击公告后跳转的地址,站内填 /news,站外填完整 URL;留空表示不可点击"
/>
</el-form-item>
<el-form-item label="生效时间" prop="start_time">
<el-date-picker
v-model="form.start_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="留空表示立即生效"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="失效时间" prop="end_time">
<el-date-picker
v-model="form.end_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="留空表示长期有效"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="首页弹窗">
<el-switch v-model="form.is_popup" :active-value="1" :inactive-value="0" />
<span class="field-tip">开启后该公告进入首页弹窗 / 侧栏公告区</span>
</el-form-item>
<el-form-item label="置顶">
<el-switch v-model="form.top" :active-value="1" :inactive-value="0" />
<span class="field-tip">置顶公告在列表中优先展示</span>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
<span class="field-tip">数字越小越靠前</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="0">草稿</el-radio>
<el-radio :value="1">已发布</el-radio>
<el-radio :value="2">已下线</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { createNotice, editNotice, getNotice } from '@/api/notice'
import { ensureWebsiteMaterialCate } from '@/utils/websiteUpload'
import { formatDateTime } from '@/utils/datetime'
const props = defineProps({
modelValue: { type: Boolean, default: false },
isEdit: { type: Boolean, default: false },
noticeId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'success'])
// 公告类型:与 go/controllers/platform_website_notice.go 保持一致
const TYPE_OPTIONS = [
{ value: 1, label: '通知公告' },
{ value: 2, label: '活动公告' },
{ value: 3, label: '系统维护' }
]
const visible = ref(props.modelValue)
const submitLoading = ref(false)
const formRef = ref(null)
// 「官网素材」文件分组ID:正文内插入的图片归入该分组
const materialCateId = ref(0)
const form = reactive({
title: '',
summary: '',
content: '',
type: 1,
link_url: '',
is_popup: 0,
top: 0,
sort: 0,
status: 0,
start_time: '',
end_time: ''
})
const rules = {
title: [{ required: true, message: '请输入公告标题', trigger: 'blur' }]
}
const isEdit = computed(() => props.isEdit)
/** 后端返回 RFC3339,转成 el-date-picker 需要的 YYYY-MM-DD HH:mm:ss;无值返回空串 */
const toPickerValue = (value) => {
if (!value) return ''
const text = formatDateTime(value)
return text === '-' ? '' : text
}
const resetForm = () => {
formRef.value?.clearValidate()
Object.assign(form, {
title: '',
summary: '',
content: '',
type: 1,
link_url: '',
is_popup: 0,
top: 0,
sort: 0,
status: 0,
start_time: '',
end_time: ''
})
}
watch(
() => props.modelValue,
async (val) => {
if (!val) {
visible.value = false
return
}
resetForm()
if (props.isEdit && props.noticeId) {
try {
const res = await getNotice(props.noticeId)
if (res.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
title: d.title || '',
summary: d.summary || '',
content: d.content || '',
type: d.type ?? 1,
link_url: d.link_url || '',
is_popup: d.is_popup ?? 0,
top: d.top ?? 0,
sort: d.sort ?? 0,
status: d.status ?? 0,
start_time: toPickerValue(d.start_time),
end_time: toPickerValue(d.end_time)
})
} else {
ElMessage.error(res.msg || '获取公告详情失败')
}
} catch (e) {
ElMessage.error('获取公告详情失败')
}
}
// 数据就绪后再打开抽屉,保证富文本编辑器挂载时即可拿到回显内容
visible.value = true
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClosed = () => {
resetForm()
emit('update:modelValue', false)
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
title: form.title,
summary: form.summary,
content: form.content,
type: form.type,
link_url: form.link_url,
is_popup: form.is_popup,
top: form.top,
sort: form.sort,
status: form.status,
// 空串表示不限制,后端按 NULL 处理
start_time: form.start_time || '',
end_time: form.end_time || ''
}
const res = isEdit.value
? await editNotice(props.noticeId, payload)
: await createNotice(payload)
if (res.code === 200) {
ElMessage.success(isEdit.value ? '保存成功' : '创建成功')
visible.value = false
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
onMounted(() => {
// 提前解析「官网素材」分组,供正文内插入图片时使用
ensureWebsiteMaterialCate().then((id) => {
materialCateId.value = id
})
})
</script>
<style lang="less" scoped>
.editor-container {
width: 100%;
height: 420px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
}
.field-tip {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,471 @@
<template>
<div class="container-box notice-manage">
<div class="header-bar">
<h2>站点公告</h2>
<div class="header-actions">
<el-button
type="danger"
plain
:disabled="selectedIds.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增公告
</el-button>
<el-button @click="fetchList" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-alert type="info" :closable="false" class="tips">
<template #title>
<div>
官网通知 / 活动 / 系统维护类公告。仅 <b>已发布</b> 且当前时间处于
<b>有效期内</b> 的公告会在官网展示;标记为「弹窗公告」的条目供首页弹窗区读取。
前台接口:<code>GET /site/notices</code>(<code>?popup=1</code> 只取弹窗公告)。
</div>
</template>
</el-alert>
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="搜索公告标题 / 摘要"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="searchForm.type"
placeholder="全部类型"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option
v-for="item in TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="searchForm.status"
placeholder="全部状态"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="searchForm.isPopup"
placeholder="是否弹窗"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option label="弹窗公告" :value="1" />
<el-option label="普通公告" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-table
:data="noticeList"
v-loading="loading"
border
stripe
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="公告标题" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.top === 1" size="small" type="danger" effect="plain">置顶</el-tag>
<span class="title-text">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">{{ typeLabel(row.type) }}</template>
</el-table-column>
<el-table-column label="弹窗" width="90" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_popup === 1"
@change="(val) => handlePopupChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="置顶" width="90" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.top === 1"
@change="(val) => handleTopChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="有效期" width="180" align="center">
<template #default="{ row }">{{ validRange(row) }}</template>
</el-table-column>
<el-table-column label="发布时间" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.publish_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
size="small"
text
:type="row.status === 1 ? 'warning' : 'success'"
@click="handleTogglePublish(row)"
>
<el-icon><Upload /></el-icon>
{{ row.status === 1 ? '下线' : '发布' }}
</el-button>
<el-button size="small" text type="danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无公告" :image-size="80" />
</template>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<NoticeEdit
v-model="dialogVisible"
:is-edit="isEdit"
:notice-id="editId"
@success="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete, Upload } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/datetime'
import {
listNotices,
deleteNotice,
batchDeleteNotices,
publishNotice,
unpublishNotice,
topNotice,
untopNotice,
popupNotice,
unpopupNotice
} from '@/api/notice'
import NoticeEdit from './components/edit.vue'
// 公告类型 / 状态:与 go/controllers/platform_website_notice.go 保持一致
const TYPE_OPTIONS = [
{ value: 1, label: '通知公告' },
{ value: 2, label: '活动公告' },
{ value: 3, label: '系统维护' }
]
const STATUS_OPTIONS = [
{ value: 0, label: '草稿' },
{ value: 1, label: '已发布' },
{ value: 2, label: '已下线' }
]
const typeLabel = (type) =>
TYPE_OPTIONS.find((item) => item.value === type)?.label || '通知公告'
const statusLabel = (status) =>
STATUS_OPTIONS.find((item) => item.value === status)?.label || '草稿'
const statusTagType = (status) => {
if (status === 1) return 'success'
if (status === 2) return 'warning'
return 'info'
}
const loading = ref(false)
const noticeList = ref([])
const selectedIds = ref([])
const searchForm = reactive({ keyword: '', type: '', status: '', isPopup: '' })
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref(0)
/** 有效期展示:两端都为空表示不限制 */
const validRange = (row) => {
const start = row.start_time ? formatDateTime(row.start_time) : ''
const end = row.end_time ? formatDateTime(row.end_time) : ''
if (!start && !end) return '长期有效'
return `${start || '立即'} ~ ${end || '不限'}`
}
const fetchList = async () => {
loading.value = true
try {
const res = await listNotices({
keyword: searchForm.keyword,
type: searchForm.type === '' ? '' : searchForm.type,
status: searchForm.status === '' ? '' : searchForm.status,
is_popup: searchForm.isPopup === '' ? '' : searchForm.isPopup,
page: pagination.page,
pageSize: pagination.pageSize
})
if (res.code === 200) {
const data = res.data
noticeList.value = Array.isArray(data) ? data : data?.list || []
pagination.total = Array.isArray(data) ? data.length : data?.total || 0
} else {
ElMessage.error(res.msg || '获取公告列表失败')
}
} catch (e) {
ElMessage.error('获取公告列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
fetchList()
}
const resetSearch = () => {
Object.assign(searchForm, { keyword: '', type: '', status: '', isPopup: '' })
pagination.page = 1
fetchList()
}
const handleSizeChange = (val) => {
pagination.pageSize = val
pagination.page = 1
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map((item) => item.id)
}
const handleAdd = () => {
isEdit.value = false
editId.value = 0
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
editId.value = row.id
dialogVisible.value = true
}
const handleTopChange = async (row, val) => {
const next = val ? 1 : 0
try {
const res = next === 1 ? await topNotice(row.id) : await untopNotice(row.id)
if (res.code === 200) {
row.top = next
ElMessage.success(next === 1 ? '已置顶' : '已取消置顶')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
}
const handlePopupChange = async (row, val) => {
const next = val ? 1 : 0
try {
const res = next === 1 ? await popupNotice(row.id) : await unpopupNotice(row.id)
if (res.code === 200) {
row.is_popup = next
ElMessage.success(next === 1 ? '已设为弹窗公告' : '已取消弹窗公告')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleTogglePublish = async (row) => {
const offline = row.status === 1
try {
await ElMessageBox.confirm(
offline
? `确定要下线公告「${row.title}」吗?下线后官网将不再展示。`
: `确定要发布公告「${row.title}」吗?发布后官网立即可见(受有效期限制)。`,
offline ? '下线确认' : '发布确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return
}
try {
const res = offline ? await unpublishNotice(row.id) : await publishNotice(row.id)
if (res.code === 200) {
ElMessage.success(offline ? '已下线' : '已发布')
fetchList()
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定要删除公告「${row.title}」吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch (e) {
return
}
try {
const res = await deleteNotice(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
const handleBatchDelete = async () => {
if (selectedIds.value.length === 0) return
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.length} 条公告吗?`,
'批量删除确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return
}
try {
const res = await batchDeleteNotices(selectedIds.value)
if (res.code === 200) {
ElMessage.success('删除成功')
selectedIds.value = []
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(() => {
fetchList()
})
</script>
<style lang="less" scoped>
.container-box {
padding: 16px;
background: var(--el-bg-color);
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
}
.header-actions {
display: flex;
gap: 8px;
}
.tips {
margin-bottom: 12px;
}
.search-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.title-text {
margin-left: 6px;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>