新增邮件群发和数据可视化

This commit is contained in:
eoao
2025-06-17 09:20:00 +08:00
parent ea309f9171
commit 605829f180
65 changed files with 2352 additions and 900 deletions

View File

@@ -26,6 +26,7 @@
|---------------------|---------------------|
| ![](demo/demo3.png) | ![](demo/demo4.png) |
| ![](demo/demo5.png) | ![](demo/demo6.png) |
| ![](demo/demo7.png) | ![](demo/demo8.png) |
@@ -36,20 +37,20 @@
- **💻响应式设计**响应式布局自动适配PC和大部分手机端浏览器
- **📧邮件发送**集成resend发送邮件支持内嵌图片和附件发送发送状态查看
- **📧邮件发送**集成resend发送邮件支持群发,内嵌图片和附件发送,发送状态查看
- **🛡️管理员功能**可以对用户邮件进行管理RABC权限控制对功能及使用额度限制
- **🛡️管理员功能**可以对用户邮件进行管理RABC权限控制对功能及使用资源限制
- **🔀多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台
- **📦附件收**:支持收附件使用R2对象存储保存和下载文件
- **📦附件收**:支持收附件使用R2对象存储保存和下载文件
- **📈数据可视化**使用echarts对系统数据详情用户邮件增长可视化显示
- **⭐星标邮件**:标记重要邮件,以便快速查阅
- **🎨个性化设置**:可以自定义网站标题,登录背景,透明度
- **⏱️轮询刷新**:轮询请求服务器自动获取最新邮件,可自定义间隔
- **⚙️功能设置**:可以对注册,邮件发送,添加等功能关闭和开启,设为私人站点
- **🤖人机验证**集成Turnstile人机验证防止人机批量注册

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 121 KiB

BIN
demo/demo7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

BIN
demo/demo8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -12,18 +12,18 @@
"dependencies": {
"@iconify/vue": "^4.3.0",
"@vueuse/core": "^12.0.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.8",
"compressorjs": "^1.2.1",
"date-time-format-timezone": "^1.0.22",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "^2.9.5",
"lodash-es": "^4.17.21",
"path": "^0.12.7",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"postal-mime": "^2.4.3",
"screenfull": "^6.0.2",
"vue": "^3.5.13",
"vue-cropper": "^1.1.4",
"vue-router": "^4.5.0"
@@ -33,6 +33,8 @@
"less": "^4.2.2",
"sass": "^1.82.0",
"terser": "^5.39.0",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.7.0",
"vite": "6.3.4"
}
}

View File

@@ -1,10 +1,9 @@
<template>
<router-view/>
<el-config-provider :locale="locale">
<router-view/>
</el-config-provider>
</template>
<script setup>
</script>
<style>
</style>
import zhCn from 'element-plus/es/locale/lang/zh-cn'
let locale=zhCn
</script>

View File

@@ -1,6 +1,5 @@
import axios from "axios";
import router from "@/router";
import {ElMessage} from 'element-plus';
let http = axios.create({
baseURL: import.meta.env.VITE_BASE_URL
@@ -27,6 +26,8 @@ http.interceptors.response.use((res) => {
message: data.message,
type: 'error',
plain: true,
grouping: true,
repeatNum: -4,
})
localStorage.removeItem('token')
router.push('/login')
@@ -36,6 +37,8 @@ http.interceptors.response.use((res) => {
message: data.message,
type: 'warning',
plain: true,
grouping: true,
repeatNum: -4,
})
reject(data)
} else if (data.code !== 200) {
@@ -43,8 +46,9 @@ http.interceptors.response.use((res) => {
message: data.message,
type: 'error',
plain: true,
grouping: true,
repeatNum: -4,
})
reject(data)
}
resolve(data.data)
@@ -61,12 +65,15 @@ http.interceptors.response.use((res) => {
message: '网络错误,请检查网络连接',
type: 'error',
plain: true,
grouping: true,
repeatNum: -4,
})
} else if (error.code === 'ECONNABORTED') {
ElMessage({
message: '请求超时,请稍后重试',
type: 'error',
plain: true,
grouping: true
})
ElMessage.error('')
} else if (error.response) {
@@ -74,12 +81,16 @@ http.interceptors.response.use((res) => {
message: `服务器繁忙`,
type: 'error',
plain: true,
grouping: true,
repeatNum: -4,
})
} else {
ElMessage({
message: '请求失败,请稍后再试',
type: 'error',
plain: true,
grouping: true,
repeatNum: -4,
})
}
return Promise.reject(error)

View File

@@ -154,7 +154,6 @@ import Loading from "@/components/loading/index.vue";
import {Icon} from "@iconify/vue";
import {computed, onActivated, reactive, ref, watch} from "vue";
import {onBeforeRouteLeave} from "vue-router";
import {ElMessage, ElMessageBox} from "element-plus";
import {useEmailStore} from "@/store/email.js";
import {useUiStore} from "@/store/ui.js";
import {useSettingStore} from "@/store/setting.js";

View File

