fix: 0.1.4
1. meta clean goroutine walk error 2. clean interval to args(--clean) feat: local msg/file share by webrtc
This commit is contained in:
parent
7c089bbe84
commit
7cfb0e482f
@ -15,6 +15,7 @@ server {
|
|||||||
|
|
||||||
location /ushare {
|
location /ushare {
|
||||||
proxy_pass http://localhost:9119;
|
proxy_pass http://localhost:9119;
|
||||||
|
const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {message} from "../component/message/u-message.tsx";
|
import {message} from "../hook/message/u-message.tsx";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -16,7 +16,6 @@ export const useFileUpload = () => {
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[D] api.Upload: upload file = ${file.name}, size = ${file.size}`, file);
|
|
||||||
const url = `/api/ushare/${file.name}`;
|
const url = `/api/ushare/${file.name}`;
|
||||||
|
|
||||||
// 1. 初始化上传
|
// 1. 初始化上传
|
||||||
@ -26,7 +25,7 @@ export const useFileUpload = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res1.ok) {
|
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) {
|
if (res1.status === 401) {
|
||||||
window.location.href = "/login?next=/share"
|
window.location.href = "/login?next=/share"
|
||||||
return ""
|
return ""
|
||||||
|
137
frontend/src/component/dialog/dialog.tsx
Normal file
137
frontend/src/component/dialog/dialog.tsx
Normal file
@ -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<DialogProps> = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
className = '',
|
||||||
|
footer = true,
|
||||||
|
}) => {
|
||||||
|
const classes = useClass();
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||||
|
if (e.target === dialogRef.current) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 1100,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={handleMaskClick}
|
||||||
|
>
|
||||||
|
<article className={classes.dialog} style={{opacity: 1, transform: 'translateY(0)'}}>
|
||||||
|
{title && <header className={classes.dialog_header}>{title}</header>}
|
||||||
|
<div className={classes.dialog_body}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer !== false && (
|
||||||
|
<footer className={classes.dialog_footer}>
|
||||||
|
<button
|
||||||
|
className={classes.close_button}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭对话框"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
84
frontend/src/component/drawer/drawer.tsx
Normal file
84
frontend/src/component/drawer/drawer.tsx
Normal file
@ -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<DrawerProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`${classes.backdrop} ${isOpen ? classes.visible : ''}`}
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classes.drawer_content}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
transform: isOpen ? 'translateY(0)' : 'translateY(100%)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
77
frontend/src/hook/websocket/u-ws.tsx
Normal file
77
frontend/src/hook/websocket/u-ws.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface Prop {
|
||||||
|
/** 事件处理函数(可选) */
|
||||||
|
fn?: (event: MessageEvent) => Promise<void>;
|
||||||
|
/** 最大重试次数(可选) */
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWebsocket = (prop?: Prop) => {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<number>(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
|
||||||
|
};
|
||||||
|
};
|
@ -3,14 +3,16 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||||
import {Login} from "./page/login.tsx";
|
import {Login} from "./page/login.tsx";
|
||||||
import {FileSharing} from "./page/share.tsx";
|
import {FileSharing} from "./page/share/share.tsx";
|
||||||
import {LocalSharing} from "./page/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 />},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -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 <div className={classes.container}>
|
|
||||||
<CloudBackground/>
|
|
||||||
<h1 className={classes.title}>{client?.name}</h1>
|
|
||||||
{clients && generateBubbles(clients).map(bubble => {
|
|
||||||
// const client = clients.find(c => c.id === bubble.id);
|
|
||||||
return client ? (
|
|
||||||
<div
|
|
||||||
key={bubble.id}
|
|
||||||
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={() => handleBubbleClick(bubble.id)}
|
|
||||||
>
|
|
||||||
{bubble.name}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
}
|
|
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;
|
||||||
|
};
|
163
frontend/src/page/local/component/message-dialog.tsx
Normal file
163
frontend/src/page/local/component/message-dialog.tsx
Normal file
@ -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<MessageDialogProps> = ({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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="收到新消息"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={false}
|
||||||
|
>
|
||||||
|
<div className={classes.root}>
|
||||||
|
<p className={classes.sender}>
|
||||||
|
来自: {message?.sender}
|
||||||
|
</p>
|
||||||
|
<div className={classes.msgBox}>
|
||||||
|
{message?.isFile ? (
|
||||||
|
<>
|
||||||
|
<div>📎 文件: {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)</div>
|
||||||
|
{message.receiving ? (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize: '0.95em', color: '#888'}}>正在接收... {Math.round((message.progress||0)*100)}%</div>
|
||||||
|
<div className={classes.progressBar}>
|
||||||
|
<div className={classes.progressInner} style={{width: `${Math.round((message.progress||0)*100)}%`}} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a href={message.fileBlobUrl} download={message.fileName} className={classes.downloadLink}>
|
||||||
|
点击下载
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
message?.text
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.btnRow}>
|
||||||
|
{copySuccess && (
|
||||||
|
<span className={classes.copySuccess}>
|
||||||
|
✓ 已复制
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!message?.isFile && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMessage}
|
||||||
|
className={copySuccess ? `${classes.copyBtn} ${classes.copyBtnSuccess}` : classes.copyBtn}
|
||||||
|
>
|
||||||
|
{copySuccess ? '已复制' : '复制消息'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={classes.closeBtn}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
323
frontend/src/page/local/component/rtc-handler.ts
Normal file
323
frontend/src/page/local/component/rtc-handler.ts
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import { WSMessage, ReceivedMessage} from "./types.ts";
|
||||||
|
import {useLocalStore} from "../../../store/local.ts";
|
||||||
|
|
||||||
|
// 文件接收缓存
|
||||||
|
const fileReceiveCache: Record<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
|
||||||
|
|
||||||
|
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<RTCPeerConnection | null>;
|
||||||
|
private callbacks: RTCHandlerCallbacks;
|
||||||
|
|
||||||
|
constructor(rtcRef: React.MutableRefObject<RTCPeerConnection | null>, 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
18
frontend/src/page/local/component/send-dialog.tsx
Normal file
18
frontend/src/page/local/component/send-dialog.tsx
Normal file
@ -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<SendDialogProps> = ({open, onSend, onClose, name}) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} title={`发送消息给${name}`} onClose={onClose}>
|
||||||
|
<Sender onSend={onSend} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
161
frontend/src/page/local/component/sender.tsx
Normal file
161
frontend/src/page/local/component/sender.tsx
Normal file
@ -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<SenderProps> = ({onSend}) => {
|
||||||
|
const classes = useClass();
|
||||||
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
|
const [files, setFiles] = useState<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setInputMessage(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
|
||||||
|
onChange={handleTextInput} value={inputMessage}></textarea>
|
||||||
|
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
|
||||||
|
{files && <div className={classes.file_item}>
|
||||||
|
<span>{files.name}</span>
|
||||||
|
<button className={classes.delete_btn} onClick={() => {
|
||||||
|
setFiles(null)
|
||||||
|
}}>×
|
||||||
|
</button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className={classes.buttons}>
|
||||||
|
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
|
||||||
|
<button className={classes.action_btn} onClick={() => {
|
||||||
|
fileInputRef.current && fileInputRef.current.click()
|
||||||
|
}}>📁 选择文件
|
||||||
|
</button>
|
||||||
|
<button className={classes.action_btn} onClick={handleSubmit}>✈️ 发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
38
frontend/src/page/local/component/types.ts
Normal file
38
frontend/src/page/local/component/types.ts
Normal file
@ -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; // 是否正在接收
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
};
|
242
frontend/src/page/local/local.tsx
Normal file
242
frontend/src/page/local/local.tsx
Normal file
@ -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, setChannel} = useLocalStore();
|
||||||
|
const [rtc, setRTC] = useState<RTCPeerConnection>();
|
||||||
|
const rtcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const {connect, close} = useWebsocket({});
|
||||||
|
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 [receivingFile, setReceivingFile] = useState<ReceivedMessage | null>(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<RTCHandler | null>(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<Client[]>;
|
||||||
|
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 (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<CloudBackground/>
|
||||||
|
<h1 className={classes.title}>
|
||||||
|
{name}
|
||||||
|
{/* <span> - {id}</span> */}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{bubbles.map(bubble => (
|
||||||
|
<UserBubble
|
||||||
|
key={bubble.id}
|
||||||
|
bubble={bubble}
|
||||||
|
onClick={handleBubbleClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SendDialog
|
||||||
|
open={open.send}
|
||||||
|
onSend={handleSend}
|
||||||
|
onClose={() => setOpen({send: false, receive: false})}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文件接收进度弹窗 */}
|
||||||
|
{receivingFile && (
|
||||||
|
<MessageDialog
|
||||||
|
open={true}
|
||||||
|
message={receivingFile}
|
||||||
|
onClose={() => setReceivingFile(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MessageDialog
|
||||||
|
open={showMessageDialog}
|
||||||
|
message={receivedMessage}
|
||||||
|
onClose={handleCloseMessageDialog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,9 +1,9 @@
|
|||||||
import {createUseStyles} from "react-jss";
|
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 React, {useState} from "react";
|
||||||
import {useStore} from "../../store/share.ts";
|
import {useStore} from "../../../store/share.ts";
|
||||||
import {message} from "../../component/message/u-message.tsx";
|
import {message} from "../../../hook/message/u-message.tsx";
|
||||||
import {useFileUpload} from "../../api/upload.ts";
|
import {useFileUpload} from "../../../api/upload.ts";
|
||||||
|
|
||||||
const useUploadStyle = createUseStyles({
|
const useUploadStyle = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@ -136,7 +136,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
console.log('[D] onFileChange: e =', e)
|
|
||||||
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
import {createUseStyles} from "react-jss";
|
import {createUseStyles} from "react-jss";
|
||||||
import {UButton} from "../../component/button/u-button.tsx";
|
import {UButton} from "../../../component/button/u-button.tsx";
|
||||||
import {useStore} from "../../store/share.ts";
|
import {useStore} from "../../../store/share.ts";
|
||||||
|
|
||||||
const useStyle = createUseStyles({
|
const useStyle = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@ -42,7 +42,6 @@ export const PanelRight = () => {
|
|||||||
|
|
||||||
async function onFetchFile() {
|
async function onFetchFile() {
|
||||||
const url = `/ushare/${code}`
|
const url = `/ushare/${code}`
|
||||||
console.log('[D] onFetchFile: url =', url)
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
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>
|
||||||
|
}
|
@ -1,222 +1,24 @@
|
|||||||
import {create} from 'zustand'
|
import {create} from 'zustand'
|
||||||
import {Resp} from "../interface/response.ts";
|
|
||||||
|
|
||||||
export interface Client {
|
export interface LocalStore {
|
||||||
client_type: 'desktop' | 'mobile' | 'tablet';
|
|
||||||
app_type: 'web';
|
|
||||||
room: string;
|
|
||||||
ip: number;
|
|
||||||
name: string;
|
|
||||||
id: string;
|
id: string;
|
||||||
register_at: string;
|
name: string;
|
||||||
|
channel?: RTCDataChannel;
|
||||||
|
set: (id: string, name: string) => void;
|
||||||
|
setChannel: (chan?: RTCDataChannel) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomState = {
|
export const useLocalStore = create<LocalStore>()((_set, get) => ({
|
||||||
conn: WebSocket | null
|
id: '',
|
||||||
client: Client | null
|
name: '',
|
||||||
clients: Client[]
|
set: (id: string, name: string) => {
|
||||||
pc: RTCPeerConnection | null
|
_set(state => {
|
||||||
ch: RTCDataChannel | null
|
return {...state, id: id, name: name};
|
||||||
candidate: RTCIceCandidate | null
|
|
||||||
offer: RTCSessionDescription | null
|
|
||||||
retryCount: number
|
|
||||||
reconnectTimer: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoomActions = {
|
|
||||||
register: () => Promise<void>
|
|
||||||
enter: () => Promise<void>
|
|
||||||
list: () => Promise<void>
|
|
||||||
send: (file: File) => Promise<void>
|
|
||||||
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<RoomState & RoomActions>()((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<void>(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<void>(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<Client>}).then(jes => {
|
|
||||||
set({client: jes.data, candidate: candidate, offer: offer})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
enter: async () => {
|
setChannel: (ch?: RTCDataChannel) => {
|
||||||
const {conn, reconnectTimer} = get()
|
_set(state => {
|
||||||
|
return {...state, channel: ch}
|
||||||
// 清理旧连接和定时器
|
})
|
||||||
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<Client[]>
|
|
||||||
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})
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
@ -26,6 +26,9 @@ func Start(ctx context.Context) <-chan struct{} {
|
|||||||
{
|
{
|
||||||
api := app.Group("/api/ulocal")
|
api := app.Group("/api/ulocal")
|
||||||
api.Post("/register", handler.LocalRegister())
|
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("/clients", handler.LocalClients())
|
||||||
api.Get("/ws", handler.LocalWS())
|
api.Get("/ws", handler.LocalWS())
|
||||||
}
|
}
|
||||||
|
@ -135,14 +135,25 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
|
|
||||||
// 清理一天前的文件
|
// 清理一天前的文件
|
||||||
go func() {
|
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)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
duration := time.Duration(opt.Cfg.CleanInterval) * time.Hour
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case now := <-ticker.C:
|
case now := <-ticker.C:
|
||||||
|
//log.Debug("meta.Clean: 开始清理过期文件 = %v", duration)
|
||||||
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
|
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if info == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -168,12 +179,16 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
|
|
||||||
code := strings.TrimPrefix(name, ".meta.")
|
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)
|
log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader)
|
||||||
|
|
||||||
os.RemoveAll(opt.FilePath(code))
|
if err = os.RemoveAll(opt.FilePath(code)); err != nil {
|
||||||
os.RemoveAll(path)
|
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()
|
m.Lock()
|
||||||
delete(m.m, code)
|
delete(m.m, code)
|
||||||
|
@ -32,12 +32,22 @@ const (
|
|||||||
type RoomMessageType string
|
type RoomMessageType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoomMessageTypePing RoomMessageType = "ping"
|
|
||||||
RoomMessageTypeSelf RoomMessageType = "self"
|
|
||||||
RoomMessageTypeEnter RoomMessageType = "enter"
|
RoomMessageTypeEnter RoomMessageType = "enter"
|
||||||
RoomMessageTypeLeave RoomMessageType = "leave"
|
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 {
|
type roomClient struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
controller *roomController
|
controller *roomController
|
||||||
@ -45,12 +55,9 @@ type roomClient struct {
|
|||||||
ClientType RoomClientType `json:"client_type"`
|
ClientType RoomClientType `json:"client_type"`
|
||||||
AppType RoomAppType `json:"app_type"`
|
AppType RoomAppType `json:"app_type"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Room string `json:"room"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
RegisterAt time.Time `json:"register_at"`
|
RegisterAt time.Time `json:"register_at"`
|
||||||
Offer any `json:"offer"`
|
|
||||||
Candidate any `json:"candidate"`
|
|
||||||
msgChan chan any
|
msgChan chan any
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,40 +127,24 @@ func (rc *roomClient) start(ctx context.Context) {
|
|||||||
|
|
||||||
type roomController struct {
|
type roomController struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
|
//rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
|
||||||
notReadies map[string]*roomClient
|
pre map[string]*roomClient
|
||||||
|
clients map[string]*roomClient
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
RoomController = &roomController{
|
RoomController = &roomController{
|
||||||
rooms: make(map[string]map[string]*roomClient),
|
pre: make(map[string]*roomClient),
|
||||||
notReadies: make(map[string]*roomClient),
|
clients: make(map[string]*roomClient),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (rc *roomController) Start(ctx context.Context) {
|
func (rc *roomController) Start(ctx context.Context) {
|
||||||
rc.ctx = ctx
|
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{
|
nrc := &roomClient{
|
||||||
controller: rc,
|
controller: rc,
|
||||||
ClientType: ClientTypeDesktop,
|
ClientType: ClientTypeDesktop,
|
||||||
@ -163,8 +154,6 @@ func (rc *roomController) Register(ip, userAgent string, candidate, offer any) *
|
|||||||
Name: tool.RandomName(),
|
Name: tool.RandomName(),
|
||||||
msgChan: make(chan any, 1),
|
msgChan: make(chan any, 1),
|
||||||
RegisterAt: time.Now(),
|
RegisterAt: time.Now(),
|
||||||
Candidate: candidate,
|
|
||||||
Offer: offer,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ua := useragent.Parse(userAgent)
|
ua := useragent.Parse(userAgent)
|
||||||
@ -175,89 +164,114 @@ func (rc *roomController) Register(ip, userAgent string, candidate, offer any) *
|
|||||||
nrc.ClientType = ClientTypeTablet
|
nrc.ClientType = ClientTypeTablet
|
||||||
}
|
}
|
||||||
|
|
||||||
key := "local"
|
|
||||||
if !tool.IsPrivateIP(ip) {
|
|
||||||
key = ip
|
|
||||||
}
|
|
||||||
|
|
||||||
nrc.Room = key
|
|
||||||
|
|
||||||
rc.Lock()
|
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.pre[nrc.Id] = nrc
|
||||||
rc.notReadies[nrc.Id] = nrc
|
|
||||||
if _, ok := rc.rooms[nrc.Room]; !ok {
|
|
||||||
rc.rooms[nrc.Room] = make(map[string]*roomClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
rc.Unlock()
|
|
||||||
|
|
||||||
return nrc
|
return nrc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *roomController) Enter(conn *websocket.Conn, id string) {
|
func (rc *roomController) Enter(conn *websocket.Conn, id string) *roomClient {
|
||||||
client, ok := rc.notReadies[id]
|
log.Debug("controller.room: registry client, id = %s", 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)
|
|
||||||
|
|
||||||
rc.Lock()
|
rc.Lock()
|
||||||
defer rc.Unlock()
|
defer rc.Unlock()
|
||||||
|
|
||||||
clients, ok := rc.rooms[room]
|
nrc, ok := rc.pre[id]
|
||||||
if !ok {
|
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)
|
clientList = append(clientList, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientList
|
return clientList
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *roomController) Broadcast(room string, msg any) {
|
func (rc *roomController) Broadcast(msg any) {
|
||||||
for _, client := range rc.rooms[room] {
|
for _, client := range rc.clients {
|
||||||
select {
|
select {
|
||||||
case client.msgChan <- msg:
|
case client.msgChan <- msg:
|
||||||
case <-time.After(2 * time.Second):
|
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) {
|
func (rc *roomController) Unregister(client *roomClient) {
|
||||||
key := "local"
|
log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name)
|
||||||
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)
|
|
||||||
|
|
||||||
rc.Lock()
|
rc.Lock()
|
||||||
delete(rc.rooms[key], client.Id)
|
delete(rc.clients, client.Id)
|
||||||
rc.Unlock()
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,23 +11,12 @@ import (
|
|||||||
|
|
||||||
func LocalRegister() nf.HandlerFunc {
|
func LocalRegister() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
type Req struct {
|
|
||||||
Candidate any `json:"candidate"`
|
|
||||||
Offer any `json:"offer"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
err error
|
ip = c.IP(true)
|
||||||
req = new(Req)
|
ua = c.Get("User-Agent")
|
||||||
ip = c.IP(true)
|
|
||||||
ua = c.Get("User-Agent")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err = c.BodyParser(req); err != nil {
|
client := controller.RoomController.Register(ip, ua)
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]interface{}{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
client := controller.RoomController.Register(ip, ua, req.Candidate, req.Offer)
|
|
||||||
|
|
||||||
return resp.Resp200(c, client)
|
return resp.Resp200(c, client)
|
||||||
}
|
}
|
||||||
@ -35,12 +24,7 @@ func LocalRegister() nf.HandlerFunc {
|
|||||||
|
|
||||||
func LocalClients() nf.HandlerFunc {
|
func LocalClients() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
room := c.Query("room")
|
list := controller.RoomController.List()
|
||||||
if room == "" {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": "room can't be empty"})
|
|
||||||
}
|
|
||||||
|
|
||||||
list := controller.RoomController.List(room)
|
|
||||||
|
|
||||||
return resp.Resp200(c, list)
|
return resp.Resp200(c, list)
|
||||||
}
|
}
|
||||||
@ -56,7 +40,6 @@ func LocalWS() nf.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
|
|
||||||
id := c.Query("id")
|
id := c.Query("id")
|
||||||
|
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@ -74,3 +57,70 @@ func LocalWS() nf.HandlerFunc {
|
|||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -7,10 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Debug bool
|
Debug bool
|
||||||
Address string
|
Address string
|
||||||
DataPath string
|
DataPath string
|
||||||
Auth string
|
Auth string
|
||||||
|
CleanInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
4
main.go
4
main.go
@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/loveuer/ushare/internal/api"
|
"github.com/loveuer/ushare/internal/api"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@ -16,11 +17,12 @@ func init() {
|
|||||||
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
|
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
|
||||||
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
|
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
|
||||||
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
|
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
|
||||||
|
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if opt.Cfg.Debug {
|
if opt.Cfg.Debug {
|
||||||
log.SetLogLevel(log.LogLevelDebug)
|
log.SetLogLevel(log.LogLevelDebug)
|
||||||
log.Debug("start server with debug mode")
|
tool.TablePrinter(opt.Cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
179
page/sender.html
Normal file
179
page/sender.html
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>智能文本区域</title>
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
width: 400px;
|
||||||
|
height: 300px;
|
||||||
|
margin: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
transition: padding 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box.has-files {
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list.has-files {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f0f6ff;
|
||||||
|
border: 1px solid #c2d9ff;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
animation: slideIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateY(-10px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #4dabf7;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #339af0;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<textarea class="input-box" placeholder="开始输入..."></textarea>
|
||||||
|
<div class="file-list" id="fileList"></div>
|
||||||
|
<div class="buttons">
|
||||||
|
<input type="file" id="fileInput" hidden multiple>
|
||||||
|
<button class="action-btn" onclick="document.getElementById('fileInput').click()">📁 选择文件</button>
|
||||||
|
<button class="action-btn" onclick="send()">✈️ 发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const inputBox = document.querySelector('.input-box');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const fileList = document.getElementById('fileList');
|
||||||
|
|
||||||
|
// 文件选择事件
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
updateFileList();
|
||||||
|
adjustInputPadding();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动态调整输入框间距
|
||||||
|
function adjustInputPadding() {
|
||||||
|
inputBox.classList.toggle('has-files', fileInput.files.length > 0);
|
||||||
|
fileList.classList.toggle('has-files')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文件列表显示
|
||||||
|
function updateFileList() {
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
|
||||||
|
Array.from(fileInput.files).forEach((file, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'file-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span>${file.name}</span>
|
||||||
|
<button class="delete-btn" onclick="removeFile(${index})">×</button>
|
||||||
|
`;
|
||||||
|
fileList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除单个文件
|
||||||
|
function removeFile(index) {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(fileInput.files).forEach((file, i) => {
|
||||||
|
if (i !== index) dt.items.add(file);
|
||||||
|
});
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
updateFileList();
|
||||||
|
adjustInputPadding();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送功能
|
||||||
|
function send() {
|
||||||
|
const text = inputBox.value;
|
||||||
|
const files = fileInput.files;
|
||||||
|
|
||||||
|
|
||||||
|
// 清空内容
|
||||||
|
inputBox.value = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
adjustInputPadding();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user