wip: 0.2.1

1. websocket 连接,退出 优化
  2. 基本页面
This commit is contained in:
loveuer
2025-05-15 17:39:56 +08:00
parent ec3f76e0c0
commit 3053394f03
9 changed files with 443 additions and 258 deletions

View File

@ -0,0 +1,6 @@
export interface Resp<T>{
status: number;
msg: string;
err: string;
data: T;
}

View File

@ -1,7 +1,8 @@
import {CloudBackground} from "../component/fluid/cloud.tsx";
import {useEffect, useState} from "react";
import {useEffect} from "react";
import {createUseStyles} from "react-jss";
import {useRoom} from "../store/ws.ts";
import {useRTC} from "../store/rtc.ts";
import {Client, useRoom} from "../store/local.ts";
const useClass = createUseStyles({
'@global': {
@ -27,6 +28,12 @@ const useClass = createUseStyles({
overflow: "hidden",
position: "relative",
},
title: {
width: '100%',
display: "flex",
justifyContent: "center",
color: '#1661ab',
},
bubble: {
position: "absolute",
width: "100px",
@ -50,13 +57,10 @@ const useClass = createUseStyles({
}
})
interface Client {
interface Bubble {
id: string;
name: string;
}
interface BubblePosition {
id: string;
x: number;
y: number;
color: string;
@ -66,10 +70,8 @@ interface BubblePosition {
export const LocalSharing: React.FC = () => {
const classes = useClass();
const [clients, setClients] = useState<Client[]>([]);
const [bubbles, setBubbles] = useState<BubblePosition[]>([]);
const {register, cleanup} = useRoom();
const BUBBLE_SIZE = 100;
const {register, enter, list, cleanup, client, clients} = useRoom();
const {connect, create} = useRTC();
// 生成随机颜色
const generateColor = () => {
@ -80,131 +82,92 @@ export const LocalSharing: React.FC = () => {
};
// 防碰撞位置生成
const generatePosition = (existing: BubblePosition[]) => {
const generateBubbles = (cs: Client[]) => {
if (!cs) return []
const BUBBLE_SIZE = 100;
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const maxRadius = Math.min(centerX, centerY) - BUBBLE_SIZE;
// 初始化参数
let radius = 0;
let angle = Math.random() * Math.PI * 2;
let attempts = 0;
const bubbles: Bubble[] = [];
let currentRadius = 0;
let angleStep = (2 * Math.PI) / 6; // 初始6个位置
do {
// 极坐标转笛卡尔坐标
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
for (let index = 0; index < cs.length; index++) {
let attempt = 0;
let validPosition = false;
// 边界检测
if (x < 0 || x > window.innerWidth - BUBBLE_SIZE ||
y < 0 || y > window.innerHeight - BUBBLE_SIZE) {
radius = 0;
angle += Math.PI / 6;
continue;
if (cs[index].id == client?.id) {
continue
}
// 碰撞检测
const collision = existing.some(bubble => {
const distance = Math.sqrt(
Math.pow(bubble.x - x, 2) +
Math.pow(bubble.y - y, 2)
);
return distance < BUBBLE_SIZE * 1.5;
});
while (!validPosition && attempt < 100) {
// 螺旋布局算法
const angle = angleStep * (index + attempt);
const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8);
if (!collision) {
return {
x,
y,
radius,
angle
};
}
// 极坐标转笛卡尔坐标
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
// 逐步扩大搜索半径和角度
radius += BUBBLE_SIZE * 0.7;
if (radius > maxRadius) {
radius = 0;
angle += Math.PI / 6; // 每30度尝试一次
}
// 边界检测
const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE &&
y >= 0 && y <= window.innerHeight - BUBBLE_SIZE;
attempts++;
} while (attempts < 200);
return null;
};
// 修改updateBubbles中的生成逻辑
const updateBubbles = (newClients: Client[]) => {
const newBubbles: BubblePosition[] = [];
newClients.forEach(client => {
const existing = bubbles.find(b => b.id === client.id);
if (existing) {
newBubbles.push(existing);
return;
}
const position = generatePosition([...bubbles, ...newBubbles]);
if (position) {
newBubbles.push({
id: client.id,
...position,
color: generateColor()
// 碰撞检测
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;
});
}
});
setBubbles(newBubbles);
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(() => {
// 模拟API获取数据
const fetchData = async () => {
// const response = await fetch('/api/clients');
// const data = await response.json();
await register();
const mockData: Client[] = [
{ id: '1', name: '宁静的梦境' },
{ id: '2', name: '温暖的时光' },
{ id: '3', name: '甜蜜的旋律' },
{ id: '4', name: '柔和的花园' }
];
setClients(mockData);
updateBubbles(mockData);
return () => cleanup();
};
fetchData();
register().then(() => {
enter().then(() => {
list().then()
})
});
connect().then(() => {
console.log("[D] rtc create!!!")
})
return () => cleanup();
}, []);
// 窗口尺寸变化处理
useEffect(() => {
const handleResize = () => {
const validBubbles = bubbles.filter(bubble =>
bubble.x <= window.innerWidth - BUBBLE_SIZE &&
bubble.y <= window.innerHeight - BUBBLE_SIZE
);
setBubbles(validBubbles);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [bubbles]);
// 气泡点击处理
const handleBubbleClick = (id: string) => {
// 实际开发中这里调用API删除
setClients(prev => prev.filter(c => c.id !== id));
setBubbles(prev => prev.filter(b => b.id !== id));
const handleBubbleClick = async (id: string) => {
console.log('[D] click bubble!!!', id)
await create()
};
return <div className={classes.container}>
<CloudBackground />
{bubbles.map(bubble => {
const client = clients.find(c => c.id === bubble.id);
<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}
@ -219,7 +182,7 @@ export const LocalSharing: React.FC = () => {
}}
onClick={() => handleBubbleClick(bubble.id)}
>
{client.name}
{bubble.name}
</div>
) : null;
})}

140
frontend/src/store/local.ts Normal file
View File

@ -0,0 +1,140 @@
import {create} from 'zustand'
import {Resp} from "../interface/response.ts";
export interface Client {
client_type: 'desktop' | 'mobile' | 'tablet';
app_type: 'web';
room: string;
ip: number;
name: string;
id: string;
register_at: string;
}
type RoomState = {
conn: WebSocket | null
client: Client | null
clients: Client[]
retryCount: number
reconnectTimer: number | null
}
type RoomActions = {
register: () => Promise<void>
enter: () => Promise<void>
list: () => Promise<void>
cleanup: () => void
}
interface Message {
type: 'ping' | 'self' | 'enter' | 'leave';
time: number;
body: any;
}
const MAX_RETRY_DELAY = 30000 // 最大重试间隔30秒
const NORMAL_CLOSE_CODE = 1000 // 正常关闭的状态码
export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
conn: null,
client: null,
clients: [],
retryCount: 0,
reconnectTimer: null,
register: async () => {
const api = `/api/ulocal/register`
const res = await fetch(api, {method: 'POST'})
const jes = await res.json() as Resp<Client>
return set(state => {
return {...state, client: jes.data}
})
},
enter: async () => {
const {conn, reconnectTimer} = get()
// 清理旧连接和定时器
if (reconnectTimer) clearTimeout(reconnectTimer)
if (conn) conn.close()
const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${get().client?.id}`
console.log('[D] websocket api =',api)
const newConn = new WebSocket(api)
newConn.onopen = () => {
set({conn: newConn, retryCount: 0}) // 重置重试计数器
}
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(state => {
return {...state, clients: jes.data}
})
},
cleanup: () => {
const {conn, reconnectTimer} = get()
if (reconnectTimer) clearTimeout(reconnectTimer)
if (conn) conn.close()
set({conn: null, retryCount: 0, reconnectTimer: null})
}
}))

50
frontend/src/store/rtc.ts Normal file
View File

@ -0,0 +1,50 @@
import {create} from 'zustand'
type RTCState = {
conn: RTCPeerConnection | null
}
type RTCAction = {
connect: () => Promise<void>
create: () => Promise<void>
cleanup: () => void
}
export const useRTC = create<RTCState & RTCAction>()((set, get) => ({
conn: null,
connect: async () => {
const conn = new RTCPeerConnection()
const ch = conn.createDataChannel("fileTransfer", {ordered: true})
console.log('[D] channel =', ch)
ch.onopen = (event) => {
console.log('🚀🚀🚀 / rtc open event', event)
}
ch.onclose = (event) => {
}
ch.onerror = (event) => {
}
ch.onmessage = (event) => {
console.log('🚀🚀🚀 / rtc message event', event)
}
set((state) => {
return {...state, conn: conn}
})
},
create: async () => {
const conn = get().conn
if (conn) conn.onicecandidate = async (event) => {
console.log('[D] rtc local desc =', conn.localDescription)
const offer = await conn.createOffer()
await conn.setLocalDescription(offer)
}
},
cleanup: () => {
},
}))

View File

@ -1,74 +0,0 @@
import { create } from 'zustand'
type RoomState = {
conn: WebSocket | null
retryCount: number
reconnectTimer: number | null
}
type RoomActions = {
register: () => Promise<void>
cleanup: () => void
}
const MAX_RETRY_DELAY = 30000 // 最大重试间隔30秒
const NORMAL_CLOSE_CODE = 1000 // 正常关闭的状态码
export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
conn: null,
retryCount: 0,
reconnectTimer: null,
register: async () => {
const { conn, reconnectTimer } = get()
// 清理旧连接和定时器
if (reconnectTimer) clearTimeout(reconnectTimer)
if (conn) conn.close()
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'
const wsUrl = protocol + window.location.host + '/api/ulocal/registry'
const newConn = new WebSocket(wsUrl)
newConn.onopen = () => {
console.log('WebSocket connected')
set({ conn: newConn, retryCount: 0 }) // 重置重试计数器
}
newConn.onerror = (error) => {
console.error('WebSocket error:', error)
}
newConn.onmessage = (event) => {
console.log("[D] websocket message =", event)
}
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 })
},
cleanup: () => {
const { conn, reconnectTimer } = get()
if (reconnectTimer) clearTimeout(reconnectTimer)
if (conn) conn.close()
set({ conn: null, retryCount: 0, reconnectTimer: null })
}
}))

View File

@ -10,7 +10,7 @@ export default defineConfig({
target: 'http://127.0.0.1:9119',
changeOrigin: true
},
'/api/ulocal/registry': {
'/api/ulocal/ws': {
target: 'ws://127.0.0.1:9119',
rewriteWsOrigin: true,
ws: true,