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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user