feat: message notify

This commit is contained in:
meilin.huang
2025-04-15 21:42:31 +08:00
parent 3c0292b56e
commit 1b40d345eb
104 changed files with 2681 additions and 288 deletions

View File

@@ -28,7 +28,7 @@
## 前言
web **linux(终端[终端回放、命令过滤] 文件 脚本 进程 计划任务)。数据库mysql postgres oracle sqlserver 达梦 高斯 sqlite数据操作、数据同步数据迁移。redis(单机 哨兵 集群)。mongo 等集工单流程审批于一体的统一管理操作平台。**
Web版 **统一管理操作平台**集成了对Linux系统的全面操作支持包括终端管理[终端回放、命令过滤]文件管理、脚本执行、进程监控及计划任务设置),同时提供了多种数据库(如 MySQL、PostgreSQL、Oracle、SQL Server达梦高斯、SQLite 等)的数据操作、数据同步数据迁移功能。此外,还支持 Redis单机哨兵集群模式)以及 MongoDB 的操作管理,并结合工单流程审批功能,为企业提供一站式的运维与管理解决方案。
## 开发语言与主要框架
@@ -107,3 +107,10 @@ http://go.mayfly.run
## 💌 支持作者
如果觉得项目不错,或者已经在使用了,希望你可以去 <a target="_blank" href="https://github.com/dromara/mayfly-go">Github</a> 或者 <a target="_blank" href="https://gitee.com/dromara/mayfly-go">Gitee</a> 帮我点个 ⭐ Star这将是对我极大的鼓励与支持。
> 喝杯咖啡 ☕️ 或者来杯奶茶 🧋,让作者更有精神,写出更棒的代码!这将是对作者最大的鼓励和支持!
<img class="no-margin" src="https://foruda.gitee.com/images/1744113367791412282/36a3c23b_1240250.png" alt="微信打赏" width="200" height="200">
> **特别感谢:**
> 赞助金额达 199 元以上加微信wx-error我将邀请您进入付费交流群享受更快、更优先的技术支持

View File

@@ -28,7 +28,7 @@
## Preface
Browser-based management platform. **linux(Terminal [terminal playback, command filtering], file, script, process, cronjob), database (mysql, postgres, oracle, sqlserver, Dameng, gauss, sqlite) data operation, data synchronization, data migration, redis(standlone, sentinel, cluster), mongo and other unified management and operation platforms that integrate work order process approval.**
Web-based **Unified Management and Operation Platform**, integrating comprehensive operation support for Linux systems (including terminal management [terminal playback, command filtering], file management, script execution, process monitoring, and cronjob settings). It also provides data operation, data synchronization, and data migration for multiple databases (such as MySQL, PostgreSQL, Oracle, SQL Server, Dameng, Gauss, SQLite, etc.). Additionally, it supports Redis operations (standalone, sentinel, and cluster modes) and MongoDB management, combined with work order process approval functionality to offer enterprises an all-in-one solution for operations and management.
## Development languages and major frameworks

View File

