init
This commit is contained in:
		
						commit
						1cd6e2a253
					
				
							
								
								
									
										14
									
								
								src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/App.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| <template> | ||||
|   <Index /> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| import { onMounted, onUnmounted } from "vue"; | ||||
| import Index from "./pages/file/index.vue"; | ||||
| onMounted(() => { | ||||
|   console.log("App Launch"); | ||||
| }); | ||||
| onUnmounted(() => { | ||||
|   console.log("App Hide"); | ||||
| }); | ||||
| </script> | ||||
| <style></style> | ||||
							
								
								
									
										8
									
								
								src/env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| /// <reference types="vite/client" />
 | ||||
| 
 | ||||
| declare module '*.vue' { | ||||
|   import { DefineComponent } from 'vue' | ||||
|   // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
 | ||||
|   const component: DefineComponent<{}, {}, any> | ||||
|   export default component | ||||
| } | ||||
							
								
								
									
										7
									
								
								src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/main.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| import { createApp } from 'vue' | ||||
| import App from './App.vue' | ||||
| import 'ant-design-vue/dist/reset.css'; | ||||
| import Antd from 'ant-design-vue'; | ||||
| const app = createApp(App); | ||||
| app.use(Antd); | ||||
| app.mount('#app') | ||||
							
								
								
									
										371
									
								
								src/pages/file/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										371
									
								
								src/pages/file/index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,371 @@ | ||||
| <template> | ||||
|   <div class="container"> | ||||
|     <!-- 连接状态 --> | ||||
|     <div class="status-bar"> | ||||
|       <div class="peer-info"> | ||||
|         我的ID: <span class="id-text">{{ myId || "等待连接..." }}</span> | ||||
|         <Button type="primary" @click="copyId" v-if="myId">复制</Button> | ||||
|         <Button type="link" @click="shareUrl" v-if="myId">分享</Button> | ||||
|       </div> | ||||
|       <!-- 显示流量 丢包率--> | ||||
|       <div class="status-info"> | ||||
|         <div class="status-item"> | ||||
|           <span>流量:</span> | ||||
|           <span>{{ formatSize(transInfo.bytes) }}/s</span> | ||||
|         </div> | ||||
|         <div class="status-item"> | ||||
|           <span>包数:</span> | ||||
|           <span>{{ transInfo.packets }}/s</span> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="connect-section"> | ||||
|         <input | ||||
|           v-model="targetId" | ||||
|           :placeholder="!myId ? '请稍后...' : '输入对方ID'" | ||||
|           :disabled="isConnected || !myId" | ||||
|         /> | ||||
|         <Button @click="handleConnect" :disabled="!targetId || isConnected"> | ||||
|           {{ isConnected ? "已连接" : "连接" }} | ||||
|         </Button> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- 文件传输区域 --> | ||||
|     <div class="file-transfer"> | ||||
|       <!-- 本地文件区域 --> | ||||
|       <FileView :isRemote="false" :selectedFiles="selectedLocalFiles" /> | ||||
| 
 | ||||
|       <!-- 传输控制 --> | ||||
|       <div class="transfer-controls"> | ||||
|         <Clipboard v-if="isConnected" /> | ||||
|         <Button | ||||
|           type="primary" | ||||
|           :disabled="!isConnected || selectedLocalFiles.length === 0" | ||||
|           @click="handleSend" | ||||
|         > | ||||
|           发送 | ||||
|         </Button> | ||||
|         <Button | ||||
|           type="primary" | ||||
|           :disabled="!isConnected || selectedRemoteFiles.length === 0" | ||||
|           @click="handleReceive" | ||||
|           :loading="receiveLoading" | ||||
|         > | ||||
|           接收 | ||||
|         </Button> | ||||
|       </div> | ||||
| 
 | ||||
|       <!-- 远程文件区域 --> | ||||
|       <FileView :isRemote="true" :selectedFiles="selectedRemoteFiles" /> | ||||
|     </div> | ||||
|   </div> | ||||
|   <FileTransferView /> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import Clipboard from "./item/clipboard.vue"; | ||||
| import { ref, onMounted } from "vue"; | ||||
| import { peer } from "./utils/peer"; | ||||
| import FileView from "./item/fileView.vue"; | ||||
| import { notification } from "ant-design-vue"; | ||||
| import { fileMgrInstance, type FileData } from "./utils/fileMgr"; | ||||
| import { Button } from "ant-design-vue"; | ||||
| import { | ||||
|   formatSize, | ||||
|   getUrlParam, | ||||
|   localCurrentFile, | ||||
|   remoteCurrentFile, | ||||
| } from "./utils/common"; | ||||
| import FileTransferView from "./item/fileTranserView.vue"; | ||||
| 
 | ||||
| const receiveLoading = ref(false); | ||||
| const myId = ref(""); | ||||
| const targetId = ref(""); | ||||
| const isConnected = ref(false); | ||||
| // 文件系统相关 | ||||
| const selectedLocalFiles = ref<string[]>([]); | ||||
| const selectedRemoteFiles = ref<string[]>([]); | ||||
| 
 | ||||
