diff --git a/frontend/src/component/dialog/dialog.tsx b/frontend/src/component/dialog/dialog.tsx new file mode 100644 index 0000000..9a23bf5 --- /dev/null +++ b/frontend/src/component/dialog/dialog.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useRef, ReactNode } from 'react'; +import {createUseStyles} from "react-jss"; + +const useClass = createUseStyles({ + dialog: { + border: "none", + borderRadius: "8px", + padding: "0", + 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)" + }, + }, + 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; +} + +/** + * 使用 HTML 原生 dialog 元素的模态对话框组件 + */ +export const Dialog: React.FC = ({ + open, + title, + children, + onClose, + className = '', + }) => { + const classes = useClass(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + // 根据 open 属性打开/关闭对话框 + if (open) { + // 先关闭再打开确保动画效果(如果有) + if (dialog.open) dialog.close(); + dialog.showModal(); + } else { + dialog.close(); + } + }, [open]); + + // 处理关闭事件(点击背景/ESC键) + const handleClose = (e: React.MouseEvent) => { + // 确保关闭事件来自背景点击 + if (e.target === dialogRef.current) { + onClose(); + } + }; + + // 处理取消事件(ESC键) + const handleCancel = () => { + onClose(); + }; + + return ( + +
+ {title &&
{title}
} + +
+ {children} +
+ +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b492286..fdad411 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,12 +5,14 @@ import {createBrowserRouter, RouterProvider} from "react-router-dom"; import {Login} from "./page/login.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/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..0f4407d --- /dev/null +++ b/frontend/src/page/local/component/message-dialog.tsx @@ -0,0 +1,90 @@ +import React, {useState} from 'react'; +import {Dialog} from "../../../component/dialog/dialog.tsx"; +import {ReceivedMessage} from "./types.ts"; + +interface MessageDialogProps { + open: boolean; + message: ReceivedMessage | null; + onClose: () => void; +} + +export const MessageDialog: React.FC = ({open, message, onClose}) => { + const [copySuccess, setCopySuccess] = useState(false); + + const handleCopyMessage = () => { + if (message) { + navigator.clipboard.writeText(message.text).then(() => { + console.log('消息已复制到剪贴板'); + setCopySuccess(true); + // 2秒后隐藏成功提示 + setTimeout(() => setCopySuccess(false), 2000); + }).catch(err => { + console.error('复制失败:', err); + alert('复制失败,请手动复制'); + }); + } + }; + + return ( + +
+

+ 来自: {message?.sender} +

+
+ {message?.text} +
+
+
+ {copySuccess && ( + + ✓ 已复制 + + )} + +
+
+ ); +}; \ 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..eea12a8 --- /dev/null +++ b/frontend/src/page/local/component/rtc-handler.ts @@ -0,0 +1,228 @@ +import {Resp} from "../../../interface/response.ts"; +import {Client, WSMessage, ReceivedMessage} from "./types.ts"; +import {useLocalStore} from "../../../store/local.ts"; + +// 接收文件块 +export const handleFileChunk = (chunk: any) => { + console.log("[D] rtc file chunk =", chunk); +}; + +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) => { + console.log('[D] Updating RTC handler callbacks'); + this.callbacks = newCallbacks; + }; + + setupDataChannel = async (ch: RTCDataChannel, type: 'sender' | 'receiver') => { + console.log(`[D] Setting up data channel for type: ${type}`); + ch.onopen = () => { + console.log(`[D] 通道已打开!类型: ${type}`); + console.log('[D] Calling onChannelOpen callback with type:', 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 || '未知用户' + }; + console.log('[D] Calling onMessageReceived callback with message:', message); + this.callbacks.onMessageReceived(message); + } else if (data.type === 'file') { + // 处理文件消息 + handleFileChunk(data); + } + } catch (error) { + // 如果不是JSON格式,当作普通文本处理 + const message: ReceivedMessage = { + text: e.data, + timestamp: Date.now(), + sender: '未知用户' + }; + console.log('[D] Calling onMessageReceived callback with plain text message:', message); + this.callbacks.onMessageReceived(message); + } + }; + + ch.onclose = () => { + console.log('[D] 通道关闭'); + console.log('[D] Calling onChannelClose callback'); + this.callbacks.onChannelClose(); + useLocalStore.getState().setChannel(); + }; + }; + + handleBubbleClick = async (bubbleId: string, currentUserId: string) => { + console.log(`[D] click id = ${bubbleId}`); + const current_rtc = this.rtcRef.current; + if (!current_rtc) return; + + current_rtc.onnegotiationneeded = async () => { + const offer = await current_rtc.createOffer(); + console.log('[D] offer created', offer); + 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) => { + console.log('[D] on candidate ', 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) { + console.log('[W] candidate data null'); + return; + } + await current_rtc.addIceCandidate(candidate_data.candidate); + return; + } + }; + + sendMessage = (msg: string, files: File[], senderName: string) => { + const ch = useLocalStore.getState().channel; + console.log('[D] ready to send:', msg, files, ch); + + 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 fileData = { + type: 'file', + name: file.name, + size: file.size, + sender: senderName, + timestamp: Date.now() + }; + ch.send(JSON.stringify(fileData)); + + // 这里可以添加文件分块发送逻辑 + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + ch.send(e.target.result as ArrayBuffer); + } + }; + reader.readAsArrayBuffer(file); + }); + } + } + }; +} \ 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..3cb233a --- /dev/null +++ b/frontend/src/page/local/component/types.ts @@ -0,0 +1,32 @@ +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; +} \ 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 index 0f7a2c6..8b14a08 100644 --- a/frontend/src/page/local/local.tsx +++ b/frontend/src/page/local/local.tsx @@ -1,11 +1,16 @@ import {CloudBackground} from "../../component/fluid/cloud.tsx"; -import {useEffect, useRef, useState} from "react"; +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 {Drawer} from "../../component/drawer/drawer.tsx"; import {Sender} from "./component/sender.tsx"; +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"; const useClass = createUseStyles({ '@global': { @@ -22,12 +27,21 @@ const useClass = createUseStyles({ 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", - // background: "linear-gradient(45deg, #e6e9f0, #eef1f5)", overflow: "hidden", position: "relative", }, @@ -36,339 +50,162 @@ const useClass = createUseStyles({ 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; // 新增角度属性 -} - -interface WSMessage { - data: any; - time: number; - type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate" -} - -interface Client { - client_type: 'desktop' | 'mobile' | 'tablet'; - app_type: 'web'; - ip: number; - name: string; - id: string; - register_at: string; - offer: RTCSessionDescription; - candidate: RTCIceCandidateInit; -} - - -// 接收文件块 -function handleFileChunk(chunk: any) { - console.log("[D] rtc file chunk =", chunk) -} - +}); export const LocalSharing: React.FC = () => { const classes = useClass(); - const {id, name, set, setChannel} = useLocalStore() + const {id, name, set, setChannel} = useLocalStore(); const [rtc, setRTC] = useState(); - const rtcRef = useRef(); + 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 {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 setupDataChannel = async (ch: RTCDataChannel) => { - ch.onopen = () => { - console.log('通道已打开!') - setOpen(val => { - return {...val, send: true} - }) - setChannel(ch) - } - ch.onmessage = (e) => handleFileChunk(e.data); - ch.onclose = () => { - console.log('通道关闭') - setChannel() - } - } + // RTC处理器的回调函数 - 使用useCallback确保稳定性 + const onChannelOpen = useCallback((type: 'sender' | 'receiver') => { + console.log(`[D] Channel opened: ${type}`); + setOpen(val => ({...val, [type]: true})); + }, []); - // 生成随机颜色 - const generateColor = () => { - const hue = Math.random() * 360; - return `hsla(${hue}, - ${Math.random() * 30 + 40}%, - ${Math.random() * 10 + 75}%, 0.9)`; + const onMessageReceived = useCallback((message: ReceivedMessage) => { + console.log('[D] Message received:', message); + setReceivedMessage(message); + setShowMessageDialog(true); + }, []); + + const onChannelClose = useCallback(() => { + console.log('[D] Channel closed'); + setOpen({send: false, receive: false}); + }, []); + + const rtcCallbacks: RTCHandlerCallbacks = { + onChannelOpen, + onMessageReceived, + onChannelClose }; - // 防碰撞位置生成 - const generateBubbles = () => { - 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 == 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: clients[index].id, - name: clients[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++; - } + // 创建RTC处理器实例 - 使用useRef确保实例稳定 + const rtcHandlerRef = useRef(null); + + // 更新RTC处理器的回调函数 + useEffect(() => { + if (rtcHandlerRef.current) { + console.log('[D] Updating RTC handler callbacks'); + rtcHandlerRef.current.updateCallbacks(rtcCallbacks); } - - return bubbles; - }; + }, [rtcCallbacks]); const updateClients = async () => { setTimeout(async () => { - const res = await fetch(`/api/ulocal/clients`) - const jes = await res.json() as Resp - // console.log('[D] update clients =', jes) - setClients(jes.data) - }, 500) - } - - const handleWSEvent = async (e: MessageEvent) => { - let current_id: string; - let current_rtc: RTCPeerConnection - const msg = JSON.parse(e.data) as WSMessage - console.log('[D] ws event msg =', msg) - switch (msg.type) { - case "enter": - await updateClients() - return - case "leave": - await updateClients() - 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 = 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) => { - setupDataChannel(e.channel) - } - - 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 = 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 = rtcRef.current - if (!current_rtc) { - console.warn('[W] rtc undefined') - return - } - if (!candidate_data.candidate) { - console.log('[W] candidate data null') - return - } - await current_rtc.addIceCandidate(candidate_data.candidate) - return - } - } - -// 气泡点击处理 - const handleBubbleClick = async (bubble: Bubble) => { - console.log(`[D] click id = ${bubble.id}`) - const current_rtc = rtcRef.current - current_rtc.onnegotiationneeded = async () => { - const offer = await current_rtc.createOffer() - console.log('[D] offer created', offer) - await current_rtc.setLocalDescription(offer) - const data = { - id: bubble.id, - from: useLocalStore.getState().id, - offer: offer, - } - await fetch('/api/ulocal/offer', { - method: 'POST', - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(data) - }) - } - current_rtc.onicecandidate = async (e) => { - console.log('[D] on candidate ', e) - await fetch('/api/ulocal/candidate', { - method: 'POST', - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({candidate: e.candidate, id: useLocalStore.getState().id}) - }) - } - - const ch = current_rtc.createDataChannel('local', {ordered: true}) - await setupDataChannel(ch) + const res = await fetch(`/api/ulocal/clients`); + const jes = await res.json() as Resp; + setClients(jes.data); + }, 500); }; - const handleSend = (msg: string, files: File) => { - const ch = useLocalStore.getState().channel - console.log('[D] ready to send:', msg, files, ch) - if (ch) { - ch.send(msg) + const handleWSEvent = async (e: MessageEvent) => { + const msg = JSON.parse(e.data); + console.log('[D] ws event msg =', msg); + + if (msg.type === "enter" || msg.type === "leave") { + await updateClients(); + return; } - } + + // 其他RTC相关事件由RTC处理器处理 + if (rtcHandlerRef.current) { + await rtcHandlerRef.current.handleWSEvent(e); + } + }; + + const handleBubbleClick = async (bubble: any) => { + console.log('[D] Bubble clicked:', bubble.id, 'Current RTC handler:', rtcHandlerRef.current); + if (rtcHandlerRef.current) { + await rtcHandlerRef.current.handleBubbleClick(bubble.id, id); + } else { + console.error('[E] RTC handler is null!'); + } + }; + + const handleSend = (msg: string, files: File[]) => { + if (rtcHandlerRef.current) { + rtcHandlerRef.current.sendMessage(msg, files, name); + } + }; + + 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) + set(data.id, data.name); console.log(`[D] register id = ${data.id}`); - connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent}) - await updateClients() + connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent}); + await updateClients(); const _rtc = new RTCPeerConnection(); - rtcRef.current = _rtc; // 同步设置 ref - setRTC(_rtc); // 更新状态(如果需要触发渲染) + rtcRef.current = _rtc; + setRTC(_rtc); + // 在RTC连接创建后立即创建处理器实例 + console.log('[D] Creating RTC handler after connection setup'); + rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks); return () => { close(); - if (rtcRef) { - rtcRef.current.close() + if (rtcRef.current) { + rtcRef.current.close(); } - } + }; }; - fn() - }, []) + fn(); + }, []); - return
- -

{name} - - {id} -

- {clients && generateBubbles().map(bubble => { - return ( -
handleBubbleClick(bubble)} - > - {bubble.name} -
- ); - })} - setOpen({send: false, receive: false})}> - - -
-} \ No newline at end of file + const bubbles = generateBubbles(clients, id); + + return ( +
+ +

+ {name} + - {id} +

+ + {bubbles.map(bubble => ( + + ))} + + setOpen({send: false, receive: false})} + > + + + + setOpen({send: false, receive: false})} + /> + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/test/test.tsx b/frontend/src/page/test/test.tsx new file mode 100644 index 0000000..837d583 --- /dev/null +++ b/frontend/src/page/test/test.tsx @@ -0,0 +1,22 @@ +import {useState} from "react"; +import {createUseStyles} from "react-jss"; +import {Dialog} from "../../component/dialog/dialog.tsx"; + +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