466 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | ||
|   <div class="file-reader" v-if="file">
 | ||
|     <div class="reader-header">
 | ||
|       <h3>{{ file.name }}</h3>
 | ||
|       <div class="header-actions">
 | ||
|         <button
 | ||
|           v-if="file.isRemote && loadedFile.type !== ''"
 | ||
|           @click="downloadFile"
 | ||
|         >
 | ||
|           {{ fileTypeCheck == "unsupported-size" ? "远程传输" : "浏览器下载" }}
 | ||
|         </button>
 | ||
|         <button v-if="canEdit" @click="toggleEdit">
 | ||
|           {{ isEditing ? "保存" : "编辑" }}
 | ||
|         </button>
 | ||
|         <button @click="$emit('close')">关闭</button>
 | ||
|       </div>
 | ||
|     </div>
 | ||
| 
 | ||
|     <div class="reader-content">
 | ||
|       <!-- 图片预览 -->
 | ||
|       <div v-if="fileTypeCheck === 'image'" class="image-viewer">
 | ||
|         <img :src="fileUrl" :alt="file.name" />
 | ||
|       </div>
 | ||
| 
 | ||
|       <!-- 视频预览 -->
 | ||
|       <div v-else-if="fileTypeCheck === 'video'" class="video-viewer">
 | ||
|         <video controls>
 | ||
|           <source :src="fileUrl" :type="mimeType" />
 | ||
|           您的浏览器不支持视频播放
 | ||
|         </video>
 | ||
|       </div>
 | ||
| 
 | ||
|       <!-- 代码预览 -->
 | ||
|       <div v-else-if="fileTypeCheck === 'code'" class="code-viewer">
 | ||
|         <div class="code-header">
 | ||
|           <select v-model="selectedLanguage">
 | ||
|             <option value="auto">自动检测</option>
 | ||
|             <option value="javascript">JavaScript</option>
 | ||
|             <option value="typescript">TypeScript</option>
 | ||
|             <option value="html">HTML</option>
 | ||
|             <option value="css">CSS</option>
 | ||
|             <option value="python">Python</option>
 | ||
|             <option value="java">Java</option>
 | ||
|             <option value="cpp">C++</option>
 | ||
|             <option value="go">Go</option>
 | ||
|             <option value="rust">Rust</option>
 | ||
|             <option value="lua">Lua</option>
 | ||
|             <option value="sql">SQL</option>
 | ||
|             <option value="shell">Shell</option>
 | ||
|             <option value="json">JSON</option>
 | ||
|             <option value="other">其他</option>
 | ||
|           </select>
 | ||
|         </div>
 | ||
|         <div v-if="isEditing" class="editor">
 | ||
|           <textarea
 | ||
|             v-model="editingContent"
 | ||
|             :style="{ height: editorHeight + 'px', minHeight: '100%' }"
 | ||
|             @input="adjustEditorHeight"
 | ||
|           ></textarea>
 | ||
|         </div>
 | ||
|         <pre v-else><code v-html="highlightedCode"></code></pre>
 | ||
|       </div>
 | ||
| 
 | ||
|       <!-- 文本预览 -->
 | ||
|       <div
 | ||
|         v-else-if="
 | ||
|           fileTypeCheck === 'text' || fileTypeCheck === 'unsupported-type'
 | ||
|         "
 | ||
|         class="text-viewer"
 | ||
|       >
 | ||
|         <div v-if="isEditing" class="editor">
 | ||
|           <textarea
 | ||
|             v-model="editingContent"
 | ||
|             :style="{ height: editorHeight + 'px' }"
 | ||
|             @input="adjustEditorHeight"
 | ||
|           ></textarea>
 | ||
|         </div>
 | ||
|         <pre v-else>{{ fileContent }}</pre>
 | ||
|       </div>
 | ||
|       <!-- 不支持的文件类型 -->
 | ||
|       <div v-else class="unsupported">
 | ||
|         {{
 | ||
|           fileTypeCheck === "unsupported-size"
 | ||
|             ? "文件过大,无法预览,请传输后查看"
 | ||
|             : "不支持预览该类型的文件"
 | ||
|         }}
 | ||
|       </div>
 | ||
|     </div>
 | ||
|   </div>
 | ||
| </template>
 | ||
| 
 | ||
| <script setup lang="ts">
 | ||
| import { ref, onMounted, onUnmounted, computed } from "vue";
 | ||
| import {
 | ||
|   fileMgrInstance,
 | ||
|   type FileData,
 | ||
|   type FileInfo,
 | ||
| } from "../utils/fileMgr";
 | ||
