feat(mail): 云端联系人匹配与收件人标签输入 + 邮件列表多项修复
- 写信收件人改标签输入(回车生成 label),输入匹配云端联系人下拉 - 邮件列表前端显式按接收时间降序(最新在顶部) - 快速切换文件夹竞态守卫,仅采纳最后请求结果 - 操作栏移至列表底部、删除/标记 loading 遮罩、长标题截断
This commit is contained in:
1 parent
0900bfb7b5
commit
864a9fae3b
3 files changed
+147
-36
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user