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 {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 {useRegister} from "./hook/register.tsx";
|
|
||||||
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";
|
||||||
|
|
||||||
const useClass = createUseStyles({
|
const useClass = createUseStyles({
|
||||||
'@global': {
|
'@global': {
|
||||||
@ -87,11 +88,6 @@ interface Client {
|
|||||||
candidate: RTCIceCandidateInit;
|
candidate: RTCIceCandidateInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDataChannel(ch: RTCDataChannel) {
|
|
||||||
ch.onopen = () => console.log('通道已打开!');
|
|
||||||
ch.onmessage = (e) => handleFileChunk(e.data);
|
|
||||||
ch.onclose = () => console.log('通道关闭');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 接收文件块
|
// 接收文件块
|
||||||
function handleFileChunk(chunk: any) {
|
function handleFileChunk(chunk: any) {
|
||||||
@ -101,11 +97,27 @@ function handleFileChunk(chunk: any) {
|
|||||||
|
|
||||||
export const LocalSharing: React.FC = () => {
|
export const LocalSharing: React.FC = () => {
|
||||||
const classes = useClass();
|
const classes = useClass();
|
||||||
const {id, name, set} = useLocalStore()
|
const {id, name, set, setChannel} = useLocalStore()
|
||||||
const [rtc, setRTC] = useState<RTCPeerConnection>();
|
const [rtc, setRTC] = useState<RTCPeerConnection>();
|
||||||
const rtcRef = useRef<RTCPeerConnection>();
|
const rtcRef = useRef<RTCPeerConnection>();
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const {connect, close} = useWebsocket({})
|
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 = () => {
|
const generateColor = () => {
|
||||||
@ -209,7 +221,7 @@ export const LocalSharing: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
current_rtc = rtcRef.current
|
current_rtc = rtcRef.current
|
||||||
if(!current_rtc) {
|
if (!current_rtc) {
|
||||||
console.warn('[W] rtc undefined')
|
console.warn('[W] rtc undefined')
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -217,12 +229,17 @@ export const LocalSharing: React.FC = () => {
|
|||||||
|
|
||||||
await current_rtc.setRemoteDescription(offer_data.offer)
|
await current_rtc.setRemoteDescription(offer_data.offer)
|
||||||
const answer = await current_rtc.createAnswer()
|
const answer = await current_rtc.createAnswer()
|
||||||
if(!answer) {
|
if (!answer) {
|
||||||
console.log('[W] answer undefined')
|
console.log('[W] answer undefined')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await current_rtc.setLocalDescription(answer)
|
await current_rtc.setLocalDescription(answer)
|
||||||
|
|
||||||
|
current_rtc.ondatachannel = (e) => {
|
||||||
|
setupDataChannel(e.channel)
|
||||||
|
}
|
||||||
|
|
||||||
await fetch('/api/ulocal/answer', {
|
await fetch('/api/ulocal/answer', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
@ -231,14 +248,14 @@ export const LocalSharing: React.FC = () => {
|
|||||||
|
|
||||||
return
|
return
|
||||||
case "answer":
|
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
|
current_id = useLocalStore.getState().id
|
||||||
if (answer_data.id !== current_id) {
|
if (answer_data.id !== current_id) {
|
||||||
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data)
|
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
current_rtc = rtcRef.current
|
current_rtc = rtcRef.current
|
||||||
if(!current_rtc) {
|
if (!current_rtc) {
|
||||||
console.warn('[W] rtc undefined')
|
console.warn('[W] rtc undefined')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -249,12 +266,12 @@ export const LocalSharing: React.FC = () => {
|
|||||||
case "candidate":
|
case "candidate":
|
||||||
const candidate_data = msg.data as { candidate: RTCIceCandidateInit }
|
const candidate_data = msg.data as { candidate: RTCIceCandidateInit }
|
||||||
current_rtc = rtcRef.current
|
current_rtc = rtcRef.current
|
||||||
if(!current_rtc) {
|
if (!current_rtc) {
|
||||||
console.warn('[W] rtc undefined')
|
console.warn('[W] rtc undefined')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if(!candidate_data.candidate) {
|
if (!candidate_data.candidate) {
|
||||||
console.log('[W] candidate data null')
|
console.log('[W] candidate data null')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await current_rtc.addIceCandidate(candidate_data.candidate)
|
await current_rtc.addIceCandidate(candidate_data.candidate)
|
||||||
@ -266,7 +283,7 @@ export const LocalSharing: React.FC = () => {
|
|||||||
const handleBubbleClick = async (bubble: Bubble) => {
|
const handleBubbleClick = async (bubble: Bubble) => {
|
||||||
console.log(`[D] click id = ${bubble.id}`)
|
console.log(`[D] click id = ${bubble.id}`)
|
||||||
const current_rtc = rtcRef.current
|
const current_rtc = rtcRef.current
|
||||||
current_rtc.onnegotiationneeded = async() => {
|
current_rtc.onnegotiationneeded = async () => {
|
||||||
const offer = await current_rtc.createOffer()
|
const offer = await current_rtc.createOffer()
|
||||||
console.log('[D] offer created', offer)
|
console.log('[D] offer created', offer)
|
||||||
await current_rtc.setLocalDescription(offer)
|
await current_rtc.setLocalDescription(offer)
|
||||||
@ -282,21 +299,30 @@ export const LocalSharing: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
current_rtc.onicecandidate = async (e) => {
|
current_rtc.onicecandidate = async (e) => {
|
||||||
console.log('[D] on candidate ',e )
|
console.log('[D] on candidate ', e)
|
||||||
await fetch('/api/ulocal/candidate', {
|
await fetch('/api/ulocal/candidate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({candidate: e.candidate, id: useLocalStore.getState().id})
|
body: JSON.stringify({candidate: e.candidate, id: useLocalStore.getState().id})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ch = current_rtc.createDataChannel('local', {ordered: true})
|
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(() => {
|
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'});
|
||||||
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)
|
set(data.id, data.name)
|
||||||
console.log(`[D] register id = ${data.id}`);
|
console.log(`[D] register id = ${data.id}`);
|
||||||
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent})
|
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent})
|
||||||
@ -306,6 +332,7 @@ export const LocalSharing: React.FC = () => {
|
|||||||
rtcRef.current = _rtc; // 同步设置 ref
|
rtcRef.current = _rtc; // 同步设置 ref
|
||||||
setRTC(_rtc); // 更新状态(如果需要触发渲染)
|
setRTC(_rtc); // 更新状态(如果需要触发渲染)
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
close();
|
close();
|
||||||
if (rtcRef) {
|
if (rtcRef) {
|
||||||
@ -340,5 +367,8 @@ export const LocalSharing: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Drawer isOpen={open.send} width={"80%"} height={"400px"} close={() => setOpen({send: false, receive: false})}>
|
||||||
|
<Sender onSend={handleSend}></Sender>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
@ -3,7 +3,9 @@ import {create} from 'zustand'
|
|||||||
export interface LocalStore {
|
export interface LocalStore {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
channel?: RTCDataChannel;
|
||||||
set: (id: string, name: string) => void;
|
set: (id: string, name: string) => void;
|
||||||
|
setChannel: (chan?: RTCDataChannel) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLocalStore = create<LocalStore>()((_set, get) => ({
|
export const useLocalStore = create<LocalStore>()((_set, get) => ({
|
||||||
@ -13,5 +15,10 @@ export const useLocalStore = create<LocalStore>()((_set, get) => ({
|
|||||||
_set(state => {
|
_set(state => {
|
||||||
return {...state, id: id, name: name};
|
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