<template>
|
<div class="carousel-container">
|
<!-- 页面标题 -->
|
<div class="page-header">
|
<h2>新闻与推广管理</h2>
|
</div>
|
|
<!-- 搜索区域 -->
|
<div class="search-section">
|
<el-form :model="searchForm" inline>
|
<el-form-item label="标题">
|
<el-input
|
v-model="searchForm.title"
|
placeholder="请输入标题关键词"
|
clearable
|
@keyup.enter="handleSearch"
|
/>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="success" @click="handleAdd">
|
<el-icon><Plus /></el-icon>
|
新增轮播图
|
</el-button>
|
<el-button @click="updateSortOrders">设置顺序</el-button>
|
</el-form-item>
|
</el-form>
|
</div>
|
|
<!-- 数据表格 -->
|
<div class="table-section">
|
<el-table
|
v-loading="loading"
|
:data="tableData"
|
stripe
|
border
|
style="width: 100%"
|
>
|
<el-table-column prop="title" label="标题" min-width="200" />
|
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
|
<el-table-column label="排序" width="100" align="center">
|
<template #default="{ row, $index }">
|
<el-input-number
|
v-model="row.sortOrder"
|
:min="0"
|
:max="999"
|
size="small"
|
controls-position="right"
|
@change="handleSortOrderChange(row, $index)"
|
/>
|
</template>
|
</el-table-column>
|
<el-table-column prop="mediaCount" label="媒体数量" width="100" align="center">
|
<template #default="{ row }">
|
<el-tag type="info">{{ row.mediaCount || 0 }}</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="createTime" label="创建时间" width="180" />
|
<el-table-column label="操作" width="200" fixed="right">
|
<template #default="{ row }">
|
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
|
<!-- 分页 -->
|
<div class="pagination-section">
|
<el-pagination
|
v-model:current-page="pagination.page"
|
v-model:page-size="pagination.size"
|
:total="pagination.total"
|
:page-sizes="[10, 20, 50, 100]"
|
layout="total, sizes, prev, pager, next, jumper"
|
@size-change="handleSizeChange"
|
@current-change="handleCurrentChange"
|
/>
|
</div>
|
</div>
|
|
<!-- 编辑对话框 -->
|
<el-dialog
|
v-model="dialogVisible"
|
:title="dialogTitle"
|
width="600px"
|
@close="handleDialogClose"
|
>
|
<el-form
|
ref="formRef"
|
:model="formData"
|
:rules="formRules"
|
label-width="80px"
|
>
|
<el-form-item label="标题" prop="title">
|
<el-input v-model="formData.title" placeholder="请输入标题" />
|
</el-form-item>
|
<el-form-item label="内容" prop="content">
|
<el-input
|
v-model="formData.content"
|
type="textarea"
|
:rows="4"
|
placeholder="请输入内容"
|
/>
|
</el-form-item>
|
<el-form-item label="排序" prop="sortOrder">
|
<el-input-number
|
v-model="formData.sortOrder"
|
:min="0"
|
:max="999"
|
placeholder="排序值"
|
/>
|
</el-form-item>
|
<!-- 媒体上传与列表 -->
|
<div class="media-section">
|
<h4 style="margin: 10px 0;">媒体管理(图片/视频)</h4>
|
<div class="media-actions" style="margin-bottom: 10px;">
|
<el-upload
|
:show-file-list="false"
|
:auto-upload="false"
|
accept="image/*,video/*"
|
@change="onMediaFileChange"
|
>
|
<el-button type="primary">选择文件</el-button>
|
</el-upload>
|
<span v-if="selectedMediaFiles.length > 0" style="margin-left: 10px; color: #67c23a;">
|
已选择 {{ selectedMediaFiles.length }} 个文件,保存时将上传
|
</span>
|
</div>
|
|
<!-- 新选择的文件预览 -->
|
<div v-if="selectedMediaFiles.length > 0" style="margin-bottom: 10px;">
|
<h5>待上传文件:</h5>
|
<el-table :data="selectedMediaFiles" size="small" style="width: 100%;">
|
<el-table-column prop="name" label="文件名" min-width="200" />
|
<el-table-column label="类型" width="100">
|
<template #default="{ row }">
|
{{ row.file.type.startsWith('image/') ? '图片' : '视频' }}
|
</template>
|
</el-table-column>
|
<el-table-column label="预览" width="100">
|
<template #default="{ row }">
|
<img v-if="row.file.type.startsWith('image/')" :src="row.previewUrl" style="width: 40px; height: 40px; object-fit: cover;" />
|
<span v-else>视频文件</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="80">
|
<template #default="{ row, $index }">
|
<el-button type="danger" size="small" @click="removeSelectedFile($index)">移除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
|
<!-- 已上传的媒体列表 -->
|
<div v-if="mediaList.length > 0">
|
<h5>已上传媒体:</h5>
|
<el-table :data="mediaList" size="small" style="width: 100%;">
|
<el-table-column prop="name" label="文件名" min-width="200" />
|
<el-table-column prop="mediaType" label="类型" width="100">
|
<template #default="{ row }">
|
{{ row.mediaType === 1 ? '图片' : '视频' }}
|
</template>
|
</el-table-column>
|
<el-table-column label="预览" width="120">
|
<template #default="{ row }">
|
<div v-if="row.mediaType === 1" class="media-preview" @click="openMediaInNewTab(row.fullUrl)">
|
<img :src="row.fullUrl" class="preview-image clickable" />
|
</div>
|
<div v-else-if="row.mediaType === 2" class="media-preview" @click="openMediaInNewTab(row.fullUrl)">
|
<video :src="row.fullUrl" class="preview-video clickable" muted></video>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="160">
|
<template #default="{ row }">
|
<el-button type="danger" size="small" @click="removeMedia(row)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</div>
|
</el-form>
|
<template #footer>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { Plus } from '@element-plus/icons-vue'
|
import { CarouselApi } from '@/api/carousel'
|
import { uploadFile, getMediasByTarget, saveMedia, deleteMedia } from '@/api/media'
|
|
// 响应式数据
|
const loading = ref(false)
|
const submitting = ref(false)
|
const dialogVisible = ref(false)
|
const dialogTitle = ref('')
|
const tableData = ref([])
|
const formRef = ref()
|
|
// 搜索表单
|
const searchForm = reactive({
|
title: ''
|
})
|
|
// 分页数据
|
const pagination = reactive({
|
page: 1,
|
size: 10,
|
total: 0
|
})
|
|
// 表单数据
|
const formData = reactive({
|
id: null,
|
title: '',
|
content: '',
|
sortOrder: 99999
|
})
|
|
// 媒体管理响应式状态
|
const mediaList = ref([])
|
const selectedMediaFiles = ref([])
|
const uploadingMedia = ref(false)
|
|
// 表单验证规则
|
const formRules = {
|
title: [
|
{ required: true, message: '请输入标题', trigger: 'blur' },
|
{ max: 100, message: '标题长度不能超过100个字符', trigger: 'blur' }
|
],
|
sortOrder: [
|
{ required: true, message: '请输入排序值', trigger: 'blur' }
|
]
|
}
|
|
// 加载数据
|
const loadData = async () => {
|
try {
|
loading.value = true
|
const page = pagination.page - 1 // GraphQL 通常从0开始
|
const size = pagination.size
|
const title = searchForm.title || null // 后端使用 null 而不是 undefined
|
|
const response = await CarouselApi.getCarousels(page, size, title)
|
// 按sortOrder从小到大排序
|
const sortedData = (response.content || []).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
tableData.value = sortedData
|
pagination.total = response.totalElements || 0
|
} catch (error) {
|
console.error('加载数据失败:', error)
|
ElMessage.error('加载数据失败')
|
} finally {
|
loading.value = false
|
}
|
}
|
|
// 搜索
|
const handleSearch = () => {
|
pagination.page = 1
|
loadData()
|
}
|
|
|
|
// 新增
|
const handleAdd = () => {
|
dialogTitle.value = '新增轮播图'
|
resetForm()
|
dialogVisible.value = true
|
}
|
|
// 编辑
|
const handleEdit = async (row) => {
|
dialogTitle.value = '编辑轮播图'
|
Object.assign(formData, {
|
id: row.id,
|
title: row.title,
|
content: row.content,
|
sortOrder: row.sortOrder
|
})
|
dialogVisible.value = true
|
await loadMedias()
|
}
|
|
// 删除
|
const handleDelete = async (row) => {
|
try {
|
await ElMessageBox.confirm(
|
`确定要删除轮播图"${row.title}"吗?`,
|
'确认删除',
|
{
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
}
|
)
|
|
await CarouselApi.deleteCarousel(row.id)
|
ElMessage.success('删除成功')
|
loadData()
|
} catch (error) {
|
if (error !== 'cancel') {
|
console.error('删除失败:', error)
|
ElMessage.error('删除失败')
|
}
|
}
|
}
|
|
// 提交表单(与评委逻辑一致:先保存基本信息,再上传媒体)
|
const handleSubmit = async () => {
|
try {
|
await formRef.value.validate()
|
submitting.value = true
|
|
// 1. 先保存轮播图基本信息
|
const carouselInput = {
|
id: formData.id,
|
title: formData.title?.trim(),
|
content: formData.content?.trim() || '',
|
sortOrder: parseInt(formData.sortOrder) || 99999
|
}
|
console.log('准备保存轮播图:', carouselInput)
|
const savedCarousel = await CarouselApi.saveCarousel(carouselInput)
|
console.log('轮播图保存成功:', savedCarousel)
|
|
// 2. 上传新选择的媒体文件
|
if (savedCarousel.id && selectedMediaFiles.value.length > 0) {
|
await handleMediaFilesUpload(savedCarousel.id)
|
}
|
|
ElMessage.success(formData.id ? '修改成功' : '新增成功')
|
|
// 3. 新增模式下保持弹窗打开,编辑模式下关闭
|
if (!formData.id) {
|
// 新增模式:更新formData.id,加载媒体列表,清空待上传文件
|
formData.id = savedCarousel.id
|
await loadMedias()
|
selectedMediaFiles.value = []
|
} else {
|
// 编辑模式:关闭弹窗
|
dialogVisible.value = false
|
}
|
|
loadData()
|
} catch (error) {
|
console.error('保存失败:', error)
|
ElMessage.error('保存失败')
|
} finally {
|
submitting.value = false
|
}
|
}
|
|
// 关闭对话框
|
const handleDialogClose = () => {
|
resetForm()
|
}
|
|
// 重置表单
|
const resetForm = () => {
|
Object.assign(formData, {
|
id: null,
|
title: '',
|
content: '',
|
sortOrder: 99999
|
})
|
formRef.value?.clearValidate()
|
mediaList.value = []
|
selectedMediaFiles.value = []
|
}
|
|
// 分页大小改变
|
const handleSizeChange = (size) => {
|
pagination.size = size
|
pagination.page = 1
|
loadData()
|
}
|
|
// 当前页改变
|
const handleCurrentChange = (page) => {
|
pagination.page = page
|
loadData()
|
}
|
|
// 初始化
|
onMounted(() => {
|
loadData()
|
})
|
|
// 加载媒体列表(TargetType 约定:carousel)
|
const CAROUSEL_TARGET_TYPE = 4 // 后端代码中使用的是 targetType=4
|
const loadMedias = async () => {
|
if (!formData.id) return
|
try {
|
const list = await getMediasByTarget(CAROUSEL_TARGET_TYPE, formData.id)
|
mediaList.value = Array.isArray(list) ? list : []
|
} catch (e) {
|
console.error('加载媒体失败:', e)
|
}
|
}
|
|
// 选择文件(与评委逻辑一致:选择后本地预览,保存时上传)
|
const onMediaFileChange = (fileEvent) => {
|
const files = fileEvent?.target?.files || [fileEvent?.raw || fileEvent]
|
|
for (const file of files) {
|
if (!file) continue
|
|
// 验证文件类型和大小
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
ElMessage.error('只能上传图片或视频文件!')
|
continue
|
}
|
if (file.size / 1024 / 1024 > 50) {
|
ElMessage.error('文件大小不能超过 50MB!')
|
continue
|
}
|
|
// 添加到待上传列表
|
selectedMediaFiles.value.push({
|
file: file,
|
name: file.name,
|
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
uploaded: false
|
})
|
}
|
|
if (selectedMediaFiles.value.length > 0) {
|
ElMessage.success(`已选择 ${selectedMediaFiles.value.length} 个文件,保存时将上传`)
|
}
|
}
|
|
// 移除选中的文件
|
const removeSelectedFile = (index) => {
|
const file = selectedMediaFiles.value[index]
|
if (file.previewUrl) {
|
URL.revokeObjectURL(file.previewUrl)
|
}
|
selectedMediaFiles.value.splice(index, 1)
|
}
|
|
// 上传媒体文件(在保存轮播图后调用)
|
const handleMediaFilesUpload = async (carouselId) => {
|
if (selectedMediaFiles.value.length === 0) return
|
|
console.log('=== 开始上传媒体文件 ===')
|
console.log('轮播图ID:', carouselId)
|
console.log('待上传文件数量:', selectedMediaFiles.value.length)
|
|
try {
|
uploadingMedia.value = true
|
|
for (const mediaFile of selectedMediaFiles.value) {
|
if (mediaFile.uploaded) continue
|
|
console.log('上传文件:', mediaFile.name)
|
|
// 上传文件到COS
|
const uploadResult = await uploadFile(mediaFile.file)
|
console.log('上传结果:', uploadResult)
|
|
if (uploadResult.success) {
|
// 保存媒体信息到数据库
|
const input = {
|
name: uploadResult.fileName || mediaFile.name,
|
path: uploadResult.path,
|
fileSize: uploadResult.fileSize,
|
fileExt: uploadResult.fileName?.split('.').pop() || mediaFile.name.split('.').pop() || '',
|
mediaType: mediaFile.file.type.startsWith('image/') ? 1 : 2, // 1-图片, 2-视频
|
targetType: CAROUSEL_TARGET_TYPE,
|
targetId: parseInt(carouselId)
|
}
|
|
await saveMedia(input)
|
mediaFile.uploaded = true
|
console.log('媒体保存成功:', mediaFile.name)
|
}
|
}
|
|
ElMessage.success('媒体文件上传完成')
|
} catch (error) {
|
console.error('媒体上传失败:', error)
|
ElMessage.error(`媒体上传失败: ${error.message}`)
|
} finally {
|
uploadingMedia.value = false
|
}
|
}
|
|
// 删除媒体
|
const removeMedia = async (row) => {
|
try {
|
await ElMessageBox.confirm('确定删除该媒体吗?', '提示', { type: 'warning' })
|
await deleteMedia(row.id)
|
ElMessage.success('删除成功')
|
await loadMedias()
|
await loadData()
|
} catch (e) {
|
if (e !== 'cancel') {
|
console.error('删除媒体失败:', e)
|
ElMessage.error('删除媒体失败')
|
}
|
}
|
}
|
|
// 记录修改过的排序值
|
const modifiedSortOrders = ref(new Map())
|
|
// 排序值修改处理
|
const handleSortOrderChange = (row, index) => {
|
console.log('排序值修改:', row.id, row.sortOrder)
|
// 只接受数字,非数字则忽略
|
if (typeof row.sortOrder !== 'number' || isNaN(row.sortOrder)) {
|
row.sortOrder = 99999
|
}
|
// 记录修改
|
modifiedSortOrders.value.set(row.id, row.sortOrder)
|
console.log('当前修改记录:', modifiedSortOrders.value)
|
}
|
|
|
|
const updateSortOrders = async () => {
|
try {
|
console.log('开始更新排序,修改记录数量:', modifiedSortOrders.value.size)
|
|
// 如果有当前页面修改过的排序值,使用批量更新API
|
if (modifiedSortOrders.value.size > 0) {
|
console.log('保存当前页面修改的排序值:', modifiedSortOrders.value)
|
|
// 转换为数组格式,符合后端 List<CarouselSortOrderInput> 的要求
|
const sortOrders = []
|
modifiedSortOrders.value.forEach((sortOrder, carouselId) => {
|
sortOrders.push({
|
id: parseInt(carouselId),
|
sortOrder: parseInt(sortOrder) || 99999
|
})
|
})
|
|
console.log('批量更新排序参数:', sortOrders)
|
|
try {
|
const result = await CarouselApi.updateSortOrders(sortOrders)
|
console.log('更新排序结果:', result)
|
|
ElMessage.success('排序更新成功')
|
|
// 清空修改记录
|
modifiedSortOrders.value.clear()
|
|
// 重新加载数据
|
await loadData()
|
} catch (apiError) {
|
console.error('API调用失败:', apiError)
|
throw apiError
|
}
|
} else {
|
ElMessage.info('没有需要保存的排序修改')
|
}
|
|
|
} catch (e) {
|
console.error('更新排序失败:', e)
|
ElMessage.error('更新排序失败: ' + e.message)
|
}
|
}
|
|
// 在新标签页预览媒体
|
const openMediaInNewTab = (url) => {
|
// 创建一个HTML页面来预览媒体
|
const mediaType = url.toLowerCase().includes('.mp4') || url.toLowerCase().includes('.avi') || url.toLowerCase().includes('.mov') ? 'video' : 'image'
|
|
let htmlContent = ''
|
if (mediaType === 'video') {
|
htmlContent = `
|
<!DOCTYPE html>
|
<html>
|
<head>
|
<title>视频预览</title>
|
<style>
|
body { margin: 0; padding: 20px; background: #000; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
video { max-width: 100%; max-height: 100%; }
|
</style>
|
</head>
|
<body>
|
<video controls autoplay style="max-width: 100%; max-height: 100%;">
|
<source src="${url}" type="video/mp4">
|
您的浏览器不支持视频播放。
|
</video>
|
</body>
|
</html>
|
`
|
} else {
|
htmlContent = `
|
<!DOCTYPE html>
|
<html>
|
<head>
|
<title>图片预览</title>
|
<style>
|
body { margin: 0; padding: 20px; background: #000; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
</style>
|
</head>
|
<body>
|
<img src="${url}" alt="图片预览" style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
</body>
|
</html>
|
`
|
}
|
|
const blob = new Blob([htmlContent], { type: 'text/html' })
|
const blobUrl = URL.createObjectURL(blob)
|
const newWindow = window.open(blobUrl, '_blank')
|
|
// 清理blob URL
|
setTimeout(() => {
|
URL.revokeObjectURL(blobUrl)
|
}, 1000)
|
}
|
</script>
|
|
<style lang="scss" scoped>
|
.carousel-container {
|
padding: 20px;
|
|
.page-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20px;
|
|
h2 {
|
margin: 0;
|
color: #303133;
|
}
|
}
|
|
.search-section {
|
background: #fff;
|
padding: 20px;
|
border-radius: 4px;
|
margin-bottom: 20px;
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
}
|
|
.table-section {
|
background: #fff;
|
padding: 20px;
|
border-radius: 4px;
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
}
|
|
.pagination-section {
|
margin-top: 20px;
|
text-align: right;
|
}
|
}
|
|
.header-actions {
|
display: flex;
|
gap: 10px;
|
}
|
|
/* 媒体预览样式 */
|
.media-preview {
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
width: 80px;
|
height: 60px;
|
border-radius: 4px;
|
overflow: hidden;
|
}
|
|
.preview-image {
|
width: 100%;
|
height: 100%;
|
object-fit: cover;
|
border-radius: 4px;
|
}
|
|
.preview-video {
|
width: 100%;
|
height: 100%;
|
border-radius: 4px;
|
}
|
|
.clickable {
|
cursor: pointer;
|
transition: opacity 0.3s;
|
}
|
|
.clickable:hover {
|
opacity: 0.8;
|
}
|
</style>
|