1125 lines
40 KiB
Vue
1125 lines
40 KiB
Vue
<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>
|