tangning 20 uur geleden
bovenliggende
commit
312bda93f0

+ 0 - 1
src/view/case/download.vue

@@ -142,7 +142,6 @@ onUnmounted(() => clearTimeout(timer));
 
 defineExpose<QuiskExpose>({
   submit: async () => {
-    alert("downloadCaseScene");
     await initial();
     const loading = ElLoading.service({
       lock: true,

+ 435 - 0
src/view/newFireCase/newFireDetails/components/shot copy.vue

@@ -0,0 +1,435 @@
+<template>
+  <teleport :to="appEl" v-if="appEl">
+    <Toolbar :showBottomBar="showBottomBar" class="shot-ctrl">
+      <el-button type="primary" class="btn" @click="close">取消</el-button>
+      <el-button
+        type="primary"
+        class="btn"
+        :class="{ disabled: blobs.length === 0 }"
+        @click="complete"
+        >合并视频</el-button
+      >
+      <div :style="{ bottom: barHeight }" class="other">
+        <span @click="start" class="other-icon" style="color: #000;"><i class="iconfont icon-record" />继续录制</span>
+      </div>
+      <div class="video-list" v-if="videoList.length">
+        <div class="shot-layout" :style="{ width: `${videoList.length * 130}px` }">
+          <div v-for="video in videoList" :key="video.cover" class="cover">
+            <img :src="video.cover" />
+            <el-icon color="#fff" class="preview" @click="openVideo(video.origin)"><VideoPlay /></el-icon>
+            <!-- <ui-icon
+              type="preview"
+              ctrl
+              class="preview"
+              @click="palyUrl = video.origin"
+            /> -->
+          </div>
+        </div>
+      </div>
+    </Toolbar>
+    <!-- <Preview
+      v-if="palyUrl"
+      :items="[{ type: MetaType.video, url: palyUrl }]"
+      @close="palyUrl = null"
+    /> -->
+  </teleport>
+</template>
+
+<script setup lang="ts">
+import {
+  ref,
+  defineComponent,
+  onUnmounted,
+  watch,
+  shallowReactive,
+  computed,
+  watchEffect,
+} from "vue";
+import Toolbar from "./Toolbar.vue";
+import { ElMessage } from "element-plus";
+// import { VideoRecorder as videorecorder } from "simaqcore";
+// import { VideoRecorder } from "@/util/simaqcore";
+import { VideoRecorder } from "simaqcore";
+import { getVideoCover } from "@/util/video-cover";
+import { getRecordFragmentBlobs, appEl, getResource } from "@/store/system";
+
+type VideoItem = { origin: Blob | string; cover: string };
+const props = defineProps<{ record?: any }>();
+const emit = defineEmits<{
+  'append': [blobs: Blob[]],
+  'updateCover': [cover: any], 
+  'close': [value: any],
+  'preview': [value: any],
+  'deleteRecord': [value: any],
+  'playVideo': [value: any],
+}>()
+const isFirefox = /firefox/i.test(navigator.userAgent);
+
+function getSupportedMimeType() {
+      const firefoxMimeTypes = [
+        'video/webm;codecs=vp8,opus',
+        'video/webm'
+      ];
+      const defaultMimeTypes = [
+        'video/webm;codecs=vp9,opus',
+        'video/webm;codecs=vp8,opus',
+        'video/webm',
+        'video/mp4'
+      ];
+      const mimeTypes = isFirefox ? firefoxMimeTypes : defaultMimeTypes;
+
+      for (const type of mimeTypes) {
+        if (MediaRecorder && MediaRecorder.isTypeSupported(type)) {
+          return type;
+        }
+      }
+      return null;
+    }
+const config: any = {
+  uploadUrl: "",
+  resolution: "4k",
+  debug: false,
+};
+const MetaType = 'video';
+
+const videoRecorder = new VideoRecorder(config);
+videoRecorder.codecType = 'video/webm;codecs=vp8,opus';
+videoRecorder._codecType = 'video/webm;codecs=vp8,opus';
+let showBottomBar = ref(false);
+const MAX_SIZE = 2 * 1024 * 1024 * 1024;
+const MAX_TIME = 30 * 60 * 1000;
+const previewVisible = ref(false);
+
+const countdown = ref(0);
+let interval: NodeJS.Timer;
+let recordIng = ref(false);
+  const start = () => {
+  // 3. 检测兼容的编码格式
+  const mimeType = getSupportedMimeType();
+  if (!mimeType) {
+    throw new Error('当前设备不支持任何视频录制格式');
+  }
+  if (size.value > MAX_SIZE || pauseTime.value < 2000) {
+    return ElMessage.warning("已超出限制大小无法继续录制,可保存后继续录制!");
+  }
+
+  showBottomBar.value = false;
+  countdown.value = 2;
+  const timeiffe = () => {
+    if (--countdown.value <= 0) {
+      clearInterval(interval);
+      videoRecorder.startRecord();
+      recordIng.value = true;
+    } else {
+      interval = setTimeout(timeiffe, 300);
+    }
+  };
+  timeiffe();
+};
+
+const pause = () => {
+  console.log('appEl:', appEl.value)
+  if (countdown.value === 0 && recordIng.value) {
+    videoRecorder.endRecord();
+    recordIng.value = false;
+  }
+  countdown.value = 0;
+  showBottomBar.value = true;
+  console.log(showBottomBar.value, 444)
+  clearInterval(interval);
+};
+
+watch(recordIng, (_n, _o, onCleanup) => {
+  if (recordIng.value) {
+    const timeout = setTimeout(() => videoRecorder.endRecord(), pauseTime.value);
+    onCleanup(() => clearTimeout(timeout));
+  }
+});
+
+const blobs: File[] = shallowReactive([]);
+const size = computed(() => {
+  console.log('videoList:', videoList);
+  return videoList.reduce(
+    (t, f) => (typeof f.origin === "string" ? t : t + f.origin.size),
+    0
+  );
+});
+const pauseTime = computed(() => (MAX_TIME / MAX_SIZE) * (MAX_SIZE - size.value));
+videoRecorder.off("*");
+videoRecorder.on("record", (blob) => {
+    console.log('videoList:', blob);
+  if (recordIng.value) {
+    blobs.push(new File([blob], "录屏.mp4", { type: "video/mp4; codecs=h264" }));
+  }
+});
+videoRecorder.on("cancelRecord", pause);
+videoRecorder.on("endRecord", pause);
+
+const videoList: any = [];
+let initial = false;
+watch([blobs, props], async () => {
+    const existsVideos = [];
+    if (props.record.url) {
+      existsVideos.push(getResource(props.record.url));
+    }
+    const fragmentBlobs = getRecordFragmentBlobs(props.record);
+    existsVideos.push(...fragmentBlobs, ...blobs);
+    for (const blob of existsVideos) {
+      if (videoList.some((item) => item.origin === blob)) {
+        continue;
+      }
+      const cover = await getVideoCover(blob, 3, 120, 80);
+      videoList.push({ origin: blob, cover });
+    }
+    for (let i = 0; i < videoList.length; i++) {
+      if (!existsVideos.some((blob) => videoList[i].origin === blob)) {
+        videoList.splice(i--, 1);
+      }
+    }
+    if (!props.record.cover && videoList.length) {
+      emit("updateCover", videoList[0].cover);
+    }
+    if (!initial) {
+      initial = true;
+      start();
+    }
+  },
+  { immediate: true }
+);
+const openVideo = (url: string | Blob) => {
+  const playUrl = typeof url === "string"
+    ? getResource(url)
+    : URL.createObjectURL(url)
+  emit('playVideo', playUrl)
+}
+
+const upHandler = (ev: KeyboardEvent) => ev.code === `Escape` && videoRecorder.endRecord();
+document.body.addEventListener("keyup", upHandler, { capture: true });
+
+const complete = () => {
+  emit("append", blobs);
+  close();
+};
+const close = () => {
+  pause();
+  emit("close");
+};
+const barHeight = computed(() => (videoList.length ? "180px" : "60px"));
+onUnmounted(() => {
+  document.body.removeEventListener("keyup", upHandler, { capture: true });
+});
+</script>
+
+<style lang="scss" scoped>
+
+.btns {
+  display: flex;
+
+  .unit,
+  .start {
+    height: 38px;
+  }
+  .unit {
+    flex: none;
+    margin-left: 10px;
+  }
+
+  .start {
+    flex: 1;
+  }
+}
+
+.tree {
+  margin-top: 20px;
+  padding-bottom: 100px;
+}
+
+.header-btns {
+  margin: 0 -20px;
+  padding: 0 20px 20px;
+  border-bottom: 1px solid rgba(255,255,255,0.1600);;
+}
+
+
+.sign {
+  padding: 20px 0;
+  border-top: 1px solid rgba(255,255,255,0.1600);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0 !important;
+
+  &:last-child {
+    border-bottom: 1px solid rgba(255,255,255,0.1600);
+  }
+}
+
+.content {
+  display: flex;
+  align-items: center;
+
+  .cover {
+    display: flex;
+    position: relative;
+    width: 48px;
+    height: 48px;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 16px;
+    margin-right: 10px;
+    flex: none;
+    cursor: pointer;
+
+    img,
+    &::before {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+      right: 0;
+    } 
+    
+    &::before{
+      content: '';
+      z-index: 2;
+      background: rgba(0,0,0,0.5);
+    }
+
+    img {
+      position: absolute;
+      z-index: 1;
+      left: 0;
+      top: 0;
+      width: 100%;
+      height: 100%;
+    }
+
+    .preview {
+      position: relative;
+      z-index: 3;
+    }
+  }
+
+  .title {
+    p {
+      font-size: 14px;
+    }
+    span {
+      font-size: 12px;
+      color: rgba(255,255,255,0.4000);
+    }
+  }
+}
+
+.action {
+  color: #fff;
+  font-size: 14px;
+  flex: none;
+  margin-left: 10px;
+}
+
+.countdown {
+  font-size: 14px;
+  color: rgba(255,255,255,0.6);
+  background-color: rgba(27, 27, 28, .8);
+  position: absolute;
+  z-index: 99;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  padding: 30px 60px;
+  pointer-events: none;
+
+  p:not(:last-child) {
+    margin-bottom: 15px;
+  }
+
+  .title {
+    color: #fff;
+
+    span {
+      font-size: 32px;
+      font-weight: bold;
+      color: #00C8AF;
+      margin-right: 14px;
+    }
+  }
+}
+
+.shot-ctrl {
+  z-index: 2;
+  .btn {
+    flex: none;
+    width: 160px;
+
+    &:not(:last-child) {
+      margin-right: 20px;
+    }
+  }
+
+  .other {
+    width: 100vw;
+    height: 56px;
+    position: absolute;
+    bottom: calc(100% + 120px);
+    box-shadow: 0px -6px 12px -8px rgba(0, 0, 0, 0.2);
+    left: 0;
+    background-color: #fff;
+    .other-icon{
+      display: flex;
+      height: 100%;
+      align-items: center;
+      justify-content: center;
+      font-size: 14px;
+      color: #000;
+      gap: 8px;
+    }
+  }
+}
+
+
+.video-list {
+  position: absolute;
+  bottom: 100%;
+  left: 0;
+  width: 100%;
+  height: 120px;
+  overflow-x: auto;
+  background-color: #fff;
+  border-bottom: 1px solid #E6E6E6;
+  border-top: 1px solid #E6E6E6;
+
+  .shot-layout {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    justify-content: space-around;
+  }
+
+  .cover {
+    height: 80px;
+    position: relative;
+    cursor: pointer;
+    
+    &::before {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+      right: 0;
+      content: '';
+      z-index: 2;
+      background: rgba(0,0,0,0.3);
+    } 
+    .preview {
+      position: absolute;
+      z-index: 3;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%);
+      font-size: 22px;
+    }
+  }
+}
+</style>

+ 119 - 5
src/view/newFireCase/newFireDetails/components/shot.vue

@@ -1,5 +1,5 @@
 <template>
-  <teleport :to="appEl" v-if="appEl">
+  <teleport :to="appEl" v-if="appEl">dasdad
     <Toolbar :showBottomBar="showBottomBar" class="shot-ctrl">
       <el-button type="primary" class="btn" @click="close">取消</el-button>
       <el-button
@@ -10,9 +10,9 @@
         >合并视频</el-button
       >
       <div :style="{ bottom: barHeight }" class="other">
-        <span @click="start" class="other-icon" style="color: #000;"><i class="iconfont icon-record" />继续录制</span>
+        <span @click="startRecording" class="other-icon" style="color: #000;"><i class="iconfont icon-record" />继续录制</span>
       </div>
-      <div class="video-list" v-if="videoList.length">
+      <div class="video-list" v-if="videoList.length" :key="videoList.length">
         <div class="shot-layout" :style="{ width: `${videoList.length * 130}px` }">
           <div v-for="video in videoList" :key="video.cover" class="cover">
             <img :src="video.cover" />
@@ -38,6 +38,7 @@
 <script setup lang="ts">
 import {
   ref,
+  reactive,
   defineComponent,
   onUnmounted,
   watch,
@@ -64,6 +65,111 @@ const emit = defineEmits<{
   'playVideo': [value: any],
 }>()
 const isFirefox = /firefox/i.test(navigator.userAgent);
+// 响应式状态
+const isRecording = ref(false)          // 是否正在录屏
+const recordingStatus = ref('未开始')   // 录屏状态文本
+const mediaRecorder = ref(null)         // MediaRecorder 实例
+const recordedBlobs = ref([])           // 录制的视频数据
+const recordedBlob = ref(null)          // 最终合成的视频Blob
+const downloadUrl = ref('')             // 下载链接
+const stream = ref(null)    
+// 开始录屏
+const startRecording = async () => {
+      console.log('startRecording')
+  try {
+    // 获取屏幕流(包含音频选项)
+    stream.value = await navigator.mediaDevices.getDisplayMedia({
+      video: true,
+      audio: true // 如果需要录制声音,开启这个选项
+    })
+    
+    // 创建MediaRecorder实例
+    mediaRecorder.value = new MediaRecorder(stream.value, {
+      mimeType: 'video/webm; codecs=vp8'
+    })
+    
+    // 监听数据可用事件(收集录制数据)
+    mediaRecorder.value.ondataavailable = (event) => {
+      if (event.data && event.data.size > 0) {
+        recordedBlobs.value.push(event.data)
+      }
+    }
+    
+    // 监听录屏停止事件(主动停止/流中断)
+    mediaRecorder.value.onstop = () => {
+      console.log('录制完成onstop', recordIng.value)
+
+      isRecording.value = false
+      recordingStatus.value = '已终止'
+      
+      // 合成最终的视频Blob
+      let blob = new Blob(recordedBlobs.value, { type: 'video/webm' })
+      recordedBlob.value = blob
+
+      if (recordIng.value) {
+        blobs.push(new File([blob], "录屏.mp4", { type: "video/mp4; codecs=h264" }));
+      }
+      // 创建下载链接
+      // downloadUrl.value = URL.createObjectURL(recordedBlob.value)
+      
+      // 清理资源
+      stopAllTracks()
+      recordedBlobs.value = []
+      recordIng.value = false;
+      console.log('录屏已终止', videoList)
+      countdown.value = 0;
+      showBottomBar.value = true;
+      // complete()
+    }
+    
+    // 监听录屏错误事件
+    mediaRecorder.value.onerror = (error) => {
+      isRecording.value = false
+      recordingStatus.value = `录制出错: ${error.name}`
+      console.error('录屏错误:', error)
+      countdown.value = 0;
+      showBottomBar.value = true;
+      // 清理资源
+      stopAllTracks()
+    }
+    
+    // 开始录制(每100ms收集一次数据)
+    mediaRecorder.value.start(100)
+    isRecording.value = true
+    recordingStatus.value = '正在录制'
+    recordIng.value = true;
+    console.log('录屏已开始')
+    
+  } catch (error) {
+    // 处理用户取消授权或其他错误
+    if (error.name === 'NotAllowedError') {
+      recordingStatus.value = '用户拒绝了录屏权限'
+    } else if (error.name === 'NotFoundError') {
+      recordingStatus.value = '没有可用的屏幕源'
+    } else {
+      recordingStatus.value = `启动失败: ${error.message}`
+    }
+    showBottomBar.value = true;
+    console.error('启动录屏失败:', error)
+  }
+}
+
+// 停止录屏
+const stopRecording = () => {
+  if (mediaRecorder.value && isRecording.value) {
+    // 主动停止录制
+    mediaRecorder.value.stop()
+    recordingStatus.value = '正在停止...'
+  }
+}
+
+// 停止所有媒体轨道
+const stopAllTracks = () => {
+  if (stream.value) {
+    stream.value.getTracks().forEach(track => track.stop())
+    stream.value = null
+  }
+}
 
 function getSupportedMimeType() {
       const firefoxMimeTypes = [
@@ -157,6 +263,7 @@ const size = computed(() => {
 const pauseTime = computed(() => (MAX_TIME / MAX_SIZE) * (MAX_SIZE - size.value));
 videoRecorder.off("*");
 videoRecorder.on("record", (blob) => {
+    console.log('videoList:', blob);
   if (recordIng.value) {
     blobs.push(new File([blob], "录屏.mp4", { type: "video/mp4; codecs=h264" }));
   }
@@ -164,7 +271,7 @@ videoRecorder.on("record", (blob) => {
 videoRecorder.on("cancelRecord", pause);
 videoRecorder.on("endRecord", pause);
 
-const videoList: any = [];
+const videoList = reactive([]);
 let initial = false;
 watch([blobs, props], async () => {
     const existsVideos = [];
@@ -173,13 +280,16 @@ watch([blobs, props], async () => {
     }
     const fragmentBlobs = getRecordFragmentBlobs(props.record);
     existsVideos.push(...fragmentBlobs, ...blobs);
+    console.log('props.recordblobs变化', props.record, fragmentBlobs,'existsVideos',existsVideos)
     for (const blob of existsVideos) {
+      console.log('blob:', blob, videoList.some((item) => item.origin === blob));
       if (videoList.some((item) => item.origin === blob)) {
         continue;
       }
       const cover = await getVideoCover(blob, 3, 120, 80);
       videoList.push({ origin: blob, cover });
     }
+    console.log('videoList:', videoList);
     for (let i = 0; i < videoList.length; i++) {
       if (!existsVideos.some((blob) => videoList[i].origin === blob)) {
         videoList.splice(i--, 1);
@@ -190,7 +300,11 @@ watch([blobs, props], async () => {
     }
     if (!initial) {
       initial = true;
-      start();
+      // if(isFirefox){
+        startRecording();
+      // }else{
+      //   start();
+      // }
     }
   },
   { immediate: true }

+ 2 - 0
src/view/newFireCase/newFireDetails/index.vue

@@ -10,6 +10,7 @@
     <!-- 编辑页 -->
     <editIndex :caseId="caseId" :currentRecord="currentRecord" :fromRoute="fromRoute" :showObj="showObj" :processingIds="processingIds" :recentAddedItem="recentAddedItem" @start="startShot" @playVideo="playVideo" v-else />
   </div>
+  {{ record }}
   <shot v-if="isShot" @close="closeHandler" @append="appendFragment" @playVideo="playVideo"
     @updateCover="(cover: string) => $emit('updateCover', cover)" @deleteRecord="$emit('delete')" :record="record" />
   <el-dialog
@@ -242,6 +243,7 @@ const processingIds = ref<(number|string)[]>([]);
 // 最近新增的录制项,传递到 ScreenShot 以便即时加入列表
 const recentAddedItem = ref<any | null>(null);
 const appendFragment = (blobs: Blob[]) => {
+  console.log('appendFragment', blobs)
   const current = record.value;
   if (!current || !current.id) return;
   recordFragments.value.push(