新增邮件和用户列表右键菜单

This commit is contained in:
eoao
2026-01-22 23:50:00 +08:00
parent aaf4a29013
commit 5ae3fe277a
17 changed files with 530 additions and 131 deletions

View File

@@ -20,7 +20,7 @@
"dayjs": "^1.11.13",
"dexie": "^4.0.11",
"echarts": "^5.6.0",
"element-plus": "^2.9.11",
"element-plus": "^2.13.1",
"lodash-es": "^4.17.21",
"nprogress": "^0.2.0",
"path": "^0.12.7",

View File

@@ -36,8 +36,8 @@ importers:
specifier: ^5.6.0
version: 5.6.0
element-plus:
specifier: ^2.9.11
version: 2.11.1(vue@3.5.20)
specifier: ^2.13.1
version: 2.13.1(vue@3.5.20)
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@@ -1085,8 +1085,8 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -1144,6 +1144,9 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
@@ -1152,8 +1155,8 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
@@ -1161,8 +1164,8 @@ packages:
'@vueuse/metadata@14.1.0':
resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
@@ -1172,9 +1175,6 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
@@ -1349,6 +1349,9 @@ packages:
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -1406,10 +1409,10 @@ packages:
electron-to-chromium@1.5.211:
resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==}
element-plus@2.11.1:
resolution: {integrity: sha512-weYFIniyNXTAe9vJZnmZpYzurh4TDbdKhBsJwhbzuo0SDZ8PLwHVll0qycJUxc6SLtH+7A9F7dvdDh5CnqeIVA==}
element-plus@2.13.1:
resolution: {integrity: sha512-eG4BDBGdAsUGN6URH1PixzZb0ngdapLivIk1meghS1uEueLvQ3aljSKrCt5x6sYb6mUk8eGtzTQFgsPmLavQcA==}
peerDependencies:
vue: ^3.2.0
vue: ^3.3.0
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
@@ -1452,9 +1455,6 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -3522,7 +3522,7 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/web-bluetooth@0.0.16': {}
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
@@ -3611,6 +3611,16 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.20)
vue: 3.5.20
'@vueuse/core@10.11.1(vue@3.5.20)':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.20)
vue-demi: 0.14.10(vue@3.5.20)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/core@12.8.2':
dependencies:
'@types/web-bluetooth': 0.0.21
@@ -3627,21 +3637,18 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.20)
vue: 3.5.20
'@vueuse/core@9.13.0(vue@3.5.20)':
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.5.20)
vue-demi: 0.14.10(vue@3.5.20)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@14.1.0': {}
'@vueuse/metadata@9.13.0': {}
'@vueuse/shared@10.11.1(vue@3.5.20)':
dependencies:
vue-demi: 0.14.10(vue@3.5.20)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@12.8.2':
dependencies:
@@ -3653,13 +3660,6 @@ snapshots:
dependencies:
vue: 3.5.20
'@vueuse/shared@9.13.0(vue@3.5.20)':
dependencies:
vue-demi: 0.14.10(vue@3.5.20)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
acorn@8.15.0: {}
ajv@8.17.1:
@@ -3859,6 +3859,8 @@ snapshots:
dayjs@1.11.18: {}
dayjs@1.11.19: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -3907,7 +3909,7 @@ snapshots:
electron-to-chromium@1.5.211: {}
element-plus@2.11.1(vue@3.5.20):
element-plus@2.13.1(vue@3.5.20):
dependencies:
'@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.3.2(vue@3.5.20)
@@ -3915,10 +3917,9 @@ snapshots:
'@popperjs/core': '@sxzz/popperjs-es@2.11.7'
'@types/lodash': 4.17.20
'@types/lodash-es': 4.17.12
'@vueuse/core': 9.13.0(vue@3.5.20)
'@vueuse/core': 10.11.1(vue@3.5.20)
async-validator: 4.2.5
dayjs: 1.11.18
escape-html: 1.0.3
dayjs: 1.11.19
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
@@ -4044,8 +4045,6 @@ snapshots:
escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@5.0.0: {}
estree-walker@1.0.1: {}

View File

