完善业务闭环,实现对轮播图的管理,优化数据库表文件
This commit is contained in:
@@ -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}`),
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user