@@ -14,18 +14,18 @@ services:
restart: always
server:
image: ccr.ccs.tencentyun.com/mayfly/mayfly-go:v1.8.5
image: ccr.ccs.tencentyun.com/mayfly/mayfly-go:latest
build:
context: .
dockerfile: Dockerfile
container_name: mayfly-go-server
ports:
- "8888:8888"
- "18888:18888"
environment:
TZ: Asia/Shanghai
WAIT_HOSTS: mysql:3306
volumes:
- ./server/config.yml.example:/mayfly/config.yml
- ./server/config.yml:/mayfly/config.yml
depends_on:
- mysql
restart: always

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^12.8.2",
"@vueuse/core": "^13.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
@@ -22,48 +22,46 @@
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "^2.9.5",
"element-plus": "^2.9.7",
"js-base64": "^3.7.7",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"monaco-sql-languages": "^0.12.2",
"monaco-themes": "^0.4.4",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.2",
"qrcode.vue": "^3.6.0",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.6",
"splitpanes": "^3.1.8",
"splitpanes": "^4.0.3",
"sql-formatter": "^15.4.10",
"trzsz": "^1.1.5",
"uuid": "^9.0.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-i18n": "^11.1.3",
"vue-router": "^4.5.0",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.178",
"@types/node": "^18.14.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/compiler-sfc": "^3.5.13",
"code-inspector-plugin": "^0.4.5",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.31.0",
"eslint-plugin-vue": "^10.0.0",
"prettier": "^3.2.5",
"sass": "^1.85.1",
"sass": "^1.86.3",
"typescript": "^5.8.2",
"vite": "^6.2.1",
"vite": "^6.2.6",
"vite-plugin-progress": "0.0.7",
"vue-eslint-parser": "^9.4.3"
"vue-eslint-parser": "^10.1.3"
},
"browserslist": [
"> 1%",

View File

@@ -15,7 +15,7 @@ const config = {
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.9.3',
version: 'v1.9.4',
};
export default config;

View File

@@ -23,7 +23,7 @@ export const Rules = {
},
accountUsername: {
pattern: /^[a-zA-Z0-9_]{5,16}$/g,
pattern: /^[a-zA-Z0-9_.@:-]{5,16}$/,
message: i18n.global.t('system.account.usernamePatternErrMsg'),
trigger: 'blur',
},

View File

@@ -0,0 +1,7 @@
<template>
<FormDesign ref="makingForm" upload preview generate-code generate-json clearable> </FormDesign>
<!-- <dev></dev> -->
</template>
<script lang="ts" setup>
// import { FormDesign } from 'mayfly-lc';
</script>

View File

@@ -7,7 +7,7 @@
<script lang="ts" setup>
const props = defineProps({
enums: {
type: Object, // 需要为EnumValue类型
type: Object || Array, // 需要为EnumValue类型
required: true,
},
});

View File

@@ -38,7 +38,7 @@ export class TableColumn {
/**
* 插槽名,
*/
slotName: string = '';
private slotName: string = '';
showOverflowTooltip: boolean = true;
@@ -73,12 +73,12 @@ export class TableColumn {
/**
* 是否显示该列
*/
show: boolean = true;
private show: boolean = true;
/**
* 是否展示美化按钮主要用于美化json文本等
*/
isBeautify: boolean = false;
private isBeautify: boolean = false;
constructor(prop: string, label: string) {
this.prop = prop;

View File

@@ -108,5 +108,7 @@ const setIconSvgInsStyle = computed(() => {
align-items: center;
cursor: pointer;
vertical-align: middle;
height: 100%; /* 确保高度与父元素一致 */
line-height: 1; /* 确保行高与高度一致 */
}
</style>

View File

@@ -73,8 +73,7 @@ import { TerminalExpose } from '@/components/terminal-rdp/index';
import SvgIcon from '@/components/svgIcon/index.vue';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import { exitFullscreen, launchIntoFullscreen, unWatchFullscreenChange, watchFullscreenChange } from '@/components/terminal-rdp/guac/screen';
import { useEventListener } from '@vueuse/core';
import { debounce } from 'lodash';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import { ClientState, TunnelState } from '@/components/terminal-rdp/guac/states';
import { ElMessage } from 'element-plus';
import { joinClientParams } from '@/common/request';
@@ -191,7 +190,7 @@ const installClipboard = () => {
const installResize = () => {
// 在resize事件结束后300毫秒执行
useEventListener('resize', debounce(resize, 300));
useEventListener('resize', useDebounceFn(resize, 300));
};
const installDisplay = () => {

View File

@@ -17,9 +17,8 @@ import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import TerminalSearch from './TerminalSearch.vue';
import { debounce } from 'lodash';
import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import themes from './themes';
import { TrzszFilter } from 'trzsz';
import { useI18n } from 'vue-i18n';
@@ -129,7 +128,7 @@ async function initTerm() {
term.loadAddon(fitAddon);
fitTerminal();
// 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
useEventListener('resize', useDebounceFn(fitTerminal, 400));
initSocket();
// 注册其他插件

View File

@@ -17,6 +17,8 @@ export default {
remark: 'Remark',
status: 'Status',
username: 'Username',
mobile: 'Mobile',
email: 'Email',
role: 'Role',
msg: 'Message',
type: 'Type',
@@ -281,6 +283,17 @@ export default {
flowProcDefSave: 'Save Process Define',
flowProcDefDelete: 'Delete Process Define',
msgManage: 'Message',
channel: 'Message Channel',
msgChannelBase: 'Base Permission',
saveMsgChannel: 'Save Message Channel',
delMsgChannel: 'Delete Message Channel',
msgTmpl: 'Message Template',
msgTmplBase: 'Base Permission',
saveMsgTmpl: 'Save Message Template',
delMsgTmpl: 'Delete Message Template',
sendMsg: 'Send Message',
system: 'System',
menuPermission: 'Menu & Permission',
menuPermissionBase: 'Base Permission',

View File

@@ -89,5 +89,6 @@ export default {
taskName: 'Task Name',
taskBeginTime: 'Begin Time',
flowAudit: 'Approval Process',
notify: 'Notification',
},
};

View File

@@ -0,0 +1,21 @@
export default {
msg: {
name: 'Name',
email: 'Email',
dingBot: 'DingTalk Bot',
qywxBot: 'WeChat Work Bot',
feishuBot: 'Feishu Bot',
msgChannel: 'Message Channel',
smtpAccount: 'SMTP Account',
smtpPassword: 'SMTP Password',
msgTmpl: 'Message Template',
relateChannel: 'Related Channel',
title: 'Title',
tmpl: 'Template',
send: 'Send',
sendMsg: 'Send Message',
selectTmplPlaceholder: 'Select message template, support fuzzy search by number',
msgTmplTooltip:
'Message template supports variable replacement, the variable format is {{.variable}}, the following are common built-in variables <br/>{{.receiver}}: used to @ specify the recipient',
},
};

View File

@@ -48,7 +48,7 @@ export default {
lastLoginTime: 'Last Login Time',
usernamePlacholder: '5-16 uppercase letters, numbers, -.: characters',
random: 'Random',
usernamePatternErrMsg: 'Only 5-16 uppercase letters, numbers, and -.: characters are allowed',
usernamePatternErrMsg: 'Only 5-16 uppercase letters, numbers, and -@.: characters are allowed',
accountSearchPlaceholder: 'Enter account fuzzy search and select',
accountInfo: 'Account Information',
allocateRoleTitle: 'Allocate the `{name}` role',

View File

@@ -17,6 +17,8 @@ export default {
remark: '备注',
status: '状态',
username: '用户名',
mobile: '手机号',
email: '邮箱',
role: '角色',
msg: '消息',
type: '类型',
@@ -291,6 +293,17 @@ export default {
flowProcDefSave: '保存流程定义',
flowProcDefDelete: '删除流程定义',
msgManage: '消息管理',
channel: '消息渠道',
msgChannelBase: '基础权限',
saveMsgChannel: '保存消息渠道',
delMsgChannel: '删除消息渠道',
msgTmpl: '消息模板',
msgTmplBase: '基础权限',
saveMsgTmpl: '保存消息模板',
delMsgTmpl: '删除消息模板',
sendMsg: '发送消息',
system: '系统管理',
menuPermission: '菜单权限',
menuPermissionBase: '基本权限',

View File

@@ -36,7 +36,7 @@ export default {
startProcess: '发起流程',
cancelProcessConfirm: '确认取消该流程?',
bizType: '业务类型',
bizKey: '业务Key',
bizKey: '业务编号',
initiator: '发起人',
procdefName: '流程名',
bizStatus: '业务状态',
@@ -89,5 +89,6 @@ export default {
taskName: '当前节点',
taskBeginTime: '开始时间',
flowAudit: '流程审批',
notify: '通知',
},
};

View File

@@ -0,0 +1,20 @@
export default {
msg: {
name: '名称',
email: '邮箱',
dingBot: '钉钉机器人',
qywxBot: '企微机器人',
feishuBot: '飞书机器人',
msgChannel: '消息渠道',
smtpAccount: 'SMTP账号',
smtpPassword: 'SMTP密码',
msgTmpl: '消息模板',
relateChannel: '关联渠道',
title: '标题',
tmpl: '模板',
send: '发送',
sendMsg: '发送消息',
selectTmplPlaceholder: '选择消息模板,支持编号模糊搜索',
msgTmplTooltip: '消息模板支持变量替换,变量格式为{{.变量名}},以下为通用内置变量 <br/>{{.receiver}}:用于@指定接收人',
},
};

View File

@@ -25,7 +25,7 @@ export default {
success: '成功',
menuCodeTips: `菜单类型则为访问路径(若菜单路径不以'/'开头则访问地址会自动拼接父菜单路径)、否则为资源唯一编码`,
menuCodePlaceholder: `菜单不以'/'开头则自动拼接父菜单路径`,
routerNameTips: '与vue的组件名一致才可使组件缓存生效如ResourceLis',
routerNameTips: '与vue的组件名一致才可使组件缓存生效如ResourceList',
componentPathTips: '访问的组件路径,如:`system/resource/ResourceList`,默认在`views`目录下',
isCacheTips: '选择是则会被`keep-alive`缓存(重新进入页面不会刷新页面及重新请求数据)需要路由名与vue的组件名一致',
isHideTips: '选择隐藏则路由将不会出现在菜单栏中,但仍然可以访问。禁用则不可访问与操作',
@@ -47,7 +47,7 @@ export default {
deleteAccountConfirm: '确定删除【{name}】的账号?',
usernamePlacholder: '5-16位大小写字母、数字、_-.:',
random: '随机',
usernamePatternErrMsg: '只允许输入5-16位大小写字母、数字、_-.:',
usernamePatternErrMsg: '只允许输入5-16位大小写字母、数字、_-@.:',
accountSearchPlaceholder: '输入账号模糊搜索并选择',
accountInfo: '账号信息',
allocateRoleTitle: '分配 `{name}` 的角色',
@@ -60,6 +60,9 @@ export default {
userMenuTitle: '`{name}` 的菜单&权限',
statusEnable: '启用',
statusDisable: '禁用',
qywxUserId: '企微UserId',
dingUserId: '钉钉UserId',
feishuUserId: '飞书UserId',
},
role: {
permissionDetail: '权限详情',

View File

@@ -12,7 +12,7 @@ export const URL_404: string = '/404';
export const LAYOUT_ROUTE_NAME: string = 'layout';
// 路由白名单地址(本地存在的路由 staticRouter.ts 中)
export const ROUTER_WHITE_LIST: string[] = [URL_404, URL_401, '/oauth2/callback'];
export const ROUTER_WHITE_LIST: string[] = [URL_404, URL_401, '/oauth2/callback', '/form-design'];
// 静态路由
export const staticRoutes: Array<RouteRecordRaw> = [
@@ -65,6 +65,17 @@ export const staticRoutes: Array<RouteRecordRaw> = [
titleRename: true,
},
},
{
path: '/form-design',
name: 'formDesign',
component: () => import('@/components/df/design.vue'),
meta: {
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
title: 'terminal | {name}',
// 是否根据query对标题名进行参数替换即最终显示为终端_机器名
titleRename: true,
},
},
];
// 错误页面路由

View File

@@ -4,121 +4,95 @@
.slide-right-leave-active,
.slide-left-enter-active,
.slide-left-leave-active {
will-change: transform;
transition: all 0.3s ease;
will-change: transform;
transition: all 0.3s ease;
}
// slide-right
.slide-right-enter-from {
opacity: 0;
transform: translateX(-20px);
opacity: 0;
transform: translateX(-20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
opacity: 0;
transform: translateX(20px);
}
// slide-left
.slide-left-enter-from {
@extend .slide-right-leave-to;
@extend .slide-right-leave-to;
}
.slide-left-leave-to {
@extend .slide-right-enter-from;
@extend .slide-right-enter-from;
}
// opacitys
.opacitys-enter-active,
.opacitys-leave-active {
will-change: transform;
transition: all 0.3s ease;
will-change: transform;
transition: all 0.3s ease;
}
.opacitys-enter-from,
.opacitys-leave-to {
opacity: 0;
opacity: 0;
}
/* Breadcrumb 面包屑过渡动画
------------------------------- */
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.3s;
transition: all 0.3s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-active {
position: absolute;
position: absolute;
}
/* logo 过渡动画
------------------------------- */
@keyframes logoAnimation {
0% {
transform: scale(0);
}
80% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
0% {
transform: scale(0);
}
80% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
/* 404、401 过渡动画
------------------------------- */
@keyframes error-num {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes error-img {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
/* 登录页动画
------------------------------- */
@keyframes loginLeft {
0% {
left: -100%;
}
50%,
100% {
left: 100%;
}
}
@keyframes loginTop {
0% {
top: -100%;
}
50%,
100% {
top: 100%;
}
}
@keyframes loginRight {
0% {
right: -100%;
}
50%,
100% {
right: 100%;
}
}
@keyframes loginBottom {
0% {
bottom: -100%;
}
50%,
100% {
bottom: 100%;
}
@keyframes error-img {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -31,6 +31,10 @@
<el-input v-model.trim="form.remark" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="msgTmplId" :label="$t('flow.notify')">
<MsgTmplSelect v-model="form.msgTmplId" clearable />
</el-form-item>
<el-form-item ref="tagSelectRef" prop="codePaths" :label="$t('tag.relateTag')">
<tag-tree-check height="300px" v-model="form.codePaths" :tag-type="[TagResourceTypePath.Db, TagResourceTypeEnum.Redis.value]" />
</el-form-item>
@@ -95,6 +99,7 @@ import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { useI18n } from 'vue-i18n';
import FormItemTooltip from '@/components/form/FormItemTooltip.vue';
import { Rules } from '@/common/rule';
import MsgTmplSelect from '../msg/components/MsgTmplSelect.vue';
const { t } = useI18n();
@@ -129,6 +134,7 @@ const state = reactive({
status: null,
condition: '',
remark: null,
msgTmplId: null,
// 流程的审批节点任务
tasks: '',
codePaths: [],
@@ -140,9 +146,9 @@ const { form, tasks } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveFlowDefExec } = procdefApi.save.useApi(form);
watch(props, (newValue: any) => {
watch(props, async (newValue: any) => {
if (newValue.data) {
state.form = { ...newValue.data };
state.form = await procdefApi.detail.request({ id: newValue.data.id });
state.form.codePaths = newValue.data.tags?.map((tag: any) => tag.codePath);
const tasks = JSON.parse(state.form.tasks);
tasks.forEach((t: any) => {

View File

@@ -47,6 +47,7 @@ const { t } = useI18n();
const searchItems = [
SearchItem.select('status', 'common.status').withEnum(ProcinstTaskStatus),
SearchItem.input('bizKey', 'flow.bizKey'),
SearchItem.select('bizType', 'flow.bizType').withEnum(FlowBizType),
];
const columns = [

View File

@@ -2,6 +2,7 @@ import Api from '@/common/Api';
export const procdefApi = {
list: Api.newGet('/flow/procdefs'),
detail: Api.newGet('/flow/procdefs/detail/{id}'),
getByResource: Api.newGet('/flow/procdefs/{resourceType}/{resourceCode}'),
save: Api.newPost('/flow/procdefs'),
del: Api.newDelete('/flow/procdefs/{id}'),

View File

@@ -238,8 +238,6 @@ const oauth2Login = () => {
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, transparent, var(--el-color-primary));
animation: loginLeft 3s linear infinite;
}
&::after {
@@ -248,9 +246,6 @@ const oauth2Login = () => {
right: 2px;
width: 3px;
height: 100%;
background: linear-gradient(180deg, transparent, var(--el-color-primary));
animation: loginTop 3s linear infinite;
animation-delay: 0.7s;
}
}
@@ -261,9 +256,6 @@ const oauth2Login = () => {
right: -100%;
width: 100%;
height: 3px;
background: linear-gradient(270deg, transparent, var(--el-color-primary));
animation: loginRight 3s linear infinite;
animation-delay: 1.4s;
}
&::after {
@@ -272,9 +264,6 @@ const oauth2Login = () => {
left: 0px;
width: 3px;
height: 100%;
background: linear-gradient(360deg, transparent, var(--el-color-primary));
animation: loginBottom 3s linear infinite;
animation-delay: 2.1s;
}
}

View File

@@ -0,0 +1,15 @@
import Api from '@/common/Api';
export const channelApi = {
list: Api.newGet('/msg/channels'),
save: Api.newPost('/msg/channels'),
del: Api.newDelete('/msg/channels'),
};
export const tmplApi = {
list: Api.newGet('/msg/tmpls'),
relateChannels: Api.newGet('/msg/tmpls/{id}/channels'),
save: Api.newPost('/msg/tmpls'),
del: Api.newDelete('/msg/tmpls'),
sendMsg: Api.newPost('/msg/tmpls/{code}/send'),
};

View File

@@ -0,0 +1,12 @@
<template>
<el-form-item prop="extra.secret" :rules="[Rules.requiredInput('secret')]" :label="$t('Secret')">
<el-input v-model.trim="extra.secret" auto-complete="off" clearable></el-input>
</el-form-item>
</template>
<script lang="ts" setup>
import { Rules } from '@/common/rule';
const extra = defineModel<any>('extra', { default: {} });
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,138 @@
<template>
<div>
<el-drawer :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="formData" ref="formRef" :rules="rules" label-position="top" label-width="auto">
<el-form-item prop="name" :label="$t('msg.name')">
<el-input v-model.trim="formData.name" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="status" :label="$t('common.status')">
<EnumSelect :enums="ChannelStatusEnum" v-model="formData.status" />
</el-form-item>
<el-form-item prop="remark" :label="$t('common.remark')">
<el-input v-model.trim="formData.remark" auto-complete="off" type="textarea" clearable></el-input>
</el-form-item>
<el-form-item prop="type" :label="$t('common.type')">
<EnumSelect
:enums="ChannelTypeEnum"
v-model="formData.type"
@change="
() => {
formData.extra = {};
}
"
/>
</el-form-item>
<el-form-item prop="url" label="URL">
<el-input v-model.trim="formData.url" auto-complete="off" clearable></el-input>
</el-form-item>
<component v-if="channelTypeComp" :is="channelTypeComp" v-model:extra="formData.extra" />
</el-form>
<template #footer>
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watchEffect, useTemplateRef, shallowReactive, computed } from 'vue';
import { channelApi } from '../api';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { Rules } from '@/common/rule';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import EnumSelect from '@/components/enumselect/EnumSelect.vue';
import { ChannelStatusEnum, ChannelTypeEnum } from '../enums';
import EnumValue from '@/common/Enum';
import ChannelEmail from './ChannelEmail.vue';
import ChannelDing from './ChannelDing.vue';
const props = defineProps({
form: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
const channels: any = shallowReactive({
ChannelEmail,
ChannelDing,
});
const channelTypeComp = computed(() => {
return channels[EnumValue.getEnumByValue(ChannelTypeEnum, state.form.type)?.extra?.component];
});
//定义事件
const emit = defineEmits(['cancel', 'success']);
const visible = defineModel<boolean>('visible', { default: false });
const formRef: any = useTemplateRef('formRef');
const rules = {
name: [Rules.requiredInput('msg.name')],
type: [Rules.requiredSelect('common.type')],
url: [Rules.requiredInput('URL')],
};
const defaultForm = () => {
return {
id: null,
name: null,
type: null,
url: '',
status: ChannelStatusEnum.Enable.value,
remark: '',
extra: {},
};
};
const state = reactive({
edit: false,
form: defaultForm(),
});
const { form: formData } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveFormExec } = channelApi.save.useApi(formData);
watchEffect(() => {
const form: any = props.form;
if (form) {
state.form = { ...form };
state.edit = true;
} else {
state.edit = false;
state.form = defaultForm();
}
});
const btnOk = async () => {
await useI18nFormValidate(formRef);
await saveFormExec();
useI18nSaveSuccessMsg();
emit('success', state.form);
//重置表单域
formRef.value.resetFields();
cancel();
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,16 @@
<template>
<el-form-item prop="extra.smtpAccount" :rules="[Rules.requiredInput('msg.smtpAccount')]" :label="$t('msg.smtpAccount')">
<el-input v-model.trim="extra.smtpAccount" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="extra.smtpPassword" :rules="[Rules.requiredInput('msg.smtpPassword')]" :label="$t('msg.smtpPassword')">
<el-input v-model.trim="extra.smtpPassword" auto-complete="off" clearable></el-input>
</el-form-item>
</template>
<script lang="ts" setup>
import { Rules } from '@/common/rule';
const extra = defineModel<any>('extra', { default: {} });
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,113 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="channelApi.list"
:search-items="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
>
<template #tableHeader>
<el-button v-auth="perms.saveChannel" type="primary" icon="plus" @click="editChannel(false)">{{ $t('common.create') }}</el-button>
<el-button v-auth="perms.delChannel" :disabled="state.selectionData.length < 1" @click="deleteChannel()" type="danger" icon="delete">
{{ $t('common.delete') }}
</el-button>
</template>
<template #action="{ data }">
<el-button link v-auth="perms.saveChannel" @click="editChannel(data)" type="primary">{{ $t('common.edit') }}</el-button>
</template>
</page-table>
<ChannelEdit v-model:visible="editDialog.visible" :form="editDialog.data" :title="editDialog.title" @success="search" />
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, Ref } from 'vue';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import { useI18nCreateTitle, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nEditTitle } from '@/hooks/useI18n';
import { channelApi } from '../api';
import { ChannelStatusEnum, ChannelTypeEnum } from '../enums';
import ChannelEdit from './ChannelEdit.vue';
const perms = {
saveChannel: 'msg:channel:save',
delChannel: 'msg:channel:del',
};
const searchItems = [SearchItem.input('name', 'msg.name')];
const columns = [
TableColumn.new('code', 'common.code'),
TableColumn.new('name', 'msg.name'),
TableColumn.new('status', 'common.status').typeTag(ChannelStatusEnum),
TableColumn.new('type', 'common.type').typeTag(ChannelTypeEnum).setAddWidth(15),
TableColumn.new('url', 'URL'),
TableColumn.new('remark', 'common.remark'),
TableColumn.new('creator', 'common.creator'),
TableColumn.new('createTime', 'common.createTime').isTime(),
];
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveChannel, perms.delChannel]);
const actionColumn = TableColumn.new('action', 'common.operation').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: '',
code: '',
type: '',
pageNum: 1,
pageSize: 0,
},
editDialog: {
title: '',
visible: false,
data: null as any,
},
});
const { selectionData, query, editDialog } = toRefs(state);
onMounted(() => {
if (Object.keys(actionBtns).length > 0) {
columns.push(actionColumn);
}
});
const search = async () => {
pageTableRef.value.search();
};
const editChannel = (data: any) => {
if (!data) {
state.editDialog.title = useI18nCreateTitle('msg.msgChannel');
state.editDialog.data = null;
} else {
state.editDialog.title = useI18nEditTitle('msg.msgChannel');
state.editDialog.data = data;
}
state.editDialog.visible = true;
};
const deleteChannel = async () => {
await useI18nDeleteConfirm(state.selectionData.map((x: any) => x.code).join('、'));
await channelApi.del.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
useI18nDeleteSuccessMsg();
search();
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,47 @@
<template>
<el-select
remote
:remote-method="getMsgTmpls"
v-model="tmplId"
filterable
:placeholder="$t('msg.selectTmplPlaceholder')"
v-bind="$attrs"
:ref="(el: any) => props.focus && el?.focus()"
>
<el-option v-for="item in tmpls" :key="item.id" :label="item.name" :value="item.id">
{{ item.code }}
<el-divider direction="vertical" />
{{ item.name }}
</el-option>
</el-select>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { tmplApi } from '../api';
const props = defineProps({
// 是否获取焦点
focus: {
type: Boolean,
default: false,
},
});
const tmplId = defineModel('modelValue');
const tmpls: any = ref([]);
onMounted(() => {
// 如果初始化时有tmplId则需要获取对应消息模板信息用于回显
tmplApi.list.request({ id: tmplId.value }).then((res) => {
tmpls.value = res.list;
});
});
const getMsgTmpls = (code: any) => {
tmplApi.list.request({ code }).then((res) => {
tmpls.value = res.list;
});
};
</script>

View File

@@ -0,0 +1,24 @@
import { EnumValue } from '@/common/Enum';
export const ChannelStatusEnum = {
Enable: EnumValue.of(1, 'common.enable').tagTypeSuccess(),
Disable: EnumValue.of(-1, 'common.disable').tagTypeDanger(),
};
export const TmplStatusEnum = {
Enable: EnumValue.of(1, 'common.enable').tagTypeSuccess(),
Disable: EnumValue.of(-1, 'common.disable').tagTypeDanger(),
};
export const TmplTypeEnum = {
Text: EnumValue.of(1, 'text'),
Markdown: EnumValue.of(2, 'markdown'),
Html: EnumValue.of(3, 'html'),
};
export const ChannelTypeEnum = {
Email: EnumValue.of('email', 'msg.email').setExtra({ component: 'ChannelEmail', msgTypes: [TmplTypeEnum.Text, TmplTypeEnum.Markdown, TmplTypeEnum.Html] }),
DingBot: EnumValue.of('dingBot', 'msg.dingBot').setExtra({ component: 'ChannelDing', msgTypes: [TmplTypeEnum.Text, TmplTypeEnum.Markdown] }),
QywxBot: EnumValue.of('qywxBot', 'msg.qywxBot').setExtra({ msgTypes: [TmplTypeEnum.Text.value, TmplTypeEnum.Markdown] }),
FeishuBot: EnumValue.of('feishuBot', 'msg.feishuBot').setExtra({ component: 'ChannelDing', msgTypes: [TmplTypeEnum.Text] }),
};

View File

@@ -0,0 +1,152 @@
<template>
<div>
<el-drawer :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="formData" ref="formRef" :rules="rules" label-position="top" label-width="auto">
<el-form-item prop="name" :label="$t('msg.name')">
<el-input v-model.trim="formData.name" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="status" :label="$t('common.status')">
<EnumSelect :enums="ChannelStatusEnum" v-model="formData.status" />
</el-form-item>
<el-form-item prop="remark" :label="$t('common.remark')">
<el-input v-model.trim="formData.remark" auto-complete="off" type="textarea" clearable></el-input>
</el-form-item>
<el-form-item prop="channelIds" :label="$t('msg.msgChannel')">
<el-select v-model="formData.channelIds" multiple clearable filterable>
<el-option v-for="item in state.channels" :key="item.id" :label="item.name" :value="item.id">
{{ $t(EnumValue.getLabelByValue(ChannelTypeEnum, item.type)) }}
<el-divider direction="vertical" />
{{ item.code }}
<el-divider direction="vertical" />
{{ item.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="msgType" :label="$t('common.type')">
<EnumSelect :enums="TmplTypeEnum" v-model="formData.msgType" />
</el-form-item>
<el-form-item prop="title" :label="$t('msg.title')">
<el-input v-model.trim="formData.title" auto-complete="off" clearable></el-input>
</el-form-item>
<FormItemTooltip prop="tmpl" :label="$t('msg.tmpl')" :tooltip="$t('msg.msgTmplTooltip')">
<MonacoEditor
class="w100"
height="200px"
v-model="formData.tmpl"
:language="EnumValue.getLabelByValue(TmplTypeEnum, formData.msgType)"
></MonacoEditor>
</FormItemTooltip>
</el-form>
<template #footer>
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { reactive, watchEffect, useTemplateRef, toRefs } from 'vue';
import { channelApi, tmplApi } from '../api';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { Rules } from '@/common/rule';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import EnumSelect from '@/components/enumselect/EnumSelect.vue';
import { ChannelStatusEnum, TmplStatusEnum, TmplTypeEnum, ChannelTypeEnum } from '../enums';
import EnumValue from '@/common/Enum';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import FormItemTooltip from '@/components/form/FormItemTooltip.vue';
const props = defineProps({
form: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
//定义事件
const emit = defineEmits(['cancel', 'success']);
const visible = defineModel<boolean>('visible', { default: false });
const formRef: any = useTemplateRef('formRef');
const rules = {
name: [Rules.requiredInput('msg.name')],
type: [Rules.requiredSelect('common.type')],
tmpl: [Rules.requiredInput('msg.tmpl')],
};
const defaultForm = () => {
return {
id: null,
name: null,
msgType: TmplTypeEnum.Text.value,
title: '',
tmpl: '',
status: TmplStatusEnum.Enable.value,
remark: '',
channelIds: [],
extra: {},
};
};
const state = reactive({
edit: false,
form: defaultForm(),
channels: [] as any,
});
const { form: formData } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveFormExec } = tmplApi.save.useApi(formData);
watchEffect(() => {
if (visible.value) {
channelApi.list.request({ pageNum: 1, pageSize: 200 }).then((res) => {
state.channels = res?.list;
});
}
const form: any = props.form;
if (form) {
state.form = { ...form };
tmplApi.relateChannels.request({ id: form.id }).then((res) => {
state.form.channelIds = res.map((item: any) => item.id);
});
state.edit = true;
} else {
state.edit = false;
state.form = defaultForm();
}
});
const btnOk = async () => {
await useI18nFormValidate(formRef);
await saveFormExec();
useI18nSaveSuccessMsg();
emit('success', state.form);
//重置表单域
formRef.value.resetFields();
cancel();
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,180 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="tmplApi.list"
:search-items="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
>
<template #tableHeader>
<el-button v-auth="perms.saveTmpl" type="primary" icon="plus" @click="editTmpl(false)">{{ $t('common.create') }}</el-button>
<el-button v-auth="perms.delTmpl" :disabled="state.selectionData.length < 1" @click="deleteTmpl()" type="danger" icon="delete">
{{ $t('common.delete') }}
</el-button>
</template>
<template #relateChannel="{ data }">
<el-popover placement="top-start" trigger="click" width="auto">
<template #reference>
<el-link @click="getRelateChannels(data.id)" icon="view" type="primary" :underline="false"></el-link>
</template>
<el-row v-for="item in state.relateChannels" :key="item.id">
{{ $t(EnumValue.getLabelByValue(ChannelTypeEnum, item.type)) }}
<el-divider direction="vertical" />
{{ item.code }}
<el-divider direction="vertical" />
{{ item.name }}
</el-row>
</el-popover>
</template>
<template #action="{ data }">
<el-button link v-auth="perms.saveTmpl" @click="editTmpl(data)" type="primary">{{ $t('common.edit') }}</el-button>
<el-button link v-auth="perms.sendMsg" @click="showSendMsgDialog(data)" type="warning">{{ $t('msg.send') }}</el-button>
</template>
</page-table>
<TmplEdit v-model:visible="editDialog.visible" :form="editDialog.data" :title="editDialog.title" @success="search" />
<el-dialog width="500px" :title="$t('msg.sendMsg')" v-model="sendMsgDialog.visible">
<el-form label-width="auto">
<el-form-item prop="params" :label="$t('params')">
<el-input v-model.trim="sendMsgDialog.params" type="textarea" rows="5" placeholder="JSON" clearable></el-input>
</el-form-item>
<AccountSelectFormItem multiple v-model="sendMsgDialog.receiverIds" />
</el-form>
<template #footer>
<el-button @click="() => (sendMsgDialog.visible = false)">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="sendMsg()">{{ $t('msg.send') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, Ref } from 'vue';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import { useI18nCreateTitle, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nEditTitle, useI18nOperateSuccessMsg } from '@/hooks/useI18n';
import { tmplApi } from '../api';
import { TmplStatusEnum, TmplTypeEnum, ChannelTypeEnum } from '../enums';
import TmplEdit from './TmplEdit.vue';
import EnumValue from '../../../common/Enum';
import AccountSelectFormItem from '@/views/system/account/components/AccountSelectFormItem.vue';
const perms = {
saveTmpl: 'msg:tmpl:save',
delTmpl: 'msg:tmpl:del',
sendMsg: 'msg:tmpl:send',
};
const searchItems = [SearchItem.input('code', 'common.code')];
const columns = [
TableColumn.new('code', 'common.code'),
TableColumn.new('name', 'msg.name'),
TableColumn.new('status', 'common.status').typeTag(TmplStatusEnum),
TableColumn.new('msgType', 'common.type').typeTag(TmplTypeEnum).setAddWidth(20),
TableColumn.new('tmpl', 'msg.tmpl').canBeautify(),
TableColumn.new('relateChannel', 'msg.relateChannel').isSlot().alignCenter(),
TableColumn.new('remark', 'common.remark'),
TableColumn.new('creator', 'common.creator'),
TableColumn.new('createTime', 'common.createTime').isTime(),
];
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveTmpl, perms.delTmpl]);
const actionColumn = TableColumn.new('action', 'common.operation').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: '',
code: '',
type: '',
pageNum: 1,
pageSize: 0,
},
relateChannelsVisible: false,
relateChannels: [] as any,
editDialog: {
title: '',
visible: false,
data: null as any,
},
sendMsgDialog: {
tmpl: null,
title: '',
visible: false,
params: '',
receiverIds: [],
},
});
const { selectionData, query, editDialog, sendMsgDialog } = toRefs(state);
onMounted(() => {
if (Object.keys(actionBtns).length > 0) {
columns.push(actionColumn);
}
});
const search = async () => {
pageTableRef.value.search();
};
const getRelateChannels = async (id: number) => {
state.relateChannels = [];
state.relateChannels = await tmplApi.relateChannels.request({ id });
};
const editTmpl = (data: any) => {
if (!data) {
state.editDialog.title = useI18nCreateTitle('msg.msgTmpl');
state.editDialog.data = null;
} else {
state.editDialog.title = useI18nEditTitle('msg.msgTmpl');
state.editDialog.data = data;
}
state.editDialog.visible = true;
};
const deleteTmpl = async () => {
await useI18nDeleteConfirm(state.selectionData.map((x: any) => x.code).join('、'));
await tmplApi.del.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
useI18nDeleteSuccessMsg();
search();
};
const showSendMsgDialog = (tmpl: any) => {
state.sendMsgDialog.tmpl = tmpl;
state.sendMsgDialog.params = '';
state.sendMsgDialog.receiverIds = [];
state.sendMsgDialog.visible = true;
};
const sendMsg = async () => {
const tmpl: any = state.sendMsgDialog.tmpl;
await tmplApi.sendMsg.request({
code: tmpl.code,
params: state.sendMsgDialog.params,
receiverIds: state.sendMsgDialog.receiverIds,
});
useI18nOperateSuccessMsg();
state.sendMsgDialog.visible = false;
};
</script>
<style lang="scss"></style>

View File

@@ -21,7 +21,7 @@ const { width } = useWindowSize();
console.log(width);
const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 25));
const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 24));
// 处理 resize 事件
const handleResize = (event: any) => {

View File

@@ -157,11 +157,11 @@ import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import CrontabInput from '@/components/crontab/CrontabInput.vue';
import { getDbDialect, getDbDialectMap } from '@/views/ops/db/dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
import _ from 'lodash';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { useI18n } from 'vue-i18n';
import { Rules } from '@/common/rule';
import { deepClone } from '@/common/utils/object';
const { t } = useI18n();
@@ -274,7 +274,8 @@ watch(dialogVisible, async (newValue: boolean) => {
});
return;
}
state.form = _.cloneDeep(props.data) as FormData;
state.form = deepClone(props.data) as FormData;
let { srcDbId, targetDbId } = state.form;
// 初始化src数据源

View File

@@ -464,7 +464,7 @@ export class DbInst {
* @returns
*/
static isNumber(columnType: string) {
return columnType && columnType.match(/(int|uint|double|float|number|decimal|byte|bit)/gi);
return columnType && columnType.match(/(int|uint|double|float|number|numeric|decimal|byte|bit)/gi);
}
/**

View File

@@ -102,9 +102,9 @@ import { TagResourceTypeEnum } from '@/common/commonEnum';
import { cmdConfApi } from '../api';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import TagCodePath from '../../component/TagCodePath.vue';
import _ from 'lodash';
import { useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { Rules } from '@/common/rule';
import { deepClone } from '@/common/utils/object';
const rules = {
tags: [Rules.requiredInput('machine.relateMachine')],
@@ -166,7 +166,7 @@ const openFormDialog = (data: any) => {
if (!data) {
state.form = { ...DefaultForm };
} else {
state.form = _.cloneDeep(data);
state.form = deepClone(data);
state.form.codePaths = data.tags?.map((tag: any) => tag.codePath);
}
state.dialogVisible = true;

View File

@@ -44,12 +44,12 @@
<template #right>
<Splitpanes class="default-theme">
<Pane size="40" max-size="45">
<Pane size="35" max-size="50">
<div class="key-list-vtree card pd5">
<el-scrollbar>
<el-row>
<el-row :gutter="5">
<el-col :span="2">
<el-input v-model="state.keySeparator" :placeholder="$t('redis.delimiter')" size="small" class="ml5" />
<el-input v-model="state.keySeparator" :placeholder="$t('redis.delimiter')" size="small" />
</el-col>
<el-col :span="18">
<el-input
@@ -59,12 +59,10 @@
:placeholder="$t('redis.keyMatchTips')"
clearable
size="small"
class="ml10"
/>
</el-col>
<el-col :span="4">
<el-button
class="ml15"
:disabled="!scanParam.id || !scanParam.db"
@click="searchKey()"
type="success"
@@ -75,18 +73,11 @@
</el-col>
</el-row>
<el-row class="mb5 mt5">
<el-row :gutter="5" class="mb5 mt5">
<el-col :span="19">
<el-button
class="ml5"
:disabled="!scanParam.id || !scanParam.db"
@click="scan(true)"
type="success"
icon="more"
size="small"
plain
>{{ $t('redis.loadMore') }}</el-button
>
<el-button :disabled="!scanParam.id || !scanParam.db" @click="scan(true)" type="success" icon="more" size="small" plain>
{{ $t('redis.loadMore') }}
</el-button>
<el-button
v-auth="'redis:data:save'"
@@ -96,8 +87,10 @@
icon="plus"
size="small"
plain
>{{ $t('redis.addKey') }}</el-button
class="ml5"
>
{{ $t('redis.addKey') }}
</el-button>
<el-button
:disabled="!scanParam.id || !scanParam.db"
@@ -107,11 +100,13 @@
v-auth="'redis:data:del'"
size="small"
icon="delete"
>flush</el-button
class="ml5"
>
flush
</el-button>
</el-col>
<el-col :span="5">
<span style="display: inline-block" class="mt5">keys:{{ state.dbsize }}</span>
<span class="mt5" style="display: inline-block">keys:{{ state.dbsize }}</span>
</el-col>
</el-row>
@@ -148,7 +143,7 @@
</div>
</Pane>
<Pane min-size="40">
<Pane>
<div class="key-detail card pd5">
<el-tabs @tab-remove="removeDataTab" v-model="state.activeName">
<el-tab-pane closable v-for="dt in state.dataTabs" :key="dt.key" :label="dt.label" :name="dt.key">

View File

@@ -187,17 +187,17 @@ const ttlConveter = (ttl: any) => {
}
.key-header-item.key-name-input {
width: calc(100% - 332px);
min-width: 220px;
width: calc(100% - 322px);
min-width: 230px;
max-width: 800px;
margin-right: 10px;
margin-bottom: 10px;
margin-right: 5px;
margin-bottom: 5px;
}
.key-header-item.key-ttl-input {
width: 200px;
margin-right: 10px;
margin-bottom: 10px;
width: 190px;
margin-right: 5px;
margin-bottom: 5px;
}
/*hide number input button*/

View File

@@ -1,6 +1,6 @@
<template>
<div class="account-dialog">
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :show-close="false" width="600px" :destroy-on-close="true">
<el-dialog :title="title" v-model="visible" :before-close="cancel" :show-close="false" width="600px" :destroy-on-close="true">
<el-form :model="form" ref="accountForm" :rules="rules" label-width="auto">
<el-form-item prop="name" :label="$t('system.account.name')">
<el-input v-model.trim="form.name" auto-complete="off" clearable></el-input>
@@ -16,6 +16,14 @@
></el-input>
</el-form-item>
<el-form-item prop="mobile" :label="$t('common.mobile')">
<el-input v-model.trim="form.mobile" clearable></el-input>
</el-form-item>
<el-form-item prop="email" :label="$t('common.email')">
<el-input v-model.trim="form.email" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item :required="!edit" prop="password" :label="$t('common.password')">
<el-input type="password" v-model.trim="form.password" autocomplete="new-password" show-password>
<template #append>
@@ -31,29 +39,31 @@
</template>
</el-input>
</el-form-item>
<el-form-item :label="$t('system.account.qywxUserId')">
<el-input v-model.trim="form.extra.qywxUserId" clearable></el-input>
</el-form-item>
<el-form-item :label="$t('system.account.feishuUserId')">
<el-input v-model.trim="form.extra.feishuUserId" clearable></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</div>
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, ref, watchEffect } from 'vue';
import { toRefs, reactive, watch, ref } from 'vue';
import { accountApi } from '../api';
import { randomPassword } from '@/common/utils/string';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { Rules } from '@/common/rule';
const props = defineProps({
visible: {
type: Boolean,
},
account: {
type: [Boolean, Object],
},
@@ -65,6 +75,8 @@ const props = defineProps({
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const visible = defineModel<boolean>('visible', { default: false });
const accountForm: any = ref(null);
const rules = {
@@ -73,43 +85,42 @@ const rules = {
password: [Rules.requiredInput('common.password')],
};
const state = reactive({
dialogVisible: false,
edit: false,
form: {
const defaultForm = () => {
return {
id: null,
name: null,
username: null,
mobile: null,
email: null,
password: '',
repassword: null,
},
extra: {
qywxUserId: '',
feishuUserId: '',
},
};
};
const state = reactive({
edit: false,
form: defaultForm(),
});
const { dialogVisible, edit, form } = toRefs(state);
const { edit, form } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveAccountExec } = accountApi.save.useApi(form);
watch(props, (newValue: any) => {
if (newValue.account) {
state.form = { ...newValue.account };
if (!state.form.extra) {
state.form.extra = {} as any;
}
state.edit = true;
} else {
state.edit = false;
state.form = {} as any;
state.form = defaultForm();
}
state.dialogVisible = newValue.visible;
});
watchEffect(() => {
const account: any = props.account;
if (account) {
state.form = { ...account };
state.edit = true;
} else {
state.edit = false;
state.form = {} as any;
}
state.dialogVisible = props.visible;
});
const btnOk = async () => {
@@ -119,11 +130,10 @@ const btnOk = async () => {
emit('val-change', state.form);
//重置表单域
accountForm.value.resetFields();
state.form = {} as any;
};
const cancel = () => {
emit('update:visible', false);
visible.value = false;
emit('cancel');
};
</script>

View File

@@ -84,6 +84,8 @@ const searchItems = [SearchItem.input('username', 'common.username')];
const columns = [
TableColumn.new('name', 'system.account.name'),
TableColumn.new('username', 'common.username'),
TableColumn.new('mobile', 'common.mobile'),
TableColumn.new('email', 'common.email'),
TableColumn.new('status', 'common.status').typeTag(AccountStatusEnum),
TableColumn.new('lastLoginTime', 'system.account.lastLoginTime').isTime(),
TableColumn.new('creator', 'common.creator'),

View File

@@ -117,7 +117,7 @@
</template>
</el-table-column>
<el-table-column prop="assigner" :label="$t('system.role.assigner')"></el-table-column>
<el-table-column prop="allocateTime" :label="$t('system.role.allocateTime')">
<el-table-column prop="allocateTime" :label="$t('system.role.allocateTime')" min-width="150">
<template #default="scope">
{{ formatDate(scope.row.allocateTime) }}
</template>

View File

@@ -3,7 +3,7 @@ module mayfly-go
go 1.24
require (
gitee.com/chunanyong/dm v1.8.18
gitee.com/chunanyong/dm v1.8.19
gitee.com/liuzongyang/libpq v1.10.11
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/emirpasic/gods v1.18.1
@@ -14,8 +14,8 @@ require (
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.25.0
github.com/go-sql-driver/mysql v1.9.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/go-sql-driver/mysql v1.9.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20241220152942-06eb5c6e8230
@@ -23,18 +23,18 @@ require (
github.com/microsoft/go-mssqldb v1.8.0
github.com/mojocn/base64Captcha v1.3.8 //
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.7
github.com/pkg/sftp v1.13.9
github.com/pquerna/otp v1.4.0
github.com/redis/go-redis/v9 v9.7.1
github.com/redis/go-redis/v9 v9.7.3
github.com/robfig/cron/v3 v3.0.1 //
github.com/sijms/go-ora/v2 v2.8.24
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.18.0
github.com/veops/go-ansiterm v0.0.5
go.mongodb.org/mongo-driver v1.16.0 // mongo
golang.org/x/crypto v0.36.0 // ssh
golang.org/x/oauth2 v0.26.0
golang.org/x/sync v0.12.0
golang.org/x/crypto v0.37.0 // ssh
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.13.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
// gorm
@@ -93,8 +93,8 @@ require (
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect

View File

@@ -16,12 +16,12 @@ var (
initFuncs = make([]InitFunc, 0)
)
// 添加初始化ioc函数由各个默认自行添加(直接init方法中ioc.Register注册不会打印ioc相关日志)
// 添加初始化ioc函数由各个模块自行添加(直接init方法中ioc.Register注册不会打印ioc相关日志)
func AddInitIocFunc(initIocFunc InitIocFunc) {
initIocFuncs = append(initIocFuncs, initIocFunc)
}
// 添加初始化函数,由各个默认自行添加
// 添加初始化函数,由各个模块自行添加
func AddInitFunc(initFunc InitFunc) {
initFuncs = append(initFuncs, initFunc)
}

View File

@@ -30,4 +30,5 @@ const (
ImsgNumDb = 60000
ImsgNumRedis = 70000
ImsgNumMongo = 80000
ImsgNumMsg = 90000
)

View File

@@ -226,9 +226,10 @@ var (
SQLValue: SQLValueNumeric,
}
// 使用string进行转换避免长度过长导致精度丢失等
DTNumeric = &DataType{
Name: "numeric",
Valuer: ValuerFloat64,
Valuer: ValuerString,
SQLValue: SQLValueNumeric,
}

View File

@@ -97,6 +97,7 @@ func (c *commonTypeConverter) Blob(col *dbi.Column) *dbi.DbDataType {
return Blob
}
func (c *commonTypeConverter) Longblob(col *dbi.Column) *dbi.DbDataType {
col.CharMaxLength = 0
return Longblob
}

View File

@@ -1,6 +1,6 @@
package event
const (
EventTopicDeleteMachine = "machine:delete" // 删除机器的事件主题
EventTopicResourceOp = "resource:op" // 资源操作主题
EventTopicResourceOp = "resource:op" // 资源操作主题
EventTopicBizMsgTmplSend = "biz:msgtmpl:send" // 发送业务关联的消息模板
)

View File

@@ -10,6 +10,7 @@ type Procdef struct {
Status entity.ProcdefStatus `json:"status" binding:"required"`
Condition string `json:"condition"`
Remark string `json:"remark"`
MsgTmplId uint64 `json:"msgTmplId"`
CodePaths []string `json:"codePaths"`
}

View File

@@ -7,11 +7,14 @@ import (
"mayfly-go/internal/flow/application/dto"
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/imsg"
msgapp "mayfly-go/internal/msg/application"
msgentity "mayfly-go/internal/msg/domain/entity"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/structx"
"strings"
"github.com/may-fly/cast"
@@ -20,12 +23,15 @@ import (
type Procdef struct {
procdefApp application.Procdef `inject:"T"`
tagTreeRelateApp tagapp.TagTreeRelate `inject:"T"`
msgTmplBizApp msgapp.MsgTmplBiz `inject:"T"`
}
func (p *Procdef) ReqConfs() *req.Confs {
reqs := [...]*req.Conf{
req.NewGet("", p.GetProcdefPage),
req.NewGet("/detail/:id", p.GetProcdefDetail),
req.NewGet("/:resourceType/:resourceCode", p.GetProcdef),
req.NewPost("", p.Save).Log(req.NewLogSaveI(imsg.LogProcdefSave)).RequiredPermissionCode("flow:procdef:save"),
@@ -49,6 +55,25 @@ func (p *Procdef) GetProcdefPage(rc *req.Ctx) {
rc.ResData = res
}
func (p *Procdef) GetProcdefDetail(rc *req.Ctx) {
def, err := p.procdefApp.GetById(cast.ToUint64(rc.PathParamInt("id")))
biz.ErrIsNil(err)
res := new(vo.Procdef)
biz.ErrIsNil(structx.Copy(res, def))
p.tagTreeRelateApp.FillTagInfo(tagentity.TagRelateTypeFlowDef, res)
bizMsgTmpl := &msgentity.MsgTmplBiz{
BizId: res.Id,
BizType: application.FlowTaskNotifyBizKey,
}
if p.msgTmplBizApp.GetByCond(bizMsgTmpl) == nil {
res.MsgTmplId = &bizMsgTmpl.TmplId
}
rc.ResData = res
}
func (p *Procdef) GetProcdef(rc *req.Ctx) {
resourceType := rc.PathParamInt("resourceType")
resourceCode := rc.PathParam("resourceCode")
@@ -61,6 +86,7 @@ func (a *Procdef) Save(rc *req.Ctx) {
rc.ReqParam = form
biz.ErrIsNil(a.procdefApp.SaveProcdef(rc.MetaCtx, &dto.SaveProcdef{
Procdef: procdef,
MsgTmplId: form.MsgTmplId,
CodePaths: form.CodePaths,
}))
}

View File

@@ -8,6 +8,8 @@ import (
type Procdef struct {
tagentity.RelateTags // 标签信息
entity.Procdef
MsgTmplId *uint64 `json:"msgTmplId" gorm:"-"` // 消息模板ID
}
func (p *Procdef) GetRelateId() uint64 {

View File

@@ -0,0 +1,5 @@
package application
const (
FlowTaskNotifyBizKey = "flow:task:notify" // 工单任务处理通知
)

View File

@@ -4,6 +4,7 @@ import "mayfly-go/internal/flow/domain/entity"
type SaveProcdef struct {
Procdef *entity.Procdef
MsgTmplId uint64 // 消息模板id
CodePaths []string
}

View File

@@ -6,6 +6,8 @@ import (
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/internal/flow/imsg"
msgapp "mayfly-go/internal/msg/application"
msgdto "mayfly-go/internal/msg/application/dto"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
@@ -36,6 +38,7 @@ type procdefAppImpl struct {
procinstApp Procinst `inject:"T"`
msgTmplBizApp msgapp.MsgTmplBiz `inject:"T"`
tagTreeApp tagapp.TagTree `inject:"T"`
tagTreeRelateApp tagapp.TagTreeRelate `inject:"T"`
}
@@ -67,6 +70,14 @@ func (p *procdefAppImpl) SaveProcdef(ctx context.Context, defParam *dto.SaveProc
return p.Tx(ctx, func(ctx context.Context) error {
return p.Save(ctx, def)
}, func(ctx context.Context) error {
// 保存通知消息模板
if err := p.msgTmplBizApp.SaveBizTmpl(ctx, msgdto.MsgTmplBizSave{
TmplId: defParam.MsgTmplId,
BizType: FlowTaskNotifyBizKey,
BizId: def.Id,
}); err != nil {
return err
}
return p.tagTreeRelateApp.RelateTag(ctx, tagentity.TagRelateTypeFlowDef, def.Id, defParam.CodePaths...)
})
}

View File

@@ -3,19 +3,24 @@ package application
import (
"context"
"fmt"
"mayfly-go/internal/event"
"mayfly-go/internal/flow/application/dto"
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/internal/flow/imsg"
msgdto "mayfly-go/internal/msg/application/dto"
"mayfly-go/pkg/base"
"mayfly-go/pkg/contextx"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/global"
"mayfly-go/pkg/i18n"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/anyx"
"mayfly-go/pkg/utils/jsonx"
"mayfly-go/pkg/utils/stringx"
"github.com/may-fly/cast"
)
type Procinst interface {
@@ -140,6 +145,7 @@ func (p *procinstAppImpl) CompleteTask(ctx context.Context, instTaskId uint64, r
procinst.SetEnd()
} else {
procinst.TaskKey = task.TaskKey
}
return p.Tx(ctx, func(ctx context.Context) error {
@@ -287,7 +293,26 @@ func (p *procinstAppImpl) createProcinstTask(ctx context.Context, procinst *enti
TaskName: task.Name,
Assignee: task.UserId,
}
return p.procinstTaskRepo.Insert(ctx, procinstTask)
if err := p.procinstTaskRepo.Insert(ctx, procinstTask); err != nil {
return err
}
// 发送通知消息
global.EventBus.Publish(ctx, event.EventTopicBizMsgTmplSend, msgdto.BizMsgTmplSend{
BizType: FlowTaskNotifyBizKey,
BizId: procinst.ProcdefId,
Params: map[string]any{
"creator": procinst.Creator,
"procdefName": procinst.ProcdefName,
"bizKey": procinst.BizKey,
"taskName": task.Name,
"procinstRemark": procinst.Remark,
},
ReceiverIds: []uint64{cast.ToUint64(task.UserId)},
})
return nil
}
// 获取下一审批节点任务

View File

@@ -15,6 +15,7 @@ type ProcinstTaskQuery struct {
ProcinstId uint64 `json:"procinstId"` // 流程实例id
ProcinstName string `json:"procinstName"` // 流程实例名称
BizType string `json:"bizType" form:"bizType"`
BizKey string `json:"bizKey" form:"bizKey"` // 业务key
Assignee string `json:"assignee"` // 分配到该任务的用户
Status ProcinstTaskStatus `json:"status" form:"status"` // 状态
}

View File

@@ -4,6 +4,7 @@ import (
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/gormx"
"mayfly-go/pkg/model"
)
@@ -31,6 +32,14 @@ func newProcinstTaskRepo() repository.ProcinstTask {
}
func (p *procinstTaskImpl) GetPageList(condition *entity.ProcinstTaskQuery, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
qd := model.NewModelCond(condition)
return p.PageByCondToAny(qd, pageParam, toEntity)
qd := gormx.NewQueryWithTableName("t_flow_procinst_task t").
Joins("JOIN t_flow_procinst tp ON t.procinst_id = tp.id ").
WithCond(model.NewCond().Columns("t.*, tp.biz_key").
Eq("tp.biz_key", condition.BizKey).
Eq0("tp.is_deleted", model.ModelUndeleted).
Eq("tp.biz_type", condition.BizType).
Eq0("t.is_deleted", model.ModelUndeleted).
Eq("t.status", condition.Status).
OrderByDesc("t.id"))
return gormx.PageQuery(qd, pageParam, toEntity)
}

View File

@@ -71,7 +71,9 @@ func (m *MachineScript) RunMachineScript(rc *req.Ctx) {
script := ms.Script
// 如果有脚本参数,则用脚本参数替换脚本中的模板占位符参数
if params := rc.Query("params"); params != "" {
script, err = stringx.TemplateParse(ms.Script, jsonx.ToMap(params))
p, err := jsonx.ToMap(params)
biz.ErrIsNil(err)
script, err = stringx.TemplateParse(ms.Script, p)
biz.ErrIsNilAppendErr(err, "failed to parse the script template parameter: %s")
}
cli, err := m.machineApp.GetCliByAc(ac)

View File

@@ -1,11 +1,6 @@
package application
import (
"context"
"mayfly-go/internal/event"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/pkg/eventbus"
"mayfly-go/pkg/global"
"mayfly-go/pkg/ioc"
"sync"
)
@@ -26,16 +21,6 @@ func Init() {
GetMachineApp().TimerUpdateStats()
GetMachineTermOpApp().TimerDeleteTermOp()
global.EventBus.Subscribe(event.EventTopicDeleteMachine, "machineFile", func(ctx context.Context, event *eventbus.Event) error {
me := event.Val.(*entity.Machine)
return GetMachineFileApp().DeleteByCond(ctx, &entity.MachineFile{MachineId: me.Id})
})
global.EventBus.Subscribe(event.EventTopicDeleteMachine, "machineScript", func(ctx context.Context, event *eventbus.Event) error {
me := event.Val.(*entity.Machine)
return GetMachineScriptApp().DeleteByCond(ctx, &entity.MachineScript{MachineId: me.Id})
})
})()
}

View File

@@ -3,7 +3,6 @@ package application
import (
"context"
"fmt"
"mayfly-go/internal/event"
"mayfly-go/internal/machine/application/dto"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/domain/repository"
@@ -15,7 +14,6 @@ import (
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/global"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/scheduler"
@@ -65,6 +63,9 @@ type machineAppImpl struct {
tagApp tagapp.TagTree `inject:"T"`
resourceAuthCertApp tagapp.ResourceAuthCert `inject:"T"`
machineScriptApp MachineScript `inject:"T"`
machineFileApp MachineFile `inject:"T"`
}
var _ (Machine) = (*machineAppImpl)(nil)
@@ -198,12 +199,15 @@ func (m *machineAppImpl) Delete(ctx context.Context, id uint64) error {
// 关闭连接
mcm.DeleteCli(id)
// 发布机器删除事件
global.EventBus.Publish(ctx, event.EventTopicDeleteMachine, machine)
resourceType := tagentity.TagTypeMachine
return m.Tx(ctx,
func(ctx context.Context) error {
if err := m.machineFileApp.DeleteByCond(ctx, &entity.MachineFile{MachineId: id}); err != nil {
return err
}
if err := m.machineScriptApp.DeleteByCond(ctx, &entity.MachineScript{MachineId: id}); err != nil {
return err
}
return m.DeleteById(ctx, id)
}, func(ctx context.Context) error {
return m.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{

View File

@@ -4,4 +4,6 @@ import "mayfly-go/pkg/ioc"
func InitIoc() {
ioc.Register(new(Msg))
ioc.Register(new(MsgChannel))
ioc.Register(new(MsgTmpl))
}

View File

@@ -0,0 +1,35 @@
package form
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgChannel struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Url string `json:"url"`
Remark string `json:"remark"`
Status int8 `json:"status" binding:"required"`
}
type MsgTmpl struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required"`
Title string `json:"title"`
Tmpl string `json:"tmpl" binding:"required"`
MsgType msgx.MsgType `json:"msgType" binding:"required"`
Remark string `json:"remark"`
Status int8 `json:"status" binding:"required"`
ChannelIds []uint64 `json:"channelIds"`
}
type SendMsg struct {
Parmas string `json:"params"`
ReceiverIds []uint64 `json:"receiverIds"`
}

View File

@@ -0,0 +1,54 @@
package api
import (
"mayfly-go/internal/msg/api/form"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/imsg"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"strings"
"github.com/may-fly/cast"
)
type MsgChannel struct {
msgChannelApp application.MsgChannel `inject:"T"`
}
func (m *MsgChannel) ReqConfs() *req.Confs {
basePermCode := "msg:channel:base"
reqs := [...]*req.Conf{
req.NewGet("", m.GetMsgChannels).RequiredPermissionCode(basePermCode),
req.NewPost("", m.SaveMsgChannels).Log(req.NewLogSaveI(imsg.LogMsgChannelSave)).RequiredPermissionCode("msg:channel:save"),
req.NewDelete("", m.DelMsgChannels).Log(req.NewLogSaveI(imsg.LogMsgChannelDelete)).RequiredPermissionCode("msg:channel:del"),
}
return req.NewConfs("/msg/channels", reqs[:]...)
}
func (m *MsgChannel) GetMsgChannels(rc *req.Ctx) {
condition := &entity.MsgChannel{}
res, err := m.msgChannelApp.GetPageList(condition, rc.GetPageParam(), new([]entity.MsgChannel))
biz.ErrIsNil(err)
rc.ResData = res
}
func (m *MsgChannel) SaveMsgChannels(rc *req.Ctx) {
form := &form.MsgChannel{}
rc.ReqParam = form
channel := req.BindJsonAndCopyTo(rc, form, new(entity.MsgChannel))
err := m.msgChannelApp.SaveChannel(rc.MetaCtx, channel)
biz.ErrIsNil(err)
}
func (m *MsgChannel) DelMsgChannels(rc *req.Ctx) {
idsStr := rc.Query("id")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNil(m.msgChannelApp.DeleteChannel(rc.MetaCtx, cast.ToUint64(v)))
}
}

View File

@@ -0,0 +1,86 @@
package api
import (
"mayfly-go/internal/msg/api/form"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/imsg"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/jsonx"
"strings"
"github.com/may-fly/cast"
)
type MsgTmpl struct {
msgTmplApp application.MsgTmpl `inject:"T"`
}
func (m *MsgTmpl) ReqConfs() *req.Confs {
basePermCode := "msg:tmpl:base"
reqs := [...]*req.Conf{
req.NewGet("", m.GetMsgTmpls).RequiredPermissionCode(basePermCode),
req.NewGet(":id/channels", m.GetMsgTmplChannels).RequiredPermissionCode(basePermCode),
req.NewPost("", m.SaveMsgTmpl).Log(req.NewLogSaveI(imsg.LogMsgTmplSave)).RequiredPermissionCode("msg:tmpl:save"),
req.NewDelete("", m.DelMsgTmpls).Log(req.NewLogSaveI(imsg.LogMsgTmplDelete)).RequiredPermissionCode("msg:tmpl:del"),
req.NewPost(":code/send", m.SendMsg).Log(req.NewLogSaveI(imsg.LogMsgTmplSave)).RequiredPermissionCode("msg:tmpl:send"),
}
return req.NewConfs("/msg/tmpls", reqs[:]...)
}
func (m *MsgTmpl) GetMsgTmpls(rc *req.Ctx) {
condition := &entity.MsgTmpl{
Code: rc.Query("code"),
}
condition.Id = cast.ToUint64(rc.QueryInt("id"))
res, err := m.msgTmplApp.GetPageList(condition, rc.GetPageParam(), new([]entity.MsgTmpl))
biz.ErrIsNil(err)
rc.ResData = res
}
func (m *MsgTmpl) GetMsgTmplChannels(rc *req.Ctx) {
channels, err := m.msgTmplApp.GetTmplChannels(rc.MetaCtx, cast.ToUint64(rc.PathParamInt("id")))
biz.ErrIsNil(err)
rc.ResData = collx.ArrayMap(channels, func(val *entity.MsgChannel) collx.M {
return collx.M{
"id": val.Id,
"name": val.Name,
"type": val.Type,
"code": val.Code,
}
})
}
func (m *MsgTmpl) SaveMsgTmpl(rc *req.Ctx) {
form := &form.MsgTmpl{}
rc.ReqParam = form
channel := req.BindJsonAndCopyTo(rc, form, new(dto.MsgTmplSave))
biz.ErrIsNil(m.msgTmplApp.SaveTmpl(rc.MetaCtx, channel))
}
func (m *MsgTmpl) DelMsgTmpls(rc *req.Ctx) {
idsStr := rc.Query("id")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNil(m.msgTmplApp.DeleteTmpl(rc.MetaCtx, cast.ToUint64(v)))
}
}
func (m *MsgTmpl) SendMsg(rc *req.Ctx) {
code := rc.PathParam("code")
form := &form.SendMsg{}
req.BindJsonAndValid(rc, form)
rc.ReqParam = form
params, err := jsonx.ToMap(form.Parmas)
biz.ErrIsNil(err)
biz.ErrIsNil(m.msgTmplApp.Send(rc.MetaCtx, code, params, form.ReceiverIds...))
}

View File

@@ -1,11 +1,15 @@
package application
import (
_ "mayfly-go/internal/msg/msgx/sender" // 注册消息发送器
"mayfly-go/pkg/ioc"
)
func InitIoc() {
ioc.Register(new(msgAppImpl), ioc.WithComponentName("MsgApp"))
ioc.Register(new(msgChannelAppImpl), ioc.WithComponentName("MsgChannelApp"))
ioc.Register(new(msgTmplAppImpl), ioc.WithComponentName("MsgTmplApp"))
ioc.Register(new(msgTmplBizAppImpl), ioc.WithComponentName("MsgTmplBizApp"))
}
func GetMsgApp() Msg {

View File

@@ -0,0 +1,36 @@
package dto
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgTmplSave struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
Status entity.MsgTmplStatus `json:"status" `
Title string `json:"title"`
Tmpl string `json:"type"`
MsgType msgx.MsgType `json:"msgType"`
ChannelIds []uint64 `json:"channelIds"`
}
// MsgTmplBizSave 消息模板关联业务信息
type MsgTmplBizSave struct {
TmplId uint64 // 消息模板id
BizId uint64 // 业务id
BizType string
}
// BizMsgTmplSend 业务消息模板发送消息
type BizMsgTmplSend struct {
BizId uint64 // 业务id
BizType string
Params map[string]any // 模板占位符参数
ReceiverIds []uint64 // 接收人id
}

View File

@@ -0,0 +1,52 @@
package application
import (
"context"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/stringx"
)
type MsgChannel interface {
base.App[*entity.MsgChannel]
GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
SaveChannel(ctx context.Context, msgChannel *entity.MsgChannel) error
DeleteChannel(ctx context.Context, id uint64) error
}
type msgChannelAppImpl struct {
base.AppImpl[*entity.MsgChannel, repository.MsgChannel]
msgTempApp MsgTmpl `inject:"T"`
}
var _ (MsgChannel) = (*msgChannelAppImpl)(nil)
func (m *msgChannelAppImpl) GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
return m.Repo.GetPageList(condition, pageParam, toEntity)
}
func (m *msgChannelAppImpl) SaveChannel(ctx context.Context, msgChannel *entity.MsgChannel) error {
if msgChannel.Id == 0 {
msgChannel.Code = stringx.Rand(8)
}
return m.Save(ctx, msgChannel)
}
func (m *msgChannelAppImpl) DeleteChannel(ctx context.Context, id uint64) error {
return m.Tx(ctx, func(ctx context.Context) error {
if err := m.DeleteById(ctx, id); err != nil {
return err
}
// 删除渠道关联的模板
if err := m.msgTempApp.DeleteTmplChannel(ctx, id); err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,208 @@
package application
import (
"context"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/internal/msg/msgx"
sysapp "mayfly-go/internal/sys/application"
sysentity "mayfly-go/internal/sys/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"mayfly-go/pkg/utils/structx"
)
type MsgTmpl interface {
base.App[*entity.MsgTmpl]
GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
SaveTmpl(ctx context.Context, msgTmpl *dto.MsgTmplSave) error
DeleteTmpl(ctx context.Context, id uint64) error
GetTmplChannels(ctx context.Context, tmplId uint64) ([]*entity.MsgChannel, error)
// Send 发送消息
Send(ctx context.Context, tmplCode string, params map[string]any, receiverId ...uint64) error
// DeleteTmplChannel 删除指定渠道关联的模板
DeleteTmplChannel(ctx context.Context, channelId uint64) error
}
type msgTmplAppImpl struct {
base.AppImpl[*entity.MsgTmpl, repository.MsgTmpl]
msgTmplChannelRepo repository.MsgTmplChannel `inject:"T"`
msgChannelApp MsgChannel `inject:"T"`
msgTmplBizApp MsgTmplBiz `inject:"T"`
accountApp sysapp.Account `inject:"T"`
}
var _ (MsgTmpl) = (*msgTmplAppImpl)(nil)
func (m *msgTmplAppImpl) GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
return m.Repo.GetPageList(condition, pageParam, toEntity)
}
func (m *msgTmplAppImpl) SaveTmpl(ctx context.Context, msgTmpl *dto.MsgTmplSave) error {
return m.Tx(ctx, func(ctx context.Context) error {
mt := &entity.MsgTmpl{}
structx.Copy(mt, msgTmpl)
isCreate := mt.Id == 0
if isCreate {
mt.Code = stringx.Rand(8)
}
if err := m.Save(ctx, mt); err != nil {
return err
}
oldTemplChannelIds := []uint64{}
if !isCreate {
oldTemplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: mt.Id}, "channel_id")
if err != nil {
return err
}
oldTemplChannelIds = collx.ArrayMap(oldTemplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
})
}
add, del, _ := collx.ArrayCompare(msgTmpl.ChannelIds, oldTemplChannelIds)
if len(add) > 0 {
tmplChannels := collx.ArrayMap(msgTmpl.ChannelIds, func(channelId uint64) *entity.MsgTmplChannel {
return &entity.MsgTmplChannel{
ChannelId: channelId,
TmplId: mt.Id,
}
})
if err := m.msgTmplChannelRepo.BatchInsert(ctx, tmplChannels); err != nil {
return err
}
}
if len(del) > 0 {
if err := m.msgTmplChannelRepo.DeleteByCond(ctx, model.NewCond().Eq("tmpl_id", mt.Id).In("channel_id", del)); err != nil {
return err
}
}
return nil
})
}
func (m *msgTmplAppImpl) DeleteTmpl(ctx context.Context, id uint64) error {
return m.Tx(ctx, func(ctx context.Context) error {
if err := m.DeleteById(ctx, id); err != nil {
return err
}
if err := m.msgTmplBizApp.DeleteByTmplId(ctx, id); err != nil {
return err
}
return m.msgTmplChannelRepo.DeleteByCond(ctx, &entity.MsgTmplChannel{TmplId: id})
})
}
func (m *msgTmplAppImpl) GetTmplChannels(ctx context.Context, tmplId uint64) ([]*entity.MsgChannel, error) {
tmplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: tmplId}, "channel_id")
if err != nil {
return nil, err
}
if len(tmplChannels) == 0 {
return []*entity.MsgChannel{}, nil
}
return m.msgChannelApp.GetByIds(collx.ArrayMap(tmplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
}))
}
func (m *msgTmplAppImpl) Send(ctx context.Context, tmplCode string, params map[string]any, receiverId ...uint64) error {
tmpl := &entity.MsgTmpl{Code: tmplCode}
err := m.GetByCond(tmpl)
if err != nil {
return errorx.NewBiz("message template does not exist")
}
if tmpl.Status != entity.TmplStatusEnable {
return errorx.NewBiz("message template is disabled")
}
tmplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: tmpl.Id}, "channel_id")
if err != nil {
return err
}
if len(tmplChannels) == 0 {
return errorx.NewBiz("message template is not associated with any channel")
}
channels, err := m.msgChannelApp.GetByIds(collx.ArrayMap(tmplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
}))
if err != nil {
return err
}
// content, err := stringx.TemplateParse(tmpl.Tmpl, params)
// if err != nil {
// return err
// }
// toAll := len(receiverId) == 0
accounts, err := m.accountApp.GetByIds(receiverId)
if err != nil {
return err
}
msg := &msgx.Msg{
Content: tmpl.Tmpl,
Params: params,
Title: tmpl.Title,
Type: tmpl.MsgType,
ExtraData: tmpl.ExtraData,
}
if len(accounts) > 0 {
msg.Receivers = collx.ArrayMap(accounts, func(account *sysentity.Account) msgx.Receiver {
return msgx.Receiver{
ExtraData: account.ExtraData,
Email: account.Email,
Mobile: account.Mobile,
}
})
}
for _, channel := range channels {
if channel.Status != entity.ChannelStatusEnable {
logx.Warnf("channel is disabled => %s", channel.Code)
continue
}
go func(channel *entity.MsgChannel) {
if err := msgx.Send(&msgx.Channel{
Type: channel.Type,
Name: channel.Name,
URL: channel.Url,
ExtraData: channel.ExtraData,
}, msg); err != nil {
logx.Errorf("send msg error => channel=%s, msg=%s, err -> %v", channel.Code, msg.Content, err)
}
}(channel)
}
return nil
}
func (m *msgTmplAppImpl) DeleteTmplChannel(ctx context.Context, channelId uint64) error {
return m.msgTmplChannelRepo.DeleteByCond(ctx, &entity.MsgTmplChannel{ChannelId: channelId})
}

View File

@@ -0,0 +1,102 @@
package application
import (
"context"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
)
type MsgTmplBiz interface {
base.App[*entity.MsgTmplBiz]
// SaveBizTmpl 保存消息模板关联业务信息
SaveBizTmpl(ctx context.Context, bizTmpl dto.MsgTmplBizSave) error
// DeleteByBiz 根据业务删除消息模板业务关联
DeleteByBiz(ctx context.Context, bizType string, bizId uint64) error
// DeleteByTmplId 根据模板ID删除消息模板业务关联
DeleteByTmplId(ctx context.Context, tmplId uint64) error
// Send 发送消息
Send(ctx context.Context, sendParam dto.BizMsgTmplSend) error
}
type msgTmplBizAppImpl struct {
base.AppImpl[*entity.MsgTmplBiz, repository.MsgTmplBiz]
msgTmplApp MsgTmpl `inject:"T"`
}
var _ (MsgTmplBiz) = (*msgTmplBizAppImpl)(nil)
func (m *msgTmplBizAppImpl) SaveBizTmpl(ctx context.Context, bizTmpl dto.MsgTmplBizSave) error {
msgTmplId := bizTmpl.TmplId
bizId := bizTmpl.BizId
bizType := bizTmpl.BizType
if bizId == 0 {
return errorx.NewBiz("business ID cannot be empty")
}
if bizType == "" {
return errorx.NewBiz("business type cannot be empty")
}
msgTmplBiz := &entity.MsgTmplBiz{
BizId: bizId,
BizType: bizType,
}
// exist
if err := m.GetByCond(msgTmplBiz); err == nil {
// tmplId不变直接返回即可
if msgTmplBiz.TmplId == msgTmplId {
return nil
}
// 如果模板ID为0表示删除业务关联
if msgTmplId == 0 {
return m.DeleteByBiz(ctx, bizTmpl.BizType, bizTmpl.BizId)
}
update := &entity.MsgTmplBiz{
TmplId: msgTmplId,
}
update.Id = msgTmplBiz.Id
return m.UpdateById(ctx, update)
}
if msgTmplId == 0 {
return nil
}
msgTmplBiz.TmplId = msgTmplId
return m.Save(ctx, msgTmplBiz)
}
func (m *msgTmplBizAppImpl) DeleteByBiz(ctx context.Context, bizType string, bizId uint64) error {
return m.DeleteByCond(ctx, &entity.MsgTmplBiz{BizId: bizId, BizType: bizType})
}
func (m *msgTmplBizAppImpl) DeleteByTmplId(ctx context.Context, tmplId uint64) error {
return m.DeleteByCond(ctx, &entity.MsgTmplBiz{TmplId: tmplId})
}
func (m *msgTmplBizAppImpl) Send(ctx context.Context, sendParam dto.BizMsgTmplSend) error {
// 获取业务关联的消息模板
msgTmplBiz := &entity.MsgTmplBiz{
BizId: sendParam.BizId,
BizType: sendParam.BizType,
}
if err := m.GetByCond(msgTmplBiz); err != nil {
return errorx.NewBiz("message tmplate association business information does not exist")
}
mstTmpl, err := m.msgTmplApp.GetById(msgTmplBiz.TmplId)
if err != nil {
return errorx.NewBiz("message template does not exist")
}
return m.msgTmplApp.Send(ctx, mstTmpl.Code, sendParam.Params, sendParam.ReceiverIds...)
}

View File

@@ -0,0 +1,29 @@
package entity
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgChannel struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:50;not null;"` // 渠道名称
Code string `json:"code" gorm:"size:50;not null;"` // 渠道编码
Type msgx.ChannelType `json:"type" gorm:"size:30;not null;"` // 渠道类型
Url string `json:"url" gorm:"size:200;"` // 渠道url
Status MsgChannelStatus `json:"status" gorm:"not null;"` // 状态
Remark *string `json:"remark" gorm:"size:200;"` // 备注
}
func (a *MsgChannel) TableName() string {
return "t_msg_channel"
}
type MsgChannelStatus int8
const (
ChannelStatusEnable MsgChannelStatus = 1 // 启用状态
ChannelStatusDisable MsgChannelStatus = -1 // 禁用状态
)

View File

@@ -0,0 +1,42 @@
package entity
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
// MsgTmpl 消息模板
type MsgTmpl struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:50;not null;"` // 模板名称
Code string `json:"code" gorm:"size:32;not null;"` // 模板编码
Title string `json:"title" gorm:"size:100;"` // 标题
Tmpl string `json:"tmpl" gorm:"size:2000;not null;"` // 消息模板
MsgType msgx.MsgType `json:"msgType" gorm:"not null;"` // 消息类型
Status MsgTmplStatus `json:"status" gorm:"not null;"` // 状态
Remark *string `json:"remark" gorm:"size:200;"` // 备注
}
func (a *MsgTmpl) TableName() string {
return "t_msg_tmpl"
}
type MsgTmplStatus int8
const (
TmplStatusEnable MsgTmplStatus = 1 // 启用状态
TmplStatusDisable MsgTmplStatus = -1 // 禁用状态
)
// MsgTmplChannel 消息模板渠道关联
type MsgTmplChannel struct {
model.CreateModelNLD
TmplId uint64 `json:"tmplId" gorm:"not null;"` // 模板id
ChannelId uint64 `json:"channelId" gorm:"not null;"` // 渠道id
}
func (a *MsgTmplChannel) TableName() string {
return "t_msg_tmpl_channel"
}

View File

@@ -0,0 +1,18 @@
package entity
import (
"mayfly-go/pkg/model"
)
// MsgTmplBiz 消息模板关联业务信息
type MsgTmplBiz struct {
model.Model
TmplId uint64 `json:"tmplId" gorm:"not null;"` // 模板id
BizId uint64 `json:"bizId" gorm:"not null;"` // 业务id
BizType string `json:"bizType" gorm:"size:32;not null;"` // 业务类型
}
func (a *MsgTmplBiz) TableName() string {
return "t_msg_tmpl_biz"
}

View File

@@ -0,0 +1,13 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type MsgChannel interface {
base.Repo[*entity.MsgChannel]
GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
}

View File

@@ -0,0 +1,17 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type MsgTmpl interface {
base.Repo[*entity.MsgTmpl]
GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
}
type MsgTmplChannel interface {
base.Repo[*entity.MsgTmplChannel]
}

View File

@@ -0,0 +1,10 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
)
type MsgTmplBiz interface {
base.Repo[*entity.MsgTmplBiz]
}

View File

@@ -0,0 +1,12 @@
package imsg
import "mayfly-go/pkg/i18n"
var En = map[i18n.MsgId]string{
LogMsgChannelSave: "Message channel- save",
LogMsgChannelDelete: "Message channel- delete",
LogMsgTmplSave: "Message template- save",
LogMsgTmplDelete: "Message template- delete",
LogMsgTmplSend: "Message template- send",
}

View File

@@ -0,0 +1,20 @@
package imsg
import (
"mayfly-go/internal/common/consts"
"mayfly-go/pkg/i18n"
)
func init() {
i18n.AppendLangMsg(i18n.Zh_CN, Zh_CN)
i18n.AppendLangMsg(i18n.En, En)
}
const (
LogMsgChannelSave = iota + consts.ImsgNumMsg
LogMsgChannelDelete
LogMsgTmplSave
LogMsgTmplDelete
LogMsgTmplSend
)

View File

@@ -0,0 +1,12 @@
package imsg
import "mayfly-go/pkg/i18n"
var Zh_CN = map[i18n.MsgId]string{
LogMsgChannelSave: "消息渠道-保存",
LogMsgChannelDelete: "消息渠道-删除",
LogMsgTmplSave: "消息模板-保存",
LogMsgTmplDelete: "消息模板-删除",
LogMsgTmplSend: "消息模板-发送",
}

View File

@@ -0,0 +1,24 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type msgChannelRepoImpl struct {
base.RepoImpl[*entity.MsgChannel]
}
func newMsgChannelRepo() repository.MsgChannel {
return &msgChannelRepoImpl{}
}
func (m *msgChannelRepoImpl) GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
pd := model.NewCond().
Eq("id", condition.Id).
Like("code", condition.Code).
OrderBy(orderBy...)
return m.PageByCondToAny(pd, pageParam, toEntity)
}

View File

@@ -0,0 +1,32 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type msgTmplRepoImpl struct {
base.RepoImpl[*entity.MsgTmpl]
}
func newMsgTmplRepo() repository.MsgTmpl {
return &msgTmplRepoImpl{}
}
func (m *msgTmplRepoImpl) GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
pd := model.NewCond().
Eq("id", condition.Id).
Like("code", condition.Code).
OrderBy(orderBy...)
return m.PageByCondToAny(pd, pageParam, toEntity)
}
type msgTmplChannelRepoImpl struct {
base.RepoImpl[*entity.MsgTmplChannel]
}
func newMsgTmplChannelRepo() repository.MsgTmplChannel {
return &msgTmplChannelRepoImpl{}
}

View File

@@ -0,0 +1,15 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
)
type msgTmplBizRepoImpl struct {
base.RepoImpl[*entity.MsgTmplBiz]
}
func newMsgTmplBizRepo() repository.MsgTmplBiz {
return &msgTmplBizRepoImpl{}
}

View File

@@ -6,4 +6,8 @@ import (
func InitIoc() {
ioc.Register(newMsgRepo(), ioc.WithComponentName("MsgRepo"))
ioc.Register(newMsgChannelRepo(), ioc.WithComponentName("MsgChannelRepo"))
ioc.Register(newMsgTmplRepo(), ioc.WithComponentName("MsgTmplRepo"))
ioc.Register(newMsgTmplChannelRepo(), ioc.WithComponentName("MsgTmplChannelRepo"))
ioc.Register(newMsgTmplBizRepo(), ioc.WithComponentName("MsgTmplBizRepo"))
}

View File

@@ -1,10 +1,16 @@
package init
import (
"context"
"mayfly-go/initialize"
"mayfly-go/internal/event"
"mayfly-go/internal/msg/api"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/infrastructure/persistence"
"mayfly-go/pkg/eventbus"
"mayfly-go/pkg/global"
"mayfly-go/pkg/ioc"
)
func init() {
@@ -13,4 +19,14 @@ func init() {
application.InitIoc()
api.InitIoc()
})
initialize.AddInitFunc(Init)
}
func Init() {
msgTmplBizApp := ioc.Get[application.MsgTmplBiz]("MsgTmplBizApp")
global.EventBus.SubscribeAsync(event.EventTopicBizMsgTmplSend, "BizMsgTmplSend", func(ctx context.Context, event *eventbus.Event) error {
return msgTmplBizApp.Send(ctx, event.Val.(dto.BizMsgTmplSend))
}, false)
}

View File

@@ -0,0 +1,84 @@
package msgx
import (
"fmt"
"mayfly-go/pkg/model"
)
type MsgType int8
type ChannelType string
const (
MsgTypeText MsgType = 1
MsgTypeMarkdown MsgType = 2
MsgTypeHtml MsgType = 3
)
const (
ChannelTypeEmail ChannelType = "email"
ChannelTypeDingBot ChannelType = "dingBot"
ChannelTypeQywxBot ChannelType = "qywxBot"
ChannelTypeFeishuBot ChannelType = "feishuBot"
)
const (
ReceiverKey = "receiver"
)
// Send 发送消息
func Send(channel *Channel, msg *Msg) error {
sender, err := GetMsgSender(channel.Type)
if err != nil {
return err
}
return sender.Send(channel, msg)
}
type Receiver struct {
model.ExtraData
Mobile string
Email string
}
type Msg struct {
model.ExtraData
Title string // 消息title
Type MsgType // 消息类型
Content string // 消息内容
Params map[string]any // 消息参数(替换消息中的占位符)
Receivers []Receiver // 消息接收人
}
// Channel 消息发送渠道信息
type Channel struct {
model.ExtraData
Type ChannelType // 渠道类型
Name string
URL string
}
// MsgSender 定义消息发送接口
type MsgSender interface {
// Send 发送消息
Send(channel *Channel, msg *Msg) error
}
var messageSenders = make(map[ChannelType]MsgSender)
// RegisterMsgSender 注册消息发送器
func RegisterMsgSender(channel ChannelType, sender MsgSender) {
messageSenders[channel] = sender
}
// GetMsgSender 获取消息发送器
func GetMsgSender(channel ChannelType) (MsgSender, error) {
sender, ok := messageSenders[channel]
if !ok {
return nil, fmt.Errorf("unsupported message channel %s", channel)
}
return sender, nil
}

View File

@@ -0,0 +1,107 @@
package sender
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
"net/url"
"time"
)
type dingBotMsgReq struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Markdown struct {
Title string `json:"title"`
Text string `json:"text"`
} `json:"markdown"`
At struct {
// AtUserIds []string `json:"atUserIds"`
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
} `json:"at"`
}
type dingBotMsgResp struct {
Code int `json:"errcode"`
Message string `json:"errmsg"`
}
// DingBotSender 钉钉机器人消息发送
type DingBotSender struct{}
func (d DingBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://open.dingtalk.com/document/robots/custom-robot-access#title-72m-8ag-pqw
msgReq := dingBotMsgReq{}
params := msg.Params
receiver := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
return a.Mobile, a.Mobile != ""
})
if len(receiver) > 0 {
msgReq.At.AtMobiles = receiver
// 替换文本中的receiver使用@mobile用于@指定用户
params[msgx.ReceiverKey] = strings.Join(collx.ArrayMap(receiver, func(a string) string { return "@" + a }), "")
} else {
msgReq.At.IsAtAll = true
params[msgx.ReceiverKey] = ""
}
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
if msg.Type == msgx.MsgTypeMarkdown {
msgReq.MsgType = "markdown"
msgReq.Markdown.Title = msg.Title
msgReq.Markdown.Text = content
} else {
msgReq.MsgType = "text"
msgReq.Text.Content = content
}
timestamp := time.Now().UnixMilli()
sign, err := d.sign(channel.GetExtraString("secret"), timestamp)
if err != nil {
return err
}
var res dingBotMsgResp
err = httpx.NewReq(fmt.Sprintf("%s&timestamp=%d&sign=%s", channel.URL, timestamp, sign)).
PostObj(msgReq).
BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}
func (d DingBotSender) sign(secret string, timestamp int64) (string, error) {
// https://open.dingtalk.com/document/robots/customize-robot-security-settings
// timestamp + key -> sha256 -> URL encode
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(secret))
_, err := h.Write([]byte(stringToSign))
if err != nil {
return "", err
}
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
signature = url.QueryEscape(signature)
return signature, nil
}

View File

@@ -0,0 +1,100 @@
package sender
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"net/smtp"
"strings"
"github.com/may-fly/cast"
)
type EmailSender struct{}
func (e EmailSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
return e.SendEmail(channel, msg)
}
func (e EmailSender) SendEmail(channel *msgx.Channel, msg *msgx.Msg) error {
subject := msg.Title
content, err := stringx.TemplateResolve(msg.Content, msg.Params)
if err != nil {
return err
}
to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
return a.Email, a.Email != ""
})
if len(to) == 0 {
return errors.New("no receiver")
}
systemName := "mayfly-go"
serverAndPort := strings.Split(channel.URL, ":")
smtpServer := serverAndPort[0]
smtpPort := 465
if len(serverAndPort) == 2 {
smtpPort = cast.ToInt(serverAndPort[1])
}
smtpAccount := channel.GetExtraString("smtpAccount")
smtpPassword := channel.GetExtraString("smtpPassword")
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
strings.Join(to, ";"), systemName, smtpAccount, encodedSubject, content))
auth := smtp.PlainAuth("", smtpAccount, smtpPassword, smtpServer)
addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
if smtpPort == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: smtpServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", smtpServer, smtpPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, smtpServer)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(smtpAccount); err != nil {
return err
}
for _, receiver := range to {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, smtpAccount, to, mail)
}
return err
}

View File

@@ -0,0 +1,100 @@
package sender
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
"time"
"github.com/may-fly/cast"
)
type feishuBotMsgReq struct {
MsgType string `json:"msg_type"`
Content struct {
Text string `json:"text"`
} `json:"content"`
Timestamp string `json:"timestamp"`
Sign string `json:"sign"`
}
type feishuBotMsgResp struct {
Code int `json:"code"`
Message string `json:"msg"`
}
// FeishuBotSender 发送飞书机器人消息
type FeishuBotSender struct{}
func (f FeishuBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
msgReq := feishuBotMsgReq{
MsgType: "text",
}
params := msg.Params
receiver := `<at user_id="all"></at>`
// 使用receiver参数替换消息内容中可能存在的接收人信息
if len(msg.Receivers) > 0 {
if to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
if uid := a.GetExtraString("feishuUserId"); uid != "" {
// 使用<at user_id="userId"></at>
return fmt.Sprintf(`<at user_id="%s"></at>`, uid), true
}
return "", false
}); len(to) > 0 {
receiver = strings.Join(to, "")
}
}
params[msgx.ReceiverKey] = receiver
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
msgReq.Content.Text = content
if secret := channel.GetExtraString("secret"); secret != "" {
timestamp := time.Now().Unix()
if sign, err := f.sign(secret, timestamp); err != nil {
return err
} else {
msgReq.Sign = sign
}
msgReq.Timestamp = cast.ToString(timestamp)
}
var res feishuBotMsgResp
err = httpx.NewReq(channel.URL).
PostObj(msgReq).
BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}
func (e FeishuBotSender) sign(secret string, timestamp int64) (string, error) {
//timestamp + key 做sha256, 再进行base64 encode
stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret
var data []byte
h := hmac.New(sha256.New, []byte(stringToSign))
_, err := h.Write(data)
if err != nil {
return "", err
}
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature, nil
}

View File

@@ -0,0 +1,82 @@
package sender
import (
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
)
type qywxBotMsgReq struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
MentionedMobileList []string `json:"mentioned_mobile_list"`
} `json:"text"`
Markdown struct {
Content string `json:"content"`
} `json:"markdown"`
}
type qywxBotMsgResp struct {
Code int `json:"errcode"`
Message string `json:"errmsg"`
}
// QywxBotSender 企业微信机器人消息发送
type QywxBotSender struct{}
func (e QywxBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://developer.work.weixin.qq.com/document/path/91770
msgReq := qywxBotMsgReq{}
params := msg.Params
receiver := ""
// 使用receiver参数替换消息内容中可能存在的接收人信息
if len(msg.Receivers) > 0 {
if to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
if uid := a.GetExtraString("qywxUserId"); uid != "" {
// 使用<@userId>用于@指定用户
return fmt.Sprintf("<@%s>", uid), true
}
return "", false
}); len(to) > 0 {
receiver = strings.Join(to, "")
}
}
params[msgx.ReceiverKey] = receiver
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
if msg.Type == msgx.MsgTypeMarkdown {
msgReq.MsgType = "markdown"
msgReq.Markdown.Content = content
// msgReq.Markdown.MentionedMobileList = receivers // markdown不支持@人,需要使用<@userId>
} else {
msgReq.MsgType = "text"
msgReq.Text.Content = content
// receivers := msg.Receivers
// if len(msg.Receivers) == 0 {
// receivers = []string{"@all"}
// }
// msgReq.Text.MentionedMobileList = receivers
}
var res qywxBotMsgResp
err = httpx.NewReq(channel.URL).PostObj(msgReq).BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}

View File

@@ -0,0 +1,10 @@
package sender
import "mayfly-go/internal/msg/msgx"
func init() {
msgx.RegisterMsgSender(msgx.ChannelTypeEmail, EmailSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeDingBot, DingBotSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeQywxBot, QywxBotSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeFeishuBot, FeishuBotSender{})
}

View File

@@ -1,15 +1,25 @@
package form
import "mayfly-go/pkg/model"
type AccountCreateForm struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required,max=16" msg:"required=name cannot be blank,max=The maximum length of a name cannot exceed 16 characters"`
Username string `json:"username" binding:"pattern=account_username"`
Mobile string `json:"mobile"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password"`
}
type AccountUpdateForm struct {
model.ExtraData
Name string `json:"name" binding:"max=16"` // 姓名
Username string `json:"username" binding:"omitempty,pattern=account_username"`
Mobile string `json:"mobile"`
Email string `json:"email" binding:"omitempty,email"`
Password *string `json:"password"`
}

View File

@@ -8,8 +8,12 @@ import (
type AccountManageVO struct {
model.Model
model.ExtraData
Name string `json:"name"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Email string `json:"email"`
Status entity.AccountStatus `json:"status"`
LastLoginTime *time.Time `json:"lastLoginTime"`
OtpSecret string `json:"otpSecret"`
@@ -19,6 +23,8 @@ type SimpleAccountVO struct {
Id uint64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Email string `json:"email"`
Roles []*AccountRoleVO `json:"roles" gorm:"-"`
}

View File

@@ -149,7 +149,7 @@ func (m *syslogAppImpl) AppendLog(logId uint64, appendLog *AppendLogReq) {
syslog.Resp = fmt.Sprintf("%s\n%s", syslog.Resp, appendLogMsg)
syslog.Type = appendLog.Type
if len(appendLog.Extra) > 0 {
existExtra := jsonx.ToMap(syslog.Extra)
existExtra, _ := jsonx.ToMap(syslog.Extra)
syslog.Extra = jsonx.ToStr(collx.MapMerge(existExtra, appendLog.Extra))
}
@@ -167,7 +167,7 @@ func (m *syslogAppImpl) SetExtra(logId uint64, key string, val any) {
syslog = sl
}
extraMap := jsonx.ToMap(syslog.Extra)
extraMap, _ := jsonx.ToMap(syslog.Extra)
if extraMap == nil {
extraMap = make(map[string]any)
}

View File

@@ -10,9 +10,12 @@ import (
type Account struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:30;not null;"`
Username string `json:"username" gorm:"size:30;not null;"`
Mobile string `json:"mobile" gorm:"size:20;"`
Email string `json:"email" gorm:"size:100;"`
Password string `json:"-" gorm:"size:64;not null;"`
Status AccountStatus `json:"status" gorm:"not null;"`
LastLoginTime *time.Time `json:"lastLoginTime"`

View File

@@ -22,10 +22,8 @@ func init() {
}
func Init() {
global.EventBus.SubscribeAsync(event.EventTopicResourceOp, "ResourceOpLogApp", func(ctx context.Context, event *eventbus.Event) error {
codePath := event.Val.(string)
return application.GetResourceOpLogApp().AddResourceOpLog(ctx, codePath)
}, false)
}

View File

@@ -44,7 +44,7 @@ func run(db *gorm.DB, fs ...func() []*gormigrate.Migration) error {
IDColumnName: "id",
IDColumnSize: 300,
UseTransaction: true,
ValidateUnknownMigrations: true,
ValidateUnknownMigrations: false,
}, ms)
if err := m.Migrate(); err != nil {
return err

View File

@@ -2,7 +2,11 @@ package migrations
import (
machineentity "mayfly-go/internal/machine/domain/entity"
msgentity "mayfly-go/internal/msg/domain/entity"
sysentity "mayfly-go/internal/sys/domain/entity"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
@@ -11,6 +15,7 @@ import (
func V1_9() []*gormigrate.Migration {
var migrations []*gormigrate.Migration
migrations = append(migrations, V1_9_3()...)
migrations = append(migrations, V1_9_4()...)
return migrations
}
@@ -40,3 +45,167 @@ func V1_9_3() []*gormigrate.Migration {
},
}
}
func V1_9_4() []*gormigrate.Migration {
return []*gormigrate.Migration{
{
ID: "20250213-v1.9.4-addMsg",
Migrate: func(tx *gorm.DB) error {
tx.AutoMigrate(&sysentity.Account{})
tx.AutoMigrate(&msgentity.MsgTmpl{}, &msgentity.MsgTmplChannel{}, &msgentity.MsgChannel{}, &msgentity.MsgTmplBiz{})
la := &model.LoginAccount{Id: 1, Username: "admin"}
// 创建审批默认消息模板
processMsgTmplCode := "7u2MRCaB"
if err := tx.Where("code = ?", processMsgTmplCode).First(&msgentity.MsgTmpl{}).Error; err != nil {
tmplRemark := "工单审批通知模板"
msgTmpl := &msgentity.MsgTmpl{
Code: processMsgTmplCode,
Name: "工单审批通知",
Tmpl: `{{.receiver}}
您有新的工单需要审批
发起人:{{.creator}}
工单标题:{{.procdefName}}
备注:{{.procinstRemark}}
业务编号:{{.bizKey}}`,
Title: "工单审批",
MsgType: 1,
Status: 1,
Remark: &tmplRemark,
}
msgTmpl.FillBaseInfo(model.IdGenTypeNone, la)
if err := tx.Create(msgTmpl).Error; err != nil {
logx.ErrorTrace("create msg tmpl error", err)
return err
}
}
resources := []*sysentity.Resource{
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742816076}}}},
Pid: 0,
UiPath: "ckg5ICnd/",
Name: "menu.msgManage",
Code: "/msg",
Type: 1,
Meta: `{"icon":"Message","isKeepAlive":true,"routeName":"msg"}`,
Weight: 60000000,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742816279}}}},
Pid: 1742816076,
UiPath: "ckg5ICnd/eKQ8qAlH/",
Name: "menu.channel",
Code: "channels",
Type: 1,
Meta: `{"component":"msg/channel/ChannelList","icon":"Message","isKeepAlive":true,"routeName":"ChannelList"}`,
Weight: 1742816279,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876893}}}},
Pid: 1742816279,
UiPath: "ckg5ICnd/eKQ8qAlH/p2Xi8asv/",
Name: "menu.msgChannelBase",
Code: "msg:channel:base",
Type: 2,
Meta: ``,
Weight: 1742823660,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742823661}}}},
Pid: 1742816279,
UiPath: "ckg5ICnd/eKQ8qAlH/Iu82rFKW/",
Name: "menu.saveMsgChannel",
Code: "msg:channel:save",
Type: 2,
Meta: ``,
Weight: 1742823661,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742826138}}}},
Pid: 1742816279,
UiPath: "ckg5ICnd/eKQ8qAlH/Y4kRzNJp/",
Name: "menu.delMsgChannel",
Code: "msg:channel:del",
Type: 2,
Meta: ``,
Weight: 1742826138,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876469}}}},
Pid: 1742816076,
UiPath: "ckg5ICnd/XiJf38uW/",
Name: "menu.msgTmpl",
Code: "tmpls",
Type: 1,
Meta: `{"component":"msg/tmpl/TmplList","icon":"Message","isKeepAlive":true,"routeName":"TmplList"}`,
Weight: 1742876469,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876795}}}},
Pid: 1742876469,
UiPath: "ckg5ICnd/XiJf38uW/ExV9tz2l/",
Name: "menu.saveMsgTmpl",
Code: "msg:tmpl:save",
Type: 2,
Meta: ``,
Weight: 1742876795,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876813}}}},
Pid: 1742876469,
UiPath: "ckg5ICnd/XiJf38uW/2y7drhga/",
Name: "menu.delMsgTmpl",
Code: "msg:tmpl:del",
Type: 2,
Meta: ``,
Weight: 1742876813,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876922}}}},
Pid: 1742876469,
UiPath: "ckg5ICnd/XiJf38uW/VRX9YtM3/",
Name: "menu.msgTmplBase",
Code: "msg:tmpl:base",
Type: 2,
Meta: ``,
Weight: 1742876794,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742912893}}}},
Pid: 1742876469,
UiPath: "ckg5ICnd/XiJf38uW/42PkAmLB/",
Name: "menu.sendMsg",
Code: "msg:tmpl:send",
Type: 2,
Meta: ``,
Weight: 1742912893,
},
}
now := time.Now()
for _, r := range resources {
if err := tx.Where("ui_path = ?", r.UiPath).First(&sysentity.Resource{}).Error; err == nil {
continue
}
r.Status = 1
r.CreateTime = &now
r.UpdateTime = &now
r.Creator = la.Username
r.Modifier = la.Username
r.CreatorId = la.Id
r.ModifierId = la.Id
if err := tx.Create(r).Error; err != nil {
logx.ErrorTrace("create msg resource menu error", err)
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
},
}
}

