Quellcode durchsuchen

feat: 湖南省博物馆 - 遇见庞贝展

chenlei vor 12 Stunden
Ursprung
Commit
c0cae1a084

+ 1 - 0
public/js/Hot.js

@@ -1918,6 +1918,7 @@ window.initHot = function(model){
             docFragment.appendChild(li);
              
         }) 
+        window.myHotList = hots
         setTimeout(()=>{        
             var ul = document.querySelector('#hotListContent ul');
             ul && ul.appendChild(docFragment);   

BIN
src/hotspot/assets/images/bd.png


+ 7 - 0
src/hotspot/assets/utils.scss

@@ -0,0 +1,7 @@
+@function vh-calc($num) {
+  @return calc(100vh * ($num / var(--design-height)));
+}
+
+@function vw-calc($num) {
+  @return calc(100vw * ($num / var(--design-width)));
+}

+ 317 - 0
src/hotspot/views/hotspot/index.pb.vue

@@ -0,0 +1,317 @@
+<template>
+  <div class="hotspot-page detail-popup">
+    <template v-if="curList.length">
+      <el-image
+        v-if="myType === 'img'"
+        fit="contain"
+        :src="curList[0]"
+        :preview-src-list="curList"
+      />
+
+      <video v-else-if="myType === 'video'" ref="videos" controls :src="curList[0].url" />
+    </template>
+
+    <el-scrollbar class="detail-popup-container">
+      <h3>{{ myTitle }}</h3>
+      <div v-html="myTxt" />
+    </el-scrollbar>
+
+    <!-- 音频播放器 -->
+    <audio
+      id="myAudio"
+      v-if="audio"
+      ref="volumeRef"
+      v-show="isOneAduio"
+      :src="audio"
+      controls
+    ></audio>
+  </div>
+</template>
+
+<script>
+  import { Swiper, SwiperSlide } from 'swiper/vue';
+  import { Navigation, Pagination } from 'swiper/modules';
+  import 'swiper/css';
+  import 'swiper/css/navigation';
+  import 'swiper/css/pagination';
+
+  import { parseUrlParams, judgeIsMobile } from '@/utils';
+  import { MESSAGE_KEY } from '@/types';
+
+  import ModelIcon from '@hotspot/assets/images/model-icon.png';
+  import ImageIcon from '@hotspot/assets/images/img-icon.png';
+  import VideoIcon from '@hotspot/assets/images/video-icon.png';
+  import VolumeOn from '@hotspot/assets/images/audio-icon.png';
+  import infoIcon from '@hotspot/assets/images/info-icon.png';
+
+  const urlParams = parseUrlParams(window.location.href);
+  const isMobile = judgeIsMobile();
+
+  export default {
+    name: 'hotspot',
+    components: {
+      Swiper,
+      SwiperSlide,
+    },
+    data() {
+      return {
+        isMobile,
+        VolumeOn,
+        m: urlParams.m,
+        id: urlParams.id,
+        // 音频地址
+        audio: '',
+        // 如果只有单独的音频
+        isOneAduio: false,
+        // 音频状态
+        audioSta: false,
+        swiperInited: false,
+
+        data: {
+          // 模型数组
+          model: [],
+          // 视频数组
+          video: [],
+          // 图片数组
+          img: [],
+        },
+        // 当前 type
+        myType: '',
+
+        // 当前索引
+        myInd: 0,
+
+        // 底部的tab
+        flooTab: [],
+
+        // 标题
+        myTitle: '',
+        // 内容
+        myTxt: '',
+        // 视频内容
+        videoTxt: [],
+        imgTxt: [],
+
+        // 只有标题和文字(没有视频,没有模型,没有图片)
+        oneTxt: false,
+
+        modules: [Navigation, Pagination],
+      };
+    },
+    computed: {
+      curList() {
+        return this.data[this.myType] || [];
+      },
+      isTextType() {
+        return this.myType === 'text';
+      },
+    },
+    watch: {
+      audioSta(val) {
+        if (!this.$refs.volumeRef) return;
+        if (val) {
+          this.$refs.volumeRef.play();
+          this.$refs.volumeRef.onended = () => {
+            // console.log("----音频播放完毕");
+            this.audioSta = false;
+          };
+        } else this.$refs.volumeRef.pause();
+      },
+    },
+    mounted() {
+      this.getData();
+    },
+    methods: {
+      async getData() {
+        // https://www.4dmodel.com/
+        let url = `${
+          Boolean(Number(import.meta.env.VITE_APP_OFFLINE)) ? '.' : 'https://super.4dage.com'
+        }/data/${this.id}/hot/js/data.js?time=${Math.random()}`;
+        let result = await fetch(url).then((response) => response.json());
+        const resData = result[this.m];
+
+        if (resData) {
+          this.audio = resData.backgroundMusic;
+          // 只有单独的音频上传
+          if (resData.backgroundMusic && !resData.model && !resData.video && !resData.images) {
+            this.isOneAduio = true;
+          }
+          // 底部的tab
+          const arr = [];
+          const obj = {};
+          if (resData.images) {
+            obj.img = resData.images;
+            arr.push({ id: 3, type: 'img', name: '图片', icon: ImageIcon });
+          }
+          if (resData.video) {
+            obj.video = resData.video;
+            arr.push({ id: 2, type: 'video', name: '视频', icon: VideoIcon });
+          } else {
+            this.$nextTick(() => {
+              if (
+                !window.navigator.userAgent.match(
+                  /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
+                )
+              ) {
+                this.audioSta = true;
+                this.$refs.volumeRef?.play();
+              }
+            });
+          }
+          if (resData.model) {
+            obj.model = resData.model;
+            arr.push({ id: 1, type: 'model', name: '模型', icon: ModelIcon });
+          }
+          if (isMobile) {
+            arr.push({ id: 4, type: 'text', name: '介绍', icon: infoIcon });
+          }
+
+          this.flooTab = arr;
+          this.data = obj;
+
+          // 当前type的值 应该为
+          if (resData.images) this.myType = 'img';
+          else if (resData.model) this.myType = 'model';
+          else if (resData.video) {
+            this.myType = 'video';
+            this.$nextTick(() => {
+              this.handleVideoPlay(this.data.video[0].url);
+            });
+          } else this.myType = 'text';
+
+          this.myTitle = resData.title || '';
+          this.myTxt = resData.content || '';
+          this.videoTxt = resData.videosDesc || [];
+          this.imgTxt = resData.imagesDesc || [];
+
+          // 只有 标题和 文字介绍(没有视频,没有模型,没有图片)
+          if (!obj.model && !obj.video && !obj.img && !resData.backgroundMusic) {
+            this.oneTxt = true;
+          }
+        }
+      },
+
+      handleTab(item) {
+        this.myInd = 0;
+        this.myType = item.type;
+        this.swiper?.slideTo(0);
+
+        switch (this.myType) {
+          case 'video':
+            this.$nextTick(() => {
+              this.handleVideoPlay(this.data.video[0].url);
+            });
+            break;
+        }
+      },
+
+      initSwiper(swiper) {
+        this.swiper = swiper;
+        this.swiperInited = true;
+      },
+      handleChange({ activeIndex }) {
+        this.myInd = activeIndex;
+
+        switch (this.myType) {
+          case 'video':
+            this.handleVideoPlay(this.data.video[activeIndex].url);
+            break;
+        }
+      },
+      handleVideoPlay(url) {
+        const video = this.$refs.videos?.find((i) => i.src === url);
+
+        this.lastVideo?.pause();
+        if (!video) return;
+
+        video.play();
+        this.lastVideo = video;
+      },
+      handlePreview(idx) {
+        window.parent.postMessage(
+          { type: MESSAGE_KEY.SHOW_VIEWER, images: [this.curList[idx]] },
+          '*'
+        );
+      },
+    },
+  };
+</script>
+
+<style lang="scss">
+  @use '@/assets/utils.scss';
+
+  .detail-popup {
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: utils.vw-calc(200);
+
+    .el-image,
+    video {
+      max-width: utils.vw-calc(800);
+      height: utils.vh-calc(620);
+      background: black;
+    }
+    &-container {
+      flex-shrink: 0;
+      width: utils.vw-calc(638);
+      height: auto;
+      color: #e0b387;
+
+      h3 {
+        position: relative;
+        margin-bottom: utils.vh-calc(55);
+        font-size: utils.vh-calc(30);
+        line-height: utils.vh-calc(40);
+        text-align: center;
+
+        &::after {
+          content: '';
+          position: absolute;
+          bottom: utils.vh-calc(-22);
+          left: 50%;
+          transform: translateX(-50%);
+          width: utils.vw-calc(304);
+          height: utils.vw-calc(10);
+          background: url('@hotspot/assets/images/bd.png') no-repeat center / contain;
+        }
+      }
+      > div {
+        font-size: utils.vh-calc(18);
+        line-height: utils.vh-calc(30);
+      }
+    }
+  }
+
+  @media only screen and (max-width: 600px) {
+    .detail-popup {
+      flex-direction: column;
+      gap: utils.vh-calc(60);
+
+      .el-image,
+      video {
+        margin-top: utils.vh-calc(120);
+        padding: 0 utils.vw-calc(45);
+        max-width: 100%;
+        width: 100%;
+        height: utils.vh-calc(674);
+      }
+      &-container {
+        flex: 1;
+        height: 0;
+        width: 100%;
+        padding: 0 utils.vw-calc(45) utils.vh-calc(158);
+
+        h3 {
+          font-size: utils.vh-calc(36);
+          line-height: utils.vh-calc(48);
+        }
+        > div {
+          font-size: utils.vh-calc(28);
+          line-height: utils.vh-calc(42);
+        }
+      }
+    }
+  }
+</style>

+ 9 - 1
src/index/components/info-popup/index.vue

@@ -15,7 +15,11 @@
         <p>展览时间:2025.07.08-2025.11.02</p>
         <p>展览城市:湖南-长沙</p>
         <p>展览地点:湖南博物院一层特展一厅</p>
-        <p>主办单位:湖南博物院</p>
+        <p>
+          主办单位:湖南博物院&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;那不勒斯国家考古博物馆&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;坎皮·弗莱格瑞考古博物馆(巴亚城堡)
+          &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;那不勒斯费德里科二世大学典藏部
+        </p>
+        <p>联合主办:北京圣轩文化集团有限公司</p>
       </div>
       <div class="info-popup-info">
         <p>
@@ -97,6 +101,10 @@
     }
     &-info {
       line-height: utils.vh-calc(28);
+
+      p {
+        margin-bottom: utils.vh-calc(30);
+      }
     }
     &-img {
       flex-shrink: 0;

+ 39 - 24
src/index/views/home/components/ant-popup/index.vue

@@ -27,21 +27,23 @@
 
     <el-scrollbar class="ant-popup-scrollbar">
       <ul>
-        <li v-for="item in filteredList" :key="item.id" @click="handleChecked(item.id)">
-          <el-image :src="`images/pb/500/${item.imgName}`" />
-          <p>{{ item.name }}</p>
-        </li>
+        <template v-for="item in filteredList" :key="item.id">
+          <li v-if="item.info.images.length > 0" @click="handleChecked(item)">
+            <el-image :src="item.info.images[0]" fit="contain" />
+            <p class="limit-line">{{ item.info.title }}</p>
+          </li>
+        </template>
       </ul>
     </el-scrollbar>
   </el-dialog>
 
-  <DetailPopup v-model:visible="detailVisible" :checkedIndex="checkedIndex" />
+  <!-- <DetailPopup v-model:visible="detailVisible" :checkedItem="checkedItem" /> -->
 </template>
 
 <script setup lang="ts">
-  import { computed, ref } from 'vue';
-  import { ANT_LIST } from './constants';
-  import DetailPopup from './detail.vue';
+  import { computed, ref, watch } from 'vue';
+  // import { ANT_LIST } from './constants';
+  // import DetailPopup from './detail.vue';
 
   const emits = defineEmits(['update:visible']);
   const props = defineProps<{
@@ -58,15 +60,16 @@
   });
 
   const query = ref('');
-  const checkedIndex = ref(-1);
-  const detailVisible = ref(false);
+  // const checkedItem = ref(null);
+  // const detailVisible = ref(false);
 
-  const originalList = ANT_LIST;
+  // @ts-ignore
+  let originalList = window.myHotList || [];
 
   const filteredList = computed(() => {
     const q = query.value.trim().toLowerCase();
     if (!q) return originalList;
-    return originalList.filter((item) => item.name.toLowerCase().includes(q));
+    return originalList.filter((item) => item.info.title.toLowerCase().includes(q));
   });
 
   const search = () => {
@@ -77,10 +80,22 @@
     query.value = '';
   };
 
-  const handleChecked = (index: number) => {
-    checkedIndex.value = index;
-    detailVisible.value = true;
+  const handleChecked = (item: any) => {
+    if (item && item.examine) {
+      show.value = false;
+      setTimeout(() => {
+        // @ts-ignore
+        item.examine(window.player, true);
+      }, 200);
+    }
   };
+
+  watch(show, (v) => {
+    if (v) {
+      // @ts-ignore
+      originalList = window.myHotList || [];
+    }
+  });
 </script>
 
 <style lang="scss">
@@ -108,22 +123,19 @@
         list-style: none;
         padding: 0;
         margin: 0;
-        column-count: 5;
-        column-gap: utils.vw-calc(40);
+        display: grid;
+        grid-template-columns: repeat(5, 1fr);
+        gap: utils.vw-calc(40);
       }
 
       li {
         display: inline-block;
         width: 100%;
-        -webkit-column-break-inside: avoid;
-        -moz-column-break-inside: avoid;
-        break-inside: avoid;
         margin: 0 0 utils.vh-calc(30) 0;
-        min-height: utils.vh-calc(80);
         cursor: pointer;
 
         .el-image {
-          min-height: utils.vw-calc(100);
+          height: utils.vw-calc(200);
         }
         p {
           margin: utils.vh-calc(12) 0 0;
@@ -243,14 +255,17 @@
         padding: 0 utils.vw-calc(30);
 
         ul {
-          column-count: 2;
-          column-gap: utils.vw-calc(30);
+          grid-template-columns: repeat(2, 1fr);
+          gap: utils.vw-calc(30);
         }
         li {
           p {
             margin: utils.vh-calc(15) 0 0;
             font-size: utils.vh-calc(28);
           }
+          .el-image {
+            height: utils.vh-calc(350);
+          }
         }
       }
       &-search {

+ 3 - 0
src/index/views/home/components/menu/index.pb.scss

@@ -32,6 +32,9 @@
   line-height: 1;
   transition: all 0.5s;
 
+  #hotList {
+    display: none !important;
+  }
   &.left {
     left: 20px;
     bottom: 0;

+ 31 - 12
src/index/views/home/components/menu/index.pb.tsx

@@ -3,6 +3,7 @@ import { homeApi } from '@/api';
 import SharePopup from '../share-popup/index.vue';
 import ChapterPopup from '../chapter-popup/index.vue';
 import AntPopup from '../ant-popup/index.vue';
+import InfoPopup from '@/components/info-popup/index.vue';
 import './index.pb.scss';
 
 export default defineComponent({
@@ -28,6 +29,7 @@ export default defineComponent({
     const antVisible = ref(false);
     const animationThumb = ref(false);
     const isMobile = ref(false);
+    const infoVisible = ref(false);
 
     const toggleSimpleMode = (on?: boolean) => {
       const next = typeof on === 'boolean' ? on : !isSimpleMode.value;
@@ -70,6 +72,7 @@ export default defineComponent({
       antVisible,
       animationThumb,
       chapterVisible,
+      infoVisible,
       handleThumb,
       toggleSimpleMode,
     };
@@ -81,10 +84,15 @@ export default defineComponent({
           <div class="pinBottom left">
             <div id="previous" class="previous desktop-only ui-icon" style="display: none;">
               <a>
-                <img src="images/pb/play.png" width="24" height="24" data-original-title="播放" />
+                <img
+                  src="images/pb/play.png"
+                  width="24"
+                  height="24"
+                  data-original-title="自动漫游"
+                />
               </a>
             </div>
-            <el-tooltip content="播放" show-arrow={false} offset={5} disabled={this.isMobile}>
+            <el-tooltip content="自动漫游" show-arrow={false} offset={5} disabled={this.isMobile}>
               <div id="play" class="ui-icon">
                 <a>
                   <img src="images/pb/play.png" width="24" height="24" />
@@ -93,7 +101,7 @@ export default defineComponent({
             </el-tooltip>
             <div id="pause" class="ui-icon" style="display: none;">
               <a>
-                <img title="暂停" src="images/pb/pause.png" width="24" height="24" />
+                <img title="暂停漫游" src="images/pb/pause.png" width="24" height="24" />
               </a>
             </div>
             <div id="next" class="next desktop-only ui-icon wide" style="display: none;">
@@ -101,12 +109,12 @@ export default defineComponent({
                 <i title="" class="icon icon-dpad-right" data-original-title="下一个"></i>
               </a>
             </div>
-            <el-tooltip content="导览" show-arrow={false} offset={5} disabled={this.isMobile}>
+            <el-tooltip content="展厅场景" show-arrow={false} offset={5} disabled={this.isMobile}>
               <div id="pullTab" title="">
                 <img
                   class="icon icon-inside"
                   src="images/pb/auto.png"
-                  title="导览"
+                  title="展厅场景"
                   data-default-url="images/pb/auto.png"
                   data-active-url="images/pb/auto.png"
                 />
@@ -131,6 +139,16 @@ export default defineComponent({
                 <img class="icon icon-inside" src="images/pb/model.png" title="迷你模型" />
               </div>
             </el-tooltip>
+            <div id="description" title="">
+              <el-tooltip content="展览介绍" show-arrow={false} offset={5} disabled={this.isMobile}>
+                <img
+                  class="icon icon-inside"
+                  src="images/pb/hotlist.png"
+                  title="展览介绍"
+                  onClick={() => (this.infoVisible = true)}
+                />
+              </el-tooltip>
+            </div>
             <div
               data-original-title="俯视图"
               id="gui-modes-floorplan"
@@ -139,22 +157,22 @@ export default defineComponent({
             >
               <img class="icon icon-inside" src="images/floor.png" title="俯视图" />
             </div>
-            <el-tooltip content="展品列表" show-arrow={false} offset={5} disabled={this.isMobile}>
+            <el-tooltip content="展品说明" show-arrow={false} offset={5} disabled={this.isMobile}>
               <div
                 id="gui-modes-antlist"
                 title=""
                 class=""
                 onClick={() => (this.antVisible = true)}
               >
-                <img class="icon icon-inside" src="images/pb/antlist.png" title="展品列表" />
+                <img class="icon icon-inside" src="images/pb/antlist.png" title="展品说明" />
               </div>
             </el-tooltip>
-            <el-tooltip content="章节列表" show-arrow={false} offset={5} disabled={this.isMobile}>
+            <el-tooltip content="单元说明" show-arrow={false} offset={5} disabled={this.isMobile}>
               <div id="gui-modes-chapter" onClick={() => (this.chapterVisible = true)}>
-                <img class="icon icon-inside" src="images/pb/chapter.png" title="章节列表" />
+                <img class="icon icon-inside" src="images/pb/chapter.png" title="单元说明" />
               </div>
             </el-tooltip>
-            <el-tooltip content={`${this.likeCount}`} show-arrow={false} offset={5}>
+            <el-tooltip content={`点赞${this.likeCount}`} show-arrow={false} offset={5}>
               <div
                 id="thumb"
                 data-original-title="点赞"
@@ -192,11 +210,11 @@ export default defineComponent({
                 <img class="icon icon-fullscreen-exit" src="images/pb/narrow_off.png" />
               </a>
             </div>
-            <el-tooltip content="清屏" show-arrow={false} offset={5} disabled={this.isMobile}>
+            {/* <el-tooltip content="清屏" show-arrow={false} offset={5} disabled={this.isMobile}>
               <div onClick={() => this.toggleSimpleMode(true)}>
                 <img class="icon icon-inside" src="images/pb/simple.png" title="清屏" />
               </div>
-            </el-tooltip>
+            </el-tooltip> */}
 
             <div data-original-title="VR" id="vr" title="" style="display: none;">
               <img class="icon icon-inside" src="images/VR.png" title="VR" />
@@ -237,6 +255,7 @@ export default defineComponent({
           退出清屏
         </div>
 
+        <InfoPopup visible={this.infoVisible} onUpdate:visible={() => (this.infoVisible = false)} />
         <AntPopup visible={this.antVisible} onUpdate:visible={(v) => (this.antVisible = v)} />
         <ChapterPopup
           visible={this.chapterVisible}

+ 48 - 0
src/index/views/home/components/popup/index.pb.scss

@@ -0,0 +1,48 @@
+@use '@/assets/utils.scss';
+
+#popup {
+  display: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: var(--z-hot-popper);
+
+  &.wait {
+    opacity: 0.1;
+  }
+}
+#id1 {
+  width: 100%;
+  height: 100%;
+}
+.popup-content {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+#closepop {
+  position: absolute;
+  top: utils.vh-calc(80);
+  right: utils.vw-calc(80);
+  width: utils.vw-calc(24);
+  height: utils.vw-calc(24);
+  cursor: pointer;
+  text-indent: -999em;
+  background: url('@/assets/images/close.png') no-repeat center / contain;
+  z-index: 1;
+}
+
+@media only screen and (max-width: 600px) {
+  #closepop {
+    top: unset;
+    left: 50%;
+    right: unset;
+    bottom: utils.vh-calc(66);
+    width: utils.vw-calc(36);
+    transform: translateX(-50%);
+  }
+}

+ 16 - 0
src/index/views/home/components/popup/index.pb.tsx

@@ -0,0 +1,16 @@
+import { defineComponent } from 'vue';
+import './index.pb.scss';
+
+export default defineComponent({
+  name: 'HomePopup',
+  render() {
+    return (
+      <div id="popup">
+        <div class="popup-wrap">
+          <div class="popup-content"></div>
+          <div id="closepop">close</div>
+        </div>
+      </div>
+    );
+  },
+});

+ 2 - 7
src/index/views/home/index.pb.tsx

@@ -9,12 +9,11 @@ import Menu from './components/menu/index.pb';
 import GuiLoading from './components/gui-loading';
 import Popup from './components/popup';
 import HotSpotList from './components/hot-spot-list';
-import Img from './images/home.png';
+// import Img from './images/home.png';
 import { useRouter } from 'vue-router';
 import { homeApi } from '@/api';
 import useBaseStore from '@/store/module/base';
 import { storeToRefs } from 'pinia';
-import InfoPopup from '@/components/info-popup/index.vue';
 import './index.pb.scss';
 
 // 自定义热点图标
@@ -42,7 +41,6 @@ export default defineComponent({
     const hotJsLoaded = ref(false);
     const visitCount = ref(0);
     const likeCount = ref(0);
-    const infoVisible = ref(false);
 
     const getSceneDetail = async () => {
       const { data } = await homeApi.getSceneDetail(window.number);
@@ -63,7 +61,6 @@ export default defineComponent({
 
     return {
       router,
-      infoVisible,
       manageJsLoaded,
       hotJsLoaded,
       visitCount,
@@ -81,7 +78,7 @@ export default defineComponent({
           <span class="limit-line">{this.visitCount}</span>
         </div>
 
-        <img class="home_img" src={Img} onClick={() => (this.infoVisible = true)} />
+        {/* <img class="home_img" src={Img} onClick={() => (this.infoVisible = true)} /> */}
 
         {/* 加载初始页面 */}
         <div id="gui-thumb" />
@@ -125,8 +122,6 @@ export default defineComponent({
           <Other />
         </div>
 
-        <InfoPopup visible={this.infoVisible} onUpdate:visible={() => (this.infoVisible = false)} />
-
         {/* TODO: 没有控制权,耦合严重;放在此处为了防止元素未渲染导致报错 */}
         <JsScript src="./js/manage.js" onLoad={() => (this.manageJsLoaded = true)} />
         {this.manageJsLoaded && (

Datei-Diff unterdrückt, da er zu groß ist
+ 111 - 0
vite.config.ts.timestamp-1772251949654-9b6c30f4c4e0b.mjs