wip: 0.2.8
发送成功
This commit is contained in:
parent
21287e0874
commit
e5f7b5e6dc
85
frontend/src/component/drawer/drawer.tsx
Normal file
85
frontend/src/component/drawer/drawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
145
frontend/src/page/local/component/sender.tsx
Normal file
145
frontend/src/page/local/component/sender.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
}
|
@ -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
181
page/sender.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user