| import hljs from "highlight.js";
 | ||
| import "highlight.js/styles/github.css";
 | ||
| import { Modal } from "ant-design-vue";
 | ||
| import { localCurrentFile, remoteCurrentFile } from "../utils/common";
 | ||
| 
 | ||
| const props = defineProps<{
 | ||
|   file: FileInfo;
 | ||
| }>();
 | ||
| 
 | ||
| const emit = defineEmits<{
 | ||
|   (e: "close"): void;
 | ||
| }>();
 | ||
| 
 | ||
| const fileUrl = ref("");
 | ||
| const fileContent = ref("");
 | ||
| const mimeType = ref("");
 | ||
| const selectedLanguage = ref("auto");
 | ||
| const isEditing = ref(false);
 | ||
| const editingContent = ref("");
 | ||
| const editorHeight = ref(300);
 | ||
| 
 | ||
| // 下载文件
 | ||
| const downloadFile = async () => {
 | ||
|   if (fileTypeCheck.value == "unsupported-size") {
 | ||
|     await props.file.getFile(
 | ||
|       false,
 | ||
|       localCurrentFile.value.path.replace(
 | ||
|         (
 | ||
|           await fileMgrInstance.getRootFile()
 | ||
|         ).path,
 | ||
|         ""
 | ||
|       ),
 | ||
|       remoteCurrentFile.value.path
 | ||
|     );
 | ||
|     localCurrentFile.value.loadLocalDirectory();
 | ||
|     return;
 | ||
|   }
 | ||
|   Modal.confirm({
 | ||
|     title: "下载文件",
 | ||
|     content: `确定要下载${props.file.name}吗?`,
 | ||
|     onOk: () => {
 | ||
|       const blob = new Blob([loadedFile.value.buffer], {
 | ||
|         type: loadedFile.value.type || "application/octet-stream",
 | ||
|       });
 | ||
|       const url = URL.createObjectURL(blob);
 | ||
|       const a = document.createElement("a");
 | ||
|       a.href = url;
 | ||
|       a.download = props.file.name;
 | ||
|       a.click();
 | ||
|       URL.revokeObjectURL(url);
 | ||
|     },
 | ||
|   });
 | ||
| };
 | ||
| 
 | ||
| // 是否可以编辑
 | ||
| const canEdit = computed(() => {
 | ||
|   return fileTypeCheck.value === "text" || fileTypeCheck.value === "code";
 | ||
| });
 | ||
| 
 | ||
| // 文件类型判断
 | ||
| const fileTypeCheck = computed(() => {
 | ||
|   //如果文件大于30M,则不预览
 | ||
|   if (props.file.size > 30 * 1024 * 1024) {
 | ||
|     return "unsupported-size";
 | ||
|   }
 | ||
|   const ext = props.file.name.split(".").pop()?.toLowerCase() || "";
 | ||
| 
 | ||
|   // 图片类型
 | ||
|   if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
 | ||
|     return "image";
 | ||
|   }
 | ||
| 
 | ||
|   // 视频类型
 | ||
|   if (["mp4", "webm", "ogg"].includes(ext)) {
 | ||
|     return "video";
 | ||
|   }
 | ||
| 
 | ||
|   // 代码类型
 | ||
|   if (
 | ||
|     [
 | ||
|       "js",
 | ||
|       "ts",
 | ||
|       "json",
 | ||
|       "html",
 | ||
|       "css",
 | ||
|       "vue",
 | ||
|       "jsx",
 | ||
|       "tsx",
 | ||
|       "py",
 | ||
|       "java",
 | ||
|       "cpp",
 | ||
|       "c",
 | ||
|       "go",
 | ||
|       "rs",
 | ||
|       "lua",
 | ||
|       "sql",
 | ||
|       "shell",
 | ||
|     ].includes(ext)
 | ||
|   ) {
 | ||
|     return "code";
 | ||
|   }
 | ||
| 
 | ||
|   // 文本类型
 | ||
|   if (["txt", "md", "log"].includes(ext)) {
 | ||
|     return "text";
 | ||
|   }
 | ||
| 
 | ||
|   return "unsupported-type";
 | ||
| });
 | ||
| 
 | ||
| // 代码高亮处理
 | ||
