u
This commit is contained in:
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>
|
||||
<{{ detailData.fromAddress.address }}>
|
||||
</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>
|
||||
<{{ to.address }}>
|
||||
<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>
|
||||
<{{ cc.address }}>
|
||||
<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>
|
||||
<{{ bcc.address }}>
|
||||
<span v-if="index < detailData.bccAddresses.length - 1">,</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<span class="label">时 间:</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">主  题</span>
|
||||
<a-input size="small" v-model="formData.subject" />
|
||||
</div>
|
||||
<div class="write-form-item" v-if="!isBulkSingleDisplay && isCC">
|
||||
<span class="label">抄  送</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">密  送</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"><{{ formData.from }}></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} <<a href="mailto:${addr.address}">${addr.address}</a>>`;
|
||||
};
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user