修复邮件详情失败展示并防止旧请求覆盖新邮件
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 06:56:54 +08:00
parent 142286ffe7
commit ff13d39bd5
4 changed files with 226 additions and 26 deletions

View File

@ -47,6 +47,40 @@
padding: 28px 30px;
}
.mail-detail-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 20px;
text-align: center;
color: var(--pm-text-secondary);
}
.mail-detail-state h1 {
margin: 0;
font-size: 22px;
color: var(--pm-text-primary);
}
.mail-detail-state p {
margin: 0;
}
.mail-detail-state-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 12px;
}
.mail-detail-state-actions .el-button + .el-button {
margin-left: 0;
}
.mail-subject {
font-size: 36px;
font-weight: 600;

View File

@ -1,6 +1,4 @@
import './EmailDetail.js'
import {ref} from 'vue'
import {onBeforeUnmount, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
@ -30,27 +28,69 @@ export function usePage() {
const ccs = ref([])
const showCC = ref(false)
http.post("/api/v1/email/detail", {id: parseInt(route.params.id)}).then(res => {
detailData.value = res.data || {}
detailData.value.attachments = res.data.attachments || [];
if (res.data.to && res.data.to !== "") {
try {
tos.value = JSON.parse(res.data.to)
} catch (error) {
tos.value = []
}
const loading = ref(false)
const loadError = ref('')
let requestSequence = 0
const parseAddresses = (value) => {
try {
const addresses = JSON.parse(value || '[]')
return Array.isArray(addresses) ? addresses : []
} catch {
return []
}
if (res.data.cc && res.data.cc !== "") {
try {
ccs.value = JSON.parse(res.data.cc)
} catch (error) {
ccs.value = []
}
}
const loadDetail = async () => {
const sequence = ++requestSequence
detailData.value = {attachments: []}
tos.value = []
ccs.value = []
showCC.value = false
loadError.value = ''
loading.value = true
const rawId = String(route.params.id || '')
const id = Number(rawId)
if (!/^\d+$/.test(rawId) || !Number.isSafeInteger(id) || id <= 0) {
loadError.value = '邮件地址无效,请返回邮件列表'
loading.value = false
return
}
showCC.value = ccs.value && ccs.value.length > 0
try {
const res = await http.post('/api/v1/email/detail', {id})
// 切换邮件或离开页面后,旧请求不得覆盖当前状态。
if (sequence !== requestSequence) return
if (res?.errorNo !== 0) {
loadError.value = res?.errorMsg || '读取邮件失败,请重试'
return
}
const data = res.data
if (!data || typeof data !== 'object' || Array.isArray(data) || Number(data.id) !== id) {
loadError.value = '邮件不可用,请返回邮件列表'
return
}
detailData.value = {...data, attachments: Array.isArray(data.attachments) ? data.attachments : []}
tos.value = parseAddresses(data.to)
ccs.value = parseAddresses(data.cc)
showCC.value = ccs.value.length > 0
} catch {
if (sequence === requestSequence) {
loadError.value = '网络连接失败,请稍后重试'
}
} finally {
if (sequence === requestSequence) loading.value = false
}
}
watch(() => route.params.id, loadDetail, {immediate: true})
onBeforeUnmount(() => {
requestSequence++
})
const backToList = () => router.push({name: 'list'})
const getInitial = (name) => {
if (!name) return '?';
@ -67,7 +107,8 @@ export function usePage() {
}
const handleDelete = () => {
const id = detailData.value.id || parseInt(route.params.id);
if (loading.value || loadError.value) return;
const id = detailData.value.id;
if (!id) return;
let tag = groupStore.tag;
@ -119,6 +160,10 @@ export function usePage() {
tos,
ccs,
showCC,
loading,
loadError,
loadDetail,
backToList,
getInitial,
formatDetailDate,
handleDelete

View File

@ -1,17 +1,28 @@
<template>
<div class="mail-detail-container">
<div class="mail-detail-header">
<el-button @click="$router.back()" plain class="back-btn">
<el-button @click="backToList" plain class="back-btn" aria-label="返回邮件列表">
<el-icon><Back /></el-icon>
</el-button>
<div class="action-buttons">
<el-button plain @click="handleDelete">
<div v-if="!loading && !loadError" class="action-buttons">
<el-button plain @click="handleDelete" aria-label="删除邮件">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="mail-detail-content">
<div v-if="loading" class="mail-detail-state" role="status" aria-live="polite">
<p>正在读取邮件…</p>
</div>
<div v-else-if="loadError" class="mail-detail-state" role="alert">
<h1>邮件暂时无法查看</h1>
<p>{{ loadError }}</p>
<div class="mail-detail-state-actions">
<el-button type="primary" @click="loadDetail">重试</el-button>
<el-button @click="backToList">返回邮件列表</el-button>
</div>
</div>
<div v-else class="mail-detail-content">
<h1 class="mail-subject">{{ detailData.subject }}</h1>
<div class="mail-meta-card">
@ -72,6 +83,10 @@ import { usePage, Back, Delete, Document, Download } from './EmailDetail.js'
const {
handleDelete,
loading,
loadError,
loadDetail,
backToList,
detailData,
getInitial,
lang,

View File

@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { reactive, ref, watch } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/EmailDetail/EmailDetail.js', import.meta.url), 'utf8')
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}/g, '') + '\nreturn usePage()',
['ref', 'watch', 'onBeforeUnmount', 'useRoute', 'useRouter', 'useGroupStore', 'http', 'ElMessage', 'ElMessageBox', 'lang', 'Document', 'Back', 'Delete', 'Download']
)
const settle = () => new Promise(resolve => setImmediate(resolve))
const email = id => ({errorNo: 0, data: {id, subject: `mail-${id}`, to: '[{"EmailAddress":"test@example.com"}]', cc: '[]', attachments: []}})
function fixture(post, id = '15') {
const route = reactive({params: {id}})
const requests = []
const navigations = []
let unmount
const page = createPage(ref, watch, callback => { unmount = callback }, () => route,
() => ({push: target => navigations.push(target)}), () => ({tag: ''}),
{post: async (...args) => { requests.push(args); return post(...args) }}, {}, {}, {}, {}, {}, {}, {})
return {page, route, requests, navigations, unmount: () => unmount()}
}
test('邮件业务错误不渲染空数据,允许重试后读取正文与附件', async () => {
let response = {errorNo: 500, errorMsg: '读取邮件失败', data: ''}
const {page, requests} = fixture(async () => response)
assert.equal(page.loading.value, true)
await settle()
assert.equal(page.loading.value, false)
assert.equal(page.loadError.value, '读取邮件失败')
assert.equal(page.detailData.value.id, undefined)
page.handleDelete()
assert.equal(requests.length, 1)
response = email(15)
response.data.attachments = [{Index: 0, Filename: '中文附件.txt'}]
await page.loadDetail()
assert.equal(page.loadError.value, '')
assert.equal(page.detailData.value.subject, 'mail-15')
assert.equal(page.detailData.value.attachments[0].Filename, '中文附件.txt')
assert.equal(page.tos.value[0].EmailAddress, 'test@example.com')
})
test('网络失败提供中文错误,返回操作明确进入邮件列表', async () => {
const {page, navigations} = fixture(async () => { throw new Error('Network Error') })
await settle()
assert.equal(page.loading.value, false)
assert.equal(page.loadError.value, '网络连接失败,请稍后重试')
page.backToList()
assert.equal(navigations[0].name, 'list')
})
test('无效编号不发请求,空响应或编号不匹配不当作成功', async () => {
for (const id of ['abc', '15abc', '-1', '0', '9007199254740992']) {
const {page, requests} = fixture(async () => email(15), id)
await settle()
assert.equal(requests.length, 0)
assert.equal(page.loadError.value, '邮件地址无效,请返回邮件列表')
}
for (const data of [null, '', [], {}, {id: 14}]) {
const {page} = fixture(async () => ({errorNo: 0, data}))
await settle()
assert.equal(page.loadError.value, '邮件不可用,请返回邮件列表')
assert.equal(page.detailData.value.id, undefined)
}
})
test('异常地址和附件字段不导致页面异常,切换邮件清除旧收件人', async () => {
const {page, route} = fixture(async (_, {id}) => ({errorNo: 0, data: {id, to: id === 15 ? '[{"EmailAddress":"old@example.com"}]' : 'null', cc: id === 15 ? '[{"EmailAddress":"copy@example.com"}]' : '{', attachments: 'bad'}}))
await settle()
assert.equal(page.showCC.value, true)
assert.equal(page.detailData.value.attachments.length, 0)
route.params.id = '16'
await settle()
assert.equal(page.detailData.value.id, 16)
assert.equal(page.tos.value.length, 0)
assert.equal(page.ccs.value.length, 0)
assert.equal(page.showCC.value, false)
})
test('切换邮件后旧请求成功或失败均不能覆盖新邮件', async () => {
for (const staleFailure of [false, true]) {
let finish
let fail
const {page, route} = fixture((_, {id}) => id === 15 ? new Promise((resolve, reject) => { finish = resolve; fail = reject }) : Promise.resolve(email(id)))
route.params.id = '16'
await settle()
assert.equal(page.detailData.value.id, 16)
if (staleFailure) fail(new Error('Network Error'))
else finish(email(15))
await settle()
assert.equal(page.detailData.value.id, 16)
assert.equal(page.loadError.value, '')
assert.equal(page.loading.value, false)
}
})
test('离开详情后未完成请求不再写入状态', async () => {
let finish
const {page, unmount} = fixture(() => new Promise(resolve => { finish = resolve }))
unmount()
finish(email(15))
await settle()
assert.equal(page.detailData.value.id, undefined)
})