From d0086380468596057adb08250dc426e5fb398151 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Wed, 17 Jun 2026 09:44:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E5=8D=87=E7=BA=A7=E5=86=99?= =?UTF-8?q?=E4=BF=A1=E7=BC=96=E8=BE=91=E5=99=A8=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20Markdown=20=E4=BE=9D=E8=B5=96=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将正文编辑器改为富文本 `contentEditable`,支持字体、字号、颜色、高亮、对齐、列表、缩进等格式化操作。 - 新增抄送/密送显示切换与“分别发送”能力,优化收件人发送方式。 - 移除 `marked` 依赖,并同步精简 `turnstile-box` 的边框样式。 --- apps/web/package-lock.json | 13 - apps/web/package.json | 1 - apps/web/src/components/turnstile-box.tsx | 2 +- apps/web/src/pages/mail.tsx | 446 +++++++++++++++------- 4 files changed, 303 insertions(+), 159 deletions(-) diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 2d57dfb..e495f7b 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -25,7 +25,6 @@ "clsx": "2.1.1", "dompurify": "3.4.10", "lucide-react": "^0.468.0", - "marked": "18.0.5", "qrcode.react": "^4.2.0", "react": "18.3.1", "react-dom": "18.3.1", @@ -4056,18 +4055,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 5499d6f..913a285 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,6 @@ "clsx": "2.1.1", "dompurify": "3.4.10", "lucide-react": "^0.468.0", - "marked": "18.0.5", "qrcode.react": "^4.2.0", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/apps/web/src/components/turnstile-box.tsx b/apps/web/src/components/turnstile-box.tsx index 442fbf9..aa6b9af 100644 --- a/apps/web/src/components/turnstile-box.tsx +++ b/apps/web/src/components/turnstile-box.tsx @@ -46,5 +46,5 @@ export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: ( if (widgetId && window.turnstile) window.turnstile.remove(widgetId) } }, [siteKey, onToken]) - return
+ return
} diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index f38e428..cee09e9 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -1,11 +1,10 @@ import * as React from "react" import DOMPurify from "dompurify" -import { marked } from "marked" import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" -import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react" -import { api, Mailbox, MailFolder, MailLabel, MailMessage } from "@/lib/api" +import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Code2, Copy, Ellipsis, Eraser, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" +import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload } from "@/lib/api" import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -15,9 +14,7 @@ import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" @@ -1255,10 +1252,35 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const [files, setFiles] = React.useState([]) + const [showCc, setShowCc] = React.useState(Boolean(draft?.cc)) + const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc)) + const [sendSeparately, setSendSeparately] = React.useState(false) const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id }) const signatureText = defaultSignature.data?.signature?.content || "" const composerText = draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "" - const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) }) + const [body, setBody] = React.useState(() => plainTextComposerValue(composerText)) + const send = useMutation({ + mutationFn: async (payloads: SendPayload[]) => { + const sent: MailMessage[] = [] + for (const payload of payloads) sent.push(await api.send(payload)) + return sent + }, + onSuccess: (_, payloads) => { + toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" }) + setFiles([]) + onSent() + }, + onError: (e) => toast({ title: "发送失败", description: e.message }), + }) + + React.useEffect(() => { + if (!open) return + setShowCc(Boolean(draft?.cc)) + setShowBcc(Boolean(draft?.bcc)) + setSendSeparately(false) + setFiles([]) + }, [open, draft?.key, draft?.cc, draft?.bcc]) + async function submit(e: React.FormEvent) { e.preventDefault() if (!mailbox) { @@ -1267,27 +1289,69 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox } const form = new FormData(e.currentTarget) const attachments = await Promise.all(files.map(fileToAttachment)) - const text = String(form.get("text") || "") - send.mutate({ mailboxId: mailbox.id, to: splitEmails(String(form.get("to") || "")), cc: splitEmails(String(form.get("cc") || "")), bcc: splitEmails(String(form.get("bcc") || "")), subject: String(form.get("subject") || ""), text, html: markdownToHtml(text), attachments }) + const to = splitEmails(String(form.get("to") || "")) + const cc = showCc ? splitEmails(String(form.get("cc") || "")) : [] + const bcc = showBcc ? splitEmails(String(form.get("bcc") || "")) : [] + const text = body.text + const html = body.html || plainTextToHtml(text) + const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: String(form.get("subject") || ""), text, html, attachments } + const separateRecipients = Array.from(new Set([...to, ...cc, ...bcc])) + const payloads = sendSeparately && separateRecipients.length > 0 + ? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] })) + : [payload] + send.mutate(payloads) } return ( - +
- + 写信 -
-
-
-
-
- -
setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 &&
{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(",")}
}
+
+ + + + + + +
+ setSendSeparately(value === true)} /> + +
+
+ } + > + + + {showCc && ( + + + + )} + {showBcc && ( + + + + )} + + + + setFiles((current) => [...current, ...nextFiles])} + onRemoveFile={(index) => setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index))} + />
- +
@@ -1295,161 +1359,255 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox ) } -type MarkdownAction = "bold" | "italic" | "strike" | "ul" | "ol" | "quote" | "code" | "link" | "image" | "hr" -type MarkdownMode = "edit" | "split" | "preview" +function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+ +
+ {children} + {action} +
+
+ ) +} -function MarkdownComposer({ defaultValue }: { defaultValue: string }) { - const [value, setValue] = React.useState(defaultValue) - const [mode, setMode] = React.useState("edit") - const textareaRef = React.useRef(null) +type ComposerValue = { text: string; html: string } + +function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { + const editorRef = React.useRef(null) + const fileInputRef = React.useRef(null) + const initialHtmlRef = React.useRef(plainTextToHtml(defaultValue)) const dirtyRef = React.useRef(false) const lastDefaultRef = React.useRef(defaultValue) - const previewHtml = React.useMemo(() => markdownToHtml(value), [value]) + const [formatOpen, setFormatOpen] = React.useState(true) + const [empty, setEmpty] = React.useState(!defaultValue.trim()) + + React.useEffect(() => { + const next = plainTextComposerValue(defaultValue) + onChange(next) + setEmpty(!next.text.trim()) + }, []) React.useEffect(() => { if (defaultValue === lastDefaultRef.current) return lastDefaultRef.current = defaultValue - if (!dirtyRef.current) setValue(defaultValue) - }, [defaultValue]) + if (!dirtyRef.current) { + const next = plainTextComposerValue(defaultValue) + if (editorRef.current) editorRef.current.innerHTML = next.html + setEmpty(!next.text.trim()) + onChange(next) + } + }, [defaultValue, onChange]) function focusEditor() { - window.requestAnimationFrame(() => textareaRef.current?.focus()) + window.requestAnimationFrame(() => editorRef.current?.focus()) } - function updateSelection(next: string, start: number, end: number) { + + function syncEditor() { + const next = composerValueFromElement(editorRef.current) + setEmpty(!next.text.trim()) + onChange(next) + } + + function runCommand(command: string, value?: string) { dirtyRef.current = true - setValue(next) - window.requestAnimationFrame(() => { - const textarea = textareaRef.current - if (!textarea) return - textarea.focus() - textarea.setSelectionRange(start, end) - }) + editorRef.current?.focus() + document.execCommand(command, false, value) + syncEditor() } - function wrap(prefix: string, suffix = prefix, placeholder = "文本") { - const textarea = textareaRef.current - if (!textarea) return - const start = textarea.selectionStart - const end = textarea.selectionEnd - const selected = value.slice(start, end) || placeholder - const next = value.slice(0, start) + prefix + selected + suffix + value.slice(end) - updateSelection(next, start + prefix.length, start + prefix.length + selected.length) + + function insertLink() { + const url = window.prompt("链接地址") + if (!url) return + const selected = window.getSelection()?.toString() + if (selected) runCommand("createLink", url) + else runCommand("insertHTML", `${escapeHtml(url)}`) } - function prefixLines(prefix: string, ordered = false) { - const textarea = textareaRef.current - if (!textarea) return - const start = textarea.selectionStart - const end = textarea.selectionEnd - const lineStart = value.lastIndexOf("\n", start - 1) + 1 - const lineEndIndex = value.indexOf("\n", end) - const lineEnd = lineEndIndex === -1 ? value.length : lineEndIndex - const block = value.slice(lineStart, lineEnd) || "列表项" - const lines = block.split("\n") - const formatted = lines.map((line, index) => `${ordered ? `${index + 1}. ` : prefix}${line || "列表项"}`).join("\n") - updateSelection(value.slice(0, lineStart) + formatted + value.slice(lineEnd), lineStart, lineStart + formatted.length) + + function insertImage() { + const url = window.prompt("图片链接") + if (url) runCommand("insertImage", url) } - function insertMarkdown(action: MarkdownAction) { - if (mode === "preview") setMode("edit") - const textarea = textareaRef.current - if (!textarea) { - focusEditor() - return - } - switch (action) { - case "bold": wrap("**", "**", "加粗文本"); break - case "italic": wrap("_", "_", "斜体文本"); break - case "strike": wrap("~~", "~~", "删除线文本"); break - case "ul": prefixLines("- "); break - case "ol": prefixLines("", true); break - case "quote": prefixLines("> "); break - case "code": wrap("`", "`", "code"); break - case "link": wrap("[", "](https://example.com)", "链接文本"); break - case "image": wrap("![", "](https://example.com/image.png)", "图片描述"); break - case "hr": { - const start = textarea.selectionStart - const before = value.slice(0, start) - const after = value.slice(textarea.selectionEnd) - const prefix = before.endsWith("\n") || before === "" ? "" : "\n" - const suffix = after.startsWith("\n") || after === "" ? "" : "\n" - const insert = `${prefix}---${suffix}` - updateSelection(before + insert + after, before.length + insert.length, before.length + insert.length) - break - } - } + + function insertSignature() { + if (!signatureText.trim()) return + runCommand("insertHTML", `

--
${plainTextToHtmlFragment(signatureText)}`) } - function setBlock(type: string) { - if (type === "p") return focusEditor() - const mark = type === "h2" ? "## " : "### " - prefixLines(mark) + + function handlePickedFiles(event: React.ChangeEvent) { + const nextFiles = Array.from(event.currentTarget.files || []) + if (nextFiles.length > 0) onPickFiles(nextFiles) + event.currentTarget.value = "" } return ( -
- - -
-
- insertMarkdown("bold")}> - insertMarkdown("italic")}> - U - insertMarkdown("strike")}> - - - - insertMarkdown("ul")}> - insertMarkdown("ol")}> - insertMarkdown("quote")}> - insertMarkdown("code")}> - - insertMarkdown("link")}> - insertMarkdown("image")}> - insertMarkdown("hr")}> -
- - Markdown - -
- {(["edit", "split", "preview"] as MarkdownMode[]).map((item) => ( - - ))} -
-
-
-
- {mode !== "preview" && ( -