"use client"; import { ChangeEvent, ClipboardEvent, useEffect, useMemo, useRef, useState } from "react"; type ChatAttachment = { name: string; meta: string; kind: "image" | "file"; url?: string; storageKey?: string; }; type ChatMessage = { id: string; user: string; time: string; content: string; file?: ChatAttachment; }; type ChatResponse = { messages: ChatMessage[]; onlineUsers: string[]; }; type PendingAttachment = { file: File; name: string; meta: string; kind: "image" | "file"; previewUrl?: string; }; function detectBrowser() { const ua = navigator.userAgent; if (ua.includes("Edg/")) return "Edge"; if (ua.includes("Chrome/")) return "Chrome"; if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari"; if (ua.includes("Firefox/")) return "Firefox"; return "Browser"; } function detectDevice() { const ua = navigator.userAgent; if (/iPhone/i.test(ua)) return "iPhone"; if (/Android/i.test(ua)) return "Android"; if (/Macintosh|Mac OS X/i.test(ua)) return "MacBook"; if (/Windows/i.test(ua)) return "Windows"; return "Device"; } function createLocalName() { const stored = window.localStorage.getItem("lan-chat-user"); if (stored) { return stored; } const suffix = Math.random().toString(36).slice(2, 6).toUpperCase(); const nextValue = `${detectDevice()}-${detectBrowser()}-${suffix}`; window.localStorage.setItem("lan-chat-user", nextValue); return nextValue; } function getAvatarSeed(name: string) { return name .split("-") .slice(0, 2) .join("") .slice(0, 2) .toUpperCase(); } function formatFileMeta(file: File) { const sizeMb = file.size / (1024 * 1024); const size = sizeMb >= 1 ? `${sizeMb.toFixed(1)} MB` : `${Math.max(1, Math.round(file.size / 1024))} KB`; return `${size} · ${file.type || "application/octet-stream"}`; } const emojiList = ["😊", "😀", "👍", "🎉", "📎", "🔥", "✨", "✅"]; export default function ChatPage() { const [messages, setMessages] = useState([]); const [onlineUsers, setOnlineUsers] = useState([]); const [draft, setDraft] = useState(""); const [userName, setUserName] = useState("局域网设备"); const [sending, setSending] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false); const [error, setError] = useState(""); const [attachments, setAttachments] = useState([]); const [previewImage, setPreviewImage] = useState(null); const [downloadedFiles, setDownloadedFiles] = useState>({}); const messageListRef = useRef(null); const fileInputRef = useRef(null); const onlineUsersWithCurrent = useMemo(() => { if (!userName || userName === "局域网设备") { return onlineUsers; } return onlineUsers.includes(userName) ? onlineUsers : [userName, ...onlineUsers]; }, [onlineUsers, userName]); useEffect(() => { document.body.classList.add("chat-body"); setUserName(createLocalName()); const storedDownloads = window.localStorage.getItem("lan-chat-downloaded-files"); if (storedDownloads) { try { setDownloadedFiles(JSON.parse(storedDownloads) as Record); } catch { window.localStorage.removeItem("lan-chat-downloaded-files"); } } return () => { document.body.classList.remove("chat-body"); }; }, []); useEffect(() => { return () => { attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [attachments]); useEffect(() => { if (!userName || userName === "局域网设备") { return; } let cancelled = false; const loadChat = async () => { try { const response = await fetch(`/api/chat?user=${encodeURIComponent(userName)}`, { cache: "no-store" }); if (!response.ok) { throw new Error("加载聊天记录失败"); } const data = (await response.json()) as ChatResponse; if (cancelled) { return; } setOnlineUsers(data.onlineUsers); setMessages((current) => { const currentLastId = current.at(-1)?.id; const nextLastId = data.messages.at(-1)?.id; if (currentLastId !== nextLastId || current.length !== data.messages.length) { queueMicrotask(() => { messageListRef.current?.scrollTo({ top: messageListRef.current.scrollHeight, behavior: "auto" }); }); } return data.messages; }); setError(""); } catch (loadError) { if (!cancelled) { setError(loadError instanceof Error ? loadError.message : "加载聊天记录失败"); } } }; void loadChat(); const timer = window.setInterval(() => void loadChat(), 2500); return () => { cancelled = true; window.clearInterval(timer); }; }, [userName]); useEffect(() => { if (!messages.length) { return; } messageListRef.current?.scrollTo({ top: messageListRef.current.scrollHeight, behavior: "auto" }); }, [messages.length]); const appendEmoji = (emoji: string) => { setDraft((current) => `${current}${emoji}`); setEmojiOpen(false); }; const openFilePicker = () => { fileInputRef.current?.click(); }; const MAX_ATTACHMENTS = 5; const clearPendingAttachments = () => { attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); }); setAttachments([]); }; const removeAttachment = (index: number) => { setAttachments((current) => { const item = current[index]; if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl); return current.filter((_, i) => i !== index); }); }; const handleClearHistory = async () => { if (sending) { return; } const confirmed = window.confirm("确认清理本地和服务器的历史聊天记录吗?"); if (!confirmed) { return; } setSending(true); try { const response = await fetch("/api/chat", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user: userName }) }); if (!response.ok) { throw new Error("清理聊天记录失败"); } const data = (await response.json()) as ChatResponse; setMessages(data.messages); setOnlineUsers(data.onlineUsers); setDraft(""); clearPendingAttachments(); setEmojiOpen(false); setPreviewImage(null); setError(""); setDownloadedFiles({}); window.localStorage.removeItem("lan-chat-downloaded-files"); queueMicrotask(() => { messageListRef.current?.scrollTo({ top: messageListRef.current.scrollHeight, behavior: "auto" }); }); } catch (clearError) { setError(clearError instanceof Error ? clearError.message : "清理聊天记录失败"); } finally { setSending(false); } }; const addFiles = (files: File[]) => { setAttachments((current) => { const remaining = MAX_ATTACHMENTS - current.length; if (remaining <= 0) return current; const toAdd = files.slice(0, remaining).map((file) => ({ file, name: file.name, meta: formatFileMeta(file), kind: (file.type.startsWith("image/") ? "image" : "file") as "image" | "file", previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined })); return [...current, ...toAdd]; }); setError(""); }; const handleFileChange = (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length) addFiles(files); event.target.value = ""; }; const handlePaste = (event: ClipboardEvent) => { const files = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) .filter((f): f is File => f !== null); if (!files.length) return; event.preventDefault(); addFiles(files); }; const handleSend = async () => { const content = draft.trim(); if ((!content && attachments.length === 0) || sending) { return; } setSending(true); try { const newMessages: ChatMessage[] = []; let lastOnlineUsers: string[] = onlineUsers; // Send text first if any if (content) { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user: userName, content }) }); if (!response.ok) throw new Error("发送失败"); const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] }; newMessages.push(data.message); lastOnlineUsers = data.onlineUsers; } // Send each attachment as a separate message for (const att of attachments) { const formData = new FormData(); formData.append("user", userName); formData.append("content", ""); formData.append("file", att.file); const response = await fetch("/api/chat", { method: "POST", body: formData }); if (!response.ok) throw new Error("文件发送失败"); const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] }; newMessages.push(data.message); lastOnlineUsers = data.onlineUsers; } setMessages((current) => [...current, ...newMessages]); setOnlineUsers(lastOnlineUsers); setDraft(""); clearPendingAttachments(); setEmojiOpen(false); setError(""); queueMicrotask(() => { messageListRef.current?.scrollTo({ top: messageListRef.current.scrollHeight, behavior: "smooth" }); }); } catch (sendError) { setError(sendError instanceof Error ? sendError.message : "发送失败"); } finally { setSending(false); } }; const markFileDownloaded = (key: string) => { setDownloadedFiles((current) => { const next = { ...current, [key]: true }; window.localStorage.setItem("lan-chat-downloaded-files", JSON.stringify(next)); return next; }); }; const handleFileOpen = (message: ChatMessage) => { if (!message.file?.url) { return; } const key = `${message.id}:${message.file.storageKey || message.file.name}`; if (downloadedFiles[key]) { window.open(message.file.url, "_blank", "noopener,noreferrer"); return; } const link = document.createElement("a"); link.href = `${message.file.url}?download=1`; link.download = message.file.name; link.click(); markFileDownloaded(key); }; return (
局域网公共聊天室
文字、图片、文件都可以直接在这里流转
{onlineUsersWithCurrent.length} 台设备在线
{onlineUsersWithCurrent.length} 台设备在线
{messages.length === 0 ?
聊天室已经准备好,现在发第一条消息吧。
: null} {messages.map((message) => { const self = message.user === userName; const imageAttachment = message.file?.kind === "image" ? message.file : null; return (
{!self ?
{getAvatarSeed(message.user)}
: null}
{!self ?
{message.user}
: null}
{message.content ?
{message.content}
: null} {message.file ? (
{imageAttachment?.url ? ( ) : null} {imageAttachment ? ( ) : message.file.url ? ( ) : ( {message.file.name} )}
{message.file.meta}
) : null}
{message.time}
{self ?
: null}
); })}