🧑💻 寫在開頭
點贊 + 收藏 === 學會🤣🤣🤣
你是否曾經想過,在瀏覽器中直接點擊一個郵件附件,就能預覽完整的郵件內容——包括髮件人、收件人、抄送、正文甚至內嵌圖片?
今天,我們要揭秘一個基於 Vue 3 和 Vant UI 的郵件預覽上傳組件,它不僅能上傳.eml格式的郵件文件,還能在彈窗中完整渲染郵件內容,甚至支持附件圖片的內聯展示!
🧩 組件核心功能一覽
- ✅ 支持上傳
.eml格式郵件文件 - ✅ 限制文件類型、大小、數量
- ✅ 預覽郵件內容(含發件人、收件人、抄送、正文、圖片)
- ✅ 支持附件下載
- ✅ 響應式柵格佈局,適配移動端
🧠 技術架構與實現細節
1. 文件上傳與格式校驗
組件使用 van-uploader 實現文件選擇,並在 beforeRead 方法中進行格式和大小校驗:
const beforeRead = (file) => {
if (!props.accept.includes(file.type)) {
createToast.fail({ getContainer: 'body', message: '文件格式錯誤' })
return false
}
// 上傳邏輯...
}
2. 郵件內容解析:從二進制到可讀 HTML
這是最核心的部分!組件通過 FileReader 讀取 .eml 文件內容,並使用 emailjs-mime-codec 和 eml-format 庫進行解碼:
reader.onload = async (e) => {
let emlContent = e.target.result;
emlContent = Codec.quotedPrintableDecode(emlContent, "UTF-8");
emlFormat.read(emlContent, (err, data) => {
// 解析出郵件主題、發件人、收件人、正文等
});
}
3. 郵件主題解碼:處理 MIME 編碼
郵件主題常常是 MIME 編碼的,例如:
=?UTF-8?B?5paw5bm56Zm15a+G?=
組件使用 Codec.mimeWordDecode 進行解碼,確保中文等非 ASCII 字符正確顯示。
4. 內嵌圖片處理:Uint8Array → Base64
郵件中的圖片通常以 cid: 引用,附件中以 Uint8Array 格式存儲。組件將其轉換為 Base64 並替換到 HTML 中:
const base64String = uint8ArrayToBase64(item.data);
_html = _html.replaceAll(`cid:${cid}`, `data:image/${item.name.split('.').at(-1)};base64,${base64String}`);
5. 彈窗預覽與下載
使用 Vant 的 Dialog 組件展示郵件內容,並支持一鍵下載原文件:
Dialog({
message: concatHeader(data, title),
messageAlign: 'left',
className: "eml-dialog",
showCancelButton: true,
confirmButtonText: "下載"
}).then(async() => {
await nativeApi.downloadFile(encodeURI(item))
createToast.success({ getContainer: 'body', message: '保存成功' })
})
🎨 界面與交互設計
- 使用
van-grid實現響應式文件列表 - 每個文件項顯示為附件圖標,點擊可預覽或下載
- 右上角刪除按鈕支持編輯模式下移除文件
- 提示信息友好,限制條件明確
🛠 可擴展性與優化建議
- 類型推斷:可增加一個函數根據
file.type推斷文件後綴名,增強兼容性 - 錯誤處理:增加更多讀取失敗或格式錯誤的 fallback 邏輯
- 性能優化:大文件分片讀取,避免阻塞 UI
🚀 總結
這個組件不僅實現了郵件上傳與預覽的完整鏈路,還展示瞭如何在瀏覽器中處理複雜的 MIME 格式郵件、解碼主題、內聯圖片等高級功能。
如果你正在開發一個需要郵件附件的管理系統、工單系統或郵件審計工具,這個組件絕對是一個值得借鑑和複用的技術方案!
如果這篇文章對你有幫助,歡迎點贊、收藏、轉發!
我們也歡迎你在評論區留言,分享你在郵件解析或文件上傳方面的實戰經驗!
🚀 源碼
<template>
<div>
<van-grid :border="false" :column-num="4" :gutter="10" class="emlBox">
<!-- 上傳更多圖片 -->
<van-grid-item v-for="(item, index) in list" :key="index">
<div class="picBox">
<div @click.stop="preview(item)">
<svg-icon
icon-class="new-fujian"
icon="new-fujian"
class-name="file-svg-icon"
/>
</div>
<van-icon
@click.stop="list.splice(index,1)"
class="closeIcon"
name="clear"
v-if="isEdit"
/>
</div>
</van-grid-item>
<van-grid-item class="uploadGrid" v-if="list.length < maxCount && isEdit">
<van-uploader
:max-count="maxCount"
:max-size="maxSize*1024*1024"
:accept="accept"
class="file-upload__uploader"
:preview-image="false"
upload-icon="plus"
:before-read="beforeRead"
/>
</van-grid-item>
</van-grid>
<div class="tip" v-if="isEdit">只能上傳{{ acceptFile }}文件,不超過{{ maxSize }}M</div>
</div>
</template>
<script setup>
import { useVModel } from "@vueuse/core"
import { ref, defineProps, defineEmits } from "vue"
import inspectionApi from "@/service/apis/modules/inspectionApi.js"
import { useToast } from '@/hooks'
import * as emlFormat from 'eml-format';
import * as Codec from 'emailjs-mime-codec';
import { Dialog } from "vant";
import nativeApi from '@/tools/native.js'
const props = defineProps({
maxCount: {
type: [Number, String],
defaule: 5
},
maxSize: {
type: [Number, String],
defaule: 20
},
filelList: {
type: Array,
default: () => []
},
accept: {
type: String,
default: 'message/rfc822'
},
acceptFile: {
type: String,
default: "eml"
},
isEdit: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:filelList'])
const list = useVModel(props, 'filelList', emit, {
defaultValue: []
})
const { createToast } = useToast()
const beforeRead = (file) => {
if (!props.accept.includes(file.type)) {
createToast.fail({ getContainer: 'body', message: '文件格式錯誤' })
return false
}
let formData = new FormData();
formData.append("file", file);
inspectionApi.uploadVideoAPI(formData).then(res => {
list.value.push(res);
})
return true
}
function removeGarbledChars(html) {
// 刪除最後一個div閉合標籤後的多餘字符
let content = html;
let lastDivIndex = html.lastIndexOf('</div>');
if (lastDivIndex !== -1) {
content = content.substring(0, lastDivIndex + 6);
}
return content
}
// 拼接郵件內容的發件人/收件人/抄送/附件等信息
function concatHeader (file, title) {
const {to, from, cc, attachments, html} = file;
let header = `<div><b>主題:</b>${title}</div>`;
header += `<div><b>發件人:</b>${from.name} <${from.email}></div>`;
let toList = to.map(item => `${item.name} <${item.email}>`).join('; ');
header += `<div><b>收件人:</b>${toList}</div>`;
let ccList = cc.map(item => `${item.name} <${item.email}>`).join('; ');
header += `<div><b>抄送:</b>${ccList}</div>`;
header += '<div><b>郵件內容:</b></div>';
header += removeGarbledChars(html);
return header;
}
const preview = async (item) => {
// 郵件預覽功能暫時取消,直接下載文件,附件回顯問題無法解決
if (item.split(".").at(-1).toLowerCase() === 'eml') {
fetch(encodeURI(item)).then(res => res.blob()).then((data) => {
const blob = new Blob([data]);
const reader = new FileReader();
reader.onload = async (e) => {
let emlContent = e.target.result;
emlContent = Codec.quotedPrintableDecode(emlContent);
emlFormat.read(emlContent, (err, data) => {
let title = ''
if (data.subject) {
title = Codec.mimeWorsdDecode(data.subject);
}
Dialog({
message: concatHeader(data, title),
messageAlign: 'left',
className: "eml-dialog",
showCancelButton: true,
confirmButtonText: "下載"
})
})
}
reader.readAsText(blob);
})
}
}
// 文件對象中的type和後綴名不一定一致,所以需要判斷,寫一個函數根據文件的type返回文件後綴名
</script>
<style lang="less">
.eml-dialog {
.van-dialog__message {
display: flex;
flex-direction: column;
}
.van-dialog__message > div {
width: fit-content;
}
}
</style>
<style lang="less" scoped>
.tip {
color: #999999;
margin-bottom: 12px;
padding-left: 16px !important;
}
.closeIcon {
position: absolute;
right: 0px;
top: 10px;
font-size: 20px;
}
.file-upload__uploader {
width: 100%;
::v-deep {
.van-uploader__upload {
margin: 0;
}
.van-uploader__upload-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
font-weight: bold;
width: 100%;
height: 80px;
background: #fdfdfd;
border-radius: 6px;
border: 1px solid #e5e5e5;
overflow: hidden;
font-size: 12px;
}
}
}
.emlBox {
padding-left: 16px !important;
padding-right: 6px;
.picBox {
width: 100%;
}
.uploadGrid {
padding-right: 0 !important;
}
::v-deep {
.van-grid-item__content {
padding: 10px 0;
justify-content: start;
position: relative;
}
}
}
.file-svg-icon {
width: 100%;
height: 80px;
}
</style>