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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
Frontend1/node_modules
.history
PyServer/static

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"codeium.codeium",
"esbenp.prettier-vscode",
"lokalise.i18n-ally",
"vue.volar",
"gruntfuggly.todo-tree"
]
}

41
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"editor.tabSize": 2,
"editor.indentSize": "tabSize",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.guides.bracketPairs": "active",
"html.format.maxPreserveNewLines": 120,
"javascript.format.semicolons": "remove",
"javascript.preferences.quoteStyle": "single",
"typescript.format.semicolons": "remove",
"typescript.preferences.quoteStyle": "single",
"workbench.editor.wrapTabs": true,
"prettier.printWidth": 120,
"prettier.useTabs": false,
"prettier.singleQuote": true,
"prettier.semi": false,
"path-intellisense.mappings": {
"~": "${workspaceRoot}/lib"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"i18n-ally.enabledFrameworks": [
"vue",
"vue-sfc"
],
"i18n-ally.localesPaths": "lib/locale",
"i18n-ally.enabledParsers": [
"ts"
],
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.keystyle": "nested",
}

0
1
View File

445
Client/main.lua Normal file
View File

@@ -0,0 +1,445 @@
-- simple_file_client.lua
-- 支持创建房间、WebSocket 连接、异步文件操作、心跳保活
-- 使用 Basalt 实现非阻塞异步任务
local args = {...}
local httpServer = args[1] or "http://192.168.2.200:8080"
local roomId = args[2] -- 可选的房间ID参数
local wsPort = 8081
local heartbeatInterval = 1 -- 心跳间隔(秒)
-- ========== 加载 Basalt ==========
local basaltUrl = "https://git.liulikeji.cn/GitHub/Basalt/releases/download/v1.7/basalt.lua"
local basaltResp = http.get(basaltUrl)
if not basaltResp then
error("无法下载 Basalt 框架,请检查网络或使用本地缓存")
end
local basalt = load(basaltResp.readAll())()
basaltResp.close()
local mainFrame = basalt.createFrame()
-- ========== 工具函数 ==========
local function log(msg)
--basalt.debug("[FileClient] " .. tostring(msg))
end
function table_to_json(t, indent)
indent = indent or 0
local spaces = string.rep(" ", indent)
local result = {}
if type(t) ~= "table" then
if type(t) == "string" then
-- 正确转义所有特殊字符
local escaped = t:gsub("[\\\"\b\f\n\r\t]", function(c)
local replacements = {
['\\'] = '\\\\',
['"'] = '\\"',
['\b'] = '\\b',
['\f'] = '\\f',
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t'
}
return replacements[c]
end)
return '"' .. escaped .. '"'
elseif type(t) == "number" or type(t) == "boolean" then
return tostring(t)
else
return '"' .. tostring(t) .. '"'
end
end
-- 检查是否是数组
local is_array = true
local max_index = 0
local count = 0
for k, v in pairs(t) do
count = count + 1
if type(k) ~= "number" or k <= 0 or math.floor(k) ~= k then
is_array = false
end
if type(k) == "number" and k > max_index then
max_index = k
end
end
-- 空表当作对象处理
if count == 0 then
is_array = false
end
if is_array then
-- 处理数组
table.insert(result, "[")
local items = {}
for i = 1, max_index do
if t[i] ~= nil then
table.insert(items, table_to_json(t[i], indent + 2))
else
table.insert(items, "null")
end
end
table.insert(result, table.concat(items, ", "))
table.insert(result, "]")
else
-- 处理对象
table.insert(result, "{")
local items = {}
for k, v in pairs(t) do
local key = '"' .. tostring(k) .. '"'
local value = table_to_json(v, indent + 2)
if indent > 0 then
table.insert(items, spaces .. " " .. key .. ": " .. value)
else
table.insert(items, key .. ":" .. value)
end
end
if indent > 0 then
table.insert(result, table.concat(items, ",\n"))
table.insert(result, "\n" .. spaces .. "}")
else
table.insert(result, table.concat(items, ","))
table.insert(result, "}")
end
end
return table.concat(result, indent > 0 and "\n" .. spaces or "")
end
local function cleanPath(path)
if string.sub(path, 1, 9) == "computer/" then
return string.sub(path, 10)
elseif string.sub(path, 1, 9) == "computer\\" then
return string.sub(path, 10)
end
return path
end
local function sendJson(ws, obj)
local payload = table_to_json(obj)
ws.send(payload)
end
-- ========== 文件系统操作(纯逻辑,无网络)==========
local function isLikelyText(data, maxCheck)
if not data then return false end
maxCheck = maxCheck or math.min(#data, 1024)
for i = 1, maxCheck do
local b = data:byte(i)
if b < 32 and not (b == 9 or b == 10 or b == 13) then
return false
end
end
return true
end
local function getFiles(currentPath, result, prefix)
local fullPrefix
if currentPath == "" then
fullPrefix = prefix:sub(1, -2) -- 移除末尾的斜杠,将 "computer/" 变为 "computer"
else
fullPrefix = prefix .. currentPath
end
local absPath = "/" .. (currentPath == "" and "" or currentPath)
if fs.isDir(absPath) then
result[fullPrefix] = { isFolder = true }
for _, entry in ipairs(fs.list(absPath)) do
if entry ~= "rom" then
local nextPath = currentPath == "" and entry or (currentPath .. "/" .. entry)
getFiles(nextPath, result, prefix)
end
end
else
local content = "[binary]"
local ok, handle = pcall(fs.open, absPath, "rb")
if ok and handle then
local data = handle.readAll()
handle.close()
log(absPath.." File size: " .. #data)
if data and isLikelyText(data) then
content = data
log(absPath .. " is text file")
end
if content == "[binary]" then print("binary file: " .. absPath) end
end
result[fullPrefix] = { isFile = true, content = content }
end
end
local function fetchFiles()
local files = {}
getFiles("", files, "computer/")
return files
end
local function saveFile(path, content)
path = cleanPath(path)
local dir = fs.getDir(path)
if not fs.exists(dir) then fs.makeDir(dir) end
local f = fs.open(path, "w")
f.write(content or "")
f.close()
end
local function createFile(path)
path = cleanPath(path)
local dir = fs.getDir(path)
if not fs.exists(dir) then fs.makeDir(dir) end
if not fs.exists(path) then
local f = fs.open(path, "w")
f.close()
end
end
local function createFolder(path)
path = cleanPath(path)
fs.makeDir(path)
end
local function renameFile(oldPath, newPath)
oldPath = cleanPath(oldPath)
newPath = cleanPath(newPath)
fs.move(oldPath, newPath)
end
local function deleteFile(path)
path = cleanPath(path)
if fs.exists(path) then
fs.delete(path)
end
end
-- ========== 异步任务处理器 ==========
local function handleFetchFiles(ws, reqId, sender)
local success, result = pcall(fetchFiles)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
data = success and result or nil,
error = success and nil or tostring(result),
target_client_id = sender
})
log("Async fetch_files completed: " .. (success and "OK" or "FAILED"))
end
local function handleSaveFile(ws, data, reqId, sender)
local success, err = pcall(function()
assert(data.path, "Missing path")
saveFile(data.path, data.content)
end)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
error = success and nil or tostring(err),
target_client_id = sender
})
log("Async save_file completed: " .. (success and "OK" or "FAILED"))
end
local function handleCreateFile(ws, data, reqId, sender)
local success, err = pcall(function()
assert(data.path, "Missing path")
createFile(data.path)
end)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
error = success and nil or tostring(err),
target_client_id = sender
})
log("Async create_file completed: " .. (success and "OK" or "FAILED"))
end
local function handleCreateFolder(ws, data, reqId, sender)
local success, err = pcall(function()
assert(data.path, "Missing path")
createFolder(data.path)
end)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
error = success and nil or tostring(err),
target_client_id = sender
})
log("Async create_folder completed: " .. (success and "OK" or "FAILED"))
end
local function handleRename(ws, data, reqId, sender)
local success, err = pcall(function()
assert(data.path and data.newPath, "Missing path or newPath")
renameFile(data.path, data.newPath)
end)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
error = success and nil or tostring(err),
target_client_id = sender
})
log("Async rename completed: " .. (success and "OK" or "FAILED"))
end
local function handleDelete(ws, data, reqId, sender)
log("delete:"..data.path)
local success, err = pcall(function()
assert(data.path, "Missing path")
deleteFile(data.path)
end)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = success,
error = success and nil or tostring(err),
target_client_id = sender
})
log("Async delete completed: " .. (success and "OK" or "FAILED"))
end
-- ========== 房间管理函数 ==========
local function createRoom()
log("正在创建房间...")
local roomUrl = httpServer .. "/api/room"
local roomResp = http.post(roomUrl, "{}")
if not roomResp then
error("无法连接到 HTTP 服务器创建房间")
end
local body = roomResp.readAll()
roomResp.close()
local roomData = textutils.unserialiseJSON(body)
if not roomData or not roomData.room_id or not roomData.ws_url then
error("无效的房间响应: " .. tostring(body))
end
log("房间创建成功,房间 ID: " .. roomData.room_id)
return roomData
end
local function joinRoom(roomId)
log("正在加入房间: " .. roomId)
-- 构建 WebSocket URL
local wsUrl = httpServer:gsub("^http", "ws") .. "/ws?room_id=" .. roomId
log("连接 WebSocket: " .. wsUrl)
local ws = http.websocket(wsUrl)
if not ws then
error("无法打开 WebSocket 连接")
end
-- 加入房间
sendJson(ws, {
type = "join_room",
room_id = roomId,
client_type = "file_client"
})
return ws, roomId
end
-- ========== 主函数 ==========
local function main()
local ws, finalRoomId
if roomId then
-- 如果提供了房间ID直接加入
ws, finalRoomId = joinRoom(roomId)
else
-- 否则创建新房间
local roomData = createRoom()
ws, finalRoomId = joinRoom(roomData.room_id)
end
-- 启动心跳
local heartbeatTimer = os.startTimer(heartbeatInterval)
mainFrame:addProgram():execute(function ()
shell.run("shell")
end):setSize("parent.w","parent.h")
-- 消息循环
parallel.waitForAll(
function ()
sleep(0.5)
os.queueEvent("mouse_click",1,1,1)
while true do
local payload = ws.receive()
if payload then
local ok, msg = pcall(textutils.unserialiseJSON, payload)
if ok and type(msg) == "table" then
local msgType = msg.type
log("收到消息: " .. tostring(msgType))
if msgType == "connected" then
log("分配的客户端 ID: " .. tostring(msg.client_id))
elseif msgType == "file_operation" or msgType == "file_operation_request" then
op = msg.operation_type or msg.type
data = msg.data or {}
reqId = msg.requestId or msg.request_id
sender = msg.sender_id
-- 派发到后台线程
if op == "fetch_files" then
mainFrame:addThread():start(function () handleFetchFiles(ws, reqId, sender) end )
elseif op == "create_or_save_file" then
mainFrame:addThread():start(function () handleSaveFile(ws, data, reqId, sender) end)
elseif op == "new_file" then
mainFrame:addThread():start(function () handleCreateFile(ws, data, reqId, sender) end)
elseif op == "new_folder" then
mainFrame:addThread():start(function () handleCreateFolder(ws, data, reqId, sender) end)
elseif op == "rename" then
mainFrame:addThread():start(function () handleRename(ws, data, reqId, sender) end)
elseif op == "delete_file" then
mainFrame:addThread():start(function () handleDelete(ws, data, reqId, sender) end)
else
-- 同步返回未知操作错误(轻量)
sendJson(ws, {
type = "file_operation_response",
requestId = reqId,
success = false,
error = "Unknown operation: " .. tostring(op),
target_client_id = sender
})
end
end
else
log("无效 JSON: " .. tostring(payload))
end
end
end
end,
function ()
while true do
local event, param1, param2, param3 = os.pullEvent()
if event == "timer" and param1 == heartbeatTimer then
sendJson(ws, { type = "ping" })
heartbeatTimer = os.startTimer(heartbeatInterval)
elseif event == "websocket_closed" and param1 == ws then
log("WebSocket 连接已关闭")
break
elseif event == "key" and param1 == keys.q then
log("用户按 Q 退出")
break
end
end
end)
ws.close()
log("客户端已停止。房间ID: " .. finalRoomId)
end
-- 启动主逻辑和Basalt事件循环
parallel.waitForAll(basalt.autoUpdate, main)

