This commit is contained in:
parent
142286ffe7
commit
ff13d39bd5
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
106
test/frontend/email-detail.test.mjs
Normal file
106
test/frontend/email-detail.test.mjs
Normal 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)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user