| const shareUrl = async () => { | ||||
|   if (myId.value) { | ||||
|     const url = | ||||
|       window.location.origin + window.location.pathname + "?sign=" + myId.value; | ||||
|     await copyToClipboard(url); | ||||
|     notification.success({ | ||||
|       message: "链接已复制", | ||||
|       description: "已成功复制到剪贴板", | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| // 复制ID | ||||
| const copyId = async () => { | ||||
|   if (myId.value) { | ||||
|     try { | ||||
|       await copyToClipboard(myId.value); | ||||
|       notification.success({ | ||||
|         message: "ID已复制", | ||||
|         description: "已成功复制到剪贴板", | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       notification.error({ | ||||
|         message: "复制失败", | ||||
|         icon: "error", | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| //复制到剪贴板 | ||||
| const copyToClipboard = async (text: string) => { | ||||
|   await navigator.clipboard.writeText(text); | ||||
| }; | ||||
| 
 | ||||
| // 连接处理 | ||||
| const handleConnect = () => { | ||||
|   if (!targetId.value) return; | ||||
|   peer.connect(targetId.value); | ||||
| }; | ||||
| let lastBytes = 0; | ||||
| let lastPackets = 0; | ||||
| const transInfo = ref({ | ||||
|   bytes: 0, | ||||
|   packets: 0, | ||||
| }); | ||||
| const checkBytes = () => { | ||||
|   transInfo.value.bytes = peer.transbytesNum - lastBytes; | ||||
|   transInfo.value.packets = peer.transpackNum - lastPackets; | ||||
|   lastBytes = peer.transbytesNum; | ||||
|   lastPackets = peer.transpackNum; | ||||
|   setTimeout(() => { | ||||
|     checkBytes(); | ||||
|   }, 1000); | ||||
| }; | ||||
| const handleSend = async () => { | ||||
|   notification.info({ | ||||
|     message: "未完成", | ||||
|     description: "开发中...", | ||||
|   }); | ||||
| }; | ||||
| const handleReceive = async () => { | ||||
|   receiveLoading.value = true; | ||||
|   const rootFile = await fileMgrInstance.getRootFile(); | ||||
|   console.log(localCurrentFile.value.path); | ||||
|   try { | ||||
|     let getFileHandles: Promise<FileData | FileData[]>[] = []; | ||||
|     selectedRemoteFiles.value.forEach((filepath) => { | ||||
|       getFileHandles.push( | ||||
|         fileMgrInstance.remoteRootFile | ||||
|           .getFileInfo(filepath) | ||||
|           .getFile( | ||||
|             false, | ||||
|             localCurrentFile.value.path.replace(rootFile.path, ""), | ||||
|             remoteCurrentFile.value.path | ||||
|           ) | ||||
|       ); | ||||
|     }); | ||||
|     await Promise.all(getFileHandles); | ||||
|   } catch (err) { | ||||
|     notification.error({ | ||||
|       message: "接收文件失败", | ||||
|     }); | ||||
|   } | ||||
|   receiveLoading.value = false; | ||||
| 
 | ||||
|   await localCurrentFile.value.loadLocalDirectory(); | ||||
| }; | ||||
| // 事件监听 | ||||
| onMounted(() => { | ||||
|   peer.on("open", ((event: CustomEvent) => { | ||||
|     myId.value = event.detail; | ||||
|     const sign = getUrlParam("sign"); | ||||
|     if (sign) { | ||||
|       targetId.value = sign; | ||||
|       handleConnect(); | ||||
|     } | ||||
|   }) as EventListener); | ||||
| 
 | ||||
|   peer.on("connection-open", ((event: CustomEvent) => { | ||||
|     if (event.detail.peer == peer.remoteConnection?.peer) { | ||||
|       checkBytes(); | ||||
|       isConnected.value = peer.remoteConnection?.open; | ||||
|       fileMgrInstance.remoteRootFile.loadLocalDirectory(); | ||||
|       notification.success({ | ||||
|         message: "连接成功", | ||||
|       }); | ||||
|     } else { | ||||
|       notification.success({ | ||||
|         message: "出现新的连接", | ||||
|       }); | ||||
|     } | ||||
|   }) as EventListener); | ||||
| 
 | ||||
|   peer.on("peer-disconnected", ((event: CustomEvent) => { | ||||
|     isConnected.value = peer.remoteConnection?.open; | ||||
|     notification.error({ | ||||
|       message: event.detail.peer + "连接已断开", | ||||
|     }); | ||||
|   }) as EventListener); | ||||
| 
 | ||||
|   peer.on("error", ((event: CustomEvent) => { | ||||
|     notification.error({ | ||||
|       message: "发生错误", | ||||
|     }); | ||||
|     console.error("连接错误:", event.detail); | ||||
|   }) as EventListener); | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .container { | ||||
|   padding: 20px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   height: calc(100vh - 75px); | ||||
|   gap: 20px; | ||||
| } | ||||
| 
 | ||||
| .status-bar { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   padding: 10px; | ||||
|   background: #f5f5f5; | ||||
|   border-radius: 8px; | ||||
| } | ||||
| 
 | ||||
| .peer-info { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .connect-section { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .file-transfer { | ||||
|   display: flex; | ||||
|   flex: 1; | ||||
|   gap: 20px; | ||||
|   min-height: 0; | ||||
| } | ||||
| 
 | ||||
| .file-panel { | ||||
|   flex: 1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 8px; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .panel-header { | ||||
|   padding: 10px; | ||||
|   background: #f5f5f5; | ||||
|   border-bottom: 1px solid #ddd; | ||||
| } | ||||
| 
 | ||||
| .path-nav { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
|   margin-top: 5px; | ||||
| } | ||||
| 
 | ||||
| .file-list { | ||||
|   flex: 1; | ||||
|   overflow-y: auto; | ||||
|   padding: 10px; | ||||
| } | ||||
| 
 | ||||
| .file-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   padding: 8px; | ||||
|   cursor: pointer; | ||||
|   border-radius: 4px; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .file-item:hover { | ||||
|   background: #f5f5f5; | ||||
| } | ||||
| 
 | ||||
| .file-item.selected { | ||||
|   background: #e3f2fd; | ||||
| } | ||||
| 
 | ||||
| .file-icon { | ||||
|   font-size: 24px; | ||||
| } | ||||
| 
 | ||||
| .file-info { | ||||
|   flex: 1; | ||||
| } | ||||
| 
 | ||||
| .file-name { | ||||
|   font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| .file-meta { | ||||
|   font-size: 12px; | ||||
|   color: #666; | ||||
| } | ||||
| 
 | ||||
| .panel-footer { | ||||
|   padding: 10px; | ||||
|   background: #f5f5f5; | ||||
|   border-top: 1px solid #ddd; | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .transfer-controls { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: center; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .transfer-progress { | ||||
|   padding: 10px; | ||||
|   background: #f5f5f5; | ||||
|   border-radius: 8px; | ||||
| } | ||||
| 
 | ||||
| .progress-info { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 5px; | ||||
| } | ||||
| 
 | ||||
| .progress-bar { | ||||
|   height: 4px; | ||||
|   background: #ddd; | ||||
|   border-radius: 2px; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .progress-fill { | ||||
|   height: 100%; | ||||
|   background: #007aff; | ||||
|   transition: width 0.3s ease; | ||||
| } | ||||
| 
 | ||||
| input { | ||||
|   padding: 8px 12px; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 4px; | ||||
| } | ||||
| 
 | ||||
| input:disabled { | ||||
|   background: #f5f5f5; | ||||
| } | ||||
| 
 | ||||
| .id-text { | ||||
|   font-weight: bold; | ||||
|   color: #007aff; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										132
									
								
								src/pages/file/item/clipboard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/pages/file/item/clipboard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,132 @@ | ||||
| <template> | ||||
|   <div | ||||
|     class="clipboard-container" | ||||
|     @mouseenter="mouseEntering = true" | ||||
|     @mouseleave="mouseEntering = false" | ||||
|   > | ||||
|     <Transition name="fade"> | ||||
|       <img | ||||
|         class="copy-icon" | ||||
|         v-if="!mouseEntering" | ||||
|         src="/static/copy.png" | ||||
|         alt="copy" | ||||
|       /> | ||||
|       <div v-else> | ||||
|         <div class="clipboard-header"> | ||||
|           <div class="clipboard-actions"> | ||||
|             <Button @click="getRemoteClipboard" :loading="loading" | ||||
|               >获取对方粘贴板</Button | ||||
|             > | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="clipboard-content"> | ||||
|           <div class="clipboard-text"> | ||||
|             {{ remoteClipboard || "暂无内容" }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Transition> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { ref } from "vue"; | ||||
| import { Button } from "ant-design-vue"; | ||||
| import { notification } from "ant-design-vue"; | ||||
| import { peer, MessageType } from "../utils/peer"; | ||||
| 
 | ||||
| const loading = ref(false); | ||||
| const remoteClipboard = ref(""); | ||||
| const mouseEntering = ref(false); | ||||
| 
 | ||||
| // 获取远程粘贴板内容 | ||||
| const getRemoteClipboard = async () => { | ||||
|   loading.value = true; | ||||
|   try { | ||||
|     const result = await peer.send({ | ||||
|       type: MessageType.request_copyClipboard, | ||||
|       data: null, | ||||
|     }); | ||||
|     remoteClipboard.value = result; | ||||
|     notification.success({ | ||||
|       message: "获取成功", | ||||
|     }); | ||||
|   } catch (error: any) { | ||||
|     notification.error({ | ||||
|       message: "获取失败", | ||||
|       description: error?.message || "未知错误", | ||||
|     }); | ||||
|   } finally { | ||||
|     loading.value = false; | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .fade-enter-active, | ||||
| .fade-leave-active { | ||||
|   transition: opacity 0.5s ease, max-height 0.5s ease; | ||||
|   max-height: 200px; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .fade-enter-from, | ||||
| .fade-leave-to { | ||||
|   opacity: 0; | ||||
|   max-height: 0; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .clipboard-container { | ||||
|   padding: 20px; | ||||
|   background: white; | ||||
|   border-radius: 8px; | ||||
|   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   overflow: hidden; | ||||
|   max-width: 180px; | ||||
| } | ||||
| 
 | ||||
| .clipboard-header { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| .clipboard-header h3 { | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| .clipboard-actions { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
| } | ||||
| 
 | ||||
| .clipboard-content::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
| .clipboard-content::-webkit-scrollbar-thumb { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .clipboard-text { | ||||
|   background: white; | ||||
|   padding: 10px; | ||||
|   border-radius: 4px; | ||||
|   min-height: 60px; | ||||
|   word-break: break-all; | ||||
|   white-space: pre-wrap; | ||||
|   font-size: 14px; | ||||
|   color: #666; | ||||
|   text-align: center; | ||||
| } | ||||
| .copy-icon { | ||||
|   width: 20px; | ||||
|   height: 20px; | ||||
|   position: absolute; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										100
									
								
								src/pages/file/item/fileIcon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/pages/file/item/fileIcon.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,100 @@ | ||||
| <template> | ||||
|   <div class="file-icon"> | ||||
|     <img class="file-icon-img" :src="getIcon(file)" alt="file-icon" /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { FileInfo } from "../utils/fileMgr"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|   file: FileInfo; | ||||
| }>(); | ||||
| const iconMap = { | ||||
|   "/static/icons/IMG.webp": [ | ||||
|     "jpg", | ||||
|     "png", | ||||
|     "jpeg", | ||||
|     "gif", | ||||
|     "bmp", | ||||
|     "tiff", | ||||
|     "ico", | ||||
|     "webp", | ||||
|   ], | ||||
|   "/static/icons/PPT.webp": ["ppt", "pptx"], | ||||
|   "/static/icons/TXT.webp": ["txt", "md", "markdown"], | ||||
|   "/static/icons/WORD.webp": ["doc", "docx"], | ||||
|   "/static/icons/PDF.webp": ["pdf"], | ||||
|   "/static/icons/VIDEO.webp": [ | ||||
|     "mp4", | ||||
|     "avi", | ||||
|     "mov", | ||||
|     "wmv", | ||||
|     "flv", | ||||
|     "mkv", | ||||
|     "rmvb", | ||||
|     "rm", | ||||
|     "asf", | ||||
|     "wmv", | ||||
|     "mpg", | ||||
|     "mpeg", | ||||
|     "mpe", | ||||
|     "m4v", | ||||
|     "3gp", | ||||
|     "3g2", | ||||
|     "flv", | ||||
|     "f4v", | ||||
|     "swf", | ||||
|     "vob", | ||||
|   ], | ||||
|   "/static/icons/MUSIC.webp": [ | ||||
|     "mp3", | ||||
|     "wav", | ||||
|     "wma", | ||||
|     "aac", | ||||
|     "flac", | ||||
|     "ape", | ||||
|     "m4a", | ||||
|     "ogg", | ||||
|     "m3u", | ||||
|     "m3u8", | ||||
|     "pls", | ||||
|     "qmc", | ||||
|     "qmcflac", | ||||
|     "qmcogg", | ||||
|     "qmcwma", | ||||
|     "rm", | ||||
|     "rmvb", | ||||
|     "wavpack", | ||||
|   ], | ||||
|   "/static/icons/ZIP.webp": [ | ||||
|     "zip", | ||||
|     "rar", | ||||
|     "7z", | ||||
|     "tar", | ||||
|     "gz", | ||||
|     "bz2", | ||||
|     "iso", | ||||
|     "7zip", | ||||
|   ], | ||||
|   "/static/icons/XML.webp": ["xml", "html", "htm", "xhtml", "shtml", "shtm"], | ||||
| }; | ||||
| //根据文件类型与后缀生成icon | ||||
| const getIcon = (file: FileInfo): string => { | ||||
|   if (file.isDirectory) { | ||||
|     return "/static/icons/wenjianjia.webp"; | ||||
|   } | ||||
|   for (const [iconUrl, extensions] of Object.entries(iconMap)) { | ||||
|     if (extensions.includes(file.name.split(".").pop()!)) { | ||||
|       return iconUrl; | ||||
|     } | ||||
|   } | ||||
|   return "/static/icons/OTHER.webp"; | ||||
| }; | ||||
| </script> | ||||
| <style scoped> | ||||
| .file-icon-img { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										50
									
								
								src/pages/file/item/fileItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/pages/file/item/fileItem.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| <template> | ||||
|   <div class="file-item"> | ||||
|     <div class="file-icon"> | ||||
|       <FileIcon :file="file" /> | ||||
|     </div> | ||||
|     <div class="file-name">{{ file.name }}</div> | ||||
|     <div class="file-info"> | ||||
|       <div class="file-last-modified"> | ||||
|         {{ new Date(file.lastModified).toLocaleString() }} | ||||
|       </div> | ||||
|       <div class="file-meta"> | ||||
|         {{ file.isDirectory ? "" : formatSize(file.size) }} | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { FileInfo } from "../utils/fileMgr"; | ||||
| import FileIcon from "./fileIcon.vue"; | ||||
| import { formatSize } from "../utils/common"; | ||||
| const props = defineProps<{ | ||||
|   file: FileInfo; | ||||
| }>(); | ||||
| </script> | ||||
| <style scoped> | ||||
| .file-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   padding: 10px; | ||||
|   border-bottom: 1px solid #ccc; | ||||
| } | ||||
| .file-icon { | ||||
|   width: 24px; | ||||
|   height: 24px; | ||||
| } | ||||
| .file-name { | ||||
|   flex: 1; | ||||
|   margin-left: 10px; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
| } | ||||
| .file-info { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: flex-end; | ||||
|   margin-left: 10px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										78
									
								
								src/pages/file/item/filePermissionSetDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/pages/file/item/filePermissionSetDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,78 @@ | ||||
| <template> | ||||
|   <Modal | ||||
|     :open="visible" | ||||
|     title="权限设置" | ||||
|     @ok="handleOk" | ||||
|     @cancel="handleCancel" | ||||
|     :maskClosable="false" | ||||
|   > | ||||
|     <div class="permission-container"> | ||||
|       <div | ||||
|         class="permission-item" | ||||
|         v-for="(allowed, permission) in localPermissionSet" | ||||
|         :key="permission" | ||||
|       > | ||||
|         <Checkbox v-model:checked="localPermissionSet[permission]"> | ||||
|           {{ permissionLabels[permission] }} | ||||
|         </Checkbox> | ||||
|         <div class="permission-desc">{{ permissionDescs[permission] }}</div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </Modal> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { Modal, Checkbox } from "ant-design-vue"; | ||||
| import { ref, reactive } from "vue"; | ||||
| import { Permission } from "../utils/peer"; | ||||
| import { getPermissionSet, setPermissionSet } from "../utils/common"; | ||||
| 
 | ||||
| const visible = ref(false); | ||||
| const localPermissionSet = reactive({ ...getPermissionSet() }); | ||||
| 
 | ||||
| const permissionLabels = { | ||||
|   [Permission.edit]: "编辑权限", | ||||
|   [Permission.view]: "查看权限", | ||||
|   [Permission.download]: "下载权限", | ||||
| }; | ||||
| 
 | ||||
| const permissionDescs = { | ||||
|   [Permission.edit]: "允许创建、修改、删除文件和文件夹", | ||||
|   [Permission.view]: "允许查看文件内容和目录结构", | ||||
|   [Permission.download]: "允许传输文件到本地", | ||||
| }; | ||||
| 
 | ||||
| const handleOk = () => { | ||||
|   setPermissionSet(localPermissionSet); | ||||
|   visible.value = false; | ||||
| }; | ||||
| 
 | ||||
| const handleCancel = () => { | ||||
|   Object.assign(localPermissionSet, getPermissionSet()); | ||||
|   visible.value = false; | ||||
| }; | ||||
| 
 | ||||
| const showDialog = () => { | ||||
|   visible.value = true; | ||||
| }; | ||||
| 
 | ||||
| defineExpose({ | ||||
|   showDialog, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .permission-container { | ||||
|   padding: 10px 0; | ||||
| } | ||||
| 
 | ||||
| .permission-item { | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| .permission-desc { | ||||
|   margin-left: 24px; | ||||
|   color: #666; | ||||
|   font-size: 12px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										465
									
								
								src/pages/file/item/fileReader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										465
									
								
								src/pages/file/item/fileReader.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,465 @@ | ||||
| <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> | ||||
							
								
								
									
										220
									
								
								src/pages/file/item/fileTranserView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								src/pages/file/item/fileTranserView.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,220 @@ | ||||
| <template> | ||||
|   <div class="transfer-container"> | ||||
|     <div class="transfer-header"> | ||||
|       传输列表 ({{ tasks.length }}) | ||||
|       <div class="transfer-header-btns"> | ||||
|         <Button type="ghost" class="clear-btn" @click="clearTasks">清理</Button> | ||||
|         <Button | ||||
|           type="ghost" | ||||
|           class="toggle-btn" | ||||
|           @click="isExpanded = !isExpanded" | ||||
|           >{{ isExpanded ? "▼" : "▲" }}</Button | ||||
|         > | ||||
|       </div> | ||||
|     </div> | ||||
|     <Transition name="file-transfer-view"> | ||||
|       <div v-show="isExpanded" class="transfer-content"> | ||||
|         <div | ||||
|           v-for="task in tasks" | ||||
|           :key="task.fileData.path" | ||||
|           class="transfer-item" | ||||
|         > | ||||
|           <div class="task-header"> | ||||
|             <div class="file-name">{{ task.fileData.name }}</div> | ||||
|             <div class="task-status" :class="task.progress.status"> | ||||
|               {{ getStatusText(task.progress.status) }} | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="file-info"> | ||||
|             <span>{{ formatSize(task.fileData.size) }}</span> | ||||
|             <span v-if="task.progress.speed" | ||||
|               >· {{ formatSize(task.progress.speed) }}/s</span | ||||
|             > | ||||
|           </div> | ||||
|           <Progress | ||||
|             :percent="task.progress.percent" | ||||
|             :status="getProgressStatus(task.progress.status)" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Transition> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| import { Button } from "ant-design-vue"; | ||||
| import { Progress } from "ant-design-vue"; | ||||
| import { | ||||
|   fileTransferMgrInstance, | ||||
|   type TransferTask, | ||||
|   TransferStatus, | ||||
| } from "../utils/fileTransfer"; | ||||
| import { type Ref, ref } from "vue"; | ||||
| import { formatSize } from "../utils/common"; | ||||
| 
 | ||||
| const isExpanded = ref(true); | ||||
| const tasks: Ref<TransferTask[]> = ref([]); | ||||
| 
 | ||||
| const clearTasks = () => { | ||||
|   fileTransferMgrInstance.getAllFileTransfers().forEach((transfer) => { | ||||
|     transfer.clearCompletedTasks(); | ||||
|   }); | ||||
|   updateTasks(); | ||||
| }; | ||||
| 
 | ||||
| const getStatusText = (status: TransferStatus) => { | ||||
|   const statusMap: Record<TransferStatus, string> = { | ||||
|     [TransferStatus.WAITING]: "等待中", | ||||
|     [TransferStatus.SENDING]: "发送中", | ||||
|     [TransferStatus.RECEIVING]: "接收中", | ||||
|     [TransferStatus.COMPLETED]: "已完成", | ||||
|     [TransferStatus.ERROR]: "失败", | ||||
|     [TransferStatus.PAUSED]: "已暂停", | ||||
|   }; | ||||
|   return statusMap[status] || status; | ||||
| }; | ||||
| 
 | ||||
| const getProgressStatus = (status: TransferStatus) => { | ||||
|   if (status === TransferStatus.ERROR) return "exception"; | ||||
|   if (status === TransferStatus.COMPLETED) return "success"; | ||||
|   return "active"; | ||||
| }; | ||||
| 
 | ||||
| const updateTasks = () => { | ||||
|   tasks.value = Array.from( | ||||
|     fileTransferMgrInstance | ||||
|       .getAllFileTransfers() | ||||
|       .flatMap((transfer) => transfer.getTasks()) | ||||
|   ); | ||||
|   // newTasks.forEach((task) => { | ||||
|   //   const index = tasks.value.findIndex( | ||||
|   //     (t) => t.fileData.path === task.fileData.path | ||||
|   //   ); | ||||
|   //   if (index === -1) { | ||||
|   //     tasks.value.push(task); | ||||
|   //   } | ||||
|   // }); | ||||
| }; | ||||
| 
 | ||||
| fileTransferMgrInstance.onTransferChanged((transfer) => { | ||||
|   updateTasks(); | ||||
| }); | ||||
| 
 | ||||
| updateTasks(); | ||||
| </script> | ||||
| <style scoped> | ||||
| .transfer-container { | ||||
|   position: fixed; | ||||
|   bottom: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   background: white; | ||||
|   box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); | ||||
|   z-index: 1000; | ||||
| } | ||||
| 
 | ||||
| .transfer-header { | ||||
|   padding: 10px 20px; | ||||
|   background: #f5f5f5; | ||||
|   cursor: pointer; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   border-top: 1px solid #ddd; | ||||
|   font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| .transfer-content { | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   max-height: 600px; | ||||
| } | ||||
| 
 | ||||
| .transfer-item { | ||||
|   padding: 12px; | ||||
|   padding-left: 20px; | ||||
|   padding-right: 20px; | ||||
|   border-bottom: 1px solid #eee; | ||||
| } | ||||
| 
 | ||||
| .task-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
| 
 | ||||
| .file-name { | ||||
|   font-weight: 500; | ||||
|   flex: 1; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
| } | ||||
| 
 | ||||
| .task-status { | ||||
|   font-size: 12px; | ||||
|   padding: 2px 8px; | ||||
|   border-radius: 10px; | ||||
|   background: #f0f0f0; | ||||
| } | ||||
| 
 | ||||
| .task-status.completed { | ||||
|   background: #e6f7e6; | ||||
|   color: #52c41a; | ||||
| } | ||||
| 
 | ||||
| .task-status.error { | ||||
|   background: #fff1f0; | ||||
|   color: #ff4d4f; | ||||
| } | ||||
| 
 | ||||
| .task-status.sending, | ||||
| .task-status.receiving { | ||||
|   background: #e6f7ff; | ||||
|   color: #1890ff; | ||||
| } | ||||
| 
 | ||||
| .task-status.waiting { | ||||
|   background: #f5f5f5; | ||||
|   color: #8c8c8c; | ||||
| } | ||||
| 
 | ||||
| .task-status.paused { | ||||
|   background: #fff7e6; | ||||
|   color: #faad14; | ||||
| } | ||||
| 
 | ||||
| .file-info { | ||||
|   margin-bottom: 8px; | ||||
|   font-size: 12px; | ||||
|   color: #666; | ||||
| } | ||||
| 
 | ||||
| .toggle-icon { | ||||
|   font-size: 12px; | ||||
| } | ||||
| .transfer-header-btns { | ||||
|   display: flex; | ||||
|   align-items: flex-end; | ||||
| } | ||||
| .clear-btn { | ||||
|   margin-left: 10px; | ||||
| } | ||||
| 
 | ||||
| /* 过渡动画 */ | ||||
| .file-transfer-view-enter-active, | ||||
| .file-transfer-view-leave-active { | ||||
|   transition: max-height 0.5s ease-in-out; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .file-transfer-view-enter-from, | ||||
| .file-transfer-view-leave-to { | ||||
|   max-height: 0; | ||||
| } | ||||
| 
 | ||||
| .file-transfer-view-enter-to, | ||||
| .file-transfer-view-leave-from { | ||||
|   max-height: 600px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										555
									
								
								src/pages/file/item/fileView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										555
									
								
								src/pages/file/item/fileView.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,555 @@ | ||||
| <template> | ||||
|   <div class="file-panel local"> | ||||
|     <div class="panel-header"> | ||||
|       <div class="path-nav"> | ||||
|         <Button @click="navigateLocal('..')" :disabled="!canNavigateUpLocal"> | ||||
|           上级目录 | ||||
|         </Button> | ||||
|         <Select class="sort-select" :value="sortType" @change="refreshLocal"> | ||||
|           <SelectOption value="name">按名称</SelectOption> | ||||
|           <SelectOption value="size">按大小</SelectOption> | ||||
|           <SelectOption value="type">按类型</SelectOption> | ||||
|           <SelectOption value="date">按日期</SelectOption> | ||||
|         </Select> | ||||
|         <Button danger v-if="!isRemote" @click="showPermissionSetting" | ||||
|           >权限设置</Button | ||||
|         > | ||||
|       </div> | ||||
|       <div> | ||||
|         <Breadcrumb> | ||||
|           <BreadcrumbItem v-for="(path, index) in pathSegments" :key="index"> | ||||
|             <a @click="navigateToPath(index)">{{ path }}</a> | ||||
|           </BreadcrumbItem> | ||||
|         </Breadcrumb> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="file-list" @contextmenu.prevent="showContextMenu($event, null)"> | ||||
|       <div | ||||
|         style="text-align: center" | ||||
|         v-if="isRemote && !currentDirInfo?.isInit" | ||||
|       > | ||||
|         <Button @click="refreshLocal">初始化</Button> | ||||
|       </div> | ||||
|       <div v-else-if="!currentDirInfo?.isInit"> | ||||
|         <div @click="selectLocalDirectory" class="file-item"> | ||||
|           <div class="file-icon">📁</div> | ||||
|           <div class="file-info"> | ||||
|             <div class="file-name">请选择一个目录</div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <FileItem | ||||
|         v-for="file in localFiles" | ||||
|         :key="file.name" | ||||
|         :file="file" | ||||
|         @dblclick="handleLocalFileClick(file)" | ||||
|         :class="{ selected: selectedLocalFiles.includes(file.path) }" | ||||
|         @click="toggleLocalFileSelect(file)" | ||||
|         @contextmenu.stop.prevent="showContextMenu($event, file)" | ||||
|       /> | ||||
|     </div> | ||||
|     <div v-if="currentDirInfo?.isInit" class="panel-footer"> | ||||
|       <Button v-if="!isRemote" @click="selectLocalDirectory">选择目录</Button> | ||||
|       <Button @click="refreshLocal">刷新</Button> | ||||
|       <Button @click="createNewFolder">新建文件夹</Button> | ||||
|       <Button @click="createNewFile">新建文件</Button> | ||||
|       <Button @click="deleteSelected" :disabled="!hasSelectedFiles"> | ||||
|         删除 | ||||
|       </Button> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- 右键菜单 --> | ||||
|     <div v-if="showMenu" class="context-menu" :style="menuPosition"> | ||||
|       <div v-if="activeFile"> | ||||
|         <div class="menu-item" @click="handleRename">重命名</div> | ||||
|         <div class="menu-item" @click="handleDelete">删除</div> | ||||
|         <div | ||||
|           class="menu-item" | ||||
|           v-if="props.isRemote && remoteCurrentFile?.isInit" | ||||
|           @click="handleDownload" | ||||
|         > | ||||
|           下载 | ||||
|         </div> | ||||
|       </div> | ||||
|       <div v-else-if="!currentDirInfo?.isInit"> | ||||
|         <div class="menu-item" @click="selectLocalDirectory">选择本地目录</div> | ||||
|       </div> | ||||
|       <div v-else> | ||||
|         <div class="menu-item" @click="refreshLocal">刷新</div> | ||||
|         <div class="menu-item" @click="createNewFolder">新建文件夹</div> | ||||
|         <div class="menu-item" @click="createNewFile">新建文件</div> | ||||
|         <div class="menu-item" @click="deleteSelected" v-if="hasSelectedFiles"> | ||||
|           删除 | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- 文件预览 --> | ||||
|     <FileReader | ||||
|       v-if="previewFile" | ||||
|       :file="previewFile" | ||||
|       @close="previewFile = null" | ||||
|     /> | ||||
| 
 | ||||
|     <!-- 权限设置对话框 --> | ||||
|     <FilePermissionSetDialog ref="permissionDialogRef" /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   ref, | ||||
|   onMounted, | ||||
|   computed, | ||||
|   onUnmounted, | ||||
|   type Ref, | ||||
|   h, | ||||
|   reactive, | ||||
| } from "vue"; | ||||
| import { Button, Select, SelectOption, Modal } from "ant-design-vue"; | ||||
| import { FileInfo, fileMgrInstance } from "../utils/fileMgr"; | ||||
| import FileItem from "./fileItem.vue"; | ||||
| import FileReader from "./fileReader.vue"; | ||||
| import { Breadcrumb, BreadcrumbItem } from "ant-design-vue"; | ||||
| import emitter, { EmitterEvent } from "../utils/emitter"; | ||||
| import { localCurrentFile, remoteCurrentFile } from "../utils/common"; | ||||
| import FilePermissionSetDialog from "./filePermissionSetDialog.vue"; | ||||
| const props = defineProps<{ | ||||
|   isRemote: boolean; | ||||
|   selectedFiles: string[]; | ||||
| }>(); | ||||
| 
 | ||||
| // 状态管理 | ||||
| const currentLocalPath = ref("/"); | ||||
| const localFiles: Ref<FileInfo[]> = ref([]); | ||||
| const selectedLocalFiles = ref(props.selectedFiles); | ||||
| const newFileName = ref(""); | ||||
| const folderName = ref(""); | ||||
| const fileName = ref(""); | ||||
| const currentDirInfo = props.isRemote ? remoteCurrentFile : localCurrentFile; | ||||
| const sortType = ref<"name" | "size" | "type" | "date">("type"); | ||||
| currentDirInfo.value = props.isRemote ? fileMgrInstance.remoteRootFile : null; | ||||
| // 右键菜单状态 | ||||
| const showMenu = ref(false); | ||||
| const menuPosition = ref({ top: "0px", left: "0px" }); | ||||
| const activeFile: Ref<FileInfo> = ref(null); | ||||
| // 文件预览状态 | ||||
| const previewFile: Ref<FileInfo> = ref(null); | ||||
| 
 | ||||
| // 计算属性 | ||||
| const canNavigateUpLocal = ref(false); | ||||
| const hasSelectedFiles = computed(() => selectedLocalFiles.value.length > 0); | ||||
| 
 | ||||
| // 计算当前路径的分段 | ||||
| const pathSegments = computed(() => { | ||||
|   return currentLocalPath.value.split("/").filter((p) => p); | ||||
| }); | ||||
| 
 | ||||
| // 导航到指定层级的路径 | ||||
| const navigateToPath = (index: number) => { | ||||
|   const targetPath = pathSegments.value.slice(0, index + 1).join("/"); | ||||
|   navigateLocal(targetPath); | ||||
| }; | ||||
| 
 | ||||
| //选择目录 | ||||
| !props.isRemote && | ||||
|   emitter.on(EmitterEvent.choosed_local_directory, async (file: FileInfo) => { | ||||
|     currentDirInfo.value = await fileMgrInstance.getRootFile(); | ||||
|     await refreshLocal(); | ||||
|   }); | ||||
| 
 | ||||
| //选择目录 | ||||
| const selectLocalDirectory = async () => { | ||||
|   try { | ||||
|     await fileMgrInstance.selectLocalDirectory(); | ||||
|   } catch (err) { | ||||
|     console.error("选择目录失败:", err); | ||||
|   } | ||||
| }; | ||||
| const handleDownload = async () => { | ||||
|   if (!activeFile.value) return; | ||||
|   const file = activeFile.value; | ||||
|   await fileMgrInstance.getRootFile(); | ||||
|   await file.getFile( | ||||
|     false, | ||||
|     localCurrentFile.value.path.replace( | ||||
|       ( | ||||
|         await fileMgrInstance.getRootFile() | ||||
|       ).path, | ||||
|       "" | ||||
|     ), | ||||
|     remoteCurrentFile.value.path | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| //刷新目录 | ||||
| emitter.on(EmitterEvent.update_directory, async (file: FileInfo) => { | ||||
|   if (file.isRemote != props.isRemote) return; | ||||
|   if (currentDirInfo.value.path == file.path) { | ||||
|     const files = currentDirInfo.value.files; | ||||
|     for (const file of files) { | ||||
|       if (!file.isDirectory) { | ||||
|         file.size = await file.getFileSize(); | ||||
|       } | ||||
|     } | ||||
|     currentLocalPath.value = currentDirInfo.value.path; | ||||
|     localFiles.value = files; | ||||
|     canNavigateUpLocal.value = currentDirInfo.value.parentFileInfo != null; | ||||
|   } | ||||
| }); | ||||
| const refreshLocal = async () => { | ||||
|   try { | ||||
|     if (!currentDirInfo.value) { | ||||
|       throw new Error("当前目录为空"); | ||||
|     } | ||||
|     await currentDirInfo.value.loadLocalDirectory(sortType.value); | ||||
|   } catch (err) { | ||||
|     console.error("刷新目录失败:", err); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const navigateLocal = async (path: string) => { | ||||
|   try { | ||||
|     if (path === "..") { | ||||
|       if (currentDirInfo.value?.parentFileInfo) { | ||||
|         currentDirInfo.value = currentDirInfo.value.parentFileInfo; | ||||
|         currentLocalPath.value = currentDirInfo.value.path; | ||||
|       } | ||||
|     } else { | ||||
|       let root = currentDirInfo.value?.isRemote | ||||
|         ? fileMgrInstance.remoteRootFile | ||||
|         : await fileMgrInstance.getRootFile(); | ||||
|       const targetDir = root.getFileInfo(path); | ||||
|       if (targetDir) { | ||||
|         currentDirInfo.value = targetDir; | ||||
|         currentLocalPath.value = targetDir.path; | ||||
|       } | ||||
|     } | ||||
|     selectedLocalFiles.value.length = 0; | ||||
|     await refreshLocal(); | ||||
|   } catch (err) { | ||||
|     console.error("导航失败:", err); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const handleLocalFileClick = async (file: FileInfo) => { | ||||
|   if (file.isDirectory) { | ||||
|     await navigateLocal(file.path); | ||||
|   } else { | ||||
|     previewFile.value = file; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const toggleLocalFileSelect = (file: FileInfo) => { | ||||
|   const index = selectedLocalFiles.value.indexOf(file.path); | ||||
|   if (index === -1) { | ||||
|     selectedLocalFiles.value.push(file.path); | ||||
|   } else { | ||||
|     selectedLocalFiles.value.splice(index, 1); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| // 右键菜单相关 | ||||
| const showContextMenu = (event: MouseEvent, file: FileInfo) => { | ||||
|   event.preventDefault(); | ||||
|   showMenu.value = true; | ||||
|   menuPosition.value = { | ||||
|     top: `${event.clientY}px`, | ||||
|     left: `${event.clientX}px`, | ||||
|   }; | ||||
|   activeFile.value = file; | ||||
|   console.log(props.isRemote && localCurrentFile.value?.isInit); | ||||
| }; | ||||
| 
 | ||||
| // 关闭右键菜单 | ||||
| const closeContextMenu = () => { | ||||
|   showMenu.value = false; | ||||
|   activeFile.value = null; | ||||
| }; | ||||
| 
 | ||||
| // 重命名文件 | ||||
| const handleRename = async () => { | ||||
|   if (!activeFile.value) return; | ||||
| 
 | ||||
|   newFileName.value = activeFile.value.name; | ||||
|   const acf = activeFile.value; | ||||
|   Modal.confirm({ | ||||
|     title: "重命名", | ||||
|     class: "custom-modal", | ||||
|     content: h("div", [ | ||||
|       h("input", { | ||||
|         class: "ant-input", | ||||
|         placeholder: "请输入新的名称", | ||||
|         value: newFileName.value, | ||||
|         onInput: (e: Event) => { | ||||
|           newFileName.value = (e.target as HTMLInputElement).value; | ||||
|         }, | ||||
|         style: { | ||||
|           width: "100%", | ||||
|           marginTop: "8px", | ||||
|         }, | ||||
|       }), | ||||
|     ]), | ||||
|     okText: "确定", | ||||
|     cancelText: "取消", | ||||
|     async onOk() { | ||||
|       if (!newFileName.value || newFileName.value === acf.name) { | ||||
|         return Promise.reject(); | ||||
|       } | ||||
|       try { | ||||
|         await acf.renameFile(newFileName.value); | ||||
|         await refreshLocal(); | ||||
|       } catch (err) { | ||||
|         console.error("重命名失败:", err); | ||||
|         Modal.error({ | ||||
|           title: "重命名失败", | ||||
|           content: (err as Error).message || (err as string), | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
|   closeContextMenu(); | ||||
| }; | ||||
| 
 | ||||
| // 删除文件 | ||||
| const handleDelete = async () => { | ||||
|   if (activeFile.value) { | ||||
|     if (!selectedLocalFiles.value.includes(activeFile.value.name)) { | ||||
|       selectedLocalFiles.value.push(activeFile.value.name); | ||||
|     } | ||||
|   } | ||||
|   deleteSelected(); | ||||
|   closeContextMenu(); | ||||
| }; | ||||
| 
 | ||||
| // 删除选中的文件 | ||||
| const deleteSelected = async () => { | ||||
|   if (!selectedLocalFiles.value.length) return; | ||||
| 
 | ||||
|   Modal.confirm({ | ||||
|     title: "确认删除", | ||||
|     content: `确定要删除以下文件吗?<br>${selectedLocalFiles.value.join( | ||||
|       "<br>" | ||||
|     )}<br>共 ${selectedLocalFiles.value.length} 个文件`, | ||||
|     okText: "删除", | ||||
|     okType: "danger", | ||||
|     cancelText: "取消", | ||||
|     async onOk() { | ||||
|       try { | ||||
|         for (const fileName of selectedLocalFiles.value) { | ||||
|           const file = localFiles.value.find((f) => f.path === fileName); | ||||
|           if (file) { | ||||
|             await file.deleteItself(); | ||||
|           } | ||||
|         } | ||||
|         selectedLocalFiles.value = []; | ||||
|         await refreshLocal(); | ||||
|       } catch (err) { | ||||
|         console.error("批量删除失败:", err); | ||||
|         Modal.error({ | ||||
|           title: "删除失败", | ||||
|           content: (err as Error).message, | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| // 创建新文件夹 | ||||
| const createNewFolder = async () => { | ||||
|   if (!currentDirInfo) return; | ||||
| 
 | ||||
|   folderName.value = ""; | ||||
|   Modal.confirm({ | ||||
|     title: "新建文件夹", | ||||
|     class: "custom-modal", | ||||
|     content: h("div", [ | ||||
|       h("input", { | ||||
|         class: "ant-input", | ||||
|         placeholder: "请输入文件夹名称", | ||||
|         value: folderName.value, | ||||
|         onInput: (e: Event) => { | ||||
|           folderName.value = (e.target as HTMLInputElement).value; | ||||
|         }, | ||||
|         style: { | ||||
|           width: "100%", | ||||
|           marginTop: "8px", | ||||
|         }, | ||||
|       }), | ||||
|     ]), | ||||
|     okText: "创建", | ||||
|     cancelText: "取消", | ||||
|     async onOk() { | ||||
|       if (!folderName.value) { | ||||
|         Modal.warning({ | ||||
|           title: "提示", | ||||
|           content: "请输入文件夹名称", | ||||
|         }); | ||||
|         return Promise.reject(); | ||||
|       } | ||||
|       try { | ||||
|         await currentDirInfo.value.createDirectory(folderName.value); | ||||
|         await refreshLocal(); | ||||
|       } catch (err) { | ||||
|         console.error("创建文件夹失败:", err); | ||||
|         Modal.error({ | ||||
|           title: "创建失败", | ||||
|           content: (err as Error).message, | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| // 创建新文件 | ||||
| const createNewFile = async () => { | ||||
|   if (!currentDirInfo) return; | ||||
| 
 | ||||
|   fileName.value = ""; | ||||
|   Modal.confirm({ | ||||
|     title: "新建文件", | ||||
|     class: "custom-modal", | ||||
|     content: h("div", [ | ||||
|       h("input", { | ||||
|         class: "ant-input", | ||||
|         placeholder: "请输入文件名称", | ||||
|         value: fileName.value, | ||||
|         onInput: (e: Event) => { | ||||
|           fileName.value = (e.target as HTMLInputElement).value; | ||||
|         }, | ||||
|         style: { | ||||
|           width: "100%", | ||||
|           marginTop: "8px", | ||||
|         }, | ||||
|       }), | ||||
|     ]), | ||||
|     okText: "创建", | ||||
|     cancelText: "取消", | ||||
|     async onOk() { | ||||
|       if (!fileName.value) { | ||||
|         Modal.warning({ | ||||
|           title: "提示", | ||||
|           content: "请输入文件名称", | ||||
|         }); | ||||
|         return Promise.reject(); | ||||
|       } | ||||
|       try { | ||||
|         const emptyBuffer = new ArrayBuffer(0); | ||||
|         await currentDirInfo.value.createFile(fileName.value, emptyBuffer); | ||||
|         await refreshLocal(); | ||||
|       } catch (err) { | ||||
|         console.error("创建文件失败:", err); | ||||
|         Modal.error({ | ||||
|           title: "创建失败", | ||||
|           content: (err as Error).message, | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| // 点击其他地方关闭右键菜单 | ||||
| onMounted(() => { | ||||
|   document.addEventListener("click", closeContextMenu); | ||||
| }); | ||||
| 
 | ||||
| onUnmounted(() => { | ||||
|   document.removeEventListener("click", closeContextMenu); | ||||
| }); | ||||
| 
 | ||||
| const permissionDialogRef = ref(); | ||||
| 
 | ||||
| const showPermissionSetting = () => { | ||||
|   permissionDialogRef.value?.showDialog(); | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .file-panel { | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   border: 1px solid #ddd; | ||||
| } | ||||
| 
 | ||||
| .panel-header { | ||||
|   padding: 10px; | ||||
|   border-bottom: 1px solid #ddd; | ||||
| } | ||||
| 
 | ||||
| .path-nav { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .file-list { | ||||
|   flex: 1; | ||||
|   overflow-y: auto; | ||||
|   padding: 10px; | ||||
| } | ||||
| 
 | ||||
| .file-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   padding: 8px; | ||||
|   cursor: pointer; | ||||
|   border-radius: 4px; | ||||
|   margin-bottom: 4px; | ||||
| } | ||||
| 
 | ||||
| .file-item:hover { | ||||
|   background-color: #f5f5f5; | ||||
| } | ||||
| 
 | ||||
| .file-item.selected { | ||||
|   background-color: #e3f2fd; | ||||
| } | ||||
| 
 | ||||
| .file-icon { | ||||
|   margin-right: 10px; | ||||
|   font-size: 1.2em; | ||||
| } | ||||
| 
 | ||||
| .file-info { | ||||
|   flex: 1; | ||||
| } | ||||
| 
 | ||||
| .file-name { | ||||
|   font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| .file-meta { | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
| } | ||||
| 
 | ||||
| .panel-footer { | ||||
|   padding: 10px; | ||||
|   border-top: 1px solid #ddd; | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .context-menu { | ||||
|   position: fixed; | ||||
|   background: white; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 4px; | ||||
|   padding: 4px 0; | ||||
|   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||
|   z-index: 1000; | ||||
| } | ||||
| 
 | ||||
| .menu-item { | ||||
|   padding: 8px 16px; | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .menu-item:hover { | ||||
|   background-color: #f5f5f5; | ||||
| } | ||||
| .sort-select { | ||||
|   width: 100px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										68
									
								
								src/pages/file/utils/common.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/pages/file/utils/common.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,68 @@ | ||||
| import { type Ref, ref } from "vue"; | ||||
| import { FileInfo } from "./fileMgr"; | ||||
| import { Permission } from "./peer"; | ||||
| 
 | ||||
| export const formatSize = (size: number): string => { | ||||
|     if (size < 1024) return `${size} B`; | ||||
|     if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; | ||||
|     return `${(size / (1024 * 1024)).toFixed(2)} MB`; | ||||
| }; | ||||
| 
 | ||||
| export let localCurrentFile: Ref<FileInfo> = ref(null); | ||||
| export let remoteCurrentFile: Ref<FileInfo> = ref(null); | ||||
| export const stringToBase64 = (str: string) => { | ||||
|     //汉字转base64
 | ||||
|     return btoa(encodeURIComponent(str)) | ||||
| } | ||||
| export const base64ToString = (base64: string) => { | ||||
|     //base64转汉字
 | ||||
|     return decodeURIComponent(atob(base64)) | ||||
| } | ||||
| const commonCharsCN = '的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞'; | ||||
| const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; | ||||
| export const randomChars = (num: number = 3, chars: string = commonCharsEN) => { | ||||
|     const result = []; | ||||
|     for (let i = 0; i < num; i++) { | ||||
|         const randomIndex = Math.floor(Math.random() * chars.length); | ||||
|         result.push(chars[randomIndex]); | ||||
|     } | ||||
|     return result.join(''); | ||||
| } | ||||
| export const sign2peerid = (sign: string) => { | ||||
|     sign = sign.replace(/[^a-zA-Z0-9]/g, '') | ||||
|     sign = sign + '+kuraa.cc+explorer' | ||||
|     return stringToBase64(sign).slice(0, 32) | ||||
| } | ||||
| //获取url query
 | ||||
| export const getUrlParam = (key: string) => { | ||||
|     var query = window.location.search.substring(1); | ||||
|     var vars = query.split("&"); | ||||
|     for (var i = 0; i < vars.length; i++) { | ||||
|         var pair = vars[i].split("="); | ||||
|         if (pair[0] == key) { return pair[1]; } | ||||
|     } | ||||
|     return null; | ||||
| } | ||||
| // 权限设置
 | ||||
| let permissionSet: { [key in Permission]: boolean } = null; | ||||
| export function getPermissionSet(): { [key in Permission]: boolean } { | ||||
|     if (!permissionSet) { | ||||
|         permissionSet = getCache('permissionSet') || { | ||||
|             edit: false, | ||||
|             view: true, | ||||
|             download: true, | ||||
|         }; | ||||
|     } | ||||
|     return permissionSet; | ||||
| } | ||||
| export function setPermissionSet(set: { [key in Permission]: boolean }) { | ||||
|     cacheIt('permissionSet', set); | ||||
|     permissionSet = set; | ||||
| } | ||||
| //缓存
 | ||||
| export function cacheIt(key: string, value: any) { | ||||
|     localStorage.setItem(key, JSON.stringify(value)); | ||||
| } | ||||
| export function getCache(key: string) { | ||||
|     return JSON.parse(localStorage.getItem(key) || null); | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/pages/file/utils/emitter.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/pages/file/utils/emitter.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| class Emitter { | ||||
|     private events: { [key: string]: ((...args: any[]) => void)[] } = {}; | ||||
|     on(event: string, listener: (...args: any[]) => void) { | ||||
|         if (!this.events[event]) { | ||||
|             this.events[event] = []; | ||||
|         } | ||||
|         this.events[event].push(listener); | ||||
|     } | ||||
|     emit(event: string, ...args: any[]) { | ||||
|         if (this.events[event]) { | ||||
|             this.events[event].forEach(listener => listener(...args)); | ||||
|         } | ||||
|     } | ||||
|     off(event: string, listener: (...args: any[]) => void) { | ||||
|         if (this.events[event]) { | ||||
|             this.events[event] = this.events[event].filter(l => l !== listener); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| export default new Emitter(); | ||||
| export enum EmitterEvent { | ||||
|     choosed_local_directory = 'choosed_local_directory', | ||||
|     update_directory = 'update_directory', | ||||
| } | ||||
							
								
								
									
										517
									
								
								src/pages/file/utils/fileMgr.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										517
									
								
								src/pages/file/utils/fileMgr.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,517 @@ | ||||
| import { type DataConnection } from "peerjs"; | ||||
| import { FileTransfer, TransferStatus, TransferTask } from "./fileTransfer"; | ||||
| import { peer } from "./peer"; | ||||
| import { MessageType } from "./peer"; | ||||
| import { notification } from "ant-design-vue"; | ||||
| import emitter, { EmitterEvent } from "./emitter"; | ||||
| 
 | ||||
| 
 | ||||
| export class FileInfo { | ||||
|     /**文件句柄 */ | ||||
|     public fileDirHandler: FileSystemDirectoryHandle | FileSystemFileHandle; | ||||
|     /**父级文件夹 */ | ||||
|     public parentFileInfo: FileInfo | null = null; | ||||
|     /**文件名 */ | ||||
|     public name: string = ''; | ||||
|     /**是否是文件夹 */ | ||||
|     public isDirectory: boolean = false; | ||||
|     /**文件路径 */ | ||||
|     public path: string = ''; | ||||
|     /**文件大小 */ | ||||
|     public size: number = 0; | ||||
|     /**文件最后修改时间 */ | ||||
|     public lastModified: number = 0; | ||||
|     /**子(文件/目录) */ | ||||
|     public files: FileInfo[] = []; | ||||
|     /**是否是远程文件 */ | ||||
|     public isRemote: boolean = false; | ||||
|     /**是否初始化 */ | ||||
|     public isInit: boolean = false; | ||||
|     /**传输任务 */ | ||||
|     public transfer: FileTransfer | null = null; | ||||
|     constructor(isRemote: boolean = false) { | ||||
|         this.isRemote = isRemote; | ||||
|     } | ||||
|     public async init(fileHandler: FileSystemDirectoryHandle | FileSystemFileHandle, parentFileInfo: FileInfo | null = null) { | ||||
|         this.fileDirHandler = fileHandler; | ||||
|         this.name = fileHandler.name; | ||||
|         this.isDirectory = fileHandler.kind === "directory"; | ||||
|         this.parentFileInfo = parentFileInfo; | ||||
|         this.path = parentFileInfo ? `${parentFileInfo.path}/${this.name}` : this.name; | ||||
|         this.size = await this.getFileSize(); | ||||
|         this.lastModified = await this.getFileLastModified(); | ||||
|         this.isInit = true; | ||||
|     } | ||||
|     public async updateRemote(fileInfo: FileInfo) { | ||||
|         this.isRemote = true; | ||||
|         this.name = fileInfo.name; | ||||
|         this.isDirectory = fileInfo.isDirectory; | ||||
|         this.path = fileInfo.path; | ||||
|         this.size = fileInfo.size; | ||||
|         this.lastModified = fileInfo.lastModified; | ||||
|         this.files = fileInfo.files.map(file => { | ||||
|             const newFile = new FileInfo(); | ||||
|             newFile.updateRemote(file); | ||||
|             newFile.parentFileInfo = this; | ||||
|             return newFile; | ||||
|         }); | ||||
|         this.isInit = true; | ||||
|     } | ||||
|     public addTransfer(transfer: FileTransfer) { | ||||
|         this.transfer = transfer; | ||||
|     } | ||||
|     public getTransfer(conn: DataConnection): FileTransfer { | ||||
|         if (this.transfer) { | ||||
|             if (this.transfer.conn !== conn) { | ||||
|                 this.transfer.conn = conn; | ||||
|             } | ||||
|             return this.transfer; | ||||
|         } | ||||
|         return new FileTransfer(conn, this); | ||||
|     } | ||||
|     public getFileInfo(path: string): FileInfo | null { | ||||
|         if (path === '') return this; | ||||
|         if (this.path === path) return this; | ||||
|         if (this.files.length > 0) { | ||||
|             for (const file of this.files) { | ||||
|                 const info = file.getFileInfo(path); | ||||
|                 if (info) return info; | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     public toJson(): object { | ||||
|         return { | ||||
|             name: this.name, | ||||
|             isDirectory: this.isDirectory, | ||||
|             size: this.size, | ||||
|             lastModified: this.lastModified, | ||||
|             path: this.path, | ||||
|             files: this.files.map(file => file.toJson()), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /**加载目录 */ | ||||
|     public async loadLocalDirectory(sortType: 'name' | 'size' | 'type' | 'date' = 'type', sortOrder: 'asc' | 'desc' = 'asc'): Promise<FileInfo[]> { | ||||
|         if (this.isRemote) { | ||||
|             await peer.send({ | ||||
|                 type: MessageType.request_fileInfo, | ||||
|                 data: { | ||||
|                     path: this.path, | ||||
|                 } | ||||
|             }).then((data: FileInfo) => { | ||||
|                 this.updateRemote(data) | ||||
|             }) | ||||
|         } else { | ||||
|             if (!this.isDirectory) return []; | ||||
|             this.files = []; | ||||
|             const fileList: FileInfo[] = []; | ||||
|             try { | ||||
|                 for await (const entry of (this.fileDirHandler as FileSystemDirectoryHandle).values()) { | ||||
|                     const file = new FileInfo(); | ||||
|                     await file.init(entry, this); | ||||
|                     fileList.push(file); | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 console.error('加载目录失败:', error); | ||||
|                 return []; | ||||
|             } | ||||
|             this.files = fileList; | ||||
|         } | ||||
| 
 | ||||
|         this.files.sort((a, b) => { | ||||
|             if (sortType === 'name') return sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); | ||||
|             if (sortType === 'size') return sortOrder === 'asc' ? a.size - b.size : b.size - a.size; | ||||
|             if (sortType === 'type') return sortOrder === 'asc' ? (a.isDirectory ? -1 : 1) : (a.isDirectory ? 1 : -1); | ||||
|             if (sortType === 'date') return sortOrder === 'asc' ? a.lastModified - b.lastModified : b.lastModified - a.lastModified; | ||||
|             return 0; | ||||
|         }); | ||||
|         emitter.emit(EmitterEvent.update_directory, this); | ||||
|         return this.files; | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     /**获取文件大小 */ | ||||
|     public async getFileSize(): Promise<number> { | ||||
|         if (this.isDirectory) return 0; | ||||
|         try { | ||||
|             if (this.isRemote) { | ||||
|                 await peer.send({ | ||||
|                     type: MessageType.request_fileSize, | ||||
|                     data: { | ||||
|                         path: this.path, | ||||
|                     } | ||||
|                 }).then((data: number) => { | ||||
|                     this.size = data; | ||||
|                 }) | ||||
|             } else { | ||||
|                 const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|                 this.size = file.size; | ||||
| 
 | ||||
|             } | ||||
|             return this.size; | ||||
|         } catch (error) { | ||||
|             console.error('获取文件大小失败:', error); | ||||
|             return 0; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /**获取文件的MD5值 */ | ||||
|     public async getFileMD5(): Promise<string> { | ||||
|         if (this.isDirectory) return ''; | ||||
|         const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|         // 获取文件的MD5值
 | ||||
|         // todo
 | ||||
|         return ''; | ||||
|     } | ||||
| 
 | ||||
|     /**删除文件 */ | ||||
|     public async deleteItself() { | ||||
|         try { | ||||
|             if (this.parentFileInfo) { | ||||
|                 if (this.isRemote) { | ||||
|                     await peer.send({ | ||||
|                         type: MessageType.request_deleteFile, | ||||
|                         data: { | ||||
|                             path: this.path, | ||||
|                         } | ||||
|                     }).then(async () => { | ||||
|                         await this.parentFileInfo.loadLocalDirectory(); | ||||
|                     }).catch((err) => { | ||||
|                         throw err; | ||||
|                     }) | ||||
|                 } else { | ||||
|                     await (this.parentFileInfo.fileDirHandler as FileSystemDirectoryHandle) | ||||
|                         .removeEntry(this.name, { recursive: true }); | ||||
|                     await this.parentFileInfo.loadLocalDirectory(); | ||||
|                 } | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('删除文件失败:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /**文件重命名 */ | ||||
|     public async renameFile(newName: string) { | ||||
|         try { | ||||
|             if (this.parentFileInfo) { | ||||
|                 if (this.isRemote) { | ||||
|                     await peer.send({ | ||||
|                         type: MessageType.request_renameFile, | ||||
|                         data: { | ||||
|                             path: this.path, | ||||
|                             newName: newName | ||||
|                         } | ||||
|                     }).then(async () => { | ||||
|                         await this.parentFileInfo.loadLocalDirectory(); | ||||
|                     }).catch((err) => { | ||||
|                         throw err; | ||||
|                     }) | ||||
|                 } else { | ||||
|                     const parentDir = this.parentFileInfo.fileDirHandler as FileSystemDirectoryHandle; | ||||
|                     // 创建新文件
 | ||||
|                     if (this.isDirectory) { | ||||
|                         const newDir = await parentDir.getDirectoryHandle(newName, { create: true }); | ||||
|                         // 复制所有文件到新目录
 | ||||
|                         await this.copyDirectory(this.fileDirHandler as FileSystemDirectoryHandle, newDir); | ||||
|                     } else { | ||||
|                         const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|                         const newFile = await parentDir.getFileHandle(newName, { create: true }); | ||||
|                         const writable = await newFile.createWritable(); | ||||
|                         await writable.write(await file.arrayBuffer()); | ||||
|                         await writable.close(); | ||||
|                     } | ||||
|                     // 删除旧文件
 | ||||
|                     await parentDir.removeEntry(this.name, { recursive: true }); | ||||
|                     // 更新当前实例
 | ||||
|                     this.name = newName; | ||||
|                     this.path = `${this.parentFileInfo.path}/${newName}`; | ||||
|                     await this.parentFileInfo.loadLocalDirectory(); | ||||
|                 } | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('重命名文件失败:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /**文件夹复制 */ | ||||
|     private async copyDirectory(source: FileSystemDirectoryHandle, target: FileSystemDirectoryHandle) { | ||||
|         if (this.isRemote) throw new Error('不能复制远程文件夹'); | ||||
|         for await (const entry of source.values()) { | ||||
|             if (entry.kind === 'file') { | ||||
|                 const file = await entry.getFile(); | ||||
|                 const newFile = await target.getFileHandle(entry.name, { create: true }); | ||||
|                 const writable = await newFile.createWritable(); | ||||
|                 await writable.write(await file.arrayBuffer()); | ||||
|                 await writable.close(); | ||||
|             } else { | ||||
|                 const newDir = await target.getDirectoryHandle(entry.name, { create: true }); | ||||
|                 await this.copyDirectory(entry, newDir); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     /**创建路径 */ | ||||
|     public async createPath(path: string): Promise<FileSystemDirectoryHandle> { | ||||
|         const pathArr = path.split('/'); | ||||
|         let currentPath = this.fileDirHandler as FileSystemDirectoryHandle; | ||||
|         for (const dir of pathArr) { | ||||
|             if (dir === '') continue; | ||||
|             currentPath = await currentPath.getDirectoryHandle(dir, { create: true }); | ||||
|         } | ||||
|         return currentPath; | ||||
|     } | ||||
|     /**创建文件夹 */ | ||||
|     public async createDirectory(name: string) { | ||||
|         if (!this.isDirectory) throw new Error('不能创建文件夹'); | ||||
|         if (this.isRemote) { | ||||
|             await peer.send({ | ||||
|                 type: MessageType.request_createDirectory, | ||||
|                 data: { | ||||
|                     path: this.path, | ||||
|                     name: name | ||||
|                 } | ||||
|             }).then(async () => { | ||||
|                 await this.loadLocalDirectory(); | ||||
|             }).catch((err) => { | ||||
|                 throw err; | ||||
|             }) | ||||
|         } else { | ||||
|             await (this.fileDirHandler as FileSystemDirectoryHandle).getDirectoryHandle(name, { create: true }); | ||||
|             await this.loadLocalDirectory(); | ||||
|         } | ||||
|     } | ||||
|     /**预览缓存 */ | ||||
|     public previewCache: Uint8Array; | ||||
|     /**添加预览缓存 */ | ||||
|     public addPreviewCacheBuffer(buffer: ArrayBuffer, offset: number = 0, totalSize: number = 0) { | ||||
|         if (!this.previewCache) { | ||||
|             this.previewCache = new Uint8Array(totalSize); | ||||
|         } | ||||
|         this.previewCache.set(new Uint8Array(buffer), offset); | ||||
|     } | ||||
|     /**清除预览缓存 */ | ||||
|     public clearPreviewCache() { | ||||
|         this.previewCache = null; | ||||
|     } | ||||
|     /**创建文件 | ||||
|      * @param name 文件名或路径+文件名 | ||||
|      * @param buffer 文件内容 | ||||
|      * @param offset 偏移量 | ||||
|      */ | ||||
|     public async createFile(name: string, buffer: ArrayBuffer, offset: number = 0) { | ||||
|         if (!this.isDirectory) throw new Error('不能创建文件'); | ||||
|         if (this.isRemote) { | ||||
|             await peer.send({ | ||||
|                 type: MessageType.request_createFile, | ||||
|                 data: { | ||||
|                     path: this.path, | ||||
|                     name: name, | ||||
|                     buffer: buffer, | ||||
|                     offset: offset | ||||
|                 } | ||||
|             }).then(async () => { | ||||
|                 await this.loadLocalDirectory(); | ||||
|             }).catch((err) => { | ||||
|                 throw err; | ||||
|             }) | ||||
|         } else { | ||||
|             let dir = this.fileDirHandler as FileSystemDirectoryHandle; | ||||
|             if (name.split('/').length > 1) { | ||||
|                 // 创建深路径文件
 | ||||
|                 const path = name.split('/').slice(0, -1).join('/'); | ||||
|                 name = name.split('/').slice(-1).join('/'); | ||||
|                 dir = await this.createPath(path); | ||||
|             } | ||||
|             if (offset > 0) { | ||||
|                 const fileHandler = await dir.getFileHandle(name, { create: true }); | ||||
|                 const arrayBuffer = await (await fileHandler.getFile()).arrayBuffer(); | ||||
|                 const newSize = buffer.byteLength + offset; | ||||
|                 const newArrayBuffer = arrayBuffer.byteLength >= newSize ? new Uint8Array(arrayBuffer.byteLength) : new Uint8Array(newSize); | ||||
|                 newArrayBuffer.set(new Uint8Array(arrayBuffer), 0); | ||||
|                 newArrayBuffer.set(new Uint8Array(buffer), offset); | ||||
| 
 | ||||
|                 const writable = await fileHandler.createWritable(); | ||||
|                 await writable.write(newArrayBuffer); | ||||
|                 await writable.close(); | ||||
|             } else { | ||||
| 
 | ||||
|                 const file = await dir.getFileHandle(name, { create: true }); | ||||
|                 const writable = await file.createWritable(); | ||||
|                 await writable.write(buffer); | ||||
|                 await writable.close(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     /**保存文件 */ | ||||
|     public async saveFile(buffer: ArrayBuffer, offset: number = 0) { | ||||
|         if (this.isDirectory) { | ||||
|             throw new Error('不能保存文件夹'); | ||||
|         } | ||||
|         if (this.isRemote) { | ||||
|             await peer.send({ | ||||
|                 type: MessageType.request_saveFile, | ||||
|                 data: { | ||||
|                     path: this.path, | ||||
|                     buffer: buffer, | ||||
|                     offset: offset | ||||
|                 } | ||||
|             }).then(async () => { | ||||
|                 await this.loadLocalDirectory(); | ||||
|             }).catch((err) => { | ||||
|                 throw err; | ||||
|             }) | ||||
|         } else { | ||||
|             if (offset > 0) { | ||||
|                 const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|                 const arrayBuffer = await file.arrayBuffer(); | ||||
|                 const newSize = buffer.byteLength + offset; | ||||
|                 const newArrayBuffer = arrayBuffer.byteLength >= newSize ? new Uint8Array(arrayBuffer.byteLength) : new Uint8Array(newSize); | ||||
|                 newArrayBuffer.set(new Uint8Array(arrayBuffer), 0); | ||||
|                 newArrayBuffer.set(new Uint8Array(buffer), offset); | ||||
| 
 | ||||
|                 const writable = await (this.fileDirHandler as FileSystemFileHandle).createWritable(); | ||||
|                 await writable.write(newArrayBuffer); | ||||
|                 await writable.close(); | ||||
|             } else { | ||||
|                 const writable = await (this.fileDirHandler as FileSystemFileHandle).createWritable(); | ||||
|                 await writable.write(buffer); | ||||
|                 await writable.close(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /**获取文件最后修改时间 */ | ||||
|     public async getFileLastModified(): Promise<number> { | ||||
|         if (this.isDirectory) return 0; | ||||
|         if (this.isRemote) { | ||||
|             await peer.send({ | ||||
|                 type: MessageType.request_getFileLastModified, | ||||
|                 data: { | ||||
|                     path: this.path, | ||||
|                 } | ||||
|             }).then((data: number) => { | ||||
|                 this.lastModified = data; | ||||
|             }) | ||||
|         } else { | ||||
|             const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|             this.lastModified = file.lastModified; | ||||
|         } | ||||
|         return this.lastModified; | ||||
|     } | ||||
| 
 | ||||
|     /**获取文件 | ||||
|      * @param preView 是否预览 | ||||
|      * @param savePath 在当前路径保存内容(文件/文件夹) | ||||
|      * @param excludePath 排除路径(生成文件夹路径时排除的选项) | ||||
|      */ | ||||
|     public async getFile(preView: boolean = false, savePath: string = '', excludePath: string = ''): Promise<FileData | FileData[]> { | ||||
|         if (this.isDirectory) { | ||||
|             const fileDatas: FileData[] = []; | ||||
|             await this.loadLocalDirectory(); | ||||
|             this.files.forEach(async file => { | ||||
|                 const fData = await file.getFile(preView, savePath, excludePath); | ||||
|                 if (Array.isArray(fData)) { | ||||
|                     fileDatas.push(...fData); | ||||
|                 } else { | ||||
|                     fileDatas.push(fData); | ||||
|                 } | ||||
|             }) | ||||
|             return fileDatas; | ||||
|         } else { | ||||
|             if (this.isRemote) { | ||||
|                 return await peer.send({ | ||||
|                     type: MessageType.request_getFile, | ||||
|                     data: { | ||||
|                         path: this.path, | ||||
|                         preView: preView, | ||||
|                         savePath: savePath + '/' + this.path.replace(excludePath, '').replace(this.name, '') | ||||
|                     } | ||||
|                 }).then(async (data: FileData) => { | ||||
|                     if (data.buffer && !data.preView) { | ||||
|                         (await fileMgrInstance.getRootFile()).createFile(savePath + '/' + this.path.replace(excludePath, ''), data.buffer).then(() => { | ||||
|                             this.getTransfer(peer.remoteConnection).addTask(new TransferTask(data, this)).updateFileData(data, TransferStatus.COMPLETED) | ||||
|                             notification.success({ | ||||
|                                 message: this.name + "文件接收成功", | ||||
|                             }); | ||||
|                         }) | ||||
|                             .catch((err) => { | ||||
|                                 notification.error({ | ||||
|                                     message: this.name + "文件接收失败", | ||||
|                                     description: err.message, | ||||
|                                 }); | ||||
|                             });; | ||||
|                     } | ||||
|                     return data; | ||||
|                 }).catch((err) => { | ||||
|                     notification.error({ | ||||
|                         message: this.name + "文件接收失败", | ||||
|                         description: err.message || err, | ||||
|                     }); | ||||
|                     throw err; | ||||
|                 }) | ||||
|             } else { | ||||
|                 const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); | ||||
|                 return { | ||||
|                     type: file.type, | ||||
|                     buffer: await file.arrayBuffer(), | ||||
|                     name: file.name, | ||||
|                     size: file.size, | ||||
|                     lastModified: file.lastModified, | ||||
|                     path: this.path, | ||||
|                     MD5: await this.getFileMD5(), | ||||
|                     preView: preView | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     //分片传输
 | ||||
|     public async sendFileChunk(conn: DataConnection, preView: boolean = false, savePath: string = ''): Promise<boolean> { | ||||
|         return this.getTransfer(conn).init(preView).sendFile(savePath) | ||||
|     } | ||||
| } | ||||
| class fileMgr { | ||||
|     private _rootFile: FileInfo; | ||||
|     public remoteRootFile: FileInfo = new FileInfo(true); | ||||
|     public async selectLocalDirectory() { | ||||
|         try { | ||||
|             const dirHandle = await window.showDirectoryPicker({ | ||||
|                 id: 'p2p-explorer-web', | ||||
|                 mode: 'readwrite', | ||||
|                 startIn: 'downloads' | ||||
|             }); | ||||
|             this._rootFile = new FileInfo(); | ||||
|             await this._rootFile.init(dirHandle); | ||||
|             emitter.emit(EmitterEvent.choosed_local_directory); | ||||
|         } catch (error) { | ||||
|             console.error('选择目录失败:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|     public async getRootFile(): Promise<FileInfo> { | ||||
|         if (!this._rootFile) { | ||||
|             await this.selectLocalDirectory(); | ||||
|         } | ||||
|         return this._rootFile; | ||||
|     } | ||||
| } | ||||
| export const fileMgrInstance = new fileMgr(); | ||||
| export interface FileData { | ||||
|     type: string; | ||||
|     buffer: ArrayBuffer; | ||||
|     name: string; | ||||
|     size: number; | ||||
|     lastModified: number; | ||||
|     path: string; | ||||
|     MD5?: string; | ||||
|     preView?: boolean; | ||||
|     savePath?: string; | ||||
|     chunkData?: { | ||||
|         offset: number; | ||||
|         totalSize: number; | ||||
|         buffer: ArrayBuffer; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										343
									
								
								src/pages/file/utils/fileTransfer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								src/pages/file/utils/fileTransfer.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,343 @@ | ||||
| import { type FileData, type FileInfo } from "./fileMgr"; | ||||
| import { MessageType, peer } from "./peer"; | ||||
| import { type DataConnection } from "peerjs"; | ||||
| // 传输状态枚举
 | ||||
| export enum TransferStatus { | ||||
|     WAITING = "waiting",     // 等待传输
 | ||||
|     SENDING = "sending",     // 发送中
 | ||||
|     RECEIVING = "receiving", // 接收中
 | ||||
|     PAUSED = "paused",      // 已暂停
 | ||||
|     COMPLETED = "completed", // 已完成
 | ||||
|     ERROR = "error",        // 错误
 | ||||
| } | ||||
| 
 | ||||
| // 传输进度接口
 | ||||
| export interface TransferProgress { | ||||
|     transferredSize: number;  // 已传输大小
 | ||||
|     totalSize: number;       // 总大小
 | ||||
|     speed: number;          // 传输速度 (bytes/s)
 | ||||
|     status: TransferStatus;  // 传输状态
 | ||||
|     percent: number;       // 进度百分比
 | ||||
|     costTime: number;        // 传输时间
 | ||||
|     updateTime: number;      // 更新时间
 | ||||
| } | ||||
| 
 | ||||
| // 分片配置
 | ||||
| const CHUNK_SIZE = 64 * 1024; // 64KB
 | ||||
| const MAX_CHUNK_SIZE = 200 * 1024; // 200KB
 | ||||
| //多大的文件需要分片
 | ||||
| export const NEED_CHUNK_FILE_SIZE = 200 * 1024; // 200KB
 | ||||
| export const NEED_CHUNK_FILE_SIZE_PREVIEW = 50 * 1024 * 1024; // 50MB
 | ||||
| 
 | ||||
| export class FileTransfer { | ||||
|     public conn: DataConnection; | ||||
|     private file: FileInfo; | ||||
|     private chunkSize: number; | ||||
|     private transferredSize: number = 0; | ||||
|     private lastTransferredSize: number = 0; | ||||
|     private startTime: number = 0; | ||||
|     private status: TransferStatus = TransferStatus.WAITING; | ||||
|     private pausePromise?: Promise<void>; | ||||
|     private pauseResolve?: () => void; | ||||
|     private aborted: boolean = false; | ||||
|     private preView: boolean = false; | ||||
|     // 进度回调
 | ||||
|     private onProgressCallback?: (transfer: FileTransfer) => void; | ||||
|     public getFile(): FileInfo { | ||||
|         return this.file; | ||||
|     } | ||||
|     constructor(conn: DataConnection, file: FileInfo, chunkSize: number = CHUNK_SIZE) { | ||||
|         this.conn = conn; | ||||
|         this.file = file; | ||||
|         this.chunkSize = Math.min(chunkSize, MAX_CHUNK_SIZE); | ||||
|         file.addTransfer(this) | ||||
|         fileTransferMgrInstance.addFileTransfer(this) | ||||
|     } | ||||
|     public init(preView: boolean = false) { | ||||
|         this.clear(); | ||||
|         this.status = TransferStatus.WAITING; | ||||
|         this.preView = preView; | ||||
| 
 | ||||
|         return this; | ||||
|     } | ||||
|     public clear() { | ||||
|         this.offset = 0; | ||||
|         this.totalSize = 0; | ||||
|         this.fileBuffer = null; | ||||
|         this.fileData = null; | ||||
|         this.transferredSize = 0; | ||||
|         this.lastTransferredSize = 0; | ||||
|         this.startTime = 0; | ||||
|         this.pausePromise = undefined; | ||||
|         this.pauseResolve = undefined; | ||||
|         this.aborted = false; | ||||
|     } | ||||
| 
 | ||||
|     // 设置进度回调
 | ||||
|     public onProgress(callback: (transfer: FileTransfer) => void) { | ||||
|         this.onProgressCallback = callback; | ||||
|     } | ||||
|     public currentProgress(): TransferProgress { | ||||
|         return this.getProgress(); | ||||
|     } | ||||
|     // 获取当前进度
 | ||||
|     private getProgress(): TransferProgress { | ||||
|         const now = Date.now(); | ||||
|         const timeElapsed = (now - this.startTime) / 1000; // 转换为秒
 | ||||
|         const speed = timeElapsed > 0 ? (this.transferredSize - this.lastTransferredSize) / timeElapsed : 0; | ||||
|         let totalSize = this.fileData?.chunkData?.totalSize || this.file.size; | ||||
|         const progress: TransferProgress = { | ||||
|             transferredSize: this.transferredSize, | ||||
|             totalSize: totalSize, | ||||
|             speed, | ||||
|             status: this.status, | ||||
|             percent: (this.transferredSize / totalSize) * 100, | ||||
|             costTime: timeElapsed, | ||||
|             updateTime: now | ||||
|         }; | ||||
| 
 | ||||
|         // 更新上次传输大小和开始时间
 | ||||
|         this.lastTransferredSize = this.transferredSize; | ||||
|         this.startTime = now; | ||||
|         return progress; | ||||
|     } | ||||
|     public fileData: FileData | ||||
|     public offset: number = 0; | ||||
|     public totalSize: number = 0; | ||||
|     private fileBuffer: ArrayBuffer | null = null; | ||||
|     // 发送文件
 | ||||
|     public async sendFile(savePath: string = ''): Promise<boolean> { | ||||
|         try { | ||||
|             if (this.status == TransferStatus.WAITING) { | ||||
|                 this.startTime = Date.now(); | ||||
|                 this.fileData = await this.file.getFile() as FileData; | ||||
|                 this.totalSize = this.fileData.size; | ||||
|                 this.fileBuffer = this.fileData.buffer; | ||||
|                 this.fileData.preView = this.preView; | ||||
|                 this.fileData.savePath = savePath; | ||||
|                 this.addTask(new TransferTask(this.fileData, this.file)); | ||||
|                 this.status = TransferStatus.SENDING; | ||||
|                 this.updateProgress(); | ||||
|             } | ||||
| 
 | ||||
|             if (this.offset < this.totalSize && !this.aborted) { | ||||
|                 // 检查是否暂停
 | ||||
|                 if (this.pausePromise) { | ||||
|                     this.status = TransferStatus.PAUSED; | ||||
|                     this.updateProgress(); | ||||
|                     await this.pausePromise; | ||||
|                     this.status = TransferStatus.SENDING; | ||||
|                 } | ||||
| 
 | ||||
|                 // 发送分片
 | ||||
|                 const chunk = this.fileBuffer.slice(this.offset, this.offset + this.chunkSize); | ||||
|                 this.fileData.buffer = null; | ||||
|                 this.fileData.chunkData = { | ||||
|                     offset: this.offset, | ||||
|                     totalSize: this.totalSize, | ||||
|                     buffer: chunk | ||||
|                 } | ||||
|                 return this.sendFileChunk(this.fileData).then((): Promise<boolean> => { | ||||
|                     this.offset += chunk.byteLength; | ||||
|                     this.transferredSize = this.offset; | ||||
|                     if (this.offset >= this.totalSize) { | ||||
|                         return this.sendFileComplete(); | ||||
|                     } else { | ||||
|                         return this.sendFile(); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
| 
 | ||||
|             if (!this.aborted) { | ||||
|                 this.status = TransferStatus.COMPLETED; | ||||
|                 this.updateProgress(); | ||||
|                 return true; | ||||
|             } else { | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|         } catch (error) { | ||||
|             this.status = TransferStatus.ERROR; | ||||
|             this.updateProgress(); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|     private sendFileChunk(chunk: FileData): Promise<void> { | ||||
|         this.updateProgress() | ||||
|         return peer.send({ | ||||
|             type: MessageType.push_file_chunk, | ||||
|             data: chunk, | ||||
|         }, this.conn); | ||||
|     } | ||||
|     private async sendFileComplete(): Promise<boolean> { | ||||
|         await peer.send({ | ||||
|             type: MessageType.push_file_complete, | ||||
|             data: this.fileData, | ||||
|         }, this.conn); | ||||
|         this.status = TransferStatus.COMPLETED; | ||||
|         this.updateProgress(); | ||||
|         return true; | ||||
|     } | ||||
|     // 接收文件
 | ||||
|     public async receiveFile(fData: FileData, preView: boolean = false): Promise<void> { | ||||
|         try { | ||||
|             let task = this.getTask(fData) | ||||
|             if (!task) { | ||||
|                 this.addTask(new TransferTask(fData, this.file)); | ||||
|             } else { | ||||
|                 task.updateFileData(fData, TransferStatus.RECEIVING); | ||||
|             } | ||||
|             this.status = TransferStatus.RECEIVING; | ||||
|             this.transferredSize = fData.chunkData.offset; | ||||
|             this.startTime = Date.now(); | ||||
|             this.totalSize = fData.chunkData.totalSize; | ||||
|             if (preView) { | ||||
|                 await this.file.addPreviewCacheBuffer(fData.chunkData.buffer, fData.chunkData.offset, fData.chunkData.totalSize); | ||||
|             } else { | ||||
|                 await this.file.createFile(fData.savePath + '/' + fData.name, fData.chunkData.buffer, fData.chunkData.offset) | ||||
|             } | ||||
|             if (fData.chunkData.totalSize <= fData.chunkData.buffer.byteLength + fData.chunkData.offset) { | ||||
|                 this.status = TransferStatus.COMPLETED; | ||||
|             } | ||||
|             this.updateProgress(fData); | ||||
|             return | ||||
|         } catch (error) { | ||||
|             this.status = TransferStatus.ERROR; | ||||
|             this.updateProgress(fData); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // 暂停传输
 | ||||
|     public pause() { | ||||
|         if (this.status === TransferStatus.SENDING || this.status === TransferStatus.RECEIVING) { | ||||
|             this.pausePromise = new Promise(resolve => { | ||||
|                 this.pauseResolve = resolve; | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // 恢复传输
 | ||||
|     public resume() { | ||||
|         if (this.pauseResolve) { | ||||
|             this.pauseResolve(); | ||||
|             this.pausePromise = undefined; | ||||
|             this.pauseResolve = undefined; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // 取消传输
 | ||||
|     public abort() { | ||||
|         this.aborted = true; | ||||
|         this.resume(); // 恢复暂停的传输以便能够正确退出
 | ||||
|     } | ||||
| 
 | ||||
|     // 更新进度
 | ||||
|     private updateProgress(fData: FileData = this.fileData) { | ||||
|         this.updateTask(fData); | ||||
|         if (this.onProgressCallback) { | ||||
|             this.onProgressCallback(this); | ||||
|         } | ||||
|     } | ||||
|     private tasks: TransferTask[] = []; | ||||
|     //记录传输任务
 | ||||
|     public addTask(task: TransferTask) { | ||||
|         this.tasks.push(task); | ||||
|         this.updateProgress(task.fileData); | ||||
|         return task; | ||||
|     } | ||||
|     public getTasks() { | ||||
|         this.tasks.forEach(task => task.updateProgress()); | ||||
|         return this.tasks; | ||||
|     } | ||||
|     public updateTask(fData: FileData) { | ||||
|         const task = this.tasks.find(task => task.fileData.path === fData.path); | ||||
|         task.updateFileData(fData, this.status); | ||||
|     } | ||||
|     public getTask(fData: FileData) { | ||||
|         return this.tasks.find(task => task.fileData.path === fData.path); | ||||
|     } | ||||
|     //清除已完成任务
 | ||||
|     public clearCompletedTasks() { | ||||
|         this.tasks = this.tasks.filter(t => t.status !== TransferStatus.COMPLETED); | ||||
|     } | ||||
| } | ||||
| class FileTransferMgr { | ||||
|     private fileTransfers: Map<string, FileTransfer> = new Map(); | ||||
|     public addFileTransfer(transfer: FileTransfer) { | ||||
|         transfer.onProgress(this.notifyTransferChanged.bind(this)); | ||||
|         this.fileTransfers.set(transfer.getFile().path, transfer); | ||||
| 
 | ||||
|     } | ||||
|     public getFileTransfer(path: string) { | ||||
|         return this.fileTransfers.get(path); | ||||
|     } | ||||
|     public getAllFileTransfers() { | ||||
|         return Array.from(this.fileTransfers.values()); | ||||
|     } | ||||
|     // 传输进度变化回调
 | ||||
|     private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = [] | ||||
|     public onTransferChanged(handler: (transfer: FileTransfer) => void) { | ||||
|         this.onTransferChangedHandler.push(handler); | ||||
|     } | ||||
|     public removeTransferChangedHandler(handler: (transfer: FileTransfer) => void) { | ||||
|         this.onTransferChangedHandler = this.onTransferChangedHandler.filter(h => h !== handler); | ||||
|     } | ||||
|     public notifyTransferChanged(transfer: FileTransfer) { | ||||
|         this.onTransferChangedHandler.forEach(handler => handler(transfer)); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| export class TransferTask { | ||||
|     //传输文件数据
 | ||||
|     fileData: FileData; | ||||
|     //接收载体目录
 | ||||
|     file: FileInfo; | ||||
|     //开始时间
 | ||||
|     startTime: number = 0; | ||||
|     //传输状态
 | ||||
|     status: TransferStatus = TransferStatus.WAITING; | ||||
|     //传输进度
 | ||||
|     progress: TransferProgress = { | ||||
|         transferredSize: 0, | ||||
|         totalSize: 0, | ||||
|         speed: 0, | ||||
|         status: TransferStatus.WAITING, | ||||
|         percent: 0, | ||||
|         costTime: 0, | ||||
|         updateTime: 0 | ||||
|     }; | ||||
|     constructor(fileData: FileData, file: FileInfo) { | ||||
|         this.fileData = fileData; | ||||
|         this.file = file; | ||||
|         this.startTime = Date.now(); | ||||
|     } | ||||
|     public updateFileData(fileData: FileData, status: TransferStatus = TransferStatus.SENDING) { | ||||
|         this.fileData = fileData; | ||||
|         this.status = status; | ||||
|         this.updateProgress(); | ||||
|     } | ||||
|     public updateProgress(): TransferProgress { | ||||
|         if (this.status == TransferStatus.COMPLETED) { | ||||
|             this.progress.updateTime = Date.now(); | ||||
|             return this.progress; | ||||
|         } | ||||
|         const totalSize = this.fileData.chunkData?.totalSize || this.fileData.size; | ||||
|         const transferredSize = this.fileData.chunkData?.offset || this.fileData.size; | ||||
|         const speed = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / (Date.now() - this.startTime) * 1000 : this.fileData.size / (Date.now() - this.startTime) * 1000; | ||||
|         const percent = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / totalSize * 100 : transferredSize / totalSize * 100; | ||||
|         this.status = transferredSize >= totalSize ? TransferStatus.COMPLETED : this.status; | ||||
|         this.progress = { | ||||
|             transferredSize: transferredSize, | ||||
|             totalSize: totalSize, | ||||
|             speed: speed == Infinity ? totalSize : speed, | ||||
|             status: this.status, | ||||
|             percent: percent, | ||||
|             costTime: Date.now() - this.startTime, | ||||
|             updateTime: Date.now() | ||||
|         } | ||||
|         return this.progress; | ||||
|     } | ||||
| } | ||||
| export const fileTransferMgrInstance = new FileTransferMgr() | ||||
							
								
								
									
										362
									
								
								src/pages/file/utils/peer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										362
									
								
								src/pages/file/utils/peer.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,362 @@ | ||||
| import { Peer as PeerJs } from 'peerjs' | ||||
| import type { DataConnection } from 'peerjs' | ||||
| import { fileMgrInstance, type FileData, type FileInfo } from './fileMgr' | ||||
| import { NEED_CHUNK_FILE_SIZE, NEED_CHUNK_FILE_SIZE_PREVIEW, TransferStatus, TransferTask } from './fileTransfer' | ||||
| import { notification } from 'ant-design-vue' | ||||
| import { randomChars, sign2peerid, getPermissionSet } from './common' | ||||
| 
 | ||||
| // 发送超时时间(毫秒)
 | ||||
| const SEND_TIMEOUT = 10000; | ||||
| 
 | ||||
| class Peer extends EventTarget { | ||||
|     peer: PeerJs | ||||
|     //被动连接
 | ||||
|     connections: Map<string, DataConnection> = new Map() | ||||
|     //主动连接
 | ||||
|     remoteConnection: DataConnection | null = null | ||||
|     //我的id
 | ||||
|     id: string | null = null | ||||
|     //自定义标识
 | ||||
|     sign: string | null = null | ||||
|     constructor(sign: string) { | ||||
|         super() | ||||
|         this.sign = sign | ||||
|         this.peer = new PeerJs(sign2peerid(sign)) | ||||
|         this.peer.on('open', (id) => { | ||||
|             console.log('peer open', id) | ||||
|             this.id = id | ||||
|             this.dispatchEvent(new CustomEvent('open', { detail: this.sign })) | ||||
|         }) | ||||
| 
 | ||||
|         this.peer.on('connection', (conn) => { | ||||
|             console.log('peer connection', conn) | ||||
|             this.setupConnection(conn) | ||||
|         }) | ||||
| 
 | ||||
|         this.peer.on('error', (err) => { | ||||
|             console.log('peer error', err) | ||||
|             this.dispatchEvent(new CustomEvent('error', { detail: err })) | ||||
|         }) | ||||
| 
 | ||||
|         this.peer.on('close', () => { | ||||
|             console.log('peer close') | ||||
|             this.dispatchEvent(new CustomEvent('close')) | ||||
|         }) | ||||
| 
 | ||||
|         this.peer.on('disconnected', () => { | ||||
|             console.log('peer disconnected') | ||||
|             this.dispatchEvent(new CustomEvent('disconnected')) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private setupConnection(conn: DataConnection) { | ||||
|         this.connections.set(conn.peer, conn) | ||||
|         conn.on('data', (data: unknown) => { | ||||
|             const typedData = data as Message | ||||
| 
 | ||||
|             if (this.callbackMap.has(typedData.id)) { | ||||
|                 this.callbackMap.get(typedData.id)?.(typedData) | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             this.handleMessage(typedData, conn) | ||||
| 
 | ||||
|             this.dispatchEvent(new CustomEvent(typedData.type, { | ||||
|                 detail: { peerId: conn.peer, data: typedData } | ||||
|             })) | ||||
|         }) | ||||
|         conn.on('open', () => { | ||||
|             this.dispatchEvent(new CustomEvent('connection-open', { | ||||
|                 detail: { peer: conn.peer, conn: conn } | ||||
|             })) | ||||
|         }) | ||||
|         conn.on('close', () => { | ||||
|             this.connections.delete(conn.peer) | ||||
|             this.dispatchEvent(new CustomEvent('peer-disconnected', { | ||||
|                 detail: { peer: conn.peer, conn: conn } | ||||
|             })) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     connect(id: string) { | ||||
|         const conn = this.peer.connect(sign2peerid(id)) | ||||
|         this.setupConnection(conn) | ||||
|         this.remoteConnection = conn | ||||
|         this.checkConnection() | ||||
|         return conn | ||||
|     } | ||||
|     private callbackMap: Map<string, (data: any) => void> = new Map() | ||||
|     async send(data: Message, conn: DataConnection = this.remoteConnection, isHandleResponse: boolean = false): Promise<any> { | ||||
|         data.id = data.id || uuidv4() | ||||
|         if (!conn) { | ||||
|             notification.error({ | ||||
|                 message: '请创建连接', | ||||
|             }) | ||||
|             return Promise.reject('连接不存在') | ||||
|         } | ||||
|         conn.send(data, true) | ||||
|         if (isHandleResponse) { | ||||
|             return Promise.resolve(data.data) | ||||
|         } else { | ||||
|             return new Promise((resolve, reject) => { | ||||
|                 const timeoutId = setTimeout(() => { | ||||
|                     this.callbackMap.delete(data.id) | ||||
|                     notification.error({ | ||||
|                         message: '返回超时', | ||||
|                         description: '对方可能已离线或网络异常' | ||||
|                     }) | ||||
|                     reject(new Error('返回超时')) | ||||
|                 }, SEND_TIMEOUT) | ||||
|                 this.callbackMap.set(data.id, (data: Message) => { | ||||
|                     clearTimeout(timeoutId) | ||||
|                     if (data.type === MessageType.response_getFile && data.data.sendType == 'chunk') { | ||||
|                         //分片传输 特殊处理 可以长时间等待进度
 | ||||
|                         return | ||||
|                     } | ||||
|                     if (data.type === MessageType.error) { | ||||
|                         reject(data.data) | ||||
|                     } else { | ||||
|                         resolve(data.data) | ||||
|                     } | ||||
|                     this.callbackMap.delete(data.id) | ||||
|                 }) | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
|     /**共交换的字节数 */ | ||||
|     public transbytesNum: number = 0; | ||||
|     /**共交换的包数 */ | ||||
|     public transpackNum: number = 0; | ||||
|     private checkConnection() { | ||||
|         if (!this.remoteConnection) { | ||||
|             return | ||||
|         } | ||||
|         const rtcp: RTCPeerConnection = this.remoteConnection.peerConnection; | ||||
|         rtcp.getStats().then((stats) => { | ||||
|             stats.forEach((stat) => { | ||||
|                 if (stat.type == "data-channel") { | ||||
|                     //流量
 | ||||
|                     this.transbytesNum = stat.bytesReceived + stat.bytesSent; | ||||
|                     //包数
 | ||||
|                     this.transpackNum = stat.messagesReceived + stat.messagesSent; | ||||
|                 } | ||||
|             }) | ||||
|         }) | ||||
|         setTimeout(() => { | ||||
|             this.checkConnection() | ||||
|         }, 1000); | ||||
|     } | ||||
| 
 | ||||
|     // 便捷方法用于添加事件监听器
 | ||||
|     on(event: string, callback: EventListener) { | ||||
|         this.addEventListener(event, callback) | ||||
|     } | ||||
| 
 | ||||
|     // 便捷方法用于移除事件监听器
 | ||||
|     off(event: string, callback: EventListener) { | ||||
|         this.removeEventListener(event, callback) | ||||
|     } | ||||
| 
 | ||||
|     async handleMessage(data: Message, conn: DataConnection) { | ||||
|         const remoteD = data.data; | ||||
|         let file: FileInfo | null = null; | ||||
|         let resData: Message = { | ||||
|             type: MessageType.error, | ||||
|             data: null, | ||||
|             id: data.id | ||||
|         } | ||||
|         try { | ||||
|             // 权限校验
 | ||||
|             /** 如果通过权限校验,则permissionNo为null */ | ||||
|             let permissionNo: Permission | null = null; | ||||
|             for (const [permission, allowed] of Object.entries(getPermissionSet())) { | ||||
|                 if (PermissionLimit[permission as Permission].includes(data.type)) { | ||||
|                     if (allowed) { | ||||
|                         permissionNo = null; | ||||
|                         break; | ||||
|                     } else { | ||||
|                         permissionNo = permission as Permission; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             if (permissionNo) { | ||||
|                 throw new Error(`没有权限执行该操作:${data.type},请检查权限${permissionNo}设置`) | ||||
|             } | ||||
| 
 | ||||
|             switch (data.type) { | ||||
|                 case MessageType.request_copyClipboard: | ||||
|                     resData.type = MessageType.response_copyClipboard; | ||||
|                     //读取粘贴板数据
 | ||||
|                     resData.data = await navigator.clipboard.readText().then(text => { | ||||
|                         return text | ||||
|                     }).catch(err => { | ||||
|                         return '对方窗口未聚焦,无法复制' | ||||
|                     }); | ||||
|                     break; | ||||
|                 case MessageType.request_fileInfo: | ||||
|                     resData.type = MessageType.response_fileInfo; | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path); | ||||
|                     if (file) { | ||||
|                         await file.loadLocalDirectory() | ||||
|                         const json = file.toJson() | ||||
|                         resData.data = json | ||||
|                     } else { | ||||
|                         resData.data = null | ||||
|                     } | ||||
|                     break; | ||||
|                 case MessageType.request_fileSize: | ||||
|                     resData.type = MessageType.response_fileSize | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     resData.data = await file.getFileSize() | ||||
|                     break; | ||||
|                 case MessageType.request_deleteFile: | ||||
|                     resData.type = MessageType.response_deleteFile | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     await file.deleteItself() | ||||
|                     break; | ||||
|                 case MessageType.request_renameFile: | ||||
|                     resData.type = MessageType.response_renameFile | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     await file.renameFile(remoteD.newName) | ||||
|                     break; | ||||
|                 case MessageType.request_createDirectory: | ||||
|                     resData.type = MessageType.response_createDirectory | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     await file.createDirectory(remoteD.name) | ||||
|                     break; | ||||
|                 case MessageType.request_createFile: | ||||
|                     resData.type = MessageType.response_createFile | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     await file.createFile(remoteD.name, remoteD.buffer) | ||||
|                     break; | ||||
|                 case MessageType.request_saveFile: | ||||
|                     resData.type = MessageType.response_saveFile | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     await file.saveFile(remoteD.buffer) | ||||
|                     break; | ||||
|                 case MessageType.request_getFileLastModified: | ||||
|                     resData.type = MessageType.response_getFileLastModified | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     resData.data = await file.getFileLastModified() | ||||
|                     break; | ||||
|                 case MessageType.request_getFile: | ||||
|                     if (!remoteD.preView) { | ||||
|                         //特殊处理下载权限
 | ||||
|                         if (!getPermissionSet()[Permission.download]) { | ||||
|                             throw new Error(`没有权限执行该操作:${data.type},请检查权限${Permission.download}设置`) | ||||
|                         } | ||||
|                     } | ||||
|                     resData.type = MessageType.response_getFile | ||||
|                     file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) | ||||
|                     let fileData = await file.getFile(remoteD.preView) as FileData | ||||
|                     if (fileData.size > NEED_CHUNK_FILE_SIZE) { | ||||
|                         this.send({ | ||||
|                             type: MessageType.response_getFile, | ||||
|                             data: { | ||||
|                                 sendType: 'chunk' | ||||
|                             }, | ||||
|                             id: data.id | ||||
|                         }, conn, true) | ||||
|                         //分片传输
 | ||||
|                         if (remoteD.preView) { | ||||
|                             if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) { | ||||
|                                 await file.sendFileChunk(conn, remoteD.preView) | ||||
|                             } | ||||
|                         } else { | ||||
|                             await file.sendFileChunk(conn, false, remoteD.savePath) | ||||
|                         } | ||||
|                         fileData.buffer = null; | ||||
|                     } else { | ||||
|                         //直接传输
 | ||||
|                         file.getTransfer(conn).addTask(new TransferTask(fileData, file)).updateFileData(fileData, TransferStatus.COMPLETED) | ||||
|                     } | ||||
|                     resData.data = fileData | ||||
|                     break; | ||||
|                 case MessageType.push_file_chunk: | ||||
|                     resData.type = MessageType.response_push_file_chunk | ||||
|                     let fData = remoteD as FileData | ||||
|                     if (fData.preView) { | ||||
|                         await fileMgrInstance.remoteRootFile.getFileInfo(fData.path).getTransfer(conn).receiveFile(fData, true) | ||||
|                     } else { | ||||
|                         await (await fileMgrInstance.getRootFile()).getTransfer(conn).receiveFile(fData) | ||||
|                     } | ||||
|                     resData.data = 'ok' | ||||
|                     break; | ||||
|                 case MessageType.push_file_complete: | ||||
|                     resData.type = MessageType.response_push_file_complete | ||||
|                     console.log('push_file_complete', remoteD) | ||||
|                     if (remoteD.preView) { | ||||
|                         notification.success({ | ||||
|                             message: '文件缓存建立完成', | ||||
|                         }) | ||||
|                     } else { | ||||
|                         notification.success({ | ||||
|                             message: '文件流式传输完成', | ||||
|                             description: remoteD.name | ||||
|                         }) | ||||
|                     } | ||||
|                     resData.data = 'ok' | ||||
|                     break; | ||||
|                 default: | ||||
|                     resData.type = MessageType.error | ||||
|                     resData.data = '未知消息类型' | ||||
|                     break; | ||||
|             } | ||||
|         } catch (err: any) { | ||||
|             console.error('handleMessage error', err) | ||||
|             resData.type = MessageType.error | ||||
|             resData.data = err.message || JSON.stringify(err) | ||||
|         } | ||||
|         this.send(resData, conn, true) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export const peer = new Peer(randomChars(6)) | ||||
| export interface Message { | ||||
|     type: MessageType | ||||
|     data: any | ||||
|     id?: string  //uuid
 | ||||
| } | ||||
| export enum MessageType { | ||||
|     request_copyClipboard = 'request_copyClipboard', | ||||
|     response_copyClipboard = 'response_copyClipboard', | ||||
|     request_getFile = 'request_getFile', | ||||
|     response_getFile = 'response_getFile', | ||||
|     request_getFileLastModified = 'request_getFileLastModified', | ||||
|     response_getFileLastModified = 'response_getFileLastModified', | ||||
|     request_saveFile = 'request_saveFile', | ||||
|     response_saveFile = 'response_saveFile', | ||||
|     request_createFile = 'request_createFile', | ||||
|     response_createFile = 'response_createFile', | ||||
|     request_createDirectory = 'request_createDirectory', | ||||
|     response_createDirectory = 'response_createDirectory', | ||||
|     request_renameFile = 'request_renameFile', | ||||
|     response_renameFile = 'response_renameFile', | ||||
|     request_deleteFile = 'request_deleteFile', | ||||
|     response_deleteFile = 'response_deleteFile', | ||||
|     error = 'handle_error', | ||||
|     request_fileSize = 'request_fileSize', | ||||
|     response_fileSize = 'response_fileSize', | ||||
|     response_fileInfo = 'response_fileInfo', | ||||
|     request_fileInfo = 'request_fileInfo', | ||||
|     push_file_chunk = 'push_file_chunk', | ||||
|     push_file_complete = 'push_file_complete', | ||||
|     response_push_file_chunk = 'response_push_file_chunk', | ||||
|     response_push_file_complete = 'response_push_file_complete', | ||||
| } | ||||
| export enum Permission { | ||||
|     edit = 'edit', | ||||
|     view = 'view', | ||||
|     download = 'download', | ||||
| } | ||||
| export const PermissionLimit = { | ||||
|     [Permission.edit]: [MessageType.request_saveFile, MessageType.request_createFile, MessageType.request_createDirectory, MessageType.request_renameFile, MessageType.request_deleteFile], | ||||
|     [Permission.view]: [MessageType.request_copyClipboard, MessageType.request_getFile, MessageType.request_getFileLastModified, MessageType.request_fileInfo, MessageType.request_fileSize], | ||||
|     [Permission.download]: [MessageType.request_getFile], | ||||
| } | ||||
| export function uuidv4(): string { | ||||
|     return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | ||||
|         var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); | ||||
|         return v.toString(16); | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										6
									
								
								src/shime-uni.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/shime-uni.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| export {} | ||||
| 
 | ||||
| declare module 'vue' { | ||||
|   type Hooks = App.AppInstance & Page.PageInstance | ||||
|   interface ComponentCustomOptions extends Hooks {} | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user