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

@@ -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>