13
Client/main.py Normal file
View File

@@ -0,0 +1,13 @@
import tomllib
# 您的 TOML 字符串
toml_str = '''{ a = "你好", "a-c-v" = "你好", b = "世界", }'''
# 反序列化
data = tomllib.loads(toml_str)
print(data)
# 输出: {'a': '你好', 'a-c-v': '你好', 'b': '世界'}
# 访问数据
print(data['a']) # 输出: 你好
print(data['a-c-v']) # 输出: 你好

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()],
})

620
PyServer/main.py Normal file
View File

@@ -0,0 +1,620 @@
import asyncio
import json
import logging
import uuid
import time
import os
from datetime import datetime, timedelta
from typing import Dict, Any, Set
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import websockets
from urllib.parse import parse_qs, urlparse
import mimetypes
# 设置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 存储房间信息
rooms = {}
connected_clients = {}
ws_port = 81 # ws服务外部端口
# 静态文件目录
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
logger.info(f"创建静态文件目录: {STATIC_DIR}")
class Room:
def __init__(self, room_id: str, server_host: str):
self.room_id = room_id
self.created_at = datetime.now()
self.last_activity = datetime.now()
self.clients: Set[str] = set()
# 使用80端口
self.frontend_url = f"http://{server_host}/?id={room_id}&ws=ws://{server_host}"
self.ws_url = f"ws://{server_host}:{ws_port}"
def add_client(self, client_id: str):
self.clients.add(client_id)
self.last_activity = datetime.now()
logger.info(f"客户端 {client_id} 加入房间 {self.room_id}, 当前客户端数: {len(self.clients)}")
def remove_client(self, client_id: str):
if client_id in self.clients:
self.clients.remove(client_id)
self.last_activity = datetime.now()
logger.info(f"客户端 {client_id} 离开房间 {self.room_id}, 剩余客户端数: {len(self.clients)}")
def is_empty(self) -> bool:
return len(self.clients) == 0
def to_dict(self) -> Dict[str, Any]:
return {
'room_id': self.room_id,
'frontend_url': self.frontend_url,
'ws_url': self.ws_url,
'client_count': len(self.clients),
'created_at': self.created_at.isoformat(),
'last_activity': self.last_activity.isoformat()
}
def cleanup_empty_rooms():
"""定期清理空房间"""
while True:
time.sleep(300) # 每5分钟检查一次
current_time = datetime.now()
empty_rooms = []
for room_id, room in list(rooms.items()):
if room.is_empty() and current_time - room.last_activity > timedelta(minutes=10):
empty_rooms.append(room_id)
for room_id in empty_rooms:
if room_id in rooms:
del rooms[room_id]
logger.info(f"清理空房间: {room_id}")
# 启动清理线程
cleanup_thread = threading.Thread(target=cleanup_empty_rooms, daemon=True)
cleanup_thread.start()
class HTTPHandler(BaseHTTPRequestHandler):
def do_GET(self):
"""处理HTTP GET请求"""
try:
logger.info(f"收到GET请求: {self.path} from {self.client_address[0]}")
logger.info(f"请求头: {dict(self.headers)}")
parsed_path = urlparse(self.path)
path = parsed_path.path
query_params = parse_qs(parsed_path.query)
# API路由
if path == '/api/room' and self.command == 'POST':
self.handle_create_room()
elif path.startswith('/api/room/') and self.command == 'GET':
room_id = path.split('/')[-1]
self.handle_get_room(room_id)
elif path == '/api/rooms' and self.command == 'GET':
self.handle_list_rooms()
# 根路径处理
elif path == '/':
self.handle_root_path(query_params)
# 静态文件服务
else:
self.handle_static_file(path)
except Exception as e:
logger.error(f"处理GET请求时发生错误: {e}")
self.send_error(500, f"Internal Server Error: {str(e)}")
def do_POST(self):
"""处理HTTP POST请求"""
try:
logger.info(f"收到POST请求: {self.path} from {self.client_address[0]}")
parsed_path = urlparse(self.path)
path = parsed_path.path
if path == '/api/room':
self.handle_create_room()
else:
self.send_error(404, "Not Found")
except Exception as e:
logger.error(f"处理POST请求时发生错误: {e}")
self.send_error(500, f"Internal Server Error: {str(e)}")
def handle_root_path(self, query_params: Dict[str, Any]):
"""处理根路径请求"""
room_id = query_params.get('id', [None])[0]
ws_url = query_params.get('ws', [None])[0]
if room_id:
# 有房间ID参数直接返回前端页面
logger.info(f"请求根路径有房间ID: {room_id}")
self.serve_static_file('/index.html')
else:
# 没有房间ID创建新房间并重定向
try:
# 生成唯一房间ID
room_id = str(uuid.uuid4())[:8]
# 获取服务器主机地址
host = self.headers.get('Host', 'localhost')
# 创建房间
room = Room(room_id, host)
rooms[room_id] = room
logger.info(f"通过根路径创建新房间: {room_id}")
# 重定向到带房间ID和WebSocket URL的URL
redirect_url = f'/?id={room_id}&ws=ws://{host}:{ws_port}'
self.send_response(302)
self.send_header('Location', redirect_url)
self.end_headers()
except Exception as e:
logger.error(f"创建房间失败: {e}")
self.send_error(500, str(e))
def handle_static_file(self, path: str):
"""处理静态文件请求"""
logger.info(f"处理静态文件请求: {path}")
# 安全检查:防止路径遍历攻击
if '..' in path:
logger.warning(f"检测到可疑路径: {path}")
self.send_error(403, "Forbidden: Path traversal not allowed")
return
# 规范化路径
if path == '/':
path = '/index.html'
# 移除开头的斜杠
file_path = path.lstrip('/')
if not file_path:
file_path = 'index.html'
# 构建完整文件路径
full_path = os.path.join(STATIC_DIR, file_path)
logger.info(f"尝试访问文件: {full_path}")
# 如果是目录尝试查找index.html
if os.path.isdir(full_path):
index_path = os.path.join(full_path, 'index.html')
if os.path.exists(index_path):
full_path = index_path
logger.info(f"重定向到目录索引文件: {index_path}")
else:
logger.warning(f"目录不存在索引文件: {full_path}")
self.send_error(404, "Directory index not found")
return
# 检查文件是否存在且是普通文件
if not os.path.exists(full_path):
logger.warning(f"文件不存在: {full_path}")
self.send_error(404, f"File not found: {path}")
return
if not os.path.isfile(full_path):
logger.warning(f"路径不是文件: {full_path}")
self.send_error(403, "Not a file")
return
try:
# 读取文件内容
with open(full_path, 'rb') as f:
content = f.read()
# 获取MIME类型
mime_type, encoding = mimetypes.guess_type(full_path)
if mime_type is None:
mime_type = 'application/octet-stream'
logger.info(f"成功读取文件: {full_path}, 大小: {len(content)} bytes, MIME类型: {mime_type}")
# 发送响应头
self.send_response(200)
self.send_header('Content-Type', mime_type)
self.send_header('Content-Length', str(len(content)))
# 添加缓存控制头(可选)
self.send_header('Cache-Control', 'public, max-age=3600') # 缓存1小时
self.end_headers()
# 发送文件内容
self.wfile.write(content)
logger.info(f"文件发送完成: {full_path}")
except Exception as e:
logger.error(f"读取或发送文件失败: {e}")
self.send_error(500, f"Error reading file: {str(e)}")
def serve_static_file(self, path: str):
"""服务静态文件(内部方法)"""
self.handle_static_file(path)
def handle_create_room(self):
"""创建新房间"""
try:
# 生成唯一房间ID
room_id = str(uuid.uuid4())[:8]
# 获取服务器主机地址
host = self.headers.get('Host', 'localhost')
# 创建房间
room = Room(room_id, host)
rooms[room_id] = room
logger.info(f"创建新房间: {room_id}")
response = {
'success': True,
'room_id': room_id,
'frontend_url': room.frontend_url,
'ws_url': room.ws_url
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"创建房间失败: {e}")
self.send_error(500, str(e))
def handle_get_room(self, room_id: str):
"""获取房间信息"""
try:
if room_id not in rooms:
self.send_error(404, '房间不存在')
return
room = rooms[room_id]
response = {'success': True, 'data': room.to_dict()}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"获取房间信息失败: {e}")
self.send_error(500, str(e))
def handle_list_rooms(self):
"""列出所有活跃房间"""
try:
active_rooms = [room.to_dict() for room in rooms.values() if not room.is_empty()]
response = {
'success': True,
'data': {
'total_rooms': len(active_rooms),
'rooms': active_rooms
}
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"获取房间列表失败: {e}")
self.send_error(500, str(e))
def log_message(self, format, *args):
"""重写日志方法"""
logger.info("%s - - [%s] %s" % (self.client_address[0],
self.log_date_time_string(),
format % args))
class WebSocketHTTPRequestHandler(HTTPHandler):
"""支持WebSocket升级的HTTP请求处理器"""
def do_GET(self):
"""处理GET请求支持WebSocket升级"""
if self.headers.get('Upgrade') == 'websocket':
self.handle_websocket_upgrade()
else:
super().do_GET()
def handle_websocket_upgrade(self):
"""处理WebSocket升级请求"""
# 这里我们只是记录日志实际的WebSocket处理在websockets库中完成
logger.info(f"WebSocket连接请求: {self.path}")
self.send_error(426, "WebSocket upgrade required") # 这个错误不会被触发因为websockets库会拦截请求
async def handle_websocket(websocket, path):
"""处理WebSocket连接"""
client_id = str(uuid.uuid4())
room_id = None
client_type = 'unknown'
try:
# 发送连接成功消息
await send_message(websocket, {
'type': 'connected',
'message': '连接成功',
'client_id': client_id,
'timestamp': datetime.now().isoformat()
})
logger.info(f"客户端连接: {client_id}")
# 处理消息
async for message in websocket:
try:
data = json.loads(message)
# 处理消息并获取房间和客户端类型信息
result = await handle_websocket_message(websocket, client_id, data)
if result:
room_id, client_type = result
except json.JSONDecodeError:
logger.error(f"消息格式错误: {message}")
except Exception as e:
logger.error(f"处理消息错误: {e}")
except websockets.exceptions.ConnectionClosed:
logger.info(f"客户端断开连接: {client_id}")
finally:
# 清理连接
if client_id in connected_clients:
info = connected_clients.pop(client_id)
room_id = info.get('room_id')
logger.info(f"清理客户端: {client_id}, 房间: {room_id}")
if room_id and room_id in rooms:
room = rooms[room_id]
room.remove_client(client_id)
# 通知房间内其他客户端
await broadcast_to_room(room_id, {
'type': 'user_left',
'client_id': client_id,
'message': '用户离开房间',
'room_size': len(room.clients),
'timestamp': datetime.now().isoformat()
}, exclude_client=client_id)
# 额外检查:即使不在 connected_clients 中,如果还在房间里也要移除
elif room_id and room_id in rooms:
room = rooms[room_id]
room.remove_client(client_id)
# 通知房间内其他客户端
await broadcast_to_room(room_id, {
'type': 'user_left',
'client_id': client_id,
'message': '用户离开房间',
'room_size': len(room.clients),
'timestamp': datetime.now().isoformat()
}, exclude_client=client_id)
async def handle_websocket_message(websocket, client_id: str, data: Dict[str, Any]):
"""处理WebSocket消息"""
message_type = data.get('type')
if message_type == 'join_room':
return await handle_join_room(websocket, client_id, data)
elif message_type == 'file_operation':
await handle_file_operation(websocket, client_id, data)
elif message_type == 'file_operation_response':
await handle_file_operation_response(websocket, client_id, data)
elif message_type == 'leave_room':
await handle_leave_room(websocket, client_id, data)
elif message_type == 'ping':
await send_message(websocket, {
'type': 'pong',
'message': 'pong',
'timestamp': datetime.now().isoformat()
})
else:
logger.warning(f"未知消息类型: {message_type}")
async def handle_join_room(websocket, client_id: str, data: Dict[str, Any]):
"""处理加入房间请求"""
room_id = data.get('room_id')
client_type = data.get('client_type', 'unknown')
if not room_id:
await send_message(websocket, {
'type': 'error',
'message': '房间ID不能为空'
})
return
if room_id not in rooms:
await send_message(websocket, {
'type': 'error',
'message': '房间不存在'
})
return
room = rooms[room_id]
room.add_client(client_id)
# 存储客户端信息
connected_clients[client_id] = {
'websocket': websocket,
'room_id': room_id,
'client_type': client_type
}
logger.info(f"客户端 {client_id} ({client_type}) 加入房间 {room_id}")
# 发送加入成功消息
await send_message(websocket, {
'type': 'joined_room',
'room_id': room_id,
'message': f'成功加入房间 {room_id}',
'client_count': len(room.clients),
'timestamp': datetime.now().isoformat()
})
# 通知房间内其他客户端
await broadcast_to_room(room_id, {
'type': 'user_joined',
'client_id': client_id,
'client_type': client_type,
'message': '新用户加入房间',
'room_size': len(room.clients),
'timestamp': datetime.now().isoformat()
}, exclude_client=client_id)
# 返回房间ID和客户端类型用于finally块清理
return room_id, client_type
async def handle_leave_room(websocket, client_id: str, data: Dict[str, Any]):
"""处理离开房间请求"""
room_id = data.get('room_id')
if not room_id:
room_id = connected_clients.get(client_id, {}).get('room_id')
if room_id and room_id in rooms:
room = rooms[room_id]
room.remove_client(client_id)
# 从连接客户端中移除
if client_id in connected_clients:
del connected_clients[client_id]
# 通知房间内其他客户端
await broadcast_to_room(room_id, {
'type': 'user_left',
'client_id': client_id,
'message': '用户离开房间',
'room_size': len(room.clients),
'timestamp': datetime.now().isoformat()
}, exclude_client=client_id)
logger.info(f"客户端 {client_id} 主动离开房间 {room_id}")
async def handle_file_operation(websocket, client_id: str, data: Dict[str, Any]):
"""处理文件操作请求"""
room_id = data.get('room_id')
request_id = data.get('requestId')
operation_type = data.get('operation_type')
if not room_id or room_id not in rooms:
await send_message(websocket, {
'type': 'error',
'message': '无效的房间ID',
'requestId': request_id
})
return
# 记录操作
logger.info(f"收到文件操作请求: {operation_type} from {client_id} in room {room_id}")
# 查找文件客户端
file_clients = []
for cid, client_info in connected_clients.items():
if client_info['room_id'] == room_id and cid != client_id:
if client_info.get('client_type') == 'file_client':
file_clients.append(client_info['websocket'])
if file_clients:
# 转发给文件客户端
data['sender_id'] = client_id
await send_message(file_clients[0], {
'type': 'file_operation_request',
**data
})
else:
# 没有文件客户端,返回错误
await send_message(websocket, {
'type': 'file_operation_response',
'requestId': request_id,
'success': False,
'error': '房间内没有文件客户端',
'timestamp': datetime.now().isoformat()
})
async def handle_file_operation_response(websocket, client_id: str, data: Dict[str, Any]):
"""处理文件操作响应"""
request_id = data.get('requestId')
target_client_id = data.get('target_client_id')
logger.info(f"收到文件操作响应: {request_id} -> {target_client_id}")
if target_client_id and target_client_id in connected_clients:
# 发送给指定客户端
target_websocket = connected_clients[target_client_id]['websocket']
await send_message(target_websocket, {
'type': 'file_operation_response',
**data
})
else:
logger.error("文件操作响应没有指定目标客户端或目标客户端不存在")
async def send_message(websocket, message: Dict[str, Any]):
"""发送消息到WebSocket"""
try:
await websocket.send(json.dumps(message))
except websockets.exceptions.ConnectionClosed:
logger.warning("连接已关闭,无法发送消息")
async def broadcast_to_room(room_id: str, message: Dict[str, Any], exclude_client: str = None):
"""向房间内所有客户端广播消息"""
if room_id not in rooms:
return
room = rooms[room_id]
for client_id in room.clients:
if client_id != exclude_client and client_id in connected_clients:
try:
await send_message(connected_clients[client_id]['websocket'], message)
except Exception as e:
logger.error(f"广播消息失败: {e}")
def run_http_server():
"""运行HTTP服务器"""
try:
server = HTTPServer(('0.0.0.0', 80), WebSocketHTTPRequestHandler)
logger.info("HTTP服务器启动在端口 80")
server.serve_forever()
except Exception as e:
logger.error(f"HTTP服务器启动失败: {e}")
async def run_websocket_server():
"""运行WebSocket服务器"""
try:
server = await websockets.serve(handle_websocket, '0.0.0.0', 81)
logger.info("WebSocket服务器启动在端口 81")
await server.wait_closed()
except Exception as e:
logger.error(f"WebSocket服务器启动失败: {e}")
async def main():
"""主函数"""
# 启动HTTP服务器在单独线程中
http_thread = threading.Thread(target=run_http_server, daemon=True)
http_thread.start()
# 启动WebSocket服务器
await run_websocket_server()
if __name__ == '__main__':
logger.info("启动服务器...")
# 检查端口权限
try:
asyncio.run(main())
except PermissionError:
logger.error("需要管理员权限才能绑定到80端口")
logger.info("尝试使用8080端口...")
# 可以在这里添加回退到其他端口的逻辑