feat(mail): 邮箱登录持久化、企业化登录页与会话失效回退

- 登录 token 存 localStorage,刷新自动恢复会话,失效自动清除回退登录页 - 登录从弹窗改为企业邮箱登录页(品牌区/邮箱徽标/密码输入/修改密码) - Mail/Login 与 Mail/Jmap 接口 silent 化,401 不再误触发系统登出 - 全部邮箱视图不再上传空 filter(修复 502) - JmapClient 新增 onAuthExpired 回调与 JmapAuthError,401 统一清理回退
This commit is contained in:
TermiNexus committed 2026-08-08 16:07:44 +08:00
1 parent b2263a779c
commit ebb77fa632
3 files changed
+216 -81

No files matched your search

+4 -4
View File
@@ -61,15 +61,15 @@ export const mailApi = {
/** 修改邮箱密码 */
changePassword: (NewPassword: string): Promise<void> => post<void>('/API/Mail/ChangePassword', { NewPassword }),
/** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存) */
login: (Password: string): Promise<MailLoginResult> => post<MailLoginResult>('/API/Mail/Login', { Password }),
/** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存;401 为密码错误,不触发系统登出) */
login: (Password: string): Promise<MailLoginResult> => post<MailLoginResult>('/API/Mail/Login', { Password }, { silent: true }),
/** 邮箱登出:吊销会话 token */
logout: (Token: string): Promise<void> => post<void>('/API/Mail/Logout', { Token }),
/** JMAP 收发代理(携带会话 token,不再传输明文密码) */
/** JMAP 收发代理(携带会话 token,不再传输明文密码;401 为邮箱会话失效,不触发系统登出) */
jmap: (data: { Token: string; MethodCalls: Array<[string, Record<string, unknown>, string]>; AccountId?: string }): Promise<JmapProxyResult> =>
post<JmapProxyResult>('/API/Mail/Jmap', data),
post<JmapProxyResult>('/API/Mail/Jmap', data, { silent: true }),
/** 管理员:全部邮箱列表 */
adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'),
+34 -13
View File
@@ -41,6 +41,14 @@ export interface JmapEmail {
inReplyTo?: string[] | null
}
/** JMAP 会话失效错误(后端校验 token 不通过:过期/后端重启),前端应回退登录页 */
export class JmapAuthError extends Error {
constructor() {
super('邮箱登录已失效,请重新登录')
this.name = 'JmapAuthError'
}
}
/** JMAP 标准方法调用结果 */
export interface JmapCallResult {
methodResponses: Array<[string, Record<string, unknown>, string]>
@@ -51,10 +59,13 @@ export class JmapClient {
readonly mailboxName: string
readonly token: string
private accountId = ''
/** 邮箱会话失效(401)时回调:前端据此清理本地会话并回退登录页 */
onAuthExpired?: () => void
constructor(mailboxName: string, token: string) {
constructor(mailboxName: string, token: string, onAuthExpired?: () => void) {
this.mailboxName = mailboxName
this.token = token
this.onAuthExpired = onAuthExpired
}
/** 登录验证:通过一次 Mailbox/get 触发后端发现账户 ID */
@@ -64,13 +75,23 @@ export class JmapClient {
/** 批量调用 JMAP 方法(经后端代理,携带会话 token,密码不落前端) */
async request(methodCalls: Array<[string, Record<string, unknown>, string]>): Promise<JmapCallResult> {
const result = await mailApi.jmap({
Token: this.token,
MethodCalls: methodCalls,
AccountId: this.accountId || undefined,
})
this.accountId = result.AccountId
return result.Data
try {
const result = await mailApi.jmap({
Token: this.token,
MethodCalls: methodCalls,
AccountId: this.accountId || undefined,
})
this.accountId = result.AccountId
return result.Data
} catch (e) {
// 后端校验 token 不通过(HTTP 401)→ 触发失效回调,抛 JmapAuthError 供调用方静默回退
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 401) {
this.onAuthExpired?.()
throw new JmapAuthError()
}
throw e
}
}
/** 提取首个指定方法的响应 */
@@ -93,14 +114,14 @@ export class JmapClient {
/** 查询邮箱内邮件 id(mailboxId 为空时查全部;按接收时间倒序) */
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', {
// filter 为空时不传该字段:Stalwart v0.11 对空 filter {} 会返回 serverFail(全部邮件视图 502)
const args: Record<string, unknown> = {
accountId: this.accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
})
}
if (mailboxId) args.filter = { inMailbox: mailboxId }
const payload = await this.call<{ ids: string[] }>('Email/query', args)
return payload.ids || []
}
+178 -64
View File
@@ -4,7 +4,7 @@
*
* 流程:
* - 未申请 → 申请表单(邮箱名 + 密码 + 确认密码)
* - 已申请 → 输入邮箱密码登录 JMAP(凭据仅存内存)→ 收发邮件
* - 已申请 → 企业化登录页输入密码登录 JMAP(token 存 localStorage,刷新自动恢复)
* - 三栏布局:左侧文件夹树(未读数徽标)+ 中间邮件列表(多选/批量已读/删除)
* + 右侧阅读窗格(回复/转发/删除)
* - 支持:收件箱/全部/草稿/已发送/垃圾箱查看、标记已读、删除(移入垃圾箱)、
@@ -17,11 +17,11 @@
* - POST /API/Mail/Login → 校验密码并签发会话 token(密码仅传一次)
* - POST /API/Mail/Jmap → JMAP 收发代理(携带 token,不传明文密码)
*/
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Message, EditPen, Promotion, Delete, Folder, Warning, Refresh, RefreshLeft } from '@element-plus/icons-vue'
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 { JmapClient, type JmapMailbox, type JmapEmail } from '@/utils/jmap'
import { JmapClient, JmapAuthError, type JmapMailbox, type JmapEmail } from '@/utils/jmap'
// === 状态 ===
@@ -35,9 +35,9 @@ const applyForm = ref({ MailboxName: '', Password: '', ConfirmPassword: '' })
// JMAP 会话
const client = ref<JmapClient | null>(null)
const loginVisible = ref(false)
const loginForm = ref({ Password: '' })
const connecting = ref(false)
const restoring = ref(false)
// 邮件数据
const ALL_ID = '__all__'
@@ -62,6 +62,53 @@ const changePwdForm = ref({ NewPassword: '', ConfirmPassword: '' })
const myAddress = computed(() => mailbox.value?.MailboxName || '')
// === 邮箱会话持久化 ===
// 登录成功后将会话 token 存入 localStorage,刷新/重开页面自动恢复,无需重复输入密码。
// token 由后端签发(24h 有效,存进程内存映射邮箱凭据),失效时自动清除并回到登录页。
const MAIL_TOKEN_KEY = 'gerp_mail_token'
const MAIL_NAME_KEY = 'gerp_mail_name'
function saveMailSession(token: string): void {
localStorage.setItem(MAIL_TOKEN_KEY, token)
localStorage.setItem(MAIL_NAME_KEY, mailbox.value?.MailboxName || '')
}
function clearMailSession(): void {
localStorage.removeItem(MAIL_TOKEN_KEY)
localStorage.removeItem(MAIL_NAME_KEY)
}
/** 邮箱会话失效(后端校验 token 不通过):清除本地会话并回退到登录页 */
function handleMailSessionExpired(): void {
clearMailSession()
client.value = null
mailboxes.value = []
emails.value = []
currentEmail.value = null
currentMailboxId.value = ''
selectedIds.value = []
ElMessage.warning('邮箱登录已失效,请重新登录')
}
/** 恢复上次邮箱会话:localStorage 中的 token 直接连接 JMAP,失效则自动清除 */
async function restoreSession(): Promise<void> {
if (!mailbox.value || client.value) return
const token = localStorage.getItem(MAIL_TOKEN_KEY)
const name = localStorage.getItem(MAIL_NAME_KEY)
if (!token || name !== mailbox.value.MailboxName) return
restoring.value = true
try {
const c = new JmapClient(name, token, handleMailSessionExpired)
await c.discover()
client.value = c
await loadMailboxes()
} catch {
clearMailSession()
} finally {
restoring.value = false
}
}
// === 初始化 ===
async function init(): Promise<void> {
@@ -76,7 +123,10 @@ async function init(): Promise<void> {
}
}
onMounted(init)
onMounted(async () => {
await init()
await restoreSession()
})
// === 申请邮箱 ===
@@ -116,13 +166,14 @@ async function submitLogin(): Promise<void> {
try {
// 后端校验密码并签发会话 token(密码仅此一次传输,后端存内存;后续 JMAP 调用只带 token)
const result = await mailApi.login(loginForm.value.Password)
const c = new JmapClient(result.MailboxName, result.Token)
const c = new JmapClient(result.MailboxName, result.Token, handleMailSessionExpired)
await c.discover()
client.value = c
loginVisible.value = false
saveMailSession(result.Token)
loginForm.value.Password = ''
await loadMailboxes()
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '登录失败')
} finally {
connecting.value = false
@@ -184,6 +235,7 @@ async function loadMailboxes(): Promise<void> {
currentMailboxId.value = inbox?.id || ''
await loadEmails()
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '加载邮箱失败')
}
}
@@ -196,6 +248,7 @@ async function loadEmails(): Promise<void> {
emails.value = ids.length ? await client.value.getEmails(ids) : []
selectedIds.value = []
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '加载邮件失败')
} finally {
emailsLoading.value = false
@@ -244,6 +297,7 @@ async function markSelected(seen: boolean): Promise<void> {
selectedIds.value = []
ElMessage.success(seen ? '已标记为已读' : '已标记为未读')
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '操作失败')
}
}
@@ -266,6 +320,7 @@ async function deleteSelected(): Promise<void> {
selectedIds.value = []
ElMessage.success('已删除')
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '删除失败')
}
}
@@ -280,6 +335,7 @@ async function deleteCurrent(): Promise<void> {
currentEmail.value = null
ElMessage.success('已删除')
} catch (err) {
if (err instanceof JmapAuthError) return
ElMessage.error((err as Error).message || '删除失败')
}
}
@@ -366,6 +422,7 @@ async function submitCompose(): Promise<void> {
composeVisible.value = false
await loadEmails()
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '发送失败')
} finally {
sending.value = false
@@ -389,6 +446,7 @@ async function submitChangePassword(): Promise<void> {
ElMessage.success('密码修改成功,请重新登录邮箱')
changePwdVisible.value = false
changePwdForm.value = { NewPassword: '', ConfirmPassword: '' }
clearMailSession()
client.value = null
mailboxes.value = []
emails.value = []
@@ -406,18 +464,13 @@ async function logoutMail(): Promise<void> {
currentEmail.value = null
currentMailboxId.value = ''
selectedIds.value = []
clearMailSession()
if (c) {
try { await mailApi.logout(c.token) } catch { /* 忽略 */ }
}
ElMessage.success('已退出邮箱')
}
// 打开邮箱登录
async function openMailbox(): Promise<void> {
loginVisible.value = true
await nextTick()
}
// === 渲染辅助 ===
const emailBodyText = computed(() => plainBody(currentEmail.value))
@@ -479,25 +532,40 @@ function isSelected(id: string): boolean {
<el-button type="primary" @click="applyVisible = true">申请邮箱</el-button>
</el-empty>
<!-- 已申请:邮箱概览 -->
<!-- 已申请:企业邮箱登录页 -->
<template v-else-if="mailbox && !client">
<div class="mailbox-card">
<div class="mailbox-card-inner">
<el-icon :size="48" color="var(--el-color-primary)"><Message /></el-icon>
<div class="mailbox-address">
<div class="address-text">{{ mailbox.MailboxName }}</div>
<div class="address-meta">
配额 {{ mailbox.QuotaMB }} MB
<el-tag size="small" :type="mailbox.Status === 1 ? 'success' : 'danger'">
{{ mailbox.Status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
<div v-loading="restoring" class="mail-login-wrap">
<div class="mail-login-card">
<div class="login-brand">
<div class="login-logo"><el-icon :size="34"><Message /></el-icon></div>
<div class="login-title">企业邮箱</div>
<div class="login-subtitle">GVSDS · 银河锋锐 企业级邮件服务</div>
</div>
<div class="mailbox-actions">
<el-button @click="changePwdVisible = true">修改密码</el-button>
<el-button type="primary" size="large" @click="openMailbox">登录邮箱</el-button>
<div class="login-address">
<el-icon><User /></el-icon>
<span>{{ myAddress }}</span>
</div>
<el-input
v-model="loginForm.Password"
type="password"
show-password
size="large"
placeholder="请输入邮箱密码"
@keyup.enter="submitLogin"
>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
<el-button type="primary" size="large" class="login-submit" :loading="connecting" @click="submitLogin">
登 录
</el-button>
<div class="login-links">
<el-button text size="small" @click="changePwdVisible = true">修改密码</el-button>
<span class="link-divider" />
<el-button text size="small" type="danger" @click="logoutMail">退出当前邮箱</el-button>
</div>
<div class="login-tip">登录后即可网页收发邮件,也可在 Outlook 等客户端配置使用</div>
</div>
<div class="login-footer">© 2026 GVSDS 企业邮箱 · 银河锋锐</div>
</div>
</template>
@@ -620,16 +688,6 @@ function isSelected(id: string): boolean {
</template>
</el-dialog>
<!-- 邮箱登录弹窗 -->
<el-dialog v-model="loginVisible" title="登录邮箱" width="380px">
<div class="login-tip">使用邮箱 {{ myAddress }} 的密码登录</div>
<el-input v-model="loginForm.Password" type="password" show-password placeholder="邮箱密码" @keyup.enter="submitLogin" />
<template #footer>
<el-button @click="loginVisible = false">取消</el-button>
<el-button type="primary" :loading="connecting" @click="submitLogin">登录</el-button>
</template>
</el-dialog>
<!-- 写信弹窗(回复/转发复用) -->
<el-dialog v-model="composeVisible" title="写信" width="640px">
<el-form label-width="70px">
@@ -681,37 +739,97 @@ function isSelected(id: string): boolean {
align-items: stretch;
}
/* 邮箱概览卡片 */
.mailbox-card {
display: flex;
justify-content: center;
padding-top: 10vh;
}
.mailbox-card-inner {
/* 企业邮箱登录页 */
.mail-login-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
justify-content: center;
gap: 24px;
background:
radial-gradient(1100px 480px at 50% -8%, var(--el-color-primary-light-8), transparent),
var(--el-bg-color-page);
}
.mail-login-card {
width: 380px;
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);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(31, 45, 61, 0.1);
padding: 36px 36px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.address-text {
font-size: 20px;
font-weight: 600;
.login-brand {
text-align: center;
}
.address-meta {
margin-top: 6px;
color: var(--el-text-color-secondary);
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 12px;
border-radius: 14px;
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-dark-2));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.login-title {
font-size: 22px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.login-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.login-address {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-submit {
width: 100%;
height: 42px;
font-size: 15px;
}
.login-links {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.mailbox-actions {
display: flex;
align-items: center;
gap: 12px;
.link-divider {
width: 1px;
height: 12px;
background: var(--el-border-color);
}
.login-tip {
text-align: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.login-footer {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
/* 深色主题:登录页顶部光晕改用半透明主色(light-8 在深色下过亮) */
html.dark .mail-login-wrap {
background:
radial-gradient(1100px 480px at 50% -8%, rgba(64, 158, 255, 0.14), transparent),
var(--el-bg-color-page);
}
/* === 三栏布局 === */
@@ -999,10 +1117,6 @@ function isSelected(id: string): boolean {
align-items: center;
justify-content: center;
}
.login-tip {
color: var(--el-text-color-secondary);
margin-bottom: 12px;
}
/* ===== 深色主题适配 ===== */
/* 选中态:light-9 背景在深色下过亮,改用半透明主色(与全局 global.scss 约定一致) */