- 后端 ws.rs: 处理前端上传的 base64 内容,保存到本地文件并更新路径 - 后端 ws.rs: 历史消息加载时从文件读取内容填充 base64,过滤 media_refs_json - 前端 App.tsx: 传递 attachments 给 handleMessage 实现实时显示 - 前端 useChat.ts: handleMessage 支持 attachments 参数 - 前端 MessageInput.tsx: 支持剪贴板粘贴文件/图片 - 前端 MessageInput.tsx: 修复拖拽文件时闪烁问题 - 测试 test_request_format.rs: 补充缺失的 attachments 字段
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { Send, Loader2, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react'
|
||
import { useState, useRef, useEffect } from 'react'
|
||
import type { Attachment } from '../../types/protocol'
|
||
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||
|
||
interface MessageInputProps {
|
||
onSend: (content: string, attachments: Attachment[]) => void
|
||
disabled?: boolean
|
||
isLoading?: boolean
|
||
placeholder?: string
|
||
isReadOnly?: boolean
|
||
channelName?: string
|
||
}
|
||
|
||
interface FileAttachment {
|
||
file: File
|
||
attachment: Attachment
|
||
preview?: string // 用于图片预览
|
||
}
|
||
|
||
// 根据 MIME 类型判断 media_type
|
||
function getMediaType(mimeType: string): string {
|
||
if (mimeType.startsWith('image/')) return 'image'
|
||
if (mimeType.startsWith('audio/')) return 'audio'
|
||
if (mimeType.startsWith('video/')) return 'video'
|
||
return 'file'
|
||
}
|
||
|
||
export function MessageInput({
|
||
onSend,
|
||
disabled = false,
|
||
isLoading = false,
|
||
placeholder = '输入消息...按 / 查看命令',
|
||
isReadOnly = false,
|
||
channelName,
|
||
}: MessageInputProps) {
|
||
const [content, setContent] = useState('')
|
||
const [attachments, setAttachments] = useState<FileAttachment[]>([])
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const wasLoadingRef = useRef(false)
|
||
|
||
useEffect(() => {
|
||
const textarea = textareaRef.current
|
||
if (textarea) {
|
||
textarea.style.height = 'auto'
|
||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||
}
|
||
}, [content])
|
||
|
||
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
|
||
useEffect(() => {
|
||
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
|
||
textareaRef.current?.focus()
|
||
}
|
||
wasLoadingRef.current = isLoading
|
||
}, [isLoading, isReadOnly])
|
||
|
||
// 处理文件选择
|
||
const handleFileSelect = async (files: FileList | null) => {
|
||
if (!files) return
|
||
setError(null)
|
||
|
||
const newAttachments: FileAttachment[] = []
|
||
for (const file of Array.from(files)) {
|
||
// 检查文件大小
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
||
continue
|
||
}
|
||
|
||
// 读取文件为 base64
|
||
const base64 = await readFileAsBase64(file)
|
||
const mimeType = file.type || 'application/octet-stream'
|
||
const mediaType = getMediaType(mimeType)
|
||
|
||
const attachment: Attachment = {
|
||
path: file.name,
|
||
media_type: mediaType,
|
||
mime_type: mimeType,
|
||
content_base64: base64,
|
||
file_name: file.name,
|
||
}
|
||
|
||
const fileAttachment: FileAttachment = {
|
||
file,
|
||
attachment,
|
||
preview: mediaType === 'image' ? base64 : undefined,
|
||
}
|
||
|
||
newAttachments.push(fileAttachment)
|
||
}
|
||
|
||
setAttachments(prev => [...prev, ...newAttachments])
|
||
}
|
||
|
||
// 读取文件为 base64
|
||
const readFileAsBase64 = (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
const result = reader.result as string
|
||
// 移除 data:xxx;base64, 前缀
|
||
const base64 = result.split(',')[1]
|
||
resolve(base64)
|
||
}
|
||
reader.onerror = reject
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
// 点击附件按钮
|
||
const handleAttachClick = () => {
|
||
fileInputRef.current?.click()
|
||
}
|
||
|
||
// 删除附件
|
||
const handleRemoveAttachment = (index: number) => {
|
||
setAttachments(prev => prev.filter((_, i) => i !== index))
|
||
}
|
||
|
||
// 粘贴事件处理
|
||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||
if (disabled || isReadOnly) return
|
||
|
||
const clipboardData = e.clipboardData
|
||
const items = clipboardData.items
|
||
|
||
// 检查是否有文件(图片或其他文件)
|
||
const files: File[] = []
|
||
for (const item of Array.from(items)) {
|
||
if (item.kind === 'file') {
|
||
const file = item.getAsFile()
|
||
if (file) {
|
||
files.push(file)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果有文件,处理文件并阻止默认粘贴行为
|
||
if (files.length > 0) {
|
||
e.preventDefault()
|
||
// 直接处理文件数组
|
||
setError(null)
|
||
for (const file of files) {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
||
continue
|
||
}
|
||
|
||
const base64 = await readFileAsBase64(file)
|
||
const mimeType = file.type || 'application/octet-stream'
|
||
const mediaType = getMediaType(mimeType)
|
||
|
||
const attachment: Attachment = {
|
||
path: file.name,
|
||
media_type: mediaType,
|
||
mime_type: mimeType,
|
||
content_base64: base64,
|
||
file_name: file.name,
|
||
}
|
||
|
||
const fileAttachment: FileAttachment = {
|
||
file,
|
||
attachment,
|
||
preview: mediaType === 'image' ? base64 : undefined,
|
||
}
|
||
|
||
setAttachments(prev => [...prev, fileAttachment])
|
||
}
|
||
}
|
||
// 否则让默认的文本粘贴行为继续
|
||
}
|
||
|
||
// 拖拽事件
|
||
const handleDragEnter = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!disabled && !isReadOnly) {
|
||
setIsDragging(true)
|
||
}
|
||
}
|
||
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
// 检查是否真的离开了拖拽区域(而不是进入子元素)
|
||
const relatedTarget = e.relatedTarget as Node | null
|
||
const currentTarget = e.currentTarget
|
||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||
setIsDragging(false)
|
||
}
|
||
}
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setIsDragging(false)
|
||
if (!disabled && !isReadOnly) {
|
||
handleFileSelect(e.dataTransfer.files)
|
||
}
|
||
}
|
||
|
||
const handleSend = () => {
|
||
const hasContent = content.trim() || attachments.length > 0
|
||
if (hasContent && !disabled && !isReadOnly) {
|
||
onSend(
|
||
content.trim(),
|
||
attachments.map(a => a.attachment)
|
||
)
|
||
setContent('')
|
||
setAttachments([])
|
||
setError(null)
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = 'auto'
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
handleSend()
|
||
}
|
||
}
|
||
|
||
// 获取附件图标
|
||
const getAttachmentIcon = (mediaType: string) => {
|
||
switch (mediaType) {
|
||
case 'image':
|
||
return <ImageIcon className="h-4 w-4" />
|
||
case 'audio':
|
||
return <MusicIcon className="h-4 w-4" />
|
||
case 'video':
|
||
return <VideoIcon className="h-4 w-4" />
|
||
default:
|
||
return <FileIcon className="h-4 w-4" />
|
||
}
|
||
}
|
||
|
||
// 只读模式:显示提示占位符
|
||
if (isReadOnly) {
|
||
return (
|
||
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
|
||
<div className="max-w-5xl mx-auto">
|
||
<div className="rounded-xl border border-zinc-500/20 bg-[#1a1a25]/50 px-4 py-5 text-center">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div className="flex items-center gap-2 text-zinc-400">
|
||
<Eye className="h-5 w-5" />
|
||
<span className="font-medium">只读模式</span>
|
||
</div>
|
||
<p className="text-sm text-zinc-500">
|
||
{channelName ? (
|
||
<>{channelName} 通道仅支持查看历史消息</>
|
||
) : (
|
||
'当前通道仅支持查看历史消息'
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-zinc-600">
|
||
请切换至 WebSocket 通道进行输入
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
|
||
<div className="max-w-5xl mx-auto">
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件预览列表 */}
|
||
{attachments.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2">
|
||
{attachments.map((att, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#1a1a25] px-2 py-1.5 text-sm"
|
||
>
|
||
{att.preview ? (
|
||
<img
|
||
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
|
||
alt={att.attachment.file_name}
|
||
className="h-8 w-8 rounded object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-8 w-8 rounded bg-zinc-700/50 flex items-center justify-center text-zinc-400">
|
||
{getAttachmentIcon(att.attachment.media_type)}
|
||
</div>
|
||
)}
|
||
<span className="text-zinc-300 max-w-[120px] truncate">
|
||
{att.attachment.file_name}
|
||
</span>
|
||
<button
|
||
onClick={() => handleRemoveAttachment(index)}
|
||
className="text-zinc-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入区域 */}
|
||
<div
|
||
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[#00f0ff]/50 rounded-xl' : ''}`}
|
||
onDragEnter={handleDragEnter}
|
||
onDragLeave={handleDragLeave}
|
||
onDragOver={handleDragOver}
|
||
onDrop={handleDrop}
|
||
>
|
||
{/* 拖拽提示 */}
|
||
{isDragging && (
|
||
<div className="absolute inset-0 rounded-xl bg-[#00f0ff]/10 border-2 border-[#00f0ff]/50 flex items-center justify-center z-10">
|
||
<div className="text-[#00f0ff] text-sm font-medium">
|
||
拖放文件到这里
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件按钮 */}
|
||
<button
|
||
onClick={handleAttachClick}
|
||
disabled={disabled}
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-[#1a1a25] text-zinc-400 hover:text-white hover:border-white/20 disabled:opacity-50 transition-all"
|
||
>
|
||
<Paperclip className="h-5 w-5" />
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => handleFileSelect(e.target.files)}
|
||
/>
|
||
|
||
{/* 输入框 */}
|
||
<div className="flex-1 relative flex items-center">
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
rows={1}
|
||
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all self-center scrollbar-hide"
|
||
/>
|
||
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
|
||
</div>
|
||
|
||
{/* 发送按钮 */}
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={disabled || (!content.trim() && attachments.length === 0)}
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
{disabled ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Send className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 提示 */}
|
||
<div className="mt-2 text-center text-xs text-zinc-500">
|
||
按 Enter 发送,Shift+Enter 换行 · 支持拖拽/粘贴文件 · 最大 50MB
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |