page.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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="chat-wechat-shell">
  334. <aside className="chat-wechat-sidebar" aria-label="在线用户">
  335. <div className="chat-wechat-sidebar__title">局域网在线设备</div>
  336. <div className="chat-wechat-sidebar__meta">{onlineUsersWithCurrent.length} 台设备在线</div>
  337. <div className="chat-wechat-member-list">
  338. {onlineUsersWithCurrent.map((name) => (
  339. <div key={name} className={`chat-wechat-member${name === userName ? " chat-wechat-member--self" : ""}`}>
  340. <div className="chat-wechat-member__avatar">{getAvatarSeed(name)}</div>
  341. <div className="chat-wechat-member__body">
  342. <strong>{name}</strong>
  343. <span>{name === userName ? "当前设备" : "局域网已连接"}</span>
  344. </div>
  345. </div>
  346. ))}
  347. </div>
  348. </aside>
  349. <section className="chat-wechat-main">
  350. <header className="chat-wechat-header">
  351. <div className="chat-wechat-header__row">
  352. <div className="chat-wechat-header__main">
  353. <div className="chat-wechat-header__title">局域网公共聊天室</div>
  354. <div className="chat-wechat-header__meta">文字、图片、文件都可以直接在这里流转</div>
  355. </div>
  356. <div className="chat-wechat-status" aria-label="在线状态">
  357. <span className="chat-wechat-status__text">{onlineUsersWithCurrent.length} 台设备在线</span>
  358. <span className="chat-wechat-status__icon" aria-hidden="true">
  359. LAN
  360. </span>
  361. </div>
  362. </div>
  363. <div className="chat-wechat-header__presence">
  364. <div className="chat-wechat-header__avatars" aria-hidden="true">
  365. {onlineUsersWithCurrent.slice(0, 4).map((name) => (
  366. <span key={name} className="chat-wechat-header__avatar">
  367. {getAvatarSeed(name)}
  368. </span>
  369. ))}
  370. </div>
  371. <div className="chat-wechat-header__presence-text">{onlineUsersWithCurrent.length} 台设备在线</div>
  372. </div>
  373. </header>
  374. <section className="chat-wechat-messages" aria-label="聊天消息列表" ref={messageListRef}>
  375. {messages.length === 0 ? <div className="chat-wechat-empty">聊天室已经准备好,现在发第一条消息吧。</div> : null}
  376. {messages.map((message) => {
  377. const self = message.user === userName;
  378. const imageAttachment = message.file?.kind === "image" ? message.file : null;
  379. return (
  380. <article key={message.id} className={`chat-wechat-message${self ? " chat-wechat-message--self" : ""}`}>
  381. {!self ? <div className="chat-wechat-avatar">{getAvatarSeed(message.user)}</div> : null}
  382. <div
  383. className={`chat-wechat-message__body${
  384. imageAttachment && !message.content ? " chat-wechat-message__body--image" : ""
  385. }`}
  386. >
  387. {!self ? <div className="chat-wechat-message__name">{message.user}</div> : null}
  388. <div
  389. className={`chat-wechat-bubble${imageAttachment ? " chat-wechat-bubble--image" : ""}${
  390. !message.content && message.file ? " chat-wechat-bubble--attachment-only" : ""
  391. }`}
  392. >
  393. {message.content ? <div className="chat-wechat-bubble__text">{message.content}</div> : null}
  394. {message.file ? (
  395. <div
  396. className={`chat-wechat-file${imageAttachment ? " chat-wechat-file--image" : ""}${
  397. !imageAttachment ? " chat-wechat-file--download" : ""
  398. }`}
  399. >
  400. {imageAttachment?.url ? (
  401. <button
  402. type="button"
  403. className="chat-wechat-file__image-button"
  404. onClick={() => setPreviewImage(imageAttachment)}
  405. >
  406. <img src={imageAttachment.url} alt={message.file.name} className="chat-wechat-file__image" />
  407. </button>
  408. ) : null}
  409. {imageAttachment ? (
  410. <button
  411. type="button"
  412. className="chat-wechat-file__link chat-wechat-file__link--image"
  413. onClick={() => setPreviewImage(imageAttachment)}
  414. >
  415. <strong>{message.file.name}</strong>
  416. </button>
  417. ) : message.file.url ? (
  418. <button type="button" className="chat-wechat-file__download" onClick={() => handleFileOpen(message)}>
  419. <span className="chat-wechat-file__icon" aria-hidden="true">
  420. </span>
  421. <span className="chat-wechat-file__info">
  422. <strong>{message.file.name}</strong>
  423. <span>
  424. {downloadedFiles[`${message.id}:${message.file.storageKey || message.file.name}`]
  425. ? "点击打开"
  426. : "点击下载"}
  427. </span>
  428. </span>
  429. </button>
  430. ) : (
  431. <strong>{message.file.name}</strong>
  432. )}
  433. <div className="chat-wechat-file__meta">{message.file.meta}</div>
  434. </div>
  435. ) : null}
  436. </div>
  437. <div className="chat-wechat-message__time">{message.time}</div>
  438. </div>
  439. {self ? <div className="chat-wechat-avatar chat-wechat-avatar--self">我</div> : null}
  440. </article>
  441. );
  442. })}
  443. </section>
  444. <footer className="chat-wechat-composer">
  445. <input
  446. ref={fileInputRef}
  447. type="file"
  448. hidden
  449. accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
  450. onChange={handleFileChange}
  451. />
  452. <div className="chat-wechat-composer__tools">
  453. <div className="chat-wechat-tools-left">
  454. <button
  455. className={`chat-wechat-tool${emojiOpen ? " is-active" : ""}`}
  456. type="button"
  457. aria-label="表情"
  458. onClick={() => setEmojiOpen((current) => !current)}
  459. >
  460. 😊
  461. </button>
  462. <button className="chat-wechat-tool" type="button" aria-label="选择文件" onClick={openFilePicker}>
  463. <svg className="chat-wechat-tool__icon" viewBox="0 0 24 24" aria-hidden="true">
  464. <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  465. </svg>
  466. </button>
  467. <button
  468. className="chat-wechat-tool chat-wechat-tool--danger"
  469. type="button"
  470. aria-label="清理聊天记录"
  471. title="清理聊天记录"
  472. onClick={() => void handleClearHistory()}
  473. >
  474. </button>
  475. </div>
  476. {error ? <div className="chat-wechat-error">{error}</div> : null}
  477. </div>
  478. {emojiOpen ? (
  479. <div className="chat-wechat-emoji-panel" aria-label="表情面板">
  480. {emojiList.map((emoji) => (
  481. <button key={emoji} type="button" onClick={() => appendEmoji(emoji)}>
  482. {emoji}
  483. </button>
  484. ))}
  485. </div>
  486. ) : null}
  487. {attachment ? (
  488. <div className="chat-wechat-attachment">
  489. {attachment.kind === "image" && attachment.previewUrl ? (
  490. <img src={attachment.previewUrl} alt={attachment.name} className="chat-wechat-attachment__image" />
  491. ) : null}
  492. <div className="chat-wechat-attachment__meta">
  493. <strong>{attachment.name}</strong>
  494. <span>{attachment.meta}</span>
  495. </div>
  496. <button type="button" onClick={clearPendingAttachment} aria-label="移除附件">
  497. ×
  498. </button>
  499. </div>
  500. ) : null}
  501. <div className="chat-wechat-composer__row">
  502. <textarea
  503. className="chat-wechat-input"
  504. placeholder="输入消息,或直接粘贴图片 / 文件"
  505. value={draft}
  506. rows={1}
  507. onChange={(event) => setDraft(event.target.value)}
  508. onPaste={(event) => void handlePaste(event)}
  509. onKeyDown={(event) => {
  510. if (event.key === "Enter" && !event.shiftKey) {
  511. event.preventDefault();
  512. void handleSend();
  513. }
  514. }}
  515. />
  516. <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
  517. {sending ? "发送中" : "发送"}
  518. </button>
  519. </div>
  520. </footer>
  521. </section>
  522. </section>
  523. {previewImage?.url ? (
  524. <div className="chat-wechat-image-viewer" onClick={() => setPreviewImage(null)}>
  525. <img
  526. src={previewImage.url}
  527. alt={previewImage.name}
  528. className="chat-wechat-image-viewer__image"
  529. onClick={(event) => event.stopPropagation()}
  530. />
  531. </div>
  532. ) : null}
  533. </main>
  534. );
  535. }