This commit is contained in:
lzcode
2026-10-16 16:05:01 +08:00
parent 24d63e6702
commit 9962136fc8
19 changed files with 1425 additions and 208 deletions
+5
View File
@@ -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)
}
+2
View File
@@ -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: [] },
],
+12
View File
@@ -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',
+168
View File
@@ -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>
+6 -6
View File
@@ -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) {
+5 -5
View File
@@ -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
}
+178
View File
@@ -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
View File
@@ -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>
+30 -20
View File
@@ -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 回调
-6
View File
@@ -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;
}
+1
View File
@@ -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,
+315 -56
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -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,