新增邮件发送和管理员功能

This commit is contained in:
eoao
2025-05-29 17:38:50 +08:00
parent de742d31e7
commit 2341d14b2d
364 changed files with 24202 additions and 1079 deletions

View File

@@ -1,31 +1,10 @@
<template>
<router-view/>
<div class="loading" v-if="uiStore.init">
<loading/>
</div>
</template>
<script setup>
import loading from "@/components/loading/index.vue";
import {useUiStore} from "@/store/ui.js";
import {useSettingStore} from "@/store/setting.js";
import {watch} from "vue";
const settingStore = useSettingStore();
const uiStore = useUiStore();
watch(() => settingStore.settings.title,
(title) => document.title = title,
{immediate: true}
)
</script>
<style>
.loading {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -12,9 +12,17 @@ http.interceptors.request.use(config => {
})
http.interceptors.response.use((res) => {
return new Promise((resolve, reject) => {
const showMsg = res.config.noMsg;
const data = res.data
if (data.code === 401) {
if (showMsg) {
data.code === 200 ? resolve(data.data) : reject(data)
} else if (data.code === 401) {
ElMessage({
message: data.message,
type: 'error',
@@ -26,7 +34,7 @@ http.interceptors.response.use((res) => {
} else if (data.code === 403) {
ElMessage({
message: data.message,
type: 'error',
type: 'warning',
plain: true,
})
reject(data)
@@ -36,17 +44,19 @@ http.interceptors.response.use((res) => {
type: 'error',
plain: true,
})
setTimeout(() => {
reject(data)
}, 1)
reject(data)
}
setTimeout(() => {
resolve(data.data)
}, 1)
resolve(data.data)
})
},
(error) => {
if (error.message.includes('Network Error')) {
const showMsg = error.config.noMsg;
if (showMsg) {
return Promise.reject(error)
} else if (error.message.includes('Network Error')) {
ElMessage({
message: '网络错误,请检查网络连接',
type: 'error',

View File

@@ -8,50 +8,139 @@
@change="handleCheckAllChange"
>
</el-checkbox>
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refreshList" />
<Icon class="icon" icon="uiw:delete" width="16" height="16" v-if="getSelectedMailsIds().length > 0" @click="handleDelete" />
<Icon class="more-icon icon" width="16" height="16" icon="akar-icons:dot-grid-fill" @click="changeAccountShow" />
<div class="header-left" :style="'padding-left:' + actionLeft">
<slot name="first"></slot>
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refresh"/>
<Icon v-perm="'email:delete'" class="icon" icon="uiw:delete" width="16" height="16"
v-if="getSelectedMailsIds().length > 0"
@click="handleDelete"/>
</div>
<div class="header-right">
<span class="email-count" v-if="total"> {{ total }} </span>
<Icon v-if="showAccountIcon" class="more-icon icon" width="16" height="16" icon="akar-icons:dot-grid-fill"
@click="changeAccountShow"/>
</div>
</div>
<div ref="scroll" class="scroll">
<el-scrollbar>
<div style="height: 100%;" :infinite-scroll-immediate="false" v-infinite-scroll="loadData" infinite-scroll-distance="600">
<div ref="scroll" class="scroll">
<el-scrollbar ref="scrollbarRef" @scroll="handleScroll" >
<div class="scroll-box" :infinite-scroll-immediate="false" v-infinite-scroll="loadData"
infinite-scroll-distance="600">
<div v-for="item in emailList" :key="item.emailId">
<div class="email-row"
:data-checked="item.checked"
@click="jumpDetails(item)"
>
<el-checkbox v-model="item.checked" @click.stop></el-checkbox>
<div @click.stop="starChange(item)" class="pc-star">
<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" />
<el-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"/>
</div>
<div class="title" :class="accountShow ? 'title-column' : ''">
<div class="email-sender">{{ item.name }}</div>
<div class="email-subject">{{ item.subject}}</div>
</div>
<div class="email-right">
<span class="email-time">{{ fromNow(item.createTime) }}</span>
<div class="phone-star" :class="item.isStar ? 'star-pd' : ''" @click.stop="starChange(item)">
<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" />
<div v-if="!showStar"></div>
<div class="title" :class="accountShow ? 'title-column' : 'title-column'">
<div class="email-sender" :style=" showStatus ? 'gap: 10px;' : ''">
<div class="email-status" v-if="showStatus">
<el-tooltip v-if="item.status === 0"
effect="dark"
content="已接收"
>
<Icon icon="ic:round-mark-email-read" style="color: #67C23A" width="20" height="20"/>
/>
</el-tooltip>
<el-tooltip v-if="item.status === 1"
effect="dark"
content="已发送"
>
<Icon icon="bi:send-arrow-up" style="color: #67C23A" width="20" height="20"
/>
</el-tooltip>
<el-tooltip v-if="item.status === 2"
effect="dark"
content="发送成功"
>
<Icon icon="bi:send-check-fill" style="color: #67C23A" width="20"
height="20"/>
</el-tooltip>
<el-tooltip v-if="item.status === 3"
effect="dark"
content="发送失败"
>
<Icon icon="bi:send-x-fill" style="color: #F56C6C" width="20"
height="20"/>
</el-tooltip>
<el-tooltip v-if="item.status === 4"
effect="dark"
content="被标记垃圾邮件"
>
<Icon icon="bi:send-exclamation-fill" style="color:#FBBD08" width="20"
height="20"/>
</el-tooltip>
<el-tooltip v-if="item.status === 5"
effect="dark"
content="发送延迟"
>
<Icon icon="quill:send-later" style="color:#FBBD08" width="20"
height="20"/>
</el-tooltip>
</div>
<div v-else></div>
<span class="name">
<span>{{ item.name }}</span>
<span>
<Icon v-if="item.isStar" icon="fluent-color:star-16" width="18" height="18"/>
</span>
</span>
<span class="phone-time">{{ fromNow(item.createTime) }}</span>
</div>
<div>
<div class="email-text">
<span class="email-subject">{{ item.subject }}</span>
<span class="email-content">{{ htmlToText(item) }}</span>
</div>
<div class="user-info" v-if="showUserInfo">
<div class="user">
<span>
<Icon icon="mynaui:user" width="20" height="20"/>
</span>
<span>{{ item.userEmail }}</span>
</div>
<div class="account">
<span>
<Icon icon="mdi-light:email" width="20" height="20"/>
</span>
<span>{{ item.accountEmail }}</span>
</div>
<div class="del-status" v-if="item.isDel">
<el-tag type="info" size="small">已删除</el-tag>
</div>
</div>
</div>
</div>
<div class="email-right" :style="showUserInfo ? 'align-self: start;':''">
<span class="email-time">{{ fromNow(item.createTime) }}</span>
</div>
</div>
</div>
<div class="loading" v-if="loading">
<Loading />
<div class="loading" :class="loading ? 'loading-show' : 'loading-hide'">
<Loading/>
</div>
<div class="follow-loading" v-if="emailList.length > 0 && !noLoading">
<Loading />
<div class="follow-loading" v-if="followLoading">
<Loading/>
</div>
<div class="noLoading" v-if="noLoading && emailList.length > 0">
<div>没有更多数据了</div>
</div>
<div class="empty" v-if="noLoading && emailList.length === 0">
<el-empty description="没有任何邮件" />
<el-empty description="没有任何邮件"/>
</div>
</div>
</el-scrollbar>
@@ -64,13 +153,12 @@
import Loading from "@/components/loading/index.vue";
import {Icon} from "@iconify/vue";
import {computed, onActivated, reactive, ref, watch} from "vue";
import router from "@/router/index.js";
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";
import {fromNow} from "@/day/day.js";
import {fromNow} from "@/utils/day.js";
const props = defineProps({
getEmailList: Function,
@@ -79,6 +167,30 @@ const props = defineProps({
starCancel: Function,
cancelSuccess: Function,
starSuccess: Function,
actionLeft: {
type: String,
default: '0'
},
timeSort: {
type: Number,
default: 0,
},
showStatus: {
type: Boolean,
default: false
},
showAccountIcon: {
type: Boolean,
default: true,
},
showUserInfo: {
type: Boolean,
default: false
},
showStar: {
type: Boolean,
default: true
},
allowStar: {
type: Boolean,
default: true
@@ -86,6 +198,8 @@ const props = defineProps({
})
const emit = defineEmits(['jump', 'refresh-before'])
const settingStore = useSettingStore()
const uiStore = useUiStore();
const emailStore = useEmailStore();
@@ -93,12 +207,15 @@ const loading = ref(false);
const followLoading = ref(false);
const noLoading = ref(false);
const emailList = reactive([])
const mailTotal = ref(0);
const total = ref(0);
const checkAll = ref(false);
const isIndeterminate = ref(false);
const scroll = ref(null)
const firstLoad = ref(true)
let scrollTop = 0
const latestEmail = ref(null)
const scrollbarRef = ref(null)
let reqLock = false
const queryParam = reactive({
emailId: 0,
size: 30,
@@ -107,10 +224,12 @@ const queryParam = reactive({
defineExpose({
refreshList,
deleteEmail,
addItemStar,
editEmailStar,
addItem,
emailList,
firstLoad
firstLoad,
latestEmail,
noLoading,
total
})
onActivated(() => {
@@ -131,7 +250,7 @@ watch(
updateCheckStatus();
}
},
{ deep: true }
{deep: true}
);
@@ -145,6 +264,28 @@ const accountShow = computed(() => {
return uiStore.accountShow && settingStore.settings.manyEmail === 0
})
function handleScroll(e) {
}
function htmlToText(email) {
if (email.content) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = email.content;
const scriptsAndStyles = tempDiv.querySelectorAll('script, style, title');
scriptsAndStyles.forEach(el => el.remove());
const text = tempDiv.textContent || tempDiv.innerText || '';
return text.replace(/\s+/g, ' ').trim();
}
if (email.text) {
return email.text
} else {
return ''
}
}
function starChange(email) {
@@ -156,7 +297,8 @@ function starChange(email) {
props.starAdd(email.emailId).then(() => {
email.isStar = 1;
props.starSuccess(email)
}).catch(() => {
}).catch(e => {
console.error(e)
email.isStar = 0
})
} else {
@@ -165,7 +307,8 @@ function starChange(email) {
props.starCancel(email.emailId).then(() => {
email.isStar = 0;
props.cancelSuccess?.(email)
}).catch(() => {
}).catch(e => {
console.error(e)
email.isStar = 1;
})
}
@@ -187,7 +330,7 @@ const handleDelete = () => {
ElMessage({
message: '删除成功',
type: 'success',
plain: true,
plain: true
})
emailStore.deleteIds = emailIds;
})
@@ -196,18 +339,18 @@ const handleDelete = () => {
function deleteEmail(emailIds) {
emailIds.forEach(emailId => {
emailList.forEach((item,index) => {
emailList.forEach((item, index) => {
if (emailId === item.emailId) {
emailList.splice(index,1);
emailList.splice(index, 1);
}
})
})
if (emailList.length < queryParam.size && !noLoading.value) {
getEemailList()
getEmailList()
}
}
function addItemStar(email) {
function addItem(email) {
const existIndex = emailList.findIndex(item => item.emailId === email.emailId)
@@ -215,6 +358,18 @@ function addItemStar(email) {
return
}
if (props.timeSort) {
if (noLoading.value) {
emailList.push(email)
latestEmail.value = email
total.value++
} else {
total.value++
}
return;
}
const index = emailList.findIndex(item => item.emailId < email.emailId)
if (index !== -1) {
@@ -224,13 +379,9 @@ function addItemStar(email) {
emailList.push(email)
}
}
}
function editEmailStar(emailId,isStar) {
const index = emailList.findIndex(item => item.emailId === emailId)
if (index !== -1) {
emailList[index].isStar = isStar
}
latestEmail.value = email
total.value++
}
function handleCheckAllChange(val) {
@@ -251,56 +402,75 @@ function updateCheckStatus() {
}
function jumpDetails(email) {
emailStore.readEmail = email
router.push("/content");
emit('jump', email)
}
function getEmailList() {
if (loading.value || followLoading.value || noLoading.value) return;
function getEmailList(refresh = false) {
if (reqLock) return;
reqLock = true
if (!refresh) {
if (loading.value || noLoading.value) {
reqLock = false
return
}
} else {
loading.value = true
}
if (emailList.length === 0) {
loading.value = true
}else {
followLoading.value = true
} else {
followLoading.value = !refresh;
}
props.getEmailList(queryParam.emailId, queryParam.size).then(data => {
if (emailList.length === 0) {
firstLoad.value = false
}
firstLoad.value = false
let list = data.list.map(item => ({
...item,
checked: false
}));
if (data.list.length < queryParam.size) {
noLoading.value = true
if (refresh) {
emailList.length = 0
}
latestEmail.value = data.latestEmail
emailList.push(...list);
mailTotal.value = data.total;
queryParam.emailId = data.list.at(-1).emailId;
if (refresh) scrollbarRef.value?.setScrollTop(0);
noLoading.value = data.list.length < queryParam.size;
followLoading.value = data.list.length >= queryParam.size;
total.value = data.total;
queryParam.emailId = data.list.length > 0 ? data.list.at(-1).emailId : 0
}).finally(() => {
loading.value = false
followLoading.value = false
}).catch(() => {
loading.value = false
followLoading.value = false
reqLock = false
})
}
function refresh() {
emit('refresh-before')
refreshList()
}
function refreshList() {
emailList.splice(0);
noLoading.value = false;
mailTotal.value = 0;
checkAll.value = false;
firstLoad.value = true
isIndeterminate.value = false;
queryParam.emailId = 0;
getEmailList();
getEmailList(true);
}
function loadData() {
@@ -312,8 +482,9 @@ function loadData() {
<style lang="scss" scoped>
.email-container {
background: #FFFFFF;
border-radius: 8px;
display: grid;
grid-template-rows: auto 1fr;
padding: 0;
font-size: 14px;
color: #2e2e2e;
@@ -323,49 +494,106 @@ function loadData() {
.scroll {
margin: 0;
height: calc(100% - 38px);
overflow: auto;
height: 100%;
position: relative;
.scroll-box {
height: 100%;
}
.empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.noLoading {
display: flex;
justify-content: center;
align-items: center;
padding: 10px 0;
padding: 15px 0;
color: gray;
}
.follow-loading {
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.8);
height: 100%;
width: 100%;
position: absolute;
z-index: 1;
top: 0;
left: 0;
}
.loading-show {
transition: all 200ms ease 200ms;
opacity: 1;
}
.loading-hide {
pointer-events: none;
transition: all 200ms;
opacity: 0;
}
}
.email-row {
display: flex;
align-items: center;
padding: 8px 0;
justify-content: space-between;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
cursor: pointer;
align-items: center;
position: relative;
transition: background 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
.el-checkbox{
width: 40px;
@media (max-width: 1199px) {
}
.user-info {
display: flex;
flex-wrap: wrap;
column-gap: 10px;
margin-top: 5px;
margin-bottom: 5px;
color: rgba(25, 41, 59, 0.4);
.user, .account {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: all 300ms;
line-height: 12px;
max-width: 290px;
span:first-child {
position: relative;
}
span:last-child {
margin-left: 5px;
position: relative;
bottom: 5px;
}
}
}
.el-checkbox {
display: flex;
padding-left: 15px;
padding-right: 20px;
justify-content: center;
padding-left: 6px;
}
.title-column {
@@ -378,9 +606,10 @@ function loadData() {
.title {
flex: 1;
display: grid;
gap: 20px;
grid-template-columns: 200px 1fr;
grid-template-columns: 220px 1fr;
@media (max-width: 1199px) {
padding-right: 15px;
}
@media (max-width: 991px) {
grid-template-columns: 1fr;
gap: 4px;
@@ -389,38 +618,90 @@ function loadData() {
.email-sender {
font-weight: bold;
color: #1a1a1a;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: grid;
grid-template-columns: auto 1fr auto;
.email-status {
display: flex;
align-content: center;
}
.name {
display: grid;
gap: 5px;
grid-template-columns: auto 1fr;
@media (min-width: 992px) {
grid-template-columns: 1fr;
> span:last-child {
display: none;
}
}
> span:first-child {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.phone-time {
font-weight: normal;
font-size: 12px;
color: #333 !important;
@media (min-width: 1200px) {
display: none;
}
}
}
.email-subject {
.email-text {
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: grid;
grid-template-columns: auto 1fr;
@media (max-width: 1199px) {
grid-template-columns: 1fr;
}
.email-subject {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.email-content {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 10px;
color: rgba(25, 41, 59, 0.4);
@media (max-width: 1199px) {
padding-left: 0;
margin-top: 0;
}
}
}
}
.email-right {
align-self: center;
text-align: right;
font-size: 12px;
white-space: nowrap;
display: flex;
padding-left: 15px;
align-items: center;
color: #333;
padding-left: 20px;
@media (max-width: 1199px) {
display: none;
}
}
&:hover {
box-shadow:
inset 1px 0 0 rgb(218, 220, 224),
inset -1px 0 0 rgb(218, 220, 224),
0 1px 2px 0 rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
z-index: 2;
box-shadow: inset 1px 0 0 rgb(218, 220, 224),
inset -1px 0 0 rgb(218, 220, 224),
0 1px 2px 0 rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
z-index: 0;
}
/*&[data-checked="true"] {
@@ -434,7 +715,6 @@ function loadData() {
}
.pc-star {
padding-left: 8px;
display: flex;
width: 40px;
}
@@ -443,22 +723,15 @@ function loadData() {
.pc-star {
display: none;
}
.email-right {
display: flex;
flex-direction: column;
}
.phone-star {
display: block;
align-self: end;
padding-right: 16px;
padding-top: 8px;
}
.star-pd{
.star-pd {
padding-top: 6px !important;
}
.title {
padding-left: 8px;
}
}
.email-time {
@@ -470,18 +743,43 @@ function loadData() {
}
.header-actions {
display: flex;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 3px 16px;
gap: 15px;
padding: 3px 15px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
.header-left {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 15px;
row-gap: 8px;
padding-left: 2px;
}
.header-right {
display: grid;
grid-template-columns: auto auto;
align-items: start;
height: 100%;
.email-count {
white-space: nowrap;
margin-top: 6px;
}
}
.icon {
font-size: 18px;
margin-left: 16px;
cursor: pointer;
}
.more-icon {
margin-left: auto;
margin-top: 8px;
margin-left: 15px;
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<div style="padding: 0 15px;cursor: pointer" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"

View File

@@ -0,0 +1,23 @@
<template>
<div class="percent">
<Icon icon="line-md:loading-loop" width="18" height="18" />
<div>
<span>{{value}}%</span>
<span style="margin-left: 5px">邮件正在发送中</span>
</div>
</div>
</template>
<script setup>
import {Icon} from "@iconify/vue";
defineProps({
value: [Number,String]
})
</script>
<style scoped lang="scss">
.percent {
display: flex;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="content-box" ref="contentBox">
<div ref="container" class="content-html"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const props = defineProps({
html: {
type: String,
required: true
}
})
const container = ref(null)
const contentBox = ref(null)
let shadowRoot = null
// 确保字体在 Shadow DOM 中可用
function loadFontInShadow() {
const style = document.createElement('style')
style.textContent = `
@font-face {
font-family: 'HarmonyOS';
src: url('@/assets/fonts/HarmonyOS_Sans_SC_Regular.woff2') format('woff2');
font-display: swap;
}
`
document.head.appendChild(style)
}
function updateContent() {
if (!shadowRoot) return
shadowRoot.innerHTML = `
<style>
:host {
all: initial;
display: block;
width: 100%;
height: 100%;
font-family: 'HarmonyOS', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #13181D;
}
.shadow-content {
width: fit-content;
height: fit-content;
min-width: 100%;
}
img {
max-width: 100%;
height: auto !important;
display: block;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
}
a {
color: #409EFF !important;
}
</style>
<div class="shadow-content">
${props.html}
</div>
`
}
function autoScale() {
if (!shadowRoot || !contentBox.value) return
const parent = contentBox.value
const shadowContent = shadowRoot.querySelector('.shadow-content')
if (!shadowContent) return
const parentWidth = parent.offsetWidth
const parentHeight = parent.offsetHeight
const childWidth = shadowContent.scrollWidth
const childHeight = shadowContent.scrollHeight
if (childWidth === 0 || childHeight === 0) return
const scaleX = parentWidth / childWidth
const scaleY = parentHeight / childHeight
const scale = Math.min(scaleX, scaleY)
const hostElement = shadowRoot.host
hostElement.style.transform = `scale(${scale})`
hostElement.style.transformOrigin = 'top left'
}
onMounted(() => {
loadFontInShadow() // 预加载字体
shadowRoot = container.value.attachShadow({ mode: 'open' })
updateContent()
autoScale()
})
watch(() => props.html, () => {
updateContent()
autoScale()
})
</script>
<style scoped>
.content-box {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'HarmonyOS', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.content-html {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<div class="editor-box" :class="showLoading ? 'editor-box-loading' : ''">
<loading class="loading" v-if="showLoading" />
<textarea v-else style="outline: none" :id="editorId" ref="editorRef"></textarea>
</div>
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount, watch} from 'vue';
import loading from "@/components/loading/index.vue";
import {compressImage} from "@/utils/file-utils.js";
defineExpose({
clearEditor
})
const props = defineProps({
modelValue: {
type: String,
default: ''
},
editorId: {
type: String,
default: () => `editor-${Date.now()}`
}
});
const emit = defineEmits(['change']);
const editor = ref(null);
const isInitialized = ref(false);
const editorRef = ref(null);
const showLoading = ref(false);
onMounted(() => {
initTinyMCE();
});
onBeforeUnmount(() => {
destroyEditor();
});
watch(() => props.modelValue, (newValue) => {
if (editor.value && editor.value.getContent() !== newValue) {
editor.value.setContent(newValue);
}
});
function clearEditor() {
if (editor.value) {
editor.value.setContent('');
}
}
function initTinyMCE() {
if (window.tinymce) {
initEditor();
} else {
showLoading.value = true;
const script = document.createElement('script');
script.src = '/tinymce/tinymce.min.js';
script.onload = () => initEditor();
document.head.appendChild(script);
showLoading.value = false;
}
}
function initEditor() {
window.tinymce.init({
selector: `#${props.editorId}`,
statusbar: false,
height: "100%",
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_mode: 'scrolling',
mobile: {
toolbar: 'fullscreen bold emoticons forecolor fontsize | alignleft aligncenter alignright alignjustify | outdent indent | bullist numlist | link image | table code preview ',
},
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; }",
setup: (ed) => {
editor.value = ed;
ed.on('init', () => {
ed.setContent(props.modelValue);
isInitialized.value = true;
});
ed.on('input change', () => {
const content = ed.getContent();
const text = ed.getContent({ format: 'text' });
emit('change', content, text);
});
},
branding: false,
file_picker_types: 'image',
image_dimensions: false,
image_description: false,
link_title: false,
dialog_type: 'none',
file_picker_callback: (callback, value, meta) => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', async (e) => {
let file = e.target.files[0];
file = await compressImage(file);
const reader = new FileReader();
reader.onload = () => {
const id = 'blobid' + (new Date()).getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(',')[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
callback(blobInfo.blobUri(), { title: file.name });
}
reader.readAsDataURL(file);
});
input.click();
}
});
}
function destroyEditor() {
if (editor.value) {
editor.value.destroy();
editor.value = null;
}
}
</script>
<style lang="scss" scoped>
.editor-box {
height: 100%;
width: 100%;
}
.loading {
margin: auto;
}
.editor-box-loading {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.tox-tbtn.tox-tbtn--select.tox-tbtn--bespoke) {
width: 80px !important;
}
:deep(.tox.tox-tinymce.tox-fullscreen) {
padding-right: 15px;
padding-left: 15px;
padding-bottom: 15px;
background: #FFFFFF;
}
:deep(.tox-tinymce) {
border: none;
border-radius: 0;
}
:deep(.tox-toolbar__group) {
padding-left: 0 !important;
margin: 0 !important;
}
:deep(.tox-tbtn) {
margin: 0 !important;
}
</style>

View File

@@ -1,21 +0,0 @@
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.locale('zh-cn')
dayjs.tz.setDefault('Asia/Shanghai')
function day(time) {
return dayjs(time).add(8, 'hours')
}
export const fromNow = (time) => {
return time ? day(time).fromNow() : ''
}
export default day

View File

@@ -0,0 +1,21 @@
import { useUserStore } from "@/store/user.js";
export default {
mounted(el, binding) {
const userStore = useUserStore();
const permKeys = userStore.user.permKeys;
const value = binding.value;
if (permKeys.includes('*')) {
return;
}
const hasPermission = Array.isArray(value)
? value.some(key => permKeys.includes(key))
: permKeys.includes(value);
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el);
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="account-box">
<div class="head-opt">
<Icon v-if="settingStore.settings.addEmail === 0" class="icon" icon="ion:add-outline" width="23" height="23" @click="add" />
<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 class="icon" icon="ion:reload" width="18" height="18" @click="refresh" />
</div>
<el-scrollbar class="scrollbar">
@@ -15,7 +15,7 @@
<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" icon="fluent:settings-24-filled" width="20" height="20" color="#909399" />
<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 >
<Icon icon="fluent:settings-24-filled" width="20" height="20" color="#909399" />
<template #dropdown >
@@ -68,7 +68,6 @@
</el-scrollbar>
<el-dialog v-model="showAdd" title="添加邮箱" >
<form>
<div class="container">
<el-input v-model="addForm.email" type="text" placeholder="邮箱" autocomplete="off">
<template #append>
@@ -97,7 +96,6 @@
>添加
</el-button>
</div>
</form>
<div
class="add-email-turnstile"
:class="verifyShow ? 'turnstile-show' : 'turnstile-hide'"
@@ -116,6 +114,7 @@ import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
import {useUserStore} from "@/store/user.js";
import hasPerm from "@/utils/perm.js";
const userStore = useUserStore();
const accountStore = useAccountStore();
@@ -136,12 +135,15 @@ const addForm = reactive({
})
const queryParams = {
accountId: 0,
size: 12
size: 20
}
const mySelect = ref()
getAccountList()
if (hasPerm('account:query')) {
getAccountList()
}
const openSelect = () => {
mySelect.value.toggleMenu()
@@ -192,6 +194,7 @@ function refresh() {
}
function changeAccount(account) {
accountStore.currentAccountId = account.accountId
accountStore.currentAccount = account
}
function add() {
@@ -270,6 +273,7 @@ function submit() {
type: "success",
plain: true
})
userStore.refreshUserInfo()
}).catch(res => {
if (res.code === 400) {
verifyToken = ''
@@ -292,12 +296,13 @@ function submit() {
align-items: center;
height: 38px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
padding-left: 10px;
padding-right: 10px;
.icon{
cursor: pointer;
margin-left: 10px;
}
.icon:nth-child(2) {
margin-left: 20px;
margin-left: 15px;
}
}
.scrollbar {
@@ -364,6 +369,7 @@ function submit() {
}
}
.setting-icon {
position: relative;
top: 6px;
@@ -392,6 +398,11 @@ function submit() {
pointer-events: none;
}
:deep(.el-pagination .el-select) {
width: 100px;
background: #FFF;
}
.add-email-turnstile {
margin-top: 15px;
}

View File

@@ -6,22 +6,45 @@
<div>{{settingStore.settings.title}}</div>
</div>
<el-menu :collapse="false" text-color="#fff" active-text-color="#fff" style="margin-top: 10px">
<el-menu-item @click="router.push('/email')" index="email"
<el-menu-item @click="router.push({name: 'email'})" index="email"
:class="route.meta.name === 'email' ? 'choose-item' : ''">
<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('/star')" index="star"
<el-menu-item @click="router.push({name: 'send'})" index="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: 'star'})" index="star"
:class="route.meta.name === 'star' ? 'choose-item' : ''">
<Icon icon="solar:star-line-duotone" width="20" height="20" />
<span class="menu-name" style="margin-left: 20px">星标邮件</span>
</el-menu-item>
<el-menu-item @click="router.push('/setting')" index="setting"
<el-menu-item @click="router.push({name: 'setting'})" index="setting"
:class="route.meta.name === 'setting' ? 'choose-item' : ''">
<Icon icon="fluent:settings-48-regular" width="20" height="20" />
<span class="menu-name" style="margin-left: 20px">个人设置</span>
</el-menu-item>
<el-menu-item @click="router.push('/sys-setting')" index="sys-setting" v-if="userStore.user.type === 0"
<div class="manage-title" v-perm="['user:query','role:query','setting:query']">
<div>管理</div>
</div>
<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" />
<span class="menu-name" style="margin-left: 16px">用户列表</span>
</el-menu-item>
<el-menu-item @click="router.push({name: 'sys-email'})" index="sys-email" v-perm="'sys-email:query'"
:class="route.meta.name === 'sys-email' ? 'choose-item' : ''">
<Icon icon="fluent:mail-list-28-regular" width="22" height="22" />
<span class="menu-name" style="margin-left: 18px">邮件列表</span>
</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" />
<span class="menu-name" style="margin-left: 18px">权限控制</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" />
<span class="menu-name" style="margin-left: 23px">系统设置</span>
@@ -37,15 +60,14 @@
</template>
<script setup>
import {useUserStore} from "@/store/user.js";
import router from "@/router/index.js";
import { useRoute } from "vue-router";
import {Icon} from "@iconify/vue";
import {useSettingStore} from "@/store/setting.js";
const settingStore = useSettingStore();
const userStore = useUserStore();
const route = useRoute();
</script>
<style lang="scss" scoped>
@@ -63,7 +85,6 @@ const route = useRoute();
gap: 5px;
color: #ffffff;
background: linear-gradient(135deg, #1890ff, #1c6dd0);
box-shadow: 0 0 12px rgba(24, 144, 255, 0.6), 0 0 20px rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
:deep(.el-icon) {
@@ -81,6 +102,11 @@ const route = useRoute();
}
.manage-title {
margin-top: 10px;
padding-left: 20px;
color: #fff;
}
.el-menu-item {
margin: 5px 10px !important;
@@ -122,7 +148,7 @@ const route = useRoute();
border-right: 0;
width: 250px;
@media (max-width: 1199px) {
width: 200px;
width: 230px;
}
}
@@ -132,7 +158,7 @@ const route = useRoute();
}
.scroll {
box-shadow: 6px 0 20px rgba(0, 21, 41, 0.35);
}
.github {

View File

@@ -1,12 +1,19 @@
<template>
<div class="header">
<div class="btn">
<div class="header-btn">
<hanburger @click="changeAside"></hanburger>
<span class="breadcrumb-item">{{ route.meta.title }}</span>
</div>
<div class="writer-box" @click="openSend">
<div class="writer" >
<Icon icon="material-symbols:edit-outline-sharp" width="22" height="22" />
</div>
</div>
<div class="toolbar">
<div class="email">{{ userStore.user.email }}</div>
<el-dropdown>
<div class="email">
<span>{{ userStore.user.email }}</span>
</div>
<el-dropdown :teleported="false" popper-class="detail-dropdown" >
<div class="avatar">
<div class="avatar-text">
<div>{{ formatName(userStore.user.email) }}</div>
@@ -14,14 +21,38 @@
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="24" height="24" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/setting')">
个人设置
</el-dropdown-item>
<el-dropdown-item @click="clickLogout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
<div class="user-details">
<div class="details-avatar">
{{ formatName(userStore.user.email) }}
</div>
<div class="detail-email">
{{ userStore.user.email }}
</div>
<div class="detail-user-type">
<el-tag type="warning">{{userStore.user.role.name}}</el-tag>
</div>
<div class="action-info">
<div>
<span style="margin-right: 10px">邮件发送 :</span>
<span style="margin-right: 10px">邮箱添加 :</span>
</div>
<div>
<div>
<span v-if="sendCount !== null" style="margin-right: 5px">{{ sendCount }}</span>
<el-tag v-if="!hasPerm('email:send')" type="warning">{{sendType}}</el-tag>
<el-tag v-else type="success">{{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')" type="success">无限制</el-tag>
<el-tag v-if="!hasPerm('account:add')" type="warning">无权限</el-tag>
</div>
</div>
</div>
<div class="logout">
<el-button type="primary" :loading="logoutLoading" @click="clickLogout">退出</el-button>
</div>
</div>
</template>
</el-dropdown>
</div>
@@ -36,18 +67,58 @@ import {Icon} from "@iconify/vue";
import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js";
import { useRoute } from "vue-router";
import {computed, ref} from "vue";
import hasPerm from "@/utils/perm.js";
const route = useRoute();
const userStore = useUserStore();
const uiStore = useUiStore();
const logoutLoading = ref(false)
const accountCount = computed(() => {
return userStore.user.role.accountCount
})
const sendType = computed(() => {
if (!hasPerm('email:send')) {
return '无权限'
}
if (userStore.user.role.sendType === 'day') {
return '每天'
}
if (userStore.user.role.sendType === 'count') {
return '次数'
}
})
const sendCount = computed(() => {
if (!hasPerm('email:send')) {
return null
}
if (!userStore.user.role.sendCount) {
return '无限制'
}
return userStore.user.sendCount + '/' + userStore.user.role.sendCount
})
function openSend() {
uiStore.writerRef.open()
}
function changeAside() {
uiStore.asideShow = !uiStore.asideShow
}
function clickLogout() {
logoutLoading.value = true
logout().then(() => {
localStorage.removeItem("token")
router.push('/login')
}).finally(() => {
logoutLoading.value = false
})
}
@@ -61,7 +132,7 @@ function formatName(email) {
.breadcrumb-item {
font-weight: bold;
font-size: 16px;
font-size: 14px;
white-space: nowrap;
}
@@ -71,16 +142,113 @@ function formatName(email) {
bottom: 10px;
}
:deep(.el-popper.is-pure) {
border-radius: 8px;
}
.user-details {
width: 250px;
font-size: 14px;
color: #333;
display: grid;
grid-template-columns: 1fr;
justify-items: center;
.detail-user-type {
margin-top: 10px;
}
.action-info {
width: 100%;
display: grid;
grid-template-columns: auto auto;
margin-top: 10px;
>div:first-child {
display: grid;
align-items: center;
gap: 10px;
}
>div:last-child {
display: grid;
gap: 10px;
text-align: center;
>div {
display: flex;
align-items: center;
}
}
}
.detail-email {
padding-left: 20px;
padding-right: 20px;
margin-top: 10px;
width: 250px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
}
.logout {
margin-top: 20px;
width: 100%;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
.el-button {
height: 26px;
width: 100%;
}
}
.details-avatar {
margin-top: 20px;
height: 45px;
width: 45px;
border: 1px solid #ccc;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
}
.header {
text-align: right;
font-size: 12px;
display: grid;
height: 100%;
gap: 10px;
grid-template-columns: auto 1fr;
grid-template-columns: auto auto 1fr;
}
.btn {
.writer-box {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 5px;
.writer {
width: 36px;
height: 36px;
border-radius: 50%;
color: #ffffff;
background: linear-gradient(135deg, #1890ff, #1c6dd0);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
.writer-text {
margin-left: 15px;
font-size: 14px;
font-weight: bold;
}
}
}
.header-btn {
display: inline-flex;
align-items: center;
height: 100%;
@@ -102,12 +270,13 @@ function formatName(email) {
text-overflow: ellipsis;
font-weight: bold;
width: 100%;
}
.avatar {
display: flex;
align-items: center;
cursor: pointer;
.avatar-text {
height: 30px;
width: 30px;

View File

@@ -9,7 +9,6 @@
:class="(uiStore.asideShow && isMobile)? 'overlay-show':'overlay-hide'"
@click="uiStore.asideShow = false"
></div>
<el-container class="main-container">
<el-main>
<el-header>
@@ -19,6 +18,7 @@
</el-main>
</el-container>
</el-container>
<writer ref="writerRef" />
</template>
<script setup>
@@ -27,17 +27,20 @@ import Header from '@/layout/header/index.vue'
import Main from '@/layout/main/index.vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import {useUiStore} from "@/store/ui.js";
import writer from '@/layout/write/index.vue'
const uiStore = useUiStore();
const isMobile = ref(window.innerWidth < 768)
const writerRef = ref({})
const isMobile = ref(window.innerWidth < 992)
const handleResize = () => {
isMobile.value = window.innerWidth < 768
uiStore.asideShow = window.innerWidth >= 992;
isMobile.value = window.innerWidth < 992
uiStore.asideShow = window.innerWidth >= 991;
}
onMounted(() => {
uiStore.writerRef = writerRef
window.addEventListener('resize', handleResize)
handleResize()
})
@@ -58,10 +61,12 @@ onBeforeUnmount(() => {
}
.aside-show {
-webkit-box-shadow: 3px 0 5px rgba(0, 21, 41, .35);
box-shadow: 3px 0 5px rgba(0, 21, 41, 0.35);
transform: translateX(0);
transition: all 100ms ease;
z-index: 101;
@media (max-width: 767px) {
@media (max-width: 991px) {
position: fixed;
top: 0;
left: 0;

View File

@@ -1,9 +1,9 @@
<template>
<div :class=" accountShow ? 'main-box-show' : 'main-box-hide'">
<div :class="accountShow ? 'block-show' : 'block-hide'" @click="uiStore.accountShow = false"></div>
<account :class="accountShow ? 'show' : 'hide'" />
<div :class="accountShow && hasPerm('account:query') ? 'main-box-show' : 'main-box-hide'">
<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-setting','star']">
<keep-alive :include="['email','sys-email','send','sys-setting','star','user','role']">
<component :is="Component" :key="route.name"/>
</keep-alive>
</router-view>
@@ -15,6 +15,7 @@ import {useUiStore} from "@/store/ui.js";
import {useSettingStore} from "@/store/setting.js";
import {computed, onBeforeUnmount, onMounted} from "vue";
import { useRoute } from 'vue-router'
import hasPerm from "@/utils/perm.js";
const props = defineProps({
openSend: Function
@@ -39,10 +40,10 @@ onBeforeUnmount(() => {
})
const handleResize = () => {
if (['content','email'].includes(route.meta.name)) {
if (['content','email','send'].includes(route.meta.name)) {
if (innerWidth !== window.innerWidth) {
innerWidth = window.innerWidth;
uiStore.accountShow = window.innerWidth >= 768;
uiStore.accountShow = window.innerWidth >= 767;
}
}
}
@@ -77,7 +78,7 @@ const handleResize = () => {
@media (max-width: 767px) {
position: fixed;
z-index: 100;
width: 199px;
width: 229px;
}
}
@@ -86,8 +87,8 @@ const handleResize = () => {
position: fixed;
transform: translateX(-100%);
opacity: 0;
@media (max-width: 767px) {
width: 199px;
@media (max-width: 991px) {
width: 229px;
z-index: 100;
}
}
@@ -96,10 +97,13 @@ const handleResize = () => {
.main-box-show {
display: grid;
grid-template-columns: 250px 1fr;
height: calc(100% - 60px);
@media (max-width: 1200px) {
grid-template-columns: 229px 1fr;
}
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
height: calc(100% - 60px);
}
.main-box-hide {

View File

@@ -0,0 +1,378 @@
<template>
<div class="send" v-show="show">
<div class="write-box">
<div class="title">
<div class="title-left">
<span class="title-text">写邮件</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="">
<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"/>
<div class="button-item">
<div class="att-add" @click="chooseFile">
<Icon icon="iconamoon:attachment-fill" width="26" height="26"/>
</div>
<div class="att-clear" @click="clearContent">
<Icon icon="icon-park-outline:clear-format" width="26" height="26"/>
</div>
<div class="att-list">
<div class="att-item" v-for="(item,index) in form.attachments" :key="index">
<Icon :icon="getIconByName(item.filename)" width="20" height="20"/>
<span class="att-filename">{{ item.filename }}</span>
<span class="att-size">{{ formatBytes(item.size) }}</span>
<Icon style="cursor: pointer;" icon="material-symbols-light:close-rounded" @click="delAtt(index)"
width="22" height="22"/>
</div>
</div>
<div>
<el-button type="primary" @click="sendEmail">发送</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import tinyEditor from '@/components/tiny-editor/index.vue'
import {h, onMounted, onUnmounted, reactive, ref} from "vue";
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 hasPerm from "@/utils/perm.js";
defineExpose({
open
})
const emailStore = useEmailStore();
const accountStore = useAccountStore()
const editor = ref({})
const userStore = useUserStore();
const show = ref(false);
const percent = ref(0)
let percentMessage = null
let sending = false
const form = reactive({
sendEmail: '',
receiveEmail: '',
accountId: -1,
name: '',
subject: '',
content: '',
text: '',
attachments: []
})
function clearContent() {
ElMessageBox.confirm('确定要清空邮件吗?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
resetForm()
})
}
function delAtt(index) {
form.attachments.splice(index, 1);
}
function chooseFile() {
const doc = document.createElement("input")
doc.setAttribute("type", "file")
doc.click()
doc.onchange = async (e) => {
const file = e.target.files[0]
const size = file.size
const filename = file.name
const contentType = file.type
const TotalSize = form.attachments.reduce((acc, item) => acc + item.size, 0);
if ((TotalSize + size) > 29360128) {
ElMessage({
message: '附件文件大小限制28mb',
type: 'error',
plain: true,
})
return
}
const content = await fileToBase64(file)
form.attachments.push({content, filename, size, contentType})
}
}
async function sendEmail() {
if (!form.receiveEmail) {
ElMessage({
message: '收件人邮箱地址不能为空',
type: 'error',
plain: true,
})
return
}
if (!isEmail(form.receiveEmail)) {
ElMessage({
message: '请输入正确的收件人邮箱',
type: 'error',
plain: true,
})
return
}
if (!form.subject) {
ElMessage({
message: '主题不能为空',
type: 'error',
plain: true,
})
return
}
if (!form.content) {
ElMessage({
message: '正文不能为空',
type: 'error',
plain: true,
})
return
}
if (sending) {
ElMessage({
message: '邮件正在发送中',
type: 'error',
plain: true,
})
return
}
percentMessage = ElMessage({
message: () => h(sendPercent, { value: percent.value }),
dangerouslyUseHTMLString: true,
plain: true,
duration: 0,
customClass: 'message-bottom'
})
sending = true
close()
emailSend(form, (e) => {
percent.value = Math.round((e.loaded * 98) / e.total)
}).then(email => {
emailStore.sendScroll?.addItem(email)
resetForm()
show.value = false
ElNotification({
title: '邮件已发送',
type: "success",
message: h('span', { style: 'color: teal' }, email.subject),
position: 'bottom-right'
})
userStore.refreshUserInfo();
}).catch((e) => {
ElNotification({
title: '发送失败',
type: e.code === 403 ? 'warning' : 'error',
message: h('span', { style: 'color: teal' }, e.message),
position: 'bottom-right'
})
}).finally(() => {
percentMessage.close()
percent.value = 0
sending = false
})
}
function resetForm() {
form.receiveEmail = ''
form.name = ''
form.subject = ''
form.content = ''
form.attachments = []
editor.value.clearEditor()
}
function change(content, text) {
form.content = content;
form.text = text
}
function open() {
if (!accountStore.currentAccount.email) {
form.sendEmail = userStore.user.email;
form.accountId = userStore.user.accountId;
} else {
form.sendEmail = accountStore.currentAccount.email;
form.accountId = accountStore.currentAccount.accountId;
}
show.value = true;
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
close()
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
function close() {
show.value = false;
}
</script>
<style scoped lang="scss">
.send {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.write-box {
background: #FFFFFF;
width: 900px;
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;
@media (max-width: 991px) {
width: 100%;
height: 100%;
border-radius: 0;
}
@media (min-width: 992px) {
height: min(800px, calc(100vh - 60px));
}
.title {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
.title-left {
align-items: center;
display: flex;
gap: 10px;
}
.title-text {
font-weight: bold;
font-size: 16px;
}
div {
display: flex;
}
}
.container {
height: 100%;
display: flex;
flex-direction: column;
gap: 15px;
.item-title {
color: #333;
margin-right: 8px;
}
.button-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
.att-add {
cursor: pointer;
}
.att-clear {
cursor: pointer;
margin-left: 10px;
}
.att-list {
display: grid;
gap: 5px;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
padding-left: 10px;
padding-right: 10px;
max-height: 110px;
overflow-y: auto;
@media (max-width: 450px) {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.att-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 5px;
height: 32px;
font-size: 14px;
border: 1px solid var(--el-border-color-light);
padding: 5px 5px;
border-radius: 4px;
.att-filename {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
}
}
}
.icon {
cursor: pointer;
}
</style>

View File

@@ -4,17 +4,21 @@ 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)
app.use(router).use(VueCropper).directive('perm',perm)
app.config.devtools = true;
app.mount('#app');

View File

@@ -1,17 +1,22 @@
import http from '@/axios/index.js';
export function emailList(accountId, emailId, size) {
return http.get('/email/list', {params: {accountId, emailId, size}})
export function emailList(accountId, emailId, timeSort, size, type) {
return http.get('/email/list', {params: {accountId, emailId, timeSort, size, type}})
}
export function emailDelete(emailIds) {
return http.delete('/email/delete?emailIds=' + emailIds)
}
export function attList(emailId) {
return http.get('/email/attList', {params: {emailId}})
export function emailLatest(emailId, accountId) {
return http.get('/email/latest', {params: {emailId, accountId}, noMsg: true })
}
export function emailLatest(emailId,accountId) {
return http.get('/email/latest',{params: {emailId,accountId}})
export function emailSend(form,progress) {
return http.post('/email/send', form,{
onUploadProgress: (e) => {
progress(e)
},
noMsg: true
})
}

View File

@@ -0,0 +1,14 @@
import http from '@/axios/index.js';
export function loginUserInfo() {
return http.get('/my/loginUserInfo')
}
export function resetPassword(password) {
return http.put('/my/resetPassword', {password})
}
export function userDelete() {
return http.delete('/my/delete')
}

View File

@@ -0,0 +1,30 @@
import http from '@/axios/index.js';
export function roleAdd(params) {
return http.post('/role/add',params)
}
export function rolePermTree() {
return http.get('/role/permTree')
}
export function roleRoleList() {
return http.get('/role/list')
}
export function roleSet(params) {
return http.put('/role/set',params)
}
export function roleDelete(roleId) {
return http.delete('/role/delete',{params:{roleId}})
}
export function roleSetDef(roleId) {
return http.put('/role/setDefault',{roleId})
}
export function roleSelectUse() {
return http.get('/role/selectUse')
}

View File

@@ -1,13 +0,0 @@
import http from "@/axios/index.js";
export function sendEmail(form) {
return http.post('/send/sendEmail', form)
}
export function sendList(accountId, sendId, size) {
return http.get('/send/list',{ params: {accountId, sendId, size}})
}
export function sendDelete(sendIds) {
return http.delete('/send/delete?sendIds=' + sendIds);
}

View File

@@ -6,4 +6,12 @@ export function settingSet(setting) {
export function settingQuery() {
return http.get('/setting/query')
}
export function setBackground(background) {
return http.put('/setting/setBackground',{background})
}
export function physicsDeleteAll() {
return http.delete('/setting/physicsDeleteAll')
}

View File

@@ -0,0 +1,9 @@
import http from '@/axios/index.js';
export function sysEmailAll(params) {
return http.get('/sys-email/list', {params: {...params}})
}
export function sysEmailDelete(emailIds) {
return http.delete('/sys-email/delete?emailIds=' + emailIds)
}

View File

@@ -1,13 +1,35 @@
import http from '@/axios/index.js';
import http from '@/axios/index.js'
export function loginUserInfo() {
return http.get('/user/loginUserInfo')
export function userList(params) {
return http.get('/user/list', {params: {...params}})
}
export function resetPassword(password) {
return http.put('/user/resetPassword', {password})
export function userSetPwd(params) {
return http.put('/user/setPwd', params)
}
export function userDelete() {
return http.delete('/user/delete')
export function userSetStatus(params) {
return http.put('/user/setStatus', params)
}
export function userSetType(params) {
return http.put('/user/setType', params)
}
export function userDelete(userId) {
return http.delete('/user/delete', {params:{userId}})
}
export function userAdd(form) {
return http.post('/user/add', form)
}
export function userRestSendCount(userId) {
return http.put('/user/resetSendCount', {userId})
}
export function userRestore(userId,type) {
return http.put('/user/restore', {userId,type})
}

View File

@@ -1,9 +1,5 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import {loginUserInfo} from "@/request/user.js";
import {createRouter, createWebHistory} from 'vue-router'
import {useUiStore} from "@/store/ui.js";
import {useAccountStore} from "@/store/account.js";
import {useUserStore} from "@/store/user.js";
import {useSettingStore} from "@/store/setting.js";
const routes = [
{
@@ -22,6 +18,16 @@ const routes = [
menu: true
}
},
{
path: '/send',
name: 'send',
component: () => import('@/views/send/index.vue'),
meta: {
title: '已发送',
name: 'send',
menu: true
}
},
{
path: '/content',
name: 'content',
@@ -42,16 +48,6 @@ const routes = [
menu: true
}
},
{
path: '/sys-setting',
name: 'sys-setting',
component: () => import('@/views/sys-setting/index.vue'),
meta: {
title: '系统设置',
name: 'sys-setting',
menu: true
}
},
{
path: '/star',
name: 'star',
@@ -74,28 +70,26 @@ const routes = [
path: '/test',
name: 'test',
component: () => import('@/views/test/index.vue')
},
{
path: '/:pathMatch(.*)*',
name: '404',
component: () => import('@/views/404/index.vue')
}
]
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
let firstLogin = true
router.beforeEach(async (to, from, next) => {
const uiStore = useUiStore()
if (uiStore.init && to.name === 'login') {
await initSetting()
}
const token = localStorage.getItem('token')
if (!token && to.name !== 'login') {
return next({
name: 'login'
})
@@ -105,18 +99,6 @@ router.beforeEach(async (to, from, next) => {
return next()
}
if (uiStore.init && to.name !== 'login') {
await initSettingAndUserInfo()
firstLogin = false
return next()
}
if (firstLogin) {
await initUserInfo()
firstLogin = false
return next()
}
next()
})
@@ -125,60 +107,16 @@ router.afterEach((to) => {
const uiStore = useUiStore()
if (to.meta.menu) {
if (['content', 'email'].includes(to.meta.name)) {
if (['content', 'email', 'send'].includes(to.meta.name)) {
uiStore.accountShow = window.innerWidth > 767;
} else {
uiStore.accountShow = false
}
}
if (to.name === 'login') {
firstLogin = true
}
if (window.innerWidth < 768) {
if (window.innerWidth < 992) {
uiStore.asideShow = false
}
})
async function initSettingAndUserInfo() {
const uiStore = useUiStore()
const userStore = useUserStore()
const settingStore = useSettingStore()
const accountStore = useAccountStore()
const [setting,user] = await Promise.all([settingStore.initSetting(),loginUserInfo()])
uiStore.init = false
accountStore.currentAccountId = user.accountId
userStore.user = user
}
async function initUserInfo() {
const uiStore = useUiStore()
const userStore = useUserStore()
try {
const user = await loginUserInfo()
const accountStore = useAccountStore()
accountStore.currentAccountId = user.accountId
userStore.user = user
uiStore.loginLoading = false
} catch (e) {
uiStore.loginLoading = false
throw e
}
}
async function initSetting() {
const settingStore = useSettingStore()
const uiStore = useUiStore()
try {
await settingStore.initSetting();
uiStore.init = false
} catch (e) {
throw e
}
}
export default router

View File

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

View File

@@ -5,9 +5,14 @@ export const useEmailStore = defineStore('email', {
deleteIds: 0,
starScroll: null,
emailScroll: null,
readEmail: {},
contentData: {
email: null,
delType: null,
showStar: true
},
sendScroll: null,
}),
persist: {
pick: ['readEmail'],
pick: ['contentData'],
},
})

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
export const useRoleStore = defineStore('role', {
state: () => ({
refresh: 0,
}),
actions: {
refreshSelect() {
this.refresh ++
}
}
})

View File

@@ -1,21 +1,13 @@
import { defineStore } from 'pinia'
import { settingQuery} from "@/request/setting.js";
export const useSettingStore = defineStore('setting', {
state: () => ({
domainList: [],
settings: {
title: '-'
r2Domain: ''
}
}),
actions: {
async initSetting() {
if (this.domainList.length === 0) {
const data = await settingQuery()
this.domainList.push(...data.domainList)
delete data.domainList
this.settings = data
}
}
}
})

View File

@@ -3,9 +3,14 @@ import { defineStore } from 'pinia'
export const useUiStore = defineStore('ui', {
state: () => ({
asideShow: window.innerWidth > 991,
loginLoading: false,
accountShow: false,
init: true,
backgroundLoading: true,
writerRef: null,
asideCount: {
email: 0,
send: 0,
sysEmail: 0
}
}),
persist: {
pick: ['accountShow'],

View File

@@ -1,7 +1,15 @@
import { defineStore } from 'pinia'
import {loginUserInfo} from "@/request/my.js";
export const useUserStore = defineStore('user', {
state: () => ({
user: {},
})
}),
actions: {
refreshUserInfo() {
loginUserInfo().then(user => {
this.user = user
})
}
}
})

View File

@@ -64,6 +64,26 @@ button, input, select, textarea {
outline: none;
}
.tox .tox-dialog--width-lg {
@media (min-width: 992px) {
height: 850px !important;
}
}
.tox .tox-dialog__body-content {
max-height: min(850px, calc(100vh - 110px)) !important;
@media (min-width: 992px) {
max-height: min(850px, calc(100vh - 110px));
}
}
.tox .tox-dialog__body-content {
@media (min-width: 992px) {
max-height: min(850px, calc(100vh - 110px)) !important;
}
}
:root {
--el-color-primary: #1890ff;
--el-color-primary-dark-2: #1064c0;
@@ -73,3 +93,32 @@ button, input, select, textarea {
--el-color-primary-light-9: #e6f7ff;
--el-text-color-regular: #333;
}
.message-bottom {
top: auto !important;
bottom: 20px !important;
.el-icon {
display: none !important;
}
}
@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;
}
}

View File

@@ -1,6 +1,5 @@
import {useSettingStore} from "@/store/setting.js";
export function cvtR2Url(key) {
const settingStore = useSettingStore();
return 'https://' + settingStore.settings.r2Domain + '/' + key
return settingStore.settings.r2Domain + '/' + key
}

43
mail-vue/src/utils/day.js Normal file
View File

@@ -0,0 +1,43 @@
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.locale('zh-cn')
export function fromNow(date) {
const d = dayjs.utc(date).tz('Asia/Shanghai');
const now = dayjs();
const diffSeconds = now.diff(d, 'second');
const diffMinutes = now.diff(d, 'minute');
const diffHours = now.diff(d, 'hour');
const isToday = now.isSame(d, 'day');
if (isToday) {
if (diffSeconds < 60) return `几秒前`;
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
if (diffHours >= 1 && diffHours < 2) return '1小时前';
return d.format('HH:mm');
}
else if (now.subtract(1, 'day').isSame(d, 'day')) {
return `昨天 ${d.format('HH:mm')}`;
}
else if (now.subtract(2, 'day').isSame(d, 'day')) {
return `前天 ${d.format('HH:mm')}`;
}
return d.year() === now.year()
? d.format('M月D日')
: d.format('YYYY/M/D');
}
export function formatDetailDate(time) {
return dayjs.utc(time).format('YYYY年M月D日 ddd AH:mm')
}
export function tzDayjs(time) {
return dayjs.utc(time).tz('Asia/Shanghai')
}

View File

@@ -1,3 +1,5 @@
import Compressor from "compressorjs";
export function getExtName(fileName) {
const index = fileName.lastIndexOf('.')
return index !== -1 ? fileName.slice(index + 1).toLowerCase() : ''
@@ -10,4 +12,38 @@ export function formatBytes(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = (bytes / Math.pow(k, i)).toFixed(2);
return `${size} ${units[i]}`;
}
}
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
}
export function base64Size(base64String) {
const padding = (base64String.match(/=*$/) || [''])[0].length;
const base64Length = base64String.length;
return (base64Length * 3) / 4 - padding;
}
export function compressImage(file, quality = 0.6) {
return new Promise((resolve, reject) => {
new Compressor(file, {
quality,
mimeType: 'image/jpeg',
convertSize: 1024 * 1024,
success(result) {
resolve(result);
},
error(err) {
reject(err);
},
});
});
}

View File

@@ -0,0 +1,16 @@
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 (['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';
if (['mp3', 'wav', 'aac', 'ogg', 'flac', 'm4a'].includes(extName)) return 'mynaui:music';
if (['.ppt', 'pptx', 'pps', 'potx', 'pot'].includes(extName)) return 'lsicon:file-ppt-filled'
if (extName === 'pdf') return 'hugeicons:pdf-02';
if (extName === 'apk') return 'proicons:android';
if (extName === 'exe') return 'bi:filetype-exe';
return "hugeicons:attachment-01"
}

View File

@@ -0,0 +1,71 @@
import { useUserStore } from "@/store/user.js";
import { useSettingStore } from "@/store/setting.js";
import { useAccountStore } from "@/store/account.js";
import { loginUserInfo } from "@/request/my.js";
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 = '-'
const settingStore = useSettingStore();
const userStore = useUserStore();
const accountStore = useAccountStore();
const token = localStorage.getItem('token');
let setting = null;
if (token) {
const userPromise = loginUserInfo().catch(e => {
console.error(e);
return null;
});
const [s, user] = await Promise.all([settingQuery(), userPromise]);
setting = s;
settingStore.settings = setting;
settingStore.domainList = setting.domainList;
document.title = setting.title;
if (user) {
accountStore.currentAccountId = user.accountId;
userStore.user = user;
const routers = permsToRouter(user.permKeys);
routers.forEach(routerData => {
router.addRoute('layout', routerData);
});
}
} else {
setting = await settingQuery();
settingStore.settings = setting;
settingStore.domainList = setting.domainList;
document.title = setting.title;
}
const loading = document.getElementById('loading-first');
if (!setting.background) {
loading.remove();
return;
}
const img = new Image();
img.src = cvtR2Url(setting.background);
img.onload = () => {
loading.remove();
};
img.onerror = () => {
console.warn('背景图片加载失败:', img.src);
loading.remove();
};
}

View File

@@ -0,0 +1,60 @@
import {useUserStore} from "@/store/user.js";
export default function hasPerm(permKey) {
const {permKeys} = useUserStore().user;
return permKeys.includes('*') || permKeys.includes(permKey);
}
export function permsToRouter(permKeys) {
const routerList = []
Object.keys(routers).forEach(perm => {
if (permKeys.includes(perm) || permKeys.includes('*')) {
routerList.push(routers[perm])
}
})
return routerList;
}
const routers = {
'user:query': {
path: '/sys/user',
name: 'user',
component: () => import('@/views/user/index.vue'),
meta: {
title: '用户列表',
name: 'user',
menu: true
}
},
'role:query': {
path: '/sys/role',
name: 'role',
component: () => import('@/views/role/index.vue'),
meta: {
title: '权限控制',
name: 'role',
menu: true
}
},
'setting:query': {
path: '/sys/setting',
name: 'sys-setting',
component: () => import('@/views/sys-setting/index.vue'),
meta: {
title: '系统设置',
name: 'sys-setting',
menu: true
}
},
'sys-email:query': {
path: '/sys/email',
name: 'sys-email',
component: () => import('@/views/sys-email/index.vue'),
meta: {
title: '邮箱列表',
name: 'sys-email',
menu: true
}
}
}

View File

@@ -0,0 +1,21 @@
<script setup>
import router from "@/router/index.js";
</script>
<template>
<div class="box-404">
<el-empty class="empty" description="404错误, 找不到页面" >
<el-button type="primary" @click="router.replace({name: 'layout'})">返回首页</el-button>
</el-empty>
</div>
</template>
<style scoped lang="scss">
.box-404 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -2,9 +2,11 @@
<div class="box">
<div class="header-actions">
<Icon class="icon" icon="material-symbols-light:arrow-back-ios-new" width="20" height="20" @click="handleBack"/>
<Icon class="icon" icon="uiw:delete" width="16" height="16" @click="handleDelete"/>
<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"/>
<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>
</div>
<div></div>
<el-scrollbar class="scrollbar">
@@ -26,95 +28,112 @@
<div>{{ formatDetailDate(email.createTime) }}</div>
</div>
</div>
<div class="icon">
</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 />
</div>
<el-scrollbar class="htm-scrollbar">
<div v-html="email.content"/>
<el-scrollbar class="htm-scrollbar" :class="email.attList.length === 0 ? 'bottom-distance' : ''">
<ShadowHtml :html="formatImage(email)" />
</el-scrollbar>
<div class="att" v-if="atts.length > 0">
<div class="att-title">附件列表</div>
<div class="att-item" v-for="att in atts" :key="att.attId">
<img v-if="isImage(att.filename)" class="att-image" :src="cvtR2Url(att.key)"/>
<div class="att-icon" v-else>
<Icon :icon="toIcon(att.filename)" width="20" height="20"/>
</div>
<div class="att-name">
{{ att.filename }}
</div>
<div>{{ formatBytes(att.size) }}</div>
<div class="down-icon att-icon">
<a style="color: #333" :href="cvtR2Url(att.key)" download>
<Icon icon="system-uicons:push-down" width="22" height="22"/>
</a>
<div class="att" v-if="email.attList.length > 0">
<div class="att-title">
<span>附件列表</span>
<span> {{email.attList.length}} </span>
</div>
<div class="att-box">
<div class="att-item" v-for="att in email.attList" :key="att.attId">
<div class="att-icon" @click="showImage(att.key)">
<Icon :icon="getIconByName(att.filename)" width="20" height="20"/>
</div>
<el-tooltip
effect="dark"
:content="att.filename"
>
<div class="att-name" @click="showImage(att.key)">
{{ att.filename }}
</div>
</el-tooltip>
<div style="color: rgba(24, 36, 48, 0.6);">{{ formatBytes(att.size) }}</div>
<div class="opt-icon att-icon">
<Icon v-if="isImage(att.filename)" icon="hugeicons:view" width="22" height="22" @click="showImage(att.key)"/>
<a :href="cvtR2Url(att.key)" download>
<Icon icon="system-uicons:push-down" width="22" height="22"/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</el-scrollbar>
<el-image-viewer
v-if="showPreview"
:url-list="srcList"
show-progress
@close="showPreview = false"
/>
</div>
</template>
<script setup>
import {reactive, watch} from "vue";
import ShadowHtml from '@/components/shadow-html/index.vue'
import {reactive, ref, watch} from "vue";
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {attList, emailDelete} from "@/request/email.js";
import {emailDelete} from "@/request/email.js";
import {Icon} from "@iconify/vue";
import {useEmailStore} from "@/store/email.js";
import {useAccountStore} from "@/store/account.js";
import day from "@/day/day.js";
import {formatDetailDate} from "@/utils/day.js";
import {starAdd, starCancel} from "@/request/star.js";
import {getExtName, formatBytes} from "@/utils/file-utils.js";
import {cvtR2Url} from "@/utils/convert-utils.js";
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";
const settingStore = useSettingStore();
const accountStore = useAccountStore();
const emailStore = useEmailStore();
const router = useRouter()
const email = emailStore.readEmail
const atts = reactive([])
attList(email.emailId).then(list => {
atts.push(...list)
})
const email = emailStore.contentData.email
const showPreview = ref(false)
const srcList = reactive([])
watch(() => accountStore.currentAccountId, () => {
handleBack()
})
function toIcon(filename) {
const extName = getExtName(filename)
if (['zip', 'rar', '7z', 'tar', 'tgz'].includes(extName)) return 'hugeicons:file-zip';
if (['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'].includes(extName)) return 'fluent:video-clip-24-regular';
if (['txt', 'doc', 'docx', 'md'].includes(extName)) return 'hugeicons:google-doc'
if (['xls', 'csv', 'xlsx'].includes(extName)) return 'codicon:table';
if (['mp3', 'wav', 'aac', 'ogg', 'flac', 'm4a'].includes(extName)) return 'mynaui:music';
if (['.ppt', 'pptx', 'pps', 'potx', 'pot'].includes(extName)) return 'lsicon:file-ppt-filled'
if (extName === 'pdf') return 'hugeicons:pdf-02';
if (extName === 'apk') return 'proicons:android';
if (extName === 'exe') return 'bi:filetype-exe';
return "hugeicons:attachment-01"
function toMessage(message) {
return message ? JSON.parse(message).message : '';
}
function formatImage(email) {
let content = email.content || email.text || ''
const domain = settingStore.settings.r2Domain;
return content.replace('{{domain}}', domain + '/');
}
function showImage(key) {
if (!isImage(key)) return;
const url = cvtR2Url(key)
srcList.length = 0
srcList.push(url)
showPreview.value = true
}
function isImage(filename) {
return ['png', 'jpg', 'jpeg', 'bmp', 'gif'].includes(getExtName(filename))
}
const formatDetailDate = (time) => {
return day(time).format('YYYY年M月D日 ddd AH:mm')
}
function changeStar() {
if (email.isStar) {
email.isStar = 0;
starCancel(email.emailId).then(() => {
email.isStar = 0;
emailStore.emailScroll?.editEmailStar(email.emailId, 0)
emailStore.starScroll?.deleteEmail([email.emailId])
}).catch((e) => {
console.error(e)
@@ -124,8 +143,7 @@ function changeStar() {
email.isStar = 1;
starAdd(email.emailId).then(() => {
email.isStar = 1;
emailStore.emailScroll?.editEmailStar(email.emailId, 1)
emailStore.starScroll?.addItemStar(email)
emailStore.starScroll?.addItem(email)
}).catch((e) => {
console.error(e)
email.isStar = 0;
@@ -143,14 +161,27 @@ const handleDelete = () => {
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
emailDelete(email.value.emailId).then(() => {
ElMessage({
message: '删除成功',
type: 'success',
plain: true,
if (emailStore.contentData.delType === 'logic') {
emailDelete(email.emailId).then(() => {
ElMessage({
message: '删除成功',
type: 'success',
plain: true,
})
emailStore.deleteIds = [email.emailId]
})
emailStore.deleteIds = [email.value.emailId]
})
} else {
sysEmailDelete(email.emailId).then(() => {
ElMessage({
message: '删除成功',
type: 'success',
plain: true,
})
emailStore.deleteIds = [email.emailId]
})
}
router.back()
})
}
@@ -165,7 +196,7 @@ const handleDelete = () => {
padding: 9px 15px;
display: flex;
align-items: center;
gap: 28px;
gap: 20px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
@@ -193,8 +224,6 @@ const handleDelete = () => {
}
.htm-scrollbar {
padding-bottom: 15px;
word-break: break-all;
}
.content {
@@ -202,28 +231,41 @@ const handleDelete = () => {
flex-direction: column;
.att {
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e7e9ec;
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 30px;
margin-bottom: 30px;
border: 1px solid #e4e7ed;
box-shadow: var(--el-box-shadow-light);
padding: 10px;
border-radius: 4px;
width: fit-content;
.att-box {
min-width: min(410px,calc(100vw - 53px));
display: grid;
gap: 10px;
grid-template-rows: 1fr;
}
.att-title {
font-weight: bold;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
span:first-child {
font-weight: bold;
}
}
.att-item {
cursor: pointer;
div {
align-self: center;
}
padding: 10px;
padding: 5px 8px;
border-radius: 4px;
align-self: start;
border: 1px solid #e7e9ec;
display: grid;
grid-template-columns: auto 1fr auto 20px;
grid-template-columns: auto 1fr auto auto;
gap: 10px;
.att-icon {
@@ -231,7 +273,7 @@ const handleDelete = () => {
}
.att-name {
margin-right: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -244,29 +286,36 @@ const handleDelete = () => {
object-fit: contain;
}
.down-icon {
.opt-icon {
color: rgba(24, 36, 48, 0.6);
align-items: center;
display: flex;
gap: 8px;
cursor: pointer;
a {
color: rgba(24, 36, 48, 0.6);
align-items: center;
display: flex;
}
}
}
}
.email-info {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #e7e9ec;
margin-bottom: 15px;
/*.date {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
}*/
.date {
color: #585d69;
margin-bottom: 6px;
}
.email-msg {
max-width: 400px;
width: fit-content;
margin-bottom: 15px;
}
.send {
display: flex;
margin-bottom: 6px;
@@ -278,7 +327,7 @@ const handleDelete = () => {
}
.send-name-title {
padding-right: 10px;
padding-right: 5px;
}
}
@@ -305,4 +354,9 @@ const handleDelete = () => {
}
}
.bottom-distance {
margin-bottom: 30px;
}
</style>

View File

@@ -5,18 +5,33 @@
:getEmailList="getEmailList"
:emailDelete="emailDelete"
:star-add="starAdd"
:star-cancel="starCancel"/>
:star-cancel="starCancel"
:time-sort="params.timeSort"
actionLeft="4px"
@jump="jumpContent"
>
<template #first>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-down-outline"
v-if="params.timeSort === 0" width="28" height="28"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-up-outline" v-else
width="28" height="28"/>
</template>
</emailScroll>
</template>
<script setup>
import {useAccountStore} from "@/store/account.js";
import {useEmailStore} from "@/store/email.js";
import {useSettingStore} from "@/store/setting.js";
import {useUiStore} from "@/store/ui.js";
import emailScroll from "@/components/email-scroll/index.vue"
import {emailList, emailDelete, emailLatest} from "@/request/email.js";
import {starAdd, starCancel} from "@/request/star.js";
import {defineOptions, onMounted, ref, watch} from "vue";
import {defineOptions, onMounted, reactive, ref, watch} from "vue";
import {sleep} from "@/utils/time-utils.js";
import router from "@/router/index.js";
import {Icon} from "@iconify/vue";
defineOptions({
name: 'email'
@@ -26,6 +41,9 @@ const emailStore = useEmailStore();
const accountStore = useAccountStore();
const settingStore = useSettingStore();
const scroll = ref({})
const params = reactive({
timeSort: 0,
})
onMounted(() => {
emailStore.emailScroll = scroll;
@@ -37,16 +55,39 @@ watch(() => accountStore.currentAccountId, () => {
scroll.value.refreshList();
})
function changeTimeSort() {
params.timeSort = params.timeSort ? 0 : 1
scroll.value.refreshList();
}
function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
router.push('/content')
}
const existIds = new Set();
async function latest() {
while (true) {
const latestId = scroll.value.emailList[0]?.emailId ?? 0
const latestId = scroll.value.latestEmail?.emailId || 0
if (!scroll.value.firstLoad && settingStore.settings.autoRefreshTime) {
try {
const accountId = accountStore.currentAccountId
const list = await emailLatest(latestId,accountId)
if (accountId === accountStore.currentAccountId) {
scroll.value.emailList.unshift(...list)
const curTimeSort = params.timeSort
const list = await emailLatest(latestId, accountId)
if (accountId === accountStore.currentAccountId && params.timeSort === curTimeSort) {
if (list.length > 0) {
list.forEach(email => {
existIds.add(email.emailId)
scroll.value.addItem(email)
})
}
}
} catch (e) {
console.error(e)
@@ -57,7 +98,7 @@ async function latest() {
}
function addStar(email) {
emailStore.starScroll?.addItemStar(email)
emailStore.starScroll?.addItem(email)
}
function cancelStar(email) {
@@ -65,8 +106,12 @@ function cancelStar(email) {
}
function getEmailList(emailId, size) {
return emailList(accountStore.currentAccountId, emailId, size)
return emailList(accountStore.currentAccountId, emailId, params.timeSort, size, 0)
}
</script>
<style>
.icon {
cursor: pointer;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div id="box">
<div id="background-wrap">
<div id="login-box" :style="background">
<div id="background-wrap" v-if="!settingStore.settings.background">
<div class="x1 cloud"></div>
<div class="x2 cloud"></div>
<div class="x3 cloud"></div>
@@ -8,19 +8,18 @@
<div class="x5 cloud"></div>
</div>
<div class="form-wrapper">
<el-form autocomplete="off">
<div class="container" >
<div class="container">
<span class="form-title">{{ settingStore.settings.title }}</span>
<div class="custom-style" v-if="settingStore.settings.register === 0">
<el-segmented v-model="show" :options="options" />
<el-segmented v-model="show" :options="options"/>
</div>
<div v-if="show === 'login'">
<el-input v-model="form.email" type="text" placeholder="邮箱" autocomplete="off">
<template #prefix>
<Icon icon="weui:email-outlined" width="22" height="22" />
<Icon icon="weui:email-outlined" width="22" height="22"/>
</template>
<template #append>
<div @click.stop="openSelect">
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="suffix"
@@ -35,28 +34,28 @@
/>
</el-select>
<div style="color: #333">
<span >{{suffix}}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20" />
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-input v-model="form.password" placeholder="密码" type="password" autocomplete="off">
<template #prefix>
<Icon icon="carbon:password" width="22" height="22" />
<Icon icon="carbon:password" width="22" height="22"/>
</template>
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="uiStore.loginLoading"
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
>登录
</el-button>
</div>
<div v-else>
<el-input v-model="registerForm.email" type="text" placeholder="邮箱" autocomplete="off">
<template #prefix>
<Icon icon="weui:email-outlined" width="22" height="22" />
<Icon icon="weui:email-outlined" width="22" height="22"/>
</template>
<template #append>
<div @click.stop="openSelect">
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="suffix"
@@ -71,20 +70,20 @@
/>
</el-select>
<div style="color: #333">
<span>{{suffix}}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20" />
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-input v-model="registerForm.password" placeholder="密码" type="password" autocomplete="off">
<template #prefix>
<Icon icon="carbon:password" width="22" height="22" />
<Icon icon="carbon:password" width="22" height="22"/>
</template>
</el-input>
<el-input v-model="registerForm.confirmPassword" placeholder="确认密码" type="password" autocomplete="off">
<template #prefix>
<Icon icon="carbon:password" width="22" height="22" />
<Icon icon="carbon:password" width="22" height="22"/>
</template>
</el-input>
<el-button class="btn" type="primary" @click="submitRegister" :loading="registerLoading"
@@ -92,7 +91,6 @@
</el-button>
</div>
</div>
</el-form>
</div>
<el-dialog
v-model="verifyShow"
@@ -111,24 +109,30 @@
<script setup>
import router from "@/router";
import {nextTick, reactive, ref} from "vue";
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 {useUiStore} from "@/store/ui.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
import {useUserStore} from "@/store/user.js";
import {Icon} from "@iconify/vue";
import {cvtR2Url} from "@/utils/convert.js";
import {loginUserInfo} from "@/request/my.js";
import {permsToRouter} from "@/utils/perm.js";
const accountStore = useAccountStore();
const userStore = useUserStore();
const settingStore = useSettingStore();
const uiStore = useUiStore()
const loginLoading = ref(false)
const show = ref('login')
const form = reactive({
email: '',
password: '',
});
const options = [{label: '登录', value: 'login'},{label: '注册', value: 'register'}];
const options = [{label: '登录', value: 'login'}, {label: '注册', value: 'register'}];
const mySelect = ref()
const suffix = ref('')
const registerForm = reactive({
@@ -136,18 +140,28 @@ const registerForm = reactive({
password: '',
confirmPassword: ''
})
const domainList = settingStore.domainList;
const domainList = settingStore.domainList;
const registerLoading = ref(false)
suffix.value = domainList[0]
const verifyShow = ref(false)
let verifyToken = ''
let turnstileId = ''
const background = computed(() => {
return settingStore.settings.background ? {
'background-image': `url(${cvtR2Url(settingStore.settings.background)})`,
'background-repeat': 'no-repeat',
'background-size': 'cover',
'background-position': 'center'
} : ''
})
window.onTurnstileSuccess = (token) => {
verifyToken = token;
setTimeout(() => {
verifyShow.value = false
},1500)
}, 1500)
};
const openSelect = () => {
@@ -184,21 +198,19 @@ const submit = () => {
return
}
if (form.password.length < 6) {
ElMessage({
message: '密码最少六位',
type: 'error',
plain: true,
})
return
}
uiStore.loginLoading = true
login(form.email+suffix.value, form.password).then(data => {
loginLoading.value = true
login(form.email + suffix.value, form.password).then(async data => {
localStorage.setItem('token', data.token)
router.replace({name: 'layout'})
}).catch(() => {
uiStore.loginLoading = false
const user = await loginUserInfo();
accountStore.currentAccountId = user.accountId;
userStore.user = user;
const routers = permsToRouter(user.permKeys);
routers.forEach(routerData => {
router.addRoute('layout', routerData);
});
await router.replace({name: 'layout'})
}).finally(() => {
loginLoading.value = false
})
}
@@ -255,7 +267,7 @@ function submitRegister() {
verifyShow.value = true
if (!turnstileId) {
nextTick(() => {
turnstileId = window.turnstile.render('.register-turnstile')
turnstileId = window.turnstile.render('.register-turnstile')
})
} else {
nextTick(() => {
@@ -266,7 +278,7 @@ function submitRegister() {
}
registerLoading.value = true
register({email: registerForm.email + suffix.value, password: registerForm.password,token: verifyToken}).then(() => {
register({email: registerForm.email + suffix.value, password: registerForm.password, token: verifyToken}).then(() => {
show.value = 'login'
registerForm.email = ''
registerForm.password = ''
@@ -305,14 +317,17 @@ function submitRegister() {
}
.container {
max-width: 380px;
background: #FFFFFF;
padding: 20px;
margin: 0 20px;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px solid #e4e7ed;
box-shadow: var(--el-box-shadow-light);
.switch {
padding-top: 10px;
font-size: 14px;
@@ -334,10 +349,6 @@ function submitRegister() {
}
}
form{
max-width: 410px;
padding: 0 20px;
}
.setting-icon {
position: relative;
@@ -357,15 +368,17 @@ form{
opacity: 0;
pointer-events: none;
}
.custom-style {
margin-bottom: 10px;
}
.custom-style .el-segmented {
--el-border-radius-base: 8px;
--el-border-radius-base: 6px;
width: 180px;
}
#box {
#login-box {
background: linear-gradient(to bottom, #2980b9, #6dd5fa, #fff);
color: #333;
font: 100% Arial, sans-serif;
@@ -375,6 +388,7 @@ form{
overflow-x: hidden;
}
#background-wrap {
height: 100%;
z-index: 0;

View File

@@ -0,0 +1,411 @@
<template>
<div class="perm-box">
<div class="header-actions">
<Icon class="icon" icon="ion:add-outline" width="23" height="23" @click="openAddRole" />
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refresh" />
</div>
<div class="loading" v-if="tableLoading">
<loading/>
</div>
<el-scrollbar v-else class="perm-scrollbar">
<div>
<el-table
:data="roles"
style="width: 100%;"
>
<el-table-column width="10" />
<el-table-column label="身份名称" prop="name" :min-width="roleWidth">
<template #default="props">
<div class="role-name">
<span >{{props.row.name}}</span>
<span v-if="props.row.isDefault"><el-tag class="def-tag" type="success">默认</el-tag></span>
</div>
</template>
</el-table-column>
<el-table-column label="排序" :width="sortWidth" prop="sort"/>
<el-table-column v-if="desShow" label="描述" min-width="200" prop="description" >
<template #default="props">
<div class="description">
<span >{{props.row.description}}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" :width="settingWidth">
<template #default="props">
<el-dropdown trigger="click">
<el-button size="small" type="primary">操作</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openRoleSet(props.row)">修改</el-dropdown-item>
<el-dropdown-item @click="setDef(props.row)">默认</el-dropdown-item>
<el-dropdown-item @click="delRole(props.row)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
</el-scrollbar>
<el-dialog 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" />
<div class="dialog-input">
<el-input-number placeholder="排序" :min="0" :max="9999" v-model.number="form.sort" controls-position="right" autocomplete="off" />
</div>
<el-segmented size="small" class="perm-expand" @change="expandChange" v-model="expand" :options="options" />
<el-tree
:expand-on-click-node="false"
:check-on-click-node="false"
ref="tree"
:data="treeList"
show-checkbox
node-key="permId"
:default-expand-all="expand"
:props="{
label: 'name'
}"
>
<template #default="{ node, data }">
<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>
<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>
</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>
</span>
</div>
</template>
</el-tree>
<el-button class="btn" type="primary" :loading="permLoading" @click="roleFormClick"
>保存
</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
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";
defineOptions({
name: 'role'
})
const roleStore = useRoleStore();
const roleFormShow = ref(false)
const treeList = reactive([])
const roles = ref([])
const tree = ref({})
const permLoading = ref(false)
const tableLoading = ref(false)
const desShow = ref(true)
const settingWidth = ref(null)
const sortWidth = ref(null)
const roleWidth = ref(200)
const dialogType = reactive({
title: '',
type: ''
})
const form = reactive({
name: null,
description: null,
sendType: 'count',
sendCount: '',
accountCount: '',
sort: 0,
isDefault: 0,
})
const expand = ref(false)
const options = [{ label: '展开',value: true }, { label:'收起',value: false}]
let chooseRole = {}
refresh()
rolePermTree().then(tree => {
treeList.push(...tree)
})
function roleFormClick() {
if (dialogType.type === 'add') {
addRole()
} else {
setRole()
}
}
function setDef(role) {
roleSetDef(role.roleId).then(() => {
ElMessage({
message: "设置成功",
type: "success",
plain: true
})
getRoleList()
})
}
function delRole(role) {
ElMessageBox.confirm(`确认删除 ${role.name} 吗?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
roleDelete(role.roleId).then(() => {
ElMessage({
message: "删除成功",
type: "success",
plain: true
})
getRoleList()
roleStore.refreshSelect()
})
});
}
function expandChange(e) {
if (e) {
const nodes = tree.value?.store.nodesMap;
for (const key in nodes) {
nodes[key].expanded = true;
}
} else {
const nodes = tree.value?.store.nodesMap;
for (const key in nodes) {
nodes[key].expanded = false;
}
}
}
function setRole() {
if (!form.name) {
ElMessage({
message: "身份名不能为空",
type: "error",
plain: true
})
return
}
const params = {...form,roleId: chooseRole.roleId}
const checkedId = tree.value.getCheckedKeys()
const halfId = tree.value.getHalfCheckedKeys()
params.permIds = [...checkedId, ...halfId]
permLoading.value = true
roleSet(params).then(() => {
ElMessage({
message: "修改成功",
type: "success",
plain: true
})
roleFormShow.value = false
getRoleList()
}).finally(() => {
permLoading.value = false
})
}
function resetForm() {
form.name = null
form.description = null
form.sort = 0
form.sendType = 'count'
form.sendCount = ''
form.accountCount = ''
tree.value.setCheckedKeys([])
}
function openRoleSet(role) {
chooseRole = role
dialogType.title = '修改身份'
dialogType.type = 'set'
roleFormShow.value = true
form.sort = role.sort
form.name = role.name
form.sendType = role.sendType
form.sendCount = role.sendCount
form.accountCount = role.accountCount
nextTick(() => {
tree.value.setCheckedKeys(role.permIds)
})
}
function openAddRole() {
dialogType.title = '添加身份'
dialogType.type = 'add'
roleFormShow.value = true
}
function addRole() {
const params = {...form}
const checkedId = tree.value.getCheckedKeys()
const halfId = tree.value.getHalfCheckedKeys()
params.permIds = [...checkedId, ...halfId]
permLoading.value = true
roleAdd(params).then(() => {
ElMessage({
message: "添加成功",
type: "success",
plain: true
})
roleFormShow.value = false
getRoleList()
roleStore.refreshSelect()
}).finally(() => {
permLoading.value = false
})
}
function refresh() {
tableLoading.value = true
roles.length = 0
getRoleList()
}
function getRoleList() {
roleRoleList().then(list => {
roles.value = list
}).finally(() => {
tableLoading.value = false
})
}
function adjustWidth() {
desShow.value = window.innerWidth > 767
settingWidth.value = window.innerWidth < 480 ? 75 : null
sortWidth.value = window.innerWidth < 480 ? 75 : null
roleWidth.value = window.innerWidth < 480 ? 180 : 200
}
adjustWidth()
window.onresize = () => {
adjustWidth()
};
</script>
<style scoped lang="scss">
.perm-box {
height: 100%;
overflow: hidden;
width: 100%;
.perm-scrollbar {
height: 100%;
}
}
.send-num {
margin-left: 10px;
.el-input-number {
width: 95px;
}
}
.def-tag {
margin-left: 10px;
height: 20px;
}
.header-actions {
padding: 9px 15px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
.search {
:deep(.el-input-group) {
height: 28px;
}
:deep(.el-input__inner) {
height: 28px;
}
}
.icon {
cursor: pointer;
}
}
:deep(.description) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.role-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-segmented--small .el-segmented__item) {
border-radius: 8px !important;
overflow: hidden;
}
.dialog-box {
.dialog-input {
margin-bottom: 15px !important;
}
}
.perm-expand {
margin-bottom: 10px;
--el-border-radius-base: 8px;
}
:deep(.el-dialog) {
margin-top: 15vh !important;
margin-bottom: 20px !important;
width: 400px !important;
@media (max-width: 440px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
.btn {
width: 100%;
margin-top: 15px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<emailScroll ref="sendScroll"
:cancel-success="cancelStar"
:star-success="addStar"
:getEmailList="getEmailList"
:emailDelete="emailDelete"
:star-add="starAdd"
show-status
actionLeft="4px"
:star-cancel="starCancel"
@jump="jumpContent"
:time-sort="params.timeSort"
>
<template #first>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-down-outline"
v-if="params.timeSort === 0" width="28" height="28"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-up-outline" v-else
width="28" height="28"/>
</template>
</emailScroll>
</template>
<script setup>
import {useAccountStore} from "@/store/account.js";
import {useEmailStore} from "@/store/email.js";
import emailScroll from "@/components/email-scroll/index.vue"
import {emailList, emailDelete} from "@/request/email.js";
import {starAdd, starCancel} from "@/request/star.js";
import {defineOptions, onMounted, reactive, ref, watch} from "vue";
import router from "@/router/index.js";
import {Icon} from "@iconify/vue";
defineOptions({
name: 'send'
})
const emailStore = useEmailStore();
const accountStore = useAccountStore();
const sendScroll = ref({})
const params = reactive({
timeSort: 0,
})
onMounted(() => {
emailStore.sendScroll = sendScroll;
})
watch(() => accountStore.currentAccountId, () => {
sendScroll.value.refreshList();
})
function changeTimeSort() {
params.timeSort = params.timeSort ? 0 : 1
sendScroll.value.refreshList();
}
function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
router.push('/content')
}
function addStar(email) {
emailStore.starScroll?.addItem(email)
}
function cancelStar(email) {
emailStore.starScroll?.deleteEmail([email.emailId])
}
function getEmailList(emailId, size) {
return emailList(accountStore.currentAccountId, emailId, params.timeSort, size, 1)
}
</script>
<style scoped>
.icon {
cursor: pointer;
}
</style>

View File

@@ -13,7 +13,7 @@
</div>
</div>
</div>
<div class="del-email">
<div class="del-email" v-perm="'my:delete'">
<div class="title">删除账户</div>
<div style="color: #585d69;">
此操作将永久删除您的账户及其所有数据无法恢复
@@ -23,20 +23,18 @@
</div>
</div>
<el-dialog v-model="pwdShow" title="修改密码" width="340">
<form>
<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>
</form>
</el-dialog>
</div>
</template>
<script setup>
import {defineOptions} from "vue";
import {reactive, ref} from 'vue'
import {resetPassword, userDelete} from "@/request/user.js";
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";

View File

@@ -5,7 +5,11 @@
:getEmailList="starList"
:emailDelete="emailDelete"
:star-add="starAdd"
:star-cancel="starCancel"/>
:star-cancel="starCancel"
@jump="jumpContent"
actionLeft="6px"
:show-account-icon="false"
/>
</template>
<script setup>
@@ -14,6 +18,7 @@ import {emailDelete} from "@/request/email.js";
import {starAdd, starCancel, starList} from "@/request/star.js";
import {useEmailStore} from "@/store/email.js";
import {defineOptions, onMounted, ref} from "vue";
import router from "@/router/index.js";
defineOptions({
name: 'star'
@@ -22,8 +27,14 @@ defineOptions({
const scroll = ref({})
const emailStore = useEmailStore();
function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'logic'
emailStore.contentData.showStar = true
router.push('/content')
}
function cancelStar(email) {
emailStore.emailScroll?.editEmailStar(email.emailId, 0)
scroll.value.deleteEmail([email.emailId])
}

View File

@@ -0,0 +1,216 @@
<template>
<div class="email-list-box">
<emailScroll ref="sysEmailScroll"
:get-emailList="getEmailList"
:email-delete="sysEmailDelete"
:star-add="starAdd"
:star-cancel="starCancel"
:show-star="false"
show-user-info
show-status
:show-account-icon="false"
@jump="jumpContent"
@refresh-before="refreshBefore"
>
<template #first>
<el-input
v-model="searchValue"
placeholder="输入内容查询"
class="search-input"
>
<template #prefix>
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="searchType"
placeholder="请选择"
class="select"
>
<el-option key="1" label="用户" :value="'user'"/>
<el-option key="2" label="邮箱" :value="'account'"/>
<el-option key="3" label="发件人" :value="'name'"/>
<el-option key="4" label="主题" :value="'subject'"/>
</el-select>
<div style="color: #333;display: flex;">
<span>{{ selectTitle }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-select v-model="params.type" placeholder="Select" class="status-select">
<el-option key="1" label="全部" value="all"/>
<el-option key="2" label="发件" value="send"/>
<el-option key="3" label="收件" value="receive"/>
<el-option key="4" label="删除" value="delete"/>
</el-select>
<Icon class="icon" icon="iconoir:search" @click="search" width="20" height="20"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-down-outline"
v-if="params.timeSort === 0" width="28" height="28"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-up-outline" v-else
width="28" height="28"/>
</template>
</emailScroll>
</div>
</template>
<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 {useEmailStore} from "@/store/email.js";
import {
sysEmailAll,
sysEmailDelete
} from "@/request/sys-email.js";
import {Icon} from "@iconify/vue";
import router from "@/router/index.js";
defineOptions({
name: 'sys-email'
})
const emailStore = useEmailStore();
const sysEmailScroll = ref({})
const searchType = ref('user')
const searchValue = ref('')
const mySelect = ref()
const openSelect = () => {
mySelect.value.toggleMenu()
}
const params = reactive({
timeSort: 0,
type: 'all',
userEmail: null,
accountEmail: null,
name: null,
subject: null
})
const selectTitle = computed(() => {
if (searchType.value === 'user') return '用户'
if (searchType.value === 'account') return '邮箱'
if (searchType.value === 'name') return '发件人'
if (searchType.value === 'subject') return '主题'
})
function refreshBefore() {
searchValue.value = null
params.timeSort = 0
params.type = 'all'
params.userEmail = null
params.accountEmail = null
params.name = null
params.subject = null
}
function search() {
params.userEmail = null
params.accountEmail = null
params.name = null
params.subject = null
if (searchType.value === 'user') {
params.userEmail = searchValue.value
}
if (searchType.value === 'account') {
params.accountEmail = searchValue.value
}
if (searchType.value === 'name') {
params.name = searchValue.value
}
if (searchType.value === 'subject') {
params.subject = searchValue.value
}
sysEmailScroll.value.refreshList();
}
function changeTimeSort() {
params.timeSort = params.timeSort ? 0 : 1
sysEmailScroll.value.refreshList();
}
function jumpContent(email) {
emailStore.contentData.email = email
emailStore.contentData.delType = 'physics'
emailStore.contentData.showStar = false
router.push('/content')
}
function getEmailList(emailId, size) {
return sysEmailAll({emailId, size, ...params})
}
</script>
<style scoped lang="scss">
.email-list-box {
height: 100%;
width: 100%;
overflow: hidden;
}
.search {
padding-top: 5px;
padding-bottom: 5px;
}
.select {
position: absolute;
width: 40px;
opacity: 0;
pointer-events: none;
}
:deep(.header-actions) {
padding-top: 8px;
padding-bottom: 8px;
}
.search-input {
width: min(280px,calc(100vw - 140px));
height: 28px;
.setting-icon {
position: relative;
top: 3px;
}
}
.status-select {
margin-bottom: 2px;
width: 80px;
:deep(.el-select__wrapper) {
min-height: 28px;
}
}
.input-with-select {
max-width: 200px;
border-radius: 0 4px 4px 0;
}
:deep(.input-with-select .el-input-group__append) {
background-color: var(--el-fill-color-blank);
}
:deep(.el-select__wrapper) {
padding: 2px 10px;
min-height: 28px;
}
.icon {
cursor: pointer;
}
</style>

View File

@@ -1,177 +1,486 @@
<template>
<div class="box">
<div class="setting">
<div class="title">网站设置</div>
<div class="setting-item">
<div><span>用户注册</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.register"/>
</div>
</div>
<div class="setting-item">
<div><span>添加邮箱</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.addEmail"/>
</div>
</div>
<div class="setting-item">
<div><span>邮件接收</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.receive"/>
</div>
</div>
<div class="setting-item">
<div><span>注册验证</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.registerVerify"/>
</div>
</div>
<div class="setting-item">
<div><span>添加验证</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.addEmailVerify"/>
</div>
</div>
<div class="setting-item">
<div>
<span>多号模式</span>
<el-popover
content="开启后账号栏出现一个用户可以添加多个邮箱"
>
<template #reference>
<Icon class="warning" icon="fe:warning" width="20" height="20" />
</template>
</el-popover>
</div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.manyEmail"/>
</div>
</div>
<div class="setting-item ">
<div>
<span>轮询刷新</span>
<el-popover
content="轮询请求服务器获取最新邮件人多可能会超出免费额度"
>
<template #reference>
<Icon class="warning" icon="fe:warning" width="20" height="20" />
</template>
</el-popover>
</div>
<div class="item-select">
<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 class="title-item"><span>网站标题</span></div>
<div class="email-title">
<div>{{ setting.title }}</div>
<div @click="editTitleShow = true">
<Icon icon="iconamoon:edit-light" width="24" height="24"/>
<div class="settings-container">
<el-scrollbar class="scroll">
<div class="card-grid">
<!-- Website Settings Card -->
<div class="settings-card">
<div class="card-title">网站设置</div>
<div class="card-content">
<div class="setting-item">
<div><span>用户注册</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.register"/>
</div>
</div>
<div class="setting-item">
<div><span>添加邮箱</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.addEmail"/>
</div>
</div>
<div class="setting-item">
<div>
<span>多号模式</span>
<el-tooltip effect="dark" content="开启后账号栏出现一个用户可以添加多个邮箱">
<Icon class="warning" icon="fe:warning" width="20" height="20"/>
</el-tooltip>
</div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
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="20" height="20"/>
</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>
<el-tooltip effect="dark" content="该操作会物理清空所有已被删除的数据">
<Icon class="warning" icon="fe:warning" width="20" height="20"/>
</el-tooltip>
</div>
<div>
<el-button class="opt-button" style="margin-top: 0" @click="physicsDeleteAllData" size="small"
type="primary">
<Icon icon="material-symbols:delete-outline-rounded" width="16" height="16"/>
</el-button>
</div>
</div>
</div>
</div>
<!-- Personalization Settings Card -->
<div class="settings-card">
<div class="card-title">个性化设置</div>
<div class="card-content">
<div class="setting-item">
<div class="title-item"><span>网站标题</span></div>
<div class="email-title">
<span>{{ setting.title }}</span>
<el-button class="opt-button" size="small" type="primary" @click="editTitleShow = true">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
</div>
</div>
<div class="setting-item personalized">
<div><span>登录背景</span></div>
<div>
<el-image
class="background"
:src="cvtR2Url(setting.background)"
:preview-src-list="[cvtR2Url(setting.background)]"
show-progress
fit="cover"
>
<template #error>
<div class="error-image" @click="openCut">
<Icon icon="ph:image" width="24" height="24"/>
</div>
</template>
</el-image>
<div class="background-btn">
<el-button class="opt-button" size="small" type="primary" @click="openCut">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
<el-button class="opt-button" size="small" type="primary" @click="delBackground">
<Icon icon="material-symbols:delete-outline-rounded" width="16" height="16"/>
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- Email Sending Settings Card -->
<div class="settings-card">
<div class="card-title">邮件设置</div>
<div class="card-content">
<div class="setting-item">
<div><span>邮件接收</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.receive"/>
</div>
</div>
<div class="setting-item">
<div><span>邮件发送</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.send"/>
</div>
</div>
<div class="setting-item">
<div><span>添加resend令牌</span></div>
<div>
<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>
<!-- R2 Object Storage Card -->
<div class="settings-card">
<div class="card-title">R2对象存储</div>
<div class="card-content">
<div class="setting-item">
<div><span>访问域名</span></div>
<div class="r2domain">
<span>{{ setting.r2Domain || '空' }}</span>
<el-button class="opt-button" size="small" type="primary" @click="r2DomainShow = true">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
</div>
</div>
</div>
</div>
<!-- Turnstile Verification Card -->
<div class="settings-card">
<div class="card-title">Turnstile 人机验证</div>
<div class="card-content">
<div class="setting-item">
<div><span>注册验证</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.registerVerify"/>
</div>
</div>
<div class="setting-item">
<div><span>添加验证</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.addEmailVerify"/>
</div>
</div>
<div class="setting-item">
<div><span>siteKey</span></div>
<div class="bot-verify">
<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>
</div>
</div>
<div class="setting-item">
<div><span>secretKey</span></div>
<div class="bot-verify">
<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>
</div>
</div>
</div>
</div>
<div class="settings-card">
<div class="card-title">关于</div>
<div class="card-content">
<div class="concerning-item">
<span>版本:</span>
<span>v1.1.0</span>
</div>
<div class="concerning-item">
<span>交流:</span>
<el-button @click="jumpTelegram">
telegram
<template #icon>
<Icon icon="logos:telegram" width="30" height="30"/>
</template>
</el-button>
</div>
</div>
</div>
</div>
</div>
<el-dialog v-model="editTitleShow" title="修改标题" width="340" @close="editTitle = ''">
<form>
<el-input type="text" placeholder="网站标题" v-model="editTitle"/>
<el-button type="primary" :loading="editTitleLoading" @click="saveTitle">保存</el-button>
</form>
</el-dialog>
<!-- Dialogs remain the same -->
<el-dialog v-model="editTitleShow" title="修改标题" width="340" @closed="editTitle = ''">
<form>
<el-input type="text" placeholder="网站标题" v-model="editTitle"/>
<el-button type="primary" :loading="settingLoading" @click="saveTitle">保存</el-button>
</form>
</el-dialog>
<el-dialog v-model="resendTokenFormShow" title="添加resend令牌" width="340" @closed="cleanResendTokenForm">
<form>
<el-select style="margin-bottom: 15px" v-model="resendTokenForm.domain" placeholder="Select">
<el-option
v-for="item in settingStore.domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<el-input type="text" placeholder="令牌" v-model="resendTokenForm.token"/>
<el-button type="primary" :loading="settingLoading" @click="saveResendToken">保存</el-button>
</form>
</el-dialog>
<el-dialog v-model="r2DomainShow" title="添加R2访问域名" width="340" @closed="r2DomainInput = ''">
<form>
<el-input type="text" placeholder="R2访问域名" v-model="r2DomainInput"/>
<el-button type="primary" :loading="settingLoading" @click="saveR2domain">保存</el-button>
</form>
</el-dialog>
<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"/>
<el-input type="text" style="margin-top: 15px" placeholder="secretKey" v-model="turnstileForm.secretKey"/>
<el-button type="primary" :loading="settingLoading" @click="saveTurnstileKey">保存</el-button>
</form>
</el-dialog>
<el-dialog
v-model="cutShow"
title="背景截图"
class="cut-dialog"
>
<div class="cropper">
<vueCropper
ref="cropper"
:img="cutImage"
:fixedNumber="[16,9]"
outputType="jpeg"
:fixed="true"
:centerBox="true"
:full="true"
:autoCrop="true"
:outputSize="0.9"
></vueCropper>
</div>
<div class="cut-button">
<el-button type="primary" :loading="settingLoading" @click="saveBackground">保存</el-button>
</div>
</el-dialog>
</el-scrollbar>
</div>
</template>
<script setup>
import {defineOptions} from "vue";
import {ref} from 'vue'
import {settingSet} from "@/request/setting.js";
import {ElMessage} from "element-plus";
import {defineOptions, nextTick, 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";
import {Icon} from "@iconify/vue";
import {compressImage} from "@/utils/file-utils.js";
import {cvtR2Url} from "@/utils/convert.js";
import {storeToRefs} from "pinia";
defineOptions({
name: 'sys-setting'
})
const cropper = ref()
const cutImage = ref('')
const cutShow = ref(false)
const accountStore = useAccountStore();
const userStore = useUserStore();
const editTitleShow = ref(false)
const resendTokenFormShow = ref(false)
const r2DomainShow = ref(false)
const turnstileShow = ref(false)
const settingStore = useSettingStore();
let setting = ref(settingStore.settings)
const {settings: setting} = storeToRefs(settingStore);
const editTitle = ref('')
const editTitleLoading = ref(false)
const settingLoading = ref(false)
const r2DomainInput = ref('')
let backup = {}
const resendTokenForm = reactive({
domain: '',
token: '',
})
const turnstileForm = reactive({
siteKey: '',
secretKey: ''
})
const options = [
{
label: '关闭',
value: 0,
},
{
label: '3s',
value: 3,
},
{
label: '5s',
value: 5,
},
{
label: '7s',
value: 7,
},
{
label: '10s',
value: 10,
},
{
label: '15s',
value: 15,
},
{
label: '20s',
value: 20,
}
{label: '关闭', value: 0},
{label: '3s', value: 3},
{label: '5s', value: 5},
{label: '7s', value: 7},
{label: '10s', value: 10},
{label: '15s', value: 15},
{label: '20s', value: 20}
]
function beforeChange() {
onMounted(() => {
resendTokenForm.domain = settingStore.domainList[0];
})
function physicsDeleteAllData() {
ElMessageBox.prompt('此操作不可逆转, 输入 <b style="font-weight: bold">确认删除</b> 继续操作', {
confirmButtonText: '确定',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true,
title: '警告',
type: 'warning',
inputPattern: /^确认删除$/,
inputErrorMessage: '请输入确认删除',
}).then(() => {
physicsDeleteAll().then(() => {
ElMessage({
message: "删除成功",
type: "success",
plain: true
})
})
})
}
function delBackground() {
ElMessageBox.confirm('确定要删除这个背景吗?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
setting.value.background = null
editSetting({background: null})
})
}
function saveTurnstileKey() {
const settingForm = {}
settingForm.siteKey = turnstileForm.siteKey
settingForm.secretKey = turnstileForm.secretKey
editSetting(settingForm)
}
function saveBackground() {
settingLoading.value = true
cropper.value.getCropData(data => {
setBackground(data).then(key => {
setting.value.background = key
cutShow.value = false
ElMessage({
message: "设置成功",
type: "success",
plain: true
})
}).finally(() => {
settingLoading.value = false
})
})
}
function openCut() {
const doc = document.createElement('input')
doc.setAttribute('type', 'file')
doc.setAttribute('accept', 'image/*')
doc.click()
doc.onchange = async (e) => {
const image = await compressImage(e.target.files[0], 0.9)
cutImage.value = URL.createObjectURL(image)
cutShow.value = true
}
}
function saveR2domain() {
const settingForm = {r2Domain: r2DomainInput.value}
if (settingForm.r2Domain && !settingForm.r2Domain.startsWith('http')) {
ElMessage({
message: "域名必须以http或https开头",
type: "error",
plain: true
})
return;
}
editSetting(settingForm)
}
function openResendForm() {
resendTokenFormShow.value = true
}
function saveResendToken() {
const settingForm = {
resendTokens: {}
}
const domain = resendTokenForm.domain.slice(1)
settingForm.resendTokens[domain] = resendTokenForm.token
editSetting(settingForm)
}
function backupSetting() {
const settingForm = {...setting.value}
delete settingForm.resendTokens
delete settingForm.siteKey
delete settingForm.secretKey
backup = JSON.stringify(setting.value)
}
function cleanResendTokenForm() {
resendTokenForm.token = ''
}
function beforeChange() {
if (settingLoading.value) return false
backupSetting()
return true
}
function change() {
editSetting(setting)
function change(e) {
const settingForm = {...setting.value}
delete settingForm.siteKey
delete settingForm.secretKey
delete settingForm.resendTokens
editSetting(settingForm, false)
}
function refresh() {
settingQuery().then(setting => {
settingStore.settings = setting;
settingStore.domainList = setting.domainList;
})
}
function saveTitle() {
backup = JSON.stringify(setting.value)
setting.value.title = editTitle.value
editSetting(setting)
editSetting({title: editTitle.value})
}
function editSetting(setting) {
editTitleLoading.value = true
settingSet(setting.value).then(() => {
editTitleLoading.value = false
function jumpTelegram() {
const doc = document.createElement('a')
doc.href = 'https://t.me/cloud_mail_tg'
doc.target = '_blank'
doc.click()
}
function editSetting(settingForm, refreshStatus = true) {
if (settingLoading.value) return
settingLoading.value = true
settingSet(settingForm).then(() => {
settingLoading.value = false
ElMessage({
message: "设置成功",
type: "success",
@@ -180,69 +489,256 @@ function editSetting(setting) {
if (setting.value.manyEmail === 1) {
accountStore.currentAccountId = userStore.user.accountId;
}
if (refreshStatus) {
refresh()
}
editTitleShow.value = false
r2DomainShow.value = false
resendTokenFormShow.value = false
turnstileShow.value = false
}).catch(() => {
editTitleLoading.value = false
setting.value = JSON.parse(backup)
setting.value = {...setting.value, ...JSON.parse(backup)}
}).finally(() => {
settingLoading.value = false
})
}
</script>
<style scoped lang="scss">
.box {
.settings-container {
height: 100%;
overflow: hidden;
}
padding: 40px 40px;
.scroll {
width: 100%;
}
.title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
padding: 20px;
gap: 20px;
@media (max-width: 500px) {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
@media (max-width: 767px) {
gap: 15px;
padding: 15px;
}
}
.background {
width: 230px;
height: 120px;
border-radius: 4px;
border: 1px solid #e4e7ed;
@media (max-width: 500px) {
width: 140px;
height: 79px;
}
}
.background-btn {
display: flex;
gap: 10px;
flex-direction: column;
}
.settings-card {
background-color: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
transition: all 300ms;
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.card-title {
font-size: 16px;
font-weight: bold;
padding: 10px 20px;
border-bottom: 1px solid #e6e6e6;
}
.card-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-item {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
font-weight: bold;
> div:first-child {
display: flex;
align-items: center;
gap: 5px;
}
.setting {
font-size: 14px;
> div:last-child {
display: grid;
grid-template-columns: 1fr auto;
justify-items: flex-end;
font-weight: normal;
}
}
.warning {
margin-left: 5px;
color: gray;
cursor: pointer;
}
.cropper {
height: 397px;
width: 705px;
@media (max-width: 767px) {
width: calc(100vw - 60px);
height: calc((100vw - 60px) * 9 / 16);
}
}
:deep(.cut-dialog.el-dialog) {
width: fit-content !important;
height: fit-content !important;
}
.error-image {
background: #f5f7fa;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cut-button {
padding-top: 15px;
width: 100%;
display: flex;
justify-content: end;
.el-button {
width: fit-content;
}
}
.bot-verify {
display: grid;
grid-template-columns: 1fr auto;
span {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 40px;
align-items: center;
}
.setting-item {
display: flex;
gap: 140px;
font-weight: bold;
position: relative;
@media (max-width: 767px) {
gap: 80px;
}
.el-button {
width: 48px;
margin: 0 0 0 10px;
}
}
>div:first-child {
display: flex;
justify-content: center;
align-items: center;
}
.opt-button {
width: fit-content !important;
}
.r2domain {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.el-button {
width: 48px;
margin: 0 0 0 10px;
}
}
.personalized {
align-items: start;
> div:last-child {
display: flex;
justify-content: end;
.el-button {
margin-left: 10px;
margin-top: 0;
}
}
}
.warning {
position: absolute;
left: 65px;
color: gray;
.concerning-item {
display: flex;
align-items: center;
:deep(.el-button) {
padding: 0 10px;
i {
font-size: 22px;
}
}
> span:first-child {
font-weight: bold;
padding-right: 20px;
}
}
.email-title {
font-weight: normal !important;
display: flex;
display: grid;
gap: 10px;
div:first-child {
display: flex;
align-items: center;
grid-template-columns: 1fr auto;
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.el-button {
margin-top: 0;
}
}
.el-button {
.token-item {
padding-top: 0;
div:last-child {
font-weight: normal;
}
}
form .el-button {
margin-top: 15px;
width: 100%;
}
.el-switch {
height: 28px;
}
:deep(.el-button--small) {
margin-top: 2px !important;
margin-bottom: 2px !important;
height: 24px;
}
:deep(.el-select__wrapper) {
min-height: 28px;
}
</style>

View File

@@ -0,0 +1,973 @@
<template>
<div class="user-box">
<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.email"
class="search-input"
placeholder="输入邮箱名搜索"
>
</el-input>
</div>
<el-select v-model="params.status" placeholder="Select" class="status-select">
<el-option :key="-1" label="全部" :value="-1"/>
<el-option :key="0" label="正常" :value="0"/>
<el-option :key="1" label="封禁" :value="1"/>
<el-option :key="-2" label="删除" :value="-2"/>
</el-select>
<Icon class="icon" icon="iconoir:search" @click="search" width="20" height="20"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-down-outline"
v-if="params.timeSort === 1" width="28" height="28"/>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-up-outline" v-else width="28"
height="28"/>
<Icon class="icon" icon="ion:reload" width="18" height="18" @click="refresh"/>
<Icon class="icon" icon="pepicons-pencil:expand" width="26" height="26" @click="changeExpand"/>
</div>
<el-scrollbar ref="scrollbarRef" class="scrollbar">
<div>
<div class="loading" :class="tableLoading ? 'loading-show' : 'loading-hide'">
<loading/>
</div>
<el-table
@filter-change="tableFilter"
:empty-text="first ? '' : null"
:default-expand-all="expandStatus"
:data="users"
:preserve-expanded-content="preserveExpanded"
style="width: 100%;"
:key="key"
>
<el-table-column :width="expandWidth" type="expand">
<template #default="props">
<div class="details">
<div v-if="!sendNumShow"><span class="details-item-title">发件数量:</span>{{ props.row.sendEmailCount }}
</div>
<div v-if="!accountNumShow"><span class="details-item-title">邮箱数量:</span>{{
props.row.accountCount
}}
</div>
<div v-if="!createTimeShow"><span class="details-item-title">注册时间:</span>{{
tzDayjs(props.row.createTime).format('YYYY-MM-DD HH:mm:ss')
}}
</div>
<div v-if="!typeShow"><span class="details-item-title">身份类型:</span> {{ toRoleName(props.row.type) }}
</div>
<div v-if="!statusShow">
<span class="details-item-title">状态:</span>
<el-tag disable-transitions v-if="props.row.isDel === 1" type="info">删除</el-tag>
<el-tag disable-transitions v-else-if="props.row.status === 0" type="primary">正常</el-tag>
<el-tag disable-transitions v-else-if="props.row.status === 1" type="danger">封禁</el-tag>
</div>
<div><span class="details-item-title">注册IP:</span>{{ props.row.createIp || '未知' }}</div>
<div><span class="details-item-title">近期IP:</span>{{ props.row.activeIp || '未知' }}</div>
<div><span class="details-item-title">近期活动:</span>{{
props.row.activeTime ? tzDayjs(props.row.activeTime).format('YYYY-MM-DD') : '未知'
}}
</div>
<div><span class="details-item-title">登录设备:</span>{{ props.row.device || '未知' }}</div>
<div><span class="details-item-title">登录系统:</span>{{ props.row.os || '未知' }}</div>
<div><span class="details-item-title">登录浏览器:</span>{{ props.row.browser || '未知' }}</div>
<div>
<span class="details-item-title">发件次数:</span>
<span>{{ formatSendCount(props.row) }}</span>
<el-tag style="margin-left: 10px" v-if="props.row.sendAction.hasPerm" type="success">
{{ formatSendType(props.row) }}
</el-tag>
<el-button size="small" style="margin-left: 10px"
v-if="props.row.sendAction.hasPerm && props.row.sendAction.sendCount"
@click="resetSendCount(props.row)" type="primary">重置
</el-button>
</div>
</div>
</template>
</el-table-column>
<el-table-column show-overflow-tooltip :tooltip-formatter="tableRowFormatter" label="用户邮箱" :min-width="emailWidth">
<template #default="props">
<div class="email-row">{{ props.row.email }}</div>
</template>
</el-table-column>
<el-table-column :formatter="formatterReceive" label-class-name="receive" column-key="receive"
:filtered-value="filteredValue" :filters="filters" :width="receiveWidth" label="收件数量"
prop="receiveEmailCount"/>
<el-table-column :formatter="formatterSend" label-class-name="send" column-key="send"
:filtered-value="filteredValue" :filters="filters" v-if="sendNumShow" label="发件数量"
prop="sendEmailCount"/>
<el-table-column :formatter="formatterAccount" label-class-name="account" column-key="account"
:filtered-value="filteredValue" :filters="filters" v-if="accountNumShow" label="邮箱数量"
prop="accountCount"/>
<el-table-column v-if="createTimeShow" label="注册时间" min-width="160" prop="createTime">
<template #default="props">
{{ tzDayjs(props.row.createTime).format('YYYY-MM-DD HH:mm:ss') }}
</template>
</el-table-column>
<el-table-column v-if="statusShow" min-width="60px" label="状态" prop="status">
<template #default="props">
<el-tag disable-transitions v-if="props.row.isDel === 1" type="info">删除</el-tag>
<el-tag disable-transitions v-else-if="props.row.status === 0" type="primary">正常</el-tag>
<el-tag disable-transitions v-else-if="props.row.status === 1" type="danger">封禁</el-tag>
</template>
</el-table-column>
<el-table-column v-if="typeShow" label="身份类型" min-width="140" prop="type">
<template #default="props">
<div class="type">
{{ toRoleName(props.row.type) }}
</div>
</template>
</el-table-column>
<el-table-column label="设置" :width="settingWidth">
<template #default="props">
<el-dropdown trigger="click">
<el-button size="small" type="primary">操作</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openSetPwd(props.row)">改密</el-dropdown-item>
<el-dropdown-item @click="openSetType(props.row)">权限</el-dropdown-item>
<el-dropdown-item v-if="props.row.isDel !== 1" @click="setStatus(props.row)">
{{ setStatusName(props.row) }}
</el-dropdown-item>
<el-dropdown-item v-else @click="restore(props.row)">恢复</el-dropdown-item>
<el-dropdown-item @click="delUser(props.row)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<div class="pagination" v-if="total > 10">
<el-pagination
:size="pageSize"
:current-page="params.num"
:page-size="params.size"
:pager-count="pagerCount"
:page-sizes="[10, 15, 20, 25, 30, 50]"
background
:layout="layout"
:total="total"
@size-change="sizeChange"
@current-change="numChange"
/>
<el-pagination
v-if="phonePageShow"
:size="pageSize"
:current-page="params.num"
:page-size="params.size"
:pager-count="pagerCount"
:page-sizes="[10, 15, 20, 25, 30, 50]"
background
layout="sizes, total"
:total="total"
@size-change="sizeChange"
@current-change="numChange"
/>
</div>
</div>
</el-scrollbar>
<el-dialog class="dialog" v-model="setPwdShow" title="修改密码" @closed="resetUserForm">
<div class="dialog-box">
<el-input v-model="userForm.password" type="password" placeholder="密码" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" :loading="settingLoading" @click="updatePwd"
>保存
</el-button>
</div>
</el-dialog>
<el-dialog class="dialog" v-model="setTypeShow" title="设置权限" @closed="resetUserForm">
<div class="dialog-box">
<el-select v-model="userForm.type" placeholder="Select">
<el-option v-for="item in roleList" :label="item.name" :value="item.roleId" :key="item.roleId"/>
</el-select>
<el-button class="btn" :loading="settingLoading" type="primary" @click="setType"
>保存
</el-button>
</div>
</el-dialog>
<el-dialog v-model="showAdd" title="添加用户">
<div class="container">
<el-input v-model="addForm.email" type="text" placeholder="邮箱" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="addForm.suffix"
placeholder="请选择"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ addForm.suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-input type="password" v-model="addForm.password" placeholder="密码"/>
<el-select v-model="addForm.type" placeholder="身份类型">
<el-option v-for="item in roleList" :label="item.name" :value="item.roleId" :key="item.roleId"/>
</el-select>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
>添加
</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import {defineOptions, h, reactive, ref, watch} from 'vue'
import {
userList,
userDelete,
userSetPwd,
userSetStatus,
userSetType,
userAdd,
userRestSendCount, userRestore
} 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";
import {isEmail} from "@/utils/verify-utils.js";
import {useRoleStore} from "@/store/role.js";
defineOptions({
name: 'user'
})
const roleStore = useRoleStore()
const settingStore = useSettingStore()
const filteredValue = ['normal', 'del']
const filters = [{text: '正常', value: 'normal'}, {text: '删除', value: 'del'}]
const preserveExpanded = ref(false)
const emailWidth = ref(230)
const expandWidth = ref(40)
const settingWidth = ref(null)
const sendNumShow = ref(true)
const accountNumShow = ref(true)
const createTimeShow = ref(true)
const statusShow = ref(true)
const typeShow = ref(true)
const receiveWidth = ref(null)
const phonePageShow = ref(false)
const layout = ref('prev, pager, next, sizes, total')
const pageSize = ref('')
const expandStatus = ref(false)
const users = ref([])
const total = ref(0)
const first = ref(true)
const scrollbarRef = ref(null)
const domainList = settingStore.domainList
const addForm = reactive({
email: '',
suffix: settingStore.domainList[0],
password: '',
type: null,
})
const params = reactive({
email: '',
num: 1,
size: 15,
timeSort: 0,
status: -1
})
let chooseUser = {}
const userForm = reactive({
password: null,
type: -1,
userId: 0,
})
const showAdd = ref(false)
const addLoading = ref(false);
const setTypeShow = ref(false)
const setPwdShow = ref(false)
const pagerCount = ref(10)
const settingLoading = ref(false)
const tableLoading = ref(true)
const roleList = reactive([])
const mySelect = ref({})
const key = ref(0)
roleSelectUse().then(list => {
roleList.length = 0
roleList.push(...list)
})
watch(() => roleStore.refresh, () => {
roleSelectUse().then(list => {
roleList.length = 0
roleList.push(...list)
})
})
getUserList()
const filterItem = reactive({
send: ['normal', 'del'],
account: ['normal', 'del'],
receive: ['normal', 'del']
})
function tableFilter(e) {
if (e.send) filterItem.send = e.send
if (e.account) filterItem.account = e.account
if (e.receive) filterItem.receive = e.receive
}
function formatterSend(e) {
if (filterItem.send.length === 2) {
return e.sendEmailCount + e.delSendEmailCount
}
if (filterItem.send.includes('normal')) {
return e.sendEmailCount
}
if (filterItem.send.includes('del')) {
return e.delSendEmailCount
}
return 0
}
function formatterAccount(e) {
if (filterItem.account.length === 2) {
return e.accountCount + e.delAccountCount
}
if (filterItem.account.includes('normal')) {
return e.accountCount
}
if (filterItem.account.includes('del')) {
return e.delAccountCount
}
return 0
}
function formatterReceive(e) {
if (filterItem.receive.length === 2) {
return e.receiveEmailCount + e.delReceiveEmailCount
}
if (filterItem.receive.includes('normal')) {
return e.receiveEmailCount
}
if (filterItem.receive.includes('del')) {
return e.delReceiveEmailCount
}
return 0
}
function setStatusName(user) {
if (user.isDel === 1) return '恢复'
if (user.status === 0) return '禁用'
if (user.status === 1) return '启用'
}
const tableRowFormatter = (data) => {
return data.row.email
}
function changeExpand() {
expandStatus.value = !expandStatus.value
key.value++
}
const openSelect = () => {
mySelect.value.toggleMenu()
}
function resetAddForm() {
addForm.email = ''
addForm.suffix = settingStore.domainList[0]
addForm.type = null
addForm.password = ''
}
function openAdd() {
showAdd.value = true
}
function submit() {
if (!addForm.email) {
ElMessage({
message: "邮箱不能为空",
type: "error",
plain: true
})
return
}
if (!isEmail(addForm.email + addForm.suffix)) {
ElMessage({
message: "非法邮箱",
type: "error",
plain: true
})
return
}
if (!addForm.password) {
ElMessage({
message: "密码不能为空",
type: "error",
plain: true
})
return
}
if (addForm.password.length < 6) {
ElMessage({
message: "密码至少六位",
type: "error",
plain: true
})
return
}
if (!addForm.type) {
ElMessage({
message: "身份类型不能为空",
type: "error",
plain: true
})
return
}
addLoading.value = true
const form = {...addForm}
form.email = form.email + form.suffix
userAdd(form).then(() => {
addLoading.value = false
showAdd.value = false
ElMessage({
message: "添加成功",
type: "success",
plain: true
})
resetAddForm()
getUserList(false)
}).finally(res => {
addLoading.value = false
})
}
function formatSendType(user) {
if (user.sendAction.sendType === 'day') return '每天'
if (user.sendAction.sendType === 'count') return '总数'
}
function formatSendCount(user) {
if (!user.sendAction.hasPerm) {
return '无权限'
}
if (!user.sendAction.sendCount) {
return '无限制';
}
return user.sendCount + '/' + user.sendAction.sendCount + '次'
}
function toRoleName(type) {
if (type === 0) {
return '超级管理员'
}
const index = roleList.findIndex(role => role.roleId === type)
if (index > -1) {
return roleList[index].name
}
return ""
}
function resetSendCount(user) {
ElMessageBox.confirm(`确认重置${user.email}发件次数吗?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userRestSendCount(user.userId).then(() => {
ElMessage({
message: "重置成功",
type: "success",
plain: true
})
user.sendCount = 0
})
});
}
function delUser(user) {
ElMessageBox.confirm(`确认删除${user.email}吗?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userDelete(user.userId).then(() => {
ElMessage({
message: "删除成功",
type: "success",
plain: true
})
getUserList(false)
})
});
}
function restore(user) {
const type = ref(0)
ElMessageBox.confirm( null, {
confirmButtonText: '确定',
cancelButtonText: '取消',
message: () => h('div', [
h('div', { class: 'mb-2' }, `确认要恢复 ${user.email}`),
h(ElRadioGroup, {
modelValue: type.value,
'onUpdate:modelValue': (val) => (type.value = val),
}, [
h(ElRadio, { label: 'option1', value: 0 }, '普通恢复'),
h(ElRadio, { label: 'option2', value: 1 }, '包括已删除的数据'),
])
]),
type: 'warning'
}).then(() => {
userRestore(user.userId,type.value).then(() => {
user.isDel = 0
ElMessage({
message: "恢复成功",
type: "success",
plain: true
})
})
});
}
function setStatus(user) {
if (user.status === 0) {
ElMessageBox.confirm(`确认禁用 ${user.email} 吗?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
httpSetStatus(user)
});
} else {
httpSetStatus(user)
}
}
function httpSetStatus(user) {
let status = user.status ? 0 : 1
userSetStatus({status: status, userId: user.userId}).then(() => {
user.status = status
ElMessage({
message: "设置成功",
type: "success",
plain: true
})
})
}
function setType() {
settingLoading.value = true
userSetType({type: userForm.type, userId: userForm.userId}).then(() => {
chooseUser.type = userForm.type
setTypeShow.value = false
ElMessage({
message: "设置成功",
type: "success",
plain: true
})
}).finally(() => {
settingLoading.value = false
})
}
function resetUserForm() {
userForm.password = null
userForm.userId = 0
userForm.type = 0
}
function search() {
params.num = 1
getUserList()
}
function updatePwd() {
if (userForm.password.length < 6) {
ElMessage({
message: '密码不能为空',
type: 'error',
plain: true,
})
return
}
if (userForm.password.length < 6) {
ElMessage({
message: '密码最少六位',
type: 'error',
plain: true,
})
return
}
settingLoading.value = true
userSetPwd({password: userForm.password, userId: userForm.userId}).then(() => {
setPwdShow.value = false
ElMessage({
message: "设置成功",
type: "success",
plain: true
})
}).finally(() => {
settingLoading.value = false
})
}
function openSetType(user) {
chooseUser = user
userForm.userId = user.userId
userForm.type = user.type
setTypeShow.value = true
}
function openSetPwd(user) {
userForm.userId = user.userId
setPwdShow.value = true
}
function refresh() {
params.email = ''
params.num = 1
params.status = -1
params.timeSort = 0
getUserList();
roleSelectUse().then(list => {
roleList.length = 0
roleList.push(...list)
})
}
function changeTimeSort() {
params.num = 1
params.timeSort = params.timeSort ? 0 : 1
getUserList()
}
function numChange(num) {
params.num = num
getUserList()
}
function sizeChange(size) {
params.size = size
getUserList()
}
function getUserList(loading = true) {
tableLoading.value = loading
const newParams = {...params}
if (newParams.status === -2) {
delete newParams.status
newParams.isDel = 1
}
userList(newParams).then(data => {
users.value = data.list
total.value = data.total
scrollbarRef.value?.setScrollTop(0);
}).finally(() => {
tableLoading.value = false
first.value = false
})
}
window.onresize = () => {
adjustWidth()
};
adjustWidth()
function adjustWidth() {
const width = window.innerWidth
statusShow.value = width > 1060
createTimeShow.value = width > 1200
accountNumShow.value = width > 650
sendNumShow.value = width > 685
typeShow.value = width > 767
emailWidth.value = width > 480 ? 230 : null
settingWidth.value = width < 480 ? 75 : null
expandWidth.value = width < 480 ? 25 : 40
pagerCount.value = width < 768 ? 7 : 11
receiveWidth.value = width < 480 ? 90 : null
layout.value = width < 768 ? 'pager' : 'prev, pager, next,sizes, total'
phonePageShow.value = width < 768
pageSize.value = width < 380 ? 'small' : ''
}
</script>
<style>
.el-message-box__container {
align-items: start !important;
}
.el-message-box__message {
word-break: break-all;
}
</style>
<style lang="scss" scoped>
.user-box {
overflow: hidden;
height: 100%;
}
:deep(.el-dialog) {
width: 400px !important;
@media (max-width: 440px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
.header-actions {
padding: 9px 15px;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
font-size: 18px;
.search-input {
width: min(200px, calc(100vw - 140px));
}
.search {
:deep(.el-input-group) {
height: 28px;
}
:deep(.el-input__inner) {
height: 28px;
}
}
.icon {
cursor: pointer;
}
}
.container {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
.type {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choose-star {
color: var(--el-color-primary)
}
.scrollbar {
width: 100%;
overflow: auto;
height: calc(100% - 50px);
@media (max-width: 464px) {
height: calc(100% - 90px);
}
}
.details {
padding: 20px;
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
.details-item-title {
white-space: pre;
color: #909399;
font-weight: bold;
padding-right: 10px;
}
}
.pagination {
margin-top: 15px;
margin-bottom: 20px;
padding-right: 30px;
width: 100%;
display: flex;
flex-direction: column;
justify-content: end;
gap: 10px;
@media (max-width: 767px) {
padding-right: 10px;
}
.el-pagination {
align-self: end;
}
}
.email-row {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-select {
width: 80px;
:deep(.el-select__wrapper) {
min-height: 28px;
}
}
.dialog {
.dialog-box {
.el-button {
width: 100%;
margin-top: 15px;
}
}
}
.select {
position: absolute;
right: 30px;
width: 100px;
opacity: 0;
pointer-events: none;
}
.loading {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
left: 0;
z-index: 1000;
top: 0;
width: 100%;
height: 100%;
}
.loading-show {
transition: all 200ms ease 200ms;
opacity: 1;
}
.loading-hide {
pointer-events: none;
transition: all 200ms;
opacity: 0;
}
.setting-icon {
position: relative;
top: 6px;
}
.btn {
width: 100%;
}
:deep(.el-pagination .el-select) {
width: 100px;
background: #FFF;
}
:deep(.el-input-group__append) {
padding: 0 !important;
padding-left: 8px !important;
background: #FFFFFF;
}
:deep(.el-dialog) {
width: 400px !important;
@media (max-width: 440px) {
width: calc(100% - 40px) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}
}
:deep(.cell) {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
:deep(.receive .cell) {
white-space: nowrap;
}
:deep(.send .cell) {
white-space: nowrap;
}
:deep(.account .cell) {
white-space: nowrap;
}
:deep(.el-table th.el-table__cell>.cell.highlight) {
color: #909399;
}
:deep(.el-table__inner-wrapper:before) {
background: #fff;
}
:deep(.el-message-box__container) {
align-items: start;
}
</style>