This commit is contained in:
oneao committed 2025-06-10 21:27:20 +08:00
commit c570ec514c
550 files changed
+152597

No files matched your search

@@ -0,0 +1,139 @@
<template>
<div class="auth-container">
<div class="auth-main">
<div class="auth-img">
<SvgIcon local-icon="email" />
</div>
<div class="auth-form">
<div class="title">Online-Email</div>
<a-form :model="form" layout="vertical" auto-label-width @submit="handleSubmit">
<a-form-item
field="email"
label="Email邮箱号"
tooltip="系统默认使用 IMAP 和 SMTP 协议,请确保邮箱已开启对应服务。"
:rules="[
{ required: true, message: '邮箱号不能为空!' },
{ type: 'email', message: '请输入正确的邮箱格式!' }
]">
<a-input v-model="form.email" placeholder="请输入邮箱号" />
</a-form-item>
<a-form-item field="password" label="密码" tooltip="请填写邮箱的授权码,而非登录密码。" :rules="[{ required: true, message: '密码不能为空!' }]">
<a-input-password v-model="form.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item>
<a-button html-type="submit" long type="primary" :loading="loading">登录</a-button>
</a-form-item>
</a-form>
</div>
</div>
<TipModal v-model:visible="visible" :title="streamData.folderContent" :content="streamData.emailContent" :percent="streamData.progress" />
</div>
</template>
<script setup lang="ts">
import TipModal from "./modules/tip-modal.vue";
import { ref } from "vue";
import { useUserStore } from "@/store/modules/user";
import { useRouterStore } from "@/store/modules/router";
import { LoginParams, loginApi } from "@/service/auth";
import { fetchGet } from "@/utils/fetch";
import { Message } from "@arco-design/web-vue";
import type { EmailPullData } from "@/types/email";
const userStore = useUserStore();
const routerStore = useRouterStore();
const visible = ref(false);
const form = ref<LoginParams>({
email: userStore.userInfo.email || "",
password: ""
});
const streamData = ref<EmailPullData>({
folderContent: "正在初始化数据,请稍等 . . .",
emailContent: "正在处理邮件 . . .",
progress: 0
});
const loading = ref<boolean>(false);
const handleSubmit = ({ errors }: { errors: any }) => {
if (errors) {
return;
}
loading.value = true;
loginApi(form.value)
.then((res) => {
Message.success(res.message);
userStore.setUserInfo(res.data);
if (res.data.isInit) {
routerStore.navigateTo("/");
} else {
visible.value = true;
fetchStreamData();
}
})
.finally(() => {
loading.value = false;
});
};
async function fetchStreamData() {
try {
await fetchGet<EmailPullData>("/email/init", {
params: {}, // 示例参数
stream: true,
onStreamData: (res) => {
if (res.data !== null) {
streamData.value = res.data;
}
}
}).then(() => {
visible.value = false;
Message.success("初始化成功");
routerStore.navigateTo("/");
});
} catch (error) {
console.error("流式数据请求失败", error);
}
}
</script>
<style lang="scss" scoped>
.auth-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e0e7ff, #f2f3f5);
}
.auth-main {
background-color: #ffffff;
border-radius: 8px;
display: flex;
align-items: center;
height: 400px;
width: 700px;
}
.auth-form {
width: 50%;
padding: 32px;
display: flex;
flex-direction: column;
align-items: center;
.title {
font-weight: bold;
font-size: 24px;
margin-bottom: 48px;
}
}
.auth-img {
width: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 240px;
background-color: #f4f9fd;
height: 100%;
}
</style>
@@ -0,0 +1,119 @@
<template>
<div v-if="visible" class="init-overlay">
<div class="init-modal">
<div class="init-title">{{ title }}</div>
<div class="init-message">{{ content }}</div>
<div class="progress-container">
<a-progress :percent="percent" size="large" :status="status" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch } from "vue";
import type { PropType } from "vue";
const props = defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
required: true
},
status: {
type: String as PropType<"normal" | "success" | "warning" | "danger">,
default: "normal"
},
percent: {
type: Number,
required: true
},
visible: {
type: Boolean,
required: true
}
});
watch(
() => props.visible,
(newVal) => {
if (!newVal) {
// 当模态框关闭时,重置百分比或执行其他操作
console.log("模态框关闭");
}
}
);
</script>
<style lang="scss" scoped>
.init-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 99;
animation: fadeIn 0.5s ease-in-out; /* 添加渐现动画 */
}
.init-modal {
background-color: white;
padding: 30px 40px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 600px;
max-width: 90%;
text-align: center;
animation: scaleUp 0.5s ease-in-out; /* 添加缩放动画 */
}
/* 渐现动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 缩放动画 */
@keyframes scaleUp {
from {
transform: scale(0.8);
}
to {
transform: scale(1);
}
}
.init-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 30px;
color: #333;
}
.init-message {
font-size: 16px;
color: #666;
margin-bottom: 14px;
min-height: 24px;
word-break: break-all;
}
.progress-container {
width: 100%;
}
.init-status {
font-size: 14px;
color: #888;
}
</style>
@@ -0,0 +1,25 @@
<template>
<div class="email-container">
<Sidebar class="email-sidebar" />
<Index />
</div>
</template>
<script setup lang="ts">
import Sidebar from "./modules/sidebar/index.vue";
import Index from "./modules/index/index.vue";
</script>
<style lang="scss" scoped>
.email-container {
display: flex;
height: 100%;
}
.email-sidebar {
width: 200px;
height: 100%;
flex-shrink: 0;
border-right: 1px solid #e0e0e0;
box-sizing: border-box; // 关键设置
}
</style>
@@ -0,0 +1,29 @@
<template>
<div class="index-container">
<List class="index-list" />
<Detail class="index-detail" />
</div>
</template>
<script setup lang="ts">
import List from "./modules/list.vue";
import Detail from "./modules/detail.vue";
</script>
<style lang="scss" scoped>
.index-container {
width: 100%;
height: 100%;
display: flex;
}
.index-list {
height: 100%;
width: 550px;
border-right: 1px solid #e5e7eb;
flex-shrink: 0;
}
.index-detail {
height: 100%;
flex: 1;
}
</style>
@@ -0,0 +1,348 @@
<template>
<div v-bind="$attrs" v-if="emailStore.currentEmail.id && emailStore.currentFolder.category !== EmailFolderCategoryEnum.DRAFT" class="detail-container">
<div class="detail-main">
<div class="actions">
<a-button size="mini" type="outline" @click="handleReply">
<template #icon><SvgIcon icon="ri:reply-line" /></template>
回复
</a-button>
<a-button size="mini" type="outline" @click="handleReplyAll">
<template #icon><SvgIcon icon="ri:reply-all-line" /></template>
回复全部
</a-button>
<a-button size="mini" type="outline" @click="handleForward">
<template #icon><SvgIcon icon="ri:share-forward-line" /></template>
转发
</a-button>
<a-button size="mini" type="outline">
<template #icon><SvgIcon icon="ri:more-line" /></template>
更多
</a-button>
</div>
<div class="subject">{{ detailData.subject }}</div>
<div class="meta-item">
<span class="label">发件人:</span>
<span class="value">
<span class="name">{{ detailData.fromAddress.addressName }}</span>
&lt;{{ detailData.fromAddress.address }}&gt;
</span>
</div>
<div class="meta-item">
<span class="label">收件人:</span>
<span class="value">
<span v-for="(to, index) in detailData.toAddresses" :key="index" class="recipient">
<span class="name">
{{ to.address === userStore.userInfo.email ? "我" : to.addressName }}
</span>
&lt;{{ to.address }}&gt;
<span v-if="index < detailData.toAddresses.length - 1">,</span>
</span>
</span>
</div>
<div v-if="detailData.ccAddresses && detailData.ccAddresses.length > 0" class="meta-item">
<span class="label">抄送人:</span>
<span class="value">
<span v-for="(cc, index) in detailData.ccAddresses" :key="index" class="recipient">
<span class="name">
{{ cc.address === userStore.userInfo.email ? "我" : cc.addressName }}
</span>
&lt;{{ cc.address }}&gt;
<span v-if="index < detailData.ccAddresses.length - 1">,</span>
</span>
</span>
</div>
<div v-if="detailData.bccAddresses && detailData.bccAddresses.length > 0" class="meta-item">
<span class="label">
密送人
<a-tooltip content="密送人仅在已发送文件夹显示,对发件人可见。">
<icon-question-circle-fill class="icon" />
</a-tooltip>
:
</span>
<span class="value">
<span v-for="(bcc, index) in detailData.bccAddresses" :key="index" class="recipient">
<span class="name">
{{ bcc.address === userStore.userInfo.email ? "我" : bcc.addressName }}
</span>
&lt;{{ bcc.address }}&gt;
<span v-if="index < detailData.bccAddresses.length - 1">,</span>
</span>
</span>
</div>
<div class="meta-item">
<span class="label">时&nbsp;&nbsp;&nbsp;&nbsp;间:</span>
<span class="value">{{ detailData.createTime }}</span>
</div>
</div>
<!-- <iframe ref="iframeRef" :src="iframeSrc" @load="handleLoadFinishIframe" scrolling="no" /> -->
<EmailShadowRenderer :html="detailData.content" class="email-body" />
<div v-if="attachments.length > 0" class="file-container">
<div class="file-title">附件</div>
<div class="file-list">
<div class="file-item" v-for="file in attachments" :key="file.id" @click="handleDownloadFile(file)">
<div class="file-icon">
<FileIcon :file-name="file.fileName" />
</div>
<div class="file-name">{{ file.fileName }}</div>
<div class="file-size">{{ formatFileSizeToString(file.fileSize) }}</div>
</div>
</div>
</div>
</div>
<div v-bind="$attrs" v-else class="empty-container">
<SvgIcon local-icon="email-empty" class="empty-img" />
<div class="empty-content">未选中邮件</div>
</div>
</template>
<script setup lang="ts">
import { IconQuestionCircleFill } from "@arco-design/web-vue/es/icon";
import EmailShadowRenderer from "@/components/email-shadow-renderer.vue";
import { nextTick, watch, ref, computed } from "vue";
import { useEmailStore } from "@/store/modules/email";
import { useUserStore } from "@/store/modules/user";
import { useRouterStore } from "@/store/modules/router";
import { getEmailDetailApi } from "@/service/email";
import type { EmailDetail, EmailFile } from "@/types/email";
import { EmailFolderCategoryEnum, EmailFileTypeEnum } from "@/enums";
import { formatFileSizeToString } from "@/utils/common";
const routerStore = useRouterStore();
const userStore = useUserStore();
const emailStore = useEmailStore();
const detailData = ref<EmailDetail>({
id: "",
content: "",
contentType: "",
subject: "",
createTime: "",
fromAddress: { id: "", address: "", addressName: "" },
toAddresses: [],
ccAddresses: [],
bccAddresses: [],
replyAddresses: [],
senderAddresses: [],
attachments: []
});
watch(
() => emailStore.currentEmail.id,
() => {
nextTick(async () => {
if (emailStore.currentEmail.id) {
await initData();
// 这里滚动
const detailContainer = document.querySelector(".detail-container");
if (detailContainer) {
// detailContainer.scrollTo({ top: 0, behavior: "smooth" });
detailContainer.scrollTo({ top: 0 });
}
} else {
emailStore.resetCurrentEmail();
}
});
}
);
// 附件
const attachments = computed(() => detailData.value.attachments.filter((item) => item.fileType === EmailFileTypeEnum.ATTACHMENT) || []);
const initData = async () => {
const res = await getEmailDetailApi(emailStore.currentEmail.id);
detailData.value = res.data;
// 处理内联图片
const inlineAttachments = detailData.value.attachments.filter((item) => item.fileType === EmailFileTypeEnum.INLINE) || [];
const cidMap = new Map<string, string>();
for (const att of inlineAttachments) {
const rawCid = att.emailCid.replace(/^<|>$/g, "");
cidMap.set(rawCid, att.fileUrl);
}
let content = detailData.value.content;
// 替换 cid
content = content.replace(/cid:([^"' >]+)/g, (_, cid) => {
const mapped = cidMap.get(cid);
return mapped || `cid:${cid}`;
});
detailData.value.content = content;
emailStore.currentEmail = detailData.value;
};
const handleReply = () => {
routerStore.navigateTo({
path: "/write",
query: {
isText: 0,
isReply: 1
}
});
};
const handleReplyAll = () => {
routerStore.navigateTo({
path: "/write",
query: {
isText: 0,
isReplyAll: 1
}
});
};
const handleForward = () => {
routerStore.navigateTo({
path: "/write",
query: {
isText: 0,
isForward: 1
}
});
};
const handleDownloadFile = (data: EmailFile) => {
const link = document.createElement("a");
link.href = data.fileUrl;
link.download = data.fileName || "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
</script>
<style lang="scss" scoped>
.detail-container {
flex: 1;
height: 100%;
padding: 16px 8px;
flex-direction: column;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
}
.detail-main {
display: flex;
flex-direction: column;
font-size: 14px;
color: #6e6e73;
border-bottom: 1px solid #d3d3d3;
.actions {
display: flex;
margin-bottom: 12px;
gap: 8px;
}
.subject {
font-weight: bold;
font-size: 16px;
color: #000;
margin-bottom: 8px;
}
.meta-item {
display: flex;
align-items: flex-start;
line-height: 1.6;
margin-bottom: 6px;
.label {
flex-shrink: 0;
color: #6e6e73;
.icon {
font-size: 13px;
margin-bottom: 4px;
cursor: pointer;
}
}
.value {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
line-height: 1.6;
color: #6e6e73;
.name {
color: #000;
}
.recipient {
margin-right: 4px;
}
}
}
}
.email-body {
margin: 16px 0px;
}
// iframe {
// width: 100%;
// border: none;
// display: block;
// overflow: hidden;
// }
.file-list {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 1rem;
.file-item {
padding: 8px 16px;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
.file-icon {
font-size: 36px;
}
.file-name {
margin-top: 6px;
font-size: 16px;
}
.file-size {
margin-top: 6px;
color: #909399;
}
}
.file-item:hover {
background-color: #e9eff9;
}
}
.file-title {
font-size: 20px;
font-weight: bold;
border-bottom: 1px solid #dcdfe6;
padding: 8px 0px;
}
.empty-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 16px;
.empty-img {
width: 300px;
height: 300px;
}
.empty-content {
font-size: 22px;
color: #666;
}
}
</style>
@@ -0,0 +1,324 @@
<template>
<div v-if="loading">
<a-skeleton :animation="true" class="skeleton">
<a-skeleton-line :rows="1" v-for="i in 10" :key="i" />
</a-skeleton>
</div>
<div v-else-if="total > 0" class="summary-container">
<div class="summary-header">
<a-checkbox :model-value="isAllSelected" :indeterminate="isIndeterminate" @change="toggleSelectAll" />
<!-- <a-input size="small" style="width: 200px"></a-input> -->
<!-- <span>{{ emailStore.currentFolder.label }}</span> -->
</div>
<div class="summary-list">
<div class="summary-item" :class="{ 'is-checked': emailStore.currentEmail.id === item.id }" v-for="item in emailList" :key="item.id" @click="handleClickItem(item)">
<div class="summary-top">
<a-checkbox class="summary-checkbox" :model-value="emailStore.selectedIds.has(item.id)" @click.stop @change="toggleSelectItem(item.id)" />
<div class="summary-name" v-if="item.addresses.length > 0">
<span v-for="(address, index) in item.addresses" :key="address.id">
{{ address.addressName }}
<span v-if="index !== item.addresses.length - 1">,</span>
</span>
</div>
<div class="summary-name" v-else>(收件人未填写)</div>
<div class="summary-date">{{ showFormatDate(item) }}</div>
</div>
<div class="summary-bottom">
<div class="summary-icon" :class="[item.isSeen === 0 ? 'text-[#ffb300]' : '']">
<SvgIcon :icon="item.isSeen === 0 ? 'tabler:mail-filled' : ''" />
</div>
<div class="summary-subject">{{ item.subject || "(无主题)" }}</div>
</div>
</div>
</div>
<div class="pagination">
<a-pagination v-model:current="queryParams.page" :total="total" :page-size="queryParams.pageSize" @change="handlePageChange" @page-size-change="handlePageSizeChange" show-page-size simple />
</div>
</div>
<div v-else class="empty-container">
<SvgIcon local-icon="email-empty" class="empty-img" />
<div class="empty-content">暂无邮件待处理</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onActivated, computed } from "vue";
import { useEmailListManager } from "@/hooks/useEmailListManager";
import { useEmailStore } from "@/store/modules/email";
import { useRouterStore } from "@/store/modules/router";
import type { EmailSummary } from "@/types/email";
import { updateEmailSeenApi } from "@/service/email";
import { EmailFolderCategoryEnum } from "@/enums";
const loading = ref<boolean>(true);
const { queryParams, resetData, emailList, total, fetchData } = useEmailListManager();
const routerStore = useRouterStore();
const emailStore = useEmailStore();
const isAllSelected = computed(() => {
return emailList.value.length > 0 && emailStore.selectedIds.size === emailList.value.length;
});
const isIndeterminate = computed(() => {
return emailStore.selectedIds.size > 0 && !isAllSelected.value;
});
const toggleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = emailList.value.map((item) => item.id);
emailStore.selectedIds = new Set(allIds);
} else {
emailStore.selectedIds.clear();
}
};
const toggleSelectItem = (id: string) => {
if (emailStore.selectedIds.has(id)) {
emailStore.selectedIds.delete(id);
} else {
emailStore.selectedIds.add(id);
}
emailStore.selectedIds = new Set([...emailStore.selectedIds]);
};
onActivated(() => {
if (queryParams.folderId === "") {
return;
}
initData();
});
function initData() {
loading.value = true;
emailStore.selectedIds.clear();
resetData();
fetchData();
loading.value = false;
}
watch(
() => emailStore.currentFolder.id,
(newVal) => {
if (newVal) {
queryParams.folderId = newVal;
initData();
}
}
);
const handlePageChange = (current: number) => {
emailStore.selectedIds.clear();
queryParams.page = current;
fetchData();
};
const handlePageSizeChange = (pageSize: number) => {
emailStore.selectedIds.clear();
queryParams.page = 1;
queryParams.pageSize = pageSize;
fetchData();
};
const handleClickItem = (data: EmailSummary) => {
if (emailStore.currentFolder.category === EmailFolderCategoryEnum.DRAFT) {
routerStore.navigateTo({
path: "/write",
query: {
emailId: data.id
}
});
emailStore.resetCurrentEmail();
} else {
if (data.isSeen === 0) {
data.isSeen = 1;
updateEmailSeenApi({
folderId: emailStore.currentFolder.id,
emailIds: [data.id],
isSeen: 1
});
}
emailStore.currentEmail.id = data.id;
}
};
function showFormatDate(data: EmailSummary) {
if (emailStore.currentFolder.category === EmailFolderCategoryEnum.DRAFT) {
if (data.createTime === data.updateTime) {
return formatDate(data.sentTime);
} else {
return formatDate(data.updateTime);
}
}
return formatDate(data.sentTime);
}
function formatDate(inputDate: string): string {
const now = new Date();
const date = new Date(inputDate);
// 判断是否是有效日期
if (isNaN(date.getTime())) {
throw new Error("Invalid date format");
}
const diffInSeconds = (now.getTime() - date.getTime()) / 1000; // 计算时间差(秒)
const oneMinute = 60;
// 刚刚(1分钟以内)
if (diffInSeconds < oneMinute) {
return "刚刚";
}
// 几分钟前(5分钟以内)
if (diffInSeconds < 5 * oneMinute) {
return `${Math.floor(diffInSeconds / oneMinute)}分钟前`;
}
// 今天
const isToday = now.toDateString() === date.toDateString();
if (isToday) {
return `${date.getHours()}:${date.getMinutes().toString().padStart(2, "0")}`;
}
// 昨天
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1); // 设置为昨天
const isYesterday = yesterday.toDateString() === date.toDateString();
if (isYesterday) {
return `昨天 ${date.getHours()}:${date.getMinutes().toString().padStart(2, "0")}`;
}
// 本年
const isThisYear = now.getFullYear() === date.getFullYear();
if (isThisYear) {
return `${date.getMonth() + 1}月${date.getDate()}日`;
}
// 非本年
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
}
</script>
<style lang="scss" scoped>
.summary-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.summary-header {
display: flex;
gap: 8px;
align-items: center;
padding: 12px 16px;
background-color: #f9f9f9;
border-bottom: 1px solid #ddd;
font-weight: bold;
font-size: 16px;
:deep(.arco-checkbox) {
padding-left: 0px;
}
}
.summary-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
.summary-item {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 12px;
cursor: pointer;
height: 68px;
:deep(.arco-checkbox) {
padding-left: 0px;
}
}
.summary-item:hover {
background-color: #f5f6f7;
}
.is-checked {
background-color: #dbebfe;
}
.is-checked:hover {
background-color: #dbebfe;
}
.summary-top {
display: flex;
align-items: center;
}
.summary-checkbox {
width: 28px;
}
.summary-name {
font-weight: bold;
flex: 1; /* 自动占据剩余空间 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-date {
flex-shrink: 0; /* 防止被压缩 */
font-size: 13px;
}
.summary-bottom {
display: flex;
line-height: 14px;
}
.summary-subject {
font-size: 13px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-icon {
width: 28px; /* 固定宽度 */
font-size: 14px;
display: inline-block;
}
}
.pagination {
flex-shrink: 0; // ⭐ 防止 pagination 被压缩
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
border-top: 1px solid #e5e7eb;
}
.empty-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 16px;
.empty-img {
width: 300px;
height: 300px;
}
.empty-content {
font-size: 22px;
color: #666;
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 4px;
:deep(.arco-skeleton-line-row) {
height: 68px !important;
}
}
</style>
@@ -0,0 +1,136 @@
<template>
<div v-if="loading">
<a-skeleton :animation="true" class="skeleton">
<a-skeleton-line :rows="1" v-for="i in 6" :key="i" />
</a-skeleton>
</div>
<a-menu v-else class="menu" :selected-keys="selectdKeys" :open-keys="openkeys" @sub-menu-click="handleClickSubMenuItem" @menu-item-click="handleClickMenuItem">
<template v-for="item in emailStore.folderList" :key="item.name">
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.id">
<template #icon><SvgIcon :icon="item.icon" /></template>
<template #title>
{{ item.label }}
</template>
<a-menu-item v-for="child in item.children" :key="child.id">
<template #icon><SvgIcon :icon="child.icon" /></template>
{{ child.name }}
</a-menu-item>
</a-sub-menu>
<template v-else>
<a-menu-item :key="item.id">
<template #icon>
<SvgIcon :icon="item.icon" />
</template>
{{ item.label }}
</a-menu-item>
</template>
</template>
</a-menu>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useEmailStore } from "@/store/modules/email";
import { getEmailFolderApi } from "@/service/email";
import type { EmailFolder } from "@/types/email";
const emailStore = useEmailStore();
const loading = ref<boolean>(true);
const selectdKeys = ref<string[]>([]);
const openkeys = ref<string[]>([]);
onMounted(async () => {
loading.value = true;
getEmailFolderApi().then((res) => {
emailStore.setFolderList(res.data);
if (res.data.length > 0) {
emailStore.currentFolder = res.data[0];
}
loading.value = false;
});
});
watch(
() => emailStore.currentFolder.id,
(newVal) => {
selectdKeys.value = [];
selectdKeys.value.push(newVal);
},
{
immediate: true
}
);
const findFolderById = (folders: EmailFolder[], id: string): EmailFolder | undefined => {
for (const folder of folders) {
if (folder.id === id) return folder;
if (folder.children?.length) {
const found = findFolderById(folder.children, id);
if (found) return found;
}
}
return undefined;
};
const handleClickMenuItem = (key: string) => {
if (key) {
const clickFolder = findFolderById(emailStore.folderList, key);
if (clickFolder) {
emailStore.currentFolder = clickFolder;
emailStore.resetCurrentEmail();
console.log(emailStore.currentEmail);
} else {
console.warn("Folder not found for key:", key);
}
}
};
const handleClickSubMenuItem = (key: string) => {
const index = openkeys.value.indexOf(key);
if (index === -1) {
openkeys.value.push(key); // 如果不包含就添加
} else {
openkeys.value.splice(index, 1); // 如果包含就移除
}
};
</script>
<style lang="scss" scoped>
.menu {
font-size: 14px !important;
background-color: transparent;
:deep(.arco-menu-inline-header) {
line-height: 32px;
padding: 2px 16px;
.arco-menu-icon {
margin-right: 12px;
font-size: 16px;
}
}
:deep(.arco-menu-inner) {
padding: 0px;
}
:deep(.arco-menu-item) {
line-height: 30px;
padding: 2px 16px;
.arco-menu-icon {
margin-right: 12px;
font-size: 16px;
margin-top: 2px;
}
}
:deep(.arco-menu-selected) {
background-color: #f2f3f5;
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 8px;
:deep(.arco-skeleton-line-row) {
height: 28px !important;
}
}
</style>
@@ -0,0 +1,58 @@
<template>
<div class="account-container">
<!-- <Left class="account-left" /> -->
<Right class="account-right" :data="accountDetail" />
</div>
</template>
<script setup lang="ts">
// import Left from "./modules/left.vue";
import Right from "./modules/right.vue";
import { ref, onMounted } from "vue";
// import { EmailAccount, getEmailAccountDetailApi } from "@/service/userAccount";
import { useOnceMounted } from "@/hooks/useOnceMounted";
const accountDetail = ref<EmailAccount>({
id: "",
email: "",
password: "",
displayName: "",
imapHost: "",
imapPort: "",
imapSsl: "",
smtpHost: "",
smtpPort: "",
smtpSsl: ""
});
onMounted(() => {
console.log("setting-sub-mounted");
});
useOnceMounted(() => {
// getEmailAccountDetailApi().then((res) => {
// accountDetail.value = res.data;
// });
});
</script>
<style lang="scss" scoped>
.account-container {
display: flex;
width: 100%;
height: 100%;
padding: 16px;
background-color: #f9f9f9;
position: relative;
}
.account-left {
position: relative;
height: 100%;
width: 250px;
background-color: #fff;
}
.account-right {
flex: 1;
background-color: #fff;
border-left: 1px solid #f2f6fc;
}
</style>
@@ -0,0 +1,87 @@
<template>
<div class="left-container">
<div class="scope">
<div class="head">
<span>账号列表</span>
<a-button size="mini" shape="round">
<template #icon>
<SvgIcon icon="material-symbols:add-2" class="arco-icon" />
</template>
</a-button>
</div>
<div class="data">
<div class="data-item is-active">
<span>账号a</span>
<SvgIcon icon="material-symbols:chevron-right" class="icon" />
</div>
<div class="data-item">
<span class="data-item-title">账号assaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>
<SvgIcon icon="material-symbols:chevron-right" class="icon" />
</div>
</div>
</div>
<AddEmailAccountModal />
</div>
</template>
<script setup lang="ts">
import AddEmailAccountModal from "@/components/modals/AddEmailAccountModal.vue";
</script>
<style lang="scss" scoped>
.left-container {
.scope {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
box-sizing: border-box;
white-space: nowrap;
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 10px;
border-bottom: 1px solid #f2f6fc;
}
.data {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px;
gap: 4px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
.data-item {
cursor: pointer;
background-color: #fafafa;
padding: 12px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
.data-item-title {
width: 80%;
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 隐藏超出的文本 */
text-overflow: ellipsis; /* 显示省略号 */
}
}
.is-active {
background-color: #165dff;
color: #ffffff;
}
}
}
:deep(.icon) {
font-size: 18px !important;
}
}
.account-right {
flex: 1;
}
</style>
@@ -0,0 +1,154 @@
<template>
<div class="right-container">
<div class="section">
<div class="title">基础设置</div>
<div class="content">
<a-form auto-label-width :model="form">
<a-form-item field="name" label="Email地址">
<a-input v-model="form.email" readonly />
</a-form-item>
<a-form-item field="post" label="密码">
<a-input-password v-model="form.password" />
</a-form-item>
<a-form-item field="post" label="显示名称">
<a-input v-model="form.displayName" />
</a-form-item>
</a-form>
</div>
</div>
<div class="section">
<div class="title">服务器设置</div>
<div class="content">
<a-form auto-label-width :model="form">
<a-row>
<a-col :span="12">
<a-form-item field="name" label="收件服务器">
<a-input v-model="form.smtpHost" readonly />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="端口">
<a-input v-model="form.smtpPort" readonly />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="发件服务器">
<a-input v-model="form.imapHost" readonly />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="端口">
<a-input v-model="form.imapPort" readonly />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</div>
<div class="footer">
<a-button type="primary" size="small" :loading="loading" @click="handleUpdate">保存</a-button>
</div>
</div>
</template>
<script setup lang="ts">
// import { EmailAccount, updateEmailAccountApi } from "@/service/userAccount";
import { Message } from "@arco-design/web-vue";
import { ref, watch } from "vue";
const props = defineProps<{
data: EmailAccount;
}>();
const form = ref<EmailAccount>({
id: "",
email: "",
password: "",
displayName: "",
imapHost: "",
imapPort: "",
imapSsl: "",
smtpHost: "",
smtpPort: "",
smtpSsl: ""
});
watch(
() => props.data,
(newVal) => {
form.value = newVal;
},
{ immediate: true }
);
const loading = ref<boolean>(false);
const handleUpdate = () => {
if (form.value.password === "") {
Message.error("密码不能为空");
return;
}
if (form.value.displayName === "") {
Message.error("显示名称不能为空");
return;
}
loading.value = true;
// updateEmailAccountApi({
// id: form.value.id,
// password: form.value.password,
// displayName: form.value.displayName
// })
// .then((res) => {
// Message.success(res.message);
// })
// .finally(() => {
// loading.value = false;
// });
};
</script>
<style lang="scss" scoped>
.right-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
box-sizing: border-box;
position: relative;
padding: 16px;
.section {
background-color: #fff;
border-radius: 4px;
width: 800px;
}
.title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
text-align: left;
border-left: 4px solid #1677ff;
padding-left: 12px;
}
.content {
font-size: 14px;
color: #666;
line-height: 1.5;
}
.footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
padding: 8px 0px;
}
}
</style>
@@ -0,0 +1,106 @@
<template>
<CustomLayout>
<template #sidebar>
<div class="sidebar-container">
<a-menu class="menu" :collapsed="false" :selected-keys="selectdKeys" :open-keys="openkeys" @menu-item-click="handleClickMenuItem" @sub-menu-click="handleClickSubMenuItem">
<a-menu-item-group title="基础设置">
<a-menu-item key="/setting/account">
<template #icon>
<IconUserGroup />
</template>
账号管理
</a-menu-item>
</a-menu-item-group>
</a-menu>
</div>
</template>
<template #content>
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="route.name" v-if="route.meta.keepAlive"></component>
</keep-alive>
<component :is="Component" :key="route.name" v-if="!route.meta.keepAlive"></component>
</router-view>
</template>
</CustomLayout>
</template>
<script setup lang="ts">
import { IconUserGroup } from "@arco-design/web-vue/es/icon";
import { ref, watch } from "vue";
import { useRouterStore } from "@/store/modules/router";
const routerStore = useRouterStore();
const baseUrl = "/settings";
const selectdKeys = ref<string[]>([]);
const openkeys = ref<string[]>([]);
function getOpenKey(fullPath: string) {
const parts = fullPath.split("/").filter(Boolean); // 移除空字符串
if (parts.length <= 2) return baseUrl;
return "/" + parts.slice(0, parts.length - 1).join("/");
}
watch(
() => routerStore.currentPath,
(newVal) => {
selectdKeys.value = [newVal];
openkeys.value = [getOpenKey(newVal)];
},
{ immediate: true }
);
const handleClickMenuItem = (key: string) => {
if (key) {
routerStore.navigateTo(key);
}
};
const handleClickSubMenuItem = (key: string) => {
const index = openkeys.value.indexOf(key);
if (index === -1) {
openkeys.value.push(key); // 如果不包含就添加
} else {
openkeys.value.splice(index, 1); // 如果包含就移除
}
};
</script>
<style lang="scss" scoped>
.sidebar-container {
display: flex;
flex-direction: column;
width: 200px;
}
.menu {
width: 100%;
font-size: 14px !important;
background-color: transparent;
:deep(.arco-menu-inline-header) {
line-height: 32px;
.arco-menu-icon {
margin-right: 6px;
font-size: 16px;
}
}
:deep(.arco-menu-inner) {
padding: 0px;
}
:deep(.arco-menu-item) {
line-height: 30px;
padding: 0px 4px;
.arco-menu-icon {
margin-right: 6px;
font-size: 16px;
}
}
:deep(.arco-menu-selected) {
background-color: #f2f3f5;
}
:deep(.arco-menu-group-title) {
line-height: 30px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<EmailEditor v-model="data" ref="editorRef" />
<a-button
@click="
() => {
console.log(editorRef.getPlainText());
}
">
测试
</a-button>
</template>
<script setup lang="ts">
import EmailEditor from "@/components/email-editor/index.vue";
import { ref } from "vue";
const editorRef = ref<any>(null);
const data = ref("");
</script>
<style lang="scss" scoped>
.preview-iframe {
background-color: white;
}
</style>
@@ -0,0 +1,836 @@
<template>
<a-spin v-if="!showTip" :loading="loading.init" tip="正在加载中,请稍等 . . ." class="h-full">
<div class="write-container">
<div class="write-header">
<a-button @click="handleSent" type="primary" size="small" :loading="loading.sent">
<template #icon>
<SvgIcon icon="mingcute:send-plane-fill" />
</template>
发送
</a-button>
<a-button size="small" type="outline" @click="handleToSentEmailDetail">
<template #icon>
<SvgIcon icon="qlementine-icons:print-preview-16" />
</template>
预览
</a-button>
<a-button size="small" type="outline" @click="triggerUpload">
<template #icon>
<SvgIcon icon="typcn:attachment-outline" />
</template>
附件
</a-button>
<a-button size="small" type="outline" :loading="loading.draft" @click="handleSaveToDrafts">
<template #icon>
<SvgIcon icon="ri:draft-line" />
</template>
存草稿
</a-button>
<div class="sent-btns">
<a-button type="text" size="small" v-if="!isBulkSingleDisplay" @click="isCC = !isCC">
{{ isCC ? "取消抄送" : "抄送" }}
</a-button>
<a-button type="text" size="small" v-if="!isBulkSingleDisplay" @click="isBcc = !isBcc">
{{ isBcc ? "取消密送" : "密送" }}
</a-button>
<a-button type="text" size="small" @click="handleChangeBulkSingleDisplay">
{{ isBulkSingleDisplay ? "取消群发单显" : "群发单显" }}
</a-button>
<a-dropdown>
<a-button type="text">
<template #icon>
<SvgIcon icon="ri:more-fill" />
</template>
编辑模式
</a-button>
<template #content>
<a-doption @click="isRichMode = true">
<template #icon v-if="isRichMode">
<SvgIcon icon="ion:md-checkmark" />
</template>
<template #default>富文本模式</template>
</a-doption>
<a-doption @click="handleSwitchEditorModeText()">
<template #icon v-if="!isRichMode">
<SvgIcon icon="ion:md-checkmark" />
</template>
<template #default>纯文本模式</template>
</a-doption>
</template>
</a-dropdown>
</div>
</div>
<div class="write-form">
<div class="write-form-item">
<span class="label">
{{ !isBulkSingleDisplay ? "收件人" : "分别发送" }}
</span>
<a-input-tag v-model="formData.toRecipients" size="small" allow-clear />
</div>
<div class="write-form-item">
<span class="label">主&ensp;&ensp;题</span>
<a-input size="small" v-model="formData.subject" />
</div>
<div class="write-form-item" v-if="!isBulkSingleDisplay && isCC">
<span class="label">抄&ensp;&ensp;送</span>
<a-input-tag v-model="formData.ccRecipients" size="small" allow-clear />
</div>
<div class="write-form-item" v-if="!isBulkSingleDisplay && isBcc">
<span class="label">密&ensp;&ensp;送</span>
<a-input-tag v-model="formData.bccRecipients" size="small" allow-clear />
</div>
</div>
<div class="attachment-container" v-if="attachments.length > 0">
<div class="attachment-header" @click="isCollapsed = !isCollapsed">
📁 附件列表(1)
<span class="collapse-icon">
<SvgIcon :icon="isCollapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" />
</span>
</div>
<div class="attachment-list" v-if="!isCollapsed">
<a-spin v-for="(attachment, index) in attachments" :loading="attachment.isProgress" :tip="attachment.progress" :size="16" :key="index">
<div class="attachment-item">
<FileIcon :file-name="attachment.fileName" class="icon" />
<div class="main">
<div class="name">{{ attachment.fileName }}</div>
<div class="size">
{{ formatFileSizeToString(attachment.fileSize) }}
<span class="error" v-if="attachment.isFailed">上传失败</span>
</div>
</div>
<div class="close-btn" @click="handleDeleteTempFile(attachment)">
<SvgIcon icon="material-symbols:close" />
</div>
</div>
</a-spin>
</div>
</div>
<div class="write-editor">
<EmailEditor v-if="isRichMode" v-model="formData.body" ref="editorRef" class="rich-editor" @upload-image="hanldeUploadInlines" />
<textarea v-else v-model="formData.body" placeholder="点击输入内容..." class="text-editor" />
</div>
<div class="write-footer">
<div class="write-sent">
<span class="sent-label">发件人:</span>
<div v-if="isEditSender" class="sent-edit">
<a-input v-model="senderName" size="mini" placeholder="输入发件人名称" @press-enter="handleCofirmUpdateSenderName" />
<a-button size="mini" type="primary" @click="handleCofirmUpdateSenderName">确认</a-button>
<a-button size="mini" @click="handleCancelUpdateSenderName">取消</a-button>
</div>
<div v-else @click="handleShowUpdateSender" class="sent-view">
<span class="sent-name">
{{ formData.fromName }}
</span>
<span class="sent-email">&lt;{{ formData.from }}&gt;</span>
</div>
</div>
</div>
</div>
</a-spin>
<div v-else class="tip-container">
<SvgIcon class="tip-icon" local-icon="email-sent-success" />
<div class="tip-title">邮件已成功发送</div>
<div class="tip-subtitle">您可以查看邮件详情,或继续发送下一封。</div>
<div class="tip-actions">
<a-button type="primary" status="success" @click="handleToSentEmailDetail">查看已发邮件</a-button>
<a-button type="outline" @click="handleContinueSent">继续发送</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Message, Modal } from "@arco-design/web-vue";
import { reactive, ref, onMounted } from "vue";
import { useUserStore } from "@/store/modules/user";
import { EmailSentDTO, uploadTempFile, deleteTempFile, sentEmailApi, getEmailDetailApi } from "@/service/email";
import { useEmailStore } from "@/store/modules/email";
import { useRouterStore } from "@/store/modules/router";
import { EmailFileTypeEnum, EmailFolderCategoryEnum } from "@/enums";
import type { EmailSummary, EmailFile } from "@/types/email";
import { formatFileSizeToString } from "@/utils/common";
const userStore = useUserStore();
const emailStore = useEmailStore();
const routerStore = useRouterStore();
const loading = reactive({
init: false,
sent: false,
draft: false
});
const newEmailSummary = ref<EmailSummary | null>(null);
const showTip = ref<boolean>(false);
const editorRef = ref();
const isBulkSingleDisplay = ref(false); // 群发单显标志位
const isCC = ref(false); // 是否展示抄送
const isBcc = ref(false); // 是否展示密送
const isRichMode = ref<boolean>(true); // 编辑模式是否是富文本
const isEditSender = ref<boolean>(false); // 是否编辑发件人
const senderName = ref(userStore.userInfo.displayName); // 发件人名字,不和form在一起
const isCollapsed = ref(false);
const defaultEmailSentDTO: () => EmailSentDTO = () => ({
emailId: "",
subject: "",
body: "",
from: userStore.userInfo.email,
fromName: userStore.userInfo.displayName,
isSingSent: 0,
isHtml: 1,
toRecipients: [],
ccRecipients: [],
bccRecipients: [],
attachments: [],
inlines: [],
isSent: 1
});
const formData = ref<EmailSentDTO>(defaultEmailSentDTO());
interface FileAttachment extends EmailFile {
isProgress?: boolean;
isFailed?: boolean; // ⬅️ 新增:上传失败标志
progress?: string;
}
const attachments = ref<FileAttachment[]>([]);
const inlines = ref<FileAttachment[]>([]);
function prepareDefaultContent(query: any) {
const email = emailStore.currentEmail;
if (!emailStore.isEmailSelected) return;
const isReply = query?.isReply === "1";
const isReplyAll = query?.isReplyAll === "1";
const isForward = query?.isForward === "1";
const addAddresses = (list: { address: string }[] = []) => {
list.forEach(({ address }) => {
if (address && !formData.value.toRecipients.includes(address)) {
formData.value.toRecipients.push(address);
}
});
};
// 收件人处理
if (isReply || isReplyAll) {
if (email.replyAddresses?.length) {
addAddresses(email.replyAddresses);
} else if (email.fromAddress?.address) {
addAddresses([email.fromAddress]);
}
}
// 抄送处理
if (isReplyAll && email.ccAddresses?.length) {
addAddresses(email.ccAddresses);
}
// 主题处理
if (isReply || isReplyAll) {
formData.value.subject = `Re: ${email.subject || ""}`;
} else if (isForward) {
formData.value.subject = `Fw: ${email.subject || ""}`;
}
const formatSingleEmail = (addr: { address: string; addressName: string }) => {
const displayName = addr.addressName || addr.address;
return `${displayName} &lt;<a href="mailto:${addr.address}">${addr.address}</a>&gt;`;
};
const formatEmailList = (list: { address: string; addressName: string }[] = []) => list.map(formatSingleEmail).join("; ");
const metaFields = [
{ label: "发件人", value: email.fromAddress?.address ? formatSingleEmail(email.fromAddress) : "" },
{ label: "发件时间", value: email.createTime || "" },
{ label: "收件人", value: formatEmailList(email.toAddresses) }
];
if (isReplyAll && email.ccAddresses?.length) {
metaFields.push({ label: "抄送", value: formatEmailList(email.ccAddresses) });
}
metaFields.push({ label: "主题", value: email.subject || "" });
const metaHtml = metaFields
.filter((field) => field.value)
.map((field) => `<div><span style="color: #999;">${field.label}:</span>${field.value}</div>`)
.join("");
formData.value.body = `
<br/><br/><br/><br/><br/><br/><br/><br/>
<div style="font-size:12px">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<span style="margin-right: 8px; color:#995DA6;">原始邮件</span>
<div style="flex-grow: 1; height: 1px; background-color: #ccc;"></div>
</div>
<div style="background-color: #f5f6f7; padding: 12px 16px; line-height: 1.6;">
${metaHtml}
</div>
<br/>
${email.content || ""}
</div>
`;
}
function initData(query: any) {
isRichMode.value = true;
loading.init = true;
getEmailDetailApi(query.emailId)
.then((res) => {
const data = res.data || {};
formData.value.emailId = data.id;
formData.value.subject = data.subject;
formData.value.body = data.content;
formData.value.from = data.fromAddress?.address || "";
formData.value.fromName = data.fromAddress?.addressName || "";
formData.value.toRecipients = (data.toAddresses || []).map((item) => item.address);
formData.value.ccRecipients = (data.ccAddresses || []).map((item) => item.address);
formData.value.bccRecipients = (data.bccAddresses || []).map((item) => item.address);
attachments.value = (data.attachments || []).filter((item) => item.fileType === EmailFileTypeEnum.ATTACHMENT);
inlines.value = (data.attachments || []).filter((item) => item.fileType === EmailFileTypeEnum.INLINE);
isCC.value = (data.ccAddresses?.length || 0) > 0;
isBcc.value = (data.bccAddresses?.length || 0) > 0;
const cidMap = new Map<string, string>();
for (const att of inlines.value) {
const rawCid = att.emailCid.replace(/^<|>$/g, "");
cidMap.set(rawCid, att.fileUrl);
}
let content = formData.value.body;
// 替换 cid
content = content.replace(/cid:([^"' >]+)/g, (_, cid) => {
const mapped = cidMap.get(cid);
return mapped || `cid:${cid}`;
});
formData.value.body = content;
})
.finally(() => {
loading.init = false;
});
}
onMounted(() => {
const query = routerStore.route.query;
if (query?.isText === "1") {
isRichMode.value = false;
}
if (query.isReply === "1" || query.isReplyAll === "1" || query.isForward === "1") {
prepareDefaultContent(query);
}
if (query?.toEmail && query?.toEmail !== "") {
formData.value.toRecipients.push(query?.toEmail as string);
}
if (query.emailId && query.emailId !== "") {
initData(query);
}
});
function handleSwitchEditorModeText() {
if (isRichMode.value) {
Modal.warning({
title: "编辑器模式切换提示",
content: "如果切换到纯文本模式,将清除所有格式和嵌入图像,确认要继续么?",
hideCancel: false,
onOk: () => {
inlines.value = [];
formData.value.body = editorRef.value?.getPlainText();
isRichMode.value = false;
}
});
}
}
const handleShowUpdateSender = () => {
isEditSender.value = true;
};
const handleCofirmUpdateSenderName = () => {
if (senderName.value === "") {
return;
}
formData.value.fromName = senderName.value;
isEditSender.value = false;
};
const handleCancelUpdateSenderName = () => {
senderName.value = formData.value.fromName;
isEditSender.value = false;
};
const handleChangeBulkSingleDisplay = () => {
isBcc.value = false;
isCC.value = false;
isBulkSingleDisplay.value = !isBulkSingleDisplay.value;
};
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const triggerUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "*/*";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
const startIndex = attachments.value.length;
const uploadPromises: Promise<void>[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const index = startIndex + i;
const overLimit = file.size > MAX_SIZE;
const newAttachment: FileAttachment = {
id: "",
fileName: file.name,
fileExtension: "",
fileSize: file.size,
filePath: "",
emailId: "",
fileUrl: "",
emailCid: "",
fileType: 0,
isProgress: !overLimit,
progress: overLimit ? undefined : "0%",
isFailed: overLimit
};
attachments.value.push(newAttachment);
if (overLimit) {
Message.error(`"${file.name}" 超出大小限制,最大不能超过 25MB`);
continue;
}
const promise = uploadTempFile(file, (progressEvent) => {
if (progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
attachments.value[index].progress = percent + "%";
}
})
.then((res) => {
attachments.value[index] = res.data;
attachments.value[index].isProgress = false;
attachments.value[index].progress = undefined;
attachments.value[index].isFailed = false;
})
.catch((err) => {
console.error("上传失败:", err);
attachments.value[index].isProgress = false;
attachments.value[index].progress = undefined;
attachments.value[index].isFailed = true;
});
uploadPromises.push(promise);
}
Promise.allSettled(uploadPromises).finally(() => {
document.body.removeChild(input);
});
};
input.click();
};
const hanldeUploadInlines = (file: EmailFile) => {
inlines.value.push(file);
};
const handleDeleteTempFile = (attachment: FileAttachment) => {
const index = attachments.value.findIndex((item) => item.fileUrl === attachment.fileUrl);
if (index === -1) return;
// 如果是上传失败的,直接移除
if (attachment.isFailed || !attachment.fileUrl) {
attachments.value.splice(index, 1);
return;
}
// 正常调用后端删除接口
deleteTempFile(attachment.id)
.catch((err) => {
console.error("删除临时文件失败:", err);
})
.finally(() => {
attachments.value.splice(index, 1);
});
};
const handleSent = () => {
if (formData.value.subject === "") {
Message.error("主题");
return;
}
if (formData.value.toRecipients.length === 0) {
Message.error("收件人不能为空!");
return;
}
loading.sent = true;
formData.value.attachments = attachments.value;
formData.value.inlines = inlines.value;
formData.value.isSingSent = isBulkSingleDisplay.value ? 1 : 0;
// 是否是富文本
if (isRichMode.value) {
formData.value.isHtml = 1;
} else {
formData.value.isHtml = 0;
}
sentEmailApi(formData.value)
.then((res) => {
Message.success("发送成功");
showTip.value = true;
newEmailSummary.value = res.data;
})
.finally(() => {
loading.sent = false;
});
};
const handleSaveToDrafts = () => {
loading.draft = true;
formData.value.isSent = 0;
formData.value.attachments = attachments.value;
formData.value.inlines = inlines.value;
formData.value.isSingSent = isBulkSingleDisplay.value ? 1 : 0;
// 是否是富文本
if (isRichMode.value) {
formData.value.isHtml = 1;
} else {
formData.value.isHtml = 0;
}
sentEmailApi(formData.value)
.then((res) => {
Message.success("保存成功");
formData.value.emailId = res.data.id;
})
.finally(() => {
loading.draft = false;
});
};
const handleToSentEmailDetail = () => {
const sentFolder = emailStore.folderList.find((item) => item.category === EmailFolderCategoryEnum.SENT);
if (sentFolder) {
emailStore.currentFolder = sentFolder;
if (newEmailSummary.value !== null) {
emailStore.currentEmail.id = newEmailSummary.value.id;
routerStore.navigateTo("/home");
}
}
};
const handleResetFrom = () => {
formData.value = defaultEmailSentDTO();
attachments.value = [];
inlines.value = [];
isBulkSingleDisplay.value = false;
isCC.value = false;
isBcc.value = false;
isEditSender.value = false;
newEmailSummary.value = null;
editorRef.value?.clear();
};
const handleContinueSent = () => {
handleResetFrom();
showTip.value = false;
};
</script>
<style lang="scss" scoped>
.write-container {
margin: 0 auto;
height: 100%;
padding: 16px 120px;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-gutter: stable;
}
.write-header {
display: flex;
gap: 16px;
align-items: center;
.sent-btns {
margin-left: auto;
}
}
.write-form {
display: flex;
flex-direction: column;
margin-top: 12px;
.write-form-item {
display: flex;
align-items: center;
font-size: 14px;
padding: 8px 0px;
.label {
flex-shrink: 0;
align-items: center;
padding-right: 16px;
border-right: 1px solid #f0f0f0;
transition: color 0.3s ease;
}
border-bottom: 1px solid #e0e0e0;
}
.write-form-item:focus-within > .label {
color: #165dff;
}
:deep(.arco-input-wrapper),
:deep(.arco-input),
:deep(.arco-input-tag) {
background-color: transparent;
border: none !important;
box-shadow: none !important;
}
:deep(.arco-input-wrapper.arco-input-focus) {
border: none !important;
box-shadow: none !important;
}
:deep(.arco-input-tag-tag) {
background-color: #165dff;
color: white;
}
:deep(.arco-input-tag-tag .arco-icon-hover::before) {
background-color: #3c84ff !important;
opacity: 0 !important; /* 默认隐藏 */
transition: opacity 0.2s; /* 动画效果 */
}
:deep(.arco-input-tag-tag .arco-icon-hover:hover::before) {
opacity: 1 !important; /* hover 时显示 */
}
:deep(.arco-tag .arco-tag-close-btn) {
margin-left: 8px;
}
}
.attachment-container {
margin-top: 8px;
.attachment-header {
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
.collapse-icon {
margin-left: 8px;
font-size: 22px;
display: flex;
align-items: center;
}
}
.attachment-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
max-height: 200px;
overflow-y: auto;
scrollbar-gutter: stable;
.attachment-item {
width: 260px;
display: flex;
padding: 4px 8px;
background-color: #f3f4f6;
align-items: center;
height: 44px;
:deep(.icon) {
width: 38px;
height: 38px;
}
.main {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
padding: 1px 0px;
margin-left: 6px;
flex: 1;
min-width: 0;
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #888;
}
.error {
color: red;
}
}
.close-btn {
margin-left: auto;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background-color: #dce0e5;
}
}
}
}
.write-editor {
height: 600px;
margin-top: 12px;
flex-shrink: 0; /* 不允许缩小 */
.rich-editor {
height: 100%;
}
.text-editor {
width: 100%;
height: 100%;
resize: none; /* 禁止拉伸 */
padding: 8px 12px; /* 内边距 */
box-sizing: border-box; /* 包含padding */
border: 1px solid #ccc;
transition:
border-color 0.3s ease,
box-shadow 0.3s ease;
}
.text-editor:focus {
outline: none;
border-color: #4a86ff;
box-shadow: 0 0 4px rgba(74, 134, 255, 0.3);
}
}
.write-footer {
display: flex;
gap: 16px;
align-items: center;
margin-top: 12px;
.write-sent {
display: flex;
align-items: center;
.sent-label {
flex-shrink: 0;
}
.sent-edit {
display: flex;
gap: 8px;
align-items: center;
}
.sent-view {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
color: #666;
transition: background-color 0.2s ease;
&:hover {
background-color: #f2f3f5;
}
}
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tip-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 24px;
animation: fadeInUp 0.4s ease-out;
.tip-icon {
width: 160px;
height: 160px;
opacity: 0.9;
}
.tip-title {
font-size: 22px;
font-weight: 600;
color: #333;
}
.tip-subtitle {
font-size: 14px;
color: #666;
}
.tip-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
}
</style>