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: {
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<DialogProps> = ({
children,
onClose,
className = '',
footer = true,
}) => {
const classes = useClass();
const dialogRef = useRef<HTMLDialogElement>(null);
const dialogRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
if (e.target === dialogRef.current) {
onClose();
}
};
// 处理取消事件ESC键
const handleCancel = () => {
onClose();
};
if (!open) return null;
return (
<dialog
<div
ref={dialogRef}
className={`${classes.dialog} ${className}`}
onClick={handleClose}
onCancel={handleCancel}
aria-modal="true"
className={className}
style={{
position: 'fixed',
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>}
<div className={classes.dialog_body}>
{children}
</div>
<footer className={classes.dialog_footer}>
<button
className={classes.close_button}
onClick={onClose}
aria-label="关闭对话框"
>
</button>
</footer>
{footer !== false && (
<footer className={classes.dialog_footer}>
<button
className={classes.close_button}
onClick={onClose}
aria-label="关闭对话框"
>
</button>
</footer>
)}
</article>
</dialog>
</div>
);
};

View File

@ -13,7 +13,7 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({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<MessageDialogProps> = ({open, message, onCl
open={open}
title="收到新消息"
onClose={onClose}
footer={false}
>
<div style={{ marginBottom: '1rem' }}>
<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',
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 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 && (
<span style={{
color: '#28a745',
@ -59,11 +69,38 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
</span>
)}
<button
onClick={handleCopyMessage}
{!message?.isFile && (
<button
onClick={handleCopyMessage}
style={{
padding: '8px 16px',
background: copySuccess ? '#28a745' : '#007aff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!copySuccess) {
e.currentTarget.style.backgroundColor = '#0056b3';
}
}}
onMouseLeave={(e) => {
if (!copySuccess) {
e.currentTarget.style.backgroundColor = '#007aff';
}
}}
>
{copySuccess ? '已复制' : '复制消息'}
</button>
)}
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: copySuccess ? '#28a745' : '#007aff',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
@ -71,18 +108,8 @@ export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onCl
fontSize: '0.9rem',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!copySuccess) {
e.currentTarget.style.backgroundColor = '#0056b3';
}
}}
onMouseLeave={(e) => {
if (!copySuccess) {
e.currentTarget.style.backgroundColor = '#007aff';
}
}}
>
{copySuccess ? '已复制' : '复制消息'}
</button>
</div>
</Dialog>

View File

@ -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<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
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();
});
}
}

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 {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<SenderProps> = ({onSend}) => {
const classes = useClass();
const [message, setMessage] = useState('');
const [inputMessage, setInputMessage] = useState('');
const [files, setFiles] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null)
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
setInputMessage(e.target.value)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={classes.container}>
<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}`}>
{files && <div className={classes.file_item}>
<span>{files.name}</span>
@ -134,10 +150,17 @@ export const Sender: React.FC<SenderProps> = ({onSend}) => {
</div>
<div className={classes.buttons}>
<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()
}}>📁
</button>
</button> */}
<button className={classes.action_btn} onClick={handleSubmit}> </button>
</div>
</div>

View File

@ -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;
}

View File

@ -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 = () => {
<CloudBackground/>
<h1 className={classes.title}>
{name}
<span> - {id}</span>
{/* <span> - {id}</span> */}
</h1>
{bubbles.map(bubble => (
@ -186,21 +205,13 @@ export const LocalSharing: React.FC = () => {
/>
))}
<Drawer
isOpen={open.send}
width={"80%"}
height={"400px"}
close={() => setOpen({send: false, receive: false})}
>
<Sender onSend={handleSend}/>
</Drawer>
<Drawer
isOpen={open.receive}
width={"60%"}
close={() => setOpen({send: false, receive: false})}
<SendDialog
open={open.send}
onSend={handleSend}
onClose={() => setOpen({send: false, receive: false})}
/>
<MessageDialog
open={showMessageDialog}
message={receivedMessage}