视频弹窗答题

main
king 1 year ago
parent 17a28c0f68
commit a704da9240
  1. 1
      src/app.tsx
  2. 22
      src/components/image/image.tsx
  3. 171
      src/components/spinner/index.tsx
  4. 13
      src/components/spinner/loading.svg
  5. 66
      src/components/spinner/style.scss
  6. 37
      src/components/topic/single.module.scss
  7. 98
      src/components/topic/single.tsx
  8. 6
      src/components/video/type.ts
  9. 23
      src/components/video/video.tsx
  10. 105
      src/pages/business/videoInfo/components/course.tsx
  11. 4
      src/pages/business/videoInfo/videoInfo.scss
  12. 4
      src/pages/home/components/feature_recommended.tsx
  13. 20
      src/pages/preview/brand/list/list.tsx
  14. BIN
      src/static/img/shard.png

@ -32,7 +32,6 @@ function updateApp() {
function App(props) { function App(props) {
// 可以使用所有的 React Hooks
Taro.useLaunch(() => { Taro.useLaunch(() => {
updateApp() updateApp()

@ -1,22 +1,19 @@
import {FC, useEffect, useState} from "react"; import {FC, useEffect, useState} from "react";
import {Image, View} from "@tarojs/components"; import {Image, ImageProps, View} from "@tarojs/components";
import emptyImg from '@/static/img/empty.png'
import {AtActivityIndicator} from "taro-ui"; import {AtActivityIndicator} from "taro-ui";
import shard from '@/static/img/shard.png'
interface Props { interface Props extends ImageProps {
src: string
mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "widthFix" | "heightFix" | "top" | "bottom" | "center" | "left" | "right" | "top left" | "top right" | "bottom left" | "bottom right" | undefined
width: number width: number
height: number height: number
fallback?: string fallback?: string
} }
const Img: FC<Props> = ({src,mode = 'aspectFill',width,height,fallback = emptyImg}) => { const Img: FC<Props> = ({src, mode = 'aspectFill', width, height, fallback = shard}) => {
const [isError, setIsError] = useState(false) const [isError, setIsError] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
console.log(src, !src)
if (!src) { if (!src) {
setIsError(true) setIsError(true)
setLoading(false) setLoading(false)
@ -25,11 +22,13 @@ const Img: FC<Props> = ({src,mode = 'aspectFill',width,height,fallback = emptyIm
setLoading(false) setLoading(false)
} }
}, [src]) }, [src])
// 图片加载失败 // 图片加载失败
function onErrorHandler() { function onErrorHandler() {
setLoading(false) setLoading(false)
setIsError(true) setIsError(true)
} }
function onLoadHandler() { function onLoadHandler() {
setLoading(false) setLoading(false)
setIsError(false) setIsError(false)
@ -46,13 +45,14 @@ const Img: FC<Props> = ({src,mode = 'aspectFill',width,height,fallback = emptyIm
onLoad={onLoadHandler}> onLoad={onLoadHandler}>
</Image> </Image>
} }
{ loading && {
<AtActivityIndicator mode={"center"} content='加载中...'></AtActivityIndicator> loading &&
<AtActivityIndicator mode={"center"} content='加载中...'/>
} }
{ isError && !loading && {
isError && !loading &&
<Image mode={'aspectFill'} src={fallback} style={{width: `${width}rpx`, height: `${height}rpx`}}></Image> <Image mode={'aspectFill'} src={fallback} style={{width: `${width}rpx`, height: `${height}rpx`}}></Image>
} }
</View> </View>
) )
} }

@ -0,0 +1,171 @@
import Taro from "@tarojs/taro";
import { View, Image } from '@tarojs/components'
import { Component, ReactNode } from "react";
import indicator from './loading.svg'
import './style.scss'
// 动画状态
type Status =
| 'dismissed'
| 'forward'
| 'reverse'
| 'completed'
interface Props {
enable?: boolean
overlay?: boolean
}
interface State {
status?: Status
background?: Record<string, unknown>,
rotation?: Record<string, unknown>,
}
type StateSetter = (state: State) => void
type Controller = {
setTick: (enabled: boolean | undefined) => void
clear: () => void
}
function createController(setState: StateSetter): Controller {
const background = Taro.createAnimation({ duration: 600 })
const rotation = Taro.createAnimation({ duration: 600 })
let rotateTimer: ReturnType<typeof setTimeout> | undefined
let status: Status | undefined
const notifyListener = () => setState({
status,
background: background.export(),
rotation: rotation.export(),
})
const clearAnimation = (notify = true) => {
// 清空旋转动画定时器
if (rotateTimer != null) {
clearTimeout(rotateTimer)
rotateTimer = undefined
}
// 清空动画
background.export()
rotation.export()
// 通知 UI 刷新
if (notify) {
notifyListener()
}
}
const setAnimation = (opacity: number) => {
// 停止旋转动画
if (rotateTimer != null) {
clearTimeout(rotateTimer)
}
// 旋转动画定时器
const rotate = () => {
rotation.opacity(opacity).rotate(360).step({ duration: 600 })
notifyListener()
rotateTimer = setTimeout(rotate, 600)
}
// 背景动画
background.backgroundColor(`rgba(255,255,255,${opacity})`).step()
// 启动旋转动画
rotate()
}
const onFinish = (opacity: number, nextStatus: Status) => {
const lockStatus = status
setTimeout(() => {
if (lockStatus === status) {
background.backgroundColor(`rgba(255,255,255,${opacity})`).step({ duration: 0 })
if (nextStatus === 'dismissed') {
clearAnimation()
}
status = nextStatus
notifyListener()
}
}, 600)
}
const setStatus = (newStatus: Status, opacity: number) => {
if (status !== newStatus) {
status = newStatus
setAnimation(opacity)
if (status === 'reverse') {
onFinish(0, 'dismissed')
} else if (status === 'forward') {
onFinish(1, 'completed')
}
}
}
const setTick = (enabled?: boolean) => {
if (enabled !== true) {
if (status === 'dismissed') {
clearAnimation()
} else if (status !== 'reverse') {
setStatus('reverse', 0)
}
} else if (status === 'completed') {
// ignore
} else if (status !== 'forward') {
setStatus('forward', 1)
}
}
return {
setTick,
clear() {
clearAnimation(false)
},
}
}
export default class Spin extends Component<Props, State> {
constructor(props) {
super(props)
this.controller = createController(this.onAnimation)
this.state = {}
}
controller: Controller
onAnimation = (state: State) => {
this.setState((s) => ({...s, ...state}))
}
componentDidMount(): void {
console.log(this.props.enable)
this.controller.setTick(this.props.enable)
}
componentDidUpdate(): void {
this.controller.setTick(this.props.enable)
}
componentWillUnmount(): void {
this.controller.clear()
}
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
return nextProps.enable !== this.props.enable
|| nextProps.overlay !== this.props.overlay
|| nextState.status != this.state.status
}
render(): ReactNode {
return (
<View className={`spinner-wrapper ${this.state.status} ${this.props.overlay ? 'is-fixed' : ''}`}>
<View className={`spinner ${this.state.status}`}>
<Image className="spinner-icon" src={indicator} />
</View>
</View>
)
}
}

@ -0,0 +1,13 @@
<svg width="48" height="48" viewBox="0 0 36 36" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-icon="spin">
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="100%" id="linearGradient-1">
<stop stop-color="currentColor" stop-opacity="0" offset="0%"></stop>
<stop stop-color="currentColor" stop-opacity="0.50" offset="39.9430698%"></stop>
<stop stop-color="currentColor" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect fill-opacity="0.01" fill="none" x="0" y="0" width="36" height="36"></rect>
<path d="M34,18 C34,9.163444 26.836556,2 18,2 C11.6597233,2 6.18078805,5.68784135 3.59122325,11.0354951" stroke="url(#linearGradient-1)" stroke-width="4" stroke-linecap="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 813 B

@ -0,0 +1,66 @@
.spinner-wrapper {
background-color: rgba( #fff, 1.0);
transition: background-color 1200ms ease-out;
&.is-fixed {
z-index: 99999;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
&.reverse,
&.dismissed {
background-color: rgba( #fff, 0.0);
}
&.dismissed {
position: fixed;
width: 0;
height: 0;
left: -10000px;
top: -10000px;
right: auto;
bottom: auto;
overflow: hidden;
}
}
.spinner-wrapper,
.spinner {
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.spinner {
padding: 10rpx;
opacity: 1;
transition: opacity 1200ms ease-out;
&.reverse,
&.dismissed {
opacity: 0;
}
}
.spinner-icon {
width: 38px;
height: 38px;
animation: spinner-rotation 600ms linear infinite;
}
@keyframes spinner-rotation {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}

@ -0,0 +1,37 @@
.single-cover {
position: absolute;
bottom: 0;
top: 0;
margin: auto;
height: 220rpx;
right: 20rpx;
max-width: 50%;
min-width: 300rpx;
padding: 25rpx;
background: rgba(#000, .7);
border-radius: 20rpx;
line-height: 40rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
view:first-child {
font-weight: bold;
color: #fff;
}
view:not(:first-child) {
background: #fff;
border-radius: 10rpx;
padding: 15rpx;
}
}
.correct{
}
.mistake{
}

@ -1,9 +1,101 @@
import {FC} from "react"; import {FC, useEffect, useMemo, useState} from "react";
import {View} from "@tarojs/components"; import {View} from "@tarojs/components";
import Taro from "@tarojs/taro";
import styles from './single.module.scss'
interface Props {
full: boolean
topic?: ShareSubject
examination: (result: boolean) => void
}
type AnswerType = "true" | 'false'
export const Single: FC<Props> = (props) => {
if (!props.topic) return (<></>)
let timer: NodeJS.Timeout
const [lastState, setLastState] = useState<0 | 1>(0) // 0为竖屏,1为横屏
const [result, setResult] = useState<AnswerType | undefined>(undefined)
let lastTime = Date.now()
Taro.startAccelerometer()
Taro.onAccelerometerChange((res) => {
const now = Date.now();
if (now - lastTime < 500) return;
lastTime = now;
let nowState;
const Roll = Math.atan2(-res.x, Math.sqrt(res.y * res.y + res.z * res.z)) * 57.3;
const Pitch = Math.atan2(res.y, res.z) * 57.3;
// 横屏状态
if (Roll > 50) {
if ((Pitch > -180 && Pitch < -60) || (Pitch > 130)) {
nowState = 1;
} else {
nowState = lastState;
}
} else if ((Roll > 0 && Roll < 30) || (Roll < 0 && Roll > -30)) {
let absPitch = Math.abs(Pitch);
// 如果手机平躺,保持原状态不变,40容错率
if ((absPitch > 140 || absPitch < 40)) {
nowState = lastState;
} else if (Pitch < 0) {
// 收集竖向正立的情况
nowState = 0;
} else {
nowState = lastState;
}
} else {
nowState = lastState;
}
// 状态变化时,触发
if (nowState !== lastState) {
setLastState(nowState);
}
})
useEffect(() => {
// timer = setTimeout(() => {
// props.examination(false)
// }, 4000)
}, [props.topic])
const style: React.CSSProperties = useMemo(() => ({
transform: lastState === 0 && props.full ? "rotate(-90deg)" : 'none'
}), [lastState])
function examination(result: AnswerType) {
clearTimeout(timer)
setResult(result)
setTimeout(() => {
props.examination(props.topic?.right_value === result)
setResult(undefined)
}, 2000)
}
function judgment(answer: AnswerType): string {
if (props.topic?.right_value === answer && result === answer) {
return styles.correct
}
return styles.mistake
}
export const Single:FC = ()=>{
return ( return (
<View>1</View> <View className={styles.singleCover} style={style}>
<View>{props.topic.question}</View>
<View className={judgment("true")}
onClick={() => examination("true")}>
{props.topic.right_value}
</View>
<View className={judgment("false")}
onClick={() => examination("false")}>
{props.topic.error_value}
</View>
</View>
) )
} }

@ -12,9 +12,11 @@ export interface HVideoOptions {
/** 视频断点 */ /** 视频断点 */
breakpoint: number[] breakpoint: number[]
/** 进入断点 */ /** 进入断点 */
onBreakpoint: (id: number) => void onBreakpoint: (time: number) => void
/** 视频播放结束 */ /** 视频播放结束 */
onEnded: () => void onEnded: () => void
setTime: (fn: (time: number) => void) => void // setTime: (fn: (time: number) => void) => void
/** 全屏改变 */
fullChange: (fullScreen: boolean) => void
children?: ReactNode children?: ReactNode
} }

@ -44,26 +44,17 @@ const HVideo: FC<HVideoOptions> = (opt: HVideoOptions) => {
/** 判断是否进入断点 */ /** 判断是否进入断点 */
opt.breakpoint.forEach(d => { opt.breakpoint.forEach(d => {
if (time < d + deviation && time > d - deviation) { if (time < d + deviation && time > d - deviation) {
video?.pause()
if (process.env.TARO_ENV === 'h5') {
try {
document?.exitFullscreen().then()
} catch (e) {
}
}
video?.exitFullScreen()
opt.onBreakpoint(d) opt.onBreakpoint(d)
return
} }
}) })
} }
opt.setTime((time?: number) => { // opt.setTime((time?: number) => {
if (typeof time === 'number') { // if (typeof time === 'number') {
video?.seek(time) // video?.seek(time)
} // }
video?.play() // video?.play()
}) // })
function onEnded() { function onEnded() {
if (currentTime + 1 > opt.duration) { if (currentTime + 1 > opt.duration) {
@ -118,11 +109,11 @@ const HVideo: FC<HVideoOptions> = (opt: HVideoOptions) => {
poster={opt?.poster || ''} poster={opt?.poster || ''}
src={opt.src} src={opt.src}
enableProgressGesture={opt.preview} enableProgressGesture={opt.preview}
direction={90}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onEnded={onEnded} onEnded={onEnded}
onPlay={onPlay} onPlay={onPlay}
onPause={onPause} onPause={onPause}
onFullScreenChange={(event) => opt.fullChange((event.target as any).fullScreen)}
> >
{opt.children} {opt.children}
</Video> </Video>

@ -1,14 +1,9 @@
import {CoverView, ScrollView, Text, View} from "@tarojs/components";
import {FC, useEffect, useState} from "react"; import {FC, useEffect, useState} from "react";
import HVideo from "@/components/video/video"; import HVideo from "@/components/video/video";
import {curriculum, HourPlayData} from "@/api"; import {curriculum, HourPlayData} from "@/api";
import {Profile} from '@/store' import {Profile} from '@/store'
import Taro from "@tarojs/taro"; import Taro from "@tarojs/taro";
import Judge from "@/components/topic/judge";
import unique_ident from "@/hooks/unique_ident"; import unique_ident from "@/hooks/unique_ident";
import MyButton from "@/components/button/MyButton";
import {formatMinute} from "@/utils/time";
import CustomPageContainer from "@/components/custom-page-container/custom-page-container";
import Single from "@/components/topic/single"; import Single from "@/components/topic/single";
interface Props { interface Props {
@ -19,25 +14,24 @@ interface Props {
} }
let seek: (time: number) => void
const Course: FC<Props> = ({id, courseId, preview, curEnd}) => { const Course: FC<Props> = ({id, courseId, preview, curEnd}) => {
const [breakpoint, setBreakpoint] = useState<number[]>([]) // 断点 const [breakpoints, setBreakpoints] = useState<number[]>([]) // 断点
const [show, setShow] = useState(false) // 题 const [isFull, setIsFull] = useState(false)
const [examAll, setExamAll] = useState<Record<number, ShareSubject[]>>([]) // 题库
const [data, setData] = useState<HourPlayData | null>(null) const [data, setData] = useState<HourPlayData | null>(null)
const [examAll, setExamAll] = useState<Record<number, (ShareSubject | Multi)[]>>([]) // 题库
const [time, setTime] = useState<number>(0) // 进入断点的时间 const [time, setTime] = useState<number>(0) // 进入断点的时间
const [validate, setValidate] = useState(false) // 开启验证
const [record, setRecord] = useState<boolean[]>([]) // 考题记录
const [testId, setTestId] = useState<number | null>(null) const [testId, setTestId] = useState<number | null>(null)
const {user} = Profile.useContainer() const {user} = Profile.useContainer()
async function onEnded() { async function onEnded() {
// 学习记录
const startRecording = unique_ident.get() const startRecording = unique_ident.get()
startRecording && await curriculum.curEnd(courseId, id, {...startRecording, duration: data?.duration!}) // 结束 startRecording && await curriculum.curEnd(courseId, id, {...startRecording, duration: data?.duration!}) // 结束
unique_ident.remove() unique_ident.remove()
if (testId) { if (testId) {
if (preview) { // 预览 if (preview) {
Taro.showModal({ Taro.showModal({
title: "是否前往考试", title: "是否前往考试",
success({confirm}) { success({confirm}) {
@ -58,65 +52,33 @@ const Course: FC<Props> = ({id, courseId, preview, curEnd}) => {
} }
} }
/** 进入断点 */
function onBreakpoint(breakpoint: number) {
setTime(breakpoint)
setShow(true)
}
async function getData() { async function getData() {
unique_ident.put(id, Date.now()) unique_ident.put(id, Date.now())
const res = await curriculum.hourPlay(courseId, id) const res = await curriculum.hourPlay(courseId, id)
if (res) { if (res) {
setData(res) setData(res)
setBreakpoint(res.timeList) setBreakpoints(res.timeList)
setExamAll(res.hourExamQuestions || []) setExamAll(res.hourExamQuestions || [])
setTestId(res?.hour_test?.id || null) setTestId(res?.hour_test?.id || null)
} }
} }
useEffect(() => { useEffect(() => {
init()
getData() getData()
}, [id]) }, [id])
function examination(result: boolean) {
function init(show = true) {
show && setShow(false)
setValidate(false)
setRecord([])
setTime(0)
}
useEffect(() => {
if (!record.length) return;
const pass = record.every(d => d)
/** 考题正确 */
const {id: question_id, question_type} = examAll?.[time]?.[0] const {id: question_id, question_type} = examAll?.[time]?.[0]
curriculum.answerRecord(id, { curriculum.answerRecord(id, {
is_pass: pass, is_pass: result,
user_id: user?.id!, user_id: user?.id!,
time: time, time: time,
question_type, question_type,
question_id question_id
}).then() })
setTime(0)
/** 删除断点 */
const old: number[] = JSON.parse(JSON.stringify(breakpoint))
const index = old.indexOf(time)
old.splice(index, 1)
setBreakpoint(old)
if (pass) {
seek(time)
init()
} }
}, [record])
function videoSeek(fn: (time: number) => void) {
seek = fn
}
return ( return (
<> <>
@ -125,46 +87,15 @@ const Course: FC<Props> = ({id, courseId, preview, curEnd}) => {
preview={preview} preview={preview}
src={data?.url || ''} src={data?.url || ''}
onEnded={onEnded} onEnded={onEnded}
breakpoint={breakpoint} breakpoint={breakpoints}
onBreakpoint={onBreakpoint} onBreakpoint={(time) => setTime(time)}
setTime={videoSeek} fullChange={(fullScreen) => setIsFull(fullScreen)}
> >
{/*<CoverView className='single-cover'>*/} <Single
{/* <Single/>*/} full={isFull}
{/*</CoverView>*/} examination={examination}
topic={examAll?.[time]?.[0]}/>
</HVideo> </HVideo>
<CustomPageContainer show={show} position='bottom'>
<View>
<View className='text-center mt-2 text-muted'>
<Text className='mr-2'>{formatMinute(time)}</Text>
</View>
{
examAll?.[time]?.slice(0, 1)?.map((d) =>
<ScrollView style='height:60vh' scrollY key={d.id}>
{d.question_type === 2 &&
<Judge
data={d as ShareSubject}
validate={validate}
onAnswer={(isAnswer) => setRecord([isAnswer])}
/>}
</ScrollView>)
}
<View>
<View className='statistics'>
{
record.length > 0
? <MyButton fillet onClick={() => {
init();
seek(time)
}}></MyButton>
: <MyButton fillet onClick={() => setValidate(true)}></MyButton>
}
</View>
</View>
</View>
</CustomPageContainer>
</> </>
) )
} }

@ -106,8 +106,4 @@
filter: saturate(0); filter: saturate(0);
} }
.single-cover {
position: absolute;
color: #fff;
}

@ -164,10 +164,8 @@ const FeatureRecommended: FC<Props> = (props) => {
onClick={() => jump(d.detailsUrl + c.path, c.id, d.type)}> onClick={() => jump(d.detailsUrl + c.path, c.id, d.type)}>
<View style={{position:'relative'}}> <View style={{position:'relative'}}>
<View className={styles.featureImage} > <View className={styles.featureImage} >
<Img src={c.imageUrl} height={100} width={140}></Img> <Img src={c.imageUrl} height={100} width={140}/>
</View> </View>
{/*<Image src={c.imageUrl} className={styles.featureImage} mode='aspectFill'/>*/}
<Image src={[first, second, third][index]} className={styles.ranking} mode='aspectFill'/> <Image src={[first, second, third][index]} className={styles.ranking} mode='aspectFill'/>
</View> </View>
<View className={styles.featureText}> <View className={styles.featureText}>

@ -4,30 +4,21 @@ import {brandApi, BrandRecord} from "@/api";
import styles from './list.module.scss' import styles from './list.module.scss'
import Taro, {useReachBottom} from "@tarojs/taro"; import Taro, {useReachBottom} from "@tarojs/taro";
import Empty from "@/components/empty/empty"; import Empty from "@/components/empty/empty";
import {AtActivityIndicator} from "taro-ui"; import Spinner from "@/components/spinner";
const BrandList: FC = () => { const BrandList: FC = () => {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [brands, setBrands] = useState<BrandRecord[]>([]) const [brands, setBrands] = useState<BrandRecord[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [text, setText] = useState('') const [text, setText] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
Taro.showLoading({
title: '加载中',
mask: true
})
setTimeout(function () {
Taro.hideLoading()
}, 650)
getData() getData()
}, [page]) }, [page])
const getData = useCallback(async () => { const getData = useCallback(async () => {
try { try {
setText('加载中...')
const res = await brandApi.list(page, 10) const res = await brandApi.list(page, 10)
if (page === 1) { if (page === 1) {
if (res.list.length < 10) { if (res.list.length < 10) {
@ -43,6 +34,7 @@ const BrandList: FC = () => {
]) ])
} catch (e) { } catch (e) {
} }
setLoading(false)
}, [page]) }, [page])
@ -61,6 +53,7 @@ const BrandList: FC = () => {
return ( return (
<View className='p-2' style={{display: text ? 'block' : 'none'}}> <View className='p-2' style={{display: text ? 'block' : 'none'}}>
<Spinner enable={loading} overlay/>
{ {
brands.length ? brands.length ?
<> <>
@ -72,12 +65,7 @@ const BrandList: FC = () => {
</View> </View>
</View>) </View>)
} }
{ text === '加载中...' ?
<View style={{width:'710rpx',display:'flex',justifyContent:'center'}} className="mt-3">
<AtActivityIndicator color={'#999'} content='加载中...'></AtActivityIndicator>
</View>:
<View style={{width: '710rpx', textAlign: 'center', color: '#999'}} className="font-28 mt-3">{text}</View> <View style={{width: '710rpx', textAlign: 'center', color: '#999'}} className="font-28 mt-3">{text}</View>
}
</> : <Empty name='空空如也'/> </> : <Empty name='空空如也'/>
} }
</View> </View>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Loading…
Cancel
Save