| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- "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<ChatMessage[]>([]);
- const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
- 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<PendingAttachment[]>([]);
- const [previewImage, setPreviewImage] = useState<ChatAttachment | null>(null);
- const [downloadedFiles, setDownloadedFiles] = useState<Record<string, boolean>>({});
- const messageListRef = useRef<HTMLElement | null>(null);
- const fileInputRef = useRef<HTMLInputElement | null>(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<string, boolean>);
- } 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<HTMLInputElement>) => {
- const files = Array.from(event.target.files ?? []);
- if (files.length) addFiles(files);
- event.target.value = "";
- };
- const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
- 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 (
- <main className="page-shell chat-wechat-page">
- <section className="chat-wechat-shell">
- <aside className="chat-wechat-sidebar" aria-label="在线用户">
- <div className="chat-wechat-sidebar__title">局域网在线设备</div>
- <div className="chat-wechat-sidebar__meta">{onlineUsersWithCurrent.length} 台设备在线</div>
- <div className="chat-wechat-member-list">
- {onlineUsersWithCurrent.map((name) => (
- <div key={name} className={`chat-wechat-member${name === userName ? " chat-wechat-member--self" : ""}`}>
- <div className="chat-wechat-member__avatar">{getAvatarSeed(name)}</div>
- <div className="chat-wechat-member__body">
- <strong>{name}</strong>
- <span>{name === userName ? "当前设备" : "局域网已连接"}</span>
- </div>
- </div>
- ))}
- </div>
- </aside>
- <section className="chat-wechat-main">
- <header className="chat-wechat-header">
- <div className="chat-wechat-header__row">
- <div className="chat-wechat-header__main">
- <div className="chat-wechat-header__title">局域网公共聊天室</div>
- <div className="chat-wechat-header__meta">文字、图片、文件都可以直接在这里流转</div>
- </div>
- <div className="chat-wechat-status" aria-label="在线状态">
- <span className="chat-wechat-status__text">{onlineUsersWithCurrent.length} 台设备在线</span>
- <span className="chat-wechat-status__icon" aria-hidden="true">
- LAN
- </span>
- </div>
- </div>
- <div className="chat-wechat-header__presence">
- <div className="chat-wechat-header__avatars" aria-hidden="true">
- {onlineUsersWithCurrent.slice(0, 4).map((name) => (
- <span key={name} className="chat-wechat-header__avatar">
- {getAvatarSeed(name)}
- </span>
- ))}
- </div>
- <div className="chat-wechat-header__presence-text">{onlineUsersWithCurrent.length} 台设备在线</div>
- </div>
- </header>
- <section className="chat-wechat-messages" aria-label="聊天消息列表" ref={messageListRef}>
- {messages.length === 0 ? <div className="chat-wechat-empty">聊天室已经准备好,现在发第一条消息吧。</div> : null}
- {messages.map((message) => {
- const self = message.user === userName;
- const imageAttachment = message.file?.kind === "image" ? message.file : null;
- return (
- <article key={message.id} className={`chat-wechat-message${self ? " chat-wechat-message--self" : ""}`}>
- {!self ? <div className="chat-wechat-avatar">{getAvatarSeed(message.user)}</div> : null}
- <div
- className={`chat-wechat-message__body${
- imageAttachment && !message.content ? " chat-wechat-message__body--image" : ""
- }`}
- >
- {!self ? <div className="chat-wechat-message__name">{message.user}</div> : null}
- <div
- className={`chat-wechat-bubble${imageAttachment ? " chat-wechat-bubble--image" : ""}${
- !message.content && message.file ? " chat-wechat-bubble--attachment-only" : ""
- }`}
- >
- {message.content ? <div className="chat-wechat-bubble__text">{message.content}</div> : null}
- {message.file ? (
- <div
- className={`chat-wechat-file${imageAttachment ? " chat-wechat-file--image" : ""}${
- !imageAttachment ? " chat-wechat-file--download" : ""
- }`}
- >
- {imageAttachment?.url ? (
- <button
- type="button"
- className="chat-wechat-file__image-button"
- onClick={() => setPreviewImage(imageAttachment)}
- >
- <img src={imageAttachment.url} alt={message.file.name} className="chat-wechat-file__image" />
- </button>
- ) : null}
- {imageAttachment ? (
- <button
- type="button"
- className="chat-wechat-file__link chat-wechat-file__link--image"
- onClick={() => setPreviewImage(imageAttachment)}
- >
- <strong>{message.file.name}</strong>
- </button>
- ) : message.file.url ? (
- <button type="button" className="chat-wechat-file__download" onClick={() => handleFileOpen(message)}>
- <span className="chat-wechat-file__icon" aria-hidden="true">
- 文
- </span>
- <span className="chat-wechat-file__info">
- <strong>{message.file.name}</strong>
- <span>
- {downloadedFiles[`${message.id}:${message.file.storageKey || message.file.name}`]
- ? "点击打开"
- : "点击下载"}
- </span>
- </span>
- </button>
- ) : (
- <strong>{message.file.name}</strong>
- )}
- <div className="chat-wechat-file__meta">{message.file.meta}</div>
- </div>
- ) : null}
- </div>
- <div className="chat-wechat-message__time">{message.time}</div>
- </div>
- {self ? <div className="chat-wechat-avatar chat-wechat-avatar--self">我</div> : null}
- </article>
- );
- })}
- </section>
- <footer className="chat-wechat-composer">
- <input
- ref={fileInputRef}
- type="file"
- hidden
- multiple
- accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
- onChange={handleFileChange}
- />
- <div className="chat-wechat-composer__tools">
- <div className="chat-wechat-tools-left">
- <button
- className={`chat-wechat-tool${emojiOpen ? " is-active" : ""}`}
- type="button"
- aria-label="表情"
- onClick={() => setEmojiOpen((current) => !current)}
- >
- 😊
- </button>
- <button className="chat-wechat-tool" type="button" aria-label="选择文件" onClick={openFilePicker}>
- <svg className="chat-wechat-tool__icon" viewBox="0 0 24 24" aria-hidden="true">
- <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
- </svg>
- </button>
- <button
- className="chat-wechat-tool chat-wechat-tool--danger"
- type="button"
- aria-label="清理聊天记录"
- title="清理聊天记录"
- onClick={() => void handleClearHistory()}
- >
- 清
- </button>
- </div>
- {error ? <div className="chat-wechat-error">{error}</div> : null}
- </div>
- {emojiOpen ? (
- <div className="chat-wechat-emoji-panel" aria-label="表情面板">
- {emojiList.map((emoji) => (
- <button key={emoji} type="button" onClick={() => appendEmoji(emoji)}>
- {emoji}
- </button>
- ))}
- </div>
- ) : null}
- {attachments.length > 0 ? (
- <div className="chat-wechat-attachments">
- {attachments.map((att, index) => (
- <div key={index} className="chat-wechat-attachment">
- {att.kind === "image" && att.previewUrl ? (
- <img src={att.previewUrl} alt={att.name} className="chat-wechat-attachment__image" />
- ) : (
- <span className="chat-wechat-attachment__icon">文</span>
- )}
- <div className="chat-wechat-attachment__meta">
- <strong>{att.name}</strong>
- <span>{att.meta}</span>
- </div>
- <button type="button" onClick={() => removeAttachment(index)} aria-label="移除附件">
- ×
- </button>
- </div>
- ))}
- {attachments.length < MAX_ATTACHMENTS ? (
- <button type="button" className="chat-wechat-attachment-add" onClick={openFilePicker} aria-label="继续添加">
- +
- </button>
- ) : null}
- </div>
- ) : null}
- <div className="chat-wechat-composer__row">
- <textarea
- className="chat-wechat-input"
- placeholder="输入消息,或直接粘贴图片 / 文件"
- value={draft}
- rows={1}
- onChange={(event) => setDraft(event.target.value)}
- onPaste={(event) => void handlePaste(event)}
- onKeyDown={(event) => {
- if (event.key === "Enter" && !event.shiftKey) {
- event.preventDefault();
- void handleSend();
- }
- }}
- />
- <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
- {sending ? "发送中" : attachments.length > 0 ? `发送(${attachments.length})` : "发送"}
- </button>
- </div>
- </footer>
- </section>
- </section>
- {previewImage?.url ? (
- <div className="chat-wechat-image-viewer" onClick={() => setPreviewImage(null)}>
- <img
- src={previewImage.url}
- alt={previewImage.name}
- className="chat-wechat-image-viewer__image"
- onClick={(event) => event.stopPropagation()}
- />
- </div>
- ) : null}
- </main>
- );
- }
|