diff --git a/src/app.tsx b/src/app.tsx index 14389ff..9e6d8c4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -32,7 +32,6 @@ function updateApp() { function App(props) { - // 可以使用所有的 React Hooks Taro.useLaunch(() => { updateApp() diff --git a/src/components/image/image.tsx b/src/components/image/image.tsx index d3591b8..1ca8d81 100644 --- a/src/components/image/image.tsx +++ b/src/components/image/image.tsx @@ -1,58 +1,58 @@ import {FC, useEffect, useState} from "react"; -import {Image, View} from "@tarojs/components"; -import emptyImg from '@/static/img/empty.png' +import {Image, ImageProps, View} from "@tarojs/components"; import {AtActivityIndicator} from "taro-ui"; +import shard from '@/static/img/shard.png' -interface Props { - src: string - mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "widthFix" | "heightFix" | "top" | "bottom" | "center" | "left" | "right" | "top left" | "top right" | "bottom left" | "bottom right" | undefined - width:number - height:number +interface Props extends ImageProps { + width: number + height: number fallback?: string } -const Img: FC = ({src,mode = 'aspectFill',width,height,fallback = emptyImg}) => { - const [isError,setIsError] = useState(false) - const [loading,setLoading] = useState(true) +const Img: FC = ({src, mode = 'aspectFill', width, height, fallback = shard}) => { + const [isError, setIsError] = useState(false) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!src) { + setIsError(true) + setLoading(false) + } else { + setIsError(false) + setLoading(false) + } + }, [src]) - useEffect(()=>{ - console.log(src, !src) - if(!src){ - setIsError(true) - setLoading(false) - } else { - setIsError(false) - setLoading(false) - } - },[src]) // 图片加载失败 function onErrorHandler() { setLoading(false) setIsError(true) } - function onLoadHandler() { - setLoading(false) - setIsError(false) - } + + function onLoadHandler() { + setLoading(false) + setIsError(false) + } return ( - - { !isError && + + {!isError && } - { loading && - + { + loading && + } - { isError && !loading && - + { + isError && !loading && + } - ) } diff --git a/src/components/spinner/index.tsx b/src/components/spinner/index.tsx new file mode 100644 index 0000000..0ecfb5e --- /dev/null +++ b/src/components/spinner/index.tsx @@ -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, + rotation?: Record, +} + +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 | 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 { + 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, nextState: Readonly): boolean { + return nextProps.enable !== this.props.enable + || nextProps.overlay !== this.props.overlay + || nextState.status != this.state.status + } + + render(): ReactNode { + return ( + + + + + + ) + } +} diff --git a/src/components/spinner/loading.svg b/src/components/spinner/loading.svg new file mode 100644 index 0000000..6a23520 --- /dev/null +++ b/src/components/spinner/loading.svg @@ -0,0 +1,13 @@ + diff --git a/src/components/spinner/style.scss b/src/components/spinner/style.scss new file mode 100644 index 0000000..1ff6261 --- /dev/null +++ b/src/components/spinner/style.scss @@ -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); + } +} diff --git a/src/components/topic/single.module.scss b/src/components/topic/single.module.scss new file mode 100644 index 0000000..c794da4 --- /dev/null +++ b/src/components/topic/single.module.scss @@ -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{ + +} diff --git a/src/components/topic/single.tsx b/src/components/topic/single.tsx index 9a2f92d..030abb7 100644 --- a/src/components/topic/single.tsx +++ b/src/components/topic/single.tsx @@ -1,9 +1,101 @@ -import {FC} from "react"; +import {FC, useEffect, useMemo, useState} from "react"; import {View} from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import styles from './single.module.scss' -export const Single:FC = ()=>{ - return( - 1 +interface Props { + full: boolean + topic?: ShareSubject + examination: (result: boolean) => void +} + +type AnswerType = "true" | 'false' + +export const Single: FC = (props) => { + if (!props.topic) return (<>) + + let timer: NodeJS.Timeout + const [lastState, setLastState] = useState<0 | 1>(0) // 0为竖屏,1为横屏 + const [result, setResult] = useState(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 + } + + return ( + + {props.topic.question} + examination("true")}> + {props.topic.right_value} + + examination("false")}> + {props.topic.error_value} + + ) } diff --git a/src/components/video/type.ts b/src/components/video/type.ts index a0fc3fa..1c51c51 100644 --- a/src/components/video/type.ts +++ b/src/components/video/type.ts @@ -12,9 +12,11 @@ export interface HVideoOptions { /** 视频断点 */ breakpoint: number[] /** 进入断点 */ - onBreakpoint: (id: number) => void + onBreakpoint: (time: number) => void /** 视频播放结束 */ onEnded: () => void - setTime: (fn: (time: number) => void) => void + // setTime: (fn: (time: number) => void) => void + /** 全屏改变 */ + fullChange: (fullScreen: boolean) => void children?: ReactNode } diff --git a/src/components/video/video.tsx b/src/components/video/video.tsx index 69e9501..55ea43d 100644 --- a/src/components/video/video.tsx +++ b/src/components/video/video.tsx @@ -44,26 +44,17 @@ const HVideo: FC = (opt: HVideoOptions) => { /** 判断是否进入断点 */ opt.breakpoint.forEach(d => { 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) - return } }) } - opt.setTime((time?: number) => { - if (typeof time === 'number') { - video?.seek(time) - } - video?.play() - }) + // opt.setTime((time?: number) => { + // if (typeof time === 'number') { + // video?.seek(time) + // } + // video?.play() + // }) function onEnded() { if (currentTime + 1 > opt.duration) { @@ -118,11 +109,11 @@ const HVideo: FC = (opt: HVideoOptions) => { poster={opt?.poster || ''} src={opt.src} enableProgressGesture={opt.preview} - direction={90} onTimeUpdate={onTimeUpdate} onEnded={onEnded} onPlay={onPlay} onPause={onPause} + onFullScreenChange={(event) => opt.fullChange((event.target as any).fullScreen)} > {opt.children} diff --git a/src/pages/business/videoInfo/components/course.tsx b/src/pages/business/videoInfo/components/course.tsx index 53ba61b..4bf9128 100644 --- a/src/pages/business/videoInfo/components/course.tsx +++ b/src/pages/business/videoInfo/components/course.tsx @@ -1,14 +1,9 @@ -import {CoverView, ScrollView, Text, View} from "@tarojs/components"; import {FC, useEffect, useState} from "react"; import HVideo from "@/components/video/video"; import {curriculum, HourPlayData} from "@/api"; import {Profile} from '@/store' import Taro from "@tarojs/taro"; -import Judge from "@/components/topic/judge"; 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"; interface Props { @@ -19,25 +14,24 @@ interface Props { } -let seek: (time: number) => void const Course: FC = ({id, courseId, preview, curEnd}) => { - const [breakpoint, setBreakpoint] = useState([]) // 断点 - const [show, setShow] = useState(false) // 题 + const [breakpoints, setBreakpoints] = useState([]) // 断点 + const [isFull, setIsFull] = useState(false) + + const [examAll, setExamAll] = useState>([]) // 题库 const [data, setData] = useState(null) - const [examAll, setExamAll] = useState>([]) // 题库 const [time, setTime] = useState(0) // 进入断点的时间 - const [validate, setValidate] = useState(false) // 开启验证 - const [record, setRecord] = useState([]) // 考题记录 const [testId, setTestId] = useState(null) const {user} = Profile.useContainer() async function onEnded() { + // 学习记录 const startRecording = unique_ident.get() startRecording && await curriculum.curEnd(courseId, id, {...startRecording, duration: data?.duration!}) // 结束 unique_ident.remove() if (testId) { - if (preview) { // 预览 + if (preview) { Taro.showModal({ title: "是否前往考试", success({confirm}) { @@ -58,66 +52,34 @@ const Course: FC = ({id, courseId, preview, curEnd}) => { } } - /** 进入断点 */ - function onBreakpoint(breakpoint: number) { - setTime(breakpoint) - setShow(true) - } - async function getData() { unique_ident.put(id, Date.now()) const res = await curriculum.hourPlay(courseId, id) if (res) { setData(res) - setBreakpoint(res.timeList) + setBreakpoints(res.timeList) setExamAll(res.hourExamQuestions || []) setTestId(res?.hour_test?.id || null) } } useEffect(() => { - init() getData() }, [id]) - - function init(show = true) { - show && setShow(false) - setValidate(false) - setRecord([]) - setTime(0) - } - - - useEffect(() => { - if (!record.length) return; - const pass = record.every(d => d) - /** 考题正确 */ + function examination(result: boolean) { const {id: question_id, question_type} = examAll?.[time]?.[0] curriculum.answerRecord(id, { - is_pass: pass, + is_pass: result, user_id: user?.id!, time: time, question_type, question_id - }).then() - - /** 删除断点 */ - 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 + }) + setTime(0) } + return ( <> = ({id, courseId, preview, curEnd}) => { preview={preview} src={data?.url || ''} onEnded={onEnded} - breakpoint={breakpoint} - onBreakpoint={onBreakpoint} - setTime={videoSeek} + breakpoint={breakpoints} + onBreakpoint={(time) => setTime(time)} + fullChange={(fullScreen) => setIsFull(fullScreen)} > - {/**/} - {/* */} - {/**/} + - - - - - {formatMinute(time)}考题 - - { - examAll?.[time]?.slice(0, 1)?.map((d) => - - {d.question_type === 2 && - setRecord([isAnswer])} - />} - ) - } - - - - { - record.length > 0 - ? { - init(); - seek(time) - }}>关闭 - : setValidate(true)}>交卷 - } - - - - ) } diff --git a/src/pages/business/videoInfo/videoInfo.scss b/src/pages/business/videoInfo/videoInfo.scss index 7057f0c..da6c6f2 100644 --- a/src/pages/business/videoInfo/videoInfo.scss +++ b/src/pages/business/videoInfo/videoInfo.scss @@ -106,8 +106,4 @@ filter: saturate(0); } -.single-cover { - position: absolute; - color: #fff; -} diff --git a/src/pages/home/components/feature_recommended.tsx b/src/pages/home/components/feature_recommended.tsx index 2cd2fc9..f482f33 100644 --- a/src/pages/home/components/feature_recommended.tsx +++ b/src/pages/home/components/feature_recommended.tsx @@ -164,10 +164,8 @@ const FeatureRecommended: FC = (props) => { onClick={() => jump(d.detailsUrl + c.path, c.id, d.type)}> - + - {/**/} - diff --git a/src/pages/preview/brand/list/list.tsx b/src/pages/preview/brand/list/list.tsx index f873dc1..cf422a0 100644 --- a/src/pages/preview/brand/list/list.tsx +++ b/src/pages/preview/brand/list/list.tsx @@ -4,35 +4,26 @@ import {brandApi, BrandRecord} from "@/api"; import styles from './list.module.scss' import Taro, {useReachBottom} from "@tarojs/taro"; import Empty from "@/components/empty/empty"; -import {AtActivityIndicator} from "taro-ui"; - - +import Spinner from "@/components/spinner"; const BrandList: FC = () => { const [page, setPage] = useState(1) const [brands, setBrands] = useState([]) const [total, setTotal] = useState(0) const [text, setText] = useState('') + const [loading, setLoading] = useState(true) useEffect(() => { - Taro.showLoading({ - title: '加载中', - mask: true - }) - setTimeout(function () { - Taro.hideLoading() - }, 650) getData() }, [page]) const getData = useCallback(async () => { try { - setText('加载中...') const res = await brandApi.list(page, 10) - if(page === 1){ - if(res.list.length < 10) { + if (page === 1) { + if (res.list.length < 10) { setText('没有更多了~') - }else{ + } else { setText('上拉加载更多~') } } @@ -43,6 +34,7 @@ const BrandList: FC = () => { ]) } catch (e) { } + setLoading(false) }, [page]) @@ -53,32 +45,28 @@ const BrandList: FC = () => { useReachBottom(useCallback(() => { if (brands?.length < total) { setPage(page + 1) - }else{ + } else { setText('没有更多了~') } }, [total, brands])) return ( - + + { brands.length ? <> - { brands.map((d) => jumpInfo(d.id)} className={styles.box} key={d.id}> + {brands.map((d) => jumpInfo(d.id)} className={styles.box} key={d.id}> {d.name} {d.graphic_introduction} ) - } - { text === '加载中...' ? - - - : - {text} } - : + {text} + : } ) diff --git a/src/static/img/shard.png b/src/static/img/shard.png new file mode 100644 index 0000000..2e16c7b Binary files /dev/null and b/src/static/img/shard.png differ