wip: 0.2.8

发送成功
This commit is contained in:
loveuer 2025-05-30 16:24:42 +08:00
parent 21287e0874
commit e5f7b5e6dc
5 changed files with 466 additions and 18 deletions

View File

@ -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<DrawerProps> = ({
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 (
<div
className={`${classes.backdrop} ${isOpen ? classes.visible : ''}`}
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={classes.drawer_content}
style={{
height,
width,
transform: isOpen ? 'translateY(0)' : 'translateY(100%)'
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};

View File

@ -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<SenderProps> = ({onSend}) => {
const classes = useClass();
const [message, setMessage] = useState('');
const [files, setFiles] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null)
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(e.target.files[0]);
}
};
const handleSubmit = () => {
if (message.trim() || files) {
onSend(message, files?[files]:[]);
setMessage('');
setFiles(null);
}
};
return (
<div className={classes.container}>
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
onChange={handleTextInput}>{message}</textarea>
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
{files && <div className={classes.file_item}>
<span>{files.name}</span>
<button className={classes.delete_btn} onClick={() => {
setFiles(null)
}}>×
</button>
</div>}
</div>
<div className={classes.buttons}>
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
<button className={classes.action_btn} onClick={() => {
fileInputRef.current && fileInputRef.current.click()
}}>📁
</button>
<button className={classes.action_btn} onClick={handleSubmit}> </button>
</div>
</div>
);
};

View File

@ -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<RTCPeerConnection>();
const rtcRef = useRef<RTCPeerConnection>();
const [clients, setClients] = useState<Client[]>([]);
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 = () => {
</div>
);
})}
<Drawer isOpen={open.send} width={"80%"} height={"400px"} close={() => setOpen({send: false, receive: false})}>
<Sender onSend={handleSend}></Sender>
</Drawer>
</div>
}

View File

@ -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<LocalStore>()((_set, get) => ({
@ -13,5 +15,10 @@ export const useLocalStore = create<LocalStore>()((_set, get) => ({
_set(state => {
return {...state, id: id, name: name};
})
},
setChannel: (ch?: RTCDataChannel) => {
_set(state => {
return {...state, channel: ch}
})
}
}))

181
page/sender.html Normal file
View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>智能文本区域</title>
<style>
.container {
width: 400px;
height: 300px;
margin: 20px;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.input-box {
width: 100%;
height: 100%;
padding: 10px;
box-sizing: border-box;
border: none;
resize: none;
outline: none;
font-family: Arial, sans-serif;
transition: padding 0.3s ease;
}
.input-box.has-files {
padding-top: 50px;
}
.file-list {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid #eee;
display: none;
flex-wrap: wrap;
gap: 6px;
max-height: 60px;
overflow-y: auto;
}
.file-list.has-files {
display: flex;
}
.file-item {
display: flex;
align-items: center;
background: #f0f6ff;
border: 1px solid #c2d9ff;
border-radius: 15px;
padding: 4px 12px;
font-size: 13px;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.delete-btn {
width: 16px;
height: 16px;
border: none;
background: #ff6b6b;
color: white;
border-radius: 50%;
margin-left: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.delete-btn: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;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: #339af0;
transform: translateY(-1px);
}
</style>
</head>
<body>
<div class="container">
<textarea class="input-box" placeholder="开始输入..."></textarea>
<div class="file-list" id="fileList"></div>
<div class="buttons">
<input type="file" id="fileInput" hidden multiple>
<button class="action-btn" onclick="document.getElementById('fileInput').click()">📁 选择文件</button>
<button class="action-btn" onclick="send()">✈️ 发送</button>
</div>
</div>
<script>
const inputBox = document.querySelector('.input-box');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
// 文件选择事件
fileInput.addEventListener('change', function() {
updateFileList();
adjustInputPadding();
});
// 动态调整输入框间距
function adjustInputPadding() {
inputBox.classList.toggle('has-files', fileInput.files.length > 0);
fileList.classList.toggle('has-files')
}
// 更新文件列表显示
function updateFileList() {
fileList.innerHTML = '';
Array.from(fileInput.files).forEach((file, index) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<span>${file.name}</span>
<button class="delete-btn" onclick="removeFile(${index})">×</button>
`;
fileList.appendChild(item);
});
}
// 删除单个文件
function removeFile(index) {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((file, i) => {
if (i !== index) dt.items.add(file);
});
fileInput.files = dt.files;
updateFileList();
adjustInputPadding();
}
// 发送功能
function send() {
const text = inputBox.value;
const files = fileInput.files;
console.log('文本内容:', text);
console.log('发送文件:', Array.from(files));
// 清空内容
inputBox.value = '';
fileInput.value = '';
fileList.innerHTML = '';
adjustInputPadding();
}
</script>
</body>
</html>