wip: v0.2.7(发送消息)
This commit is contained in:
parent
e5f7b5e6dc
commit
1b4ba1cb61
127
frontend/src/component/dialog/dialog.tsx
Normal file
127
frontend/src/component/dialog/dialog.tsx
Normal file
@ -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<DialogProps> = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const classes = useClass();
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(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 (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={`${classes.dialog} ${className}`}
|
||||||
|
onClick={handleClose}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<article className={classes.dialog_content}>
|
||||||
|
{title && <header className={classes.dialog_header}>{title}</header>}
|
||||||
|
|
||||||
|
<div className={classes.dialog_body}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={classes.dialog_footer}>
|
||||||
|
<button
|
||||||
|
className={classes.close_button}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭对话框"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
};
|
@ -5,12 +5,14 @@ import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
|||||||
import {Login} from "./page/login.tsx";
|
import {Login} from "./page/login.tsx";
|
||||||
import {FileSharing} from "./page/share/share.tsx";
|
import {FileSharing} from "./page/share/share.tsx";
|
||||||
import {LocalSharing} from "./page/local/local.tsx";
|
import {LocalSharing} from "./page/local/local.tsx";
|
||||||
|
import {TestPage} from "./page/test/test.tsx";
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
const root = createRoot(container!)
|
const root = createRoot(container!)
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{path: "/login", element: <Login />},
|
{path: "/login", element: <Login />},
|
||||||
{path: "/share", element: <FileSharing />},
|
{path: "/share", element: <FileSharing />},
|
||||||
|
{path: "/test", element: <TestPage />},
|
||||||
{path: "*", element: <LocalSharing />},
|
{path: "*", element: <LocalSharing />},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
75
frontend/src/page/local/component/bubble-layout.ts
Normal file
75
frontend/src/page/local/component/bubble-layout.ts
Normal file
@ -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;
|
||||||
|
};
|
90
frontend/src/page/local/component/message-dialog.tsx
Normal file
90
frontend/src/page/local/component/message-dialog.tsx
Normal file
@ -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<MessageDialogProps> = ({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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="收到新消息"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.9rem', color: '#666' }}>
|
||||||
|
来自: {message?.sender}
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{message?.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
|
{copySuccess && (
|
||||||
|
<span style={{
|
||||||
|
color: '#28a745',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
marginRight: '0.5rem',
|
||||||
|
animation: 'fadeIn 0.3s ease-in'
|
||||||
|
}}>
|
||||||
|
✓ 已复制
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMessage}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: copySuccess ? '#28a745' : '#007aff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!copySuccess) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#0056b3';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!copySuccess) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#007aff';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copySuccess ? '已复制' : '复制消息'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
228
frontend/src/page/local/component/rtc-handler.ts
Normal file
228
frontend/src/page/local/component/rtc-handler.ts
Normal file
@ -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<RTCPeerConnection | null>;
|
||||||
|
private callbacks: RTCHandlerCallbacks;
|
||||||
|
|
||||||
|
constructor(rtcRef: React.MutableRefObject<RTCPeerConnection | null>, 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
32
frontend/src/page/local/component/types.ts
Normal file
32
frontend/src/page/local/component/types.ts
Normal file
@ -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;
|
||||||
|
}
|
51
frontend/src/page/local/component/user-bubble.tsx
Normal file
51
frontend/src/page/local/component/user-bubble.tsx
Normal file
@ -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<UserBubbleProps> = ({bubble, onClick}) => {
|
||||||
|
const classes = useClass();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.bubble}
|
||||||
|
style={{
|
||||||
|
left: bubble.x,
|
||||||
|
top: bubble.y,
|
||||||
|
backgroundColor: bubble.color,
|
||||||
|
animationDelay: `${Math.random() * 0.5}s, ${0.5 + Math.random() * 2}s`
|
||||||
|
}}
|
||||||
|
onClick={() => onClick(bubble)}
|
||||||
|
>
|
||||||
|
{bubble.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,11 +1,16 @@
|
|||||||
import {CloudBackground} from "../../component/fluid/cloud.tsx";
|
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 {createUseStyles} from "react-jss";
|
||||||
import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
|
import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
|
||||||
import {Resp} from "../../interface/response.ts";
|
import {Resp} from "../../interface/response.ts";
|
||||||
import {useLocalStore} from "../../store/local.ts";
|
import {useLocalStore} from "../../store/local.ts";
|
||||||
import {Drawer} from "../../component/drawer/drawer.tsx";
|
import {Drawer} from "../../component/drawer/drawer.tsx";
|
||||||
import {Sender} from "./component/sender.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({
|
const useClass = createUseStyles({
|
||||||
'@global': {
|
'@global': {
|
||||||
@ -22,12 +27,21 @@ const useClass = createUseStyles({
|
|||||||
transform: 'scale(1) translate(-50%, -50%)',
|
transform: 'scale(1) translate(-50%, -50%)',
|
||||||
opacity: 1
|
opacity: 1
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'@keyframes fadeIn': {
|
||||||
|
'0%': {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(-10px)'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateY(0)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
margin: "0",
|
margin: "0",
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
// background: "linear-gradient(45deg, #e6e9f0, #eef1f5)",
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
@ -36,339 +50,162 @@ const useClass = createUseStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
color: '#1661ab',
|
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 = () => {
|
export const LocalSharing: React.FC = () => {
|
||||||
const classes = useClass();
|
const classes = useClass();
|
||||||
const {id, name, set, setChannel} = useLocalStore()
|
const {id, name, set, setChannel} = useLocalStore();
|
||||||
const [rtc, setRTC] = useState<RTCPeerConnection>();
|
const [rtc, setRTC] = useState<RTCPeerConnection>();
|
||||||
const rtcRef = useRef<RTCPeerConnection>();
|
const rtcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const {connect, close} = useWebsocket({})
|
const {connect, close} = useWebsocket({});
|
||||||
const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false})
|
const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false});
|
||||||
|
const [receivedMessage, setReceivedMessage] = useState<ReceivedMessage | null>(null);
|
||||||
|
const [showMessageDialog, setShowMessageDialog] = useState(false);
|
||||||
|
|
||||||
const setupDataChannel = async (ch: RTCDataChannel) => {
|
// RTC处理器的回调函数 - 使用useCallback确保稳定性
|
||||||
ch.onopen = () => {
|
const onChannelOpen = useCallback((type: 'sender' | 'receiver') => {
|
||||||
console.log('通道已打开!')
|
console.log(`[D] Channel opened: ${type}`);
|
||||||
setOpen(val => {
|
setOpen(val => ({...val, [type]: true}));
|
||||||
return {...val, send: true}
|
}, []);
|
||||||
})
|
|
||||||
setChannel(ch)
|
|
||||||
}
|
|
||||||
ch.onmessage = (e) => handleFileChunk(e.data);
|
|
||||||
ch.onclose = () => {
|
|
||||||
console.log('通道关闭')
|
|
||||||
setChannel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机颜色
|
const onMessageReceived = useCallback((message: ReceivedMessage) => {
|
||||||
const generateColor = () => {
|
console.log('[D] Message received:', message);
|
||||||
const hue = Math.random() * 360;
|
setReceivedMessage(message);
|
||||||
return `hsla(${hue},
|
setShowMessageDialog(true);
|
||||||
${Math.random() * 30 + 40}%,
|
}, []);
|
||||||
${Math.random() * 10 + 75}%, 0.9)`;
|
|
||||||
|
const onChannelClose = useCallback(() => {
|
||||||
|
console.log('[D] Channel closed');
|
||||||
|
setOpen({send: false, receive: false});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rtcCallbacks: RTCHandlerCallbacks = {
|
||||||
|
onChannelOpen,
|
||||||
|
onMessageReceived,
|
||||||
|
onChannelClose
|
||||||
};
|
};
|
||||||
|
|
||||||
// 防碰撞位置生成
|
// 创建RTC处理器实例 - 使用useRef确保实例稳定
|
||||||
const generateBubbles = () => {
|
const rtcHandlerRef = useRef<RTCHandler | null>(null);
|
||||||
if (!clients) return []
|
|
||||||
|
|
||||||
const BUBBLE_SIZE = 100;
|
// 更新RTC处理器的回调函数
|
||||||
const centerX = window.innerWidth / 2;
|
useEffect(() => {
|
||||||
const centerY = window.innerHeight / 2;
|
if (rtcHandlerRef.current) {
|
||||||
|
console.log('[D] Updating RTC handler callbacks');
|
||||||
const bubbles: Bubble[] = [];
|
rtcHandlerRef.current.updateCallbacks(rtcCallbacks);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
}, [rtcCallbacks]);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bubbles;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateClients = async () => {
|
const updateClients = async () => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const res = await fetch(`/api/ulocal/clients`)
|
const res = await fetch(`/api/ulocal/clients`);
|
||||||
const jes = await res.json() as Resp<Client[]>
|
const jes = await res.json() as Resp<Client[]>;
|
||||||
// console.log('[D] update clients =', jes)
|
setClients(jes.data);
|
||||||
setClients(jes.data)
|
}, 500);
|
||||||
}, 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 handleSend = (msg: string, files: File) => {
|
const handleWSEvent = async (e: MessageEvent) => {
|
||||||
const ch = useLocalStore.getState().channel
|
const msg = JSON.parse(e.data);
|
||||||
console.log('[D] ready to send:', msg, files, ch)
|
console.log('[D] ws event msg =', msg);
|
||||||
if (ch) {
|
|
||||||
ch.send(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(() => {
|
useEffect(() => {
|
||||||
const fn = async () => {
|
const fn = async () => {
|
||||||
const response = await fetch('/api/ulocal/register', {method: 'POST'});
|
const response = await fetch('/api/ulocal/register', {method: 'POST'});
|
||||||
const data = ((await response.json()) as Resp<{ id: string; name: string }>).data;
|
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}`);
|
console.log(`[D] register id = ${data.id}`);
|
||||||
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent})
|
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent});
|
||||||
await updateClients()
|
await updateClients();
|
||||||
|
|
||||||
const _rtc = new RTCPeerConnection();
|
const _rtc = new RTCPeerConnection();
|
||||||
rtcRef.current = _rtc; // 同步设置 ref
|
rtcRef.current = _rtc;
|
||||||
setRTC(_rtc); // 更新状态(如果需要触发渲染)
|
setRTC(_rtc);
|
||||||
|
|
||||||
|
// 在RTC连接创建后立即创建处理器实例
|
||||||
|
console.log('[D] Creating RTC handler after connection setup');
|
||||||
|
rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
close();
|
close();
|
||||||
if (rtcRef) {
|
if (rtcRef.current) {
|
||||||
rtcRef.current.close()
|
rtcRef.current.close();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fn()
|
};
|
||||||
}, [])
|
fn();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <div className={classes.container}>
|
const bubbles = generateBubbles(clients, id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
<CloudBackground/>
|
<CloudBackground/>
|
||||||
<h1 className={classes.title}>{name}
|
<h1 className={classes.title}>
|
||||||
|
{name}
|
||||||
<span> - {id}</span>
|
<span> - {id}</span>
|
||||||
</h1>
|
</h1>
|
||||||
{clients && generateBubbles().map(bubble => {
|
|
||||||
return (
|
{bubbles.map(bubble => (
|
||||||
<div
|
<UserBubble
|
||||||
key={bubble.id}
|
key={bubble.id}
|
||||||
className={classes.bubble}
|
bubble={bubble}
|
||||||
style={{
|
onClick={handleBubbleClick}
|
||||||
left: bubble.x,
|
/>
|
||||||
top: bubble.y,
|
))}
|
||||||
backgroundColor: bubble.color,
|
|
||||||
animationDelay:
|
<Drawer
|
||||||
`${Math.random() * 0.5}s,
|
isOpen={open.send}
|
||||||
${0.5 + Math.random() * 2}s`
|
width={"80%"}
|
||||||
}}
|
height={"400px"}
|
||||||
onClick={() => handleBubbleClick(bubble)}
|
close={() => setOpen({send: false, receive: false})}
|
||||||
>
|
>
|
||||||
{bubble.name}
|
<Sender onSend={handleSend}/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
isOpen={open.receive}
|
||||||
|
width={"60%"}
|
||||||
|
close={() => setOpen({send: false, receive: false})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MessageDialog
|
||||||
|
open={showMessageDialog}
|
||||||
|
message={receivedMessage}
|
||||||
|
onClose={handleCloseMessageDialog}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
<Drawer isOpen={open.send} width={"80%"} height={"400px"} close={() => setOpen({send: false, receive: false})}>
|
|
||||||
<Sender onSend={handleSend}></Sender>
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
|
||||||
}
|
|
22
frontend/src/page/test/test.tsx
Normal file
22
frontend/src/page/test/test.tsx
Normal file
@ -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<boolean>(false)
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={classes.container}>
|
||||||
|
<button onClick={handleOpen}>open</button>
|
||||||
|
<Dialog open={open} title="hello world" >
|
||||||
|
<input />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user