587 lines
17 KiB
Vue
587 lines
17 KiB
Vue
<!-- 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 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 asset = await uploadFile(options.file, {
|
||
bizType: 'BANNER',
|
||
onProgress: (percent) => (imagePercent.value = percent)
|
||
})
|
||
form.imageUrl = asset.url
|
||
imageFileName.value = options.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">支持 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>
|