Explorar o código

feat(front): 水印

Go %!s(int64=4) %!d(string=hai) anos
pai
achega
6cbc61c445

+ 4 - 0
front/project/www/app.less

@@ -202,6 +202,10 @@
   cursor: pointer;
 }
 
+.c-w {
+  color: white;
+}
+
 .w-1 {
   width: 10%;
 }

+ 2 - 1
front/project/www/components/Item/index.js

@@ -43,7 +43,7 @@ export class SingleItem extends Component {
     const { add } = this.state;
     return (
       <div className="single-item">
-        <div className="img" onClick={() => linkTo(`/course/detail/${data.id}`)}>
+        <div className="img c-p" style={{ backgroundImage: `url(${data.cover})` }} onClick={() => linkTo(`/course/detail/${data.id}`)}>
           <div className="title">
             <div className="tag">{CrowdMap[data.crowd]}</div>
             <Link className='f-w-b' to={`/course/detail/${data.id}`} target="_blank">{data.title}</Link>
@@ -243,6 +243,7 @@ export class DataItem extends Component {
           width={264}
           height={309}
           name=""
+          className="c-p"
           src={data.cover}
           onClick={() => linkTo(`/course/data/detail/${data.id}`)}
         />

+ 44 - 18
front/project/www/components/Video/index.js

@@ -3,7 +3,7 @@ import { Slider } from 'antd';
 import './index.less';
 import videojs from 'video.js';
 import Assets from '@src/components/Assets';
-import { generateUUID } from '@src/services/Tools';
+import { generateUUID, formatSecondAuto } from '@src/services/Tools';
 
 function fullScreen(id) {
   const element = document.getElementById(id);
@@ -99,7 +99,7 @@ export default class Video extends Component {
     this.timeInterval = setInterval(() => {
       const { onTimeUpdate } = this.props;
       if (onTimeUpdate) onTimeUpdate(this.player.currentTime());
-      // this.setState({ progress: this.player.currentTime() * 100 / this.player.duration() });
+      this.setState({ progress: this.player.currentTime() * 100 / this.player.duration() });
     }, 1000);
   }
 
@@ -107,6 +107,12 @@ export default class Video extends Component {
     if (!this.ready) return;
     this.player.currentTime((this.player.duration() * value) / 100);
     this.setState({ progress: (this.player.currentTime() * 100) / this.player.duration() });
+    const { onChangeProgress } = this.props;
+    const { playing } = this.state;
+    if (!playing) {
+      this.player.pause();
+    }
+    if (onChangeProgress) onChangeProgress(this.player.currentTime());
   }
 
   onPlay() {
@@ -114,7 +120,7 @@ export default class Video extends Component {
     const { onPlay } = this.props;
     this.player.play();
     this.setState({ playing: true });
-    if (onPlay) onPlay();
+    if (onPlay) onPlay(this.player.currentTime());
     this.refreshTimeUpdate();
   }
 
@@ -123,7 +129,7 @@ export default class Video extends Component {
     const { onPause } = this.props;
     this.player.pause();
     this.setState({ playing: false });
-    if (onPause) onPause();
+    if (onPause) onPause(this.player.currentTime());
     this.clearTimeUpdate();
   }
 
@@ -158,24 +164,46 @@ export default class Video extends Component {
     exitFullscreen();
   }
 
+  showProgressTip(e) {
+    let x = e.clientX;
+    const percent = x * 100 / this.progress.clientWidth;
+    const text = percent > 0 ? formatSecondAuto(percent * this.player.duration() / 100) : '00:00';
+    const width = text.length > 5 ? 67.8 : 47.3;
+    x += 1;
+    x -= width / 2;
+    if (x < 0) {
+      x = 0;
+    } else if (x + width > this.progress.clientWidth) {
+      x = this.progress.clientWidth - width;
+    }
+    this.setState({ pt: { left: x, display: 'block', text, width } });
+  }
+
+  hideProgressTip() {
+    this.setState({ pt: {} });
+  }
+
   render() {
-    const { btnList = [], children, onAction, hideAction } = this.props;
+    const { btnList = [], children, onAction, hideAction, water } = this.props;
     const { playing, fulling, id, selectSpeed, speed } = this.state;
     return (
       <div id={id} className={`video-item ${!hideAction ? 'action' : ''} ${fulling ? 'full' : ''}`}>
-        <div className="video-wrapper">
+        <div className="video-wrapper c-p" onClick={() => {
+          return playing ? this.onPause() : this.onPlay();
+        }}>
           <video
             ref={node => {
               this.videoNode = node;
             }}
           // vjs-fluid
           />
-          {!playing && <Assets className="play" name="play" onClick={() => this.onPlay()} />}
-          {playing && <Assets className="stop" name="stop" onClick={() => this.onPause()} />}
+          {water}
+          {!playing && <Assets className="play c-p" name="play" />}
+          {playing && <Assets className="stop c-p" name="stop" />}
         </div>
         <div className="video-bottom">
-          <div className="progress" />
-          {/* {this.renderProgress()} */}
+          {/* <div className="progress" /> */}
+          {this.renderProgress()}
           {!hideAction && (
             <div className="action-bar">
               <div className="d-i-b m-r-1">
@@ -188,8 +216,9 @@ export default class Video extends Component {
               <div className="d-i-b m-r-1">
                 <Assets name="next2" onClick={() => this.onNext()} />
               </div>
-              {/* <div className="m-r-1">{this.ready ? (formatMinuteSecond(this.player.currentTime())) : ('00:00')}</div>
-            <div className="m-r-1">/{this.ready ? (formatMinuteSecond(this.player.duration())) : ('00:00')}</div> */}
+              <div className="m-r-1 c-w">{this.ready ? (formatSecondAuto(this.player.currentTime())) : ('00:00')}</div>
+              <div className="m-r-1 c-w">/</div>
+              <div className="m-r-1 c-w">{this.ready ? (formatSecondAuto(this.player.duration())) : ('00:00')}</div>
               <div className="flex-block" />
               {btnList.map(btn => {
                 if (btn.full && !fulling) return '';
@@ -209,10 +238,7 @@ export default class Video extends Component {
                     ) : (<div
                       className={`btn-action ${btn.active ? 'active' : ''}`}
                       onClick={() => onAction && onAction(btn.key)}
-                    >
-                      {btn.title}
-                    </div>
-                    )}
+                    >{btn.title}</div>)}
                   </div>
                 );
               })}
@@ -257,10 +283,10 @@ export default class Video extends Component {
 
   renderProgress() {
     const { hideProgress } = this.props;
-    const { progress } = this.state;
+    const { progress, pt = {} } = this.state;
     return (
       !hideProgress && (
-        <Slider value={progress || 0} tooltipVisible={false} onChange={value => this.onChangeProgress(value)} />
+        <div ref={ref => { if (ref) this.progress = ref; }} onMouseMove={(e) => this.showProgressTip(e)} onMouseLeave={() => this.hideProgressTip()}><Slider value={progress || 0} step={0.01} tooltipVisible={false} onChange={value => this.onChangeProgress(value)} /><div className={'show-progress-tip'} style={{ ...pt }}>{pt.text}</div></div>
       )
     );
   }

+ 31 - 15
front/project/www/components/Video/index.less

@@ -6,24 +6,25 @@
   overflow: hidden;
   background: #3A3A3AFF;
 
-  // .video-wrapper {
+  .video-wrapper {
 
-  //   .vjs-loading-spinner {
-  //     display: none;s
-  //   }
+    .vjs-loading-spinner {
+      display: none;
+    }
 
-  //   .vjs-big-play-button {
-  //     display: none;
-  //   }
+    .vjs-big-play-button {
+      display: none;
+    }
+
+    .vjs-control-bar {
+      display: none;
+    }
 
-  //   .vjs-control-bar {
-  //     display: none;
-  //   }
+    .vjs-modal-dialog {
+      display: none;
+    }
+  }
 
-  //   .vjs-modal-dialog {
-  //     display: none;
-  //   }
-  // }
   .select-speed {
     position: absolute;
     bottom: 55px;
@@ -52,6 +53,7 @@
     bottom: 0;
     left: 0;
     right: 0;
+    user-select: none;
   }
 
   .ant-slider {
@@ -68,8 +70,12 @@
 
     .ant-slider-handle {
       border: none;
-      background-color: transparent;
+      // background-color: transparent;
       box-shadow: none;
+      width: 4px;
+      height: 4px;
+      margin-top: 0;
+      margin-left: -2px;
     }
 
     .ant-slider-handle-click-focused {
@@ -82,6 +88,16 @@
     background: #616161FF;
   }
 
+  .show-progress-tip {
+    display: none;
+    position: absolute;
+    top: -26px;
+    color: #fff;
+    padding: 2px 5px;
+    background: rgba(0, 0, 0, 0.5);
+    text-align: center;
+  }
+
   .action-bar {
     height: 50px;
     line-height: 50px;

+ 29 - 0
front/project/www/routes/course/detail/index.less

@@ -47,10 +47,39 @@
           height: 520px;
           background: #3A3A3AFF;
 
+          .video-water {
+            display: none;
+            position: absolute;
+            font-size: 2%;
+            color: #fff;
+            width: 100%;
+            user-select: none;
+            transition-timing-function: linear;
+
+            &.style-1 {
+              display: block;
+              top: 17%;
+            }
+
+            &.style-2 {
+              display: block;
+              top: 50%;
+            }
+
+            &.style-3 {
+              display: block;
+              top: 83%;
+            }
+          }
+
           .video-item.full {
             .video-fixed {
               display: block;
             }
+
+            .video-water {
+              font-size: 16px;
+            }
           }
 
           .video-fixed {

+ 60 - 7
front/project/www/routes/course/detail/page.js

@@ -198,7 +198,8 @@ export default class extends Page {
     const { id } = this.params;
     Course.detail(id).then(result => {
       result = this.formatRecord(result);
-      result.have = true;
+      // result.have = true;
+      // result.waters = ['高*', '152****4895'];
       this.setState({ data: result });
       // 选择课时
       if (this.state.search.no) {
@@ -288,18 +289,68 @@ export default class extends Page {
       this.updateProgress(item.id, second, item.time);
     }
     this.lastSecond = second;
+    this.showWater(second);
   }
 
-  playVideo() {
+  playVideo(second) {
     // 开始计时
     this.lastTime = new Date();
+    this.showWater(second);
   }
 
-  pauseVideo() {
+  showWater(second, extend) {
+    const { data, water = {} } = this.state;
+    const { waters = [] } = data;
+    if (!this.lastTime && water.show) {
+      water.show = false;
+      second -= 1;
+      water.transition = '';
+    } else if (!water.show) {
+      water.show = true;
+      water.transition = 'transform 1s linear 0s';
+    }
+    if (water.show) {
+      water.opacity = 1;
+    } else {
+      water.opacity = 0;
+    }
+    if (extend) {
+      Object.assign(water, extend);
+    }
+    const time = 15;
+    const stop = 0;
+    const times = (second / (time + stop));
+    const index = parseInt(times % waters.length, 10);
+    const current = (second % (time + stop));
+    water.style = [2, 0, 2, 1][parseInt((times % 4), 10)] + 1;
+    water.text = waters[[0, 1][index]];
+    // console.log(water.show, current, current / 4, (current / 4) % 2);
+    if (water.show) {
+      water.opacity = (current / 4) % 2 > 1 ? 0 : 1;
+    }
+    // if (water.style % 2) {
+    //   water.transform = `translateX(${current * 100 / time}%)`;
+    // } else {
+    water.transform = `translateX(${100 - ((current - stop / 2) * 100 / time)}%)`;
+    // }
+    this.setState({ water });
+  }
+
+  onChangeProgress(second) {
+    this.showWater(second - 1, this.lastTime ? { transition: '', opacity: 0 } : { transition: '', opacity: 0 });
+    setTimeout(() => {
+      this.showWater(second, this.lastTime ? { transition: 'transform 1s linear 0s', opacity: 1 } : { transition: '', opacity: 0 });
+    }, 1);
+  }
+
+  pauseVideo(second) {
     // 停止计时
     const now = new Date();
-    this.time += (now.getTime() - this.lastTime.getTime()) / 1000;
+    if (this.lastTime != null) {
+      this.time += (now.getTime() - this.lastTime.getTime()) / 1000;
+    }
     this.lastTime = null;
+    this.showWater(second);
   }
 
   next() {
@@ -371,7 +422,7 @@ export default class extends Page {
   }
 
   renderView() {
-    const { base = {}, data = {}, item = {}, add, rightTab, showTab, showAsk, showNote, dataStructMap = {}, showComment, comment = {}, showFaq, faq = {}, showFinish, note = {}, ask = {}, timelineSelect = [] } = this.state;
+    const { base = {}, data = {}, item = {}, add, rightTab, showTab, showAsk, showNote, dataStructMap = {}, showComment, comment = {}, showFaq, faq = {}, showFinish, note = {}, ask = {}, timelineSelect = [], water = {} } = this.state;
     const { courseNos = [] } = data;
     return (
       <div>
@@ -413,6 +464,7 @@ export default class extends Page {
                   width={750}
                   height={467}
                   ref={ref => this.setVideo(ref)}
+                  water={<div key={water.style} className={`video-water style-${water.style}`} style={{ ...water }}>{water.text}</div>}
                   btnList={[
                     { title: '提问', key: 'ask', show: data.have, active: showAsk, pause: true },
                     {
@@ -427,8 +479,9 @@ export default class extends Page {
                     { title: '笔记', key: 'note', show: data.have, active: showNote, pause: true },
                     { title: '课表', key: 'list', show: true, full: true, active: showTab && rightTab === '2' },
                   ]}
-                  onPlay={() => this.playVideo()}
-                  onPause={() => this.pauseVideo()}
+                  onPlay={(second) => this.playVideo(second)}
+                  onPause={(second) => this.pauseVideo(second)}
+                  onChangeProgress={(second) => this.onChangeProgress(second)}
                   onNext={() => this.next()}
                   onAction={key => this.onVideoAction(key)}
                   onTimeUpdate={time => this.onTimeUpdate(time)}

+ 2 - 2
front/project/www/routes/course/main/page.js

@@ -61,11 +61,11 @@ export default class extends Page {
           <div className="main-title">找到你的Style</div>
           <div className="video-list">
             <div className="video-div">
-              <Video src={courseIndex.onlineVideo || '/1.mp4'} width={580} height={360} hideAction />
+              <Video src={courseIndex.onlineVideo || '/1.mp4'} width={580} height={360} hideAction hideProgress />
               <div className="name" onClick={() => linkTo('/course/online')}>在线课程 ></div>
             </div>
             <div className="video-div">
-              <Video src={'/1.mp4'} width={580} height={360} hideAction />
+              <Video src={courseIndex.vsVideo || '/1.mp4'} width={580} height={360} hideAction hideProgress />
               <div className="name" onClick={() => linkTo('/course/vs')}>1v1私教 ></div>
             </div>
           </div>

+ 33 - 0
front/src/services/Tools.js

@@ -271,6 +271,39 @@ export function formatMinuteSecond(value) {
   return `${minuteTime}:${secondTime}`;
 }
 
+export function formatSecondAuto(value) {
+  let secondTime = parseInt(value || 0, 10); // 秒
+  let minuteTime = 0;
+  let hourTime = 0;
+  if (secondTime > 60) {
+    minuteTime = parseInt(secondTime / 60, 10);
+    secondTime = parseInt(secondTime % 60, 10);
+  }
+  if (minuteTime > 60) {
+    hourTime = parseInt(minuteTime / 60, 10);
+    minuteTime = parseInt(minuteTime % 60, 10);
+  }
+  if (hourTime >= 10) {
+    hourTime = `${hourTime}`;
+  } else {
+    hourTime = `0${hourTime}`;
+  }
+  if (minuteTime >= 10) {
+    minuteTime = `${minuteTime}`;
+  } else {
+    minuteTime = `0${minuteTime}`;
+  }
+  if (secondTime >= 10) {
+    secondTime = `${secondTime}`;
+  } else {
+    secondTime = `0${secondTime}`;
+  }
+  if (hourTime > 0) {
+    return `${hourTime}:${minuteTime}:${secondTime}`;
+  }
+  return `${minuteTime}:${secondTime}`;
+}
+
 export function formatFormError(data, err, prefix = '') {
   const r = {};
   Object.keys(err).forEach(field => {

+ 1 - 0
server/data/src/main/java/com/qxgmat/data/relation/UserOrderRecordRelationMapper.java

@@ -20,6 +20,7 @@ public interface UserOrderRecordRelationMapper {
             @Param("structId") Integer structId,
             @Param("courseId") Integer courseId,
             @Param("userId") Integer userId,
+            @Param("teacher") String teacher,
             @Param("order") String order,
             @Param("direction") String direction
     );

+ 9 - 0
server/data/src/main/java/com/qxgmat/data/relation/mapping/UserOrderRecordRelationMapper.xml

@@ -76,6 +76,9 @@
     获取用户学习记录
   -->
   <select id="listWithStudyAdmin" resultMap="IdMap">
+    <if test="teacher != null">
+      <bind name="teacherLike" value="'%' + teacher + '%'" />
+    </if>
     select
     <include refid="Id_Column_List" />
     from `user_order_record` uor
@@ -86,17 +89,23 @@
         #{item, jdbcType=VARCHAR}
       </foreach>
     </if>
+    left join `course_teacher` on ct.`id` = uor.`teacher_id`
     <if test="structId != null">
       and (c.`struct_id` = #{structId,jdbcType=VARCHAR} or c.`parent_struct_id` = #{structId,jdbcType=VARCHAR})
     </if>
     <if test="courseId != null">
       and c.`id` = #{courseId,jdbcType=VARCHAR}
     </if>
+    <if test="teacher != null">
+      and (c.`teacher` like #{teacherLike,jdbcType=VARCHAR}
+      or ct.`realname` like #{teacherLike,jdbcType=VARCHAR})
+    </if>
     where
     c.`id` &gt; 0
     <if test="userId != null">
       and uor.`user_id` = #{userId,jdbcType=VARCHAR}
     </if>
+    group by
     order by ${order} ${direction}
   </select>
 

+ 2 - 0
server/gateway-api/src/main/java/com/qxgmat/controller/admin/CommonController.java

@@ -55,6 +55,8 @@ public class CommonController {
             JSONObject videoInfo = new JSONObject();
             videoInfo.put("url", url);
             videoInfo.put("time", videoHelp.getVideoTime(dest.getAbsolutePath()));
+            // 生成hls文件用于播放
+            videoHelp.generateHLS(dest.getAbsolutePath());
             return ResponseHelp.success(videoInfo);
         } catch (IOException e) {
             e.printStackTrace();

+ 6 - 2
server/gateway-api/src/main/java/com/qxgmat/controller/api/MyController.java

@@ -64,6 +64,9 @@ public class MyController {
     @Autowired
     private PdfHelp pdfHelp;
 
+    @Autowired
+    private VideoHelp videoHelp;
+
     @Value("${upload.local_path}")
     private String localPath;
 
@@ -2217,10 +2220,11 @@ public class MyController {
         }
         try {
             String resource = courseNo.getResource();
+//            String fileUrl = videoHelp.getHLS(resource);
 //            String fileName = pdfHelp.generatePdfImage(user, resource, false);
 //            String fileUrl = pdfHelp.getOfflineUrl(fileName);
-            response.setHeader("content-disposition","attachment;filename="+Tools.stringMD5(resource)+resource.substring(resource.lastIndexOf(".")));
-            response.setHeader("content-type", "application/octet-stream");
+//            response.setHeader("content-disposition","attachment;filename="+Tools.stringMD5(resource)+resource.substring(resource.lastIndexOf(".")));
+//            response.setHeader("content-type", "application/octet-stream");
             response.setHeader("X-Accel-Redirect", resource);
 //            FileInputStream fileInputStream = new FileInputStream(fileName);
 //            ServletOutputStream outputStream = response.getOutputStream();

+ 57 - 0
server/gateway-api/src/main/java/com/qxgmat/help/VideoHelp.java

@@ -1,11 +1,13 @@
 package com.qxgmat.help;
 
+import com.nuliji.tools.Tools;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.io.BufferedReader;
+import java.io.File;
 import java.io.InputStreamReader;
 import java.util.List;
 import java.util.regex.Matcher;
@@ -18,6 +20,61 @@ public class VideoHelp {
     @Value("${video.ffmpeg}")
     private String ffmpegPath;
 
+    @Value("${upload.local_path}")
+    private String localPath;
+
+    @Value("${upload.web_url}")
+    private String webUrl;
+
+    @Value("${upload.hls_path}")
+    private String hlsPath;
+
+    @Value("${upload.hls_url}")
+    private String hlsUrl;
+
+    public String getHLS(String videoUrl){
+        return videoUrl.replace(webUrl, hlsUrl+"/"+"video.m3u8");
+    }
+
+    public void generateHLS(String videoPath){
+        File file = new File(videoPath);
+        String fileName = file.getName();
+        File dir = new File(hlsPath);
+        if (!dir.exists()) {
+            file.mkdirs();
+        }
+        File dest = new File( dir.getAbsolutePath() + File.separator+fileName+File.separator+"video.m3u8");
+
+        File dirs = new File(dest.getAbsolutePath());
+        if (!dirs.exists()){
+            dirs.mkdirs();
+        }
+
+        List<String> commands = new java.util.ArrayList<String>();
+        commands.add(ffmpegPath);
+        commands.add("-i");
+        commands.add(videoPath);
+        commands.add("-c:v libx264 -c:a aac -strict -2 -f hls "+dest);
+        try {
+            ProcessBuilder builder = new ProcessBuilder();
+            builder.command(commands);
+            final Process p = builder.start();
+
+            //从输入流中读取视频信息
+            BufferedReader br = new BufferedReader(new InputStreamReader(p.getErrorStream()));
+            StringBuffer sb = new StringBuffer();
+            String line = "";
+            while ((line = br.readLine()) != null) {
+                sb.append(line);
+            }
+            br.close();
+            logger.debug("videoPath: {}", videoPath);
+            logger.debug("ffmpeg: {}", sb.toString());
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
     public int getVideoTime(String videoPath) {
         List<String> commands = new java.util.ArrayList<String>();
         commands.add(ffmpegPath);

+ 1 - 1
server/gateway-api/src/main/java/com/qxgmat/service/inline/UserOrderRecordService.java

@@ -593,7 +593,7 @@ public class UserOrderRecordService extends AbstractService {
         String finalOrder = order;
         DirectionStatus finalDirection = direction;
         Page<UserOrderRecord> p = page(
-            ()-> userOrderRecordRelationMapper.listWithStudyAdmin(modules, structId, courseId, userId, finalOrder, finalDirection.key)
+            ()-> userOrderRecordRelationMapper.listWithStudyAdmin(modules, structId, courseId, userId, teacher, finalOrder, finalDirection.key)
         , page, size);
 
         Collection ids = Transform.getIds(p, UserOrderRecord.class, "id");

+ 2 - 0
server/gateway-api/src/main/profile/dev/application-runtime.yml

@@ -88,6 +88,8 @@ upload:
   web_url: /upload/
   offline_path: ../../offline/
   offline_url: /offline/
+  hls_path: ../../hls/
+  hls_url: /hls/
   water: /upload
   font: ./NotoSansCJK-Bold.ttc
 

+ 2 - 0
server/gateway-api/src/main/profile/prod/application-runtime.yml

@@ -88,6 +88,8 @@ upload:
   web_url: /upload/
   offline_path: ../offline/
   offline_url: /offline/
+  hls_path: ../../hls/
+  hls_url: /hls/
   water: /upload
   font: ./NotoSansCJK-Bold.ttc
 

+ 2 - 0
server/gateway-api/src/main/profile/test/application-runtime.yml

@@ -88,6 +88,8 @@ upload:
   web_url: /upload/
   offline_path: ../offline/
   offline_url: /offline/
+  hls_path: ../../hls/
+  hls_url: /hls/
   water: /upload
   font: ./NotoSansCJK-Bold.ttc