Forráskód Böngészése

Fix reader theme-color crash and add multi-file chat upload

- Fix return crash: use id-based meta tag instead of querySelectorAll to avoid touching React fiber nodes
- Chat: support up to 5 files/images per send (multiple select, multi-paste, individual remove)
- Chat: show file count on send button when attachments pending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zhaozhi 2 hete
szülő
commit
6e0e04495b
3 módosított fájl, 128 hozzáadás és 99 törlés
  1. 90 91
      app/chat/page.tsx
  2. 27 1
      app/globals.css
  3. 11 7
      components/reader/reader-view.tsx

+ 90 - 91
app/chat/page.tsx

@@ -89,7 +89,7 @@ export default function ChatPage() {
   const [sending, setSending] = useState(false);
   const [emojiOpen, setEmojiOpen] = useState(false);
   const [error, setError] = useState("");
-  const [attachment, setAttachment] = useState<PendingAttachment | null>(null);
+  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);
@@ -123,11 +123,10 @@ export default function ChatPage() {
 
   useEffect(() => {
     return () => {
-      if (attachment?.previewUrl) {
-        URL.revokeObjectURL(attachment.previewUrl);
-      }
+      attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); });
     };
-  }, [attachment]);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [attachments]);
 
   useEffect(() => {
     if (!userName || userName === "局域网设备") {
@@ -203,12 +202,19 @@ export default function ChatPage() {
     fileInputRef.current?.click();
   };
 
-  const clearPendingAttachment = () => {
-    if (attachment?.previewUrl) {
-      URL.revokeObjectURL(attachment.previewUrl);
-    }
+  const MAX_ATTACHMENTS = 5;
 
-    setAttachment(null);
+  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 () => {
@@ -243,7 +249,7 @@ export default function ChatPage() {
       setMessages(data.messages);
       setOnlineUsers(data.onlineUsers);
       setDraft("");
-      clearPendingAttachment();
+      clearPendingAttachments();
       setEmojiOpen(false);
       setPreviewImage(null);
       setError("");
@@ -263,101 +269,82 @@ export default function ChatPage() {
     }
   };
 
-  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
+  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 = async (event: ChangeEvent<HTMLInputElement>) => {
-    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 handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const files = Array.from(event.target.files ?? []);
+    if (files.length) addFiles(files);
+    event.target.value = "";
   };
 
-  const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
-    const fileItem = Array.from(event.clipboardData.items).find((item) => item.kind === "file");
-
-    if (!fileItem) {
-      return;
-    }
-
-    const file = fileItem.getAsFile();
-
-    if (!file) {
-      return;
-    }
+  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();
-
-    try {
-      await applyAttachment(file);
-    } catch (fileError) {
-      setError(fileError instanceof Error ? fileError.message : "文件读取失败");
-    }
+    addFiles(files);
   };
 
   const handleSend = async () => {
     const content = draft.trim();
 
-    if ((!content && !attachment) || sending) {
+    if ((!content && attachments.length === 0) || 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);
+      const newMessages: ChatMessage[] = [];
+      let lastOnlineUsers: string[] = onlineUsers;
 
-        response = await fetch("/api/chat", {
+      // Send text first if any
+      if (content) {
+        const 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
-          })
+          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;
       }
 
-      if (!response.ok) {
-        throw new Error("发送失败");
+      // 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;
       }
 
-      const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] };
-      setMessages((current) => [...current, data.message]);
-      setOnlineUsers(data.onlineUsers);
+      setMessages((current) => [...current, ...newMessages]);
+      setOnlineUsers(lastOnlineUsers);
       setDraft("");
-      clearPendingAttachment();
+      clearPendingAttachments();
       setEmojiOpen(false);
       setError("");
 
@@ -534,6 +521,7 @@ export default function ChatPage() {
               ref={fileInputRef}
               type="file"
               hidden
+              multiple
               accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
               onChange={handleFileChange}
             />
@@ -577,18 +565,29 @@ export default function ChatPage() {
               </div>
             ) : null}
 
-            {attachment ? (
-              <div className="chat-wechat-attachment">
-                {attachment.kind === "image" && attachment.previewUrl ? (
-                  <img src={attachment.previewUrl} alt={attachment.name} className="chat-wechat-attachment__image" />
+            {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 className="chat-wechat-attachment__meta">
-                  <strong>{attachment.name}</strong>
-                  <span>{attachment.meta}</span>
-                </div>
-                <button type="button" onClick={clearPendingAttachment} aria-label="移除附件">
-                  ×
-                </button>
               </div>
             ) : null}
 
@@ -609,7 +608,7 @@ export default function ChatPage() {
               />
 
               <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
-                {sending ? "发送中" : "发送"}
+                {sending ? "发送中" : attachments.length > 0 ? `发送(${attachments.length})` : "发送"}
               </button>
             </div>
           </footer>

+ 27 - 1
app/globals.css

@@ -2308,17 +2308,43 @@ textarea {
   font-size: 0.86rem;
 }
 
+.chat-wechat-attachments {
+  display: grid;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
 .chat-wechat-attachment {
   display: grid;
   grid-template-columns: auto minmax(0, 1fr) 36px;
   gap: 10px;
   align-items: center;
   padding: 10px 12px;
-  margin-bottom: 10px;
   border-radius: 16px;
   background: rgba(255, 255, 255, 0.78);
 }
 
+.chat-wechat-attachment__icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 10px;
+  background: rgba(184, 92, 56, 0.1);
+  color: #9a583a;
+  display: grid;
+  place-items: center;
+  font-weight: 700;
+}
+
+.chat-wechat-attachment-add {
+  height: 36px;
+  border: 1px dashed rgba(184, 92, 56, 0.35);
+  border-radius: 12px;
+  background: transparent;
+  color: #9a583a;
+  font-size: 1.2rem;
+  cursor: pointer;
+}
+
 .chat-wechat-attachment__image {
   width: 44px;
   height: 44px;

+ 11 - 7
components/reader/reader-view.tsx

@@ -63,18 +63,22 @@ export function ReaderView({
     document.body.style.background = pageColor;
     document.documentElement.style.background = pageColor;
 
-    // Force iOS Safari to pick up theme-color by removing old tags and inserting a fresh one
-    document.querySelectorAll('meta[name="theme-color"]').forEach((m) => m.remove());
-    const metaTheme = document.createElement("meta");
-    metaTheme.setAttribute("name", "theme-color");
-    metaTheme.setAttribute("content", pageColor);
-    document.head.appendChild(metaTheme);
+    // Use a dedicated reader-only meta tag (separate from React-managed one)
+    const READER_THEME_ID = "reader-theme-color";
+    let metaTheme = document.getElementById(READER_THEME_ID) as HTMLMetaElement | null;
+    if (!metaTheme) {
+      metaTheme = document.createElement("meta");
+      metaTheme.name = "theme-color";
+      metaTheme.id = READER_THEME_ID;
+      document.head.appendChild(metaTheme);
+    }
+    metaTheme.content = pageColor;
 
     return () => {
       document.body.classList.remove("reader-body");
       document.body.style.background = previousBodyBackground;
       document.documentElement.style.background = previousHtmlBackground;
-      metaTheme.setAttribute("content", "#f4efe7");
+      metaTheme?.remove();
     };
   }, [themeIndex]);