mirror of
https://github.com/schroinerxy/cloud-mail.git
synced 2026-06-21 19:35:50 +08:00
新增支持API添加用户,查询邮件
This commit is contained in:
+10
-95
@@ -13,9 +13,10 @@
|
||||
|
||||
## Project Showcase
|
||||
|
||||
[**👉 Online Demo**](https://skymail.ink)
|
||||
- [Online Demo](https://skymail.ink)<br>
|
||||
- [Deployment Guide](https://doc.skymail.ink/en/)<br>
|
||||
- [Beginner’s Guide – UI Deployment](https://doc.skymail.ink/en/guide/via-ui.html)
|
||||
|
||||
[**👉 Beginner’s Guide – UI Deployment**](https://doc.skymail.ink)
|
||||
|
||||
|  |  |
|
||||
|--------------------------|---------------------|
|
||||
@@ -25,6 +26,8 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **💰 Low-Cost Usage**: No server required — deploy to Cloudflare Workers to reduce costs.
|
||||
|
||||
- **💻 Responsive Design**: Automatically adapts to both desktop and most mobile browsers.
|
||||
|
||||
- **📧 Email Sending**: Integrated with Resend for bulk email sending, embedded images, attachments, and status tracking.
|
||||
@@ -37,6 +40,8 @@
|
||||
|
||||
- **🔔 Email Push**: Forward received emails to Telegram bots or other email providers.
|
||||
|
||||
- **📡 Open API**: Supports batch user creation via API and multi-condition email queries
|
||||
|
||||
- **📈 Data Visualization**: Use Echarts to visualize system data, including user email growth.
|
||||
|
||||
- **⭐ Starred Emails**: Mark important emails for quick access.
|
||||
@@ -67,108 +72,18 @@
|
||||
|
||||
- **File Storage**: [Cloudflare R2](https://developers.cloudflare.com/r2/)
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### System Requirements
|
||||
|
||||
Nodejs v18.20 +
|
||||
|
||||
Cloudflare account (with a bound domain)
|
||||
|
||||
**Clone the project to your local machine:**
|
||||
``` shell
|
||||
git clone https://github.com/eoao/cloud-mail
|
||||
cd cloud-mail/mail-worker
|
||||
```
|
||||
|
||||
**Install Dependencies:**
|
||||
```shell
|
||||
npm i
|
||||
```
|
||||
|
||||
**Configure the Project**
|
||||
|
||||
mail-worker/wrangler.toml
|
||||
|
||||
```toml
|
||||
[[d1_databases]]
|
||||
binding = "db" # Default binding name for D1 database, cannot be changed
|
||||
database_name = "" # Database name
|
||||
database_id = "" # Database ID
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "kv" # Default binding name for KV storage, cannot be changed
|
||||
id = "" # KV namespace ID
|
||||
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "r2" # Default binding name for R2 storage, cannot be changed
|
||||
bucket_name = "" # R2 bucket name
|
||||
|
||||
[assets]
|
||||
binding = "assets" # Static asset binding name, cannot be changed
|
||||
directory = "./dist" # Directory for frontend Vue project build, default: dist
|
||||
|
||||
[vars]
|
||||
orm_log = false
|
||||
domain = [] # Configure email domains, example: ["example1.com", "example2.com"]
|
||||
admin = "" # Admin email, example: "admin@example.com"
|
||||
jwt_secret = "" # JWT secret for login tokens, choose a random string
|
||||
```
|
||||
|
||||
**Deploy Remotely**
|
||||
|
||||
1. Create KV, D1 database, and R2 object storage in Cloudflare Console.
|
||||
2. In the project directory `mail-worker/wrangler.toml`, configure the environment variables and database IDs/names.
|
||||
3. Run the deployment command:
|
||||
|
||||
```shell
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
4. In Cloudflare → Account Home → Your Domain → Email → Email Routing → Route Rules → Catch-all Address, edit and route to the worker.
|
||||
|
||||
5. In your browser, visit `https://your-project-domain/api/init/your-jwt-secret` to initialize or update the D1 and KV databases.
|
||||
|
||||
6. After deployment, log in to the site with the admin account to configure R2 domains, Turnstile keys, and more.
|
||||
|
||||
|
||||
**Run Locally**
|
||||
|
||||
1. Run locally. Databases and object storage will automatically be set up, no manual creation needed. Data is stored in the `mail-worker/.wrangler` folder.
|
||||
|
||||
```shell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. In your browser, visit `http://127.0.0.1:8787/api/init/your-jwt-secret` to initialize D1 and KV databases.
|
||||
|
||||
3. For local testing, you can set the R2 domain to `http://127.0.0.1:8787/api/file`.
|
||||
|
||||
**Email Sending**
|
||||
|
||||
1. Register on Resend, then click on “Domains” to add and verify your domain. Wait for verification.
|
||||
|
||||
2. Go to "API Keys" to create an API key, then copy the token and paste it in the project website settings.
|
||||
|
||||
3. Go to "Webhooks" and add a callback URL `https://your-project-domain/api/webhooks`.
|
||||
Select the following events: ✅ (email.bounced, email.complained, email.delivered, email.delivery_delayed).
|
||||
|
||||
|
||||
**Project Update**
|
||||
|
||||
After the update, run `https://your-project-domain/api/init/your-jwt-secret` to synchronize the database schema.
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://support.skymail.ink">
|
||||
|
||||
<a href="https://doc.skymail.ink/support.html">
|
||||
<img width="170px" src="./doc/images/support.png" alt="">
|
||||
</a><br><br>
|
||||
|
||||
|
||||
**Special Sponsors**
|
||||
|
||||
[DartNode](https://dartnode.com):Providing cloud computing service resource support
|
||||
[DartNode](https://dartnode.com):Providing cloud computing service resource support.
|
||||
|
||||
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1>Cloud Mail</h1>
|
||||
</div>
|
||||
<div align="center">
|
||||
<h4>使用Vue3开发的响应式简约邮箱服务,支持邮件发送附件收发,可以部署到Cloudflare云平台实现免费白嫖🎉</h4>
|
||||
<h4>使用Vue3开发的响应式邮箱服务,支持邮件发送,无需服务器可部署到Cloudflare平台 🎉</h4>
|
||||
</div>
|
||||
<div align="center">
|
||||
<span>简体中文 | <a href="/README-en.md" style="margin-left: 5px">English </a></span>
|
||||
@@ -14,13 +14,13 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloud flare部署,Resend推送邮件,无需服务器费用,搭建属于自己的邮箱服务
|
||||
只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloudflare部署,Resend推送邮件,无需服务器费用,搭建自己的邮箱服务
|
||||
|
||||
## 项目展示
|
||||
## 项目展示
|
||||
|
||||
[**👉 在线演示**](https://skymail.ink)
|
||||
|
||||
[**👉 小白保姆教程-界面部署**](https://doc.skymail.ink)
|
||||
- [在线演示](https://skymail.ink)<br>
|
||||
- [部署文档](https://doc.skymail.ink)<br>
|
||||
- [小白保姆教程-界面部署](https://doc.skymail.ink/guide/via-ui.html)
|
||||
|
||||
|  |  |
|
||||
|--------------------------|---------------------|
|
||||
@@ -33,31 +33,33 @@
|
||||
|
||||
## 功能介绍
|
||||
|
||||
- **💰免费白嫖**:无需服务器,部署到Cloudflare Workers 免费使用,不要钱
|
||||
- **💰 低成本使用**:无需服务器,部署到 Cloudflare Workers 降低使用成本
|
||||
|
||||
- **💻响应式设计**:响应式布局自动适配PC和大部分手机端浏览器
|
||||
- **💻 响应式设计**:响应式布局自动适配PC和大部分手机端浏览器
|
||||
|
||||
- **📧邮件发送**:集成resend发送邮件,支持群发,内嵌图片和附件发送,发送状态查看
|
||||
- **📧 邮件发送**:集成resend发送邮件,支持群发,内嵌图片和附件发送,发送状态查看
|
||||
|
||||
- **🛡️管理员功能**:可以对用户,邮件进行管理,RABC权限控制对功能及使用资源限制
|
||||
- **🛡️ 管理员功能**:可以对用户,邮件进行管理,RABC权限控制对功能及使用资源限制
|
||||
|
||||
- **🔀多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台
|
||||
- **🔀 多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台
|
||||
|
||||
- **📦附件收发**:支持收发附件,使用R2对象存储保存和下载文件
|
||||
- **📦 附件收发**:支持收发附件,使用R2对象存储保存和下载文件
|
||||
|
||||
- **🔔邮件推送**:接收邮件后可以转发到TG机器人或其他服务商邮箱
|
||||
- **🔔 邮件推送**:接收邮件后可以转发到TG机器人或其他服务商邮箱
|
||||
|
||||
- **📈数据可视化**:使用echarts对系统数据详情,用户邮件增长可视化显示
|
||||
- **📡 开放API**:支持使用API批量生成用户,多条件查询邮件
|
||||
|
||||
- **⭐星标邮件**:标记重要邮件,以便快速查阅
|
||||
- **📈 数据可视化**:使用echarts对系统数据详情,用户邮件增长可视化显示
|
||||
|
||||
- **🎨个性化设置**:可以自定义网站标题,登录背景,透明度
|
||||
- **⭐ 星标邮件**:标记重要邮件,以便快速查阅
|
||||
|
||||
- **⚙️功能设置**:可以对注册,邮件发送,添加等功能关闭和开启,设为私人站点
|
||||
- **🎨 个性化设置**:可以自定义网站标题,登录背景,透明度
|
||||
|
||||
- **🤖人机验证**:集成Turnstile人机验证,防止人机批量注册
|
||||
- **⚙️ 功能设置**:可以对注册,邮件发送,添加等功能关闭和开启,设为私人站点
|
||||
|
||||
- **📜更多功能**:正在开发中...
|
||||
- **🤖 人机验证**:集成Turnstile人机验证,防止人机批量注册
|
||||
|
||||
- **📜 更多功能**:正在开发中...
|
||||
|
||||
|
||||
|
||||
@@ -80,110 +82,10 @@
|
||||
- **文件存储**:[Cloudflare R2](https://developers.cloudflare.com/r2/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 使用教程
|
||||
|
||||
### 环境要求
|
||||
|
||||
Nodejs v18.20 +
|
||||
|
||||
Cloudflare 账号 (需要绑定域名)
|
||||
|
||||
|
||||
**克隆项目到本地**
|
||||
``` shell
|
||||
git clone https://github.com/eoao/cloud-mail #拉取代码
|
||||
cd cloud-mail/mail-worker #进入worker目录
|
||||
```
|
||||
|
||||
**安装依赖**
|
||||
```shell
|
||||
npm i
|
||||
```
|
||||
|
||||
**项目配置**
|
||||
|
||||
mail-worker/wrangler.toml
|
||||
|
||||
```toml
|
||||
[[d1_databases]]
|
||||
binding = "db" #d1数据库绑定名默认不可修改
|
||||
database_name = "" #d1数据库名字
|
||||
database_id = "" #d1数据库id
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "kv" #kv绑定名默认不可修改
|
||||
id = "" #kv数据库id
|
||||
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "r2" #r2对象存储绑定名默认不可修改
|
||||
bucket_name = "" #r2对象存储桶的名字
|
||||
|
||||
|
||||
[assets]
|
||||
binding = "assets" #静态资源绑定名默认不可修改
|
||||
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
|
||||
|
||||
[vars]
|
||||
orm_log = false
|
||||
domain = [] #邮件域名可以配置多个示例: ["example1.com","example2.com"]
|
||||
admin = "" #管理员的邮箱 示例: "admin@example.com"
|
||||
jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
**远程部署**
|
||||
|
||||
1. 在 Cloudflare 控制台创建KV,D1数据库,R2对象存储
|
||||
2. 在项目目录 `mail-worker/wrangler.toml` 配置文件中配置对应环境变量,以及创建的数据库id和名称
|
||||
3. 执行远程部署命令
|
||||
|
||||
```shell
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
4. 在Cloudflare→账户主页→你的域名→电子邮件→电子邮件路由→路由规则→Catch-all地址,编辑发送到worker
|
||||
|
||||
5. 浏览器输入 `https://你的项目域名/api/init/你的jwt_secret` 初始化或更新 d1和kv数据库
|
||||
|
||||
6. 部署完成登录网站,使用管理员账号可以在设置页面添加配置 R2域名 Turnstile密钥 等
|
||||
|
||||
|
||||
[👉 使用 Github Action 部署](/doc/github-action.md)
|
||||
|
||||
**本地运行**
|
||||
|
||||
1. 本地运行,数据库,对象存储会自动安装,无需创建,数据库数据保存在 `mail-worker/.wrangler` 文件夹
|
||||
|
||||
```shell
|
||||
npm run dev
|
||||
```
|
||||
2. 浏览器输入 `http://127.0.0.1:8787/api/init/你的jwt_secret` 初始化d1和kv数据库
|
||||
|
||||
3. 本地运行项目设置页面r2域名可设置为 `http://127.0.0.1:8787/api/file`
|
||||
|
||||
**邮件发送**
|
||||
|
||||
1. 在 resend 官网注册后,点击左侧 Domains 添加并验证你的域名,等待验证完成
|
||||
2. 点击左侧 Api Keys 创建立api key, 复制token回到项目网站设置页面添加 resend token
|
||||
|
||||
3. 点击左侧 Webhooks 添加回调地址 `https://你的项目域名/api/webhooks`
|
||||
|
||||
勾选✅ (email.bounced email.complained email.delivered email.delivery_delayed)
|
||||
|
||||
**项目更新**
|
||||
|
||||
更新后需要执行 `https://你的项目域名/api/init/你的jwt_secret` 来同步数据库结构
|
||||
|
||||
## 赞助
|
||||
|
||||
|
||||
<a href="https://support.skymail.ink" >
|
||||
<a href="https://doc.skymail.ink/support.html" >
|
||||
<img width="170px" src="./doc/images/support.png" alt="">
|
||||
</a><br><br>
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
NODE_ENV = 'remote'
|
||||
VITE_APP_TITLE = '远程环境'
|
||||
VITE_BASE_URL = 'https://mornglow.top/api'
|
||||
VITE_BASE_URL = ''
|
||||
|
||||
@@ -133,6 +133,7 @@ const en = {
|
||||
loginSwitch: 'Sign in',
|
||||
websiteSetting: 'Website',
|
||||
websiteReg: 'Sign Up',
|
||||
loginDomain: 'Sign-In Box Domain',
|
||||
multipleEmail: 'Multiple Accounts',
|
||||
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts.',
|
||||
physicallyWipeData: 'Physically Wipe Data',
|
||||
@@ -262,7 +263,7 @@ const en = {
|
||||
rulesVerifyTitle: 'Trigger After {count} Daily Uses per IP',
|
||||
botVerifyMsg: 'Please verify that you are human',
|
||||
noticeTitle: 'Notice',
|
||||
noticePopup: 'Sign-in Popup',
|
||||
noticePopup: 'Sign-In Popup',
|
||||
icon: 'Icon',
|
||||
position: 'Position',
|
||||
offset: 'Offset',
|
||||
@@ -278,7 +279,9 @@ const en = {
|
||||
popUp: 'Pop Up',
|
||||
noRecipientTitle: 'No Recipient',
|
||||
noRecipientDesc: 'Emails can be received even without a registered email address.',
|
||||
preview: 'Preview'
|
||||
preview: 'Preview',
|
||||
help: 'Help',
|
||||
document: 'Document'
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -18,7 +18,7 @@ const zh = {
|
||||
deleteUserBtn: '删除账户',
|
||||
changePassword: '修改密码',
|
||||
newPassword: '新的密码',
|
||||
confirmPassword: '旧的密码',
|
||||
confirmPassword: '确认密码',
|
||||
add: '添加',
|
||||
manage: '管理',
|
||||
rename: '改名',
|
||||
@@ -133,6 +133,7 @@ const zh = {
|
||||
loginSwitch: '去登录',
|
||||
websiteSetting: '网站设置',
|
||||
websiteReg: '用户注册',
|
||||
loginDomain: '登录框域名',
|
||||
multipleEmail: '多号模式',
|
||||
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
|
||||
physicallyWipeData: '物理清空数据',
|
||||
@@ -246,7 +247,7 @@ const zh = {
|
||||
totalUserAccount: '{msg} 个',
|
||||
sendBanned: '已禁用',
|
||||
wrote: '来信',
|
||||
support: '赞助',
|
||||
support: '捐助',
|
||||
supportDesc: '请我喝杯奶茶',
|
||||
featDesc: '功能说明',
|
||||
emailInterception: '邮件拦截',
|
||||
@@ -278,7 +279,9 @@ const zh = {
|
||||
popUp: '弹出',
|
||||
noRecipientTitle: '无人收件',
|
||||
noRecipientDesc: '即使没有注册的邮箱也能收到邮件',
|
||||
preview: '预览'
|
||||
preview: '预览',
|
||||
help: '帮助',
|
||||
document: '项目文档'
|
||||
|
||||
}
|
||||
export default zh
|
||||
export default zh
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
<template>
|
||||
<div class="account-box">
|
||||
<div class="head-opt" >
|
||||
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add" />
|
||||
<Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh" />
|
||||
<div class="head-opt">
|
||||
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add"/>
|
||||
<Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh"/>
|
||||
</div>
|
||||
<el-scrollbar class="scrollbar">
|
||||
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false">
|
||||
<el-card class="item" :class="itemBg(item.accountId)" v-for="item in accounts" :key="item.accountId" @click="changeAccount(item)">
|
||||
<div class="account" >
|
||||
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false">
|
||||
<el-card class="item" :class="itemBg(item.accountId)" v-for="item in accounts" :key="item.accountId"
|
||||
@click="changeAccount(item)">
|
||||
<div class="account">
|
||||
{{ item.email }}
|
||||
</div>
|
||||
<div class="opt">
|
||||
<div class="send-email" @click.stop>
|
||||
<Icon icon="eva:email-fill" width="22" height="22" color="#fccb1a" />
|
||||
<Icon icon="eva:email-fill" width="22" height="22" color="#fccb1a"/>
|
||||
</div>
|
||||
<div class="settings" @click.stop>
|
||||
<Icon icon="fluent-color:clipboard-24" width="22" height="22" @click.stop="copyAccount(item.email)"/>
|
||||
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" v-if="showNullSetting(item)" />
|
||||
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399"
|
||||
v-if="showNullSetting(item)"/>
|
||||
<el-dropdown v-else>
|
||||
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" />
|
||||
<template #dropdown >
|
||||
<el-dropdown-menu >
|
||||
<el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{$t('rename')}}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')" @click="remove(item)">{{$t('delete')}}</el-dropdown-item>
|
||||
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399"/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{ $t('rename') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')"
|
||||
@click="remove(item)">{{ $t('delete') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -32,13 +37,13 @@
|
||||
|
||||
<!-- Initial Loading Skeleton -->
|
||||
<template v-if="loading">
|
||||
<el-skeleton v-for="i in 3" :key="i" animated>
|
||||
<el-skeleton v-for="i in 3" :key="i" animated>
|
||||
<template #template>
|
||||
<el-card class="item">
|
||||
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px" />
|
||||
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px"/>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<el-skeleton-item variant="text" style="width: 20px" />
|
||||
<el-skeleton-item variant="text" style="width: 20px" />
|
||||
<el-skeleton-item variant="text" style="width: 20px"/>
|
||||
<el-skeleton-item variant="text" style="width: 20px"/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -50,10 +55,10 @@
|
||||
<el-skeleton animated>
|
||||
<template #template>
|
||||
<el-card class="item">
|
||||
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px" />
|
||||
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px"/>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<el-skeleton-item variant="text" style="width: 20px" />
|
||||
<el-skeleton-item variant="text" style="width: 20px" />
|
||||
<el-skeleton-item variant="text" style="width: 20px"/>
|
||||
<el-skeleton-item variant="text" style="width: 20px"/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -61,43 +66,43 @@
|
||||
</template>
|
||||
|
||||
<div class="noLoading" v-if="noLoading && accounts.length > 0">
|
||||
<div>{{$t('noMoreData')}}</div>
|
||||
<div>{{ $t('noMoreData') }}</div>
|
||||
</div>
|
||||
<div class="empty" v-if="noLoading && accounts.length === 0">
|
||||
<el-empty :description="$t('noMessagesFound')" />
|
||||
<el-empty :description="$t('noMessagesFound')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-scrollbar>
|
||||
<el-dialog v-model="showAdd" :title="$t('addAccount')" >
|
||||
<div class="container">
|
||||
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
ref="mySelect"
|
||||
v-model="addForm.suffix"
|
||||
:placeholder="$t('select')"
|
||||
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>
|
||||
<el-dialog v-model="showAdd" :title="$t('addAccount')">
|
||||
<div class="container">
|
||||
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
ref="mySelect"
|
||||
v-model="addForm.suffix"
|
||||
:placeholder="$t('select')"
|
||||
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>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
|
||||
>{{$t('add')}}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
|
||||
>{{ $t('add') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
class="add-email-turnstile"
|
||||
:class="verifyShow ? 'turnstile-show' : 'turnstile-hide'"
|
||||
@@ -105,15 +110,15 @@
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
>
|
||||
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">人机验证模块加载失败,请刷新浏览器</span>
|
||||
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{ $t('verifyModuleFailed') }}</span>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="setNameShow" :title="$t('changeUserName')" >
|
||||
<el-dialog v-model="setNameShow" :title="$t('changeUserName')">
|
||||
<div class="container">
|
||||
<el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off">
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="setName" :loading="setNameLoading"
|
||||
>{{$t('save')}}
|
||||
>{{ $t('save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
@@ -127,10 +132,10 @@ 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 "@/perm/perm.js"
|
||||
import {hasPerm} from "@/perm/perm.js"
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const accountStore = useAccountStore();
|
||||
const settingStore = useSettingStore();
|
||||
@@ -150,6 +155,7 @@ let account = null
|
||||
let turnstileId = null
|
||||
const botJsError = ref(false)
|
||||
let verifyToken = ''
|
||||
let verifyErrorCount = 0
|
||||
const addForm = reactive({
|
||||
email: '',
|
||||
suffix: settingStore.domainList[0]
|
||||
@@ -175,14 +181,20 @@ const openSelect = () => {
|
||||
}
|
||||
|
||||
window.onTurnstileError = (e) => {
|
||||
console.log('人机验加载失败')
|
||||
nextTick(() => {
|
||||
if (!turnstileId) {
|
||||
turnstileId = window.turnstile.render('.register-turnstile')
|
||||
} else {
|
||||
window.turnstile.reset(turnstileId);
|
||||
}
|
||||
})
|
||||
if (verifyErrorCount >= 4) {
|
||||
return
|
||||
}
|
||||
verifyErrorCount++
|
||||
console.warn('人机验加载失败', e)
|
||||
setTimeout(() => {
|
||||
nextTick(() => {
|
||||
if (!turnstileId) {
|
||||
turnstileId = window.turnstile.render('.add-email-turnstile')
|
||||
} else {
|
||||
window.turnstile.reset(turnstileId);
|
||||
}
|
||||
})
|
||||
}, 1500)
|
||||
};
|
||||
|
||||
window.onTurnstileSuccess = (token) => {
|
||||
@@ -208,7 +220,7 @@ function setName() {
|
||||
}
|
||||
|
||||
setNameLoading.value = true
|
||||
accountSetName(account.accountId,name).then(() => {
|
||||
accountSetName(account.accountId, name).then(() => {
|
||||
account.name = name
|
||||
setNameShow.value = false
|
||||
|
||||
@@ -221,12 +233,12 @@ function setName() {
|
||||
type: "success",
|
||||
plain: true
|
||||
})
|
||||
}).finally(()=> {
|
||||
}).finally(() => {
|
||||
setNameLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function openSetName (accountItem) {
|
||||
function openSetName(accountItem) {
|
||||
accountName.value = accountItem.name
|
||||
account = accountItem
|
||||
setNameShow.value = true
|
||||
@@ -241,7 +253,7 @@ function itemBg(accountId) {
|
||||
}
|
||||
|
||||
function remove(account) {
|
||||
ElMessageBox.confirm(t('delConfirm',{msg: account.email}), {
|
||||
ElMessageBox.confirm(t('delConfirm', {msg: account.email}), {
|
||||
confirmButtonText: t('confirm'),
|
||||
cancelButtonText: t('cancel'),
|
||||
type: 'warning'
|
||||
@@ -272,6 +284,7 @@ function refresh() {
|
||||
accounts.splice(0, accounts.length)
|
||||
getAccountList()
|
||||
}
|
||||
|
||||
function changeAccount(account) {
|
||||
accountStore.currentAccountId = account.accountId
|
||||
accountStore.currentAccount = account
|
||||
@@ -281,7 +294,7 @@ function add() {
|
||||
showAdd.value = true
|
||||
setTimeout(() => {
|
||||
addRef.value.focus()
|
||||
},100)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
async function copyAccount(account) {
|
||||
@@ -308,11 +321,11 @@ function getAccountList() {
|
||||
|
||||
if (accounts.length === 0) {
|
||||
loading.value = true
|
||||
}else {
|
||||
} else {
|
||||
followLoading.value = true
|
||||
}
|
||||
|
||||
accountList(queryParams.accountId,queryParams.size).then(list => {
|
||||
accountList(queryParams.accountId, queryParams.size).then(list => {
|
||||
if (list.length < queryParams.size) {
|
||||
noLoading.value = true
|
||||
}
|
||||
@@ -331,9 +344,9 @@ function getAccountList() {
|
||||
}
|
||||
|
||||
|
||||
function submit() {
|
||||
function submit() {
|
||||
|
||||
if (!addForm.email){
|
||||
if (!addForm.email) {
|
||||
ElMessage({
|
||||
message: t('emptyEmailMsg'),
|
||||
type: "error",
|
||||
@@ -342,7 +355,7 @@ function submit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmail(addForm.email+addForm.suffix)) {
|
||||
if (!isEmail(addForm.email + addForm.suffix)) {
|
||||
ElMessage({
|
||||
message: t('notEmailMsg'),
|
||||
type: "error",
|
||||
@@ -376,7 +389,7 @@ function submit() {
|
||||
}
|
||||
|
||||
addLoading.value = true
|
||||
accountAdd(addForm.email+addForm.suffix,verifyToken).then(account => {
|
||||
accountAdd(addForm.email + addForm.suffix, verifyToken).then(account => {
|
||||
addLoading.value = false
|
||||
showAdd.value = false
|
||||
addForm.email = ''
|
||||
@@ -418,6 +431,7 @@ path[fill="#ffdda1"] {
|
||||
background-color: #FFF;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.head-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -425,9 +439,11 @@ path[fill="#ffdda1"] {
|
||||
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
.icon{
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -440,6 +456,7 @@ path[fill="#ffdda1"] {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
@@ -447,12 +464,14 @@ path[fill="#ffdda1"] {
|
||||
@media (max-width: 767px) {
|
||||
height: calc(100% - 98px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noLoading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -461,6 +480,7 @@ path[fill="#ffdda1"] {
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
@@ -474,6 +494,7 @@ path[fill="#ffdda1"] {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
.account {
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
@@ -488,11 +509,13 @@ path[fill="#ffdda1"] {
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.send-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -503,8 +526,9 @@ path[fill="#ffdda1"] {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.item:first-child{
|
||||
margin-top: 10px ;
|
||||
|
||||
.item:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.item-choose {
|
||||
@@ -560,4 +584,4 @@ path[fill="#ffdda1"] {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import {useUiStore} from "@/store/ui.js";
|
||||
import {replace} from "lodash-es";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -81,7 +82,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (!token && to.name !== 'login') {
|
||||
|
||||
return next({
|
||||
name: 'login'
|
||||
name: 'login',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,6 +90,10 @@ router.beforeEach(async (to, from, next) => {
|
||||
return next()
|
||||
}
|
||||
|
||||
if (token && to.name === 'login') {
|
||||
next(from.path)
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div v-if="analysisLoading" class="analysis-loading">
|
||||
<loading />
|
||||
<loading/>
|
||||
</div>
|
||||
<el-scrollbar v-else style="height: 100%;">
|
||||
<el-scrollbar v-else style="height: 100%;">
|
||||
<div class="analysis" :key="boxKey">
|
||||
<div class="number">
|
||||
<div class="number-item">
|
||||
<div class="top">
|
||||
<div class="left">
|
||||
<div>{{$t('totalReceived')}}</div>
|
||||
<div>{{ $t('totalReceived') }}</div>
|
||||
<div>
|
||||
<el-statistic :formatter="value => Math.round(value)" :value="receiveData"/>
|
||||
</div>
|
||||
@@ -20,14 +20,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-ratio">
|
||||
<div>{{$t('active')}} <span class="normal">{{numberCount.normalReceiveTotal}}</span></div>
|
||||
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delReceiveTotal}}</span></div>
|
||||
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalReceiveTotal }}</span></div>
|
||||
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delReceiveTotal }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="number-item">
|
||||
<div class="top">
|
||||
<div class="left">
|
||||
<div>{{$t('totalSent')}}</div>
|
||||
<div>{{ $t('totalSent') }}</div>
|
||||
<div>
|
||||
<el-statistic :formatter="value => Math.round(value)" :value="sendData"/>
|
||||
</div>
|
||||
@@ -39,14 +39,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-ratio">
|
||||
<div>{{$t('active')}} <span class="normal">{{numberCount.normalSendTotal}}</span></div>
|
||||
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delSendTotal}}</span></div>
|
||||
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalSendTotal }}</span></div>
|
||||
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delSendTotal }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="number-item">
|
||||
<div class="top">
|
||||
<div class="left">
|
||||
<div>{{$t('totalMailboxes')}}</div>
|
||||
<div>{{ $t('totalMailboxes') }}</div>
|
||||
<div>
|
||||
<el-statistic :formatter="value => Math.round(value)" :value="accountData"/>
|
||||
</div>
|
||||
@@ -58,14 +58,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-ratio">
|
||||
<div>{{$t('active')}} <span class="normal">{{numberCount.normalAccountTotal}}</span></div>
|
||||
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delAccountTotal}}</span></div>
|
||||
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalAccountTotal }}</span></div>
|
||||
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delAccountTotal }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="number-item">
|
||||
<div class="top">
|
||||
<div class="left">
|
||||
<div>{{$t('totalUsers')}}</div>
|
||||
<div>{{ $t('totalUsers') }}</div>
|
||||
<div>
|
||||
<el-statistic :formatter="value => Math.round(value)" :value="userData"/>
|
||||
</div>
|
||||
@@ -77,19 +77,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-ratio">
|
||||
<div>{{$t('active')}} <span class="normal">{{numberCount.normalUserTotal}}</span></div>
|
||||
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delUserTotal}}</span></div>
|
||||
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalUserTotal }}</span></div>
|
||||
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delUserTotal }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picture">
|
||||
<div class="picture-item">
|
||||
<div class="title" style="display: flex;justify-content: space-between;">
|
||||
<span>{{$t('emailSource')}}</span>
|
||||
<span>{{ $t('emailSource') }}</span>
|
||||
<span class="source-button" v-if="false">
|
||||
<el-radio-group v-model="checkedSourceType" >
|
||||
<el-radio-button label="发件人" value="sender" />
|
||||
<el-radio-button label="邮箱" value="email" />
|
||||
<el-radio-group v-model="checkedSourceType">
|
||||
<el-radio-button label="发件人" value="sender"/>
|
||||
<el-radio-button label="邮箱" value="email"/>
|
||||
</el-radio-group>
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="picture-item">
|
||||
<div class="title">{{$t('userGrowth')}}</div>
|
||||
<div class="title">{{ $t('userGrowth') }}</div>
|
||||
<div class="increase-line">
|
||||
|
||||
</div>
|
||||
@@ -106,11 +106,11 @@
|
||||
</div>
|
||||
<div class="picture-cs">
|
||||
<div class="picture-cs-item">
|
||||
<div class="title">{{$t('emailGrowth')}}</div>
|
||||
<div class="title">{{ $t('emailGrowth') }}</div>
|
||||
<div class="email-column"></div>
|
||||
</div>
|
||||
<div class="picture-cs-item">
|
||||
<div class="title">{{$t('sentToday')}}</div>
|
||||
<div class="title">{{ $t('sentToday') }}</div>
|
||||
<div class="send-count"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,13 +129,13 @@ import {useUiStore} from "@/store/ui.js";
|
||||
import {debounce} from "lodash-es";
|
||||
import loading from "@/components/loading/index.vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
|
||||
defineOptions({
|
||||
name: 'analysis'
|
||||
})
|
||||
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
const uiStore = useUiStore()
|
||||
const checkedSourceType = ref('sender')
|
||||
@@ -218,13 +218,12 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
userLineData.xdata = data.userDayCount.map(item => dayjs(item.date).format("M.D"))
|
||||
userLineData.xdata = data.userDayCount.map(item => dayjs(item.date).format("M.D"));
|
||||
userLineData.sdata = data.userDayCount.map(item => item.total)
|
||||
|
||||
emailColumnData.daysData = data.emailDayCount.receiveDayCount.map(item => dayjs(item.date).format("M.D"))
|
||||
emailColumnData.receiveData = data.emailDayCount.receiveDayCount.map(item => item.total)
|
||||
emailColumnData.sendData = data.emailDayCount.sendDayCount.map(item => item.total)
|
||||
|
||||
daySendTotal = data.daySendTotal
|
||||
analysisLoading.value = false
|
||||
initPicture();
|
||||
@@ -234,8 +233,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
function initPicture() {
|
||||
if(route.name !== 'analysis') return
|
||||
boxKey.value ++
|
||||
if (route.name !== 'analysis') return
|
||||
boxKey.value++
|
||||
setTimeout(() => {
|
||||
createSenderPie()
|
||||
createIncreaseLine()
|
||||
@@ -282,7 +281,7 @@ function setStyle() {
|
||||
const measureCtx = document.createElement('canvas').getContext('2d');
|
||||
measureCtx.font = '12px sans-serif';
|
||||
|
||||
function truncateTextByWidth(text,maxWidth = 140) {
|
||||
function truncateTextByWidth(text, maxWidth = 140) {
|
||||
|
||||
let width = measureCtx.measureText(text).width;
|
||||
if (width <= maxWidth) return text;
|
||||
@@ -311,22 +310,22 @@ function createSenderPie() {
|
||||
return `${params.marker} ${params.name}: ${params.value} (${params.percent}%)`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
left: '10',
|
||||
top: '20',
|
||||
formatter: function (name) {
|
||||
return truncateTextByWidth(name)
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
left: '10',
|
||||
top: '20',
|
||||
formatter: function (name) {
|
||||
return truncateTextByWidth(name)
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: senderData.value,
|
||||
name: '',
|
||||
type: 'pie',
|
||||
radius: ['40%', '65%'],
|
||||
center: [ senderPieLeft, '45%'],
|
||||
center: [senderPieLeft, '45%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
@@ -378,7 +377,7 @@ function createIncreaseLine() {
|
||||
formatter: function (params) {
|
||||
let result = ''
|
||||
params.forEach(item => {
|
||||
result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`;
|
||||
result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
@@ -438,7 +437,7 @@ function createIncreaseLine() {
|
||||
},
|
||||
boundaryGap: [0, 0.1],
|
||||
max: (params) => {
|
||||
if (params.max < 8 ) {
|
||||
if (params.max < 8) {
|
||||
return 10
|
||||
}
|
||||
},
|
||||
@@ -493,6 +492,21 @@ function createIncreaseLine() {
|
||||
]
|
||||
};
|
||||
increaseLine.setOption(option);
|
||||
|
||||
let max = increaseLine.getModel().getComponent('yAxis', 0).axis.scale.getExtent()[1];
|
||||
|
||||
let left = 35
|
||||
|
||||
if (max > 99) left = 42
|
||||
if (max > 999) left = 51
|
||||
if (max > 9999) left = 58
|
||||
if (max > 99999) left = 66
|
||||
|
||||
increaseLine.setOption({
|
||||
grid: {
|
||||
left: left
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createEmailColumnChart() {
|
||||
@@ -537,7 +551,7 @@ function createEmailColumnChart() {
|
||||
},
|
||||
yAxis: {
|
||||
max: (params) => {
|
||||
if (params.max < 8 ) {
|
||||
if (params.max < 8) {
|
||||
return 10
|
||||
}
|
||||
},
|
||||
@@ -584,7 +598,7 @@ function createEmailColumnChart() {
|
||||
}
|
||||
|
||||
function createSendGauge() {
|
||||
if(sendGauge) {
|
||||
if (sendGauge) {
|
||||
sendGauge.dispose()
|
||||
}
|
||||
sendGauge = echarts.init(document.querySelector(".send-count"));
|
||||
@@ -649,6 +663,7 @@ function createSendGauge() {
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.percentage-label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
@@ -663,6 +678,7 @@ function createSendGauge() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.analysis {
|
||||
height: 100%;
|
||||
padding: 20px 20px 30px;
|
||||
@@ -674,12 +690,14 @@ function createSendGauge() {
|
||||
padding: 15px 15px 30px;
|
||||
gap: 15px
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 10px;
|
||||
margin-left: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.number {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
@@ -691,11 +709,13 @@ function createSendGauge() {
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.number-item {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
padding: 21px 20px;
|
||||
|
||||
.top {
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
@@ -719,6 +739,7 @@ function createSendGauge() {
|
||||
.right {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
.count-icon {
|
||||
top: 3px;
|
||||
position: relative;
|
||||
@@ -740,6 +761,7 @@ function createSendGauge() {
|
||||
justify-content: start;
|
||||
gap: 20px;
|
||||
padding-top: 5px;
|
||||
|
||||
.normal {
|
||||
width: fit-content;
|
||||
color: var(--el-color-success);
|
||||
@@ -768,24 +790,29 @@ function createSendGauge() {
|
||||
@media (max-width: 1024px) {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.picture-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
|
||||
.source-button {
|
||||
padding-right: 15px;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.sender-pie {
|
||||
height: 350px;
|
||||
@media (max-width: 767px) {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.increase-line {
|
||||
height: 350px;
|
||||
@media (max-width: 767px) {
|
||||
@@ -803,16 +830,19 @@ function createSendGauge() {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.picture-cs-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
|
||||
.send-count {
|
||||
height: 350px;
|
||||
@media (max-width: 767px) {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.email-column {
|
||||
height: 350px;
|
||||
@media (max-width: 767px) {
|
||||
|
||||
@@ -9,89 +9,96 @@
|
||||
</div>
|
||||
<div v-else :style="background"></div>
|
||||
<div class="form-wrapper">
|
||||
<div class="container">
|
||||
<span class="form-title">{{settingStore.settings.title}}</span>
|
||||
<span class="form-desc" v-if="show === 'login'">{{$t('loginTitle')}}</span>
|
||||
<span class="form-desc" v-else>{{$t('regTitle')}}</span>
|
||||
<div v-show="show === 'login'">
|
||||
<el-input class="email-input" v-model="form.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show === 'login'"
|
||||
ref="mySelect"
|
||||
v-model="suffix"
|
||||
:placeholder="$t('select')"
|
||||
class="select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in domainList"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="color: #333">
|
||||
<span>{{ suffix }}</span>
|
||||
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
|
||||
</div>
|
||||
<div class="container">
|
||||
<span class="form-title">{{ settingStore.settings.title }}</span>
|
||||
<span class="form-desc" v-if="show === 'login'">{{ $t('loginTitle') }}</span>
|
||||
<span class="form-desc" v-else>{{ $t('regTitle') }}</span>
|
||||
<div v-show="show === 'login'">
|
||||
<el-input :class="settingStore.settings.loginDomain === 0 ? 'email-input' : ''" v-model="form.email"
|
||||
type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append v-if="settingStore.settings.loginDomain === 0">
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show === 'login'"
|
||||
ref="mySelect"
|
||||
v-model="suffix"
|
||||
:placeholder="$t('select')"
|
||||
class="select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in domainList"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="color: #333">
|
||||
<span>{{ suffix }}</span>
|
||||
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
|
||||
>{{$t('loginBtn')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="show !== 'login'">
|
||||
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
|
||||
<template #append>
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show !== 'login'"
|
||||
ref="mySelect"
|
||||
v-model="suffix"
|
||||
:placeholder="$t('select')"
|
||||
class="select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in domainList"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="color: #333">
|
||||
<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="$t('password')" type="password" autocomplete="off" />
|
||||
<el-input v-model="registerForm.confirmPassword" :placeholder="$t('confirmPwd')" type="password" autocomplete="off" />
|
||||
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" :placeholder="$t('regKey')" type="text" autocomplete="off" />
|
||||
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code" :placeholder="$t('regKeyOptional')" type="text" autocomplete="off" />
|
||||
<div v-show="verifyShow"
|
||||
class="register-turnstile"
|
||||
:data-sitekey="settingStore.settings.siteKey"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-after-interactive-callback="loadAfter"
|
||||
data-before-interactive-callback="loadBefore"
|
||||
>
|
||||
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{$t('verifyModuleFailed')}}</span>
|
||||
</div>
|
||||
<el-button class="btn" type="primary" @click="submitRegister" :loading="registerLoading"
|
||||
>{{$t('regBtn')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<template v-if="settingStore.settings.register === 0">
|
||||
<div class="switch" @click="show = 'register'" v-if="show === 'login'">{{$t('noAccount')}} <span>{{$t('regSwitch')}}</span></div>
|
||||
<div class="switch" @click="show = 'login'" v-else>{{$t('hasAccount')}} <span>{{$t('loginSwitch')}}</span></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
|
||||
</el-input>
|
||||
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
|
||||
>{{ $t('loginBtn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="show !== 'login'">
|
||||
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
|
||||
autocomplete="off">
|
||||
<template #append>
|
||||
<div @click.stop="openSelect">
|
||||
<el-select
|
||||
v-if="show !== 'login'"
|
||||
ref="mySelect"
|
||||
v-model="suffix"
|
||||
:placeholder="$t('select')"
|
||||
class="select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in domainList"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="color: #333">
|
||||
<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="$t('password')" type="password" autocomplete="off"/>
|
||||
<el-input v-model="registerForm.confirmPassword" :placeholder="$t('confirmPwd')" type="password"
|
||||
autocomplete="off"/>
|
||||
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" :placeholder="$t('regKey')"
|
||||
type="text" autocomplete="off"/>
|
||||
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code"
|
||||
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off"/>
|
||||
<div v-show="verifyShow"
|
||||
class="register-turnstile"
|
||||
:data-sitekey="settingStore.settings.siteKey"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-after-interactive-callback="loadAfter"
|
||||
data-before-interactive-callback="loadBefore"
|
||||
>
|
||||
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{ $t('verifyModuleFailed') }}</span>
|
||||
</div>
|
||||
<el-button class="btn" type="primary" @click="submitRegister" :loading="registerLoading"
|
||||
>{{ $t('regBtn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<template v-if="settingStore.settings.register === 0">
|
||||
<div class="switch" @click="show = 'register'" v-if="show === 'login'">{{ $t('noAccount') }}
|
||||
<span>{{ $t('regSwitch') }}</span></div>
|
||||
<div class="switch" @click="show = 'login'" v-else>{{ $t('hasAccount') }} <span>{{ $t('loginSwitch') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,7 +119,7 @@ import {loginUserInfo} from "@/request/my.js";
|
||||
import {permsToRouter} from "@/perm/perm.js";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
const accountStore = useAccountStore();
|
||||
const userStore = useUserStore();
|
||||
const uiStore = useUiStore();
|
||||
@@ -139,20 +146,27 @@ const verifyShow = ref(false)
|
||||
let verifyToken = ''
|
||||
let turnstileId = null
|
||||
let botJsError = ref(false)
|
||||
let verifyErrorCount = 0
|
||||
|
||||
window.onTurnstileSuccess = (token) => {
|
||||
verifyToken = token;
|
||||
};
|
||||
|
||||
window.onTurnstileError = (e) => {
|
||||
console.log('人机验加载失败')
|
||||
nextTick(() => {
|
||||
if (!turnstileId) {
|
||||
turnstileId = window.turnstile.render('.register-turnstile')
|
||||
} else {
|
||||
window.turnstile.reset(turnstileId);
|
||||
}
|
||||
})
|
||||
if (verifyErrorCount >= 4) {
|
||||
return
|
||||
}
|
||||
verifyErrorCount++
|
||||
console.warn('人机验加载失败', e)
|
||||
setTimeout(() => {
|
||||
nextTick(() => {
|
||||
if (!turnstileId) {
|
||||
turnstileId = window.turnstile.render('.register-turnstile')
|
||||
} else {
|
||||
window.turnstile.reset(turnstileId);
|
||||
}
|
||||
})
|
||||
}, 1500)
|
||||
};
|
||||
|
||||
window.loadAfter = (e) => {
|
||||
@@ -193,7 +207,9 @@ const submit = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmail(form.email + suffix.value)) {
|
||||
let email = form.email + (settingStore.settings.loginDomain === 0 ? suffix.value : '');
|
||||
|
||||
if (!isEmail(email)) {
|
||||
ElMessage({
|
||||
message: t('notEmailMsg'),
|
||||
type: 'error',
|
||||
@@ -212,7 +228,7 @@ const submit = () => {
|
||||
}
|
||||
|
||||
loginLoading.value = true
|
||||
login(form.email + suffix.value, form.password).then(async data => {
|
||||
login(email, form.password).then(async data => {
|
||||
localStorage.setItem('token', data.token)
|
||||
const user = await loginUserInfo();
|
||||
accountStore.currentAccountId = user.accountId;
|
||||
@@ -277,7 +293,7 @@ function submitRegister() {
|
||||
return
|
||||
}
|
||||
|
||||
if(settingStore.settings.regKey === 0) {
|
||||
if (settingStore.settings.regKey === 0) {
|
||||
|
||||
if (!registerForm.code) {
|
||||
|
||||
@@ -414,6 +430,7 @@ function submitRegister() {
|
||||
margin-right: 18px;
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
@@ -434,6 +451,7 @@ function submitRegister() {
|
||||
.switch {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
color: #006be6;
|
||||
cursor: pointer;
|
||||
@@ -444,7 +462,7 @@ function submitRegister() {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__wrapper){
|
||||
.email-input :deep(.el-input__wrapper) {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
@@ -452,6 +470,7 @@ function submitRegister() {
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
margin-bottom: 18px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 36px;
|
||||
}
|
||||
@@ -497,7 +516,6 @@ function submitRegister() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
#login-box {
|
||||
background: linear-gradient(to bottom, #2980b9, #6dd5fa, #fff);
|
||||
color: #333;
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
|
||||
<div class="update-pwd">
|
||||
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password"/>
|
||||
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd"/>
|
||||
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password" autocomplete="off"/>
|
||||
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd" autocomplete="off"/>
|
||||
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">{{$t('save')}}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
@@ -256,4 +256,4 @@ function submitPwd() {
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
v-model="setting.register"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{$t('loginDomain')}}</span></div>
|
||||
<div>
|
||||
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
|
||||
v-model="setting.loginDomain"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div><span>{{$t('regKey')}}</span></div>
|
||||
<div>
|
||||
@@ -326,7 +333,7 @@
|
||||
<div class="card-content">
|
||||
<div class="concerning-item">
|
||||
<span>{{$t('version')}} :</span>
|
||||
<span>v1.6.0</span>
|
||||
<span>v1.7.0</span>
|
||||
</div>
|
||||
<div class="concerning-item">
|
||||
<span>{{$t('community')}} : </span>
|
||||
@@ -345,13 +352,22 @@
|
||||
</div>
|
||||
<div class="concerning-item">
|
||||
<span>{{$t('support')}} : </span>
|
||||
<el-button @click="jump('https://support.skymail.ink')" >
|
||||
<el-button @click="jump('https://doc.skymail.ink/support.html')" >
|
||||
{{t('supportDesc')}}
|
||||
<template #icon>
|
||||
<Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" />
|
||||
</template>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="concerning-item">
|
||||
<span>{{$t('help')}} : </span>
|
||||
<el-button @click="jump('https://doc.skymail.ink')" >
|
||||
{{t('document')}}
|
||||
<template #icon>
|
||||
<Icon color="#79D6B5" icon="fluent-color:document-32" width="18" height="18" />
|
||||
</template>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1150,8 +1166,8 @@ function editSetting(settingForm, refreshStatus = true) {
|
||||
}
|
||||
|
||||
.background {
|
||||
width: 230px;
|
||||
height: 120px;
|
||||
width: 250px;
|
||||
height: 140px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
@media (max-width: 500px) {
|
||||
@@ -1178,11 +1194,7 @@ function editSetting(settingForm, refreshStatus = true) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 885px) {
|
||||
.about {
|
||||
height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
@@ -1501,4 +1513,4 @@ form .el-button {
|
||||
<style>
|
||||
.el-popper.is-dark {
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
-197
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+197
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<script type="module" crossorigin src="/assets/index-DQO7jFFS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BP2DuLPL.css">
|
||||
<script type="module" crossorigin src="/assets/index-ONNky_gH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-NqVTnf-N.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-first">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import app from '../hono/hono';
|
||||
import result from '../model/result';
|
||||
import publicService from '../service/public-service';
|
||||
|
||||
app.post('/public/genToken', async (c) => {
|
||||
const data = await publicService.genToken(c, await c.req.json());
|
||||
return c.json(result.ok(data));
|
||||
});
|
||||
|
||||
app.post('/public/emailList', async (c) => {
|
||||
const list = await publicService.emailList(c, await c.req.json());
|
||||
return c.json(result.ok(list));
|
||||
});
|
||||
|
||||
app.post('/public/addUser', async (c) => {
|
||||
await publicService.addUser(c, await c.req.json());
|
||||
return c.json(result.ok());
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
const KvConst = {
|
||||
AUTH_INFO: 'auth-uid:',
|
||||
SETTING: 'setting:',
|
||||
SEND_DAY_COUNT: 'send_day_count:'
|
||||
SEND_DAY_COUNT: 'send_day_count:',
|
||||
PUBLIC_KEY: "public_key:"
|
||||
}
|
||||
|
||||
export default KvConst;
|
||||
|
||||
@@ -33,6 +33,7 @@ export const setting = sqliteTable('setting', {
|
||||
noticeOffset: integer('notice_offset').default(0).notNull(),
|
||||
noticeWidth: integer('notice_width').default(400).notNull(),
|
||||
notice: integer('notice').default(0).notNull(),
|
||||
noRecipient: integer('no_recipient').default(1).notNull()
|
||||
noRecipient: integer('no_recipient').default(1).notNull(),
|
||||
loginDomain: integer('login_domain').default(0).notNull()
|
||||
});
|
||||
export default setting
|
||||
|
||||
@@ -17,4 +17,5 @@ import '../api/all-email-api'
|
||||
import '../api/init-api'
|
||||
import '../api/analysis-api'
|
||||
import '../api/reg-key-api'
|
||||
import '../api/public-api'
|
||||
export default app;
|
||||
|
||||
@@ -59,6 +59,9 @@ const en = {
|
||||
noDomainPermRegKey: "Registration code not valid for this domain",
|
||||
noDomainPermSend: "No permission to send from this domain email",
|
||||
JWTMismatch: 'JWT secret mismatch',
|
||||
publicTokenFail: 'Token validation failed',
|
||||
notAdmin: 'The entered email is not an administrator email',
|
||||
emailExistDatabase: 'Email already exists in the database',
|
||||
perms: {
|
||||
"邮件": "Email",
|
||||
"邮件发送": "Send Email",
|
||||
|
||||
@@ -21,7 +21,7 @@ const resources = {
|
||||
};
|
||||
|
||||
i18next.init({
|
||||
fallbackLng: 'en',
|
||||
fallbackLng: 'zh',
|
||||
resources,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const zh = {
|
||||
noRegKeyTotal: '注册码使用次数已耗尽',
|
||||
regKeyExpire: '注册码已过期',
|
||||
emailAndPwdEmpty: '邮箱和密码不能为空',
|
||||
notExistUser: '该邮箱不存在',
|
||||
notExistUser: '输入的邮箱不存在',
|
||||
isDelUser: '该邮箱已被注销',
|
||||
isBanUser: '该邮箱已被禁用',
|
||||
regKeyUseCount: '使用次数不能为空',
|
||||
@@ -59,6 +59,9 @@ const zh = {
|
||||
noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱',
|
||||
noDomainPermSend: '你没有权限使用该域名邮箱发送邮件',
|
||||
JWTMismatch: 'jwt_secret 不匹配',
|
||||
publicTokenFail: 'token验证失败',
|
||||
notAdmin: '输入的邮箱不是管理员邮箱',
|
||||
emailExistDatabase: '有邮箱已存在数据库中',
|
||||
perms: {
|
||||
"邮件": "邮件",
|
||||
"邮件发送": "邮件发送",
|
||||
|
||||
@@ -20,10 +20,15 @@ const init = {
|
||||
await this.v1_4DB(c);
|
||||
await this.v1_5DB(c);
|
||||
await this.v1_6DB(c);
|
||||
await this.v1_7DB(c);
|
||||
await settingService.refresh(c);
|
||||
return c.text(t('initSuccess'));
|
||||
},
|
||||
|
||||
async v1_7DB(c) {
|
||||
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN login_domain INTEGER NOT NULL DEFAULT 0;`).run();
|
||||
},
|
||||
|
||||
async v1_6DB(c) {
|
||||
|
||||
const noticeContent = '<div style="color: teal;margin-bottom: 5px;">欢迎使用 Cloud Mail 🎉 </div >\n' +
|
||||
|
||||
@@ -14,7 +14,8 @@ const exclude = [
|
||||
'/file',
|
||||
'/setting/websiteConfig',
|
||||
'/webhooks',
|
||||
'/init'
|
||||
'/init',
|
||||
'/public/genToken'
|
||||
];
|
||||
|
||||
const requirePerms = [
|
||||
@@ -95,6 +96,16 @@ app.use('*', async (c, next) => {
|
||||
return await next();
|
||||
}
|
||||
|
||||
if (path.startsWith('/public')) {
|
||||
|
||||
const userPublicToken = await c.env.kv.get(KvConst.PUBLIC_KEY);
|
||||
const publicToken = c.req.header(constant.TOKEN_HEADER);
|
||||
if (publicToken !== userPublicToken) {
|
||||
throw new BizError(t('publicTokenFail'), 401);
|
||||
}
|
||||
return await next();
|
||||
}
|
||||
|
||||
|
||||
const jwt = c.req.header(constant.TOKEN_HEADER);
|
||||
|
||||
|
||||
@@ -152,6 +152,10 @@ const accountService = {
|
||||
await orm(c).insert(account).values({ ...params }).returning();
|
||||
},
|
||||
|
||||
async insertList(c, list) {
|
||||
await orm(c).insert(account).values(list).run();
|
||||
},
|
||||
|
||||
async physicsDeleteAll(c) {
|
||||
const accountIdsRow = await orm(c).select({accountId: account.accountId}).from(account).where(eq(account.isDel,isDel.DELETE)).limit(99);
|
||||
if (accountIdsRow.length === 0) {
|
||||
|
||||
@@ -119,10 +119,10 @@ const loginService = {
|
||||
|
||||
const userId = await userService.insert(c, { email, regKeyId,password: hash, salt, type: type || defType });
|
||||
|
||||
await userService.updateUserInfo(c, userId, true);
|
||||
|
||||
await accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
|
||||
|
||||
await userService.updateUserInfo(c, userId, true);
|
||||
|
||||
if (regKey !== settingConst.regKey.CLOSE && type) {
|
||||
await regKeyService.reduceCount(c, code, 1);
|
||||
}
|
||||
@@ -136,6 +136,10 @@ const loginService = {
|
||||
|
||||
},
|
||||
|
||||
async registerVerify() {
|
||||
|
||||
},
|
||||
|
||||
async handleOpenRegKey(c, regKey, code) {
|
||||
|
||||
if (!code) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import BizError from '../error/biz-error';
|
||||
import orm from '../entity/orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
import saltHashUtils from '../utils/crypto-utils';
|
||||
import cryptoUtils from '../utils/crypto-utils';
|
||||
import emailUtils from '../utils/email-utils';
|
||||
import roleService from './role-service';
|
||||
import verifyUtils from '../utils/verify-utils';
|
||||
import { t } from '../i18n/i18n';
|
||||
import reqUtils from '../utils/req-utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { isDel, roleConst } from '../const/entity-const';
|
||||
import email from '../entity/email';
|
||||
import userService from './user-service';
|
||||
import KvConst from '../const/kv-const';
|
||||
|
||||
const publicService = {
|
||||
|
||||
async emailList(c, params) {
|
||||
|
||||
let { toEmail, content, subject, sendName, sendEmail, timeSort, num, size, type , isDel } = params
|
||||
|
||||
const query = orm(c).select({
|
||||
emailId: email.emailId,
|
||||
sendEmail: email.sendEmail,
|
||||
sendName: email.name,
|
||||
subject: email.subject,
|
||||
toEmail: email.toEmail,
|
||||
toName: email.toName,
|
||||
type: email.type,
|
||||
createTime: email.createTime,
|
||||
content: email.content,
|
||||
text: email.text,
|
||||
isDel: email.isDel,
|
||||
}).from(email)
|
||||
|
||||
if (!size) {
|
||||
size = 20
|
||||
}
|
||||
|
||||
if (!num) {
|
||||
num = 1
|
||||
}
|
||||
|
||||
size = Number(size);
|
||||
num = Number(num);
|
||||
|
||||
num = (num - 1) * size;
|
||||
|
||||
let conditions = []
|
||||
|
||||
if (toEmail) {
|
||||
conditions.push(sql`${email.toEmail} COLLATE NOCASE LIKE ${toEmail}`)
|
||||
}
|
||||
|
||||
if (sendEmail) {
|
||||
conditions.push(sql`${email.sendEmail} COLLATE NOCASE LIKE ${sendEmail}`)
|
||||
}
|
||||
|
||||
if (sendName) {
|
||||
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${sendName}`)
|
||||
}
|
||||
|
||||
if (subject) {
|
||||
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${subject}`)
|
||||
}
|
||||
|
||||
if (content) {
|
||||
conditions.push(sql`${email.content} COLLATE NOCASE LIKE ${content}`)
|
||||
}
|
||||
|
||||
if (type || type === 0) {
|
||||
conditions.push(eq(email.type, type))
|
||||
}
|
||||
|
||||
if (isDel || isDel === 0) {
|
||||
conditions.push(eq(email.isDel, isDel))
|
||||
}
|
||||
|
||||
if (conditions.length === 1) {
|
||||
query.where(...conditions)
|
||||
} else if (conditions.length > 1) {
|
||||
query.where(and(...conditions))
|
||||
}
|
||||
|
||||
if (timeSort === 'asc') {
|
||||
query.orderBy(asc(email.emailId));
|
||||
} else {
|
||||
query.orderBy(desc(email.emailId));
|
||||
}
|
||||
|
||||
return query.limit(size).offset(num);
|
||||
|
||||
},
|
||||
|
||||
async addUser(c, params) {
|
||||
const { list } = params;
|
||||
|
||||
if (list.length === 0) return;
|
||||
|
||||
for (const emailRow of list) {
|
||||
if (!verifyUtils.isEmail(emailRow.email)) {
|
||||
throw new BizError(t('notEmail'));
|
||||
}
|
||||
|
||||
if (!c.env.domain.includes(emailUtils.getDomain(emailRow.email))) {
|
||||
throw new BizError(t('notEmailDomain'));
|
||||
}
|
||||
|
||||
const { salt, hash } = await saltHashUtils.hashPassword(
|
||||
emailRow.password || cryptoUtils.genRandomPwd()
|
||||
);
|
||||
|
||||
emailRow.salt = salt;
|
||||
emailRow.hash = hash;
|
||||
}
|
||||
|
||||
|
||||
const activeIp = reqUtils.getIp(c);
|
||||
const { os, browser, device } = reqUtils.getUserAgent(c);
|
||||
const activeTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const roleList = await roleService.roleSelectUse(c);
|
||||
const defRole = roleList.find(roleRow => roleRow.isDefault === roleConst.isDefault.OPEN);
|
||||
|
||||
const userList = [];
|
||||
|
||||
for (const emailRow of list) {
|
||||
let { email, hash, salt, roleName } = emailRow;
|
||||
let type = defRole.roleId;
|
||||
|
||||
if (roleName) {
|
||||
const roleRow = roleList.find(role => role.name === roleName);
|
||||
type = roleRow ? roleRow.roleId : type;
|
||||
}
|
||||
|
||||
const userSql = `INSERT INTO user (email, password, salt, type, os, browser, active_ip, create_ip, device, active_time, create_time)
|
||||
VALUES ('${email}', '${hash}', '${salt}', '${type}', '${os}', '${browser}', '${activeIp}', '${activeIp}', '${device}', '${activeTime}', '${activeTime}')`
|
||||
|
||||
const accountSql = `INSERT INTO account (email, name, user_id)
|
||||
VALUES ('${email}', '${emailUtils.getName(email)}', 0);`;
|
||||
|
||||
userList.push(c.env.db.prepare(userSql));
|
||||
userList.push(c.env.db.prepare(accountSql));
|
||||
|
||||
}
|
||||
|
||||
userList.push(c.env.db.prepare(`UPDATE account SET user_id = (SELECT user_id FROM user WHERE user.email = account.email) WHERE user_id = 0;`))
|
||||
|
||||
try {
|
||||
await c.env.db.batch(userList);
|
||||
} catch (e) {
|
||||
if(e.message.includes('SQLITE_CONSTRAINT')) {
|
||||
throw new BizError(t('emailExistDatabase'))
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
async genToken(c, params) {
|
||||
|
||||
await this.verifyUser(c, params)
|
||||
|
||||
const uuid = uuidv4();
|
||||
|
||||
await c.env.kv.put(KvConst.PUBLIC_KEY, uuid, { expirationTtl: 60 * 60 * 24 * 7 });
|
||||
|
||||
return {token: uuid}
|
||||
},
|
||||
|
||||
async verifyUser(c, params) {
|
||||
|
||||
const { email, password } = params
|
||||
|
||||
const userRow = await userService.selectByEmailIncludeDel(c, email);
|
||||
|
||||
if (email !== c.env.admin) {
|
||||
throw new BizError(t('notAdmin'));
|
||||
}
|
||||
|
||||
if (!userRow || userRow.isDel === isDel.DELETE) {
|
||||
throw new BizError(t('notExistUser'));
|
||||
}
|
||||
|
||||
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password)) {
|
||||
throw new BizError(t('IncorrectPwd'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default publicService
|
||||
@@ -116,7 +116,7 @@ const roleService = {
|
||||
},
|
||||
|
||||
roleSelectUse(c) {
|
||||
return orm(c).select({ name: role.name, roleId: role.roleId }).from(role).orderBy(asc(role.sort)).all();
|
||||
return orm(c).select({ name: role.name, roleId: role.roleId, isDefault: role.isDefault }).from(role).orderBy(asc(role.sort)).all();
|
||||
},
|
||||
|
||||
async selectDefaultRole(c) {
|
||||
@@ -170,6 +170,10 @@ const roleService = {
|
||||
})
|
||||
|
||||
return availIndex > -1
|
||||
},
|
||||
|
||||
selectByName(c, roleName) {
|
||||
return orm(c).select().from(role).where(eq(role.name, roleName)).get();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ const settingService = {
|
||||
noticeWidth: settingRow.noticeWidth,
|
||||
noticeOffset: settingRow.noticeOffset,
|
||||
notice: settingRow.notice,
|
||||
loginDomain: settingRow.loginDomain
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import kvConst from '../const/kv-const';
|
||||
import KvConst from '../const/kv-const';
|
||||
import cryptoUtils from '../utils/crypto-utils';
|
||||
import emailService from './email-service';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import dayjs from 'dayjs';
|
||||
import permService from './perm-service';
|
||||
import roleService from './role-service';
|
||||
@@ -16,6 +15,7 @@ import emailUtils from '../utils/email-utils';
|
||||
import saltHashUtils from '../utils/crypto-utils';
|
||||
import constant from '../const/constant';
|
||||
import { t } from '../i18n/i18n'
|
||||
import reqUtils from '../utils/req-utils';
|
||||
|
||||
const userService = {
|
||||
|
||||
@@ -216,49 +216,22 @@ const userService = {
|
||||
|
||||
async updateUserInfo(c, userId, recordCreateIp = false) {
|
||||
|
||||
const ua = c.req.header('user-agent') || '';
|
||||
console.log(ua);
|
||||
const parser = new UAParser(ua);
|
||||
const { browser, device, os } = parser.getResult();
|
||||
|
||||
let browserInfo = null;
|
||||
let osInfo = null;
|
||||
|
||||
if (browser.name) {
|
||||
browserInfo = browser.name + ' ' + browser.version;
|
||||
}
|
||||
const activeIp = reqUtils.getIp(c);
|
||||
|
||||
if (os.name) {
|
||||
osInfo = os.name + os.version;
|
||||
}
|
||||
|
||||
let deviceInfo = 'Desktop';
|
||||
|
||||
const hasVendor = !!device?.vendor;
|
||||
const hasModel = !!device?.model;
|
||||
|
||||
if (hasVendor || hasModel) {
|
||||
const vendor = device.vendor || '';
|
||||
const model = device.model || '';
|
||||
const type = device.type || '';
|
||||
|
||||
const namePart = [vendor, model].filter(Boolean).join(' ');
|
||||
const typePart = type ? ` (${type})` : '';
|
||||
deviceInfo = (namePart + typePart).trim();
|
||||
}
|
||||
|
||||
const userIp = c.req.header('cf-connecting-ip') || '';
|
||||
const {os, browser, device} = reqUtils.getUserAgent(c);
|
||||
|
||||
const params = {
|
||||
os: osInfo,
|
||||
browser: browserInfo,
|
||||
device: deviceInfo,
|
||||
activeIp: userIp,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
activeIp,
|
||||
activeTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
};
|
||||
|
||||
if (recordCreateIp) {
|
||||
params.createIp = userIp;
|
||||
params.createIp = activeIp;
|
||||
}
|
||||
|
||||
await orm(c)
|
||||
|
||||
@@ -2,13 +2,13 @@ import orm from '../entity/orm';
|
||||
import verifyRecord from '../entity/verify-record';
|
||||
import { eq, sql, and } from 'drizzle-orm';
|
||||
import dayjs from 'dayjs';
|
||||
import ipUtils from '../utils/ip-utils';
|
||||
import reqUtils from '../utils/req-utils';
|
||||
import { verifyRecordType } from '../const/entity-const';
|
||||
|
||||
const verifyRecordService = {
|
||||
|
||||
async selectListByIP(c) {
|
||||
const ip = ipUtils.getIp(c)
|
||||
const ip = reqUtils.getIp(c)
|
||||
return orm(c).select().from(verifyRecord).where(eq(verifyRecord.ip, ip)).all();
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ const verifyRecordService = {
|
||||
|
||||
async isOpenRegVerify(c, regVerifyCount) {
|
||||
|
||||
const ip = ipUtils.getIp(c)
|
||||
const ip = reqUtils.getIp(c)
|
||||
|
||||
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get();
|
||||
|
||||
@@ -35,7 +35,7 @@ const verifyRecordService = {
|
||||
|
||||
async isOpenAddVerify(c, addVerifyCount) {
|
||||
|
||||
const ip = ipUtils.getIp(c)
|
||||
const ip = reqUtils.getIp(c)
|
||||
|
||||
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get();
|
||||
|
||||
@@ -53,7 +53,7 @@ const verifyRecordService = {
|
||||
|
||||
async increaseRegCount(c) {
|
||||
|
||||
const ip = ipUtils.getIp(c)
|
||||
const ip = reqUtils.getIp(c)
|
||||
|
||||
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get();
|
||||
const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
@@ -70,7 +70,7 @@ const verifyRecordService = {
|
||||
|
||||
async increaseAddCount(c) {
|
||||
|
||||
const ip = ipUtils.getIp(c)
|
||||
const ip = reqUtils.getIp(c)
|
||||
|
||||
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get();
|
||||
const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
@@ -25,6 +25,15 @@ const saltHashUtils = {
|
||||
async verifyPassword(inputPassword, salt, storedHash) {
|
||||
const hash = await this.genHashPassword(inputPassword, salt);
|
||||
return hash === storedHash;
|
||||
},
|
||||
|
||||
genRandomPwd(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
const ipUtils = {
|
||||
getIp(c) {
|
||||
return c.req.header('CF-Connecting-IP') ||
|
||||
c.req.header('X-Forwarded-For') ||
|
||||
'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export default ipUtils
|
||||
@@ -0,0 +1,45 @@
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
const reqUtils = {
|
||||
getIp(c) {
|
||||
return c.req.header('CF-Connecting-IP') ||
|
||||
c.req.header('X-Forwarded-For') ||
|
||||
'Unknown';
|
||||
},
|
||||
|
||||
getUserAgent(c) {
|
||||
const ua = c.req.header('user-agent') || '';
|
||||
|
||||
const parser = new UAParser(ua);
|
||||
const { browser, device, os } = parser.getResult();
|
||||
|
||||
let browserInfo = null;
|
||||
let osInfo = null;
|
||||
|
||||
if (browser.name) {
|
||||
browserInfo = browser.name + ' ' + browser.version;
|
||||
}
|
||||
|
||||
if (os.name) {
|
||||
osInfo = os.name + os.version;
|
||||
}
|
||||
|
||||
let deviceInfo = 'Desktop';
|
||||
|
||||
const hasVendor = !!device?.vendor;
|
||||
const hasModel = !!device?.model;
|
||||
|
||||
if (hasVendor || hasModel) {
|
||||
const vendor = device.vendor || '';
|
||||
const model = device.model || '';
|
||||
const type = device.type || '';
|
||||
|
||||
const namePart = [vendor, model].filter(Boolean).join(' ');
|
||||
const typePart = type ? ` (${type})` : '';
|
||||
deviceInfo = (namePart + typePart).trim();
|
||||
}
|
||||
|
||||
return {browser: browserInfo || '', device: deviceInfo || '', os: osInfo || ''}
|
||||
}
|
||||
}
|
||||
|
||||
export default reqUtils
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "cloud-mail"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-07-29"
|
||||
compatibility_date = "2025-06-04"
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "cloud-mail-dev"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-07-29"
|
||||
compatibility_date = "2025-06-04"
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "cloud-mail-test"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-07-29"
|
||||
compatibility_date = "2025-06-04"
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "cloud-mail"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2025-07-29"
|
||||
compatibility_date = "2025-06-04"
|
||||
keep_vars = true
|
||||
|
||||
[observability]
|
||||
|
||||
Reference in New Issue
Block a user