新增权限域名限制和链接背景上传

This commit is contained in:
eoao
2025-07-26 23:11:00 +08:00
parent 0e86b93e0c
commit 12833cd901
36 changed files with 584 additions and 354 deletions

View File

@@ -22,7 +22,6 @@
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-cropper": "^1.1.4",
"vue-i18n": "^11.1.10",
"vue-router": "^4.5.0"
},
@@ -3842,12 +3841,6 @@
}
}
},
"node_modules/vue-cropper": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.1.4.tgz",
"integrity": "sha512-5m98vBsCEI9rbS4JxELxXidtAui3qNyTHLHg67Qbn7g8cg+E6LcnC+hh3SM/p94x6mFh6KRxT1ttnta+wCYqWA==",
"license": "ISC"
},
"node_modules/vue-i18n": {
"version": "11.1.10",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.10.tgz",

View File

@@ -24,7 +24,6 @@
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-cropper": "^1.1.4",
"vue-i18n": "^11.1.10",
"vue-router": "^4.5.0"
},

View File

@@ -7,7 +7,7 @@
import { useI18n } from "vue-i18n";
import {useSettingStore} from "@/store/setting.js";
const settingStore = useSettingStore()
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import zhCn from 'element-plus/es/locale/lang/zh-cn';;
const { locale } = useI18n()
locale.value = settingStore.lang
</script>

View File

