feat(mail): 邮箱界面重构为企业邮箱三栏风格并适配深色主题
- 三栏布局:左侧文件夹树(未读徽标)+ 邮件列表(多选/批量已读/删除)+ 阅读窗格(回复/转发/删除) - 新增全部邮件视图、删除移入垃圾箱、批量已读、回复/转发引用头(inReplyTo/references) - 退出邮箱吊销后端会话 token - 硬编码颜色替换为 Element Plus CSS 变量,深色下选中态改用半透明主色
This commit is contained in:
1 parent
82d4b03e2a
commit
b2263a779c
2 files changed
+673
-166
No files matched your search
+36
-3
@@ -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
@@ -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>
|
||||||
Reference in new issue
Block a user