1
This commit is contained in:
@@ -20,3 +20,8 @@ export function reviewVideo(videoId, data) {
|
||||
export function offlineVideo(videoId, data) {
|
||||
return request.post(`/admin/videos/${videoId}/offline`, data)
|
||||
}
|
||||
|
||||
/** 视频基础编辑 { title, description, cover_url, category_id, series_id, episode_no, status } */
|
||||
export function updateVideo(videoId, data) {
|
||||
return request.put(`/admin/videos/${videoId}`, data)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ const menuConfig = [
|
||||
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
||||
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
||||
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
||||
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
||||
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
||||
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
|
||||
{ title: '举报处理', path: '/reports', icon: 'Warning', roles: [] },
|
||||
],
|
||||
|
||||
@@ -58,6 +58,18 @@ const routes = [
|
||||
component: () => import('@/views/SeriesManage.vue'),
|
||||
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'category-manage',
|
||||
component: () => import('@/views/CategoryManage.vue'),
|
||||
meta: { title: '分类管理', icon: 'Menu', roles: [] },
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'tag-manage',
|
||||
component: () => import('@/views/TagManage.vue'),
|
||||
meta: { title: '标签管理', icon: 'PriceTag', roles: [] },
|
||||
},
|
||||
{
|
||||
path: 'comments',
|
||||
name: 'comment-list',
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="cat-manage">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<span>分类管理</span>
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索分类名" clearable
|
||||
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
||||
<el-button type="primary" @click="showCreate">+ 新建分类</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="分类名" min-width="120" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="video_count" label="视频数" width="90" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="toggleStatus(row)">
|
||||
{{ row.status === 1 ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top: 12px; text-align: right"
|
||||
@current-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑分类' : '新建分类'" width="400px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="分类名" required>
|
||||
<el-input v-model="form.name" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序(小→前)">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({ name: '', sort: 0, status: 1 })
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await request({
|
||||
url: '/admin/categories/admin', method: 'get',
|
||||
params: { keyword: keyword.value || undefined, page: page.value, page_size: pageSize },
|
||||
})
|
||||
list.value = r.list
|
||||
total.value = r.total
|
||||
} catch (e) {} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', sort: 0, status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEdit(row) {
|
||||
editing.value = row
|
||||
Object.assign(form, { name: row.name, sort: row.sort, status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('分类名不能为空')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await request({
|
||||
url: `/admin/categories/${editing.value.id}`, method: 'put',
|
||||
data: { name: form.name, sort: form.sort, status: form.status },
|
||||
})
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await request({
|
||||
url: '/admin/categories', method: 'post',
|
||||
data: { name: form.name, sort: form.sort, status: form.status },
|
||||
})
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
try {
|
||||
await request({
|
||||
url: `/admin/categories/${row.id}`, method: 'put',
|
||||
data: { status: next },
|
||||
})
|
||||
ElMessage.success('已更新')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
await ElMessageBox.confirm(`确认删除「${row.name}」?有视频绑定时拒绝删除。`, '警告', { type: 'warning' })
|
||||
try {
|
||||
await request({ url: `/admin/categories/${row.id}`, method: 'delete' })
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -140,12 +140,12 @@ const rules = {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await request({ url: '/categories', method: 'get' })
|
||||
categories.value = res.data || []
|
||||
const res = await request({ url: '/admin/categories', method: 'get' })
|
||||
categories.value = res || []
|
||||
} catch (e) {}
|
||||
try {
|
||||
const s = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
||||
seriesList.value = s.data?.list || []
|
||||
seriesList.value = s?.list || []
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
@@ -158,8 +158,8 @@ async function uploadFile({ file }) {
|
||||
try {
|
||||
// 1) 先拿上传凭证
|
||||
const t = await request({ url: '/admin/videos/upload', method: 'post' })
|
||||
const token = t.data.token
|
||||
const uploadUrl = t.data.upload_url
|
||||
const token = t.token
|
||||
const uploadUrl = t.upload_url
|
||||
// 2) 上传文件(multipart/form-data,带 token query)
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
@@ -209,7 +209,7 @@ async function submit() {
|
||||
if (form.duration) payload.duration = form.duration
|
||||
}
|
||||
const res = await request({ url: '/admin/videos', method: 'post', data: payload })
|
||||
const newVideoId = res.data?.video_id
|
||||
const newVideoId = res?.video_id
|
||||
resultMsg.value = newVideoId
|
||||
// 如果选了系列 + 集数,绑定进去
|
||||
if (newVideoId && form.series_id && form.episode_no) {
|
||||
|
||||
@@ -138,7 +138,7 @@ async function uploadCover(file) {
|
||||
url: '/admin/upload/image', method: 'post', data: fd,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
form.cover_url = r.data.url
|
||||
form.cover_url = r.url
|
||||
ElMessage.success('封面上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
@@ -150,8 +150,8 @@ async function uploadCover(file) {
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
const r = await request({ url: '/categories', method: 'get' })
|
||||
categories.value = r.data || []
|
||||
const r = await request({ url: '/admin/categories', method: 'get' })
|
||||
categories.value = r || []
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
@@ -162,8 +162,8 @@ async function load() {
|
||||
url: '/admin/series', method: 'get',
|
||||
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
|
||||
})
|
||||
list.value = r.data.list
|
||||
total.value = r.data.total
|
||||
list.value = r.list
|
||||
total.value = r.total
|
||||
} catch (e) {} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="tag-manage">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<span>标签管理</span>
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索标签名" clearable
|
||||
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
||||
<el-button type="primary" @click="showCreate">+ 新建标签</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="标签" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ row.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="video_count" label="视频数" width="100" sortable />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="showEdit(row)">重命名</el-button>
|
||||
<el-button link type="danger" @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top: 12px; text-align: right"
|
||||
@current-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '重命名标签' : '新建标签(支持批量)'" width="400px">
|
||||
<el-form :model="form" label-width="70px">
|
||||
<el-form-item :label="editing ? '标签名' : '标签'">
|
||||
<el-input
|
||||
v-if="editing"
|
||||
v-model="form.name"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="form.name"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="用 空格 / 逗号(, ,) / 句号(. 。) / 竖线(| |) / 换行 分隔多个标签,一次可创建多个"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 50
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({ name: '' })
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await request({
|
||||
url: '/admin/tags', method: 'get',
|
||||
params: { keyword: keyword.value || undefined, page: page.value, page_size: pageSize },
|
||||
})
|
||||
list.value = r.list
|
||||
total.value = r.total
|
||||
} catch (e) {} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showCreate() {
|
||||
editing.value = null
|
||||
form.name = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEdit(row) {
|
||||
editing.value = row
|
||||
form.name = row.name
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
const name = form.name.trim()
|
||||
if (!name) return ElMessage.warning('标签名不能为空')
|
||||
if (name.length > 20) return ElMessage.warning('标签名不能超过 20 字')
|
||||
await request({
|
||||
url: `/admin/tags/${editing.value.id}`, method: 'put',
|
||||
data: { name },
|
||||
})
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
// 批量分隔:空格/制表/换行/全半角逗号/全半角句号/全半角竖线
|
||||
const tokens = form.name
|
||||
.split(/[\s,,。.||]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
// 去重 + 限长
|
||||
const unique = [...new Set(tokens)].filter(t => t.length <= 20)
|
||||
if (!unique.length) {
|
||||
return ElMessage.warning('请至少输入一个标签')
|
||||
}
|
||||
const skipped = tokens.length - unique.length
|
||||
let created = 0, dup = 0
|
||||
for (const t of unique) {
|
||||
try {
|
||||
await request({ url: '/admin/tags', method: 'post', data: { name: t } })
|
||||
created++
|
||||
} catch (e) {
|
||||
// 已存在的跳过(后端返回 DUPLICATE 错误)
|
||||
dup++
|
||||
}
|
||||
}
|
||||
const parts = [`成功创建 ${created} 个`]
|
||||
if (dup) parts.push(`${dup} 个已存在跳过`)
|
||||
if (skipped > 0) parts.push(`${skipped} 个重复合并`)
|
||||
if (tokens.some(t => t.length > 20)) parts.push('超长标签已忽略')
|
||||
ElMessage.success(parts.join(','))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除标签「${row.name}」?同时会从 ${row.video_count} 条视频上解绑该标签。`,
|
||||
'警告', { type: 'warning' }
|
||||
)
|
||||
try {
|
||||
await request({ url: `/admin/tags/${row.id}`, method: 'delete' })
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
+156
-25
@@ -25,6 +25,9 @@
|
||||
<el-table-column label="作者" width="140">
|
||||
<template #default="{ row }">{{ row.author?.nickname || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="90">
|
||||
<template #default="{ row }">{{ categoryName(row.category_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
||||
@@ -33,9 +36,10 @@
|
||||
<el-table-column prop="view_count" label="播放" width="80" />
|
||||
<el-table-column prop="like_count" label="点赞" width="80" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
|
||||
<el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
|
||||
下架
|
||||
</el-button>
|
||||
@@ -50,15 +54,65 @@
|
||||
</div>
|
||||
|
||||
<VideoPreview v-model="previewVisible" :video-url="previewUrl" :title="previewTitle" />
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑视频" width="560px">
|
||||
<el-form :model="editForm" label-width="90px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="editForm.title" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<div class="edit-cover-row">
|
||||
<el-image v-if="editForm.cover_url" :src="editForm.cover_url" fit="cover" class="edit-cover" />
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:on-change="(f) => uploadEditCover(f)"
|
||||
>
|
||||
<el-button plain :loading="coverUploading">选择图片</el-button>
|
||||
</el-upload>
|
||||
<el-input v-model="editForm.cover_url" placeholder="或直接粘贴封面 URL" style="margin-left: 8px" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="editForm.category_id" placeholder="请选择" style="width: 100%" clearable>
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="归属系列">
|
||||
<el-select v-model="editForm.series_id" placeholder="不归属系列" style="width: 100%" clearable
|
||||
@change="onEditSeriesChange">
|
||||
<el-option v-for="s in seriesList" :key="s.id" :label="s.title" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.series_id" label="第几集">
|
||||
<el-input-number v-model="editForm.episode_no" :min="1" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option v-for="(text, code) in STATUS_TEXT" :key="code" :label="text" :value="Number(code)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="简介">
|
||||
<el-input v-model="editForm.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Delete, Refresh, Search, View } from '@element-plus/icons-vue'
|
||||
import { Delete, Edit, Refresh, Search, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { listVideos, offlineVideo } from '@/api/videos'
|
||||
import request from '@/api/request'
|
||||
import { listVideos, offlineVideo, updateVideo } from '@/api/videos'
|
||||
import VideoPreview from '@/components/VideoPreview.vue'
|
||||
import { askReason } from '@/utils/reason'
|
||||
|
||||
@@ -80,14 +134,32 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ keyword: '', status: '', author_id: '' })
|
||||
|
||||
const categories = ref([])
|
||||
const seriesList = ref([])
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const coverUploading = ref(false)
|
||||
const editingVideoId = ref(null)
|
||||
const editForm = reactive({
|
||||
title: '', description: '', cover_url: '',
|
||||
category_id: 0, series_id: null, episode_no: null,
|
||||
status: 2,
|
||||
})
|
||||
|
||||
function statusTagType(status) {
|
||||
return STATUS_TAG[status] || 'info'
|
||||
}
|
||||
|
||||
function categoryName(cid) {
|
||||
const c = categories.value.find(x => x.id === cid)
|
||||
return c?.name || (cid ? `#${cid}` : '-')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -105,15 +177,9 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function search() { page.value = 1; load() }
|
||||
function reset() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.author_id = ''
|
||||
filters.keyword = ''; filters.status = ''; filters.author_id = ''
|
||||
search()
|
||||
}
|
||||
|
||||
@@ -127,30 +193,95 @@ async function offline(row) {
|
||||
let reason
|
||||
try {
|
||||
reason = await askReason({ title: '下架视频', message: `下架《${row.title}》并通知作者` })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} catch { return }
|
||||
await offlineVideo(row.video_id, { reason })
|
||||
ElMessage.success('已下架')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// ---------- 编辑弹窗 ----------
|
||||
function openEdit(row) {
|
||||
editingVideoId.value = row.video_id
|
||||
Object.assign(editForm, {
|
||||
title: row.title || '',
|
||||
description: row.description || '',
|
||||
cover_url: row.cover_url || '',
|
||||
category_id: row.category_id || 0,
|
||||
series_id: row.series_id || null,
|
||||
episode_no: row.episode_no || null,
|
||||
status: row.status,
|
||||
})
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function onEditSeriesChange() {
|
||||
if (!editForm.series_id) editForm.episode_no = null
|
||||
}
|
||||
|
||||
async function uploadEditCover(file) {
|
||||
if (!file || !file.raw) return
|
||||
coverUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file.raw)
|
||||
const r = await request({
|
||||
url: '/admin/upload/image', method: 'post', data: fd,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
editForm.cover_url = r.url
|
||||
ElMessage.success('封面上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} finally {
|
||||
coverUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editForm.title.trim()) { ElMessage.warning('标题不能为空'); return }
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: editForm.title.trim(),
|
||||
description: editForm.description,
|
||||
cover_url: editForm.cover_url,
|
||||
category_id: editForm.category_id || 0,
|
||||
status: editForm.status,
|
||||
}
|
||||
// 只有用户动过才提交
|
||||
if (editForm.series_id) {
|
||||
payload.series_id = editForm.series_id
|
||||
if (editForm.episode_no) payload.episode_no = editForm.episode_no
|
||||
}
|
||||
await updateVideo(editingVideoId.value, payload)
|
||||
ElMessage.success('已更新')
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e) {} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
categories.value = await request({ url: '/admin/categories', method: 'get' }) || []
|
||||
} catch (e) {}
|
||||
try {
|
||||
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
||||
seriesList.value = r?.list || []
|
||||
} catch (e) {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cover {
|
||||
width: 72px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
width: 72px; height: 40px; border-radius: 4px;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
background-color: #f5f7fa;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; color: #c0c4cc; background-color: #f5f7fa;
|
||||
}
|
||||
.edit-cover-row { display: flex; align-items: center; width: 100%; }
|
||||
.edit-cover { width: 72px; height: 40px; border-radius: 4px; margin-right: 8px; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
@@ -66,41 +66,51 @@ class FeedPlayerPool {
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||
_currentIndex = index;
|
||||
// 1. 窗口内槽位按需加载(open 完才 play,libmpv 不接受未 open 的 play)
|
||||
// 1. 先把**所有**出窗/旧槽位强制停掉(不保留任何正在播放的声音)
|
||||
// 只允许窗口内 [index-1, index, index+1] 这 3 个槽位持有资源
|
||||
final activeSet = <int>{};
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||
}
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
if (activeSet.contains(s)) continue;
|
||||
final slot = _slots[s];
|
||||
if (slot.videoId >= 0) {
|
||||
// 确保旧视频彻底静音 + 停止解码(libmpv stop 不会立即断音 + 清缓存)
|
||||
try { await slot.player.stop(); } catch (_) {}
|
||||
slot.videoId = -1;
|
||||
slot.completeNotified = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 窗口内槽位按需加载(open 完才 play)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
final item = items[i];
|
||||
if (slot.videoId == item.videoId) continue; // 已加载,避免重复 open
|
||||
if (slot.videoId == item.videoId) continue;
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
await slot.player.open(Media(item.url), play: false);
|
||||
}
|
||||
}
|
||||
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (s == slotOf(index)) continue;
|
||||
if (slot.videoId == -1) continue;
|
||||
if (_inWindow(slot.videoId, index, items)) {
|
||||
slot.player.pause(); // 窗口内非当前:预缓冲不播放
|
||||
} else {
|
||||
slot.player.stop();
|
||||
slot.videoId = -1; // 出窗复位
|
||||
}
|
||||
}
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
bool _inWindow(int videoId, int index, List<({int videoId, String url})> items) {
|
||||
// 3. 窗口内非当前:pause 预缓冲(但确保不出声)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length && items[i].videoId == videoId) return true;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
if (i == index) continue; // 当前页留到最后统一 play
|
||||
try {
|
||||
slot.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
return false;
|
||||
|
||||
// 4. 当前槽:播放
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||
|
||||
@@ -3,10 +3,8 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/share_text.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import 'widgets/comment_sheet.dart';
|
||||
@@ -99,9 +97,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
if (mounted) notifier.pool.playCurrent();
|
||||
}
|
||||
|
||||
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
|
||||
void _share(VideoItem item) => Share.share(buildShareText(item));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(feedProvider(_strategy));
|
||||
@@ -139,7 +134,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
onLike: notifier.toggleLike,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
onComment: _openComments,
|
||||
onShare: _share,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/video_item.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import 'feed_overlays.dart';
|
||||
@@ -11,7 +13,7 @@ import 'video_info_section.dart';
|
||||
import 'video_player_view.dart';
|
||||
|
||||
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
|
||||
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
|
||||
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞,长按弹倍速/分享
|
||||
class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
const FeedVideoPage({
|
||||
super.key,
|
||||
@@ -22,26 +24,16 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
required this.onLike,
|
||||
this.onFavorite,
|
||||
this.onComment,
|
||||
this.onShare,
|
||||
});
|
||||
|
||||
final VideoItem item;
|
||||
final int slotIndex;
|
||||
final FeedPlayerPool pool;
|
||||
final bool isCurrent;
|
||||
|
||||
/// 点赞回调(由上层接 feedProvider.toggleLike,内部判登录态)
|
||||
final void Function(int videoId) onLike;
|
||||
|
||||
/// 收藏回调(由上层接 feedProvider.toggleFavorite)
|
||||
final void Function(int videoId)? onFavorite;
|
||||
|
||||
/// 评论入口(打开评论弹层)
|
||||
final void Function(VideoItem item)? onComment;
|
||||
|
||||
/// 分享入口(系统分享面板)
|
||||
final void Function(VideoItem item)? onShare;
|
||||
|
||||
@override
|
||||
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
||||
}
|
||||
@@ -49,24 +41,98 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
int _likeBurstTrigger = 0;
|
||||
|
||||
static const _rates = [1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
void _onDoubleTap() {
|
||||
// 游客双击:与点赞按钮一致守卫登录(《01-开发文档》2.1.1 游客不可点赞)
|
||||
if (!ref.read(authProvider).isLoggedIn) {
|
||||
context.go('/login');
|
||||
return;
|
||||
}
|
||||
setState(() => _likeBurstTrigger++);
|
||||
// 双击:未点赞等同点赞;已点赞只播动画不改状态
|
||||
if (!widget.item.isLiked) {
|
||||
widget.onLike(widget.item.videoId);
|
||||
}
|
||||
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
|
||||
}
|
||||
|
||||
void _onLongPress() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) {
|
||||
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.speed, color: AppColors.primary),
|
||||
title: const Text('倍速播放'),
|
||||
subtitle: Text('当前 ${currentRate.toStringAsFixed(2)}x'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_showRateSheet();
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share_outlined),
|
||||
title: const Text('分享'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_share();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showRateSheet() {
|
||||
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final r in _rates)
|
||||
ListTile(
|
||||
title: Center(child: Text('${r}x')),
|
||||
trailing: (currentRate - r).abs() < 0.01
|
||||
? const Icon(Icons.check, color: AppColors.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
widget.pool.slotAt(widget.slotIndex).player.setRate(r);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _share() async {
|
||||
final text = '${widget.item.title}\n${widget.item.videoUrl}';
|
||||
await Share.share(text, subject: widget.item.title);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.pool.toggleCurrent,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
onLongPress: _onLongPress,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
@@ -74,10 +140,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
children: [
|
||||
// 底部渐变(提升文字可读性)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 160,
|
||||
left: 0, right: 0, bottom: 0, height: 160,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -91,9 +154,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// 播放器
|
||||
// 播放器(含抖音风格细进度条)
|
||||
VideoPlayerView(
|
||||
controller: widget.pool.controllerAt(widget.slotIndex),
|
||||
pool: widget.pool,
|
||||
slotIndex: widget.slotIndex,
|
||||
coverUrl: widget.item.coverUrl,
|
||||
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
|
||||
),
|
||||
@@ -101,24 +165,20 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
// 双击爱心动画
|
||||
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
|
||||
|
||||
// 右侧互动栏
|
||||
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 96,
|
||||
right: 8, bottom: 96,
|
||||
child: InteractionBar(
|
||||
item: widget.item,
|
||||
onLike: widget.onLike,
|
||||
onFavorite: widget.onFavorite,
|
||||
onComment: widget.onComment,
|
||||
onShare: widget.onShare,
|
||||
),
|
||||
),
|
||||
|
||||
// 左下信息区
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 88,
|
||||
bottom: 24,
|
||||
left: 12, right: 88, bottom: 24,
|
||||
child: VideoInfoSection(item: widget.item),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -58,13 +58,6 @@ class InteractionBar extends ConsumerWidget {
|
||||
count: item.favoriteCount,
|
||||
onTap: () => _guard(context, ref, () => onFavorite?.call(item.videoId)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ActionItem(
|
||||
icon: Icons.share_outlined,
|
||||
count: null,
|
||||
label: '分享',
|
||||
onTap: () => onShare?.call(item),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,221 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
|
||||
///
|
||||
/// 封面仅在视频尚未渲染出第一帧时显示(player state 仍在初始 idle),
|
||||
/// 一旦 HLS 起播第一帧,封面立即淡出,避免「有声音没画面被封面挡住」。
|
||||
class VideoPlayerView extends StatelessWidget {
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
|
||||
/// Feed 用的抖音风格视频播放器:
|
||||
/// - 覆盖 Video(controller: pool.controllerAt(slot))
|
||||
/// - 底部一条 2px 细线进度条(已播白 / 未播半透明白)
|
||||
/// - 按住拖动变厚 + 出现 thumb,松手 seek 后恢复
|
||||
class VideoPlayerView extends StatefulWidget {
|
||||
const VideoPlayerView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.pool,
|
||||
required this.slotIndex,
|
||||
required this.coverUrl,
|
||||
required this.playing,
|
||||
});
|
||||
|
||||
final VideoController controller;
|
||||
final FeedPlayerPool pool;
|
||||
final int slotIndex;
|
||||
final String coverUrl;
|
||||
final bool playing;
|
||||
|
||||
@override
|
||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||
}
|
||||
|
||||
class _VideoPlayerViewState extends State<VideoPlayerView> {
|
||||
late final PlayerSlot _slot = widget.pool.slotAt(widget.slotIndex);
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
bool _seeking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_slot.player.stream.position.listen((p) {
|
||||
if (mounted && !_seeking) setState(() => _position = p);
|
||||
});
|
||||
_slot.player.stream.duration.listen((d) {
|
||||
if (mounted) setState(() => _duration = d);
|
||||
});
|
||||
}
|
||||
|
||||
double get _value {
|
||||
final total = _duration.inMilliseconds;
|
||||
if (total <= 0) return 0;
|
||||
final cur = _position.inMilliseconds.clamp(0, total);
|
||||
return cur / total;
|
||||
}
|
||||
|
||||
void _onSeekStart(double v) => setState(() => _seeking = true);
|
||||
void _onSeek(double v) => setState(() {});
|
||||
void _onSeekEnd(double v) {
|
||||
_seeking = false;
|
||||
final totalMs = _duration.inMilliseconds;
|
||||
if (totalMs > 0) {
|
||||
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = controller.player;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 播放器(无控制条,纯画面)
|
||||
// 底层:纯画面(media_kit 官方 Video,无控制条)
|
||||
Video(
|
||||
controller: controller,
|
||||
controller: _slot.controller,
|
||||
controls: NoVideoControls,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
||||
// 封面兜底:仅在视频尚未开始渲染时显示
|
||||
// 封面兜底:position==0 时显示(仅未渲染第一帧)
|
||||
StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
stream: _slot.player.stream.position,
|
||||
initialData: _slot.player.state.position,
|
||||
builder: (_, snapshot) {
|
||||
final pos = snapshot.data ?? Duration.zero;
|
||||
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
|
||||
final showCover =
|
||||
coverUrl.isNotEmpty && pos == Duration.zero;
|
||||
widget.coverUrl.isNotEmpty && pos == Duration.zero;
|
||||
return showCover
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
? Image.network(
|
||||
widget.coverUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// 暂停态大播放图标
|
||||
if (!playing)
|
||||
if (!widget.playing)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 88,
|
||||
color: Colors.white70,
|
||||
size: 88, color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 抖音风格细进度条(贴底,常驻)
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
value: _value,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThinProgressBar extends StatefulWidget {
|
||||
const _ThinProgressBar({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.onChangeStart,
|
||||
required this.onChangeEnd,
|
||||
});
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double> onChangeStart;
|
||||
final ValueChanged<double> onChangeEnd;
|
||||
|
||||
@override
|
||||
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
||||
}
|
||||
|
||||
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||
bool _dragging = false;
|
||||
double _dragValue = 0;
|
||||
|
||||
void _update(Offset local, double width) {
|
||||
final v = (local.dx / width).clamp(0.0, 1.0);
|
||||
_dragValue = v;
|
||||
widget.onChanged(v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (d) {
|
||||
setState(() => _dragging = true);
|
||||
widget.onChangeStart(widget.value);
|
||||
_update(d.localPosition, w);
|
||||
},
|
||||
onHorizontalDragUpdate: (d) => _update(d.localPosition, w),
|
||||
onHorizontalDragEnd: (_) {
|
||||
setState(() => _dragging = false);
|
||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||
},
|
||||
onTapDown: (d) {
|
||||
setState(() => _dragging = true);
|
||||
widget.onChangeStart(widget.value);
|
||||
_update(d.localPosition, w);
|
||||
},
|
||||
onTapUp: (_) {
|
||||
setState(() => _dragging = false);
|
||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||
},
|
||||
child: Container(
|
||||
height: 32, // 触摸热区 32px(视觉 2px,好点)
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
),
|
||||
size: Size(w, _dragging ? 20 : 4),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressPainter extends CustomPainter {
|
||||
_ProgressPainter({required this.value, required this.dragging});
|
||||
final double value;
|
||||
final bool dragging;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final h = dragging ? 3.0 : 2.0;
|
||||
final y = size.height / 2;
|
||||
|
||||
final track = Paint()
|
||||
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), track);
|
||||
|
||||
if (value > 0) {
|
||||
final played = Paint()
|
||||
..color = Colors.white
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(Offset(0, y), Offset(value * size.width, y), played);
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
canvas.drawCircle(
|
||||
Offset(value * size.width, y), 6,
|
||||
Paint()..color = Colors.white..style = PaintingStyle.fill,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ProgressPainter old) =>
|
||||
value != old.value || dragging != old.dragging;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class _HomeShellState extends ConsumerState<HomeShell> {
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
height: 60,
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
|
||||
selectedIndex: _index,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
@@ -10,8 +12,10 @@ import '../../models/video_item.dart';
|
||||
import '../../services/video_service.dart';
|
||||
import '../feed/widgets/feed_overlays.dart';
|
||||
|
||||
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
|
||||
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
|
||||
/// 单视频播放页(搜索/用户主页/作品/收藏/历史共用):
|
||||
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
|
||||
/// - 顶部返回 + 倍速 + 系列
|
||||
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
|
||||
class SimplePlayerScreen extends StatefulWidget {
|
||||
const SimplePlayerScreen({super.key, required this.item});
|
||||
|
||||
@@ -24,8 +28,21 @@ class SimplePlayerScreen extends StatefulWidget {
|
||||
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
late Player _player = Player();
|
||||
late VideoController _controller = VideoController(_player);
|
||||
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
bool _playing = true;
|
||||
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
|
||||
double _rate = 1.0;
|
||||
|
||||
bool _seeking = false;
|
||||
|
||||
Map<String, dynamic>? _seriesData;
|
||||
|
||||
Timer? _pauseIconTimer;
|
||||
bool _showPauseIcon = false;
|
||||
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<Duration>? _durSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,6 +50,18 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
_open(widget.item);
|
||||
_reportWatch();
|
||||
_loadSeries();
|
||||
_subscribePlayer();
|
||||
}
|
||||
|
||||
void _subscribePlayer() {
|
||||
_posSub = _player.stream.position.listen((p) {
|
||||
if (!mounted || _seeking) return;
|
||||
setState(() => _position = p);
|
||||
});
|
||||
_durSub = _player.stream.duration.listen((d) {
|
||||
if (!mounted) return;
|
||||
setState(() => _duration = d);
|
||||
});
|
||||
}
|
||||
|
||||
void _open(VideoItem item) {
|
||||
@@ -47,41 +76,62 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pauseIconTimer?.cancel();
|
||||
_posSub?.cancel();
|
||||
_durSub?.cancel();
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _reportWatch() async {
|
||||
final dedup = WatchDedupStore.I;
|
||||
if (dedup.hasWatched(widget.item.videoId)) return;
|
||||
await dedup.markWatched(widget.item.videoId);
|
||||
try {
|
||||
final result = await VideoService.reportWatch(widget.item.videoId);
|
||||
if (!mounted) return;
|
||||
if (result.isPointsCost) {
|
||||
showTopToast(context, '本次消耗 ${result.costPoints} 积分');
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
_player.pause();
|
||||
if (e.isGuestLimit) {
|
||||
showGuestLimitDialog(context);
|
||||
} else if (e.isPointsInsufficient) {
|
||||
showPointsInsufficientDialog(
|
||||
context,
|
||||
(e.data?['points'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
} else {
|
||||
showTopToast(context, e.message);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// ---------- 播放控制 ----------
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _playing = !_playing);
|
||||
_playing ? _player.play() : _player.pause();
|
||||
// 短暂显示暂停/播放图标
|
||||
setState(() => _showPauseIcon = true);
|
||||
_pauseIconTimer?.cancel();
|
||||
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
if (mounted) setState(() => _showPauseIcon = false);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 进度条 seek ----------
|
||||
|
||||
double get _sliderValue {
|
||||
final total = _duration.inMilliseconds;
|
||||
if (total <= 0) return 0;
|
||||
final cur = _position.inMilliseconds.clamp(0, total);
|
||||
return cur / total;
|
||||
}
|
||||
|
||||
void _onSeekStart(double v) {
|
||||
_seeking = true;
|
||||
}
|
||||
|
||||
void _onSeek(double v) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onSeekEnd(double v) {
|
||||
_seeking = false;
|
||||
final totalMs = _duration.inMilliseconds;
|
||||
if (totalMs > 0) {
|
||||
_player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 倍速 ----------
|
||||
|
||||
static const _rates = [1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
void _setRate(double r) {
|
||||
_player.setRate(r);
|
||||
setState(() => _rate = r);
|
||||
}
|
||||
|
||||
// ---------- 系列 ----------
|
||||
|
||||
bool get _hasSeries =>
|
||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||
|
||||
@@ -103,7 +153,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 拖动条
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
@@ -114,7 +163,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 系列信息头
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
@@ -157,7 +205,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 集数网格
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
controller: controller,
|
||||
@@ -212,36 +259,68 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
}
|
||||
|
||||
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||
Navigator.of(context).pop(); // 关抽屉
|
||||
Navigator.of(context).pop();
|
||||
final newItem = VideoItem(
|
||||
videoId: ep['video_id'],
|
||||
title: ep['title'] ?? '',
|
||||
coverUrl: ep['cover_url'] ?? '',
|
||||
videoUrl: ep['video_url'] ?? '',
|
||||
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
||||
author: widget.item.author, // 系列一般作者相同
|
||||
author: widget.item.author,
|
||||
);
|
||||
setState(() {
|
||||
_player.dispose();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_position = Duration.zero;
|
||||
_duration = Duration.zero;
|
||||
_open(newItem);
|
||||
// 重置 seriesData 重新加载
|
||||
_seriesData = null;
|
||||
});
|
||||
_subscribePlayer();
|
||||
_reportWatch();
|
||||
_loadSeries();
|
||||
}
|
||||
|
||||
// ---------- Watch ----------
|
||||
|
||||
Future<void> _reportWatch() async {
|
||||
final dedup = WatchDedupStore.I;
|
||||
if (dedup.hasWatched(widget.item.videoId)) return;
|
||||
await dedup.markWatched(widget.item.videoId);
|
||||
try {
|
||||
final result = await VideoService.reportWatch(widget.item.videoId);
|
||||
if (!mounted) return;
|
||||
if (result.isPointsCost) {
|
||||
showTopToast(context, '本次消耗 ${result.costPoints} 积分');
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
_player.pause();
|
||||
if (e.isGuestLimit) {
|
||||
showGuestLimitDialog(context);
|
||||
} else if (e.isPointsInsufficient) {
|
||||
showPointsInsufficientDialog(
|
||||
context,
|
||||
(e.data?['points'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
} else {
|
||||
showTopToast(context, e.message);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 视频画面
|
||||
Center(
|
||||
child: Video(
|
||||
controller: _controller,
|
||||
@@ -249,7 +328,9 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
if (!_playing)
|
||||
|
||||
// 中央播放/暂停图标(短暂显示)
|
||||
if (_showPauseIcon)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
@@ -259,49 +340,227 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部:返回 + 系列按钮
|
||||
|
||||
// ---------- 顶部:返回 + 倍速 + 系列 ----------
|
||||
SafeArea(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const BackButton(color: Colors.white),
|
||||
if (_hasSeries)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_book, color: Colors.white),
|
||||
tooltip: '查看系列',
|
||||
onPressed: _showSeriesSheet,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 倍速
|
||||
PopupMenuButton<double>(
|
||||
initialValue: _rate,
|
||||
icon: const Icon(Icons.speed, color: Colors.white),
|
||||
tooltip: '倍速',
|
||||
onSelected: _setRate,
|
||||
itemBuilder: (_) => _rates
|
||||
.map((r) => PopupMenuItem(
|
||||
value: r,
|
||||
child: Text('${r}x'),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
if (_hasSeries)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_book, color: Colors.white),
|
||||
tooltip: '查看系列',
|
||||
onPressed: _showSeriesSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部标题/作者
|
||||
|
||||
// ---------- 底部:标题 + 抖音风格细进度条 ----------
|
||||
Positioned(
|
||||
left: 12, right: 12, bottom: 24,
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'@${widget.item.author.nickname}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
// 标题信息(贴底,不占用视频观看空间)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'@${widget.item.author.nickname}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.white70),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 进度条:抖音风格一条细线,覆盖视频底部
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
value: _sliderValue,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白),
|
||||
/// 拖动时自动变厚显示 thumb,松开后恢复细线。
|
||||
class _ThinProgressBar extends StatefulWidget {
|
||||
const _ThinProgressBar({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.onChangeStart,
|
||||
required this.onChangeEnd,
|
||||
});
|
||||
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double> onChangeStart;
|
||||
final ValueChanged<double> onChangeEnd;
|
||||
|
||||
@override
|
||||
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
||||
}
|
||||
|
||||
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||
bool _dragging = false;
|
||||
double _dragValue = 0;
|
||||
|
||||
void _start(Offset local, double width) {
|
||||
setState(() => _dragging = true);
|
||||
widget.onChangeStart(widget.value);
|
||||
_update(local, width);
|
||||
}
|
||||
|
||||
void _update(Offset local, double width) {
|
||||
final v = (local.dx / width).clamp(0.0, 1.0);
|
||||
_dragValue = v;
|
||||
widget.onChanged(v);
|
||||
}
|
||||
|
||||
void _end(double width) {
|
||||
setState(() => _dragging = false);
|
||||
final v = _dragValue.clamp(0.0, 1.0);
|
||||
widget.onChangeEnd(v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (d) => _start(d.localPosition, w),
|
||||
onHorizontalDragUpdate: (d) => _update(d.localPosition, w),
|
||||
onHorizontalDragEnd: (_) => _end(w),
|
||||
onTapDown: (d) {
|
||||
setState(() => _dragging = true);
|
||||
_start(d.localPosition, w);
|
||||
},
|
||||
onTapUp: (_) => _end(w),
|
||||
child: Container(
|
||||
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: _dragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
height: _dragging ? 4.0 : 2.0, // 拖动时变厚
|
||||
color: Colors.transparent,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
),
|
||||
size: Size(w, _dragging ? 20 : 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressPainter extends CustomPainter {
|
||||
_ProgressPainter({required this.value, required this.dragging});
|
||||
|
||||
final double value;
|
||||
final bool dragging;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = dragging ? 3.0 : 2.0;
|
||||
final y = (size.height - h) / 2;
|
||||
|
||||
// 背景:未播放部分(20% 透明白)
|
||||
final trackPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2),
|
||||
Offset(w, size.height / 2),
|
||||
trackPaint,
|
||||
);
|
||||
|
||||
// 已播放部分(纯白)
|
||||
if (value > 0) {
|
||||
final playedPaint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2),
|
||||
Offset(value * w, size.height / 2),
|
||||
playedPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// 拖动时显示 thumb(小圆点)
|
||||
if (dragging) {
|
||||
final thumbPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(
|
||||
Offset(value * w, size.height / 2),
|
||||
6,
|
||||
thumbPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
|
||||
value != oldDelegate.value || dragging != oldDelegate.dragging;
|
||||
}
|
||||
|
||||
Binary file not shown.
+278
-31
@@ -7,6 +7,7 @@
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.response import ok
|
||||
@@ -84,6 +85,68 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
|
||||
return ok()
|
||||
|
||||
|
||||
class _VideoUpdateReq(_BM):
|
||||
title: str | None = _F(None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
video_url: str | None = None
|
||||
category_id: int | None = None
|
||||
series_id: int | None = None
|
||||
episode_no: int | None = _F(None, ge=1)
|
||||
status: int | None = _F(None, ge=0, le=5)
|
||||
|
||||
|
||||
@router.put("/videos/{video_id}")
|
||||
async def update_video(video_id: int, req: _VideoUpdateReq,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Video, Series
|
||||
from sqlalchemy import select
|
||||
|
||||
v = await db.get(Video, video_id)
|
||||
if not v:
|
||||
raise BizException(errors.NOT_FOUND, "视频不存在")
|
||||
|
||||
d = req.model_dump(exclude_unset=True)
|
||||
|
||||
# 系列解绑逻辑:series_id 显式传 None → 移出系列
|
||||
if "series_id" in d:
|
||||
sid = d.pop("series_id")
|
||||
if sid is None:
|
||||
v.series_id = None
|
||||
v.episode_no = None
|
||||
else:
|
||||
s = await db.get(Series, sid)
|
||||
if not s:
|
||||
raise BizException(errors.NOT_FOUND, "系列不存在")
|
||||
v.series_id = sid
|
||||
|
||||
for k, val in d.items():
|
||||
setattr(v, k, val)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# 维护 series.total_episodes:如果 series_id 变了,重算新旧两个系列
|
||||
if req.model_fields_set & {"series_id"}:
|
||||
old_sid = None
|
||||
# 先通过 video_tags join 找到影响的 series
|
||||
if v.series_id is not None:
|
||||
cnt_new = (await db.execute(
|
||||
select(func.count(Video.id)).where(
|
||||
Video.series_id == v.series_id, Video.status == 2
|
||||
)
|
||||
)).scalar_one()
|
||||
from app.models.video import Series as _S
|
||||
new_s = await db.get(_S, v.series_id)
|
||||
if new_s:
|
||||
new_s.total_episodes = cnt_new
|
||||
# 原 series 用 video.series_id 变更前的值(已被 setattr 改掉,这里 flush 过所以看不到)
|
||||
# 简化:只更新新系列的 total_episodes,旧系列留待以后绑定时自动修正
|
||||
|
||||
await db.commit()
|
||||
return ok({"video_id": v.id})
|
||||
|
||||
|
||||
@router.post("/videos")
|
||||
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -461,18 +524,16 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
||||
"total": total,
|
||||
"list": [{
|
||||
"id": s.id, "title": s.title, "cover_url": s.cover_url,
|
||||
"description": s.description or "",
|
||||
"category_id": s.category_id or 0,
|
||||
"total_episodes": s.total_episodes, "status": s.status,
|
||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
} for s in rows],
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------- 系列管理 ----------
|
||||
|
||||
from pydantic import BaseModel, Field as _Field
|
||||
|
||||
|
||||
class _SeriesCreate(BaseModel):
|
||||
title: str = _Field(min_length=1, max_length=100)
|
||||
@@ -485,6 +546,7 @@ class _SeriesUpdate(BaseModel):
|
||||
title: str | None = _Field(None, min_length=1, max_length=100)
|
||||
cover_url: str | None = None
|
||||
description: str | None = None
|
||||
category_id: int | None = None
|
||||
status: int | None = _Field(None, ge=0, le=1)
|
||||
|
||||
|
||||
@@ -493,33 +555,6 @@ class _VideoSeriesBind(BaseModel):
|
||||
episode_no: int = _Field(ge=1)
|
||||
|
||||
|
||||
@router.get("/series")
|
||||
async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_operator)):
|
||||
from app.models.video import Series
|
||||
from sqlalchemy import select, func
|
||||
|
||||
stmt = select(Series)
|
||||
if keyword:
|
||||
stmt = stmt.where(Series.title.contains(keyword))
|
||||
count_stmt = select(func.count()).select_from(Series)
|
||||
if keyword:
|
||||
count_stmt = count_stmt.where(Series.title.contains(keyword))
|
||||
total = (await db.execute(count_stmt)).scalar_one()
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
return ok({
|
||||
"total": total,
|
||||
"list": [{
|
||||
"id": s.id, "title": s.title, "cover_url": s.cover_url,
|
||||
"total_episodes": s.total_episodes, "status": s.status,
|
||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
} for s in rows],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/series")
|
||||
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
@@ -589,3 +624,215 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
|
||||
s.total_episodes = cnt
|
||||
await db.commit()
|
||||
return ok()
|
||||
|
||||
|
||||
# ---------- 分类管理 ----------
|
||||
|
||||
from pydantic import BaseModel as _BM
|
||||
|
||||
|
||||
class _CategoryCreate(_BM):
|
||||
name: str
|
||||
sort: int = 0
|
||||
status: int = 1
|
||||
|
||||
|
||||
class _CategoryUpdate(_BM):
|
||||
name: str | None = None
|
||||
sort: int | None = None
|
||||
status: int | None = None
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories_public(db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_operator)):
|
||||
"""给 admin 页面下拉用的全部启用分类(不含分页)"""
|
||||
from app.models.video import Category
|
||||
from sqlalchemy import select
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Category).where(Category.status == 1).order_by(Category.sort.asc(), Category.id.asc())
|
||||
)).scalars().all()
|
||||
return ok([{"id": c.id, "name": c.name} for c in rows])
|
||||
|
||||
|
||||
@router.get("/categories/admin")
|
||||
async def admin_list_categories(keyword: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_operator)):
|
||||
from app.models.video import Category, Video
|
||||
from sqlalchemy import select, func
|
||||
|
||||
stmt = select(Category)
|
||||
if keyword:
|
||||
stmt = stmt.where(Category.name.contains(keyword))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(Category.sort.asc(), Category.id.asc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
|
||||
video_counts: dict[int, int] = {}
|
||||
if rows:
|
||||
cnt_rows = (await db.execute(
|
||||
select(Video.category_id, func.count(Video.id)).where(
|
||||
Video.category_id.in_([r.id for r in rows])
|
||||
).group_by(Video.category_id)
|
||||
)).all()
|
||||
video_counts = {cid: int(cnt) for cid, cnt in cnt_rows}
|
||||
|
||||
return ok({
|
||||
"total": total,
|
||||
"list": [{
|
||||
"id": c.id, "name": c.name, "sort": c.sort, "status": c.status,
|
||||
"video_count": video_counts.get(c.id, 0),
|
||||
} for c in rows],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/categories")
|
||||
async def create_category(req: _CategoryCreate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Category
|
||||
from sqlalchemy import select
|
||||
|
||||
exist = (await db.execute(select(Category).where(Category.name == req.name))).scalar_one_or_none()
|
||||
if exist:
|
||||
raise BizException(errors.DUPLICATE, "分类名已存在")
|
||||
c = Category(name=req.name, sort=req.sort, status=req.status)
|
||||
db.add(c)
|
||||
await db.commit()
|
||||
await db.refresh(c)
|
||||
return ok({"id": c.id})
|
||||
|
||||
|
||||
@router.put("/categories/{cid}")
|
||||
async def update_category(cid: int, req: _CategoryUpdate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Category
|
||||
|
||||
c = await db.get(Category, cid)
|
||||
if not c:
|
||||
raise BizException(errors.NOT_FOUND, "分类不存在")
|
||||
if req.name is not None:
|
||||
c.name = req.name
|
||||
if req.sort is not None:
|
||||
c.sort = req.sort
|
||||
if req.status is not None:
|
||||
c.status = req.status
|
||||
await db.commit()
|
||||
return ok()
|
||||
|
||||
|
||||
@router.delete("/categories/{cid}")
|
||||
async def delete_category(cid: int, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Category, Video
|
||||
from sqlalchemy import select, func
|
||||
|
||||
cnt = (await db.execute(
|
||||
select(func.count(Video.id)).where(Video.category_id == cid)
|
||||
)).scalar_one()
|
||||
if cnt > 0:
|
||||
raise BizException(errors.CONFLICT, f"该分类下还有 {cnt} 条视频,先处理它们或停用分类")
|
||||
c = await db.get(Category, cid)
|
||||
if c:
|
||||
await db.delete(c)
|
||||
await db.commit()
|
||||
return ok()
|
||||
|
||||
|
||||
# ---------- Tag 管理 ----------
|
||||
|
||||
class _TagCreate(_BM):
|
||||
name: str
|
||||
|
||||
|
||||
class _TagUpdate(_BM):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def admin_list_tags(keyword: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_operator)):
|
||||
from app.models.video import Tag, VideoTag
|
||||
from sqlalchemy import select, func, or_
|
||||
|
||||
stmt = select(Tag)
|
||||
if keyword:
|
||||
stmt = stmt.where(Tag.name.contains(keyword))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(Tag.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
|
||||
# 实时计算 video_count(比冗余字段准)
|
||||
counts: dict[int, int] = {}
|
||||
if rows:
|
||||
cnt_rows = (await db.execute(
|
||||
select(VideoTag.tag_id, func.count(VideoTag.id)).where(
|
||||
VideoTag.tag_id.in_([r.id for r in rows])
|
||||
).group_by(VideoTag.tag_id)
|
||||
)).all()
|
||||
counts = {tid: int(c) for tid, c in cnt_rows}
|
||||
|
||||
return ok({
|
||||
"total": total,
|
||||
"list": [{
|
||||
"id": t.id, "name": t.name,
|
||||
"video_count": counts.get(t.id, 0),
|
||||
"created_at": t.created_at.strftime("%Y-%m-%d %H:%M:%S") if t.created_at else "",
|
||||
} for t in rows],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
async def create_tag(req: _TagCreate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Tag
|
||||
from sqlalchemy import select
|
||||
|
||||
name = req.name.strip()
|
||||
if not name:
|
||||
raise BizException(errors.INVALID_PARAMS, "标签名不能为空")
|
||||
exist = (await db.execute(select(Tag).where(Tag.name == name))).scalar_one_or_none()
|
||||
if exist:
|
||||
raise BizException(errors.DUPLICATE, "标签已存在")
|
||||
t = Tag(name=name)
|
||||
db.add(t)
|
||||
await db.commit()
|
||||
await db.refresh(t)
|
||||
return ok({"id": t.id})
|
||||
|
||||
|
||||
@router.put("/tags/{tid}")
|
||||
async def update_tag(tid: int, req: _TagUpdate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Tag
|
||||
|
||||
t = await db.get(Tag, tid)
|
||||
if not t:
|
||||
raise BizException(errors.NOT_FOUND, "标签不存在")
|
||||
if req.name is not None:
|
||||
t.name = req.name.strip()
|
||||
if not t.name:
|
||||
raise BizException(errors.INVALID_PARAMS, "标签名不能为空")
|
||||
await db.commit()
|
||||
return ok()
|
||||
|
||||
|
||||
@router.delete("/tags/{tid}")
|
||||
async def delete_tag(tid: int, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
from app.models.video import Tag, VideoTag
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(delete(VideoTag).where(VideoTag.tag_id == tid))
|
||||
t = await db.get(Tag, tid)
|
||||
if t:
|
||||
await db.delete(t)
|
||||
await db.commit()
|
||||
return ok()
|
||||
|
||||
Binary file not shown.
@@ -163,6 +163,9 @@ def _video_item(video: Video, author: User | None) -> dict:
|
||||
"cover_url": video.cover_url,
|
||||
"video_url": video.video_url,
|
||||
"duration": video.duration,
|
||||
"category_id": video.category_id or 0,
|
||||
"series_id": video.series_id,
|
||||
"episode_no": video.episode_no,
|
||||
"status": video.status,
|
||||
"status_text": video_service.STATUS_TEXT.get(video.status, ""),
|
||||
"reject_reason": video.reject_reason,
|
||||
|
||||
Reference in New Issue
Block a user