page.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  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 [attachment, setAttachment] = useState<PendingAttachment | null>(null);
  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. if (attachment?.previewUrl) {
  105. URL.revokeObjectURL(attachment.previewUrl);
  106. }
  107. };
  108. }, [attachment]);
  109. useEffect(() => {
  110. if (!userName || userName === "局域网设备") {
  111. return;
  112. }
  113. let cancelled = false;
  114. const loadChat = async () => {
  115. try {
  116. const response = await fetch(`/api/chat?user=${encodeURIComponent(userName)}`, { cache: "no-store" });
  117. if (!response.ok) {
  118. throw new Error("加载聊天记录失败");
  119. }
  120. const data = (await response.json()) as ChatResponse;
  121. if (cancelled) {
  122. return;
  123. }
  124. setOnlineUsers(data.onlineUsers);
  125. setMessages((current) => {
  126. const currentLastId = current.at(-1)?.id;
  127. const nextLastId = data.messages.at(-1)?.id;
  128. if (currentLastId !== nextLastId || current.length !== data.messages.length) {
  129. queueMicrotask(() => {
  130. messageListRef.current?.scrollTo({
  131. top: messageListRef.current.scrollHeight,
  132. behavior: "auto"
  133. });
  134. });
  135. }
  136. return data.messages;
  137. });
  138. setError("");
  139. } catch (loadError) {
  140. if (!cancelled) {
  141. setError(loadError instanceof Error ? loadError.message : "加载聊天记录失败");
  142. }
  143. }
  144. };
  145. void loadChat();
  146. const timer = window.setInterval(() => void loadChat(), 2500);
  147. return () => {
  148. cancelled = true;
  149. window.clearInterval(timer);
  150. };
  151. }, [userName]);
  152. useEffect(() => {
  153. if (!messages.length) {
  154. return;
  155. }
  156. messageListRef.current?.scrollTo({
  157. top: messageListRef.current.scrollHeight,
  158. behavior: "auto"
  159. });
  160. }, [messages.length]);
  161. const appendEmoji = (emoji: string) => {
  162. setDraft((current) => `${current}${emoji}`);
  163. setEmojiOpen(false);
  164. };
  165. const openFilePicker = () => {
  166. fileInputRef.current?.click();
  167. };
  168. const clearPendingAttachment = () => {
  169. if (attachment?.previewUrl) {
  170. URL.revokeObjectURL(attachment.previewUrl);
  171. }
  172. setAttachment(null);
  173. };
  174. const handleClearHistory = async () => {
  175. if (sending) {
  176. return;
  177. }
  178. const confirmed = window.confirm("确认清理本地和服务器的历史聊天记录吗?");
  179. if (!confirmed) {
  180. return;
  181. }
  182. setSending(true);
  183. try {
  184. const response = await fetch("/api/chat", {
  185. method: "DELETE",
  186. headers: {
  187. "Content-Type": "application/json"
  188. },
  189. body: JSON.stringify({
  190. user: userName
  191. })
  192. });
  193. if (!response.ok) {
  194. throw new Error("清理聊天记录失败");
  195. }
  196. const data = (await response.json()) as ChatResponse;
  197. setMessages(data.messages);
  198. setOnlineUsers(data.onlineUsers);
  199. setDraft("");
  200. clearPendingAttachment();
  201. setEmojiOpen(false);
  202. setPreviewImage(null);
  203. setError("");
  204. setDownloadedFiles({});
  205. window.localStorage.removeItem("lan-chat-downloaded-files");
  206. queueMicrotask(() => {
  207. messageListRef.current?.scrollTo({
  208. top: messageListRef.current.scrollHeight,
  209. behavior: "auto"
  210. });
  211. });
  212. } catch (clearError) {
  213. setError(clearError instanceof Error ? clearError.message : "清理聊天记录失败");
  214. } finally {
  215. setSending(false);
  216. }
  217. };
  218. const applyAttachment = async (file: File) => {
  219. clearPendingAttachment();
  220. setAttachment({
  221. file,
  222. name: file.name,
  223. meta: formatFileMeta(file),
  224. kind: file.type.startsWith("image/") ? "image" : "file",
  225. previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined
  226. });
  227. setError("");
  228. };
  229. const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
  230. const file = event.target.files?.[0];
  231. if (!file) {
  232. return;
  233. }
  234. try {
  235. await applyAttachment(file);
  236. } catch (fileError) {
  237. setError(fileError instanceof Error ? fileError.message : "文件读取失败");
  238. } finally {
  239. event.target.value = "";
  240. }
  241. };
  242. const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
  243. const fileItem = Array.from(event.clipboardData.items).find((item) => item.kind === "file");
  244. if (!fileItem) {
  245. return;
  246. }
  247. const file = fileItem.getAsFile();
  248. if (!file) {
  249. return;
  250. }
  251. event.preventDefault();
  252. try {
  253. await applyAttachment(file);
  254. } catch (fileError) {
  255. setError(fileError instanceof Error ? fileError.message : "文件读取失败");
  256. }
  257. };
  258. const handleSend = async () => {
  259. const content = draft.trim();
  260. if ((!content && !attachment) || sending) {
  261. return;
  262. }
  263. setSending(true);
  264. try {
  265. let response: Response;
  266. if (attachment) {
  267. const formData = new FormData();
  268. formData.append("user", userName);
  269. formData.append("content", content);
  270. formData.append("file", attachment.file);
  271. response = await fetch("/api/chat", {
  272. method: "POST",
  273. body: formData
  274. });
  275. } else {
  276. response = await fetch("/api/chat", {
  277. method: "POST",
  278. headers: {
  279. "Content-Type": "application/json"
  280. },
  281. body: JSON.stringify({
  282. user: userName,
  283. content
  284. })
  285. });
  286. }
  287. if (!response.ok) {
  288. throw new Error("发送失败");
  289. }
  290. const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] };
  291. setMessages((current) => [...current, data.message]);
  292. setOnlineUsers(data.onlineUsers);
  293. setDraft("");
  294. clearPendingAttachment();
  295. setEmojiOpen(false);
  296. setError("");
  297. queueMicrotask(() => {
  298. messageListRef.current?.scrollTo({
  299. top: messageListRef.current.scrollHeight,
  300. behavior: "smooth"
  301. });
  302. });
  303. } catch (sendError) {
  304. setError(sendError instanceof Error ? sendError.message : "发送失败");
  305. } finally {
  306. setSending(false);
  307. }
  308. };
  309. const markFileDownloaded = (key: string) => {
  310. setDownloadedFiles((current) => {
  311. const next = { ...current, [key]: true };
  312. window.localStorage.setItem("lan-chat-downloaded-files", JSON.stringify(next));
  313. return next;
  314. });
  315. };
  316. const handleFileOpen = (message: ChatMessage) => {
  317. if (!message.file?.url) {
  318. return;
  319. }
  320. const key = `${message.id}:${message.file.storageKey || message.file.name}`;
  321. if (downloadedFiles[key]) {
  322. window.open(message.file.url, "_blank", "noopener,noreferrer");
  323. return;
  324. }
  325. const link = document.createElement("a");
  326. link.href = `${message.file.url}?download=1`;
  327. link.download = message.file.name;
  328. link.click();
  329. markFileDownloaded(key);
  330. };
  331. return (
  332. <main className="page-shell chat-wechat-page">
  333. <section className="page-title chat-wechat-page-title">
  334. <h1>局域网聊天室</h1>
  335. <p>像群聊一样在同一个界面里收发文字、图片和文件,PC 端同时显示在线成员。</p>
  336. </section>
  337. <section className="chat-wechat-shell">
  338. <aside className="chat-wechat-sidebar" aria-label="在线用户">
  339. <div className="chat-wechat-sidebar__title">局域网在线设备</div>
  340. <div className="chat-wechat-sidebar__meta">{onlineUsersWithCurrent.length} 台设备在线</div>
  341. <div className="chat-wechat-member-list">
  342. {onlineUsersWithCurrent.map((name) => (
  343. <div key={name} className={`chat-wechat-member${name === userName ? " chat-wechat-member--self" : ""}`}>
  344. <div className="chat-wechat-member__avatar">{getAvatarSeed(name)}</div>
  345. <div className="chat-wechat-member__body">
  346. <strong>{name}</strong>
  347. <span>{name === userName ? "当前设备" : "局域网已连接"}</span>
  348. </div>
  349. </div>
  350. ))}
  351. </div>
  352. </aside>
  353. <section className="chat-wechat-main">
  354. <header className="chat-wechat-header">
  355. <div className="chat-wechat-header__row">
  356. <div className="chat-wechat-header__main">
  357. <div className="chat-wechat-header__title">局域网公共聊天室</div>
  358. <div className="chat-wechat-header__meta">文字、图片、文件都可以直接在这里流转</div>
  359. </div>
  360. <div className="chat-wechat-status" aria-label="在线状态">
  361. <span className="chat-wechat-status__text">{onlineUsersWithCurrent.length} 台设备在线</span>
  362. <span className="chat-wechat-status__icon" aria-hidden="true">
  363. LAN
  364. </span>
  365. </div>
  366. </div>
  367. <div className="chat-wechat-header__presence">
  368. <div className="chat-wechat-header__avatars" aria-hidden="true">
  369. {onlineUsersWithCurrent.slice(0, 4).map((name) => (
  370. <span key={name} className="chat-wechat-header__avatar">
  371. {getAvatarSeed(name)}
  372. </span>
  373. ))}
  374. </div>
  375. <div className="chat-wechat-header__presence-text">{onlineUsersWithCurrent.length} 台设备在线</div>
  376. </div>
  377. </header>
  378. <section className="chat-wechat-messages" aria-label="聊天消息列表" ref={messageListRef}>
  379. {messages.length === 0 ? <div className="chat-wechat-empty">聊天室已经准备好,现在发第一条消息吧。</div> : null}
  380. {messages.map((message) => {
  381. const self = message.user === userName;
  382. const imageAttachment = message.file?.kind === "image" ? message.file : null;
  383. return (
  384. <article key={message.id} className={`chat-wechat-message${self ? " chat-wechat-message--self" : ""}`}>
  385. {!self ? <div className="chat-wechat-avatar">{getAvatarSeed(message.user)}</div> : null}
  386. <div
  387. className={`chat-wechat-message__body${
  388. imageAttachment && !message.content ? " chat-wechat-message__body--image" : ""
  389. }`}
  390. >
  391. {!self ? <div className="chat-wechat-message__name">{message.user}</div> : null}
  392. <div
  393. className={`chat-wechat-bubble${imageAttachment ? " chat-wechat-bubble--image" : ""}${
  394. !message.content && message.file ? " chat-wechat-bubble--attachment-only" : ""
  395. }`}
  396. >
  397. {message.content ? <div className="chat-wechat-bubble__text">{message.content}</div> : null}
  398. {message.file ? (
  399. <div
  400. className={`chat-wechat-file${imageAttachment ? " chat-wechat-file--image" : ""}${
  401. !imageAttachment ? " chat-wechat-file--download" : ""
  402. }`}
  403. >
  404. {imageAttachment?.url ? (
  405. <button
  406. type="button"
  407. className="chat-wechat-file__image-button"
  408. onClick={() => setPreviewImage(imageAttachment)}
  409. >
  410. <img src={imageAttachment.url} alt={message.file.name} className="chat-wechat-file__image" />
  411. </button>
  412. ) : null}
  413. {imageAttachment ? (
  414. <button
  415. type="button"
  416. className="chat-wechat-file__link chat-wechat-file__link--image"
  417. onClick={() => setPreviewImage(imageAttachment)}
  418. >
  419. <strong>{message.file.name}</strong>
  420. </button>
  421. ) : message.file.url ? (
  422. <button type="button" className="chat-wechat-file__download" onClick={() => handleFileOpen(message)}>
  423. <span className="chat-wechat-file__icon" aria-hidden="true">
  424. </span>
  425. <span className="chat-wechat-file__info">
  426. <strong>{message.file.name}</strong>
  427. <span>
  428. {downloadedFiles[`${message.id}:${message.file.storageKey || message.file.name}`]
  429. ? "点击打开"
  430. : "点击下载"}
  431. </span>
  432. </span>
  433. </button>
  434. ) : (
  435. <strong>{message.file.name}</strong>
  436. )}
  437. <div className="chat-wechat-file__meta">{message.file.meta}</div>
  438. </div>
  439. ) : null}
  440. </div>
  441. <div className="chat-wechat-message__time">{message.time}</div>
  442. </div>
  443. {self ? <div className="chat-wechat-avatar chat-wechat-avatar--self">我</div> : null}
  444. </article>
  445. );
  446. })}
  447. </section>
  448. <footer className="chat-wechat-composer">
  449. <input
  450. ref={fileInputRef}
  451. type="file"
  452. hidden
  453. accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
  454. onChange={handleFileChange}
  455. />
  456. <div className="chat-wechat-composer__tools">
  457. <div className="chat-wechat-tools-left">
  458. <button
  459. className={`chat-wechat-tool${emojiOpen ? " is-active" : ""}`}
  460. type="button"
  461. aria-label="表情"
  462. onClick={() => setEmojiOpen((current) => !current)}
  463. >
  464. 😊
  465. </button>
  466. <button className="chat-wechat-tool" type="button" aria-label="选择文件" onClick={openFilePicker}>
  467. <svg className="chat-wechat-tool__icon" viewBox="0 0 24 24" aria-hidden="true">
  468. <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  469. </svg>
  470. </button>
  471. <button
  472. className="chat-wechat-tool chat-wechat-tool--danger"
  473. type="button"
  474. aria-label="清理聊天记录"
  475. title="清理聊天记录"
  476. onClick={() => void handleClearHistory()}
  477. >
  478. </button>
  479. </div>
  480. {error ? <div className="chat-wechat-error">{error}</div> : null}
  481. </div>
  482. {emojiOpen ? (
  483. <div className="chat-wechat-emoji-panel" aria-label="表情面板">
  484. {emojiList.map((emoji) => (
  485. <button key={emoji} type="button" onClick={() => appendEmoji(emoji)}>
  486. {emoji}
  487. </button>
  488. ))}
  489. </div>
  490. ) : null}
  491. {attachment ? (
  492. <div className="chat-wechat-attachment">
  493. {attachment.kind === "image" && attachment.previewUrl ? (
  494. <img src={attachment.previewUrl} alt={attachment.name} className="chat-wechat-attachment__image" />
  495. ) : null}
  496. <div className="chat-wechat-attachment__meta">
  497. <strong>{attachment.name}</strong>
  498. <span>{attachment.meta}</span>
  499. </div>
  500. <button type="button" onClick={clearPendingAttachment} aria-label="移除附件">
  501. ×
  502. </button>
  503. </div>
  504. ) : null}
  505. <div className="chat-wechat-composer__row">
  506. <textarea
  507. className="chat-wechat-input"
  508. placeholder="输入消息,或直接粘贴图片 / 文件"
  509. value={draft}
  510. rows={1}
  511. onChange={(event) => setDraft(event.target.value)}
  512. onPaste={(event) => void handlePaste(event)}
  513. onKeyDown={(event) => {
  514. if (event.key === "Enter" && !event.shiftKey) {
  515. event.preventDefault();
  516. void handleSend();
  517. }
  518. }}
  519. />
  520. <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
  521. {sending ? "发送中" : "发送"}
  522. </button>
  523. </div>
  524. </footer>
  525. </section>
  526. </section>
  527. {previewImage?.url ? (
  528. <div className="chat-wechat-image-viewer" onClick={() => setPreviewImage(null)}>
  529. <img
  530. src={previewImage.url}
  531. alt={previewImage.name}
  532. className="chat-wechat-image-viewer__image"
  533. onClick={(event) => event.stopPropagation()}
  534. />
  535. </div>
  536. ) : null}
  537. </main>
  538. );
  539. }