wip: v0.2.8 dialog 美化

This commit is contained in:
loveuer 2025-06-18 23:01:08 +08:00
parent 1b4ba1cb61
commit 9fb248dff0
7 changed files with 266 additions and 110 deletions

View File

@ -5,7 +5,7 @@ const useClass = createUseStyles({
dialog: { dialog: {
border: "none", border: "none",
borderRadius: "8px", borderRadius: "8px",
padding: "0", padding: "2rem 3rem",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "90%", maxWidth: "90%",
width: "500px", width: "500px",
@ -17,6 +17,7 @@ const useClass = createUseStyles({
background: "rgba(0, 0, 0, 0.5)", background: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(2px)" backdropFilter: "blur(2px)"
}, },
background: "rgba(255, 255, 255, 0.4)",
}, },
dialog_content: { dialog_content: {
padding: "1.5rem", padding: "1.5rem",
@ -55,6 +56,8 @@ export interface DialogProps {
onClose: () => void; onClose: () => void;
/** 自定义样式类名 */ /** 自定义样式类名 */
className?: string; className?: string;
/** 是否显示底部footer关闭按钮 */
footer?: boolean;
} }
/** /**
@ -66,52 +69,57 @@ export const Dialog: React.FC<DialogProps> = ({
children, children,
onClose, onClose,
className = '', className = '',
footer = true,
}) => { }) => {
const classes = useClass(); const classes = useClass();
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
// ESC 键关闭
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; if (!open) return;
if (!dialog) return; const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// 根据 open 属性打开/关闭对话框 onClose();
if (open) {
// 先关闭再打开确保动画效果(如果有)
if (dialog.open) dialog.close();
dialog.showModal();
} else {
dialog.close();
} }
}, [open]); };
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
// 处理关闭事件(点击背景/ESC键 // 遮罩点击关闭
const handleClose = (e: React.MouseEvent) => { const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 确保关闭事件来自背景点击
if (e.target === dialogRef.current) { if (e.target === dialogRef.current) {
onClose(); onClose();
} }
}; };
// 处理取消事件ESC键 if (!open) return null;
const handleCancel = () => {
onClose();
};
return ( return (
<dialog <div
ref={dialogRef} ref={dialogRef}
className={`${classes.dialog} ${className}`} className={className}
onClick={handleClose} style={{
onCancel={handleCancel} position: 'fixed',
aria-modal="true" 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_content}> <article className={classes.dialog} style={{opacity: 1, transform: 'translateY(0)'}}>
{title && <header className={classes.dialog_header}>{title}</header>} {title && <header className={classes.dialog_header}>{title}</header>}
<div className={classes.dialog_body}> <div className={classes.dialog_body}>
{children} {children}
</div> </div>
{footer !== false && (
<footer className={classes.dialog_footer}> <footer className={classes.dialog_footer}>
<button <button
className={classes.close_button} className={classes.close_button}
@ -121,7 +129,8 @@ export const Dialog: React.FC<DialogProps> = ({
</button> </button>
</footer> </footer>
)}
</article> </article>
</dialog> </div>
); );
}; };

View File

@ -13,7 +13,7 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
const handleCopyMessage = () => { const handleCopyMessage = () => {
if (message) { if (message) {
navigator.clipboard.writeText(message.text).then(() => { navigator.clipboard.writeText(message.text || '').then(() => {
console.log('消息已复制到剪贴板'); console.log('消息已复制到剪贴板');
setCopySuccess(true); setCopySuccess(true);
// 2秒后隐藏成功提示 // 2秒后隐藏成功提示
@ -30,6 +30,7 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
open={open} open={open}
title="收到新消息" title="收到新消息"
onClose={onClose} onClose={onClose}
footer={false}
> >
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.9rem', color: '#666' }}> <p style={{ margin: '0 0 0.5rem 0', fontSize: '0.9rem', color: '#666' }}>
@ -45,10 +46,19 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word' wordBreak: 'break-word'
}}> }}>
{message?.text} {message?.isFile ? (
<>
<div>📎 : {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)</div>
<a href={message.fileBlobUrl} download={message.fileName} style={{color:'#007aff',textDecoration:'underline'}}>
</a>
</>
) : (
message?.text
)}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'row', gap: '0.5rem', justifyContent: 'flex-end', alignItems: 'center', marginTop: '8px' }}>
{copySuccess && ( {copySuccess && (
<span style={{ <span style={{
color: '#28a745', color: '#28a745',
@ -59,6 +69,7 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
</span> </span>
)} )}
{!message?.isFile && (
<button <button
onClick={handleCopyMessage} onClick={handleCopyMessage}
style={{ style={{
@ -84,6 +95,22 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
> >
{copySuccess ? '已复制' : '复制消息'} {copySuccess ? '已复制' : '复制消息'}
</button> </button>
)}
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s'
}}
>
</button>
</div> </div>
</Dialog> </Dialog>
); );

View File

@ -2,9 +2,45 @@ import {Resp} from "../../../interface/response.ts";
import {Client, WSMessage, ReceivedMessage} from "./types.ts"; import {Client, WSMessage, ReceivedMessage} from "./types.ts";
import {useLocalStore} from "../../../store/local.ts"; import {useLocalStore} from "../../../store/local.ts";
// 接收文件块 // 文件接收缓存
export const handleFileChunk = (chunk: any) => { const fileReceiveCache: Record<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
console.log("[D] rtc file chunk =", chunk);
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 { export interface RTCHandlerCallbacks {
@ -52,7 +88,7 @@ export class RTCHandler {
this.callbacks.onMessageReceived(message); this.callbacks.onMessageReceived(message);
} else if (data.type === 'file') { } else if (data.type === 'file') {
// 处理文件消息 // 处理文件消息
handleFileChunk(data); handleFileChunk(data, this.callbacks.onMessageReceived);
} }
} catch (error) { } catch (error) {
// 如果不是JSON格式当作普通文本处理 // 如果不是JSON格式当作普通文本处理
@ -188,6 +224,8 @@ export class RTCHandler {
sendMessage = (msg: string, files: File[], senderName: string) => { sendMessage = (msg: string, files: File[], senderName: string) => {
const ch = useLocalStore.getState().channel; const ch = useLocalStore.getState().channel;
console.log('[D] ready to send:', msg, files, ch); 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 (ch && ch.readyState === 'open') {
if (msg.trim()) { if (msg.trim()) {
@ -202,25 +240,52 @@ export class RTCHandler {
} }
if (files && files.length > 0) { if (files && files.length > 0) {
// 发送文件消息
files.forEach(file => { files.forEach(file => {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const timestamp = Date.now();
// 先发送文件元信息
const fileData = { const fileData = {
type: 'file', type: 'file',
name: file.name, name: file.name,
size: file.size, size: file.size,
sender: senderName, sender: senderName,
timestamp: Date.now() timestamp,
totalChunks
}; };
ch.send(JSON.stringify(fileData)); ch.send(JSON.stringify(fileData));
// 这里可以添加文件分块发送逻辑 ch.bufferedAmountLowThreshold = BUFFERED_AMOUNT_THRESHOLD;
let offset = 0;
let chunkIndex = 0;
const reader = new FileReader(); const reader = new FileReader();
function sendNextChunk() {
if (offset >= file.size) return;
if (ch && ch.bufferedAmount && ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) {
// 等待缓冲区变低
ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true });
return;
}
const slice = file.slice(offset, offset + CHUNK_SIZE);
reader.onload = (e) => { reader.onload = (e) => {
if (e.target?.result) { if (e.target?.result) {
ch.send(e.target.result as ArrayBuffer); 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(file); reader.readAsArrayBuffer(slice);
}
sendNextChunk();
}); });
} }
} }

View File

@ -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<SendDialogProps> = ({open, onSend, onClose}) => {
return (
<Dialog open={open} title="发送消息" onClose={onClose}>
<Sender onSend={onSend} />
</Dialog>
);
};

View File

@ -1,26 +1,33 @@
import React, {useRef, useState} from 'react'; import React, {useRef, useState} from 'react';
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import { message } from '../../../hook/message/u-message.tsx';
const useClass = createUseStyles({ const useClass = createUseStyles({
container: { container: {
width: "calc(100% - 45px)", width: "100%",
height: "calc(100% - 45px)", minHeight: "260px",
margin: "20px", margin: "0",
border: "1px solid #ddd", border: "1px solid #ddd",
borderRadius: "8px", borderRadius: "8px",
overflow: "hidden", overflow: "hidden",
position: "relative", position: "relative",
background: "#fff",
boxSizing: "border-box",
padding: "20px 16px 60px 16px"
}, },
input_box: { input_box: {
width: "100%", width: "100%",
height: "100%", minHeight: "180px",
padding: "10px", padding: "12px",
boxSizing: "border-box", boxSizing: "border-box",
border: "none", border: "none",
resize: "none", resize: "vertical",
outline: "none", outline: "none",
fontFamily: "Arial, sans-serif", fontFamily: "Arial, sans-serif",
fontSize: "1.1rem",
transition: "padding 0.3s ease", transition: "padding 0.3s ease",
background: "#f8fafd",
borderRadius: "6px"
}, },
input_has_files: { input_has_files: {
paddingTop: '50px', paddingTop: '50px',
@ -74,18 +81,19 @@ const useClass = createUseStyles({
}, },
buttons: { buttons: {
position: "absolute", position: "absolute",
bottom: "10px", bottom: "16px",
right: "10px", right: "16px",
display: "flex", display: "flex",
gap: "8px" gap: "12px"
}, },
action_btn: { action_btn: {
padding: "8px 16px", padding: "10px 22px",
background: "#4dabf7", background: "#4dabf7",
color: "white", color: "white",
border: "none", border: "none",
borderRadius: "20px", borderRadius: "20px",
cursor: "pointer", cursor: "pointer",
fontSize: "1rem",
transition: "all 0.2s", transition: "all 0.2s",
"&:hover": {background: "#339af0", transform: "translateY(-1px)"} "&:hover": {background: "#339af0", transform: "translateY(-1px)"}
}, },
@ -97,32 +105,40 @@ export interface SenderProps {
export const Sender: React.FC<SenderProps> = ({onSend}) => { export const Sender: React.FC<SenderProps> = ({onSend}) => {
const classes = useClass(); const classes = useClass();
const [message, setMessage] = useState(''); const [inputMessage, setInputMessage] = useState('');
const [files, setFiles] = useState<File | null>(null); const [files, setFiles] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value) setInputMessage(e.target.value)
} }
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
setFiles(e.target.files[0]); setFiles(e.target.files[0]);
} else {
message.warning('未能选择文件');
} }
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (message.trim() || files) { if (!(inputMessage.trim() || files)) {
onSend(message, files?[files]:[]); message.warning('请输入内容或选择文件');
setMessage(''); return;
}
try {
onSend(inputMessage, files ? [files] : []);
setInputMessage('');
setFiles(null); setFiles(null);
} catch (e) {
message.error('发送失败,请重试');
} }
}; };
return ( return (
<div className={classes.container}> <div className={classes.container}>
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..." <textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
onChange={handleTextInput}>{message}</textarea> onChange={handleTextInput} value={inputMessage}></textarea>
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}> <div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
{files && <div className={classes.file_item}> {files && <div className={classes.file_item}>
<span>{files.name}</span> <span>{files.name}</span>
@ -134,10 +150,17 @@ export const Sender: React.FC<SenderProps> = ({onSend}) => {
</div> </div>
<div className={classes.buttons}> <div className={classes.buttons}>
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/> <input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
<button className={classes.action_btn} onClick={() => { <button
className={classes.action_btn}
style={{ opacity: 0.5, cursor: 'not-allowed' }}
onClick={() => { message.warning('暂不支持文件发送') }}
>
📁
</button>
{/* <button className={classes.action_btn} onClick={() => {
fileInputRef.current && fileInputRef.current.click() fileInputRef.current && fileInputRef.current.click()
}}>📁 }}>📁
</button> </button> */}
<button className={classes.action_btn} onClick={handleSubmit}> </button> <button className={classes.action_btn} onClick={handleSubmit}> </button>
</div> </div>
</div> </div>

View File

@ -26,7 +26,11 @@ export interface Client {
} }
export interface ReceivedMessage { export interface ReceivedMessage {
text: string; text?: string;
timestamp: number; timestamp: number;
sender: string; sender: string;
fileName?: string;
fileSize?: number;
fileBlobUrl?: string;
isFile?: boolean;
} }

View File

@ -4,13 +4,13 @@ import {createUseStyles} from "react-jss";
import {useWebsocket} from "../../hook/websocket/u-ws.tsx"; import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
import {Resp} from "../../interface/response.ts"; import {Resp} from "../../interface/response.ts";
import {useLocalStore} from "../../store/local.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 {UserBubble} from "./component/user-bubble.tsx";
import {MessageDialog} from "./component/message-dialog.tsx"; import {MessageDialog} from "./component/message-dialog.tsx";
import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts"; import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts";
import {generateBubbles} from "./component/bubble-layout.ts"; import {generateBubbles} from "./component/bubble-layout.ts";
import {Client, ReceivedMessage} from "./component/types.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({ const useClass = createUseStyles({
'@global': { '@global': {
@ -107,32 +107,50 @@ export const LocalSharing: React.FC = () => {
}; };
const handleWSEvent = async (e: MessageEvent) => { const handleWSEvent = async (e: MessageEvent) => {
const msg = JSON.parse(e.data); const msgData = JSON.parse(e.data);
console.log('[D] ws event msg =', msg); console.log('[D] ws event msg =', msgData);
if (msg.type === "enter" || msg.type === "leave") { if (msgData.type === "enter" || msgData.type === "leave") {
await updateClients(); await updateClients();
return; return;
} }
// 其他RTC相关事件由RTC处理器处理 // 其他RTC相关事件由RTC处理器处理
if (rtcHandlerRef.current) { if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleWSEvent(e); await rtcHandlerRef.current.handleWSEvent(e);
} catch (err) {
message.error('通信异常,请刷新页面');
}
} else {
message.error('内部错误:通信模块未初始化');
} }
}; };
const handleBubbleClick = async (bubble: any) => { const handleBubbleClick = async (bubble: any) => {
setOpen({send: true, receive: false});
console.log('[D] Bubble clicked:', bubble.id, 'Current RTC handler:', rtcHandlerRef.current); console.log('[D] Bubble clicked:', bubble.id, 'Current RTC handler:', rtcHandlerRef.current);
if (rtcHandlerRef.current) { if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleBubbleClick(bubble.id, id); await rtcHandlerRef.current.handleBubbleClick(bubble.id, id);
} catch (e) {
message.error('建立连接失败,请重试');
}
} else { } else {
message.error('内部错误:通信模块未初始化');
console.error('[E] RTC handler is null!'); console.error('[E] RTC handler is null!');
} }
}; };
const handleSend = (msg: string, files: File[]) => { const handleSend = (msg: string, files: File[]) => {
if (rtcHandlerRef.current) { if (rtcHandlerRef.current) {
try {
rtcHandlerRef.current.sendMessage(msg, files, name); rtcHandlerRef.current.sendMessage(msg, files, name);
} catch (e) {
message.error('发送失败,请重试');
}
} else {
message.error('内部错误:通信模块未初始化');
} }
}; };
@ -141,6 +159,7 @@ export const LocalSharing: React.FC = () => {
setReceivedMessage(null); setReceivedMessage(null);
}; };
useEffect(() => { useEffect(() => {
const fn = async () => { const fn = async () => {
const response = await fetch('/api/ulocal/register', {method: 'POST'}); const response = await fetch('/api/ulocal/register', {method: 'POST'});
@ -175,7 +194,7 @@ export const LocalSharing: React.FC = () => {
<CloudBackground/> <CloudBackground/>
<h1 className={classes.title}> <h1 className={classes.title}>
{name} {name}
<span> - {id}</span> {/* <span> - {id}</span> */}
</h1> </h1>
{bubbles.map(bubble => ( {bubbles.map(bubble => (
@ -186,21 +205,13 @@ export const LocalSharing: React.FC = () => {
/> />
))} ))}
<Drawer <SendDialog
isOpen={open.send} open={open.send}
width={"80%"} onSend={handleSend}
height={"400px"} onClose={() => setOpen({send: false, receive: false})}
close={() => setOpen({send: false, receive: false})}
>
<Sender onSend={handleSend}/>
</Drawer>
<Drawer
isOpen={open.receive}
width={"60%"}
close={() => setOpen({send: false, receive: false})}
/> />
<MessageDialog <MessageDialog
open={showMessageDialog} open={showMessageDialog}
message={receivedMessage} message={receivedMessage}