chore(web): 收口前几轮后台改动(品牌 / 媒资上传 / 轮播图 / 类型管理 / 操作日志 / App 用户)
为什么:这些改动在前几轮就已完成并在 :3005 验证过,但一直留在工作区没有入库。 下一步 3-4 的四个页面依赖其中的公共样式入口(src/styles/business/index.css)与 api/admin 的公共类型(PageResult 等),如果不先收口,新页面的提交就会夹带旧改动, 既说不清 diff 边界,也让 index.css 这类多轮共用的文件长期处于半提交状态。 本提交只做归档,不改行为: - 品牌与接口基址改成 FruitShorts / 短剧服务(.env.*、README、vite 代理) - 去掉前端 mock 数据目录(src/data、src/permissions、stores/auth),改为走真实接口 - 新增 api/admin 下的接口封装与 src/utils/uploader.ts(媒资分片直传) - 新增/改造:轮播图管理、类型管理、操作日志、App 用户管理、后台布局与公共样式
This commit is contained in:
1 parent
40546bfd01
commit
44c2c46a68
59 files changed
+4082
-982
No files matched your search
@@ -0,0 +1,407 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
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 editingId = ref(null)
|
||||
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: '打开链接' }
|
||||
]
|
||||
|
||||
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
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function nextSort() {
|
||||
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!form.imageUrl.trim()) return ElMessage.warning('请上传轮播图片')
|
||||
if (form.linkType === 'DRAMA' && !form.linkValue) {
|
||||
return ElMessage.warning('请选择要跳转的短剧')
|
||||
}
|
||||
if (form.linkType === 'URL' && !form.linkValue.trim()) {
|
||||
return ElMessage.warning('请填写跳转地址')
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(options) {
|
||||
uploadingImage.value = true
|
||||
imagePercent.value = 0
|
||||
try {
|
||||
const asset = await uploadFile(options.file, {
|
||||
bizType: 'BANNER',
|
||||
onProgress: (percent) => (imagePercent.value = percent)
|
||||
})
|
||||
form.imageUrl = asset.url
|
||||
ElMessage.success('图片已上传')
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建轮播图
|
||||
</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 }">
|
||||
<img v-if="row.imageUrl" :src="row.imageUrl" class="banner-thumb" />
|
||||
<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="{ row, $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="620px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="轮播图片" required>
|
||||
<div class="banner-field">
|
||||
<div class="banner-field-actions">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="uploadImage"
|
||||
:disabled="uploadingImage"
|
||||
>
|
||||
<el-button :loading="uploadingImage">{{
|
||||
form.imageUrl ? '更换图片' : '选择图片'
|
||||
}}</el-button>
|
||||
</el-upload>
|
||||
<p v-if="uploadingImage" class="selected-file">上传中 {{ imagePercent }}%</p>
|
||||
<p v-else-if="form.imageUrl" class="selected-file">图片已上传</p>
|
||||
<el-input v-model="form.imageUrl" placeholder="也可以直接填写图片地址" />
|
||||
</div>
|
||||
<img v-if="form.imageUrl" :src="form.imageUrl" class="form-banner-preview" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="展示位置" required>
|
||||
<el-select v-model="form.position">
|
||||
<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="跳转短剧" required>
|
||||
<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="跳转地址" required>
|
||||
<el-input v-model="form.linkValue" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :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>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
Reference in new issue
Block a user