This commit is contained in:
nnwang
2025-12-01 17:24:01 +08:00
parent a635b91e66
commit 5a3ce18193
25 changed files with 6665 additions and 0 deletions

2063
Frontend1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

10
Frontend1/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"devDependencies": {
"@tailwindcss/vite": "^4.1.17",
"daisyui": "^5.5.5",
"tailwindcss": "^4.1.17"
},
"dependencies": {
"monaco-tree-editor": "^1.1.6"
}
}

24
Frontend1/vite-project/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-project</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2169
Frontend1/vite-project/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "vite-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"monaco-tree-editor": "^1.1.6",
"vue": "^3.5.24"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"daisyui": "^5.5.5",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,639 @@
<template>
<div class="full-screen-wrapper" style="width: 100vw; height: 100vh;">
<MonacoTreeEditor ref="monacoEditorRef" :font-size="14" :files="files" :sider-min-width="250" filelist-title="文件列表"
language="zh-CN" @reload="handleReload" @new-file="handleNewFile" @new-folder="handleNewFolder"
@save-file="handleSaveFile" @delete-file="handleDeleteFile" @delete-folder="handleDeleteFolder"
@rename-file="handleRename" @rename-folder="handleRename" :file-menu="fileMenu"
@contextmenu-select="handleContextMenuSelect"></MonacoTreeEditor>
<!-- 隐藏的文件上传输入框 -->
<input ref="fileInputRef" type="file" multiple style="display: none" @change="handleFileUpload" />
</div>
</template>
<style scoped>
.full-screen-wrapper :deep(.url-info-text) {
font-size: 13px;
background-color: var(--url-info-bg, rgba(0, 0, 0, 0.05));
border-color: var(--url-info-border, rgba(0, 0, 0, 0.1));
color: var(--url-info-color, inherit);
}
.full-screen-wrapper :deep(.message-container) {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 上传按钮样式 - 插入到文件列表标题栏 */
.full-screen-wrapper :deep(.monaco-tree-editor-list-title) {
position: relative;
user-select: none;
padding-right: 80px;
}
.full-screen-wrapper :deep(.upload-file-btn) {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
z-index: 10;
white-space: nowrap;
}
.full-screen-wrapper :deep(.upload-error-message) {
position: fixed;
top: 20px;
right: 20px;
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-width: 300px;
font-size: 14px;
}
</style>
<script setup lang="ts">
import { Editor as MonacoTreeEditor, useMonaco, type Files } from 'monaco-tree-editor'
import 'monaco-tree-editor/index.css'
import { ref, onMounted, nextTick, onUnmounted } from 'vue'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import * as server from './mock-server'
// ================ 初始化 ================
// 声明 Window 接口扩展
declare global {
interface Window {
MonacoEnvironment: {
getWorker: (moduleId: any, label: string) => Worker
globalAPI?: boolean
}
}
}
window.MonacoEnvironment = {
getWorker: function (_moduleId: any, label: string) {
if (label === 'json') {
return new jsonWorker()
} else if (label === 'ts' || label === 'typescript') {
return new tsWorker()
} else if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new htmlWorker()
} else if (label === 'css' || label === 'scss' || label === 'less') {
return new cssWorker()
}
return new editorWorker()
},
globalAPI: true,
}
// 初始化 Monaco
useMonaco(monaco)
// ================ 回调函数 =================
const files = ref<Files>()
const fileInputRef = ref<HTMLInputElement>()
const monacoEditorRef = ref()
const handleReload = (resolve: () => void, reject: (msg?: string) => void) => {
server
.fetchFiles()
.then((response) => {
files.value = response
nextTick(() => {
setTimeout(insertUrlInfoText, 100)
setTimeout(insertUploadButton, 100)
})
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleSaveFile = (path: string, content: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.createOrSaveFile(path, content)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleDeleteFile = (path: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.deleteFile(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleDeleteFolder = (path: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.deleteFile(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleNewFile = (path: string, resolve: Function, reject: Function) => {
server
.newFile(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleNewFolder = (path: string, resolve: Function, reject: Function) => {
server
.newFolder(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleRename = (path: string, newPath: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.rename(path, newPath)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
// ================ 自定义菜单 =================
const fileMenu = ref([
{ label: '下载文件', value: 'download' },
])
const handleContextMenuSelect = (path: string, item: { label: string | import('vue').ComputedRef<string>; value: any }) => {
console.log('选中菜单项:', item.value)
if (item.value === 'download') {
downloadFile(path)
}
}
// ================ 下载文件功能 =================
const downloadFile = (path: string) => {
const file = files.value?.[path]
if (!file || !file.isFile) {
console.error('文件不存在或不是文件:', path)
return
}
try {
const content = file.content || ''
const fileName = path.split('\\').pop() || 'file'
// 创建Blob对象
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
// 创建下载链接
const link = document.createElement('a')
link.href = url
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 释放URL对象
URL.revokeObjectURL(url)
console.log('文件下载成功:', fileName)
} catch (error) {
console.error('文件下载失败:', error)
}
}
// ================ 上传文件功能 =================
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB限制
const triggerFileUpload = () => {
fileInputRef.value?.click()
}
const showErrorMessage = (message: string) => {
// 移除已存在的错误消息
const existingError = document.querySelector('.upload-error-message')
if (existingError) {
existingError.remove()
}
// 创建新的错误消息元素
const errorElement = document.createElement('div')
errorElement.className = 'upload-error-message'
errorElement.textContent = message
document.body.appendChild(errorElement)
// 3秒后自动移除
setTimeout(() => {
if (errorElement.parentNode) {
errorElement.remove()
}
}, 3000)
}
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
// 检查每个文件的大小
const oversizedFiles: string[] = []
const validFiles: File[] = []
Array.from(files).forEach(file => {
if (file.size > MAX_FILE_SIZE) {
oversizedFiles.push(file.name)
} else {
validFiles.push(file)
}
})
// 如果有超过大小的文件,显示错误信息
if (oversizedFiles.length > 0) {
const errorMessage = `以下文件超过1MB限制无法上传\n${oversizedFiles.join('\n')}`
showErrorMessage(errorMessage)
console.error('文件大小超过限制:', oversizedFiles)
// 如果所有文件都超过大小,直接返回
if (validFiles.length === 0) {
input.value = ''
return
}
}
// 处理有效的文件
validFiles.forEach(file => {
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
const filePath = `/${file.name}`
// 模拟保存文件到服务器
server.createOrSaveFile(filePath, content)
.then(() => {
console.log('文件上传成功:', file.name)
// 重新加载文件列表
handleReload(() => { }, (msg) => console.error(msg))
})
.catch(error => {
console.error('文件上传失败:', error)
showErrorMessage(`文件上传失败: ${file.name}`)
})
}
reader.onerror = () => {
console.error('文件读取失败:', file.name)
showErrorMessage(`文件读取失败: ${file.name}`)
}
reader.readAsText(file)
})
// 清空input允许重复选择同一文件
input.value = ''
}
// ================ 命令管理功能 ================
const commandManager = {
// 存储所有命令配置
commands: new Map(),
// 添加命令
add(label: string, command: string) {
this.commands.set(label, command)
this.refreshDisplay()
},
// 移除命令
remove(label: string) {
this.commands.delete(label)
this.refreshDisplay()
},
// 清空所有命令
clear() {
this.commands.clear()
this.refreshDisplay()
},
// 刷新显示
refreshDisplay() {
const existingElement = document.querySelector('.url-info-text')
if (existingElement) {
existingElement.remove()
}
insertUrlInfoText()
},
// 获取所有命令的显示文本
getDisplayText() {
if (this.commands.size === 0) {
return '暂无可用命令'
}
const labels = Array.from(this.commands.keys())
return labels.join(' | ')
},
// 根据标签获取命令
getCommand(label: string) {
return this.commands.get(label)
},
// 获取所有命令
getAllCommands() {
return Array.from(this.commands.entries())
}
}
// ================ URL参数提取和命令生成逻辑 ================
const extractUrlParams = () => {
const currentUrl = window.location.href
console.log('当前URL:', currentUrl)
try {
const url = new URL(currentUrl)
const host = url.host // 提取host包含端口号
const id = url.searchParams.get('id') || '未找到ID'
const ws = url.searchParams.get('ws') || '未找到WS'
// 生成CC: Tweaked命令
const ccTweakedCommand = `wget run http://${host}/Client/cc/main.lua ${ws} ${id}`
// 添加CC: Tweaked命令
commandManager.add('CC: Tweaked连接命令', ccTweakedCommand)
return true
} catch (error) {
console.error('URL解析错误:', error)
commandManager.add('错误', 'URL解析错误请检查URL格式')
return false
}
}
const copyToClipboard = (text: string) => {
// 如果文本是错误信息,直接复制错误信息
if (text.includes('URL解析错误') || text.includes('未找到')) {
navigator.clipboard.writeText(text).then(() => {
console.log('文本已复制到剪贴板:', text)
}).catch(err => {
console.error('复制失败:', err)
})
return
}
// 否则复制命令
navigator.clipboard.writeText(text).then(() => {
console.log('命令已复制到剪贴板:', text)
}).catch(err => {
console.error('复制失败:', err)
const textArea = document.createElement('textarea')
textArea.value = text
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
console.log('命令已复制到剪贴板(降级方案):', text)
} catch (fallbackError) {
console.error('复制失败(降级方案):', fallbackError)
}
document.body.removeChild(textArea)
})
}
// ================ DOM操作逻辑 ================
let observer: MutationObserver | null = null
onMounted(() => {
extractUrlParams()
nextTick(() => {
setTimeout(insertUrlInfoText, 100)
setTimeout(insertUploadButton, 100)
// 添加命令选择菜单的CSS样式
const style = document.createElement('style')
style.textContent = `
.command-selection-menu div:last-child {
border-bottom: none;
}
.command-selection-menu div:hover {
background-color: #f5f5f5;
}
`
document.head.appendChild(style)
})
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
const emptyArea = document.querySelector('.monaco-tree-editor-area-empty')
if (emptyArea && !emptyArea.querySelector('.url-info-text')) {
insertUrlInfoText()
}
const titleArea = document.querySelector('.monaco-tree-editor-list-title')
if (titleArea && !titleArea.querySelector('.upload-file-btn')) {
insertUploadButton()
}
}
}
})
const container = document.querySelector('.full-screen-wrapper')
if (container) {
observer.observe(container, {
childList: true,
subtree: true
})
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
const insertUrlInfoText = () => {
const emptyArea = document.querySelector('.monaco-tree-editor-area-empty')
if (emptyArea && !emptyArea.querySelector('.url-info-text')) {
const displayText = commandManager.getDisplayText()
const infoElement = document.createElement('div')
infoElement.className = 'url-info-text'
infoElement.textContent = displayText
// 设置悬停提示,显示所有命令的完整内容
const commands = commandManager.getAllCommands()
const tooltip = commands.map(([label, cmd]) => `${label}:\n${cmd}`).join('\n\n')
infoElement.title = tooltip || '暂无命令'
infoElement.addEventListener('click', (event) => {
// 如果有多个命令,让用户选择复制哪一个
if (commands.length > 1) {
showCommandSelection(event, commands)
} else if (commands.length === 1 && commands[0]) {
// 只有一个命令,直接复制
copyCommand(commands[0][1], infoElement)
}
})
const labels = emptyArea.querySelectorAll('label')
if (labels.length > 0) {
const lastLabel = labels[labels.length - 1]
if (lastLabel) {
emptyArea.insertBefore(infoElement, lastLabel.nextSibling)
} else {
emptyArea.appendChild(infoElement)
}
} else {
emptyArea.appendChild(infoElement)
}
}
}
// 显示命令选择菜单
const showCommandSelection = (event: MouseEvent, commands: [string, string][]) => {
// 移除已存在的菜单
const existingMenu = document.querySelector('.command-selection-menu')
if (existingMenu) {
existingMenu.remove()
}
const menu = document.createElement('div')
menu.className = 'command-selection-menu'
menu.style.cssText = `
position: fixed;
left: ${event.clientX}px;
top: ${event.clientY}px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
min-width: 200px;
`
commands.forEach(([label, command]) => {
const menuItem = document.createElement('div')
menuItem.style.cssText = `
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
`
menuItem.textContent = label
menuItem.title = command
menuItem.addEventListener('click', () => {
copyCommand(command, document.querySelector('.url-info-text') as HTMLElement)
menu.remove()
})
menuItem.addEventListener('mouseenter', () => {
menuItem.style.background = '#f5f5f5'
})
menuItem.addEventListener('mouseleave', () => {
menuItem.style.background = 'white'
})
menu.appendChild(menuItem)
})
document.body.appendChild(menu)
// 点击其他地方关闭菜单
const closeMenu = (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove()
document.removeEventListener('click', closeMenu)
}
}
setTimeout(() => {
document.addEventListener('click', closeMenu)
}, 0)
}
// 复制命令并显示反馈
const copyCommand = (command: string, element: HTMLElement) => {
copyToClipboard(command)
const originalText = element.textContent
element.textContent = '已复制,运行后请刷新文件列表'
element.style.color = '#52c41a'
setTimeout(() => {
element.textContent = originalText
element.style.color = ''
}, 1000)
}
const insertUploadButton = () => {
const titleArea = document.querySelector('.monaco-tree-editor-list-title')
if (titleArea && !titleArea.querySelector('.upload-file-btn')) {
const uploadBtn = document.createElement('button')
uploadBtn.className = 'upload-file-btn'
uploadBtn.textContent = '上传文件'
uploadBtn.title = '点击上传文件最大1MB'
uploadBtn.addEventListener('click', triggerFileUpload)
// 确保标题文本和按钮正确布局
const titleText = titleArea.querySelector('span')
if (titleText && titleArea instanceof HTMLElement) {
titleArea.style.display = 'flex'
titleArea.style.justifyContent = 'space-between'
titleArea.style.alignItems = 'center'
titleArea.style.paddingRight = '80px'
}
titleArea.appendChild(uploadBtn)
}
}
</script>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,477 @@
import type { Files } from 'monaco-tree-editor'
// WebSocket 连接管理
let ws: WebSocket | null = null
let roomId: string | null = null
let wsServer: string | null = null
let isConnected = false
let clientId: string | null = null
let reconnectAttempts = 0
let heartbeatInterval: number | null = null
const maxReconnectAttempts = 5
const reconnectDelay = 2000
const heartbeatIntervalMs = 30000 // 30秒发送一次心跳
let isDisconnecting = false
// 请求回调映射
const pendingRequests = new Map<
string,
{
resolve: (value: any) => void
reject: (error: Error) => void
timeout: number
}
>()
// 待处理初始请求队列
const pendingInitialRequests: Array<{
operation: string
data?: any
resolve: (value: any) => void
reject: (error: Error) => void
}> = []
// 生成唯一请求ID
function generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 从URL获取房间ID和WebSocket服务器地址
function getParamsFromUrl(): { roomId: string | null; wsServer: string | null } {
const urlParams = new URLSearchParams(window.location.search)
const roomId = urlParams.get('id')
const wsServer = urlParams.get('ws') || urlParams.get('server')
return { roomId, wsServer }
}
// 处理页面关闭前的清理
function handleBeforeUnload() {
if (!isDisconnecting && isConnected && ws && ws.readyState === WebSocket.OPEN) {
isDisconnecting = true
try {
// 发送离开房间消息
sendMessage({
type: 'leave_room',
room_id: roomId,
client_id: clientId,
})
} catch (error) {
console.error('发送离开消息失败:', error)
}
// 断开连接
disconnect()
}
}
// 初始化WebSocket连接
export async function initWebSocketConnection(): Promise<void> {
const params = getParamsFromUrl()
roomId = params.roomId
wsServer = params.wsServer
if (!roomId) {
throw new Error('未找到房间ID请通过有效的URL访问URL应包含?id=房间ID参数')
}
// 如果没有提供ws服务器地址使用默认值
const serverUrl = (wsServer || 'ws://localhost:8081').replace(/^http/, 'ws')
return new Promise((resolve, reject) => {
if (ws && ws.readyState === WebSocket.OPEN) {
resolve()
return
}
// 创建WebSocket连接
ws = new WebSocket(serverUrl)
// 连接事件
ws.onopen = () => {
isConnected = true
reconnectAttempts = 0
console.log('WebSocket连接已建立服务器:', serverUrl)
// 启动心跳
startHeartbeat()
// 发送加入房间消息
sendMessage({
type: 'join_room',
room_id: roomId,
client_type: 'frontend',
})
// 添加页面关闭监听
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', handleBeforeUnload)
}
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleMessage(data)
} catch (error) {
console.error('消息解析错误:', error)
}
}
ws.onclose = (event) => {
isConnected = false
console.log('WebSocket连接已断开:', event.code, event.reason)
// 停止心跳
stopHeartbeat()
// 移除事件监听
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
// 拒绝所有待处理的请求
for (const [requestId, { reject }] of pendingRequests) {
reject(new Error('WebSocket连接已断开'))
pendingRequests.delete(requestId)
}
// 自动重连(除非是主动断开)
if (!isDisconnecting && reconnectAttempts < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts++
console.log(`尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})`)
initWebSocketConnection().catch(console.error)
}, reconnectDelay)
}
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
reject(new Error('WebSocket连接失败'))
}
// 设置连接超时
setTimeout(() => {
if (!isConnected) {
reject(new Error('WebSocket连接超时'))
}
}, 10000)
})
}
// 启动心跳
function startHeartbeat() {
stopHeartbeat() // 先停止可能存在的旧心跳
heartbeatInterval = setInterval(() => {
if (isConnected && ws && ws.readyState === WebSocket.OPEN) {
sendMessage({
type: 'ping',
timestamp: new Date().toISOString(),
})
}
}, heartbeatIntervalMs)
}
// 停止心跳
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
heartbeatInterval = null
}
}
// 处理接收到的消息
function handleMessage(data: any): void {
const messageType = data.type
switch (messageType) {
case 'connected':
clientId = data.client_id
console.log('连接成功:', data.message)
console.log('客户端ID:', clientId)
break
case 'joined_room':
console.log('加入房间成功:', data.message)
console.log('客户端数量:', data.client_count)
// 连接成功后处理所有待处理的初始请求
processPendingInitialRequests()
break
case 'file_operation_response':
handleFileOperationResponse(data)
break
case 'user_joined':
console.log('新用户加入:', data.client_id, data.client_type)
break
case 'user_left':
console.log('用户离开:', data.client_id)
break
case 'pong':
// 心跳响应,不需要处理
break
case 'error':
console.error('错误:', data.message)
// 处理文件操作错误
if (data.requestId && pendingRequests.has(data.requestId)) {
const { reject, timeout } = pendingRequests.get(data.requestId)!
clearTimeout(timeout)
reject(new Error(data.message || '请求失败'))
pendingRequests.delete(data.requestId)
}
break
default:
console.log('未知消息类型:', messageType, data)
}
}
// 处理所有待处理的初始请求
function processPendingInitialRequests() {
while (pendingInitialRequests.length > 0) {
const request = pendingInitialRequests.shift()!
const { operation, data, resolve, reject } = request
try {
// 重新发送请求
sendFileOperationInternal(operation, data).then(resolve).catch(reject)
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
}
}
// 发送消息
function sendMessage(message: any): void {
if (!isConnected || !ws || ws.readyState !== WebSocket.OPEN) {
if (message.type !== 'leave_room') {
// 离开房间消息可以在关闭时发送
throw new Error('WebSocket未连接')
}
return
}
try {
ws.send(JSON.stringify(message))
} catch (error) {
if (message.type !== 'leave_room') {
// 忽略离开消息的发送错误
throw new Error(`发送消息失败: ${error}`)
}
}
}
// 处理文件操作响应
function handleFileOperationResponse(data: any): void {
const requestId = data.requestId
if (requestId && pendingRequests.has(requestId)) {
const { resolve, reject, timeout } = pendingRequests.get(requestId)!
clearTimeout(timeout)
if (data.success !== false) {
resolve(data.data || data)
} else {
reject(new Error(data.error || '请求失败'))
}
pendingRequests.delete(requestId)
}
}
// 内部发送文件操作请求(不处理连接状态)
function sendFileOperationInternal(operationType: string, data?: any, timeoutMs: number = 10000): Promise<any> {
return new Promise((resolve, reject) => {
const requestId = generateRequestId()
const timeout = setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId)
reject(new Error('请求超时'))
}
}, timeoutMs)
pendingRequests.set(requestId, { resolve, reject, timeout })
try {
sendMessage({
type: 'file_operation',
requestId: requestId,
operation_type: operationType,
data: data,
room_id: roomId,
})
} catch (error) {
pendingRequests.delete(requestId)
clearTimeout(timeout)
reject(new Error(`发送请求失败: ${error}`))
}
})
}
// 发送文件操作请求(处理连接状态)
async function sendFileOperation(operationType: string, data?: any, timeoutMs: number = 10000): Promise<any> {
if (!isConnected) {
// 如果未连接,将请求加入待处理队列
return new Promise((resolve, reject) => {
pendingInitialRequests.push({
operation: operationType,
data,
resolve,
reject,
})
// 如果还没有连接,尝试初始化连接
if (!ws) {
initWebSocketConnection().catch((error) => {
console.error('初始化连接失败:', error)
reject(error instanceof Error ? error : new Error(String(error)))
})
}
})
}
return sendFileOperationInternal(operationType, data, timeoutMs)
}
// 文件操作函数
export const fetchFiles = async (): Promise<Files> => {
try {
console.log('开始获取文件列表...')
const filesData = await sendFileOperation('fetch_files')
console.log('成功获取文件列表,文件数量:', Object.keys(filesData).length)
console.log(filesData)
return filesData
} catch (error) {
console.error('获取文件列表失败:', error)
throw new Error(`获取文件列表失败: ${error}`)
}
}
export const createOrSaveFile = async (path: string, content: string) => {
try {
await sendFileOperation('create_or_save_file', { path, content })
} catch (error) {
throw new Error(`保存文件失败: ${error}`)
}
}
export const newFile = async (path: string) => {
try {
await sendFileOperation('new_file', { path })
} catch (error) {
throw new Error(`创建新文件失败: ${error}`)
}
}
export const newFolder = async (path: string) => {
try {
await sendFileOperation('new_folder', { path })
} catch (error) {
throw new Error(`创建新文件夹失败: ${error}`)
}
}
export const rename = async (path: string, newPath: string) => {
try {
await sendFileOperation('rename', { path, newPath })
return true
} catch (error) {
throw new Error(`重命名失败: ${error}`)
}
}
export const deleteFile = async (path: string) => {
try {
await sendFileOperation('delete_file', { path })
return true
} catch (error) {
throw new Error(`删除失败: ${error}`)
}
}
// 工具函数
export const getConnectionStatus = () => ({
isConnected,
roomId,
clientId,
wsServer,
})
export const disconnect = () => {
isDisconnecting = true
// 发送离开房间消息
if (ws && isConnected && ws.readyState === WebSocket.OPEN) {
try {
sendMessage({
type: 'leave_room',
room_id: roomId,
client_id: clientId,
})
} catch (error) {
console.error('发送离开消息失败:', error)
}
}
if (ws) {
ws.close()
ws = null
}
isConnected = false
roomId = null
wsServer = null
clientId = null
isDisconnecting = false
// 停止心跳
stopHeartbeat()
// 清空待处理请求
pendingInitialRequests.length = 0
// 移除事件监听
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}
export const getShareableUrl = (includeWs: boolean = true): string => {
if (!roomId) {
throw new Error('未加入任何房间')
}
const currentUrl = new URL(window.location.href)
currentUrl.searchParams.set('id', roomId)
if (includeWs && wsServer) {
currentUrl.searchParams.set('ws', wsServer)
}
return currentUrl.toString()
}
// 设置WebSocket服务器地址
export const setWsServer = (serverUrl: string) => {
wsServer = serverUrl
}

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(),tailwindcss()],
})