Files
tougao_web/src/components/page/mailboxCollect.vue

1125 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="mail-container">
<div class="mail-sidebar">
<div class="p-10">
<div class="sidebar-top-actions">
<el-button type="primary" icon="el-icon-plus" class="write-btn" @click="handleWrite">
{{ $t('mailboxCollect.writeBtn') }}
</el-button>
<el-button type="success" icon="el-icon-refresh" class="receive-btn" :loading="syncLoading" @click="handleSyncInbox">
{{ $t('mailboxCollect.receiveBtn') }}
</el-button>
</div>
</div>
<ul class="folder-list">
<li :class="{ active: currentFolder === 'inbox' }" @click="switchFolder('inbox')">
<i class="el-icon-message"></i> {{ $t('mailboxCollect.inboxTab') }}
<span class="badge" v-if="queryIn.num > 0">{{ queryIn.num }}</span>
</li>
<!-- <li :class="{ active: currentFolder === 'sent' }" @click="switchFolder('sent')">
<i class="el-icon-position"></i><span style="font-size: 14px">{{ $t('mailboxCollect.outboxTab') }}</span>
</li> -->
<!-- <li @click="notImplemented"><i class="el-icon-document"></i> <span style="font-size: 14px;">{{ $t('mailboxCollect.draftsTab')}}</span> </li>
<li @click="notImplemented"><i class="el-icon-delete"></i> <span style="font-size: 14px;">{{ $t('mailboxCollect.deletedTab')}}</span> </li>
-->
</ul>
<div class="sidebar-footer">
<div class="user-card">
<el-avatar size="small" icon="el-icon-user-solid" class="user-avatar"></el-avatar>
<div class="user-detail">
<div class="user-name">{{ selectedAccount ? selectedAccount.smtp_from_name || 'Nova Demo' : '-' }}</div>
<div class="user-email" :title="selectedAccountEmail">{{ selectedAccountEmail }}</div>
</div>
</div>
<el-button size="mini" type="text" @click="openAccountDialog" class="switch-btn">
{{ $t('mailboxCollect.changeAccountBtn') }}
</el-button>
</div>
</div>
<div class="mail-list-panel" :style="{ width: listWidth + 'px' }" v-if="selectedAccount">
<!-- <div class="panel-header">
<el-input
v-model="searchKeyword"
prefix-icon="el-icon-search"
:placeholder="$t('mailboxCollect.searchPlaceholder')"
clearable
@change="handleSearch"
></el-input>
<el-button icon="el-icon-refresh" circle :loading="syncLoading" @click="handleSyncInbox" style="margin-left: 10px;"></el-button>
</div> -->
<div
ref="listScrollArea"
class="list-scroll-area"
@scroll="onListScroll"
v-loading="inboxLoading"
:element-loading-text="$t('mailboxCollect.loading')"
>
<transition name="el-zoom-in-top">
<div v-if="newMailsBuffer.length > 0" class="new-mail-alert" @click="newMailsBuffer=[]">
<i class="el-icon-message"></i>
{{ $t('mailboxCollect.newMailArrived', { count: newMailsBuffer.length }) || `收到 ${newMailsBuffer.length} 封新邮件,点击查看` }}
</div>
</transition>
<template v-if="displayList.length > 0">
<div
v-for="(item,i) in displayList"
:key="item.id"
:class="['mail-item-box', { active: activeMailId === item.id, unread: item.state === 1 }]"
@click="selectMail(item,i)"
>
<div class="item-left">
<div v-if="item.is_read === 0" class="unread-dot"></div>
<el-avatar :size="32" icon="el-icon-user-solid" class="sender-avatar"></el-avatar>
</div>
<div class="item-right">
<div class="row-one">
<span class="sender-name">{{ item.from_name || item.email }}</span>
<span class="send-time"><span style="color: #999;margin-right: 4px;" v-if="item.has_attachment==1"><i class="el-icon-paperclip"></i></span>{{ formatDisplayTime(item.email_date) }}</span>
</div>
<div class="row-two">
<span class="mail-subject">{{ item.subject || $t('mailboxCollect.noSubject') }}</span>
</div>
<div class="row-three">
<p class="mail-excerpt">{{ stripHtml(item.content || '') }}</p>
</div>
</div>
</div>
</template>
<div v-if="inboxLoadingMore" class="load-more-tip">
<i class="el-icon-loading"></i> {{ $t('mailboxCollect.loadingMore') || '加载更多...' }}
</div>
<div v-else-if="displayList.length > 0 && inboxPage >= inboxTotalPages" class="load-more-tip no-more">
{{ $t('mailboxCollect.noMore') || '没有更多了' }}
</div>
<div v-else-if="displayList.length === 0" class="empty-list-container">
<div class="empty-wrapper">
<i class="el-icon-message"></i>
<p>{{ $t('mailboxCollect.emptyText') }}</p>
</div>
</div>
</div>
</div>
<div class="list-resizer" @mousedown="initResize" v-if="selectedAccount"></div>
<div class="mail-content-panel" v-if="selectedAccount" v-loading="detailLoading">
<template v-if="activeMailId && !detailLoading">
<div class="mail-page">
<mail-detail v-if="detailMail" :mailData="detailMail" @close="closeDetail" />
</div>
</template>
<div v-else class="empty-state">
<div class="empty-wrapper">
<i class="el-icon-message"></i>
<p>{{ $t('mailboxCollect.selectMailTip') }}</p>
</div>
</div>
</div>
<el-dialog
:title="$t('mailboxCollect.selectAccountTitle')"
:visible.sync="accountDialogVisible"
width="800px"
append-to-body
:close-on-click-modal="false"
:show-close="true"
:before-close="handleAccountDialogBeforeClose"
>
<el-form inline style="margin-bottom: 10px">
<el-form-item :label="$t('mailboxCollect.journal')">
<el-select v-model="accountJournalId" style="width: 260px" :loading="journalLoading" @change="loadAccountsForJournal">
<el-option v-for="item in journalList" :key="item.journal_id" :label="item.title" :value="item.journal_id" />
</el-select>
</el-form-item>
</el-form>
<el-table :data="accountList" v-loading="accountLoading" size="small">
<el-table-column prop="smtp_user" :label="$t('mailboxCollect.accountColumn')" />
<el-table-column prop="smtp_from_name" :label="$t('mailboxCollect.nameColumn')" />
<el-table-column :label="$t('mailboxCollect.operation')" width="140">
<template slot-scope="scope">
<span v-if="isCurrentAccount(scope.row)" class="current-account-text">{{
$t('mailboxCollect.currentAccountText')
}}</span>
<el-button v-else type="primary" size="mini" @click="chooseAccount(scope.row)">{{
$t('mailboxCollect.switchColumn')
}}</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script>
const API = {
getInboxList: 'api/email_client/getInboxList',
inboxSse: 'api/email_client/inboxSse',
getEmailDetail: 'api/email_client/getEmailDetail',
listAttachments: 'api/email_client/listEmailAttachments',
getAttachment: 'api/email_client/getAttachment',
syncInbox: 'api/email_client/syncInbox',
getAccounts: 'api/email_client/getAccounts',
getOneEmail: 'api/email_client/getOneEmail',
getAllJournal: 'api/Article/getJournal'
};
import MailDetail from '../../components/page/components/email/MailDetail.vue';
export default {
data() {
return {
baseUrl: this.Common.baseUrl,
currentFolder: 'inbox',
searchKeyword: '',
syncLoading: false,
activeMailId: null,
tableData_in: [],
tableData_out: [],
detailMail: {},
queryIn: { num: 0 },
inboxPage: 1,
inboxPerPage:100,
inboxTotalPages: 1,
inboxTotal: 0,
inboxLoadingMore: false,
inboxLoading: false,
listWidth: 350,
minWidth: 260,
maxWidth: 600,
selectedAccount: null,
accountDialogVisible: false,
journalList: [],
journalLoading: false,
accountJournalId: null,
accountList: [],
accountLoading: false,
detailLoading: false,
// SSE (POST 长连接)
sseAbortController: null,
sseReconnectTimer: null,
sseReader: null,
sseBuffer: '',
sseManuallyStopped: false,
inboxRefreshTimer: null,
sseSyncing: false,
newMailsBuffer: [], // SSE 发现的新邮件暂存区
};
},
components: { MailDetail },
computed: {
displayList() {
return this.currentFolder === 'inbox' ? this.tableData_in : this.tableData_out;
},
selectedAccountEmail() {
return this.selectedAccount ? this.selectedAccount.smtp_user : '';
}
},
created() {
this.loadJournals();
this.initAccountSelection();
},
beforeDestroy() {
this.stopInboxSse(true);
if (this.inboxRefreshTimer) {
clearTimeout(this.inboxRefreshTimer);
this.inboxRefreshTimer = null;
}
},
methods: {
insertMailsFromBuffer() {
// 1. 合并数据
this.tableData_in = [...this.newMailsBuffer, ...this.tableData_in];
// 3. 滚动到顶部
this.$nextTick(() => {
this.$refs.listScrollArea.scrollTop = 0;
});
},
closeDetail() {
this.activeMailId = null;
this.detailMail = {};
},
// 拖拽逻辑
initResize(e) {
const startX = e.clientX;
const startWidth = this.listWidth;
const doDrag = (moveEvent) => {
const newWidth = startWidth + (moveEvent.clientX - startX);
if (newWidth >= this.minWidth && newWidth <= this.maxWidth) {
this.listWidth = newWidth;
}
};
const stopDrag = () => {
document.removeEventListener('mousemove', doDrag);
document.removeEventListener('mouseup', stopDrag);
document.body.style.cursor = 'default';
};
document.addEventListener('mousemove', doDrag);
document.addEventListener('mouseup', stopDrag);
document.body.style.cursor = 'col-resize';
},
initAccountSelection() {
const q = this.$route.query;
const storedEmailId = localStorage.getItem('mailboxCollect_j_email_id');
const storedJournalId = localStorage.getItem('mailboxCollect_journal_id');
// 1. 兼容老链接:如果地址栏里带有参数,优先用并写入本地
if (q.j_email_id) {
this.loadAccountById(q.j_email_id);
localStorage.setItem('mailboxCollect_j_email_id', q.j_email_id);
if (q.journal_id) {
localStorage.setItem('mailboxCollect_journal_id', q.journal_id);
}
return;
}
// 2. 否则尝试从本地缓存恢复上次选择
if (storedEmailId) {
this.loadAccountById(storedEmailId);
return;
}
// 3. 都没有则弹出选择账号弹窗
this.openAccountDialog();
},
clearStoredAccount() {
localStorage.removeItem('mailboxCollect_j_email_id');
localStorage.removeItem('mailboxCollect_journal_id');
},
loadAccountById(jEmailId) {
this.$api
.post(API.getOneEmail, { j_email_id: jEmailId })
.then((res) => {
const email = res && res.data ? res.data.email : null;
if (res && res.code === 0 && email) {
this.selectedAccount = email;
this.fetchData();
this.startInboxSse();
return;
}
// 账号不存在或不属于当前用户:清除本地缓存并弹窗选择
this.clearStoredAccount();
this.selectedAccount = null;
this.$message.warning(this.$t('mailboxCollect.accountNotBelong'));
this.openAccountDialog();
})
.catch(() => {
this.clearStoredAccount();
this.selectedAccount = null;
this.$message.warning(this.$t('mailboxCollect.accountNotBelong'));
this.openAccountDialog();
});
},
openAccountDialog() {
this.accountDialogVisible = true;
// 期刊列表进入页面时已加载;仅在首次为空时补拉,避免每次切换账号都请求
if (!this.journalList || this.journalList.length === 0) {
this.loadJournals();
}
if (this.selectedAccount && this.selectedAccount.journal_id) {
this.accountJournalId = this.selectedAccount.journal_id;
this.loadAccountsForJournal(this.selectedAccount.journal_id);
}
},
loadJournals() {
this.journalLoading = true;
this.$api
.post(API.getAllJournal, { username: localStorage.getItem('U_name') })
.then((res) => {
this.journalList = (res || []).map((i) => ({
journal_id: i.journal_id || i.id,
title: i.title || i.name || ''
}));
if (!this.accountJournalId && this.journalList.length > 0) {
this.accountJournalId = this.journalList[0].journal_id;
this.loadAccountsForJournal(this.accountJournalId);
}
})
.finally(() => {
this.journalLoading = false;
});
},
loadAccountsForJournal(id) {
this.accountLoading = true;
this.$api.post(API.getAccounts, { journal_id: id }).then((res) => {
this.accountLoading = false;
this.accountList = res.data || [];
});
},
isCurrentAccount(row) {
if (!row || !this.selectedAccount) return false;
return String(row.j_email_id || '') === String(this.selectedAccount.j_email_id || '');
},
chooseAccount(row) {
this.selectedAccount = row;
this.accountDialogVisible = false;
this.closeDetail();
// 将所选邮箱配置持久化到本地,避免暴露在地址栏
if (row && row.j_email_id) {
localStorage.setItem('mailboxCollect_j_email_id', String(row.j_email_id));
}
if (row && row.journal_id) {
localStorage.setItem('mailboxCollect_journal_id', String(row.journal_id));
}
// 不再写入路由 query直接拉取数据
this.fetchData();
this.startInboxSse();
},
handleAccountDialogBeforeClose(done) {
const hasAccount = this.selectedAccount || this.$route.query.j_email_id;
if (hasAccount) {
done();
} else {
this.$message.error(this.$t('mailboxCollect.selectAccountTip'));
}
// 没选账号时不允许关闭
},
// 修改此方法:收到 SSE 后,只拉取最新的一封或几封,不刷新整页
syncInboxBySse(payload) {
if (!payload || this.sseSyncing) return;
const jEmailId = String(payload.j_email_id || '');
const journalId = String(payload.journal_id || '');
this.sseSyncing = true;
this.$api.post(API.syncInbox, { j_email_id: jEmailId, journal_id: journalId })
.then(() => {
// 同步成功后,拉取最新的一封邮件数据(假设后端支持或通过 getInboxList 第一页获取)
this.fetchLatestSingleMail(jEmailId, journalId);
})
.finally(() => { this.sseSyncing = false; });
},
fetchLatestSingleMail(jEmailId, journalId) {
// 获取第一页的最新数据
this.$api.post(API.getInboxList, { j_email_id: jEmailId, journal_id: journalId, page: 1, per_page: 100 })
.then(res => {
const list = res.data.list || res.data || [];
this.queryIn.num = res.data.total || 0;
// 找出 tableData_in 中不存在的新邮件
list.forEach(item => {
const isNew = !this.tableData_in.some(old => (old.id === (item.inbox_id || item.id)));
const isNotBuffered = !this.newMailsBuffer.some(b => (b.id === (item.inbox_id || item.id)));
if (isNew && isNotBuffered) {
// 格式化数据结构(保持与 fetchData 映射一致)
const formatted = {
id: item.inbox_id || item.id,
inbox_id: item.inbox_id || item.id,
email: item.from_email,
from_name: item.from_name,
subject: item.subject,
email_date: item.email_date,
content: item.content_html || item.content_text || '',
is_read: item.is_read
};
this.newMailsBuffer.unshift(formatted);
}
});
this.insertMailsFromBuffer();
});
},
// 拉取收件列表支持分页page=1 时替换列表page>1 时追加;接口返回 total、page、per_page、total_pages
fetchData(page) {
if (!this.selectedAccount) return;
const isFirstPage = page === 1 || page == null;
if (isFirstPage) {
this.inboxPage = 1;
// 首次拉取:显示加载中
this.inboxLoading = true;
// 避免复用旧列表造成“先空后满”的闪烁
this.tableData_in = [];
}
const params = {
j_email_id: this.selectedAccount.j_email_id,
journal_id: this.selectedAccount.journal_id,
// keyword: this.searchKeyword || '',
page: isFirstPage ? 1 : page,
per_page: this.inboxPerPage
};
if (isFirstPage) {
// 第一页不显示 loadingMore仅翻页时显示
} else {
this.inboxLoadingMore = true;
}
this.$api
.post(API.getInboxList, params)
.then((res) => {
const data = res && res.data ? res.data : {};
const list = Array.isArray(data.list) ? data.list : Array.isArray(data) ? data : [];
const rows = list.map((item) => ({
id: item.inbox_id || item.id,
inbox_id: item.inbox_id || item.id,
email: item.from_email,
has_attachment: item.has_attachment,
from_name: item.from_name,
subject: item.subject,
email_date: item.email_date,
content: item.content_html || item.content_text || '',
is_read: item.is_read
}));
if (isFirstPage) {
this.tableData_in = rows;
} else {
// 过滤掉已经在列表中存在的 id (可能来自 SSE 之前手动插入的)
const filteredRows = rows.filter(row =>
!this.tableData_in.some(existing => existing.id === row.id)
);
this.tableData_in = this.tableData_in.concat(filteredRows);
}
this.inboxPage = data.page != null ? Number(data.page) : isFirstPage ? 1 : this.inboxPage;
this.inboxTotalPages = data.total_pages != null ? Number(data.total_pages) : 1;
this.inboxTotal = data.total != null ? Number(data.total) : this.tableData_in.length;
this.queryIn.num = this.inboxTotal;
this.inboxLoadingMore = false;
if (isFirstPage) this.inboxLoading = false;
})
.catch(() => {
this.inboxLoadingMore = false;
if (isFirstPage) this.inboxLoading = false;
});
},
switchFolder(f) {
this.currentFolder = f;
this.activeMailId = null;
},
selectMail(item,index) {
this.activeMailId = item.id;
this.detailLoading = true;
const inboxId = item.inbox_id || item.id;
this.$api
.post(API.getEmailDetail, { inbox_id: inboxId })
.then((res) => {
const d = res && res.data;
if (res && res.code === 0 && d) {
this.detailMail = { ...d, inbox_id: String(inboxId), attachments: [] };
item.state = 0;
this.displayList[index].is_read = d.is_read;
if (Number(d.has_attachment) === 1) {
this.fetchAttachments(String(inboxId));
} else {
this.detailLoading = false;
}
} else {
this.detailLoading = false;
}
})
.catch(() => {
this.detailLoading = false;
});
},
fetchAttachments(inboxId) {
this.$api
.post(API.listAttachments, { inbox_id: inboxId })
.then((res) => {
const list = (res && res.data) || (res && res.list) || [];
const arr = Array.isArray(list) ? list : [];
const attachments = arr.map((a, idx) => ({
...a,
part_index: a.part_index != null ? a.part_index : idx,
name: a.filename || a.name || `attachment_${idx}`,
size: a.size || '',
file_url: ''
}));
this.$set(this.detailMail, 'attachments', attachments);
this.batchFetchDownloadUrls(inboxId, attachments);
})
.catch(() => {})
.finally(() => {
this.detailLoading = false;
});
},
batchFetchDownloadUrls(inboxId, attachments) {
attachments.forEach((att) => {
this.$api
.post(API.getAttachment, {
inbox_id: String(inboxId),
part_index: String(att.part_index)
})
.then((res) => {
if (res && res.data && res.data.file_url) {
this.$set(att, 'file_url', res.data.file_url);
}
})
.catch(() => {});
});
},
// 同步收件箱api/email_client/syncInbox 参数 j_email_id、journal_id均为 String完成后重新拉取 displayList
handleSyncInbox() {
if (!this.selectedAccount) return;
this.syncLoading = true;
const params = {
j_email_id: String(this.selectedAccount.j_email_id),
journal_id: String(this.selectedAccount.journal_id)
};
this.$api
.post(API.syncInbox, params)
.then((res) => {
this.syncLoading = false;
if (res && res.code === 0) {
this.newMailsBuffer=[]
this.$message.success(this.$t('mailboxCollect.syncSuccess'));
this.fetchData();
} else {
this.$message.error((res && res.msg) || this.$t('mailboxCollect.syncFail'));
}
})
.catch(() => {
this.syncLoading = false;
this.$message.error(this.$t('mailboxCollect.syncFail'));
});
},
/**
* 格式化邮件显示时间
* @param {Number|String} timestamp 后端返回的时间戳 (如: 1773110269)
* @returns {String} 格式化后的字符串 (如: 15:57, 昨天, 3月8日, 2025-12-05)
*/
formatDisplayTime(timestamp) {
if (!timestamp) return '';
// 1. 处理 10 位秒级时间戳,转为毫秒并实例化 Date 对象
const date = new Date(Number(timestamp) * 1000);
const now = new Date();
// 提取日期信息用于比较
const dateYear = date.getFullYear();
const nowYear = now.getFullYear();
const lang = localStorage.getItem('langs') === 'zh' ? 'zh-CN' : 'en-US';
// 获取昨天日期
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
// 获取具体时分并补零 (例如 09:05)
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timePart = `${hours}:${minutes}`;
// --- 按照图示规则判断返回 ---
// 1. 如果是今天:只显示时间,如 "15:57"
if (date.toDateString() === now.toDateString()) {
return timePart;
}
// 2. 如果是昨天:显示 "昨天 15:57"
if (date.toDateString() === yesterday.toDateString()) {
return this.$t('mailboxCollect.yesterday') + ' ' + timePart;
}
// 3. 如果是今年(非今天/昨天):按语言显示月日
if (dateYear === nowYear) {
return new Intl.DateTimeFormat(lang, { month: 'short', day: 'numeric' }).format(date);
}
// 4. 如果是往年:显示完整年月日,如 "2025-12-05"
const dateMonth = date.getMonth() + 1;
const dateDay = date.getDate();
const fullMonth = dateMonth.toString().padStart(2, '0');
const fullDay = dateDay.toString().padStart(2, '0');
return `${dateYear}-${fullMonth}-${fullDay}`;
},
stripHtml(html) {
return new DOMParser().parseFromString(html, 'text/html').body.textContent || '';
},
handleWrite() {
if (!this.selectedAccount) return;
this.$router.push('/mailboxSend');
},
handleSearch() {
this.fetchData(1);
},
onListScroll(e) {
const el = e.target;
if (!el || this.currentFolder !== 'inbox' || this.inboxLoadingMore) return;
if (this.inboxPage >= this.inboxTotalPages) return;
const threshold = 80;
if (el.scrollHeight - el.scrollTop - el.clientHeight <= threshold) {
this.fetchData(this.inboxPage + 1);
}
},
buildSseUrl(jEmailId) {
// 与现有 axios baseURL=/api + 相对路径 规则一致GET + query 传参
const base = `${this.baseUrl}${API.inboxSse}`;
const q = new URLSearchParams({ j_email_id: String(jEmailId) }).toString();
return `${base}?${q}`;
},
stopInboxSse(manual) {
this.sseManuallyStopped = !!manual;
if (this.sseReconnectTimer) {
clearTimeout(this.sseReconnectTimer);
this.sseReconnectTimer = null;
}
if (this.sseReader) {
try {
this.sseReader.cancel();
} catch (e) {}
this.sseReader = null;
}
if (this.sseAbortController) {
try {
this.sseAbortController.abort();
} catch (e) {}
this.sseAbortController = null;
}
this.sseBuffer = '';
},
scheduleInboxRefresh() {
if (!this.selectedAccount || this.currentFolder !== 'inbox') return;
if (this.inboxRefreshTimer) return;
this.inboxRefreshTimer = setTimeout(() => {
this.inboxRefreshTimer = null;
this.fetchData(1);
}, 1200);
},
syncInboxBySse(payload) {
if (!payload || this.sseSyncing) return;
const jEmailId = payload.j_email_id != null ? String(payload.j_email_id) : '';
const journalId = payload.journal_id != null ? String(payload.journal_id) : '';
if (!jEmailId || !journalId) return;
this.sseSyncing = true;
this.$api
.post(API.syncInbox, {
j_email_id: jEmailId,
journal_id: journalId
})
.then(() => {
this.fetchLatestSingleMail(jEmailId, journalId);
// this.scheduleInboxRefresh();
})
.catch(() => {})
.finally(() => {
this.sseSyncing = false;
});
},
handleSsePayload(rawData) {
// 仅 inbox_updated 且 synced=1 时才处理;其它消息不刷新
let payload = rawData;
try {
payload = JSON.parse(rawData);
} catch (e) {}
if (payload && payload.type === 'heartbeat') return;
if (payload && payload.type === 'inbox_updated' && payload.synced>0) {
this.syncInboxBySse(payload);
}
},
handleSseDisconnected() {
// 收到后端的“断开”事件:挂掉当前连接并立即重连
if (this.sseReconnectTimer) return;
// 标记为手动停止:避免当前 startInboxSse 的 catch 再额外 schedule 重连
this.stopInboxSse(true);
this.sseReconnectTimer = setTimeout(() => {
this.sseReconnectTimer = null;
this.sseManuallyStopped = false;
this.startInboxSse();
}, 800);
},
processSseChunk(textChunk) {
// 归一化换行,避免 \r\n 导致按 \n\n 切块失败
const normalized = String(textChunk).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
this.sseBuffer += normalized;
const events = this.sseBuffer.split('\n\n');
this.sseBuffer = events.pop() || '';
events.forEach((evt) => {
const lines = evt.split('\n');
const eventNameLine = lines.find((line) => line.trim().startsWith('event:'));
const eventName = eventNameLine ? eventNameLine.replace(/^\s*event:\s?/, '').trim() : '';
const dataLines = lines
.filter((line) => line.trim().startsWith('data:'))
.map((line) => line.replace(/^\s*data:\s?/, ''));
// 断开事件可能没有 data因此需要单独处理 event
if (eventName === 'disconnected') {
this.handleSseDisconnected();
return;
}
if (!dataLines.length) return;
this.handleSsePayload(dataLines.join('\n'));
});
},
// startInboxSse() {
// // 1. 使用原生 EventSource它会自动处理长连接
// // 2. 注意如果存在跨域URL 必须写全;如果配置了 proxy写代理路径
// const url = "/api/api/email_client/inboxSse?j_email_id=12";
// const es = new EventSource(url);
// es.onopen = () => console.log("✅ SSE 连接成功!");
// es.onmessage = (e) => {
// console.log("📩 收到数据:", e.data);
// };
// es.onerror = (err) => {
// console.error("❌ SSE 连接失败,请检查 Network 面板的 Status Code", err);
// es.close();
// };
// },
async startInboxSse() {
if (!this.selectedAccount || !this.selectedAccount.j_email_id) return;
// 账号切换前先断开旧连接
this.stopInboxSse(false);
this.sseManuallyStopped = false;
const jEmailId = String(this.selectedAccount.j_email_id);
try {
this.sseAbortController = new AbortController();
const response = await fetch(this.buildSseUrl(jEmailId), {
method: 'GET',
signal: this.sseAbortController.signal
});
if (!response.ok || !response.body) {
throw new Error('SSE response invalid');
}
this.sseReader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await this.sseReader.read();
if (done) {
// 流结束:把缓冲区里剩余内容当作最后一个事件处理
if (this.sseBuffer) {
this.processSseChunk('\n\n');
}
break;
}
if (value) this.processSseChunk(decoder.decode(value, { stream: true }));
}
} catch (e) {
// 只有非手动停止时才尝试重连
if (!this.sseManuallyStopped) {
if (this.sseReconnectTimer) clearTimeout(this.sseReconnectTimer);
this.sseReconnectTimer = setTimeout(() => {
this.sseReconnectTimer = null;
this.startInboxSse();
}, 3000);
}
}
},
notImplemented() {
this.$message.info(this.$t('mailboxCollect.featureDev'));
}
}
};
</script>
<style scoped>
.mail-container {
display: flex;
height: calc(100vh - 120px);
background: #fff;
overflow: hidden;
}
/* 1. 左侧栏 */
.mail-sidebar {
width: 240px;
background-color: #f8f9fa;
border-right: 1px solid #eaeaea;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.p-10 {
padding: 10px;
}
.p-20 {
padding: 20px;
}
.sidebar-top-actions {
display: flex;
gap: 10px;
padding: 0 6px;
}
.write-btn {
flex: 1;
width: auto;
border-radius: 8px;
font-weight: bold;
padding-left: 10px;
padding-right: 10px;
}
.receive-btn {
flex: 1;
width: auto;
border-radius: 8px;
font-weight: bold;
margin-left: 0px;
padding-left: 10px;
padding-right: 10px;
}
.folder-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
}
.folder-list li {
padding: 12px 20px;
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
}
.folder-list li i {
margin-right: 12px;
font-size: 18px;
}
.folder-list li.active {
background: #edeef0;
color: #006699;
font-weight: bold;
border-left: 3px solid #006699;
}
.badge {
margin-left: auto;
background: #ddd;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.sidebar-footer {
padding: 10px 0px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
.user-card {
display: flex;
align-items: center;
margin-bottom: 8px;
overflow: hidden;
}
.user-avatar {
flex-shrink: 0;
background: #ffeded;
color: #f56c6c;
}
.user-detail {
margin-left: 10px;
overflow: hidden;
flex: 1;
}
.user-name {
font-size: 14px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.switch-btn {
padding: 0;
font-size: 12px;
color: #409eff;
}
/* 2. 中间列表栏 - 参考 image_8649db.png */
.mail-list-panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
background: #fff;
}
.panel-header {
padding: 15px;
border-bottom: 1px solid #f5f5f5;
display: flex;
}
.list-scroll-area {
flex: 1;
overflow-y: auto;
}
.mail-item-box {
display: flex;
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.mail-item-box:hover {
background: #f5f7fa;
}
.mail-item-box.active {
background: #eef1f6;
border-left: 3px solid #006699;
}
.item-left {
margin-right: 12px;
display: flex;
align-items: flex-start;
padding-top: 4px;
}
.sender-avatar {
background: #c0c4cc;
}
.item-right {
flex: 1;
overflow: hidden;
}
.row-one {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.sender-name {
font-size: 14px;
color: #303133;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.send-time {
font-size: 12px;
color: #909399;
flex-shrink: 0;
}
.row-two {
margin-bottom: 4px;
}
.mail-subject {
font-size: 13px;
color: #6a7282;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mail-excerpt {
font-size: 12px;
color: #606266;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.load-more-tip {
text-align: center;
padding: 12px;
font-size: 12px;
color: #909399;
}
.load-more-tip.no-more {
color: #c0c4cc;
}
.current-account-text {
color: #006699;
font-size: 12px;
}
/* 拖拽条 */
.list-resizer {
width: 4px;
cursor: col-resize;
border-left: 1px solid #eaeaea;
transition: background 0.2s;
}
.list-resizer:hover {
background: #409eff;
}
/* 3. 右侧详情 */
.mail-content-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mail-page {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.detail-toolbar {
padding: 10px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
}
.detail-content {
padding: 30px 40px;
overflow-y: auto;
}
.mail-title {
font-size: 24px;
margin-bottom: 20px;
}
.mail-info {
display: flex;
align-items: center;
}
.info-main {
margin-left: 15px;
}
.mail-body {
line-height: 1.6;
font-size: 15px;
}
.empty-state,
.empty-list-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
text-align: center;
}
.empty-state i,
.empty-list-container i {
font-size: 48px;
margin-bottom: 10px;
}
.new-mail-alert {
background-color: #f0f9eb;
color: #67c23a;
padding: 10px;
text-align: center;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid #e1f3d8;
position: sticky;
top: 0;
z-index: 10;
font-weight: bold;
}
.new-mail-alert:hover {
background-color: #e1f3d8;
}
.item-left {
position: relative; /* 关键:让红点相对于此容器定位 */
display: flex;
align-items: center;
margin-right: 12px;
}
.unread-dot {
position: absolute;
top: 16px; /* 调整红点上下位置 */
left: -13px; /* 调整红点左右位置 */
width: 8px; /* 红点大小 */
height: 8px;
background-color: #F56C6C; /* 经典的警告红 */
border-radius: 50%;
border: 1.5px solid #fff; /* 加个白边,在深色背景或头像上更清晰 */
z-index: 10;
}
</style>