wip: 0.2.0
1. websocket 连接,退出,消息 2. 基本页面
This commit is contained in:
parent
9146c87cad
commit
ec3f76e0c0
@ -1,19 +1,21 @@
|
|||||||
import { StrictMode } from 'react'
|
// import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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.tsx";
|
||||||
|
import {LocalSharing} from "./page/local.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: "*", element: <FileSharing />},
|
{path: "/share", element: <FileSharing />},
|
||||||
|
{path: "*", element: <LocalSharing />},
|
||||||
])
|
])
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
// <StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</StrictMode>,
|
// </StrictMode>,
|
||||||
)
|
)
|
||||||
|
227
frontend/src/page/local.tsx
Normal file
227
frontend/src/page/local.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import {CloudBackground} from "../component/fluid/cloud.tsx";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {createUseStyles} from "react-jss";
|
||||||
|
import {useRoom} from "../store/ws.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",
|
||||||
|
},
|
||||||
|
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 Client {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BubblePosition {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
radius: number; // 新增半径属性
|
||||||
|
angle: number; // 新增角度属性
|
||||||
|
}
|
||||||
|
|
||||||
|
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 generateColor = () => {
|
||||||
|
const hue = Math.random() * 360;
|
||||||
|
return `hsla(${hue},
|
||||||
|
${Math.random() * 30 + 40}%,
|
||||||
|
${Math.random() * 10 + 75}%, 0.9)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 防碰撞位置生成
|
||||||
|
const generatePosition = (existing: BubblePosition[]) => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 极坐标转笛卡尔坐标
|
||||||
|
const x = centerX + radius * Math.cos(angle);
|
||||||
|
const y = centerY + radius * Math.sin(angle);
|
||||||
|
|
||||||
|
// 边界检测
|
||||||
|
if (x < 0 || x > window.innerWidth - BUBBLE_SIZE ||
|
||||||
|
y < 0 || y > window.innerHeight - BUBBLE_SIZE) {
|
||||||
|
radius = 0;
|
||||||
|
angle += Math.PI / 6;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!collision) {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
radius,
|
||||||
|
angle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐步扩大搜索半径和角度
|
||||||
|
radius += BUBBLE_SIZE * 0.7;
|
||||||
|
if (radius > maxRadius) {
|
||||||
|
radius = 0;
|
||||||
|
angle += Math.PI / 6; // 每30度尝试一次
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setBubbles(newBubbles);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 窗口尺寸变化处理
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className={classes.container}>
|
||||||
|
<CloudBackground />
|
||||||
|
{bubbles.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)}
|
||||||
|
>
|
||||||
|
{client.name}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
74
frontend/src/store/ws.ts
Normal file
74
frontend/src/store/ws.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
|
}))
|
@ -10,6 +10,11 @@ export default defineConfig({
|
|||||||
target: 'http://127.0.0.1:9119',
|
target: 'http://127.0.0.1:9119',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
},
|
},
|
||||||
|
'/api/ulocal/registry': {
|
||||||
|
target: 'ws://127.0.0.1:9119',
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
'/ushare': {
|
'/ushare': {
|
||||||
target: 'http://127.0.0.1:9119',
|
target: 'http://127.0.0.1:9119',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
2
go.mod
2
go.mod
@ -23,6 +23,7 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
|
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
@ -30,6 +31,7 @@ require (
|
|||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mileusna/useragent v1.3.5 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
4
go.sum
4
go.sum
@ -46,6 +46,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
|
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
|
||||||
@ -70,6 +72,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||||
|
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||||
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
||||||
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
@ -23,6 +23,11 @@ func Start(ctx context.Context) <-chan struct{} {
|
|||||||
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
||||||
app.Post("/api/uauth/login", handler.AuthLogin())
|
app.Post("/api/uauth/login", handler.AuthLogin())
|
||||||
|
|
||||||
|
{
|
||||||
|
api := app.Group("/api/ulocal")
|
||||||
|
api.Get("/registry", handler.LocalRegistry())
|
||||||
|
}
|
||||||
|
|
||||||
ready := make(chan struct{})
|
ready := make(chan struct{})
|
||||||
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
172
internal/controller/room.go
Normal file
172
internal/controller/room.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// room controller:
|
||||||
|
// local share websocket room controller
|
||||||
|
// same remote ip as a
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
|
"github.com/mileusna/useragent"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomClientType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClientTypeDesktop RoomClientType = "desktop"
|
||||||
|
ClientTypeMobile RoomClientType = "mobile"
|
||||||
|
ClientTypeTablet RoomClientType = "tablet"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomAppType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoomAppTypeWeb = "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roomClient struct {
|
||||||
|
controller *roomController
|
||||||
|
conn *websocket.Conn
|
||||||
|
clientType RoomClientType
|
||||||
|
appType RoomAppType
|
||||||
|
ip string
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
msgChan chan any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *roomClient) start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, _ := <-rc.msgChan:
|
||||||
|
err := rc.conn.WriteJSON(msg)
|
||||||
|
log.Debug("RoomClient: write json message, ip = %s, id = %s, name = %s, err = %v", rc.ip, rc.id, rc.name, err)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("RoomClient: write json message failed, ip = %s, id = %s, name = %s, err = %s", rc.ip, rc.id, rc.name, err.Error())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
mt, bs, err := rc.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("RoomClient: read message failed, ip = %s, id = %s, name = %s, err = %s", rc.ip, rc.id, rc.name, err.Error())
|
||||||
|
rc.controller.Unregister(rc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mt {
|
||||||
|
case websocket.PingMessage:
|
||||||
|
rs, _ := json.Marshal(map[string]any{"type": "pong", "time": time.Now().UnixMilli(), "id": rc.id, "name": rc.name})
|
||||||
|
if err := rc.conn.WriteMessage(websocket.PongMessage, rs); err != nil {
|
||||||
|
log.Error("RoomClient: response ping message failed, ip = %s, id = %s, name = %s, err = %s", rc.ip, rc.id, rc.name, err.Error())
|
||||||
|
}
|
||||||
|
case websocket.CloseMessage:
|
||||||
|
log.Debug("RoomClient: received close message, unregister ip = %s id = %s, name = %s", rc.ip, rc.id, rc.name)
|
||||||
|
rc.controller.Unregister(rc)
|
||||||
|
return
|
||||||
|
case websocket.TextMessage:
|
||||||
|
log.Info("RoomClient: received text message, ip = %s, id = %s, name = %s, text = %s", rc.ip, rc.id, rc.name, string(bs))
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
// todo
|
||||||
|
log.Info("RoomClient: received bytes message, ip = %s, id = %s, name = %s, text = %s", rc.ip, rc.id, rc.name, string(bs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomController struct {
|
||||||
|
sync.Mutex
|
||||||
|
ctx context.Context
|
||||||
|
rooms map[string]map[string]*roomClient // map[room_id(remote-ip)][id]
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
RoomController = &roomController{
|
||||||
|
rooms: make(map[string]map[string]*roomClient),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rc *roomController) Start(ctx context.Context) {
|
||||||
|
rc.ctx = ctx
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
for room := range rc.rooms {
|
||||||
|
rc.Broadcast(room, now.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *roomController) Register(c *websocket.Conn, ip, userAgent string) {
|
||||||
|
nrc := &roomClient{
|
||||||
|
controller: rc,
|
||||||
|
conn: c,
|
||||||
|
clientType: ClientTypeDesktop,
|
||||||
|
appType: RoomAppTypeWeb,
|
||||||
|
ip: ip,
|
||||||
|
id: uuid.Must(uuid.NewV7()).String(),
|
||||||
|
name: tool.RandomName(),
|
||||||
|
msgChan: make(chan any, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := useragent.Parse(userAgent)
|
||||||
|
switch {
|
||||||
|
case ua.Mobile:
|
||||||
|
nrc.clientType = ClientTypeMobile
|
||||||
|
case ua.Tablet:
|
||||||
|
nrc.clientType = ClientTypeTablet
|
||||||
|
}
|
||||||
|
|
||||||
|
key := "local"
|
||||||
|
if !tool.IsPrivateIP(ip) {
|
||||||
|
key = ip
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.Lock()
|
||||||
|
|
||||||
|
if _, ok := rc.rooms[key]; !ok {
|
||||||
|
rc.rooms[key] = make(map[string]*roomClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
nrc.start(rc.ctx)
|
||||||
|
log.Debug("controller.room: registry client, ip = %s(%s), id = %s, name = %s", key, nrc.ip, nrc.id, nrc.name)
|
||||||
|
rc.rooms[key][nrc.id] = nrc
|
||||||
|
|
||||||
|
rc.Unlock()
|
||||||
|
|
||||||
|
rc.Broadcast(key, "new member")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *roomController) Broadcast(room string, msg any) {
|
||||||
|
for _, client := range rc.rooms[room] {
|
||||||
|
client.msgChan <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *roomController) Unregister(client *roomClient) {
|
||||||
|
key := "local"
|
||||||
|
if !tool.IsPrivateIP(client.ip) {
|
||||||
|
key = client.ip
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.Lock()
|
||||||
|
defer rc.Unlock()
|
||||||
|
|
||||||
|
log.Debug("controller.room: unregister client, ip = %s(%s), id = %s, name = %s", client.ip, key, client.id, client.name)
|
||||||
|
|
||||||
|
delete(rc.rooms[key], client.id)
|
||||||
|
}
|
35
internal/handler/local.go
Normal file
35
internal/handler/local.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/loveuer/nf"
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LocalRegistry() nf.HandlerFunc {
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
|
||||||
|
ip := c.IP(true)
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("LocalRegistry: failed to upgrade websocket connection, err = %s", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.RoomController.Register(conn, ip, ua)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
59
internal/pkg/tool/ip.go
Normal file
59
internal/pkg/tool/ip.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privateIPv4Blocks []*net.IPNet
|
||||||
|
privateIPv6Blocks []*net.IPNet
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// IPv4私有地址段
|
||||||
|
for _, cidr := range []string{
|
||||||
|
"10.0.0.0/8", // A类私有地址
|
||||||
|
"172.16.0.0/12", // B类私有地址
|
||||||
|
"192.168.0.0/16", // C类私有地址
|
||||||
|
"169.254.0.0/16", // 链路本地地址
|
||||||
|
"127.0.0.0/8", // 环回地址
|
||||||
|
} {
|
||||||
|
_, block, _ := net.ParseCIDR(cidr)
|
||||||
|
privateIPv4Blocks = append(privateIPv4Blocks, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6私有地址段
|
||||||
|
for _, cidr := range []string{
|
||||||
|
"fc00::/7", // 唯一本地地址
|
||||||
|
"fe80::/10", // 链路本地地址
|
||||||
|
"::1/128", // 环回地址
|
||||||
|
} {
|
||||||
|
_, block, _ := net.ParseCIDR(cidr)
|
||||||
|
privateIPv6Blocks = append(privateIPv6Blocks, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPrivateIP(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理IPv4和IPv4映射的IPv6地址
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
for _, block := range privateIPv4Blocks {
|
||||||
|
if block.Contains(ip4) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理IPv6地址
|
||||||
|
for _, block := range privateIPv6Blocks {
|
||||||
|
if block.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
@ -3,14 +3,31 @@ package tool
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
mrand "math/rand"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
letterNum = []byte("0123456789")
|
letterNum = []byte("0123456789")
|
||||||
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
|
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
|
||||||
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
letterSyb = []byte("!@#$%^&*()_+-=")
|
letterSyb = []byte("!@#$%^&*()_+-=")
|
||||||
|
adjectives = []string{
|
||||||
|
"开心的", "灿烂的", "温暖的", "阳光的", "活泼的",
|
||||||
|
"聪明的", "优雅的", "幸运的", "甜蜜的", "勇敢的",
|
||||||
|
"宁静的", "热情的", "温柔的", "幽默的", "坚强的",
|
||||||
|
"迷人的", "神奇的", "快乐的", "健康的", "自由的",
|
||||||
|
"梦幻的", "勤劳的", "真诚的", "浪漫的", "自信的",
|
||||||
|
}
|
||||||
|
|
||||||
|
plants = []string{
|
||||||
|
"苹果", "香蕉", "橘子", "葡萄", "草莓",
|
||||||
|
"西瓜", "樱桃", "菠萝", "柠檬", "蜜桃",
|
||||||
|
"蓝莓", "芒果", "石榴", "甜瓜", "雪梨",
|
||||||
|
"番茄", "南瓜", "土豆", "青椒", "洋葱",
|
||||||
|
"黄瓜", "萝卜", "豌豆", "玉米", "蘑菇",
|
||||||
|
"菠菜", "茄子", "芹菜", "莲藕", "西兰花",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func RandomInt(max int64) int64 {
|
func RandomInt(max int64) int64 {
|
||||||
@ -52,3 +69,7 @@ func RandomPassword(length int, withSymbol bool) string {
|
|||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RandomName() string {
|
||||||
|
return adjectives[mrand.Intn(len(adjectives))] + plants[mrand.Intn(len(plants))]
|
||||||
|
}
|
||||||
|
1
main.go
1
main.go
@ -31,6 +31,7 @@ func main() {
|
|||||||
opt.Init(ctx)
|
opt.Init(ctx)
|
||||||
controller.UserManager.Start(ctx)
|
controller.UserManager.Start(ctx)
|
||||||
controller.MetaManager.Start(ctx)
|
controller.MetaManager.Start(ctx)
|
||||||
|
controller.RoomController.Start(ctx)
|
||||||
api.Start(ctx)
|
api.Start(ctx)
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
149
page/bubble.html
Normal file
149
page/bubble.html
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>柔和气泡</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(45deg, #e6e9f0, #eef1f5);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
position: absolute;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Microsoft Yahei', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
animation: float 6s 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);
|
||||||
|
box-shadow: inset 0 -5px 15px rgba(255,255,255,0.3),
|
||||||
|
0 5px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 高光效果 */
|
||||||
|
.bubble::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble:hover {
|
||||||
|
transform: scale(1.05) rotate(3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
33% { transform: translateY(-20px) rotate(3deg); }
|
||||||
|
66% { transform: translateY(10px) rotate(-3deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
const prefixes = ['宁静', '温暖', '甜蜜', '柔和', '灿烂', '神秘', '清爽'];
|
||||||
|
const suffixes = ['梦境', '时光', '旋律', '花园', '云端', '海洋', '晨露'];
|
||||||
|
const existingPositions = [];
|
||||||
|
const BUBBLE_SIZE = 100;
|
||||||
|
|
||||||
|
// 检测碰撞
|
||||||
|
function checkCollision(x, y) {
|
||||||
|
return existingPositions.some(pos => {
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(x - pos.x, 2) +
|
||||||
|
Math.pow(y - pos.y, 2)
|
||||||
|
);
|
||||||
|
return distance < BUBBLE_SIZE * 1.2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBubble() {
|
||||||
|
let x, y;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
// 生成非重叠位置
|
||||||
|
do {
|
||||||
|
x = Math.random() * (window.innerWidth - BUBBLE_SIZE);
|
||||||
|
y = Math.random() * (window.innerHeight - BUBBLE_SIZE);
|
||||||
|
attempts++;
|
||||||
|
} while (checkCollision(x, y) && attempts < 100);
|
||||||
|
|
||||||
|
if (attempts >= 100) return;
|
||||||
|
|
||||||
|
existingPositions.push({x, y});
|
||||||
|
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = 'bubble';
|
||||||
|
|
||||||
|
// 随机颜色
|
||||||
|
const hue = Math.random() * 360;
|
||||||
|
bubble.style.backgroundColor = `hsla(${hue},
|
||||||
|
${Math.random() * 30 + 40}%,
|
||||||
|
${Math.random() * 10 + 75}%, 0.9)`;
|
||||||
|
|
||||||
|
// 随机名称
|
||||||
|
bubble.textContent = `${prefixes[Math.random()*prefixes.length|0]}的${suffixes[Math.random()*suffixes.length|0]}`;
|
||||||
|
|
||||||
|
// 位置和动画
|
||||||
|
bubble.style.left = x + 'px';
|
||||||
|
bubble.style.top = y + 'px';
|
||||||
|
bubble.style.animationDelay = Math.random() * 2 + 's';
|
||||||
|
|
||||||
|
// 点击效果
|
||||||
|
bubble.addEventListener('click', () => {
|
||||||
|
bubble.style.transition = 'transform 0.5s, opacity 0.5s';
|
||||||
|
bubble.style.transform = 'scale(1.5)';
|
||||||
|
bubble.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
bubble.remove();
|
||||||
|
existingPositions.splice(existingPositions.findIndex(
|
||||||
|
pos => pos.x === x && pos.y === y), 1);
|
||||||
|
createBubble();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(bubble);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化15个气泡
|
||||||
|
for (let i = 0; i < 15; i++) createBubble();
|
||||||
|
|
||||||
|
// 自动补充气泡
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.querySelectorAll('.bubble').length < 20) {
|
||||||
|
createBubble();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// 窗口大小变化时重置
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
existingPositions.length = 0;
|
||||||
|
document.querySelectorAll('.bubble').forEach(bubble => bubble.remove());
|
||||||
|
for (let i = 0; i < 15; i++) createBubble();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user