@@ -37,12 +37,14 @@
v-if="!loading && emailList.length > 0"
:key="keyCount"
>
<template #default="{ data: item, index }">
<template #default="{ data: item, index }" >
<div :class="'email-row ' + props.type"
:data-checked="item.checked"
@click="jumpDetails(item)"
v-if="!item.expand"
:key="item.emailId"
@contextmenu="handleContextmenu($event, item)"
:style="item.rightChecked ? 'background: #FDF6EC' : ''"
>
<el-checkbox :class=" props.type === 'all-email' ? 'all-email-checkbox' : 'checkbox'"
v-model="item.checked" @click.stop></el-checkbox>
@@ -133,16 +135,89 @@
:showUserInfo="showUserInfo"
:type="type"/>
<div class="empty" v-if="noLoading && emailList.length === 0 && !loading">
<el-empty :image-size="isMobile ? 120 : 0" :description="$t('noMessagesFound')"/>
<el-empty :image-size="isMobile ? 120 : null" :description="$t('noMessagesFound')"/>
</div>
</div>
<el-dropdown
ref="dropdownRef"
@visible-change="visibleChange"
:virtual-ref="triggerRef"
:show-arrow="false"
:popper-options="{
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
}"
virtual-triggering
trigger="contextmenu"
placement="bottom-start"
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="props.type === 'email'" @click="emailRead()" >
<template #default>
<div class="right-dropdown-item">
<Icon icon="fluent:mail-read-20-regular" width="20" height="20" />
<span>{{t('markAsRead')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="props.type === 'email'" @click="openReply(rightClickEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="la:reply" width="20" height="20" />
<span>{{t('reply')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="['email','send', 'star'].includes(props.type)" @click="starChange(rightClickEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="solar:star-line-duotone" width="19" height="19"/>
<span>{{t('star')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="props.type === 'all-email'" @click="handleSearch('user', rightClickEmail.userEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="iconoir:search" width="20" height="20" />
<span>{{t('searchUser')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="props.type === 'all-email' " @click="handleSearch('account', rightClickEmail.toEmail)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="iconoir:search" width="20" height="20" />
<span>{{t('searchEmail')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="props.type === 'all-email' " @click="handleSearch('name', rightClickEmail.name)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="iconoir:search" width="20" height="20" />
<span>{{t('searchSender')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item @click="rightDelete(rightClickEmail.emailId)">
<template #default>
<div class="right-dropdown-item">
<Icon icon="uiw:delete" width="16" height="20" style="margin-left: 1px;margin-right: 3px" />
<span>{{t('delete')}}</span>
</div>
</template>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import {Icon} from "@iconify/vue";
import skeletonBlock from "@/components/email-scroll/skeleton/index.vue"
import {computed, onActivated, reactive, ref, watch, nextTick} from "vue";
import {computed, onActivated, reactive, ref, watch, nextTick, onMounted, onUnmounted } from "vue";
import {useEmailStore} from "@/store/email.js";
import {useUiStore} from "@/store/ui.js";
import {useSettingStore} from "@/store/setting.js";
@@ -191,7 +266,7 @@ const props = defineProps({
},
type: {
type: String,
default: ''
default: 'email'
},
showFirstLoading: {
type: Boolean,
@@ -203,7 +278,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['jump', 'refresh-before', 'delete-draft'])
const emit = defineEmits(['jump', 'refresh-before', 'delete-draft', 'right-search'])
const {t} = useI18n()
const settingStore = useSettingStore()
const uiStore = useUiStore();
@@ -225,7 +300,26 @@ let reqLock = false
let isMobile = ref(innerWidth < 1367)
let skeletonRows = 0
const timePaddingRight = ref('');
const keyCount = ref(0)
const keyCount = ref(0);
const dropdownRef = ref(null);
const dropdownCloseLock = ref(false);
const dropdownShow = ref(false);
const rightClickEmail = ref({});
const checkedEmailCount = ref(0);
let timer = null
const position = ref(
DOMRect.fromRect({
x: 0,
y: 0,
})
)
const triggerRef = ref({
getBoundingClientRect() {
return position.value;
}
})
const queryParam = reactive({
size: 50
});
@@ -234,6 +328,7 @@ defineExpose({
refreshList,
deleteEmail,
addItem,
handleList,
emailList,
firstLoad,
latestEmail,
@@ -248,6 +343,18 @@ onActivated(() => {
})
})
onMounted(() => {
timer = setInterval(() => {
emailList.forEach(email => {
email.formatCreateTime = fromNow(email.createTime);
})
}, 1000 * 60);
})
onUnmounted(() => {
clearInterval(timer)
})
getEmailList()
window.onresize = () => {
@@ -323,6 +430,7 @@ watch(() => arrivedState.bottom, (isBottom) => {
watch(
() => emailList.map(item => item.checked),
() => {
checkedEmailCount.value = emailList.length
if (emailList.length > 0) {
updateCheckStatus();
}
@@ -353,6 +461,50 @@ watch(() => emailStore.addStarEmailId, () => {
})
})
window.addEventListener('wheel', (event) => {
if (dropdownShow.value) {
dropdownRef.value.handleClose();
}
})
function openReply(email) {
uiStore.writerRef.openReply(email)
}
function visibleChange(e) {
dropdownShow.value = e;
dropdownCloseLock.value = true;
setTimeout(() => {
dropdownCloseLock.value = false;
},1500)
if (!e && rightClickEmail.value.rightChecked) {
rightClickEmail.value.rightChecked = false
}
}
const handleContextmenu = (event, email) => {
if (props.type === 'draft') {
return
}
if (rightClickEmail.value.rightChecked) {
rightClickEmail.value.rightChecked = false
}
const { clientX, clientY } = event
position.value = DOMRect.fromRect({
x: clientX,
y: clientY,
})
event.preventDefault();
dropdownRef.value?.handleOpen();
rightClickEmail.value = email;
rightClickEmail.value.rightChecked = true
}
function updateHasScrollbar() {
nextTick(() => {
const doc = document.querySelector('.virtual');
@@ -407,7 +559,6 @@ function cleanSpace(text) {
.trim();
}
function starChange(email) {
if (!email.isStar) {
@@ -451,7 +602,45 @@ const handleRead = () => {
})
}
const handleDelete = () => {
function emailRead() {
const emailIds = getSelectedMailsIds();
props.emailRead(emailIds)
}
function rightDelete(emailId) {
if (props.type === 'all-email') {
ElMessageBox.confirm(t('delOneEmailConfirm'), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
}).then(() => {
props.emailDelete([emailId]).then(() => {
ElMessage({
message: t('delSuccessMsg'),
type: 'success',
plain: true
})
emailStore.deleteIds = [emailId];
})
})
return;
}
props.emailDelete([emailId]).then(() => {
ElMessage({
message: t('delSuccessMsg'),
type: 'success',
plain: true
})
emailStore.deleteIds = [emailId];
})
}
function handleSearch(type, value) {
emit('right-search', type, value);
}
function handleDelete() {
ElMessageBox.confirm(t('delEmailsConfirm'), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
@@ -551,13 +740,24 @@ function getSelectedDraftsIds() {
function updateCheckStatus() {
const checkedCount = emailList.filter(item => item.checked).length;
checkedEmailCount.value = checkedCount;
checkAll.value = checkedCount === emailList.length;
isIndeterminate.value = checkedCount > 0 && checkedCount < emailList.length;
}
function jumpDetails(email) {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
if (dropdownShow.value) {
dropdownRef.value.handleClose();
return;
}
if (!dropdownCloseLock.value) {
const sel = window.getSelection();
if (sel.toString().trim()) {
return
}
}
emit('jump', email)
}
@@ -1054,6 +1254,26 @@ function loadData() {
bottom: 1px;
}
.right-dropdown-item {
display: flex;
gap: 10px;
}
:deep(.el-dropdown-menu__item:last-child) {
padding-bottom: 10px;
}
:deep(.el-dropdown-menu__item:first-child) {
padding-top: 10px;
}
:deep(.el-dropdown-menu__item) {
padding-right: 14px;
padding-left: 14px;
}
.unread {
height: 6px;
width: 6px;

View File

@@ -12,10 +12,10 @@ const en = {
SystemSettings: 'System Settings',
noMoreData: 'No more data',
noMessagesFound: 'No messages found',
addAccount: 'Add Account',
addAccount: 'Add Email Address',
emailAccount: 'Email',
account: 'Account',
userAccount: 'User Accounts',
account: 'Address',
userAccount: 'User Email Address',
deleteUser: 'Delete Account',
deleteUserBtn: 'Delete',
changePassword: 'Change Password',
@@ -35,7 +35,7 @@ const en = {
delAccountMsg: 'This will permanently delete your account and data. It cannot be reactivated',
totalReceived: 'Total Received',
totalSent: 'Total Sent',
totalMailboxes: 'Total Accounts',
totalMailboxes: 'Total Email Addresses',
totalUsers: 'Total Users',
deleted: 'Deleted',
selectDeleted: 'Deleted',
@@ -52,7 +52,7 @@ const en = {
tabEmailAddress: 'Email',
tabReceived: 'Received',
tabSent: 'Sent',
tabMailboxes: 'Accounts',
tabMailboxes: 'Addresses',
tabRegisteredAt: 'Registered at',
tabStatus: 'Status',
tabRole: 'Role',
@@ -73,9 +73,9 @@ const en = {
unauthorized: 'Unauthorized',
unlimited: 'Unlimited',
sendCount: 'Send email : ',
accountCount: 'Add account : ',
accountCount: 'Add Address : ',
action: 'Action',
chgPwd: 'Pwd',
chgPwd: 'Password',
perm: 'Role',
btnBan: 'Ban',
admin: 'Admin',
@@ -136,8 +136,8 @@ const en = {
websiteSetting: 'Website',
websiteReg: 'Sign Up',
loginDomain: 'Sign-In Box Domain',
multipleEmail: 'Multiple Accounts',
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts',
multipleEmail: 'Multiple Email Address',
multipleEmailDesc: 'Enable this feature to allow users to add multiple email',
customization: 'Customization',
websiteTitle: 'Title',
loginBoxOpacity: 'Login Box Opacity',
@@ -161,7 +161,7 @@ const en = {
rules: 'Rules',
turnstileSetting: 'Turnstile',
signUpVerification: 'Sign Up Verification',
addEmailVerification: 'Add Account Verification',
addEmailVerification: 'Add Email Verification',
about: 'About',
version: 'Version',
community: 'Community',
@@ -207,6 +207,7 @@ const en = {
emptyCountMsg: 'Available count cannot be empty',
addSuccessMsg: 'Addition successful',
delConfirm: 'Confirm deleting {msg}?',
delUsersConfirm: 'Confirm deletion of selected users?',
emptyRoleNameMsg: 'Role name cannot be empty',
saveSuccessMsg: 'Saved successfully',
changeRoleTitle: 'Change Role',
@@ -237,7 +238,8 @@ const en = {
sendSuccessMsg: 'Send successful',
sendFailMsg: 'Send failed',
saveDraftConfirm: 'Save draft?',
delEmailsConfirm: 'Confirm batch delete these emails?',
delEmailsConfirm: 'Confirm deletion of selected emails?',
delOneEmailConfirm: 'Confirm deletion of this email?',
sending: 'Sending email...',
sendingErrorMsg: 'Sending in progress',
networkErrorMsg: 'Network error. Check your internet',
@@ -312,7 +314,18 @@ const en = {
mustNotContainDesc: 'Separate with commas',
setSuccess: 'Settings saved successfully',
details: 'Details',
userDetails: 'User Details'
userDetails: 'User Details',
markAsRead: 'Mark as Read',
star: 'Star',
setRole: 'Set Role',
adminDeleteUser: 'Delete User',
banUser: 'Ban User',
enableUser: 'Enable User',
restoreUser: 'Restore User',
searchUser: 'Search by user',
searchEmail: 'Search by Email',
searchSender: 'Search by Sender',
userEmail: 'Email Address'
}
export default en

View File

@@ -207,6 +207,7 @@ const zh = {
emptyCountMsg: '可用次数不能为空',
addSuccessMsg: '添加成功',
delConfirm: '确认删除{msg}吗?',
delUsersConfirm: '确定删除选中的用户吗?',
emptyRoleNameMsg: '身份名不能为空',
saveSuccessMsg: '保存成功',
changeRoleTitle: '修改身份',
@@ -237,7 +238,8 @@ const zh = {
sendSuccessMsg: '发送成功',
sendFailMsg: '发送失败',
saveDraftConfirm: '是否保存草稿?',
delEmailsConfirm: '确认批量删除这些邮件吗?',
delEmailsConfirm: '确认删除选中的邮件吗?',
delOneEmailConfirm: '确认删除这个邮件吗?',
sending: '邮件正在发送中',
sendingErrorMsg: '邮件正在发送中',
networkErrorMsg: '网络错误,请检查网络连接',
@@ -312,6 +314,17 @@ const zh = {
mustNotContainDesc: '输入多个值用,分开',
setSuccess: '设置成功',
details: '详情',
userDetails: '用户详情'
userDetails: '用户详情',
markAsRead: '标为已读',
star: '星标',
setRole: '设置权限',
adminDeleteUser: '删除用户',
banUser: '封禁用户',
enableUser: '启动用户',
restoreUser: '恢复用户',
searchUser: '搜索用户',
searchEmail: '搜索邮箱',
searchSender: '搜索发件人',
userEmail: '用户邮箱'
}
export default zh

View File

@@ -13,7 +13,7 @@ export function emailLatest(emailId, accountId, allReceive) {
}
export function emailRead(emailIds) {
return http.put('/email/read', {emailIds}, {noMsg: true})
return http.put('/email/read', {emailIds})
}
export function emailSend(form,progress) {

View File

@@ -56,6 +56,14 @@ export function fromNow(date) {
}
export function updateNow(date) {
if (isToday) {
if (diffSeconds < 60) return `Just now`;
if (diffMinutes < 60) return `${diffMinutes} min ago`;
if (diffHours < 2) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return d.format('hh:mm A');
}
}
export function formatDetailDate(time) {
const d = dayjs.utc(time).tz(timeZone);

View File

@@ -14,6 +14,7 @@
:item-height="65"
@jump="jumpContent"
@refresh-before="refreshBefore"
@right-search="rightSearch"
:type="'all-email'"
>
@@ -227,6 +228,12 @@ function batchDelete() {
})
}
function rightSearch(type, value) {
params.searchType = type;
searchValue.value = value;
search();
}
function refreshBefore() {
searchValue.value = null
params.timeSort = 0
@@ -337,7 +344,7 @@ async function latest() {
}
} catch (e) {
if (e.code === 401) {
if (e.code === 401 || e.code === 403) {
settingStore.settings.autoRefresh = AutoRefreshEnum.DISABLED;
}
console.error(e)

View File

@@ -1,7 +1,6 @@
<template>
<emailScroll ref="scroll"
:allow-star="false"
:cancel-success="cancelStar"
:getEmailList="getEmailList"
:emailDelete="emailDelete"
:star-add="starAdd"
@@ -15,7 +14,7 @@
:type="'draft'"
>
<template #name="props">
<span class="send-email">{{ props.email.receiveEmail.join(',') || '(' + $t('noRecipient') + ')' }}</span>
<span class="send-email">{{ props.email.receiveEmail?.join(',') || '(' + $t('noRecipient') + ')' }}</span>
</template>
<template #subject="props">
{{ props.email.subject || '(' + $t('noSubject') + ')' }}
@@ -27,8 +26,7 @@
import emailScroll from "@/components/email-scroll/index.vue"
import {emailDelete} from "@/request/email.js";
import {starAdd, starCancel} from "@/request/star.js";
import {useEmailStore} from "@/store/email.js";
import {defineOptions, onMounted, ref, watch, toRaw} from "vue";
import {defineOptions, ref, watch, toRaw} from "vue";
import {useUiStore} from "@/store/ui.js";
import {userDraftStore} from "@/store/draft.js";
import db from "@/db/db.js"
@@ -40,7 +38,6 @@ defineOptions({
const draftStore = userDraftStore();
const uiStore = useUiStore();
const scroll = ref({})
const emailStore = useEmailStore();
watch(() => draftStore.setDraft, async () => {
@@ -68,6 +65,7 @@ watch(() => draftStore.setDraft, async () => {
watch(() => draftStore.refreshList, async () => {
const {list} = await getEmailList();
scroll.value.emailList.length = 0
scroll.value.handleList(list);
scroll.value.emailList.push(...list)
})
@@ -90,15 +88,6 @@ async function jumpContent(email) {
uiStore.writerRef.openDraft(email);
}
function cancelStar(email) {
emailStore.cancelStarEmailId = email.emailId
scroll.value.deleteEmail([email.emailId])
}
onMounted(() => {
emailStore.starScroll = scroll
})
</script>
<style>
.send-email {

View File

@@ -133,7 +133,7 @@ async function latest() {
}
} catch (e) {
if (e.code === 401) {
if (e.code === 401 || e.code === 403) {
settingStore.settings.autoRefresh = AutoRefreshEnum.DISABLED;
}
console.error(e)

View File

@@ -10,6 +10,7 @@
:star-cancel="starCancel"
@jump="jumpContent"
:time-sort="params.timeSort"
:type="'send'"
>
<template #first>
<Icon class="icon" @click="changeTimeSort" icon="material-symbols-light:timer-arrow-down-outline"
@@ -29,7 +30,6 @@ 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";
import {AccountAllReceiveEnum} from "@/enums/account-enum.js";
defineOptions({
name: 'send'

View File

@@ -754,7 +754,7 @@ defineOptions({
name: 'sys-setting'
})
const currentVersion = 'v2.7.0'
const currentVersion = 'v2.8.0'
const hasUpdate = ref(false)
let getUpdateErrorCount = 1;
const {t, locale} = useI18n();

View File

@@ -38,6 +38,8 @@
:preserve-expanded-content="preserveExpanded"
style="width: 100%;"
ref="tableRef"
@cell-contextmenu="handleContextmenu"
:cell-class-name="cellClassName"
>
<el-table-column :width="expandWidth" type="selection" :selectable="row => row.type !== 0" />
<el-table-column show-overflow-tooltip :tooltip-formatter="tableRowFormatter" :label="$t('tabEmailAddress')"
@@ -81,20 +83,21 @@
</el-table-column>
<el-table-column :label="$t('tabSetting')" :width="settingWidth">
<template #default="props">
<el-dropdown>
<el-button size="small" type="primary" v-if="(props.row.type === 0 && userStore.user.type !== 0)" >{{ $t('action') }}</el-button>
<el-dropdown v-else >
<el-button size="small" type="primary">{{ $t('action') }}</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openSetPwd(props.row)" v-if="(props.row.type !== 0 || userStore.user.type === 0)">{{ $t('chgPwd') }}</el-dropdown-item>
<el-dropdown-item @click="openSetType(props.row)">{{ $t('perm') }}</el-dropdown-item>
<el-dropdown-item @click="openSetPwd(props.row)" >{{ $t('chgPwd') }}</el-dropdown-item>
<el-dropdown-item @click="openSetType(props.row)" >{{ $t('perm') }}</el-dropdown-item>
<template v-if="props.row.type !== 0">
<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)">{{ $t('restore') }}</el-dropdown-item>
</template>
<el-dropdown-item @click="openAccountList(props.row.userId)" v-if="(props.row.type !== 0 || userStore.user.type === 0)" >{{ $t('account') }}</el-dropdown-item>
<el-dropdown-item @click="openDetails(props.row)" v-if="(props.row.type !== 0 || userStore.user.type === 0)" >{{ $t('details') }}</el-dropdown-item>
<el-dropdown-item @click="openAccountList(props.row.userId)" >{{ $t('account') }}</el-dropdown-item>
<el-dropdown-item @click="openDetails(props.row)" >{{ $t('details') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -286,6 +289,78 @@
</div>
</div>
</el-dialog>
<el-dropdown
:show-timeout="0"
:hide-timeout="0"
ref="dropdownRef"
@visible-change="visibleChange"
:virtual-ref="triggerRef"
:show-arrow="false"
:popper-options="{
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
}"
virtual-triggering
trigger="contextmenu"
placement="bottom-start"
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openSetPwd(rightClickUser)">
<template #default>
<div class="right-dropdown-item">
<icon icon="fluent:fingerprint-20-filled" width="22" height="22" />
<span>{{t('changePassword')}}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item @click="openSetType(rightClickUser)">
<template #default>
<div class="right-dropdown-item">
<icon icon="fluent:lock-closed-16-regular" width="21" height="21" />
<span>{{ t('setRole') }}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="rightClickUser.type !== 0">
<template #default>
<div class="right-dropdown-item" v-if="rightClickUser.isDel !== 1" @click="setStatus(rightClickUser)" >
<Icon icon="ion:reload" v-if="rightClickUser.status" style="margin-left: 1px;margin-right: 1px" width="19" height="19" />
<Icon icon="ion:ban-outline" v-else style="margin-left: 1px;margin-right: 1px" width="19" height="19" />
<span>{{ setRightStatusName(rightClickUser) }}</span>
</div>
<div class="right-dropdown-item" v-else @click="restore(rightClickUser)">
<Icon icon="ion:reload" style="margin-left: 1px;margin-right: 1px" width="19" height="19" />
<span>{{ t('restoreUser') }}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item @click="openAccountList(rightClickUser.userId)" >
<template #default>
<div class="right-dropdown-item" >
<Icon icon="hugeicons:mailbox-01" width="20" height="20" />
<span>{{ t('userEmail') }}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item @click="openDetails(rightClickUser)" >
<template #default>
<div class="right-dropdown-item" >
<Icon icon="si:user-alt-2-line" width="20" height="20" />
<span>{{ t('userDetails') }}</span>
</div>
</template>
</el-dropdown-item>
<el-dropdown-item v-if="rightClickUser.type !== 0" @click="delOneUser(rightClickUser)" >
<template #default>
<div class="right-dropdown-item" >
<Icon icon="uiw:delete" width="18" height="18" style="margin-left: 1px;margin-right: 1px" />
<span>{{ t('adminDeleteUser') }}</span>
</div>
</template>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
@@ -344,7 +419,21 @@ const total = ref(0)
const first = ref(true)
const scrollbarRef = ref(null)
const accountLoading = ref(false)
const dropdownRef = ref(null);
const dropdownShow = ref(false);
const rightClickUser = ref({});
const position = ref(
DOMRect.fromRect({
x: 0,
y: 0,
})
)
const triggerRef = ref({
getBoundingClientRect() {
return position.value;
}
})
const domainList = settingStore.domainList
const addForm = reactive({
@@ -425,6 +514,43 @@ const filterItem = reactive({
receive: ['normal', 'del']
})
window.addEventListener('wheel', (event) => {
if (dropdownShow.value) {
dropdownRef.value.handleClose();
}
})
function visibleChange(e) {
dropdownShow.value = e;
if (!e) {
rightClickUser.value.checkedClass = '';
}
}
function cellClassName({ row }) {
return row.checkedClass;
}
const handleContextmenu = (row, column, cell, event) => {
if (row.type === 0 && userStore.user.type !== 0) {
return
}
rightClickUser.value.checkedClass = '';
const { clientX, clientY } = event
position.value = DOMRect.fromRect({
x: clientX,
y: clientY,
})
event.preventDefault()
dropdownRef.value?.handleOpen()
row.checkedClass = 'checked-row';
rightClickUser.value = row;
}
function deleteAccount(account) {
ElMessageBox.confirm(t('delConfirm', {msg: account.email}), {
confirmButtonText: t('confirm'),
@@ -540,6 +666,12 @@ function setStatusName(user) {
if (user.status === 1) return t('enable')
}
function setRightStatusName(user) {
if (user.isDel === 1) return t('adminDeleteUser')
if (user.status === 0) return t('banUser')
if (user.status === 1) return t('enableUser')
}
const tableRowFormatter = (data) => {
return data.row.email
}
@@ -683,7 +815,7 @@ function delUser(user) {
if (userIds.length === 0) {
return;
}
ElMessageBox.confirm(t('delConfirm', {msg: user.email}), {
ElMessageBox.confirm(t('delUsersConfirm'), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
@@ -699,6 +831,23 @@ function delUser(user) {
});
}
function delOneUser(user) {
ElMessageBox.confirm(t('delConfirm', {msg: user.email}), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
}).then(() => {
userDelete([user.userId]).then(() => {
ElMessage({
message: t('delSuccessMsg'),
type: "success",
plain: true
})
getUserList(true)
})
});
}
function restore(user) {
const type = ref(0)
@@ -707,14 +856,14 @@ function restore(user) {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
message: () => h('div', [
h('div', {class: 'mb-2'}, t('restoreConfirm', {msg: user.email})),
h(ElRadioGroup, {
modelValue: type.value,
'onUpdate:modelValue': (val) => (type.value = val),
}, [
h(ElRadio, {label: 'option1', value: 0}, t('normalRestore')),
h(ElRadio, {label: 'option2', value: 1}, t('allRestore')),
])
h('div', {class: 'mb-2'}, t('restoreConfirm', {msg: user.email}))
// h(ElRadioGroup, {
// modelValue: type.value,
// 'onUpdate:modelValue': (val) => (type.value = val),
// }, [
// h(ElRadio, {label: 'option1', value: 0}, t('normalRestore')),
// h(ElRadio, {label: 'option2', value: 1}, t('allRestore')),
// ])
]),
type: 'warning'
}).then(() => {
@@ -730,18 +879,7 @@ function restore(user) {
}
function setStatus(user) {
if (user.status === 0) {
ElMessageBox.confirm(t('banRestore', {msg: user.email}), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
}).then(() => {
httpSetStatus(user)
});
} else {
httpSetStatus(user)
}
httpSetStatus(user);
}
function httpSetStatus(user) {
@@ -866,7 +1004,7 @@ function getUserList(loading = true) {
newParams.isDel = 1
}
userList(newParams).then(data => {
users.value = data.list
users.value = data.list.map(item => ({...item, checkedClass: ''}))
total.value = data.total
scrollbarRef.value?.setScrollTop(0);
}).finally(() => {
@@ -917,6 +1055,10 @@ function adjustWidth() {
</style>
<style lang="scss" scoped>
:deep(.el-table .checked-row) {
background: var(--el-color-warning-light-9);
}
.user-box {
overflow: hidden;
height: 100%;
@@ -994,7 +1136,7 @@ function adjustWidth() {
}
.details {
padding: 10px 10px 10px 10px;
padding: 0 10px 10px 10px;
display: grid;
gap: 10px;
.details-item-title {
@@ -1093,6 +1235,10 @@ function adjustWidth() {
top: 6px;
}
.right-dropdown-item {
display: flex;
gap: 10px;
}
.btn {
width: 100%;

View File

@@ -1,14 +1,14 @@
const en = {
IncorrectPwd: 'Incorrect password',
addAccountDisabled: 'Add account feature is disabled',
addAccountDisabled: 'Add Email Address feature is disabled',
regDisabled: 'Sign up is disabled',
emptyEmail: 'Email cannot be empty',
notEmail: 'Invalid email',
notExistDomain: 'Email domain does not exist',
isDelAccount: 'This Email has been deleted',
isRegAccount: 'This Email is already registered',
accountLimit: 'Account limit reached',
delMyAccount: 'Cannot delete your own account',
accountLimit: 'Email address limit reached',
delMyAccount: 'Cannot delete your own email',
noUserAccount: 'This email does not belong to the current user',
usernameLengthLimit: 'Username length exceeds the limit',
noOsSendPic: 'Cannot send body images: object storage not configured',
@@ -64,13 +64,13 @@ const en = {
emailExistDatabase: 'Email already exists in the database',
notConfigOss: 'Object storage not configured',
perms: {
"邮件": "Email",
"邮件": "Emails",
"邮件发送": "Send Email",
"邮件删除": "Delete Email",
"邮箱侧栏": "Account",
"邮箱查看": "View Account",
"邮箱添加": "Add Account",
"邮箱删除": "Delete Account",
"邮箱侧栏": "Email Address",
"邮箱查看": "View Email",
"邮箱添加": "Add Email",
"邮箱删除": "Delete Email",
"个人设置": "Settings",
"用户注销": "Delete User",
"分析页": "Analytics",

View File

@@ -28,6 +28,7 @@ const requirePerms = [
'/account/delete',
'/account/add',
'/my/delete',
'/analysis/echarts',
'/role/add',
'/role/list',
'/role/delete',
@@ -36,6 +37,8 @@ const requirePerms = [
'/role/setDefault',
'/allEmail/list',
'/allEmail/delete',
'/allEmail/batchDelete',
'/allEmail/latest',
'/setting/setBackground',
'/setting/deleteBackground',
'/setting/set',

View File

@@ -449,7 +449,7 @@ const emailService = {
let count = 0
let list = []
while ((count < 10) && list.length === 0) {
while ((count < 6) && list.length === 0) {
list = await orm(c).select({...email}).from(email)
.leftJoin(
account,
@@ -467,7 +467,7 @@ const emailService = {
.orderBy(desc(email.emailId))
.limit(20);
await sleep(3000);
await sleep(5000);
count++
}
@@ -556,24 +556,24 @@ const emailService = {
}
if (userEmail) {
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${userEmail + '%'}`);
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${'%'+ userEmail + '%'}`);
}
if (accountEmail) {
conditions.push(
or(
sql`${email.toEmail} COLLATE NOCASE LIKE ${accountEmail + '%'}`,
sql`${email.sendEmail} COLLATE NOCASE LIKE ${accountEmail + '%'}`,
sql`${email.toEmail} COLLATE NOCASE LIKE ${'%'+ accountEmail + '%'}`,
sql`${email.sendEmail} COLLATE NOCASE LIKE ${'%'+ accountEmail + '%'}`,
)
)
}
if (name) {
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${name + '%'}`);
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${'%'+ name + '%'}`);
}
if (subject) {
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${subject + '%'}`);
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${'%'+ subject + '%'}`);
}
conditions.push(ne(email.status, emailConst.status.SAVING));
@@ -633,7 +633,7 @@ const emailService = {
let count = 0
let list = []
while ((count < 10) && list.length === 0) {
while ((count < 6) && list.length === 0) {
list = await orm(c).select({...email, userEmail: user.email}).from(email)
.leftJoin(user, eq(email.userId, user.userId))
.where(
@@ -645,7 +645,7 @@ const emailService = {
.orderBy(desc(email.emailId))
.limit(20);
await sleep(3000);
await sleep(5000);
count++
}

View File

@@ -130,7 +130,7 @@ const userService = {
if (email) {
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${email + '%'}`);
conditions.push(sql`${user.email} COLLATE NOCASE LIKE ${'%'+ email + '%'}`);
}
@@ -250,6 +250,7 @@ const userService = {
const { password, userId } = params;
await this.resetPassword(c, { password }, userId);
await c.env.kv.delete(KvConst.AUTH_INFO + userId);
},
async setStatus(c, params) {