fix(mail): 修复邮件正文为空并新增联系人管理视图

- Email/get 补 bodyValues,正文按 partId 取值,HTML 去标签 - 左栏联系人入口 + 中栏 CRUD 管理(新增/编辑/删除) - 文件夹项选中态绑定当前视图,切换联系人时取消高亮
This commit is contained in:
TermiNexus committed 2026-08-08 20:49:51 +08:00
1 parent 864a9fae3b
commit 5c320e0e4a
3 files changed
+188 -8

No files matched your search

+5 -1
View File
@@ -1,4 +1,4 @@
import { get, post } from './request'
import { get, post, del } from './request'
/** 我的邮箱信息 */
export interface MailboxInfo {
@@ -87,6 +87,10 @@ export const mailApi = {
addContact: (data: { ContactEmail: string; ContactName?: string }): Promise<{ Created: boolean }> =>
post<{ Created: boolean }>('/API/Mail/Contacts', data),
/** 删除联系人(按 MailContactUUID 或 ContactEmail) */
deleteContact: (data: { MailContactUUID?: string; ContactEmail?: string }): Promise<void> =>
del<void>('/API/Mail/Contacts', data),
/** 管理员:全部邮箱列表 */
adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'),
+2 -1
View File
@@ -37,6 +37,7 @@ export interface JmapEmail {
preview?: string
textBody?: Array<{ type: string; partId?: string; value?: string; blobId?: string }>
htmlBody?: Array<{ type: string; partId?: string; value?: string; blobId?: string }>
bodyValues?: Record<string, { value?: string }>
messageId: string[]
inReplyTo?: string[] | null
}
@@ -153,7 +154,7 @@ export class JmapClient {
ids,
properties: [
'id', 'subject', 'from', 'to', 'cc', 'receivedAt', 'size',
'keywords', 'preview', 'textBody', 'htmlBody', 'messageId', 'inReplyTo',
'keywords', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'messageId', 'inReplyTo',
],
})
const byId = new Map<string, JmapEmail>()
+181 -6
View File
@@ -60,6 +60,12 @@ const sending = ref(false)
// 联系人(云端 SQL 存储,发件时自动保存;写信输入匹配下拉)
const contactOptions = ref<MailContact[]>([])
const contactsLoading = ref(false)
// 联系人管理视图(中栏切换)
const currentPane = ref<'mail' | 'contacts'>('mail')
const contactRows = ref<MailContact[]>([])
const contactVisible = ref(false)
const contactForm = ref({ ContactName: '', ContactEmail: '' })
const contactSaving = ref(false)
// 改密
const changePwdVisible = ref(false)
@@ -268,6 +274,7 @@ async function loadMailboxes(): Promise<void> {
const inbox = mailboxes.value.find((m) => m.role === 'inbox') || mailboxes.value[0]
currentMailboxId.value = inbox?.id || ''
await loadEmails()
void refreshContacts()
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '加载邮箱失败')
@@ -309,6 +316,7 @@ async function loadEmails(): Promise<void> {
async function switchMailbox(id: string): Promise<void> {
if (id === currentMailboxId.value) return
currentMailboxId.value = id
currentPane.value = 'mail'
currentEmail.value = null
await loadEmails()
}
@@ -422,6 +430,66 @@ function searchContacts(keyword: string): void {
void loadContacts(keyword)
}
// === 联系人管理(中栏 CRUD) ===
async function refreshContacts(): Promise<void> {
try {
const result = await mailApi.contacts()
contactRows.value = result.list
} catch {
// 拦截器已提示
}
}
function switchPane(pane: 'mail' | 'contacts'): void {
currentPane.value = pane
if (pane === 'contacts') void refreshContacts()
}
function openContactAdd(): void {
contactForm.value = { ContactName: '', ContactEmail: '' }
contactVisible.value = true
}
function openContactEdit(c: MailContact): void {
contactForm.value = { ContactName: c.ContactName, ContactEmail: c.ContactEmail }
contactVisible.value = true
}
async function saveContact(): Promise<void> {
const email = contactForm.value.ContactEmail.trim().toLowerCase()
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}$/.test(email)) {
ElMessage.warning('请输入有效的邮箱地址')
return
}
contactSaving.value = true
try {
await mailApi.addContact({ ContactEmail: email, ContactName: contactForm.value.ContactName.trim() })
ElMessage.success('已保存')
contactVisible.value = false
await refreshContacts()
} catch {
// 拦截器已提示
} finally {
contactSaving.value = false
}
}
async function removeContact(c: MailContact): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除联系人 ${c.ContactName || c.ContactEmail} 吗?`, '删除确认', { type: 'warning' })
} catch {
return
}
try {
await mailApi.deleteContact({ MailContactUUID: c.MailContactUUID })
ElMessage.success('已删除')
await refreshContacts()
} catch {
// 拦截器已提示
}
}
function openCompose(): void {
composeForm.value = { ToList: [], Subject: '', TextBody: '' }
composeInReplyTo.value = undefined
@@ -432,10 +500,17 @@ function openCompose(): void {
function plainBody(e: JmapEmail | null): string {
if (!e) return ''
const textPart = e.textBody?.find((p) => p.value != null)
if (textPart?.value) return textPart.value
const htmlPart = e.htmlBody?.find((p) => p.value != null)
if (htmlPart?.value) return htmlPart.value.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ')
// 正文按 bodyValues 引用(JMAP 标准:textBody/htmlBody 仅含 partId,value 在 bodyValues 中)
const partValue = (parts: JmapEmail['textBody']): string => {
for (const p of parts || []) {
if (p.partId && e.bodyValues?.[p.partId]?.value != null) return e.bodyValues[p.partId].value as string
}
return ''
}
const text = partValue(e.textBody)
if (text) return text
const html = partValue(e.htmlBody)
if (html) return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return '(无正文内容)'
}
@@ -671,13 +746,22 @@ function isSelected(id: string): boolean {
v-for="f in displayFolders"
:key="f.id"
class="folder-item"
:class="{ active: currentMailboxId === f.id }"
:class="{ active: currentPane === 'mail' && currentMailboxId === f.id }"
@click="switchMailbox(f.id)"
>
<el-icon class="folder-icon"><component :is="folderIcon(f)" /></el-icon>
<span class="folder-name">{{ folderLabel(f) }}</span>
<span v-if="f.unreadEmails > 0" class="folder-badge">{{ f.unreadEmails > 99 ? '99+' : f.unreadEmails }}</span>
</div>
<div
class="folder-item"
:class="{ active: currentPane === 'contacts' }"
@click="switchPane('contacts')"
>
<el-icon class="folder-icon"><User /></el-icon>
<span class="folder-name">联系人</span>
<span v-if="contactRows.length" class="folder-badge">{{ contactRows.length }}</span>
</div>
</div>
<div class="sidebar-footer">
<el-button text size="small" @click="changePwdVisible = true">修改密码</el-button>
@@ -685,8 +769,9 @@ function isSelected(id: string): boolean {
</div>
</aside>
<!-- 中栏:邮件列表 -->
<!-- 中栏:邮件列表 / 联系人管理 -->
<section class="mail-list-pane">
<template v-if="currentPane === 'mail'">
<div class="list-head">
<span class="toolbar-title">{{ currentFolderName }}</span>
<span v-if="selectedIds.length" class="toolbar-count">已选 {{ selectedIds.length }} 封</span>
@@ -728,6 +813,37 @@ function isSelected(id: string): boolean {
<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>
</template>
<!-- 联系人管理视图 -->
<template v-else>
<div class="list-head">
<span class="toolbar-title">联系人</span>
<span v-if="contactRows.length" class="toolbar-count">{{ contactRows.length }} 位</span>
<div class="spacer" />
<el-tooltip content="刷新">
<el-button text size="small" :icon="Refresh" @click="refreshContacts" />
</el-tooltip>
</div>
<div class="contacts-list">
<div v-for="c in contactRows" :key="c.MailContactUUID" class="contact-row">
<div class="contact-main">
<div class="contact-name">{{ c.ContactName || c.ContactEmail }}</div>
<div class="contact-email">{{ c.ContactEmail }}</div>
</div>
<div class="contact-actions">
<el-button text size="small" @click="openContactEdit(c)">编辑</el-button>
<el-button text size="small" type="danger" @click="removeContact(c)">删除</el-button>
</div>
</div>
<el-empty v-if="!contactRows.length" description="暂无联系人,发件后自动保存" :image-size="80" />
</div>
<div class="list-actions">
<el-button type="primary" size="small" @click="openContactAdd">新增联系人</el-button>
<div class="spacer" />
<el-button text size="small" @click="switchPane('mail')">返回邮箱</el-button>
</div>
</template>
</section>
<!-- 右栏:阅读窗格 -->
@@ -831,6 +947,22 @@ function isSelected(id: string): boolean {
<el-button type="primary" @click="submitChangePassword">确认修改</el-button>
</template>
</el-dialog>
<!-- 联系人新增/编辑弹窗 -->
<el-dialog v-model="contactVisible" :title="contactForm.ContactEmail ? '编辑联系人' : '新增联系人'" width="420px">
<el-form label-width="70px">
<el-form-item label="姓名">
<el-input v-model="contactForm.ContactName" placeholder="联系人姓名(可选)" maxlength="128" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="contactForm.ContactEmail" placeholder="name@example.com" maxlength="256" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="contactVisible = false">取消</el-button>
<el-button type="primary" :loading="contactSaving" @click="saveContact">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -1109,6 +1241,49 @@ html.dark .mail-login-wrap {
color: var(--el-text-color-secondary);
white-space: nowrap;
}
/* 联系人管理 */
.contacts-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.contact-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background 0.12s;
}
.contact-row:hover {
background: var(--el-fill-color-light);
}
.contact-main {
min-width: 0;
}
.contact-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-email {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-actions {
flex-shrink: 0;
display: flex;
align-items: center;
}
.email-list {
flex: 1;
overflow-y: auto;