| const highlightedCode = computed(() => {
 | ||
|   if (!fileContent.value) return "";
 | ||
|   try {
 | ||
|     if (selectedLanguage.value && selectedLanguage.value !== "auto") {
 | ||
|       // 使用指定的语言
 | ||
|       const result = hljs.highlight(fileContent.value, {
 | ||
|         language: selectedLanguage.value,
 | ||
|       });
 | ||
|       return result.value;
 | ||
|     } else {
 | ||
|       // 自动检测语言
 | ||
|       const result = hljs.highlightAuto(fileContent.value);
 | ||
|       selectedLanguage.value = result.language || "auto";
 | ||
|       return result.value;
 | ||
|     }
 | ||
|   } catch (err) {
 | ||
|     console.error("代码高亮失败:", err);
 | ||
|     return fileContent.value;
 | ||
|   }
 | ||
| });
 | ||
| 
 | ||
| // 切换编辑模式
 | ||
| const toggleEdit = async () => {
 | ||
|   if (isEditing.value) {
 | ||
|     // 保存文件
 | ||
|     try {
 | ||
|       const encoder = new TextEncoder();
 | ||
|       const buffer = encoder.encode(editingContent.value);
 | ||
|       await props.file.saveFile(buffer);
 | ||
|       fileContent.value = editingContent.value;
 | ||
|       isEditing.value = false;
 | ||
|     } catch (err) {
 | ||
|       console.error("保存文件失败:", err);
 | ||
|       alert("保存文件失败: " + (err as Error).message);
 | ||
|     }
 | ||
|   } else {
 | ||
|     // 进入编辑模式
 | ||
|     editingContent.value = fileContent.value;
 | ||
|     isEditing.value = true;
 | ||
|     adjustEditorHeight();
 | ||
|   }
 | ||
| };
 | ||
| 
 | ||
| // 自动调整编辑器高度
 | ||
| const adjustEditorHeight = () => {
 | ||
|   const textarea = document.querySelector(
 | ||
|     ".editor textarea"
 | ||
|   ) as HTMLTextAreaElement;
 | ||
|   if (textarea) {
 | ||
|     textarea.style.height = "auto";
 | ||
|     textarea.style.height = textarea.scrollHeight + "px";
 | ||
|     editorHeight.value = textarea.scrollHeight;
 | ||
|   }
 | ||
| };
 | ||
| const loadedFile = ref<{
 | ||
|   type: string;
 | ||
|   buffer: ArrayBuffer;
 | ||
|   name: string;
 | ||
|   size: number;
 | ||
|   lastModified: number;
 | ||
| }>({
 | ||
|   type: "",
 | ||
|   buffer: undefined,
 | ||
|   name: "",
 | ||
|   size: 0,
 | ||
|   lastModified: 0,
 | ||
| });
 | ||
| // 加载文件内容
 | ||
| const loadFile = async () => {
 | ||
|   try {
 | ||
|     const file: FileData = (await props.file.getFile(true)) as FileData;
 | ||
|     loadedFile.value = file;
 | ||
|     if (file.buffer == null) {
 | ||
|       file.buffer = props.file.previewCache;
 | ||
|     }
 | ||
|     if (fileTypeCheck.value === "image" || fileTypeCheck.value === "video") {
 | ||
|       fileUrl.value = URL.createObjectURL(new Blob([file.buffer]));
 | ||
|       mimeType.value = file.type;
 | ||
|     } else if (
 | ||
|       fileTypeCheck.value === "code" ||
 | ||
|       fileTypeCheck.value === "text"
 | ||
|     ) {
 | ||
|       fileContent.value = new TextDecoder().decode(file.buffer);
 | ||
|     } else {
 | ||
|       fileContent.value = new TextDecoder().decode(file.buffer);
 | ||
|     }
 | ||
|   } catch (err) {
 | ||
|     console.error("加载文件失败:", err);
 | ||
|   }
 | ||
| };
 | ||
| 
 | ||
| onMounted(() => {
 | ||
|   loadFile();
 | ||
| });
 | ||
| 
 | ||
| // 组件卸载时清理资源
 | ||
| onUnmounted(() => {
 | ||
|   if (fileUrl.value) {
 | ||
|     URL.revokeObjectURL(fileUrl.value);
 | ||
|     props.file.clearPreviewCache();
 | ||
|   }
 | ||
| });
 | ||
| </script>
 | ||
| 
 | ||
| <style scoped>
 | ||
| .file-reader {
 | ||
|   position: fixed;
 | ||
|   top: 0;
 | ||
|   left: 0;
 | ||
|   right: 0;
 | ||
|   bottom: 0;
 | ||
|   background: rgba(255, 255, 255, 0.98);
 | ||
|   z-index: 1000;
 | ||
|   display: flex;
 | ||
|   flex-direction: column;
 | ||
|   padding: 20px;
 | ||
| }
 | ||
| 
 | ||