@@ -53,7 +53,6 @@ function updateContent() {
font-family: 'HarmonyOS', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #13181D;
overflow-wrap: break-word;
}
@@ -71,7 +70,7 @@ function updateContent() {
display: block;
}
* {
*:not(p) {
margin: 0;
padding: 0;
box-sizing: border-box;

View File

@@ -6,15 +6,16 @@
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount, watch} from 'vue';
import {ref, onMounted, onBeforeUnmount, watch, nextTick} from 'vue';
import loading from "@/components/loading/index.vue";
import {compressImage} from "@/utils/file-utils.js";
defineExpose({
clearEditor
clearEditor,
focus
})
const props = defineProps({
modelValue: {
defValue: {
type: String,
default: ''
},
@@ -39,7 +40,7 @@ onBeforeUnmount(() => {
destroyEditor();
});
watch(() => props.modelValue, (newValue) => {
watch(() => props.defValue, (newValue) => {
if (editor.value && editor.value.getContent() !== newValue) {
editor.value.setContent(newValue);
}
@@ -69,23 +70,54 @@ function initEditor() {
selector: `#${props.editorId}`,
statusbar: false,
height: "100%",
auto_focus: true,
forced_root_block: 'div',
plugins: 'link image advlist lists emoticons fullscreen table preview code',
toolbar: 'bold emoticons forecolor fontsize | alignleft aligncenter alignright alignjustify | outdent indent | bullist numlist | link image | table code preview fullscreen',
toolbar: 'bold emoticons forecolor backcolor italic fontsize | alignleft aligncenter alignright alignjustify | outdent indent | bullist numlist | link image | table code preview fullscreen',
toolbar_mode: 'scrolling',
mobile: {
toolbar: 'fullscreen bold emoticons forecolor fontsize | alignleft aligncenter alignright alignjustify | outdent indent | bullist numlist | link image | table code preview ',
toolbar: 'fullscreen bold emoticons forecolor backcolor italic fontsize | alignleft aligncenter alignright alignjustify | outdent indent | bullist numlist | link image | table code preview ',
},
font_size_formats: '8pt 10pt 12pt 14pt 16pt 18pt 24pt 36pt',
font_size_formats: '8px 10px 12px 14px 16px 18px 24px 36px',
emoticons_search: false,
language: 'zh_CN',
language_url: '/tinymce/langs/zh_CN.js',
menubar: false,
license_key: 'gpl',
content_style: " .tox-dialog__body-content { margin: 0 !important; } img { max-width: 100%; height: auto; }",
content_style: ` .tox-dialog__body-content { margin: 0 !important; }
img { max-width: 100%; height: auto; }
body {margin: 10px 8px 0 5px !important; font-family: 'HarmonyOS'; font-size: 14px;}
@media (pointer: fine) and (hover: hover) {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
cursor: pointer;
}
}
table, th, td {
border: none !important;
}
a {
color: #409EFF !important;
}
`,
setup: (ed) => {
editor.value = ed;
ed.on('init', () => {
ed.setContent(props.modelValue);
ed.setContent(props.defValue);
isInitialized.value = true;
});
ed.on('input change', () => {
@@ -94,6 +126,7 @@ function initEditor() {
emit('change', content, text);
});
},
autofocus: true,
branding: false,
file_picker_types: 'image',
image_dimensions: false,
@@ -108,7 +141,6 @@ function initEditor() {
input.addEventListener('change', async (e) => {
let file = e.target.files[0];
file = await compressImage(file);
console.log(file.size / 1024)
const reader = new FileReader();
reader.onload = () => {
const id = 'blobid' + (new Date()).getTime();
@@ -127,6 +159,12 @@ function initEditor() {
});
}
function focus() {
nextTick(() => {
editor.value.focus()
})
}
function destroyEditor() {
if (editor.value) {
@@ -161,6 +199,11 @@ function destroyEditor() {
padding-left: 15px;
padding-bottom: 15px;
background: #FFFFFF;
@media (max-width: 767px) {
padding-right: 10px;
padding-left: 10px;
padding-bottom: 10px;
}
}
:deep(.tox-tinymce) {
@@ -177,4 +220,8 @@ function destroyEditor() {
margin: 0 !important;
}
:deep(.tox .tox-edit-area::before) {
display: none;
}
</style>

View File

@@ -0,0 +1,26 @@
import * as echarts from 'echarts/core';
import { BarChart,PieChart,LineChart,GaugeChart} from 'echarts/charts';
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component
import {
TooltipComponent,
GridComponent,
} from 'echarts/components';
// 标签自动布局、全局过渡动画等特性
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer } from 'echarts/renderers';
import { LegendComponent } from 'echarts/components';
// 注册必须的组件
echarts.use([
GaugeChart,
LegendComponent,
PieChart,
TooltipComponent,
GridComponent,
BarChart,
LineChart,
CanvasRenderer
]);
export default echarts

View File

@@ -1,7 +1,7 @@
<template>
<div class="account-box">
<div class="head-opt" >
<Icon v-if="settingStore.settings.addEmail === 0" v-perm="'account:add'" class="icon" icon="ion:add-outline" width="23" height="23" @click="add" />
<Icon v-perm="'account:add'" class="icon" icon="ion:add-outline" width="23" height="23" @click="add" />
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refresh" />
</div>
<el-scrollbar class="scrollbar">
@@ -15,12 +15,12 @@
<Icon icon="eva:email-fill" width="22" height="22" color="#fbbd08" />
</div>
<div class="settings" @click.stop>
<Icon v-if="item.accountId === userStore.user.accountId || !hasPerm('account:delete')" icon="fluent:settings-24-filled" width="20" height="20" color="#909399" />
<el-dropdown v-else >
<el-dropdown>
<Icon icon="fluent:settings-24-filled" width="20" height="20" color="#909399" />
<template #dropdown >
<el-dropdown-menu>
<el-dropdown-item @click="remove(item)">删除</el-dropdown-item>
<el-dropdown-item @click="openSetName(item)">改名</el-dropdown-item>
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')" @click="remove(item)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -69,7 +69,7 @@
</el-scrollbar>
<el-dialog v-model="showAdd" title="添加邮箱" >
<div class="container">
<el-input v-model="addForm.email" type="text" placeholder="邮箱" autocomplete="off">
<el-input v-model="addForm.email" ref="addRef" type="text" placeholder="邮箱" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
@@ -103,13 +103,21 @@
data-callback="onTurnstileSuccess"
></div>
</el-dialog>
<el-dialog v-model="setNameShow" title="修改名字" >
<div class="container">
<el-input v-model="accountName" type="text" placeholder="名字" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" @click="setName" :loading="setNameLoading"
>保存
</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import {Icon} from "@iconify/vue";
import {nextTick, reactive, ref} from "vue";
import {accountList, accountAdd, accountDelete} from "@/request/account.js";
import {ElMessage, ElMessageBox} from "element-plus";
import {nextTick, reactive, ref, watch} from "vue";
import {accountList, accountAdd, accountDelete, accountSetName} from "@/request/account.js";
import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
@@ -127,6 +135,11 @@ const noLoading = ref(false)
const loading = ref(false)
const followLoading = ref(false);
const verifyShow = ref(false)
const setNameShow = ref(false)
const setNameLoading = ref(false)
const accountName = ref(null)
const addRef = ref({})
let account = null
let turnstileId = null
let verifyToken = ''
const addForm = reactive({
@@ -144,6 +157,10 @@ if (hasPerm('account:query')) {
getAccountList()
}
watch(() => accountStore.changeUserAccountName, () => {
accounts[0].name = accountStore.changeUserAccountName
})
const openSelect = () => {
mySelect.value.toggleMenu()
@@ -156,6 +173,49 @@ window.onTurnstileSuccess = (token) => {
},1500)
};
function setName() {
let name = accountName.value
if (name === account.name) {
setNameShow.value = false
return
}
if (!name) {
ElMessage({
message: '用户名不能为空',
type: 'error',
plain: true,
})
return;
}
setNameLoading.value = true
accountSetName(account.accountId,name).then(() => {
account.name = name
setNameShow.value = false
if (account.accountId === userStore.user.accountId) {
userStore.user.name = name
}
ElMessage({
message: "保存成功",
type: "success",
plain: true
})
}).finally(()=> {
setNameLoading.value = false
})
}
function openSetName (accountItem) {
accountName.value = accountItem.name
account = accountItem
setNameShow.value = true
}
function itemBg(accountId) {
return accountStore.currentAccountId === accountId ? 'item-choose' : ''
}
@@ -199,6 +259,9 @@ function changeAccount(account) {
function add() {
showAdd.value = true
setTimeout(() => {
addRef.value.focus()
},100)
}
function getAccountList() {

View File

@@ -26,9 +26,14 @@
<Icon icon="fluent:settings-48-regular" width="20" height="20" />
<span class="menu-name" style="margin-left: 20px">个人设置</span>
</el-menu-item>
<div class="manage-title" v-perm="['user:query','role:query','setting:query']">
<div class="manage-title" v-perm="['user:query','role:query','setting:query','analysis:query']">
<div>管理</div>
</div>
<el-menu-item @click="router.push({name: 'analysis'})" index="analysis" v-perm="'analysis:query'"
:class="route.meta.name === 'analysis' ? 'choose-item' : ''">
<Icon icon="fluent:data-pie-20-regular" width="24" height="24" />
<span class="menu-name" style="margin-left: 16px">分析页</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'user'})" index="setting" v-perm="'user:query'"
:class="route.meta.name === 'user' ? 'choose-item' : ''">
<Icon icon="iconoir:user" width="24" height="24" />
@@ -70,7 +75,7 @@ const route = useRoute();
.title {
margin: 15px 10px;
height: 45px;
border-radius: 8px;
border-radius: 6px;
display: flex;
position: relative;
font-size: 16px;

View File

@@ -25,6 +25,9 @@
<div class="details-avatar">
{{ formatName(userStore.user.email) }}
</div>
<div class="user-name">
{{userStore.user.name}}
</div>
<div class="detail-email">
{{ userStore.user.email }}
</div>
@@ -43,9 +46,10 @@
<el-tag v-else >{{sendType}}</el-tag>
</div>
<div>
<span v-if="accountCount && hasPerm('account:add')" style="margin-right: 5px">{{ accountCount }}</span>
<el-tag v-if="!accountCount && hasPerm('account:add')" >无限制</el-tag>
<el-tag v-if="!hasPerm('account:add')" ></el-tag>
<el-tag v-if="settingStore.settings.manyEmail || settingStore.settings.addEmail" >已关闭</el-tag>
<span v-else-if="accountCount && hasPerm('account:add')" style="margin-right: 5px">{{ accountCount }}</span>
<el-tag v-else-if="!accountCount && hasPerm('account:add')" >无限</el-tag>
<el-tag v-else-if="!hasPerm('account:add')" >无权限</el-tag>
</div>
</div>
</div>
@@ -55,6 +59,9 @@
</div>
</template>
</el-dropdown>
<div class="full" @click="full">
<Icon icon="iconamoon:screen-full-light" width="22" height="22" />
</div>
</div>
</div>
</template>
@@ -68,9 +75,12 @@ import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js";
import { useRoute } from "vue-router";
import {computed, ref} from "vue";
import {useSettingStore} from "@/store/setting.js";
import hasPerm from "@/utils/perm.js";
import screenfull from "screenfull";
const route = useRoute();
const settingStore = useSettingStore();
const userStore = useUserStore();
const uiStore = useUiStore();
const logoutLoading = ref(false)
@@ -131,6 +141,10 @@ function formatName(email) {
return email[0]?.toUpperCase() || ''
}
function full() {
screenfull.toggle();
}
</script>
<style lang="scss" scoped>
@@ -141,14 +155,8 @@ function formatName(email) {
white-space: nowrap;
}
.setting-icon {
margin-right: 10px;
position: relative;
bottom: 10px;
}
:deep(.el-popper.is-pure) {
border-radius: 10px;
border-radius: 6px;
}
.user-details {
@@ -158,7 +166,17 @@ function formatName(email) {
display: grid;
grid-template-columns: 1fr;
justify-items: center;
.user-name {
font-weight: bold;
margin-top: 10px;
padding-left: 20px;
padding-right: 20px;
width: 250px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
}
.detail-user-type {
margin-top: 10px;
}
@@ -187,12 +205,12 @@ function formatName(email) {
.detail-email {
padding-left: 20px;
padding-right: 20px;
margin-top: 10px;
width: 250px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
color: #5c5958;
}
.logout {
margin-top: 20px;
@@ -201,7 +219,7 @@ function formatName(email) {
padding-right: 10px;
padding-bottom: 10px;
.el-button {
border-radius: 8px;
border-radius: 6px;
height: 28px;
width: 100%;
}
@@ -264,9 +282,21 @@ function formatName(email) {
.toolbar {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
margin-left: auto;
gap: 10px;
@media (max-width: 1024px) {
grid-template-columns: 1fr auto;
}
.full {
display: flex;
align-items: center;
justify-content: center;
padding-right: 10px;
cursor: pointer;
@media (max-width: 1024px) {
display: none;
}
}
.email {
align-self: center;
font-size: 14px;
@@ -276,13 +306,13 @@ function formatName(email) {
text-overflow: ellipsis;
font-weight: bold;
width: 100%;
}
.avatar {
display: flex;
align-items: center;
cursor: pointer;
margin-left: 10px;
.avatar-text {
height: 30px;
width: 30px;
@@ -296,6 +326,11 @@ function formatName(email) {
.setting-icon {
position: relative;
top: 0;
margin-right: 5px;
bottom: 10px;
@media (max-width: 1024px) {
margin-right: 10px;
}
}
}

View File

@@ -31,10 +31,10 @@ import writer from '@/layout/write/index.vue'
const uiStore = useUiStore();
const writerRef = ref({})
const isMobile = ref(window.innerWidth < 1024)
const isMobile = ref(window.innerWidth < 1025)
const handleResize = () => {
isMobile.value = window.innerWidth < 1024
uiStore.asideShow = window.innerWidth >= 1024;
isMobile.value = window.innerWidth < 1025
uiStore.asideShow = window.innerWidth > 1024;
}
onMounted(() => {
@@ -66,7 +66,7 @@ onBeforeUnmount(() => {
transform: translateX(0);
transition: all 100ms ease;
z-index: 101;
@media (max-width: 1024px) {
@media (max-width: 1025px) {
position: fixed;
top: 0;
left: 0;

View File

@@ -3,7 +3,7 @@
<div :class="accountShow && hasPerm('account:query') ? 'block-show' : 'block-hide'" @click="uiStore.accountShow = false"></div>
<account :class="accountShow && hasPerm('account:query') ? 'show' : 'hide'" />
<router-view class="main-view" v-slot="{ Component,route }">
<keep-alive :include="['email','sys-email','send','sys-setting','star','user','role']">
<keep-alive :include="['email','sys-email','send','sys-setting','star','user','role','analysis']">
<component :is="Component" :key="route.name"/>
</keep-alive>
</router-view>

View File

@@ -3,40 +3,38 @@
<div class="write-box">
<div class="title">
<div class="title-left">
<span class="title-text">写邮件</span>
<span class="title-text">
<Icon icon="hugeicons:quill-write-01" width="28" height="28" />
</span>
<span class="sender">发件人:</span>
<span class="sender-name">{{form.name}}</span>
<span class="send-email"><{{form.sendEmail}}></span>
</div>
<div @click="close" style="cursor: pointer;">
<Icon icon="material-symbols-light:close-rounded" width="22" height="22"/>
</div>
</div>
<div class="container">
<el-input v-model="form.sendEmail" disabled placeholder="">
<el-input-tag @add-tag="addTagChange" tag-type="primary" size="default" v-model="form.receiveEmail" placeholder="多邮个箱用, 分开 example1.com,example2.com" >
<template #prefix>
<div class="item-title">件人 :</div>
<div class="item-title">件人 </div>
</template>
<template #suffix>
<span class="distribute" :class="form.manyType ? 'checked' : ''" @click.stop="checkDistribute" >分别发送</span>
</template>
</el-input-tag>
<el-input v-model="form.subject" placeholder="请输入邮件主题">
<template #prefix>
<div class="item-title">主题 </div>
</template>
</el-input>
<el-input v-model="form.receiveEmail" placeholder="收件人邮箱地址">
<template #prefix>
<div class="item-title">收件人 :</div>
</template>
</el-input>
<el-input v-model="form.name" placeholder="发件人名字,不填则默认使用邮箱名">
<template #prefix>
<div class="item-title">名字 :</div>
</template>
</el-input>
<el-input v-model="form.subject" placeholder="邮件主题">
<template #prefix>
<div class="item-title">主题 :</div>
</template>
</el-input>
<tinyEditor ref="editor" @change="change"/>
<tinyEditor :def-value="defValue" ref="editor" @change="change"/>
<div class="button-item">
<div class="att-add" @click="chooseFile">
<Icon icon="iconamoon:attachment-fill" width="26" height="26"/>
<Icon icon="iconamoon:attachment-fill" width="24" height="24"/>
</div>
<div class="att-clear" @click="clearContent">
<Icon icon="icon-park-outline:clear-format" width="26" height="26"/>
<Icon icon="icon-park-outline:clear-format" width="24" height="24 "/>
</div>
<div class="att-list">
<div class="att-item" v-for="(item,index) in form.attachments" :key="index">
@@ -48,7 +46,8 @@
</div>
</div>
<div>
<el-button type="primary" @click="sendEmail">发送</el-button>
<el-button type="primary" @click="sendEmail" v-if="form.sendType === 'reply'">回复</el-button>
<el-button type="primary" @click="sendEmail" v-else >发送</el-button>
</div>
</div>
</div>
@@ -62,15 +61,16 @@ import {Icon} from "@iconify/vue";
import {useUserStore} from "@/store/user.js";
import {emailSend} from "@/request/email.js";
import {isEmail} from "@/utils/verify-utils.js";
import {ElMessage, ElMessageBox, ElNotification} from "element-plus";
import {useAccountStore} from "@/store/account.js";
import {useEmailStore} from "@/store/email.js";
import {fileToBase64, formatBytes} from "@/utils/file-utils.js";
import {getIconByName} from "@/utils/icon-utils.js";
import sendPercent from "@/components/send-percent/index.vue"
import {formatDetailDate} from "@/utils/day.js";
defineExpose({
open
open,
openReply
})
const emailStore = useEmailStore();
@@ -81,17 +81,43 @@ const show = ref(false);
const percent = ref(0)
let percentMessage = null
let sending = false
const defValue = ref('')
const form = reactive({
sendEmail: '',
receiveEmail: '',
receiveEmail: [],
accountId: -1,
manyType: null,
name: '',
subject: '',
content: '',
sendType: '',
text: '',
emailId: 0,
attachments: []
})
function addTagChange(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
form.receiveEmail.splice(form.receiveEmail.length - 1, 1)
emails.forEach(email => {
if (isEmail(email) && !form.receiveEmail.includes(email)) {
form.receiveEmail.push(email)
}
})
}
function checkDistribute() {
form.manyType = form.manyType ? null : 'divide'
}
function clearContent() {
ElMessageBox.confirm('确定要清空邮件吗?', {
confirmButtonText: '确定',
@@ -135,7 +161,7 @@ function chooseFile() {
async function sendEmail() {
if (!form.receiveEmail) {
if (form.receiveEmail.length === 0) {
ElMessage({
message: '收件人邮箱地址不能为空',
type: 'error',
@@ -144,15 +170,6 @@ async function sendEmail() {
return
}
if (!isEmail(form.receiveEmail)) {
ElMessage({
message: '请输入正确的收件人邮箱',
type: 'error',
plain: true,
})
return
}
if (!form.subject) {
ElMessage({
message: '主题不能为空',
@@ -171,6 +188,15 @@ async function sendEmail() {
return
}
if (form.manyType === 'divide' && form.attachments.length > 0) {
ElMessage({
message: '分别发送暂时不支持附件',
type: 'error',
plain: true,
})
return
}
if (sending) {
ElMessage({
message: '邮件正在发送中',
@@ -192,8 +218,11 @@ async function sendEmail() {
close()
emailSend(form, (e) => {
percent.value = Math.round((e.loaded * 98) / e.total)
}).then(email => {
emailStore.sendScroll?.addItem(email)
}).then(emailList => {
const email = emailList[0]
emailList.forEach(item => {
emailStore.sendScroll?.addItem(item)
})
resetForm()
show.value = false
ElNotification({
@@ -219,11 +248,13 @@ async function sendEmail() {
function resetForm() {
form.receiveEmail = ''
form.name = ''
form.receiveEmail = []
form.subject = ''
form.content = ''
form.manyType = null
form.attachments = []
form.sendType = null
form.emailId = 0
editor.value.clearEditor()
}
@@ -232,15 +263,45 @@ function change(content, text) {
form.text = text
}
function openReply(email) {
resetForm();
email.subject = email.subject || ''
form.receiveEmail.push(email.sendEmail)
form.subject = (email.subject.startsWith('Re:') || email.subject.startsWith('回复:')) ? email.subject : 'Re: ' + email.subject
form.sendType = 'reply'
form.emailId = email.emailId
defValue.value = ''
setTimeout(() => {
defValue.value = `
<div></div>
<div>
<br>
${ formatDetailDate(email.createTime) }${email.name} &lt${email.sendEmail}&gt 来信:
</div>
<blockquote style="margin: 0 0 0 0.8ex;border-left: 1px solid rgb(204,204,204);padding-left: 1ex;">${email.content}</blockquote>`
open()
})
}
function open() {
if (!accountStore.currentAccount.email) {
form.sendEmail = userStore.user.email;
form.accountId = userStore.user.accountId;
form.name = userStore.user.name;
} else {
form.sendEmail = accountStore.currentAccount.email;
form.accountId = accountStore.currentAccount.accountId;
form.name = accountStore.currentAccount.name;
}
show.value = true;
editor.value.focus()
}
const handleKeyDown = (event) => {
@@ -276,22 +337,24 @@ function close() {
.write-box {
background: #FFFFFF;
width: 902px;
width: min(1200px,calc(100% - 80px));
box-shadow: var(--el-box-shadow-light);
border: 1px solid var(--el-border-color-light);
transition: var(--el-transition-duration);
padding: 15px;
border-radius: 8px;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
@media (max-width: 1024px) {
width: 100%;
height: 100%;
border-radius: 0;
padding-top: 10px;
}
@media (min-width: 1024px) {
height: min(750px, calc(100vh - 60px));
@media (min-width: 1025px) {
height: min(800px, calc(100vh - 60px));
}
.title {
@@ -300,28 +363,63 @@ function close() {
margin-bottom: 10px;
.title-left {
align-items: center;
display: flex;
gap: 10px;
display: grid;
grid-template-columns: auto auto auto 1fr;
}
.title-text {
}
.sender {
margin-left: 8px;
}
.sender-name {
margin-left: 8px;
font-weight: bold;
font-size: 16px;
}
.send-email {
color: #999896;
margin-left: 5px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
div {
display: flex;
align-items: center;
}
}
.container {
height: 100%;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 15px;
.distribute {
color: var(--el-color-info);
background: var(--el-color-info-light-9);
border: var(--el-color-info-light-8);
border-radius: 4px;
font-size: 12px;
padding: 0 5px;
}
.distribute.checked {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary) !important;
border-radius: 4px;
}
.distribute:hover {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary) !important;
border-radius: 4px;
}
.item-title {
color: #333;
margin-right: 8px;
}
.button-item {
@@ -371,6 +469,11 @@ function close() {
}
}
:deep(.el-input-tag__suffix) {
padding-right: 4px;
}
.icon {
cursor: pointer;
}

View File

@@ -1,23 +1,16 @@
import {createApp} from 'vue';
import App from './App.vue';
import router from './router';
import 'element-plus/dist/index.css';
import './style.css';
import ElementPlus from 'element-plus';
import VueCropper from 'vue-cropper';
import 'vue-cropper/dist/index.css'
import { init } from '@/utils/init.js';
import { createPinia } from 'pinia';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import piniaPersistedState from 'pinia-plugin-persistedstate';
import perm from "@/directives/perm.js";
const pinia = createPinia().use(piniaPersistedState)
const app = createApp(App).use(pinia)
app.use(ElementPlus, {
locale: zhCn,
});
await init()
app.use(router).use(VueCropper).directive('perm',perm)
app.config.devtools = true;

View File

@@ -8,6 +8,10 @@ export function accountAdd(email,token) {
return http.post('/account/add', {email,token})
}
export function accountSetName(accountId,name) {
return http.put('/account/setName', {name,accountId})
}
export function accountDelete(accountId) {
return http.delete('/account/delete', {params: {accountId}})
}

View File

@@ -0,0 +1,5 @@
import http from '@/axios/index.js'
export function analysisEcharts() {
return http.get('/analysis/echarts');
}

View File

@@ -114,7 +114,7 @@ router.afterEach((to) => {
}
}
if (window.innerWidth < 1024) {
if (window.innerWidth < 1025) {
uiStore.asideShow = false
}
})

View File

@@ -3,6 +3,7 @@
export const useAccountStore = defineStore('account', {
state: () => ({
currentAccountId: 0,
currentAccount: {}
currentAccount: {},
changeUserAccountName: ''
})
})

View File

@@ -8,7 +8,8 @@ export const useEmailStore = defineStore('email', {
contentData: {
email: null,
delType: null,
showStar: true
showStar: true,
showReply: true,
},
sendScroll: null,
}),

View File

@@ -65,25 +65,30 @@ button, input, select, textarea {
}
.tox .tox-dialog--width-lg {
@media (min-width: 1024px) {
height: 850px !important;
height: 850px !important;
@media (max-width: 1024px) {
height: calc(100% - 40px) !important;
}
}
.tox .tox-dialog__body-content {
max-height: min(850px, calc(100vh - 110px)) !important;
@media (min-width: 1024px) {
max-height: min(850px, calc(100vh - 110px));
@media (max-width: 1024px) {
max-height: min(850px, calc(100% - 40px)) !important;
}
}
.tox .tox-dialog__body-content {
@media (min-width: 1024px) {
max-height: min(850px, calc(100vh - 110px)) !important;
overflow: initial !important;
max-height: min(850px, calc(100vh - 110px)) !important;
@media (max-width: 1024px) {
box-sizing: initial !important;
max-height: min(850px, calc(100% - 40px)) !important;
}
}
:root {
--el-color-primary: #1890ff;
--el-color-primary-dark-2: #1064c0;

View File

@@ -3,7 +3,7 @@ import {getExtName} from "@/utils/file-utils.js";
export function getIconByName(filename) {
const extName = getExtName(filename)
if (['zip', 'rar', '7z', 'tar', 'tgz'].includes(extName)) return 'octicon:file-zip-24';
if (['png', 'jpg', 'jpeg','gif','webp'].includes(extName)) return 'mingcute:pic-line';
if (['png', 'jpg', 'jpeg','gif','webp','jfif'].includes(extName)) return 'mingcute:pic-line';
if (['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'].includes(extName)) return 'fluent:video-clip-24-regular';
if (['txt', 'doc', 'docx', 'md','ini','conf'].includes(extName)) return 'hugeicons:google-doc'
if (['xls', 'csv', 'xlsx'].includes(extName)) return 'codicon:table';

View File

@@ -6,10 +6,9 @@ import { permsToRouter } from "@/utils/perm.js";
import router from "@/router";
import { settingQuery } from "@/request/setting.js";
import {cvtR2Url} from "@/utils/convert.js";
import {ElMessage} from "element-plus";
export async function init() {
document.title = '-'
document.title = 'loading...'
const settingStore = useSettingStore();
const userStore = useUserStore();

View File

@@ -56,5 +56,15 @@ const routers = {
name: 'sys-email',
menu: true
}
},
'analysis:query': {
path: '/analysis',
name: 'analysis',
component: () => import('@/views/analysis/index.vue'),
meta: {
title: '分析页',
name: 'analysis',
menu: true
}
}
}

View File

@@ -0,0 +1,838 @@
<template>
<div v-if="analysisLoading" class="analysis-loading">
<loading />
</div>
<el-scrollbar v-else style="height: 100%;">
<div class="analysis" :key="boxKey">
<div class="number">
<div class="number-item">
<div class="top">
<div class="left">
<div>收件数量</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="receiveData"/>
</div>
</div>
<div class="right">
<div class="count-icon">
<Icon icon="hugeicons:mailbox-01" width="25" height="25"></Icon>
</div>
</div>
</div>
<div class="delete-ratio">
<div>正常 <span class="normal">{{numberCount.normalReceiveTotal}}</span></div>
<div>删除 <span class="deleted">{{numberCount.delReceiveTotal}}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>发件数量</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="sendData"/>
</div>
</div>
<div class="right">
<div class="count-icon">
<Icon icon="cil:send" width="25" height="25"></Icon>
</div>
</div>
</div>
<div class="delete-ratio">
<div>正常 <span class="normal">{{numberCount.normalSendTotal}}</span></div>
<div>删除 <span class="deleted">{{numberCount.delSendTotal}}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>邮箱数量</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="accountData"/>
</div>
</div>
<div class="right">
<div class="count-icon">
<Icon icon="lets-icons:e-mail" width="23" height="23"></Icon>
</div>
</div>
</div>
<div class="delete-ratio">
<div>正常 <span class="normal">{{numberCount.normalAccountTotal}}</span></div>
<div>删除 <span class="deleted">{{numberCount.delAccountTotal}}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>用户数量</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="userData"/>
</div>
</div>
<div class="right">
<div class="count-icon">
<Icon icon="iconoir:user" width="25" height="25"></Icon>
</div>
</div>
</div>
<div class="delete-ratio">
<div>正常 <span class="normal">{{numberCount.normalUserTotal}}</span></div>
<div>删除 <span class="deleted">{{numberCount.delUserTotal}}</span></div>
</div>
</div>
</div>
<div class="picture">
<div class="picture-item">
<div class="title" style="display: flex;justify-content: space-between;">
<span>邮件来源</span>
<span class="source-button" v-if="false">
<el-radio-group v-model="checkedSourceType" >
<el-radio-button label="发件人" value="sender" />
<el-radio-button label="邮箱" value="email" />
</el-radio-group>
</span>
</div>
<div class="sender-pie">
</div>
</div>
<div class="picture-item">
<div class="title">用户增长</div>
<div class="increase-line">
</div>
</div>
</div>
<div class="picture-cs">
<div class="picture-cs-item">
<div class="title">邮件增长</div>
<div class="email-column"></div>
</div>
<div class="picture-cs-item">
<div class="title">今日发件</div>
<div class="send-count"></div>
</div>
</div>
</div>
</el-scrollbar>
</template>
<script setup>
import {Icon} from "@iconify/vue";
import {useTransition} from "@vueuse/core";
import {defineOptions, onActivated, onDeactivated, onMounted, reactive, ref, watch} from "vue";
import echarts from "@/echarts/index.js";
import dayjs from "dayjs";
import {analysisEcharts} from "@/request/analysis.js";
import {useUiStore} from "@/store/ui.js";
import {debounce} from "lodash-es";
import loading from "@/components/loading/index.vue";
import {useRoute} from "vue-router";
defineOptions({
name: 'analysis'
})
const route = useRoute();
const uiStore = useUiStore()
const checkedSourceType = ref('sender')
const receiveTotal = ref(0)
const sendTotal = ref(0)
const accountTotal = ref(0)
const userTotal = ref(0)
const analysisLoading = ref(true)
const numberCount = reactive({
normalReceiveTotal: 0,
normalSendTotal: 0,
normalAccountTotal: 0,
normalUserTotal: 0,
delReceiveTotal: 0,
delSendTotal: 0,
delAccountTotal: 0,
delUserTotal: 0
})
const receiveData = useTransition(receiveTotal, {
duration: 1500,
})
const sendData = useTransition(sendTotal, {
duration: 1500,
})
const accountData = useTransition(accountTotal, {
duration: 1500,
})
const userData = useTransition(userTotal, {
duration: 1500,
})
const senderData = ref(null)
const userLineData = reactive({
xdata: [],
sdata: []
})
const emailColumnData = {
barWidth: window.innerWidth > 767 ? '40%' : '60%',
receiveData: [],
sendData: [],
daysData: []
}
let daySendTotal = 0
let leaveWidth = 0
let senderPie = null
let increaseLine = null
let emailColumn = null
let sendGauge = null
let first = true
let boxKey = ref(0)
let senderPieLeft = window.innerWidth < 500 ? `${window.innerWidth - 110}` : '72%'
onMounted(() => {
analysisEcharts().then(data => {
receiveTotal.value = data.numberCount.receiveTotal
sendTotal.value = data.numberCount.sendTotal
accountTotal.value = data.numberCount.accountTotal
userTotal.value = data.numberCount.userTotal
numberCount.normalReceiveTotal = data.numberCount.normalReceiveTotal
numberCount.normalSendTotal = data.numberCount.normalSendTotal
numberCount.normalAccountTotal = data.numberCount.normalAccountTotal
numberCount.normalUserTotal = data.numberCount.normalUserTotal
numberCount.delReceiveTotal = data.numberCount.delReceiveTotal
numberCount.delSendTotal = data.numberCount.delSendTotal
numberCount.delAccountTotal = data.numberCount.delAccountTotal
numberCount.delUserTotal = data.numberCount.delUserTotal
senderData.value = data.receiveRatio.nameRatio.map(item => {
return {
name: item.name || ' ',
value: item.total
}
})
userLineData.xdata = data.userDayCount.map(item => dayjs(item.date).format("M.D"))
userLineData.sdata = data.userDayCount.map(item => item.total)
emailColumnData.daysData = data.emailDayCount.receiveDayCount.map(item => dayjs(item.date).format("M.D"))
emailColumnData.receiveData = data.emailDayCount.receiveDayCount.map(item => item.total)
emailColumnData.sendData = data.emailDayCount.sendDayCount.map(item => item.total)
daySendTotal = data.daySendTotal
analysisLoading.value = false
initPicture();
first = false
})
})
function initPicture() {
if(route.name !== 'analysis') return
boxKey.value ++
setTimeout(() => {
createSenderPie()
createIncreaseLine()
createEmailColumnChart();
createSendGauge();
})
}
const widthChange = debounce(initPicture, 500, {
leading: false,
trailing: true
})
watch(() => uiStore.asideShow, () => {
if (window.innerWidth > 1024) {
widthChange()
}
})
onActivated(() => {
if (first) return
if (window.innerWidth !== leaveWidth && leaveWidth !== 0) {
widthChange()
} else if (!senderPie) {
widthChange()
}
})
onDeactivated(() => {
leaveWidth = window.innerWidth
})
window.onresize = () => {
setStyle()
widthChange()
}
function setStyle() {
senderPieLeft = window.innerWidth < 500 ? `${window.innerWidth - 110}` : '72%'
emailColumnData.barWidth = window.innerWidth > 767 ? '40%' : '60%'
}
const measureCtx = document.createElement('canvas').getContext('2d');
measureCtx.font = '12px sans-serif';
function truncateTextByWidth(text,maxWidth = 140) {
let width = measureCtx.measureText(text).width;
if (width <= maxWidth) return text;
let result = '';
for (let i = 0; i < text.length; i++) {
result += text[i];
if (measureCtx.measureText(result + '…').width > maxWidth) {
return result.slice(0, -1) + '…';
}
}
return text;
}
function createSenderPie() {
if (senderPie) {
senderPie.dispose()
}
senderPie = echarts.init(document.querySelector(".sender-pie"))
let option = {
tooltip: {
trigger: 'item',
formatter: params => {
return `${params.marker} ${params.name} ${params.value} (${params.percent}%)`;
}
},
legend: {
type: 'scroll',
orient: 'vertical',
left: '10',
top: '20',
formatter: function (name) {
return truncateTextByWidth(name)
}
},
series: [
{
data: senderData.value,
name: '',
type: 'pie',
radius: ['40%', '65%'],
center: [ senderPieLeft, '45%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'outside', // 标签显示在外部
formatter: '{d}%', // 显示名称和占比
color: '#333',
fontSize: 14 // 设置字体大小
},
emphasis: {
label: {
show: false,
fontSize: 40,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
color: ['#3CB2FF', '#13DEB9', '#FBBF24', '#FF7F50', '#BAE6FD', '#C084FC'] // 添加符合主题的配色
}
]
}
senderPie.setOption(option)
}
function createIncreaseLine() {
if (increaseLine) {
increaseLine.dispose()
}
increaseLine = echarts.init(document.querySelector(".increase-line"))
let option = {
tooltip: {
trigger: 'axis', // 设置触发方式为 'axis',在坐标轴上显示信息
axisPointer: {
type: 'cross', // 指示器的类型为交叉型,适用于折线图等
crossStyle: {
color: '#999' // 设置指示器线的颜色
},
axis: 'x',
},
formatter: function (params) {
let result = ''
params.forEach(item => {
result = `${item.marker} 用户数: ${item.value}`;
});
return result;
},
backgroundColor: '#fff', // 设置背景颜色
borderColor: '#ccc', // 设置边框颜色
borderWidth: 1, // 设置边框宽度
padding: 10, // 设置内边距
textStyle: {
color: '#333', // 设置文字颜色
}
},
grid: {
top: '8%',
right: '20',
left: '35',
bottom: '35'
},
xAxis: {
type: 'category',
data: userLineData.xdata,
axisTick: {
show: false,
alignWithLabel: false, // 刻度线与标签对齐,
lineStyle: {
color: 'red',
}
},
axisPointer: {
label: {
show: false
}
},
axisLine: {
lineStyle: {
color: '#909399',
width: 1,
type: 'solid'
}
},
axisLabel: {
formatter: function (value, index) {
if (index === 0) {
return ' ' + value;
}
if (index === userLineData.xdata.length - 1) {
return value + ' '
}
return value;
},
},
boundaryGap: false,
},
yAxis: {
type: 'value',
axisLabel: {
margin: 5, // 增加y轴刻度数字与网格线之间的间距
},
boundaryGap: [0, 0.1],
max: (params) => {
if (params.max < 8 ) {
return 10
}
},
axisLine: {
show: true,
lineStyle: {
color: '#909399',
width: 1,
}
},
axisPointer: {
label: {
show: true,
formatter: (e) => {
return Math.round(e.value)
}
}
},
splitLine: {
show: true, // 显示网格线
lineStyle: {
type: 'dashed', // 设置网格线为虚线
color: '#ccc' // 设置虚线的颜色
}
}
},
series: [
{
data: userLineData.sdata,
type: 'line',
smooth: 0.1,
symbol: 'none',
lineStyle: {
color: '#1D84FF',
width: 2.5
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(29, 132, 255, 0.3)'
},
{
offset: 1,
color: 'rgba(29, 132, 255, 0.03)'
}
])
},
color: ['#1D84FF'],
}
]
};
increaseLine.setOption(option);
}
function createEmailColumnChart() {
if (emailColumn) {
emailColumn.dispose()
}
emailColumn = echarts.init(document.querySelector(".email-column"));
const option = {
tooltip: {
formatter: function (params) {
params.marker
return `${params.marker} ${params.seriesName}: ${params.value}`
}
},
legend: {
data: ['接收', '发送'],
top: '0'
},
grid: {
left: '18',
right: '18',
bottom: '15',
top: '50',
containLabel: true
},
xAxis: {
type: 'category',
data: emailColumnData.daysData,
axisTick: {
show: false
},
axisLine: {
show: true,
lineStyle: {
color: '#909399',
width: 1,
}
},
},
yAxis: {
type: 'value',
boundaryGap: [0, 0.1],
},
series: [
{
name: '接收',
type: 'bar',
stack: 'total', // 堆叠组标识(必须相同)
barWidth: emailColumnData.barWidth,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.3)',
}
},
data: emailColumnData.receiveData,
itemStyle: {
color: '#3CB2FF',
}
},
{
name: '发送',
type: 'bar',
stack: 'total', // 堆叠组标识(必须相同)
barWidth: '45%',
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.3)'
}
},
data: emailColumnData.sendData,
itemStyle: {
color: '#13deb9',
}
}
]
};
emailColumn.setOption(option);
}
function createSendGauge() {
if(sendGauge) {
sendGauge.dispose()
}
sendGauge = echarts.init(document.querySelector(".send-count"));
let option = {
tooltip: {},
series: [{
name: '今日发件',
type: 'gauge',
max: 100,
// 进度条颜色(新增)
progress: {
show: true,
roundCap: true,
itemStyle: {
color: '#3CB2FF'
}
},
// 指针颜色(新增)
pointer: {
itemStyle: {
color: '#3CB2FF'
}
},
// 轴线背景色(新增)
axisLine: {
roundCap: true,
lineStyle: {
color: [[1, '#E6EBF8']]
}
},
// 刻度颜色(新增)
axisTick: {
lineStyle: {
color: '#999'
}
},
// 中心文字颜色(新增)
detail: {
valueAnimation: true,
formatter: '{value}',
color: '#333' // 黑色文字
},
data: [{
value: daySendTotal,
name: '次数',
// 名称标签颜色(新增)
title: {
color: '#333' // 灰色标签
}
}]
}],
color: ['#3CB2FF']
};
sendGauge.setOption(option);
}
</script>
<style>
.percentage-value {
display: block;
margin-top: 10px;
font-size: 28px;
}
.percentage-label {
display: block;
margin-top: 10px;
font-size: 12px;
}
</style>
<style scoped lang="scss">
.analysis-loading {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.analysis {
height: 100%;
padding: 20px 20px 30px;
gap: 20px;
background: #FAFCFF;
display: grid;
grid-auto-rows: min-content;
@media (max-width: 1024px) {
padding: 15px 15px 30px;
gap: 15px
}
.title {
margin-top: 10px;
margin-left: 15px;
font-size: 18px;
font-weight: 500;
}
.number {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 20px;
@media (max-width: 1024px) {
grid-template-columns: 1fr 1fr;
gap: 15px;
}
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
.number-item {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
padding: 25px 20px;
.top {
display: grid;
justify-content: space-between;
align-content: center;
grid-template-columns: auto auto;
.left {
display: grid;
gap: 5px;
grid-auto-rows: min-content;
> div:last-child {
font-size: 13px;
}
:deep(.el-statistic__number) {
font-size: 26px;
}
}
.right {
display: grid;
align-items: center;
.count-icon {
top: 3px;
position: relative;
display: grid;
align-items: center;
padding: 14px;
border-radius: 8px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
}
.delete-ratio {
width: 100%;
display: grid;
grid-template-columns: auto auto;
justify-content: start;
gap: 20px;
padding-top: 5px;
.normal {
width: fit-content;
color: var(--el-color-success);
font-weight: bold;
margin-left: 3px;
}
.deleted {
width: fit-content;
color: var(--el-color-danger);
font-weight: bold;
margin-left: 3px;
}
}
}
}
.picture {
display: grid;
grid-template-columns: 500px 1fr;
gap: 20px;
@media (max-width: 1620px) {
grid-template-columns: 1fr;
}
@media (max-width: 1024px) {
gap: 15px;
}
.picture-item {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
.source-button {
padding-right: 15px;
display: flex;
align-items: start;
:deep(.el-radio-button__inner) {
padding: 6px 10px;
}
}
.sender-pie {
height: 350px;
@media (max-width: 767px) {
height: 200px;
}
}
.increase-line {
height: 350px;
@media (max-width: 767px) {
height: 280px;
}
}
}
}
.picture-cs {
display: grid;
grid-template-columns: 1fr 500px;
gap: 20px;
@media (max-width: 1620px) {
grid-template-columns: 1fr;
gap: 15px;
}
.picture-cs-item {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
.send-count {
height: 350px;
@media (max-width: 767px) {
height: 320px;
}
}
.email-column {
height: 350px;
@media (max-width: 767px) {
height: 250px;
}
}
}
}
}
</style>

View File

@@ -3,10 +3,11 @@
<div class="header-actions">
<Icon class="icon" icon="material-symbols-light:arrow-back-ios-new" width="20" height="20" @click="handleBack"/>
<Icon v-perm="'email:delete'" class="icon" icon="uiw:delete" width="16" height="16" @click="handleDelete"/>
<template v-if="emailStore.contentData.showStar">
<Icon class="icon" @click="changeStar" v-if="email.isStar" icon="fluent-color:star-16" width="20" height="20"/>
<Icon class="icon" @click="changeStar" v-else icon="solar:star-line-duotone" width="18" height="18"/>
</template>
<span class="star" v-if="emailStore.contentData.showStar">
<Icon class="icon" @click="changeStar" v-if="email.isStar" icon="fluent-color:star-16" width="21" height="20"/>
<Icon class="icon" @click="changeStar" v-else icon="solar:star-line-duotone" width="19" height="19"/>
</span>
<Icon class="icon" v-if="emailStore.contentData.showReply" @click="openReply" icon="carbon:reply" width="20" height="20" />
</div>
<div></div>
<el-scrollbar class="scrollbar">
@@ -23,12 +24,11 @@
<span><{{ email.sendEmail }}></span>
</div>
</div>
<div class="receive"><span class="source">收件人</span><span>{{ email.receiveEmail }}</span></div>
<div class="receive"><span class="source">收件人</span><span class="receive-email">{{ formateReceive(email.recipient) }}</span></div>
<div class="date">
<div>{{ formatDetailDate(email.createTime) }}</div>
</div>
</div>
<el-alert v-if="email.status === 3" :closable="false" :title="'发送失败: ' + toMessage(email.message)" class="email-msg" type="error" show-icon />
<el-alert v-if="email.status === 4" :closable="false" title="被标记为垃圾邮件" class="email-msg" type="warning" show-icon />
<el-alert v-if="email.status === 5" :closable="false" title="邮件发送被延迟" class="email-msg" type="warning" show-icon />
@@ -87,7 +87,9 @@ import {cvtR2Url} from "@/utils/convert.js";
import {getIconByName} from "@/utils/icon-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {sysEmailDelete} from "@/request/sys-email.js";
import {useUiStore} from "@/store/ui.js";
const uiStore = useUiStore();
const settingStore = useSettingStore();
const accountStore = useAccountStore();
const emailStore = useEmailStore();
@@ -96,10 +98,15 @@ const email = emailStore.contentData.email
const showPreview = ref(false)
const srcList = reactive([])
watch(() => accountStore.currentAccountId, () => {
handleBack()
})
function openReply() {
uiStore.writerRef.openReply(email)
}
function toMessage(message) {
return message ? JSON.parse(message).message : '';
}
@@ -119,10 +126,13 @@ function showImage(key) {
}
function isImage(filename) {
return ['png', 'jpg', 'jpeg', 'bmp', 'gif'].includes(getExtName(filename))
return ['png', 'jpg', 'jpeg', 'bmp', 'gif','jfif'].includes(getExtName(filename))
}
function formateReceive(recipient) {
recipient = JSON.parse(recipient)
return recipient.map(item => item.address).join(', ')
}
function changeStar() {
if (email.isStar) {
@@ -194,7 +204,11 @@ const handleDelete = () => {
gap: 20px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
.star {
display: flex;
align-items: center;
min-width: 21px;
}
.icon {
cursor: pointer;
}
@@ -232,8 +246,7 @@ const handleDelete = () => {
.att {
margin-top: 30px;
margin-bottom: 30px;
border: 1px solid #e4e7ed;
box-shadow: var(--el-box-shadow-light);
border: 1px solid var(--el-border-color);
padding: 10px;
border-radius: 4px;
width: fit-content;
@@ -336,7 +349,11 @@ const handleDelete = () => {
.receive {
margin-bottom: 6px;
display: flex;
.receive-email {
max-width: 700px;
word-break: break-word;
}
span:nth-child(2) {
color: #585d69;
}

View File

@@ -64,6 +64,7 @@ function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/content')
}

View File

@@ -90,7 +90,6 @@ import router from "@/router";
import {computed, nextTick, reactive, ref} from "vue";
import {login} from "@/request/login.js";
import {register} from "@/request/login.js";
import {ElMessage} from 'element-plus'
import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
@@ -328,7 +327,6 @@ function submitRegister() {
box-shadow: var(--el-box-shadow-light);
@media (max-width: 1024px) {
padding: 20px 18px;
border-radius: 6px;
width: 384px;
margin-left: 18px;
}

View File

@@ -96,7 +96,6 @@
import {Icon} from "@iconify/vue";
import {defineOptions, nextTick, reactive, ref} from "vue";
import {roleAdd, roleDelete, rolePermTree, roleRoleList, roleSet, roleSetDef} from "@/request/role.js";
import {ElMessage, ElMessageBox} from "element-plus";
import loading from '@/components/loading/index.vue';
import {useRoleStore} from "@/store/role.js";
import {useUserStore} from "@/store/user.js";

View File

@@ -58,6 +58,7 @@ function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/content')
}

View File

@@ -2,6 +2,23 @@
<div class="box">
<div class="pass">
<div class="title">账户与密码</div>
<div class="pass-item">
<div>用户名</div>
<div>
<span v-if="setNameShow" class="edit-name-input">
<el-input v-model="accountName" ></el-input>
<span class="edit-name" @click="setName">
保存
</span>
</span>
<span v-else class="user-name">
<span >{{ userStore.user.name }}</span>
<span class="edit-name" @click="showSetName">
修改
</span>
</span>
</div>
</div>
<div class="pass-item">
<div>邮箱</div>
<div>{{ userStore.user.email }}</div>
@@ -9,7 +26,7 @@
<div class="pass-item">
<div>密码</div>
<div>
<el-button type="primary" @click="pwdShow = true">修改密码</el-button>
<el-button type="primary" @click="pwdShow = true">修改密码</el-button>
</div>
</div>
</div>
@@ -23,11 +40,11 @@
</div>
</div>
<el-dialog v-model="pwdShow" title="修改密码" width="340">
<div class="update-pwd">
<el-input type="password" placeholder="新的密码" v-model="form.password"/>
<el-input type="password" placeholder="确认密码" v-model="form.newPwd"/>
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">保存</el-button>
</div>
<div class="update-pwd">
<el-input type="password" placeholder="新的密码" v-model="form.password"/>
<el-input type="password" placeholder="确认密码" v-model="form.newPwd"/>
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">保存</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -35,16 +52,60 @@
import {defineOptions} from "vue";
import {reactive, ref} from 'vue'
import {resetPassword, userDelete} from "@/request/my.js";
import {ElMessage, ElMessageBox} from "element-plus";
import {useUserStore} from "@/store/user.js";
import router from "@/router/index.js";
import {accountSetName} from "@/request/account.js";
import {useAccountStore} from "@/store/account.js";
const accountStore = useAccountStore()
const userStore = useUserStore();
const setPwdLoading = ref(false)
const setNameShow = ref(false)
const accountName = ref(null)
defineOptions({
name: 'setting'
})
function showSetName() {
accountName.value = userStore.user.name
setNameShow.value = true
}
function setName() {
if (!accountName.value) {
ElMessage({
message: '用户名不能为空',
type: 'error',
plain: true,
})
return;
}
setNameShow.value = false
let name = accountName.value
if (name === userStore.user.name) {
return
}
userStore.user.name = accountName.value
accountSetName(userStore.user.accountId,name).then(() => {
ElMessage({
message: '修改成功',
type: 'success',
plain: true,
})
accountStore.changeUserAccountName = name
}).catch(() => {
userStore.user.name = name
})
}
const pwdShow = ref(false)
const form = reactive({
password: '',
@@ -121,6 +182,10 @@ function submitPwd() {
.box {
padding: 40px 40px;
@media (max-width: 767px) {
padding: 30px 30px;
}
.update-pwd {
display: flex;
flex-direction: column;
@@ -134,8 +199,7 @@ function submitPwd() {
.pass {
font-size: 14px;
display: flex;
flex-direction: column;
display: grid;
gap: 20px;
margin-bottom: 40px;
@@ -143,12 +207,39 @@ function submitPwd() {
display: grid;
grid-template-columns: 50px 1fr;
gap: 140px;
@media (max-width: 767px) {
gap: 80px;
position: relative;
.user-name {
display: grid;
grid-template-columns: auto 1fr;
span:first-child {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.edit-name-input {
position: absolute;
bottom: -6px;
.el-input {
width: min(200px,calc(100vw - 222px));
}
}
.edit-name {
color: #4dabff;
padding-left: 10px;
cursor: pointer;
}
@media (max-width: 767px) {
gap: 70px;
}
div:first-child {
font-weight: bold;
}
div:last-child {
overflow: hidden;
white-space: nowrap;

View File

@@ -31,6 +31,7 @@ function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
emailStore.contentData.showReply = true
router.push('/content')
}

View File

@@ -144,6 +144,7 @@ function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'physics'
emailStore.contentData.showStar = false
emailStore.contentData.showReply = false
router.push('/content')
}

View File

@@ -299,7 +299,6 @@
<script setup>
import {defineOptions, onMounted, reactive, ref} from "vue";
import {physicsDeleteAll, setBackground, settingQuery, settingSet} from "@/request/setting.js";
import {ElMessage, ElMessageBox} from "element-plus";
import {useSettingStore} from "@/store/setting.js";
import {useUserStore} from "@/store/user.js";
import {useAccountStore} from "@/store/account.js";
@@ -498,8 +497,6 @@ function jump(href) {
doc.click()
}
function editSetting(settingForm, refreshStatus = true) {
if (settingLoading.value) return
settingLoading.value = true
@@ -533,6 +530,7 @@ function editSetting(settingForm, refreshStatus = true) {
.settings-container {
height: 100%;
overflow: hidden;
background: #FAFCFF;
}
.scroll {
@@ -573,18 +571,17 @@ function editSetting(settingForm, refreshStatus = true) {
.settings-card {
background-color: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
border-radius: 8px;
border: 1px solid var(--el-border-color);
transition: all 300ms;
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.card-title {
font-size: 16px;
font-size: 15px;
font-weight: bold;
padding: 10px 20px;
border-bottom: 1px solid #e6e6e6;
border-bottom: 1px solid var(--el-border-color);
}
.card-content {

View File

@@ -233,7 +233,6 @@ import {
} from '@/request/user.js'
import {roleSelectUse} from "@/request/role.js";
import {Icon} from "@iconify/vue";
import {ElMessage, ElMessageBox, ElRadio, ElRadioGroup} from "element-plus";
import loading from "@/components/loading/index.vue";
import {tzDayjs} from "@/utils/day.js";
import {useSettingStore} from "@/store/setting.js";
@@ -841,11 +840,12 @@ function adjustWidth() {
}
.details {
padding: 20px;
padding: 15px 15px 15px 52px;
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@media (max-width: 767px) {
padding-left: 35px;
}
.details-item-title {
white-space: pre;
color: #909399;

View File

@@ -1,6 +1,10 @@
import {defineConfig,loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), 'VITE')
return {
@@ -10,7 +14,13 @@ export default defineConfig(({mode}) => {
hmr: true,
},
base: env.VITE_STATIC_URL || '/',
plugins: [vue()
plugins: [vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
})
],
resolve: {
alias: {
@@ -23,7 +33,11 @@ export default defineConfig(({mode}) => {
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: () => 'all-in-one'
manualChunks(id) {
return 'main'
},
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
}
},
assetsInclude: ['**/*.json'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<title></title>
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module" crossorigin src="/assets/index-CWszkgaK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ii38gjPC.css">
<script type="module" crossorigin src="/assets/index-74wu16JB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-iIoGrcM-.css">
</head>
<body>
<div id="loading-first">

View File

@@ -18,3 +18,7 @@ app.post('/account/add', async (c) => {
return c.json(result.ok(account));
});
app.put('/account/setName', async (c) => {
await accountService.setName(c, await c.req.json(), userContext.getUserId(c));
return c.json(result.ok());
});

View File

@@ -0,0 +1,8 @@
import app from '../hono/hono';
import analysisService from '../service/analysis-service';
import result from '../model/result';
app.get('/analysis/echarts', async (c) => {
const data = await analysisService.echarts(c);
return c.json(result.ok(data));
})

View File

@@ -1,5 +1,5 @@
import app from '../hono/hono';
import initService from '../service/init-service';
import initService from '../init/init';
app.get('/init/:secret', (c) => {
return initService.init(c);

View File

@@ -1,6 +1,7 @@
const KvConst = {
AUTH_INFO: 'auth-uid:',
SETTING: 'setting:',
SEND_DAY_COUNT: 'send_day_count:'
}
export default KvConst;

View File

@@ -0,0 +1,104 @@
const analysisDao = {
async numberCount(c) {
const { results } = await c.env.db.prepare(`
SELECT
e.receiveTotal,
e.sendTotal,
e.delReceiveTotal,
e.delSendTotal,
e.normalReceiveTotal,
e.normalSendTotal,
u.userTotal,
u.normalUserTotal,
u.delUserTotal,
a.accountTotal,
a.normalAccountTotal,
a.delAccountTotal
FROM
(
SELECT
SUM(CASE WHEN type = 0 THEN 1 ELSE 0 END) AS receiveTotal,
SUM(CASE WHEN type = 1 THEN 1 ELSE 0 END) AS sendTotal,
SUM(CASE WHEN type = 0 AND is_del = 1 THEN 1 ELSE 0 END) AS delReceiveTotal,
SUM(CASE WHEN type = 1 AND is_del = 1 THEN 1 ELSE 0 END) AS delSendTotal,
SUM(CASE WHEN type = 0 AND is_del = 0 THEN 1 ELSE 0 END) AS normalReceiveTotal,
SUM(CASE WHEN type = 1 AND is_del = 0 THEN 1 ELSE 0 END) AS normalSendTotal
FROM
email
) e
CROSS JOIN (
SELECT
COUNT(*) AS userTotal,
SUM(CASE WHEN is_del = 1 THEN 1 ELSE 0 END) AS delUserTotal,
SUM(CASE WHEN is_del = 0 THEN 1 ELSE 0 END) AS normalUserTotal
FROM
user
) u
CROSS JOIN (
SELECT
COUNT(*) AS accountTotal,
SUM(CASE WHEN is_del = 1 THEN 1 ELSE 0 END) AS delAccountTotal,
SUM(CASE WHEN is_del = 0 THEN 1 ELSE 0 END) AS normalAccountTotal
FROM
account
) a
`).all();
return results[0];
},
async userDayCount(c) {
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time,'+8 hours') AS date,
COUNT(*) AS total
FROM
user
WHERE
DATE(create_time,'+8 hours') BETWEEN DATE('now', '-14 days', '+8 hours') AND DATE('now','-1 day','+8 hours')
GROUP BY
DATE(create_time,'+8 hours')
ORDER BY
date ASC
`).all();
return results;
},
async receiveDayCount(c) {
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time,'+8 hours') AS date,
COUNT(*) AS total
FROM
email
WHERE
DATE(create_time,'+8 hours') BETWEEN DATE('now', '-14 days', '+8 hours') AND DATE('now','-1 day','+8 hours')
AND type = 0
GROUP BY
DATE(create_time,'+8 hours')
ORDER BY
date ASC
`).all();
return results;
},
async sendDayCount(c) {
const { results } = await c.env.db.prepare(`
SELECT
DATE(create_time,'+8 hours') AS date,
COUNT(*) AS total
FROM
email
WHERE
DATE(create_time,'+8 hours') BETWEEN DATE('now', '-14 days', '+8 hours') AND DATE('now','-1 day','+8 hours')
AND type = 1
GROUP BY
DATE(create_time,'+8 hours')
ORDER BY
date ASC
`).all();
return results;
}
};
export default analysisDao;

View File

@@ -28,13 +28,20 @@ export async function email(message, env, ctx) {
const email = await PostalMime.parse(content);
console.warn(email)
const params = {
sendEmail: email.from.address,
name: email.from.name,
receiveEmail: message.to,
subject: email.subject,
content: email.html,
text: email.text,
cc: email.cc ? JSON.stringify(email.cc) : '[]',
bcc:email.bcc ? JSON.stringify(email.bcc) : '[]',
recipient: JSON.stringify(email.to),
inReplyTo: email.inReplyTo,
relation: email.references,
messageId: email.messageId,
userId: account.userId,
accountId: account.accountId,
isDel: isDel.DELETE,

View File

@@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm';
export const account = sqliteTable('account', {
accountId: integer('account_id').primaryKey({ autoIncrement: true }),
email: text('email').notNull(),
name: text('name').notNull().default(''),
status: integer('status').default(0).notNull(),
latestEmailTime: text('latest_email_time'),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`),

View File

@@ -4,12 +4,17 @@ export const email = sqliteTable('email', {
emailId: integer('email_id').primaryKey({ autoIncrement: true }),
sendEmail: text('send_email'),
name: text('name'),
receiveEmail: text('receive_email').notNull(),
accountId: integer('account_id').notNull(),
userId: integer('user_id').notNull(),
subject: text('subject'),
text: text('text'),
content: text('content'),
cc: text('cc').default('[]'),
bcc: text('bcc').default('[]'),
recipient: text('recipient'),
inReplyTo: text('in_reply_to').default(''),
relation: text('relation').default(''),
messageId: text('message_id').default(''),
type: integer('type').default(0).notNull(),
status: integer('status').default(0).notNull(),
resendEmailId: text('resend_email_id'),

View File

@@ -15,4 +15,5 @@ import '../api/my-api';
import '../api/role-api'
import '../api/sys-email-api'
import '../api/init-api'
import '../api/analysis-api'
export default app;

View File

@@ -1,6 +1,6 @@
import settingService from './setting-service';
const initService = {
import settingService from '../service/setting-service';
import emailUtils from '../utils/email-utils';
const init = {
async init(c) {
const secret = c.req.param('secret');
@@ -10,12 +10,53 @@ const initService = {
}
await this.intDB(c);
await this.v1DB(c);
await this.v1_1DB(c);
await this.v1_2DB(c);
await settingService.refresh(c);
return c.text('初始化成功');
},
async v1DB(c) {
async v1_2DB(c){
const ADD_COLUMN_SQL_LIST = [
`ALTER TABLE email ADD COLUMN recipient TEXT NOT NULL DEFAULT '[]';`,
`ALTER TABLE email ADD COLUMN cc TEXT NOT NULL DEFAULT '[]';`,
`ALTER TABLE email ADD COLUMN bcc TEXT NOT NULL DEFAULT '[]';`,
`ALTER TABLE email ADD COLUMN message_id TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE email ADD COLUMN in_reply_to TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE email ADD COLUMN relation TEXT NOT NULL DEFAULT '';`
];
for (let sql of ADD_COLUMN_SQL_LIST) {
try {
await c.env.db.prepare(sql).run();
} catch (e) {
console.warn(`跳过字段添加,原因:${e.message}`);
}
}
await this.receiveEmailToRecipient(c);
await this.initAccountName(c);
try {
await c.env.db.prepare(`
INSERT INTO perm (perm_id, name, perm_key, pid, type, sort) VALUES
(31,'分析页', NULL, 0, 1, 2.1),
(32,'数据查看', 'analysis:query', 31, 2, 1)`).run();
} catch (e) {
console.warn(`跳过数据,原因:${e.message}`);
}
try {
await c.env.db.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS idx_account_email ON account (email)`).run();
await c.env.db.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON user (email)`).run();
} catch (e) {
console.warn(`跳过添加唯一邮箱索引,原因:${e.message}`);
}
},
async v1_1DB(c) {
// 添加字段
const ADD_COLUMN_SQL_LIST = [
`ALTER TABLE email ADD COLUMN type INTEGER NOT NULL DEFAULT 0;`,
@@ -81,7 +122,6 @@ const initService = {
(11, '用户删除', 'user:delete', 6, 2, 7),
(12, '用户收藏', 'user:star', 6, 2, 5),
(13, '权限控制', '', 0, 1, 5),
(30, '身份添加', 'role:add', 13, 2, -1),
(14, '身份查看', 'role:query', 13, 2, 0),
(15, '身份修改', 'role:set', 13, 2, 1),
(16, '身份删除', 'role:delete', 13, 2, 2),
@@ -97,7 +137,8 @@ const initService = {
(26, '发件重置', 'user:reset-send', 6, 2, 6),
(27, '邮件列表', '', 0, 1, 4),
(28, '邮件查看', 'sys-email:query', 27, 2, 0),
(29, '邮件删除', 'sys-email:delete', 27, 2, 0)
(29, '邮件删除', 'sys-email:delete', 27, 2, 0),
(30, '身份添加', 'role:add', 13, 2, -1)
`).run();
}
@@ -163,7 +204,6 @@ const initService = {
email_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
send_email TEXT,
name TEXT,
receive_email TEXT NOT NULL,
account_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
subject TEXT,
@@ -247,7 +287,55 @@ const initService = {
SELECT 0, 0, 0, 1, 'Cloud 邮箱', 0, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM setting)
`).run();
},
async receiveEmailToRecipient(c) {
const receiveEmailColumn = await c.env.db.prepare(`SELECT * FROM pragma_table_info('email') WHERE name = 'receive_email' limit 1`).first();
if (!receiveEmailColumn) {
return
}
const queryList = []
const {results} = await c.env.db.prepare('SELECT receive_email,email_id FROM email').all();
results.forEach(emailRow => {
const recipient = {}
recipient.address = emailRow.receive_email
recipient.name = ''
const recipientStr = JSON.stringify([recipient]);
const sql = c.env.db.prepare('UPDATE email SET recipient = ? WHERE email_id = ?').bind(recipientStr,emailRow.email_id);
queryList.push(sql)
})
queryList.push(c.env.db.prepare("ALTER TABLE email DROP COLUMN receive_email"));
await c.env.db.batch(queryList);
},
async initAccountName(c) {
const nameColumn = await c.env.db.prepare(`SELECT * FROM pragma_table_info('account') WHERE name = 'name' limit 1`).first();
if (nameColumn) {
return
}
const queryList = []
queryList.push(c.env.db.prepare(`ALTER TABLE account ADD COLUMN name TEXT NOT NULL DEFAULT ''`));
const {results} = await c.env.db.prepare(`SELECT account_id, email FROM account`).all();
results.forEach(accountRow => {
const name = emailUtils.getName(accountRow.email);
const sql = c.env.db.prepare('UPDATE account SET name = ? WHERE account_id = ?').bind(name,accountRow.account_id);
queryList.push(sql)
})
await c.env.db.batch(queryList);
}
};
export default initService;
export default init;

View File

@@ -65,7 +65,8 @@ const premKey = {
'sys-email:delete': ['/sys-email/delete'],
'setting:query': [],
'setting:set': ['/setting/set', '/setting/setBackground'],
'setting:clean': ['/setting/physicsDeleteAll']
'setting:clean': ['/setting/physicsDeleteAll'],
'analysis:query': ['/analysis/echarts']
};
app.use('*', async (c, next) => {

View File

@@ -5,7 +5,7 @@ import userService from './user-service';
import emailService from './email-service';
import orm from '../entity/orm';
import account from '../entity/account';
import { and, asc, eq, gt, inArray, count, like } from 'drizzle-orm';
import { and, asc, eq, gt, inArray, count, sql } from 'drizzle-orm';
import { isDel } from '../const/entity-const';
import settingService from './setting-service';
import turnstileService from './turnstile-service';
@@ -33,7 +33,7 @@ const accountService = {
throw new BizError('不存在的邮箱域名');
}
const accountRow = await this.selectByEmailIncludeDel(c, email);
const accountRow = await this.selectByEmailIncludeDelNoCase(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
@@ -55,9 +55,16 @@ const accountService = {
await turnstileService.verify(c, token);
}
return orm(c).insert(account).values({ email: email, userId: userId }).returning().get();
return orm(c).insert(account).values({ email: email, userId: userId, name: emailUtils.getName(email) }).returning().get();
},
selectByEmailIncludeDelNoCase(c, email) {
return orm(c)
.select()
.from(account)
.where(sql`${account.email} COLLATE NOCASE = ${email}`)
.get();
},
selectByEmailIncludeDel(c, email) {
return orm(c).select().from(account).where(eq(account.email, email)).get();
},
@@ -170,6 +177,11 @@ const accountService = {
async restoreByUserId(c, userId) {
await orm(c).update(account).set({isDel: isDel.NORMAL}).where(eq(account.userId, userId)).run();
},
async setName(c, params, userId) {
const { name, accountId } = params
await orm(c).update(account).set({name}).where(and(eq(account.userId, userId),eq(account.accountId, accountId))).run();
}
};

View File

@@ -0,0 +1,88 @@
import analysisDao from '../dao/analysis-dao';
import orm from '../entity/orm';
import email from '../entity/email';
import { desc, count, eq } from 'drizzle-orm';
import { emailConst } from '../const/entity-const';
import kvConst from '../const/kv-const';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(timezone)
const analysisService = {
async echarts(c) {
const [
numberCount,
nameRatio,
sendEmailRatio,
userDayCountRaw,
receiveDayCountRaw,
sendDayCountRaw,
daySendTotalRaw
] = await Promise.all([
analysisDao.numberCount(c),
orm(c)
.select({ name: email.name, total: count() })
.from(email)
.where(eq(email.type, emailConst.type.RECEIVE))
.groupBy(email.name)
.orderBy(desc(count()))
.limit(6),
orm(c)
.select({ email: email.sendEmail, total: count() })
.from(email)
.where(eq(email.type, emailConst.type.RECEIVE))
.groupBy(email.sendEmail)
.orderBy(desc(count()))
.limit(6),
analysisDao.userDayCount(c),
analysisDao.receiveDayCount(c),
analysisDao.sendDayCount(c),
c.env.kv.get(kvConst.SEND_DAY_COUNT + dayjs().format('YYYY-MM-DD')),
]);
const userDayCount = this.filterEmptyDay(userDayCountRaw);
const receiveDayCount = this.filterEmptyDay(receiveDayCountRaw);
const sendDayCount = this.filterEmptyDay(sendDayCountRaw);
const daySendTotal = daySendTotalRaw || 0;
return {
numberCount,
userDayCount,
receiveRatio: {
nameRatio,
sendEmailRatio
},
emailDayCount: {
receiveDayCount,
sendDayCount
},
daySendTotal: Number(daySendTotal)
};
},
filterEmptyDay(data) {
const today = dayjs().tz('Asia/Shanghai').subtract(1, 'day');
const previousDays = Array.from({ length: 15 }, (_, i) => {
return today.subtract(i, 'day').format('YYYY-MM-DD');
}).reverse();
return previousDays.map(day => {
const index = data.findIndex(item => item.date === day)
const total = index > - 1 ? data[index].total : 0
return {date: day,total}
})
}
}
export default analysisService

View File

@@ -15,6 +15,9 @@ import roleService from './role-service';
import user from '../entity/user';
import account from '../entity/account';
import starService from './star-service';
import dayjs from 'dayjs';
import kvConst from '../const/kv-const';
import constant from '../const/constant';
const emailService = {
@@ -146,11 +149,10 @@ const emailService = {
async send(c, params, userId) {
let { accountId, name, receiveEmail, text, content, subject, attachments } = params;
let { accountId, name, sendType, emailId, receiveEmail, manyType, text, content, subject, attachments } = params;
const { resendTokens, r2Domain, send } = await settingService.query(c);
const { attDataList, html } = await attService.toImageUrlHtml(c, content, r2Domain);
if (attDataList.length > 0 && !r2Domain) {
@@ -173,13 +175,26 @@ const emailService = {
throw new BizError('邮箱发送功能已停用', 403);
}
if (attachments.length > 0 && manyType === 'divide') {
throw new BizError('分别发送暂时不支持附件');
}
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
if (userRow.sendCount >= roleRow.sendCount && c.env.admin !== userRow.email) {
if (roleRow.sendType === 'day') throw new BizError('已到达每日发送数量限制', 403);
if (roleRow.sendType === 'count') throw new BizError('已到达发送数量限制', 403);
if (c.env.admin !== userRow.email && roleRow.sendCount) {
if (userRow.sendCount >= roleRow.sendCount) {
if (roleRow.sendType === 'day') throw new BizError('已到达每日发送次数限制', 403);
if (roleRow.sendType === 'count') throw new BizError('已到达发送次数限制', 403);
}
if (userRow.sendCount + receiveEmail.length > roleRow.sendCount) {
if (roleRow.sendType === 'day') throw new BizError('剩余每日发送次数不足', 403);
if (roleRow.sendType === 'count') throw new BizError('剩余发送次数不足', 403);
}
}
@@ -203,53 +218,161 @@ const emailService = {
name = emailUtils.getName(accountRow.email);
}
let emailRow = {
messageId: null
};
if (sendType === 'reply') {
emailRow = await this.selectById(c, emailId);
if (!emailRow) {
throw new BizError('邮件不存在无法回复');
}
}
let resendResult = null;
const resend = new Resend(resendToken);
const { data, error } = await resend.emails.send({
from: `${name} <${accountRow.email}>`,
to: [receiveEmail],
subject: subject,
text: text,
html: html,
attachments: attachments
});
if (manyType === 'divide') {
let sendFormList = [];
receiveEmail.forEach(email => {
const sendForm = {
from: `${name} <${accountRow.email}>`,
to: [email],
subject: subject,
text: text,
html: html
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
sendFormList.push(sendForm);
});
resendResult = await resend.batch.send(sendFormList);
} else {
const sendForm = {
from: `${name} <${accountRow.email}>`,
to: [...receiveEmail],
subject: subject,
text: text,
html: html,
attachments: attachments
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
resendResult = await resend.emails.send(sendForm);
}
const { data, error } = resendResult;
if (error) {
console.error(error);
throw new BizError(error.message);
}
const emailData = {};
emailData.sendEmail = accountRow.email;
emailData.name = name;
emailData.subject = subject;
emailData.receiveEmail = receiveEmail;
emailData.content = html;
emailData.text = text;
emailData.accountId = accountId;
emailData.type = emailConst.type.SEND;
emailData.userId = userId;
emailData.status = emailConst.status.SENT;
emailData.resendEmailId = data.id;
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
const emailDataList = [];
if (attDataList.length > 0) {
await attService.saveArticleAtt(c, attDataList, userId, accountId, emailRow.emailId);
if (manyType === 'divide') {
receiveEmail.forEach((item, index) => {
const emailDataItem = { ...emailData };
emailDataItem.resendEmailId = data.data[index].id;
emailDataItem.recipient = JSON.stringify([{ address: item, name: '' }]);
emailDataList.push(emailDataItem);
});
} else {
emailData.resendEmailId = data.id;
const recipient = [];
receiveEmail.forEach(item => {
recipient.push({ address: item, name: '' });
});
emailData.recipient = JSON.stringify(recipient);
emailDataList.push(emailData);
}
if (sendType === 'reply') {
emailDataList.forEach(emailData => {
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
});
}
if (roleRow.sendCount) {
await userService.incrUserService(c, 1, userId);
await userService.incrUserSendCount(c, receiveEmail.length, userId);
}
if (attachments?.length > 0 && c.env.r2) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
const emailRowList = await Promise.all(
emailDataList.map(async (emailData) => {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
if (attDataList.length > 0) {
await attService.saveArticleAtt(c, attDataList, userId, accountId, emailRow.emailId);
}
if (attachments?.length > 0 && c.env.r2) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
}
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
})
);
const dateStr = dayjs().format('YYYY-MM-DD');
let daySendTotal = await c.env.kv.get(kvConst.SEND_DAY_COUNT + dateStr);
if (!daySendTotal) {
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(receiveEmail.length), { expirationTtl: 60 * 60 * 24 });
} else {
daySendTotal = Number(daySendTotal) + receiveEmail.length
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(daySendTotal), { expirationTtl: 60 * 60 * 24 });
}
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
return emailRowList;
},
selectById(c, emailId) {
@@ -398,7 +521,7 @@ const emailService = {
conditions.push(like(email.subject, `${subject}%`));
}
conditions.push(ne(email.status, emailConst.status.SAVING))
conditions.push(ne(email.status, emailConst.status.SAVING));
const countConditions = [...conditions];
@@ -447,7 +570,10 @@ const emailService = {
},
async completeReceive(c, emailId) {
await orm(c).update(email).set({ isDel: isDel.NORMAL, status: emailConst.status.RECEIVE }).where(eq(email.emailId, emailId)).run();
await orm(c).update(email).set({
isDel: isDel.NORMAL,
status: emailConst.status.RECEIVE
}).where(eq(email.emailId, emailId)).run();
}
};

View File

@@ -38,7 +38,7 @@ const loginService = {
throw new BizError('非法邮箱域名');
}
const accountRow = await accountService.selectByEmailIncludeDel(c, email);
const accountRow = await accountService.selectByEmailIncludeDelNoCase(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
@@ -60,7 +60,7 @@ const loginService = {
await userService.updateUserInfo(c, userId, true);
await accountService.insert(c, { userId: userId, email });
await accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
},
async login(c, params) {
@@ -110,7 +110,6 @@ const loginService = {
}
await userService.updateUserInfo(c, userRow.userId);
await c.env.kv.put(KvConst.AUTH_INFO + userRow.userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE });

View File

@@ -33,6 +33,7 @@ const userService = {
user.sendCount = userRow.sendCount;
user.email = userRow.email;
user.accountId = account.accountId;
user.name = account.name;
user.permKeys = permKeys;
user.role = roleRow
@@ -304,7 +305,7 @@ const userService = {
},
async incrUserService(c, quantity, userId) {
async incrUserSendCount(c, quantity, userId) {
await orm(c).update(user).set({
sendCount: sql`${user.sendCount}
+
@@ -352,7 +353,7 @@ const userService = {
const userId = await userService.insert(c, { email, password: hash, salt, type });
await accountService.insert(c, { userId: userId, email, type });
await accountService.insert(c, { userId: userId, email, type, name: emailUtils.getName(email) });
},
async resetDaySendCount(c) {