feat(mail): 邮箱界面重构为企业邮箱三栏风格并适配深色主题

- 三栏布局:左侧文件夹树(未读徽标)+ 邮件列表(多选/批量已读/删除)+ 阅读窗格(回复/转发/删除) - 新增全部邮件视图、删除移入垃圾箱、批量已读、回复/转发引用头(inReplyTo/references) - 退出邮箱吊销后端会话 token - 硬编码颜色替换为 Element Plus CSS 变量,深色下选中态改用半透明主色
This commit is contained in:
TermiNexus committed 2026-08-08 15:59:21 +08:00
1 parent 82d4b03e2a
commit b2263a779c
2 files changed
+673 -166

No files matched your search

+36 -3
View File
@@ -91,11 +91,13 @@ export class JmapClient {
return payload.list || [] return payload.list || []
} }
/** 查询指定邮箱内的邮件 id(默认收件箱,按接收时间倒序) */ /** 查询邮箱内邮件 id(mailboxId 为空时查全部;按接收时间倒序) */
async queryEmailIds(mailboxId: string, limit = 50): Promise<string[]> { async queryEmailIds(mailboxId: string | null = null, limit = 50): Promise<string[]> {
const filter: Record<string, unknown> = {}
if (mailboxId) filter.inMailbox = mailboxId
const payload = await this.call<{ ids: string[] }>('Email/query', { const payload = await this.call<{ ids: string[] }>('Email/query', {
accountId: this.accountId, accountId: this.accountId,
filter: { inMailbox: mailboxId }, filter,
sort: [{ property: 'receivedAt', isAscending: false }], sort: [{ property: 'receivedAt', isAscending: false }],
limit, limit,
}) })
@@ -132,6 +134,32 @@ export class JmapClient {
}) })
} }
/** 批量标记已读/未读 */
async setSeenBatch(emailIds: string[], seen: boolean): Promise<void> {
if (!emailIds.length) return
const update: Record<string, unknown> = {}
for (const id of emailIds) {
update[id] = { keywords: seen ? { $seen: true } : { $seen: false } }
}
await this.call<unknown>('Email/set', { accountId: this.accountId, update })
}
/** 批量删除:传入 fromMailboxId 且有垃圾箱时移入垃圾箱;否则直接销毁 */
async deleteEmailBatch(emailIds: string[], fromMailboxId?: string): Promise<void> {
if (!emailIds.length) return
const mailboxes = await this.getMailboxes()
const trash = mailboxes.find((m) => m.role === 'trash')
if (!fromMailboxId || !trash) {
await this.call<unknown>('Email/set', { accountId: this.accountId, destroy: emailIds })
return
}
const update: Record<string, unknown> = {}
for (const id of emailIds) {
update[id] = { mailboxIds: { [trash.id]: true, [fromMailboxId]: false } }
}
await this.call<unknown>('Email/set', { accountId: this.accountId, update })
}
/** 获取发信身份(Identity) */ /** 获取发信身份(Identity) */
private async getIdentityId(): Promise<string> { private async getIdentityId(): Promise<string> {
const payload = await this.call<{ list: Array<{ id: string }> }>('Identity/get', { accountId: this.accountId, ids: null }) const payload = await this.call<{ list: Array<{ id: string }> }>('Identity/get', { accountId: this.accountId, ids: null })
@@ -148,6 +176,8 @@ export class JmapClient {
subject: string subject: string
textBody?: string textBody?: string
htmlBody?: string htmlBody?: string
inReplyTo?: string[]
references?: string[]
}): Promise<void> { }): Promise<void> {
const mailboxes = await this.getMailboxes() const mailboxes = await this.getMailboxes()
const drafts = mailboxes.find((m) => m.role === 'drafts') || mailboxes[0] const drafts = mailboxes.find((m) => m.role === 'drafts') || mailboxes[0]
@@ -160,6 +190,9 @@ export class JmapClient {
to: opts.to.map((t) => ({ name: t.name || '', email: t.email })), to: opts.to.map((t) => ({ name: t.name || '', email: t.email })),
subject: opts.subject, subject: opts.subject,
} }
// 回复/转发时携带引用头,形成会话线程
if (opts.inReplyTo?.length) emailData.inReplyTo = opts.inReplyTo
if (opts.references?.length) emailData.references = opts.references
// 正文必须用标准 JMAP bodyValues + partId 引用方式: // 正文必须用标准 JMAP bodyValues + partId 引用方式:
// Stalwart v0.11 不允许在 textBody/htmlBody 数组里内联 value(报 invalidProperties "Cannot set property.") // Stalwart v0.11 不允许在 textBody/htmlBody 数组里内联 value(报 invalidProperties "Cannot set property.")
if (opts.htmlBody) { if (opts.htmlBody) {
+637 -163
View File
@@ -1,20 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 我的邮箱(Stalwart) * 我的邮箱(Stalwart)—— 163/企业邮箱风格
* *
* 流程: * 流程:
* - 未申请 → 申请表单(邮箱名 + 密码 + 确认密码) * - 未申请 → 申请表单(邮箱名 + 密码 + 确认密码)
* - 已申请 → 输入邮箱密码登录 JMAP(凭据仅存内存)→ 收发邮件 * - 已申请 → 输入邮箱密码登录 JMAP(凭据仅存内存)→ 收发邮件
* - 支持:收件箱/全部/已发送查看、邮件详情(标记已读)、删除、写信发信、修改邮箱密码 * - 三栏布局:左侧文件夹树(未读数徽标)+ 中间邮件列表(多选/批量已读/删除)
* + 右侧阅读窗格(回复/转发/删除)
* - 支持:收件箱/全部/草稿/已发送/垃圾箱查看、标记已读、删除(移入垃圾箱)、
* 写信/回复/转发、修改邮箱密码
* *
* 接口: * 接口:
* - GET /API/Mail/MyMailbox → { Mailbox, Session } * - GET /API/Mail/MyMailbox → { Mailbox, Session }
* - POST /API/Mail/Apply → 申请(MailboxName/Password/QuotaMB) * - POST /API/Mail/Apply → 申请(MailboxName/Password/QuotaMB)
* - POST /API/Mail/ChangePassword → 改密(NewPassword) * - POST /API/Mail/ChangePassword → 改密(NewPassword)
* - 收发走浏览器直连 Stalwart JMAP(/jmap/),Basic 认证 * - POST /API/Mail/Login → 校验密码并签发会话 token(密码仅传一次)
* - POST /API/Mail/Jmap → JMAP 收发代理(携带 token,不传明文密码)
*/ */
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Message, EditPen, Promotion, Delete, Folder, Warning, Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { mailApi, type MailboxInfo, type MailSessionConfig } from '@/api/mail' import { mailApi, type MailboxInfo, type MailSessionConfig } from '@/api/mail'
import { JmapClient, type JmapMailbox, type JmapEmail } from '@/utils/jmap' import { JmapClient, type JmapMailbox, type JmapEmail } from '@/utils/jmap'
@@ -35,17 +40,20 @@ const loginForm = ref({ Password: '' })
const connecting = ref(false) const connecting = ref(false)
// 邮件数据 // 邮件数据
const ALL_ID = '__all__'
const mailboxes = ref<JmapMailbox[]>([]) const mailboxes = ref<JmapMailbox[]>([])
const currentMailboxId = ref('') const currentMailboxId = ref('')
const emails = ref<JmapEmail[]>([]) const emails = ref<JmapEmail[]>([])
const emailsLoading = ref(false) const emailsLoading = ref(false)
const totalEmails = ref(0) const totalEmails = ref(0)
const selectedIds = ref<string[]>([])
// 详情 / 写信 // 阅读 / 写信
const detailVisible = ref(false)
const currentEmail = ref<JmapEmail | null>(null) const currentEmail = ref<JmapEmail | null>(null)
const composeVisible = ref(false) const composeVisible = ref(false)
const composeForm = ref({ To: '', Subject: '', TextBody: '' }) const composeForm = ref({ To: '', Subject: '', TextBody: '' })
const composeInReplyTo = ref<string | undefined>()
const composeReferences = ref<string[]>([])
const sending = ref(false) const sending = ref(false)
// 改密 // 改密
@@ -121,15 +129,59 @@ async function submitLogin(): Promise<void> {
} }
} }
// === 左侧文件夹 ===
const ROLE_LABELS: Record<string, string> = {
inbox: '收件箱',
drafts: '草稿箱',
sent: '已发送',
trash: '垃圾箱',
junk: '垃圾邮件',
archive: '存档',
outbox: '发件箱',
}
const FOLDER_ORDER = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive']
function folderIcon(f: JmapMailbox) {
switch (f.role) {
case 'inbox': return Message
case 'drafts': return EditPen
case 'sent': return Promotion
case 'trash': return Delete
case 'junk': return Warning
default: return Folder
}
}
function folderLabel(f: JmapMailbox): string {
if (f.id === ALL_ID) return '全部邮件'
if (f.role && ROLE_LABELS[f.role]) return ROLE_LABELS[f.role]
return f.name
}
const displayFolders = computed<JmapMailbox[]>(() => {
const all: JmapMailbox = { id: ALL_ID, name: '全部邮件', role: null, totalEmails: totalEmails.value, unreadEmails: 0 }
const sorted = [...mailboxes.value].sort((a, b) => {
const ia = a.role ? FOLDER_ORDER.indexOf(a.role) : -1
const ib = b.role ? FOLDER_ORDER.indexOf(b.role) : -1
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
return [all, ...sorted]
})
const currentMailbox = computed(() => mailboxes.value.find((m) => m.id === currentMailboxId.value) || null)
const currentFolderName = computed(() => displayFolders.value.find((x) => x.id === currentMailboxId.value)?.name || '')
// === 邮件加载 === // === 邮件加载 ===
async function loadMailboxes(): Promise<void> { async function loadMailboxes(): Promise<void> {
if (!client.value) return if (!client.value) return
try { try {
mailboxes.value = await client.value.getMailboxes() mailboxes.value = await client.value.getMailboxes()
totalEmails.value = mailboxes.value.reduce((s, m) => s + (m.totalEmails || 0), 0)
const inbox = mailboxes.value.find((m) => m.role === 'inbox') || mailboxes.value[0] const inbox = mailboxes.value.find((m) => m.role === 'inbox') || mailboxes.value[0]
currentMailboxId.value = inbox?.id || '' currentMailboxId.value = inbox?.id || ''
totalEmails.value = inbox?.totalEmails || 0
await loadEmails() await loadEmails()
} catch (e) { } catch (e) {
ElMessage.error((e as Error).message || '加载邮箱失败') ElMessage.error((e as Error).message || '加载邮箱失败')
@@ -137,11 +189,12 @@ async function loadMailboxes(): Promise<void> {
} }
async function loadEmails(): Promise<void> { async function loadEmails(): Promise<void> {
if (!client.value || !currentMailboxId.value) return if (!client.value) return
emailsLoading.value = true emailsLoading.value = true
try { try {
const ids = await client.value.queryEmailIds(currentMailboxId.value, 50) const ids = await client.value.queryEmailIds(currentMailboxId.value === ALL_ID ? null : currentMailboxId.value, 50)
emails.value = ids.length ? await client.value.getEmails(ids) : [] emails.value = ids.length ? await client.value.getEmails(ids) : []
selectedIds.value = []
} catch (e) { } catch (e) {
ElMessage.error((e as Error).message || '加载邮件失败') ElMessage.error((e as Error).message || '加载邮件失败')
} finally { } finally {
@@ -150,51 +203,137 @@ async function loadEmails(): Promise<void> {
} }
async function switchMailbox(id: string): Promise<void> { async function switchMailbox(id: string): Promise<void> {
if (id === currentMailboxId.value) return
currentMailboxId.value = id currentMailboxId.value = id
currentEmail.value = null
await loadEmails() await loadEmails()
} }
// === 邮件详情 / 操作 === // === 选择 / 标记已读 ===
function formatSender(e: JmapEmail): string { const allChecked = computed(() => emails.value.length > 0 && selectedIds.value.length === emails.value.length)
const from = e.from?.[0] const someChecked = computed(() => selectedIds.value.length > 0 && selectedIds.value.length < emails.value.length)
if (!from) return '未知发件人'
return from.name ? `${from.name} <${from.email}>` : from.email
}
async function openDetail(e: JmapEmail): Promise<void> { function toggleSelect(id: string, v: boolean): void {
currentEmail.value = e if (v) {
detailVisible.value = true if (!selectedIds.value.includes(id)) selectedIds.value.push(id)
if (!e.keywords?.$seen) { } else {
await client.value?.setSeen(e.id, true) selectedIds.value = selectedIds.value.filter((x) => x !== id)
e.keywords = { ...e.keywords, $seen: true }
} }
} }
function formatDate(iso: string): string { function toggleSelectAll(): void {
if (!iso) return '' selectedIds.value = allChecked.value ? [] : emails.value.map((e) => e.id)
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false })
} }
async function deleteEmail(e: JmapEmail): Promise<void> { async function selectEmail(e: JmapEmail): Promise<void> {
currentEmail.value = e
if (!e.keywords?.$seen) {
e.keywords = { ...e.keywords, $seen: true }
void client.value?.setSeen(e.id, true)
}
}
async function markSelected(seen: boolean): Promise<void> {
if (!selectedIds.value.length || !client.value) return
try { try {
await ElMessageBox.confirm('确定删除这封邮件吗?', '删除确认', { type: 'warning' }) await client.value.setSeenBatch(selectedIds.value, seen)
for (const e of emails.value) {
if (selectedIds.value.includes(e.id)) e.keywords = { ...e.keywords, $seen: seen }
}
selectedIds.value = []
ElMessage.success(seen ? '已标记为已读' : '已标记为未读')
} catch (e) {
ElMessage.error((e as Error).message || '操作失败')
}
}
// === 删除 ===
async function deleteSelected(): Promise<void> {
if (!selectedIds.value.length || !client.value) return
try {
await ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 封邮件吗?`, '删除确认', { type: 'warning' })
} catch { } catch {
return return
} }
try { try {
await client.value?.deleteEmail(e.id) const inTrash = currentMailbox.value?.role === 'trash'
await client.value.deleteEmailBatch(selectedIds.value, inTrash ? undefined : currentMailboxId.value)
const removed = new Set(selectedIds.value)
emails.value = emails.value.filter((x) => !removed.has(x.id))
if (currentEmail.value && removed.has(currentEmail.value.id)) currentEmail.value = null
selectedIds.value = []
ElMessage.success('已删除')
} catch (e) {
ElMessage.error((e as Error).message || '删除失败')
}
}
async function deleteCurrent(): Promise<void> {
const e = currentEmail.value
if (!e || !client.value) return
try {
const inTrash = currentMailbox.value?.role === 'trash'
await client.value.deleteEmailBatch([e.id], inTrash ? undefined : currentMailboxId.value)
emails.value = emails.value.filter((x) => x.id !== e.id) emails.value = emails.value.filter((x) => x.id !== e.id)
if (currentEmail.value?.id === e.id) detailVisible.value = false currentEmail.value = null
ElMessage.success('已删除') ElMessage.success('已删除')
} catch (err) { } catch (err) {
ElMessage.error((err as Error).message || '删除失败') ElMessage.error((err as Error).message || '删除失败')
} }
} }
// === 写信 / 回复 / 转发 ===
function openCompose(): void { function openCompose(): void {
composeForm.value = { To: '', Subject: '', TextBody: '' } composeForm.value = { To: '', Subject: '', TextBody: '' }
composeInReplyTo.value = undefined
composeReferences.value = []
composeVisible.value = true
}
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, ' ')
return '(无正文内容)'
}
function buildQuote(e: JmapEmail): string {
return [
'',
'------ 原始邮件 ------',
`发件人: ${formatSender(e)}`,
`收件人: ${(e.to || []).map((t) => t.email).join(', ')}`,
`发送时间: ${formatDate(e.receivedAt || e.date)}`,
`主题: ${e.subject || '(无主题)'}`,
'',
plainBody(e),
].join('\n')
}
function replyTo(e: JmapEmail): void {
composeForm.value = {
To: 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
}
function forwardTo(e: JmapEmail): void {
composeForm.value = {
To: '',
Subject: /^Fw:/i.test(e.subject) ? e.subject : `Fw: ${e.subject || '(无主题)'}`,
TextBody: buildQuote(e),
}
composeInReplyTo.value = undefined
composeReferences.value = []
composeVisible.value = true composeVisible.value = true
} }
@@ -220,9 +359,12 @@ async function submitCompose(): Promise<void> {
to: toList, to: toList,
subject: composeForm.value.Subject.trim() || '(无主题)', subject: composeForm.value.Subject.trim() || '(无主题)',
textBody: composeForm.value.TextBody, textBody: composeForm.value.TextBody,
inReplyTo: composeInReplyTo.value ? [composeInReplyTo.value] : undefined,
references: composeReferences.value.length ? composeReferences.value : undefined,
}) })
ElMessage.success('发送成功') ElMessage.success('发送成功')
composeVisible.value = false composeVisible.value = false
await loadEmails()
} catch (e) { } catch (e) {
ElMessage.error((e as Error).message || '发送失败') ElMessage.error((e as Error).message || '发送失败')
} finally { } finally {
@@ -250,34 +392,82 @@ async function submitChangePassword(): Promise<void> {
client.value = null client.value = null
mailboxes.value = [] mailboxes.value = []
emails.value = [] emails.value = []
currentEmail.value = null
} catch { } catch {
// 拦截器已提示 // 拦截器已提示
} }
} }
function logoutMail(): void { async function logoutMail(): Promise<void> {
const c = client.value
client.value = null client.value = null
mailboxes.value = [] mailboxes.value = []
emails.value = [] emails.value = []
currentEmail.value = null
currentMailboxId.value = ''
selectedIds.value = []
if (c) {
try { await mailApi.logout(c.token) } catch { /* 忽略 */ }
}
ElMessage.success('已退出邮箱') ElMessage.success('已退出邮箱')
} }
// 打开邮箱登录
async function openMailbox(): Promise<void> {
loginVisible.value = true
await nextTick()
}
// === 渲染辅助 === // === 渲染辅助 ===
const emailBodyText = computed(() => { const emailBodyText = computed(() => plainBody(currentEmail.value))
const e = currentEmail.value
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, ' ')
return '(无正文内容)'
})
// 打开邮箱详情页时挂载邮箱列表 function formatSender(e: JmapEmail): string {
async function openMailbox(): Promise<void> { const from = e.from?.[0]
loginVisible.value = true if (!from) return '未知发件人'
await nextTick() return from.name ? `${from.name} <${from.email}>` : from.email
}
function senderLabel(e: JmapEmail): string {
const isSent = currentMailbox.value?.role === 'sent' || currentMailbox.value?.role === 'drafts'
const who = isSent ? e.to?.[0] : e.from?.[0]
if (!who) return '未知'
return who.name || who.email
}
function toList(e: JmapEmail): string {
return (e.to || []).map((t) => t.email).join(', ')
}
function formatDate(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false })
}
function smartDate(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const t = d.getTime()
if (t >= startOfToday) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
const startOfYear = new Date(now.getFullYear(), 0, 1).getTime()
if (t >= startOfYear) return `${d.getMonth() + 1}月${d.getDate()}日`
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function rowClass(e: JmapEmail): string {
return [
!e.keywords?.$seen ? 'row-unread' : '',
currentEmail.value?.id === e.id ? 'row-active' : '',
].filter(Boolean).join(' ')
}
function isSelected(id: string): boolean {
return selectedIds.value.includes(id)
} }
</script> </script>
@@ -291,74 +481,119 @@ async function openMailbox(): Promise<void> {
<!-- 已申请:邮箱概览 --> <!-- 已申请:邮箱概览 -->
<template v-else-if="mailbox && !client"> <template v-else-if="mailbox && !client">
<el-card shadow="never" class="mailbox-card"> <div class="mailbox-card">
<template #header> <div class="mailbox-card-inner">
<div class="card-header"> <el-icon :size="48" color="var(--el-color-primary)"><Message /></el-icon>
<span>我的企业邮箱</span>
<el-button text type="primary" @click="changePwdVisible = true">修改密码</el-button>
</div>
</template>
<div class="mailbox-info">
<div class="mailbox-address"> <div class="mailbox-address">
<el-icon :size="40" color="var(--el-color-primary)"><Message /></el-icon> <div class="address-text">{{ mailbox.MailboxName }}</div>
<div> <div class="address-meta">
<div class="address-text">{{ mailbox.MailboxName }}</div> 配额 {{ mailbox.QuotaMB }} MB
<div class="address-meta"> <el-tag size="small" :type="mailbox.Status === 1 ? 'success' : 'danger'">
配额 {{ mailbox.QuotaMB }} MB {{ mailbox.Status === 1 ? '启用' : '禁用' }}
<el-tag size="small" :type="mailbox.Status === 1 ? 'success' : 'danger'"> </el-tag>
{{ mailbox.Status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
</div> </div>
</div> </div>
<el-button type="primary" size="large" @click="openMailbox">登录邮箱</el-button> <div class="mailbox-actions">
<el-button @click="changePwdVisible = true">修改密码</el-button>
<el-button type="primary" size="large" @click="openMailbox">登录邮箱</el-button>
</div>
</div> </div>
</el-card> </div>
</template> </template>
<!-- 已登录 JMAP:邮件工作台 --> <!-- 已登录 JMAP:三栏工作台 -->
<template v-else-if="mailbox && client"> <template v-else-if="mailbox && client">
<div class="mail-toolbar"> <div class="mail-shell">
<el-select :model-value="currentMailboxId" style="width: 180px" @change="switchMailbox"> <!-- 左栏:文件夹 -->
<el-option v-for="m in mailboxes" :key="m.id" :value="m.id" :label="`${m.name}(${m.totalEmails})`" /> <aside class="mail-sidebar">
</el-select> <div class="sidebar-account">
<el-button type="primary" :icon="'EditPen'" @click="openCompose">写信</el-button> <div class="avatar">{{ (myAddress[0] || 'M').toUpperCase() }}</div>
<el-button :icon="'Refresh'" @click="loadEmails">刷新</el-button> <div class="account-name" :title="myAddress">{{ myAddress }}</div>
<div class="spacer" /> </div>
<el-button text type="danger" @click="logoutMail">退出邮箱</el-button> <el-button type="primary" class="compose-btn" :icon="EditPen" @click="openCompose">写信</el-button>
</div> <div class="folder-list">
<div
<el-table v-loading="emailsLoading" :data="emails" class="email-table" @row-click="(row: any) => openDetail(row as JmapEmail)"> v-for="f in displayFolders"
<el-table-column width="50"> :key="f.id"
<template #default="{ row }"> class="folder-item"
<span v-if="!row.keywords?.$seen" class="unread-dot" /> :class="{ active: currentMailboxId === f.id }"
</template> @click="switchMailbox(f.id)"
</el-table-column> >
<el-table-column prop="from" :width="260"> <el-icon class="folder-icon"><component :is="folderIcon(f)" /></el-icon>
<template #default="{ row }"> <span class="folder-name">{{ folderLabel(f) }}</span>
<span :class="{ 'unread-text': !row.keywords?.$seen }">{{ formatSender(row as JmapEmail) }}</span> <span v-if="f.unreadEmails > 0" class="folder-badge">{{ f.unreadEmails > 99 ? '99+' : f.unreadEmails }}</span>
</template>
</el-table-column>
<el-table-column prop="subject" min-width="240">
<template #default="{ row }">
<span :class="{ 'unread-text': !row.keywords?.$seen }">
{{ row.subject || '(无主题)' }}
</span>
<span v-if="row.preview" class="email-preview"> — {{ row.preview }}</span>
</template>
</el-table-column>
<el-table-column :width="180" align="right">
<template #default="{ row }">
<div class="email-date">
<span>{{ formatDate(row.receivedAt || row.date) }}</span>
<el-button text type="danger" size="small" @click.stop="deleteEmail(row as JmapEmail)">删除</el-button>
</div> </div>
</div>
<div class="sidebar-footer">
<el-button text size="small" @click="changePwdVisible = true">修改密码</el-button>
<el-button text size="small" type="danger" @click="logoutMail">退出邮箱</el-button>
</div>
</aside>
<!-- 中栏:邮件列表 -->
<section class="mail-list-pane">
<div class="list-toolbar">
<el-checkbox :model-value="allChecked" :indeterminate="someChecked" @change="toggleSelectAll" title="全选" />
<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-for="e in emails"
:key="e.id"
class="email-row"
:class="rowClass(e)"
@click="selectEmail(e)"
>
<div class="row-check" @click.stop>
<el-checkbox :model-value="isSelected(e.id)" @change="toggleSelect(e.id, !isSelected(e.id))" />
</div>
<div class="row-main">
<div class="row-top">
<span class="row-sender" :class="{ 'txt-unread': !e.keywords?.$seen }">{{ senderLabel(e) }}</span>
<span class="row-date">{{ smartDate(e.receivedAt || e.date) }}</span>
</div>
<div class="row-subject" :class="{ 'txt-unread': !e.keywords?.$seen }">
<span v-if="!e.keywords?.$seen" class="unread-dot" />
<span class="subject-text">{{ e.subject || '(无主题)' }}</span>
</div>
<div v-if="e.preview" class="row-preview">{{ e.preview }}</div>
</div>
</div>
<el-empty v-if="!emailsLoading && !emails.length" description="暂无邮件" :image-size="80" />
</div>
</section>
<!-- 右栏:阅读窗格 -->
<section class="mail-read-pane">
<template v-if="currentEmail">
<div class="read-toolbar">
<el-button size="small" :icon="RefreshLeft" @click="replyTo(currentEmail)">回复</el-button>
<el-button size="small" :icon="Promotion" @click="forwardTo(currentEmail)">转发</el-button>
<div class="spacer" />
<el-button size="small" text type="danger" :icon="Delete" @click="deleteCurrent">删除</el-button>
</div>
<div class="read-subject">{{ currentEmail.subject || '(无主题)' }}</div>
<div class="read-meta">
<div class="read-line"><span class="meta-label">发件人</span><b>{{ formatSender(currentEmail) }}</b></div>
<div class="read-line"><span class="meta-label">收件人</span>{{ toList(currentEmail) }}</div>
<div class="read-line"><span class="meta-label">时间</span>{{ formatDate(currentEmail.receivedAt || currentEmail.date) }}</div>
</div>
<el-divider />
<div class="read-body">{{ emailBodyText }}</div>
</template> </template>
</el-table-column> <div v-else class="read-empty">
<template #empty> <el-empty description="选择一封邮件阅读" :image-size="120" />
<el-empty description="暂无邮件" :image-size="80" /> </div>
</template> </section>
</el-table> </div>
</template> </template>
<el-empty v-else-if="!loading" description="暂无数据" /> <el-empty v-else-if="!loading" description="暂无数据" />
@@ -395,25 +630,8 @@ async function openMailbox(): Promise<void> {
</template> </template>
</el-dialog> </el-dialog>
<!-- 邮件详情弹窗 --> <!-- 写信弹窗(回复/转发复用) -->
<el-dialog v-model="detailVisible" :title="currentEmail?.subject || '邮件详情'" width="640px"> <el-dialog v-model="composeVisible" title="写信" width="640px">
<template v-if="currentEmail">
<div class="email-detail-meta">
<div><b>发件人:</b>{{ formatSender(currentEmail) }}</div>
<div><b>收件人:</b>{{ (currentEmail.to || []).map((t) => t.email).join(', ') }}</div>
<div><b>时间:</b>{{ formatDate(currentEmail.receivedAt || currentEmail.date) }}</div>
</div>
<el-divider />
<pre class="email-body">{{ emailBodyText }}</pre>
</template>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button type="danger" plain @click="deleteEmail(currentEmail!)">删除</el-button>
</template>
</el-dialog>
<!-- 写信弹窗 -->
<el-dialog v-model="composeVisible" title="写信" width="620px">
<el-form label-width="70px"> <el-form label-width="70px">
<el-form-item label="收件人"> <el-form-item label="收件人">
<el-input v-model="composeForm.To" placeholder="多个收件人用逗号或分号分隔" /> <el-input v-model="composeForm.To" placeholder="多个收件人用逗号或分号分隔" />
@@ -422,7 +640,7 @@ async function openMailbox(): Promise<void> {
<el-input v-model="composeForm.Subject" maxlength="200" /> <el-input v-model="composeForm.Subject" maxlength="200" />
</el-form-item> </el-form-item>
<el-form-item label="正文"> <el-form-item label="正文">
<el-input v-model="composeForm.TextBody" type="textarea" :rows="10" placeholder="请输入邮件正文" /> <el-input v-model="composeForm.TextBody" type="textarea" :rows="12" placeholder="请输入邮件正文" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -451,33 +669,33 @@ async function openMailbox(): Promise<void> {
<style scoped> <style scoped>
.mail-page { .mail-page {
padding: 16px;
height: 100%; height: 100%;
padding: 16px;
box-sizing: border-box; box-sizing: border-box;
background: var(--el-bg-color-page);
} }
.mail-container { .mail-container {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
/* 邮箱概览卡片 */
.mailbox-card { .mailbox-card {
max-width: 560px;
}
.card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center; padding-top: 10vh;
} }
.mailbox-info { .mailbox-card-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.mailbox-address {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 20px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 32px 40px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
} }
.address-text { .address-text {
font-size: 20px; font-size: 20px;
@@ -490,53 +708,309 @@ async function openMailbox(): Promise<void> {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.mail-toolbar { .mailbox-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* === 三栏布局 === */
.mail-shell {
flex: 1;
display: flex;
min-height: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
overflow: hidden;
}
/* 左栏 */
.mail-sidebar {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--el-fill-color-light);
border-right: 1px solid var(--el-border-color-lighter);
}
.sidebar-account {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 14px 8px;
}
.sidebar-account .avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff, #66b1ff);
color: #fff;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.account-name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose-btn {
margin: 8px 14px 12px;
height: 40px;
font-size: 15px;
border-radius: 6px;
background: linear-gradient(135deg, #409eff, #337ecc);
border: none;
}
.compose-btn:hover {
background: linear-gradient(135deg, #337ecc, #409eff);
}
.folder-list {
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
}
.folder-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px; padding: 9px 12px;
margin: 2px 10px;
border-radius: 6px;
cursor: pointer;
color: var(--el-text-color-regular);
font-size: 14px;
transition: background 0.15s;
} }
.mail-toolbar .spacer { .folder-item:hover {
background: var(--el-fill-color-light);
}
.folder-item.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 600;
}
.folder-icon {
font-size: 16px;
flex-shrink: 0;
}
.folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-badge {
min-width: 18px;
height: 18px;
line-height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--el-color-primary);
color: #fff;
font-size: 11px;
text-align: center;
}
.sidebar-footer {
display: flex;
justify-content: space-between;
padding: 8px 6px;
border-top: 1px solid var(--el-border-color-lighter);
}
/* 中栏 */
.mail-list-pane {
width: 330px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--el-border-color-lighter);
min-width: 0;
}
.list-toolbar {
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.list-toolbar .spacer {
flex: 1; flex: 1;
} }
.email-table { .toolbar-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.toolbar-count {
font-size: 12px;
color: var(--el-color-primary);
}
.email-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.email-row {
display: flex;
gap: 8px;
padding: 11px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
cursor: pointer; cursor: pointer;
transition: background 0.12s;
} }
.unread-dot { .email-row:hover {
display: inline-block; background: var(--el-fill-color-light);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-primary);
} }
.unread-text { .email-row.row-active {
font-weight: 700; background: var(--el-color-primary-light-9);
} }
.email-preview { .email-row.row-unread .row-main {
color: var(--el-text-color-secondary); border-left: 3px solid var(--el-color-primary);
padding-left: 6px;
} }
.email-date { .row-check {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.row-main {
flex: 1;
min-width: 0;
}
.row-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: 8px; gap: 8px;
}
.row-sender {
font-size: 13px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sender.txt-unread {
color: var(--el-text-color-primary);
font-weight: 600;
}
.row-date {
font-size: 12px;
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.row-subject {
margin-top: 3px;
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.row-subject.txt-unread {
color: var(--el-text-color-primary);
font-weight: 600;
}
.unread-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary);
flex-shrink: 0;
}
.subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-preview {
margin-top: 3px;
font-size: 12px;
color: var(--el-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 右栏 */
.mail-read-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
}
.read-toolbar {
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.read-toolbar .spacer {
flex: 1;
}
.read-subject {
padding: 20px 24px 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.read-meta {
padding: 12px 24px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.9;
}
.read-line {
display: flex;
gap: 10px;
}
.meta-label {
color: var(--el-text-color-placeholder);
flex-shrink: 0;
width: 48px;
}
.read-body {
flex: 1;
overflow-y: auto;
padding: 8px 24px 24px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
font-size: 14px;
color: var(--el-text-color-primary);
}
.read-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
} }
.login-tip { .login-tip {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
margin-bottom: 12px; margin-bottom: 12px;
} }
.email-detail-meta {
line-height: 1.9; /* ===== 深色主题适配 ===== */
color: var(--el-text-color-regular); /* 选中态:light-9 背景在深色下过亮,改用半透明主色(与全局 global.scss 约定一致) */
} html.dark {
.email-body { .folder-item.active,
white-space: pre-wrap; .email-row.row-active {
word-break: break-word; background: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
font-family: inherit; color: var(--el-color-primary-light-3);
line-height: 1.7; }
max-height: 420px;
overflow-y: auto;
margin: 0;
} }
</style> </style>