page.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. "use client";
  2. import { ChangeEvent, ClipboardEvent, useEffect, useMemo, useRef, useState } from "react";
  3. type ChatAttachment = {
  4. name: string;
  5. meta: string;
  6. kind: "image" | "file";
  7. url?: string;
  8. storageKey?: string;
  9. };
  10. type ChatMessage = {
  11. id: string;
  12. user: string;
  13. time: string;
  14. content: string;
  15. file?: ChatAttachment;
  16. };
  17. type ChatResponse = {
  18. messages: ChatMessage[];
  19. onlineUsers: string[];
  20. };
  21. type PendingAttachment = {
  22. file: File;
  23. name: string;
  24. meta: string;
  25. kind: "image" | "file";
  26. previewUrl?: string;
  27. };
  28. function detectBrowser() {
  29. const ua = navigator.userAgent;
  30. if (ua.includes("Edg/")) return "Edge";
  31. if (ua.includes("Chrome/")) return "Chrome";
  32. if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
  33. if (ua.includes("Firefox/")) return "Firefox";
  34. return "Browser";
  35. }
  36. function detectDevice() {
  37. const ua = navigator.userAgent;
  38. if (/iPhone/i.test(ua)) return "iPhone";
  39. if (/Android/i.test(ua)) return "Android";
  40. if (/Macintosh|Mac OS X/i.test(ua)) return "MacBook";
  41. if (/Windows/i.test(ua)) return "Windows";
  42. return "Device";
  43. }
  44. function createLocalName() {
  45. const stored = window.localStorage.getItem("lan-chat-user");
  46. if (stored) {
  47. return stored;
  48. }
  49. const suffix = Math.random().toString(36).slice(2, 6).toUpperCase();
  50. const nextValue = `${detectDevice()}-${detectBrowser()}-${suffix}`;
  51. window.localStorage.setItem("lan-chat-user", nextValue);
  52. return nextValue;
  53. }
  54. function getAvatarSeed(name: string) {
  55. return name
  56. .split("-")
  57. .slice(0, 2)
  58. .join("")
  59. .slice(0, 2)
  60. .toUpperCase();
  61. }
  62. function formatFileMeta(file: File) {
  63. const sizeMb = file.size / (1024 * 1024);
  64. const size = sizeMb >= 1 ? `${sizeMb.toFixed(1)} MB` : `${Math.max(1, Math.round(file.size / 1024))} KB`;
  65. return `${size} · ${file.type || "application/octet-stream"}`;
  66. }
  67. const emojiList = ["😊", "😀", "👍", "🎉", "📎", "🔥", "✨", "✅"];
  68. export default function ChatPage() {
  69. const [messages, setMessages] = useState<ChatMessage[]>([]);
  70. const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
  71. const [draft, setDraft] = useState("");
  72. const [userName, setUserName] = useState("局域网设备");
  73. const [sending, setSending] = useState(false);
  74. const [emojiOpen, setEmojiOpen] = useState(false);
  75. const [error, setError] = useState("");
  76. const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
  77. const [previewImage, setPreviewImage] = useState<ChatAttachment | null>(null);
  78. const [downloadedFiles, setDownloadedFiles] = useState<Record<string, boolean>>({});
  79. const messageListRef = useRef<HTMLElement | null>(null);
  80. const fileInputRef = useRef<HTMLInputElement | null>(null);
  81. const onlineUsersWithCurrent = useMemo(() => {
  82. if (!userName || userName === "局域网设备") {
  83. return onlineUsers;
  84. }
  85. return onlineUsers.includes(userName) ? onlineUsers : [userName, ...onlineUsers];
  86. }, [onlineUsers, userName]);
  87. useEffect(() => {
  88. document.body.classList.add("chat-body");
  89. setUserName(createLocalName());
  90. const storedDownloads = window.localStorage.getItem("lan-chat-downloaded-files");
  91. if (storedDownloads) {
  92. try {
  93. setDownloadedFiles(JSON.parse(storedDownloads) as Record<string, boolean>);
  94. } catch {
  95. window.localStorage.removeItem("lan-chat-downloaded-files");
  96. }
  97. }
  98. return () => {
  99. document.body.classList.remove("chat-body");
  100. };
  101. }, []);
  102. useEffect(() => {
  103. return () => {
  104. attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); });
  105. };
  106. // eslint-disable-next-line react-hooks/exhaustive-deps
  107. }, [attachments]);
  108. useEffect(() => {
  109. if (!userName || userName === "局域网设备") {
  110. return;
  111. }
  112. let cancelled = false;
  113. const loadChat = async () => {
  114. try {
  115. const response = await fetch(`/api/chat?user=${encodeURIComponent(userName)}`, { cache: "no-store" });
  116. if (!response.ok) {
  117. throw new Error("加载聊天记录失败");
  118. }
  119. const data = (await response.json()) as ChatResponse;
  120. if (cancelled) {
  121. return;
  122. }
  123. setOnlineUsers(data.onlineUsers);
  124. setMessages((current) => {
  125. const currentLastId = current.at(-1)?.id;
  126. const nextLastId = data.messages.at(-1)?.id;
  127. if (currentLastId !== nextLastId || current.length !== data.messages.length) {
  128. queueMicrotask(() => {
  129. messageListRef.current?.scrollTo({
  130. top: messageListRef.current.scrollHeight,
  131. behavior: "auto"
  132. });
  133. });
  134. }
  135. return data.messages;
  136. });
  137. setError("");
  138. } catch (loadError) {
  139. if (!cancelled) {
  140. setError(loadError instanceof Error ? loadError.message : "加载聊天记录失败");
  141. }
  142. }
  143. };
  144. void loadChat();
  145. const timer = window.setInterval(() => void loadChat(), 2500);
  146. return () => {
  147. cancelled = true;
  148. window.clearInterval(timer);
  149. };
  150. }, [userName]);
  151. useEffect(() => {
  152. if (!messages.length) {
  153. return;
  154. }
  155. messageListRef.current?.scrollTo({
  156. top: messageListRef.current.scrollHeight,
  157. behavior: "auto"
  158. });
  159. }, [messages.length]);
  160. const appendEmoji = (emoji: string) => {
  161. setDraft((current) => `${current}${emoji}`);
  162. setEmojiOpen(false);
  163. };
  164. const openFilePicker = () => {
  165. fileInputRef.current?.click();
  166. };
  167. const MAX_ATTACHMENTS = 5;
  168. const clearPendingAttachments = () => {
  169. attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); });
  170. setAttachments([]);
  171. };
  172. const removeAttachment = (index: number) => {
  173. setAttachments((current) => {
  174. const item = current[index];
  175. if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
  176. return current.filter((_, i) => i !== index);
  177. });
  178. };
  179. const handleClearHistory = async () => {
  180. if (sending) {
  181. return;
  182. }
  183. const confirmed = window.confirm("确认清理本地和服务器的历史聊天记录吗?");
  184. if (!confirmed) {
  185. return;
  186. }
  187. setSending(true);
  188. try {
  189. const response = await fetch("/api/chat", {
  190. method: "DELETE",
  191. headers: {
  192. "Content-Type": "application/json"
  193. },
  194. body: JSON.stringify({
  195. user: userName
  196. })
  197. });
  198. if (!response.ok) {
  199. throw new Error("清理聊天记录失败");
  200. }
  201. const data = (await response.json()) as ChatResponse;
  202. setMessages(data.messages);
  203. setOnlineUsers(data.onlineUsers);
  204. setDraft("");
  205. clearPendingAttachments();
  206. setEmojiOpen(false);
  207. setPreviewImage(null);
  208. setError("");
  209. setDownloadedFiles({});
  210. window.localStorage.removeItem("lan-chat-downloaded-files");
  211. queueMicrotask(() => {
  212. messageListRef.current?.scrollTo({
  213. top: messageListRef.current.scrollHeight,
  214. behavior: "auto"
  215. });
  216. });
  217. } catch (clearError) {
  218. setError(clearError instanceof Error ? clearError.message : "清理聊天记录失败");
  219. } finally {
  220. setSending(false);
  221. }
  222. };
  223. const addFiles = (files: File[]) => {
  224. setAttachments((current) => {
  225. const remaining = MAX_ATTACHMENTS - current.length;
  226. if (remaining <= 0) return current;
  227. const toAdd = files.slice(0, remaining).map((file) => ({
  228. file,
  229. name: file.name,
  230. meta: formatFileMeta(file),
  231. kind: (file.type.startsWith("image/") ? "image" : "file") as "image" | "file",
  232. previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined
  233. }));
  234. return [...current, ...toAdd];
  235. });
  236. setError("");
  237. };
  238. const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
  239. const files = Array.from(event.target.files ?? []);
  240. if (files.length) addFiles(files);
  241. event.target.value = "";
  242. };
  243. const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
  244. const files = Array.from(event.clipboardData.items)
  245. .filter((item) => item.kind === "file")
  246. .map((item) => item.getAsFile())
  247. .filter((f): f is File => f !== null);
  248. if (!files.length) return;
  249. event.preventDefault();
  250. addFiles(files);
  251. };
  252. const handleSend = async () => {
  253. const content = draft.trim();
  254. if ((!content && attachments.length === 0) || sending) {
  255. return;
  256. }
  257. setSending(true);
  258. try {
  259. const newMessages: ChatMessage[] = [];
  260. let lastOnlineUsers: string[] = onlineUsers;
  261. // Send text first if any
  262. if (content) {
  263. const response = await fetch("/api/chat", {
  264. method: "POST",
  265. headers: { "Content-Type": "application/json" },
  266. body: JSON.stringify({ user: userName, content })
  267. });
  268. if (!response.ok) throw new Error("发送失败");
  269. const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] };
  270. newMessages.push(data.message);
  271. lastOnlineUsers = data.onlineUsers;
  272. }
  273. // Send each attachment as a separate message
  274. for (const att of attachments) {
  275. const formData = new FormData();
  276. formData.append("user", userName);
  277. formData.append("content", "");
  278. formData.append("file", att.file);
  279. const response = await fetch("/api/chat", { method: "POST", body: formData });
  280. if (!response.ok) throw new Error("文件发送失败");
  281. const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] };
  282. newMessages.push(data.message);
  283. lastOnlineUsers = data.onlineUsers;
  284. }
  285. setMessages((current) => [...current, ...newMessages]);
  286. setOnlineUsers(lastOnlineUsers);
  287. setDraft("");
  288. clearPendingAttachments();
  289. setEmojiOpen(false);
  290. setError("");
  291. queueMicrotask(() => {
  292. messageListRef.current?.scrollTo({
  293. top: messageListRef.current.scrollHeight,
  294. behavior: "smooth"
  295. });
  296. });
  297. } catch (sendError) {
  298. setError(sendError instanceof Error ? sendError.message : "发送失败");
  299. } finally {
  300. setSending(false);
  301. }
  302. };
  303. const markFileDownloaded = (key: string) => {
  304. setDownloadedFiles((current) => {
  305. const next = { ...current, [key]: true };
  306. window.localStorage.setItem("lan-chat-downloaded-files", JSON.stringify(next));
  307. return next;
  308. });
  309. };
  310. const handleFileOpen = (message: ChatMessage) => {
  311. if (!message.file?.url) {
  312. return;
  313. }
  314. const key = `${message.id}:${message.file.storageKey || message.file.name}`;
  315. if (downloadedFiles[key]) {
  316. window.open(message.file.url, "_blank", "noopener,noreferrer");
  317. return;
  318. }
  319. const link = document.createElement("a");
  320. link.href = `${message.file.url}?download=1`;
  321. link.download = message.file.name;
  322. link.click();
  323. markFileDownloaded(key);
  324. };
  325. return (
  326. <main className="page-shell chat-wechat-page">
  327. <section className="chat-wechat-shell">
  328. <aside className="chat-wechat-sidebar" aria-label="在线用户">
  329. <div className="chat-wechat-sidebar__title">局域网在线设备</div>
  330. <div className="chat-wechat-sidebar__meta">{onlineUsersWithCurrent.length} 台设备在线</div>
  331. <div className="chat-wechat-member-list">
  332. {onlineUsersWithCurrent.map((name) => (
  333. <div key={name} className={`chat-wechat-member${name === userName ? " chat-wechat-member--self" : ""}`}>
  334. <div className="chat-wechat-member__avatar">{getAvatarSeed(name)}</div>
  335. <div className="chat-wechat-member__body">
  336. <strong>{name}</strong>
  337. <span>{name === userName ? "当前设备" : "局域网已连接"}</span>
  338. </div>
  339. </div>
  340. ))}
  341. </div>
  342. </aside>
  343. <section className="chat-wechat-main">
  344. <header className="chat-wechat-header">
  345. <div className="chat-wechat-header__row">
  346. <div className="chat-wechat-header__main">
  347. <div className="chat-wechat-header__title">局域网公共聊天室</div>
  348. <div className="chat-wechat-header__meta">文字、图片、文件都可以直接在这里流转</div>
  349. </div>
  350. <div className="chat-wechat-status" aria-label="在线状态">
  351. <span className="chat-wechat-status__text">{onlineUsersWithCurrent.length} 台设备在线</span>
  352. <span className="chat-wechat-status__icon" aria-hidden="true">
  353. LAN
  354. </span>
  355. </div>
  356. </div>
  357. <div className="chat-wechat-header__presence">
  358. <div className="chat-wechat-header__avatars" aria-hidden="true">
  359. {onlineUsersWithCurrent.slice(0, 4).map((name) => (
  360. <span key={name} className="chat-wechat-header__avatar">
  361. {getAvatarSeed(name)}
  362. </span>
  363. ))}
  364. </div>
  365. <div className="chat-wechat-header__presence-text">{onlineUsersWithCurrent.length} 台设备在线</div>
  366. </div>
  367. </header>
  368. <section className="chat-wechat-messages" aria-label="聊天消息列表" ref={messageListRef}>
  369. {messages.length === 0 ? <div className="chat-wechat-empty">聊天室已经准备好,现在发第一条消息吧。</div> : null}
  370. {messages.map((message) => {
  371. const self = message.user === userName;
  372. const imageAttachment = message.file?.kind === "image" ? message.file : null;
  373. return (
  374. <article key={message.id} className={`chat-wechat-message${self ? " chat-wechat-message--self" : ""}`}>
  375. {!self ? <div className="chat-wechat-avatar">{getAvatarSeed(message.user)}</div> : null}
  376. <div
  377. className={`chat-wechat-message__body${
  378. imageAttachment && !message.content ? " chat-wechat-message__body--image" : ""
  379. }`}
  380. >
  381. {!self ? <div className="chat-wechat-message__name">{message.user}</div> : null}
  382. <div
  383. className={`chat-wechat-bubble${imageAttachment ? " chat-wechat-bubble--image" : ""}${
  384. !message.content && message.file ? " chat-wechat-bubble--attachment-only" : ""
  385. }`}
  386. >
  387. {message.content ? <div className="chat-wechat-bubble__text">{message.content}</div> : null}
  388. {message.file ? (
  389. <div
  390. className={`chat-wechat-file${imageAttachment ? " chat-wechat-file--image" : ""}${
  391. !imageAttachment ? " chat-wechat-file--download" : ""
  392. }`}
  393. >
  394. {imageAttachment?.url ? (
  395. <button
  396. type="button"
  397. className="chat-wechat-file__image-button"
  398. onClick={() => setPreviewImage(imageAttachment)}
  399. >
  400. <img src={imageAttachment.url} alt={message.file.name} className="chat-wechat-file__image" />
  401. </button>
  402. ) : null}
  403. {imageAttachment ? (
  404. <button
  405. type="button"
  406. className="chat-wechat-file__link chat-wechat-file__link--image"
  407. onClick={() => setPreviewImage(imageAttachment)}
  408. >
  409. <strong>{message.file.name}</strong>
  410. </button>
  411. ) : message.file.url ? (
  412. <button type="button" className="chat-wechat-file__download" onClick={() => handleFileOpen(message)}>
  413. <span className="chat-wechat-file__icon" aria-hidden="true">
  414. </span>
  415. <span className="chat-wechat-file__info">
  416. <strong>{message.file.name}</strong>
  417. <span>
  418. {downloadedFiles[`${message.id}:${message.file.storageKey || message.file.name}`]
  419. ? "点击打开"
  420. : "点击下载"}
  421. </span>
  422. </span>
  423. </button>
  424. ) : (
  425. <strong>{message.file.name}</strong>
  426. )}
  427. <div className="chat-wechat-file__meta">{message.file.meta}</div>
  428. </div>
  429. ) : null}
  430. </div>
  431. <div className="chat-wechat-message__time">{message.time}</div>
  432. </div>
  433. {self ? <div className="chat-wechat-avatar chat-wechat-avatar--self">我</div> : null}
  434. </article>
  435. );
  436. })}
  437. </section>
  438. <footer className="chat-wechat-composer">
  439. <input
  440. ref={fileInputRef}
  441. type="file"
  442. hidden
  443. multiple
  444. accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
  445. onChange={handleFileChange}
  446. />
  447. <div className="chat-wechat-composer__tools">
  448. <div className="chat-wechat-tools-left">
  449. <button
  450. className={`chat-wechat-tool${emojiOpen ? " is-active" : ""}`}
  451. type="button"
  452. aria-label="表情"
  453. onClick={() => setEmojiOpen((current) => !current)}
  454. >
  455. 😊
  456. </button>
  457. <button className="chat-wechat-tool" type="button" aria-label="选择文件" onClick={openFilePicker}>
  458. <svg className="chat-wechat-tool__icon" viewBox="0 0 24 24" aria-hidden="true">
  459. <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  460. </svg>
  461. </button>
  462. <button
  463. className="chat-wechat-tool chat-wechat-tool--danger"
  464. type="button"
  465. aria-label="清理聊天记录"
  466. title="清理聊天记录"
  467. onClick={() => void handleClearHistory()}
  468. >
  469. </button>
  470. </div>
  471. {error ? <div className="chat-wechat-error">{error}</div> : null}
  472. </div>
  473. {emojiOpen ? (
  474. <div className="chat-wechat-emoji-panel" aria-label="表情面板">
  475. {emojiList.map((emoji) => (
  476. <button key={emoji} type="button" onClick={() => appendEmoji(emoji)}>
  477. {emoji}
  478. </button>
  479. ))}
  480. </div>
  481. ) : null}
  482. {attachments.length > 0 ? (
  483. <div className="chat-wechat-attachments">
  484. {attachments.map((att, index) => (
  485. <div key={index} className="chat-wechat-attachment">
  486. {att.kind === "image" && att.previewUrl ? (
  487. <img src={att.previewUrl} alt={att.name} className="chat-wechat-attachment__image" />
  488. ) : (
  489. <span className="chat-wechat-attachment__icon">文</span>
  490. )}
  491. <div className="chat-wechat-attachment__meta">
  492. <strong>{att.name}</strong>
  493. <span>{att.meta}</span>
  494. </div>
  495. <button type="button" onClick={() => removeAttachment(index)} aria-label="移除附件">
  496. ×
  497. </button>
  498. </div>
  499. ))}
  500. {attachments.length < MAX_ATTACHMENTS ? (
  501. <button type="button" className="chat-wechat-attachment-add" onClick={openFilePicker} aria-label="继续添加">
  502. +
  503. </button>
  504. ) : null}
  505. </div>
  506. ) : null}
  507. <div className="chat-wechat-composer__row">
  508. <textarea
  509. className="chat-wechat-input"
  510. placeholder="输入消息,或直接粘贴图片 / 文件"
  511. value={draft}
  512. rows={1}
  513. onChange={(event) => setDraft(event.target.value)}
  514. onPaste={(event) => void handlePaste(event)}
  515. onKeyDown={(event) => {
  516. if (event.key === "Enter" && !event.shiftKey) {
  517. event.preventDefault();
  518. void handleSend();
  519. }
  520. }}
  521. />
  522. <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
  523. {sending ? "发送中" : attachments.length > 0 ? `发送(${attachments.length})` : "发送"}
  524. </button>
  525. </div>
  526. </footer>
  527. </section>
  528. </section>
  529. {previewImage?.url ? (
  530. <div className="chat-wechat-image-viewer" onClick={() => setPreviewImage(null)}>
  531. <img
  532. src={previewImage.url}
  533. alt={previewImage.name}
  534. className="chat-wechat-image-viewer__image"
  535. onClick={(event) => event.stopPropagation()}
  536. />
  537. </div>
  538. ) : null}
  539. </main>
  540. );
  541. }