From e5f7b5e6dc26e977116d34c58f665a338986363e Mon Sep 17 00:00:00 2001 From: loveuer Date: Fri, 30 May 2025 16:24:42 +0800 Subject: [PATCH] =?UTF-8?q?wip:=200.2.8=20=20=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/component/drawer/drawer.tsx | 85 +++++++++ frontend/src/page/local/component/sender.tsx | 145 +++++++++++++++ frontend/src/page/local/local.tsx | 66 +++++-- frontend/src/store/local.ts | 7 + page/sender.html | 181 +++++++++++++++++++ 5 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 frontend/src/component/drawer/drawer.tsx create mode 100644 frontend/src/page/local/component/sender.tsx create mode 100644 page/sender.html diff --git a/frontend/src/component/drawer/drawer.tsx b/frontend/src/component/drawer/drawer.tsx new file mode 100644 index 0000000..feebe4c --- /dev/null +++ b/frontend/src/component/drawer/drawer.tsx @@ -0,0 +1,85 @@ +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 = ({ + isOpen, + close, + onClose, + height = '300px', + width = '100%', + children + }) => { + const classes = useClass(); + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + console.log('[D] escape close:', close) + close() + } + }; + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'auto'; + } + }, [isOpen, onClose]); + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +}; diff --git a/frontend/src/page/local/component/sender.tsx b/frontend/src/page/local/component/sender.tsx new file mode 100644 index 0000000..fdfcfeb --- /dev/null +++ b/frontend/src/page/local/component/sender.tsx @@ -0,0 +1,145 @@ +import React, {useRef, useState} from 'react'; +import {createUseStyles} from "react-jss"; + +const useClass = createUseStyles({ + container: { + width: "calc(100% - 45px)", + height: "calc(100% - 45px)", + margin: "20px", + border: "1px solid #ddd", + borderRadius: "8px", + overflow: "hidden", + position: "relative", + }, + input_box: { + width: "100%", + height: "100%", + padding: "10px", + boxSizing: "border-box", + border: "none", + resize: "none", + outline: "none", + fontFamily: "Arial, sans-serif", + transition: "padding 0.3s ease", + }, + 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: "10px", + right: "10px", + display: "flex", + gap: "8px" + }, + action_btn: { + padding: "8px 16px", + background: "#4dabf7", + color: "white", + border: "none", + borderRadius: "20px", + cursor: "pointer", + transition: "all 0.2s", + "&:hover": {background: "#339af0", transform: "translateY(-1px)"} + }, +}) + +export interface SenderProps { + onSend: (message: string, files: File[]) => void; +} + +export const Sender: React.FC = ({onSend}) => { + const classes = useClass(); + const [message, setMessage] = useState(''); + const [files, setFiles] = useState(null); + const fileInputRef = useRef(null) + + const handleTextInput = (e: React.ChangeEvent) => { + setMessage(e.target.value) + } + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + setFiles(e.target.files[0]); + } + }; + + const handleSubmit = () => { + if (message.trim() || files) { + onSend(message, files?[files]:[]); + setMessage(''); + setFiles(null); + } + }; + + return ( +
+ +
+ {files &&
+ {files.name} + +
} +
+
+ + + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/local.tsx b/frontend/src/page/local/local.tsx index b73da92..0f7a2c6 100644 --- a/frontend/src/page/local/local.tsx +++ b/frontend/src/page/local/local.tsx @@ -3,8 +3,9 @@ import {useEffect, useRef, useState} from "react"; import {createUseStyles} from "react-jss"; import {useWebsocket} from "../../hook/websocket/u-ws.tsx"; import {Resp} from "../../interface/response.ts"; -import {useRegister} from "./hook/register.tsx"; import {useLocalStore} from "../../store/local.ts"; +import {Drawer} from "../../component/drawer/drawer.tsx"; +import {Sender} from "./component/sender.tsx"; const useClass = createUseStyles({ '@global': { @@ -87,11 +88,6 @@ interface Client { candidate: RTCIceCandidateInit; } -function setupDataChannel(ch: RTCDataChannel) { - ch.onopen = () => console.log('通道已打开!'); - ch.onmessage = (e) => handleFileChunk(e.data); - ch.onclose = () => console.log('通道关闭'); -} // 接收文件块 function handleFileChunk(chunk: any) { @@ -101,11 +97,27 @@ function handleFileChunk(chunk: any) { export const LocalSharing: React.FC = () => { const classes = useClass(); - const {id, name, set} = useLocalStore() + const {id, name, set, setChannel} = useLocalStore() const [rtc, setRTC] = useState(); const rtcRef = useRef(); const [clients, setClients] = useState([]); const {connect, close} = useWebsocket({}) + const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false}) + + const setupDataChannel = async (ch: RTCDataChannel) => { + ch.onopen = () => { + console.log('通道已打开!') + setOpen(val => { + return {...val, send: true} + }) + setChannel(ch) + } + ch.onmessage = (e) => handleFileChunk(e.data); + ch.onclose = () => { + console.log('通道关闭') + setChannel() + } + } // 生成随机颜色 const generateColor = () => { @@ -209,7 +221,7 @@ export const LocalSharing: React.FC = () => { } current_rtc = rtcRef.current - if(!current_rtc) { + if (!current_rtc) { console.warn('[W] rtc undefined') return @@ -217,12 +229,17 @@ export const LocalSharing: React.FC = () => { await current_rtc.setRemoteDescription(offer_data.offer) const answer = await current_rtc.createAnswer() - if(!answer) { + if (!answer) { console.log('[W] answer undefined') return } + await current_rtc.setLocalDescription(answer) + current_rtc.ondatachannel = (e) => { + setupDataChannel(e.channel) + } + await fetch('/api/ulocal/answer', { method: 'POST', headers: {"Content-Type": "application/json"}, @@ -231,14 +248,14 @@ export const LocalSharing: React.FC = () => { return case "answer": - const answer_data = msg.data as {answer: RTCSessionDescriptionInit; id: string} + const answer_data = msg.data as { answer: RTCSessionDescriptionInit; id: string } current_id = useLocalStore.getState().id if (answer_data.id !== current_id) { console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data) } current_rtc = rtcRef.current - if(!current_rtc) { + if (!current_rtc) { console.warn('[W] rtc undefined') return } @@ -249,12 +266,12 @@ export const LocalSharing: React.FC = () => { case "candidate": const candidate_data = msg.data as { candidate: RTCIceCandidateInit } current_rtc = rtcRef.current - if(!current_rtc) { + if (!current_rtc) { console.warn('[W] rtc undefined') return } - if(!candidate_data.candidate) { - console.log('[W] candidate data null') + if (!candidate_data.candidate) { + console.log('[W] candidate data null') return } await current_rtc.addIceCandidate(candidate_data.candidate) @@ -266,7 +283,7 @@ export const LocalSharing: React.FC = () => { const handleBubbleClick = async (bubble: Bubble) => { console.log(`[D] click id = ${bubble.id}`) const current_rtc = rtcRef.current - current_rtc.onnegotiationneeded = async() => { + current_rtc.onnegotiationneeded = async () => { const offer = await current_rtc.createOffer() console.log('[D] offer created', offer) await current_rtc.setLocalDescription(offer) @@ -282,21 +299,30 @@ export const LocalSharing: React.FC = () => { }) } current_rtc.onicecandidate = async (e) => { - console.log('[D] on candidate ',e ) + console.log('[D] on candidate ', e) await fetch('/api/ulocal/candidate', { method: 'POST', headers: {"Content-Type": "application/json"}, body: JSON.stringify({candidate: e.candidate, id: useLocalStore.getState().id}) }) } + const ch = current_rtc.createDataChannel('local', {ordered: true}) - setupDataChannel(ch) + await setupDataChannel(ch) }; + const handleSend = (msg: string, files: File) => { + const ch = useLocalStore.getState().channel + console.log('[D] ready to send:', msg, files, ch) + if (ch) { + ch.send(msg) + } + } + 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; + const data = ((await response.json()) as Resp<{ id: string; name: string }>).data; set(data.id, data.name) console.log(`[D] register id = ${data.id}`); connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent}) @@ -306,6 +332,7 @@ export const LocalSharing: React.FC = () => { rtcRef.current = _rtc; // 同步设置 ref setRTC(_rtc); // 更新状态(如果需要触发渲染) + return () => { close(); if (rtcRef) { @@ -340,5 +367,8 @@ export const LocalSharing: React.FC = () => { ); })} + setOpen({send: false, receive: false})}> + + } \ No newline at end of file diff --git a/frontend/src/store/local.ts b/frontend/src/store/local.ts index 27edaae..52b3b5f 100644 --- a/frontend/src/store/local.ts +++ b/frontend/src/store/local.ts @@ -3,7 +3,9 @@ import {create} from 'zustand' export interface LocalStore { id: string; name: string; + channel?: RTCDataChannel; set: (id: string, name: string) => void; + setChannel: (chan?: RTCDataChannel) => void; } export const useLocalStore = create()((_set, get) => ({ @@ -13,5 +15,10 @@ export const useLocalStore = create()((_set, get) => ({ _set(state => { return {...state, id: id, name: name}; }) + }, + setChannel: (ch?: RTCDataChannel) => { + _set(state => { + return {...state, channel: ch} + }) } })) \ No newline at end of file diff --git a/page/sender.html b/page/sender.html new file mode 100644 index 0000000..a951306 --- /dev/null +++ b/page/sender.html @@ -0,0 +1,181 @@ + + + + + 智能文本区域 + + + +
+ +
+
+ + + +
+
+ + + + \ No newline at end of file