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:
Vicky committed 2026-10-15 13:28:01 +08:00
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>
@@ -0,0 +1,166 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
createDramaCategoryApi,
deleteDramaCategoryApi,
listDramaCategoriesApi,
updateDramaCategoryApi
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const loading = ref(false)
const list = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', sort: 1, status: 1 })
async function load() {
loading.value = true
try {
const res = await listDramaCategoriesApi()
list.value = res.data || []
} finally {
loading.value = false
}
}
function nextSort() {
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
}
function openCreate() {
editingId.value = null
Object.assign(form, { name: '', sort: nextSort(), status: 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
dialogVisible.value = true
}
async function saveCategory() {
if (!form.name.trim()) return ElMessage.warning('请填写类型名称')
saving.value = true
try {
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
if (editingId.value) {
await updateDramaCategoryApi(editingId.value, payload)
ElMessage.success('类型已更新')
} else {
await createDramaCategoryApi(payload)
ElMessage.success('类型已创建')
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function confirmDelete(row) {
const count = row.dramaCount || 0
try {
await ElMessageBox.confirm(
count > 0
? `「${row.name}」下还有 ${count} 部短剧,删除会被拒绝。确定继续吗?`
: `确定删除类型「${row.name}」吗?`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await deleteDramaCategoryApi(row.id)
ElMessage.success('类型已删除')
await load()
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="类型管理"
description="维护短剧类型,供新建短剧选择,也作为客户端分类筛选的依据。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<el-icon><Plus /></el-icon>
新建类型
</el-button>
</PageHeading>
<section class="panel">
<el-table
v-loading="loading"
:data="list"
row-key="id"
border
class="drama-el-table"
empty-text="还没有短剧类型"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="类型名称" min-width="200" />
<el-table-column label="短剧数" width="110">
<template #default="{ row }">{{ row.dramaCount || 0 }} 部</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.status === 1 ? '启用' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button v-if="can('drama:update')" type="primary" size="small" @click="openEdit(row)"
>编辑</el-button
>
<el-button
v-if="can('drama:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="460px">
<el-form label-position="top">
<el-form-item label="类型名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:都市情感" />
</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="saveCategory">保存</el-button>
</template>
</el-dialog>
</template>
@@ -1,83 +1,237 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { dramas } from '@/data/dramas'
import {
changeDramaStatusApi,
createDramaApi,
deleteDramaApi,
listDramaCategoryOptionsApi,
pageDramasApi,
updateDramaApi
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status'
import { uploadFile } from '@/utils/uploader'
const { can } = useRbac()
const search = ref('')
const status = ref('全部状态')
const currentPage = ref(1)
const router = useRouter()
const { can, hasAny } = useRbac()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const categories = ref([])
const keyword = ref('')
const statusFilter = ref(undefined)
const categoryId = ref(undefined)
const hotFilter = ref(undefined)
const pageIndex = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingCover = ref(false)
const coverPercent = ref(0)
const editingId = ref(null)
const form = reactive({ title: '', genre: '', description: '', episodes: 1, cover: '' })
const filtered = computed(() =>
dramas.filter(
(item) =>
item.title.includes(search.value) &&
(status.value === '全部状态' || item.status === status.value)
)
)
const paginated = computed(() =>
filtered.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value)
)
watch([search, status], () => {
currentPage.value = 1
const form = reactive({
title: '',
categoryId: undefined,
description: '',
freeEpisodes: 3,
unlockCoin: 0,
cover: ''
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'reviewing', text: '审核中' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' },
reject: { label: '驳回', done: '已驳回' },
publish: { label: '上线', done: '已上线' },
offline: { label: '下线', done: '已下线' }
}
async function load() {
loading.value = true
try {
const res = await pageDramasApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined,
categoryId: categoryId.value,
isHot: hotFilter.value
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
async function loadCategories() {
const res = await listDramaCategoryOptionsApi()
categories.value = res.data || []
}
function search() {
pageIndex.value = 1
load()
}
function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
categoryId.value = undefined
hotFilter.value = undefined
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function openCreate() {
editingId.value = null
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
dialogVisible.value = true
}
function openEdit(item) {
editingId.value = item.id
Object.assign(form, {
title: item.title,
genre: item.genre,
description: item.description,
episodes: item.episodes,
cover: item.cover || ''
title: '',
categoryId: undefined,
description: '',
freeEpisodes: 3,
unlockCoin: 0,
isHot: false,
cover: ''
})
dialogVisible.value = true
}
function saveDrama() {
if (!form.title || !form.genre) return ElMessage.warning('请填写短剧名称和类型')
const updatedAt = new Date().toISOString().slice(0, 10)
if (editingId.value) {
const drama = dramas.find((item) => item.id === editingId.value)
if (drama) Object.assign(drama, { ...form, updatedAt })
} else {
dramas.unshift({ id: Date.now(), ...form, status: '草稿', views: '—', updatedAt })
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
title: row.title,
categoryId: row.categoryId || undefined,
description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3,
unlockCoin: row.unlockCoin ?? 0,
isHot: !!row.isHot,
cover: row.cover || ''
})
dialogVisible.value = true
}
async function saveDrama() {
if (!form.title.trim()) return ElMessage.warning('请填写短剧名称')
if (!form.categoryId) return ElMessage.warning('请选择短剧类型')
const category = categories.value.find((item) => item.id === form.categoryId)
const payload = {
...form,
title: form.title.trim(),
genre: category?.name || ''
}
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
dialogVisible.value = false
ElMessage.success(editingId.value ? '短剧信息已更新' : '短剧草稿已保存')
editingId.value = null
}
function uploadCover(file) {
if (can('上传封面素材')) form.cover = URL.createObjectURL(file.raw)
}
async function confirmDelete(item) {
saving.value = true
try {
await ElMessageBox.confirm(`确定删除短剧「${item.title}」吗?删除后无法恢复。`, '删除确认', {
if (editingId.value) {
await updateDramaApi(editingId.value, payload)
ElMessage.success('短剧信息已更新')
} else {
await createDramaApi(payload)
ElMessage.success('短剧草稿已保存')
pageIndex.value = 1
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
async function uploadCover(options) {
uploadingCover.value = true
coverPercent.value = 0
try {
const asset = await uploadFile(options.file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent)
})
form.cover = asset.url
ElMessage.success('封面已上传')
} finally {
uploadingCover.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除短剧「${row.title}」吗?删除后无法恢复。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
distinguishCancelAndClose: true
})
const index = dramas.findIndex((drama) => drama.id === item.id)
if (index !== -1) dramas.splice(index, 1)
if (currentPage.value > Math.ceil(filtered.value.length / pageSize.value))
currentPage.value = Math.max(1, currentPage.value - 1)
ElMessage.success('短剧已删除')
} catch {
// 用户取消或关闭确认框时不执行删除。
return
}
await deleteDramaApi(row.id)
ElMessage.success('短剧已删除')
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
await load()
}
/** 审核发布动作:驳回需要填写原因,其余二次确认。 */
async function runAction(row, action) {
const meta = actionMeta[action]
let reason = ''
try {
if (action === 'reject') {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回「${row.title}」`, {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPlaceholder: '例如:封面素材不合规'
})
reason = value
} else {
await ElMessageBox.confirm(`确定将「${row.title}」${meta.label}吗?`, '操作确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
}
} catch {
return
}
await changeDramaStatusApi(row.id, action, reason)
ElMessage.success(meta.done)
await load()
}
function reviewActions(row) {
const canReview = can('drama:review')
if (row.status === 'draft' || row.status === 'offline') {
return hasAny('drama:update', 'drama:review') ? ['submit'] : []
}
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : []
if (row.status === 'approved') return canReview ? ['publish'] : []
if (row.status === 'published') return canReview ? ['offline'] : []
return []
}
onMounted(async () => {
await loadCategories()
await load()
})
</script>
<template>
@@ -85,7 +239,7 @@ async function confirmDelete(item) {
><div
><div class="eyebrow">内容管理</div><h1>短剧管理</h1
><p>管理短剧信息、封面、剧集和发布状态。</p></div
><el-button v-if="can('创建短剧')" type="primary" class="primary-button" @click="openCreate"
><el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate"
><el-icon><Plus /></el-icon>新建短剧</el-button
></div
>
@@ -93,19 +247,39 @@ async function confirmDelete(item) {
<div class="library-toolbar"
><div class="drama-filters"
><el-input
v-model="search"
v-model="keyword"
placeholder="搜索短剧名称"
:prefix-icon="Search"
clearable /><el-select v-model="status"
clearable
@keyup.enter="search"
@clear="search"
/><el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search"
><el-option
v-for="item in ['全部状态', '已上线', '审核中', '草稿']"
:key="item"
:label="item"
:value="item" /></el-select></div
><span class="table-result-count">共 {{ filtered.length }} 部短剧</span></div
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code" /></el-select
><el-select v-model="categoryId" clearable placeholder="全部类型" @change="search"
><el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id" /></el-select
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
><el-option label="仅热门" :value="true" /><el-option
label="非热门"
:value="false" /></el-select
><el-button @click="resetFilters">重置</el-button></div
><span class="table-result-count">共 {{ total }} 部短剧</span></div
>
<el-table :data="paginated" row-key="id" empty-text="没有符合条件的短剧" class="drama-el-table">
<el-table-column label="短剧名称" min-width="340"
<el-table
v-loading="loading"
:data="list"
row-key="id"
empty-text="没有符合条件的短剧"
class="drama-el-table"
>
<el-table-column label="短剧名称" min-width="220"
><template #default="{ row }"
><div class="drama-cell"
><img v-if="row.cover" :src="row.cover" class="drama-cover-image" /><div
@@ -113,42 +287,69 @@ async function confirmDelete(item) {
class="drama-cover"
>暂无封面</div
><div
><b>{{ row.title }}</b
><small>{{ row.description }}</small></div
><b>{{ row.title }}</b></div
></div
></template
></el-table-column
>
<el-table-column prop="genre" label="类型" width="140" />
<el-table-column label="集数" width="110"
<el-table-column label="简介" min-width="200" show-overflow-tooltip
><template #default="{ row }"
><span v-if="row.description">{{ row.description }}</span
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column prop="genre" label="类型" width="110" />
<el-table-column label="集数" width="90"
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
>
<el-table-column prop="views" label="播放量" width="120" />
<el-table-column label="状态" width="120"
<el-table-column label="播放量" width="110"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
>
<el-table-column label="状态" width="110"
><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
row.status
row.statusText
}}</el-tag></template
></el-table-column
>
<el-table-column prop="updatedAt" label="更新时间" width="150" />
<el-table-column label="操作" width="240" fixed="right"
<el-table-column label="热门" width="90"
><template #default="{ row }"
><el-tag v-if="row.isHot" type="danger" effect="light">热门</el-tag
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="审核" width="140"
><template #default="{ row }"
><div class="table-actions"
><el-button
v-if="can('编辑短剧信息')"
v-for="action in reviewActions(row)"
:key="action"
:type="action === 'reject' || action === 'offline' ? 'warning' : 'success'"
size="small"
@click="runAction(row, action)"
>{{ actionMeta[action].label }}</el-button
><span v-if="!reviewActions(row).length" class="table-empty-text">—</span></div
></template
></el-table-column
>
<el-table-column label="操作" width="200" fixed="right"
><template #default="{ row }"
><div class="table-actions"
><el-button
v-if="can('drama:update')"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('查看剧集列表')"
v-if="can('episode:list')"
type="success"
size="small"
@click="$router.push({ name: 'episodes', query: { drama: row.id } })"
@click="router.push({ name: 'episodes', query: { drama: row.id } })"
>剧集</el-button
><el-button
v-if="can('删除短剧')"
v-if="can('drama:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
@@ -160,13 +361,15 @@ async function confirmDelete(item) {
</el-table>
<div class="pagination"
><el-pagination
v-model:current-page="currentPage"
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="filtered.length"
:total="total"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/></div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑短剧' : '新建短剧'" width="600px"
@@ -175,38 +378,48 @@ async function confirmDelete(item) {
><el-form-item label="短剧名称" required
><el-input v-model="form.title" placeholder="请输入短剧名称" /></el-form-item
><el-form-item label="类型" required
><el-select v-model="form.genre" placeholder="请选择"
><el-select v-model="form.categoryId" placeholder="请选择"
><el-option
v-for="genre in ['都市情感', '甜宠爱情', '重生逆袭', '悬疑复仇', '古装言情']"
:key="genre"
:label="genre"
:value="genre" /></el-select></el-form-item></div
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id" /></el-select></el-form-item></div
><el-form-item label="简介"
><el-input
v-model="form.description"
type="textarea"
:rows="3"
:rows="2"
maxlength="200"
show-word-limit /></el-form-item
><div class="form-row"
><el-form-item label="预计集数"
><el-input-number v-model="form.episodes" :min="1" :max="200" /></el-form-item
><el-form-item v-if="can('上传封面素材')" label="短剧封面"
><el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="uploadCover"
><el-button size="small">{{
form.cover ? '更换封面' : '选择封面'
}}</el-button></el-upload
></el-form-item
></div
><img v-if="form.cover" :src="form.cover" class="form-cover-preview" /></el-form
><el-form-item label="免费集数"
><el-input-number v-model="form.freeEpisodes" :min="0" :max="200" /></el-form-item
><el-form-item label="单集解锁金币"
><el-input-number v-model="form.unlockCoin" :min="0" :max="9999" /></el-form-item
></div>
<el-form-item label="是否热门"><el-switch v-model="form.isHot" /></el-form-item>
<el-form-item label="短剧封面"
><div class="cover-field"
><div class="cover-field-actions"
><el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/*"
:http-request="uploadCover"
:disabled="uploadingCover"
><el-button :loading="uploadingCover">{{
form.cover ? '更换封面' : '选择封面'
}}</el-button></el-upload
><p v-if="uploadingCover" class="selected-file">上传中 {{ coverPercent }}%</p
><p v-else-if="form.cover" class="selected-file">封面已上传</p
><el-input v-model="form.cover" placeholder="也可以直接填写图片地址" /></div
><img
v-if="form.cover"
:src="form.cover"
class="form-cover-preview" /></div></el-form-item></el-form
><template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" @click="saveDrama">{{
><el-button type="primary" :loading="saving" @click="saveDrama">{{
editingId ? '保存修改' : '保存草稿'
}}</el-button></template
></el-dialog
@@ -1,136 +1,401 @@
<script setup>
import { computed, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { dramas } from '@/data/dramas'
import { episodeData } from '@/data/episodes'
import { pageDramasApi } from '@/api/admin/drama'
import {
createEpisodeApi,
deleteEpisodeApi,
listEpisodesApi,
sortEpisodesApi,
updateEpisodeApi
} from '@/api/admin/episode'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
const route = useRoute()
const { can } = useRbac()
const selectedDramaId = ref(Number(route.query.drama) || dramas[0]?.id)
const loading = ref(false)
const dramas = ref([])
const episodes = ref([])
const selectedDramaId = ref(undefined)
const dialogVisible = ref(false)
const form = ref({ title: '', fileName: '' })
const selectedDrama = computed(
() => dramas.find((item) => item.id === selectedDramaId.value) || dramas[0]
)
const episodes = computed(() =>
episodeData
.filter((item) => item.dramaId === selectedDramaId.value)
.sort((a, b) => a.number - b.number)
)
function pickVideo(file) {
form.value.fileName = file.name
const saving = ref(false)
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const previewVisible = ref(false)
const previewEpisode = ref(null)
const editingId = ref(null)
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
async function loadDramas() {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
dramas.value = res.data?.list || []
const fromQuery = Number(route.query.drama)
if (fromQuery && dramas.value.some((item) => item.id === fromQuery)) {
selectedDramaId.value = fromQuery
} else if (!selectedDramaId.value && dramas.value.length) {
selectedDramaId.value = dramas.value[0].id
}
}
function saveEpisode() {
if (!form.value.title || !form.value.fileName)
return ElMessage.warning('请填写标题并选择视频文件')
episodeData.push({
id: Date.now(),
dramaId: selectedDramaId.value,
number: episodes.value.length + 1,
title: form.value.title,
duration: '待处理',
status: '待发布',
views: '—',
fileName: form.value.fileName
async function loadEpisodes() {
if (!selectedDramaId.value) {
episodes.value = []
return
}
loading.value = true
try {
const res = await listEpisodesApi(selectedDramaId.value)
episodes.value = res.data || []
} finally {
loading.value = false
}
}
function nextNumber() {
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
}
function openCreate() {
editingId.value = null
Object.assign(form, {
number: nextNumber(),
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: selectedDrama.value?.unlockCoin ?? 0,
status: 'draft'
})
selectedDrama.value.episodes += 1
form.value = { title: '', fileName: '' }
dialogVisible.value = false
ElMessage.success('剧集已加入本地上传队列')
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
number: row.number,
title: row.title,
durationSec: row.durationSec || 0,
videoUrl: row.videoUrl || '',
isFree: !!row.isFree,
unlockCoin: row.unlockCoin || 0,
status: row.status
})
dialogVisible.value = true
}
async function saveEpisode() {
if (!form.title.trim()) return ElMessage.warning('请填写剧集标题')
if (!form.number || form.number < 1) return ElMessage.warning('请填写正确的集号')
const payload = {
dramaId: selectedDramaId.value,
number: form.number,
title: form.title.trim(),
durationSec: form.durationSec || 0,
videoUrl: form.videoUrl.trim(),
isFree: form.isFree,
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateEpisodeApi(editingId.value, payload)
ElMessage.success('剧集已更新')
} else {
await createEpisodeApi(payload)
ElMessage.success('剧集已创建')
}
dialogVisible.value = false
await loadEpisodes()
} finally {
saving.value = false
}
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
form.videoUrl = asset.url
if (duration > 0) form.durationSec = duration
ElMessage.success('视频已上传')
} finally {
uploadingVideo.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除第 ${row.number} 集「${row.title}」吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteEpisodeApi(row.id)
ElMessage.success('剧集已删除')
await loadEpisodes()
}
function openPreview(row) {
if (!row.videoUrl) {
ElMessage.warning('该剧集还没有上传视频')
return
}
previewEpisode.value = row
previewVisible.value = true
}
/** 鼠标移入播放、移出回到首帧,列表里就能直接看到视频内容 */
function playThumb(event) {
const video = event.currentTarget
video.play?.()?.catch(() => {})
}
function stopThumb(event) {
const video = event.currentTarget
try {
video.pause()
video.currentTime = 0
} catch {
// 元数据还没就绪时忽略
}
}
/** 上移/下移后把新顺序整表提交,由后端重排 sort。 */
async function move(index, offset) {
const target = index + offset
if (target < 0 || target >= episodes.value.length) return
const next = [...episodes.value]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
await sortEpisodesApi(next.map((item) => item.id))
ElMessage.success('播放顺序已更新')
await loadEpisodes()
}
async function onDramaChange() {
await loadEpisodes()
}
onMounted(async () => {
await loadDramas()
await loadEpisodes()
})
</script>
<template>
<div class="page-heading"
><div
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
><p>为短剧添加视频剧集并维护播放顺序。</p></div
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
><el-button
v-if="can('上传剧集视频')"
v-if="can('episode:upload')"
type="primary"
class="primary-button"
@click="dialogVisible = true"
><el-icon><Plus /></el-icon>上传剧集</el-button
:disabled="!selectedDramaId"
@click="openCreate"
><el-icon><Plus /></el-icon>新增剧集</el-button
></div
>
<section class="panel episode-panel">
<div class="episode-context"
><div class="episode-context-copy"
><span>选择短剧</span><b>{{ selectedDrama?.title }}</b></div
><el-select v-model="selectedDramaId" class="drama-select"
><span>选择短剧</span><b>{{ selectedDrama?.title || '暂无短剧' }}</b></div
><el-select v-model="selectedDramaId" class="drama-select" @change="onDramaChange"
><el-option
v-for="item in dramas"
:key="item.id"
:label="item.title"
:label="`${item.title}(${item.episodes} 集)`"
:value="item.id" /></el-select
></div>
<div class="episode-toolbar"
><div
><h2>剧集列表({{ episodes.length }})</h2><p>按剧集编号顺序播放</p></div
><h2>剧集列表({{ episodes.length }})</h2><p>按列表顺序播放</p></div
></div
>
<el-table
v-loading="loading"
:data="episodes"
row-key="id"
empty-text="该短剧暂无剧集"
class="drama-el-table episode-el-table"
>
<el-table-column label="集数" width="100"
<el-table-column label="集数" width="90"
><template #default="{ row }">{{
String(row.number).padStart(2, '0')
}}</template></el-table-column
>
<el-table-column label="剧集名称" min-width="280"
<el-table-column label="剧集名称" min-width="240"
><template #default="{ row }"
><div class="episode-table-title"
><b>{{ row.title }}</b
><small v-if="row.fileName">{{ row.fileName }}</small></div
><small v-if="!row.videoUrl">未上传视频</small></div
></template
></el-table-column
>
<el-table-column prop="duration" label="时长" width="140" />
<el-table-column prop="views" label="播放量" width="180"
<el-table-column label="视频" width="150"
><template #default="{ row }"
>{{ row.views }}<span v-if="row.views !== '—'"> 次</span></template
><video
v-if="row.videoUrl"
:src="row.videoUrl"
class="episode-video-thumb"
muted
playsinline
preload="metadata"
title="点击放大播放"
@mouseenter="playThumb"
@mouseleave="stopThumb"
@click="openPreview(row)"
></video
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column label="状态" width="150"
<el-table-column prop="duration" label="时长" width="110" />
<el-table-column label="付费" width="120"
><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
row.status
><el-tag :type="row.isFree ? 'success' : 'warning'" effect="light">{{
row.isFree ? '免费' : `${row.unlockCoin} 金币`
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="150" fixed="right"
><template #default
><el-button v-if="can('管理剧集排序')" type="primary" link>调整顺序</el-button></template
<el-table-column label="播放量" width="120"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
>
<el-table-column label="状态" width="110"
><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
row.statusText
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="250" fixed="right"
><template #default="{ row, $index }"
><div class="table-actions"
><el-button
v-if="can('episode:upload')"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('episode:sort')"
size="small"
:disabled="$index === 0"
@click="move($index, -1)"
>上移</el-button
><el-button
v-if="can('episode:sort')"
size="small"
:disabled="$index === episodes.length - 1"
@click="move($index, 1)"
>下移</el-button
><el-button
v-if="can('episode:upload')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
></div
></template
></el-table-column
>
</el-table>
</section>
<el-dialog v-model="dialogVisible" title="上传剧集" width="560px"
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑剧集' : '新增剧集'" width="560px"
><el-form label-position="top"
><div class="form-row"
><el-form-item label="集号" required
><el-input-number v-model="form.number" :min="1" :max="999" /></el-form-item
><el-form-item label="时长(秒)"
><el-input-number v-model="form.durationSec" :min="0" :max="9999" /></el-form-item></div
><el-form-item label="剧集标题" required
><el-input v-model="form.title" placeholder="例如:命运的相遇" /></el-form-item
><el-form-item label="视频文件" required
><el-upload
drag
action="#"
:auto-upload="false"
accept="video/mp4,video/quicktime"
:on-change="pickVideo"
><div class="upload-copy"
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV</small></div
></el-upload
></el-form-item
><p v-if="form.fileName" class="selected-file">已选择:{{ form.fileName }}</p></el-form
><template #footer
><el-form-item label="视频文件"
><div class="video-field"
><div class="video-field-actions"
><el-upload
drag
:show-file-list="false"
accept="video/*"
:http-request="uploadVideo"
:disabled="uploadingVideo"
><div class="upload-copy"
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV,自动分片上传</small></div
></el-upload
><p v-if="uploadingVideo" class="selected-file">上传中 {{ videoPercent }}%</p
><p v-else-if="form.videoUrl" class="selected-file">视频已上传</p
><el-input v-model="form.videoUrl" placeholder="也可以直接填写视频地址" /></div
><video
v-if="form.videoUrl"
:src="form.videoUrl"
controls
class="form-video-preview"
></video></div></el-form-item
><div class="form-row"
><el-form-item label="是否免费"><el-switch v-model="form.isFree" /></el-form-item
><el-form-item v-if="!form.isFree" label="解锁金币"
><el-input-number v-model="form.unlockCoin" :min="0" :max="9999" /></el-form-item
><el-form-item label="状态"
><el-select v-model="form.status"
><el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code" /></el-select></el-form-item></div
></el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" @click="saveEpisode">加入上传队列</el-button></template
><el-button type="primary" :loading="saving" @click="saveEpisode">保存</el-button></template
></el-dialog
>
<el-dialog
v-model="previewVisible"
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
width="720px"
destroy-on-close
>
<video
v-if="previewEpisode?.videoUrl"
:src="previewEpisode.videoUrl"
controls
autoplay
class="episode-video-player"
></video>
<el-empty v-else description="该剧集还没有上传视频" />
</el-dialog>
</template>