@@ -1,7 +1,7 @@
'use client' ;
import { useEffect , useState } from 'react' ;
import { Button , Card , Space , Spin , Switch , Tag , Typography , Upload , message } from 'antd' ;
import { Button , Card , InputNumber , Space , Spin , Switch , Tag , Typography , Upload , message } from 'antd' ;
import { DeleteOutlined , UploadOutlined } from '@ant-design/icons' ;
import { api , errMsg } from '@/lib/api' ;
import { canDo } from '@/lib/auth' ;
@@ -9,55 +9,58 @@ import { mediaUrl } from '@/lib/media';
import type { GuideVideoConfig as GuideCfg } from '@/lib/types' ;
const { Text } = Typography ;
/** 后端 media.save_guide_video 只认 MP4 魔数;这里先在浏览器挡一道,省得白传 100MB。 */
const MAX_BYTES = 100 * 1024 * 1024 ;
type Scene = 'coupon' | 'comparison' ;
/**
* 领券等候浮层的「新手引导视频」配置。
*
* 用户点首页「一键自动领取」→ 出等候浮层,浮层下方那块位置**前 3 次**放这支引导视频
* (而不是广告),每次固定发 120 金币;播完若浮层还开着,自动接着放广告(原逻辑)。
* 「系统配置 → 领券引导视频」tab 的一个区块。
*
* 后台只管两件事:**开关** 和 **换片**。次数(3)/ 金币(120)走服务端默认值,产品已拍板不再
* 开放配置,所以这里不渲染输入框、PATCH 也不带这两个字段(后端仍保留字段与默认值)。
*/
export default function GuideVideoConfig() {
const META : Record < Scene , { title : string ; action : string } > = {
coupon : { title : '一键自动领取引导视频' , action : '一键自动领取' } ,
comparison : { title : '开始比价引导视频' , action : '开始比价' } ,
} ;
function SceneConfig ( { scene } : { scene : Scene } ) {
const meta = META [ scene ] ;
const [ cfg , setCfg ] = useState < GuideCfg | null > ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ uploading , setUploading ] = useState ( false ) ;
// 本地编辑态,保存时一次性 PATCH
const [ enabled , setEnabled ] = useState ( true ) ;
const [ maxPlays , setMaxPlays ] = useState ( 3 ) ;
const [ rewardCoin , setRewardCoin ] = useState ( 100 ) ;
const canEdit = canDo ( [ 'operator' ] ) ;
const sync = ( c : GuideCfg ) = > {
setCfg ( c ) ;
setEnabled ( c . enabled ) ;
const sync = ( value : GuideCfg ) = > {
setCfg ( value ) ;
setEnabled ( value . enabled ) ;
setMaxPlays ( value . max_plays ) ;
setRewardCoin ( value . reward_coin ) ;
} ;
const load = async ( ) = > {
setLoading ( true ) ;
try {
const { data } = await api . get < GuideCfg > ( '/admin/api/guide-video' ) ;
const { data } = await api . get < GuideCfg > ( '/admin/api/guide-video' , { params : { scene } } ) ;
sync ( data ) ;
} catch ( e ) {
message . error ( errMsg ( e ) ) ;
} finally {
setLoading ( false ) ;
}
} ;
useEffect ( ( ) = > {
load ( ) ;
} , [ ] ) ;
void load ( ) ;
} , [ scene ] ) ;
const save = async ( ) = > {
setSaving ( true ) ;
try {
const { data } = await api . patch < GuideCfg > ( '/admin/api/guide-video' , { enabled } ) ;
const { data } = await api . patch < GuideCfg > (
'/admin/api/guide-video' ,
{ enabled , max_plays : maxPlays , reward_coin : rewardCoin } ,
{ params : { scene } } ,
) ;
sync ( data ) ;
message . success ( '已保存,用户下一次进入领券浮层即生效' ) ;
message . success ( ` ${ meta . title } 配置已保存,下次触发即生效 ` ) ;
} catch ( e ) {
message . error ( errMsg ( e ) ) ;
} finally {
@@ -65,11 +68,27 @@ export default function GuideVideoConfig() {
}
} ;
// antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。
const uploadVideo = async ( file : File ) = > {
const form = new FormData ( ) ;
form . append ( 'file' , file ) ;
setUploading ( true ) ;
try {
const { data } = await api . post < GuideCfg > (
'/admin/api/guide-video/video' ,
form ,
{ params : { scene } } ,
) ;
sync ( data ) ;
message . success ( ` ${ meta . title } 已更新 ` ) ;
} catch ( e ) {
message . error ( errMsg ( e ) ) ;
} finally {
setUploading ( false ) ;
}
} ;
const beforeUpload = ( file : File ) = > {
// 有些系统给 .mp4 的 type 是空串,不能只看 type,再兜一层扩展名。
const looksMp4 = file . type === 'video/mp4' || /\.mp4$/i . test ( file . name ) ;
if ( ! looksMp4 ) {
if ( file . type !== 'video/mp4' && ! /\.mp4$/i . test ( file . name ) ) {
message . error ( '仅支持 MP4 视频(H.264 编码)' ) ;
return Upload . LIST_IGNORE ;
}
@@ -81,27 +100,15 @@ export default function GuideVideoConfig() {
return false ;
} ;
const uploadVideo = async ( file : File ) = > {
const form = new FormData ( ) ;
form . append ( 'file' , file ) ;
setUploading ( true ) ;
try {
const { data } = await api . post < GuideCfg > ( '/admin/api/guide-video/video' , form ) ;
sync ( data ) ;
message . success ( '引导视频已更新,用户下一次进入领券浮层即生效' ) ;
} catch ( e ) {
message . error ( errMsg ( e ) ) ;
} finally {
setUploading ( false ) ;
}
} ;
const removeVideo = async ( ) = > {
setUploading ( true ) ;
try {
const { data } = await api . delete < GuideCfg > ( '/admin/api/guide-video/video' ) ;
const { data } = await api . delete < GuideCfg > (
'/admin/api/guide-video/video' ,
{ params : { scene } } ,
) ;
sync ( data ) ;
message . success ( '已移除引导视频,领券浮层恢复为只放广告' ) ;
message . success ( ` 已移除 ${ meta . title } ` ) ;
} catch ( e ) {
message . error ( errMsg ( e ) ) ;
} finally {
@@ -112,82 +119,48 @@ export default function GuideVideoConfig() {
return (
< Card
size = "small"
title = "领券引导视频(App 领券等候浮层,前 3 次替代广告)"
title = { meta . title }
style = { { marginBottom : 16 } }
extra = {
cfg ? . updated_at ? (
< Text type = "secondary" style = { { fontSize : 12 } } >
更 新 于 { new Date ( cfg . updated_at ) . toLocaleString ( 'zh-CN' ) }
< / Text >
) : null
}
extra = { cfg ? . updated_at ? < Text type = "secondary" > 更 新 于 { new Date ( cfg . updated_at ) . toLocaleString ( 'zh-CN' ) } < / Text > : null }
>
< p style = { { color : '#999 ' , marginTop : 0 } } >
用 户 点 「 一 键 自 动 领 取 」 后 出 现 的 等 候 浮 层 , 下 方 那 块 广 告 位 < b > 前 3 次 < / b > 改 放 这 支 引 导 视 频 。
视 频 < b > 不 可 快 进 < / b > ; 播 完 或 中 途 关 闭 都 算 看 完 , 各 发 一 次 < b > 120 金 币 < / b > ; 播 完 若 浮 层 还 开 着 , 会 自 动 接 着 放 广 告 。
次 数 < b > 按 账 号 < / b > 计 ( 换 设 备 不 重 置 ) , < b > 视 频 一 开 播 就 算 用 掉 一 次 < / b > 。
次 数 与 金 币 走 固 定 值 、 后 台 不 开 放 调 整 ; 这 里 只 管 < b > 开 关 < / b > 和 < b > 换 片 < / b > , 每 次 改 动 进 审 计 日 志 。
< p style = { { color : '#777 ' , marginTop : 0 } } >
Android 用 户 点 击 「 { meta . action } 」 后 , 前 若 干 次 在 等 候 浮 层 广 告 位 播 放 本 视 频 并 奖 励 金 币 。
次 数 按 账 号 、 按 功 能 分 别 计 算 ; 开 播 即 计 次 , 播 完 或 中 途 关 闭 均 发 放 当 次 配 置 的 金 币 。
< / p >
{ loading || ! cfg ? (
< Spin style = { { display : 'block' , margin : '24px 0' } } / >
) : (
< Space align = "start" size = { 32 } wrap >
{ /* 左:视频预览 */ }
< div >
< Text type = "secondary" style = { { fontSize : 12 } } >
当 前 引 导 视 频
< / Text >
< div style = { { marginTop : 8 , width : 240 } } >
{ cfg . video_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
< video
src = { mediaUrl ( cfg . video_url ) }
controls
style = { {
width : 240 ,
maxHeight : 420 ,
borderRadius : 12 ,
background : '#000' ,
border : '1px solid #f0f0f0' ,
} }
/ >
) : (
< div
style = { {
width : 240 ,
height : 320 ,
borderRadius : 12 ,
border : '1px dashed #d9d9d9' ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'center' ,
color : '#bbb' ,
fontSize : 13 ,
textAlign : 'center' ,
padding : 16 ,
} }
>
未 上 传 视 频
< br / >
( 领 券 浮 层 照 旧 只 放 广 告 )
< / div >
) }
< / div >
{ ! enabled && cfg . video_url && (
< div style = { { color : '#fa8c16' , fontSize : 12 , marginTop : 6 } } >
当 前 为 「 关 闭 」 : 浮 层 不 放 引 导 视 频 , 直 接 放 广 告
< div style = { { width : 240 } } >
{ cfg . video_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
< video
src = { mediaUrl ( cfg . video_url ) }
controls
style = { { width : 240 , maxHeight : 420 , borderRadius : 12 , background : '#000' } }
/ >
) : (
< div style = { { height : 280 , border : '1px dashed #d9d9d9' , borderRadius : 12 , display : 'grid' , placeItems : 'center' , color : '#aaa' } } >
未 上 传 视 频
< / div >
) }
< / div >
{ /* 右:编辑控件 */ }
< Space direction = "vertical" size = "middle" style = { { minWidth : 360 } } >
< Space direction = "vertical" size = "middle" style = { { minWidth : 390 } } >
< Space >
< span > 启 用 引 导 视 频 : < / span >
< span > 启 用 : < / span >
< Switch checked = { enabled } disabled = { ! canEdit } onChange = { setEnabled } / >
{ ! enabled && < Tag color = "orange" > 已 关 闭 < / Tag > }
< / Space >
< Space >
< span > 播 放 次 数 : < / span >
< InputNumber min = { 0 } max = { 50 } precision = { 0 } value = { maxPlays } disabled = { ! canEdit } onChange = { ( v ) = > setMaxPlays ( v ? ? 0 ) } / >
< Text type = "secondary" > 每 个 账 号 前 N 次 ( 默 认 3 ) < / Text >
< / Space >
< Space >
< span > 每 次 金 币 : < / span >
< InputNumber min = { 0 } max = { 10000 } precision = { 0 } value = { rewardCoin } disabled = { ! canEdit } onChange = { ( v ) = > setRewardCoin ( v ? ? 0 ) } / >
< Text type = "secondary" > 默 认 100 < / Text >
< / Space >
< Space wrap >
< Upload accept = "video/mp4,.mp4" showUploadList = { false } beforeUpload = { beforeUpload } disabled = { ! canEdit } >
< Button icon = { < UploadOutlined / > } loading = { uploading } disabled = { ! canEdit } >
@@ -195,28 +168,26 @@ export default function GuideVideoConfig() {
< / Button >
< / Upload >
{ cfg . video_url && (
< Button
icon = { < DeleteOutlined / > }
danger
loading = { uploading }
disabled = { ! canEdit }
onClick = { removeVideo }
>
< Button icon = { < DeleteOutlined / > } danger loading = { uploading } disabled = { ! canEdit } onClick = { removeVideo } >
移 除 视 频
< / Button >
) }
< Text type = "secondary" style = { { fontSize : 12 } } >
MP4 ( H . 264 ) , ≤ 100 MB
< / Text >
< Text type = "secondary" > MP4 ( H . 264 ) , ≤ 100 MB < / Text >
< / Space >
< Button type = "primary" loading = { saving } disabled = { ! canEdit } onClick = { save } >
保 存 开 关
< / Button >
{ ! canEdit && < Text type = "secondary" > 仅 operator / super_admin 可 修 改 < / Text > }
< Button type = "primary" loading = { saving } disabled = { ! canEdit } onClick = { save } > 保 存 配 置 < / Button >
< Text type = "secondary" > 累 计 播 放 { cfg . total_plays } 次 , 已 发 币 { cfg . granted_plays } 次 < / Text >
< / Space >
< / Space >
) }
< / Card >
) ;
}
export default function GuideVideoConfig() {
return (
< >
< SceneConfig scene = "coupon" / >
< SceneConfig scene = "comparison" / >
< / >
) ;
}