This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
View File
+92
View File
@@ -0,0 +1,92 @@
# 播放链路(Feed 播放页)实施计划
## Context
三端骨架已完成(server 32 后台接口 + 55 个 App 接口、admin Vue3 已验证、app Flutter 骨架 analyze 0 issue)。下一步是 App 的核心价值链路:**首页 Feed 沉浸式播放 + 观看计费**。
关键前提(已与用户确认):
- 服务端 `/hls` 未就绪(无转码产物、未挂载静态路由),本次 **只做 App 端**,播放器验证用 `--dart-define=TEST_VIDEO_URL` 注入公网测试 m3u8;真实 /hls 留到上传/转码迭代。
- 本机无 Xcode/Android SDK,验证以 `flutter analyze` + `flutter test` 为准。
契约依据:docs/03 §5.1-5.4(feed/watch/complete/like/favorite)、docs/01 §5.2.1/5.3(布局与交互规范)。
## 架构决策
1. **播放器 3 槽位池化**(`FeedPlayerPool`):media_kit 每个 Player 是一个 mpv 实例,PageView 无限列表每页持有会 OOM。窗口 `[i-1, i, i+1]` 映射到 `index % 3` 槽位:当前 play、相邻 open 预缓冲、出窗 stop。
2. **watch 去重持久化**(SharedPreferences):游客免费额度按设备计费,杀进程重进重复上报会白扣游客次数。key 按日分组 `watch_yyyyMMdd`,隔日惰性清理;complete 用 `complete_` 前缀同理。
3. **状态两层**:`feedProvider`(family 按策略 recommend/latest/hot)管列表/游标/当前索引/freeRemainingToday;`FeedPlayerPool` 管播放器生命周期。计费触发收在 Notifier.onShow,UI 不直接调 service。
4. **router 放开 /home 游客访问**:游客可进 Feed,超限(2002)才弹登录引导。
5. **骨架小改**:`ApiException` 增加 `data` 字段(2001 需读 points);`reportWatch` 返回强类型 `WatchResult`;VideoItem 补 `copyWith`(乐观更新)。
## 文件清单
### 新增(11 个)
| 文件 | 职责 |
|---|---|
| `app/lib/models/watch_result.dart` | watch 响应模型(costType/costPoints/remainingPoints/freeRemainingToday) |
| `app/lib/core/storage/watch_dedup_store.dart` | video_id+日期 去重集合(watch/complete 两组),SharedPreferences 持久化,隔日失效 |
| `app/lib/core/player/feed_player_pool.dart` | 3 槽位 Player 池:bindWindow/播放/暂停/预缓冲/释放;进度监听聚合(≥95% 或 completed 回调一次);构造接受 Player 工厂便于测试注入 fake |
| `app/lib/providers/feed_provider.dart` | FeedState + FeedNotifier:refresh/loadMore/onPageChanged(计费触发)/toggleLike/toggleFavorite |
| `app/lib/core/config/test_stream.dart` | dev 钩子 `resolvePlayUrl`:`String.fromEnvironment('TEST_VIDEO_URL')` 非空且 video_url 为空或非 http 时替换;做成可选参数纯函数便于单测 |
| `app/lib/screens/feed/feed_screen.dart` | Feed 页骨架:策略 Tab + 搜索占位、PageView.builder、提示条挂载;WidgetsBindingObserver 退后台暂停 |
| `app/lib/screens/feed/widgets/feed_video_page.dart` | 单页组装:播放器 + 手势层(单击暂停/继续、双击点赞)+ 互动栏 + 信息区 + 爱心动画层 |
| `app/lib/screens/feed/widgets/video_player_view.dart` | media_kit Video 包装:封面兜底(cached_network_image)、缓冲转圈、暂停态大播放图标 |
| `app/lib/screens/feed/widgets/interaction_bar.dart` | 右侧栏:头像、点赞/评论/收藏/分享带计数;未登录跳登录;评论/分享 Toast 占位(二期) |
| `app/lib/screens/feed/widgets/video_info_section.dart` | 左下:昵称、标题+#标签(本期仅样式不跳转)、描述展开/收起 |
| `app/lib/screens/feed/widgets/feed_overlays.dart` | 双击爱心动画、FreeQuotaBanner(≤3 显示)、2002 登录引导与 2001 积分不足弹窗 |
### 修改(5 个,均小改)
| 文件 | 改动 |
|---|---|
| `app/lib/services/video_service.dart` | reportWatch 返回 `WatchResult`;新增 `reportComplete(videoId, watchDuration) → bool` |
| `app/lib/core/network/api_exception.dart` | 增加 `data` 字段 + `isGuestLimit`(2002) / `isPointsInsufficient`(2001) getter |
| `app/lib/core/network/api_client.dart` | `_UnwrapInterceptor` 抛异常时透传 `body['data']` |
| `app/lib/models/video_item.dart` | 补 `copyWith` |
| `app/lib/screens/home/home_shell.dart` | 改 StatefulWidget,Tab1 = FeedScreen,IndexedStack 保活,其余 Tab 占位 |
| `app/lib/router.dart` | redirect 放开 /home 游客访问 |
## 关键实现要点
### 计费链路(FeedNotifier.onShow)
```
onShow(index):
pool.bindWindow(index, items) // 先播放
item = items[index]
if dedup.contains(item.videoId) return // 同日已上报
dedup.mark(item.videoId) // 先标记,失败不回滚(防重复扣费)
try:
r = VideoService.reportWatch(item.videoId)
if r.costType == 1: 顶部 Toast「本次消耗 1 积分」
state.freeRemainingToday = r.freeRemainingToday // 驱动提示条
on ApiException e:
2002 → pool.pauseCurrent(); 弹登录引导(确认跳 /login)
2001 → pool.pauseCurrent(); 弹「积分不足」(去充值/开通会员,本期 Toast 占位)
其他 → 静默,不影响播放
```
- complete:pool 进度回调 → `reportComplete`,走 `complete_` 去重集合;credited=false 无 UI。
- Toast 用简单 OverlayEntry 顶部条,不引第三方库。
### 互动乐观更新
未登录点击 → 跳 /login;已登录 → `copyWith` 立即更新 → 调接口 → 异常回滚 + Toast。双击:未点赞等同 toggleLike,已点赞只播动画。
### 免费剩余提示条
游客或(非会员且 remaining ≤ 3)时显示;初始 null 不显示,首次 watch 响应后落地。
## 验证方式
**自动化(本机可跑)**:
1. `WatchDedupStore` 单测:`SharedPreferences.setMockInitialValues` 模拟,验证 mark/contains、跨日 key 清理
2. `WatchResult.fromJson` 解析单测(cost_type 0/1/2/3)
3. `resolvePlayUrl` 纯函数三分支单测
4. Widget 测试:InteractionBar 计数与回调、FreeQuotaBanner 显隐(remaining=2/5/null)、FeedVideoPage 暂停图标(pool 工厂注入 fake)
5. `flutter analyze` 0 issues
**有设备后人工验证**(写进交接注释):注入测试流验证滑动播放/暂停/双击点赞;切策略 Tab;杀进程重进验证不重复扣费;mock 2002 弹登录引导;抓包确认 complete 只发一次。
## 不做(明确边界,防蔓延)
- 评论页、分享落地、关注、标签跳转(均二期,按钮 Toast 占位)
- 服务端 /hls 挂载与转码产物(上传/转码迭代)
- 去充值/开通会员页面(弹窗按钮 Toast 占位)
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>短视频管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1836
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "video-admin",
"version": "1.0.0",
"private": true,
"description": "短视频应用管理后台(Vue3 + Element Plus + Vite)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.7",
"element-plus": "^2.8.4",
"hls.js": "^1.5.17",
"pinia": "^2.2.4",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup>
// 根组件:仅承载路由出口,布局与鉴权在 router/layout 中处理
</script>
+6
View File
@@ -0,0 +1,6 @@
import request from './request'
/** 11.1 管理员登录 */
export function adminLogin(data) {
return request.post('/admin/auth/login', data)
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.26 评论列表 */
export function listComments(params) {
return request.get('/admin/comments', { params: cleanParams(params) })
}
/** 11.27 删除评论(敏感) { reason } */
export function deleteComment(commentId, data) {
return request.delete(`/admin/comments/${commentId}`, { data })
}
+11
View File
@@ -0,0 +1,11 @@
import request from './request'
/** 11.23 平台配置列表 */
export function listConfigs() {
return request.get('/admin/configs')
}
/** 11.24 修改平台配置 { value } */
export function updateConfig(configKey, data) {
return request.put(`/admin/configs/${encodeURIComponent(configKey)}`, data)
}
+6
View File
@@ -0,0 +1,6 @@
import request from './request'
/** 11.2 仪表盘 */
export function getDashboard() {
return request.get('/admin/dashboard')
}
+6
View File
@@ -0,0 +1,6 @@
import request from './request'
/** 11.17 赠送会员/积分(敏感) { user_ids, gift_type, plan_code?|points?, reason } */
export function giftUsers(data) {
return request.post('/admin/gift', data)
}
+7
View File
@@ -0,0 +1,7 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.18 操作日志(仅 super) */
export function listLogs(params) {
return request.get('/admin/logs', { params: cleanParams(params) })
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.11 充值订单查询 */
export function listOrders(params) {
return request.get('/admin/orders', { params: cleanParams(params) })
}
/** 11.12 人工补单(敏感) { reason, third_trade_no? } */
export function resettleOrder(orderNo, data) {
return request.post(`/admin/orders/${orderNo}/resettle`, data)
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
/** 11.13 充值套餐列表(含下架) */
export function listPackages() {
return request.get('/admin/packages')
}
/** 11.14 新增套餐 { amount_yuan, bonus_points, sort, points? } */
export function createPackage(data) {
return request.post('/admin/packages', data)
}
/** 11.15 修改套餐 { amount_yuan?, bonus_points?, sort?, status? } */
export function updatePackage(packageId, data) {
return request.put(`/admin/packages/${packageId}`, data)
}
/** 11.16 删除套餐 */
export function deletePackage(packageId) {
return request.delete(`/admin/packages/${packageId}`)
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.9 积分调整(敏感) { user_id, change_amount, reason } */
export function adjustPoints(data) {
return request.post('/admin/points/adjust', data)
}
/** 11.10 积分流水查询 */
export function listPointRecords(params) {
return request.get('/admin/points/records', { params: cleanParams(params) })
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.21 推荐关系列表 */
export function listReferrals(params) {
return request.get('/admin/referrals', { params: cleanParams(params) })
}
/** 11.22 推荐风控处理(敏感) { action: release|invalid, reason } */
export function reviewReferral(referralId, data) {
return request.post(`/admin/referrals/${referralId}/review`, data)
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.28 举报列表 */
export function listReports(params) {
return request.get('/admin/reports', { params: cleanParams(params) })
}
/** 11.29 举报处理(敏感) { action: offline_video|delete_comment|reject, reason } */
export function reviewReport(reportId, data) {
return request.post(`/admin/reports/${reportId}/review`, data)
}
+71
View File
@@ -0,0 +1,71 @@
/**
* axios 统一封装
* - baseURL:/api/v1(开发经 Vite 代理,生产经 nginx 反代)
* - 请求头自动注入 Authorization: Bearer <admin_token>
* - 响应统一按 { code, message, data } 结构解析:
* code=0 → 直接返回 data
* code≠0 → Element Plus 错误提示并 reject
* 401 → 清空登录态并跳转登录页
* 403 → 权限不足提示(不跳转)
*/
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { clearAuth, getToken } from '@/utils/auth'
const service = axios.create({
baseURL: '/api/v1',
timeout: 20000,
})
// 请求拦截:注入管理员 Token
service.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
/** 登录失效:清空登录态并跳转登录页 */
function toLogin() {
clearAuth()
if (!window.location.hash.includes('/login') && !window.location.pathname.includes('/login')) {
window.location.replace('/login')
}
}
// 响应拦截:解包统一响应结构
service.interceptors.response.use(
(response) => {
const res = response.data
if (res && typeof res.code !== 'undefined') {
if (res.code === 0) {
return res.data
}
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
const status = error.response?.status
const body = error.response?.data
if (status === 401) {
ElMessage.error(body?.message || '登录已过期,请重新登录')
toLogin()
} else if (status === 403) {
ElMessage.error(body?.message || '权限不足')
} else if (status === 429) {
ElMessage.error('请求过于频繁,请稍后再试')
} else {
ElMessage.error(body?.message || error.message || '网络异常')
}
return Promise.reject(error)
},
)
export default service
+17
View File
@@ -0,0 +1,17 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.30 敏感词列表 */
export function listSensitiveWords(params) {
return request.get('/admin/sensitive-words', { params: cleanParams(params) })
}
/** 11.31 添加敏感词 { word } */
export function addSensitiveWord(data) {
return request.post('/admin/sensitive-words', data)
}
/** 11.32 删除敏感词 */
export function deleteSensitiveWord(id) {
return request.delete(`/admin/sensitive-words/${id}`)
}
+17
View File
@@ -0,0 +1,17 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.6 用户列表 */
export function listUsers(params) {
return request.get('/admin/users', { params: cleanParams(params) })
}
/** 11.7 用户详情 */
export function getUserDetail(userId) {
return request.get(`/admin/users/${userId}`)
}
/** 11.8 封禁/解封用户(敏感) { status: 1|2, reason } */
export function setUserStatus(userId, data) {
return request.put(`/admin/users/${userId}/status`, data)
}
+22
View File
@@ -0,0 +1,22 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.3 待审核视频列表 */
export function listPendingVideos(params) {
return request.get('/admin/videos/pending', { params: cleanParams(params) })
}
/** 11.25 视频列表 */
export function listVideos(params) {
return request.get('/admin/videos', { params: cleanParams(params) })
}
/** 11.4 审核视频 { action: approve|reject, reason? } */
export function reviewVideo(videoId, data) {
return request.post(`/admin/videos/${videoId}/review`, data)
}
/** 11.5 下架视频(敏感) { reason } */
export function offlineVideo(videoId, data) {
return request.post(`/admin/videos/${videoId}/offline`, data)
}
+12
View File
@@ -0,0 +1,12 @@
import request from './request'
import { cleanParams } from '@/utils/params'
/** 11.19 提现列表 */
export function listWithdrawals(params) {
return request.get('/admin/withdrawals', { params: cleanParams(params) })
}
/** 11.20 提现审核(敏感) { action: approve|reject, reason? } */
export function reviewWithdrawal(withdrawNo, data) {
return request.post(`/admin/withdrawals/${withdrawNo}/review`, data)
}
+84
View File
@@ -0,0 +1,84 @@
<template>
<el-dialog v-model="visible" :title="title" width="720px" destroy-on-close @closed="destroy">
<div class="preview-wrap">
<video v-if="videoUrl" ref="videoRef" class="preview-video" controls autoplay playsinline />
<el-empty v-else description="暂无视频地址" />
</div>
<div v-if="videoUrl" class="preview-url text-muted">{{ videoUrl }}</div>
</el-dialog>
</template>
<script setup>
/**
* 视频预览弹窗:支持 HLS(m3u8) 在线播放
* - Safari/移动端原生支持 m3u8,直接赋值 src
* - 其他浏览器使用 hls.js 播放
* - HLS 由 nginx/CDN 提供(生产),本地开发若未部署 /hls 将提示无法播放
*/
import Hls from 'hls.js'
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
videoUrl: { type: String, default: '' },
title: { type: String, default: '视频预览' },
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const videoRef = ref()
let hls = null
function destroy() {
if (hls) {
hls.destroy()
hls = null
}
if (videoRef.value) {
videoRef.value.removeAttribute('src')
videoRef.value.load()
}
}
watch(
() => props.modelValue,
async (val) => {
if (!val || !props.videoUrl) return
await nextTick()
const el = videoRef.value
if (!el) return
if (Hls.isSupported()) {
hls = new Hls()
hls.loadSource(props.videoUrl)
hls.attachMedia(el)
} else if (el.canPlayType('application/vnd.apple.mpegurl')) {
el.src = props.videoUrl
}
},
)
</script>
<style scoped>
.preview-wrap {
display: flex;
justify-content: center;
background-color: #000;
min-height: 200px;
align-items: center;
}
.preview-video {
width: 100%;
max-height: 405px;
}
.preview-url {
margin-top: 8px;
font-size: 12px;
word-break: break-all;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<el-container class="layout">
<!-- 侧边菜单:按当前管理员角色过滤 -->
<el-aside width="220px" class="aside">
<div class="logo">短视频管理后台</div>
<el-menu :default-active="activeMenu" router background-color="#1f2d3d" text-color="#c0c4cc"
active-text-color="#409eff" unique-opened>
<template v-for="item in menus" :key="item.path || item.title">
<!-- 单项菜单 -->
<el-menu-item v-if="!item.children" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
<!-- 分组菜单 -->
<el-sub-menu v-else :index="item.title">
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</template>
<el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-title">{{ currentTitle }}</div>
<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>
</div>
</el-header>
<el-main class="main">
<router-view v-slot="{ Component }">
<keep-alive :max="5">
<component :is="Component" />
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { canAccess } from '@/router'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
// 菜单定义:roles 为空数组表示仅 super 可见(与路由 meta.roles 保持一致)
const menuConfig = [
{ title: '仪表盘', path: '/dashboard', icon: 'DataLine', roles: ['operator'] },
{
title: '内容管理',
icon: 'VideoCamera',
children: [
{ title: '视频审核', path: '/videos/pending', icon: 'VideoPlay', roles: ['reviewer'] },
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
{ title: '举报处理', path: '/reports', icon: 'Warning', roles: [] },
],
},
{
title: '用户管理',
icon: 'User',
children: [
{ title: '用户列表', path: '/users', icon: 'User', roles: [] },
{ title: '积分流水', path: '/points/records', icon: 'Coin', roles: [] },
],
},
{
title: '交易管理',
icon: 'Money',
children: [
{ title: '充值订单', path: '/orders', icon: 'List', roles: [] },
{ title: '充值套餐', path: '/packages', icon: 'Goods', roles: ['operator'] },
{ title: '提现审核', path: '/withdrawals', icon: 'CreditCard', roles: [] },
{ title: '赠送管理', path: '/gift', icon: 'Present', roles: ['operator'] },
],
},
{
title: '运营管理',
icon: 'Operation',
children: [
{ title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] },
{ title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] },
{ title: '平台配置', path: '/configs', icon: 'Setting', roles: [] },
{ title: '操作日志', path: '/logs', icon: 'Document', roles: [] },
],
},
]
// 按角色过滤菜单(分组内全部子项无权限时隐藏整组)
const menus = computed(() => {
const role = auth.role
return menuConfig
.map((item) => {
if (!item.children) {
return canAccess(role, item.roles) ? item : null
}
const children = item.children.filter((c) => canAccess(role, c.roles))
return children.length ? { ...item, children } : null
})
.filter(Boolean)
})
// 当前高亮菜单:详情页等隐藏路由回退到父级
const activeMenu = computed(() => {
if (route.name === 'user-detail') return '/users'
return route.path
})
const currentTitle = computed(() => route.meta.title || '')
async function handleLogout() {
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning' })
auth.logout()
ElMessage.success('已退出登录')
router.replace('/login')
}
</script>
<style scoped>
.layout {
height: 100vh;
}
.aside {
background-color: #1f2d3d;
overflow-x: hidden;
}
.logo {
height: 56px;
line-height: 56px;
text-align: center;
color: #fff;
font-size: 16px;
font-weight: 600;
background-color: #16222f;
}
.aside :deep(.el-menu) {
border-right: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
}
.header-title {
font-size: 16px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.admin-name {
font-size: 14px;
}
.main {
padding: 0;
overflow-y: auto;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'
// 全局图标注册(侧边菜单、按钮均使用)
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+162
View File
@@ -0,0 +1,162 @@
/**
* 路由与角色守卫
* 角色矩阵(与《03-接口文档》第 11 章一致,super 拥有全部权限):
* - super:全部
* - reviewer:仅视频审核(待审核列表 / 审核)
* - operator:仪表盘 / 套餐管理 / 赠送
*/
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/store/auth'
// 各角色登录后的默认落地页
export const ROLE_HOME = {
super: '/dashboard',
reviewer: '/videos/pending',
operator: '/dashboard',
}
const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', public: true },
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'DataLine', roles: ['operator'] },
},
{
path: 'videos/pending',
name: 'video-pending',
component: () => import('@/views/VideoPending.vue'),
meta: { title: '视频审核', icon: 'VideoPlay', roles: ['reviewer'] },
},
{
path: 'videos',
name: 'video-list',
component: () => import('@/views/VideoList.vue'),
meta: { title: '视频管理', icon: 'Film', roles: [] },
},
{
path: 'comments',
name: 'comment-list',
component: () => import('@/views/CommentList.vue'),
meta: { title: '评论管理', icon: 'ChatDotRound', roles: [] },
},
{
path: 'users',
name: 'user-list',
component: () => import('@/views/UserList.vue'),
meta: { title: '用户管理', icon: 'User', roles: [] },
},
{
path: 'users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetail.vue'),
meta: { title: '用户详情', roles: [], hidden: true },
},
{
path: 'points/records',
name: 'point-records',
component: () => import('@/views/PointRecords.vue'),
meta: { title: '积分流水', icon: 'Coin', roles: [] },
},
{
path: 'orders',
name: 'order-list',
component: () => import('@/views/OrderList.vue'),
meta: { title: '充值订单', icon: 'List', roles: [] },
},
{
path: 'packages',
name: 'package-list',
component: () => import('@/views/PackageList.vue'),
meta: { title: '充值套餐', icon: 'Goods', roles: ['operator'] },
},
{
path: 'gift',
name: 'gift',
component: () => import('@/views/Gift.vue'),
meta: { title: '赠送管理', icon: 'Present', roles: ['operator'] },
},
{
path: 'withdrawals',
name: 'withdrawal-list',
component: () => import('@/views/WithdrawList.vue'),
meta: { title: '提现审核', icon: 'Money', roles: [] },
},
{
path: 'referrals',
name: 'referral-list',
component: () => import('@/views/ReferralList.vue'),
meta: { title: '推荐风控', icon: 'Share', roles: [] },
},
{
path: 'reports',
name: 'report-list',
component: () => import('@/views/ReportList.vue'),
meta: { title: '举报处理', icon: 'Warning', roles: [] },
},
{
path: 'sensitive-words',
name: 'sensitive-words',
component: () => import('@/views/SensitiveWords.vue'),
meta: { title: '敏感词库', icon: 'Collection', roles: [] },
},
{
path: 'configs',
name: 'config-list',
component: () => import('@/views/ConfigList.vue'),
meta: { title: '平台配置', icon: 'Setting', roles: [] },
},
{
path: 'logs',
name: 'log-list',
component: () => import('@/views/LogList.vue'),
meta: { title: '操作日志', icon: 'Document', roles: [] },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
/** 判断角色是否可访问某路由(super 全放行;roles 为空表示仅 super) */
export function canAccess(role, routeRoles) {
if (!role) return false
if (role === 'super') return true
return Array.isArray(routeRoles) && routeRoles.includes(role)
}
router.beforeEach((to) => {
const auth = useAuthStore()
document.title = to.meta.title ? `${to.meta.title} - 短视频管理后台` : '短视频管理后台'
if (to.meta.public) {
// 已登录访问登录页 → 回到角色首页
return auth.isLoggedIn ? ROLE_HOME[auth.role] || '/dashboard' : true
}
if (!auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.roles !== undefined && !canAccess(auth.role, to.meta.roles)) {
// 越权访问 → 回到该角色首页
return ROLE_HOME[auth.role] || '/login'
}
return true
})
export default router
+48
View File
@@ -0,0 +1,48 @@
/**
* 管理员登录态 store(Pinia)
* token 与管理员信息持久化到 localStorage,刷新页面不丢失
*/
import { defineStore } from 'pinia'
import { adminLogin } from '@/api/auth'
import { clearAuth, getAdminInfo, getToken, setAdminInfo, setToken } from '@/utils/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: getToken(),
admin: getAdminInfo(), // { id, name, role }
}),
getters: {
isLoggedIn: (state) => !!state.token,
role: (state) => state.admin?.role || '',
adminName: (state) => state.admin?.name || '',
/** super 拥有全部权限 */
isSuper: (state) => state.admin?.role === 'super',
isReviewer: (state) => state.admin?.role === 'reviewer',
isOperator: (state) => state.admin?.role === 'operator',
roleText: (state) => {
const map = { super: '超级管理员', reviewer: '审核员', operator: '运营员' }
return map[state.admin?.role] || state.admin?.role || '-'
},
},
actions: {
/** 登录:成功后写入登录态 */
async login(payload) {
const data = await adminLogin(payload)
this.token = data.admin_token
this.admin = data.admin
setToken(data.admin_token)
setAdminInfo(data.admin)
return data
},
/** 退出登录:清空本地登录态 */
logout() {
this.token = ''
this.admin = null
clearAuth()
},
},
})
+54
View File
@@ -0,0 +1,54 @@
/* 全局基础样式 */
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
background-color: #f5f7fa;
color: #303133;
}
/* 表格页通用:查询区 + 表格 + 分页 */
.page-container {
padding: 16px;
}
.filter-bar {
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.table-toolbar {
margin-bottom: 12px;
display: flex;
justify-content: flex-end;
}
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
/* 表格内多行文本(评论内容/详情)自动换行 */
.cell-wrap {
white-space: pre-wrap;
word-break: break-word;
}
.text-muted {
color: #909399;
}
.text-danger {
color: #f56c6c;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 管理员登录态本地存储(token + 管理员信息)
* 统一管理 localStorage key,供 axios 拦截器与 Pinia store 共用,避免循环依赖
*/
const TOKEN_KEY = 'admin_token'
const ADMIN_KEY = 'admin_info'
/** 读取管理员 Token */
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
/** 写入管理员 Token */
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
/** 读取管理员信息 { id, name, role } */
export function getAdminInfo() {
const raw = localStorage.getItem(ADMIN_KEY)
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
/** 写入管理员信息 */
export function setAdminInfo(info) {
localStorage.setItem(ADMIN_KEY, JSON.stringify(info || {}))
}
/** 清空登录态 */
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ADMIN_KEY)
}
+10
View File
@@ -0,0 +1,10 @@
/** 请求参数清理:剔除空字符串/空值,避免后端收到无效筛选参数 */
export function cleanParams(obj) {
const out = {}
Object.entries(obj || {}).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
out[key] = value
}
})
return out
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 敏感操作原因输入框(标注【敏感】的接口要求 reason 必填,缺失返回 4002)
* 用户取消时 reject,调用方直接 return 即可
*/
import { ElMessageBox } from 'element-plus'
export async function askReason({
title = '敏感操作确认',
message = '该操作会记录操作日志,请填写操作原因',
required = true,
maxlength = 200,
} = {}) {
const { value } = await ElMessageBox.prompt(message, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请填写原因(必填)',
inputValidator: (val) => {
const text = (val || '').trim()
if (required && !text) return '请填写原因'
if (text.length > maxlength) return `原因不能超过 ${maxlength} 字`
return true
},
})
return value.trim()
}
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="page-container">
<!-- 查询条件 -->
<div class="filter-bar">
<el-input
v-model="filters.video_id"
placeholder="视频 ID"
clearable
style="width: 140px"
@keyup.enter="search"
/>
<el-input
v-model="filters.user_id"
placeholder="用户 ID"
clearable
style="width: 140px"
@keyup.enter="search"
/>
<el-input
v-model="filters.keyword"
placeholder="评论内容"
clearable
style="width: 200px"
@keyup.enter="search"
/>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<!-- 评论列表 -->
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="comment_id" label="ID" width="80" />
<el-table-column prop="video_id" label="视频 ID" width="90" />
<el-table-column prop="video_title" label="视频标题" min-width="160" show-overflow-tooltip />
<el-table-column label="用户" width="160">
<template #default="{ row }">
<span>{{ row.nickname || '-' }}</span>
<span class="text-muted"> #{{ row.user_id }}</span>
</template>
</el-table-column>
<el-table-column prop="content" label="评论内容" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-wrap">{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column prop="like_count" label="点赞" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_TAG[row.status] || 'info'" size="small">
{{ STATUS_TEXT[row.status] || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
link
type="danger"
:icon="Delete"
:disabled="row.status !== 1"
@click="remove(row)"
>
删除
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</div>
</template>
<script setup>
import { Delete, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { deleteComment, listComments } from '@/api/comments'
import { askReason } from '@/utils/reason'
// 评论状态映射
const STATUS_TEXT = { 1: '正常', 2: '已删除', 3: '审核中' }
const STATUS_TAG = { 1: 'success', 2: 'info', 3: 'warning' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ video_id: '', user_id: '', keyword: '' })
async function load() {
loading.value = true
try {
const data = await listComments({
video_id: filters.video_id,
user_id: filters.user_id,
keyword: filters.keyword,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.video_id = ''
filters.user_id = ''
filters.keyword = ''
search()
}
async function remove(row) {
let reason
try {
reason = await askReason({ title: '删除评论', message: '删除后触发评论奖励回退' })
} catch {
return
}
await deleteComment(row.comment_id, { reason })
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="page-container">
<el-alert type="info" :closable="false" show-icon class="hint"
title="仅白名单配置键可修改,修改后服务端配置缓存 60 秒自动失效,实时生效。" />
<div class="filter-bar">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span class="text-muted">共 {{ rows.length }} 项配置</span>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="config_key" label="配置键" min-width="240" />
<el-table-column prop="description" label="说明" min-width="220" />
<el-table-column label="当前值" min-width="180">
<template #default="{ row }">
<el-switch v-if="isSwitch(row)" :model-value="row.config_value === '1'"
@change="(val) => toggle(row, val)" />
<span v-else class="cell-wrap">{{ row.config_value }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="!isSwitch(row)" link type="primary" :icon="Edit" @click="openEdit(row)">
修改
</el-button>
<span v-else class="text-muted">开关项</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<el-dialog v-model="dialogVisible" title="修改配置" width="480px">
<el-form label-width="90px" @submit.prevent>
<el-form-item label="配置键">
<span>{{ current.config_key }}</span>
</el-form-item>
<el-form-item label="说明">
<span class="text-muted">{{ current.description || '-' }}</span>
</el-form-item>
<el-form-item label="配置值" required>
<el-input v-model="editValue" placeholder="请输入配置值" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { Edit, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listConfigs, updateConfig } from '@/api/configs'
const loading = ref(false)
const rows = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const current = reactive({ config_key: '', description: '' })
const editValue = ref('')
// 仅以 .enabled 结尾的开关项用 switch 展示(值 "0" / "1"),其余为文本项
function isSwitch(row) {
return row.config_key.endsWith('.enabled')
}
async function load() {
loading.value = true
try {
const data = await listConfigs()
rows.value = data.list || []
} finally {
loading.value = false
}
}
// 开关项:切换时把布尔值转回字符串 "0" / "1" 提交
async function toggle(row, val) {
const value = val ? '1' : '0'
await updateConfig(row.config_key, { value })
ElMessage.success('修改实时生效')
load()
}
function openEdit(row) {
current.config_key = row.config_key
current.description = row.description
editValue.value = row.config_value
dialogVisible.value = true
}
async function submit() {
const value = editValue.value.trim()
if (!value) {
ElMessage.warning('配置值不能为空')
return
}
saving.value = true
try {
await updateConfig(current.config_key, { value })
ElMessage.success('修改实时生效')
dialogVisible.value = false
load()
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.hint {
margin-bottom: 16px;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="page-container">
<el-row :gutter="16">
<el-col v-for="card in cards" :key="card.label" :span="6">
<el-card shadow="never" class="stat-card">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value" :class="card.className">{{ card.value }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="block">
<template #header>
<div class="block-header">
<span>今日概览</span>
<el-tag v-if="data" type="warning" effect="plain">
待审核视频:{{ data.pending_review_count }}
</el-tag>
</div>
</template>
<el-descriptions v-if="data" :column="3" border>
<el-descriptions-item label="今日活跃(DAU)">{{ data.today.dau }}</el-descriptions-item>
<el-descriptions-item label="今日新增用户">{{ data.today.new_users }}</el-descriptions-item>
<el-descriptions-item label="今日充值(元)">{{ data.today.recharge_yuan }}</el-descriptions-item>
<el-descriptions-item label="今日发放积分">{{ data.today.points_issued }}</el-descriptions-item>
<el-descriptions-item label="今日消耗积分">{{ data.today.points_consumed }}</el-descriptions-item>
<el-descriptions-item label="今日观看次数">{{ data.today.views }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!loading" description="暂无数据" />
</el-card>
<el-card shadow="never" class="block">
<template #header><span>累计数据</span></template>
<el-descriptions v-if="data" :column="3" border>
<el-descriptions-item label="累计用户">{{ data.total.users }}</el-descriptions-item>
<el-descriptions-item label="累计视频">{{ data.total.videos }}</el-descriptions-item>
<el-descriptions-item label="累计充值(元)">{{ data.total.recharge_yuan }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getDashboard } from '@/api/dashboard'
const loading = ref(false)
const data = ref(null)
const cards = computed(() => [
{ label: '今日充值(元)', value: data.value?.today.recharge_yuan ?? '-', className: 'primary' },
{ label: '今日新增用户', value: data.value?.today.new_users ?? '-' },
{ label: '今日观看次数', value: data.value?.today.views ?? '-' },
{ label: '待审核视频', value: data.value?.pending_review_count ?? '-', className: 'danger' },
])
async function load() {
loading.value = true
try {
data.value = await getDashboard()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.stat-card {
margin-bottom: 16px;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 26px;
font-weight: 600;
}
.stat-value.primary {
color: #409eff;
}
.stat-value.danger {
color: #f56c6c;
}
.block {
margin-bottom: 16px;
}
.block-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="page-container">
<el-card shadow="never" class="gift-card">
<template #header>
<span>赠送会员 / 积分</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="用户 ID" prop="user_ids">
<el-input v-model="form.user_ids" type="textarea" :rows="4"
placeholder="多个用户 ID 用逗号 / 换行 / 空格分隔" />
<div class="text-muted tip">将向解析出的有效数字 ID 去重后赠送</div>
</el-form-item>
<el-form-item label="赠送类型" prop="gift_type">
<el-radio-group v-model="form.gift_type">
<el-radio value="membership">赠送会员</el-radio>
<el-radio value="points">赠送积分</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.gift_type === 'membership'" label="会员套餐" prop="plan_code">
<el-select v-model="form.plan_code" placeholder="请选择会员套餐" style="width: 260px">
<el-option v-for="(text, code) in PLAN_TEXT" :key="code" :label="text" :value="code" />
</el-select>
</el-form-item>
<el-form-item v-if="form.gift_type === 'points'" label="积分数量" prop="points">
<el-input-number v-model="form.points" :min="1" :step="100" style="width: 260px" />
</el-form-item>
<el-form-item label="赠送原因" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="200" show-word-limit
placeholder="请填写赠送原因(必填)" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">提交赠送</el-button>
</el-form-item>
</el-form>
<div class="text-muted note">
说明:赠送会员写入 membership_orders(source=2),赠送积分写入积分流水(biz_type=5)。
</div>
</el-card>
</div>
</template>
<script setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import { reactive, ref } from 'vue'
import { giftUsers } from '@/api/gift'
const PLAN_TEXT = {
trial: '体验会员',
month: '月卡',
year: '年卡',
lifetime: '终身卡',
}
const formRef = ref()
const submitting = ref(false)
const form = reactive({
user_ids: '',
gift_type: 'membership',
plan_code: 'month',
points: 100,
reason: '',
})
const rules = {
user_ids: [{ required: true, message: '请输入用户 ID', trigger: 'blur' }],
gift_type: [{ required: true, message: '请选择赠送类型', trigger: 'change' }],
plan_code: [{ required: true, message: '请选择会员套餐', trigger: 'change' }],
points: [{ required: true, type: 'number', min: 1, message: '积分需大于 0', trigger: 'blur' }],
reason: [{ required: true, message: '请填写赠送原因', trigger: 'blur' }],
}
/** 解析用户 ID:支持逗号/换行/空格等分隔,过滤非数字并去重 */
function parseUserIds(raw) {
return [
...new Set(
(raw || '')
.split(/[\s,,、;;]+/)
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n > 0),
),
]
}
async function submit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const userIds = parseUserIds(form.user_ids)
if (!userIds.length) {
ElMessage.warning('请填写有效的用户 ID')
return
}
const desc =
form.gift_type === 'membership'
? `会员(${PLAN_TEXT[form.plan_code]})`
: `${form.points} 积分`
try {
await ElMessageBox.confirm(`将向 ${userIds.length} 个用户赠送 ${desc},确认继续?`, '赠送确认', {
type: 'warning',
})
} catch {
return
}
const payload = {
user_ids: userIds,
gift_type: form.gift_type,
reason: form.reason.trim(),
}
if (form.gift_type === 'membership') {
payload.plan_code = form.plan_code
} else {
payload.points = form.points
}
submitting.value = true
try {
const res = await giftUsers(payload)
const ok = Array.isArray(res?.success) ? res.success.length : res?.success || 0
const fail = Array.isArray(res?.failed) ? res.failed.length : res?.failed || 0
ElMessage.success(`成功 ${ok} 个,失败 ${fail} 个`)
// 表单保留,仅清空用户 ID
form.user_ids = ''
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.gift-card {
max-width: 720px;
}
.tip {
font-size: 12px;
line-height: 1.6;
}
.note {
font-size: 12px;
padding-left: 110px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-input v-model="filters.admin_id" placeholder="管理员 ID" clearable style="width: 160px"
@keyup.enter="search" />
<el-input v-model="filters.action" placeholder="操作类型(精确匹配)" clearable style="width: 220px"
@keyup.enter="search" />
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="admin_id" label="管理员 ID" width="110" />
<el-table-column prop="admin_name" label="管理员" width="140" />
<el-table-column prop="action" label="操作类型" width="160" />
<el-table-column prop="target" label="操作对象" min-width="160" show-overflow-tooltip />
<el-table-column prop="detail" label="详情" min-width="240" class-name="cell-wrap" />
<el-table-column prop="ip" label="IP" width="140" />
<el-table-column prop="created_at" label="操作时间" width="180" />
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
</template>
<script setup>
import { Refresh, Search } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { listLogs } from '@/api/logs'
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ admin_id: '', action: '' })
async function load() {
loading.value = true
try {
const data = await listLogs({
admin_id: filters.admin_id,
action: filters.action,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.admin_id = ''
filters.action = ''
search()
}
onMounted(load)
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="login-page">
<el-card class="login-card">
<div class="login-title">短视频管理后台</div>
<div class="login-subtitle">仅限运营与审核人员使用</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @keyup.enter="handleSubmit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="管理员账号" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" show-password
:prefix-icon="Lock" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" class="login-btn" @click="handleSubmit">
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { Lock, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ROLE_HOME } from '@/router'
import { useAuthStore } from '@/store/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入管理员账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleSubmit() {
await formRef.value.validate()
loading.value = true
try {
await auth.login({ username: form.username, password: form.password })
ElMessage.success('登录成功')
const redirect = route.query.redirect
router.replace(redirect || ROLE_HOME[auth.role] || '/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a576f 100%);
}
.login-card {
width: 380px;
padding: 8px 12px;
}
.login-title {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 6px;
}
.login-subtitle {
text-align: center;
font-size: 13px;
color: #909399;
margin-bottom: 24px;
}
.login-btn {
width: 100%;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="page-container">
<!-- 查询条件 -->
<div class="filter-bar">
<el-input
v-model="filters.order_no"
placeholder="订单号"
clearable
style="width: 200px"
@keyup.enter="search"
/>
<el-input
v-model="filters.user_id"
placeholder="用户 ID"
clearable
style="width: 140px"
@keyup.enter="search"
/>
<el-select v-model="filters.pay_method" placeholder="支付方式" clearable style="width: 140px">
<el-option
v-for="(text, code) in PAY_TEXT"
:key="code"
:label="text"
:value="Number(code)"
/>
</el-select>
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 130px">
<el-option
v-for="(text, code) in STATUS_TEXT"
:key="code"
:label="text"
:value="Number(code)"
/>
</el-select>
<el-date-picker
v-model="filters.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<!-- 订单列表 -->
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="user_id" label="用户 ID" width="90" />
<el-table-column prop="amount_yuan" label="金额(元)" width="100" />
<el-table-column prop="points" label="积分" width="90" />
<el-table-column label="支付方式" width="100">
<template #default="{ row }">{{ PAY_TEXT[row.pay_method] || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_TAG[row.status] || 'info'" size="small">
{{ row.status_text || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="third_trade_no" label="第三方交易号" min-width="160" show-overflow-tooltip />
<el-table-column prop="paid_at" label="支付时间" width="170" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" link type="primary" :icon="RefreshRight" @click="openResettle(row)">
补单
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
<!-- 人工补单弹窗 -->
<el-dialog v-model="resettleVisible" title="人工补单" width="480px" @closed="resetResettle">
<el-form ref="resettleFormRef" :model="resettleForm" :rules="resettleRules" label-width="110px">
<el-form-item label="订单号">
<span>{{ resettleForm.order_no }}</span>
</el-form-item>
<el-form-item label="第三方交易号">
<el-input v-model="resettleForm.third_trade_no" placeholder="选填,人工补单交易号" clearable />
</el-form-item>
<el-form-item label="补单原因" prop="reason">
<el-input
v-model="resettleForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请填写补单原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resettleVisible = false">取消</el-button>
<el-button type="primary" :loading="resettling" @click="submitResettle">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { Refresh, RefreshRight, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listOrders, resettleOrder } from '@/api/orders'
const PAY_TEXT = { 1: '支付宝', 2: '微信', 3: 'USDT' }
const STATUS_TEXT = { 0: '待支付', 1: '已支付', 2: '已关闭', 3: '已退款' }
const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({
order_no: '',
user_id: '',
pay_method: '',
status: '',
dateRange: [],
})
const resettleVisible = ref(false)
const resettling = ref(false)
const resettleFormRef = ref()
const resettleForm = reactive({ order_no: '', third_trade_no: '', reason: '' })
const resettleRules = {
reason: [{ required: true, message: '请填写补单原因', trigger: 'blur' }],
}
async function load() {
loading.value = true
try {
const data = await listOrders({
order_no: filters.order_no,
user_id: filters.user_id,
pay_method: filters.pay_method,
status: filters.status,
start_date: filters.dateRange?.[0],
end_date: filters.dateRange?.[1],
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.order_no = ''
filters.user_id = ''
filters.pay_method = ''
filters.status = ''
filters.dateRange = []
search()
}
function openResettle(row) {
resettleForm.order_no = row.order_no
resettleVisible.value = true
}
function resetResettle() {
resettleForm.order_no = ''
resettleForm.third_trade_no = ''
resettleForm.reason = ''
resettleFormRef.value?.clearValidate()
}
async function submitResettle() {
await resettleFormRef.value.validate()
resettling.value = true
try {
await resettleOrder(resettleForm.order_no, {
reason: resettleForm.reason,
third_trade_no: resettleForm.third_trade_no || undefined,
})
ElMessage.success('补单成功')
resettleVisible.value = false
load()
} finally {
resettling.value = false
}
}
onMounted(load)
</script>
+164
View File
@@ -0,0 +1,164 @@
<template>
<div class="page-container">
<div class="table-toolbar">
<el-button type="primary" :icon="Plus" @click="openCreate">新增套餐</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="amount_yuan" label="金额(元)" width="120" />
<el-table-column prop="points" label="到账积分" width="120" />
<el-table-column prop="bonus_points" label="赠送积分" width="120" />
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column label="状态" width="100">
<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 label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑套餐' : '新增套餐'" width="440px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="金额(元)" prop="amount_yuan">
<el-input-number v-model="form.amount_yuan" :min="1" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="赠送积分" prop="bonus_points">
<el-input-number v-model="form.bonus_points" :min="0" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item v-if="editing" label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="上架" :value="1" />
<el-option label="下架" :value="0" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { createPackage, deletePackage, listPackages, updatePackage } from '@/api/packages'
const loading = ref(false)
const rows = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const editing = ref(false)
const editingId = ref(null)
const formRef = ref()
const form = reactive({
amount_yuan: 1,
bonus_points: 0,
sort: 0,
status: 1,
})
// 金额必填且 ≥ 1,赠送积分与排序 ≥ 0
const rules = {
amount_yuan: [
{ required: true, message: '请输入金额', trigger: 'blur' },
{ type: 'number', min: 1, message: '金额需 ≥ 1 元', trigger: 'blur' },
],
bonus_points: [{ type: 'number', min: 0, message: '赠送积分需 ≥ 0', trigger: 'blur' }],
sort: [{ type: 'number', min: 0, message: '排序需 ≥ 0', trigger: 'blur' }],
}
async function load() {
loading.value = true
try {
const data = await listPackages()
rows.value = data.list || []
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = false
editingId.value = null
form.amount_yuan = 1
form.bonus_points = 0
form.sort = 0
form.status = 1
dialogVisible.value = true
}
function openEdit(row) {
editing.value = true
editingId.value = row.id
form.amount_yuan = row.amount_yuan
form.bonus_points = row.bonus_points
form.sort = row.sort
form.status = row.status
dialogVisible.value = true
}
async function submit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editing.value) {
await updatePackage(editingId.value, {
amount_yuan: form.amount_yuan,
bonus_points: form.bonus_points,
sort: form.sort,
status: form.status,
})
ElMessage.success('已保存')
} else {
// points 由服务端按汇率计算,不传
await createPackage({
amount_yuan: form.amount_yuan,
bonus_points: form.bonus_points,
sort: form.sort,
})
ElMessage.success('已新增')
}
dialogVisible.value = false
load()
} finally {
submitting.value = false
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除套餐「${row.amount_yuan} 元」?`, '删除套餐', { type: 'warning' })
} catch {
return
}
const res = await deletePackage(row.id)
if (res && res.deleted === false) {
ElMessage.warning('存在历史订单引用,已改为下架')
} else {
ElMessage.success('已删除')
}
load()
}
onMounted(load)
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="page-container">
<!-- 查询条件 -->
<div class="filter-bar">
<el-input
v-model="filters.user_id"
placeholder="用户 ID"
clearable
style="width: 160px"
@keyup.enter="search"
/>
<el-select v-model="filters.biz_type" placeholder="业务类型" clearable style="width: 160px">
<el-option
v-for="(text, code) in BIZ_TEXT"
:key="code"
:label="text"
:value="Number(code)"
/>
</el-select>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<!-- 积分流水 -->
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="user_id" label="用户 ID" width="100" />
<el-table-column label="变动" width="120">
<template #default="{ row }">
<span :class="row.change_amount >= 0 ? 'text-success' : 'text-danger'">
{{ row.change_amount > 0 ? '+' : '' }}{{ row.change_amount }}
</span>
</template>
</el-table-column>
<el-table-column prop="balance_after" label="变动后余额" width="120" />
<el-table-column label="业务类型" width="110">
<template #default="{ row }">{{ BIZ_TEXT[row.biz_type] || '-' }}</template>
</el-table-column>
<el-table-column prop="biz_id" label="业务 ID" width="120" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</div>
</template>
<script setup>
import { Refresh, Search } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { listPointRecords } from '@/api/points'
// 积分业务类型映射
const BIZ_TEXT = {
1: '充值',
2: '观看',
3: '评论奖励',
4: '会员',
5: '赠送',
6: '调整',
7: '扣回',
}
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ user_id: '', biz_type: '' })
async function load() {
loading.value = true
try {
const data = await listPointRecords({
user_id: filters.user_id,
biz_type: filters.biz_type,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.user_id = ''
filters.biz_type = ''
search()
}
onMounted(load)
</script>
<style scoped>
.text-success {
color: #67c23a;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-input v-model="filters.inviter_id" placeholder="邀请人 ID" clearable style="width: 140px"
@keyup.enter="search" />
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 160px">
<el-option label="待激活" :value="0" />
<el-option label="有效" :value="1" />
<el-option label="无效" :value="2" />
<el-option label="风控冻结" :value="3" />
</el-select>
<el-input v-model="filters.risk_flag" placeholder="风控标记" clearable style="width: 160px"
@keyup.enter="search" />
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="邀请人" width="170">
<template #default="{ row }">
<span>{{ row.inviter?.nickname || '-' }}</span>
<span class="text-muted"> #{{ row.inviter?.user_id }}</span>
</template>
</el-table-column>
<el-table-column label="被邀请人" width="170">
<template #default="{ row }">
<span>{{ row.invitee?.nickname || '-' }}</span>
<span class="text-muted"> #{{ row.invitee?.user_id }}</span>
</template>
</el-table-column>
<el-table-column prop="invite_code" label="邀请码" width="120" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="risk_flag" label="风控标记" min-width="120" show-overflow-tooltip />
<el-table-column prop="register_ip" label="注册 IP" width="140" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column prop="activated_at" label="激活时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 3">
<el-button link type="primary" @click="release(row)">解冻</el-button>
<el-button link type="danger" @click="markInvalid(row)">判无效</el-button>
</template>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
</template>
<script setup>
import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listReferrals, reviewReferral } from '@/api/referrals'
import { askReason } from '@/utils/reason'
const STATUS_TAG = { 0: 'info', 1: 'success', 2: 'info', 3: 'danger' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ inviter_id: '', status: '', risk_flag: '' })
function statusTagType(status) {
return STATUS_TAG[status] || 'info'
}
async function load() {
loading.value = true
try {
const data = await listReferrals({
inviter_id: filters.inviter_id,
status: filters.status,
risk_flag: filters.risk_flag,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.inviter_id = ''
filters.status = ''
filters.risk_flag = ''
search()
}
async function release(row) {
let reason
try {
reason = await askReason({ title: '解除风控冻结', message: '解冻后该推荐关系恢复正常' })
} catch {
return
}
await reviewReferral(row.id, { action: 'release', reason })
ElMessage.success('已解冻')
load()
}
async function markInvalid(row) {
let reason
try {
reason = await askReason({ title: '判定为无效推荐', message: '判定无效后将不发放推荐奖励' })
} catch {
return
}
await reviewReferral(row.id, { action: 'invalid', reason })
ElMessage.success('已判无效')
load()
}
onMounted(load)
</script>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<el-option label="待处理" :value="0" />
<el-option label="已处理" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<el-select v-model="filters.target_type" placeholder="举报对象" clearable style="width: 140px">
<el-option label="视频" :value="1" />
<el-option label="评论" :value="2" />
</el-select>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="report_id" label="举报 ID" width="100" />
<el-table-column label="举报人" width="170">
<template #default="{ row }">
<span>{{ row.reporter_nickname || '-' }}</span>
<span class="text-muted"> #{{ row.user_id }}</span>
</template>
</el-table-column>
<el-table-column label="对象类型" width="100">
<template #default="{ row }">{{ targetTypeText(row.target_type) }}</template>
</el-table-column>
<el-table-column prop="target_id" label="对象 ID" width="100" />
<el-table-column label="对象摘要" min-width="180">
<template #default="{ row }">
<div class="cell-wrap">{{ row.target_summary }}</div>
</template>
</el-table-column>
<el-table-column prop="reason_type" label="举报类型" width="120" show-overflow-tooltip />
<el-table-column prop="remark" label="举报说明" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="handle_result" label="处理结果" min-width="160" show-overflow-tooltip />
<el-table-column prop="created_at" label="举报时间" width="170" />
<el-table-column prop="handled_at" label="处理时间" width="170" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button v-if="row.target_type === 1" link type="danger" @click="offlineVideo(row)">
下架视频
</el-button>
<el-button v-if="row.target_type === 2" link type="danger" @click="deleteComment(row)">
删除评论
</el-button>
<el-button link type="info" @click="reject(row)">驳回</el-button>
</template>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
</template>
<script setup>
import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listReports, reviewReport } from '@/api/reports'
import { askReason } from '@/utils/reason'
const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'info' }
const TARGET_TEXT = { 1: '视频', 2: '评论' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ status: '', target_type: '' })
function statusTagType(status) {
return STATUS_TAG[status] || 'info'
}
function targetTypeText(type) {
return TARGET_TEXT[type] || '-'
}
async function load() {
loading.value = true
try {
const data = await listReports({
status: filters.status,
target_type: filters.target_type,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.status = ''
filters.target_type = ''
search()
}
async function offlineVideo(row) {
let reason
try {
reason = await askReason({ title: '下架视频', message: '下架被举报的视频并通知作者' })
} catch {
return
}
await reviewReport(row.report_id, { action: 'offline_video', reason })
ElMessage.success('已下架视频')
load()
}
async function deleteComment(row) {
let reason
try {
reason = await askReason({ title: '删除评论', message: '删除被举报的评论并通知作者' })
} catch {
return
}
await reviewReport(row.report_id, { action: 'delete_comment', reason })
ElMessage.success('已删除评论')
load()
}
async function reject(row) {
let reason
try {
reason = await askReason({ title: '驳回举报', message: '驳回后该举报将标记为已驳回' })
} catch {
return
}
await reviewReport(row.report_id, { action: 'reject', reason })
ElMessage.success('已驳回')
load()
}
onMounted(load)
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="page-container">
<el-alert type="info" :closable="false" show-icon class="hint"
title="添加或删除敏感词后,Redis 词库缓存会即时重建,实时生效。" />
<div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="敏感词关键词" clearable style="width: 200px"
@keyup.enter="search" />
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button type="success" :icon="Plus" @click="addWord">添加敏感词</el-button>
<span class="text-muted">共 {{ total }} 条</span>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="word" label="敏感词" min-width="220" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :icon="Delete" @click="removeWord(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
</template>
<script setup>
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { addSensitiveWord, deleteSensitiveWord, listSensitiveWords } from '@/api/sensitiveWords'
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '' })
async function load() {
loading.value = true
try {
const data = await listSensitiveWords({
keyword: filters.keyword,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.keyword = ''
search()
}
// 添加敏感词:重复词由后端返回业务错误并自动提示
async function addWord() {
let word
try {
const { value } = await ElMessageBox.prompt('请输入敏感词', '添加敏感词', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '请输入敏感词(不超过 50 字)',
inputValidator: (val) => {
const text = (val || '').trim()
if (!text) return '敏感词不能为空'
if (text.length > 50) return '敏感词不能超过 50 字'
return true
},
})
word = value.trim()
} catch {
return
}
await addSensitiveWord({ word })
ElMessage.success('添加成功')
load()
}
async function removeWord(row) {
try {
await ElMessageBox.confirm(`确认删除敏感词「${row.word}」?`, '删除敏感词', { type: 'warning' })
} catch {
return
}
await deleteSensitiveWord(row.id)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.hint {
margin-bottom: 16px;
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<template>
<div class="page-container" v-loading="loading">
<!-- 顶部操作栏 -->
<div class="detail-header">
<el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
<span class="detail-title">用户详情 #{{ userId }}</span>
<el-button
v-if="detail.status === 1"
type="danger"
:icon="Lock"
@click="toggleStatus(2)"
>
封禁
</el-button>
<el-button
v-else-if="detail.status === 2"
type="success"
:icon="Unlock"
@click="toggleStatus(1)"
>
解封
</el-button>
<el-button type="primary" :icon="EditPen" @click="openAdjust">积分调整</el-button>
</div>
<!-- 基础信息 -->
<el-descriptions title="基础信息" :column="2" border>
<el-descriptions-item label="用户 ID">{{ detail.user_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ detail.nickname || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ detail.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ GENDER_TEXT[detail.gender] || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="STATUS_TAG[detail.status] || 'info'" size="small">
{{ detail.status_text || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册方式">{{ detail.register_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="邀请码">{{ detail.invite_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册 IP">{{ detail.register_ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="最后登录时间">{{ detail.last_login_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ detail.created_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="简介">{{ detail.bio || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 钱包 -->
<el-descriptions title="钱包" :column="2" border class="section-gap">
<el-descriptions-item label="积分">{{ detail.wallet?.points ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="累计充值">{{ detail.wallet?.total_recharge ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="累计收益">{{ detail.wallet?.total_earned ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="累计消费">{{ detail.wallet?.total_consumed ?? '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 会员 -->
<el-descriptions title="会员" :column="2" border class="section-gap">
<el-descriptions-item label="会员状态">
<el-tag v-if="detail.membership?.is_member" type="warning" size="small">
{{ detail.membership?.is_lifetime ? '终身会员' : '会员' }}
</el-tag>
<el-tag v-else type="info" size="small">非会员</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前套餐">{{ detail.membership?.current_plan || '-' }}</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ detail.membership?.expire_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="作品数">{{ detail.video_count ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="获赞数">{{ detail.like_received ?? '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 最近积分记录 -->
<div class="section-title">最近积分记录</div>
<el-table :data="detail.recent_point_records || []" border stripe>
<el-table-column label="变动" width="120">
<template #default="{ row }">
<span :class="row.change_amount >= 0 ? 'text-success' : 'text-danger'">
{{ row.change_amount > 0 ? '+' : '' }}{{ row.change_amount }}
</span>
</template>
</el-table-column>
<el-table-column prop="balance_after" label="变动后余额" width="120" />
<el-table-column label="业务类型" width="110">
<template #default="{ row }">{{ BIZ_TEXT[row.biz_type] || '-' }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<!-- 积分调整弹窗 -->
<el-dialog v-model="adjustVisible" title="积分调整" width="480px" @closed="resetAdjust">
<el-form ref="adjustFormRef" :model="adjustForm" :rules="adjustRules" label-width="90px">
<el-form-item label="变动数量" prop="change_amount">
<el-input-number v-model="adjustForm.change_amount" :step="1" style="width: 100%" />
<div class="text-muted form-tip">正数为增加,负数为扣减,不能为 0</div>
</el-form-item>
<el-form-item label="原因" prop="reason">
<el-input
v-model="adjustForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请填写调整原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adjustVisible = false">取消</el-button>
<el-button type="primary" :loading="adjusting" @click="submitAdjust">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ArrowLeft, EditPen, Lock, Unlock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adjustPoints } from '@/api/points'
import { getUserDetail, setUserStatus } from '@/api/users'
import { askReason } from '@/utils/reason'
const GENDER_TEXT = { 0: '保密', 1: '男', 2: '女' }
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
const BIZ_TEXT = {
1: '充值',
2: '观看',
3: '评论奖励',
4: '会员',
5: '赠送',
6: '调整',
7: '扣回',
}
const route = useRoute()
const router = useRouter()
const userId = Number(route.params.id)
const loading = ref(false)
const detail = ref({})
const adjustVisible = ref(false)
const adjusting = ref(false)
const adjustFormRef = ref()
const adjustForm = reactive({ change_amount: null, reason: '' })
const adjustRules = {
change_amount: [
{
validator: (_rule, value, callback) => {
if (value === null || value === undefined || value === '') return callback(new Error('请输入变动数量'))
if (Number(value) === 0) return callback(new Error('变动数量不能为 0'))
callback()
},
trigger: 'blur',
},
],
reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }],
}
async function load() {
loading.value = true
try {
detail.value = (await getUserDetail(userId)) || {}
} finally {
loading.value = false
}
}
function goBack() {
router.back()
}
async function toggleStatus(target) {
const isBan = target === 2
let reason
try {
reason = await askReason({
title: isBan ? '封禁用户' : '解封用户',
message: `${isBan ? '封禁' : '解封'}用户 #${userId}`,
})
} catch {
return
}
await setUserStatus(userId, { status: target, reason })
ElMessage.success(isBan ? '已封禁' : '已解封')
load()
}
function openAdjust() {
adjustVisible.value = true
}
function resetAdjust() {
adjustForm.change_amount = null
adjustForm.reason = ''
adjustFormRef.value?.clearValidate()
}
async function submitAdjust() {
await adjustFormRef.value.validate()
adjusting.value = true
try {
await adjustPoints({
user_id: userId,
change_amount: Number(adjustForm.change_amount),
reason: adjustForm.reason,
})
ElMessage.success('调整成功')
adjustVisible.value = false
load()
} finally {
adjusting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detail-title {
font-size: 16px;
font-weight: 600;
margin-right: auto;
}
.section-gap {
margin-top: 16px;
}
.section-title {
margin: 20px 0 12px;
font-size: 15px;
font-weight: 600;
}
.text-success {
color: #67c23a;
}
.form-tip {
font-size: 12px;
line-height: 1.5;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<div class="page-container">
<!-- 查询条件 -->
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="用户 ID / 手机号 / 邮箱"
clearable
style="width: 220px"
@keyup.enter="search"
/>
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<el-option label="正常" :value="1" />
<el-option label="封禁" :value="2" />
<el-option label="已注销" :value="3" />
</el-select>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<!-- 用户列表 -->
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="user_id" label="ID" width="80" />
<el-table-column label="头像" width="80">
<template #default="{ row }">
<el-avatar :size="36" :src="row.avatar_url || undefined">
{{ (row.nickname || '?').charAt(0) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" min-width="160" show-overflow-tooltip />
<el-table-column prop="points" label="积分" width="90" />
<el-table-column label="会员" width="90">
<template #default="{ row }">
<el-tag :type="row.is_member ? 'warning' : 'info'" size="small">
{{ row.is_member ? '会员' : '非会员' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="video_count" label="作品数" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_TAG[row.status] || 'info'" size="small">
{{ row.status_text || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="170" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1"
link
type="danger"
:icon="Lock"
@click="toggleStatus(row, 2)"
>
封禁
</el-button>
<el-button
v-else-if="row.status === 2"
link
type="success"
:icon="Unlock"
@click="toggleStatus(row, 1)"
>
解封
</el-button>
<el-button v-else link disabled>解封</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</div>
</template>
<script setup>
import { Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listUsers, setUserStatus } from '@/api/users'
import { askReason } from '@/utils/reason'
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
const router = useRouter()
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '', status: '' })
async function load() {
loading.value = true
try {
const data = await listUsers({
keyword: filters.keyword,
status: filters.status,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.keyword = ''
filters.status = ''
search()
}
function toDetail(row) {
router.push('/users/' + row.user_id)
}
async function toggleStatus(row, target) {
const isBan = target === 2
let reason
try {
reason = await askReason({
title: isBan ? '封禁用户' : '解封用户',
message: `${isBan ? '封禁' : '解封'}用户 ${row.nickname || ''} #${row.user_id}`,
})
} catch {
return
}
await setUserStatus(row.user_id, { status: target, reason })
ElMessage.success(isBan ? '已封禁' : '已解封')
load()
}
onMounted(load)
</script>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="视频 ID / 标题" clearable style="width: 200px"
@keyup.enter="search" />
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<el-option v-for="(text, code) in STATUS_TEXT" :key="code" :label="text" :value="Number(code)" />
</el-select>
<el-input v-model="filters.author_id" placeholder="作者 ID" clearable style="width: 140px"
@keyup.enter="search" />
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="video_id" label="ID" width="80" />
<el-table-column label="封面" width="100">
<template #default="{ row }">
<el-image v-if="row.cover_url" :src="row.cover_url" fit="cover" class="cover"
:preview-src-list="[row.cover_url]" preview-teleported />
<div v-else class="cover placeholder">无封面</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column label="作者" width="140">
<template #default="{ row }">{{ row.author?.nickname || '-' }}</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>
</template>
</el-table-column>
<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">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
<el-button v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
下架
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无视频" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
<VideoPreview v-model="previewVisible" :video-url="previewUrl" :title="previewTitle" />
</div>
</template>
<script setup>
import { Delete, 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 VideoPreview from '@/components/VideoPreview.vue'
import { askReason } from '@/utils/reason'
const STATUS_TEXT = {
0: '转码中',
1: '待审核',
2: '已发布',
3: '已拒绝',
4: '已下架',
5: '转码失败',
}
const STATUS_TAG = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'danger' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '', status: '', author_id: '' })
const previewVisible = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
function statusTagType(status) {
return STATUS_TAG[status] || 'info'
}
async function load() {
loading.value = true
try {
const data = await listVideos({
keyword: filters.keyword,
status: filters.status,
author_id: filters.author_id,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.keyword = ''
filters.status = ''
filters.author_id = ''
search()
}
function openPreview(row) {
previewUrl.value = row.video_url
previewTitle.value = `预览:${row.title}`
previewVisible.value = true
}
async function offline(row) {
let reason
try {
reason = await askReason({ title: '下架视频', message: `下架《${row.title}》并通知作者` })
} catch {
return
}
await offlineVideo(row.video_id, { reason })
ElMessage.success('已下架')
load()
}
onMounted(load)
</script>
<style scoped>
.cover {
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;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span class="text-muted">共 {{ total }} 条待审核</span>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="video_id" label="ID" width="80" />
<el-table-column label="封面" width="110">
<template #default="{ row }">
<el-image v-if="row.cover_url" :src="row.cover_url" fit="cover" class="cover"
:preview-src-list="[row.cover_url]" preview-teleported />
<div v-else class="cover placeholder">无封面</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column label="作者" width="150">
<template #default="{ row }">
<span>{{ row.author?.nickname || '-' }}</span>
<span class="text-muted"> #{{ row.author?.user_id }}</span>
</template>
</el-table-column>
<el-table-column prop="duration" label="时长(秒)" width="90" />
<el-table-column prop="created_at" label="提交时间" width="170" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
<el-button link type="success" :icon="Select" @click="approve(row)">通过</el-button>
<el-button link type="danger" :icon="CloseBold" @click="reject(row)">拒绝</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无待审核视频" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
<VideoPreview v-model="previewVisible" :video-url="previewUrl" :title="previewTitle" />
</div>
</template>
<script setup>
import { CloseBold, Refresh, Select, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import { listPendingVideos, reviewVideo } from '@/api/videos'
import VideoPreview from '@/components/VideoPreview.vue'
import { askReason } from '@/utils/reason'
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const previewVisible = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
async function load() {
loading.value = true
try {
const data = await listPendingVideos({ page: page.value, page_size: pageSize.value })
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function openPreview(row) {
previewUrl.value = row.video_url
previewTitle.value = `预览:${row.title}`
previewVisible.value = true
}
async function approve(row) {
await ElMessageBox.confirm(`确认通过视频《${row.title}》并发布?`, '审核通过', { type: 'success' })
await reviewVideo(row.video_id, { action: 'approve' })
ElMessage.success('已通过并发布')
load()
}
async function reject(row) {
let reason
try {
reason = await askReason({
title: '拒绝视频',
message: `拒绝《${row.title}》,原因将回显给作者`,
})
} catch {
return
}
await reviewVideo(row.video_id, { action: 'reject', reason })
ElMessage.success('已拒绝')
load()
}
onMounted(load)
</script>
<style scoped>
.cover {
width: 80px;
height: 45px;
border-radius: 4px;
}
.cover.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
background-color: #f5f7fa;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<div class="page-container">
<div class="filter-bar">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<el-option label="待审核" :value="0" />
<el-option label="已打款" :value="1" />
<el-option label="已拒绝" :value="2" />
</el-select>
<el-input v-model="filters.user_id" placeholder="用户 ID" clearable style="width: 140px"
@keyup.enter="search" />
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="withdraw_no" label="提现单号" width="180" show-overflow-tooltip />
<el-table-column prop="user_id" label="用户 ID" width="100" />
<el-table-column prop="nickname" label="昵称" width="140" show-overflow-tooltip />
<el-table-column prop="assets" label="提现资产" width="120" />
<el-table-column prop="amount_yuan" label="金额(元)" width="110" />
<el-table-column prop="channel" label="渠道" width="100" />
<el-table-column prop="account" label="收款账号" width="180" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="160" show-overflow-tooltip />
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column prop="processed_at" label="处理时间" width="170" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button link type="success" @click="approve(row)">通过</el-button>
<el-button link type="danger" @click="reject(row)">拒绝</el-button>
</template>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" /></template>
</el-table>
<div class="pagination-wrap">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
</template>
<script setup>
import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listWithdrawals, reviewWithdrawal } from '@/api/withdrawals'
import { askReason } from '@/utils/reason'
const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'danger' }
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ status: '', user_id: '' })
function statusTagType(status) {
return STATUS_TAG[status] || 'info'
}
async function load() {
loading.value = true
try {
const data = await listWithdrawals({
status: filters.status,
user_id: filters.user_id,
page: page.value,
page_size: pageSize.value,
})
rows.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
filters.status = ''
filters.user_id = ''
search()
}
async function approve(row) {
try {
await ElMessageBox.confirm('确认已完成线下打款?', '通过提现', { type: 'success' })
} catch {
return
}
await reviewWithdrawal(row.withdraw_no, { action: 'approve' })
ElMessage.success('已通过')
load()
}
async function reject(row) {
let reason
try {
reason = await askReason({ title: '拒绝提现', message: '拒绝后将退回用户资产' })
} catch {
return
}
await reviewWithdrawal(row.withdraw_no, { action: 'reject', reason })
ElMessage.success('已拒绝')
load()
}
onMounted(load)
</script>
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// 开发环境:/api 反向代理到本地 FastAPI(http://localhost:8000)
// 生产环境:由 nginx 托管构建产物,并将 /api 反代到后端
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
// 本地文件存储(头像/封面):/static → 后端 uploads 目录
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: android
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: ios
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+16
View File
@@ -0,0 +1,16 @@
# video_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.jsnbhh.video_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.jsnbhh.video_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="video_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.jsnbhh.video_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+5
View File
@@ -0,0 +1,5 @@
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
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+616
View File
@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+13
View File
@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

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