|
|
@@ -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>
|