View File

@@ -4,7 +4,7 @@ import "fmt"
const (
AppName = "mayfly-go"
Version = "v1.9.3"
Version = "v1.9.4"
)
func GetAppInfo() string {

View File

@@ -1,4 +1,4 @@
package httpclient
package httpx
import (
"bytes"
@@ -20,7 +20,7 @@ import (
// 默认超时
const DefTimeout = 60
type RequestWrapper struct {
type Req struct {
client http.Client
url string
method string
@@ -37,16 +37,16 @@ type MultipartFile struct {
}
// 创建一个请求
func NewRequest(url string) *RequestWrapper {
return &RequestWrapper{url: url, client: http.Client{}}
func NewReq(url string) *Req {
return &Req{url: url, client: http.Client{}}
}
func (r *RequestWrapper) Url(url string) *RequestWrapper {
func (r *Req) Url(url string) *Req {
r.url = url
return r
}
func (r *RequestWrapper) Header(name, value string) *RequestWrapper {
func (r *Req) Header(name, value string) *Req {
if r.header == nil {
r.header = make(map[string]string)
}
@@ -54,12 +54,12 @@ func (r *RequestWrapper) Header(name, value string) *RequestWrapper {
return r
}
func (r *RequestWrapper) Timeout(timeout int) *RequestWrapper {
func (r *Req) Timeout(timeout int) *Req {
r.timeout = timeout
return r
}
func (r *RequestWrapper) GetByQuery(queryMap collx.M) *ResponseWrapper {
func (r *Req) GetByQuery(queryMap collx.M) *Resp {
var params string
for k, v := range queryMap {
if params != "" {
@@ -71,13 +71,13 @@ func (r *RequestWrapper) GetByQuery(queryMap collx.M) *ResponseWrapper {
return r.Get()
}
func (r *RequestWrapper) Get() *ResponseWrapper {
func (r *Req) Get() *Resp {
r.method = "GET"
r.body = nil
return sendRequest(r)
}
func (r *RequestWrapper) PostJson(body string) *ResponseWrapper {
func (r *Req) PostJson(body string) *Resp {
buf := bytes.NewBufferString(body)
r.method = "POST"
r.body = buf
@@ -88,15 +88,15 @@ func (r *RequestWrapper) PostJson(body string) *ResponseWrapper {
return sendRequest(r)
}
func (r *RequestWrapper) PostObj(body any) *ResponseWrapper {
func (r *Req) PostObj(body any) *Resp {
marshal, err := json.Marshal(body)
if err != nil {
return &ResponseWrapper{err: errors.New("解析json obj错误")}
return &Resp{err: errors.New("解析json obj错误")}
}
return r.PostJson(string(marshal))
}
func (r *RequestWrapper) PostForm(params string) *ResponseWrapper {
func (r *Req) PostForm(params string) *Resp {
buf := bytes.NewBufferString(params)
r.method = "POST"
r.body = buf
@@ -107,7 +107,7 @@ func (r *RequestWrapper) PostForm(params string) *ResponseWrapper {
return sendRequest(r)
}
func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M) *ResponseWrapper {
func (r *Req) PostMulipart(files []MultipartFile, reqParams collx.M) *Resp {
buf := &bytes.Buffer{}
// 文件写入 buf
writer := multipart.NewWriter(buf)
@@ -117,7 +117,7 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M)
if uploadFile.FilePath != "" {
file, err := os.Open(uploadFile.FilePath)
if err != nil {
return &ResponseWrapper{err: err}
return &Resp{err: err}
}
defer file.Close()
reader = file
@@ -127,18 +127,18 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M)
part, err := writer.CreateFormFile(uploadFile.FieldName, uploadFile.FileName)
if err != nil {
return &ResponseWrapper{err: err}
return &Resp{err: err}
}
io.Copy(part, reader)
}
// 如果有其他参数则写入body
for k, v := range reqParams {
if err := writer.WriteField(k, cast.ToString(v)); err != nil {
return &ResponseWrapper{err: err}
return &Resp{err: err}
}
}
if err := writer.Close(); err != nil {
return &ResponseWrapper{err: err}
return &Resp{err: err}
}
r.method = "POST"
@@ -150,8 +150,8 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M)
return sendRequest(r)
}
func sendRequest(rw *RequestWrapper) *ResponseWrapper {
respWrapper := &ResponseWrapper{}
func sendRequest(rw *Req) *Resp {
respWrapper := &Resp{}
timeout := rw.timeout
if timeout > 0 {
rw.client.Timeout = time.Duration(timeout) * time.Second
@@ -166,37 +166,37 @@ func sendRequest(rw *RequestWrapper) *ResponseWrapper {
}
setRequestHeader(req, rw.header)
resp, err := rw.client.Do(req)
return &ResponseWrapper{resp: resp, err: err}
return &Resp{resp: resp, err: err}
}
func setRequestHeader(req *http.Request, header map[string]string) {
req.Header.Set("User-Agent", "golang/mayfly")
req.Header.Set("User-Agent", "golang/mayfly-go")
for k, v := range header {
req.Header.Set(k, v)
}
}
type ResponseWrapper struct {
type Resp struct {
resp *http.Response
err error
}
// 将响应体通过json解析转为指定结构体
func (r *ResponseWrapper) BodyToObj(objPtr any) error {
// BodyTo 将响应体通过json解析转为指定结构体
func (r *Resp) BodyTo(ptr any) error {
bodyBytes, err := r.BodyBytes()
if err != nil {
return err
}
err = json.Unmarshal(bodyBytes, &objPtr)
err = json.Unmarshal(bodyBytes, &ptr)
if err != nil {
return fmt.Errorf("解析响应体-json解析失败-%s", err.Error())
}
return nil
}
// 将响应体转为strings
func (r *ResponseWrapper) BodyToString() (string, error) {
// BodyToString 将响应体转为strings
func (r *Resp) BodyToString() (string, error) {
bodyBytes, err := r.BodyBytes()
if err != nil {
return "", err
@@ -204,21 +204,20 @@ func (r *ResponseWrapper) BodyToString() (string, error) {
return string(bodyBytes), nil
}
// 将响应体通过json解析转为map
func (r *ResponseWrapper) BodyToMap() (map[string]any, error) {
// BodyToMap 将响应体通过json解析转为map
func (r *Resp) BodyToMap() (map[string]any, error) {
var res map[string]any
return res, r.BodyToObj(&res)
return res, r.BodyTo(&res)
}
// 获取响应体的字节数组
func (r *ResponseWrapper) BodyBytes() ([]byte, error) {
resp, err := r.GetHttpResp()
// BodyBytes 获取响应体的字节数组
func (r *Resp) BodyBytes() ([]byte, error) {
bodyReader, err := r.BodyReader()
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
defer bodyReader.Close()
body, err := io.ReadAll(bodyReader)
if err != nil {
return nil, fmt.Errorf("读取响应体数据失败-%s", err.Error())
@@ -226,8 +225,18 @@ func (r *ResponseWrapper) BodyBytes() ([]byte, error) {
return body, err
}
// 获取http响应结果结构体
func (r *ResponseWrapper) GetHttpResp() (*http.Response, error) {
// BodyReader 获取响应体的reader
func (r *Resp) BodyReader() (io.ReadCloser, error) {
resp, err := r.GetHttpResp()
if err != nil {
return nil, err
}
return resp.Body, nil
}
// GetHttpResp 获取http响应结果结构体
func (r *Resp) GetHttpResp() (*http.Response, error) {
if r.err != nil {
return nil, fmt.Errorf("请求失败-%s", r.err.Error())
}

Some files were not shown because too many files have changed in this diff Show More