优化公告列表界面,支持移动端自适应布局,调整查询区和列表区的显示方式,增强用户体验和可访问性。

This commit is contained in:
zyh
2025-08-27 22:17:14 +08:00
parent dac1bebabf
commit ecace6eb88

View File

@@ -1,5 +1,5 @@
<template>
<div class="announcements-page">
<div class="announcements-page" :class="{ mobile: isMobile }">
<!-- 权限检查 -->
<div v-if="!canViewAnnouncements" class="permission-denied">
<el-result
@@ -12,72 +12,147 @@
</template>
</el-result>
</div>
<!-- 公告管理内容 -->
<div v-else>
<!-- 查询区 -->
<el-card class="mb16" shadow="never">
<el-form :inline="true" :model="query" @submit.prevent>
<el-form-item label="关键词">
<el-input v-model.trim="query.keyword" placeholder="标题或内容" clearable style="width: 220px" />
<el-form
:inline="!isMobile"
:model="query"
:label-position="isMobile ? 'top' : 'right'"
class="query-form"
@submit.prevent
>
<el-form-item :label="isMobile ? '关键词' : '关键词'">
<el-input
v-model.trim="query.keyword"
:placeholder="isMobile ? '标题或内容' : '标题或内容'"
clearable
:style="isMobile ? '' : 'width:220px'"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.enabled" clearable placeholder="全部" style="width: 140px">
<el-form-item :label="isMobile ? '状态' : '状态'">
<el-select v-model="query.enabled" clearable placeholder="全部" :style="isMobile ? '' : 'width:140px'">
<el-option :value="true" label="启用" />
<el-option :value="false" label="禁用" />
</el-select>
</el-form-item>
<el-form-item>
<el-form-item class="query-actions">
<el-button type="primary" :loading="loading" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
<el-form-item v-if="canCreateAnnouncement">
<el-button type="success" @click="openCreate">新增公告</el-button>
<el-button v-if="canCreateAnnouncement" type="success" @click="openCreate">新增公告</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表区桌面端=表格移动端=卡片 -->
<el-card shadow="never">
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="jumpUrl" label="跳转链接" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.jumpUrl">{{ row.jumpUrl }}</span>
<span v-else class="text-gray"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
@change="(v) => onToggle(row, v)"
:disabled="!canEditAnnouncement"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="canEditAnnouncement" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canDeleteAnnouncement" size="small" type="danger" @click="onRemove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 桌面端表格 -->
<template v-if="!isMobile">
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="320" show-overflow-tooltip />
<el-table-column prop="jumpUrl" label="跳转链接" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.jumpUrl" :href="row.jumpUrl" target="_blank" :underline="false">
{{ row.jumpUrl }}
</el-link>
<span v-else class="text-gray"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
@change="(v) => onToggle(row, v)"
:disabled="!canEditAnnouncement"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="canEditAnnouncement" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canDeleteAnnouncement" size="small" type="danger" @click="onRemove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 移动端卡片列表 -->
<template v-else>
<el-empty v-if="!loading && (!list || list.length === 0)" description="暂无数据" />
<div v-else class="mobile-list" v-loading="loading">
<el-card v-for="item in list" :key="item.id" class="mobile-card" shadow="never">
<div class="card-header">
<div class="title">{{ item.title || '-' }}</div>
<el-tag :type="item.enabled ? 'success' : 'info'" size="small">
{{ item.enabled ? '启用' : '禁用' }}
</el-tag>
</div>
<div class="content" v-if="item.content">{{ item.content }}</div>
<div class="meta">
<div class="row">
<span class="label">创建</span>
<span class="value">{{ formatDateTime(item.createdAt) }}</span>
</div>
<div class="row">
<span class="label">更新</span>
<span class="value">{{ formatDateTime(item.updatedAt) }}</span>
</div>
<div class="row" v-if="item.jumpUrl">
<span class="label">链接</span>
<el-link class="value" :href="item.jumpUrl" target="_blank" :underline="false">
{{ item.jumpUrl }}
</el-link>
</div>
</div>
<div class="actions">
<el-switch
:model-value="item.enabled"
size="small"
@change="(v) => onToggle(item, v)"
:disabled="!canEditAnnouncement"
/>
<div class="op-buttons">
<el-button
v-if="canEditAnnouncement"
size="small"
type="primary"
text
@click="openEdit(item)"
>编辑</el-button>
<el-button
v-if="canDeleteAnnouncement"
size="small"
type="danger"
text
@click="onRemove(item)"
>删除</el-button>
</div>
</div>
</el-card>
</div>
</template>
<!-- 分页 -->
<div class="pager">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:small="isMobile"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
:total="total"
:page-size="query.size"
:current-page="query.page"
@@ -89,25 +164,20 @@
</el-card>
<!-- 编辑/新增对话框 -->
<el-dialog v-model="visible" :title="isEdit ? '编辑公告' : '新增公告'" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-dialog
v-model="visible"
:title="isEdit ? '编辑公告' : '新增公告'"
:width="isMobile ? '92vw' : '600px'"
>
<el-form ref="formRef" :model="form" :rules="rules" :label-width="isMobile ? '72px' : '88px'">
<el-form-item label="标题" prop="title">
<el-input v-model.trim="form.title" placeholder="请输入公告标题" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model.trim="form.content"
type="textarea"
:rows="6"
placeholder="请输入公告内容"
/>
<el-input v-model.trim="form.content" type="textarea" :rows="6" placeholder="请输入公告内容" />
</el-form-item>
<el-form-item label="跳转链接" prop="jumpUrl">
<el-input
v-model.trim="form.jumpUrl"
placeholder="可选点击公告后跳转的链接"
clearable
/>
<el-input v-model.trim="form.jumpUrl" placeholder="可选http(s):// 开头" clearable />
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-switch v-model="form.enabled" />
@@ -124,14 +194,14 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAnnouncementList,
createAnnouncement,
updateAnnouncement,
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import {
getAnnouncementList,
createAnnouncement,
updateAnnouncement,
deleteAnnouncement,
updateAnnouncementStatus
updateAnnouncementStatus
} from '@/api/announcement'
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
import { hasPermission, PERMISSIONS } from '@/utils/permission'
@@ -144,27 +214,29 @@ const query = reactive({
page: 1,
size: 10,
keyword: '',
enabled: undefined,
enabled: undefined
})
// 权限检查
const canViewAnnouncements = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_VIEW))
const canCreateAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_CREATE))
const canEditAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_UPDATE))
const canDeleteAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_DELETE))
// ===== 权限 =====
const canViewAnnouncements = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_VIEW))
const canCreateAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_CREATE))
const canEditAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_UPDATE))
const canDeleteAnnouncement = computed(() => hasPermission(PERMISSIONS.ANNOUNCEMENT_DELETE))
// 格式化日期时间
// ===== 自适应(小于 768 认为是移动端)=====
const width = ref(window.innerWidth)
const onResize = () => (width.value = window.innerWidth)
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
const isMobile = computed(() => width.value < 768)
// ===== 工具方法 =====
function formatDateTime(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
// 兼容移动端:固定格式,避免 locale 差异
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function unwrap(res) {
@@ -173,17 +245,14 @@ function unwrap(res) {
return d
}
// ===== 数据加载 =====
async function load() {
loading.value = true
try {
const params = { ...query }
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
Object.keys(params).forEach((k) => {
if (params[k] === '' || params[k] === undefined) delete params[k]
})
const res = await getAnnouncementList(params)
const data = unwrap(res) || {}
list.value = data.items || []
@@ -229,50 +298,36 @@ async function onRemove(row) {
}
}
// ===== 弹窗表单 =====
const visible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref()
const form = reactive({
id: undefined,
title: '',
content: '',
jumpUrl: '',
enabled: true
const form = reactive({
id: undefined,
title: '',
content: '',
jumpUrl: '',
enabled: true
})
const rules = {
title: [{ required: true, message: '请输入公告标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
jumpUrl: [
{
validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
// 简单的URL格式验证
const urlPattern = /^https?:\/\/.+/
if (!urlPattern.test(value)) {
callback(new Error('请输入有效的URL地址以http://或https://开头)'))
} else {
callback()
}
},
trigger: 'blur'
}
]
jumpUrl: [{
validator: (rule, value, callback) => {
if (!value) return callback()
const urlPattern = /^https?:\/\/.+/
if (!urlPattern.test(value)) callback(new Error('请输入有效的URL地址以http://或https://开头)'))
else callback()
},
trigger: 'blur'
}]
}
function openCreate() {
isEdit.value = false
Object.assign(form, {
id: undefined,
title: '',
content: '',
jumpUrl: '',
enabled: true
})
Object.assign(form, { id: undefined, title: '', content: '', jumpUrl: '', enabled: true })
visible.value = true
}
@@ -289,10 +344,7 @@ function doSubmit() {
jumpUrl: form.jumpUrl || null,
enabled: form.enabled
}
return isEdit.value
? updateAnnouncement(form.id, payload)
: createAnnouncement(payload)
return isEdit.value ? updateAnnouncement(form.id, payload) : createAnnouncement(payload)
}
async function onSubmit() {
@@ -321,4 +373,84 @@ onMounted(load)
.ml8 { margin-left: 8px; }
.text-gray { color: #999; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* ===== 移动端样式 ===== */
.announcements-page.mobile .query-form :deep(.el-form-item) {
margin-right: 0;
width: 100%;
}
.announcements-page.mobile .query-actions :deep(.el-button) {
width: 100%;
margin: 6px 0 0 0;
}
.announcements-page.mobile .mobile-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.announcements-page.mobile .mobile-card {
border-radius: 12px;
}
.announcements-page.mobile .card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.announcements-page.mobile .title {
font-weight: 600;
font-size: 15px;
line-height: 1.2;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.announcements-page.mobile .content {
font-size: 13px;
color: #606266;
margin: 6px 0 10px;
display: -webkit-box;
-webkit-line-clamp: 3; /* 多行省略 */
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.announcements-page.mobile .meta {
font-size: 12px;
color: #909399;
display: grid;
gap: 4px;
}
.announcements-page.mobile .meta .row {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.announcements-page.mobile .meta .label {
color: #a6a8ad;
flex: 0 0 auto;
}
.announcements-page.mobile .meta .value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.announcements-page.mobile .actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.announcements-page.mobile .op-buttons :deep(.el-button) {
padding: 0 4px;
}
/* 小屏分页靠右间距缩小 */
.announcements-page.mobile .pager {
margin-top: 8px;
justify-content: center;
}
</style>