初始
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Frontend1/node_modules
|
||||||
|
.history
|
||||||
|
PyServer/static
|
||||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal 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
41
.vscode/settings.json
vendored
Normal 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",
|
||||||
|
}
|
||||||
445
Client/main.lua
Normal file
445
Client/main.lua
Normal 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
13
Client/main.py
Normal 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
2063
Frontend1/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
Frontend1/package.json
Normal file
10
Frontend1/package.json
Normal 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
24
Frontend1/vite-project/.gitignore
vendored
Normal 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?
|
||||||
5
Frontend1/vite-project/README.md
Normal file
5
Frontend1/vite-project/README.md
Normal 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).
|
||||||
13
Frontend1/vite-project/index.html
Normal file
13
Frontend1/vite-project/index.html
Normal 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
2169
Frontend1/vite-project/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Frontend1/vite-project/package.json
Normal file
26
Frontend1/vite-project/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Frontend1/vite-project/public/vite.svg
Normal file
1
Frontend1/vite-project/public/vite.svg
Normal 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 |
639
Frontend1/vite-project/src/App.vue
Normal file
639
Frontend1/vite-project/src/App.vue
Normal 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>
|
||||||
1
Frontend1/vite-project/src/assets/vue.svg
Normal file
1
Frontend1/vite-project/src/assets/vue.svg
Normal 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 |
41
Frontend1/vite-project/src/components/HelloWorld.vue
Normal file
41
Frontend1/vite-project/src/components/HelloWorld.vue
Normal 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>
|
||||||
5
Frontend1/vite-project/src/main.ts
Normal file
5
Frontend1/vite-project/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
477
Frontend1/vite-project/src/mock-server.ts
Normal file
477
Frontend1/vite-project/src/mock-server.ts
Normal 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
|
||||||
|
}
|
||||||
2
Frontend1/vite-project/src/style.css
Normal file
2
Frontend1/vite-project/src/style.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
16
Frontend1/vite-project/tsconfig.app.json
Normal file
16
Frontend1/vite-project/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
Frontend1/vite-project/tsconfig.json
Normal file
7
Frontend1/vite-project/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
Frontend1/vite-project/tsconfig.node.json
Normal file
26
Frontend1/vite-project/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
9
Frontend1/vite-project/vite.config.ts
Normal file
9
Frontend1/vite-project/vite.config.ts
Normal 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
620
PyServer/main.py
Normal 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端口...")
|
||||||
|
# 可以在这里添加回退到其他端口的逻辑
|
||||||
Reference in New Issue
Block a user