feat(mail): 云端联系人匹配与收件人标签输入 + 邮件列表多项修复

- 写信收件人改标签输入(回车生成 label),输入匹配云端联系人下拉 - 邮件列表前端显式按接收时间降序(最新在顶部) - 快速切换文件夹竞态守卫,仅采纳最后请求结果 - 操作栏移至列表底部、删除/标记 loading 遮罩、长标题截断
This commit is contained in:
TermiNexus committed 2026-08-08 19:53:34 +08:00
1 parent 0900bfb7b5
commit 864a9fae3b
3 files changed
+147 -36

No files matched your search

+16
View File
@@ -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, unknown>, string]>; AccountId?: string }): Promise<JmapProxyResult> =>
post<JmapProxyResult>('/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'),
+5 -3
View File
@@ -127,7 +127,7 @@ export class JmapClient {
return payload.list || []
}
/** 查询邮箱内邮件 id(mailboxId 为空时查全部——排除垃圾箱/垃圾邮件;按接收时间倒序) */
/** 查询邮箱内邮件 id(mailboxId 为空时查全部——排除垃圾箱/垃圾邮件;最新邮件在顶部) */
async queryEmailIds(mailboxId: string | null = null, limit = 50): Promise<string[]> {
const args: Record<string, unknown> = {
accountId: this.accountId,
@@ -146,7 +146,7 @@ export class JmapClient {
return payload.ids || []
}
/** 获取邮件详情 */
/** 获取邮件详情(严格按查询返回的 ids 顺序排列;Stalwart v0.11 的 Email/get 可能不保序) */
async getEmails(ids: string[]): Promise<JmapEmail[]> {
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<string, JmapEmail>()
for (const e of payload.list || []) byId.set(e.id, e)
return ids.map((id) => byId.get(id)).filter((e): e is JmapEmail => !!e)
}
/** 标记已读/未读 */
+126 -33
View File
@@ -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<JmapEmail[]>([])
const emailsLoading = ref(false)
const totalEmails = ref(0)
const selectedIds = ref<string[]>([])
// 操作中遮罩(删除/标记等耗时操作),避免误操作与闪烁
const operating = ref(false)
// 阅读 / 写信
const currentEmail = ref<JmapEmail | null>(null)
const composeVisible = ref(false)
const composeForm = ref({ To: '', Subject: '', TextBody: '' })
const composeForm = ref({ ToList: [] as string[], Subject: '', TextBody: '' })
const composeInReplyTo = ref<string | undefined>()
const composeReferences = ref<string[]>([])
const sending = ref(false)
// 联系人(云端 SQL 存储,发件时自动保存;写信输入匹配下拉)
const contactOptions = ref<MailContact[]>([])
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<void> {
if (!client.value) return
try {
@@ -268,16 +276,33 @@ async function loadMailboxes(): Promise<void> {
async function loadEmails(): Promise<void> {
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<void> {
async function markSelected(seen: boolean): Promise<void> {
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<void> {
} 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<void> {
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '删除失败')
} finally {
operating.value = false
}
}
async function deleteCurrent(): Promise<void> {
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<void> {
} catch (err) {
if (err instanceof JmapAuthError) return
ElMessage.error((err as Error).message || '删除失败')
} finally {
operating.value = false
}
}
// === 写信 / 回复 / 转发 ===
async function loadContacts(keyword = ''): Promise<void> {
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<void> {
if (!client.value) return
const toList = composeForm.value.To
.split(/[;,,;\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((email) => ({ email }))
// 收件人标签可能为 "Name <email>" 或纯邮箱,统一提取邮箱
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 {
<!-- 中栏:邮件列表 -->
<section class="mail-list-pane">
<div class="list-toolbar">
<el-checkbox :model-value="allChecked" :indeterminate="someChecked" @change="toggleSelectAll" title="全选" />
<div class="toolbar-left">
<span class="toolbar-title">{{ currentFolderName }}</span>
<span v-if="selectedIds.length" class="toolbar-count">已选 {{ selectedIds.length }} 封</span>
</div>
<div class="list-head">
<span class="toolbar-title">{{ currentFolderName }}</span>
<span v-if="selectedIds.length" class="toolbar-count">已选 {{ selectedIds.length }} 封</span>
<div class="spacer" />
<el-tooltip content="刷新">
<el-button text size="small" :icon="Refresh" @click="loadEmails" />
</el-tooltip>
<el-button text size="small" :disabled="!selectedIds.length" @click="markSelected(true)">已读</el-button>
<el-button text size="small" :disabled="!selectedIds.length" @click="markSelected(false)">未读</el-button>
<el-button text size="small" type="danger" :disabled="!selectedIds.length" @click="deleteSelected">删除</el-button>
</div>
<div v-loading="emailsLoading" class="email-list">
<div v-loading="emailsLoading || operating" class="email-list">
<div
v-for="e in emails"
:key="e.id"
@@ -669,6 +720,14 @@ function isSelected(id: string): boolean {
</div>
<el-empty v-if="!emailsLoading && !emails.length" description="暂无邮件" :image-size="80" />
</div>
<div class="list-actions">
<el-checkbox :model-value="allChecked" :indeterminate="someChecked" @change="toggleSelectAll" title="全选" />
<span class="action-label">全选</span>
<div class="spacer" />
<el-button text size="small" :disabled="!selectedIds.length" @click="markSelected(true)">已读</el-button>
<el-button text size="small" :disabled="!selectedIds.length" @click="markSelected(false)">未读</el-button>
<el-button text size="small" type="danger" :disabled="!selectedIds.length" @click="deleteSelected">删除</el-button>
</div>
</section>
<!-- 右栏:阅读窗格 -->
@@ -724,7 +783,25 @@ function isSelected(id: string): boolean {
<el-dialog v-model="composeVisible" title="写信" width="640px">
<el-form label-width="70px">
<el-form-item label="收件人">
<el-input v-model="composeForm.To" placeholder="多个收件人用逗号或分号分隔" />
<el-select
v-model="composeForm.ToList"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
:remote-method="searchContacts"
:loading="contactsLoading"
placeholder="输入邮箱后回车添加,可从联系人下拉选择"
style="width: 100%"
>
<el-option
v-for="c in contactOptions"
:key="c.ContactEmail"
:value="c.ContactEmail"
:label="c.ContactName ? `${c.ContactName} <${c.ContactEmail}>` : c.ContactEmail"
/>
</el-select>
</el-form-item>
<el-form-item label="主题">
<el-input v-model="composeForm.Subject" maxlength="200" />
@@ -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;