diff --git a/deployment/nginx.conf b/deployment/nginx.conf index f19f5dd..b417480 100644 --- a/deployment/nginx.conf +++ b/deployment/nginx.conf @@ -15,6 +15,7 @@ server { location /ushare { proxy_pass http://localhost:9119; + const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]}) proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 5b29f78..7f9b084 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ import {useState} from "react"; -import {message} from "../component/message/u-message.tsx"; +import {message} from "../hook/message/u-message.tsx"; export interface User { id: number; diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts index 705d167..3a4a120 100644 --- a/frontend/src/api/upload.ts +++ b/frontend/src/api/upload.ts @@ -16,7 +16,6 @@ export const useFileUpload = () => { setProgress(0); try { - console.log(`[D] api.Upload: upload file = ${file.name}, size = ${file.size}`, file); const url = `/api/ushare/${file.name}`; // 1. 初始化上传 @@ -26,7 +25,7 @@ export const useFileUpload = () => { }); if (!res1.ok) { - console.log(`[D] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`) + console.log(`[W] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`) if (res1.status === 401) { window.location.href = "/login?next=/share" return "" diff --git a/frontend/src/component/dialog/dialog.tsx b/frontend/src/component/dialog/dialog.tsx new file mode 100644 index 0000000..a0312a1 --- /dev/null +++ b/frontend/src/component/dialog/dialog.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef, ReactNode } from 'react'; +import {createUseStyles} from "react-jss"; + +const useClass = createUseStyles({ + dialog: { + border: "none", + borderRadius: "8px", + padding: "2rem 3rem", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + maxWidth: "90%", + width: "500px", + opacity: 0, + transform: "translateY(-20px)", + transition: "opacity 0.3s ease-out, transform 0.3s ease-out", + "&[open]": { opacity: 1, transform: "translateY(0)" }, + "&::backdrop": { + background: "rgba(0, 0, 0, 0.5)", + backdropFilter: "blur(2px)" + }, + background: "rgba(212,212,212,0.85)", + backdropFilter: "blur(8px)", + }, + dialog_content: { + padding: "1.5rem", + display: "flex", + flexDirection: "column" + }, + dialog_header: { + fontSize: "1.5rem", + fontWeight: 600, + marginBottom: "1rem", + paddingBottom: "0.5rem", + borderBottom: "1px solid #eee" + }, + dialog_body: { marginBottom: "1.5rem" }, + dialog_footer: { display: "flex", justifyContent: "flex-end" }, + close_button: { + padding: "8px 16px", + background: "#007aff", + color: "white", + border: "none", + borderRadius: "4px", + cursor: "pointer", + fontSize: "1rem", + "&:hover": { background: "#0062cc" } + }, +}) + +export interface DialogProps { + /** 对话框是否打开 */ + open: boolean; + /** 对话框标题 */ + title?: string; + /** 对话框内容 */ + children: ReactNode; + /** 关闭对话框时的回调 */ + onClose: () => void; + /** 自定义样式类名 */ + className?: string; + /** 是否显示底部footer(关闭按钮) */ + footer?: boolean; +} + +/** + * 使用 HTML 原生 dialog 元素的模态对话框组件 + */ +export const Dialog: React.FC = ({ + open, + title, + children, + onClose, + className = '', + footer = true, + }) => { + const classes = useClass(); + const dialogRef = useRef(null); + + // ESC 键关闭 + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + // 遮罩点击关闭 + const handleMaskClick = (e: React.MouseEvent) => { + if (e.target === dialogRef.current) { + onClose(); + } + }; + + if (!open) return null; + + return ( +
+
+ {title &&
{title}
} +
+ {children} +
+ {footer !== false && ( +
+ +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/component/drawer/drawer.tsx b/frontend/src/component/drawer/drawer.tsx new file mode 100644 index 0000000..a33093c --- /dev/null +++ b/frontend/src/component/drawer/drawer.tsx @@ -0,0 +1,84 @@ +import React, {useEffect} from 'react'; +import {createUseStyles} from "react-jss"; // 使用 CSS Modules + +const useClass = createUseStyles({ + backdrop: { + position: "fixed", + top: "0", + left: "0", + width: "100%", + height: "100%", + maxHeight: '100%', + backgroundColor: "rgba(0, 0, 0, 0.5)", + display: "flex", + justifyContent: "center", + alignItems: "flex-end", + opacity: 0, + transition: "opacity 0.3s ease-in-out", + pointerEvents: "none", + zIndex: 1000, + overflow: 'hidden', + }, + visible: {opacity: 1, pointerEvents: "auto"}, + drawer_content: { + background: "white", + borderRadius: "8px 8px 0 0", + transition: "transform 0.3s ease-in-out", + overflow: "auto", + boxShadow: "0 -2px 8px rgba(0, 0, 0, 0.1)" + } +}) + +export interface DrawerProps { + isOpen: boolean; + close: () => void; + onClose?: () => void; + height?: string; + width?: string; + children?: React.ReactNode; +} + +export const Drawer: React.FC = ({ + isOpen, + close, + onClose, + height = '300px', + width = '100%', + children + }) => { + const classes = useClass(); + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close() + } + }; + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'auto'; + } + }, [isOpen, onClose]); + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +}; diff --git a/frontend/src/component/message/u-message.tsx b/frontend/src/hook/message/u-message.tsx similarity index 100% rename from frontend/src/component/message/u-message.tsx rename to frontend/src/hook/message/u-message.tsx diff --git a/frontend/src/hook/websocket/u-ws.tsx b/frontend/src/hook/websocket/u-ws.tsx new file mode 100644 index 0000000..b8b0974 --- /dev/null +++ b/frontend/src/hook/websocket/u-ws.tsx @@ -0,0 +1,77 @@ +import { useCallback, useRef } from 'react'; + +export interface Prop { + /** 事件处理函数(可选) */ + fn?: (event: MessageEvent) => Promise; + /** 最大重试次数(可选) */ + retry?: number; +} + +export const useWebsocket = (prop?: Prop) => { + const wsRef = useRef(null); + const retryCountRef = useRef(0); + const reconnectTimerRef = useRef(0); + const currentPropRef = useRef(prop); + + // 更新最新 prop + currentPropRef.current = prop; + + const connect = useCallback((url: string, connectProp?: Prop) => { + // 合并 prop 优先级:connectProp > hook prop + const mergedProp = { ...currentPropRef.current, ...connectProp }; + + // 清理现有连接 + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + clearTimeout(reconnectTimerRef.current); + + const createConnection = () => { + const ws = new WebSocket(url); + + ws.onopen = () => { + retryCountRef.current = 0; + }; + + ws.onmessage = (event) => { + mergedProp?.fn?.(event).catch(error => { + console.error('WebSocket message handler error:', error); + }); + }; + + ws.onclose = (event) => { + const maxRetries = mergedProp?.retry ?? 0; + + if (!event.wasClean && retryCountRef.current < maxRetries) { + retryCountRef.current += 1; + const retryDelay = Math.pow(2, retryCountRef.current) * 1000; + + reconnectTimerRef.current = setTimeout(() => { + createConnection(); + }, retryDelay); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + ws.close(); + }; + + wsRef.current = ws; + }; + + createConnection(); + }, []); + + const close = useCallback(() => { + wsRef.current?.close(); + clearTimeout(reconnectTimerRef.current); + retryCountRef.current = currentPropRef.current?.retry || 0; + }, []); + + return { + connect, + close + }; +}; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 43eb85c..fdad411 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,14 +3,16 @@ import { createRoot } from 'react-dom/client' import './index.css' import {createBrowserRouter, RouterProvider} from "react-router-dom"; import {Login} from "./page/login.tsx"; -import {FileSharing} from "./page/share.tsx"; -import {LocalSharing} from "./page/local.tsx"; +import {FileSharing} from "./page/share/share.tsx"; +import {LocalSharing} from "./page/local/local.tsx"; +import {TestPage} from "./page/test/test.tsx"; const container = document.getElementById('root') const root = createRoot(container!) const router = createBrowserRouter([ {path: "/login", element: }, {path: "/share", element: }, + {path: "/test", element: }, {path: "*", element: }, ]) diff --git a/frontend/src/page/local.tsx b/frontend/src/page/local.tsx deleted file mode 100644 index bf520bf..0000000 --- a/frontend/src/page/local.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import {CloudBackground} from "../component/fluid/cloud.tsx"; -import {useEffect} from "react"; -import {createUseStyles} from "react-jss"; -import {Client, useRoom} from "../store/local.ts"; - -const useClass = createUseStyles({ - '@global': { - '@keyframes emerge': { - '0%': { - transform: 'scale(0) translate(-50%, -50%)', - opacity: 0 - }, - '80%': { - transform: 'scale(1.1) translate(-50%, -50%)', - opacity: 1 - }, - '100%': { - transform: 'scale(1) translate(-50%, -50%)', - opacity: 1 - } - } - }, - container: { - margin: "0", - height: "100vh", - // background: "linear-gradient(45deg, #e6e9f0, #eef1f5)", - overflow: "hidden", - position: "relative", - }, - title: { - width: '100%', - display: "flex", - justifyContent: "center", - color: '#1661ab', - }, - bubble: { - position: "absolute", - width: "100px", - height: "100px", - borderRadius: "50%", - display: "flex", - justifyContent: "center", - alignItems: "center", - textAlign: "center", - cursor: "pointer", - fontFamily: "'Microsoft Yahei', sans-serif", - fontSize: "14px", - color: "rgba(255, 255, 255, 0.9)", - textShadow: "1px 1px 3px rgba(0,0,0,0.3)", - transition: "transform 0.3s ease", - transform: 'translate(-50%, -50%)', - animation: 'emerge 0.5s ease-out forwards,float 6s 0.5s ease-in-out infinite', - background: "radial-gradient(circle at 30% 30%,rgba(255, 255, 255, 0.8) 10%,rgba(255, 255, 255, 0.3) 50%,transparent 100%)", - border: "2px solid rgba(255, 255, 255, 0.5)", - boxShadow: "inset 0 -5px 15px rgba(255,255,255,0.3),0 5px 15px rgba(0,0,0,0.1)", - } -}) - - -interface Bubble { - id: string; - name: string; - x: number; - y: number; - color: string; - radius: number; // 新增半径属性 - angle: number; // 新增角度属性 -} - -export const LocalSharing: React.FC = () => { - const classes = useClass(); - const {register, enter, list, cleanup, client, clients} = useRoom(); - - // 生成随机颜色 - const generateColor = () => { - const hue = Math.random() * 360; - return `hsla(${hue}, - ${Math.random() * 30 + 40}%, - ${Math.random() * 10 + 75}%, 0.9)`; - }; - - // 防碰撞位置生成 - const generateBubbles = (cs: Client[]) => { - if (!cs) return [] - - const BUBBLE_SIZE = 100; - const centerX = window.innerWidth / 2; - const centerY = window.innerHeight / 2; - - const bubbles: Bubble[] = []; - let currentRadius = 0; - let angleStep = (2 * Math.PI) / 6; // 初始6个位置 - - for (let index = 0; index < cs.length; index++) { - let attempt = 0; - let validPosition = false; - - if (cs[index].id == client?.id) { - continue - } - - while (!validPosition && attempt < 100) { - // 螺旋布局算法 - const angle = angleStep * (index + attempt); - const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8); - - // 极坐标转笛卡尔坐标 - const x = centerX + radius * Math.cos(angle); - const y = centerY + radius * Math.sin(angle); - - // 边界检测 - const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE && - y >= 0 && y <= window.innerHeight - BUBBLE_SIZE; - - // 碰撞检测 - const collision = bubbles.some(pos => { - const distance = Math.sqrt( - Math.pow(pos.x - x, 2) + - Math.pow(pos.y - y, 2) - ); - return distance < BUBBLE_SIZE * 1.5; - }); - - if (inBounds && !collision) { - bubbles.push({ - id: cs[index].id, - name: cs[index].name, - x: x, - y: y, - color: generateColor(), - } as Bubble); - - // 动态调整布局参数 - currentRadius = Math.max(currentRadius, radius); - angleStep = (2 * Math.PI) / Math.max(6, bubbles.length * 0.7); - validPosition = true; - } - - attempt++; - } - } - - return bubbles; - }; - - useEffect(() => { - register().then(() => { - setTimeout(() => { - enter().then(() => { - list().then() - }) - }, 600) - }); - return () => cleanup(); - }, []); - - // 气泡点击处理 - const handleBubbleClick = async (id: string) => { - console.log('[D] click bubble!!!', id) - }; - - return
- -

{client?.name}

- {clients && generateBubbles(clients).map(bubble => { - // const client = clients.find(c => c.id === bubble.id); - return client ? ( -
handleBubbleClick(bubble.id)} - > - {bubble.name} -
- ) : null; - })} -
-} \ No newline at end of file diff --git a/frontend/src/page/local/component/bubble-layout.ts b/frontend/src/page/local/component/bubble-layout.ts new file mode 100644 index 0000000..d846deb --- /dev/null +++ b/frontend/src/page/local/component/bubble-layout.ts @@ -0,0 +1,75 @@ +import {Bubble, Client} from "./types.ts"; + +// 生成随机颜色 +export const generateColor = () => { + const hue = Math.random() * 360; + return `hsla(${hue}, + ${Math.random() * 30 + 40}%, + ${Math.random() * 10 + 75}%, 0.9)`; +}; + +// 防碰撞位置生成 +export const generateBubbles = (clients: Client[], currentUserId: string): Bubble[] => { + if (!clients) return []; + + const BUBBLE_SIZE = 100; + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + + const bubbles: Bubble[] = []; + let currentRadius = 0; + let angleStep = (2 * Math.PI) / 6; // 初始6个位置 + + for (let index = 0; index < clients.length; index++) { + let attempt = 0; + let validPosition = false; + + if (clients[index].id === currentUserId) { + continue; + } + + while (!validPosition && attempt < 100) { + // 螺旋布局算法 + const angle = angleStep * (index + attempt); + const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8); + + // 极坐标转笛卡尔坐标 + const x = centerX + radius * Math.cos(angle); + const y = centerY + radius * Math.sin(angle); + + // 边界检测 + const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE && + y >= 0 && y <= window.innerHeight - BUBBLE_SIZE; + + // 碰撞检测 + const collision = bubbles.some(pos => { + const distance = Math.sqrt( + Math.pow(pos.x - x, 2) + + Math.pow(pos.y - y, 2) + ); + return distance < BUBBLE_SIZE * 1.5; + }); + + if (inBounds && !collision) { + bubbles.push({ + id: clients[index].id, + name: clients[index].name, + x: x, + y: y, + color: generateColor(), + radius: 0, + angle: 0 + }); + + // 动态调整布局参数 + currentRadius = Math.max(currentRadius, radius); + angleStep = (2 * Math.PI) / Math.max(6, bubbles.length * 0.7); + validPosition = true; + } + + attempt++; + } + } + + return bubbles; +}; \ No newline at end of file diff --git a/frontend/src/page/local/component/message-dialog.tsx b/frontend/src/page/local/component/message-dialog.tsx new file mode 100644 index 0000000..d3c81e2 --- /dev/null +++ b/frontend/src/page/local/component/message-dialog.tsx @@ -0,0 +1,163 @@ +import React, {useState} from 'react'; +import {Dialog} from "../../../component/dialog/dialog.tsx"; +import {ReceivedMessage} from "./types.ts"; +import {createUseStyles} from "react-jss"; + +const useStyles = createUseStyles({ + root: { + marginBottom: '1rem', + }, + sender: { + margin: '0 0 0.5rem 0', + fontSize: '0.9rem', + color: '#666', + }, + msgBox: { + background: 'rgba(255,255,255,0.96)', + color: '#222', + padding: '1rem', + borderRadius: '4px', + border: '1px solid #ddd', + maxHeight: '400px', + minHeight: '80px', + overflowY: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + downloadLink: { + color: '#007aff', + textDecoration: 'underline', + }, + btnRow: { + display: 'flex', + flexDirection: 'row', + gap: '0.5rem', + justifyContent: 'flex-end', + alignItems: 'center', + marginTop: '8px', + }, + copySuccess: { + color: '#28a745', + fontSize: '0.9rem', + marginRight: '0.5rem', + animation: 'fadeIn 0.3s ease-in', + }, + copyBtn: { + padding: '8px 16px', + background: '#007aff', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '0.9rem', + transition: 'all 0.2s', + '&:hover': { + background: '#0056b3', + }, + }, + copyBtnSuccess: { + background: '#28a745', + }, + closeBtn: { + padding: '8px 16px', + background: '#6c757d', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '0.9rem', + transition: 'all 0.2s', + }, + progressBar: { + width: '100%', + height: 8, + background: '#eee', + borderRadius: 4, + margin: '12px 0', + overflow: 'hidden', + }, + progressInner: { + height: '100%', + background: '#4dabf7', + transition: 'width 0.3s', + }, +}); + +interface MessageDialogProps { + open: boolean; + message: ReceivedMessage | null; + onClose: () => void; +} + +export const MessageDialog: React.FC = ({open, message, onClose}) => { + const [copySuccess, setCopySuccess] = useState(false); + const classes = useStyles(); + + const handleCopyMessage = () => { + if (message) { + navigator.clipboard.writeText(message.text || '').then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }).catch(() => { + alert('复制失败,请手动复制'); + }); + } + }; + + return ( + +
+

+ 来自: {message?.sender} +

+
+ {message?.isFile ? ( + <> +
📎 文件: {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)
+ {message.receiving ? ( + <> +
正在接收... {Math.round((message.progress||0)*100)}%
+
+
+
+ + ) : ( + + 点击下载 + + )} + + ) : ( + message?.text + )} +
+
+
+ {copySuccess && ( + + ✓ 已复制 + + )} + {!message?.isFile && ( + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/component/rtc-handler.ts b/frontend/src/page/local/component/rtc-handler.ts new file mode 100644 index 0000000..90f2161 --- /dev/null +++ b/frontend/src/page/local/component/rtc-handler.ts @@ -0,0 +1,323 @@ +import { WSMessage, ReceivedMessage} from "./types.ts"; +import {useLocalStore} from "../../../store/local.ts"; + +// 文件接收缓存 +const fileReceiveCache: Record = {}; + +export const handleFileChunk = (chunk: any, onFileReceived: (msg: import("./types").ReceivedMessage) => void) => { + if (chunk.type === 'file') { + // 文件元信息,初始化缓存 + fileReceiveCache[chunk.name + '_' + chunk.timestamp] = { + chunks: new Array(chunk.totalChunks), + total: chunk.totalChunks, + received: 0, + name: chunk.name, + size: chunk.size, + sender: chunk.sender, + timestamp: chunk.timestamp + }; + // 首次弹出进度 + onFileReceived({ + sender: chunk.sender, + timestamp: chunk.timestamp, + fileName: chunk.name, + fileSize: chunk.size, + isFile: true, + progress: 0, + receiving: true + }); + } else if (chunk.type === 'file-chunk') { + const key = chunk.name + '_' + chunk.timestamp; + const cache = fileReceiveCache[key]; + if (cache) { + // 将 data 数组还原为 ArrayBuffer + const uint8 = new Uint8Array(chunk.data); + cache.chunks[chunk.chunkIndex] = uint8.buffer; + cache.received++; + // 实时回调进度 + if (cache.received < cache.total) { + onFileReceived({ + sender: cache.sender, + timestamp: cache.timestamp, + fileName: cache.name, + fileSize: cache.size, + isFile: true, + progress: cache.received / cache.total, + receiving: true + }); + } + } + } else if (chunk.type === 'file-end') { + const key = chunk.name + '_' + chunk.timestamp; + const cache = fileReceiveCache[key]; + if (cache && cache.received === cache.total) { + const blob = new Blob(cache.chunks); + const url = URL.createObjectURL(blob); + onFileReceived({ + sender: cache.sender, + timestamp: cache.timestamp, + fileName: cache.name, + fileSize: cache.size, + fileBlobUrl: url, + isFile: true, + progress: 1, + receiving: false + }); + delete fileReceiveCache[key]; + } else if (cache) { + // 分块未齐全,提示异常 + onFileReceived({ + sender: cache.sender, + timestamp: cache.timestamp, + fileName: cache.name, + fileSize: cache.size, + isFile: true, + progress: cache.received / cache.total, + receiving: true, + text: '文件分块未齐全,接收失败' + }); + } + } +}; + +export interface RTCHandlerCallbacks { + onChannelOpen: (type: 'sender' | 'receiver') => void; + onMessageReceived: (message: ReceivedMessage) => void; + onChannelClose: () => void; +} + +export class RTCHandler { + private rtcRef: React.MutableRefObject; + private callbacks: RTCHandlerCallbacks; + + constructor(rtcRef: React.MutableRefObject, callbacks: RTCHandlerCallbacks) { + this.rtcRef = rtcRef; + this.callbacks = callbacks; + } + + // 更新回调函数的方法 + updateCallbacks = (newCallbacks: RTCHandlerCallbacks) => { + this.callbacks = newCallbacks; + }; + + setupDataChannel = async (ch: RTCDataChannel, type: 'sender' | 'receiver') => { + ch.onopen = () => { + console.log(`[D] 通道已打开!类型: ${type}`); + this.callbacks.onChannelOpen(type); + useLocalStore.getState().setChannel(ch); + }; + + ch.onmessage = (e) => { + // console.log('[D] Received message:', e.data); + try { + const data = JSON.parse(e.data); + if (data.type === 'message') { + // 处理文本消息 + const message: ReceivedMessage = { + text: data.content, + timestamp: Date.now(), + sender: data.sender || '未知用户' + }; + this.callbacks.onMessageReceived(message); + } else if (data.type === 'file' || data.type === 'file-chunk' || data.type === 'file-end') { + // 处理文件相关消息 + handleFileChunk(data, this.callbacks.onMessageReceived); + } + } catch (error) { + // 如果不是JSON格式,当作普通文本处理 + const message: ReceivedMessage = { + text: e.data, + timestamp: Date.now(), + sender: '未知用户' + }; + this.callbacks.onMessageReceived(message); + } + }; + + ch.onclose = () => { + console.log('[D] 通道关闭'); + this.callbacks.onChannelClose(); + useLocalStore.getState().setChannel(); + }; + }; + + handleBubbleClick = async (bubbleId: string, currentUserId: string) => { + const current_rtc = this.rtcRef.current; + if (!current_rtc) return; + + current_rtc.onnegotiationneeded = async () => { + const offer = await current_rtc.createOffer(); + await current_rtc.setLocalDescription(offer); + const data = { + id: bubbleId, + from: currentUserId, + offer: offer, + }; + await fetch('/api/ulocal/offer', { + method: 'POST', + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(data) + }); + }; + + current_rtc.onicecandidate = async (e) => { + await fetch('/api/ulocal/candidate', { + method: 'POST', + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({candidate: e.candidate, id: currentUserId}) + }); + }; + + const ch = current_rtc.createDataChannel('local', {ordered: true}); + await this.setupDataChannel(ch, 'sender'); + }; + + handleWSEvent = async (e: MessageEvent) => { + let current_id: string; + let current_rtc: RTCPeerConnection | null; + const msg = JSON.parse(e.data) as WSMessage; + // console.log('[D] ws event msg =', msg); + + switch (msg.type) { + case "enter": + case "leave": + // 这些事件由父组件处理 + return; + case "offer": + const offer_data = msg.data as { id: string; from: string; offer: RTCSessionDescriptionInit }; + current_id = useLocalStore.getState().id; + if (offer_data.id !== current_id) { + console.warn(`[W] wrong offer id, want = ${current_id}, got = ${offer_data.id}, data =`, offer_data); + return; + } + + current_rtc = this.rtcRef.current; + if (!current_rtc) { + console.warn('[W] rtc undefined'); + return; + } + + await current_rtc.setRemoteDescription(offer_data.offer); + const answer = await current_rtc.createAnswer(); + if (!answer) { + console.log('[W] answer undefined'); + return; + } + + await current_rtc.setLocalDescription(answer); + + current_rtc.ondatachannel = (e) => { + this.setupDataChannel(e.channel, 'receiver'); + }; + + await fetch('/api/ulocal/answer', { + method: 'POST', + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({id: offer_data.from, answer: answer}) + }); + return; + + case "answer": + const answer_data = msg.data as { answer: RTCSessionDescriptionInit; id: string }; + current_id = useLocalStore.getState().id; + if (answer_data.id !== current_id) { + console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data); + } + + current_rtc = this.rtcRef.current; + if (!current_rtc) { + console.warn('[W] rtc undefined'); + return; + } + + await current_rtc.setRemoteDescription(answer_data.answer); + return; + + case "candidate": + const candidate_data = msg.data as { candidate: RTCIceCandidateInit }; + current_rtc = this.rtcRef.current; + if (!current_rtc) { + console.warn('[W] rtc undefined'); + return; + } + if (!candidate_data.candidate) { + return; + } + await current_rtc.addIceCandidate(candidate_data.candidate); + return; + } + }; + + sendMessage = (msg: string, files: File[], senderName: string) => { + const ch = useLocalStore.getState().channel; + const CHUNK_SIZE = 64 * 1024; // 64KB + const BUFFERED_AMOUNT_THRESHOLD = 1 * 1024 * 1024; // 1MB + if (ch && ch.readyState === 'open') { + if (msg.trim()) { + // 发送文本消息 + const messageData = { + type: 'message', + content: msg, + sender: senderName, + timestamp: Date.now() + }; + ch.send(JSON.stringify(messageData)); + } + + if (files && files.length > 0) { + files.forEach(file => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const timestamp = Date.now(); + // 先发送文件元信息 + const fileData = { + type: 'file', + name: file.name, + size: file.size, + sender: senderName, + timestamp, + totalChunks + }; + ch.send(JSON.stringify(fileData)); + + let offset = 0; + let chunkIndex = 0; + const reader = new FileReader(); + + const sendNextChunk = () => { + if (offset >= file.size) { + // 分块全部发送完毕,发送 file-end + ch.send(JSON.stringify({ + type: 'file-end', + name: file.name, + timestamp, + })); + return; + } + if (ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) { + ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true }); + return; + } + const slice = file.slice(offset, offset + CHUNK_SIZE); + reader.onload = (e) => { + if (e.target?.result) { + ch.send(JSON.stringify({ + type: 'file-chunk', + name: file.name, + timestamp, + chunkIndex, + totalChunks, + data: Array.from(new Uint8Array(e.target.result as ArrayBuffer)), + })); + offset += CHUNK_SIZE; + chunkIndex++; + sendNextChunk(); + } + }; + reader.readAsArrayBuffer(slice); + }; + sendNextChunk(); + }); + } + } + }; +} \ No newline at end of file diff --git a/frontend/src/page/local/component/send-dialog.tsx b/frontend/src/page/local/component/send-dialog.tsx new file mode 100644 index 0000000..ab58fc1 --- /dev/null +++ b/frontend/src/page/local/component/send-dialog.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {Dialog} from '../../../component/dialog/dialog.tsx'; +import {Sender} from './sender.tsx'; + +interface SendDialogProps { + open: boolean; + onSend: (msg: string, files: File[]) => void; + onClose: () => void; + name: string; +} + +export const SendDialog: React.FC = ({open, onSend, onClose, name}) => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/component/sender.tsx b/frontend/src/page/local/component/sender.tsx new file mode 100644 index 0000000..e36b32b --- /dev/null +++ b/frontend/src/page/local/component/sender.tsx @@ -0,0 +1,161 @@ +import React, {useRef, useState} from 'react'; +import {createUseStyles} from "react-jss"; +import { message } from '../../../hook/message/u-message.tsx'; + +const useClass = createUseStyles({ + container: { + width: "100%", + minHeight: "260px", + margin: "0", + border: "1px solid #ddd", + borderRadius: "8px", + overflow: "hidden", + position: "relative", + background: "#fff", + boxSizing: "border-box", + padding: "20px 16px 60px 16px" + }, + input_box: { + width: "100%", + minHeight: "180px", + padding: "12px", + boxSizing: "border-box", + border: "none", + resize: "vertical", + outline: "none", + fontFamily: "Arial, sans-serif", + fontSize: "1.1rem", + transition: "padding 0.3s ease", + background: "#f8fafd", + borderRadius: "6px" + }, + input_has_files: { + paddingTop: '50px', + }, + file_list: { + position: "absolute", + top: "0", + left: "0", + right: "0", + padding: "8px 10px", + background: "rgba(255, 255, 255, 0.95)", + borderBottom: "1px solid #eee", + display: "none", + flexWrap: "wrap", + gap: "6px", + maxHeight: "60px", + overflowY: "auto", + }, + list_has_files: { + display: 'flex' + }, + "@keyframes slideIn": { + from: {transform: "translateY(-10px)", opacity: 0}, + to: {transform: "translateY(0)", opacity: 1} + }, + file_item: { + display: "flex", + alignItems: "center", + background: "#f0f6ff", + color: '#1661ab', + border: "1px solid #c2d9ff", + borderRadius: "15px", + padding: "4px 12px", + fontSize: "13px", + animation: "$slideIn 0.2s ease" + }, + delete_btn: { + width: "16px", + height: "16px", + border: "none", + background: "#ff6b6b", + color: "white", + borderRadius: "50%", + marginLeft: "8px", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "12px", + "&:hover": {background: "#ff5252"}, + }, + buttons: { + position: "absolute", + bottom: "16px", + right: "16px", + display: "flex", + gap: "12px" + }, + action_btn: { + padding: "10px 22px", + background: "#4dabf7", + color: "white", + border: "none", + borderRadius: "20px", + cursor: "pointer", + fontSize: "1rem", + transition: "all 0.2s", + "&:hover": {background: "#339af0", transform: "translateY(-1px)"} + }, +}) + +export interface SenderProps { + onSend: (message: string, files: File[]) => void; +} + +export const Sender: React.FC = ({onSend}) => { + const classes = useClass(); + const [inputMessage, setInputMessage] = useState(''); + const [files, setFiles] = useState(null); + const fileInputRef = useRef(null) + + const handleTextInput = (e: React.ChangeEvent) => { + setInputMessage(e.target.value) + } + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + setFiles(e.target.files[0]); + } else { + message.warning('未能选择文件'); + } + }; + + const handleSubmit = () => { + if (!(inputMessage.trim() || files)) { + message.warning('请输入内容或选择文件'); + return; + } + try { + onSend(inputMessage, files ? [files] : []); + setInputMessage(''); + setFiles(null); + } catch (e) { + message.error('发送失败,请重试'); + } + }; + + return ( +
+ +
+ {files &&
+ {files.name} + +
} +
+
+ + + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/component/types.ts b/frontend/src/page/local/component/types.ts new file mode 100644 index 0000000..44278c1 --- /dev/null +++ b/frontend/src/page/local/component/types.ts @@ -0,0 +1,38 @@ +export interface Bubble { + id: string; + name: string; + x: number; + y: number; + color: string; + radius: number; + angle: number; +} + +export interface WSMessage { + data: any; + time: number; + type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate" +} + +export interface Client { + client_type: 'desktop' | 'mobile' | 'tablet'; + app_type: 'web'; + ip: number; + name: string; + id: string; + register_at: string; + offer: RTCSessionDescription; + candidate: RTCIceCandidateInit; +} + +export interface ReceivedMessage { + text?: string; + timestamp: number; + sender: string; + fileName?: string; + fileSize?: number; + fileBlobUrl?: string; + isFile?: boolean; + progress?: number; // 0-1 + receiving?: boolean; // 是否正在接收 +} \ No newline at end of file diff --git a/frontend/src/page/local/component/user-bubble.tsx b/frontend/src/page/local/component/user-bubble.tsx new file mode 100644 index 0000000..b9273da --- /dev/null +++ b/frontend/src/page/local/component/user-bubble.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {createUseStyles} from "react-jss"; +import {Bubble} from "./types.ts"; + +const useClass = createUseStyles({ + bubble: { + position: "absolute", + width: "100px", + height: "100px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + textAlign: "center", + cursor: "pointer", + fontFamily: "'Microsoft Yahei', sans-serif", + fontSize: "14px", + color: "rgba(255, 255, 255, 0.9)", + textShadow: "1px 1px 3px rgba(0,0,0,0.3)", + transition: "transform 0.3s ease", + transform: 'translate(-50%, -50%)', + animation: 'emerge 0.5s ease-out forwards,float 6s 0.5s ease-in-out infinite', + background: "radial-gradient(circle at 30% 30%,rgba(255, 255, 255, 0.8) 10%,rgba(255, 255, 255, 0.3) 50%,transparent 100%)", + border: "2px solid rgba(255, 255, 255, 0.5)", + boxShadow: "inset 0 -5px 15px rgba(255,255,255,0.3),0 5px 15px rgba(0,0,0,0.1)", + } +}); + +interface UserBubbleProps { + bubble: Bubble; + onClick: (bubble: Bubble) => void; +} + +export const UserBubble: React.FC = ({bubble, onClick}) => { + const classes = useClass(); + + return ( +
onClick(bubble)} + > + {bubble.name} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/local.tsx b/frontend/src/page/local/local.tsx new file mode 100644 index 0000000..6943730 --- /dev/null +++ b/frontend/src/page/local/local.tsx @@ -0,0 +1,242 @@ +import {CloudBackground} from "../../component/fluid/cloud.tsx"; +import {useEffect, useRef, useState, useCallback} from "react"; +import {createUseStyles} from "react-jss"; +import {useWebsocket} from "../../hook/websocket/u-ws.tsx"; +import {Resp} from "../../interface/response.ts"; +import {useLocalStore} from "../../store/local.ts"; +import {UserBubble} from "./component/user-bubble.tsx"; +import {MessageDialog} from "./component/message-dialog.tsx"; +import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts"; +import {generateBubbles} from "./component/bubble-layout.ts"; +import {Client, ReceivedMessage} from "./component/types.ts"; +import {SendDialog} from "./component/send-dialog.tsx"; +import {message} from "../../hook/message/u-message.tsx"; + +const useClass = createUseStyles({ + '@global': { + '@keyframes emerge': { + '0%': { + transform: 'scale(0) translate(-50%, -50%)', + opacity: 0 + }, + '80%': { + transform: 'scale(1.1) translate(-50%, -50%)', + opacity: 1 + }, + '100%': { + transform: 'scale(1) translate(-50%, -50%)', + opacity: 1 + } + }, + '@keyframes fadeIn': { + '0%': { + opacity: 0, + transform: 'translateY(-10px)' + }, + '100%': { + opacity: 1, + transform: 'translateY(0)' + } + } + }, + container: { + margin: "0", + height: "100vh", + overflow: "hidden", + position: "relative", + }, + title: { + width: '100%', + display: "flex", + justifyContent: "center", + color: '#1661ab', + } +}); + +export const LocalSharing: React.FC = () => { + const classes = useClass(); + const {id, name, set, } = useLocalStore(); + const [_rtc, setRTC] = useState(); + const rtcRef = useRef(null); + const [clients, setClients] = useState([]); + const {connect, close} = useWebsocket({}); + const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false}); + const [receivedMessage, setReceivedMessage] = useState(null); + const [showMessageDialog, setShowMessageDialog] = useState(false); + const [receivingFile, setReceivingFile] = useState(null); + + // RTC处理器的回调函数 - 使用useCallback确保稳定性 + const onChannelOpen = useCallback((type: 'sender' | 'receiver') => { + console.log(`[D] Channel opened: ${type}`); + setOpen(val => ({...val, [type]: true})); + }, []); + + const onMessageReceived = useCallback((message: ReceivedMessage) => { + if (message.isFile && message.receiving) { + setReceivingFile(message); + } else if (message.isFile && !message.receiving) { + setReceivingFile(null); + setReceivedMessage(message); + setShowMessageDialog(true); + } else { + setReceivedMessage(message); + setShowMessageDialog(true); + } + }, []); + + const onChannelClose = useCallback(() => { + console.log('[D] Channel closed'); + setOpen({send: false, receive: false}); + }, []); + + const rtcCallbacks: RTCHandlerCallbacks = { + onChannelOpen, + onMessageReceived, + onChannelClose + }; + + // 创建RTC处理器实例 - 使用useRef确保实例稳定 + const rtcHandlerRef = useRef(null); + + // 更新RTC处理器的回调函数 + useEffect(() => { + if (rtcHandlerRef.current) { + rtcHandlerRef.current.updateCallbacks(rtcCallbacks); + } + }, [rtcCallbacks]); + + const updateClients = async () => { + setTimeout(async () => { + const res = await fetch(`/api/ulocal/clients`); + const jes = await res.json() as Resp; + setClients(jes.data); + }, 500); + }; + + const handleWSEvent = async (e: MessageEvent) => { + const msgData = JSON.parse(e.data); + + if (msgData.type === "enter" || msgData.type === "leave") { + await updateClients(); + return; + } + + // 其他RTC相关事件由RTC处理器处理 + if (rtcHandlerRef.current) { + try { + await rtcHandlerRef.current.handleWSEvent(e); + } catch (err) { + message.error('通信异常,请刷新页面'); + } + } else { + message.error('内部错误:通信模块未初始化'); + } + }; + + const handleBubbleClick = async (bubble: any) => { + setOpen({send: true, receive: false}); + if (rtcHandlerRef.current) { + try { + await rtcHandlerRef.current.handleBubbleClick(bubble.id, id); + } catch (e) { + message.error('建立连接失败,请重试'); + } + } else { + message.error('内部错误:通信模块未初始化'); + console.error('[E] RTC handler is null!'); + } + }; + + const handleSend = (msg: string, files: File[]) => { + if (rtcHandlerRef.current) { + try { + rtcHandlerRef.current.sendMessage(msg, files, name); + } catch (e) { + message.error('发送失败,请重试'); + } + } else { + message.error('内部错误:通信模块未初始化'); + } + }; + + const handleCloseMessageDialog = () => { + setShowMessageDialog(false); + setReceivedMessage(null); + }; + + + useEffect(() => { + const fn = async () => { + const response = await fetch('/api/ulocal/register', {method: 'POST'}); + const data = ((await response.json()) as Resp<{ id: string; name: string }>).data; + set(data.id, data.name); + connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent}); + await updateClients(); + + const _rtc = new RTCPeerConnection(); + rtcRef.current = _rtc; + setRTC(_rtc); + + // 在RTC连接创建后立即创建处理器实例 + rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks); + + return () => { + close(); + if (rtcRef.current) { + rtcRef.current.close(); + } + }; + }; + fn(); + }, []); + + const bubbles = generateBubbles(clients, id); + + useEffect(() => { + if (receivingFile && receivingFile.isFile && !receivingFile.receiving) { + setReceivingFile(null); + setReceivedMessage(receivingFile); + setShowMessageDialog(true); + } + }, [receivingFile]); + + return ( +
+ +

+ {name} + {/* - {id} */} +

+ + {bubbles.map(bubble => ( + + ))} + + setOpen({send: false, receive: false})} + name={name} + /> + + {/* 文件接收进度弹窗 */} + {receivingFile && ( + setReceivingFile(null)} + /> + )} + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/component/panel-left.tsx b/frontend/src/page/share/component/panel-left.tsx similarity index 95% rename from frontend/src/page/component/panel-left.tsx rename to frontend/src/page/share/component/panel-left.tsx index 2b23240..569a5e0 100644 --- a/frontend/src/page/component/panel-left.tsx +++ b/frontend/src/page/share/component/panel-left.tsx @@ -1,9 +1,9 @@ import {createUseStyles} from "react-jss"; -import {UButton} from "../../component/button/u-button.tsx"; +import {UButton} from "../../../component/button/u-button.tsx"; import React, {useState} from "react"; -import {useStore} from "../../store/share.ts"; -import {message} from "../../component/message/u-message.tsx"; -import {useFileUpload} from "../../api/upload.ts"; +import {useStore} from "../../../store/share.ts"; +import {message} from "../../../hook/message/u-message.tsx"; +import {useFileUpload} from "../../../api/upload.ts"; const useUploadStyle = createUseStyles({ container: { @@ -136,7 +136,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod } function onFileChange(e: React.ChangeEvent) { - console.log('[D] onFileChange: e =', e) setFile(e.currentTarget.files ? e.currentTarget.files[0] : null) } diff --git a/frontend/src/page/component/panel-mid.tsx b/frontend/src/page/share/component/panel-mid.tsx similarity index 100% rename from frontend/src/page/component/panel-mid.tsx rename to frontend/src/page/share/component/panel-mid.tsx diff --git a/frontend/src/page/component/panel-right.tsx b/frontend/src/page/share/component/panel-right.tsx similarity index 91% rename from frontend/src/page/component/panel-right.tsx rename to frontend/src/page/share/component/panel-right.tsx index f7d6c3a..a69bd4d 100644 --- a/frontend/src/page/component/panel-right.tsx +++ b/frontend/src/page/share/component/panel-right.tsx @@ -1,6 +1,6 @@ import {createUseStyles} from "react-jss"; -import {UButton} from "../../component/button/u-button.tsx"; -import {useStore} from "../../store/share.ts"; +import {UButton} from "../../../component/button/u-button.tsx"; +import {useStore} from "../../../store/share.ts"; const useStyle = createUseStyles({ container: { @@ -42,7 +42,6 @@ export const PanelRight = () => { async function onFetchFile() { const url = `/ushare/${code}` - console.log('[D] onFetchFile: url =', url) const link = document.createElement('a'); link.href = url; document.body.appendChild(link); diff --git a/frontend/src/page/share.tsx b/frontend/src/page/share/share.tsx similarity index 100% rename from frontend/src/page/share.tsx rename to frontend/src/page/share/share.tsx diff --git a/frontend/src/page/test/test.tsx b/frontend/src/page/test/test.tsx new file mode 100644 index 0000000..b65ebfe --- /dev/null +++ b/frontend/src/page/test/test.tsx @@ -0,0 +1,18 @@ +import {useState} from "react"; +import {createUseStyles} from "react-jss"; + +const useClass = createUseStyles({ + container: {} +}) +export const TestPage = () => { + const classes = useClass() + const [_open, setOpen] = useState(false) + + const handleOpen = () => { + setOpen(true) + } + + return
+ +
+} \ No newline at end of file diff --git a/frontend/src/store/local.ts b/frontend/src/store/local.ts index 0da4367..c0ce7f2 100644 --- a/frontend/src/store/local.ts +++ b/frontend/src/store/local.ts @@ -1,222 +1,24 @@ import {create} from 'zustand' -import {Resp} from "../interface/response.ts"; -export interface Client { - client_type: 'desktop' | 'mobile' | 'tablet'; - app_type: 'web'; - room: string; - ip: number; - name: string; +export interface LocalStore { id: string; - register_at: string; + name: string; + channel?: RTCDataChannel; + set: (id: string, name: string) => void; + setChannel: (chan?: RTCDataChannel) => void; } -type RoomState = { - conn: WebSocket | null - client: Client | null - clients: Client[] - pc: RTCPeerConnection | null - ch: RTCDataChannel | null - candidate: RTCIceCandidate | null - offer: RTCSessionDescription | null - retryCount: number - reconnectTimer: number | null -} - -type RoomActions = { - register: () => Promise - enter: () => Promise - list: () => Promise - send: (file: File) => Promise - cleanup: () => void -} - -interface Message { - type: 'ping' | 'self' | 'enter' | 'leave'; - time: number; - body: any; -} - -function setupDataChannel(ch: RTCDataChannel) { - ch.onopen = () => console.log('通道已打开!'); - ch.onmessage = (e) => handleFileChunk(e.data); - ch.onclose = () => console.log('通道关闭'); -} - -// 接收文件块 -function handleFileChunk(chunk: any) { - console.log("[D] rtc file chunk =", chunk) -} - -const MAX_RETRY_DELAY = 30000 // 最大重试间隔30秒 -const NORMAL_CLOSE_CODE = 1000 // 正常关闭的状态码 - -export const useRoom = create()((set, get) => ({ - conn: null, - client: null, - clients: [], - pc: null, - ch: null, - candidate: null, - offer: null, - retryCount: 0, - reconnectTimer: null, - register: async () => { - let candidate: RTCIceCandidate; - let offer: RTCSessionDescription | null; - const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]}) - // 处理接收方DataChannel - rtc.ondatachannel = (e) => { - setupDataChannel(e.channel); - }; - - const waitCandidate = new Promise(resolve => { - rtc.onicecandidate = (e) => { - if (e.candidate) { - console.log('[D] candidate =', {candidate: e.candidate}) - candidate = e.candidate - } - resolve(); - } - }) - - // rtc.onicecandidate = (e) => { - // if (e.candidate) { - // console.log('[D] candidate =', {candidate: e.candidate}) - // candidate = e.candidate - // } - // } - - const waitOffer = new Promise(resolve => { - rtc.onnegotiationneeded = async () => { - await rtc.setLocalDescription(await rtc.createOffer()); - console.log("[D] offer =", {offer: rtc.localDescription}) - offer = rtc.localDescription - resolve(); - }; - }) - - // rtc.onnegotiationneeded = async () => { - // await rtc.setLocalDescription(await rtc.createOffer()); - // console.log("[D] offer =", {offer: rtc.localDescription}) - // offer = rtc.localDescription - // }; - - const ch = rtc.createDataChannel("fileTransfer", {ordered: true}) - - setupDataChannel(ch) - - - Promise.all([waitCandidate, waitOffer]).then(() => { - const api = `/api/ulocal/register` - fetch(api, { - method: 'POST', - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({candidate: candidate, offer: offer}) - }).then(res => {return res.json() as unknown as Resp}).then(jes => { - set({client: jes.data, candidate: candidate, offer: offer}) - }) +export const useLocalStore = create()((_set, _get) => ({ + id: '', + name: '', + set: (id: string, name: string) => { + _set(state => { + return {...state, id: id, name: name}; }) }, - enter: async () => { - const {conn, reconnectTimer} = get() - - // 清理旧连接和定时器 - if (reconnectTimer) clearTimeout(reconnectTimer) - if (conn) conn.close() - - const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${get().client?.id}` - console.log('[D] websocket api =', api) - const newConn = new WebSocket(api) - - newConn.onopen = () => { - - } - - newConn.onerror = (error) => { - console.error('WebSocket error:', error) - } - - newConn.onmessage = (event) => { - const msg = JSON.parse(event.data) as Message; - console.log('[D] ws msg =', msg) - let nc: Client - switch (msg.type) { - case "enter": - nc = msg.body as Client - if (nc.id && nc.name && nc.id !== get().client?.id) { - console.log('[D] enter new client =', nc) - set(state => { - return {...state, clients: [...get().clients, nc]} - }) - } - break - case "leave": - nc = msg.body as Client - if (nc.id) { - let idx = 0; - let items = get().clients; - for (const item of items) { - if (item.id === nc.id) { - items.splice(idx, 1) - set(state => { - return {...state, clients: items} - }) - break; - } - idx++; - } - } - break - } - } - - newConn.onclose = (event) => { - // 非正常关闭时触发重连 - if (event.code !== NORMAL_CLOSE_CODE) { - const {retryCount} = get() - const nextRetry = retryCount + 1 - const delay = Math.min(1000 * Math.pow(2, nextRetry), MAX_RETRY_DELAY) - - const timer = setTimeout(() => { - get().register() - }, delay) - - set({ - retryCount: nextRetry, - reconnectTimer: timer, - conn: null - }) - } - } - - set({conn: newConn, reconnectTimer: null}) - }, - list: async () => { - const api = "/api/ulocal/clients?room=" - const res = await fetch(api + get().client?.room) - const jes = await res.json() as Resp - set({clients: jes.data}) - }, - send: async (file: File) => { - const reader = new FileReader(); - const channel = get().ch!; - reader.onload = (e) => { - const chunkSize = 16384; // 16KB每块 - const buffer = e.target!.result! as ArrayBuffer; - let offset = 0; - while (offset < buffer.byteLength) { - const chunk = buffer.slice(offset, offset + chunkSize); - channel.send(chunk); - offset += chunkSize; - } - }; - reader.readAsArrayBuffer(file); - }, - cleanup: () => { - const {conn, reconnectTimer} = get() - if (reconnectTimer) clearTimeout(reconnectTimer) - if (conn) conn.close() - set({conn: null, retryCount: 0, reconnectTimer: null}) + setChannel: (ch?: RTCDataChannel) => { + _set(state => { + return {...state, channel: ch} + }) } -})) +})) \ No newline at end of file diff --git a/internal/api/api.go b/internal/api/api.go index be94128..a12e562 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -26,6 +26,9 @@ func Start(ctx context.Context) <-chan struct{} { { api := app.Group("/api/ulocal") api.Post("/register", handler.LocalRegister()) + api.Post("/offer", handler.LocalOffer()) + api.Post("/answer", handler.LocalAnswer()) + api.Post("/candidate", handler.LocalCandidate()) api.Get("/clients", handler.LocalClients()) api.Get("/ws", handler.LocalWS()) } diff --git a/internal/controller/meta.go b/internal/controller/meta.go index a19e6f6..321c697 100644 --- a/internal/controller/meta.go +++ b/internal/controller/meta.go @@ -135,14 +135,25 @@ func (m *meta) Start(ctx context.Context) { // 清理一天前的文件 go func() { + if opt.Cfg.CleanInterval <= 0 { + log.Warn("meta.Clean: no clean interval set, plz clean manual!!!") + return + } + ticker := time.NewTicker(5 * time.Minute) + duration := time.Duration(opt.Cfg.CleanInterval) * time.Hour for { select { case <-ctx.Done(): return case now := <-ticker.C: + //log.Debug("meta.Clean: 开始清理过期文件 = %v", duration) _ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error { + if info == nil { + return nil + } + if info.IsDir() { return nil } @@ -168,12 +179,16 @@ func (m *meta) Start(ctx context.Context) { code := strings.TrimPrefix(name, ".meta.") - if now.Sub(time.UnixMilli(mi.CreatedAt)) > 24*time.Hour { + if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration { log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader) - os.RemoveAll(opt.FilePath(code)) - os.RemoveAll(path) + if err = os.RemoveAll(opt.FilePath(code)); err != nil { + log.Warn("meta.Clean: remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error()) + } + if err = os.RemoveAll(path); err != nil { + log.Warn("meta.Clean: remove file failed, file = %s, err = %s", path, err.Error()) + } m.Lock() delete(m.m, code) diff --git a/internal/controller/room.go b/internal/controller/room.go index 90fbea1..a1c9aa5 100644 --- a/internal/controller/room.go +++ b/internal/controller/room.go @@ -32,12 +32,22 @@ const ( type RoomMessageType string const ( - RoomMessageTypePing RoomMessageType = "ping" - RoomMessageTypeSelf RoomMessageType = "self" RoomMessageTypeEnter RoomMessageType = "enter" RoomMessageTypeLeave RoomMessageType = "leave" ) +type RoomOffer struct { + SDP string `json:"sdp"` + Type string `json:"type"` +} + +type RoomCandidate struct { + Candidate string `json:"candidate"` + SdpMid string `json:"sdpMid"` + SdpMLineIndex int `json:"sdpMLineIndex"` + UsernameFragment string `json:"usernameFragment"` +} + type roomClient struct { sync.Mutex controller *roomController @@ -45,12 +55,9 @@ type roomClient struct { ClientType RoomClientType `json:"client_type"` AppType RoomAppType `json:"app_type"` IP string `json:"ip"` - Room string `json:"room"` Name string `json:"name"` Id string `json:"id"` RegisterAt time.Time `json:"register_at"` - Offer any `json:"offer"` - Candidate any `json:"candidate"` msgChan chan any } @@ -120,40 +127,24 @@ func (rc *roomClient) start(ctx context.Context) { type roomController struct { sync.Mutex - ctx context.Context - rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id] - notReadies map[string]*roomClient + ctx context.Context + //rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id] + pre map[string]*roomClient + clients map[string]*roomClient } var ( RoomController = &roomController{ - rooms: make(map[string]map[string]*roomClient), - notReadies: make(map[string]*roomClient), + pre: make(map[string]*roomClient), + clients: make(map[string]*roomClient), } ) func (rc *roomController) Start(ctx context.Context) { rc.ctx = ctx - go func() { - ticker := time.NewTicker(1 * time.Minute) - for { - select { - case <-rc.ctx.Done(): - return - case now := <-ticker.C: - for _, nrc := range rc.notReadies { - if now.Sub(nrc.RegisterAt).Minutes() > 1 { - rc.Lock() - delete(rc.notReadies, nrc.Id) - rc.Unlock() - } - } - } - } - }() } -func (rc *roomController) Register(ip, userAgent string, candidate, offer any) *roomClient { +func (rc *roomController) Register(ip, userAgent string) *roomClient { nrc := &roomClient{ controller: rc, ClientType: ClientTypeDesktop, @@ -163,8 +154,6 @@ func (rc *roomController) Register(ip, userAgent string, candidate, offer any) * Name: tool.RandomName(), msgChan: make(chan any, 1), RegisterAt: time.Now(), - Candidate: candidate, - Offer: offer, } ua := useragent.Parse(userAgent) @@ -175,89 +164,114 @@ func (rc *roomController) Register(ip, userAgent string, candidate, offer any) * nrc.ClientType = ClientTypeTablet } - key := "local" - if !tool.IsPrivateIP(ip) { - key = ip - } - - nrc.Room = key - rc.Lock() + defer rc.Unlock() - log.Debug("controller.room: registry client, IP = %s(%s), Id = %s, Name = %s", key, nrc.IP, nrc.Id, nrc.Name) - rc.notReadies[nrc.Id] = nrc - if _, ok := rc.rooms[nrc.Room]; !ok { - rc.rooms[nrc.Room] = make(map[string]*roomClient) - } - - rc.Unlock() + rc.pre[nrc.Id] = nrc return nrc } -func (rc *roomController) Enter(conn *websocket.Conn, id string) { - client, ok := rc.notReadies[id] - if !ok { - log.Warn("controller.room: entry room id not exist, id = %s", id) - return - } - - rc.Lock() - - if _, ok = rc.rooms[client.Room]; !ok { - log.Warn("controller.room: entry room not exist, room = %s, id = %s, name = %s", client.Room, id, client.Name) - return - } - - rc.rooms[client.Room][id] = client - client.conn = conn - - rc.Unlock() - - client.start(rc.ctx) - - rc.Broadcast(client.Room, map[string]any{"type": RoomMessageTypeEnter, "time": time.Now().UnixMilli(), "body": client}) -} - -func (rc *roomController) List(room string) []*roomClient { - clientList := make([]*roomClient, 0) +func (rc *roomController) Enter(conn *websocket.Conn, id string) *roomClient { + log.Debug("controller.room: registry client, id = %s", id) rc.Lock() defer rc.Unlock() - clients, ok := rc.rooms[room] + nrc, ok := rc.pre[id] if !ok { - return clientList + return nil } - for _, client := range clients { + nrc.conn = conn + nrc.start(rc.ctx) + + rc.Broadcast(map[string]any{"type": "enter", "time": time.Now().UnixMilli(), "body": nrc}) + + delete(rc.pre, nrc.Id) + rc.clients[nrc.Id] = nrc + + return nrc +} + +func (rc *roomController) List() []*roomClient { + clientList := make([]*roomClient, 0) + + for _, client := range rc.clients { clientList = append(clientList, client) } return clientList } -func (rc *roomController) Broadcast(room string, msg any) { - for _, client := range rc.rooms[room] { +func (rc *roomController) Broadcast(msg any) { + for _, client := range rc.clients { select { case client.msgChan <- msg: case <-time.After(2 * time.Second): - log.Warn("RoomController: broadcast timeout, room = %s, client Id = %s, IP = %s", room, client.Id, client.IP) + log.Warn("RoomController: broadcast timeout, client Id = %s, IP = %s", client.Id, client.IP) } } } func (rc *roomController) Unregister(client *roomClient) { - key := "local" - if !tool.IsPrivateIP(client.IP) { - key = client.IP - } - - log.Debug("controller.room: unregister client, IP = %s(%s), Id = %s, Name = %s", client.IP, key, client.Id, client.Name) + log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name) rc.Lock() - delete(rc.rooms[key], client.Id) + delete(rc.clients, client.Id) rc.Unlock() - rc.Broadcast(key, map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client}) + rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client}) +} + +func (rc *roomController) Offer(id, from string, offer *RoomOffer) { + if _, ok := rc.clients[id]; !ok { + return + } + + rc.clients[id].msgChan <- map[string]any{ + "type": "offer", + "time": time.Now().UnixMilli(), + "data": map[string]any{ + "id": id, + "from": from, + "offer": offer, + }, + } +} + +func (rc *roomController) Answer(id string, answer *RoomOffer) { + if _, ok := rc.clients[id]; !ok { + return + } + + rc.clients[id].msgChan <- map[string]any{ + "type": "answer", + "time": time.Now().UnixMilli(), + "data": map[string]any{ + "id": id, + "answer": answer, + }, + } +} + +func (rc *roomController) Candidate(id string, candidate *RoomCandidate) { + if _, ok := rc.clients[id]; !ok { + return + } + + for _, client := range rc.clients { + if client.Id == id { + continue + } + + client.msgChan <- map[string]any{ + "type": "candidate", + "time": time.Now().UnixMilli(), + "data": map[string]any{ + "id": client.Id, + "candidate": candidate, + }, + } + } } diff --git a/internal/handler/local.go b/internal/handler/local.go index 38b89ca..9eb23d7 100644 --- a/internal/handler/local.go +++ b/internal/handler/local.go @@ -11,23 +11,12 @@ import ( func LocalRegister() nf.HandlerFunc { return func(c *nf.Ctx) error { - type Req struct { - Candidate any `json:"candidate"` - Offer any `json:"offer"` - } - var ( - err error - req = new(Req) - ip = c.IP(true) - ua = c.Get("User-Agent") + ip = c.IP(true) + ua = c.Get("User-Agent") ) - if err = c.BodyParser(req); err != nil { - return c.Status(http.StatusBadRequest).JSON(map[string]interface{}{"msg": err.Error()}) - } - - client := controller.RoomController.Register(ip, ua, req.Candidate, req.Offer) + client := controller.RoomController.Register(ip, ua) return resp.Resp200(c, client) } @@ -35,12 +24,7 @@ func LocalRegister() nf.HandlerFunc { func LocalClients() nf.HandlerFunc { return func(c *nf.Ctx) error { - room := c.Query("room") - if room == "" { - return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": "room can't be empty"}) - } - - list := controller.RoomController.List(room) + list := controller.RoomController.List() return resp.Resp200(c, list) } @@ -56,7 +40,6 @@ func LocalWS() nf.HandlerFunc { } return func(c *nf.Ctx) error { - id := c.Query("id") if id == "" { @@ -74,3 +57,70 @@ func LocalWS() nf.HandlerFunc { return nil } } + +func LocalOffer() nf.HandlerFunc { + return func(c *nf.Ctx) error { + type Req struct { + Id string `json:"id"` + From string `json:"from"` + Offer *controller.RoomOffer `json:"offer"` + } + + var ( + err error + req = new(Req) + ) + + if err = c.BodyParser(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) + } + + controller.RoomController.Offer(req.Id, req.From, req.Offer) + + return resp.Resp200(c, req.Offer) + } +} + +func LocalAnswer() nf.HandlerFunc { + return func(c *nf.Ctx) error { + type Req struct { + Id string `json:"id"` + Answer *controller.RoomOffer `json:"answer"` + } + + var ( + err error + req = new(Req) + ) + + if err = c.BodyParser(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) + } + + controller.RoomController.Answer(req.Id, req.Answer) + + return resp.Resp200(c, req) + } +} + +func LocalCandidate() nf.HandlerFunc { + return func(c *nf.Ctx) error { + type Req struct { + Id string `json:"id"` + Candidate *controller.RoomCandidate `json:"candidate"` + } + + var ( + err error + req = new(Req) + ) + + if err = c.BodyParser(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) + } + + controller.RoomController.Candidate(req.Id, req.Candidate) + + return resp.Resp200(c, req) + } +} diff --git a/internal/opt/opt.go b/internal/opt/opt.go index 35049f4..5da258d 100644 --- a/internal/opt/opt.go +++ b/internal/opt/opt.go @@ -7,10 +7,11 @@ import ( ) type config struct { - Debug bool - Address string - DataPath string - Auth string + Debug bool + Address string + DataPath string + Auth string + CleanInterval int } var ( diff --git a/main.go b/main.go index d84ea02..243779d 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "github.com/loveuer/ushare/internal/api" "github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/opt" + "github.com/loveuer/ushare/internal/pkg/tool" "os/signal" "syscall" ) @@ -16,11 +17,12 @@ func init() { flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "") flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "") flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)") + flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理") flag.Parse() if opt.Cfg.Debug { log.SetLogLevel(log.LogLevelDebug) - log.Debug("start server with debug mode") + tool.TablePrinter(opt.Cfg) } } diff --git a/page/sender.html b/page/sender.html new file mode 100644 index 0000000..788ee01 --- /dev/null +++ b/page/sender.html @@ -0,0 +1,179 @@ + + + + + 智能文本区域 + + + +
+ +
+
+ + + +
+
+ + + + \ No newline at end of file