新增注册码、草稿箱、权限拦截邮件、发件菜单隐藏

This commit is contained in:
eoao
2025-07-15 20:09:56 +08:00
parent 54477dde22
commit a66c9cfa31
55 changed files with 1819 additions and 361 deletions
+7 -7
View File
@@ -14,13 +14,13 @@
"compressorjs": "^1.2.1",
"date-time-format-timezone": "^1.0.22",
"dayjs": "^1.11.13",
"dexie": "^4.0.11",
"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",
@@ -1826,6 +1826,12 @@
"node": ">=0.10"
}
},
"node_modules/dexie": {
"version": "4.0.11",
"resolved": "https://registry.npmmirror.com/dexie/-/dexie-4.0.11.tgz",
"integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==",
"license": "Apache-2.0"
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.5.0.tgz",
@@ -2954,12 +2960,6 @@
"pathe": "^2.0.3"
}
},
"node_modules/postal-mime": {
"version": "2.4.3",
"resolved": "https://registry.npmmirror.com/postal-mime/-/postal-mime-2.4.3.tgz",
"integrity": "sha512-KT8P+/We7LH48EhjmLRLEiQx+HI6zZ5TTeuzhuZkMbcJnp9mdmxm/FgiRIzmhZm8mxmk8mtisIAelq1wNwumxg==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz",
+1
View File
@@ -16,6 +16,7 @@
"compressorjs": "^1.2.1",
"date-time-format-timezone": "^1.0.22",
"dayjs": "^1.11.13",
"dexie": "^4.0.11",
"echarts": "^5.6.0",
"element-plus": "^2.9.5",
"lodash-es": "^4.17.21",
+3 -1
View File
@@ -51,7 +51,9 @@ http.interceptors.response.use((res) => {
})
reject(data)
}
resolve(data.data)
setTimeout(() => {
resolve(data.data)
},1000)
})
},
(error) => {
+49 -14
View File
@@ -2,10 +2,10 @@
<div class="email-container">
<div class="header-actions">
<el-checkbox
v-model="checkAll"
:indeterminate="isIndeterminate"
:disabled="!emailList.length"
@change="handleCheckAllChange"
v-model="checkAll"
:indeterminate="isIndeterminate"
:disabled="!emailList.length"
@change="handleCheckAllChange"
>
</el-checkbox>
<div class="header-left" :style="'padding-left:' + actionLeft">
@@ -33,7 +33,7 @@
:data-checked="item.checked"
@click="jumpDetails(item)"
>
<el-checkbox v-model="item.checked" @click.stop></el-checkbox>
<el-checkbox :class=" props.type === 'sys-email' ? 'sys-email-checkbox' : 'checkbox'" v-model="item.checked" @click.stop></el-checkbox>
<div @click.stop="starChange(item)" class="pc-star" v-if="showStar">
<Icon v-if="item.isStar" icon="fluent-color:star-16" width="20" height="20"/>
<Icon v-else icon="solar:star-line-duotone" width="18" height="18"/>
@@ -102,7 +102,9 @@
</div>
<div v-else></div>
<span class="name">
<span>{{ item.name }}</span>
<span>
<slot name="name" :email="item"> {{ item.name }}</slot>
</span>
<span>
<Icon v-if="item.isStar" icon="fluent-color:star-16" width="18" height="18"/>
</span>
@@ -111,7 +113,11 @@
</div>
<div>
<div class="email-text">
<span class="email-subject">{{ item.subject }}</span>
<span class="email-subject">
<slot name="subject" :email="item">
{{ item.subject }}
</slot>
</span>
<span class="email-content">{{ htmlToText(item) }}</span>
</div>
<div class="user-info" v-if="showUserInfo">
@@ -128,7 +134,7 @@
<span>{{ item.type === 0 ? item.toEmail : item.sendEmail }}</span>
</div>
<div class="del-status" v-if="item.isDel">
<el-tag type="info" size="small">已删除</el-tag>
<el-tag type="danger" size="small">已删除</el-tag>
</div>
</div>
</div>
@@ -201,11 +207,15 @@ const props = defineProps({
allowStar: {
type: Boolean,
default: true
},
type: {
type: String,
default: ''
}
})
const emit = defineEmits(['jump', 'refresh-before'])
const emit = defineEmits(['jump', 'refresh-before', 'delete-draft'])
const settingStore = useSettingStore()
const uiStore = useUiStore();
@@ -355,13 +365,19 @@ function changeAccountShow() {
uiStore.accountShow = !uiStore.accountShow;
}
const handleDelete = () => {
ElMessageBox.confirm('确认批量删除这些邮件吗?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
if (props.type === 'draft') {
const draftIds = getSelectedDraftsIds();
emit('delete-draft', draftIds);
return;
}
const emailIds = getSelectedMailsIds();
props.emailDelete(emailIds).then(() => {
ElMessage({
@@ -395,7 +411,6 @@ function addItem(email) {
return
}
if (props.timeSort) {
if (noLoading.value) {
emailList.push(email)
@@ -430,6 +445,9 @@ function getSelectedMailsIds() {
return emailList.filter(item => item.checked).map(item => item.emailId);
}
function getSelectedDraftsIds() {
return emailList.filter(item => item.checked).map(item => item.draftId);
}
function updateCheckStatus() {
const checkedCount = emailList.filter(item => item.checked).length;
@@ -543,6 +561,7 @@ function loadData() {
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
.noLoading {
@@ -625,13 +644,26 @@ function loadData() {
}
}
.el-checkbox {
.checkbox {
display: flex;
padding-left: 15px;
padding-right: 20px;
justify-content: center;
}
.sys-email-checkbox {
display: flex;
padding-left: 15px;
padding-right: 20px;
justify-content: center;
@media (min-width: 1200px) {
justify-content: start;
height: 100%;
align-self: start;
padding-top: 3px;
}
}
.title-column {
@media (max-width: 1200px) {
grid-template-columns: 1fr !important;
@@ -642,7 +674,7 @@ function loadData() {
.title {
flex: 1;
display: grid;
grid-template-columns: 220px 1fr;
grid-template-columns: 240px 1fr;
@media (max-width: 1199px) {
padding-right: 15px;
}
@@ -702,6 +734,9 @@ function loadData() {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@media (min-width: 1200px) {
padding-left: 5px;
}
}
.email-content {
@@ -788,7 +823,7 @@ function loadData() {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 15px;
column-gap: 18px;
row-gap: 8px;
padding-left: 2px;
}
@@ -6,12 +6,13 @@
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount, watch, nextTick} from 'vue';
import {ref, onMounted, onBeforeUnmount, watch, nextTick, shallowRef, defineEmits} from 'vue';
import loading from "@/components/loading/index.vue";
import {compressImage} from "@/utils/file-utils.js";
defineExpose({
clearEditor,
focus
focus,
getContent
})
const props = defineProps({
@@ -27,7 +28,7 @@ const props = defineProps({
const emit = defineEmits(['change']);
const editor = ref(null);
const editor = shallowRef(null);
const isInitialized = ref(false);
const editorRef = ref(null);
const showLoading = ref(false);
@@ -162,6 +163,10 @@ function focus() {
})
}
function getContent() {
return editor.value.getContent()
}
function destroyEditor() {
if (editor.value) {
+25
View File
@@ -0,0 +1,25 @@
import Dexie from "dexie";
import {useUserStore} from "@/store/user.js"
import { watch, shallowRef } from "vue";
const userStore = useUserStore();
let db = shallowRef({})
function createDB() {
db.value = new Dexie(userStore.user.email);
db.value.version(1).stores({
draft: '++draftId,createTime'
})
db.value.version(1).stores({
att: 'draftId'
})
}
createDB()
watch(() => userStore.user.email,() => createDB())
export default db;
+21 -8
View File
@@ -1,8 +1,8 @@
<template>
<div class="account-box">
<div class="head-opt" >
<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" />
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add" />
<Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh" />
</div>
<el-scrollbar class="scrollbar">
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false">
@@ -16,11 +16,12 @@
</div>
<div class="settings" @click.stop>
<Icon icon="streamline-ultimate-color:copy-paste-1" width="19" height="19" @click.stop="copyAccount(item.email)"/>
<el-dropdown>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" />
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" v-if="showNullSetting(item)" />
<el-dropdown v-else>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" />
<template #dropdown >
<el-dropdown-menu>
<el-dropdown-item @click="openSetName(item)">改名</el-dropdown-item>
<el-dropdown-menu >
<el-dropdown-item v-if="hasPerm('email:send')" @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>
@@ -217,6 +218,10 @@ function openSetName (accountItem) {
setNameShow.value = true
}
function showNullSetting(item) {
return !hasPerm('email:send') && !(item.accountId !== userStore.user.accountId && hasPerm('account:delete'))
}
function itemBg(accountId) {
return accountStore.currentAccountId === accountId ? 'item-choose' : ''
}
@@ -388,8 +393,16 @@ path[fill="#ffdda1"] {
.icon{
cursor: pointer;
}
.icon:nth-child(2) {
margin-left: 15px;
.refresh {
margin-left: 10px;
}
.add {
margin-left: 2px;
}
.head-opt:not(.add) .refresh {
margin-left: 5px;
}
}
.scrollbar {
+12 -2
View File
@@ -11,11 +11,16 @@
<Icon icon="hugeicons:mailbox-01" width="20" height="20" />
<span class="menu-name" style="margin-left: 21px">收件箱</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'send'})" index="send"
<el-menu-item @click="router.push({name: 'send'})" index="send" v-perm="'email:send'"
:class="route.meta.name === 'send' ? 'choose-item' : ''">
<Icon icon="cil:send" width="20" height="20" />
<span class="menu-name" style="margin-left: 21px">已发送</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'draft'})" index="draft" v-perm="'email:send'"
:class="route.meta.name === 'draft' ? 'choose-item' : ''">
<Icon icon="ep:document" width="19" height="19" />
<span class="menu-name" style="margin-left: 22px">草稿箱</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'star'})" index="star"
:class="route.meta.name === 'star' ? 'choose-item' : ''">
<Icon icon="solar:star-line-duotone" width="20" height="20" />
@@ -46,9 +51,14 @@
</el-menu-item>
<el-menu-item @click="router.push({name: 'role'})" index="setting" v-perm="'role:query'"
:class="route.meta.name === 'role' ? 'choose-item' : ''">
<Icon icon="hugeicons:key-02" width="22" height="22" />
<Icon icon="fluent:lock-closed-16-regular" width="22" height="22" />
<span class="menu-name" style="margin-left: 20px">权限控制</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'reg-key'})" index="reg-key" v-perm="'reg-key:query'"
:class="route.meta.name === 'reg-key' ? 'choose-item' : ''">
<Icon icon="fluent:fingerprint-20-filled" width="22" height="22" />
<span class="menu-name" style="margin-left: 20px">注册密钥</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'sys-setting'})" index="sys-setting" v-perm="'setting:query'"
:class="route.meta.name === 'sys-setting' ? 'choose-item' : ''">
<Icon icon="eos-icons:system-ok-outlined" width="18" height="18" />
+16 -3
View File
@@ -1,10 +1,10 @@
<template>
<div class="header">
<div class="header" :class="!hasPerm('email:send') ? 'not-send' : ''">
<div class="header-btn">
<hanburger @click="changeAside"></hanburger>
<span class="breadcrumb-item">{{ route.meta.title }}</span>
</div>
<div class="writer-box" @click="openSend">
<div v-perm="'email:send'" class="writer-box" @click="openSend">
<div class="writer" >
<Icon icon="material-symbols:edit-outline-sharp" width="22" height="22" />
</div>
@@ -95,10 +95,14 @@ const sendType = computed(() => {
return '无权限'
}
if (userStore.user.role.sendCount === 0) {
if (!userStore.user.role.sendCount) {
return '无限制'
}
if (userStore.user.role.sendCount < 0) {
return '无次数'
}
if (userStore.user.role.sendType === 'day') {
return '每天'
}
@@ -116,6 +120,11 @@ const sendCount = computed(() => {
if (!userStore.user.role.sendCount) {
return null
}
if (userStore.user.role.sendCount < 0) {
return null
}
return userStore.user.sendCount + '/' + userStore.user.role.sendCount
})
@@ -248,6 +257,10 @@ function full() {
grid-template-columns: auto auto 1fr;
}
.header.not-send {
grid-template-columns: auto 1fr;
}
.writer-box {
cursor: pointer;
display: flex;
+1 -1
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','analysis']">
<keep-alive :include="['email','sys-email','send','sys-setting','star','user','role','analysis','reg-key','draft']">
<component :is="Component" :key="route.name"/>
</keep-alive>
</router-view>
+101 -16
View File
@@ -1,5 +1,5 @@
<template>
<div class="send" v-show="show" @click="close">
<div class="send" v-show="show">
<div class="write-box" @click.stop>
<div class="title">
<div class="title-left">
@@ -15,7 +15,7 @@
</div>
</div>
<div class="container">
<el-input-tag @add-tag="addTagChange" tag-type="primary" size="default" v-model="form.receiveEmail" placeholder="多个箱用, 分开 example1.com,example2.com" >
<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>
</template>
@@ -56,7 +56,7 @@
</template>
<script setup>
import tinyEditor from '@/components/tiny-editor/index.vue'
import {h, onMounted, onUnmounted, reactive, ref} from "vue";
import {h, nextTick, onMounted, onUnmounted, reactive, ref, toRaw} from "vue";
import {Icon} from "@iconify/vue";
import {useUserStore} from "@/store/user.js";
import {emailSend} from "@/request/email.js";
@@ -66,14 +66,19 @@ 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";
import {formatDetailDate, fromNow} from "@/utils/day.js";
import {useSettingStore} from "@/store/setting.js";
import {userDraftStore} from "@/store/draft.js";
import db from "@/db/db.js";
import dayjs from "dayjs";
defineExpose({
open,
openReply
openReply,
openDraft
})
const draftStore = userDraftStore()
const settingStore = useSettingStore()
const emailStore = useEmailStore();
const accountStore = useAccountStore()
@@ -84,6 +89,12 @@ const percent = ref(0)
let percentMessage = null
let sending = false
const defValue = ref('')
const backReply = reactive({
receiveEmail: [],
subject: '',
content: '',
sendType: ''
})
const form = reactive({
sendEmail: '',
receiveEmail: [],
@@ -95,7 +106,8 @@ const form = reactive({
sendType: '',
text: '',
emailId: 0,
attachments: []
attachments: [],
draftId: null,
})
function addTagChange(val) {
@@ -110,10 +122,8 @@ function addTagChange(val) {
if (isEmail(email) && !form.receiveEmail.includes(email)) {
form.receiveEmail.push(email)
}
})
}
function checkDistribute() {
@@ -155,7 +165,6 @@ function chooseFile() {
})
return
}
const content = await fileToBase64(file)
form.attachments.push({content, filename, size, contentType})
}
@@ -217,7 +226,9 @@ async function sendEmail() {
})
sending = true
close()
show.value = false
emailSend(form, (e) => {
percent.value = Math.round((e.loaded * 98) / e.total)
}).then(emailList => {
@@ -225,15 +236,25 @@ async function sendEmail() {
emailList.forEach(item => {
emailStore.sendScroll?.addItem(item)
})
resetForm()
show.value = false
ElNotification({
title: '邮件已发送',
type: "success",
message: h('span', { style: 'color: teal' }, email.subject),
position: 'bottom-right'
})
userStore.refreshUserInfo();
if (form.draftId) {
form.subject = ''
form.content = ''
form.receiveEmail = []
draftStore.setDraft = {...toRaw(form)}
}
resetForm()
show.value = false
}).catch((e) => {
ElNotification({
title: '发送失败',
@@ -241,6 +262,7 @@ async function sendEmail() {
message: h('span', { style: 'color: teal' }, e.message),
position: 'bottom-right'
})
show.value = true
}).finally(() => {
percentMessage.close()
percent.value = 0
@@ -255,8 +277,13 @@ function resetForm() {
form.content = ''
form.manyType = null
form.attachments = []
form.sendType = null
form.sendType = ''
form.emailId = 0
form.draftId = null
backReply.content = ''
backReply.subject = ''
backReply.receiveEmail = []
backReply.sendType = ''
editor.value.clearEditor()
}
@@ -291,7 +318,13 @@ function openReply(email) {
</article>
</blockquote>`
open()
console.log(defValue.value)
nextTick(() => {
backReply.content = editor.value.getContent()
backReply.subject = form.subject
backReply.receiveEmail = form.receiveEmail
backReply.sendType = form.sendType
})
})
}
@@ -299,7 +332,6 @@ function openReply(email) {
function formatImage(content) {
content = content || '';
const domain = settingStore.settings.r2Domain;
console.log(content)
return content.replace(/{{domain}}/g, domain + '/');
}
@@ -317,6 +349,14 @@ function open() {
editor.value.focus()
}
function openDraft(draft) {
Object.assign(form,{...draft})
defValue.value = ''
setTimeout(() => defValue.value = form.content)
show.value = true;
editor.value.focus()
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
close()
@@ -332,7 +372,52 @@ onUnmounted(() => {
});
function close() {
show.value = false;
if (form.draftId) {
draftStore.setDraft = {...toRaw(form)}
show.value = false
resetForm()
return;
}
if (!(form.content || form.subject || form.receiveEmail.length > 0)) {
show.value = false
resetForm()
return;
}
if (backReply.sendType === 'reply') {
let subjectFlag = form.subject === backReply.subject
let contentFlag = editor.value.getContent() === backReply.content
let receiveFlag = form.receiveEmail.length === 1 && form.receiveEmail[0] === backReply.receiveEmail[0]
if (subjectFlag && contentFlag && receiveFlag) {
resetForm();
close()
return;
}
}
ElMessageBox.confirm('是否保存草稿?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}).then( async () => {
const formData = {...toRaw(form)}
delete formData.draftId
delete formData.attachments
formData.createTime = dayjs().utc().format('YYYY-MM-DD HH:mm:ss');
const draftId = await db.value.draft.add({...formData})
db.value.att.add({draftId,attachments: toRaw(form.attachments)})
draftStore.refreshList ++
show.value = false
}).catch((action) => {
if (action === 'cancel') {
show.value = false
resetForm()
}
})
}
</script>
+21
View File
@@ -0,0 +1,21 @@
import http from '@/axios/index.js';
export function regKeyList(params) {
return http.get('/regKey/list', {params:{...params}})
}
export function regKeyAdd(form) {
return http.post('/regKey/add',form)
}
export function regKeyDelete(regKeyIds) {
return http.delete('/regKey/delete?regKeyIds='+ regKeyIds)
}
export function regKeyClearNotUse() {
return http.delete('/regKey/clearNotUse')
}
export function regKeyHistory(regKeyId) {
return http.get('/regKey/history', {params:{regKeyId}})
}
+3 -3
View File
@@ -1,9 +1,9 @@
import http from '@/axios/index.js';
export function sysEmailAll(params) {
return http.get('/sys-email/list', {params: {...params}})
export function sysEmailList(params) {
return http.get('/sysEmail/list', {params: {...params}})
}
export function sysEmailDelete(emailIds) {
return http.delete('/sys-email/delete?emailIds=' + emailIds)
return http.delete('/sysEmail/delete?emailIds=' + emailIds)
}
+10
View File
@@ -28,6 +28,16 @@ const routes = [
menu: true
}
},
{
path: '/draft',
name: 'draft',
component: () => import('@/views/draft/index.vue'),
meta: {
title: '草稿箱',
name: 'draft',
menu: true
}
},
{
path: '/content',
name: 'content',
+8
View File
@@ -0,0 +1,8 @@
import { defineStore } from 'pinia'
export const userDraftStore = defineStore('draft', {
state: () => ({
refreshList: 0,
setDraft: {},
})
})
+4
View File
@@ -40,4 +40,8 @@ export function formatDetailDate(time) {
export function tzDayjs(time) {
return dayjs.utc(time).tz('Asia/Shanghai')
}
export function toUtc() {
return dayjs().utc()
}
+10
View File
@@ -47,6 +47,16 @@ const routers = {
menu: true
}
},
'reg-key:query': {
path: '/sys/reg-key',
name: 'reg-key',
component: () => import('@/views/reg-key/index.vue'),
meta: {
title: '注册密钥',
name: 'reg-key',
menu: true
}
},
'sys-email:query': {
path: '/sys/email',
name: 'sys-email',
+9
View File
@@ -0,0 +1,9 @@
export function getTextWidth(text, font = '14px sans-serif') {
// 强制设置 Canvas 分辨率
const canvas = document.createElement('canvas');
canvas.width = 2000; // 足够大的画布
canvas.style.width = '1000px'; // 避免 CSS 缩放影响
const ctx = canvas.getContext('2d');
ctx.font = font;
return ctx.measureText(text).width;
}
+2 -2
View File
@@ -680,7 +680,7 @@ function createSendGauge() {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 20px;
@media (max-width: 1024px) {
@media (max-width: 1199px) {
grid-template-columns: 1fr 1fr;
gap: 15px;
}
@@ -691,7 +691,7 @@ function createSendGauge() {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
padding: 25px 20px;
padding: 21px 20px;
.top {
display: grid;
justify-content: space-between;
+104
View File
@@ -0,0 +1,104 @@
<template>
<emailScroll ref="scroll"
:allow-star="false"
:cancel-success="cancelStar"
:getEmailList="getEmailList"
:emailDelete="emailDelete"
:star-add="starAdd"
:star-cancel="starCancel"
@jump="jumpContent"
actionLeft="6px"
:show-account-icon="false"
:showStar="false"
@delete-draft="deleteDraft"
:type="'draft'"
>
<template #name="props">
<span class="send-email" >{{props.email.receiveEmail.join(',') || '(无收件人)'}}</span>
</template>
<template #subject="props" >
{{props.email.subject || '(无标题)'}}
</template>
</emailScroll>
</template>
<script setup>
import emailScroll from "@/components/email-scroll/index.vue"
import {emailDelete} from "@/request/email.js";
import {starAdd, starCancel} from "@/request/star.js";
import {useEmailStore} from "@/store/email.js";
import {defineOptions, onMounted, ref, watch, toRaw} from "vue";
import {useUiStore} from "@/store/ui.js";
import {userDraftStore} from "@/store/draft.js";
import db from "@/db/db.js"
defineOptions({
name: 'draft'
})
const draftStore = userDraftStore();
const uiStore = useUiStore();
const scroll = ref({})
const emailStore = useEmailStore();
watch(() => draftStore.setDraft, async () => {
const draft = toRaw(draftStore.setDraft)
const draftId = draft.draftId
const attachments = toRaw(draftStore.setDraft.attachments)
delete draft.draftId
delete draft.attachments
if (!draft.content && !draft.subject && !(draft.receiveEmail.length > 0)) {
await db.value.draft.delete(draftId);
await db.value.att.delete(draftId);
scroll.value.refreshList();
return;
}
await db.value.draft.update(draftId, draft);
await db.value.att.update(draftId, {attachments: attachments});
scroll.value.refreshList();
},{
deep: true
})
watch(() => draftStore.refreshList,() => {
scroll.value.refreshList();
})
function getEmailList() {
return new Promise((resolve, reject) => {
db.value.draft.orderBy('createTime').reverse().toArray().then(list => {
resolve({list})
})
})
}
async function deleteDraft(draftIds) {
await db.value.draft.bulkDelete(draftIds);
scroll.value.refreshList();
}
async function jumpContent(email) {
const att = await db.value.att.get(email.draftId)
email.attachments = att.attachments
uiStore.writerRef.openDraft(email);
}
function cancelStar(email) {
emailStore.cancelStarEmailId = email.emailId
scroll.value.deleteEmail([email.emailId])
}
onMounted(() => {
emailStore.starScroll = scroll
})
</script>
<style>
.send-email {
font-weight: normal;
}
</style>
+28 -2
View File
@@ -69,6 +69,8 @@
</el-input>
<el-input v-model="registerForm.password" placeholder="密码" type="password" autocomplete="off" />
<el-input v-model="registerForm.confirmPassword" placeholder="确认密码" type="password" autocomplete="off" />
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" placeholder="注册码" type="text" autocomplete="off" />
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code" placeholder="注册码(可选)" type="text" autocomplete="off" />
<div v-show="verifyShow"
class="register-turnstile"
:data-sitekey="settingStore.settings.siteKey"
@@ -116,7 +118,8 @@ const suffix = ref('')
const registerForm = reactive({
email: '',
password: '',
confirmPassword: ''
confirmPassword: '',
code: null
})
const domainList = settingStore.domainList;
const registerLoading = ref(false)
@@ -248,6 +251,20 @@ function submitRegister() {
return
}
if(settingStore.settings.regKey === 0) {
if (!registerForm.code) {
ElMessage({
message: '注册码不能为空',
type: 'error',
plain: true,
})
return
}
}
if (!verifyToken && settingStore.settings.registerVerify === 0) {
verifyShow.value = true
if (!turnstileId) {
@@ -263,11 +280,20 @@ function submitRegister() {
}
registerLoading.value = true
register({email: registerForm.email + suffix.value, password: registerForm.password, token: verifyToken}).then(() => {
const form = {
email: registerForm.email + suffix.value,
password: registerForm.password,
token: verifyToken,
code: registerForm.code
}
register(form).then(() => {
show.value = 'login'
registerForm.email = ''
registerForm.password = ''
registerForm.confirmPassword = ''
registerForm.code = ''
registerLoading.value = false
turnstileId = null
verifyToken = ''
+528
View File
@@ -0,0 +1,528 @@
<template>
<div class="reg-key">
<div class="header-actions">
<Icon class="icon" icon="ion:add-outline" width="23" height="23" @click="openAdd"/>
<div class="search">
<el-input
v-model="params.code"
class="search-input"
placeholder="输入注册码搜索"
>
</el-input>
</div>
<Icon class="icon" icon="iconoir:search" @click="search" width="20" height="20"/>
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refresh"/>
<Icon class="icon" icon="fluent:broom-sparkle-16-regular" width="22" height="22" @click="clearNotUse"/>
</div>
<el-scrollbar class="scrollbar" :style="`background: ${regKeyData.length > 0 ? '#FAFCFF;' : '#FFF'}`">
<div class="loading" :class="regKeyLoading ? 'loading-show' : 'loading-hide'">
<loading />
</div>
<div class="code-box">
<div class="code-item" v-for="item in regKeyData">
<div class="code-info">
<div class="info-left">
<div class="info-left-item">
<span class="code" @click="copyCode(item.code)">{{item.code}}</span>
</div>
<div class="info-left-item">
<div>剩余次数</div>
<div v-if="item.count">{{item.count}}</div>
<el-tag v-else type="danger">已用尽</el-tag>
</div>
<div class="info-left-item">
<div>权限身份</div>
<el-tag>{{item.roleName}}</el-tag>
</div>
<div class="info-left-item">
<div>有效至期</div>
<div v-if="item.expireTime">{{ formatExpireTime(item.expireTime)}}</div>
<el-tag v-else type="danger">已过期</el-tag>
</div>
</div>
<div class="info-right">
<el-dropdown class="setting">
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" />
<template #dropdown >
<el-dropdown-menu>
<el-dropdown-item @click="copyCode(item.code)">复制</el-dropdown-item>
<el-dropdown-item @click="openHistory(item)">记录</el-dropdown-item>
<el-dropdown-item @click="deleteRegKey(item)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
<div class="empty" v-if="regKeyData.length === 0">
<el-empty v-if="!regKeyFirst" :image-size="isMobile ? 120 : 0" description="没有任何注册码"/>
</div>
</el-scrollbar>
<el-dialog v-model="showAdd" title="添加注册码">
<div class="container">
<el-input v-model="addForm.code" placeholder="注册码">
<template #suffix>
<Icon @click.stop="genCode" class="gen-code" icon="bitcoin-icons:refresh-filled" width="24" height="24" />
</template>
</el-input>
<el-select v-model="addForm.roleId" placeholder="身份类型">
<el-option v-for="item in roleList" :label="item.name" :value="item.roleId" :key="item.roleId"/>
</el-select>
<el-date-picker
v-model="addForm.expireTime"
type="date"
placeholder="有效至期"
/>
<el-input-number v-model="addForm.count" :min="1" :max="99999"/>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
>添加
</el-button>
</div>
</el-dialog>
<el-dialog class="history-list" v-model="showRegKeyHistory" title="使用记录">
<div class="loading" :class="historyLoading ? 'loading-show' : 'loading-hide'">
<loading />
</div>
<el-table v-if="!historyLoading" :data="historyList" :fit="true" style="height: 100%" >
<el-table-column :min-width="emailColumnWidth" property="email" label="用户" :show-overflow-tooltip="true" />
<el-table-column :width="createTimeColumnWidth" :formatter="formatUserCreateTime" property="createTime" label="时间" fixed="right" :show-overflow-tooltip="true" />
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import {defineOptions, reactive, ref, watch} from "vue"
import {Icon} from "@iconify/vue";
import loading from "@/components/loading/index.vue";
import {useSettingStore} from "@/store/setting.js";
import {roleSelectUse} from "@/request/role.js";
import {useRoleStore} from "@/store/role.js";
import {regKeyAdd, regKeyList, regKeyClearNotUse, regKeyDelete, regKeyHistory} from "@/request/reg-key.js";
import { getTextWidth } from "@/utils/text.js";
import dayjs from "dayjs";
import {tzDayjs} from "@/utils/day.js";
defineOptions({
name: 'reg-key'
})
const roleStore = useRoleStore();
const settingStore = useSettingStore();
const params = reactive({
code: '',
})
const roleList = reactive([])
const addLoading = ref(false)
const showAdd = ref(false)
const regKeyLoading = ref(true)
const regKeyFirst = ref(true)
const showRegKeyHistory = ref(false)
const historyList = reactive([])
const emailColumnWidth = ref(0)
const createTimeColumnWidth = ref(0)
const historyLoading = ref(false)
const isMobile = window.innerWidth < 1025
const addForm = reactive({
code: '',
count: 1,
roleId: null,
expireTime: null
})
const regKeyData = reactive([])
getList(true)
roleSelectUse().then(list => {
roleList.length = 0
roleList.push(...list)
})
watch(() => roleStore.refresh, () => {
roleSelectUse().then(list => {
roleList.length = 0
roleList.push(...list)
})
})
function openHistory(regKey) {
historyList.length = 0
historyLoading.value = true
regKeyHistory(regKey.regKeyId).then(list => {
historyList.push(...list)
if (list.length > 0) {
const email = list.reduce((a, b) =>
a.email.length > b.email.length ? a : b
).email;
emailColumnWidth.value = getTextWidth(email) + 30
emailColumnWidth.value = emailColumnWidth.value < 300 ? emailColumnWidth.value : 300
const createTime = list.reduce((a, b) =>
a.createTime.length > b.email.createTime ? a : b
);
createTimeColumnWidth.value = getTextWidth(createTime) + 30
}
}).finally(() => {
historyLoading.value = false
})
showRegKeyHistory.value = true
}
function formatUserCreateTime(regKey) {
const createTime = tzDayjs(regKey.createTime);
const currentYear = dayjs().year();
const expireYear = createTime.year();
if (expireYear === currentYear) {
return createTime.format('M月D日 HH:mm');
} else {
return createTime.format('YYYY年M月D日 HH:mm');
}
}
function formatExpireTime(expireTime) {
expireTime = tzDayjs(expireTime);
const currentYear = dayjs().year();
const expireYear = expireTime.year();
if (expireYear === currentYear) {
return expireTime.format('M月D日');
} else {
return expireTime.format('YYYY年M月D日');
}
}
function refresh() {
params.code = null
getList(true)
}
function search() {
getList(true)
}
function getList(showLoading = false) {
if (showLoading) {
regKeyLoading.value = true
}
regKeyList(params).then(list => {
regKeyData.length = 0
regKeyData.push(...list)
regKeyLoading.value = false
regKeyFirst.value = false
})
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code);
ElMessage({
message: '复制成功',
type: 'success',
plain: true,
})
} catch (err) {
console.error('复制失败:', err);
ElMessage({
message: '复制失败',
type: 'error',
plain: true,
})
}
}
function genCode() {
addForm.code = generateRandomCode()
}
function generateRandomCode(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function clearNotUse() {
ElMessageBox.confirm(`确认清除所有不可用的注册码?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
regKeyClearNotUse().then(() => {
ElMessage({
message: '清除成功',
type: 'success',
plain: true,
})
getList()
})
});
}
function submit() {
if (!addForm.code) {
ElMessage({
message: "注册码不能为空",
type: "error",
plain: true
})
return
}
if (!addForm.roleId) {
ElMessage({
message: "身份类型不能为空",
type: "error",
plain: true
})
return
}
if (!addForm.expireTime) {
ElMessage({
message: "有效时间不能为空",
type: "error",
plain: true
})
return
}
if (!addForm.count) {
ElMessage({
message: "使用次数不能为空",
type: "error",
plain: true
})
return
}
addLoading.value = true
regKeyAdd(addForm).then(() => {
showAdd.value = false
resetForm()
ElMessage({
message: "添加成功",
type: "success",
plain: true
})
getList()
}).finally(() => {
addLoading.value = false
})
}
function deleteRegKey(regKey){
ElMessageBox.confirm(`确认删除${regKey.code}吗?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
regKeyDelete([regKey.regKeyId]).then(() => {
getList()
ElMessage({
message: "删除成功",
type: "success",
plain: true
})
})
});
}
function resetForm(){
addForm.code = ''
}
function openAdd() {
genCode()
showAdd.value = true
}
</script>
<style scoped lang="scss">
.reg-key {
height: 100%;
overflow: hidden;
}
.scrollbar {
height: calc(100% - 48px);
position: relative;
@media (max-width: 372px) {
height: calc(100% - 85px);
}
.code-box {
padding: 15px 15px 25px 15px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 15px;
.code-item {
background-color: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
transition: all 300ms;
padding: 15px;
.code-info {
display: flex;
.info-left {
flex: 1;
min-width: 0;
.info-left-item {
display: flex;
padding-top: 5px;
.code {
font-weight: bold;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
}
.info-left-item:first-child {
padding-top: 0;
}
}
.info-right {
display: flex;
flex-direction: column;
padding-top: 2px;
gap: 5px;
}
}
}
}
}
.empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
:deep(.history-list.el-dialog) {
min-height: 300px;
width: 500px !important;
@media (max-width: 540px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
.history-list .loading {
position: absolute;
top: 10px;
z-index: 0;
background: rgba(255, 255, 255, 0);
}
:deep(.history-list .el-dialog__header) {
padding-bottom: 5px;
}
:deep(.el-scrollbar__view) {
height: calc(100% - 80px);
}
.loading {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.8);
z-index: 2;
}
.loading-show {
transition: all 200ms ease 200ms;
opacity: 1;
}
.loading-hide {
pointer-events: none;
transition: all 200ms;
opacity: 0;
}
.container {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
:deep(.el-dialog) {
width: 400px !important;
@media (max-width: 440px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
.setting {
cursor: pointer;
}
.gen-code {
color: #606266;
cursor: pointer;
}
.header-actions {
padding: 9px 15px;
display: flex;
gap: 18px;
flex-wrap: wrap;
align-items: center;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
@media (max-width: 767px) {
gap: 15px;
}
.search-input {
width: min(200px, calc(100vw - 140px));
}
.search {
:deep(.el-input-group) {
height: 28px;
}
:deep(.el-input__inner) {
height: 28px;
}
}
.icon {
cursor: pointer;
}
}
:deep(.el-table__inner-wrapper:before) {
background: #fff;
}
</style>
+54 -11
View File
@@ -47,10 +47,15 @@
</el-table>
</div>
</el-scrollbar>
<el-dialog class="dialog" v-model="roleFormShow" :title="dialogType.title" @closed="resetForm">
<el-dialog top="5vh" class="dialog" v-model="roleFormShow" :title="dialogType.title" @closed="resetForm">
<div class="dialog-box">
<el-input class="dialog-input" v-model="form.name" type="text" :maxlength="12" placeholder="身份名称" autocomplete="off" />
<el-input class="dialog-input" v-model="form.description" :maxlength="30" type="text" placeholder="描述" autocomplete="off" />
<el-input-tag class="dialog-input-tag" tag-type="warning" :class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail" @add-tag="banEmailAddTag" type="text" placeholder="输入邮箱拦截收件, 拦截所有前缀 *@example.com" autocomplete="off" />
<el-radio-group class="dialog-radio" v-model="form.banEmailType" v-if="form.banEmail.length > 0">
<el-radio label="丢弃邮件" :value="0" />
<el-radio label="移除正文" :value="1" />
</el-radio-group>
<div class="dialog-input">
<el-input-number placeholder="排序" :min="0" :max="9999" v-model.number="form.sort" controls-position="right" autocomplete="off" />
</div>
@@ -74,15 +79,18 @@
<div>
<span>{{node.label}}</span>
<span class="send-num" v-if="data.permKey === 'email:send'" @click.stop>
<el-input-number v-model="form.sendCount" controls-position="right" :min="0" :max="99999" size="small" placeholder="数量" >
<el-input-number v-model="form.sendCount" controls-position="right" :max="99999" size="small" placeholder="数量" >
</el-input-number>
<el-select v-model="form.sendType" placeholder="Select" size="small" style="width: 60px;margin-left: 5px;">
<el-option label="总数" value="count" />
<el-option label="每天" value="day" />
</el-select>
<el-tooltip effect="dark" content="零无限制 负数无次数">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</span>
<span class="send-num" v-if="data.permKey === 'account:add'" @click.stop>
<el-input-number v-model="form.accountCount" controls-position="right" :min="0" :max="99999" size="small" placeholder="数量" >
<el-input-number v-model="form.accountCount" controls-position="right" :min="0" :max="99999" size="small" placeholder="数量" >
</el-input-number>
</span>
</div>
@@ -102,6 +110,7 @@ import {roleAdd, roleDelete, rolePermTree, roleRoleList, roleSet, roleSetDef} fr
import loading from '@/components/loading/index.vue';
import {useRoleStore} from "@/store/role.js";
import {useUserStore} from "@/store/user.js";
import {isEmail} from "@/utils/verify-utils.js";
defineOptions({
name: 'role'
@@ -128,9 +137,11 @@ const dialogType = reactive({
const form = reactive({
name: null,
description: null,
banEmail: [],
banEmailType: 0,
sendType: 'count',
sendCount: '',
accountCount: '',
sendCount: 0,
accountCount: 0,
sort: 0,
isDefault: 0,
})
@@ -145,6 +156,21 @@ rolePermTree().then(tree => {
treeList.push(...tree)
})
function banEmailAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
form.banEmail.splice(form.banEmail.length - 1, 1)
emails.forEach(email => {
if (isEmail(email) && !form.banEmail.includes(email)) {
form.banEmail.push(email)
}
})
}
function roleFormClick() {
if (dialogType.type === 'add') {
addRole()
@@ -240,8 +266,10 @@ function resetForm() {
form.description = null
form.sort = 0
form.sendType = 'count'
form.sendCount = ''
form.accountCount = ''
form.sendCount = 0
form.accountCount = 0
form.banEmail = []
form.banEmailType = 0
tree.value.setCheckedKeys([])
}
@@ -256,6 +284,7 @@ function openRoleSet(role) {
form.sendType = role.sendType
form.sendCount = role.sendCount
form.accountCount = role.accountCount
form.banEmail = role.banEmail
nextTick(() => {
tree.value.setCheckedKeys(role.permIds)
})
@@ -346,7 +375,7 @@ window.onresize = () => {
padding: 9px 15px;
display: flex;
align-items: center;
gap: 20px;
gap: 18px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
.search {
@@ -362,6 +391,14 @@ window.onresize = () => {
}
}
.warning {
position: relative;
left: 5px;
top: 5px;
color: gray;
cursor: pointer;
}
:deep(.description) {
white-space: nowrap;
overflow: hidden;
@@ -397,6 +434,12 @@ window.onresize = () => {
.dialog-input {
margin-bottom: 15px !important;
}
.dialog-radio {
margin-top: 5px;
margin-bottom: 5px;
}
.dialog-input-tag {
}
}
.perm-expand {
@@ -406,11 +449,11 @@ window.onresize = () => {
bottom: 5px;
}
:deep(.el-dialog) {
margin-top: 15vh !important;
margin-bottom: 20px !important;
width: 400px !important;
@media (max-width: 440px) {
width: 460px !important;
@media (max-width: 500px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
+32 -15
View File
@@ -8,9 +8,11 @@
:show-star="false"
show-user-info
show-status
actionLeft="4px"
:show-account-icon="false"
@jump="jumpContent"
@refresh-before="refreshBefore"
:type="'sys-email'"
>
<template #first>
@@ -23,7 +25,7 @@
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="searchType"
v-model="params.searchType"
placeholder="请选择"
class="select"
>
@@ -59,10 +61,10 @@
<script setup>
import {starAdd, starCancel} from "@/request/star.js";
import emailScroll from "@/components/email-scroll/index.vue"
import {computed, defineOptions, reactive, ref} from "vue";
import {computed, defineOptions, reactive, ref, watch} from "vue";
import {useEmailStore} from "@/store/email.js";
import {
sysEmailAll,
sysEmailList,
sysEmailDelete
} from "@/request/sys-email.js";
import {Icon} from "@iconify/vue";
@@ -74,7 +76,6 @@ defineOptions({
const emailStore = useEmailStore();
const sysEmailScroll = ref({})
const searchType = ref('name')
const searchValue = ref('')
const mySelect = ref()
@@ -88,15 +89,31 @@ const params = reactive({
userEmail: null,
accountEmail: null,
name: null,
subject: null
subject: null,
searchType: 'name'
})
const selectTitle = computed(() => {
if (searchType.value === 'user') return '用户'
if (searchType.value === 'account') return '邮箱'
if (searchType.value === 'name') return '发件人'
if (searchType.value === 'subject') return '主题'
if (params.searchType === 'user') return '用户'
if (params.searchType === 'account') return '邮箱'
if (params.searchType === 'name') return '发件人'
if (params.searchType === 'subject') return '主题'
})
const paramsStar = localStorage.getItem('sys-email-params')
if (paramsStar) {
const locaParams = JSON.parse(paramsStar)
params.type = locaParams.type
params.timeSort = locaParams.timeSort
params.status = locaParams.status
params.searchType = locaParams.searchType
}
watch(() => params, () => {
localStorage.setItem('sys-email-params',JSON.stringify(params))
}, {
deep: true
})
function refreshBefore() {
@@ -107,7 +124,7 @@ function refreshBefore() {
params.accountEmail = null
params.name = null
params.subject = null
params.searchType = 'name'
}
function search() {
@@ -117,19 +134,19 @@ function search() {
params.name = null
params.subject = null
if (searchType.value === 'user') {
if (params.searchType === 'user') {
params.userEmail = searchValue.value
}
if (searchType.value === 'account') {
if (params.searchType === 'account') {
params.accountEmail = searchValue.value
}
if (searchType.value === 'name') {
if (params.searchType === 'name') {
params.name = searchValue.value
}
if (searchType.value === 'subject') {
if (params.searchType === 'subject') {
params.subject = searchValue.value
}
@@ -151,7 +168,7 @@ function jumpContent(email) {
function getEmailList(emailId, size) {
return sysEmailAll({emailId, size, ...params})
return sysEmailList({emailId, size, ...params})
}
</script>
+119 -37
View File
@@ -17,6 +17,24 @@
v-model="setting.register"/>
</div>
</div>
<div class="setting-item">
<div><span>注册码</span></div>
<div>
<el-select
@change="change"
style="width: 80px;"
v-model="setting.regKey"
placeholder="Select"
>
<el-option
v-for="item in regKeyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</div>
<div class="setting-item">
<div><span>添加邮箱</span></div>
<div>
@@ -36,29 +54,6 @@
v-model="setting.manyEmail"/>
</div>
</div>
<div class="setting-item">
<div>
<span>轮询刷新</span>
<el-tooltip effect="dark" content="轮询请求服务器获取最新邮件">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
<div>
<el-select
@change="change"
style="width: 80px;"
v-model="setting.autoRefreshTime"
placeholder="Select"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</div>
<div class="setting-item">
<div>
<span>物理清空数据</span>
@@ -135,6 +130,29 @@
v-model="setting.receive"/>
</div>
</div>
<div class="setting-item">
<div>
<span>轮询刷新</span>
<el-tooltip effect="dark" content="轮询请求服务器获取最新邮件">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
<div>
<el-select
@change="change"
style="width: 80px;"
v-model="setting.autoRefreshTime"
placeholder="Select"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</div>
<div class="setting-item">
<div><span>邮件发送</span></div>
<div>
@@ -145,15 +163,14 @@
<div class="setting-item">
<div><span>添加 Resend Token</span></div>
<div>
<el-button class="opt-button" style="margin-top: 0" @click="openResendList" size="small" type="primary">
<Icon icon="ic:round-list" width="18" height="18"/>
</el-button>
<el-button class="opt-button" style="margin-top: 0" @click="openResendForm" size="small" type="primary">
<Icon icon="material-symbols:add-rounded" width="16" height="16"/>
</el-button>
</div>
</div>
<div class="setting-item token-item" v-for="(value, key, index) in setting.resendTokens" :key="index">
<div><span>{{ key }}</span></div>
<div><span>{{ value }}</span></div>
</div>
</div>
</div>
@@ -227,7 +244,7 @@
<div class="setting-item">
<div><span>Site Key</span></div>
<div class="bot-verify">
<span>{{ setting.siteKey || '空' }}</span>
<span>{{ setting.siteKey }}</span>
<el-button class="opt-button" size="small" type="primary" @click="turnstileShow = true">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
@@ -236,7 +253,7 @@
<div class="setting-item">
<div><span>Secret Key</span></div>
<div class="bot-verify">
<span> {{ setting.secretKey || '空' }} </span>
<span> {{ setting.secretKey }} </span>
<el-button class="opt-button" size="small" type="primary" @click="turnstileShow = true">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
@@ -279,7 +296,7 @@
<el-button type="primary" :loading="settingLoading" @click="saveTitle">保存</el-button>
</form>
</el-dialog>
<el-dialog v-model="resendTokenFormShow" title="添加resend token" width="340" @closed="cleanResendTokenForm">
<el-dialog v-model="resendTokenFormShow" title="添加 Resend Token" width="340" @closed="cleanResendTokenForm">
<form>
<el-select style="margin-bottom: 15px" v-model="resendTokenForm.domain" placeholder="Select">
<el-option
@@ -289,7 +306,7 @@
:value="item"
/>
</el-select>
<el-input type="text" placeholder="令牌" v-model="resendTokenForm.token"/>
<el-input type="text" placeholder="输入内容添加,不填则删除" v-model="resendTokenForm.token"/>
<el-button type="primary" :loading="settingLoading" @click="saveResendToken">保存</el-button>
</form>
</el-dialog>
@@ -299,7 +316,7 @@
<el-button type="primary" :loading="settingLoading" @click="saveR2domain">保存</el-button>
</form>
</el-dialog>
<el-dialog v-model="turnstileShow" title="添加Turnstile密钥" width="340"
<el-dialog v-model="turnstileShow" title="添加 Turnstile 密钥" width="340"
@closed="turnstileForm.secretKey = '';turnstileForm.siteKey = ''">
<form>
<el-input type="text" placeholder="siteKey" v-model="turnstileForm.siteKey"/>
@@ -368,7 +385,7 @@
</div>
</template>
<div class="forward-set-body">
<el-input-tag tag-type="warning" placeholder="多个箱用, 分开 example1.com,example2.com" v-model="forwardEmail" @add-tag="emailAddTag"></el-input-tag>
<el-input-tag tag-type="warning" placeholder="多个箱用, 分开 example1.com,example2.com" v-model="forwardEmail" @add-tag="emailAddTag"></el-input-tag>
</div>
<template #footer>
<div class="dialog-footer">
@@ -392,7 +409,7 @@
</div>
</template>
<div class="forward-set-body">
<el-input-tag placeholder="多个箱用, 分开 example1.com,example2.com" tag-type="success" v-model="ruleEmail" @add-tag="ruleEmailAddTag" />
<el-input-tag placeholder="多个箱用, 分开 example1.com,example2.com" tag-type="success" v-model="ruleEmail" @add-tag="ruleEmailAddTag" />
</div>
<template #footer>
<div class="dialog-footer">
@@ -406,12 +423,18 @@
</div>
</template>
</el-dialog>
<el-dialog class="resend-table" v-model="showResendList" title="Token 列表">
<el-table :data="resendList" >
<el-table-column :min-width="emailColumnWidth" property="key" label="域名" :show-overflow-tooltip="true" />
<el-table-column :width="tokenColumnWidth" property="value" label="token" fixed="right" :show-overflow-tooltip="true" />
</el-table>
</el-dialog>
</el-scrollbar>
</div>
</template>
<script setup>
import {defineOptions, onMounted, reactive, ref} from "vue";
import {computed, defineOptions, reactive, ref} from "vue";
import {physicsDeleteAll, setBackground, settingQuery, settingSet} from "@/request/setting.js";
import {useSettingStore} from "@/store/setting.js";
import {useUserStore} from "@/store/user.js";
@@ -419,9 +442,10 @@ import {useAccountStore} from "@/store/account.js";
import {Icon} from "@iconify/vue";
import {cvtR2Url} from "@/utils/convert.js";
import {storeToRefs} from "pinia";
import { debounce } from 'lodash-es'
import {debounce} from 'lodash-es'
import {isEmail} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js";
defineOptions({
name: 'sys-setting'
@@ -440,6 +464,7 @@ const turnstileShow = ref(false)
const tgSettingShow = ref(false)
const thirdEmailShow = ref(false)
const forwardRulesShow = ref(false)
const showResendList = ref(false)
const settingStore = useSettingStore();
const {settings: setting} = storeToRefs(settingStore);
const editTitle = ref('')
@@ -455,6 +480,13 @@ const turnstileForm = reactive({
siteKey: '',
secretKey: ''
})
const regKeyOptions = [
{label: '开启', value: 0},
{label: '关闭', value: 1},
{label: '可选', value: 2},
]
const options = [
{label: '关闭', value: 0},
{label: '3s', value: 3},
@@ -468,12 +500,40 @@ const options = [
const tgChatId = ref([])
const tgBotStatus = ref(0)
const tgBotToken = ref('')
const forwardEmail = ref([])
const forwardStatus = ref(0)
const emailColumnWidth = ref(0)
const tokenColumnWidth = ref(0)
const ruleType = ref(0)
const ruleEmail = ref([])
const resendList = computed(() => {
let list = Object.keys(setting.value.resendTokens).map(key => {
return {
key: key,
value: setting.value.resendTokens[key]
};
})
if (list.length > 0) {
const key = list.reduce((a, b) =>
a.key.length > b.key.length ? a : b
).key;
emailColumnWidth.value = getTextWidth(key) + 30
const value = list.reduce((a, b) =>
a.value.length > b.value.length ? a : b
).value;
tokenColumnWidth.value = getTextWidth(value) + 30
}
return list;
});
settingQuery().then(settingData => {
setting.value = settingData
@@ -493,6 +553,10 @@ function openTgSetting() {
tgSettingShow.value = true
}
function openResendList() {
showResendList.value = true
}
function openThirdEmailSetting() {
forwardEmail.value = []
forwardStatus.value = setting.value.forwardStatus
@@ -898,6 +962,24 @@ function editSetting(settingForm, refreshStatus = true) {
}
}
:deep(.resend-table.el-dialog) {
min-height: 300px;
width: 500px !important;
@media (max-width: 540px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
:deep(.resend-table .el-dialog__header) {
padding-bottom: 5px;
}
:deep(.el-table__inner-wrapper:before) {
background: #fff;
}
:deep(.cut-dialog.el-dialog) {
width: fit-content !important;
height: fit-content !important;
+14
View File
@@ -307,6 +307,20 @@ roleSelectUse().then(list => {
roleList.push(...list)
})
const paramsStar = localStorage.getItem('user-params')
if (paramsStar) {
const locaParams = JSON.parse(paramsStar)
params.num = locaParams.num
params.size = locaParams.size
params.timeSort = locaParams.timeSort
params.status = locaParams.status
}
watch(() => params, () => {
localStorage.setItem('user-params',JSON.stringify(params))
}, {
deep: true
})
watch(() => roleStore.refresh, () => {
roleSelectUse().then(list => {
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
+2 -2
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-BXq2c1cR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAVUbrwg.css">
<script type="module" crossorigin src="/assets/index-B33pG0J-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GJlLLp1y.css">
</head>
<body>
<div id="loading-first">
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "mail-cf",
"name": "mail-worker",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail-cf",
"name": "mail-worker",
"version": "0.0.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.0.5",
-1
View File
@@ -1,7 +1,6 @@
import r2Service from '../service/r2-service';
import app from '../hono/hono';
app.get('/file/*', async (c) => {
const key = c.req.path.split('/file/')[1];
const obj = await r2Service.getObj(c, key);
+29
View File
@@ -0,0 +1,29 @@
import app from '../hono/hono';
import result from '../model/result';
import regKeyService from '../service/reg-key-service';
import userContext from '../security/user-context';
app.post('/regKey/add', async (c) => {
await regKeyService.add(c, await c.req.json(), await userContext.getUserId(c));
return c.json(result.ok());
})
app.get('/regKey/list', async (c) => {
const list = await regKeyService.list(c, c.req.query());
return c.json(result.ok(list));
})
app.delete('/regKey/delete', async (c) => {
await regKeyService.delete(c, c.req.query());
return c.json(result.ok());
})
app.delete('/regKey/clearNotUse', async (c) => {
await regKeyService.clearNotUse(c);
return c.json(result.ok());
})
app.get('/regKey/history', async (c) => {
const list = await regKeyService.history(c, c.req.query());
return c.json(result.ok(list));
})
+2 -2
View File
@@ -2,12 +2,12 @@ import app from '../hono/hono';
import emailService from '../service/email-service';
import result from '../model/result';
app.get('/sys-email/list',async (c) => {
app.get('/sysEmail/list',async (c) => {
const data = await emailService.allList(c, c.req.query());
return c.json(result.ok(data));
})
app.delete('/sys-email/delete',async (c) => {
app.delete('/sysEmail/delete',async (c) => {
const list = await emailService.physicsDelete(c, c.req.query());
return c.json(result.ok(list));
})
+9
View File
@@ -10,6 +10,10 @@ export const roleConst = {
CLOSE: 0,
OPEN: 1
},
banEmailType: {
ALL: 0,
CONTENT: 1
},
sendType: {
COUNT: 'count',
DAY: 'day'
@@ -55,6 +59,11 @@ export const settingConst = {
OPEN: 0,
CLOSE: 1,
},
regKey: {
OPEN: 0,
CLOSE: 1,
OPTIONAL: 2,
},
receive: {
OPEN: 0,
CLOSE: 1,
+54 -2
View File
@@ -5,11 +5,12 @@ import settingService from '../service/setting-service';
import attService from '../service/att-service';
import constant from '../const/constant';
import fileUtils from '../utils/file-utils';
import { attConst, emailConst, isDel, settingConst } from '../const/entity-const';
import { emailConst, isDel, roleConst, settingConst } from '../const/entity-const';
import emailUtils from '../utils/email-utils';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import roleService from '../service/role-service';
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -33,7 +34,6 @@ export async function email(message, env, ctx) {
return;
}
const account = await accountService.selectByEmailIncludeDelNoCase({ env: env }, message.to);
const reader = message.raw.getReader();
let content = '';
@@ -46,6 +46,53 @@ export async function email(message, env, ctx) {
const email = await PostalMime.parse(content);
const account = await accountService.selectByEmailIncludeDelNoCase({ env: env }, message.to);
if (account && account.email !== env.admin) {
let { banEmail, banEmailType } = await roleService.selectByUserId({ env: env}, account.userId);
banEmail = banEmail.split(",").filter(item => item !== "")
for (const item of banEmail) {
if (item.startsWith('*@')) {
const banDomain = emailUtils.getDomain(item.toLowerCase())
const receiveDomain = emailUtils.getDomain(email.from.address.toLowerCase())
if (banDomain === receiveDomain) {
if (banEmailType === roleConst.banEmailType.ALL) return
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = '邮件内容已被移除'
email.text = '邮件内容已被移除'
email.attachments = []
}
}
} else {
if (item.toLowerCase() === email.from.address.toLowerCase()) {
if (banEmailType === roleConst.banEmailType.ALL) return
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = '邮件内容已被移除'
email.text = '邮件内容已被移除'
email.attachments = []
}
}
}
}
}
const toName = email.to.find(item => item.address === message.to)?.name || '';
const params = {
@@ -68,6 +115,11 @@ export async function email(message, env, ctx) {
status: emailConst.status.SAVING
};
let headers = message.headers
console.log(headers.get('X-Cf-Spamh-Score'))
console.log(email)
const attachments = [];
const cidAttachments = [];
+12
View File
@@ -0,0 +1,12 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const regKey = sqliteTable('reg_key', {
regKeyId: integer('rege_key_id').primaryKey({ autoIncrement: true }),
code: text('code').notNull().default(''),
count: integer('count').notNull().default(0),
roleId: integer('role_id').notNull().default(0),
userId: integer('user_id').notNull().default(0),
expireTime: text('expire_time'),
createTime: text('create_time').notNull().default(sql`CURRENT_TIMESTAMP`)
});
export default regKey
+2
View File
@@ -5,6 +5,8 @@ export const role = sqliteTable('role', {
name: text('name').notNull(),
key: text('key').notNull(),
description: text('description'),
banEmail: text('ban_email').notNull().default(''),
banEmailType: integer('ban_email_type').notNull().default(0),
sort: integer('sort'),
isDefault: integer('is_default').default(0),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
+1
View File
@@ -12,6 +12,7 @@ export const setting = sqliteTable('setting', {
r2Domain: text('r2_domain'),
secretKey: text('secret_key'),
siteKey: text('site_key'),
regKey: integer('reg_key').default(1).notNull(),
background: text('background'),
tgBotToken: text('tg_bot_token').default('').notNull(),
tgChatId: text('tg_chat_id').default('').notNull(),
+1
View File
@@ -16,6 +16,7 @@ const user = sqliteTable('user', {
device: text('device'),
sort: text('sort').default(0),
sendCount: text('send_count').default(0),
regKeyId: integer('reg_key_id').default(0).notNull(),
isDel: integer('is_del').default(0).notNull()
});
export default user
+1
View File
@@ -16,4 +16,5 @@ import '../api/role-api'
import '../api/sys-email-api'
import '../api/init-api'
import '../api/analysis-api'
import '../api/reg-key-api'
export default app;
+52
View File
@@ -15,10 +15,62 @@ const init = {
await this.v1_2DB(c);
await this.v1_3DB(c);
await this.v1_3_1DB(c);
await this.v1_4DB(c);
await settingService.refresh(c);
return c.text('初始化成功');
},
async v1_4DB(c) {
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS reg_key (
rege_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL COLLATE NOCASE DEFAULT '',
count INTEGER NOT NULL DEFAULT 0,
role_id INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NOT NULL DEFAULT 0,
expire_time DATETIME,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
// 添加不区分大小写的唯一索引
try {
await c.env.db.prepare(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_setting_code ON reg_key(code COLLATE NOCASE)
`).run();
} catch (e) {
console.warn(`跳过创建索引,原因:${e.message}`);
}
try {
await c.env.db.prepare(`
INSERT INTO perm (perm_id, name, perm_key, pid, type, sort) VALUES
(33,'注册密钥', NULL, 0, 1, 5.1),
(34,'密钥查看', 'reg-key:query', 33, 2, 0),
(35,'密钥添加', 'reg-key:add', 33, 2, 1),
(36,'密钥删除', 'reg-key:delete', 33, 2, 2)`).run();
} catch (e) {
console.warn(`跳过数据,原因:${e.message}`);
}
const ADD_COLUMN_SQL_LIST = [
`ALTER TABLE setting ADD COLUMN reg_key INTEGER NOT NULL DEFAULT 1;`,
`ALTER TABLE role ADD COLUMN ban_email TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE role ADD COLUMN ban_email_type INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE user ADD COLUMN reg_key_id INTEGER NOT NULL DEFAULT 0;`
];
for (let sql of ADD_COLUMN_SQL_LIST) {
try {
await c.env.db.prepare(sql).run();
} catch (e) {
console.warn(`跳过字段添加,原因:${e.message}`);
}
}
},
async v1_3_1DB(c) {
await c.env.db.prepare(`UPDATE email SET name = SUBSTR(send_email, 1, INSTR(send_email, '@') - 1) WHERE (name IS NULL OR name = '') AND type = ${emailConst.type.RECEIVE}`).run();
},
+14 -6
View File
@@ -29,8 +29,8 @@ const requirePerms = [
'/role/tree',
'/role/set',
'/role/setDefault',
'/sys-email/list',
'/sys-email/delete',
'/sysEmail/list',
'/sysEmail/delete',
'/setting/physicsDeleteAll',
'/setting/setBackground',
'/setting/set',
@@ -41,7 +41,12 @@ const requirePerms = [
'/user/setType',
'/user/list',
'/user/resetSendCount',
'/user/add'
'/user/add',
'/regKey/add',
'/regKey/list',
'/regKey/delete',
'/regKey/clearNotUse',
'/regKey/history'
];
const premKey = {
@@ -62,12 +67,15 @@ const premKey = {
'user:set-status': ['/user/setStatus'],
'user:set-type': ['/user/setType'],
'user:delete': ['/user/delete'],
'sys-email:query': ['/sys-email/list'],
'sys-email:delete': ['/sys-email/delete'],
'sys-email:query': ['/sysEmail/list'],
'sys-email:delete': ['/sysEmail/delete'],
'setting:query': ['/setting/query'],
'setting:set': ['/setting/set', '/setting/setBackground'],
'setting:clean': ['/setting/physicsDeleteAll'],
'analysis:query': ['/analysis/echarts']
'analysis:query': ['/analysis/echarts'],
'role-key:add': ['/regKey/add'],
'role-key:query': ['/regKey/list','/regKey/history'],
'role-key:delete': ['/regKey/delete','/regKey/clearNotUse'],
};
app.use('*', async (c, next) => {
+4 -1
View File
@@ -48,7 +48,7 @@ const accountService = {
if (roleRow.accountCount && userRow.email !== c.env.admin) {
const userAccountCount = await accountService.countUserAccount(c, userId)
if(userAccountCount >= roleRow.accountCount) throw new BizError(`添加邮箱数量限制${roleRow.accountCount}`, 403);
if(userAccountCount >= roleRow.accountCount) throw new BizError(`添加邮箱数量到达限制`, 403);
}
if (await settingService.isAddEmailVerify(c)) {
@@ -181,6 +181,9 @@ const accountService = {
async setName(c, params, userId) {
const { name, accountId } = params
if (name.length > 30) {
throw new BizError('用户名长度超出限制');
}
await orm(c).update(account).set({name}).where(and(eq(account.userId, userId),eq(account.accountId, accountId))).run();
}
};
+2 -5
View File
@@ -5,10 +5,7 @@ import { desc, count, eq, and, ne, isNotNull } 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)
import { toUtc } from '../utils/date-uitil';
const analysisService = {
async echarts(c) {
@@ -62,7 +59,7 @@ const analysisService = {
},
filterEmptyDay(data) {
const today = dayjs().tz('Asia/Shanghai').subtract(1, 'day');
const today = toUtc().tz('Asia/Shanghai').subtract(1, 'day');
const previousDays = Array.from({ length: 15 }, (_, i) => {
return today.subtract(i, 'day').format('YYYY-MM-DD');
}).reverse();
+15 -9
View File
@@ -150,7 +150,7 @@ const emailService = {
}
if (send === settingConst.send.CLOSE) {
throw new BizError('邮发送功能已停用', 403);
throw new BizError('邮发送功能已停用', 403);
}
if (attachments.length > 0 && manyType === 'divide') {
@@ -163,13 +163,17 @@ const emailService = {
if (c.env.admin !== userRow.email && roleRow.sendCount) {
if (roleRow.sendCount < 0) {
throw new BizError('用户无发送次数', 403);
}
if (userRow.sendCount >= roleRow.sendCount) {
if (roleRow.sendType === 'day') throw new BizError('已到达每日发送次数限制', 403);
if (roleRow.sendType === 'count') throw new BizError('已到达发送次数限制', 403);
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 === 'day') throw new BizError('当日剩余发送次数不足', 403);
if (roleRow.sendType === 'count') throw new BizError('剩余发送次数不足', 403);
}
@@ -178,18 +182,20 @@ const emailService = {
const accountRow = await accountService.selectById(c, accountId);
if (!accountRow) {
throw new BizError('发件人邮箱不存在');
}
const domain = emailUtils.getDomain(accountRow.email);
const resendToken = resendTokens[domain];
if (!resendToken) {
throw new BizError('resend密钥未配置');
}
if (!accountRow) {
throw new BizError('邮箱不存在');
}
if (accountRow.userId !== userId) {
throw new BizError('非当前用户所属邮箱');
throw new BizError('发件人邮箱非当前用户所');
}
if (!name) {
@@ -488,7 +494,7 @@ const emailService = {
async allList(c, params) {
let { emailId, size, name, subject, accountEmail, sendEmail, userEmail, type, timeSort } = params;
let { emailId, size, name, subject, accountEmail, userEmail, type, timeSort } = params;
size = Number(size);
+92 -7
View File
@@ -1,7 +1,7 @@
import BizError from '../error/biz-error';
import userService from './user-service';
import emailUtils from '../utils/email-utils';
import { isDel, userConst } from '../const/entity-const';
import { isDel, settingConst, userConst } from '../const/entity-const';
import JwtUtils from '../utils/jwt-utils';
import { v4 as uuidv4 } from 'uuid';
import KvConst from '../const/kv-const';
@@ -14,15 +14,19 @@ import saltHashUtils from '../utils/crypto-utils';
import cryptoUtils from '../utils/crypto-utils';
import turnstileService from './turnstile-service';
import roleService from './role-service';
import regKeyService from './reg-key-service';
import dayjs from 'dayjs';
import { formatDetailDate, toUtc } from '../utils/date-uitil';
const loginService = {
async register(c, params) {
const { email, password, token } = params;
const { email, password, token, code } = params;
if (!await settingService.isRegister(c)) {
const {regKey, register} = await settingService.query(c)
if (register === settingConst.register.CLOSE) {
throw new BizError('注册功能已关闭');
}
@@ -30,7 +34,15 @@ const loginService = {
throw new BizError('非法邮箱');
}
if (password.length < 6) {
if (password.length > 30) {
throw new BizError('密码长度超出限制');
}
if (emailUtils.getName(email).length > 30) {
throw new BizError('邮箱长度超出限制');
}
if (password.length > 6) {
throw new BizError('密码必须大于6位');
}
@@ -38,6 +50,21 @@ const loginService = {
throw new BizError('非法邮箱域名');
}
let type = null;
let regKeyId = 0
if (regKey === settingConst.regKey.OPEN) {
const result = await this.handleOpenRegKey(c, regKey, code)
type = result.type
regKeyId = result.regKeyId
}
if (regKey === settingConst.regKey.OPTIONAL) {
const result = await this.handleOpenOptional(c, regKey, code)
type = result.type
regKeyId = result.regKeyId
}
const accountRow = await accountService.selectByEmailIncludeDelNoCase(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
@@ -45,7 +72,7 @@ const loginService = {
}
if (accountRow) {
throw new BizError('该邮箱已被其他用户绑定');
throw new BizError('该邮箱已被注册');
}
if (await settingService.isRegisterVerify(c)) {
@@ -54,13 +81,71 @@ const loginService = {
const { salt, hash } = await saltHashUtils.hashPassword(password);
const roleRow = await roleService.selectDefaultRole(c);
let defType = null
const userId = await userService.insert(c, { email, password: hash, salt, type: roleRow.roleId });
if (!type) {
const roleRow = await roleService.selectDefaultRole(c);
defType = roleRow.roleId
}
const userId = await userService.insert(c, { email, regKeyId,password: hash, salt, type: type || defType });
await userService.updateUserInfo(c, userId, true);
await accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
if (regKey !== settingConst.regKey.CLOSE && type) {
await regKeyService.reduceCount(c, code, 1);
}
},
async handleOpenRegKey(c, regKey, code) {
if (!code) {
throw new BizError('注册码不能为空');
}
const regKeyRow = await regKeyService.selectByCode(c, code);
if (!regKeyRow) {
throw new BizError('注册码不存在');
}
if (regKeyRow.count <= 0) {
throw new BizError('注册码使用次数已耗尽');
}
const today = toUtc().tz('Asia/Shanghai').startOf('day')
const expireTime = toUtc(regKeyRow.expireTime).tz('Asia/Shanghai').startOf('day');
if (expireTime.isBefore(today)) {
throw new BizError('注册码已过期');
}
return { type: regKeyRow.roleId, regKeyId: regKeyRow.regKeyId };
},
async handleOpenOptional(c, regKey, code) {
if (!code) {
return null
}
const regKeyRow = await regKeyService.selectByCode(c, code);
if (!regKeyRow) {
return null
}
const today = toUtc().tz('Asia/Shanghai').startOf('day')
const expireTime = toUtc(regKeyRow.expireTime).tz('Asia/Shanghai').startOf('day');
if (regKeyRow.count <= 0 || expireTime.isBefore(today)) {
return null
}
return { type: regKeyRow.roleId, regKeyId: regKeyRow.regKeyId };
},
async login(c, params) {
-3
View File
@@ -1,6 +1,3 @@
import attService from './att-service';
import constant from '../const/constant';
const r2Service = {
async putObj(c, key, content, metadata) {
await c.env.r2.put(key, content, {
+100
View File
@@ -0,0 +1,100 @@
import orm from '../entity/orm';
import regKey from '../entity/reg-key';
import { inArray, like, eq, desc, sql, or } from 'drizzle-orm';
import roleService from './role-service';
import BizError from '../error/biz-error';
import { formatDetailDate, toUtc } from '../utils/date-uitil';
import userService from './user-service';
const regKeyService = {
async add(c, params, userId) {
let {code,roleId,count,expireTime} = params;
if (!code) {
throw new BizError('注册码不能为空');
}
if (!count) {
throw new BizError('使用次数不能为空');
}
if (!expireTime) {
throw new BizError('有效时间不能为空');
}
const regKeyRow = await orm(c).select().from(regKey).where(eq(regKey.code, code)).get();
if (regKeyRow) {
throw new BizError('注册码已存在');
}
const roleRow = roleService.selectById(c, roleId);
if (!roleRow) {
throw new BizError('权限身份不存在');
}
expireTime = formatDetailDate(expireTime)
await orm(c).insert(regKey).values({code,roleId,count,userId,expireTime}).run();
},
async delete(c, params) {
let {regKeyIds} = params;
regKeyIds = regKeyIds.split(',').map(id => Number(id));
await orm(c).delete(regKey).where(inArray(regKey.regKeyId,regKeyIds)).run();
},
async clearNotUse(c) {
let now = formatDetailDate(toUtc().tz('Asia/Shanghai').startOf('day'))
await orm(c).delete(regKey).where(or(eq(regKey.count, 0),sql`datetime(${regKey.expireTime}, '+8 hours') < datetime(${now})`)).run();
},
selectByCode(c, code) {
return orm(c).select().from(regKey).where(eq(regKey.code, code)).get();
},
async list(c, params) {
const {code} = params
let query = orm(c).select().from(regKey)
if (code) {
query = query.where(like(regKey.code, `${code}%`))
}
const regKeyList = await query.orderBy(desc(regKey.regKeyId)).all();
const roleList = await roleService.roleSelectUse(c);
const today = toUtc().tz('Asia/Shanghai').startOf('day')
regKeyList.forEach(regKeyRow => {
const index = roleList.findIndex(roleRow => roleRow.roleId === regKeyRow.roleId)
regKeyRow.roleName = index > -1 ? roleList[index].name : ''
const expireTime = toUtc(regKeyRow.expireTime).tz('Asia/Shanghai').startOf('day');
if (expireTime.isBefore(today)) {
regKeyRow.expireTime = null
}
})
return regKeyList;
},
async reduceCount(c, code, count) {
await orm(c).update(regKey).set({
count: sql`${regKey.count}
-
${count}`
}).where(eq(regKey.code, code)).run();
},
async history(c, params) {
const { regKeyId } = params;
return userService.listByRegKeyId(c, regKeyId);
}
}
export default regKeyService;
+28 -4
View File
@@ -6,12 +6,15 @@ import rolePerm from '../entity/role-perm';
import perm from '../entity/perm';
import { permConst, roleConst } from '../const/entity-const';
import userService from './user-service';
import user from '../entity/user';
import emailUtils from '../utils/email-utils';
import verifyUtils from '../utils/verify-utils';
const roleService = {
async add(c, params, userId) {
let { name, permIds } = params;
let { name, permIds, banEmail } = params;
if (!name) {
throw new BizError('身份名不能为空');
@@ -23,7 +26,15 @@ const roleService = {
throw new BizError('身份名已存在');
}
roleRow = await orm(c).insert(role).values({...params, userId}).returning().get();
const notEmailIndex = banEmail.findIndex(item => !verifyUtils.isEmail(item))
if (notEmailIndex > -1) {
throw new BizError('非法邮箱');
}
banEmail = banEmail.join(',')
roleRow = await orm(c).insert(role).values({...params, banEmail, userId}).returning().get();
if (permIds.length === 0) {
return;
@@ -44,6 +55,7 @@ const roleService = {
.where(eq(perm.type, permConst.type.BUTTON)).all();
roleList.forEach(role => {
role.banEmail = role.banEmail.split(",").filter(item => item !== "")
role.permIds = permList.filter(perm => perm.roleId === role.roleId).map(perm => perm.permId);
});
@@ -52,7 +64,7 @@ const roleService = {
async setRole(c, params) {
let { name, permIds, roleId } = params;
let { name, permIds, roleId, banEmail } = params;
if (!name) {
throw new BizError('名字不能为空');
@@ -60,7 +72,15 @@ const roleService = {
delete params.isDefault
await orm(c).update(role).set({...params}).where(eq(role.roleId, roleId)).run();
const notEmailIndex = banEmail.findIndex(item => !verifyUtils.isEmail(item))
if (notEmailIndex > -1) {
throw new BizError('非法邮箱');
}
banEmail = banEmail.join(',')
await orm(c).update(role).set({...params, banEmail}).where(eq(role.roleId, roleId)).run();
await orm(c).delete(rolePerm).where(eq(rolePerm.roleId, roleId)).run();
if (permIds.length > 0) {
@@ -126,6 +146,10 @@ const roleService = {
.leftJoin(rolePerm, eq(perm.permId, rolePerm.permId))
.leftJoin(role, eq(role.roleId, rolePerm.roleId))
.where(and(eq(perm.permKey, permKey), eq(role.sendType, sendType))).all();
},
selectByUserId(c, userId) {
return orm(c).select(role).from(user).leftJoin(role, eq(role.roleId, user.type)).where(eq(user.userId, userId)).get();
}
};
+2 -11
View File
@@ -49,16 +49,6 @@ const settingService = {
await this.refresh(c);
},
async isRegister(c) {
const { register } = await this.query(c);
return register === settingConst.register.OPEN;
},
async isReceive(c) {
const { receive } = await this.query(c);
return receive === settingConst.receive.OPEN;
},
async isAddEmail(c) {
const { addEmail, manyEmail } = await this.query(c);
return addEmail === settingConst.addEmail.OPEN && manyEmail === settingConst.manyEmail.OPEN;
@@ -125,7 +115,8 @@ const settingService = {
siteKey: settingRow.siteKey,
background: settingRow.background,
loginOpacity: settingRow.loginOpacity,
domainList:settingRow.domainList
domainList:settingRow.domainList,
regKey: settingRow.regKey
};
}
};
+9
View File
@@ -382,6 +382,15 @@ const userService = {
await accountService.restoreByUserId(c, userId);
}
},
listByRegKeyId(c, regKeyId) {
return orm(c)
.select({email: user.email,createTime: user.createTime})
.from(user)
.where(eq(user.regKeyId, regKeyId))
.orderBy(desc(user.userId))
.all();
}
};
+13
View File
@@ -0,0 +1,13 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export function formatDetailDate(time) {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
export function toUtc(time) {
return dayjs.utc(time || dayjs())
}
+1 -1
View File
@@ -31,6 +31,6 @@ crons = ["0 16 * * *"] #定时任务每天晚上12点执行
#[vars]
#orm_log = false
#domain = [] #邮件域名可配置多个 示例: ["example1.com","example2.com"]
#domain = [] #邮件域名可配置多个 示例: ["example1.com","example2.com"]
#admin = "" #管理员的邮箱 示例: admin@example.com
#jwt_secret = "" #jwt令牌的密钥,随便填一串字符串