From 864a9fae3b9dc102b285d9058727828f3ebd41e8 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 8 Aug 2026 19:53:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E4=BA=91=E7=AB=AF=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=8C=B9=E9=85=8D=E4=B8=8E=E6=94=B6=E4=BB=B6?= =?UTF-8?q?=E4=BA=BA=E6=A0=87=E7=AD=BE=E8=BE=93=E5=85=A5=20+=20=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E5=88=97=E8=A1=A8=E5=A4=9A=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写信收件人改标签输入(回车生成 label),输入匹配云端联系人下拉 - 邮件列表前端显式按接收时间降序(最新在顶部) - 快速切换文件夹竞态守卫,仅采纳最后请求结果 - 操作栏移至列表底部、删除/标记 loading 遮罩、长标题截断 --- src/api/mail.ts | 16 ++++ src/utils/jmap.ts | 8 +- src/views/mail/Mailbox.vue | 159 +++++++++++++++++++++++++++++-------- 3 files changed, 147 insertions(+), 36 deletions(-) diff --git a/src/api/mail.ts b/src/api/mail.ts index a7130f3..b42fd27 100644 --- a/src/api/mail.ts +++ b/src/api/mail.ts @@ -46,6 +46,14 @@ export interface MailLoginResult { MailboxName: string } +/** 邮箱联系人(云端 SQL 存储,发件时自动保存) */ +export interface MailContact { + MailContactUUID: string + ContactName: string + ContactEmail: string + CreateTime: string | null +} + /** 邮箱 API 客户端 */ export const mailApi = { /** 获取 JMAP 连接配置 */ @@ -71,6 +79,14 @@ export const mailApi = { jmap: (data: { Token: string; MethodCalls: Array<[string, Record, string]>; AccountId?: string }): Promise => post('/API/Mail/Jmap', data, { silent: true }), + /** 我的联系人(Search 为空返回全部;发件成功后后端自动存储收件人) */ + contacts: (Search?: string): Promise<{ list: MailContact[]; total: number }> => + get<{ list: MailContact[]; total: number }>('/API/Mail/Contacts', Search ? { Search } : undefined), + + /** 新增/更新联系人 */ + addContact: (data: { ContactEmail: string; ContactName?: string }): Promise<{ Created: boolean }> => + post<{ Created: boolean }>('/API/Mail/Contacts', data), + /** 管理员:全部邮箱列表 */ adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'), diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts index 697d8e3..98ff54e 100644 --- a/src/utils/jmap.ts +++ b/src/utils/jmap.ts @@ -127,7 +127,7 @@ export class JmapClient { return payload.list || [] } - /** 查询邮箱内邮件 id(mailboxId 为空时查全部——排除垃圾箱/垃圾邮件;按接收时间倒序) */ + /** 查询邮箱内邮件 id(mailboxId 为空时查全部——排除垃圾箱/垃圾邮件;最新邮件在顶部) */ async queryEmailIds(mailboxId: string | null = null, limit = 50): Promise { const args: Record = { accountId: this.accountId, @@ -146,7 +146,7 @@ export class JmapClient { return payload.ids || [] } - /** 获取邮件详情 */ + /** 获取邮件详情(严格按查询返回的 ids 顺序排列;Stalwart v0.11 的 Email/get 可能不保序) */ async getEmails(ids: string[]): Promise { const payload = await this.call<{ list: JmapEmail[] }>('Email/get', { accountId: this.accountId, @@ -156,7 +156,9 @@ export class JmapClient { 'keywords', 'preview', 'textBody', 'htmlBody', 'messageId', 'inReplyTo', ], }) - return payload.list || [] + const byId = new Map() + for (const e of payload.list || []) byId.set(e.id, e) + return ids.map((id) => byId.get(id)).filter((e): e is JmapEmail => !!e) } /** 标记已读/未读 */ diff --git a/src/views/mail/Mailbox.vue b/src/views/mail/Mailbox.vue index 92b50b0..89cbaf6 100644 --- a/src/views/mail/Mailbox.vue +++ b/src/views/mail/Mailbox.vue @@ -20,7 +20,7 @@ import { ref, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Message, EditPen, Promotion, Delete, Folder, Warning, Refresh, RefreshLeft, User, Lock } from '@element-plus/icons-vue' -import { mailApi, type MailboxInfo, type MailSessionConfig } from '@/api/mail' +import { mailApi, type MailboxInfo, type MailSessionConfig, type MailContact } from '@/api/mail' import { JmapClient, JmapAuthError, type JmapMailbox, type JmapEmail } from '@/utils/jmap' // === 状态 === @@ -47,14 +47,19 @@ const emails = ref([]) const emailsLoading = ref(false) const totalEmails = ref(0) const selectedIds = ref([]) +// 操作中遮罩(删除/标记等耗时操作),避免误操作与闪烁 +const operating = ref(false) // 阅读 / 写信 const currentEmail = ref(null) const composeVisible = ref(false) -const composeForm = ref({ To: '', Subject: '', TextBody: '' }) +const composeForm = ref({ ToList: [] as string[], Subject: '', TextBody: '' }) const composeInReplyTo = ref() const composeReferences = ref([]) const sending = ref(false) +// 联系人(云端 SQL 存储,发件时自动保存;写信输入匹配下拉) +const contactOptions = ref([]) +const contactsLoading = ref(false) // 改密 const changePwdVisible = ref(false) @@ -252,6 +257,9 @@ const currentFolderName = computed(() => displayFolders.value.find((x) => x.id = // === 邮件加载 === +// 请求序号守卫:快速切换文件夹时多个 loadEmails 并发,仅采纳最后一次发起的请求结果 +let emailLoadSeq = 0 + async function loadMailboxes(): Promise { if (!client.value) return try { @@ -268,16 +276,33 @@ async function loadMailboxes(): Promise { async function loadEmails(): Promise { if (!client.value) return + const seq = ++emailLoadSeq emailsLoading.value = true try { const ids = await client.value.queryEmailIds(currentMailboxId.value === ALL_ID ? null : currentMailboxId.value, 50) - emails.value = ids.length ? await client.value.getEmails(ids) : [] + const rows = ids.length ? await client.value.getEmails(ids) : [] + // Stalwart v0.11 忽略 Email/query 的 sort 参数(始终旧→新返回),前端显式排序保证最新在顶部。 + // 优先按接收时间降序;若该视图邮件时间全部缺失(如全部邮件),回退为服务器顺序反转(旧→新→新→旧)。 + const ts = (iso: string): number => { + if (!iso) return 0 + const ms = new Date(iso).getTime() + return Number.isNaN(ms) ? 0 : ms + } + const hasTime = rows.some((e) => ts(e.receivedAt || e.date) > 0) + if (hasTime) { + rows.sort((a, b) => ts(b.receivedAt || b.date) - ts(a.receivedAt || a.date)) + } else { + rows.reverse() + } + if (seq !== emailLoadSeq) return // 已有更新的切换请求,丢弃本次结果 + emails.value = rows selectedIds.value = [] } catch (e) { + if (seq !== emailLoadSeq) return if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '加载邮件失败') } finally { - emailsLoading.value = false + if (seq === emailLoadSeq) emailsLoading.value = false } } @@ -316,11 +341,14 @@ async function selectEmail(e: JmapEmail): Promise { async function markSelected(seen: boolean): Promise { if (!selectedIds.value.length || !client.value) return + operating.value = true try { await client.value.setSeenBatch(selectedIds.value, seen) } catch (e) { if (e instanceof JmapAuthError) return // Stalwart v0.11 keywords 写入 bug:服务器失败仍本地生效,刷新不丢已读 + } finally { + operating.value = false } for (const e of emails.value) { if (selectedIds.value.includes(e.id)) { @@ -341,6 +369,7 @@ async function deleteSelected(): Promise { } catch { return } + operating.value = true try { const inTrash = currentMailbox.value?.role === 'trash' await client.value.deleteEmailBatch(selectedIds.value, inTrash ? undefined : currentMailboxId.value) @@ -352,12 +381,15 @@ async function deleteSelected(): Promise { } catch (e) { if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '删除失败') + } finally { + operating.value = false } } async function deleteCurrent(): Promise { const e = currentEmail.value if (!e || !client.value) return + operating.value = true try { const inTrash = currentMailbox.value?.role === 'trash' await client.value.deleteEmailBatch([e.id], inTrash ? undefined : currentMailboxId.value) @@ -367,16 +399,35 @@ async function deleteCurrent(): Promise { } catch (err) { if (err instanceof JmapAuthError) return ElMessage.error((err as Error).message || '删除失败') + } finally { + operating.value = false } } // === 写信 / 回复 / 转发 === +async function loadContacts(keyword = ''): Promise { + try { + contactsLoading.value = true + const result = await mailApi.contacts(keyword.trim() || undefined) + contactOptions.value = result.list + } catch { + // 拦截器已提示 + } finally { + contactsLoading.value = false + } +} + +function searchContacts(keyword: string): void { + void loadContacts(keyword) +} + function openCompose(): void { - composeForm.value = { To: '', Subject: '', TextBody: '' } + composeForm.value = { ToList: [], Subject: '', TextBody: '' } composeInReplyTo.value = undefined composeReferences.value = [] composeVisible.value = true + void loadContacts() } function plainBody(e: JmapEmail | null): string { @@ -403,35 +454,41 @@ function buildQuote(e: JmapEmail): string { function replyTo(e: JmapEmail): void { composeForm.value = { - To: e.from?.[0]?.email || '', + ToList: e.from?.[0]?.email ? [e.from[0].email] : [], Subject: /^Re:/i.test(e.subject) ? e.subject : `Re: ${e.subject || '(无主题)'}`, TextBody: buildQuote(e), } composeInReplyTo.value = e.messageId?.[0] composeReferences.value = [...(e.inReplyTo || []), ...(e.messageId || [])].filter((x): x is string => !!x) composeVisible.value = true + void loadContacts() } function forwardTo(e: JmapEmail): void { composeForm.value = { - To: '', + ToList: [], Subject: /^Fw:/i.test(e.subject) ? e.subject : `Fw: ${e.subject || '(无主题)'}`, TextBody: buildQuote(e), } composeInReplyTo.value = undefined composeReferences.value = [] composeVisible.value = true + void loadContacts() } async function submitCompose(): Promise { if (!client.value) return - const toList = composeForm.value.To - .split(/[;,,;\s]+/) - .map((s) => s.trim()) - .filter(Boolean) - .map((email) => ({ email })) + // 收件人标签可能为 "Name " 或纯邮箱,统一提取邮箱 + const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}/g + const toList: Array<{ email: string }> = [] + for (const raw of composeForm.value.ToList) { + const matches = raw.match(EMAIL_RE) + if (matches) { + for (const m of matches) toList.push({ email: m }) + } + } if (!toList.length) { - ElMessage.warning('请填写收件人') + ElMessage.warning('请填写收件人(输入邮箱后按回车添加)') return } if (!composeForm.value.Subject.trim() && !composeForm.value.TextBody.trim()) { @@ -630,21 +687,15 @@ function isSelected(id: string): boolean {
-
- -
- {{ currentFolderName }} - 已选 {{ selectedIds.length }} 封 -
+
+ {{ currentFolderName }} + 已选 {{ selectedIds.length }} 封
- 已读 - 未读 - 删除
-
@@ -724,7 +783,25 @@ function isSelected(id: string): boolean { - + + + @@ -984,24 +1061,19 @@ html.dark .mail-login-wrap { border-right: 1px solid var(--el-border-color-lighter); min-width: 0; } -.list-toolbar { +.list-head { height: 44px; flex-shrink: 0; display: flex; align-items: center; - gap: 6px; - padding: 0 10px; + gap: 8px; + padding: 0 12px; border-bottom: 1px solid var(--el-border-color-lighter); overflow: hidden; white-space: nowrap; } -.list-toolbar .spacer { +.list-head .spacer { flex: 1; -} -.toolbar-left { - display: flex; - align-items: baseline; - gap: 8px; min-width: 0; } .toolbar-title { @@ -1009,6 +1081,9 @@ html.dark .mail-login-wrap { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .toolbar-count { font-size: 12px; @@ -1016,6 +1091,24 @@ html.dark .mail-login-wrap { white-space: nowrap; flex-shrink: 0; } +.list-actions { + height: 44px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border-top: 1px solid var(--el-border-color-lighter); + background: var(--el-bg-color); +} +.list-actions .spacer { + flex: 1; +} +.action-label { + font-size: 12px; + color: var(--el-text-color-secondary); + white-space: nowrap; +} .email-list { flex: 1; overflow-y: auto;