From 9fb248dff0f23791e26569a9ee92601efb3d3190 Mon Sep 17 00:00:00 2001 From: loveuer Date: Wed, 18 Jun 2025 23:01:08 +0800 Subject: [PATCH] =?UTF-8?q?wip:=20v0.2.8=20dialog=20=E7=BE=8E=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/component/dialog/dialog.tsx | 87 ++++++++++-------- .../page/local/component/message-dialog.tsx | 61 +++++++++---- .../src/page/local/component/rtc-handler.ts | 91 ++++++++++++++++--- .../src/page/local/component/send-dialog.tsx | 17 ++++ frontend/src/page/local/component/sender.tsx | 59 ++++++++---- frontend/src/page/local/component/types.ts | 6 +- frontend/src/page/local/local.tsx | 55 ++++++----- 7 files changed, 266 insertions(+), 110 deletions(-) create mode 100644 frontend/src/page/local/component/send-dialog.tsx diff --git a/frontend/src/component/dialog/dialog.tsx b/frontend/src/component/dialog/dialog.tsx index 9a23bf5..c29feb3 100644 --- a/frontend/src/component/dialog/dialog.tsx +++ b/frontend/src/component/dialog/dialog.tsx @@ -5,7 +5,7 @@ const useClass = createUseStyles({ dialog: { border: "none", borderRadius: "8px", - padding: "0", + padding: "2rem 3rem", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", maxWidth: "90%", width: "500px", @@ -17,6 +17,7 @@ const useClass = createUseStyles({ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }, + background: "rgba(255, 255, 255, 0.4)", }, dialog_content: { padding: "1.5rem", @@ -55,6 +56,8 @@ export interface DialogProps { onClose: () => void; /** 自定义样式类名 */ className?: string; + /** 是否显示底部footer(关闭按钮) */ + footer?: boolean; } /** @@ -66,62 +69,68 @@ export const Dialog: React.FC = ({ children, onClose, className = '', + footer = true, }) => { const classes = useClass(); - const dialogRef = useRef(null); + const dialogRef = useRef(null); + // ESC 键关闭 useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; + if (!open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); - // 根据 open 属性打开/关闭对话框 - if (open) { - // 先关闭再打开确保动画效果(如果有) - if (dialog.open) dialog.close(); - dialog.showModal(); - } else { - dialog.close(); - } - }, [open]); - - // 处理关闭事件(点击背景/ESC键) - const handleClose = (e: React.MouseEvent) => { - // 确保关闭事件来自背景点击 + // 遮罩点击关闭 + const handleMaskClick = (e: React.MouseEvent) => { if (e.target === dialogRef.current) { onClose(); } }; - // 处理取消事件(ESC键) - const handleCancel = () => { - onClose(); - }; + if (!open) return null; return ( - -
+
{title &&
{title}
} -
{children}
- -
- -
+ {footer !== false && ( +
+ +
+ )}
-
+ ); }; \ No newline at end of file diff --git a/frontend/src/page/local/component/message-dialog.tsx b/frontend/src/page/local/component/message-dialog.tsx index 0f4407d..53b52d6 100644 --- a/frontend/src/page/local/component/message-dialog.tsx +++ b/frontend/src/page/local/component/message-dialog.tsx @@ -13,7 +13,7 @@ export const MessageDialog: React.FC = ({open, message, onCl const handleCopyMessage = () => { if (message) { - navigator.clipboard.writeText(message.text).then(() => { + navigator.clipboard.writeText(message.text || '').then(() => { console.log('消息已复制到剪贴板'); setCopySuccess(true); // 2秒后隐藏成功提示 @@ -30,6 +30,7 @@ export const MessageDialog: React.FC = ({open, message, onCl open={open} title="收到新消息" onClose={onClose} + footer={false} >

@@ -45,10 +46,19 @@ export const MessageDialog: React.FC = ({open, message, onCl whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> - {message?.text} + {message?.isFile ? ( + <> +

📎 文件: {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)
+ + 点击下载 + + + ) : ( + message?.text + )}
-
+
{copySuccess && ( = ({open, message, onCl ✓ 已复制 )} - + )} +
diff --git a/frontend/src/page/local/component/rtc-handler.ts b/frontend/src/page/local/component/rtc-handler.ts index eea12a8..2588d73 100644 --- a/frontend/src/page/local/component/rtc-handler.ts +++ b/frontend/src/page/local/component/rtc-handler.ts @@ -2,9 +2,45 @@ import {Resp} from "../../../interface/response.ts"; import {Client, WSMessage, ReceivedMessage} from "./types.ts"; import {useLocalStore} from "../../../store/local.ts"; -// 接收文件块 -export const handleFileChunk = (chunk: any) => { - console.log("[D] rtc file chunk =", chunk); +// 文件接收缓存 +const fileReceiveCache: Record = {}; + +export const handleFileChunk = (chunk: any, onFileReceived: (msg: import("./types").ReceivedMessage) => void) => { + if (chunk.type === 'file') { + // 文件元信息,初始化缓存 + fileReceiveCache[chunk.name + '_' + chunk.timestamp] = { + chunks: [], + total: chunk.totalChunks, + received: 0, + name: chunk.name, + size: chunk.size, + sender: chunk.sender, + timestamp: chunk.timestamp + }; + } 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) { + 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 + }); + delete fileReceiveCache[key]; + } + } + } }; export interface RTCHandlerCallbacks { @@ -52,7 +88,7 @@ export class RTCHandler { this.callbacks.onMessageReceived(message); } else if (data.type === 'file') { // 处理文件消息 - handleFileChunk(data); + handleFileChunk(data, this.callbacks.onMessageReceived); } } catch (error) { // 如果不是JSON格式,当作普通文本处理 @@ -188,7 +224,9 @@ export class RTCHandler { sendMessage = (msg: string, files: File[], senderName: string) => { const ch = useLocalStore.getState().channel; console.log('[D] ready to send:', msg, files, ch); - + const CHUNK_SIZE = 64 * 1024; // 64KB + const BUFFERED_AMOUNT_THRESHOLD = 1 * 1024 * 1024; // 1MB + if (ch && ch.readyState === 'open') { if (msg.trim()) { // 发送文本消息 @@ -202,25 +240,52 @@ export class RTCHandler { } 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: Date.now() + timestamp, + totalChunks }; ch.send(JSON.stringify(fileData)); - // 这里可以添加文件分块发送逻辑 + ch.bufferedAmountLowThreshold = BUFFERED_AMOUNT_THRESHOLD; + + let offset = 0; + let chunkIndex = 0; const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result) { - ch.send(e.target.result as ArrayBuffer); + + function sendNextChunk() { + if (offset >= file.size) return; + if (ch && ch.bufferedAmount && ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) { + // 等待缓冲区变低 + ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true }); + return; } - }; - reader.readAsArrayBuffer(file); + 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(); }); } } diff --git a/frontend/src/page/local/component/send-dialog.tsx b/frontend/src/page/local/component/send-dialog.tsx new file mode 100644 index 0000000..2293c8e --- /dev/null +++ b/frontend/src/page/local/component/send-dialog.tsx @@ -0,0 +1,17 @@ +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; +} + +export const SendDialog: React.FC = ({open, onSend, onClose}) => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/page/local/component/sender.tsx b/frontend/src/page/local/component/sender.tsx index fdfcfeb..ab1e688 100644 --- a/frontend/src/page/local/component/sender.tsx +++ b/frontend/src/page/local/component/sender.tsx @@ -1,26 +1,33 @@ import React, {useRef, useState} from 'react'; import {createUseStyles} from "react-jss"; +import { message } from '../../../hook/message/u-message.tsx'; const useClass = createUseStyles({ container: { - width: "calc(100% - 45px)", - height: "calc(100% - 45px)", - margin: "20px", + 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%", - height: "100%", - padding: "10px", + minHeight: "180px", + padding: "12px", boxSizing: "border-box", border: "none", - resize: "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', @@ -74,18 +81,19 @@ const useClass = createUseStyles({ }, buttons: { position: "absolute", - bottom: "10px", - right: "10px", + bottom: "16px", + right: "16px", display: "flex", - gap: "8px" + gap: "12px" }, action_btn: { - padding: "8px 16px", + 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)"} }, @@ -97,32 +105,40 @@ export interface SenderProps { export const Sender: React.FC = ({onSend}) => { const classes = useClass(); - const [message, setMessage] = useState(''); + const [inputMessage, setInputMessage] = useState(''); const [files, setFiles] = useState(null); const fileInputRef = useRef(null) const handleTextInput = (e: React.ChangeEvent) => { - setMessage(e.target.value) + setInputMessage(e.target.value) } const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files) { setFiles(e.target.files[0]); + } else { + message.warning('未能选择文件'); } }; const handleSubmit = () => { - if (message.trim() || files) { - onSend(message, files?[files]:[]); - setMessage(''); + if (!(inputMessage.trim() || files)) { + message.warning('请输入内容或选择文件'); + return; + } + try { + onSend(inputMessage, files ? [files] : []); + setInputMessage(''); setFiles(null); + } catch (e) { + message.error('发送失败,请重试'); } }; return (
+ onChange={handleTextInput} value={inputMessage}>
{files &&
{files.name} @@ -134,10 +150,17 @@ export const Sender: React.FC = ({onSend}) => {
- + {/* + */}
diff --git a/frontend/src/page/local/component/types.ts b/frontend/src/page/local/component/types.ts index 3cb233a..9ed0f76 100644 --- a/frontend/src/page/local/component/types.ts +++ b/frontend/src/page/local/component/types.ts @@ -26,7 +26,11 @@ export interface Client { } export interface ReceivedMessage { - text: string; + text?: string; timestamp: number; sender: string; + fileName?: string; + fileSize?: number; + fileBlobUrl?: string; + isFile?: boolean; } \ No newline at end of file diff --git a/frontend/src/page/local/local.tsx b/frontend/src/page/local/local.tsx index 8b14a08..0c61633 100644 --- a/frontend/src/page/local/local.tsx +++ b/frontend/src/page/local/local.tsx @@ -4,13 +4,13 @@ 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 {Drawer} from "../../component/drawer/drawer.tsx"; -import {Sender} from "./component/sender.tsx"; import {UserBubble} from "./component/user-bubble.tsx"; import {MessageDialog} from "./component/message-dialog.tsx"; import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts"; import {generateBubbles} from "./component/bubble-layout.ts"; import {Client, ReceivedMessage} from "./component/types.ts"; +import {SendDialog} from "./component/send-dialog.tsx"; +import {message} from "../../hook/message/u-message.tsx"; const useClass = createUseStyles({ '@global': { @@ -107,32 +107,50 @@ export const LocalSharing: React.FC = () => { }; const handleWSEvent = async (e: MessageEvent) => { - const msg = JSON.parse(e.data); - console.log('[D] ws event msg =', msg); + const msgData = JSON.parse(e.data); + console.log('[D] ws event msg =', msgData); - if (msg.type === "enter" || msg.type === "leave") { + if (msgData.type === "enter" || msgData.type === "leave") { await updateClients(); return; } // 其他RTC相关事件由RTC处理器处理 if (rtcHandlerRef.current) { - await rtcHandlerRef.current.handleWSEvent(e); + try { + await rtcHandlerRef.current.handleWSEvent(e); + } catch (err) { + message.error('通信异常,请刷新页面'); + } + } else { + message.error('内部错误:通信模块未初始化'); } }; const handleBubbleClick = async (bubble: any) => { + setOpen({send: true, receive: false}); console.log('[D] Bubble clicked:', bubble.id, 'Current RTC handler:', rtcHandlerRef.current); if (rtcHandlerRef.current) { - await rtcHandlerRef.current.handleBubbleClick(bubble.id, id); + 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) { - rtcHandlerRef.current.sendMessage(msg, files, name); + try { + rtcHandlerRef.current.sendMessage(msg, files, name); + } catch (e) { + message.error('发送失败,请重试'); + } + } else { + message.error('内部错误:通信模块未初始化'); } }; @@ -141,6 +159,7 @@ export const LocalSharing: React.FC = () => { setReceivedMessage(null); }; + useEffect(() => { const fn = async () => { const response = await fetch('/api/ulocal/register', {method: 'POST'}); @@ -175,7 +194,7 @@ export const LocalSharing: React.FC = () => {

{name} - - {id} + {/* - {id} */}

{bubbles.map(bubble => ( @@ -186,21 +205,13 @@ export const LocalSharing: React.FC = () => { /> ))} - setOpen({send: false, receive: false})} - > - - - - setOpen({send: false, receive: false})} + setOpen({send: false, receive: false})} /> +