1
This commit is contained in:
@@ -20,3 +20,8 @@ export function reviewVideo(videoId, data) {
|
|||||||
export function offlineVideo(videoId, data) {
|
export function offlineVideo(videoId, data) {
|
||||||
return request.post(`/admin/videos/${videoId}/offline`, 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/create', icon: 'Upload', roles: [] },
|
||||||
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
||||||
{ title: '系列管理', path: '/series', icon: 'CollectionTag', 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: '/comments', icon: 'ChatDotRound', roles: [] },
|
||||||
{ title: '举报处理', path: '/reports', icon: 'Warning', roles: [] },
|
{ title: '举报处理', path: '/reports', icon: 'Warning', roles: [] },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const routes = [
|
|||||||
component: () => import('@/views/SeriesManage.vue'),
|
component: () => import('@/views/SeriesManage.vue'),
|
||||||
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
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',
|
path: 'comments',
|
||||||
name: 'comment-list',
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await request({ url: '/categories', method: 'get' })
|
const res = await request({ url: '/admin/categories', method: 'get' })
|
||||||
categories.value = res.data || []
|
categories.value = res || []
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
const s = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
const s = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
||||||
seriesList.value = s.data?.list || []
|
seriesList.value = s?.list || []
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@ async function uploadFile({ file }) {
|
|||||||
try {
|
try {
|
||||||
// 1) 先拿上传凭证
|
// 1) 先拿上传凭证
|
||||||
const t = await request({ url: '/admin/videos/upload', method: 'post' })
|
const t = await request({ url: '/admin/videos/upload', method: 'post' })
|
||||||
const token = t.data.token
|
const token = t.token
|
||||||
const uploadUrl = t.data.upload_url
|
const uploadUrl = t.upload_url
|
||||||
// 2) 上传文件(multipart/form-data,带 token query)
|
// 2) 上传文件(multipart/form-data,带 token query)
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
@@ -209,7 +209,7 @@ async function submit() {
|
|||||||
if (form.duration) payload.duration = form.duration
|
if (form.duration) payload.duration = form.duration
|
||||||
}
|
}
|
||||||
const res = await request({ url: '/admin/videos', method: 'post', data: payload })
|
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
|
resultMsg.value = newVideoId
|
||||||
// 如果选了系列 + 集数,绑定进去
|
// 如果选了系列 + 集数,绑定进去
|
||||||
if (newVideoId && form.series_id && form.episode_no) {
|
if (newVideoId && form.series_id && form.episode_no) {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ async function uploadCover(file) {
|
|||||||
url: '/admin/upload/image', method: 'post', data: fd,
|
url: '/admin/upload/image', method: 'post', data: fd,
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
})
|
})
|
||||||
form.cover_url = r.data.url
|
form.cover_url = r.url
|
||||||
ElMessage.success('封面上传成功')
|
ElMessage.success('封面上传成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e?.message || '上传失败')
|
ElMessage.error(e?.message || '上传失败')
|
||||||
@@ -150,8 +150,8 @@ async function uploadCover(file) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
load()
|
load()
|
||||||
try {
|
try {
|
||||||
const r = await request({ url: '/categories', method: 'get' })
|
const r = await request({ url: '/admin/categories', method: 'get' })
|
||||||
categories.value = r.data || []
|
categories.value = r || []
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,8 +162,8 @@ async function load() {
|
|||||||
url: '/admin/series', method: 'get',
|
url: '/admin/series', method: 'get',
|
||||||
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
|
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
|
||||||
})
|
})
|
||||||
list.value = r.data.list
|
list.value = r.list
|
||||||
total.value = r.data.total
|
total.value = r.total
|
||||||
} catch (e) {} finally {
|
} catch (e) {} finally {
|
||||||
loading.value = false
|
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">
|
<el-table-column label="作者" width="140">
|
||||||
<template #default="{ row }">{{ row.author?.nickname || '-' }}</template>
|
<template #default="{ row }">{{ row.author?.nickname || '-' }}</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
<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="view_count" label="播放" width="80" />
|
||||||
<el-table-column prop="like_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 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 }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
|
<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 v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
|
||||||
下架
|
下架
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -50,15 +54,65 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VideoPreview v-model="previewVisible" :video-url="previewUrl" :title="previewTitle" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { ElMessage } from 'element-plus'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
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 VideoPreview from '@/components/VideoPreview.vue'
|
||||||
import { askReason } from '@/utils/reason'
|
import { askReason } from '@/utils/reason'
|
||||||
|
|
||||||
@@ -80,14 +134,32 @@ const page = ref(1)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const filters = reactive({ keyword: '', status: '', author_id: '' })
|
const filters = reactive({ keyword: '', status: '', author_id: '' })
|
||||||
|
|
||||||
|
const categories = ref([])
|
||||||
|
const seriesList = ref([])
|
||||||
|
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
const previewTitle = 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) {
|
function statusTagType(status) {
|
||||||
return STATUS_TAG[status] || 'info'
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -105,15 +177,9 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function search() {
|
function search() { page.value = 1; load() }
|
||||||
page.value = 1
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
filters.keyword = ''
|
filters.keyword = ''; filters.status = ''; filters.author_id = ''
|
||||||
filters.status = ''
|
|
||||||
filters.author_id = ''
|
|
||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,30 +193,95 @@ async function offline(row) {
|
|||||||
let reason
|
let reason
|
||||||
try {
|
try {
|
||||||
reason = await askReason({ title: '下架视频', message: `下架《${row.title}》并通知作者` })
|
reason = await askReason({ title: '下架视频', message: `下架《${row.title}》并通知作者` })
|
||||||
} catch {
|
} catch { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
await offlineVideo(row.video_id, { reason })
|
await offlineVideo(row.video_id, { reason })
|
||||||
ElMessage.success('已下架')
|
ElMessage.success('已下架')
|
||||||
load()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cover {
|
.cover {
|
||||||
width: 72px;
|
width: 72px; height: 40px; border-radius: 4px;
|
||||||
height: 40px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover.placeholder {
|
.cover.placeholder {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
font-size: 12px; color: #c0c4cc; background-color: #f5f7fa;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -66,41 +66,51 @@ class FeedPlayerPool {
|
|||||||
|
|
||||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||||
_currentIndex = index;
|
// 1. 先把**所有**出窗/旧槽位强制停掉(不保留任何正在播放的声音)
|
||||||
// 1. 窗口内槽位按需加载(open 完才 play,libmpv 不接受未 open 的 play)
|
// 只允许窗口内 [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++) {
|
for (var offset = -1; offset <= 1; offset++) {
|
||||||
final i = index + offset;
|
final i = index + offset;
|
||||||
if (i < 0 || i >= items.length) continue;
|
if (i < 0 || i >= items.length) continue;
|
||||||
final slot = _slots[slotOf(i)];
|
final slot = _slots[slotOf(i)];
|
||||||
final item = items[i];
|
final item = items[i];
|
||||||
if (slot.videoId == item.videoId) continue; // 已加载,避免重复 open
|
if (slot.videoId == item.videoId) continue;
|
||||||
slot.videoId = item.videoId;
|
slot.videoId = item.videoId;
|
||||||
slot.completeNotified = false;
|
slot.completeNotified = false;
|
||||||
if (item.url.isNotEmpty) {
|
if (item.url.isNotEmpty) {
|
||||||
await slot.player.open(Media(item.url), play: false);
|
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++) {
|
for (var offset = -1; offset <= 1; offset++) {
|
||||||
final i = index + 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 回调
|
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
|
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/utils/share_text.dart';
|
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
import '../../providers/feed_provider.dart';
|
import '../../providers/feed_provider.dart';
|
||||||
import 'widgets/comment_sheet.dart';
|
import 'widgets/comment_sheet.dart';
|
||||||
@@ -99,9 +97,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
if (mounted) notifier.pool.playCurrent();
|
if (mounted) notifier.pool.playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
|
|
||||||
void _share(VideoItem item) => Share.share(buildShareText(item));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(feedProvider(_strategy));
|
final state = ref.watch(feedProvider(_strategy));
|
||||||
@@ -139,7 +134,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
onLike: notifier.toggleLike,
|
onLike: notifier.toggleLike,
|
||||||
onFavorite: notifier.toggleFavorite,
|
onFavorite: notifier.toggleFavorite,
|
||||||
onComment: _openComments,
|
onComment: _openComments,
|
||||||
onShare: _share,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../../../core/player/feed_player_pool.dart';
|
import '../../../core/player/feed_player_pool.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/video_item.dart';
|
import '../../../models/video_item.dart';
|
||||||
import '../../../providers/auth_provider.dart';
|
import '../../../providers/auth_provider.dart';
|
||||||
import 'feed_overlays.dart';
|
import 'feed_overlays.dart';
|
||||||
@@ -11,7 +13,7 @@ import 'video_info_section.dart';
|
|||||||
import 'video_player_view.dart';
|
import 'video_player_view.dart';
|
||||||
|
|
||||||
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
|
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
|
||||||
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
|
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞,长按弹倍速/分享
|
||||||
class FeedVideoPage extends ConsumerStatefulWidget {
|
class FeedVideoPage extends ConsumerStatefulWidget {
|
||||||
const FeedVideoPage({
|
const FeedVideoPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,26 +24,16 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
|||||||
required this.onLike,
|
required this.onLike,
|
||||||
this.onFavorite,
|
this.onFavorite,
|
||||||
this.onComment,
|
this.onComment,
|
||||||
this.onShare,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoItem item;
|
final VideoItem item;
|
||||||
final int slotIndex;
|
final int slotIndex;
|
||||||
final FeedPlayerPool pool;
|
final FeedPlayerPool pool;
|
||||||
final bool isCurrent;
|
final bool isCurrent;
|
||||||
|
|
||||||
/// 点赞回调(由上层接 feedProvider.toggleLike,内部判登录态)
|
|
||||||
final void Function(int videoId) onLike;
|
final void Function(int videoId) onLike;
|
||||||
|
|
||||||
/// 收藏回调(由上层接 feedProvider.toggleFavorite)
|
|
||||||
final void Function(int videoId)? onFavorite;
|
final void Function(int videoId)? onFavorite;
|
||||||
|
|
||||||
/// 评论入口(打开评论弹层)
|
|
||||||
final void Function(VideoItem item)? onComment;
|
final void Function(VideoItem item)? onComment;
|
||||||
|
|
||||||
/// 分享入口(系统分享面板)
|
|
||||||
final void Function(VideoItem item)? onShare;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
||||||
}
|
}
|
||||||
@@ -49,24 +41,98 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
|||||||
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||||
int _likeBurstTrigger = 0;
|
int _likeBurstTrigger = 0;
|
||||||
|
|
||||||
|
static const _rates = [1.0, 1.25, 1.5, 2.0];
|
||||||
|
|
||||||
void _onDoubleTap() {
|
void _onDoubleTap() {
|
||||||
// 游客双击:与点赞按钮一致守卫登录(《01-开发文档》2.1.1 游客不可点赞)
|
|
||||||
if (!ref.read(authProvider).isLoggedIn) {
|
if (!ref.read(authProvider).isLoggedIn) {
|
||||||
context.go('/login');
|
context.go('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _likeBurstTrigger++);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: widget.pool.toggleCurrent,
|
onTap: widget.pool.toggleCurrent,
|
||||||
onDoubleTap: _onDoubleTap,
|
onDoubleTap: _onDoubleTap,
|
||||||
|
onLongPress: _onLongPress,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -74,10 +140,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
children: [
|
children: [
|
||||||
// 底部渐变(提升文字可读性)
|
// 底部渐变(提升文字可读性)
|
||||||
const Positioned(
|
const Positioned(
|
||||||
left: 0,
|
left: 0, right: 0, bottom: 0, height: 160,
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 160,
|
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -91,9 +154,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 播放器
|
// 播放器(含抖音风格细进度条)
|
||||||
VideoPlayerView(
|
VideoPlayerView(
|
||||||
controller: widget.pool.controllerAt(widget.slotIndex),
|
pool: widget.pool,
|
||||||
|
slotIndex: widget.slotIndex,
|
||||||
coverUrl: widget.item.coverUrl,
|
coverUrl: widget.item.coverUrl,
|
||||||
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
|
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
|
||||||
),
|
),
|
||||||
@@ -101,24 +165,20 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
// 双击爱心动画
|
// 双击爱心动画
|
||||||
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
|
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
|
||||||
|
|
||||||
// 右侧互动栏
|
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 8, bottom: 96,
|
||||||
bottom: 96,
|
|
||||||
child: InteractionBar(
|
child: InteractionBar(
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
onLike: widget.onLike,
|
onLike: widget.onLike,
|
||||||
onFavorite: widget.onFavorite,
|
onFavorite: widget.onFavorite,
|
||||||
onComment: widget.onComment,
|
onComment: widget.onComment,
|
||||||
onShare: widget.onShare,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 左下信息区
|
// 左下信息区
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12,
|
left: 12, right: 88, bottom: 24,
|
||||||
right: 88,
|
|
||||||
bottom: 24,
|
|
||||||
child: VideoInfoSection(item: widget.item),
|
child: VideoInfoSection(item: widget.item),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ class InteractionBar extends ConsumerWidget {
|
|||||||
count: item.favoriteCount,
|
count: item.favoriteCount,
|
||||||
onTap: () => _guard(context, ref, () => onFavorite?.call(item.videoId)),
|
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:flutter/material.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
|
||||||
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
|
import '../../../core/player/feed_player_pool.dart';
|
||||||
///
|
|
||||||
/// 封面仅在视频尚未渲染出第一帧时显示(player state 仍在初始 idle),
|
/// Feed 用的抖音风格视频播放器:
|
||||||
/// 一旦 HLS 起播第一帧,封面立即淡出,避免「有声音没画面被封面挡住」。
|
/// - 覆盖 Video(controller: pool.controllerAt(slot))
|
||||||
class VideoPlayerView extends StatelessWidget {
|
/// - 底部一条 2px 细线进度条(已播白 / 未播半透明白)
|
||||||
|
/// - 按住拖动变厚 + 出现 thumb,松手 seek 后恢复
|
||||||
|
class VideoPlayerView extends StatefulWidget {
|
||||||
const VideoPlayerView({
|
const VideoPlayerView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.pool,
|
||||||
|
required this.slotIndex,
|
||||||
required this.coverUrl,
|
required this.coverUrl,
|
||||||
required this.playing,
|
required this.playing,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoController controller;
|
final FeedPlayerPool pool;
|
||||||
|
final int slotIndex;
|
||||||
final String coverUrl;
|
final String coverUrl;
|
||||||
final bool playing;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final player = controller.player;
|
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 播放器(无控制条,纯画面)
|
// 底层:纯画面(media_kit 官方 Video,无控制条)
|
||||||
Video(
|
Video(
|
||||||
controller: controller,
|
controller: _slot.controller,
|
||||||
controls: NoVideoControls,
|
controls: NoVideoControls,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 封面兜底:仅在视频尚未开始渲染时显示
|
// 封面兜底:position==0 时显示(仅未渲染第一帧)
|
||||||
StreamBuilder<Duration>(
|
StreamBuilder<Duration>(
|
||||||
stream: player.stream.position,
|
stream: _slot.player.stream.position,
|
||||||
initialData: player.state.position,
|
initialData: _slot.player.state.position,
|
||||||
builder: (_, snapshot) {
|
builder: (_, snapshot) {
|
||||||
final pos = snapshot.data ?? Duration.zero;
|
final pos = snapshot.data ?? Duration.zero;
|
||||||
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
|
|
||||||
final showCover =
|
final showCover =
|
||||||
coverUrl.isNotEmpty && pos == Duration.zero;
|
widget.coverUrl.isNotEmpty && pos == Duration.zero;
|
||||||
return showCover
|
return showCover
|
||||||
? CachedNetworkImage(
|
? Image.network(
|
||||||
imageUrl: coverUrl,
|
widget.coverUrl, fit: BoxFit.cover,
|
||||||
fit: BoxFit.cover,
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink();
|
: const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// 暂停态大播放图标
|
// 暂停态大播放图标
|
||||||
if (!playing)
|
if (!widget.playing)
|
||||||
const IgnorePointer(
|
const IgnorePointer(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.play_arrow_rounded,
|
Icons.play_arrow_rounded,
|
||||||
size: 88,
|
size: 88, color: Colors.white70,
|
||||||
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(
|
bottomNavigationBar: NavigationBar(
|
||||||
|
height: 60,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
|
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
|
||||||
selectedIndex: _index,
|
selectedIndex: _index,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.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 '../../services/video_service.dart';
|
||||||
import '../feed/widgets/feed_overlays.dart';
|
import '../feed/widgets/feed_overlays.dart';
|
||||||
|
|
||||||
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
|
/// 单视频播放页(搜索/用户主页/作品/收藏/历史共用):
|
||||||
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
|
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
|
||||||
|
/// - 顶部返回 + 倍速 + 系列
|
||||||
|
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
|
||||||
class SimplePlayerScreen extends StatefulWidget {
|
class SimplePlayerScreen extends StatefulWidget {
|
||||||
const SimplePlayerScreen({super.key, required this.item});
|
const SimplePlayerScreen({super.key, required this.item});
|
||||||
|
|
||||||
@@ -24,8 +28,21 @@ class SimplePlayerScreen extends StatefulWidget {
|
|||||||
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||||
late Player _player = Player();
|
late Player _player = Player();
|
||||||
late VideoController _controller = VideoController(_player);
|
late VideoController _controller = VideoController(_player);
|
||||||
|
|
||||||
|
Duration _position = Duration.zero;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
bool _playing = true;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,6 +50,18 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
_open(widget.item);
|
_open(widget.item);
|
||||||
_reportWatch();
|
_reportWatch();
|
||||||
_loadSeries();
|
_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) {
|
void _open(VideoItem item) {
|
||||||
@@ -47,41 +76,62 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pauseIconTimer?.cancel();
|
||||||
|
_posSub?.cancel();
|
||||||
|
_durSub?.cancel();
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
super.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() {
|
void _toggle() {
|
||||||
setState(() => _playing = !_playing);
|
setState(() => _playing = !_playing);
|
||||||
_playing ? _player.play() : _player.pause();
|
_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 =>
|
bool get _hasSeries =>
|
||||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||||
|
|
||||||
@@ -103,7 +153,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 拖动条
|
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(top: 12),
|
margin: const EdgeInsets.only(top: 12),
|
||||||
@@ -114,7 +163,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 系列信息头
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -157,7 +205,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
// 集数网格
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
@@ -212,36 +259,68 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _switchToEpisode(Map<String, dynamic> ep) {
|
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||||
Navigator.of(context).pop(); // 关抽屉
|
Navigator.of(context).pop();
|
||||||
final newItem = VideoItem(
|
final newItem = VideoItem(
|
||||||
videoId: ep['video_id'],
|
videoId: ep['video_id'],
|
||||||
title: ep['title'] ?? '',
|
title: ep['title'] ?? '',
|
||||||
coverUrl: ep['cover_url'] ?? '',
|
coverUrl: ep['cover_url'] ?? '',
|
||||||
videoUrl: ep['video_url'] ?? '',
|
videoUrl: ep['video_url'] ?? '',
|
||||||
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
||||||
author: widget.item.author, // 系列一般作者相同
|
author: widget.item.author,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_controller = VideoController(_player);
|
_controller = VideoController(_player);
|
||||||
|
_position = Duration.zero;
|
||||||
|
_duration = Duration.zero;
|
||||||
_open(newItem);
|
_open(newItem);
|
||||||
// 重置 seriesData 重新加载
|
|
||||||
_seriesData = null;
|
_seriesData = null;
|
||||||
});
|
});
|
||||||
|
_subscribePlayer();
|
||||||
_reportWatch();
|
_reportWatch();
|
||||||
_loadSeries();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
// 视频画面
|
||||||
Center(
|
Center(
|
||||||
child: Video(
|
child: Video(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -249,7 +328,9 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_playing)
|
|
||||||
|
// 中央播放/暂停图标(短暂显示)
|
||||||
|
if (_showPauseIcon)
|
||||||
const IgnorePointer(
|
const IgnorePointer(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -259,12 +340,29 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 顶部:返回 + 系列按钮
|
|
||||||
|
// ---------- 顶部:返回 + 倍速 + 系列 ----------
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const BackButton(color: Colors.white),
|
const BackButton(color: Colors.white),
|
||||||
|
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)
|
if (_hasSeries)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.menu_book, color: Colors.white),
|
icon: const Icon(Icons.menu_book, color: Colors.white),
|
||||||
@@ -273,11 +371,21 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
// 底部标题/作者
|
),
|
||||||
|
|
||||||
|
// ---------- 底部:标题 + 抖音风格细进度条 ----------
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12, right: 12, bottom: 24,
|
left: 0, right: 0, bottom: 0,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 标题信息(贴底,不占用视频观看空间)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -285,19 +393,35 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
Text(
|
Text(
|
||||||
'@${widget.item.author.nickname}',
|
'@${widget.item.author.nickname}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15, fontWeight: FontWeight.w600,
|
fontSize: 13,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
Text(
|
||||||
widget.item.title,
|
widget.item.title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14, color: Colors.white70),
|
fontSize: 12, color: Colors.white70),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 进度条:抖音风格一条细线,覆盖视频底部
|
||||||
|
Positioned(
|
||||||
|
left: 0, right: 0, bottom: 0,
|
||||||
|
child: _ThinProgressBar(
|
||||||
|
value: _sliderValue,
|
||||||
|
onChanged: _onSeek,
|
||||||
|
onChangeStart: _onSeekStart,
|
||||||
|
onChangeEnd: _onSeekEnd,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -305,3 +429,138 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 抖音风格进度条:默认一条 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
|
import redis.asyncio as aioredis
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.response import ok
|
from app.core.response import ok
|
||||||
@@ -84,6 +85,68 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
|
|||||||
return ok()
|
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")
|
@router.post("/videos")
|
||||||
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
|
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -461,18 +524,16 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
|||||||
"total": total,
|
"total": total,
|
||||||
"list": [{
|
"list": [{
|
||||||
"id": s.id, "title": s.title, "cover_url": s.cover_url,
|
"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,
|
"total_episodes": s.total_episodes, "status": s.status,
|
||||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
} for s in rows],
|
} for s in rows],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 系列管理 ----------
|
# ---------- 系列管理 ----------
|
||||||
|
|
||||||
from pydantic import BaseModel, Field as _Field
|
|
||||||
|
|
||||||
|
|
||||||
class _SeriesCreate(BaseModel):
|
class _SeriesCreate(BaseModel):
|
||||||
title: str = _Field(min_length=1, max_length=100)
|
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)
|
title: str | None = _Field(None, min_length=1, max_length=100)
|
||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
status: int | None = _Field(None, ge=0, le=1)
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
@@ -493,33 +555,6 @@ class _VideoSeriesBind(BaseModel):
|
|||||||
episode_no: int = _Field(ge=1)
|
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")
|
@router.post("/series")
|
||||||
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
@@ -589,3 +624,215 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
|
|||||||
s.total_episodes = cnt
|
s.total_episodes = cnt
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return ok()
|
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,
|
"cover_url": video.cover_url,
|
||||||
"video_url": video.video_url,
|
"video_url": video.video_url,
|
||||||
"duration": video.duration,
|
"duration": video.duration,
|
||||||
|
"category_id": video.category_id or 0,
|
||||||
|
"series_id": video.series_id,
|
||||||
|
"episode_no": video.episode_no,
|
||||||
"status": video.status,
|
"status": video.status,
|
||||||
"status_text": video_service.STATUS_TEXT.get(video.status, ""),
|
"status_text": video_service.STATUS_TEXT.get(video.status, ""),
|
||||||
"reject_reason": video.reject_reason,
|
"reject_reason": video.reject_reason,
|
||||||
|
|||||||
Reference in New Issue
Block a user