Parcourir la source

feat: add file upload tool and related functions

1. 新增文件上传工具类型84,添加对应多语言文案
2. 增加文件上传工具到工具栏,添加工具图标
3. 完善学生端作业详情中的文件上传作业展示与预览功能,支持图片、视频、PDF及Office文档预览
4. 修复投屏消息重复处理问题,调整选择组件最大宽度,优化HTML替换逻辑
lsc il y a 2 jours
Parent
commit
eb471eedb9

BIN
src/assets/img/tool_file.png


+ 1 - 0
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -576,6 +576,7 @@ const sendAction = async (action: string) => {
       77: lang.ssEnglishSpeakingTool,
       78: lang.ssVote,
       79: lang.ssPhoto,
+      84: lang.ssFile,
       831: lang.ssKwlTitle,
     }
     return typeMap[type || 0] || lang.ssUnknown

+ 1 - 0
src/components/CollapsibleToolbar/index.vue

@@ -292,6 +292,7 @@ const getTypeLabel = (type?: number) => {
     [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
+    84: lang.ssFile,
     831: lang.ssKwlTitle,
   }
   return typeMap[type || 0] || lang.ssUnknown

+ 17 - 1
src/components/CollapsibleToolbar/index2.vue

@@ -354,6 +354,7 @@
         <img class="submenu-img" v-else-if="hoveredTool === 'choice'" key="choice" :src="toolChoice" alt="">
         <img class="submenu-img" v-else-if="hoveredTool === 'vote'" key="vote" :src="toolVote" alt="">
         <img class="submenu-img" v-else-if="hoveredTool === 'photo'" key="photo" :src="toolPhoto" alt="">
+        <img class="submenu-img" v-else-if="hoveredTool === 'file'" key="file" :src="toolFile" alt="">
       </transition>
       <div class="submenu-item-box">
         <div class="submenu-item" @click="handleToolClick('choice')" @mouseenter="hoveredTool = 'choice'"
@@ -387,6 +388,15 @@
           </svg>
           <span class="submenu-label">{{ lang.ssPhoto }}</span>
         </div>
+        <div class="submenu-item" @click="handleToolClick('file')" @mouseenter="hoveredTool = 'file'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" t="1787542984492" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7531" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
+            <path d="M239.317333 810.666667l-8.533333-1.28C173.226667 801.152 128 750.805333 128 689.152c0-51.157333 31.146667-94.72 75.050667-112.725333L256.170667 554.666667 256 495.36a213.333333 213.333333 0 0 1 420.437333-51.328l15.189334 61.354667 63.104 3.370666C832.853333 512.853333 896 578.346667 896 659.584c0 84.053333-67.413333 151.04-149.333333 151.04H725.333333v85.333333h21.333334c129.621333 0 234.666667-105.813333 234.666666-236.373333 0-126.293333-98.304-229.461333-222.08-236.074667A298.666667 298.666667 0 0 0 170.666667 495.488v1.962667a206.933333 206.933333 0 0 0-128 191.658666c0 104.192 76.458667 190.421333 175.957333 204.8v2.048H298.666667v-85.333333H239.317333z" fill="currentColor" p-id="7532"></path>
+            <path d="M512 512l219.477333 219.477333h-120.704L512 632.618667l-98.858667 98.858666H292.522667L512 512z" fill="currentColor" p-id="7533"></path>
+            <path d="M554.666667 597.333333v298.666667h-85.333334v-298.666667h85.333334z" fill="currentColor" p-id="7534"></path>
+          </svg>
+          <span class="submenu-label">{{ lang.ssFile }}</span>
+        </div>
         <div class="submenu-item" @click="handleToolClick('creative')" @mouseenter="hoveredTool = null"
           @mouseleave="hoveredTool = null" v-if="false">
           <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -776,6 +786,7 @@ import toolChoice from '@/assets/img/tool_choice.jpeg'
 import toolAnswer from '@/assets/img/tool_answer.png'
 import toolVote from '@/assets/img/tool_vote.png'
 import toolPhoto from '@/assets/img/tool_photo.png'
+import toolFile from '@/assets/img/tool_file.png'
 import axios from '@/services/config'
 import kwl from '@/assets/img/kwlBanner.jpg'
 import KwlDetail from './cocolearnCom/KwlDetail.vue'
@@ -1127,6 +1138,9 @@ const handleToolClick = _.debounce((tool: string) => {
   else if (tool === 'photo') {
     parentWindow?.addTool?.(79)
   }
+  else if (tool === 'file') {
+    parentWindow?.addTool?.(84)
+  }
   else if (tool === 'qa') {
     parentWindow?.addTool?.(15)
   }
@@ -1261,6 +1275,7 @@ const getTypeLabel = (type?: number) => {
     [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
+    84: lang.ssFile,
     831: lang.ssKwlTitle,
   }
   return typeMap[type || 0] || lang.ssUnknown
@@ -1275,7 +1290,8 @@ const getTypeClass = (type?: number) => {
     74: 'type-video',
     75: 'type-bilibili',
     76: 'type-app-center',
-    831: 'type-kwl'
+    831: 'type-kwl',
+    84: 'type-file',
   }
   return classMap[type || 0] || 'type-default'
 }

+ 1 - 0
src/components/CollapsibleToolbar/index22.vue

@@ -982,6 +982,7 @@ const getTypeLabel = (type?: number) => {
     [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
+    84: lang.ssFile,
     831: lang.ssKwlTitle,
   }
   return typeMap[type || 0] || lang.ssUnknown

+ 1 - 0
src/components/Select.vue

@@ -156,6 +156,7 @@ const handleSelect = (option: SelectOption) => {
 
   .selector {
     min-width: 50px;
+    max-width: 100px;
     height: 30px;
     line-height: 30px;
     padding-left: 10px;

+ 5 - 4
src/hooks/useImport.ts

@@ -63,10 +63,11 @@ const convertFontSizePtToPx = (html: string, ratio: number, autoFit: any) => {
     ratio = ratio * autoFit.fontScale / 100
   }
   // return html;
-  return html.replace(/\s*([\d.]+)pt/g, (match, p1) => {
-    return `${Math.floor(parseFloat(p1) * ratio)}px `
-  })
-
+  return html
+    .replace(/href="(ppt\/slides\/)/g, 'href="')
+    .replace(/\s*([\d.]+)pt/g, (match, p1) => {
+      return `${Math.floor(parseFloat(p1) * ratio)}px `
+    })
 }
 
 const getStyle = (htmlString: string) => {

+ 1 - 0
src/views/Editor/CanvasTool/WebpageInput.vue

@@ -110,6 +110,7 @@ const getTypeLabel = (type: number) => {
     77: lang.ssEnglishSpeakingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
+    84: lang.ssFile,
     831: lang.ssKwlTitle,
   }
   return typeMap[type] || lang.ssUnknown

+ 1 - 0
src/views/Editor/CanvasTool/index2.vue

@@ -356,6 +356,7 @@ const getTypeLabel = (type: number) => {
     [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
+    84: lang.ssFile,
     831: lang.ssKwlTitle,
   }
   return typeMap[type] || lang.ssUnknown

+ 354 - 4
src/views/Student/components/choiceQuestionDetailDialog.vue

@@ -428,6 +428,120 @@
         </div>
       </div>
 
+      <!-- 文件上传 -->
+      <div class="c_t15" v-if="workDetail && workDetail.type === '84' && props.showData">
+        <div class="c_t15_title">
+          <div class="c_title">
+            <span>{{ workDetail.json.answerQ }}</span>
+          </div>
+        </div>
+        <span class="c_t15_type">{{ lang.ssFile }}</span>
+        <div class="c_t15_msg" v-if="props.roleType == 1">
+          <div>{{ lang.ssAnswerCount }} {{ props.workArray.length }}<span
+              v-if="props.unsubmittedStudents.length > 0">/{{ props.workArray.length +
+                props.unsubmittedStudents.length
+              }}</span></div>
+          <span v-if="props.unsubmittedStudents.length > 0" @click.stop="viewUnsubmittedStudents()">{{
+            lang.ssViewSubmitStatus2 }}</span>
+          <div class="switch_btn" v-if="props.isCreator && props.roleType == 1">
+            <div class="switch">
+              <Switch :value="canValue" @update:value="handleCanChange" />
+              <span>{{ lang.ssShowResult }}</span>
+            </div>
+            <div class="switch">
+              <Switch :value="likeValue" @update:value="handleLikeChange" />
+              <span>{{ lang.ssLike }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="downloadBtn" @click.stop="downloadAll" :class="{ disabled: downloadLoading }"
+          v-if="props.roleType == 1">
+          <IconDownload />
+          <span>{{ downloadLoading ? lang.ssDownloadLoading : lang.ssBatchDownload }}</span>
+        </div>
+        <div class="c_t15_content" v-show="!lookWorkData">
+          <div class="c_t15_c_item" v-for="item in processedWorkArray" :key="item.id"
+            @click.stop="item.content && item.content.fileList.length > 0 ? lookWork(item.id) : ''">
+            <div class="c_t15_c_i_top">
+              <span>{{ item.name.charAt(0) }}</span>
+              <div>{{ item.name }}</div>
+              <div class="cast_sreen_btn" @click.stop="castScreen(item)"
+                v-if="props.isCreator && props.roleType == 1 && isFollowModeActive && canValue">
+                <IconCastScreen />
+                {{ lang.ssCastScreen }}
+              </div>
+            </div>
+            <div class="bottom_btn" v-if="likeValue">
+              <div class="bottom_like" :class="{ 'active': item.isLikes }" @click.stop="handleLikeClick(item)">
+                <IconThumbsUp />
+                <span>{{ item.likesCount }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="c_t79_workDetail" v-if="lookWorkData" @click.stop="clickContent(false)">
+          <div class="c_t79_wd_top">
+            <img src="../../../assets/img/arrow_left.png" @click.stop="lookWork('')" v-if="!isCastScreen" />
+            <span>{{ lookWorkData.name.charAt(0) }}</span>
+            <div>{{ lookWorkData.name }}</div>
+            <div class="cast_sreen_btn" @click.stop="exitCastScreen"
+              v-if="(props.isCreator && props.roleType == 1 && isCastScreen) || (!isFollowModeActive && isCastScreen)">
+              <IconCastScreen />
+              {{ lang.ssExitCastScreen }}
+            </div>
+          </div>
+          <div class="c_t79_wd_content">
+            <!-- 图片 -->
+            <img
+              v-if="getFileType(lookWorkData.content.fileList[lookWorkIndex].url) === 'image'"
+              :src="lookWorkData.content.fileList[lookWorkIndex].url"
+              @click.stop="lookImage(lookWorkData.content.fileList[lookWorkIndex])" />
+            <!-- 视频 -->
+            <video
+              v-else-if="getFileType(lookWorkData.content.fileList[lookWorkIndex].url) === 'video'"
+              :src="lookWorkData.content.fileList[lookWorkIndex].url"
+              controls
+              @click.stop="lookImage(lookWorkData.content.fileList[lookWorkIndex])" />
+            <!-- 文档/PDF/Office -->
+            <div
+              v-else
+              class="file-preview-card"
+              @click.stop="lookImage(lookWorkData.content.fileList[lookWorkIndex])">
+              <div class="file-preview-icon">
+                {{ getFileIcon(getFileType(lookWorkData.content.fileList[lookWorkIndex].url)) }}
+              </div>
+              <div class="file-preview-name">
+                {{ lookWorkData.content.fileList[lookWorkIndex].name || '文件' }}
+              </div>
+              <div class="file-preview-action">{{ lang.ssClickToPreview }}</div>
+            </div>
+          </div>
+          <!-- <div class="nextAndUpBtn" v-if="lookWorkData.content.fileList.length>1">
+            <span :class="{no_active:lookWorkIndex==0}" @click="changelookWorkIndex(0)">{{ lang.ssPrevP }}</span>
+            <span :class="{no_active:lookWorkData.content.fileList.length-1<=lookWorkIndex}"  @click="changelookWorkIndex(1)">{{ lang.ssNextP }}</span>
+          </div> -->
+          <div class="cq_changeBtn" v-if="lookWorkData.content.fileList.length > 1">
+            <div :class="{ cq_cb_disabled: lookWorkIndex <= 0 }" @click.stop="changelookWorkIndex(0)">
+              <svg style="transform: rotate(-90deg);" viewBox="0 0 1024 1024" version="1.1" width="200" height="200">
+                <path
+                  d="M512 330.666667c14.933333 0 29.866667 4.266667 40.533333 14.933333l277.33333399 234.666667c27.733333 23.466667 29.866667 64 8.53333301 89.6-23.466667 27.733333-64 29.866667-89.6 8.53333299L512 477.866667l-236.8 200.53333299c-27.733333 23.466667-68.266667 19.19999999-89.6-8.53333299-23.466667-27.733333-19.19999999-68.266667 8.53333301-89.6l277.33333399-234.666667c10.666667-10.666667 25.6-14.933333 40.533333-14.933333z"
+                  fill=""></path>
+              </svg>
+            </div>
+            <span>{{ lookWorkIndex + 1 }}/{{ lookWorkData.content.fileList.length }}</span>
+            <div :class="{ cq_cb_disabled: lookWorkIndex >= lookWorkData.content.fileList.length - 1 }"
+              @click.stop="changelookWorkIndex(1)">
+              <svg style="transform: rotate(90deg);" viewBox="0 0 1024 1024" version="1.1" width="200" height="200">
+                <path
+                  d="M512 330.666667c14.933333 0 29.866667 4.266667 40.533333 14.933333l277.33333399 234.666667c27.733333 23.466667 29.866667 64 8.53333301 89.6-23.466667 27.733333-64 29.866667-89.6 8.53333299L512 477.866667l-236.8 200.53333299c-27.733333 23.466667-68.266667 19.19999999-89.6-8.53333299-23.466667-27.733333-19.19999999-68.266667 8.53333301-89.6l277.33333399-234.666667c10.666667-10.666667 25.6-14.933333 40.533333-14.933333z"
+                  fill=""></path>
+              </svg>
+            </div>
+          </div>
+        </div>
+      </div>
+
       <!-- AI应用 -->
       <div class="c_t72" v-if="props.showData && props.showData.toolType === 72">
         <div class="c_t72_title">
@@ -739,6 +853,44 @@
       </div>
     </div>
     <previewImageTool ref="previewImageToolRef" />
+
+    <!-- 视频预览弹窗 -->
+    <Teleport to="body">
+      <div v-if="videoPreviewVisible" class="file-preview-modal" @click.self="closeVideoPreview">
+        <div class="file-preview-close" @click="closeVideoPreview">
+          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18" />
+            <line x1="6" y1="6" x2="18" y2="18" />
+          </svg>
+        </div>
+        <video :src="videoPreviewUrl" controls autoplay class="file-preview-video"></video>
+      </div>
+    </Teleport>
+
+    <!-- 文档预览弹窗 -->
+    <Teleport to="body">
+      <div v-if="docPreviewVisible" class="file-preview-modal" @click.self="closeDocPreview">
+        <div class="file-preview-close" @click="closeDocPreview">
+          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18" />
+            <line x1="6" y1="6" x2="18" y2="18" />
+          </svg>
+        </div>
+        <iframe
+          v-if="docPreviewType === 'pdf'"
+          :src="docPreviewUrl"
+          class="file-preview-frame"
+          frameborder="0"
+        ></iframe>
+        <div v-else class="file-preview-office">
+          <div class="file-preview-office-icon">📝</div>
+          <div class="file-preview-office-name">{{ docPreviewUrl.split('/').pop() }}</div>
+          <a :href="docPreviewUrl" target="_blank" class="file-preview-office-link">
+            {{ lang.ssClickToOpen }}
+          </a>
+        </div>
+      </div>
+    </Teleport>
     <selectUserDialog ref="selectUserDialogRef" />
     <echartsDialog ref="echartsDialogRef" />
   </div>
@@ -1032,13 +1184,68 @@ const lookWorkData = computed(() => {
   return _result
 })
 
-// 查看图片
-const lookImage = (url: string) => {
-  if (previewImageToolRef.value) {
-    previewImageToolRef.value.previewImage(url)
+// 判断文件类型
+const getFileType = (url: string): 'image' | 'video' | 'pdf' | 'office' | 'other' => {
+  if (!url) return 'other'
+  const lower = url.toLowerCase().split('?')[0]
+  if (/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(lower)) return 'image'
+  if (/\.(mp4|webm|ogg|mov|avi)$/.test(lower)) return 'video'
+  if (/\.(pdf)$/.test(lower)) return 'pdf'
+  if (/\.(doc|docx|xls|xlsx|ppt|pptx)$/.test(lower)) return 'office'
+  return 'other'
+}
+
+const getFileIcon = (type: string) => {
+  switch (type) {
+    case 'pdf': return '📄'
+    case 'office': return '📝'
+    case 'video': return '🎬'
+    default: return '📎'
   }
 }
 
+// 查看文件(图片/视频/文档)
+const videoPreviewVisible = ref(false)
+const videoPreviewUrl = ref('')
+const docPreviewVisible = ref(false)
+const docPreviewUrl = ref('')
+const docPreviewType = ref('')
+
+const lookImage = (file: any) => {
+  const url = typeof file === 'string' ? file : file?.url
+  if (!url) return
+  const type = getFileType(url)
+  if (type === 'image') {
+    if (previewImageToolRef.value) {
+      previewImageToolRef.value.previewImage(url)
+    }
+  }
+  else if (type === 'video') {
+    videoPreviewUrl.value = url
+    videoPreviewVisible.value = true
+    document.body.style.overflow = 'hidden'
+  }
+  else if (type === 'pdf' || type === 'office') {
+    docPreviewUrl.value = url
+    docPreviewType.value = type
+    docPreviewVisible.value = true
+    document.body.style.overflow = 'hidden'
+  }
+  else {
+    window.open(url, '_blank')
+  }
+}
+
+const closeVideoPreview = () => {
+  videoPreviewVisible.value = false
+  document.body.style.overflow = ''
+}
+
+const closeDocPreview = () => {
+  docPreviewVisible.value = false
+  document.body.style.overflow = ''
+}
+
 const lookWorkIndex = ref<number>(0)
 // 查看作业
 const lookWork = (id: string) => {
@@ -3800,4 +4007,147 @@ onUnmounted(() => {
     grid-template-columns: 1fr;
   }
 }
+
+/* 文件预览卡片 */
+.file-preview-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 24px;
+  min-height: 200px;
+  background: #f9fafb;
+  border: 2px dashed #d1d5db;
+  border-radius: 12px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #FF9300;
+    background: #fffbeb;
+  }
+
+  &--mini {
+    width: 80px;
+    height: 80px;
+    padding: 0;
+    min-height: unset;
+    gap: 0;
+    border-style: solid;
+    flex-shrink: 0;
+  }
+}
+
+.file-preview-icon {
+  font-size: 48px;
+  line-height: 1;
+}
+
+.file-preview-card--mini .file-preview-icon {
+  font-size: 32px;
+}
+
+.file-preview-name {
+  font-size: 13px;
+  color: #374151;
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: center;
+}
+
+.file-preview-action {
+  font-size: 12px;
+  color: #3b82f6;
+  font-weight: 500;
+}
+
+/* 文件预览弹窗 */
+.file-preview-modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.85);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+}
+
+.file-preview-close {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.15);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  cursor: pointer;
+  transition: background 0.2s ease;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.25);
+  }
+}
+
+.file-preview-video {
+  max-width: 90vw;
+  max-height: 85vh;
+  border-radius: 8px;
+  object-fit: contain;
+}
+
+.file-preview-frame {
+  width: 90vw;
+  height: 85vh;
+  border-radius: 8px;
+  border: none;
+  background: #fff;
+}
+
+.file-preview-office {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16px;
+  padding: 40px;
+  background: #fff;
+  border-radius: 12px;
+  min-width: 300px;
+}
+
+.file-preview-office-icon {
+  font-size: 64px;
+}
+
+.file-preview-office-name {
+  font-size: 16px;
+  font-weight: 600;
+  color: #374151;
+  max-width: 300px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.file-preview-office-link {
+  display: inline-block;
+  padding: 10px 24px;
+  background: #3b82f6;
+  color: #fff;
+  border-radius: 8px;
+  text-decoration: none;
+  font-size: 14px;
+  font-weight: 500;
+  transition: background 0.2s ease;
+
+  &:hover {
+    background: #2563eb;
+  }
+}
 </style>

+ 6 - 1
src/views/Student/index.vue

@@ -595,7 +595,7 @@ type WorkItem = {
   [key: string]: any
 }
 const workArray = ref<WorkItem[]>([])
-const toolTypeArray = ref<number[]>([45, 15, 73, 72, 78, 79, 831])
+const toolTypeArray = ref<number[]>([45, 15, 73, 72, 78, 79, 831, 84])
 
 // 作业弹窗相关
 const selectedWork = ref<any>(null)
@@ -3775,11 +3775,15 @@ const getMessages = (msgObj: any) => {
       if (!result.can && choiceQuestionDetailDialogOpenList.value.includes(slideIndex.value)) {
         openChoiceQuestionDetail2(slideIndex.value)
       }
+      else {
+        getWork(true)
+      }
     }
   }
 
   // 投屏
   if (props.type == '2' && msgObj.type === 'cast_screen' && msgObj.courseid === props.courseid) {
+    if (is_cast_screen.value) return // 已在投屏中,忽略重复消息
     openChoiceQuestionDetail3(slideIndex.value)
     setTimeout(() => {
       is_cast_screen.value = true
@@ -3789,6 +3793,7 @@ const getMessages = (msgObj: any) => {
 
   // 退出投屏
   if (props.type == '2' && msgObj.type === 'exit_cast_screen' && msgObj.courseid === props.courseid) {
+    if (!is_cast_screen.value) return // 未在投屏中,忽略重复退出消息
     openChoiceQuestionDetail3(slideIndex.value)
     setTimeout(() => {
       is_cast_screen.value = false

+ 4 - 1
src/views/lang/cn.json

@@ -1089,5 +1089,8 @@
   "ssKwlKnown": "K-已知",
   "ssKwlWant": "W-想知",
   "ssKwlLearned": "L-已学",
-  "ssKwlReflection": "学习反思"
+  "ssKwlReflection": "学习反思",
+  "ssFile": "文件上传",
+  "ssClickToPreview": "点击预览",
+  "ssClickToOpen": "点击打开"
 }

+ 4 - 1
src/views/lang/en.json

@@ -1089,5 +1089,8 @@
   "ssKwlKnown": "K-Known",
   "ssKwlWant": "W-Want to know",
   "ssKwlLearned": "L-Learned",
-  "ssKwlReflection": "Learning Reflection"
+  "ssKwlReflection": "Learning Reflection",
+  "ssFile": "File Upload",
+  "ssClickToPreview": "Click to Preview",
+  "ssClickToOpen": "Click to Open"
 }

+ 4 - 1
src/views/lang/hk.json

@@ -1089,5 +1089,8 @@
   "ssKwlKnown": "K-已知",
   "ssKwlWant": "W-想知",
   "ssKwlLearned": "L-已學",
-  "ssKwlReflection": "學習反思"
+  "ssKwlReflection": "學習反思",
+  "ssFile": "文件上傳",
+  "ssClickToPreview": "點擊預覽",
+  "ssClickToOpen": "點擊打開"
 }