完善业务闭环,实现对轮播图的管理,优化数据库表文件

This commit is contained in:
2026-07-18 16:21:40 +08:00
parent feaf014b4a
commit 97a5eff678
78 changed files with 3210 additions and 100 deletions
+33
View File
@@ -0,0 +1,33 @@
import request from '@/utils/request'
export interface Banner {
id?: number
imageUrl: string
title: string
subtitle?: string
sortOrder?: number
isActive?: boolean
linkUrl?: string
createdAt?: string
updatedAt?: string
}
export const bannerApi = {
getAll: () =>
request.get<Banner[]>('/banners'),
getActive: () =>
request.get<Banner[]>('/banners/active'),
getById: (id: number) =>
request.get<Banner>(`/banners/${id}`),
create: (data: Banner) =>
request.post<Banner>('/banners', data),
update: (id: number, data: Banner) =>
request.put<Banner>(`/banners/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/banners/${id}`),
}
+6
View File
@@ -76,6 +76,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/notify/NoticeManagement.vue'),
meta: { title: '通知公告' }
},
{
path: 'banners',
name: 'BannerManagement',
component: () => import('@/views/banner/BannerManagement.vue'),
meta: { title: '轮播图管理' }
},
{
path: 'loginlog',
name: 'LoginLog',
+2
View File
@@ -45,6 +45,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'member/member/index': '/members',
'member/card/index': '/member-cards',
'statistics/dashboard/index': '/statistics',
'banner/index': '/banners',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -107,6 +108,7 @@ function getMenuIcon(menuName: string): string {
'会员管理': 'Avatar',
'会员卡管理': 'CreditCard',
'数据统计': 'DataAnalysis',
'轮播图管理': 'Picture',
}
return iconMap[menuName] || 'Document'
}
@@ -0,0 +1,454 @@
<template>
<div class="banner-management">
<el-card>
<template #header>
<div class="card-title">
<span>轮播图管理</span>
<el-button
type="primary"
@click="handleAdd"
>
新增轮播图
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
label="预览图"
width="120"
>
<template #default="{ row }">
<div class="cover-cell">
<img
v-if="coverCache[row.imageUrl]"
:src="coverCache[row.imageUrl]"
class="cover-thumb"
@click="previewImage(row)"
@error="onCoverError(row)"
/>
<span v-else class="cover-no-data">No data</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="title"
label="标题"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="subtitle"
label="副标题"
min-width="180"
show-overflow-tooltip
/>
<el-table-column
prop="sortOrder"
label="排序"
width="80"
align="center"
/>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag
:type="row.isActive ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.isActive ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
width="170"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 图片预览弹窗 -->
<el-dialog v-model="previewVisible" title="图片预览" width="700px">
<img
v-if="previewUrl"
:src="previewUrl"
style="width: 100%; max-height: 500px; object-fit: contain"
/>
</el-dialog>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="550px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item label="轮播图" prop="imageUrl">
<el-upload
class="banner-upload"
:http-request="handleUpload"
:show-file-list="false"
:before-upload="beforeUpload"
accept="image/*"
>
<el-icon class="banner-upload-icon"><Plus /></el-icon>
</el-upload>
<div v-if="coverPreviewSrc" class="banner-preview-wrap">
<img :src="coverPreviewSrc" class="banner-preview-img" />
</div>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formState.title" placeholder="请输入轮播图标题" />
</el-form-item>
<el-form-item label="副标题">
<el-input
v-model="formState.subtitle"
type="textarea"
:rows="2"
placeholder="请输入轮播图副标题"
/>
</el-form-item>
<el-form-item label="跳转链接">
<el-input v-model="formState.linkUrl" placeholder="选填,点击轮播图跳转地址" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formState.sortOrder" :min="0" :max="999" style="width: 100%" />
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="formState.isActive"
active-text="启用"
inactive-text="停用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { UploadRequestOptions } from 'element-plus'
import request from '@/utils/request'
import { bannerApi, type Banner } from '@/api/banner.api'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<Banner[]>([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const coverPreviewSrc = ref('')
const previewVisible = ref(false)
const previewUrl = ref('')
const formState = reactive<Banner>({
imageUrl: '',
title: '',
subtitle: '',
sortOrder: 0,
isActive: true,
linkUrl: '',
})
const formRules = {
imageUrl: [{ required: true, message: '请上传轮播图', trigger: 'change' }],
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ max: 100, message: '标题长度不能超过100个字符', trigger: 'blur' },
],
sortOrder: [{ required: true, message: '请设置排序', trigger: 'blur' }],
}
// ========== 图片缓存(表格 + 表单预览) ==========
const coverCache = reactive<Record<string, string>>({})
/** 从 imageUrl 中提取文件 ID/api/files/{id}/preview → id */
const extractFileId = (url: string | undefined): string | null => {
if (!url) return null
const match = url.match(/\/api\/files\/(\d+)\/preview$/)
if (match) return match[1]
if (/^\d+$/.test(url)) return url
return null
}
/** 加载表格中某行的封面图(完全对照 GroupCourseManagement 的 loadRowCover */
const loadRowCover = async (imageUrl: string | undefined) => {
if (!imageUrl) return
if (coverCache[imageUrl]) return
const fileId = extractFileId(imageUrl)
if (fileId) {
try {
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
coverCache[imageUrl] = URL.createObjectURL(blob)
} catch {
// leave empty, template will show "No data"
}
} else {
// 外部 URL 直接使用
coverCache[imageUrl] = imageUrl
}
}
const onCoverError = (row: Banner) => {
if (row.imageUrl) {
delete coverCache[row.imageUrl]
}
}
/** 加载表单中的封面预览(完全对照 GroupCourseManagement 的 loadCoverPreview */
const loadCoverPreview = async (val: string | undefined) => {
if (!val) {
coverPreviewSrc.value = ''
return
}
const fileId = extractFileId(val)
if (fileId) {
try {
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
const oldUrl = coverPreviewSrc.value
if (oldUrl) URL.revokeObjectURL(oldUrl)
coverPreviewSrc.value = URL.createObjectURL(blob)
} catch {
coverPreviewSrc.value = ''
}
} else {
coverPreviewSrc.value = val
}
}
// 编辑时监听 imageUrl 变化自动加载预览
watch(() => formState.imageUrl, (val) => loadCoverPreview(val), { immediate: true })
// ========== 数据加载 ==========
const fetchData = async () => {
loading.value = true
try {
const res = await bannerApi.getAll()
dataSource.value = (res as Banner[] || [])
dataSource.value.forEach(row => loadRowCover(row.imageUrl))
} catch {
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
/** 点击预览表格中的图片 */
const previewImage = (row: Banner) => {
previewUrl.value = row.imageUrl ? (coverCache[row.imageUrl] || row.imageUrl) : ''
previewVisible.value = true
}
// ========== 表单操作 ==========
const handleAdd = () => {
modalTitle.value = '新增轮播图'
Object.assign(formState, {
id: undefined,
imageUrl: '',
title: '',
subtitle: '',
sortOrder: 0,
isActive: true,
linkUrl: '',
})
modalVisible.value = true
}
const handleEdit = (row: Banner) => {
modalTitle.value = '编辑轮播图'
Object.assign(formState, { ...row })
modalVisible.value = true
}
const handleDelete = async (row: Banner) => {
try {
await ElMessageBox.confirm('确定要删除该轮播图吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await bannerApi.delete(row.id!)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
// 取消操作不提示
}
}
// ========== 图片上传 ==========
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
/**
* 自定义上传(完全对照 GroupCourseManagement 的 uploadCover
* - 存储 imageUrl 为 /api/files/{id}/preview
* - 上传成功后立即通过 loadCoverPreview 获取 blob 预览
*/
const handleUpload = async (options: UploadRequestOptions) => {
const formData = new FormData()
formData.append('file', options.file)
try {
const res: any = await request.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res && res.id) {
formState.imageUrl = `/api/files/${res.id}/preview`
loadCoverPreview(formState.imageUrl)
}
ElMessage.success('封面上传成功')
} catch {
ElMessage.error('封面上传失败')
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
await bannerApi.update(formState.id, formState)
} else {
await bannerApi.create(formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.banner-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 表格缩略图 */
.cover-cell {
display: flex;
align-items: center;
justify-content: center;
}
.cover-thumb {
width: 64px;
height: 48px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
}
.cover-no-data {
color: #c0c4cc;
font-size: 12px;
}
/* 上传区域 */
.banner-upload :deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
width: 200px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.3s;
}
.banner-upload :deep(.el-upload:hover) {
border-color: #409eff;
}
.banner-upload-icon {
font-size: 28px;
color: #8c939d;
}
.banner-preview-wrap {
margin-top: 8px;
}
.banner-preview-img {
width: 200px;
height: 120px;
object-fit: cover;
border-radius: 6px;
}
</style>
@@ -312,10 +312,12 @@ const loadRowCover = async (coverImage: string | undefined) => {
if (!coverImage) return
// already loaded
if (coverCache[coverImage]) return
// if it's a numeric file ID, fetch via preview API
if (/^\d+$/.test(coverImage)) {
// Extract file ID from either /api/files/{id}/preview format or pure numeric string
const match = coverImage.match(/\/api\/files\/(\d+)\/preview$/)
const fileId = match ? match[1] : (/^\d+$/.test(coverImage) ? coverImage : null)
if (fileId) {
try {
const blob: any = await request.get(`/files/${coverImage}/preview`, { responseType: 'blob' })
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
coverCache[coverImage] = URL.createObjectURL(blob)
} catch {
// leave empty, template will show "No data"
@@ -413,9 +415,9 @@ const uploadCover = async (options: any) => {
})
// res is the SysFile object (axios interceptor unwrapped response.data)
if (res && res.id) {
formState.coverImage = String(res.id)
formState.coverImage = `/api/files/${res.id}/preview`
// immediately fetch preview for the newly uploaded file
loadCoverPreview(String(res.id))
loadCoverPreview(formState.coverImage)
}
ElMessage.success('封面上传成功')
} catch {
@@ -499,10 +501,12 @@ const loadCoverPreview = async (val: string) => {
coverPreviewSrc.value = ''
return
}
// If it looks like a numeric file ID, fetch via preview API
if (/^\d+$/.test(val)) {
// Extract file ID from either /api/files/{id}/preview format or pure numeric string
const match = val.match(/\/api\/files\/(\d+)\/preview$/)
const fileId = match ? match[1] : (/^\d+$/.test(val) ? val : null)
if (fileId) {
try {
const blob: any = await request.get(`/files/${val}/preview`, { responseType: 'blob' })
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
const oldUrl = coverPreviewSrc.value
if (oldUrl) URL.revokeObjectURL(oldUrl)
coverPreviewSrc.value = URL.createObjectURL(blob)