"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 [attachment, setAttachment] = useState(null); 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 () => { if (attachment?.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } }; }, [attachment]); 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 clearPendingAttachment = () => { if (attachment?.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } setAttachment(null); }; 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(""); clearPendingAttachment(); 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 applyAttachment = async (file: File) => { clearPendingAttachment(); setAttachment({ file, name: file.name, meta: formatFileMeta(file), kind: file.type.startsWith("image/") ? "image" : "file", previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined }); setError(""); }; const handleFileChange = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { return; } try { await applyAttachment(file); } catch (fileError) { setError(fileError instanceof Error ? fileError.message : "文件读取失败"); } finally { event.target.value = ""; } }; const handlePaste = async (event: ClipboardEvent) => { const fileItem = Array.from(event.clipboardData.items).find((item) => item.kind === "file"); if (!fileItem) { return; } const file = fileItem.getAsFile(); if (!file) { return; } event.preventDefault(); try { await applyAttachment(file); } catch (fileError) { setError(fileError instanceof Error ? fileError.message : "文件读取失败"); } }; const handleSend = async () => { const content = draft.trim(); if ((!content && !attachment) || sending) { return; } setSending(true); try { let response: Response; if (attachment) { const formData = new FormData(); formData.append("user", userName); formData.append("content", content); formData.append("file", attachment.file); response = await fetch("/api/chat", { method: "POST", body: formData }); } else { 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[] }; setMessages((current) => [...current, data.message]); setOnlineUsers(data.onlineUsers); setDraft(""); clearPendingAttachment(); 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}
); })}