| .reader-header {
 | ||
|   display: flex;
 | ||
|   justify-content: space-between;
 | ||
|   align-items: center;
 | ||
|   padding-bottom: 20px;
 | ||
|   border-bottom: 1px solid #eee;
 | ||
| }
 | ||
| 
 | ||
| .reader-content {
 | ||
|   flex: 1;
 | ||
|   overflow: auto;
 | ||
|   padding: 20px 0;
 | ||
| }
 | ||
| 
 | ||
| .image-viewer {
 | ||
|   display: flex;
 | ||
|   justify-content: center;
 | ||
|   align-items: center;
 | ||
|   height: 100%;
 | ||
| }
 | ||
| 
 | ||
| .image-viewer img {
 | ||
|   max-width: 100%;
 | ||
|   max-height: 100%;
 | ||
|   object-fit: contain;
 | ||
| }
 | ||
| 
 | ||
| .video-viewer {
 | ||
|   display: flex;
 | ||
|   justify-content: center;
 | ||
|   align-items: center;
 | ||
|   height: 100%;
 | ||
| }
 | ||
| 
 | ||
| .video-viewer video {
 | ||
|   max-width: 100%;
 | ||
|   max-height: 100%;
 | ||
| }
 | ||
| 
 | ||
| .code-viewer,
 | ||
| .text-viewer {
 | ||
|   height: 100%;
 | ||
|   overflow: auto;
 | ||
| }
 | ||
| 
 | ||
| .code-viewer pre,
 | ||
| .text-viewer pre {
 | ||
|   margin: 0;
 | ||
|   padding: 20px;
 | ||
|   background: #f5f5f5;
 | ||
|   border-radius: 4px;
 | ||
|   white-space: pre-wrap;
 | ||
|   word-wrap: break-word;
 | ||
| }
 | ||
| 
 | ||
| .unsupported {
 | ||
|   display: flex;
 | ||
|   justify-content: center;
 | ||
|   align-items: center;
 | ||
|   height: 100%;
 | ||
|   color: #666;
 | ||
| }
 | ||
| 
 | ||
| button {
 | ||
|   padding: 6px 12px;
 | ||
|   border: 1px solid #ddd;
 | ||
|   border-radius: 4px;
 | ||
|   background: white;
 | ||
|   cursor: pointer;
 | ||
| }
 | ||
| 
 | ||
| button:hover {
 | ||
|   background: #f5f5f5;
 | ||
| }
 | ||
| 
 | ||
| .code-header {
 | ||
|   padding: 10px;
 | ||
|   background: #f8f8f8;
 | ||
|   border-bottom: 1px solid #eee;
 | ||
| }
 | ||
| 
 | ||
| .code-header select {
 | ||
|   padding: 4px 8px;
 | ||
|   border: 1px solid #ddd;
 | ||
|   border-radius: 4px;
 | ||
|   background: white;
 | ||
| }
 | ||
| 
 | ||
| .code-viewer pre {
 | ||
|   margin: 0;
 | ||
|   padding: 20px;
 | ||
|   background: #f8f8f8;
 | ||
|   border-radius: 4px;
 | ||
|   overflow-x: auto;
 | ||
| }
 | ||
| 
 | ||
| .code-viewer code {
 | ||
|   font-family: "Fira Code", "Consolas", monospace;
 | ||
|   font-size: 14px;
 | ||
|   line-height: 1.5;
 | ||
| }
 | ||
| 
 | ||
| /* 取消 scoped 样式对 highlight.js 的影响 */
 | ||
| :deep(.hljs) {
 | ||
|   background: transparent;
 | ||
|   padding: 0;
 | ||
| }
 | ||
| 
 | ||
| .header-actions {
 | ||
|   display: flex;
 | ||
|   gap: 10px;
 | ||
| }
 | ||
| 
 | ||
| .editor {
 | ||
|   height: 100%;
 | ||
|   padding: 20px;
 | ||
|   background: #f8f8f8;
 | ||
|   border-radius: 4px;
 | ||
| }
 | ||
| 
 | ||
| .editor textarea {
 | ||
|   width: 100%;
 | ||
|   min-height: 300px;
 | ||
|   padding: 10px;
 | ||
|   border: 1px solid #ddd;
 | ||
|   border-radius: 4px;
 | ||
|   font-family: "Fira Code", "Consolas", monospace;
 | ||
|   font-size: 14px;
 | ||
|   line-height: 1.5;
 | ||
|   resize: none;
 | ||
|   background: white;
 | ||
| }
 | ||
| 
 | ||
| .editor textarea:focus {
 | ||
|   outline: none;
 | ||
|   border-color: #4a9eff;
 | ||
| }
 | ||
| </style>
 |