feat(mail): 邮箱登录持久化、企业化登录页与会话失效回退
- 登录 token 存 localStorage,刷新自动恢复会话,失效自动清除回退登录页 - 登录从弹窗改为企业邮箱登录页(品牌区/邮箱徽标/密码输入/修改密码) - Mail/Login 与 Mail/Jmap 接口 silent 化,401 不再误触发系统登出 - 全部邮箱视图不再上传空 filter(修复 502) - JmapClient 新增 onAuthExpired 回调与 JmapAuthError,401 统一清理回退
This commit is contained in:
1 parent
b2263a779c
commit
ebb77fa632
3 files changed
+216
-81
No files matched your search
+4
-4
@@ -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
@@ -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
@@ -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 约定一致) */
|
||||
|
||||
Reference in new issue
Block a user