diff --git a/.github/release-notes/v1.2.36.md b/.github/release-notes/v1.2.36.md new file mode 100644 index 0000000..71a6d29 --- /dev/null +++ b/.github/release-notes/v1.2.36.md @@ -0,0 +1,11 @@ +- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。 +- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。 +- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。 +- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。 +- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。 +- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。 +- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。 +- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。 +- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。 + +**完整更新日志**:[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36) diff --git a/VERSION b/VERSION index 0ca6e94..780ac74 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.35 +1.2.36 diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3adcaa8..e68bd31 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -15,7 +15,7 @@ --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 12%; --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 42%; + --muted-foreground: 0 0% 36%; --accent: 0 0% 95%; --accent-foreground: 0 0% 12%; --destructive: 358 88% 61%; @@ -64,7 +64,7 @@ --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; + --muted-foreground: 240 5% 72%; --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 98f2c74..0ffe3cc 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -566,6 +566,7 @@ function BackupsSection() { Google 云端硬盘
+
连接前必须启用 Google Drive API

请在 OAuth 客户端所属的同一个 Google Cloud 项目中启用 API。启用后若仍提示无权限,请先断开连接,再重新授权。

setGoogleClientId(e.target.value)} />
setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} />
setGoogleFolderName(e.target.value)} />
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index a02594d..6f47bb2 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -3916,6 +3916,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "") const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle") const [lastSavedAt, setLastSavedAt] = React.useState(null) + const [closing, setClosing] = React.useState(false) const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false) const [sendIntent, setSendIntent] = React.useState(null) const sendStartedRef = React.useRef(false) @@ -4031,7 +4032,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa }, [files]) React.useEffect(() => { - if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return + if (!open || closing || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return const payloadKey = JSON.stringify({ ...composePayload, draftId }) if (payloadKey === lastSavedPayloadRef.current) return const timer = window.setTimeout(async () => { @@ -4052,7 +4053,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa } }, 5000) return () => window.clearTimeout(timer) - }, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts]) + }, [open, closing, hasDraftContent, composePayload, draftId, qc, canManageDrafts]) function buildSendWarnings(attachmentsCount: number) { const warnings: string[] = [] @@ -4104,6 +4105,10 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa const to = splitEmails(toValue) const cc = showCc ? splitEmails(ccValue) : [] const bcc = showBcc ? splitEmails(bccValue) : [] + if (to.length === 0) { + toast({ title: "请填写收件人" }) + return + } const text = body.text const html = body.html || plainTextToHtml(text) const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments } @@ -4139,11 +4144,18 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa } if (!attachmentsWithinLimit()) return const attachments = await Promise.all(files.map(fileToAttachment)) + const to = splitEmails(toValue) + const cc = showCc ? splitEmails(ccValue) : [] + const bcc = showBcc ? splitEmails(bccValue) : [] + if (to.length === 0) { + toast({ title: "请填写收件人" }) + return + } const payload: SendPayload & { draftId?: string; sendAt: string } = { mailboxId: senderMailbox.id, - to: splitEmails(toValue), - cc: showCc ? splitEmails(ccValue) : [], - bcc: showBcc ? splitEmails(bccValue) : [], + to, + cc, + bcc, subject: subjectValue, text: body.text, html: body.html || plainTextToHtml(body.text), @@ -4163,15 +4175,45 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa }, }) } + + async function closeCompose() { + if (closing) return + if (!canManageDrafts || sendStartedRef.current || !hasDraftContent) { + onOpenChange(false) + return + } + setClosing(true) + setDraftStatus("saving") + try { + const attachments = await Promise.all(files.map(fileToAttachment)) + const payload: DraftPayload = { ...composePayload, attachments } + const saved = await api.saveDraft(payload, draftId || undefined) + setDraftId(saved.id) + lastSavedPayloadRef.current = JSON.stringify({ ...payload, draftId: saved.id }) + setLastSavedAt(new Date()) + setDraftStatus("saved") + await Promise.all([ + qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["folders"] }), + qc.invalidateQueries({ queryKey: ["mail-stats"] }), + ]) + onOpenChange(false) + } catch (error) { + setDraftStatus("error") + toast({ title: "草稿保存失败", description: error instanceof Error ? error.message : "请稍后重试" }) + } finally { + setClosing(false) + } + } return ( - + { if (nextOpen) onOpenChange(true); else void closeCompose() }}> event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} > -
- + + {draftId ? "编辑草稿" : "写信"} @@ -4219,7 +4261,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> )} - + setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> {defaultSignature.isError && ( @@ -4241,10 +4283,12 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} />
- - - {canSchedule && } - {canSend && } + +
+ {canSend && } + {canSchedule && } +
+
@@ -4264,8 +4308,8 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { return ( -
- +
+
{children} {action} @@ -4337,11 +4381,17 @@ type InsertDialogValue = { url: string; text: string; alt: string } const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"] const composerFontSizeOptions = [ ["2", "小号"], - ["3", "正文"], + ["3", "标准"], ["4", "中号"], ["5", "大号"], ] as const const composerFontSizeValueByKey: Record = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" } +const composerParagraphOptions: ReadonlyArray<{ key: string; label: string; level?: 1 | 2 | 3 }> = [ + { key: "paragraph", label: "正文" }, + { key: "heading-1", label: "标题 1", level: 1 as const }, + { key: "heading-2", label: "标题 2", level: 2 as const }, + { key: "heading-3", label: "标题 3", level: 3 as const }, +] as const const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"] @@ -4383,7 +4433,15 @@ function fontLabel(value: string) { function fontSizeLabel(value: string) { const normalized = normalizeFontSize(value) || "3" - return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文" + return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "标准" +} + +function paragraphStyleLabel(editor?: Editor | null) { + if (!editor) return "正文" + for (const option of composerParagraphOptions) { + if (option.level && editor.isActive("heading", { level: option.level })) return option.label + } + return "正文" } function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) { @@ -4509,8 +4567,10 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max content: composerInitialHtml(defaultValue, defaultHtml), editorProps: { attributes: { - class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5", + class: "mail-html min-h-[220px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[260px] sm:px-5", "aria-label": "正文", + spellcheck: "true", + autocorrect: "on", }, handlePaste(view, event) { const clipboard = event.clipboardData @@ -4564,6 +4624,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3" const activeColor = textStyleAttributes?.color || "" const activeHighlight = textStyleAttributes?.backgroundColor || "" + const characterCount = editor?.getText().replace(/\s/g, "").length || 0 void selectionVersion function applyFont(font: string) { @@ -4631,9 +4692,9 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max } return ( -
+
-
+
editor?.chain().focus().undo().run()}> editor?.chain().focus().redo().run()}> @@ -4650,7 +4711,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max editor?.chain().focus().setHorizontalRule().run()}>分隔线 - 附件 {maxAttachmentText} + 附件上限 {maxAttachmentText} } onClick={() => setScheduleOpen(true)} /> @@ -4675,9 +4736,24 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
{formatOpen && ( -
+
editor?.chain().focus().unsetAllMarks().clearNodes().run()}> - + + + + + + + {composerParagraphOptions.map((option) => ( + option.level ? editor?.chain().focus().toggleHeading({ level: option.level }).run() : editor?.chain().focus().setParagraph().run()}> + + {option.label} + + ))} + + + + + editor?.chain().focus().liftListItem("listItem").run()}>减少缩进 + editor?.chain().focus().sinkListItem("listItem").run()}>增加缩进 + editor?.chain().focus().setTextAlign("left").run()}>左对齐 + editor?.chain().focus().setTextAlign("center").run()}>居中 + editor?.chain().focus().setTextAlign("right").run()}>右对齐 + editor?.chain().focus().toggleBlockquote().run()}>引用 + editor?.chain().focus().toggleCodeBlock().run()}>代码块 + + + {characterCount} 字
)}
- +
{files.length > 0 && ( -
+
{files.map((file, index) => ( @@ -4788,7 +4873,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max 邮件预览 -

" }} /> +

" }} />