@@ -96,7 +96,6 @@ const en = {
expand: 'Expand',
collapse: 'Collapse',
daily: 'Daily',
emailBlock: '*Intercept all incoming emails to example.com',
searchRegKeyDesc: 'Enter invite code to search',
remainingUses: 'Remaining uses',
exhausted: 'Exhausted',
@@ -168,11 +167,11 @@ const en = {
addOsDomain: 'Add domain',
domainDesc: 'Domain',
addTurnstileSecret: 'Add turnstile secret',
backgroundCropping: 'Background cropping',
backgroundTitle: 'Change background',
tgBotDesc: 'Forward received emails to a Telegram bot',
tgBotToken: 'Bot token',
toBotTokenDesc: 'Multiple user chat_ids, separated by commas',
otherEmailDec: 'emails can be forwarded to external email, but must be verified via cloudflare.',
otherEmailDesc: 'emails can be forwarded to external email, but must be verified via cloudflare.',
otherEmailInputDesc: 'Separate multiple email addresses with commas.',
forwardingRulesDesc: 'Rule-based forwarding only forwards emails received by the specified address.',
ruleEmailsInputDesc: 'Separate multiple email addresses with commas.',
@@ -246,7 +245,17 @@ const en = {
totalUserAccount: '{msg}',
sendBanned: 'Banned',
wrote: 'wrote',
support: 'Support'
support: 'Support',
supportDesc: 'Buy me tea',
featDesc: 'Feature Description',
emailInterception: 'Email Interception',
emailInterceptionDesc: '*Intercept emails by blocking entire domain using example.com to prevent users from receiving emails from certain websites.',
availableDomains: 'Available domains',
availableDomainsDesc: 'Restrict users to email domains specified. Domains not on the approved list will be blocked from registration, adding email addresses, and sending/receiving emails. If left blank, all domains will be allowed by default.',
backgroundUrlDesc: 'Image URL',
localUpload: ' Local upload',
imageLink: 'Image URL',
backgroundWarning: 'Image file size affects website load speed.'
}
export default en

View File

@@ -96,7 +96,6 @@ const zh = {
expand: '展开',
collapse: '收起',
daily: '每天',
emailBlock: '输入邮箱拦截收件, 拦截所有前缀 *example.com',
searchRegKeyDesc: '输入注册码搜索',
remainingUses: '剩余次数',
exhausted: '已用尽',
@@ -165,14 +164,14 @@ const zh = {
community: '交流',
changeTitle: '修改标题',
addResendTokenDesc: '输入内容添加,不填则删除',
addOsDomain: '添加访对象存储问域名',
addOsDomain: '添加访问域名',
domainDesc: '域名',
addTurnstileSecret: '添加 Turnstile 密钥',
backgroundCropping: '背景截图',
backgroundTitle: '设置背景',
tgBotDesc: '可以将接收的邮件转发到Tg机器人',
tgBotToken: '机器人 token',
toBotTokenDesc: '用户 chat_id 多个用,分开',
otherEmailDec: '可以将邮件转到其他服务商邮箱需要在cloudflare验证邮箱',
otherEmailDesc: '可以将邮件转到其他服务商邮箱需要在cloudflare验证邮箱',
otherEmailInputDesc: '多个邮箱用, 分开',
forwardingRulesDesc: '规则转发只会转发设置邮箱所接收的邮件',
ruleEmailsInputDesc: '多个邮箱用, 分开',
@@ -246,6 +245,16 @@ const zh = {
totalUserAccount: '{msg} 个',
sendBanned: '已禁用',
wrote: '来信',
support: '赞助'
support: '赞助',
supportDesc: '请我喝杯奶茶',
featDesc: '功能说明',
emailInterception: '邮件拦截',
emailInterceptionDesc: '拦截邮件, 要拦截整个域名输入 *example.com, 可用于禁止用户接收某些网站的邮件',
availableDomains: '可用域名',
availableDomainsDesc: '限制用户只能使用指定的域名邮箱,不在配置名单内的域名会被禁止使用注册添加邮箱,接收发送邮件等功能,留空默认允许可用所有域名',
backgroundUrlDesc: '在线图片链接',
localUpload: '本地上传',
imageLink: '图片链接',
backgroundWarning: '图片文件大小会影响网站加载速度',
}
export default zh

View File

@@ -61,8 +61,8 @@
</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: 24px">{{$t('SystemSettings')}}</span>
<Icon icon="eos-icons:system-ok-outlined" width="18" height="18" style="margin-left: 2px" />
<span class="menu-name" style="margin-left: 22px">{{$t('SystemSettings')}}</span>
</el-menu-item>
</el-menu>
</div>

View File

@@ -38,7 +38,6 @@ const handleResize = () => {
}
onMounted(() => {
uiStore.writerRef = writerRef
window.addEventListener('resize', handleResize)

View File

@@ -2,8 +2,6 @@ import {createApp} from 'vue';
import App from './App.vue';
import router from './router';
import './style.css';
import VueCropper from 'vue-cropper';
import 'vue-cropper/dist/index.css'
import { init } from '@/init/init.js';
import { createPinia } from 'pinia';
import piniaPersistedState from 'pinia-plugin-persistedstate';
@@ -12,7 +10,7 @@ const pinia = createPinia().use(piniaPersistedState)
import i18n from "@/i18n/index.js";
const app = createApp(App).use(pinia)
await init()
app.use(router).use(VueCropper).use(i18n).directive('perm',perm)
app.use(router).use(i18n).directive('perm',perm)
app.config.devtools = true;
app.mount('#app');

View File

@@ -1,5 +1,24 @@
import {useSettingStore} from "@/store/setting.js";
export function cvtR2Url(key) {
const settingStore = useSettingStore();
return settingStore.settings.r2Domain + '/' + key
if (!key) {
return + 'https://' + ''
}
if (key.startsWith('https://')) {
return key
}
const { settings } = useSettingStore();
let domain = settings.r2Domain
if (!domain.startsWith('http')) {
return 'https://' + domain + '/' + key
}
if (domain.endsWith("/")) {
domain = domain.slice(0, -1);
}
return domain + '/' + key
}

View File

@@ -40,11 +40,11 @@ export function fromNow(date) {
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');
return d.format('hh:mm A');
}
if (now.subtract(1, 'day').isSame(d, 'day')) {
return d.format('hh:mm A');
return d.format('MMM D');
}
return d.year() === now.year()

View File

@@ -14,13 +14,18 @@ export function formatBytes(bytes) {
return `${size} ${units[i]}`;
}
export function fileToBase64(file) {
export function fileToBase64(file, type = false) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
if (type) {
const base64 = reader.result;
resolve(base64);
} else {
const base64 = reader.result.split(',')[1];
resolve(base64);
}
};
reader.onerror = reject;
});
@@ -50,4 +55,4 @@ export function compressImage(file, config = {}) {
},
});
});
}
}

View File

