Files
ReelShortWeb/src/views/contentManagement/BannerManagementView.vue
T
Vicky 923b7dce1e feat(upload): 封面上传支持横版与前端压缩
- 新增 imageCompress 与 imagePresets,上传前按规格高质量等比压缩
- 短剧封面拆分为竖版/横版两个上传位,带建议尺寸提示
- 轮播图、创作者中心封面与分集封面接入压缩与提示
2026-10-29 17:32:56 +08:00

591 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Author: fruit.com.cn -->
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import {
createBannerApi,
deleteBannerApi,
pageBannersApi,
sortBannersApi,
updateBannerApi,
updateBannerStatusApi
} from '@/api/admin/banner'
import { pageDramasApi } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { uploadFile } from '@/utils/uploader'
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const positionFilter = ref(undefined)
const statusFilter = ref(undefined)
const dramas = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingImage = ref(false)
const imagePercent = ref(0)
const imageError = ref('')
const imageFileName = ref('')
const editingId = ref(null)
const formRef = ref()
const form = reactive({
imageUrl: '',
position: 'HOME_TOP',
linkType: 'NONE',
linkValue: '',
sort: 0,
status: 1
})
const positionOptions = [
{ code: 'HOME_TOP', text: '首页顶部' },
{ code: 'DISCOVER', text: '发现页' }
]
const linkTypeOptions = [
{ code: 'NONE', text: '不跳转' },
{ code: 'DRAMA', text: '跳转短剧' },
{ code: 'URL', text: '打开链接' }
]
const MAX_BANNER_IMAGE_SIZE = 5 * 1024 * 1024
const BANNER_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
imageUrl: [{ required: true, message: '请上传轮播图片', trigger: 'change' }],
position: [{ required: true, message: '请选择展示位置', trigger: 'change' }],
linkValue: [
{
validator: (_rule, value, callback) => {
if (form.linkType === 'DRAMA' && !value) {
callback(new Error('请选择要跳转的短剧'))
} else if (form.linkType === 'URL' && !String(value || '').trim()) {
callback(new Error('请填写跳转地址'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const bannerPreviewList = computed(() => list.value.map((item) => item.imageUrl).filter(Boolean))
const imageDisplayName = computed(
() => imageFileName.value || (form.imageUrl ? bannerBasename(form.imageUrl) : '尚未选择图片')
)
function bannerPreviewIndex(row) {
return Math.max(0, bannerPreviewList.value.indexOf(row.imageUrl))
}
function bannerBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
async function load() {
loading.value = true
try {
const res = await pageBannersApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
position: positionFilter.value,
status: statusFilter.value
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
async function loadDramas() {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
dramas.value = res.data?.list || []
}
function search() {
pageIndex.value = 1
load()
}
function resetFilters() {
positionFilter.value = undefined
statusFilter.value = undefined
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function openCreate() {
editingId.value = null
Object.assign(form, {
imageUrl: '',
position: 'HOME_TOP',
linkType: 'NONE',
linkValue: '',
sort: nextSort(),
status: 1
})
imageFileName.value = ''
imageError.value = ''
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
imageUrl: row.imageUrl,
position: row.position,
linkType: row.linkType,
linkValue: row.linkValue || '',
sort: row.sort ?? 0,
status: row.status
})
imageFileName.value = row.imageUrl ? bannerBasename(row.imageUrl) : ''
imageError.value = ''
dialogVisible.value = true
resetValidation()
}
function nextSort() {
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
}
async function saveBanner() {
if (saving.value || uploadingImage.value) return
try {
await formRef.value?.validate()
} catch {
return
}
const payload = {
...form,
imageUrl: form.imageUrl.trim(),
linkValue: form.linkType === 'NONE' ? '' : String(form.linkValue).trim()
}
saving.value = true
try {
if (editingId.value) {
await updateBannerApi(editingId.value, payload)
ElMessage.success('轮播图已更新')
} else {
await createBannerApi(payload)
ElMessage.success('轮播图已创建')
pageIndex.value = 1
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
function beforeImageUpload(file) {
imageError.value = ''
if (!BANNER_IMAGE_TYPES.includes(file.type)) {
imageError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_BANNER_IMAGE_SIZE) {
imageError.value = '图片大小不能超过 5MB'
return false
}
return true
}
async function uploadImage(options) {
uploadingImage.value = true
imagePercent.value = 0
imageError.value = ''
try {
const file = await compressForPreset(options.file, BANNER_IMAGE)
const asset = await uploadFile(file, {
bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent)
})
form.imageUrl = asset.url
imageFileName.value = file.name
await resetValidation()
ElMessage.success('图片已上传')
} catch {
imageError.value = '图片上传失败,请检查网络后重试'
} finally {
uploadingImage.value = false
}
}
function removeImage() {
if (uploadingImage.value) return
form.imageUrl = ''
imageFileName.value = ''
imageError.value = ''
resetValidation()
}
function beforeDialogClose(done) {
if (saving.value || uploadingImage.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '图片正在上传,请稍候')
return
}
done()
}
async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1
await updateBannerStatusApi(row.id, next)
ElMessage.success(next === 1 ? '轮播图已启用' : '轮播图已停用')
await load()
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除轮播图「${row.title}」吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteBannerApi(row.id)
ElMessage.success('轮播图已删除')
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
await load()
}
/** 上移/下移后整表提交新顺序 */
async function move(index, offset) {
const target = index + offset
if (target < 0 || target >= list.value.length) return
const next = [...list.value]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
await sortBannersApi(next.map((item) => item.id))
ElMessage.success('展示顺序已更新')
await load()
}
onMounted(async () => {
await loadDramas()
await load()
})
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="轮播图管理"
description="配置客户端首页与发现页的轮播位,控制顺序、跳转和上下线。"
>
<el-button
v-if="can('banner:create')"
type="primary"
class="primary-button"
@click="openCreate"
>
<AppIcon :icon="AppIcons.add" />
新建轮播图
</el-button>
</PageHeading>
<section class="panel library-panel">
<div class="library-toolbar">
<div class="drama-filters">
<el-select v-model="positionFilter" clearable placeholder="全部位置" @change="search">
<el-option
v-for="item in positionOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search">
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
<el-button @click="resetFilters">重置</el-button>
</div>
<span class="table-result-count">共 {{ total }} 张轮播图</span>
</div>
<el-table
v-loading="loading"
:data="list"
row-key="id"
empty-text="还没有轮播图"
class="drama-el-table"
>
<el-table-column label="预览" width="190">
<template #default="{ row }">
<AppImage
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-list="bannerPreviewList"
:initial-index="bannerPreviewIndex(row)"
fit="cover"
class="banner-thumb"
:alt="row.title"
/>
<div v-else class="banner-thumb-empty">暂无图片</div>
</template>
</el-table-column>
<el-table-column prop="positionText" label="位置" width="110" />
<el-table-column label="跳转" min-width="200">
<template #default="{ row }">
<span v-if="row.linkType === 'NONE'" class="table-empty-text">不跳转</span>
<span v-else>{{ row.linkTypeText }}:{{ row.linkText }}</span>
</template>
</el-table-column>
<el-table-column label="排序" width="150">
<template #default="{ $index }">
<div class="table-actions">
<el-button
v-if="can('banner:update')"
size="small"
:disabled="$index === 0"
@click="move($index, -1)"
>上移</el-button
>
<el-button
v-if="can('banner:update')"
size="small"
:disabled="$index === list.length - 1"
@click="move($index, 1)"
>下移</el-button
>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-button
v-if="can('banner:update')"
link
:type="row.status === 1 ? 'primary' : 'info'"
@click="toggleStatus(row)"
>{{ row.statusText }}</el-button
>
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.statusText
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="can('banner:update')"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
>
<el-button
v-if="can('banner:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑轮播图' : '新建轮播图'"
width="680px"
class="banner-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="banner-editor-form"
>
<section class="banner-editor-section">
<h3 class="banner-editor-section-title">轮播图片</h3>
<el-form-item prop="imageUrl">
<div class="banner-image-editor">
<div class="banner-image-preview-card">
<img v-if="form.imageUrl" :src="form.imageUrl" alt="轮播图预览" />
<div v-else class="banner-image-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无图片</span>
</div>
<div v-if="uploadingImage" class="banner-image-upload-mask">
<span>上传中</span>
<b>{{ imagePercent }}%</b>
</div>
</div>
<div class="banner-image-meta">
<div class="banner-image-status">
<el-tag :type="form.imageUrl ? 'success' : 'info'" effect="light">
{{ form.imageUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="banner-image-filename" :title="imageDisplayName">
{{ imageDisplayName }}
</span>
</div>
<p class="banner-image-hint"
>{{ BANNER_IMAGE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
>
<el-alert
v-if="imageError"
:title="imageError"
type="error"
:closable="false"
show-icon
/>
<div class="banner-image-actions">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeImageUpload"
:http-request="uploadImage"
:disabled="uploadingImage"
>
<el-button :loading="uploadingImage" :disabled="uploadingImage">
<AppIcon :icon="AppIcons.upload" />
{{ form.imageUrl ? '更换图片' : '选择图片' }}
</el-button>
</el-upload>
<el-button
v-if="form.imageUrl"
type="danger"
:disabled="uploadingImage"
@click="removeImage"
>
移除
</el-button>
<a
v-if="form.imageUrl"
class="banner-original-link"
:href="form.imageUrl"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</el-form-item>
</section>
<section class="banner-editor-section">
<h3 class="banner-editor-section-title">跳转配置</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="展示位置" prop="position">
<el-select v-model="form.position" placeholder="请选择展示位置">
<el-option
v-for="item in positionOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
<el-form-item label="跳转类型">
<el-select v-model="form.linkType">
<el-option
v-for="item in linkTypeOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
</div>
<el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" prop="linkValue">
<el-select v-model="form.linkValue" filterable placeholder="搜索短剧">
<el-option
v-for="item in dramas"
:key="item.id"
:label="`${item.title}(${item.statusText})`"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.linkType === 'URL'" label="跳转地址" prop="linkValue">
<el-input v-model="form.linkValue" placeholder="https://..." />
</el-form-item>
</section>
<section class="banner-editor-section">
<h3 class="banner-editor-section-title">排序与状态</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="排序">
<el-input-number
v-model="form.sort"
class="banner-editor-number"
controls-position="right"
:min="0"
:max="999"
/>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</div>
</section>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingImage" @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingImage"
@click="saveBanner"
>
保存
</el-button>
</template>
</el-dialog>
</template>