This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+33
View File
@@ -0,0 +1,33 @@
# Flutter
app/pubspec.lock
app/build/
app/.dart_tool/
app/.idea/
app/*.iml
app/local.properties
# Server
server/.venv/
server/__pycache__/
server/**/__pycache__/
server/*.pyc
server/.env.local
# Admin (Vue3)
admin/node_modules/
admin/dist/
# 运行时产物(nginx 分发的 APK/HLS/uploads)
runtime/
server/uploads/
h5/downloads/
h5/videos/
# IDE
.idea/
*.iml
.vscode/
.DS_Store
# Trae
.trae/
Binary file not shown.
+4 -1
View File
@@ -32,7 +32,8 @@
<div class="header-right">
<el-tag size="small" type="info">{{ auth.roleText }}</el-tag>
<span class="admin-name">{{ auth.adminName }}</span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
<el-button link type="primary" @click="router.push('/account/password')">修改密码</el-button>
<el-button link type="danger" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
@@ -67,7 +68,9 @@ const menuConfig = [
icon: 'VideoCamera',
children: [
{ title: '视频审核', path: '/videos/pending', icon: 'VideoPlay', roles: ['reviewer'] },
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
{ title: '举报处理', path: '/reports', icon: 'Warning', roles: [] },
],
+18
View File
@@ -40,12 +40,24 @@ const routes = [
component: () => import('@/views/VideoPending.vue'),
meta: { title: '视频审核', icon: 'VideoPlay', roles: ['reviewer'] },
},
{
path: 'videos/create',
name: 'video-create',
component: () => import('@/views/CreateVideo.vue'),
meta: { title: '发布视频', icon: 'Upload', roles: [] },
},
{
path: 'videos',
name: 'video-list',
component: () => import('@/views/VideoList.vue'),
meta: { title: '视频管理', icon: 'Film', roles: [] },
},
{
path: 'series',
name: 'series-manage',
component: () => import('@/views/SeriesManage.vue'),
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
},
{
path: 'comments',
name: 'comment-list',
@@ -124,6 +136,12 @@ const routes = [
component: () => import('@/views/LogList.vue'),
meta: { title: '操作日志', icon: 'Document', roles: [] },
},
{
path: 'account/password',
name: 'change-password',
component: () => import('@/views/ChangePassword.vue'),
meta: { title: '修改密码', icon: 'Lock', roles: ['operator'], hidden: true },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="change-password">
<el-card shadow="never">
<template #header>
<span>修改管理员密码</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
style="max-width: 520px"
>
<el-form-item label="原密码" prop="old_password">
<el-input v-model="form.old_password" type="password" show-password placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input v-model="form.new_password" type="password" show-password placeholder="至少 6 位" />
</el-form-item>
<el-form-item label="确认新密码" prop="confirm">
<el-input v-model="form.confirm" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">确认修改</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
const formRef = ref()
const submitting = ref(false)
const form = reactive({
old_password: '',
new_password: '',
confirm: '',
})
const rules = {
old_password: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '至少 6 位', trigger: 'blur' },
],
confirm: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_, v, cb) => (v === form.new_password ? cb() : cb(new Error('两次输入不一致'))),
trigger: 'blur',
},
],
}
async function submit() {
await formRef.value.validate()
submitting.value = true
try {
await request({
url: '/admin/auth/password',
method: 'put',
data: { old_password: form.old_password, new_password: form.new_password },
})
ElMessage.success('密码修改成功')
formRef.value.resetFields()
} catch (e) {
// request.js 里已经 toast 了
} finally {
submitting.value = false
}
}
</script>
+248
View File
@@ -0,0 +1,248 @@
<template>
<div class="create-video">
<el-card shadow="never">
<template #header>
<span>发布视频</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- 标题 / 分类 / 简介 -->
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="100" show-word-limit placeholder="视频标题" />
</el-form-item>
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类" style="width: 240px">
<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-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="视频描述(可选)" />
</el-form-item>
<!-- 两种模式切换 -->
<el-form-item label="视频来源">
<el-radio-group v-model="mode">
<el-radio-button label="upload">上传文件(自动转码 HLS)</el-radio-button>
<el-radio-button label="url">填 m3u8 地址</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 模式 A:上传 -->
<template v-if="mode === 'upload'">
<el-form-item label="视频文件" required>
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="uploadFile"
accept=".mp4,.mov,.mkv,.avi"
:disabled="!!form.upload_token"
>
<el-button type="primary" :loading="uploading">
{{ uploading ? '上传中…' : '选择视频上传' }}
</el-button>
<span v-if="form.upload_token" class="ml8 ok">✓ 已上传:{{ uploadedName }}</span>
<span v-else class="ml8 hint">支持 mp4/mov/mkv/avi,最大由服务器 nginx 限制</span>
</el-upload>
</el-form-item>
</template>
<!-- 模式 B:URL -->
<template v-else>
<el-form-item label="m3u8 地址" prop="video_url">
<el-input v-model="form.video_url" placeholder="https://xxx.com/videos/xxx/index.m3u8" />
</el-form-item>
<el-form-item label="封面图 URL" prop="cover_url">
<el-input v-model="form.cover_url" placeholder="https://xxx.com/cover.jpg" />
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number v-model="form.duration" :min="1" :max="36000" :step="10" placeholder="可选" />
</el-form-item>
</template>
<el-form-item label="发布状态">
<el-radio-group v-model="form.status">
<el-radio :label="1">待审核</el-radio>
<el-radio :label="2">直接发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="归属系列">
<el-select v-model="form.series_id" clearable placeholder="不归属系列"
style="width: 240px" @change="onSeriesChange">
<el-option v-for="s in seriesList" :key="s.id" :label="s.title" :value="s.id" />
</el-select>
<el-input-number v-if="form.series_id" v-model="form.episode_no"
:min="1" :step="1" placeholder="第几集" style="margin-left: 8px; width: 120px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">提交</el-button>
<el-button @click="reset">重置</el-button>
<span v-if="resultMsg" class="ml8 ok">提交成功,video_id={{ resultMsg }}</span>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
const formRef = ref()
const uploading = ref(false)
const submitting = ref(false)
const mode = ref('upload') // 'upload' | 'url'
const uploadedName = ref('')
const resultMsg = ref('')
const categories = ref([])
const seriesList = ref([])
const form = reactive({
title: '',
description: '',
category_id: null,
upload_token: '',
video_url: '',
cover_url: '',
duration: null,
status: 1,
series_id: null,
episode_no: null,
})
const rules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
category_id: [{ required: true, message: '请选择分类', trigger: 'change' }],
video_url: [
{
validator: (_, v, cb) => {
if (mode.value !== 'url') return cb()
if (!v) return cb(new Error('请填 m3u8 地址'))
if (!v.startsWith('http')) return cb(new Error('必须 http(s) 开头'))
cb()
},
trigger: 'blur',
},
],
cover_url: [
{
validator: (_, v, cb) => {
if (mode.value !== 'url') return cb()
if (!v) return cb(new Error('URL 模式必须填封面'))
cb()
},
trigger: 'blur',
},
],
}
onMounted(async () => {
try {
const res = await request({ url: '/categories', method: 'get' })
categories.value = res.data || []
} catch (e) {}
try {
const s = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
seriesList.value = s.data?.list || []
} catch (e) {}
})
function onSeriesChange() {
if (!form.series_id) form.episode_no = null
}
async function uploadFile({ file }) {
uploading.value = true
try {
// 1) 先拿上传凭证
const t = await request({ url: '/admin/videos/upload', method: 'post' })
const token = t.data.token
const uploadUrl = t.data.upload_url
// 2) 上传文件(multipart/form-data,带 token query)
const fd = new FormData()
fd.append('file', file)
const res = await fetch(uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'token=' + token, {
method: 'POST',
headers: { Authorization: 'Bearer ' + localStorage.getItem('admin_token') },
body: fd,
})
const json = await res.json()
if (json.code === 0) {
form.upload_token = json.data.upload_token
uploadedName.value = file.name + '(' + (json.data.file_size / 1024 / 1024).toFixed(1) + 'MB)'
ElMessage.success('上传成功')
} else {
ElMessage.error(json.message || '上传失败')
}
} catch (e) {
ElMessage.error('上传异常:' + (e.message || e))
} finally {
uploading.value = false
}
}
async function submit() {
await formRef.value.validate()
if (mode.value === 'upload' && !form.upload_token) {
ElMessage.warning('请先上传视频文件')
return
}
if (mode.value === 'url' && !form.video_url) {
ElMessage.warning('请填 m3u8 地址')
return
}
submitting.value = true
try {
const payload = {
title: form.title,
description: form.description,
category_id: form.category_id,
status: form.status,
}
if (mode.value === 'upload') {
payload.upload_token = form.upload_token
} else {
payload.video_url = form.video_url
payload.cover_url = form.cover_url
if (form.duration) payload.duration = form.duration
}
const res = await request({ url: '/admin/videos', method: 'post', data: payload })
const newVideoId = res.data?.video_id
resultMsg.value = newVideoId
// 如果选了系列 + 集数,绑定进去
if (newVideoId && form.series_id && form.episode_no) {
try {
await request({
url: `/admin/videos/${newVideoId}/series`, method: 'post',
data: { series_id: form.series_id, episode_no: form.episode_no },
})
} catch (e) {}
}
ElMessage.success('提交成功')
reset()
} catch (e) {} finally {
submitting.value = false
}
}
function reset() {
formRef.value.resetFields()
form.upload_token = ''
form.video_url = ''
form.cover_url = ''
form.duration = null
form.status = 1
form.series_id = null
form.episode_no = null
uploadedName.value = ''
resultMsg.value = ''
}
</script>
<style scoped>
.ml8 { margin-left: 8px; }
.hint { color: #909399; font-size: 12px; }
.ok { color: #67c23a; }
</style>
+184
View File
@@ -0,0 +1,184 @@
<template>
<div class="series-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="title" label="系列标题" min-width="160">
<template #default="{ row }">
<div class="title-col">
<img v-if="row.cover_url" :src="row.cover_url" class="cover" />
<span>{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="total_episodes" label="集数" width="90" />
<el-table-column label="状态" width="80">
<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="created_at" label="创建时间" width="170" />
<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="480px">
<el-form :model="form" label-width="90px">
<el-form-item label="标题">
<el-input v-model="form.title" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="封面图 URL">
<el-input v-model="form.cover_url" placeholder="https://..." />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category_id" placeholder="请选择" style="width: 100%">
<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-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
</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 categories = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const form = reactive({
title: '', cover_url: '', description: '', category_id: 0,
})
onMounted(async () => {
load()
try {
const r = await request({ url: '/categories', method: 'get' })
categories.value = r.data || []
} catch (e) {}
})
async function load() {
loading.value = true
try {
const r = await request({
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
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
Object.assign(form, { title: '', cover_url: '', description: '', category_id: 0 })
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, {
title: row.title, cover_url: row.cover_url,
description: row.description, category_id: row.category_id ?? 0,
})
dialogVisible.value = true
}
async function save() {
if (!form.title) { ElMessage.warning('请填标题'); return }
submitting.value = true
try {
if (editing.value) {
await request({
url: `/admin/series/${editing.value.id}`, method: 'put', data: form,
})
ElMessage.success('已更新')
} else {
await request({ url: '/admin/series', method: 'post', data: form })
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/series/${row.id}`, method: 'put',
data: { status: next },
})
ElMessage.success('已更新')
load()
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确认删除「${row.title}」?`, '警告', { type: 'warning' })
try {
await request({ url: `/admin/series/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
.title-col { display: flex; align-items: center; gap: 8px; }
.cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
</style>
+3 -2
View File
@@ -1,8 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="video_app"
android:label="喵舍"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="linear"
android:angle="270"
android:startColor="#3A3F4D"
android:endColor="#14161B" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.workers.max=2
android.useAndroidX=true
android.enableJetifier=true
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.12-all.zip
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3A3F4D"/>
<stop offset="1" stop-color="#14161B"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<!-- 猫脸(白)+ 双耳 -->
<g fill="#FFFFFF">
<circle cx="512" cy="575" r="270"/>
<polygon points="352,205 300,430 470,372"/>
<polygon points="672,205 724,430 554,372"/>
</g>
<!-- 播放键(挖空) -->
<polygon points="450,470 450,680 640,575" fill="#15171C"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108">
<!-- Android 自适应图标前景:透明底,图案居于中心安全区 -->
<g transform="translate(54,54) scale(0.097) translate(-512,-525)">
<g fill="#FFFFFF">
<circle cx="512" cy="575" r="270"/>
<polygon points="352,205 300,430 470,372"/>
<polygon points="672,205 724,430 554,372"/>
</g>
<polygon points="450,470 450,680 640,575" fill="#15171C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+1 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Video App</string>
<string>喵舍</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+2 -2
View File
@@ -3,8 +3,8 @@ class AppConfig {
AppConfig._();
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
static const String appVersion = '1.0.0';
static const int appVersionCode = 1;
static const String appVersion = '1.1.0';
static const int appVersionCode = 2;
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
static const String platformNameIOS = 'ios';
+137 -29
View File
@@ -43,28 +43,53 @@ class ApiClient {
}
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理;
/// 这里兜底把 DioException 转成 ApiException(否则 UI 只能收到未知类型异常,
/// 2002 游客超限 / 2001 积分不足等业务弹窗全部失效)
static Future<T> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
return resp.data as T;
try {
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> post<T>(String path,
{Object? data, ProgressCallback? onSendProgress}) async {
final resp = await _dio.post<dynamic>(path,
data: data, onSendProgress: onSendProgress);
return resp.data as T;
try {
final resp = await _dio.post<dynamic>(path,
data: data, onSendProgress: onSendProgress);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> put<T>(String path, {Object? data}) async {
final resp = await _dio.put<dynamic>(path, data: data);
return resp.data as T;
try {
final resp = await _dio.put<dynamic>(path, data: data);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> delete<T>(String path, {Object? data}) async {
final resp = await _dio.delete<dynamic>(path, data: data);
return resp.data as T;
try {
final resp = await _dio.delete<dynamic>(path, data: data);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// DioException → ApiException:保留拦截器已包装的业务错误(含 data)
static ApiException _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) return err;
return ApiException(-1, '网络异常,请稍后重试');
}
}
@@ -86,6 +111,37 @@ class _CommonHeadersInterceptor extends Interceptor {
}
}
/// 递归把 data 中所有 `*_url` / `*_Url` 结尾、且以 `/` 开头(非 `//`)的相对路径
/// 拼上 [ApiDomain.current]。只补一次,幂等。
Object? _resolveRelativeUrls(Object? node, String baseUrl) {
if (node is Map<String, dynamic>) {
for (final entry in node.entries) {
final v = entry.value;
if (v is String &&
v.isNotEmpty &&
v.startsWith('/') &&
!v.startsWith('//') &&
(entry.key.endsWith('_url') ||
entry.key.endsWith('_Url') ||
entry.key.endsWith('_URL') ||
entry.key == 'url' ||
entry.key == 'src')) {
node[entry.key] = baseUrl + v;
} else {
node[entry.key] = _resolveRelativeUrls(v, baseUrl);
}
}
return node;
}
if (node is List) {
for (var i = 0; i < node.length; i++) {
node[i] = _resolveRelativeUrls(node[i], baseUrl);
}
return node;
}
return node;
}
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
class _UnwrapInterceptor extends Interceptor {
@override
@@ -95,7 +151,7 @@ class _UnwrapInterceptor extends Interceptor {
if (body is Map && body.containsKey('code')) {
final code = body['code'] as int;
if (code == 0) {
response.data = body['data'];
response.data = _resolveRelativeUrls(body['data'], ApiDomain.current);
return handler.next(response);
}
return handler.reject(DioException(
@@ -110,12 +166,27 @@ class _UnwrapInterceptor extends Interceptor {
),
));
}
// 非统一包裹响应(如 FastAPI 直接返回的 {"detail": ...} 4xx):
// 不能当作成功把 detail 透传给业务层,统一转 ApiException
final status = response.statusCode ?? 200;
if (status >= 400) {
final detail = body is Map ? (body['detail']?.toString() ?? '') : '';
return handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
error: ApiException(-1, detail.isNotEmpty ? detail : '请求失败(HTTP $status)'),
));
}
handler.next(response);
}
}
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,确认会话失效才登出
class _ErrorInterceptor extends Interceptor {
/// 刷新单飞锁:并发 401 只触发一次刷新,避免 refresh token 轮换互踩
/// (多请求同时携带旧 refresh 请求,后到者必被服务端拒绝 → 误登出)
static Future<String?>? _refreshing;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
// 网络层失败(域名不可达等):计数并按需重新发现域名
@@ -132,7 +203,16 @@ class _ErrorInterceptor extends Interceptor {
if (status == 401 &&
err.requestOptions.extra['retried'] != true &&
TokenStore.refreshToken != null) {
final newToken = await _refreshToken();
String? newToken;
try {
newToken = await _refreshToken();
} on ApiException catch (refreshErr) {
// 刷新请求本身网络失败:不清登录态,透传错误
return handler.next(DioException(
requestOptions: err.requestOptions,
error: refreshErr,
));
}
if (newToken != null) {
try {
final opts = err.requestOptions;
@@ -140,8 +220,27 @@ class _ErrorInterceptor extends Interceptor {
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} on DioException catch (retryErr) {
final retryApi = retryErr.error;
final retryStatus = retryErr.response?.statusCode;
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
final stillAuthFailed = retryStatus == 401 ||
(retryApi is ApiException && retryApi.isUnauthorized);
if (!stillAuthFailed) {
return handler.next(DioException(
requestOptions: err.requestOptions,
error: retryApi is ApiException
? retryApi
: ApiException(-1, '网络异常,请稍后重试'),
));
}
} catch (_) {
// 重试仍失败 → 走下方登出
// 非 Dio 失败:视为网络异常,不清登录态
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(-1, '网络异常,请稍后重试'),
));
}
}
await TokenStore.clear();
@@ -150,37 +249,46 @@ class _ErrorInterceptor extends Interceptor {
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
final code = apiError is ApiException ? apiError.code : -1;
final data = apiError is ApiException ? apiError.data : null;
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(code, message),
error: ApiException(code, message, data: data),
));
}
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
Future<String?> _refreshToken() async {
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归。
/// - 服务端明确拒绝(业务失败)→ 返回 null,调用方登出
/// - 网络层失败 → 抛异常,调用方透传错误、不清登录态
Future<String?> _refreshToken() {
// 单飞:进行中的刷新直接共享其结果
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
final refresh = TokenStore.refreshToken;
if (refresh == null) return null;
Response<Map<String, dynamic>> resp;
try {
final bare = Dio(BaseOptions(
baseUrl: ApiDomain.current,
validateStatus: (s) => s != null && s < 500,
));
final resp = await bare.post<Map<String, dynamic>>(
resp = await bare.post<Map<String, dynamic>>(
Endpoints.tokenRefresh,
data: {'refresh_token': refresh},
);
final body = resp.data;
if (body != null && body['code'] == 0) {
final data = body['data'] as Map<String, dynamic>;
await TokenStore.save(
access: data['access_token'] as String,
refresh: data['refresh_token'] as String,
);
return TokenStore.accessToken;
}
} catch (_) {
// 刷新失败视为会话过期
throw ApiException(-1, '网络异常,请稍后重试');
}
return null;
final body = resp.data;
if (body != null && body['code'] == 0) {
final data = body['data'] as Map<String, dynamic>;
await TokenStore.save(
access: data['access_token'] as String,
refresh: data['refresh_token'] as String,
);
return TokenStore.accessToken;
}
return null; // 服务端明确拒绝刷新 → 会话确实过期
}
}
+2
View File
@@ -81,6 +81,8 @@ class ApiDomain {
final resp = await _doh.get<dynamic>(
server,
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
// 腾讯 DoH (119.29.29.29/dns-query) 必须显式声明 JSON 才返回 JSON
options: Options(headers: {'Accept': 'application/dns-json'}),
);
final body = resp.data;
final answers = body is Map ? body['Answer'] as List? : null;
+2
View File
@@ -35,6 +35,8 @@ class Endpoints {
static String videoDetail(int videoId) => '/videos/$videoId';
static String videoWatch(int videoId) => '/videos/$videoId/watch';
static String videoWatchComplete(int videoId) => '/videos/$videoId/watch/complete';
static String videoSeries(int videoId) => '/videos/$videoId/series';
static String seriesDetail(int seriesId) => '/videos/series/$seriesId';
static String videoLike(int videoId) => '/videos/$videoId/like';
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
static String videoComments(int videoId) => '/videos/$videoId/comments';
+15 -7
View File
@@ -65,9 +65,9 @@ class FeedPlayerPool {
bool get isCurrentPlaying => currentSlot.player.state.playing;
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
void bindWindow(int index, List<({int videoId, String url})> items) {
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
_currentIndex = index;
// 1. 窗口内槽位按需加载
// 1. 窗口内槽位按需加载(open 完才 play,libmpv 不接受未 open 的 play)
for (var offset = -1; offset <= 1; offset++) {
final i = index + offset;
if (i < 0 || i >= items.length) continue;
@@ -77,7 +77,7 @@ class FeedPlayerPool {
slot.videoId = item.videoId;
slot.completeNotified = false;
if (item.url.isNotEmpty) {
slot.player.open(Media(item.url), play: false);
await slot.player.open(Media(item.url), play: false);
}
}
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
@@ -120,10 +120,18 @@ class FeedPlayerPool {
}));
_subs.add(slot.player.stream.completed.listen((done) {
if (!done) return;
if (slot.videoId < 0 || slot.completeNotified) return;
if (slot != currentSlot) return;
slot.completeNotified = true;
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
// 每视频每次绑定只触发一次 complete 上报(≥95% 或播完都算)
if (slot.videoId >= 0 && !slot.completeNotified) {
slot.completeNotified = true;
if (slot == currentSlot) {
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
}
}
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
if (slot == currentSlot) {
slot.player.seek(Duration.zero);
slot.player.play();
}
}));
}
+7 -1
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'core/storage/local_store.dart';
import 'core/storage/search_history_store.dart';
import 'core/storage/watch_dedup_store.dart';
import 'core/theme/app_theme.dart';
import 'router.dart';
/// App 入口:初始化本地存储 → MaterialApp.router
/// App 入口:本地存储 + MediaKit + MaterialApp.router
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次
MediaKit.ensureInitialized();
// 本地存储初始化(设备指纹在 splash 阶段生成)
await LocalStore.init();
// 搜索历史(本地,不上报服务端)
await SearchHistoryStore.init();
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
await WatchDedupStore.init();
runApp(const ProviderScope(child: VideoApp()));
}
+1 -2
View File
@@ -44,13 +44,12 @@ class HistoryItem {
final VideoItem video;
final String watchedAt;
/// 计费类型:0 免费额度 / 1 积分 / 2 会员 / 3 重复或本人
/// 计费类型:0 免费额度 / 1 积分 / 2 会员 / 3 重复观看或本人作品(不计费)
final int costType;
String get costLabel => switch (costType) {
1 => '积分',
2 => '会员',
3 => '免费',
_ => '免费',
};
+29 -6
View File
@@ -1,8 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_client.dart';
import '../core/network/api_exception.dart';
import '../core/storage/token_store.dart';
import '../models/user_model.dart';
import '../services/auth_service.dart';
import '../services/user_service.dart';
/// 登录态:未登录 / 已登录(含用户信息)
@@ -37,25 +39,46 @@ class AuthNotifier extends StateNotifier<AuthState> {
try {
final user = await UserService.fetchMe();
state = AuthState(user: user, restored: true);
} on ApiException catch (e) {
// 仅 Token 确认失效(1002)才清理回游客态;
// 网络异常等情况保留 Token,下次启动/请求重试恢复
if (e.isUnauthorized) await TokenStore.clear();
state = AuthState(restored: true);
} catch (_) {
// Token 失效(1002 等):本地清理,回游客态
await TokenStore.clear();
// 未知异常:保守保留 Token
state = AuthState(restored: true);
}
}
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处仅刷新用户
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处刷新用户。
/// 登录响应里的 user 是简要信息(无 points/bio/gender),
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
Future<void> onLoginSuccess(UserModel user) async {
state = AuthState(user: user, restored: true);
try {
final full = await UserService.fetchMe();
state = state.copyWith(user: full);
} catch (_) {
// 补拉失败不影响登录态,保留简要信息
}
}
/// 修改资料/头像后同步当前用户(不改变登录态与 Token)
void updateUser(UserModel user) => state = state.copyWith(user: user);
/// 退出登录:服务端注销 + 本地清理
Future<void> logout() async => _onSessionExpired();
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
Future<void> logout() async {
try {
await AuthService.logout();
} catch (_) {
// 服务端已不可达或 Token 已失效:本地清理即可
}
await _clearLocal();
}
Future<void> _onSessionExpired() async {
Future<void> _onSessionExpired() async => _clearLocal();
Future<void> _clearLocal() async {
await TokenStore.clear();
state = AuthState(restored: true);
}
+2 -1
View File
@@ -151,6 +151,7 @@ class FeedNotifier extends StateNotifier<FeedState> {
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
void onPageChanged(int index) {
state = state.copyWith(currentIndex: index);
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
_bindAndReport(index);
// 接近尾部预取下一页
if (index >= state.items.length - 3) loadMore();
@@ -160,7 +161,7 @@ class FeedNotifier extends StateNotifier<FeedState> {
Future<void> _bindAndReport(int index) async {
if (index < 0 || index >= state.items.length) return;
final items = state.items;
pool.bindWindow(
await pool.bindWindow(
index,
[for (final v in items) (videoId: v.videoId, url: resolvePlayUrl(v))],
);
+2 -1
View File
@@ -89,6 +89,7 @@ class MemberNotifier extends StateNotifier<MemberState> {
}
}
final memberProvider = StateNotifierProvider<MemberNotifier, MemberState>(
/// autoDispose:进入会员页时创建并加载,离开即销毁;登录态变化后自动取最新
final memberProvider = StateNotifierProvider.autoDispose<MemberNotifier, MemberState>(
MemberNotifier.new,
);
+3 -1
View File
@@ -101,6 +101,8 @@ class WalletNotifier extends StateNotifier<WalletState> {
}
}
final walletProvider = StateNotifierProvider<WalletNotifier, WalletState>(
/// autoDispose:进入钱包页时创建并加载,离开即销毁;
/// 避免登录态切换(登录/会员变更/充值后)仍展示上一次的旧数据
final walletProvider = StateNotifierProvider.autoDispose<WalletNotifier, WalletState>(
(_) => WalletNotifier(),
);
+8 -16
View File
@@ -31,11 +31,18 @@ import '../services/payment_service.dart';
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
final routerProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authProvider);
// 认证状态变化仅触发 redirect 重估(refreshListenable),
// 绝不能 watch 后重建 GoRouter —— 重建会把当前路由重置回启动页,
// 启动页再次执行 bootstrap → 状态再变化 → 无限循环(卡死在启动页)。
final refresh = ValueNotifier<int>(0);
ref.listen(authProvider, (_, __) => refresh.value++);
ref.onDispose(refresh.dispose);
return GoRouter(
initialLocation: '/',
refreshListenable: refresh,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggingIn = state.matchedLocation == '/login' ||
state.matchedLocation == '/register';
// 启动恢复未完成:停在 splash
@@ -116,18 +123,3 @@ final routerProvider = Provider<GoRouter>((ref) {
],
);
});
/// 兜底页面(迭代期占位)
class PlaceholderScreen extends StatelessWidget {
const PlaceholderScreen({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(child: Text(title, style: const TextStyle(fontSize: 16))),
);
}
}
+16 -10
View File
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/auth_service.dart';
/// 登录页:骨架期提供手机号+密码登录,验证码/邮箱登录随迭代补充
/// 登录页:账号(手机号/邮箱,含@自动识别)+ 密码登录
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -21,10 +22,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
String? _error;
Future<void> _submit() async {
final phone = _phone.text.trim();
final account = _phone.text.trim();
final password = _password.text;
if (phone.isEmpty || password.isEmpty) {
setState(() => _error = '请输入手机号和密码');
if (account.isEmpty || password.isEmpty) {
setState(() => _error = '请输入账号和密码');
return;
}
setState(() {
@@ -32,15 +33,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_error = null;
});
try {
// 含 @ 视为邮箱登录,否则手机号登录(服务端两种都支持密码登录)
final isEmail = account.contains('@');
final result = await AuthService.login(
type: 'phone_password',
phone: phone,
type: isEmail ? 'email_password' : 'phone_password',
phone: isEmail ? null : account,
email: isEmail ? account : null,
password: password,
);
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
if (mounted) context.go('/home');
} catch (e) {
setState(() => _error = e.toString().replaceFirst('ApiException(', '').split(', ').last);
} on ApiException catch (e) {
setState(() => _error = e.message);
} catch (_) {
setState(() => _error = '网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -62,8 +68,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
const SizedBox(height: 32),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(hintText: '手机号'),
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(hintText: '手机号 / 邮箱'),
),
const SizedBox(height: 16),
TextField(
+16 -8
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/local_store.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
@@ -55,11 +56,17 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
return;
}
try {
await AuthService.sendSmsCode(phone, 'register');
final devCode = await AuthService.sendSmsCode(phone, 'register');
setState(() {
_error = null;
_countdown = 60;
});
if (devCode != null && mounted) {
// 短信通道未配置时的开发模式:直接展示验证码(上线配置短信后服务端不再返回)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('开发模式验证码:$devCode(5 分钟内有效)'), duration: const Duration(seconds: 6)),
);
}
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_countdown <= 1) {
@@ -69,8 +76,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
setState(() => _countdown -= 1);
}
});
} catch (e) {
setState(() => _error = _errText(e));
} on ApiException catch (e) {
setState(() => _error = e.message);
} catch (_) {
setState(() => _error = '网络异常,请稍后重试');
}
}
@@ -105,16 +114,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
);
}
context.go('/home');
} catch (e) {
setState(() => _error = _errText(e));
} on ApiException catch (e) {
setState(() => _error = e.message);
} catch (_) {
setState(() => _error = '网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _errText(Object e) =>
e.toString().replaceFirst('ApiException(', '').split(', ').last.replaceAll(')', '');
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -0,0 +1,312 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../services/upload_service.dart';
import '../../services/video_service.dart';
/// 分类 Tab(首页 Tab1):分类筛选 + 双列视频网格,点击进入播放页。
/// 数据源:/videos/categories + /videos/feed(category_id 过滤,游标分页)
class CategoryScreen extends ConsumerStatefulWidget {
const CategoryScreen({super.key});
@override
ConsumerState<CategoryScreen> createState() => _CategoryScreenState();
}
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
List<VideoCategory> _categories = const [];
int? _selected; // null = 全部
List<VideoItem> _items = const [];
String? _cursor;
bool _loading = true;
bool _loadingMore = false;
bool _failed = false;
final _scroll = ScrollController();
@override
void initState() {
super.initState();
_bootstrap();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
super.dispose();
}
void _onScroll() {
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 400) {
_loadMore();
}
}
Future<void> _bootstrap() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final results = await Future.wait([
UploadService.fetchCategories(),
VideoService.fetchFeed(type: 'hot', categoryId: _selected, limit: 10),
]);
if (!mounted) return;
setState(() {
_categories = results[0] as List<VideoCategory>;
_items = (results[1] as FeedPage).items;
_cursor = (results[1] as FeedPage).nextCursor;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _selectCategory(int? id) async {
if (id == _selected) return;
setState(() => _selected = id);
await _reload();
}
Future<void> _reload() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final page = await VideoService.fetchFeed(
type: 'hot', categoryId: _selected, limit: 10);
if (!mounted) return;
setState(() {
_items = page.items;
_cursor = page.nextCursor;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || _loading || _failed || _cursor == null) return;
setState(() => _loadingMore = true);
try {
final page = await VideoService.fetchFeed(
type: 'hot', cursor: _cursor, categoryId: _selected, limit: 10);
if (!mounted) return;
setState(() {
_items = [..._items, ...page.items];
_cursor = page.nextCursor;
_loadingMore = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingMore = false);
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
// 顶栏
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Text('分类',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.search,
color: AppColors.textSecondary),
onPressed: () => context.push('/search'),
),
],
),
),
// 分类筛选条
SizedBox(
height: 44,
child: _categories.isEmpty
? const SizedBox.shrink()
: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_chip('全部', null),
for (final c in _categories) _chip(c.name, c.categoryId),
],
),
),
Expanded(child: _buildBody()),
],
),
);
}
Widget _chip(String label, int? id) {
final active = id == _selected;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: active,
onSelected: (_) => _selectCategory(id),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active ? Colors.white : AppColors.textSecondary,
),
),
);
}
Widget _buildBody() {
if (_loading && _items.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary));
}
if (_failed && _items.isEmpty) {
return Center(
child: TextButton(
onPressed: _selected == null ? _bootstrap : _reload,
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
);
}
if (_items.isEmpty) {
return const Center(
child: Text('该分类暂无视频',
style: TextStyle(color: AppColors.textSecondary)));
}
return RefreshIndicator(
onRefresh: _reload,
color: AppColors.primary,
child: GridView.builder(
controller: _scroll,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.72,
),
itemCount: _items.length + (_cursor != null ? 1 : 0),
itemBuilder: (_, index) {
if (index >= _items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return _CategoryCard(item: _items[index]);
},
),
);
}
}
class _CategoryCard extends StatelessWidget {
const _CategoryCard({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40),
)
: const Icon(Icons.play_circle_outline,
color: AppColors.textWeak, size: 40),
),
// 观看数角标
Positioned(
right: 6,
top: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow,
color: Colors.white, size: 12),
const SizedBox(width: 2),
Text(
_formatCount(item.viewCount),
style:
const TextStyle(color: Colors.white, fontSize: 10),
),
],
),
),
),
// 标题
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.all(6),
child: Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
),
],
),
),
);
}
String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k';
return '$n';
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import 'widgets/feed_overlays.dart';
import 'widgets/feed_video_page.dart';
/// 首页 Feed:全屏竖向 PageView 沉浸式播放(《01-开发文档》5.2.1)
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索图标(占位)
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索入口
/// - 底部:游客/非会员免费剩余次数提示条
/// - 滑出暂停、滑入自动播放并上报观看计费
class FeedScreen extends ConsumerStatefulWidget {
@@ -144,7 +144,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
},
),
// 顶部:策略 Tab + 搜索(占位)
// 顶部:策略 Tab + 搜索入口
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -3,6 +3,9 @@ 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 {
const VideoPlayerView({
super.key,
@@ -17,6 +20,7 @@ class VideoPlayerView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final player = controller.player;
return Stack(
fit: StackFit.expand,
children: [
@@ -27,13 +31,24 @@ class VideoPlayerView extends StatelessWidget {
fit: BoxFit.contain,
),
// 封面兜底:加载/无地址时可见(HLS 起播后被画面覆盖)
if (coverUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// 封面兜底:仅在视频尚未开始渲染时显示
StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (_, snapshot) {
final pos = snapshot.data ?? Duration.zero;
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
final showCover =
coverUrl.isNotEmpty && pos == Duration.zero;
return showCover
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
)
: const SizedBox.shrink();
},
),
// 暂停态大播放图标
if (!playing)
+31 -25
View File
@@ -1,21 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/feed_provider.dart';
import '../../screens/category/category_screen.dart';
import '../../screens/feed/feed_screen.dart';
import '../../screens/notification/notifications_screen.dart';
import '../../screens/profile/mine_screen.dart';
import '../../screens/upload/upload_screen.dart';
/// 首页壳:底部 Tab(首页/分类/发布/消息/我的),02-技术文档信息架构
/// 首页 Tab 为 Feed 播放页,我的页已接入,其余 Tab 随迭代接入;IndexedStack 保活各 Tab 状态
class HomeShell extends StatefulWidget {
/// IndexedStack 保活各 Tab 状态(Feed 播放器池 / 分类滚动位置 / 上传流程不被销毁)
class HomeShell extends ConsumerStatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
ConsumerState<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
class _HomeShellState extends ConsumerState<HomeShell> {
int _index = 0;
/// Feed 三种策略的 provider keys(切 Tab 时用于暂停/恢复播放器池)
static const _feedStrategies = FeedStrategy.values;
void _onTabSelected(int i) {
if (i == _index) return;
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
if (i != 0) {
for (final s in _feedStrategies) {
ref.read(feedProvider(s).notifier).pool.pauseCurrent();
}
}
setState(() => _index = i);
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
if (i == 0) {
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -23,9 +46,9 @@ class _HomeShellState extends State<HomeShell> {
index: _index,
children: const [
FeedScreen(),
_TabPlaceholder('分类'),
_TabPlaceholder('发布'),
_TabPlaceholder('消息'),
CategoryScreen(),
UploadScreen(),
NotificationsScreen(),
MineScreen(),
],
),
@@ -33,7 +56,7 @@ class _HomeShellState extends State<HomeShell> {
backgroundColor: AppColors.surface,
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
onDestinationSelected: _onTabSelected,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
@@ -61,20 +84,3 @@ class _HomeShellState extends State<HomeShell> {
);
}
}
/// Tab 占位(迭代期)
class _TabPlaceholder extends StatelessWidget {
const _TabPlaceholder(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'$title(随迭代接入)',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 15),
),
);
}
}
+184 -21
View File
@@ -11,8 +11,7 @@ import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
/// 与 Feed 一致的观看计费(开始播放即上报,按 video_id+日期本地去重),
/// 游客超限 2002 / 积分不足 2001 弹窗逻辑复用 feed_overlays。
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
class SimplePlayerScreen extends StatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
@@ -23,15 +22,27 @@ class SimplePlayerScreen extends StatefulWidget {
}
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
late final Player _player = Player();
late final VideoController _controller = VideoController(_player);
late Player _player = Player();
late VideoController _controller = VideoController(_player);
bool _playing = true;
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
@override
void initState() {
super.initState();
_player.open(Media(resolvePlayUrl(widget.item)));
_open(widget.item);
_reportWatch();
_loadSeries();
}
void _open(VideoItem item) {
_player.open(Media(resolvePlayUrl(item)), play: true);
}
Future<void> _loadSeries() async {
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
if (!mounted) return;
setState(() => _seriesData = data);
}
@override
@@ -40,7 +51,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
super.dispose();
}
/// 观看上报(计费):本地去重后上报,扣费 Toast / 超限弹窗
Future<void> _reportWatch() async {
final dedup = WatchDedupStore.I;
if (dedup.hasWatched(widget.item.videoId)) return;
@@ -72,6 +82,157 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
_playing ? _player.play() : _player.pause();
}
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
void _showSeriesSheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.4,
maxChildSize: 0.9,
builder: (_, controller) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// 拖动条
Center(
child: Container(
margin: const EdgeInsets.only(top: 12),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
// 系列信息头
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: (_seriesData!['series']['cover_url'] as String?)
?.isNotEmpty ==
true
? Image.network(
_seriesData!['series']['cover_url'],
width: 64, height: 64, fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(width: 64, height: 64),
)
: Container(
width: 64, height: 64, color: Colors.grey[200]),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_seriesData!['series']['title'] ?? '',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'共 ${_seriesData!['series']['total_episodes']} 集',
style: TextStyle(
color: Colors.grey[600], fontSize: 12),
),
],
),
),
],
),
),
const Divider(height: 1),
// 集数网格
Expanded(
child: GridView.builder(
controller: controller,
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount: _episodes.length,
itemBuilder: (_, i) {
final ep = _episodes[i] as Map<String, dynamic>;
final isCurrent =
ep['video_id'] == widget.item.videoId;
return GestureDetector(
onTap: () => _switchToEpisode(ep),
child: Container(
decoration: BoxDecoration(
color: isCurrent
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
border: Border.all(
color: isCurrent
? AppColors.primary
: Colors.transparent),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
ep['episode_no']?.toString() ?? '${i + 1}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isCurrent
? AppColors.primary
: Colors.black87,
),
),
),
),
);
},
),
),
],
),
),
),
);
}
void _switchToEpisode(Map<String, dynamic> ep) {
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, // 系列一般作者相同
);
setState(() {
_player.dispose();
_player = Player();
_controller = VideoController(_player);
_open(newItem);
// 重置 seriesData 重新加载
_seriesData = null;
});
_reportWatch();
_loadSeries();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -81,7 +242,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
child: Stack(
fit: StackFit.expand,
children: [
// 播放器
Center(
child: Video(
controller: _controller,
@@ -89,7 +249,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
fit: BoxFit.contain,
),
),
// 暂停态大播放图标
if (!_playing)
const IgnorePointer(
child: Center(
@@ -100,18 +259,24 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
),
),
),
// 返回按钮
const SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: BackButton(color: Colors.white),
// 顶部:返回 + 系列按钮
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,
),
],
),
),
// 底部标题/作者
Positioned(
left: 12,
right: 12,
bottom: 24,
left: 12, right: 12, bottom: 24,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -120,17 +285,15 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
fontSize: 15, fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
fontSize: 14, color: Colors.white70),
),
],
),
+21 -5
View File
@@ -1,6 +1,7 @@
import '../core/network/api_client.dart';
import '../core/network/api_exception.dart';
import '../core/network/endpoints.dart';
import '../core/storage/token_store.dart';
import '../models/user_model.dart';
/// 认证服务(《03-接口文档》第 3 章)
@@ -8,13 +9,22 @@ class AuthService {
AuthService._();
/// 3.1 发送短信验证码 scene: register/login/reset
static Future<void> sendSmsCode(String phone, String scene) async {
await ApiClient.I.post(Endpoints.smsSend, data: {'phone': phone, 'scene': scene});
/// 返回开发模式验证码(短信通道未配置时服务端下发),生产通道下为 null
static Future<String?> sendSmsCode(String phone, String scene) async {
final data = await ApiClient.post<Map<String, dynamic>>(
Endpoints.smsSend,
data: {'phone': phone, 'scene': scene},
);
return data['dev_code'] as String?;
}
/// 3.2 发送邮箱验证码
static Future<void> sendEmailCode(String email, String scene) async {
await ApiClient.I.post(Endpoints.emailSend, data: {'email': email, 'scene': scene});
static Future<String?> sendEmailCode(String email, String scene) async {
final data = await ApiClient.post<Map<String, dynamic>>(
Endpoints.emailSend,
data: {'email': email, 'scene': scene},
);
return data['dev_code'] as String?;
}
/// 3.3 注册(type: phone/email,可带邀请码),返回 Token 对与用户信息
@@ -66,7 +76,13 @@ class AuthService {
}
/// 统一解析 Token 响应:access/refresh 入安全存储,user 转模型
static LoginResult _fromTokenData(Map<String, dynamic> data) {
static Future<LoginResult> _fromTokenData(Map<String, dynamic> data) async {
// 登录/注册响应携带新 token 对,必须先落存储(否则登录态判定与后续请求鉴权全挂)
final access = data['access_token'] as String?;
final refresh = data['refresh_token'] as String?;
if (access != null && access.isNotEmpty && refresh != null && refresh.isNotEmpty) {
await TokenStore.save(access: access, refresh: refresh);
}
final user = UserModel.fromJson((data['user'] as Map<String, dynamic>?) ?? const {});
final trial = data['trial_granted'] as Map<String, dynamic>?;
return LoginResult(
+9
View File
@@ -34,6 +34,15 @@ class VideoService {
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
/// 当前视频所属系列 + 全部集数(播放器抽屉用)
static Future<Map<String, dynamic>> fetchVideoSeries(int videoId) async {
try {
return await ApiClient.get<Map<String, dynamic>>(Endpoints.videoSeries(videoId));
} catch (_) {
return {'has_series': false};
}
}
/// 5.3 观看上报(计费):开始播放时调用,客户端已按 video_id+日期 本地去重
static Future<WatchResult> reportWatch(int videoId, {int? position}) async {
final data = await ApiClient.post<Map<String, dynamic>>(
+132 -132
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@ name: video_app
description: "短视频应用(Flutter,iOS/Android 双端)"
publish_to: 'none'
version: 1.0.0+1
version: 1.1.0+2
environment:
sdk: ^3.9.2
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# ============================================================================
# 一键发版脚本:编译 APK → 覆盖两个下载地址 → 同步 DB 版本号 → 清 redis 缓存
# 用法:
# 1. 先改 pubspec.yaml 里的 version(1.1.0+2 → 1.2.0+3,+后面是 version_code)
# 2. 再改 .env 里的 APP_VERSION(可选,默认从 pubspec 自动读)
# 3. bash deploy.sh [latest_version_code] # 默认 +1 递增
#
# 自动更新流程:
# - 脚本会把 DB 里 system_configs.app.version.android 的 latest_version_code
# 和 latest_version 同步到新版
# - 用户打开老版本 APP → 启动时查 /api/v1/app/version → 服务端 latest > local
# → 弹"发现新版本"对话框 → 点立即更新跳 h5.256kb.top 下载页
# - 用户装完新版 → APP 上报的 version_code 等于 DB latest → 不再弹窗
# ============================================================================
set -euo pipefail
PROJECT_ROOT="/root/project/video"
APP_DIR="$PROJECT_ROOT/app"
SERVER_DIR="$PROJECT_ROOT/server"
RUNTIME_DIR="$PROJECT_ROOT/runtime"
APK_PATH="$APP_DIR/build/app/outputs/flutter-apk/app-release.apk"
# APK 统一输出位置(nginx h5.256kb.top/downloads/ alias 到这里)
APK_DEST="$RUNTIME_DIR/downloads/video-app.apk"
# MySQL 连接(与 .env 里一致)
DB_NAME="video"
DB_USER="video"
DB_PASS="Vd0e56543fb8f66faba0a5"
# --------------------------------------------------------------------------
# 1. 解析 pubspec.yaml 里的 version
# --------------------------------------------------------------------------
echo "▶ 解析 pubspec.yaml 版本号..."
PUBSPEC_VERSION=$(grep -E '^version:' "$APP_DIR/pubspec.yaml" | awk '{print $2}')
VERSION_NAME="${PUBSPEC_VERSION%+*}" # 1.1.0
VERSION_CODE="${PUBSPEC_VERSION##*+}" # 2
echo " pubspec: $VERSION_NAME versionCode=$VERSION_CODE"
# DB 当前 latest_version_code(用于参考)
DB_CURRENT_CODE=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version_code')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "0")
DB_CURRENT_VER=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown")
echo " DB 当前 latest: $DB_CURRENT_VER code=$DB_CURRENT_CODE"
# --------------------------------------------------------------------------
# 2. flutter analyze
# --------------------------------------------------------------------------
echo "▶ flutter analyze..."
cd "$APP_DIR"
flutter analyze 2>&1 | tail -3
echo
# --------------------------------------------------------------------------
# 3. 编译 release APK
# --------------------------------------------------------------------------
echo "▶ flutter build apk --release..."
cd "$APP_DIR"
flutter build apk --release 2>&1 | tail -5
echo
# --------------------------------------------------------------------------
# 4. 部署 APK
# --------------------------------------------------------------------------
echo "▶ 覆盖部署 APK → $APK_DEST"
mkdir -p "$(dirname "$APK_DEST")"
cp "$APK_PATH" "$APK_DEST"
chmod 644 "$APK_DEST"
ls -lh "$APK_DEST"
echo
# --------------------------------------------------------------------------
# 5. 同步 DB 版本配置(让老用户打开 APP 时能看到更新提示)
# --------------------------------------------------------------------------
NEW_CODE="$VERSION_CODE"
# 如果显式传了参数,强制用它
if [ -n "${1:-}" ]; then
NEW_CODE="$1"
fi
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$NEW_CODE,\"min_version_code\":1,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
echo "▶ 同步 DB app.version.android → $UPDATE_JSON"
mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "
INSERT INTO system_configs (config_key, config_value, description)
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
" 2>/dev/null
redis-cli DEL config:system >/dev/null 2>&1 || true
echo
echo "✅ 发版完成"
echo
echo " APK: $H5_APK"
echo " 版本: $VERSION_NAME (versionCode=$NEW_CODE)"
echo " 下载: https://h5.256kb.top/downloads/video-app.apk"
echo
echo " 老用户(versionCode < $NEW_CODE)打开 APP 会弹更新提示"
echo " 装完新版后提示自动消失"
echo
echo " 如需强制更新:把 min_version_code 改得比用户基数高(如 999)"
echo " $ mysql -u $DB_USER -p$DB_PASS $DB_NAME -e \"UPDATE system_configs SET config_value='{\\\"latest_version\\\":\\\"$VERSION_NAME\\\",\\\"latest_version_code\\\":$NEW_CODE,\\\"min_version_code\\\":999,\\\"update_url\\\":\\\"https://h5.256kb.top/downloads/video-app.apk\\\",\\\"notice\\\":\\\"\\\"}' WHERE config_key='app.version.android';\""
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>喵舍 App 下载</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
body { background:#0f0f14; color:#fff; font-family:-apple-system,"PingFang SC","Helvetica Neue",sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:32px 24px; }
.logo { width:88px; height:88px; border-radius:20px; display:flex; align-items:center; justify-content:center;
background:linear-gradient(135deg,#fe2c55,#7c4dff); font-size:44px; margin-bottom:20px;
box-shadow:0 8px 32px rgba(254,44,85,.35); }
h1 { font-size:22px; margin-bottom:8px; }
.sub { color:#9a9aa5; font-size:14px; margin-bottom:36px; text-align:center; line-height:1.7; }
.btn { width:100%; max-width:320px; padding:15px 0; border:none; border-radius:12px; font-size:16px;
font-weight:600; cursor:pointer; margin-bottom:14px; display:block; text-align:center;
text-decoration:none; }
.btn-primary { background:linear-gradient(135deg,#fe2c55,#7c4dff); color:#fff;
box-shadow:0 6px 20px rgba(254,44,85,.35); }
.btn:active { opacity:.85; }
.meta { color:#6d6d78; font-size:12px; margin-top:16px; text-align:center; line-height:1.8; }
#toast { position:fixed; left:50%; bottom:60px; transform:translateX(-50%); background:#333; color:#fff;
padding:10px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .3s; pointer-events:none; }
</style>
</head>
<body>
<div class="logo">▶</div>
<h1>喵舍 App</h1>
<p class="sub">新人注册立送体验会员<br>看视频赚积分 · 邀请好友得奖励</p>
<a class="btn btn-primary" id="dl" href="/downloads/video-app.apk">下载 Android 安装包</a>
<p class="meta">支持 Android 7.0 及以上系统<br>安装时请允许「安装未知来源应用」</p>
<div id="toast"></div>
<script>
var dl = document.getElementById('dl');
// 下载提示 + 兜底(部分浏览器拦截直链时引导长按)
dl.addEventListener('click', function () {
toast('开始下载,请留意系统通知栏');
});
function toast(t) {
var el = document.getElementById('toast');
el.textContent = t; el.style.opacity = 1;
setTimeout(function () { el.style.opacity = 0; }, 2500);
}
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
# ===== 服务 =====
APP_ENV=prod
APP_HOST=127.0.0.1
APP_PORT=8000
# ===== 数据库 / Redis =====
DATABASE_URL=mysql+aiomysql://video:Vd0e56543fb8f66faba0a5@127.0.0.1:3306/video?charset=utf8mb4
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
# ===== JWT =====
JWT_SECRET=0e79fddb233856d260b0072a979d483c1c07dbdb0c8c9b1d66bd3f540499093c
ACCESS_TOKEN_TTL=7200
REFRESH_TOKEN_TTL=2592000
ADMIN_TOKEN_TTL=43200
# ===== 对象存储 OSS(未接 OSS 时媒体走本地 /static)=====
OSS_ENDPOINT=
OSS_BUCKET=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
# ===== 支付(未配置时自动降级 dev_mock)=====
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
WECHAT_MCHID=
WECHAT_APP_ID=
WECHAT_API_V3_KEY=
WECHAT_PRIVATE_KEY=
TRON_API_KEY=
# ===== 短信(短信宝)/ 邮件(未配置时验证码走开发模式)=====
SMSBAO_USERNAME=followwin
SMSBAO_API_KEY=325edeb8e17f4c00b03a0294a8d794e3
SMS_SIGN_NAME=【followwin】
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=liuming256kb@gmail.com
SMTP_PASSWORD=xkbt oorr zylb lnjt
+4 -4
View File
@@ -31,10 +31,10 @@ WECHAT_API_V3_KEY=
WECHAT_PRIVATE_KEY=
TRON_API_KEY=
# ===== 短信 / 邮件 =====
SMS_ACCESS_KEY_ID=
SMS_ACCESS_KEY_SECRET=
SMS_SIGN_NAME=
# ===== 短信(短信宝)/ 邮件(未配置时验证码走开发模式)=====
SMSBAO_USERNAME=
SMSBAO_API_KEY=
SMS_SIGN_NAME=【followwin】
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
+1 -1
View File
@@ -35,7 +35,7 @@ deactivate () {
# unset irrelevant variables
deactivate nondestructive
VIRTUAL_ENV=/Users/256kb/Project/video/server/.venv
VIRTUAL_ENV=/root/project/video/server/.venv
export VIRTUAL_ENV
_OLD_VIRTUAL_PATH="$PATH"
+1 -1
View File
@@ -8,7 +8,7 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /Users/256kb/Project/video/server/.venv
setenv VIRTUAL_ENV /root/project/video/server/.venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
+1 -1
View File
@@ -33,7 +33,7 @@ end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /Users/256kb/Project/video/server/.venv
set -gx VIRTUAL_ENV /root/project/video/server/.venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from alembic.config import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from celery.__main__ import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from cffi._cffi_gen_src import run
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from fastapi.cli import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from httpx import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from idna.cli import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from mako.cmd import cmdline
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
-6
View File
@@ -1,6 +0,0 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
import sys
from _pytest.config import _console_main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(_console_main())
-6
View File
@@ -1,6 +0,0 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
import sys
from pygments.cmdline import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
-6
View File
@@ -1,6 +0,0 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
import sys
from _pytest.config import _console_main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(_console_main())
+1 -1
View File
@@ -1 +1 @@
python3
/root/.pyenv/versions/3.11.14/bin/python
+1 -1
View File
@@ -1 +1 @@
/Users/256kb/.pyenv/versions/3.11.14/bin/python3
python
+1 -1
View File
@@ -1 +1 @@
python3
python
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from uvicorn.main import main
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
+1 -1
View File
@@ -1,4 +1,4 @@
#!/Users/256kb/Project/video/server/.venv/bin/python3
#!/root/project/video/server/.venv/bin/python
import sys
from websockets.cli import main
if __name__ == '__main__':
@@ -1 +0,0 @@
import __editable___video_server_0_1_0_finder; __editable___video_server_0_1_0_finder.install()
@@ -1,85 +0,0 @@
from __future__ import annotations
import sys
from importlib.machinery import ModuleSpec, PathFinder
from importlib.machinery import all_suffixes as module_suffixes
from importlib.util import spec_from_file_location
from itertools import chain
from pathlib import Path
MAPPING: dict[str, str] = {'app': '/Users/256kb/Project/video/server/app'}
NAMESPACES: dict[str, list[str]] = {'app.api': ['/Users/256kb/Project/video/server/app/api'], 'app.worker': ['/Users/256kb/Project/video/server/app/worker'], 'app.services': ['/Users/256kb/Project/video/server/app/services'], 'app.api.v1': ['/Users/256kb/Project/video/server/app/api/v1']}
PATH_PLACEHOLDER = '__editable__.video_server-0.1.0.finder' + ".__path_hook__"
class _EditableFinder: # MetaPathFinder
@classmethod
def find_spec(cls, fullname: str, path=None, target=None) -> ModuleSpec | None: # type: ignore
# Top-level packages and modules (we know these exist in the FS)
if fullname in MAPPING:
pkg_path = MAPPING[fullname]
return cls._find_spec(fullname, Path(pkg_path))
# Handle immediate children modules (required for namespaces to work)
# To avoid problems with case sensitivity in the file system we delegate
# to the importlib.machinery implementation.
parent, _, child = fullname.rpartition(".")
if parent and parent in MAPPING:
return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
# Other levels of nesting should be handled automatically by importlib
# using the parent path.
return None
@classmethod
def _find_spec(cls, fullname: str, candidate_path: Path) -> ModuleSpec | None:
init = candidate_path / "__init__.py"
candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
for candidate in chain([init], candidates):
if candidate.exists():
return spec_from_file_location(fullname, candidate)
return None
class _EditableNamespaceFinder: # PathEntryFinder
@classmethod
def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:
if path == PATH_PLACEHOLDER:
return cls
raise ImportError
@classmethod
def _paths(cls, fullname: str) -> list[str]:
paths = NAMESPACES[fullname]
if not paths and fullname in MAPPING:
paths = [MAPPING[fullname]]
# Always add placeholder, for 2 reasons:
# 1. __path__ cannot be empty for the spec to be considered namespace.
# 2. In the case of nested namespaces, we need to force
# import machinery to query _EditableNamespaceFinder again.
return [*paths, PATH_PLACEHOLDER]
@classmethod
def find_spec(cls, fullname: str, target=None) -> ModuleSpec | None: # type: ignore
if fullname in NAMESPACES:
spec = ModuleSpec(fullname, None, is_package=True)
spec.submodule_search_locations = cls._paths(fullname)
return spec
return None
@classmethod
def find_module(cls, _fullname) -> None:
return None
def install():
if not any(finder == _EditableFinder for finder in sys.meta_path):
sys.meta_path.append(_EditableFinder)
if not NAMESPACES:
return
if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):
# PathEntryFinder is needed to create NamespaceSpec without private APIS
sys.path_hooks.append(_EditableNamespaceFinder._path_hook)
if PATH_PLACEHOLDER not in sys.path:
sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook

Some files were not shown because too many files have changed in this diff Show More