@@ -47,15 +47,52 @@
</el-table>
</div>
</el-scrollbar>
<el-dialog top="5vh" class="dialog" v-model="roleFormShow" :title="dialogType.title" @closed="resetForm">
<el-dialog top="5vh" class="dialog" v-model="roleFormShow" @closed="resetForm">
<template #header>
<span style="font-size: 18px">{{dialogType.title}}</span>
<el-popover
width ="340"
:title="t('featDesc')"
placement="bottom"
>
<template #reference>
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</template>
<div style="font-weight: bold;margin-bottom: 2px;">{{t('emailInterception')}}</div>
<div>{{t('emailInterceptionDesc')}}</div>
<div style="font-weight: bold;margin-top: 10px;margin-bottom: 2px;">{{t('availableDomains')}}</div>
<div>
{{t('availableDomainsDesc')}}
</div>
</el-popover>
</template>
<div class="dialog-box">
<el-input class="dialog-input" v-model="form.name" type="text" :maxlength="12" :placeholder="$t('roleName')" autocomplete="off" />
<el-input class="dialog-input" v-model="form.description" :maxlength="30" type="text" :placeholder="$t('description')" autocomplete="off" />
<el-input-tag class="dialog-input-tag" tag-type="warning" :class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail" @add-tag="banEmailAddTag" type="text" :placeholder="$t('emailBlock')" autocomplete="off" />
<el-input-tag class="dialog-input-tag" tag-type="warning" :class="form.banEmail.length === 0 ? 'dialog-input' : '' " v-model="form.banEmail" @add-tag="banEmailAddTag" type="text" :placeholder="$t('emailInterception')" autocomplete="off" />
<el-radio-group class="dialog-radio" v-model="form.banEmailType" v-if="form.banEmail.length > 0">
<el-radio :label="$t('removeAll')" :value="0" />
<el-radio :label="$t('removeBody')" :value="1" />
</el-radio-group>
<el-select
class="dialog-input"
v-model="form.availDomain"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
tag-type="success"
:placeholder="$t('availableDomains')"
@change="availDomainChange"
>
<el-option
v-for="item in domainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="dialog-input">
<el-input-number :placeholder="$t('order')" :min="0" :max="9999" v-model.number="form.sort" controls-position="right" autocomplete="off" />
</div>
@@ -108,6 +145,7 @@ import {roleAdd, roleDelete, rolePermTree, roleRoleList, roleSet, roleSetDef} fr
import loading from '@/components/loading/index.vue';
import {useRoleStore} from "@/store/role.js";
import {useUserStore} from "@/store/user.js";
import {useSettingStore} from "@/store/setting.js";
import {isEmail} from "@/utils/verify-utils.js";
import {useI18n} from "vue-i18n";
@@ -115,6 +153,7 @@ defineOptions({
name: 'role'
})
const { domainList } = useSettingStore();
const { t, locale } = useI18n();
const userStore = useUserStore();
const roleStore = useRoleStore();
@@ -144,8 +183,11 @@ const form = reactive({
accountCount: 0,
sort: 0,
isDefault: 0,
availDomain: []
})
let domainOptions = []
const expand = ref(false)
let chooseRole = {}
@@ -156,6 +198,18 @@ rolePermTree().then(tree => {
treeList.push(...tree)
})
domainOptions = domainList.map(domain => ({label: domain,value: domain}))
function availDomainChange() {
const index = form.availDomain.findIndex(domain => {
return !domainOptions.map(option => option.value).includes(domain)
})
if (index > -1) {
form.availDomain.splice(index,1)
}
}
function banEmailAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
@@ -270,6 +324,7 @@ function resetForm() {
form.accountCount = 0
form.banEmail = []
form.banEmailType = 0
form.availDomain = []
tree.value.setCheckedKeys([])
}
@@ -285,6 +340,7 @@ function openRoleSet(role) {
form.sendCount = role.sendCount
form.accountCount = role.accountCount
form.banEmail = role.banEmail
form.availDomain = role.availDomain
nextTick(() => {
tree.value.setCheckedKeys(role.permIds)
})
@@ -394,7 +450,7 @@ window.onresize = () => {
.warning {
position: relative;
left: 5px;
top: 5px;
top: 2px;
color: gray;
cursor: pointer;
}

View File

@@ -73,9 +73,8 @@ import {accountSetName} from "@/request/account.js";
import {useAccountStore} from "@/store/account.js";
import {useI18n} from "vue-i18n";
import {useSettingStore} from "@/store/setting.js";
import dayjs from "dayjs";
const { t, locale } = useI18n()
const { t } = useI18n()
const settingStore = useSettingStore()
const accountStore = useAccountStore()
const userStore = useUserStore();

View File

@@ -101,13 +101,13 @@
fit="cover"
>
<template #error>
<div class="error-image" @click="openCut">
<div class="error-image" @click="openSetBackground">
<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">
<el-button class="opt-button" size="small" type="primary" @click="openSetBackground">
<Icon icon="lsicon:edit-outline" width="16" height="16"/>
</el-button>
<el-button class="opt-button" size="small" type="primary" @click="delBackground">
@@ -286,10 +286,10 @@
</div>
<div class="concerning-item">
<span>{{$t('support')}} : </span>
<el-button @click="jump('https://afdian.com/a/eoao_')" >
Afdian
<el-button @click="jump('https://support.skymail.ink')" >
{{t('supportDesc')}}
<template #icon>
<Icon color="#8261DB" icon="simple-icons:afdian" width="24" height="24" />
<Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" />
</template>
</el-button>
</div>
@@ -299,7 +299,7 @@
</div>
<!-- Dialogs remain the same -->
<el-dialog v-model="editTitleShow" :title="$t('changeTitle')" width="340" @closed="editTitle = ''">
<el-dialog v-model="editTitleShow" :title="$t('changeTitle')" width="340" >
<form>
<el-input type="text" :placeholder="$t('websiteTitle')" v-model="editTitle"/>
<el-button type="primary" :loading="settingLoading" @click="saveTitle">{{$t('save')}}</el-button>
@@ -319,7 +319,7 @@
<el-button type="primary" :loading="settingLoading" @click="saveResendToken">{{$t('save')}}</el-button>
</form>
</el-dialog>
<el-dialog v-model="r2DomainShow" :title="$t('addOsDomain')" width="340" @closed="r2DomainInput = ''">
<el-dialog v-model="r2DomainShow" :title="$t('addOsDomain')" width="340" @closed="r2DomainInput = setting.r2Domain">
<form>
<el-input type="text" :placeholder="$t('domainDesc')" v-model="r2DomainInput"/>
<el-button type="primary" :loading="settingLoading" @click="saveR2domain">{{$t('save')}}</el-button>
@@ -334,24 +334,37 @@
</form>
</el-dialog>
<el-dialog
v-model="cutShow"
:title="$t('backgroundCropping')"
v-model="showSetBackground"
class="cut-dialog"
@closed="closedSetBackground"
>
<div class="cropper">
<vueCropper
ref="cropper"
:img="cutImage"
:fixedNumber="[16,9]"
outputType="jpeg"
:fixed="true"
:centerBox="true"
:full="true"
:autoCrop="true"
:outputSize="0.92"
></vueCropper>
</div>
<template #header>
<span style="font-size: 18px">
{{$t('backgroundTitle')}}
<el-tooltip>
<template #content>
<span>{{$t('backgroundWarning')}}</span>
</template>
<Icon class="title-icon warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</span>
</template>
<el-input :placeholder="$t('backgroundUrlDesc')" v-model="backgroundUrl" v-if="!localUpShow" class="background-url" />
<el-image
v-if="localUpShow"
:preview-src-list="[backgroundImage]"
show-progress
class="cropper"
fit="cover"
:src="backgroundImage"
></el-image>
<div class="cut-button">
<el-button type="primary" link @click="openCut" v-if="!localUpShow">
{{$t('localUpload')}}
</el-button>
<el-button type="primary" link @click="localUpShow = false" v-if="localUpShow">
{{$t('imageLink')}}
</el-button>
<el-button type="primary" :loading="settingLoading" @click="saveBackground">{{$t('save')}}</el-button>
</div>
</el-dialog>
@@ -387,7 +400,7 @@
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{$t('otherEmail')}}</span>
<el-tooltip effect="dark" :content="$t('otherEmailDec')">
<el-tooltip effect="dark" :content="$t('otherEmailDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
@@ -454,6 +467,7 @@ import {debounce} from 'lodash-es'
import {isEmail} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js";
import { fileToBase64 } from "@/utils/file-utils.js"
import { useI18n } from 'vue-i18n';
defineOptions({
@@ -462,9 +476,8 @@ defineOptions({
const { t, locale } = useI18n();
const firstLoading = ref(true)
const cropper = ref()
const cutImage = ref('')
const cutShow = ref(false)
const backgroundImage = ref('')
const localUpShow = ref(false)
const accountStore = useAccountStore();
const userStore = useUserStore();
const editTitleShow = ref(false)
@@ -481,6 +494,9 @@ const editTitle = ref('')
const settingLoading = ref(false)
const r2DomainInput = ref('')
const loginOpacity = ref(0)
const backgroundUrl = ref('')
let backgroundFile = {}
const showSetBackground = ref(false)
let backup = '{}'
const resendTokenForm = reactive({
domain: '',
@@ -517,6 +533,17 @@ const tokenColumnWidth = ref(0)
const ruleType = ref(0)
const ruleEmail = ref([])
settingQuery().then(settingData => {
setting.value = settingData
resendTokenForm.domain = setting.value.domainList[0]
loginOpacity.value = setting.value.loginOpacity
firstLoading.value = false
backgroundUrl.value = setting.value.background?.startsWith('http') ? setting.value.background : ''
editTitle.value = setting.value.title
r2DomainInput.value = setting.value.r2Domain
})
const resendList = computed(() => {
let list = Object.keys(setting.value.resendTokens).map(key => {
@@ -548,13 +575,11 @@ const compareByLengthAndUpperCase = (a, b, key) => {
};
settingQuery().then(settingData => {
setting.value = settingData
resendTokenForm.domain = setting.value.domainList[0]
loginOpacity.value = setting.value.loginOpacity
firstLoading.value = false
})
function closedSetBackground() {
backgroundImage.value = ''
localUpShow.value = false
backgroundUrl.value = setting.value.background?.startsWith('http') ? setting.value.background : ''
}
function openTgSetting() {
tgBotStatus.value = setting.value.tgBotStatus
@@ -697,6 +722,7 @@ function delBackground() {
cancelButtonText: t('cancel'),
type: 'warning'
}).then(() => {
backgroundUrl.value = ''
setting.value.background = null
editSetting({background: null})
})
@@ -709,21 +735,43 @@ function saveTurnstileKey() {
editSetting(settingForm)
}
function saveBackground() {
settingLoading.value = true
cropper.value.getCropData(data => {
setBackground(data).then(key => {
setting.value.background = key
cutShow.value = false
async function saveBackground() {
let image = ''
if (localUpShow.value) {
image = await fileToBase64(backgroundFile,true);
} else {
if (!backgroundUrl.value.startsWith('http')) {
ElMessage({
message: t('changSuccessMsg'),
type: "success",
message: '图片地址不正确',
type: "error",
plain: true
})
}).finally(() => {
settingLoading.value = false
return
}
image = backgroundUrl.value
}
settingLoading.value = true
setBackground(image).then(key => {
setting.value.background = key
showSetBackground.value = false
ElMessage({
message: t('changSuccessMsg'),
type: "success",
plain: true
})
localUpShow.value = false
backgroundImage.value = ''
}).finally(() => {
settingLoading.value = false
})
}
function openSetBackground() {
showSetBackground.value = true
}
function openCut() {
@@ -732,14 +780,15 @@ function openCut() {
doc.setAttribute('accept', 'image/*')
doc.click()
doc.onchange = async (e) => {
cutImage.value = URL.createObjectURL(e.target.files[0])
cutShow.value = true
backgroundFile = e.target.files[0]
backgroundImage.value = URL.createObjectURL(e.target.files[0])
localUpShow.value = true
}
}
function saveR2domain() {
const settingForm = {r2Domain: r2DomainInput.value}
editSetting(settingForm)
editSetting(settingForm, true)
}
function openResendForm() {
@@ -824,7 +873,7 @@ function editSetting(settingForm, refreshStatus = true) {
thirdEmailShow.value = false
forwardRulesShow.value = false
}).catch((e) => {
console.log(e)
console.error(e)
loginOpacity.value = setting.value.loginOpacity
setting.value = {...setting.value, ...JSON.parse(backup)}
}).finally(() => {
@@ -892,6 +941,8 @@ function editSetting(settingForm, refreshStatus = true) {
flex-direction: column;
}
.settings-card {
background-color: #fff;
border-radius: 8px;
@@ -939,6 +990,14 @@ function editSetting(settingForm, refreshStatus = true) {
}
}
.title-icon.warning {
position: relative;
top: 2px;
color: gray;
cursor: pointer;
margin-left: 2px;
}
.warning {
margin-left: 5px;
color: gray;
@@ -959,6 +1018,11 @@ function editSetting(settingForm, refreshStatus = true) {
justify-content: space-between;
}
.background-url {
width: min(calc(100vw - 70px), 500px);
}
:deep(.el-dialog) {
width: 400px !important;
@media (max-width: 440px) {
@@ -1027,8 +1091,7 @@ function editSetting(settingForm, refreshStatus = true) {
padding-top: 15px;
width: 100%;
display: flex;
justify-content: end;
justify-content: space-between;
.el-button {
width: fit-content;
}

View File

@@ -644,7 +644,6 @@ function setType() {
function resetUserForm() {
userForm.password = null
userForm.userId = 0
userForm.type = 0
}
function search() {