目录
本文档中用到的前端资源
VUE2使用到的资源
<!--引入vue库-->
<script type="text/javascript" src="/static/plugins/vue/2.6.9/vue.min.js"></script>
<!--引入element-ui库-->
<script src="${base}/static/plugins/element-ui/2.15.8/index.min.js"></script>
<link rel="stylesheet" href="/static/plugins/element-ui/2.15.8/theme-chalk/index.min.css">
<!--网络请求框架-->
<script src="${base}/static/plugins/axios/1.7.3/axios.min.js"></script>
<script src="${base}/static/plugins/qs/6.6.0/qs.min.js"></script>
<script src="${base}/static/plugins/ms/3.0/ms.umd.js"></script>
<script src="${base}/static/plugins/ms/3.0/ms-el-form.umd.js"></script>
<script>
ms.base = "{ms:global.contextpath/}";
</script>
<script src="${base}/static/mdiy/index.js"></script>
VUE3使用到的资源
<!--网络请求框架-->
<script src="${base}/static/plugins/axios/1.7.3/axios.min.js"></script>
<script src="${base}/static/plugins/qs/6.6.0/qs.min.js"></script>
<script type="text/javascript" src="${base}/static/plugins/vue/3.4.25/vue.global.js"></script>
<script src="${base}/static/plugins/vue-i18n/8.18.2/vue-i18n.js"></script>
<!-- element-plus引入 -->
<link rel="stylesheet" type="text/css" href="${base}/static/plugins/element-plus/2.11.2/index.css"/>
<script src="${base}/static/plugins/element-plus/2.11.2/index.full.js"></script>
<script src="${base}/static/plugins/element-plus/2.11.2/zh-cn.js"></script>
<link rel="stylesheet" type="text/css" href="${base}/static/plugins/element-icons/icon.css"/>
<!--自定义-->
<script src="${base}/static/plugins/vue3-sfc-loader/0.9.5/vue3-sfc-loader.js"></script>
<script>
//必须先设置对象,否则自定义无法渲染
window.vue3SfcLoader = window["vue3-sfc-loader"];
</script>
<!--铭飞-->
<script src="${base}/static/plugins/ms/3.0/ms.umd.js"></script>
<script src="${base}/static/plugins/ms/3.0/ms-el-form.umd.js"></script>
<link rel="stylesheet" type="text/css" href="${base}/static/plugins/ms/3.0/ms-el-form.css"/>
<script src="${base}/static/mdiy/index.js"></script>
<script>
ms.base = "{ms:global.contextpath/}";
ms.manager = "{ms:global.contextpath/}";
/**
* 封装vue创建过程,方便初始化组件
* @param obj
* @returns vue实例
* @private
*/
function _Vue(obj) {
var app = Vue.createApp(obj);
app.config.globalProperties.ms = ms;
app.use(ElementPlus,{
locale: ElementPlusLocaleZhCn
});
app.use(MsElForm);
return app.mount(obj.el);
}
</script>
接口地址
5.4.3以下的版本使用 http://localhost:8080/swagger-ui.html
5.4.3及以上版本使用 http://localhost:8080/swagger-ui/index.html
(接口截图仅供参考)
Tip
application.yml 设置swagger-enable=true ,开启api接口文档,
重要说明
Tip
由于文档与插件都会不定期更新,如果出现实际使用的功能与文档描述不一致说明版本有更新,开源的产品可以在MStore更新对应的插件即可,付费用户在订单有效期内可以更新版本。
自定义手册
包含自定义字典、自定义搜索、自定义模型、自定义业务、自定义页面等功能
依赖
当前版本:
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mdiy</artifactId>
<version>当前版本</version>
</dependency>
版本更新说明
每天都在改变、从未停止过….
版本1.0.15-SNAPSHOT
- 【优化】更友好提示,更人性化操作
版本1.0.5-1.0.13
- 【修复】bug修复
- 【升级】后台使用饿了么UI升级
- 【升级】标签可配置化,标签采用SQL升级更加灵活
- 【升级】自定义模型、自定义业务使用代码生成器升级
- 【优化】自定义字典前后端使用灵活
版本1.0.4
- 【修复】bug修复
版本1.0.3
- 【新增】自定义菜单权限的控制
- 【修复】自定义模型相关bug
版本1.0.3-SNAPSHOT
- 【新增】自定义字典功能
版本1.0.2-SNAPSHOT
- 【升级】自定义后台管理表格优化成bootstrap-table样式
版本1.0.0-SNAPSHOT
-
【新增】自定义页面功能;
-
【新增】自定义业务功能;
-
【新增】自定义模型功能;
-
【新增】自定义搜索功能;
业务开发
自定义字典
自定义字典是管理数据属性的一个通用管理工具,主要是管理数据的状态或属性
比如一个订单有很多状态:已下单、已付款、已发货、已收货、已签收、取消。就可以通过绑定字典来进行处理。

Tip
系统中的下拉、单选、多选都可以通过字典来实现

通过代码生成器可以快速生成字典代码
DictUtil工具类
业务代码中使用字典工具类
参考工具类 net.mingsoft.mdiy.util.DictUtil.java
JavaScript 工具类(推荐)
获取某个类型的字典列表
...
使用前加上
<script src="/static/mdiy/index.js"></script>
var dictList = []
//获取 自定义页面类型 字典数据
ms.mdiy.dict.list('自定义页面类型').then(function(res) {
if (res.result) {
dictList = res.data.rows; //返回值赋值
}
})
...
获取字典列表中的 标签名 或 数据值
//根据 数据值 获取对应 标签名
ms.mdiy.dict.getLabel(this.dictList,'people'); //返回 会员 中文
//根据 标签名 获取对应 数据值
ms.mdiy.dict.getValue(this.dictList,'会员'); //返回 peopl
HTTP请求接口
如果 JavaScript 工具类 无法满足业务需求,可以使用 HTTP 请求方式。
HTTP方法:GET
请求:/mdiy/dict/list.do
参数:
| 参数 | 是否必选 | 类型 | 可选值范围 | 默认值 | 说明 |
|---|---|---|---|---|---|
| dictType | 否 | String | 类型 | ||
| dictLabel | 否 | String | 标签名 | ||
| isChild | 否 | String | 子业务关联 |
返回
{
"result":true,
"code":200,
"data":{
"total":2,
"rows":[
]
}
}
范例
下拉框应用
<!--自定义页面类型分类-->
<html>
<head>
<!-- 注意这里省略了基础库的引入,具体参考本文档的 目录 章节 -->
</head>
<body >
<div id="app">
<select>
<option>请选择</option>
<option v-for="item in dictList" value="item.dictLabel">{{item.dictLabel}}</option>
</select>
</div>
</body>
</html>
<script>
var app = new Vue({
el: '#app',
data: {
dictList: []
},
created() {
var that = this;
//获取自定义页面类型
ms.http.get("/ms/mdiy/dict/list.do", {
//自定义字典类型值,可选择现有的"自定义页面类型",也可以输入新的类型
dictType: '自定义页面类型'
}).then(function (data) {
if (data.result) {
that.dictList = data.data.rows;
}
})
}
})
</script>
Tip
提供
dictType可以获取当前类型下的所有字典,
前端特殊用法
可以直接使用ms-dict组件在前端页面增加字典,不需要进入自定义字典菜单进行配置

属性:
| 属性名 | 是否必选 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
| dictType | 是 | String | 字典类型 | |
| placeholder | 否 | String | 请选择 | 占位符 |
| clearable | 否 | Boolean | true | 是否支持清空 |
| filterable | 否 | Boolean | false | 是否支持搜索 |
| disabled | 否 | Boolean | false | 是否禁用 |
| multiple | 否 | Boolean | false | 是否支持多选 |
| multipleLimit | 否 | Number | 0 | 多选数量,multiple为true时生效,为 0 则不限制 |
| loading | 否 | Boolean | false | 是否支持搜索 |
| style | 否 | Boolean | false | 是否支持搜索 |
使用方法
<#include "mdiy/components/ms-dict.ftl">
<ms-dict v-model="form.contentTags"
:style="{width: ''}"
:filterable="true"
:disabled="false"
dict-type="文章标签"
:multiple-limit="5"
:multiple="true" :clearable="true"
placeholder="请选择文章标签">
</ms-dict>
<script>
var contentForm = Vue.defineComponent({
components:{
MsDict
},
})
</script>
自定义业务
零代码实现一张表的增、删、改、查业务。
通过 代码生成器 导入自定义业务,可以快速实现基础后台数据管理,也可以实现表单数据提交,

通过代码生成器设计好配置表单

导入模型后直接后台就可以录入数据,可以实现日常简单的数据收集工作。
Tip
可以设置为前台游客模式提交数据,就可以实现留言反馈、报名收集等一些基本的信息收集
范例
快速创建一个业务数据管理
-
代码生成器拖拽留言表单 -
代码预览自定义模型复制自定义模型json代码 -
自定义业务
导入 代码生成器中的自定义模型json代码 -
数据预览就可以进行增、删、改、查操作 -
表单预览可以展示代码生成器生成的表单
Tip
通过
复制菜单JSON在权限管理菜单管理导入到一个已有到菜单中,通过这种方式可以快速实现基本数据管理业务系统
在线留言制作
首先,修改yml配置,ms.xss.exclude-url中添加/**/mdiy/model/**
再使用 代码生成器 拖拽出留言的表单,导入 自定义业务,选择允许前端提交
- 第一种方法:通过提供的js库,动态渲染自定义业务表单
VUE2下动态渲染写法
<!-- 注意这里省略了基础库的引入,具体参考本文档的 目录 章节 -->
<!-- 引入自定义库 -->
<script src="/static/mdiy/index.js"></script>
<div id="form" v-cloak>
<div id="formModel">
<!--会自动渲染代码生成器的表单-->
</div>
<!--必须包含验证码-->
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large" label-width="120px">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="验证码" prop="rand_code">
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<div style="display: flex; height: 38px;justify-content: center; align-items: center; cursor: pointer">
<img :src="verifCode" class="code-img" @click="code"/>
<div @click="code" style="margin-left: 10px">
看不清?换一张
</div>
</div>
</el-col>
</el-row>
<el-form-item label=" ">
<el-button @click="save" type="primary" :loading="isLoading" style="width: 200px">
{{isLoading?'保存中':'保存'}}
</el-button>
</el-form-item>
</el-form>
</div>
JavaScript
<script>
var form = new Vue({
el: '#form',
data: {
formModel: undefined, //自定义业务的vue对象
verifCode: "/code?t=" + new Date().getTime(),
isLoading: false,
form: {
rand_code:''
},
rules: {
rand_code: [
{required: true, message: '请输入验证码', trigger: 'blur'},
{min: 1, max: 4, message: '长度不能超过4个字符', trigger: 'change'}
],
},
},
methods: {
save: function() {
var that = this;
that.isLoading = true;
//将验证码值复制到自定义模型
window.formModel.form.rand_code = this.form.rand_code;
//调用自定义模型的保存
window.formModel.save(function(res) {
if (res.result) {
that.$notify({
title: '成功',
type: 'success',
message: '保存成功!'
});
window.formModel.form = {}
that.form.rand_code = "";
that.$refs.form.resetFields(); //清空表单
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
that.isLoading = false;
that.code();
});
},
code: function () {
this.verifCode = ms.base + "/code?t=" + (new Date).getTime();
}
},
created: function() {
window.formVue = this;
this.$nextTick(function () {
ms.mdiy.model.form("formModel", { "modelName": "留言板" }).then(function(obj) {
window.formModel = obj;
window.formModel.form.modelName = obj.modelName;
});
});
}
});
</script>
Tip
modelName(业务名称)必须与代码生成器上必须名称一致。
特殊用法,一个页面有多个自定义业务表单(仅5.5.0及以上版本支持)
// 在第一个模型下面增加以下代码就可以实现一个页面渲染多个自定义业务表单
// 注意id不能相同
<div id="testForm" v-cloak>
<div id="testModelForm">
<!--会自动渲染代码生成器的表单-->
</div>
<!--必须包含验证码-->
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large" label-width="120px">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="验证码" prop="rand_code">
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<div style="display: flex; height: 38px;justify-content: center; align-items: center; cursor: pointer">
<img :src="verifCode" class="code-img" @click="code"/>
<div @click="code" style="margin-left: 10px">
看不清?换一张
</div>
</div>
</el-col>
</el-row>
<el-form-item label=" ">
<el-button @click="save" type="primary" :loading="isLoading" style="width: 200px">
{{isLoading?'保存中':'保存'}}
</el-button>
</el-form-item>
</el-form>
</div>
<script>
//vue的实例名称必须为 form
var test = new Vue({
el: '#testForm',
data: {
testModelForm: undefined, //自定义业务的vue对象
verifCode: "/code?t=" + new Date().getTime(),
isLoading: false,
form: {
rand_code:''
},
rules: {
rand_code: [
{required: true, message: '请输入验证码', trigger: 'blur'},
{min: 1, max: 4, message: '长度不能超过4个字符', trigger: 'change'}
],
},
},
methods: {
save: function() {
var that = this;
that.isLoading = true;
//将验证码值复制到自定义模型
// 改成自定义业务的vue对象
window.testModelForm.form.rand_code = this.form.rand_code;
//调用自定义模型的保存
window.testModelForm.save(function(res) {
if (res.result) {
that.$notify({
title: '成功',
type: 'success',
message: '保存成功!'
});
window.formModel.form = {}
that.form.rand_code = "";
that.$refs.form.resetFields(); //清空表单
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
that.isLoading = false;
that.code();
});
},
code: function () {
this.verifCode = ms.base + "/code?t=" + (new Date).getTime();
}
},
created: function() {
this.$nextTick(function () {
// 挂载的dom节点需要区分
ms.mdiy.model.form("testModelForm", { "modelName": "测试模型" }).then(function(obj) {
window.testModelForm = obj;
window.testModelForm.form.modelName = obj.modelName;
});
});
}
});
</script>
VUE3下动态渲染写法
<!-- 注意这里省略了基础库的引入,具体参考本文档的 目录 章节 -->
<script>
ms.base = "{ms:global.contextpath/}";
</script>
HTML
<div id="form" v-cloak>
<ms-mdiy-form style="height: auto; !important;" ref="modelForm" type="form" :model-name="modelName"></ms-mdiy-form>
<!--必须包含验证码-->
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large" label-width="120px">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="验证码" prop="rand_code">
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<div style="display: flex; height: 38px;margin-left: 1em; align-items: center; cursor: pointer">
<img :src="verifCode" class="code-img" @click="code"/>
<div @click="code" style="margin-left: 10px">
看不清?换一张
</div>
</div>
</el-col>
</el-row>
<el-form-item label="">
<el-button @click="save" type="primary" :loading="isLoading" style="width: 200px">
{{isLoading ? '保存中' : '保存'}}
</el-button>
</el-form-item>
</el-form>
</div>
JavaScript
<script>
var form = new _Vue({
el: '#form',
data: function(){
return{
modelName: '留言板'; //modelName需要填写自己的模型昵称
verifCode: ms.base + "/code?t=" + new Date().getTime(),
isLoading: false,
form: {
rand_code: ''
},
rules: {
rand_code: [
{required: true, message: '请输入验证码', trigger: 'blur'},
{min: 1, max: 4, message: '长度不能超过4个字符', trigger: 'change'}
],
},
}
},
methods: {
save: function () {
var that = this;
that.isLoading = true;
var formModel = that.$refs.modelForm.getForm();
//将验证码值复制到自定义模型
formModel.form.rand_code = this.form.rand_code;
//调用自定义模型的保存
formModel.save(function (res) {
if (res.result) {
that.$notify({
title: '成功',
type: 'success',
message: '提交成功!'
});
// 清空表单
formModel.form = {};
that.$refs.form.resetFields();
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
that.isLoading = false;
that.code();
});
},
code: function () {
this.verifCode = ms.base + "/code?t=" + (new Date).getTime();
}
},
created: function () {}
});
</script>
- 第二种方法: 开发者自己编写表单提交数据
<!-- 注意这里省略了基础库的引入,具体参考本文档的 目录 章节 -->
<!-- 引入自定义库 -->
<script>
ms.base = "{ms:global.contextpath/}";
</script>
HTML
<div id="form">
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large">
<el-form-item label="名字" prop="username">
<el-input
v-model="form.username"
:disabled="false"
:readonly="false"
:style="{width: '100%'}"
:clearable="true"
placeholder="请输入名字">
</el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="form.email"
:disabled="false"
:readonly="false"
:style="{width: '100%'}"
:clearable="true"
placeholder="请输入邮箱">
</el-input>
</el-form-item>
<el-form-item label="留言" prop="content">
<el-input
type="textarea" :rows="5"
:disabled="false"
v-model="form.content"
:style="{width: '100%'}"
placeholder="请输入留言">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="rand_code">
<el-row>
<el-col :span="10">
<el-input v-model="form.rand_code"
:disabled="false" :readonly="false" :clearable="true"
placeholder="请输入验证码">
</el-input>
</el-col>
<el-col :span="6">
<img height="40px" :src="verifCode" @click="code" style="margin-left: 10px"/>
</el-col>
</el-row>
</el-form-item>
<el-form-item label=" ">
<el-button @click="save" type="primary" :loading="isLoading" style="width: 200px">
{{isLoading?'提交留言中':'提交留言'}}
</el-button>
</el-form-item>
</el-form>
</div>
JavaScript
<script>
var form = new Vue({
el: '#form',
data: function () {
return {
verifCode: "/code.do?" + new Date().getTime(),
isLoading: false,
form: {
modelName: "留言板", //modelName需要填写自己的模型昵称
// 名字
username:'',
// 邮箱
email:'',
// 留言
content:'',
// 验证码
rand_code:''
},
rules:{
// 名字
username: [{"required":true,"message":"名字不能为空"},{"min":0,"max":255,"message":"名字长度必须为0-255"}],
// 邮箱
email: [{"required":true,"message":"邮箱不能为空"},{"pattern":/^\w+((-\w+)|(\.\w+))*@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/,"message":"邮箱格式不匹配"},{"min":0,"max":255,"message":"邮箱长度必须为0-255"}],
// 留言
content: [{"required":true,"message":"留言不能为空"}],
// 验证码
rand_code: [{"required":true,"message":"验证码不能为空"}],
},
}
},
methods: {
code: function () {
this.verifCode = "/code.do?" + new Date().getTime();
},
save: function (e) {
e.preventDefault();//阻止默认事件跳转
var that = this;
that.$refs.form.validate(function (valid) {
if (valid) {
// 通过模型渲染的表单 则放开下面的注释(3行)
//1 window.formModel.form.rand_code = that.form.rand_code;
//2 ms.http.post("/mdiy/form/data/save.do", window.formModel.form).then(function (res) {
ms.http.post("/mdiy/form/data/save.do", that.form).then(function (res) {
if (res.result) {
that.$notify({
title: '成功',
type: 'success',
message: '感谢您的参与!'
});
//3 window.formModel.form = {}
that.form.rand_code = "";
that.$refs.form.resetFields(); //清空表单
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
that.code();
})
}
})
}
}
})
</script>
Tip
modelName(业务名称)必须代码生成器的名称一致,否则保存不成功前台可以通过调用接口获取留言数据,具体查看swagger接口中前端-自定义模块接口
前台获取自定义业务表单数据
data: function () {
return {
form: {
modelName: "在线留言", //modelName需要填写自己的模型昵称
},
datalist: [], // 自定义业务返回的数据集
total: 1, // 自定义业务返回的数据总数
}
},
methods:{
// 前台获取自定义业务数据
list: function(){
var that = this
ms.http.get("/mdiy/form/data/list.do",{modelName:that.form.modelName}).then (function (data){
if (res.result) {
that.datalist = res.data.rows
that.total=res.data.total
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
})
},
}
其他数据库导入模型
代码生成器已推出主流数据库的模板使用,在代码生成器的升级服务即可查看

代码预览选择需要的数据库模板

自定义配置
快速实现基本配置管理业务,例如:系统设置、功能设置、上传设置等,这些配置的参数通常 ConfigUtil 工具类可以很方便在业务代码中使用。

通过代码生成器设计好配置表单

通过自定义>自定义配置导入或更新模型

在代码中通过 ConfigUtil 读取对应配置值。
Tip
企业版本以上支持从缓存读取
ConfigUtil工具类
提供多种 get 方法获取配置中的值,下面列出 configutil 中的方法代码片段
getString
/**
* 返回字符串类型的数据
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @param key 对应代码生成器中的字段名称 注意:名称是驼峰式
* @return 无匹配返回空
*/
public static String getString(String configName,String key) {
...
}
/**
* 返回字符串类型的数据
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @param key 对应代码生成器中的字段名称 注意:名称是驼峰式
* @param defaultValue 默认值,如果配置中没有值,会返回默认值
* @return 无匹配返回默认值
*/
public static String getString(String configName,String key, String defaultValue) {
...
}
getInt
/**
* 返回整型类型的数据
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @param key 对应代码生成器中的字段名称 注意:名称是驼峰式
* @return 无匹配返回0
*/
public static int getInt(String configName,String key) {
...
}
/**
* 返回整型类型的数据
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @param key 对应代码生成器中的字段名称 注意:名称是驼峰式
* @param defaultValue 默认值,如果配置中没有值,会返回默认值
* @return 无匹配返回默认值
*/
public static int getInt(String configName,String key,int defaultValue) {
...
}
getObject
/**
* 如果不确定返回类型,可以使用 getObject
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @param key 对应代码生成器中的字段名称 注意:名称是驼峰式
* @return 无匹配返回null
*/
public static Object getObject(String configName,String key) {
...
}
getMap
/**
* 获取configName完整配置数据,通过一次性获取所有配置,避免重复传递 configName
* @param configName 配置名称 对应自定义配置列表上的 配置名称 字段
* @return map
*/
public static Map getMap(String configName) {
...
}
ConfigUtil范例(后端)
在业务中可能会遇到这样的场景,需要能控制某个功能是否启用,或者需要控制后台管理员可登录的时间范围
例如下面是政务版本 ManagerLoginCredentialsMatcher.java 代码片段
/**
* 锁住时间判断, 账号锁住超时则可以登录,超时时间在后台“安全设置”菜单中设置
* @param date 最后登录时间
* @return false 未超时,不可登录;true 超时,可以继续登录
*/
private boolean isLockLastLoginTime(Date date){
...
//ConfigUtil.getInt("安全设置","timeout")
Date newDate = DateUtil.offsetHour(date, ConfigUtil.getInt("安全设置","timeOut"));
...
}
基本使用步骤
1.在代码生成器中制作好模型导入至后台自定义配置
2.给配置初始化参数
3.使用ConfigUtil类取值时,注意所取参数以及配置名称需要一致
4.例如下面是以 该自定义配置 来控制静态化主页是否可用的范例代码

Tip
需要注意字段名!!! 假设 代码生成器的字段名下划线命名 例如:
time_out,ConfigUtil对应的 key 参数 为驼峰命名,例如:timeOut,非下划线定义方式与代码生成器字段命名一致。
ms.mdiy.config 方法
/**
* 获取自定义配置
* @param configName:配置名称
* @paramk key:配置的key值(可选),key == null 可以获取所有的配置
* @param isSystem true 后台调用会拼接后台地址 false前台调用
*/
function config(configName, key, isSystem)
ms.mdiy.config范例(前端)
以下为企业版 login.ftl 代码片段
getuiConfig: function () {
var that = this;
ms.mdiy.config("后台UI配置",'uiLoginSlogin').then(function (res){
that.uiConfig.uiLoginSlogin = JSON.parse(res.data)[0].url
})
ms.mdiy.config("后台UI配置",'uiLoginBg').then(function (res){
that.uiConfig.uiLoginBg = JSON.parse(res.data)[0].url
})
// 以上方法可以使用一次调用解决, 只允许第三个参数等于true的情况下才允许获取所有配置
ms.mdiy.config("后台UI配置",null,true).then(function (res){
that.uiConfig = res.data
that.uiConfig.uiLogo = JSON.parse(res.data.uiLogo)[0].url
})
}
Tip
需要注意ms方法使用的异步方法,建议在配置加载完毕的时候进行调用
默认值说明
导入新配置模型时,表单会按照代码生成器中表单项设计的默认值去填写,点击保存即可按照表单填写的默认值去保存


注意,在更新模型时,即使新增加的表单项有默认值,在配置模型已经新增产生数据的情况下,新增加的字段不会显示默认值,需要手动赋值


自定义模型
可以快速扩展现有的业务表数据,例如:文章内容字段不满足,可以通过 文章栏目 来绑定模型,还有 会员管理 的 会员扩展信息。
Tip
核心设计思想,主从表
一对一

通过代码生成器设计好配置表单
导入自定义模型
Tip
这里选择的是
文章类型模型,只会在内容管理模块生效
其他数据库导入模型
使用对应数据库的模板

手动创建
如果是非mysql数据库,但模型中json的sql项为mysql语法(开启数据库忽略大小写)
- 在数据库中手动创建表,表名需要为MDIY_MODEL_ + 代码生成器业务表名,字段和代码生成器中拖拽的保持一致
- 将模型json中的sql项置为空串 “sql”: “”, 再导入模型即可
Tip
不支持不同数据库之间的模型json导入;
eg:mysql服务下的自定义模型json,无法导入到其他数据库服务的自定义模型
栏目绑定模型
在栏目下新增文章就会多出一个文章扩展的tab选项卡
JavaScript 工具类
参考 src/main/webapp/static/mdiy/index.js
Tip
具体参考
范例中的描述
扩展一张表的信息(范例)
先通过 代码生成器 拖拽出需要的表单项,导入 自定义模型
后台获取自定义模型信息
基于后台UI规范通过 Tabs 标签页 控件实现
下面代码片段来自 会员插件 的 src/main/webapp/WEB-INF/manager/people-user/form.ftl页面
库
<script src="${base}/static/mdiy/index.js"></script>
HTML片段
...
<el-tabs v-model="activeName">
<el-tab-pane label="基本信息" name="基本信息">
...
</el-tab-pane>
<el-tab-pane label="扩展信息" name="扩展信息">
<!--关键点1-->
<!-- 后台 使用自定义组件渲染模型 -->
<ms-mdiy-form v-if="modelId!=null" ref="modelForm" type="model" :model-id="modelId" :id="form.peopleId+''"></ms-mdiy-form>
</el-tab-pane>
</el-tabs>
...
JavaScript片段
<script>
var form = new Vue({
el: '#form',
data: function() {
// data 代码片段
return {
//关键点2:定义自定义模型变量
modelId:null,
};
},
methods: {
// save 代码片段
save: function() {
// 关键点3、判断自定义模型的表单是否通过校验
var formValid = false;
if(that.$refs.modelForm) {
await that.$refs.modelForm.$refs.form.$refs.form.validate((valid,fields) => {
formValid = valid;
})
if(!formValid) {
that.activeName = '扩展信息';
return;
}
}
//保存用户信息
ms.http.post(url, data).then(function(data) {
if (data.result) {
// 关键点4,自定义模型保存
// 注意:也可参考下方前台保存关键点4的数据一次保存
if(that.$refs.modelForm) {
that.$refs.modelForm.$refs.form.form.linkId = res.data.id;
that.$refs.modelForm.getForm().save(function (resModel) {
if(resModel.result){
//模型保存成功
}else {
//模型保存失败
}
});
}
});
}
},
created: function() {
// created 代码片段
var that = this;
//关键点5,加载自定义模型,modelName:模型名称,linkId:
this.$nextTick(function (){
ms.http.get(ms.manager + "/mdiy/model/get.do", {"modelName":"扩展会员信息"}).then(function (res) {
if (res.result && res.data) {
that.modelId = res.data.id;
}
});
})
}
});
</script>
Tip
注意代码片段中提示的
关键点1到5
前台获取自定义模型
下面代码片段来自 政务版 的 src\main\webapp\template\1\out\people\content-form.htm会员投稿页面
<!-- 引入自定义js -->
<script src="${base}/static/mdiy/index.js"></script>
<div id="app" v-cloak >
...
<!-- 关键点1 定义渲染的dom节点 -->
<div v-if="item.title!='文章编辑'" id="peopleContentModel" ref="modelForm">
<!--会自动渲染代码生成器的表单-->
</div>
...
</div>
<script>
// vue实例的名称必须是form
var form = new Vue({
el: '#app',
})
data:{
···
// 关键点2
peopleContentModel:{} // 存储模型对象
···
}
methods:{
···
save:function (){
// 关键点3 自定义模型规则验证
if (that.peopleContentModel && !that.peopleContentModel.validate()) {
this.activeName = 'custom-name';
return;
}
···
let data = JSON.parse(JSON.stringify(that.form));
// 关键点4 组织模型数据传递到后台,一次保存
// 注意 如果需要主数据与模型数据一次保存,确保后端服务主业务保存时,并调用模型保存接口modelDataBiz.saveOrUpdate(linkid);
if (that.peopleContentModel) {
data.modelData= that.peopleContentModel.getFormData()
data.modelData = JSON.stringify(data.modelData);
}
ms.http.post(url, data).then(function (data) {
if (data.result) {
}
})
···
}
changeModel: function (categoryId) {
// 关键点5 渲染模型以及获取模型数据
// 注意 前台出于数据安全考虑未提供通用的模型以及模型数据获取接口,需要开发者自行新增接口,可参考后台模型数据获取接口;必须在前台的action限制模型类型
that.$nextTick(function () {
// 重新设置模型获取数据url
var modelDateUrlGet;
if (that.form.id) {
modelDateUrlGet = {
"url": "/people/cms/content/model/data.do",
"params": {"linkId": that.form.id, "modelId": _category[0].mdiyModelId}
}
}
//ms.mdiy.model.getModelData(domid,模型对象(模型id或模型名称),模型数据请求参数,模型数据获取地址)
// 具体可查看该方法上的参数注释说明
ms.mdiy.model.getModelData('peopleContentModel', { "id": _category[0].mdiyModelId }, modelDateUrlGet, '/people/cms/content/model/get.do').then(function(obj) {
that.peopleContentModel = obj;
});
})
}
}
···
</script>
// 前台自定义模型获取示例
public ResultData get(ModelEntity modelEntity, HttpServletResponse response, HttpServletRequest request) {
···
// 设置当前业务可获取的自定义模型类型
String modelType = DictUtil.getDictValue("自定义模型类型","文章");
if(StringUtils.isBlank(modelType)){
return ResultData.build().error("自定义模型类型名称为文章的字典不存在")
}
modelEntity.setModelType(modelType);
modelEntity.setModelCustomType(ModelCustomTypeEnum.MODEL.getLabel());
ModelEntity model = modelBiz.getOne(new QueryWrapper<>(modelEntity));
···
return ResultData.build().success(model);
}
// 前台自定义模型数据示例
public ResultData data(String modelId,String linkId,HttpServletResponse response,HttpServletRequest request,ModelMap modelMap){
···
// 检查SQL注入
SqlInjectionUtil.filterContent(modelId);
SqlInjectionUtil.filterContent(linkId);
// 设置当前业务可获取的自定义模型类型
ModelEntity model = modelBiz.getOne(new LambdaQueryWrapper<ModelEntity>().eq(ModelEntity::getId, modelId).eq(ModelEntity::getModelCustomType, ModelCustomTypeEnum.MODEL.getLabel())
.eq(ModelEntity::getModelType, DictUtil.getDictValue("自定义模型类型","文章")));
···
return ResultData.build().success(modelDataBiz.getModelDataByLinkId(model,linkId));
}
// 业务主数据与模型数据一次保存,如 在文章保存的biz业务中
@Transactional(rollbackFor = Exception.class)
public boolean saveOrUpdate(ContentEntity content) {
// 保存文章数据
boolean flag = contentDao.insertOrUpdate(content);
// 保存模型数据 在其他业务场景同样只需调用模型保存方法即可,参数为主数据的id
// 注意: 前端模型数据必须要调用 xxx.getFormData()获取,才能使用这个方法
modelDataBiz.saveOrUpdate(content.getId());
return flag;
}
Tip
注意代码片段中提示的
关键点1到5
文章内容扩展(范例)
文章扩展是另外一种业务场景,是通过 栏目绑定扩展模型,间接的通过业务代码实现文章内容扩展
具体参考 MCms手册常见问题中标签问题 相关案例
自定义页面
零Java代码实现一个动态页面,通过后台配置绑定对应模版方式实现。可以实现专题页、活动页的效果
新增页面时候选择对应的模版
通过列表上生成的地址就可以动态访问对应的模版
Tip
自定义页面可以灵活获取参数传递,例如:可以在自定义页面路径后添加参数,如
a.do?id=1,对应的模版使用${id}方式获取,针对get、post方法有效,推荐使用get、post方法,也可以通过 js 工具类ms.util.getParameter("id")获取,针对get方法有效,推荐使用get方法。
自定义标签
基于 freemarker 扩展的 ms标签

可以参考 freemarker文档 来编写标签
Tip
开发版本以上提供标签管理的功能,可以灵活地扩展标签
全局自定义标签
快速实现在模板上使用自定义标签,快速取出定义的数据,允许模板全局使用。
使用方法
- 通过铭软官网提供的代码生成器中制作好

- 点击新增自定义标签

- 点击数据配置,输入数据

- 点击预览,选择需要显示的字段,点击可复制

- 在模板中使用,就可以取出相对应数据

Tip
在同一个标签名称且模型名称一致,如果有相同的字段名称,则后者覆盖前者 不能和自定义配置使用表名的模型
常见问题
模型渲染失败
首先请检查插件版本是否为最新版。如果已是最新版本但仍出现模型渲染失败的情况,请在代码生成器中重新生成相关代码,然后更新模型并再次查看模型渲染结果。
自定义业务前台提交一直提示验证码错误
- 请检查是否正确向后端传递了验证码参数。
- 验证码必须是由
ms.base + "/code.do"接口返回的值。
介绍
包含会员注册、登录、取回密码、修改资料等基本功能
依赖
当前版本:
<!--会员插件-->
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mpeople</artifactId>
<version>当前版本</version>
</dependency>
Tip
注册、取回密码需要依赖发送消息插件,可以直接在MStore进行安装
配置
-
在 src/main/java/config/ShiroConfig中放开以下注释(开源6.0.3及以上,会员依赖3.0.3及以上)

-
修改
WebConfig,在addInterceptors增加会员拦截器配置(6.0.3以下)
public void addInterceptors(InterceptorRegistry registry) {
///mdiyPage/login.do 通过自定义页面定义会员登陆界面,如果没有登陆会跳转到登陆页面。
registry.addInterceptor(new net.mingsoft.people.interceptor
.ActionInterceptor("/mdiyPage/login.do"))
.addPathPatterns("/people/**");
}
接口
http://localhost:8080/swagger-ui.html#/前端-会员模块接口 http://localhost:8080/swagger-ui.html#/前端-用户-会员模块接口
Tip
application.yml 中开启swagger ,api接口文档才能访问
通过Http请求对方式调用接口,每个接口只是列出返回数据的格式,请求方式以及参数参考swagger api。
注意接口主要分需要用户登陆与不需要用户登陆,例如:路径格式people/*.do 表示需要用户登陆进行请求操作,非people/*.do的接口表示不需要用户登陆就能进行请求操作。
Tip
如果需要快速使用,不想花时间阅读文档与研究代码可以直接在平台采购
开发版代码,开发版会提供会员插件中所有的代码。
自定义页面
登录、注册、忘记密码、修改个人信息、修改密码,都是通过自定义页面进行配置
Tip
业务开发章节的代码都是基于element-ui库;
会员登录后的模版文件推荐存放在模版中的people目录;
会员登录后的自定义页面路径统一使用people/*.do定义;
版本更新说明
每天都在改变、从未停止过….
版本2.1.7
- 【新增】会员扩展模型字段
- 【优化】升级MStore插件
版本1.0.18
- 【优化】界面UI升级
版本1.0.13
- 【优化】界面UI升级
版本1.0.4
- 【升级】稳定版本发布,避免依赖失败的情况
版本1.0.3-SNAPSHOT
- 【修复】会员管理的删除、审核报错
版本1.0.2-SNAPSHOT
- 【升级】会员中心后台管理bootstrap-table升级;
- 【新增】会员菜单权限控制
版本1.0.0-SNAPSHOT
-
【新增】会员管理功能;
-
【新增】登录、注册、个人中心接口
业务开发
Tip
可以直接在MStore安装
会员插件配套默认皮肤快速使用
登录
自定义页面 路径 /mdiyPage/login.do 对应模版 login.htm
Tip
/mdiyPage/login.do可以根据实际业务需求修改名称login.htm也可以根据实际业务需求修改名称
HTML
<!--表单位置-start-->
<div id="app">
<div @keydown.13='login'>
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large" label-width="130px">
<el-form-item label="登录名" prop="peopleName">
<el-input v-model="form.peopleName" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入登录名">
</el-input>
</el-form-item>
<el-form-item label="登录密码" prop="peoplePassword">
<el-input v-model="form.peoplePassword" :disabled="false" :readonly="false" :clearable="true" type="password" placeholder="请输入登录密码">
</el-input>
</el-form-item>
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="验证码" prop="rand_code">
<el-input v-model="form.rand_code" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入验证码">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<div>
<img :src="verifCode" @click="code" />
<div @click="code">
看不清?换一张
</div>
</div>
</el-col>
</el-row>
<el-form-item label=" ">
<el-button @click="login" type="primary" :loading="loading">
{{loading?'登录中':'立即登录'}}
</el-button>
<div>
<el-link href="/mdiyPage/resetPassword.do" :underline="false" icon="el-icon-warning-outline">忘记密码
</el-link>
<el-link href="/mdiyPage/register.do" :underline="false" icon="el-icon-user">账号注册</el-link>
</div>
</el-form-item>
</el-form>
</div>
</div>
JavaScript
<!--js位置-start-->
<script>
var app = new Vue({
el: '#app',
watch: {},
data: {
loading: false,
verifCode: ms.base + "/code?t=" + new Date().getTime(),
saveDisabled: false,
//表单数据
form: {
// 验证码
rand_code: '',
// 登录名
peopleName: '',
// 登录密码
peoplePassword: '',
},
rules: {
// 登录名
peopleName: [{
"required": true,
"message": "登录名不能为空"
}, { "pattern": "^[^[!@#$%^&*()_+-/~?!@#¥%…&*()——+—?》《:“‘’]+$", "message": "登录名格式不匹配" }, {
"min": 1,
"max": 30,
"message": "登录名长度必须为1-30"
}],
// 登录密码
peoplePassword: [{
"required": true,
"message": "登录密码不能为空"
}, { "pattern": "^[^[!@#$%^&*()_+-/~?!@#¥%…&*()——+—?》《:“‘’]+$", "message": "登录密码格式不匹配" }, {
"min": 1,
"max": 30,
"message": "登录密码长度必须为1-30"
}],
rand_code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 1, max: 4, message: '长度不能超过4个字符', trigger: 'change' }
],
},
},
methods: {
//获取验证码
code: function() {
this.verifCode = ms.base + "/code?t=" + new Date().getTime();
},
//登录
login: function() {
var that = this;
//表单验证
that.$refs.form.validate(function(valid) {
if (valid) {
//更新加载状态
that.loading = true;
//请求验证接口
ms.http.post("/checkLogin.do", that.form).then(function(res) {
//成功
if (res.result) {
that.$notify({
title: '成功',
message: '登录成功',
type: 'success'
});
//重定向调整地址
var url = ms.util.getParameter("url");
if (url) {
url = decodeURIComponent(url);
var linkIndex = url.lastIndexOf("link=");
if (linkIndex > 0) {
var link = url.substring(url.lastIndexOf("link=") + 5)
url = decodeURIComponent(link);
}
} else {
url = "/people/peopleInfo.do";
}
window.location.href = url;
}
//更新加载状态
that.loading = false;
}).catch(function(err) {
that.code();
that.loading = false;
});
}
});
}
},
created: function() {
this.code();
}
})
</script>
<!--js位置-end-->
注册
自定义页面 路径 /mdiyPage/register.do对应模版 register.htm
HTML
<div id="app" v-cloak>
<!--表单位置-start-->
<el-form ref="form" :model="form" :rules="rules" label-position="right" size="large" label-width="120px">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="用户名" prop="peopleName">
<el-input v-model="form.peopleName" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入用户名">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="peoplePhone">
<el-input v-model="form.peoplePhone" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入手机号">
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="密码" prop="peoplePassword">
<el-input type="password" :show-password="true" :clearable="true" v-model="form.peoplePassword" :style="{width:'100%'}" :disabled="false" placeholder="请输入密码"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="rePeoplePassword" label="确认密码" class="input">
<el-input type="password" v-model="form.rePeoplePassword" :show-password="true" :clearable="true" placeholder="请再次输入新密码">
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="0" justify="start" align="top">
<el-col :span="12">
<el-form-item label="验证码" prop="rand_code" class="input">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="14">
<el-input v-model="form.rand_code" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入验证码">
</el-input>
</el-col>
<el-col :span="10">
<div style="display: flex; height: 38px;">
<img :src="verifCode" class="code-img" @click="code" style="cursor: pointer" alt="点击换一张" />
<div @click="code" style="margin-left: 10px">
</div>
</div>
</el-col>
</el-row>
</el-form-item>
</el-col>
<el-col :span="12">
<!--邮箱验证start-->
<el-form-item label="-邮箱验证码" prop="peopleCode" class="input">
<el-row :gutter="0" justify="start" align="top">
<el-col :span="13">
<el-input v-model="form.peopleCode" :disabled="false" :readonly="false" :clearable="true" placeholder="请输入验证码">
</el-input>
</el-col>
<el-col :span="11">
<el-button v-text="sendPeopleCodeMsg" :disabled="sendPeopleCodeDisabled" type="info" plain @click="getPeopleCode" style="width: 100px; padding: 12px 0;margin-left: 10px ">
</el-button>
</el-col>
</el-row>
</el-form-item>
</el-col>
</el-row>
<el-form-item label=" " class="input">
<el-button @click="register" type="primary" :loading="saveDisabled">
{{saveDisabled?'提交中':'立即注册'}}
</el-button>
<div>
<el-link href="/mdiyPage/login.do" :underline="false" icon="el-icon-user">账号登录</el-link>
</div>
</el-form-item>
</el-form>
<!--表单位置-end-->
</div>
JavaScript
<script>
var validatePass = function(rule, value, callback) {
if (value !== app.form.peoplePassword) {
callback(new Error('两次输入密码不一致!'));
} else {
callback();
}
};
var app = new Vue({
el: '#app',
watch: {},
data: {
//验证码计时
countdown: 0,
//发送邮箱验证码按钮
sendPeopleCodeDisabled: false,
sendPeopleCodeMsg: "发送验证码",
saveDisabled: false,
verifCode: "/code?t=" + new Date().getTime(),
organizationIdOptions: [{ 'id': '0', 'name': '自由创业者' }],
puIcon: null,
//表单数据
form: {
rePeoplePassword: "",
// 用户名
peopleName: '',
// 密码
peoplePassword: '',
// 手机号
peoplePhone: '',
// 邮箱
peopleMail: '',
// 真实姓名
puRealName: '',
// 生日
puBirthday: '',
// 身份证号
puCard: '',
// 所属机构
organizationId: '0',
// 用户头像
puIcon: '',
// 介绍
introduce: '',
},
rules: {
rePeoplePassword: [{
required: true,
message: '确认密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
},
{
validator: validatePass,
trigger: 'blur'
}
],
// 用户名
peopleName: [{ "required": true, "message": "用户名不能为空" }, {
"min": 0,
"max": 20,
"message": "用户名长度必须为0-20"
}],
// 密码
peoplePassword: [{ "required": true, "message": "密码不能为空" }, {
"min": 6,
"max": 30,
"message": "用户名长度必须为6-30"
}],
// 手机号
peoplePhone: [{ "required": true, "message": "手机号不能为空" }, {
"pattern": /^1[34578]\d{9}$/,
"message": "手机号格式不匹配"
}, { "min": 11, "max": 11, "message": "手机号长度必须为11位" }],
// 所属机构
organizationId: [{ "required": true, "message": "请选择所属机构" }],
//验证start
rand_code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 1, max: 4, message: '长度不能超过4个字符', trigger: 'change' }
],
peopleCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ min: 1, max: 6, message: '长度不能超过6个字符', trigger: 'change' }
],
//验证end
},
},
methods: {
//验证start
//倒计时
countdownSubtract: function() {
this.countdown--;
this.sendPeopleCodeMsg = "重新发送(" + this.countdown + ")";
if (this.countdown == 0) {
this.sendPeopleCodeMsg = "发送验证码";
this.sendPeopleCodeDisabled = false;
this.countdown = 0;
return;
}
setTimeout(this.countdownSubtract, 1000);
},
//获取图形验证码
code: function() {
this.verifCode = "/code?t=" + new Date().getTime();
},
//获取手机验证码
getPeopleCode: function() {
var that = this;
// 发送手机验证码
if (that.countdown > 0) {
return;
}
var flag = true;
this.$refs['form'].validateField(['rand_code', 'peoplePhone'], (Error) => {
if (Error) {
//执行
flag = false;
}
});
if (flag) {
//判断手机号码是否被绑定
ms.http.post("/isExists.do", {
peoplePhone: that.form.peoplePhone,
}).then(function(data) {
if (data.result) {
that.$notify({
title: '失败',
message: '该手机号码已绑定',
type: 'warning'
});
} else {
//请求手机验证码
ms.http.post('/sendCode.do', {
receive: that.form.peoplePhone,
modelCode: "bindPhone",
type: "sms",
isSession: true,
rand_code: that.form.rand_code,
}).then(function(data) {
if (data.result) {
that.$notify({
title: '成功',
message: '验证码已发出,请注意查收!',
type: 'success'
});
that.countdown = 60;
that.countdownSubtract();
that.sendPeopleCodeDisabled = true;
} else {
if (data.msg) {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
that.code();
}
})
}
});
}
},
//验证end
//注册
register: function() {
var that = this;
that.$refs.form.validate((valid) => {
if (valid) {
that.saveDisabled = true;
//验证start
ms.http.post("/checkSendCode.do", {
receive: that.form.peoplePhone,
code: that.form.peopleCode
}).then(function(res) {
if (!res.result) {
that.saveDisabled = false;
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
} else {
//验证end
var data = JSON.parse(JSON.stringify(that.form));
data.puIcon = that.puIcon;
data.puRealName = that.form.peopleName;
ms.http.post("/register.do", data).then(function(res) {
if (res.result != true) {
that.saveDisabled = false;
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
} else {
that.$notify({
title: '成功',
message: '注册成功!请等待管理员审核',
type: 'success',
duration: '1000',
onClose: function() {
location.href = '/';
}
});
}
})
}
})
}
});
},
},
created: function() {
//验证码start
this.code();
}
})
</script>
Tip
此代码片段中包含了
发送插件业务,需要安装发送插件才能正常使用 注册业务需根据自身需要更改
忘记密码
自定义页面 路径 /mdiyPage/resetPassword.do对应模版 reset-password.htm
HTML
<div id="app" v-cloak>
<!--表单 - start -->
<template v-if="flag">
<el-form ref="form1" :model="form" :rules="rules" label-position="right" size="large"
label-width="120px">
<el-form-item prop="peoplePhone" label="手机号">
<el-input v-model="form.peoplePhone" placeholder="请输入手机号">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="rand_code" >
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
<div >
<img :src="verifCode" @click="code"/>
<div @click="code">
看不清?换一张
</div>
</div>
</el-form-item>
<el-form-item prop="peopleCode" label="短信验证码" >
<el-input v-model="form.peopleCode" placeholder="请输入6位验证码">
</el-input>
<el-button v-text="sendPeopleCodeMsg" :disabled="sendPeopleCodeDisabled" type="info" plain
@click="getPeopleCode"
>
</el-button>
</el-form-item>
<el-form-item label=" " >
<el-button @click="verifierMail" type="primary" :loading="mailLoading">
下一步
</el-button>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form ref="form2" :model="form" :rules="rules" label-position="right" size="large"
label-width="120px">
<el-form-item prop="newPeoplePassword" label="新密码">
<el-input type="password" v-model="form.newPeoplePassword" placeholder="请输入新密码">
</el-input>
</el-form-item>
<el-form-item prop="rePeoplePassword" label="确认新密码" >
<el-input type="password" v-model="form.rePeoplePassword" placeholder="请再次输入新密码">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="rand_code">
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
<div>
<img :src="verifCode" @click="code"/>
<div @click="code" >
看不清?换一张
</div>
</div>
</el-form-item>
<el-form-item label=" " >
<el-button @click="resetPassword" type="primary"
:loading="resetLoading">
确定
</el-button>
</el-form-item>
</el-form>
</template>
<!--表单 - end -->
</div>
JavaScript
<script>
var validatePass = function (rule, value, callback) {
if (value !== app.form.newPeoplePassword) {
callback(new Error('两次输入密码不一致!'));
} else {
callback();
}
};
var app = new Vue({
el: '#app',
watch: {},
data: {
//重置按钮loading
resetLoading: false,
//下一步(校验手机号)按钮loading
mailLoading: false,
verifCode: ms.base + "/code",
flag: true,
form: {
peoplePhone: "",
newPeoplePassword: "",
rePeoplePassword: "",
peopleCode: "",
rand_code: "",
},
countdown: 0,
sendPeopleCodeDisabled: false,
sendPeopleCodeMsg: "发送验证码",
rules: {
peoplePhone: [{
required: true,
message: '手机号不能为空',
trigger: 'blur'
}, {"pattern": /^1[34578]\d{9}$/, "message": "手机号格式不匹配"}, {
"min": 11,
"max": 11,
"message": "手机号长度必须为11位"
}
],
newPeoplePassword: [{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
}
],
rePeoplePassword: [{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
},
{
validator: validatePass,
trigger: 'blur'
}
],
peopleCode: [
{
required: true,
message: '验证码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 6,
message: '请输入6位验证码',
trigger: 'blur'
},
],
rand_code: [{
required: true,
message: '验证码不能为空',
trigger: 'blur'
},
{
min: 4,
max: 4,
message: '请输入4位验证码',
trigger: 'blur'
},
],
},
},
methods: {
//校验验证码
verifierMail: function () {
var that = this;
that.$refs.form1.validate(function (valid) {
//验证
if (valid) {
ms.http.post('/checkResetPasswordCode.do', {
peopleCode: that.form.peopleCode,
rand_code: that.form.rand_code,
}).then(function (data) {
if (data.result) {
that.flag = false;
that.code();
that.form.rand_code = "";
} else {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
})
}
});
},
//重置密码
resetPassword: function () {
var that = this;
that.$refs.form2.validate(function (valid) {
if (valid) {
that.rssetPwd();
}
});
},
getPeopleCode: function () {
var that = this;
if (that.countdown > 0) {
return;
}
var flag = true;
that.$refs['form1'].validateField(['rand_code', 'peoplePhone'], (Error) => {
if (Error) {
//执行
flag = false;
}
});
if (!flag) {
return;
}
ms.http.post("/isExists.do", {
peoplePhone: that.form.peoplePhone,
}).then(function (data) {
if (!data.result) {
that.$notify({
title: '失败',
message: '该手机号未注册',
type: 'warning'
});
} else {
// 发送手机验证码
ms.http.post('/sendCode.do', {
receive: that.form.peoplePhone,
modelCode: "bindPhone",
type: "sms",
rand_code: that.form.rand_code,
}).then(function (data) {
if (data.result) {
that.$notify({
title: '成功',
message: '验证码已发出,请注意查收!',
type: 'success'
});
that.countdown = 60;
that.countdownSubtract();
that.sendPeopleCodeDisabled = true;
} else {
if (data.msg) {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
}
})
}
});
},
code: function () {
this.verifCode = ms.base + "/code?t=" + (new Date).getTime();
},
rssetPwd: function () {
var that = this;
ms.http.post('/resetPassword.do', {
peopleCode: that.form.peopleCode,
rand_code: that.form.rand_code,
peoplePassword: that.form.newPeoplePassword
}).then(function (data) {
if (data.result) {
that.$notify({
title: '成功',
message: '密码重置成功',
type: 'success'
});
window.location.href = "/mdiyPage/login.do";
} else {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
})
},
countdownSubtract: function () {
this.countdown--;
this.sendPeopleCodeMsg = "重新发送(" + this.countdown + ")";
if (this.countdown == 0) {
this.sendPeopleCodeMsg = "发送验证码";
this.sendPeopleCodeDisabled = false;
return;
}
setTimeout(this.countdownSubtract, 1000);
}
},
created: function () {
this.code();
},
})
</script>
个人信息
自定义页面 路径 /people/info.do对应模版 people/info.htm
Tip
注意这里的路径变化,
people/*.do,会员登录之后操作的页面需要用people/定义,同时文件info.htm也推荐放在模版文件夹中的people目录
HTML
<div id="app" v-cloak>
<!--表单-start-->
<el-main>
<el-form ref="form" :model="form" :rules="rules" label-width="120px" label-position="right"
size="small">
<el-row
:gutter="0"
justify="start" align="top">
<el-col :span="12">
<el-form-item label="用户名" prop="peopleName">
<el-input
v-model="form.peopleName"
:disabled="true"
:readonly="false"
:clearable="true"
placeholder="请输入用户名">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="会员类别" prop="puLevel">
<el-select v-model="form.puLevel"
@visible-change="puLevelOptionsGet"
:filterable="false"
:disabled="true"
:multiple="false" :clearable="true"
placeholder="请选择会员类别">
<el-option v-for='item in puLevelOptions' :key="item.dictValue"
:value="item.dictValue"
:label="item.dictLabel"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row
:gutter="0"
justify="start" align="top">
<el-col :span="12">
<el-form-item label="手机号" prop="peoplePhone">
<el-input
v-model="form.peoplePhone"
:disabled="true"
:readonly="false"
:clearable="true"
placeholder="请输入手机号">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证号" prop="puCard">
<el-input
v-model="form.puCard"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入身份证号">
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row
:gutter="0"
justify="start" align="top">
<el-col :span="12">
<el-form-item label="真实姓名" prop="puRealName">
<el-input
v-model="form.puRealName"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入真实姓名">
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="生日" prop="puBirthday">
<el-date-picker
v-model="form.puBirthday"
placeholder="请选择生日" :readonly="false"
:disabled="false"
:editable="true"
:clearable="true"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
type="date">
</el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row
:gutter="0"
justify="start" align="top">
<el-col :span="12">
</el-col>
<el-col :span="12">
</el-col>
</el-row>
<el-form-item label="用户头像" prop="puIcon">
<el-upload
:file-list="form.puIcon"
:action="ms.base+'/file/upload.do'"
:on-remove="puIconhandleRemove"
:style="{width:''}"
:limit="1"
:disabled="false"
:data="{uploadPath:'/people/user','isRename':true,'appId':true}"
:on-success="puIconBasicPicSuccess"
:on-exceed="puIconhandleExceed"
accept="image/*"
list-type="picture-card">
<div slot="tip" >最多上传1张头像</div>
</el-upload>
</el-form-item>
</el-form>
</el-main>
<el-header >
<el-button type="primary" icon="iconfont icon-baocun" size="mini" @click="save()"
:loading="saveDisabled">保存
</el-button>
</el-header>
<!--表单-stop-->
</div>
JavaScript
<script>
var form = new Vue({
el: '#app',
data: function () {
return {
loading: false,
saveDisabled: false,
//表单数据
form: {
// 用户名
peopleName: '',
// 密码
peoplePassword: '',
// 手机号
peoplePhone: '',
// 邮箱
// peopleMail: '',
// 真实姓名
puRealName: '',
// 生日
puBirthday: '',
// 身份证号
puCard: '',
// 会员类别
puLevel: '',
// 审核状态
peopleState: 0,
// 用户头像
puIcon: '',
// 介绍
introduce: '',
},
puIcon: null,
puLevelOptions: [],
rules: {
// 用户名
peopleName: [{"required": true, "message": "用户名不能为空"}, {
"min": 0,
"max": 20,
"message": "用户名长度必须为0-20"
}],
// 密码
peoplePassword: [],
// 手机号
peoplePhone: [{"required": true, "message": "手机号不能为空"}, {
"pattern": /^1[34578]\d{9}$/,
"message": "手机号格式不匹配"
}, {"min": 11, "max": 11, "message": "手机号长度必须为11位"}],
// 真实姓名
puRealName: [{"min": 0, "max": 20, "message": "真实姓名长度必须为0-20"}],
// 邮箱
peopleMail: [{
"pattern": "^([a-z0-9A-Z]+[-|\\.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\\.)+[a-zA-Z]{2,}$",
"message": "邮箱格式不匹配"
}, {
"min": 1,
"max": 50,
"message": "邮箱长度必须为1-50"
}],
},
}
},
watch: {},
computed: {},
methods: {
//上传超过限制
puIconhandleExceed: function (files, fileList) {
this.$notify({
title: '当前最多上传1张头像',
type: 'warning'
});
},
//puIcon文件上传完成回调
puIconBasicPicSuccess: function (response, file, fileList) {
if (response.result) {
this.puIcon = response.data;
this.form.puIcon = [];
this.form.puIcon.push({url: file.url, path: response.data, uid: file.uid});
} else {
this.$notify({
title: response.msg,
type: 'warning'
});
}
},
puIconhandleRemove: function (file, files) {
var index = -1;
index = this.form.puIcon.findIndex(function (text) {
return text == file;
});
if (index != -1) {
this.form.puIcon.splice(index, 1);
}
},
//保存
save: function () {
var that = this;
//请求update接口
var url = "/people/user/update.do"
this.$refs.form.validate(function (valid) {
if (valid) {
that.saveDisabled = true;
var data = JSON.parse(JSON.stringify(that.form));
data.puIcon = that.puIcon;
for (key in data) {
if (!data[key]) {
delete data[key];
}
}
ms.http.post(url, data).then(function (data) {
if (data.result) {
that.$notify({
title: "成功",
message: "保存成功",
type: 'success'
});
} else {
that.$notify({
title: "错误",
message: data.msg,
type: 'warning'
});
}
that.saveDisabled = false;
});
} else {
return false;
}
})
},
//获取当前用户
get: function () {
var that = this;
this.loading = true
ms.http.get("/people/user/info.do").then(function (res) {
that.loading = false
if (res.result && res.data) {
that.form = res.data;
that.puIcon = that.form.puIcon;
if (that.puIcon) {
that.form.puIcon = [{url: that.puIcon, path: that.puIcon, uid: ""}];
}
}
});
},
//获取puLevel数据源
puLevelOptionsGet: function () {
var that = this;
ms.http.get(ms.base + '/mdiy/dict/list.do', {dictType: '用户等级类型', pageSize: 99999}).then(function (res) {
that.puLevelOptions = res.data.rows;
});
},
},
created: function () {
var that = this;
this.puLevelOptionsGet();
this.get();
},
mounted: function () {
}
});
</script>
修改密码
自定义页面 路径 /people/password.do对应模版 people/password.htm
HTML
<!--表单 - start -->
<template v-if="flag">
<el-form ref="form1" :model="form" :rules="rules" label-position="right" size="large"
label-width="120px">
<el-form-item prop="peoplePhone" label="手机号">
<el-input v-model="form.peoplePhone" placeholder="请输入手机号">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="rand_code" >
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
<div >
<img :src="verifCode" @click="code"/>
<div @click="code">
看不清?换一张
</div>
</div>
</el-form-item>
<el-form-item prop="peopleCode" label="短信验证码" >
<el-input v-model="form.peopleCode" placeholder="请输入6位验证码">
</el-input>
<el-button v-text="sendPeopleCodeMsg" :disabled="sendPeopleCodeDisabled" type="info" plain
@click="getPeopleCode"
>
</el-button>
</el-form-item>
<el-form-item label=" " >
<el-button @click="verifierMail" type="primary" :loading="mailLoading">
下一步
</el-button>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form ref="form2" :model="form" :rules="rules" label-position="right" size="large"
label-width="120px">
<el-form-item prop="newPeoplePassword" label="新密码">
<el-input type="password" v-model="form.newPeoplePassword" placeholder="请输入新密码">
</el-input>
</el-form-item>
<el-form-item prop="rePeoplePassword" label="确认新密码" >
<el-input type="password" v-model="form.rePeoplePassword" placeholder="请再次输入新密码">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="rand_code">
<el-input
v-model="form.rand_code"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
<div>
<img :src="verifCode" @click="code"/>
<div @click="code" >
看不清?换一张
</div>
</div>
</el-form-item>
<el-form-item label=" " >
<el-button @click="resetPassword" type="primary"
:loading="resetLoading">
确定
</el-button>
</el-form-item>
</el-form>
</template>
<!--表单 - end -->
</div>
JavaScript
<script>
var validatePass = function (rule, value, callback) {
if (value !== app.form.newPeoplePassword) {
callback(new Error('两次输入密码不一致!'));
} else {
callback();
}
};
var app = new Vue({
el: '#app',
watch: {},
data: {
//重置按钮loading
resetLoading: false,
//下一步(校验手机号)按钮loading
mailLoading: false,
verifCode: ms.base + "/code",
flag: true,
form: {
peoplePhone: "",
newPeoplePassword: "",
rePeoplePassword: "",
peopleCode: "",
rand_code: "",
},
countdown: 0,
sendPeopleCodeDisabled: false,
sendPeopleCodeMsg: "发送验证码",
rules: {
peoplePhone: [{
required: true,
message: '手机号不能为空',
trigger: 'blur'
}, {"pattern": /^1[34578]\d{9}$/, "message": "手机号格式不匹配"}, {
"min": 11,
"max": 11,
"message": "手机号长度必须为11位"
}
],
newPeoplePassword: [{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
}
],
rePeoplePassword: [{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
},
{
validator: validatePass,
trigger: 'blur'
}
],
peopleCode: [
{
required: true,
message: '验证码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 6,
message: '请输入6位验证码',
trigger: 'blur'
},
],
rand_code: [{
required: true,
message: '验证码不能为空',
trigger: 'blur'
},
{
min: 4,
max: 4,
message: '请输入4位验证码',
trigger: 'blur'
},
],
},
},
methods: {
//校验验证码
verifierMail: function () {
var that = this;
that.$refs.form1.validate(function (valid) {
//验证
if (valid) {
ms.http.post('/checkResetPasswordCode.do', {
peopleCode: that.form.peopleCode,
rand_code: that.form.rand_code,
}).then(function (data) {
if (data.result) {
that.flag = false;
that.code();
that.form.rand_code = "";
} else {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
})
}
});
},
//重置密码
resetPassword: function () {
var that = this;
that.$refs.form2.validate(function (valid) {
if (valid) {
that.rssetPwd();
}
});
},
getPeopleCode: function () {
var that = this;
if (that.countdown > 0) {
return;
}
var flag = true;
that.$refs['form1'].validateField(['rand_code', 'peoplePhone'], (Error) => {
if (Error) {
//执行
flag = false;
}
});
if (!flag) {
return;
}
ms.http.post("/isExists.do", {
peoplePhone: that.form.peoplePhone,
}).then(function (data) {
if (!data.result) {
that.$notify({
title: '失败',
message: '该手机号未注册',
type: 'warning'
});
} else {
// 发送手机验证码
ms.http.post('/sendCode.do', {
receive: that.form.peoplePhone,
modelCode: "bindPhone",
type: "sms",
rand_code: that.form.rand_code,
}).then(function (data) {
if (data.result) {
that.$notify({
title: '成功',
message: '验证码已发出,请注意查收!',
type: 'success'
});
that.countdown = 60;
that.countdownSubtract();
that.sendPeopleCodeDisabled = true;
} else {
if (data.msg) {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
}
})
}
});
},
code: function () {
this.verifCode = ms.base + "/code?t=" + (new Date).getTime();
},
rssetPwd: function () {
var that = this;
ms.http.post('/resetPassword.do', {
peopleCode: that.form.peopleCode,
rand_code: that.form.rand_code,
peoplePassword: that.form.newPeoplePassword
}).then(function (data) {
if (data.result) {
that.$notify({
title: '成功',
message: '密码重置成功',
type: 'success'
});
window.location.href = "/mdiyPage/login.do";
} else {
that.$notify({
title: '失败',
message: data.msg,
type: 'warning'
});
}
})
},
countdownSubtract: function () {
this.countdown--;
this.sendPeopleCodeMsg = "重新发送(" + this.countdown + ")";
if (this.countdown == 0) {
this.sendPeopleCodeMsg = "发送验证码";
this.sendPeopleCodeDisabled = false;
return;
}
setTimeout(this.countdownSubtract, 1000);
}
},
created: function () {
this.code();
},
})
</script>
常见问题
后台获取当前登录的会员
PeopleEntity peopleEntity = (PeopleEntity) BasicUtil.getSession(SessionConstEnum.PEOPLE_SESSION);
企业版、政务版,会员登录总是跳转外网(内网)

访问会员需登录接口
会员登录后,将Cookie放入请求头,即可访问会员接口

评论插件手册
低代码开发评论插件,可以快速对文章、商品或其他数据进行评论。
评论插件是一个通用插件,开发者可以尽情扩展增加属于自己的业务。
Tip
通过
dataType区分是文章、商品,例如:dataType=文章,dataType=商品
依赖
当前版本:
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mcomment</artifactId>
<version>当前版本</version>
</dependency>
Tip
安装会员插件可以直接体验默认效果
前端接口
http://localhost:8080/people/comment/*.do(会员层)
http://localhost:8080/comment/*.do(web层)
会员模式(默认提供的演示业务,需要会员插件)
- 后台在文章评论配置中开启评论
- 制作的皮肤 保存评论 的函数需调用会员层的接口
- 会员层接口必须登陆才能访问
游客模式 (不推荐使用)
- 后台在文章评论配置中开启评论并且开启游客模式
- 制作的皮肤 保存评论 的函数需调用web层的接口
- 游客无需登陆,但需要校验验证码
Tip
我们不推荐使用游客模式,如果想要开启,建议根据登陆状态在前端判断调用哪个接口,并且让后端人员在web层接口根据ip等信息对评论进行一定限制。
前端参数
评论携带附件资源(图片、视频)需要传递json数据
查看接口参数规范: http://localhost:8080/swagger-ui.html#/前端-用户-会员模块接口
评论验证码:如果皮肤调用的是web接口,则需进行验证码的校验 下面是action.web.commentAction save方法的代码片段
// 是否开启验证码
if (!this.checkRandCode("rand_code")) {
return ResultData.build().error( getResString("err.error", this.getResString("rand.code")));
}
Tip
评论审核:后台在评论配置中开启评论审核,则所有评论需要通过审核才显示在页面上
后端接口
为保证评论插件的高可用,以及简化二开成本,一些重复性、必要的参数校验在业务层做了校验
以会员的保存评论为例:
开发者如果是对接自己的业务系统,可以直接新建一个action,在action里添加自己的业务逻辑,调用通用的业务层方法即可。
Tip
在二开时,注意修改action中的业务代码,业务层的保存是通用的。
版本更新说明
每天都在改变、从未停止过….
业务开发
前端开发
下面是核心的前端调用代码,具体页面效果根据自身模版的风格进行调整。
Tip
dataType是字典的评论类型,前台在对接业务时需注意,请传递字典label值。 注意:基于业务需求以及安全性考虑,评论插件web端和会员层接口只能保存或查看类型为content的文章评论,如果涉及新的业务场景,建议新增web端接口处理。
Tip
后台需要在自定义字典中增加字典值;找到评论类型,再添加新的字典,如(名称label:文章,数据值value:content)数据值作为数据保存,名称作为展示;
游客查看文章(content)类型评论
代码片段参考
下面提供的是展示方法是将获取所有评论数据,然后根据top_id(顶级评论id)comment_id(父评论id)和自身id的关系来处理形成评论层级关系
//vue代码片段
...
<div v-for="comment in commentDataList">
{{comment.peopleName?comment.peopleName:'游客'}}
{{comment.commentTime}}
{{comment.commentContent}}
<div v-for="childComment in comment.childCommentDataLists">
{{comment.peopleName?comment.peopleName:'游客'}}
{{comment.commentTime}}
{{comment.commentContent}}
</div>
</div>
...
...
ms.http.get('/comment/list.do', that.form).then(function (res) {
if (res.result) {
that.total = res.data.total; //总数
that.commentList = res.data.rows; //评论记录
// 第一级评论
var topComment = that.commentList.filter(c => !c.topId || c.topId==0)
topComment.forEach(function (data) {
// 如果当前这条评论存在图片
...
if (data.commentPicture){
var commentPicture = JSON.parse(data.commentPicture)
// 存储当前评论的图片
data['pictureList'] = []
commentPicture.forEach(function (picture){
data['pictureList'].push(picture.path)
})
}
...
//初始化子评论列表
data.childCommentDataLists = [];
// 当前遍历到评论的所有子评论
var childComments = allComment.filter(item => item.topId && item.topId==data.id)
childComments.forEach(function (childData) {
// 如果父评论为顶级评论则直接赋值
if (childData.commentId == data.id){
childData.parentName = data.peopleName;
} else {
// 找到当前遍历子评论的父评论
var parentComment = childComments.find(function (item) {
return item.id = childData.commentId;
})
if (parentComment){
childData.parentName = parentComment.peopleName;
}
}
// 将处理完的数据push到顶级评论的子集中
data.childCommentDataLists.push(childData);
});
// 存放顶级评论
that.commentDataList.push(data)
}
}
})
...
Tip
上面的案例比较适合处理层级关系不深的评论列表,三层及以上则需要去维护一大串的代码逻辑,如果要处理多层,推荐“懒加载“的方式,第一次只查询顶级评论,当用户点击某个评论的下拉列表再次去请求该评论下的子评论,以此类推。
发布文章(content)类型评论
代码片段
//vue代码片段
...
// dataType无需传递,后端接口做了限制处理
form: {
commentId:'',//父评论编号
commentContent: '',//评论内容
dataTitle: '${field.title}',//文章标题
dataId: '${field.id}',//文章id
commentPicture: [],// 评论图片JSON
commentFileJson: [],// 附件JSON
commentPoints: 0// 评论打分
},
...
...
ms.http.post('/people/comment/save.do', that.form).then(function (res) {
if (res.result) {
this.$message({
title: '成功',
message: '评论成功',
type: 'success'
});
} else {
this.$message({
title: '失败',
message: '评论失败',
type: 'error'
});
}
})
...
Tip
父子评论需注意正确传递commentId参数; 游客模式和会员有一些差异,参考http://doc.mingsoft.net/plugs/ping-lun-cha-jian/jie-shao.html#%E6%8E%A5%E5%8F%A3
新增或查看其他类型评论,如商品
在新业务模块中前台和会员端的评论接口需要重写;后台的评论接口无需调整,通过前端传递不同的datatype区分。
如下:以保存接口为例,其他接口如查询处理逻辑一致,限制datatype类型即可
<!-- 在新业务模块中调整前端评论保存接口请求地址,其余参数组织与上方发布评论接口一致 -->
ms.http.post('新业务的接口地址', that.form).then(function (res) {
if (res.result) {
} else {
}
})
// 后端:在新业务模块中对应的web端或会员端新增接口,限制类型为商品
public ResultData save(@ModelAttribute @Parameter(hidden = true) CommentEntity comment) {
// 业务处理
···
// 获取当前模块评论类型,如商品
// 注意datatype值为 自定义字典中 评论类型对应的dict_label值
comment.setDataType(Const.COMMENT_DATA_TYPE);
commentBiz.saveComment(comment);
return ResultData.build().success();
}
参数
图片、附件等参数需要json格式,其他参数正常处理即可 参考如下代码片段 代码片段:
···
that.form.commentFileJson.push({
url: file.url?file.url:file.path,
name: file.name,
path: response.data,
uid: file.uid,
status: 'success'
});
that.form.commentFileJson.push({
url: file.url?file.url:file.path,
name: file.name,
path: response.data,
uid: file.uid,
status: 'success'
});
···
// 将封装好的图片数据转为JSON
if (that.form.commentPicture && that.form.commentPicture != '[]'){
that.form.commentPicture = JSON.stringify(that.form.commentPicture)
}
// 将封装好的附件数据转为JSON
if (that.form.commentFileJson && that.form.commentFileJson != '[]'){
that.form.commentFileJson = JSON.stringify(that.form.commentFileJson)
}
···
后端开发
约定
配置名称约定
各评论数据业务有独立评论配置,评论配置名称约定为 评论类型字典中当前业务的名称 + 评论配置;
如文章的评论配置名称为: 文章评论配置

评论类型字典配置
在自定义模型 => 自定义字典 评论类型字典中 新增业务对应的评论类型名称

自定义业务评论配置
下方是自定义配置 评论配置模型json模板,title属性需要按照上面配置名称约定去设置,其余不变
{
"searchJson": "[\n]\n",
"field": "[\n {\n \"model\":\"enableComment\",\n \"key\":\"ENABLE_COMMENT\",\n \"field\":\"ENABLE_COMMENT\",\n \"javaType\":\"Boolean\",\n \"jdbcType\":\"VARCHAR\",\n \"name\":\"开启评论\",\n \"type\":\"switch\",\n \"length\":\"11\",\n \"isShow\":false,\n \"isNoRepeat\":false,\n \"isSearch\":false,\n \"isRequired\":false\n }\n ,{\n \"model\":\"enableVisitor\",\n \"key\":\"ENABLE_VISITOR\",\n \"field\":\"ENABLE_VISITOR\",\n \"javaType\":\"Boolean\",\n \"jdbcType\":\"VARCHAR\",\n \"name\":\"开启游客模式\",\n \"type\":\"switch\",\n \"length\":\"11\",\n \"isShow\":false,\n \"isNoRepeat\":false,\n \"isSearch\":false,\n \"isRequired\":false\n }\n ,{\n \"model\":\"enableAudit\",\n \"key\":\"ENABLE_AUDIT\",\n \"field\":\"ENABLE_AUDIT\",\n \"javaType\":\"Boolean\",\n \"jdbcType\":\"VARCHAR\",\n \"name\":\"开启审核\",\n \"type\":\"switch\",\n \"length\":\"11\",\n \"isShow\":false,\n \"isNoRepeat\":false,\n \"isSearch\":false,\n \"isRequired\":false\n }\n]\n\n",
"html": "\n<template id=\"custom-model\">\n <el-form ref=\"form\" :model=\"form\" :rules=\"rules\" label-width=\"120px\" label-position=\"right\" size=\"small\" :disabled=\"disabled\" v-loading=\"loading\">\n <!--开启评论-->\n \n <el-form-item label=\"开启评论\" prop=\"enableComment\">\n <el-switch v-model=\"form.enableComment\"\n :disabled=\"false\">\n </el-switch>\n <div class=\"ms-form-tip\">\n开启评论后允许发布评论 </div>\n </el-form-item>\n \n <!--开启游客模式-->\n \n <el-form-item label=\"开启游客模式\" prop=\"enableVisitor\">\n <el-switch v-model=\"form.enableVisitor\"\n :disabled=\"false\">\n </el-switch>\n <div class=\"ms-form-tip\">\n开启后,无需登录也能评论;不开启推荐 </div>\n </el-form-item>\n \n <!--开启审核-->\n \n <el-form-item label=\"开启审核\" prop=\"enableAudit\">\n <el-switch v-model=\"form.enableAudit\"\n :disabled=\"false\">\n </el-switch>\n <div class=\"ms-form-tip\">\n开启后,评论信息审核通过后才在前台显示 </div>\n </el-form-item>\n \n </el-form>\n</template>\n",
"title": "商品评论配置",
"script": "var custom_model = Vue.component(\"custom-model\",{\n el: '#custom-model',\n data:function() {\n return {\n\t\t\tloading:false,\n disabled:false,\n modelId:0,\n modelName: \"评论配置\",\n //表单数据\n form: {\n linkId:0,\n // 开启评论\n enableComment:true,\n // 开启游客模式\n enableVisitor:false,\n // 开启审核\n enableAudit:true,\n },\n\n rules:{\n },\n }\n },\n watch:{\n \n //开启评论 \n \"form.enableComment\":function(nev,old){\n if(typeof(nev)=='string') {\n this.form.enableComment = (nev=='true');\n } else if(typeof(nev)=='undefined') {\n this.form.enableComment = false;\n } \n },\n \n //开启游客模式 \n \"form.enableVisitor\":function(nev,old){\n if(typeof(nev)=='string') {\n this.form.enableVisitor = (nev=='true');\n } else if(typeof(nev)=='undefined') {\n this.form.enableVisitor = false;\n } \n },\n \n //开启审核 \n \"form.enableAudit\":function(nev,old){\n if(typeof(nev)=='string') {\n this.form.enableAudit = (nev=='true');\n } else if(typeof(nev)=='undefined') {\n this.form.enableAudit = false;\n } \n },\n },\n components:{\n },\n computed:{\n },\n methods: {\n \tlink:function(e,field,binds){\n \t\tlet that = this;\n binds.forEach(function(item){\n \t\t\t\tms.http.post(ms.manager+'/project/form/link.do', {id:that.modelId,field:item.field,value:e}).then(function (res) {\n if(res.result && res.data) {\n that.form[ms.util.camelCaseString(item.field)]=res.data[0][item.target];\n }else{\n that.$notify({\n title: '失败',\n message: res.msg,\n type: 'warning'\n });\n }\n })\n\n });\n \t},\n update: function (row) {\n var that = this;\n ms.http.post(ms.manager+\"/comment/commentConfig/update.do\", row).then(function (data) {\n if (data.result) {\n that.$notify({\n title: '成功',\n message: '更新成功',\n type: 'success'\n });\n\n } else {\n that.$notify({\n title: '失败',\n message: data.msg,\n type: 'warning'\n });\n }\n });\n }, validate:function(){\n var b = false\n this.$refs.form.validate(function(valid){\n b = valid;\n });\n return b;\n },\n getFormData() {\n var that = this;\n var form = JSON.parse(JSON.stringify(that.form));\n form.modelId = that.modelId;\n return form;\n },\n save:function(callback) {\n var that = this;\n var url = this.formURL.save.url;\n if (that.form.id > 0) {\n url = this.formURL.update.url;\n }\n this.$refs.form.validate(function(valid) {\n if (valid) {\n var form = JSON.parse(JSON.stringify(that.form));\n form.modelId = that.modelId;\n ms.http.post(url, form).then(function (res) {\n if(callback) {\n callback(res);\n }\n }).catch(function(err){\n callback(err.response.data);\n });\n } else{\n callback({\n result:false,msg:'请检查表单输入项'\n });\n }\n })\n },\n //获取当前评论配置\n get:function(id) {\n var that = this;\n that.loading = true;\n ms.http.get(this.formURL.get.url, Object.assign({\"modelId\":that.modelId},this.formURL.get.params)).then(function (res) {\n if(res.result&&res.data){\n that.form = res.data;\n that.loading = false;\n } else {\n that.loading = false;\n }\n }).catch(function (err) {\n console.log(err);\n that.loading = false;\n });\n },\n\n },\n created:function() {\n var that = this;\n //渲染create\n that.get(this.form.linkId);\n }\n});",
"tableName": "COMMENT_CONFIG"
}

自定义配置中导入配置模型json

业务菜单增加业务评论管理权限
如文章管理下管理文章评论,需要在菜单增加功能权限
comment:commentData:业务数据对应评论类型的数据值:view(reply|audit|del)

业务数据页面的操作列增加 回复列表 操作按钮(范例)
如文章管理的列表页增加回复列表,示例如下

...
<!-- 页面部分 操作列增加 回复列表 -->
<el-link v-if="ms.util.includes(ms.managerPermissions,'comment:commentData:'+commentDataType+':view') || ms.util.includes(ms.managerPermissions,'comment:comment:view')" type="primary" :underline="false" @click="viewComment(scope.row.id)" >回复列表</el-link>
...
<!-- vue data部分 定义commentDataType -->
data: function(){
return {
...
commentDataType: "content",
}
}
<!--vue method 增加viewComment实现-->
viewComment:function (id){
var that = this;
var backUrl = encodeURIComponent("/leader/box/index.do");
ms.util.openSystemUrl("/comment/data/index.do?dataType="+that.commentDataType+"&backUrl="+backUrl+"&dataId="+id);
},
点击回复列表查看该文章的评论数据列表

前台、会员层添加评论
参考comment模块 action /web/CommentAction|/people/CommentAction
通用评论保存业务
在业务开发时注意查看biz.ICommentBiz中的方法注释,有些参数校验或者字段默认赋值等操作已经在业务层统一处理,评论的dataType保存在数据库存储的都是字典的value值;
下面以业务层saveComment为例 biz.impl.commentBizImpl中saveComment方法代码片段
··· 基本数据校验
// 判断评论类型
String dataLabel = DictUtil.getDictLabel("评论类型", comment.getDataType());
if (StringUtils.isBlank(dataLabel)){
throw new BusinessException(BundleUtil.getBaseString("err.error",
BundleUtil.getString(net.mingsoft.comment.constant.Const.RESOURCES,"comment.type")));
}
// 是否开启评论
// todo 规定在各个子业务模块定义对应的评论配置,评论配置命名为 dataType在自定义字典中评论类型对应的dictLabel值,如文章 +"评论配置"
String configName = dataLabel + "评论配置";
if (!ConfigUtil.getBoolean(configName,"enableComment")){
throw new BusinessException(BundleUtil.getBaseString("fail",
BundleUtil.getString(net.mingsoft.comment.constant.Const.RESOURCES,"comment")));
}
···
// 涉及评论是否审核,开启取到配置为true,需要取反,false为未审核
comment.setCommentAudit(!ConfigUtil.getBoolean("评论配置", "enableAudit"));
// 评论ip
HashMap<String, String> map = new HashMap<>();
map.put("ipv4",BasicUtil.getIp());
map.put("addr",IpUtils.getRealAddressByIp(BasicUtil.getIp()));
comment.setCommentIp(JSONUtil.toJsonStr(map));
//评论时间
comment.setCommentTime(new Date());
comment.setUpdateDate(new Date());
comment.setCreateDate(new Date());
// 初始化评论点赞数
comment.setCommentLike(0);
// 附件及图片
if (StringUtils.isBlank(comment.getCommentPicture()) || !comment.getCommentPicture().matches("^\\[.{1,}]$")) {
comment.setCommentPicture("");
}
if (StringUtils.isBlank(comment.getCommentFileJson()) || !comment.getCommentFileJson().matches("^\\[.{1,}]$")) {
comment.setCommentFileJson("");
}
// 设置顶级评论id 判断当前回复的评论是否是顶级评论,不是则将top_id设置为当前回复评论的top_id
setTopId(comment);
return commentDao.insert(comment);
保存评论
保存时需注意,一些参数需要按指定格式传参,如peopleInfo字段需要json结构。
处理完参数调用统一的业务层方法即可。
下面是action.people.commentAction save方法的代码片段:
// 判断登陆设置peopleId
PeopleEntity people = this.getPeopleBySession();
PeopleUserEntity peopleUserEntity = (PeopleUserEntity) peopleUserBiz.getEntity(people.getIntId());
// 设置会员相关信息
if (peopleUserEntity != null){
Map commentPeopleInfo = new HashMap();
// 这里可以根据业务需求填充用户数据
commentPeopleInfo.put("puIcon",peopleUserEntity.getPuIcon());
String peopleInfo = JSONUtil.toJsonStr(commentPeopleInfo);
comment.setPeopleInfo(peopleInfo);
comment.setPeopleName(peopleUserEntity.getPuNickname());
comment.setPeopleId(peopleUserEntity.getPeopleId());
}
// 获取当前模块评论类型
comment.setDataType(Const.COMMENT_DATA_TYPE);
// 业务层再对评论数据做处理 如 ip、时间、评论关系等
commentBiz.saveComment(comment);
删除评论
后台管理可以批量删除评论,默认代码中前台只允许用户删除自己的评论,注意如果重写action的代码要对用户的id进行校验
代码参考:
PeopleEntity people = this.getPeopleBySession();
if (StringUtils.isBlank(id)){
return ResultData.build().error(getResString("err.not.exist",this.getResString("comment")));
}
CommentEntity comment = commentBiz.getById(id);
if (comment==null || !people.getId().equals(comment.getPeopleId())){
return ResultData.build().error(getResString("err.not.exist",this.getResString("comment")));
}
级联删除: 从产品角度来讲,可以允许父评论删除后保留子评论,增加逻辑删除业务,界面则不显示评论内容; 如果需要级联删除业务,建议在数据库设置外键关联,在数据库层面级联删除评论
附件、图片等处理:推荐使用我们的清理插件,可以清理一些删除评论后遗留的无用文件http://doc.mingsoft.net/plugs/qing-li-cha-jian/jie-shao.html
在默认代码中,评论log表中统计数量会随评论表中记录数增加,但是不会随着删除而减少,这是我们觉得比较合理的一个设计;当然如果并不需要这么做的话,请在删除时参考如下代码处理记录数。
//查询评论记录是否有该评论
CommentsLogEntity data = commentsLogBiz.getOne(new QueryWrapper<CommentsLogEntity>().eq("data_id",dataId).eq("data_type",dataType));
//评论数-1
data.setCommentsCount(data.getCommentsCount()-1);
commentsLogBiz.updateById(data);
修改评论
不支持修改评论
查询评论
建议调用web端查询接口,people目录下的action接口需要会员登陆
web代码参考:
//只显示审核通过的评论
comment.setCommentAudit(true);
// 确保第一次只查询出父评论
BasicUtil.startPage();
List<CommentEntity> list = commentBiz.query(comment);
return ResultData.build().success().data(new EUListBean(list,(int)BasicUtil.endPage(list).getTotal()));
业务对接
在开发时,对接其他业务如文章评论、商品评论等
在这些业务删除时,可以考虑aop处理同时删除相关联的评论数据
常见问题
评论类型错误
前台页面传递的dataType不是字典名称label,需要传递字典名称value;
JSON.parse(peopleInfo)异常
peopleInfo并不是空,可能是像{}之类的需要排查,前端不能仅判空,peopleInfo中的头像取法
// 如果有peopleInfo 取出头像
if (data.peopleInfo && data.peopleInfo!='{}'){
var peopleInfo = JSON.parse(data.peopleInfo)
data.puIcon = peopleInfo.puIcon;
}
peopleName没有值
会员没有设置puNickName导致评论后昵称显示是游客
评论细粒度划分
需要二次开发实现针对栏目或文章设置评论;企业版或政务版用户可以参考栏目数据权限进行开发;
关注收藏点赞插件手册
赞、顶、踩、鲜花、收藏等业务场景都可以通过这个插件来实现
Tip
通过
dataType区分是文章收藏、商品关注,例如:dataType=文章点赞,dataType=商品关注
依赖
当前版本:
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mattention</artifactId>
<version>当前版本</version>
</dependency>
Tip
必须安装会员插件才能正常使用关注插件
接口
http://localhost:8080/swagger-ui.html#/前端-关注模块接口
http://localhost:8080/swagger-ui.html#/前端-用户-关注模块接口
版本更新说明
每天都在改变、从未停止过….
接口
下面是核心的前端调用代码,具体页面效果根据自身模版的风格进行调整。
Tip
dataType是关注类型字典,前台在对接业务时需注意,请传递字典label值。
Tip
注意:大部分接口都需要会员登陆后才能调用,用户对该信息的关注,通过自定义
dataType值来区分收藏、赞等类型,例如:dataType=文章点赞收藏dataType=商品关注赞等等,可以自定义多种类型,对应的取消、列表、统计接口dataType要保持一致。
前端对接
Tip
后台需要在自定义字典中增加字典值;找到关注类型,再添加新的字典,如(名称label:文章点赞,数据值value:contentLike)数据值作为数据保存,名称作为展示;
查看点赞或者关注数量
// VUE代码片段
...
// 页面片段,具体业务样式,需自行开发
<span>
{{attentionTotal}}
</span>
...
attentionTotal: 0, //文章点赞数量
dataId:'${field.id}',//文章id
dataType: '文章点赞' //业务类型
...
// 查询点赞总数量方法
attentionTotals: function (dataType) {
var that = this;
ms.http.post( ms.base + "/attention/collection/queryCollectionCount.do", {
dataIds: commentDataIds.toString(),
dataType: '关注点赞'
}).then(function (res) {
that.attentionTotal = 0;
if(res.result){
that.attentionTotal = res.data[0].dataCount;
}
})
},
会员点赞、关注或者收藏
// 页面片段,具体业务样式,需自行开发
...
<div>
<i @click="saveAttention()"></i>
<span>
{{attentionTotal}}
</span>
</div>
...
// VUE代码片段
...
attentionTotal: 0, //文章点赞数量
dataId:'${field.id}',//文章id
dataType: '文章点赞' //业务类型
collectionDataTitle: '${field.title}' // 文章标题
contentLike: false, // 当前用户是否点赞此文章
...
// 文章点赞,重复点击会取消点赞
saveAttention: function () {
var that = this
ms.http.post( ms.base + "/people/attention/collection/save.do", {
dataId: that.dataId,
dataType: that.dataType
collectionDataTitle: that.collectionDataTitle,
}).then(function (res) {
if (res.result) {
// 调用查询点赞数量
that.attentionTotals(that.dataType)
}
});
},
// 查询点赞总数量方法
attentionTotals: function (dataType) {
var that = this;
ms.http.post("/people/attention/collectionLog/queryCollectionCount.do", {
dataIds: that.dataId,
dataType: that.dataType
}).then(function (res) {
that.attentionTotal = 0;
if(res.result){
// 获取文章点赞总数
that.attentionTotal = res.data[0].dataCount;
// 判断当前文章是否点赞
that.contentLike = item.like;
}
})
},
Tip
注意: 只有请求people层接口且登录才能判断当前用户是否点赞或关注此业务
会员中心-列表
需要会员登陆后才能调用的接口,查询当前会员的关注信息或者点赞信息
代码片段
list: function () {
ms.http.get("/people/attention/collectionLog/list.do",{
pageSize: 999,
dataType: "文章点赞",
pageNo: 1,
}).then(function (res){
if (res.result) {
//列表渲染
}
})
},
业务层开发
开发规范查看biz.CollectionBizImpl中的方法注释 下面以业务层saveOrDelete为例 attention.biz.impl中saveOrDelete方法代码片段,可以通过控制层直接调用biz方法实现更多的点赞或者关注业务场景,例如:商品关注、商品点赞
//基本数据校验
...
// 判断关注数据存在
CollectionLogEntity collectionLog = new CollectionLogEntity();
collectionLog.setDataId(collectionEntity.getDataId());
collectionLog.setDataType(collectionEntity.getDataType());
// 重复点赞判断
LambdaQueryWrapper<CollectionEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CollectionEntity::getDataId, collectionEntity.getDataId());
wrapper.eq(CollectionEntity::getPeopleId, collectionEntity.getPeopleId());
wrapper.eq(CollectionEntity::getDataType, collectionEntity.getDataType());
...
collectionEntity.setCreateDate(new Date());
// 插入一条数据
collectionDao.insert(collectionEntity);
return true;
常见问题
关注类型错误
前台页面传递的dataType需要为字典名称label;
JSON.parse(peopleInfo)异常
peopleInfo并不是空,可能是像{}之类的需要排查,前端不能仅判空,peopleInfo中的头像取法
// 如果有peopleInfo 取出头像
if (data.peopleInfo && data.peopleInfo!='{}'){
var peopleInfo = JSON.parse(data.peopleInfo)
data.puIcon = peopleInfo.puIcon;
}
peopleName没有值
会员没有设置puNickName导致关注后昵称显示是空
业务缩略图不显示或详情页面控制台爆JSON错误
需要使用铭软图片格式,可以参考文章缩略图写法
[{"path": "/upload/xxx/xxx/xxx.jpg"}]
介绍
支持通过邮件、短信方式进行消息推送,也支持第三方平台sendcloud
安装
当前版本
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-msend</artifactId>
<version>当前版本</version>
</dependency>
Tip
具体安装参考MStore发送插件详情
使用步骤
第一步:进行邮件与短信配置,确保信息真实有效;
第二步:配置消息模板;
第三步:通过工具类或HTTP接口调用发送接口;
消息发送
用于重要信息变更验证。支持通过邮件、短信方式进行消息推送,也支持第三方平台sendcloud
SendUtil工具类(推荐)
关键类:net.mingsoft.msend.util.SendUtil.java
关键方法:SendUtil.send(…)
Tip
configType就是自定义配置的名字
public class SendUtil {
private static final Logger LOG = LoggerFactory.getLogger(SendUtil.class);
/**
* 发送邮件
* @param configType 邮件配置的类型 默认:邮件配置|短信配置|sendCloud短信配置|sendCloud邮件配置(configType就是自定义配置的名字)
* @param receive 接收用户
* @param code 模板编码
* @param values 根据values.key值替换替换模版里面内容的${key},
* @return
*/
public static boolean send(String configType,String code, String receive, Map values) {
//获取配置信息
Map<String, String> config = ConfigUtil.getMap(configType);
if (config == null) {
throw new BusinessException(String.format("%s不存在", configType));
}
if (StringUtils.isBlank(config.get("class"))) {
throw new BusinessException(String.format("{没有配置监听类 calss"));
}
ITemplateBiz templateBiz = SpringUtil.getBean(ITemplateBiz.class);
TemplateEntity template = new TemplateEntity();
template.setTemplateCode(code);
template = (TemplateEntity) templateBiz.getEntity(template);
if(template==null) {
throw new BusinessException("邮件模板未找到");
}
if(StringUtils.isBlank(receive)) {
throw new BusinessException("接收人不能为空");
}
String[] toUser = receive.split(",");
BaseSendService sendService = (BaseSendService)SpringUtil.getBean(String.valueOf(config.get("class")));
return sendService.send(config,toUser,template,values);
}
}
Tip
实际业务开发推荐SendUtil方式调用,必须严谨、安全
configType就是自定义配置的名字
邮件发送(范例)
//将一下代码片段复制到业务代码中,根据实际的模板参数进行修改,直接调用即可
Map params = new HashMap();
//会根据code的值填充邮箱模版或者短信模版的${code} 收件人收到内容就是 “验证码8888”
params.put("code",8888);
// 调用SendUtil.send发送消息,sendType使用HTML格式
boolean status = SendUtil.send("邮件配置","bindPhone","123@163.com",params);
if(status){
//发送成功
}else{
//发送失败
}
- 邮件发送实体
//将一下代码片段复制到业务代码中,根据实际的模板参数进行修改,直接调用即可
Map params=new HashMap();
// 把实体类转Map,不需要的字段可以过滤掉
params=BeanUtil.beanToMap(entity,params,CopyOptions.create().setIgnoreProperties("createDate",
"del",
"updateDate"));
// 还需要其他参数,和下方一样put
//会根据code的值填充邮箱模版或者短信模版的${code} 收件人收到内容就是 “验证码8888”
params.put("code",8888);
// 调用SendUtil.send发送消息,sendType使用HTML格式
boolean status = SendUtil.send("邮件配置","bindPhone","123@163.com",params);
if(status){
//发送成功
}else{
//发送失败
}
Tip
非字符串类型会转换失败,不需要可以过滤掉,如果必要参数就先转成字符串类型
短信发送(范例)
//将一下代码片段复制到业务代码中,根据实际的模板参数进行修改,直接调用即可
Map params = new HashMap();
//会根据code的值填充邮箱模版或者短信模版的${code} 收件人收到内容就是 “验证码8888”
params.put("code",8888);
//调用SendUtil.send发送消息,sendType使用HTML格式
boolean status = SendUtil.send("短信配置","bindPhone","1888888888",params);
if(status){
//发送成功
}else
//发送失败
}
sendCloud发送短信(范例_推荐)
sendCloud配置

签名指短信签名,如[xxx科技]…短信内容
消息模板配置

发送代码示例
...
<el-form-item label="联系电话" prop="phone">
<el-input
v-model="form.phone"
:disabled="false"
:readonly="false"
maxlength="11"
:style="{width: '100%'}"
:clearable="true"
placeholder="请输入手机号">
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="randCode">
<el-row
:gutter=0
justify="start" align="top">
<el-col :span="14">
<el-input
v-model="form.randCode"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
</el-col>
<el-col :span=10>
<div style="display: flex; height: 38px;">
<img :src="verifyCode" class="code-img" @click="code" style="cursor: pointer"
alt="点击换一张"/>
<div @click="code">
</div>
</div>
</el-col>
</el-row>
</el-form-item>
<el-form-item label="短信验证码" prop="phoneCode">
<el-row
:gutter=0
justify="start" align="top">
<el-col :span=13>
<el-input
v-model="form.phoneCode"
:disabled="false"
:readonly="false"
:clearable="true"
placeholder="请输入验证码">
</el-input>
</el-col>
<el-col :span=11>
<el-button type="info" plain @click="getCode()">
</el-button>
</el-col>
</el-row>
</el-form-item>
...
getCode:function (){
...
ms.http.post(ms.base + '/sendCode.do',{
receive: 接收手机号,
modelCode:"code",
configType:"sendCloud短信配置",
rand_code: randCode
}).then(function (data) {
if (data.result) {
that.$notify({
title: '成功',
message: '验证码已发出,请注意查收!',
type: 'success'
});
}
})
...
}
扩展发送
可以灵活扩展第三方发送接口,开发者可以快速的扩展发送接口,
扩展步骤
1、代码生成器 设计好发送表单,表单里面必须包含 class 字典
2、通过自定义配置导入
3、创建一个类继承 net.mingsoft.msend.service.BaseSendService 基础类
4、实现send方法
5、通过 SendUtil.send方法调用扩展的接口
扩展发送(范例)
import net.mingsoft.basic.util.SpringUtil;
import net.mingsoft.msend.biz.ILogBiz;
import net.mingsoft.msend.entity.LogEntity;
import net.mingsoft.msend.entity.TemplateEntity;
import net.mingsoft.msend.util.MailUtil;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Map;
@Service("mailSendService")
public class MailSendService extends BaseSendService { //继承 BaseSendService
/**
* 发送
*
* @param config 配置的类型
* @param toUser 接收用户
* @param template 模板内容
* @param values 根据values.key值替换替换模版里面内容的${key},
* @return
*/
@Override
public boolean send(Map<String,String> config, String[] toUser, TemplateEntity template, Map values) {
String mailContent = this.rendering(values, template.getTemplateMail());
//发送
MailUtil.sendHtml(config.get("mailServer"),
Integer.parseInt(String.valueOf(config.get("mailPort"))),
config.get("mailName"),
config.get("mailPassword"),
config.get("mailFormName"),
template.getTemplateTitle(), mailContent, toUser);
//写入日志
ILogBiz logBiz = SpringUtil.getBean(ILogBiz.class);
LogEntity log = new LogEntity();
for (int i = 0; i < toUser.length; i++) {
log.setLogType(MAIL);
log.setLogDatetime(new Date());
log.setLogContent("mail类型");
log.setLogReceive(toUser[i]);
logBiz.saveEntity(log);
}
return true;
}
}
Tip
通过 自定义配置 工具类获取对应的配置参数
web接口
ResponseEntity<JSONObject> content = RestTemplateUtil.post(this.getUrl(request) + "/msend/send.do",RestTemplateUtil.getHeaders(request),requestBody,JSONObject.class);
常见问题
消息模版渲染报错
- 确认消息模版是否配置正确,模版中包含的变量是否在消息内容中存在,如:
${phone},在消息内容中不存在,则报错。 - 如果消息地址中有${phone},则需要在组织参数时,把phone给加入
支付插件手册
支持微信、支付宝两种支付场景和交易日志记录查看
依赖
当前版本:
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mpay</artifactId>
<version>当前版本</version>
</dependency>
版本更新说明
每天都在改变、从未停止过….
版本2.1.7
【修复】后台UI升级
【修复】bug修复
【优化】配合更新Store升级包
版本1.0.5
【修复】后台UI升级
【修复】bug修复
版本1.0.4
【新增】退款接口;
版本1.0.3
【修复】完善通用回调;
版本1.0.2
【修复】修复支付网关重定向页面BUG;
版本1.0.1
【新增】微信支付APP设置功能;
【新增】支付宝支付APP设置功能;
版本1.0.0
【新增】微信支付设置功能;
【新增】支付宝设置功能;
【新增】交易记录管理功能;
发起支付
关键类:net.mingsoft.pay.action.web.PayAction.java 关键方法:geteway(….)
HTTP发送接口
HTTP方法:POST
请求URL:/mpay/pay/gateway.do
请求参数:
| 参数 | 是否必选 | 类型 | 可选值范围 | 默认值 | 说明 |
|---|---|---|---|---|---|
| type | 是 | String | weixin、alipay | 支付类型 | |
| orderPrice | 是 | 价格 | |||
| orderNo | 是 | String | 订单号 | ||
| orderName | 是 | String | 订单名称 | ||
| orderDesc | 是 | 商品描述 | |||
| notifyUrl | 可选 | String | /mpay/alipay/notify.do /mpay/weixin/notify.do | 会根据 type 的值, 自动调用对应类型 | |
| attach | 是 | JSON | 扩展回调{‘notifyBeanName’:‘回调方法’,‘userId’:‘用户ID’} | ||
| returnUrl | 是 | String | 只支持支付宝,支付成功结果页面,必须是http绝对路径 |
支付回调
默认回调地址
默认回调可以完成基础订单支付
支付宝回调
/mpay/alipay/notify.do
微信回调
/mpay/weixin/notify.do
扩展回调
大多数时候需要根据回调状态更新业务数据,通过 attach 参数中的 notifyBeanName 设置对应 spring 的 bean 名称实现
定义 class 实现 IPayNotify 接口
/**
* 支付时候,定义的attach的参数值
* @param payLogEntity 支付的日志信息
* @param attach {notifyBeanName:'业务扩展的bean,经过spring管理','userId':'用户Id',自定义参数:值}
* @return
*/
@Service("diyPayNotify")
public class PayNotify implements IPayNotify {
@Override
public boolean notify(PayLogEntity payLog, HashMap attach) {
//订单号
//payLog.getOrderNo();
}
}
Tip
attach定义格式{'notifyBeanName':'diyPayNotify','userId':'用户Id'},notifyBeanName与userId参数一定要有;开发者可以根据自己的业务需求自定义参数,第三方支付平台
范例
Tip
- 必须确保已经配置好了支付宝或者微信的相关配置;
- 调用mpay/pay/gateway方法时,必须遵循请求参数规范
- 本地开发时,回调地址(notifyUrl)需要填写内网穿透后的地址。
支付宝支付
首先编写一个简单表单页面 “alipay.html”
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
...
<form>
<input type="text" name="returnUrl" value="http://localhost:8080"/><!--支付成功返回地址-->
<input type="text" name="orderNo" value="88888888"/><!--订单号 不填写系统会自动生成-->
<input type="text" name="orderName" value="铭飞开源赞助"/><!--订单名称-->
<!-- <input type="text" name="notifyUrl" value="自定义回调地址"/> --><!--回调地址,如果不填写默认由type决定调用当前系统的回调方法-->
<input type="text" name="type" value="alipay"/><!--支付类型-->
<input type="text" name="orderPrice" value="0.01"/><!--价格-->
<input type="text" name="orderDesc" value="价值源自分享"/><!--商品描述-->
<input type="text" name="attach" value="{'notifyBeanName':'diyPayNotify','userId':'用户Id'}"/><!--需要自定义扩展回调方法-->
</form>
...
<script>
$.ajax({
type: "POST",
dataType:"json",
url: "http://localhost:8080/mpay/pay/gateway.do",
data: $("form").serialize(),
success: function(res){
if(res.result) {
document.write(res.data);
}
}
});
</script>
微信扫码支付
编写一个简单的表单页,根据表单提交的结果显示二维码图片,通常结合ajax来实现
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery.qrcode/1.0/jquery.qrcode.min.js"></script>
...
<form>
<input type="hidden" name="orderNo" value="8"/><!--订单号-->
<input type="hidden" name="type" value="weixin"/><!--支付类型-->
<input type="hidden" name="orderPrice" value="0.01"/><!--价格-->
<input type="hidden" name="orderDesc" value="价值源自分享"/><!--商品描述-->
<!--<input type="text" name="notifyUrl" value="自定义回调地址"/><!--回调地址,如果不填写默认为当前系统-->
<input type="text" name="attach" value="{'notifyBeanName':'diyPayNotify','userId':'用户Id'}"/><!--需要自定义扩展回调方法-->
</form>
<!--显示二维码-->
<div id="qrcode"></div>
...
<script>
$.ajax({
type: "POST",
dataType:"json",
url: "http://localhost:8080/mpay/pay/gateway.do",
data: $("form").serialize(),
success: function(res){
if(res.result) {
$("#qrcode").qrcode(res.data);
} else {
alert('重新修改支付参数');
}
}
});
</script>
微信公众号支付
微信公众号支付需要在公众号内部完成,同时用户需要进行授权登录,注意在前往授权登录的js中,请使用示例中 location.href 赋值的方式,使用window.open()的方式会遇到被拦截的情况
参考微信官方文档
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<!--创建支付表单页面-->
<form id="weixin" name="weixin" >
<input type="hidden" name="orderNo" value="888888888"/><!--订单编号-->
<input type="hidden" name="type" value="weixin"/><!--支付类型-->
<input type="hidden" name="orderPrice" value="0.01"/><!--价格-->
<input type="hidden" name="orderDesc" value="测试公众号支付"/><!--描述-->
<input type="hidden" name="page" value="http://项目地址/wx-mp-pay.jsp"/><!--接收微信支付授权信息的页面-->
<!--多个公众号支付必传-->
<input type="hidden" name="appId" value="xxxxxxxx"/><!--微信appId-->
<input type="hidden" name="mchId" value="1234567890"/><!--微信支付商户号-->
<input type="hidden" name="key" value="xxxxxxxx"/><!--微信支付API密钥-->
<input type="hidden" name="secret" value="xxxxxxxx"/><!--Appsecret-->
<input type="submit" value="确认">
</form>
...
<script>
//授权登录,需要用户配置微信appid和项目请求支付地址
function weixinPay(){
location.href = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=微信appid&redirect_uri=" + encodeURIComponent("http://项目地址/mpay/pay/gateway.do?" + $("#weixin").serialize()) + "&response_type=code&scope=snsapi_userinfo#wechat_redirect";
}
</script>
确认支付
<!--确认支付页面-->
<script>
function pay() {
WeixinJSBridge.invoke(
'getBrandWCPayRequest', {
"appId":<%=request.getParameter("wxAppId")%>, //公众号名称,由商户传入
"timeStamp":"<%=request.getParameter("timeStamp")%>", //时间戳,自1970年以来的秒数
"nonceStr":"<%=request.getParameter("nonceStr")%>", //随机串
"package":"<%=request.getParameter("package")%>",
"signType":"MD5", //微信签名方式:
"paySign":"<%=request.getParameter("sign")%>" //微信签名
},
function(res){
if (res.err_msg == "get_brand_wcpay_request:ok") {
alert("微信支付成功!");
} else if (res.err_msg == "get_brand_wcpay_request:cancel") {
alert("用户取消支付!");
} else {
alert(JSON.stringify(res));
}
}
);
}
</script>
微信刷卡支付
刷卡支付场景描述:用户出示微信付款二维码 、 商户使用扫码枪扫码,用户输入支付密码(小额免密),商务系统提示支付成功。
开发者需要制作一张支付表单页面,提交后直接返回支付结果
<!-- 微信刷卡支付,刷卡成功后会直接扣款,金额过大或其他原因会需要支付密码 -->
<!-- 具体参考微信官方文档:https://pay.weixin.qq.com/wiki/doc/api/micropay.php?chapter=5_1 -->
<form id="submit" name="weixin" action="http://域名/项目/mpay/pay/gateway.do" method="post">
<input type="hidden" name="orderNo" value="888888"/><!--订单编号-->
<input type="hidden" name="type" value="weixin"/><!--支付类型-->
<input type="hidden" name="orderPrice" value="0.01"/>
<input type="hidden" name="orderDesc" value="铭飞商超系统"/>
<input type="text" name="authCode" value="45454XXXX3FF"/> <!--通过扫描枪扫码获取,可手动输入付款码底部对应的字符串-->
<input type="submit" value="确认">
</form>
通用支付范例
通用支付页面,在服务器端封装页面需要的参数并渲染到支付页面,提高安全性
前端自定义页面模板
...
<!--二维码-->
<div>
<qriously :value="wxCode" v-if="wxCode" :size="200"/>
</div>
<span class="pay-price">${pay.price}</span>
...
script
data: {
wxCode: "${pay.wxCode!''}",// 微信二维码
orderNo: "${pay.orderNo}",
},
methods:{
// 查询验证订单状态
checkOrder: function() {
var that = this
ms.http.get('/mpay/pay/status.do?orderNo=${pay.orderNo}').then(function(res) {
if(res.result && res.data == 'PAY') {
that.orderStatus = true;
clearInterval(that.listenerTimer)
}
})
},
}
后端 封装页面需要的参数
// 业务对接支付接口
// 组装PayBean
PayBean pay = new PayBean();
pay.setOrderPrice("price"); // 价格
pay.setOrderNo("orderNo"); // 订单号
pay.setOrderName("orderName"); // 订单名称
pay.setOrderDesc("orderDesc"); // 订单描述
// xxxNotify需要实现IPayNotify接口 实现回调业务 params 自定义参数 具体查看本章扩展回调
pay.setAttach("{'notifyBeanName':'xxxNotify','params':"+params+"}");
pay.setReturnUrl(BasicUtil.getUrl()+"renwudating.html");
// payType 为空时默认微信支付
pay.setType(PayBean.Type.WEIXIN);
//微信支付,返回支付二维码
String wxCode = HttpUtil.post(BasicUtil.getUrl() + "/mpay/pay/gateway.do", BeanUtil.beanToMap(pay));
ResultData resultData = JSONUtil.toBean(wxCode, ResultData.class);
Map<String, Object> params = BasicUtil.assemblyRequestMap();
params.put("qrCode",resultData.getData(String.class));
// ... 其他需要在页面上渲染的参数
request.setAttribute("pay",params);
// 转发到自定义页面请求
return "forward:/xxx.do"
发起退款
业务方法关键类
net.mingsoft.pay.biz.IPayLogBiz 关键方法:refund(….)
范例1
PayRefundBean payRefundBean = new PayRefundBean();
payRefundBean.setOrderNo("201908080808"); //订单号
payRefundBean.setType("WEI_XIN"); //支付宝支付:ALI_PAY 微信支付:WEI_XIN
payRefundBean.setPrice("8.8"); //退款费用
payRefundBean.setServiceCharge(0.5); //手续费
if(!net.mingsoft.pay.biz.IPayLogBiz.refund(payRefundBean){
System.out.print("退款失败");
} else {
System.out.print("退款成功");
// 流水交易为已退款
UpdateWrapper<PayLogEntity> wrapper = new UpdateWrapper<>();
wrapper.lambda().eq(PayLogEntity::getOrderNo,rewardedTask.getOrderNo()).set(PayLogEntity::getLogStatus,PayLogEntity.LogStatusEnum.REFUND);
payLogBiz.update(wrapper);
// 订单记录为已退款
···
}
注意在具体业务调用时退款同时要将订单状态和流水状态修改为已退款
范例2
除了bean形参的方法,还提供了一个重载方法形参为流水id范例
···
if(!net.mingsoft.pay.biz.IPayLogBiz.refund(payLogEntity.getId())){
System.out.print("退款失败");
} else {
System.out.print("退款成功");
// 订单记录为已退款
···
}
常见问题
交易日志中没有记录peopleId
需要在用户登录下请求网关,日志中就会记录peopleId
使用微信支付,需要判定当前环境是否为微信
function isWeiXin() {
var ua = window.navigator.userAgent.toLowerCase();
if (ua.match(/MicroMessenger/i) == 'micromessenger') {
return true;
} else {
return false;
}
}
介绍
城市数据库来自GitHub维护项目,仓库地址https://github.com/modood/Administrative-divisions-of-China
Tip
安装步骤
- 必须先导入city.sql表数据(详细步骤见业务开发)
- 在MStore中点击安装,安装成功就会创建对应的菜单功能
业务开发
将github数据库文件转换成city表步骤
下载sql文件

navicat新建sqlLite数据库

执行sql
CREATE TABLE "cms_city" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"province_id" integer(20),
"province_name" text(64),
"city_id" integer(20),
"city_name" text(64),
"county_id" integer(20),
"county_name" text(64),
"town_id" integer(20),
"town_name" text(64),
"village_id" integer(20),
"village_name" text(64)
);
INSERT INTO cms_city(village_id,village_name,town_id,county_id,city_id,province_id,town_name,county_name,city_name,province_name) SELECT * FROM (
(SELECT
vsac.*,
province.`name` AS province_name
FROM
(
(
SELECT
vsa.*,
city.`name` AS city_name
FROM
(
(
SELECT
vs.*,
area.`name` AS county_name
FROM
(
(
SELECT
village.`code` AS village_id,
village.`name` AS village_name,
village.streetCode AS town_id,
village.areaCode AS county_id,
village.cityCode AS city_id,
village.provinceCode AS province_id,
street.`name` AS town_name
FROM
village
LEFT JOIN street ON village.streetCode = street.`code`
) AS vs
)
LEFT JOIN area ON area.`code` = vs.county_id
) AS vsa
)
LEFT JOIN city ON city.`code` = vsa.city_id
) AS vsac
)
LEFT JOIN province ON province.`code` = vsac.province_id) as city
);
update cms_city set city_id = city_id *100000000,county_id = county_id*1000000,town_id = town_id*1000;
导出cms_city

在mysql创建city表
CREATE TABLE `city` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`province_id` bigint(20) unsigned DEFAULT NULL COMMENT '省编号',
`province_name` varchar(64) DEFAULT NULL COMMENT '省名称',
`city_id` bigint(20) unsigned DEFAULT NULL COMMENT '城市编号',
`city_name` varchar(64) DEFAULT NULL COMMENT '城市名称',
`county_id` bigint(20) unsigned DEFAULT NULL COMMENT '区编号',
`county_name` varchar(64) DEFAULT NULL COMMENT '区名称',
`town_id` bigint(20) unsigned DEFAULT NULL COMMENT '镇\\县编号',
`town_name` varchar(64) DEFAULT NULL COMMENT '镇\\县名称',
`village_id` bigint(20) unsigned DEFAULT NULL COMMENT '街道编号',
`village_name` varchar(64) DEFAULT NULL COMMENT '街道名称',
PRIMARY KEY (`id`) USING BTREE,
KEY `province_id` (`province_id`) USING BTREE,
KEY `city_id` (`city_id`) USING BTREE,
KEY `county_id` (`county_id`) USING BTREE,
KEY `town_id` (`town_id`) USING BTREE,
KEY `village_id` (`village_id`) USING BTREE,
KEY `id` (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=604852 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='省市县镇村数据';
在mysql数据库还原



城市数据查询优化
通过代码生成器拖拽城市控件自动生成城市业务代码。
通过城市管理手动生成city.json文件,可以减少城市的数据查询效率问题
ms.http.get("/static/json/city.json").then(function (data) {
if (data.result) {
that.provinces = data.data;
}
});
常见问题
广告插件手册
可以设定各种广告,例如京东淘宝等非常显目的位置,都可以通过广告插件设定并引用
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-ad</artifactId>
<version>当前版本</version>
</dependency>
动图演示

业务开发
- 添加广告
- 通过标签获取广告信息
标签使用
通过广告位获取广告列表
{@ms:ad "广告位"/}
//返回`广告位`下所有的广告,返回格式
//<a href='广告链接'><img src='广告图片' width="广告宽度" height="广告高度"/></a>//<a href='广告链接'><img src='广告图片' width="广告宽度" height="广告高度"/></a>
通过广告位与广告名称 精准获取一个广告
{@ms:ad "广告位" "广告名称"/}
//通过广告位与广告名称 精准获取一个广告
//<a href='广告链接'><img src='广告图片' width="广告宽度" height="广告高度"/></a>
Tip
只会返回有效期内的广告,前端展示通过
css控制img标签,广告数据更新,不需要重新静态化页面
常见问题
页面获取不到广告
这种情况可以先检查一下浏览器是否安装了广告拦截插件,这会使得请求被拦截导致页面无法获取到广告
站群插件手册
通过部署一台服务、一套MCms系统、一套数据库就可以管理多个网站(传统建站是每个网站都需要重新部署一套网站系统)。网站的数据独立管理,权限可以单独配置,适合网站业务比较多的公司或个人使用。
安装
通过MStore进行源码安装
Tip
必须按照
MStore的详情页面进行安装,安装成功后通过adminms账号登陆系统进行创建站点分配权限
动图演示
后台管理,通过默认的adminms账号进行登录

新增站点,站点域名设置具体请查看业务开发的本地调试部分

给新增的站点设置管理员账号,并分配菜单功能权限

B站点设置的管理员只能登录B站点,无法登录其他站点

同理,其他站点的管理员也无法登录B站点

Tip
站群超级管理员只管理各个“站点负责人”的账号权限;各个站点的“站点负责人”,可以在各自的站点再新增管理员,处理站点内的子业务,站点负责人新增的管理员也是只能登录当前站,无法访问其他站点
版本更新说明
每天都在改变、从未停止过….
版本1.0.2
- 【新增】数据库备份功能
版本1.0.0
- 【新增】应用管理
- 【新增】模块管理
业务开发
首先在app表中找到对应站点的id,在该站点的template目录下创建对应站点id名称的文件夹来存放模板文件
本地调试
简单的可以使用192、127、localhost模拟3个站
如果还需要更多,可以使用代理工具或修改host文件来模拟多个域名。
Windows 系统 Hosts 文件路径:C:\Windows\System32\drivers\etc\hosts;
Mac 系统 hosts 文件路径:/etc/hosts;
Linux 系统的 hosts 文件一般也是在:/etc/hosts;
格式如下:
127.0.0.1 www.a.com
127.0.0.1 www.b.com
127.0.0.1 www.c.com
这样就可以本地模拟多个域名来访问系统,方便本地体验站群插件。
Tip
可以把mcms端口修改为80,这样通过 www.a.com 就可以访问mcms系统
扩展站群涉及的业务表
安装时默认根据yml中的ms.website.table的配置的表去初始化
后续扩展需要在ms.website.table中加上业务表,但初始化过程需要手动处理;修改后需要重启服务
# 以mysql为例
# 增加app_id字段
ALTER TABLE your_table ADD COLUMN app_id varchar(20) NULL COMMENT '站点编号';
# 设置app_id字段的值
UPDATE your_table SET app_id = 'your_appid' WHERE app_id IS NULL;
生产环境配置
推荐一台服务器安装一套MCms系统,开通80端口。将所有域名绑定到服务器IP。
Tip
站群管理中的应用管理配置的域名决定了具体的前台及后台的访问地址
Nginx
nginx.conf
server {
listen 80;
#如果服务器只有一套MCms系统绑定80端口,可以不需要配置server_name
server_name www.a.com a.com www.b.com b.com;
}
Tomcat(不推荐)
server.xml
<Host name="www.abc.com" appBase="webapps" unpackWARs="true" autoDeploy="true" xmlValidation="false" xmlNamespaceAware="false">
<Alias>www.a.com</Alias>
<Alias>a.com</Alias>
<Alias>www.b.com</Alias>
<Alias>b.com</Alias>
<Context path="" docBase="项目路径 " debug="0" />
</Host>
Tip
推荐jar包的方式部署
常见问题
新增了站点也添加了管理员,去新增站点下面登录会提示管理员不存在
在本地只能设置localhost和IP为域名来区分站点,如果都建成localhost的域名站点无法区分会造成登录异常。本地测试可以将域名设置成具体的名称,不使用ip+端口作为域名;如
然后可以使用代理工具模拟多个域名。
登陆时,只有在站群管理中应用管理里存在的域名才能访问,子站的域名只能子站的管理员登录,不同站的管理员不能跨站登录
如上图:域名:https://www.testb.com/ms/index.do 只能testb管理员登录。
在新建站点下面放入对应的模板(不是通过后台上传的模式)
在src/main/webapp/template目录下新建文件夹,例如你的新站点编号为1551,那么建的文件夹名称就是1551,文件建好后导入模板即可

资源文件上传同理,在upload文件新建站点文件夹(upload/站点id)。
使用站群插件后新建站点的部分页面无法访问或功能按钮缺少
答:这个是权限没有分配的原因,只需要去权限管理里面重新分配一下权限即可

为什么新站点下面的栏目、文章都没有了,站点与站点之间的栏目不能互通吗
答:新建了站点之后,就类似重新创建了一个后台一样,虽然用的相同的数据库,但是他们之间的栏目、文章等数据是不能互通的,不同的站点数据隔离。
为什么配置了站点域名后,使用hosts给该域名配置了地址却访问不了该地址
答:只有在站群管理中应用管理里存在的域名才能访问。
登录提示账号未绑定站点
答:首先,确保每个站点设置的域名不能相同;其次,各个站点设置的管理员账号是不互通的,a站管理员无法访问b站
站群中使用短链插件的问题
答:短链插件是自定义配置中的开关控制的,站群插件中默认所有站配置都是统一的,所以一般用一个标准站来控制自定义配置相关的设置(如是否开启短链接);在分配站点管理员时,一般不分配自定义配置相关的权限,因为是全局影响;
如果想要各个站点配置不同,需要将mdiy_config表也区分站点,进行二次开发
站群环境下获取当前站点id
使用站群超管账号登录站点在应用管理查看站点信息,通过域名确认站点id

集团站群介绍
企业版本以上才可以使用该插件
Tip
与开源站群不一样,集团站群是树形站群模式,主要用于一个集团下面各个分部门(公司)的站点
特点:
- 可以灵活配置站点业务表
- 管理员、权限通用
- 可以进行站点之间的文章分发
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-site</artifactId>
</dependency>
动图演示
集团站群是父子(树形)站群,适合集团、政府使用
可以快速初始化站点,移除站点
站点配置
Tip
通过站点配置可以快速实现业务表的多站点特性
文章分发:可以快速将本站文章复制分发到其他站点(任意站点),左侧通过栏目和标题等信息筛选待分发的文章,右侧下拉选择被分发的站点再勾选下方对应栏目。
管理员需要有分发权限,且管理员只能分发自身权限范围内允许的文章,即可选文章受其他权限影响;同时被分发站点也需要是当前管理员管理的站点
站点区分
首先站点之间通过不同域名(或不同ip+port)进行区分,地址栏输入不同域名代表访问其对应的站点,若找不到对应站点会提示无站点信息。
进入后台需要先给不同站点设置站点管理员,不同站点可以设置相同管理员,代表该管理员可以管理多个站点,必须使用设置的管理员才能正常登录站点(普通管理员会被拦截,超级管理员将不能正确获取该站点信息)

版本更新说明
每天都在改变、从未停止过….
业务开发
站群配置

- 启用站群配置
开启站群功能,每个站点根据APP表中的APP_URL访问,站群涉及配置表的数据按照站点区分;需要重启才生效
- 启用路径模式
区分站群有两种方式,通过基于访问的host地址和基于Url路径
- host地址:比如 192.168.x.x,127.0.0.1,localhost;127.0.0.1:8080,127.0.0.1:8081(端口区分需要配置代理,参考文档)

Tip
host模式可以配置附属域名,通过附属域名也能访问对应站点;需要多域名或额外的nginx服务代理配置
- Url路径:比如 192.168.0.72:5108,192.168.0.72:5108/site1,192.168.0.72:5108/site2

- 在webConfig中添加配置
registry.addViewController("/*/").setViewName("forward:/msIndex.do");

Tip
Url路径模式只支持一个主站点 192.168.0.72:5108,只能在主站点下去增加路径去扩展其他站
- 站群涉及表
表示哪些表的数据需要区分站点,行隔离级别;配置好后,在集团站群 > 站点管理页面右上角进行初始化APPID,需要手动给APPID赋值(UPDATE XXX SET APP_ID = ‘站点id’)
站点设置管理员
通过操作列的 设置管理员 功能将管理员和站点进行绑定,管理员只能登录已绑定的对应站点,未绑定的站点无法登录;
主机host模式登录
管理员可以通过访问绑定站点的域名(或附属域名)去登录指定站点,登录后,如果可以管理多个站点,可以通过右上角切换站点
路径模式登录
所有站点用户通过主域名登录,如 192.168.0.72:5108 而不是 192.168.0.72:5108/site1之类的; 登录访问的站点,默认是所有绑定站点中的第一个站,其他站点通过右上角进行切换
服务器配置
参考 开源站群插件
Tip
如果从两个站直接数据快速复制,可以采用快速复制栏目数据修改对应的app_id数据,再通过文章迁移将数据复制或移动到对应的站点下面
常见问题
新增站点并发布文章
-
开启站群配置,并设置站群涉及表(cms_content,cms_category);在站点管理进行appId初始化,成功后可在站群涉及表看到新增的app_id字段
-
新增站点,站点域名本地可用127、192、localhost做3个测试站;如需要更多,可以使用代理工具配置代理;内网配置参考
// 代理参考
a.demo.com localhost:8080
b.demo.com localhost:8080
-
新站点的模板文件,头部切换到新站点后,在系统设置->模板管理处上传,也可以直接创建template/站点id/模板文件夹
-
文章分发,将站点的文章分发到其他站点,只能分发终审通过的文章
访问系统提示站点不存在

更改ShiroConfig :
注释public ManagerAuthRealm customRealm(SimpleCredentialsMatcher managerLoginCredentialsMatcher)
打开public BaseAuthRealm customRealm(SimpleCredentialsMatcher managerLoginCredentialsMatcher)
Tip
请优先确认自己的访问地址是否与站点域名一致 !
站群https域名问题
-
部署https域名,站群配置中的域名推荐统一都部署为https,开启yml中https配置参考
-
如果有使用nginx之类的做代理,不需要配置端口代理,nginx配置参考文档
站群功能未生效
在pom依赖中,站群依赖要放在其他所有模块依赖的前面,若加载顺序不对就会导致站群功能无法正常生效。
更换站点目录

需要重新静态化,并对原目录进行删除,避免通过老路径访问
主界面切换站点按钮不显示
1.将主界面注释的代码放开
通过搜索site关键字可快速定位,应有3处注释,下图为一处示例

2.在站群配置中启用站群

nginx环境下,未登录访问静态页面500,登录后访问正常
nginx代理没有把请求头携带过去
参考nginx代理配置
接口忽略站群,sql不拼接appId
需要手写xml,并在dao层方法上使用@InterceptorIgnore(tenantLine = “true”)注解
// IXxxxDao
// 非分页方法,不使用BasicUtil.startPage()开启分页
@InterceptorIgnore(tenantLine = "true")
void ignoreSiteWithoutPaginate();
// 分页方法,使用BasicUtil.startPage()开启分页
// 由于pageHelper和mp的多租户插件有冲突,分页查询count时仍会拼接appId导致总数查询错误
// https://github.com/baomidou/mybatis-plus/issues/4577
@InterceptorIgnore(tenantLine = "true")
List<XXX> ignoreSiteWithPaginate();
// 手动声明count方法解决,以下为示例 , 无需编写xml
@InterceptorIgnore(tenantLine = "true")
long ignoreSiteWithPaginate_COUNT();
接口执行xml联表查询方法,APP_ID ambiguous 歧义
需要给主表别名,app_id就会加上主表的别名,避免歧义

新增站点,新站点下面的栏目、文章都没有了
新建了站点之后,就类似重新创建了一个后台一样,虽然用的相同的数据库,但是他们之间的栏目、文章等数据是不能互通的。
站群如何分配站点管理员
- 先创建站点管理员角色,在创建管理员账号并绑定角色

- 在站点管理点击设置管理员,选择要分配的管理员,点击保存

Tip
普通管理员新增的站点,需超管按上述操作授权后才能进行管理
站群下文章审批配置
Tip
目前暂不支持站群区分审批开关的情况。
目前区分:区分各站点的审批节点数,如 A站点 初审、终审 ,B站点 初审、二审、终审。
使用描述:
-
开启站群,并新增progress_progress,progress_progress_log,approval_config用来区分站群:

-
新增站点:

-
在不同站点分配栏目审批权限 如在A站点:
如在B站点:
线上部署多站点、泛解析域名绑定
泛解析域名绑定,需要将泛解析域名绑定到站点域名。这样就不需要每次去域名服务商修改域名指向。泛解析后可以在站群后台绑定域名
多站点一般通过域名泛解析处理,例:*.website.com

站点关闭情况

路径模式关闭
如果一开始创建的路径模式下的子站点,但是后面需要关闭路径模式,则需要在站点管理中把子站点的域名设置好后访问,否者会有异常提示。建议在创建子站点之前就确定好站群配置
自定义无法访问
这种一般都是主站的自定义数据在子站点访问,主站点的自定义数据只能在主站点查看,这是做了数据隔离,防止站点之间数据错乱等
站群环境,缓存管理相关问题
- 清空缓存,已缓存文章数不清零;清空缓存只清当前站点缓存的文章
- 缓存管理,已缓存文章数大于实际文章总数;有脏数据,关闭站群清空缓存后再开启站群
站群配置开启后,进缓存管理异常
引起原因是开发阶段不规范,在开启站群前刷新缓存保存了非站群环境数据到缓存,然后在开启站群导致;
解决方法:需要各个站点删除自身缓存,未清完之前会一直有报错直到清完;或者先临时关闭站群(需重启)然后清空缓存(只清空),再开启站群(需重启),然后各个站点重新刷新缓存即可。
注意:最好在开发阶段就确定是否需要开启站群
微信插件手册
微信插件是最近后台系统更新上线的一个系统功能,微信插件可管理多个微信号,进行自定义菜单、编辑图文消息、群发功能和关注回复、被动回复、关键字回复功能、消息模板功能、场景二维码管理功能、页面分享功能。
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-mweixin</artifactId>
<version>当前版本</version>
</dependency>
<!-- 微信公众号第三方插件 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 微信公众号公共包第三方插件 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-common</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 微信公众号支付第三方插件 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-pay</artifactId>
<version>4.4.0</version>
</dependency>
依赖weixin-java-tools开源项目
感谢 https://github.com/wechat-group/weixin-java-tools
动图展示




版本更新说明
每天都在改变、从未停止过….
版本 2.1.13
大版本更新,Weixin2.0,与之前的微信插件不向下兼容,开源用户可以继续尝试使用Store中的开源版本Weixin插件
版本1.0.9
- 【优化】依赖版本更新
版本1.0.8
- 【优化】依赖版本更新
版本1.0.6
-
【新增】微信支付设置功能;
-
【新增】支付宝设置功能;
-
【新增】交易记录管理功能;
业务开发
事件推送
获取带参数的二维码
net.mingsoft.mweixin.biz.impl.QrCodeBizImpl
场景二维码管理实体作为入参,每个字段都应有值,返回值是一个存储二维码图片字节和ticket
public Map qrCodeCreateTmpTicket(QrCodeEntity qrCodeEntity) {
if (qrCodeEntity == null && StringUtils.isEmpty(qrCodeEntity.getWeixinId())) {
throw new BusinessException("未找到配置");
}
//获取微信配置
WeixinEntity weixin = (WeixinEntity)weixinBiz.getEntity(Integer.parseInt(qrCodeEntity.getWeixinId()));
if (weixin == null) {
throw new BusinessException("未找到配置");
}
PortalService service = new PortalService().build(weixin);
WxMpQrcodeService qrcodeService = service.getQrcodeService();
WxMpQrCodeTicket wxMpQrCodeTicket = null;
try {
wxMpQrCodeTicket = qrcodeService.qrCodeCreateTmpTicket(qrCodeEntity.getQrSceneStr(), qrCodeEntity.getQrExpireSeconds());
File file = qrcodeService.qrCodePicture(wxMpQrCodeTicket);
HashMap<String, Object> map = new HashMap<>();
map.put("qCode",FileUtil.readBytes(file));
map.put("key", wxMpQrCodeTicket.getTicket());
return map;
} catch (WxErrorException e) {
e.printStackTrace();
throw new BusinessException(e.getMessage());
}
}
Tip
返回值中的key可以用于前台页面检测这次二维码的状态
<img :src="previewUrl" width="300" height="300"/>
···
var codeImg = "data:image/jpeg;base64," + res.data.qCode;
···
关注及扫码等事件处理
对于一些特殊事件,微信服务器会推送消息到系统,例如用户在扫描携带参数的二维码时,分为用户已关注再扫码和用户未关注再扫码关注两种情况。
用户已关注时扫码对应扫码事件,由net.mingsoft.mweixin.service.ScanService做处理;
用户未关注时扫码关注对应关注事件,由net.mingsoft.mweixin.service.SubscribeService做处理;
SubscribeService:
- 关注服务类第一步保存了关注用户的信息,用户扫码关注或者主动查询关注没有区别;
- 第二步检测是不是带参数二维码,带参数二维码通常在关注和扫码两种事件上的业务是高度重合的,所以当检测成功时调用扫码事件处理器去处理相关业务,开发者可以自行增加场景二维码管理,并添加与其beanName相对应的处理器;
- 第三步是用于一二步仍不能满足开发业务需求时,需要额外扩展的处理,只需要写一个实现类去实现net.mingsoft.mweixin.handle.ISubscribeHandle接口即可,注意和扫码处理器接口不同,关注处理器只允许一个实现类
···
if (userWxInfo != null) {
// TODO 可以添加关注用户到本地
this.logger.debug("保存用户信息");
int weixinId = weixinService.getWeixin().getIntId();
weixinPeopleBiz.saveOrUpdate(userWxInfo, weixinId);
}
WxMpXmlOutMessage responseResult = null;
try {
//处理特殊请求,比如如果是扫码进来的,可以做相应处理
this.logger.info("处理特殊请求,比如如果是扫码进来的,可以做相应处理");
// todo 未关注扫码也获取实体的beanName走扫码事件统一处理
IScanHandle scanHandle = scanService.getScanHandleBySceneStrAndWeixinId(wxMessage.getEventKey().replace("qrscene_", ""), weixin.getId());
if (scanHandle!=null){
responseResult = scanHandle.handleSpecial(wxMessage,weixinService);
}
// todo 如果还想在关注的时候做一些事情,请写一个类实现ISubscribeHandle
if (subscribeHandle!=null){
subscribeHandle.handleSpecial(wxMessage,weixinService);
}
} catch (Exception e) {
logger.debug("扫码实现类或关注实现类处理异常");
e.printStackTrace();
}
if (responseResult != null) {
return responseResult;
}
···
ScanService: 扫码服务类中没有统一要处理的业务代码,默认每个不同的场景二维码对应一个不同的处理器,开发者在新增场景二维码时,应在业务代码中新增具体的处理实现类。
···
WeixinEntity weixin = ((PortalService) wxMpService).getWeixin();
PortalService weixinService = (PortalService) wxMpService;
// 获取扫码处理对象,为null不会执行
IScanHandle scanHandle = getScanHandleBySceneStrAndWeixinId(wxMessage.getEventKey(), weixin.getId());
if (scanHandle!=null){
return scanHandle.handleSpecial(wxMessage,weixinService);
}
return null;
···
其余事件处理
其他不同的事件基本都有默认处理的业务代码,基本都能满足业务需求,如果有需要可以参考上面两种方式进行二次开发。
微信消息模板
插件在微信官方的模板字段基础上统一了字段前缀规范,并且新增了两个字段,模板编码和模板关键词,在做业务开发时不需要关心微信本身的字段, 模板编码作为唯一标识,模板关键词作为参数传递;模板编码字段值全表唯一,不可重复
Tip
初次使用需要先同步微信消息模板,然后自定义模板编码和模板关键词,后续再同步,同一个模板的编码和关键词字段会保留
定义规范
对于模板编码字段,插件没有做任何约束,命名时能见名知意即可;
模板关键词字段需要严格按照规范定义,需遵守 微信关键词顺序对应关键词参数顺序
如下图,

注意关键词中必须使用${}取参数,命名不做约束,顺序按照模板内容占位顺序依次排列,占位关键词之间以逗号隔开
业务开发范例
// 参数组装 注意微信消息已经不再发送头尾信息
Map wordList = new HashMap();
wordList.put("first", "这是头信息");
wordList.put("keyword1", "这是关键词1");
wordList.put("keyword2", "这是关键词2");
wordList.put("remark", "这是备注信息");
TemplateBean templateBean = new TemplateBean();
templateBean.setTemplateCode("test-template-code");
// openId错误会发送失败
templateBean.setOpenIds("openId1,openId2,openId3");
templateBean.setWordList(wordList);
// 校验参数,组装消息,注意在实际业务中做参数和查询结果的校验
TemplateEntity template = templateBiz.getOne(new LambdaQueryWrapper<TemplateEntity>().eq(TemplateEntity::getTemplateCode, templateBean.getTemplateCode()));
List<WxMpTemplateData> wxMpTemplateData = TemplateMessageUtil.buildParams(template.getTemplateContent(), template.getTemplateKeyword(), templateBean.getWordList());
String[] receives = templateBean.getOpenIds().split(",");
// 根据模板获取微信
WeixinEntity weixin = weixinBiz.getById(template.getWeixinId());
PortalService service = new PortalService().build(weixin);
TemplateMessageUtil.send(service,wxMpTemplateData,templateBean.getUrl(), template.getTemplateId(),receives);
移动端自动授权登录
移动端登录核心点在于授权,通过授权获取到用户信息再核对进行登录
场景分为两种:
- 用户访问会员层接口
- 用户访问非会员层
会员层接口自动授权登录
会员层接口拥有共同特征,接口路径都是由 /people 开头,根据这个特点可以用拦截器来实现自动授权
代码片段示例参考:
···
if (BasicUtil.isMobileDevice() && BasicUtil.isWechatBrowser()) {
//组装请求参数地址
response.sendRedirect(BasicUtil.getUrl()+"/mweixin/oauth/redirectUrl.do?weixinNo="+weixinNo+"&url="+URLUtil.encodeAll(BasicUtil.buildRequestUrl(request)));
return true;
}
···
非会员层授权自动登录
如果出现非会员层也要获取用户信息的需求情况,例如菜单上有非会员层网站入口要组装成授权地址加菜单实际地址:
BasicUtil.getUrl()+"/mweixin/oauth/redirectUrl.do?weixinNo="+weixinNo+"&url="+ 任意地址(需经过url转码)
例如上面这个地址作为菜单地址,用户会经过授权再回到实际要访问的页面,这样就可以拿到用户信息了
页面分享
页面制作
页面分享首先需要引入 http://res.wx.qq.com/open/js/jweixin-1.6.0.js 用来调用微信js接口。
同时需要引入微信插件中的 /static/mweixin/weixin.js js文件来快速请求签名算法接口,在页面自定义标题描述以及图片就可以达到diy分享效果
示例:
···
<#include "head-file.htm" />
<script src="http://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script src="/static/mweixin/weixin.js"></script>
···
<script>
var shareConfig = {
weixinNo: "weixinNo",// 微信号
title: "分享卡片标题",// 标题随意
desc: "分享卡片描述",// 分享朋友圈没有描述
link: location.href,// 获取签名必须要用本页面地址,不一致会导致签名失败
imgUrl: "{ms:global.host/}template/1/default//images/bg-saas.jpg",// 图片路径必须要是绝对路径
}
ms.weixin.share(shareConfig);
</script>
Tip
示例这段js代码通常情况不需要放到vue实例中,用js标签包裹即可
数据统计
如果还需要数据统计功能,开发者需要在现有基础上进行小幅度二开
二次开发流程可以参考如下步骤:
1.增加一个会员层接口当做分享页面的入口来统计分享的点击记录(需注意参数合法性),处理完数据后再转发到实际地址
2.在获取签名时,可以将返回的签名实体中的url重新组装,将url当做参数转码拼接在分享入口的接口地址后面,这样其他用户点击的时候就会是访问分享入口
Tip
注意在写接口时要考虑微信重复请求的情况,解决办法就是给个返回值
微信绑定第三方用户案例
微信绑定第三方用户案例,默认以会员插件为示例,其他绑定可以参考以下代码:
/**
* 将微信用户与第三方用户绑定
* 若不存在用户直接注册,需要发送短信验证码,存在只创建微信用户和第三方业务的关系
* @param people 包含peoplePhone手机号、peopleCode 短信验证码(需要接发送插件来发送短信)、rand_code图片验证码
* @param response
* @param request
* @return
*/
@ApiOperation(value = "微信用户与第三方用户绑定接口")
@ApiImplicitParams({
@ApiImplicitParam(name = "weixinPeopleWeixinId", value = "关联微信Id", required = false, paramType = "query"),
@ApiImplicitParam(name = "weixinPeopleOpenId", value = "用户在微信中的唯一标识", required = false, paramType = "query"),
@ApiImplicitParam(name = "weixinPeopleProvince", value = "用户所在省份", required = false, paramType = "query"),
@ApiImplicitParam(name = "weixinPeopleCity", value = "用户所在城市", required = false, paramType = "query"),
@ApiImplicitParam(name = "weixinPeopleHeadimgUrl", value = "用户头像链接地址", required = false, paramType = "query"),
@ApiImplicitParam(name = "weixinPeopleState", value = "用户关注状态 1:关注中用户(默认) 2:取消关注用户", required = false, paramType = "query"),
})
@PostMapping("/bindPeople")
@ResponseBody
public ResultData bindPeople(@ModelAttribute @ApiIgnore PeopleBean people, HttpServletResponse response, HttpServletRequest request) {
if (!(checkRandCode())) {
return ResultData.build().error(getResString("err.error", new String[]{getResString("rand.code")}));
}
//验证手机号的值是否合法
if (StringUtil.isBlank(people.getPeoplePhone())) {
return ResultData.build().error(getResString("err.empty", this.getResString("people.phone")));
}
//验证短信验证码的值是否合法
if (StringUtil.isBlank(people.getPeopleCode())) {
return ResultData.build().error(getResString("err.empty", this.getResString("people.code")));
}
Object obj = BasicUtil.getSession(SessionConstEnum.SEND_CODE_SESSION);
if (obj != null) {
PeopleEntity _people = (PeopleEntity) obj;
if (_people.getPeoplePhone().equals(people.getPeoplePhone())) {
//验证发送的短信验证码是否一致
if (_people.getPeopleCode().equals(people.getPeopleCode())) {
people.setPeoplePhoneCheck(PeopleEnum.PHONE_CHECK);
} else {
return ResultData.build().error(this.getResString("err.error", this.getResString("people.phone.code")));
}
}
}
//授权的时候存入了session
WxMpUser wxMpUser = (WxMpUser) BasicUtil.getSession(SessionConst.WEIXIN_USER_SESSION);
WeixinEntity weixin = (WeixinEntity) BasicUtil.getSession(SessionConst.WEIXIN_SESSION);
PeopleBean _people = new PeopleBean();
_people.setPeoplePhone(people.getPeoplePhone());
_people = peopleUserBiz.getByEntity(_people);
if (ObjectUtil.isNotNull(_people)) {
//绑定微信与原有用户的关系
WeixinPeopleEntity _weixinPeople = weixinPeopleBiz.getByOpenId(wxMpUser.getOpenId());
//用户第一次授权登录,但存在用户信息,只需要绑定微信用户与平台用户的关系
if (ObjectUtil.isNull(_weixinPeople)) {
if (StringUtils.isEmpty(_people.getPuIcon())) {
_people.setPuIcon(_weixinPeople.getHeadimgUrl());
}
if (StringUtils.isEmpty(_people.getPuNickname())) {
_people.setPuNickname(wxMpUser.getNickname().replaceAll("[\ud800\udc00-\udbff\udfff\ud800-\udfff]", ""));
}
peopleBiz.updatePeople(_people);
weixinPeopleBiz.saveEntity(wxMpUser, weixin.getIntId(), _people.getId());
}
} else {
//注册新用户
weixinPeopleBiz.saveOrUpdate(wxMpUser, weixin.getIntId());
WeixinPeopleEntity _weixinPeople = weixinPeopleBiz.getByOpenId(wxMpUser.getOpenId());
//默认手机验证通过
_people = new PeopleBean();
_people.setPeoplePhoneCheck(PeopleEnum.PHONE_CHECK);
_people.setPeopleIp(BasicUtil.getIp());
_people.setPeoplePhone(people.getPeoplePhone());
peopleBiz.savePeople(_people);
// 获取peopleId信息
_people = new PeopleBean();
_people.setPeoplePhone(people.getPeoplePhone());
_people = peopleUserBiz.getByEntity(_people);
_weixinPeople.setPeopleId(_people.getId());
weixinPeopleBiz.updateById(_weixinPeople);
}
BasicUtil.setSession(SessionConstEnum.PEOPLE_SESSION, _people);
return ResultData.build().success();
}
微信插件基本基本的接口,具体参考 swagger 接口文档。
常见问题
如何进行沙箱测试
- 进入微信公众号扫码登录进入测试号管理页面(图1),
- 页面会展示 微信号、appID、appsecret三个参数,先将这三个参数填至后台微信公众号编辑表单,然后将其他参数补全然后保存;
- 最后在公众号平台接口配置信息填写生成的url地址和配置的token并提交,提示配置成功后就可以进入公众号进行发布菜单、群发、设置自动回复等功能。

微信服务器多次请求
如果利用了微信授权做了页面跳转,或者接收了推送消息,需要给微信服务器一个返回值,否则微信会在短暂间隔后再次请求,来确认是不是收到了这请求
Tip
若提示token配置失败或者测试号不能正常提供服务,请检查公众号配置和测试号管理中的参数是否是一一对应,是否存在空格大小写等问题,推荐使用复制粘贴。
微信自定义菜单保存异常
异常信息如下,提示json中有无效的编码字符
处理方法:启动时加启动参数 -Dfile.encoding
org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:391):org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Invalid UTF-8 middle byte 0x3f; nested exception is com.fasterxml.jackson.databind.JsonMappingException: Invalid UTF-8 middle byte 0x3f\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 5669] (through reference chain: java.util.ArrayList[2]->net.mingsoft.mweixin.bean.MenuBean[\"subMenuList\"]->java.util.ArrayList[1]->net.mingsoft.mweixin.entity.MenuEntity[\"menuContent\"])
微信h5页面播放视频
<video src="https://www.hbkjcs.com/upload/1/chunkFile/20230504/1683170450881.mp4"
enable-play-gesture controls="true" show-center-play-btn="true" play-btn-position="center"
http-cache="true" show-play-btn="true" show-fullscreen-btn="false" show-loading="true"
enable-progress-gesture
poster-size="fill" object-fit="fill">
修改消息加解密方式为兼容模式或者安全模式出现Illegal key size异常

这一般是加密密钥大小不合法导致,详情解决方式请参考:https://gitee.com/binary/weixin-java-tools/wikis/加解密的异常处理办法
铭飞采集器
包含采集任务、采集规则、采集日志、导入数据、自动导入等功能
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-spider</artifactId>
<version>当前版本</version>
</dependency>
依赖 webmagic-core 开源项目 感谢 https://github.com/code4craft/webmagic
动图展示
创建采集
测试采集
编辑采集规则
-
字段匹配名称: 在采集(测试)时,显示值的名称,用于标注当前列的值的含义;
-
字段匹配规则:
-
映射类型: 根据采集内容类型选择
-
字段匹配:字段匹配规则选择的是“默认“,此处填写默认值;否则填写对应匹配方案的匹配规则
-
关联表列名:此列配置对应当前采集任务导入表中的哪个字段
-
结果替换规则:对采集到的结果使用freemarker语法做二次处理;
-
内置参数:${content} 为当前列采集匹配到的内容;${app} 获取app实体的属性;
-
二次处理演示:
截取采集内容50的长度eg:<#if content?has_content>${content[0..50]}..</#if>
输出yyyy-MM格式的时间eg:<#if content?has_content><#assign content=content?date(“yyyy-MM-dd”)/> ${content?string(“yyyy-MM”)}</#if>
freemarker语法请参考freemarker文档
-
-
列表规则:开启后,推荐使用xpath匹配规则,并且只作用于列表
-
html内容:开启后,会检测当前匹配的内容是否满足html格式,不满足不会采集
Tip
列表规则的采集配置需要添加上对应内容链接的值,如通过xpath匹配列表中对应内容的图片//a[@href=“{}”]/img/@src,{}为内容链接的占位,会自动处理
采集日志
采集匹配规则
导入采集数据
版本更新说明
每天都在改变、从未停止过….
业务开发
采集任务列表
一个采集任务管理导入的数据表格与是否自动导入配置
可以点击相应任务的规则列表进入采集任务的采集规则管理页面

任务编辑

采集规则
规则列表
采集任务的规则列表,运行采集规则可以进行采集作业,也可以对单个采集规则进行采集测试

规则配置
根据约束配置采集规则
如若需要采集多页的数据,在规则编辑中选择分页,采集器会把起止页的范围内的数字通过通配符替换到url上遍历
采集作业时采集到的每条数据可以通过页面下方填写的字段匹配规则进行筛选配对
若项目安装了站群,并且该采集任务导入的数据表涉及到站群分点时,请手动在字段匹配规则中添加一个appid默认值规则

采集规则测试
在规则列表里点击测试按钮可以模拟采集作业,观察规则是否生效、采集的数据是否正确

运行采集规则
选择需要运行的规则,点击运行采集器会根据选择的规则作业,当采集任务选则了自动导入规则时采集的数据会即时导入,未选择该功能可以移步至采集日志自由选择数据进行导入
采集日志
日志列表
在日志列表中可以查询到采集器采集作业时的数据(不包含采集测试的数据) 选择需要导入的数据会根据对应的采集任务配置的规则进行导入

常见问题
列表图片采集示例

这是图文列表结构的一部分,通过xpath匹配对应内容链接的缩略图
注意 {} 中的地址自动取内容链接匹配的group(1)
//a[@href="{}"]/img/@src
提示javax.net.ssl.SSLException: Received fatal alert: protocol_version
是因为我们启动服务的TLS版本不符合采集网站的要求,从而抛出了拒绝连接的异常;
一般通过修改jdk版本解决,jdk与tls版本参考
采集没反应,控制台提示 WebSocket connection to ‘ws://…’
使用了nginx代理,但是代理中没有配置ws协议
分页怎么使用
答:首先观察被采集页面分页情况,特别是地址栏上页数参数的规律,将分页参数替换成通配符如%s,采集器会根据起止页范围替换通配符进行循环遍历采集
编写的规则采集作业没有效果

请注意:编写的正则、xpath是针对网页的源代码,而不是浏览器渲染后的(元素)结构
答:主要从以下几个方面进行检查 1、列表页地址 地址是否能够访问,若选择了分页测试采集的打印的链接是否能够访问
2、内容链接 内容链接需要使用正则表达式来匹配列表页上的链接,可以通过在线正则表达式检测进行检查填写的表达式是否正确
3、站群项目需要手动添加站点编号 安装了站群的用户如果采集数据导入的表涉及到站点编号(如文章内容表),需要在字段匹配手动添加appid默认值一列, 默认值填写需要导入数据站点的站点编号,如果导入的数据表不涉及到站群业务则不需要添加。
涉及到站群业务的表在“关联表列名“Select选择器会有app_id选项。
4、字段匹配 检查字段匹配规则是否选择正确,表达式是否生效,映射类型需要与导入的表字段类型对应。
定时自动采集
默认提供了采集任务对应的方法 TaskBizImpl.job(采集任务名称) 开发者如果系统有定时任务调度功能,直接配置调用就可以使用。 如果没有定时任务调度需要手动增加一个类来做定时任务。
代码示例
@EnableScheduling
public class SpiderJob extends BaseJob{
@Autowired
ITaskBiz taskBiz
@Scheduled(cron="* 0/5 * * * ?") //5分钟采集一次
public void task() {
List<TaskEntity> list = taskBiz.list();
list.forEach(t -> {
taskBiz.job(t.getTaskName());
});
}
}
文件管理
可以实现文件管理与在线预览,支持Word、Excel、PDF在线预览
Tip
文件预览功能需要搭建文件预览服务器,文件预览预览服务器基于开源套件 kkFileView,这里也特别感谢作者的分享。
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-file</artifactId>
</dependency>
环境配置
kkFileView 仓库地址 https://gitee.com/kekingcn/file-online-preview
docker run --name view-file -p 8012:8012 --restart=always --privileged=true keking/kkfileview
动图演示
可以在线预览基本的office文件格式
可以控制使用云存储
七牛云存储配置
版本更新说明
每天都在改变、从未停止过….
业务开发
默认采用分片上传设计,开发者只需要在自己的业务中关心抽象类返回的状态码是什么。
状态码分为三类:
- 200表示所有分片上传完毕,返回值是文件地址(相对路径);
- 206表示分片上传成功,返回值为当前总分片数;
- 500表示上传失败。其余情况均抛出异常处理。
扩展第三方云存储
默认集成了七牛云存储。
集成第三方接口步骤:
1、新建对应云存储类,并继承IUploadChunkService类,重写upload方法及checkFileIfExist方法,自定义一个 beanName 名称
2、 配置文件上传类型,打开菜单 自定义->自定义字典->文件上传类型 添加字典,标签名为 ‘存储类型’ ,值为存储类的beanName,如:七牛云 uploadQiNiuService
3、 修改存储配置,打开菜单 文件管理->存储设置下拉选择存储方式 ;
七牛云范例
创建存储类 UploadQiNiuServiceImpl
/**
* 七牛云的上传实现类
*/
@Service("uploadQiNiuService")
public class UploadQiNiuServiceImpl extends IUploadChunkService {
@Override
public ResultData upload(UploadConfigBean bean) {
ResultData resultData = super.upload(bean);
// 分片未上传完毕则返回
if (resultData.getCode()!= HttpStatus.OK.value()){
return resultData;
}
LOG.debug("七牛云上传实现");
String targetFile = resultData.getData(String.class);
try {
ImageUtil.imgWatermark(targetFile);
} catch (IOException e) {
LOG.debug("水印添加失败");
e.printStackTrace();
}
/**
* 获取七牛云配置
*/
Map<String,String> map = ConfigUtil.getMap("七牛云配置");
String qiniuAk = map.get("qiniuAk");
String qiniuSk = map.get("qiniuSk");
String qiniuDomain = map.get("qiniuDomain");
String bucketName = map.get("bucketName");
if(!qiniuDomain.endsWith("/")){
qiniuDomain += "/";
}
String uploadPath = ConfigUtil.getString(Const.CONFIG_UPLOAD, "uploadPath", "upload");
// 判断配置的上传路径是否绝对路径
boolean isReal = new File(uploadPath).isAbsolute();
String uploadMapping = ConfigUtil.getString(Const.CONFIG_UPLOAD, "uploadMapping", "/upload/**");
targetFile = targetFile.replace(uploadMapping.replace("**", ""), "");
if(isReal) {
//源图片
targetFile = uploadPath+"/"+targetFile.replace(uploadMapping.replace("**", ""), "");
} else {
targetFile = BasicUtil.getRealPath(uploadPath + File.separator +targetFile.replace(uploadMapping.replace("**", ""), ""));
}
/**
* 七牛云上传逻辑
*/
//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.autoRegion());
//...其他参数参考类注释
UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传
String accessKey = qiniuAk;
String secretKey = qiniuSk;
String bucket = bucketName;
//默认不指定key的情况下,以文件内容的hash值作为文件名
String key = bean.getFileName();
String filePath = null;
InputStream inputStream = FileUtil.getInputStream(targetFile);
Auth auth = Auth.create(accessKey, secretKey);
String upToken = auth.uploadToken(bucket);
try {
Response response = uploadManager.put(inputStream,key,upToken,null, null);
//解析上传成功的结果
DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
filePath = qiniuDomain + putRet.key;
System.out.println("完整文件路径:\t"+ filePath);
} catch (QiniuException ex) {
Response r = ex.response;
System.err.println(r.toString());
try {
System.err.println(r.bodyString());
} catch (QiniuException ex2) {
//ignore
}
}
// 清除本地服务器文件
if (FileUtil.file(targetFile).exists()){
FileUtil.del(targetFile);
}
return ResultData.build().success(filePath);
}
@Override
public boolean checkFileIfExist(String realPath) {
//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.region0());
//...其他参数参考类注释
Map<String,String> map = ConfigUtil.getMap("七牛云配置");
String qiniuAk = map.get("qiniuAk");
String qiniuSk = map.get("qiniuSk");
String bucketName = map.get("bucketName");
String key = FileUtil.getName(realPath);
Auth auth = Auth.create(qiniuAk, qiniuSk);
BucketManager bucketManager = new BucketManager(auth, cfg);
try {
FileInfo fileInfo = bucketManager.stat(bucketName, key);
LOG.debug(fileInfo.md5);
// todo 判断方式
return StringUtils.isNotBlank(fileInfo.md5);
} catch (QiniuException ex) {
System.err.println(ex.response.toString());
}
return false;
}
}
Tip
代码中的七牛云配置信息通过自定义配置实现
beanName 名称为 uploadQiNiuService
配置文件上传类型 字典

存储设置
选择七牛云存储

Tip
通过自定义配置实现
常见问题
文件上传提示成功,却没有回显文件
这种情况可能是已经在别的目录下上传过这份文件,系统不允许重复上传,建议删除其他层级下的文件在当前目录重新上传
云存储图片删除问题
使用云存储时,云存储的图片资源不会通过系统删除;删除功能按需对接处理
预览文件404
一般都是 文件管理->存储设置 中的文件存储地址错误,需要是后台服务的地址;
验证方法,在浏览器中直接输入文件地址看能否访问到
Tip
文件夹名称为中文会导致文件夹内部文件预览失败
使用kkFileView预览文件显示I`m sorry
- 优先确认该文件是否存在
- 确认文件是否支持访问,再次确认文件和kkFileView是否在同一端,外网服务无法访问内网文件
清理插件手册
系统运行一段时间后会产生许多不用等附件(垃圾文件),可以通过此插件进行定时清理。
Tip
文件清理插件不会物理删除文件,只是把要删除的文件移动到了备份目录
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-clean</artifactId>
</dependency>
使用介绍
涉及到使用上传文件资源的表都要在此处管理,支持配置多个清理路径
需配置表和字段,如APP(app_logo)、CMS_CONTENT(content_img,content_details)、CMS_CATEGORY(category_img)等都使用到了上传的文件资源;
清理会清除清理路径中符合清理类型却没有被使用到的的“垃圾“文件。
若不慎指定了错误路径导致清理掉了不必要的文件,可在回收站中还原文件(在没有被彻底删除的情况下)。
动图演示

版本更新说明
每天都在改变、从未停止过….
业务开发
零开发!!! 采用配置的方式进行文件清理,可以配合定时调度来实现自动清理。
定时调度
默认提供了任务执行方法,直接在定时调度模块配置使用。
cleanTableBizImpl.clearFile()
常见问题
添加依赖后启动失败,报Error creating bean with name ‘cleanTableAction’,‘cleanTableBizlmpl’
部分异常如下

解决方法:将pom中清理插件ms-clean依赖放在ms-mdiy后面
统计插件手册
用于统计相应的信息,核心是根据自定义SQL进行查询相应的信息,如:统计会员的相关信息
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-statistics</artifactId>
</dependency>
效果演示


Tip
默认集成了网站统计与工作量统计
版本更新说明
每天都在改变、从未停止过….
业务开发
使用流程
1、后台新增统计数据
2、前台通过接口调用获取
Tip
接口可以一次性获取多个统计数据,可以实现各种统计图表的效果,插件默认集成页面访问统计功能,工作量统计
页面统计使用
{@ms:webstatistics/}
Tip
放在通用模版文件里面使用即可,一般放置于页尾处。可以放在通用引入文件中,所有引入该通用文件的模板都会被统计;但是不管如何都得放在ms.base下面
后台管理

新增SQL:

Tip
后台直接通过SQL查询的方式进行统计。注意雪花ID前端精度丢失问题(根据不同数据库进行类型转换,如 MYSQL CAST函数)
HTTP接口
获取统计接口
请求URL:
- /statistics/statistic/list.do 这个接口是通用层调用,使用该接口时,注意敏感信息传入
- /people/statistics/statistic/list.do 这个接口会员层调用,使用该接口时,会自动获取当前用户的peopleId
- ms.manager + /statistics/statistic/list.do 这个接口只允许后台调用,权限最大
HTTP方法:POST
请求URL:/statistics/statistic/list.do
请求头配置: ‘content-type’:‘application/json’
请求参数:
| 参数 | 是否必选 | 类型 | 说明 |
|---|---|---|---|
| name | 是 | String | 查询数据的名称 |
| params | 是 | Object | 内部为json格式的key:value对象 [{name:‘统计名称’,params:{统计SQL内的字段} }] |
返回数据:
[
{ 统计sql对应名称: 统计sql返回的对应列值 },
]
范例
Tip
storeStatistics[0] 值可能为空,可以做三元运算的判断设置默认值
...
<div class="index-top-data">
<!--分享-start-->
<div class="top-data">
<span class="top-data-num"> {{storeStatistics[0] ? storeStatistics[0].share_total : 0}} </span>
</div>
<div class="top-data-desc">
<div class="top-data-desc-left">
<i class="iconfont icon-pifu top-data-desc-icon"></i>
<span class="top-data-desc-txt"> 皮肤 </span>
<span class="top-data-desc-num"> {{storeStatistics[1] ? storeStatistics[1].share_num : 0}} </span>
</div>
<div class="top-data-desc-left">
<i class="iconfont icon-fenlei top-data-desc-icon"></i>
<span class="top-data-desc-txt"> 插件 </span>
<span class="top-data-desc-num"> {{storeStatistics[2] ? storeStatistics[2].share_num : 0}} </span>
</div>
</div>
<!--分享-end-->
</div>
...
...
data() {
return {
storeStatistics: [],// 平台统计信息数组,下标对应入参
};
},
methods: {
// 平台统计数据接口,store端 params为接口参数
// 下方为多组统计一次请求的方式,返回结果为一个数组,响应结果顺序对应参数传入的顺序
statisticsStore() {
var that = this
var params = [{
name: '用户总分享数',
params: {people_id: that.peopleInfo.peopleId}
},{
name: '分享的皮肤或插件数',
params: {people_id: that.peopleInfo.peopleId,share_type: 'template'}
},{
name: '分享的皮肤或插件数',
params: {people_id: that.peopleInfo.peopleId,share_type: 'plugin'}
}];
ms.http.post('/statistics/statistic/list',params,{
headers:{'content-type':'application/json'},
}).then(function(res){
if(res.result){
that.storeStatistics = res.data// Store统计信息数组,下标对应入参
}
})
},
},
created() {
var that = this;
that.statisticsStore();
},
...
注意
接口使用规范,现在默认有三种类型
- 游客类型:通过此接口获取的统计数据,只能获取到游客类型统计数据,不能获取其他类型数据。
- 会员类型:通过此接口获取的统计数据,只能通过会员类型统计数据,会自动获取到当前登录用户ID,压入执行的SQL中,使用会员层统计接口必须登录。
- 系统类型:这一般用来后台统计使用,这个会查询传入的所有数据,所以,如果要统计会员类型数据,需要传入会员的peopleId。
常见问题
接口请求头不支持
答:配置请求头 ‘content-type’:‘application/json’ 后请求,默认情况下会采用 ‘content-type’:‘application/x-www-form-urlencoded’
根据管理员统计数据为0
答:开启数据库忽略大小写配置
为什么昨天没有统计数据
答:需要开启定时调度任务,建议每天至少执行一次,具体根据自身调整。

站群环境下使用网站数据统计
答:在站群配置中的站群涉及表中添加statistics_access表,然后初始化站点。正常使用即可

进度插件手册
可以定义不同的进度的方案,每个进度方案定义多个进度节点,每个进度节点都会产生多条进度日志。
Tip
需要根据实际业务进行代码开发才能使用
可以实现的业务场景
进度记录:A项目在整个业务系统中各个进度的展示,如创建、审批、实施、结束等
审批:可以实现信息数据的审批业务
游戏任务:游戏中的任务实现
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-progress</artifactId>
</dependency>
版本更新说明
每天都在改变、从未停止过….
业务开发
后台新增审核方案

设置审核节点
点击方案名称,进行审核方案节点设置
按需设置审核的节点数
设置办理审核节点的角色
通过操作列 设置审核按钮,设置审核节点负责角色
按需设置每个节点负责审核的角色
业务数据
业务表增加PROGRESS_STATUS字段
ALTER TABLE 业务表名 ADD COLUMN PROGRESS_STATUS varchar(50) DEFAULT NULL COMMENT '审核状态' ;
业务实体继承BaseApprovalEntity

业务控制层新增、更新方法添加@Approval注解

作用:在新增、更新业务数据时,自动设置业务数据的审核状态值
Note
注意,上例图中是新增和更新是一个方法,所以只加一处注解;@Approval注解新增、更新方法都要加
| 注解参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| schemeName | 是 | 审核方案名称 | 无 |
业务数据页面
业务数据列表页提交审核、撤销组件ms-approval-action

组件参数说明
| 组件参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| dataId | 是 | 业务数据id | 无 |
| schemeName | 是 | 审核方案名称 | 无 |
| actionType | 是 | 当前业务操作类型,支持类型 submit revoke | submit |
| dataExt | 是 | 业务扩展数据,用于待办页面展示,提交操作生效 | 无 |
| title | 是 | 操作按钮名称 | submit情况默认为提交审批,revoke情况默认为撤回 |
| callback | 否 | 提交后回调方法 | 无 |
...
<!-- 头部添加组件引入 -->
<#include "progress/components/ms-approval-action.ftl">
...
<!--操作列展示-->
<!-- 提交审核 -->
<ms-approval-action :data-id="scope.row.id"
scheme-name="角色"
@callback="list"
action-type="submit"
:data-ext="{'dataTitle':scope.row.roleName}"
v-if="scope.row.progressStatus == '待提交' ||
scope.row.progressStatus == '撤销' ||
scope.row.progressStatus.indexOf('不通过') != -1">
</ms-approval-action>
<!-- 撤销 -->
<ms-approval-action :data-id="scope.row.id"
scheme-name="角色"
@callback="list"
action-type="revoke"
v-if="scope.row.progressStatus != '待提交' &&
scope.row.progressStatus != '终审通过' &&
scope.row.progressStatus != '撤销' &&
scope.row.progressStatus.indexOf('不通过') == -1">
<!--vue js-->
components: {
// 注册组件
MsApprovalAction,
},
增加权限
权限结构:“progress:approval:” + 业务审核方案编号 + “:submit|:revoke”
常见问题
定时度插件手册
低代码实现一些定时执行的业务,如:定时采集、定时静态化、定时发短信
Tip
需要根据实际业务进行代码开发才能使用
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-quartz</artifactId>
</dependency>
动图演示


版本更新说明
每天都在改变、从未停止过….
业务开发
开发步骤:
- 定义类
- 后台配置任务
定义类
package net.mingsoft.biz.impl;
@Service("logBizImpl")
public class LogBizImpl extends BaseBizImpl<ILogDao, LogEntity> implements ILogBiz {
/**
* 日志备份
*/
@Override
public void bakup() {
//具体业务代码
}
}
Tip
定义的类必须由spring管理,在后台配置调用目标可以填写
net.mingsoft.biz.impl.logBizImpl.bakup()或logBizImpl.bakup()方法执行bakup方法
后台配置任务
具体参考后台表单提示
演示数据
INSERT INTO `quartz_job` (`ID`, `QJ_STATUS`, `QJ_ASYNC`, `QJ_POLICY`, `QJ_CRON`, `QJ_TARGET`, `QJ_GROUP`, `QJ_NAME`, `DEL`, `UPDATE_DATE`, `UPDATE_BY`, `CREATE_DATE`, `CREATE_BY`) VALUES (11, '0', '0', NULL, '0 0 0/2 * * ? ', 'basicAppAction.get()', 'example', '演示', 0, '2025-04-18 14:11:10', '57', '2025-04-18 14:00:53', '57');
常见问题
启动后异常org.quartz.SchedulerConfigException、org.quartz.JobPersistenceException
问题一般由多端同时操作产生,线上不会存在此问题;删除qrtz_开头表数据,重启后系统会自动生成

保证下面业务表的定时任务数据正常

将qrtz_表的数据清空即可
DELETE FROM qrtz_blob_triggers;
DELETE FROM qrtz_calendars;
DELETE FROM qrtz_cron_triggers;
DELETE FROM qrtz_locks;
DELETE FROM qrtz_paused_trigger_grps;
DELETE FROM qrtz_scheduler_state;
DELETE FROM qrtz_simple_triggers;
DELETE FROM qrtz_simprop_triggers;
DELETE FROM qrtz_triggers;
DELETE FROM qrtz_job_details;

集群环境下定时调度配置
以初始数据静态化配置为例,只需要关心配置的参数是否正确。 如模板是否存在、域名是否和数据库app表一致,满足以上条件则会生成html静态文件,不同服务下的静态文件目录可以通过文件同步来保持一致。
部署时可以将其余从服务上的系统移除定时调度插件,保留主系统中的定时调度即可; 可以参考下定时调度集群配置https://www.panziye.com/java/5707.html
Tip
集群环境下,所有集群服务都会按配置的cron表达式执行开启的定时任务;若要减少不必要的性能损耗,可以在其它服务打包时将定时调度插件排除。
编码规则插件
业务系统经常会用到自动编码,如:员工编号、房间编号、货架编号等,都可以使用该插件来灵活配置
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-id</artifactId>
</dependency>
效果展示

Tip
先创建规则,再创建规则配置,最后通过业务代码调用
业务开发
步骤
1、后台创建规则并配置好规则
2、调用编码规则 IDUtil.getId(“规则名称”,“序号”,Map)
Tip
规则配置
自定义变量需要在业务代码中通过参数传递,自定义变量的值 必须是自定义变量的key值,
例如:a:100,自定义变量 必须填写 a ,序号会自动补全,
例如需要填写3,业务数据id为1,最终生成的序号为 001
范例
员工代码片段
public ResultData save(@ModelAttribute @ApiIgnore EmployeeEntity employee, HttpServletResponse response, HttpServletRequest request) {
...
employee.setEmployeeCode(IDUtil.getId("员工编号", Long.parseLong(employee.getId()));
...
}
常见问题
数据权限插件手册
在角色权限的前提下,更细粒度的控制数据权限控制,例如:管理员栏目权限、组织机构员工文章权限等
Tip
可以实现任意一张表里面的数据权限控制
管理员栏目权限演示
- 新增拥有栏目、文章查看权限的角色

- 细粒度设置该角色可以查看的栏目和对应栏目下文章的操作权限

- 新增管理员并设置刚新增的角色

- 登录新增管理员查看数据


这里可以看到,登录该管理员后只能看到栏目权限管理分配的栏目;并且可以看到文化栏目的新增权限和图片栏目的删除权限都受控制的
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-datascope</artifactId>
</dependency>
配置
application.yml 增加配置
pagehelper:
helper-dialect: net.mingsoft.datascope.dialect.SqlPermissionDialect
业务开发
可以实现数据维度的权限控制,主要分两种场景;
第一种查看权限的控制,常见于数据列表显示,例如:用户只能看到自己创建的数据或被分配给自己可操作的数据,一般使用数据权限工具类DataScopeUtil.start()控制
第二种是功能权限控制,数据操作的功能权限控制,例如用户在拥有指定数据访问的前提下在进行功能权限控制,例如只能看数据,不能修改数据或删除数据, 在需要控制权限的方法上使用@DataScope注解
数据范围控制
可以控制目标者(用户)拥有的数据范围
1、使用 DataScopeUtil 在查询列表处调用
2、开发配置页面
DataScopeUtil调用
代码片段摘取自 企业版
public ResultData list(@ModelAttribute @ApiIgnore CategoryEntity category, HttpServletResponse response, HttpServletRequest request, @ApiIgnore ModelMap model, BindingResult result) {
BasicUtil.startPage();
//如果存在分页数据,必须在startPage之后调用
DataScopeUtil.start(this.getPeopleBySession().getId(),this.getLevel(),"role",true);
List categoryList = categoryBiz.query(category);
return ResultData.build().success(new EUListBean(categoryList,(int) BasicUtil.endPage(categoryList).getTotal()));
}
Tip
注意
DataScopeUtil.start(...)方法调用位置,如果存在分页必须在BasicUtil.startPage()之后调用
开发配置页面

Tip
datascope已经提供了基本的保存、更新接口,开发者只需要编辑一个视图页面调用接口就可以完成数据范围权限开发
数据功能控制
控制数据的具体功能权限,实现复杂的针对具体数据的权限功能控制。
1、方法上使用 @DataScope (可选:如果不采用后端控制,后端控制业务更严谨)
2、前端页面视图控制 v-ms-datascope 或 通过 js 控制
3、开发配置页面
Tip
与 shiro 不同点:shiro 是系统全局功能权限 ,这里实现的是针对具体数据的功能权限。
@DataScope使用
/**
* 保存文章
* @param content 文章实体
*/
@PostMapping("/save")
@ResponseBody
@ESSave
@LogAnn(title = "保存文章", businessType = BusinessTypeEnum.INSERT)
@RequiresPermissions("cms:content:save")
@DataScope(type="managerRole",id="getCategoryId",requiresPermissions = "cms:content:save")
public ResultData save(@ModelAttribute @ApiIgnore ContentEntity content, HttpServletResponse response, HttpServletRequest request) {
//.....
}
@DataScope(type=“业务分类”,id=“当前方法绑定实体的id主健值,默认getId”,requiresPermissions = “对应菜单权限标识”)
Tip
与 shiro 不同点:shiro 是系统全局功能权限 ,这里实现的是针对具体数据的功能权限。
@DataScope的方法参数必须存在BaseEntity对象
id=“getCategoryId” 会调用content实体关联的栏目id
上面配置页面编写好后,还需要在对应的控制调用DataScopeUtil.start方法,用于过滤数据
前端页面视图控制
引入js
<script src="${base}/static/datascope/index.js"></script>
- js业务调用
1、定义 datascopes变量;
2、定义两个方法 hasPermission queryDatascope
3、在 created 调用 queryDatascope
var app = new Vue({
el: '#app',
data: {
datascopes: false, //默认所有都没有权限
},
methods: {
//判断是否有权限
hasPermission:function (permission) {
var has = this.datascopes==true || this.datascopes.indexOf(permission)>-1;
return has;
},
//查询权限配置
queryDatascope:function() {
var that = this;
ms.datascope({
dataType: "managerRole",
dataId: this.form.categoryId,
isSuper: true,
}).then((datascopes)=>{
that.datascopes = datascopes;
});
}
},
created: function () {
this.queryDatascope(); //加载权限
}
})
页面调用
v-if="hasPermission('cms:content:save')"
Tip
页面中有大量验证的时候推荐使用js业务验证方式,减少接口请求
- 指令调用
不具备对应功能权限时候,会直接移掉dom元素,类似v-if的效果
v-ms-datascope="{
dataType: 'managerRole',
dataId: form.categoryId,
isSuper: true,
hasModels: 'cms:content:save,cms:content:update'
}"
Tip
每调用一次指令都会发起一次验证请求,适合页面中少部分验证,如果验证比较多推荐js业务方式验证;
使用指令 v-ms-datascope 绑定的变量必须在 mounted 进行初始化,否则会出现 指令内部无法绑定上值;
开发配置页面
事先要根据实际业务情况制作一个权限配置的页面,
例如:角色绑定栏目,可以达到控制角色可以访问哪些栏目下的文章数据,并且可以控制对应栏目的功能权限

Tip
datascope已经提供了基本的保存、更新接口,开发者只需要编辑一个视图页面调用接口就可以完成数据范围权限开发
配置页面模版
代码摘取自 企业版 角色绑定栏目
<!DOCTYPE html>
<html>
<head>
<title>管理员权限分配</title>
<#include "../../include/head-file.ftl">
</head>
<body>
<div id="index" v-cloak class="ms-index">
<el-header class="ms-header ms-tr" height="50px">
<@shiro.hasPermission name="datascope:data:save">
<el-button type="primary" icon="iconfont icon-baocun" size="mini" @click="save()" :loading="saveLoading">保存
</el-button>
</@shiro.hasPermission>
</el-header>
<el-main class="ms-container">
<el-alert
class="ms-alert-tip"
title="功能介绍"
type="info"
description="此功能必须超级管理员才能使用,通过对角色的栏目授权,可以控制对应角色下管理人员在对应栏目下的文章管理权限,注意前提是该角色具备文章管理权限,如果不具备这里的设置无效,因为角色的功能权限控制大于这里的权限绑定">
</el-alert>
<div style="display:flex;flex-direction: row;flex:1" v-loading="dataIdLoading">
<div style=" display: flex;position: relative;margin-right: 10px;">
<el-scrollbar>
<el-aside width="400px" style="background-color: #f2f6fc;">
<el-table v-loading="loading" ref="targetIdMultipleTable" height="calc(100vh - 150px)"
class="ms-table-pagination"
border
stripe
:header-cell-class-name="cellClass"
@select="targetIdSelectionRow"
@selection-change="targetIdHandleSelectionChange"
:data="targetIdList" tooltip-effect="dark">
<template slot="empty">
{{emptyText}}
</template>
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column label="需授权的角色名称" align="left" prop="roleName" show-overflow-tooltip>
</el-table-column>
</el-table>
</el-aside>
</el-scrollbar>
</div>
<div style="display: contents;flex-direction: column;flex: 1;margin-left: 220px;">
<el-scrollbar style="width: 100%; overflow-x: hidden">
<el-table ref="multipleTable" :indent="6"
border :data="dataIdList"
stripe
height="calc(100vh - 150px)"
:row-key="(row)=>{return row.id}"
default-expand-all='true'
:tree-props="{children: 'children'}"
:select-on-indeterminate="true"
tooltip-effect="dark"
@select="rowSelect"
@select-all="selectAll"
>
<template slot="empty">
{{dataIdEmptyText}}
</template>
<el-table-column type="selection" width="40" reserve-selection="true"
class-name="isCheck"></el-table-column>
<el-table-column label="栏目名称" align="left" prop="categoryTitle">
</el-table-column>
<el-table-column label="栏目下文章管理权限" align="left">
<template slot-scope="scope" v-if="scope.row.leaf">
<el-checkbox v-for="(model,index) in scope.row.dataIdModelList"
:label="model.modelTitle"
v-model="model.check"
:disabled="!scope.row.check"
:key="scope.row.categoryId+index"></el-checkbox>
</template>
</el-table-column>
</el-table>
</el-scrollbar>
</div>
</div>
</el-main>
</div>
</body>
</html>
<script>
var indexVue = new Vue({
el: '#index',
data: {
saveLoading: false,
manager: ms.manager,
loading: true,
emptyText: '',
targetIdList: [],
checked: 1,
// 右侧数据
dataIdList: [],
//加载状态
dataIdLoading: true,
//提示文字
dataIdEmptyText: '',
//右侧列表选中
dataIdSelectionList: [],
// 右侧树形表格全选
dataIdAllSelect: false,
dataScopeForm: {
dataTargetId: '',
// 数据权限类型
dataType: 'managerRole',
},
},
methods: {
//取消角色列表全选
cellClass(row) {
if (row.columnIndex === 0) {
return 'disabledCheck'
}
},
targetIdQuery: function () {
var that = this;
that.loading = true;
ms.http.get(ms.manager + "/basic/role/list.do", {pageSize: 100}).then(function (data) {
if (data.result) {
that.loading = false;
that.targetIdList = data.data.rows;
}
}).catch(function (err) {
console.log(err);
});
},
targetIdHandleSelectionChange: function (val) {
if (val.length == 0) {
this.dataIdSelectionList = [];
this.dataScopeForm.dataTargetId = "";
this.$refs.multipleTable.clearSelection();
} else if (val.length > 1) {
this.dataIdSelectionList = [];
this.$refs.targetIdMultipleTable.clearSelection()
this.$refs.targetIdMultipleTable.toggleRowSelection(val.pop())
} else {
this.dataScopeForm.dataTargetId = val[val.length - 1].id;
}
},
targetIdSelectionRow: function (selection, row) {
var selected = selection.length && selection.indexOf(row) !== -1; //为true时选中,为 0 时(false)未选中
if (selected) {
this.dataScopeForm.dataTargetId = row.id
this.getDataScopeData();
} else {
this.dataScopeForm.dataTargetId = "";
}
},
//获取选中权限数据
getDataScopeData() {
var that = this;
ms.http.get(ms.manager + "/cms/co/category/categoryList.do", this.dataScopeForm).then(function (data) {
if (data.result) {
that.dataIdLoading = false;
if (data.data.length > 0) {
that.dataIdEmptyText = '';
// that.dataIdList = data.data;
that.dataIdList = ms.util.treeData(data.data, 'id', 'categoryId', 'children');
that.$nextTick(() => {
that.toggleRowSelection(that.dataIdList);
});
} else {
that.dataIdEmptyText = '暂无数据';
that.dataIdList = [];
}
}
}).catch(function (err) {
that.dataIdLoading = false;
console.log(err);
});
},
toggleRowSelection: function (datas) {
datas.forEach(item => {
if (item.check) {
this.$refs.multipleTable.toggleRowSelection(item, true)
}
if (item.children) {
this.toggleRowSelection(item.children);
}
});
},
//权限分配
save: function () {
var that = this;
that.saveLoading = true;
this.dataIdSelectionList = this.$refs.multipleTable.selection;
var _data = new Array();
this.dataIdSelectionList.forEach(item => {
if (item.leaf) {
item.dataTargetId = that.dataScopeForm.dataTargetId;
item.dataType = that.dataScopeForm.dataType;
_data.push(item);
}
})
//如果length==0,代表清空权限
if (_data.length == 0) {
_data.push({dataType: that.dataScopeForm.dataType, dataTargetId: that.dataScopeForm.dataTargetId})
}
ms.http.post(ms.manager + "/cms/co/category/saveBatch.do", _data, {
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
that.dataIdLoading = false;
if (res.result) {
that.$notify({
title: '成功',
message: '权限设置成功',
type: 'success'
});
} else {
that.$notify({
title: '失败',
message: res.msg,
type: 'warning'
});
}
that.saveLoading = false;
}).catch(function (err) {
that.dataIdLoading = false;
that.saveLoading = false;
});
},
/*注意在获取初始数据时,所有节点(包括子节点)都增加一个isChecked 标志参数*/
rowSelect(selection, row) {
if (this.dataScopeForm.dataTargetId == "") {
this.$notify({
title: '提示',
message: '请选择需授权的接受名称',
type: 'warning'
});
this.$refs.multipleTable.clearSelection()
return;
}
let selected = selection.length && selection.indexOf(row) !== -1
if (row.children) { //只对有子节点的行响应
if (selected) { //由行数据中的元素isChecked判断当前是否被选中
row.children.map((item) => { //遍历所有子节点
this.$refs.multipleTable.toggleRowSelection(item, true); //切换该子节点选中状态
/*
方法名 说明 参数
用于多选表格,切换某一行的选中状态, row, selected
toggleRowSelection 如果使用了第二个参数,则是设置这一行
选中与否(selected 为 true 则选中)
*/
item.check = true;
item.dataIdModelList.map(model => {
model.check = true;
})
});
row.check = true; //当前行isChecked标志元素切换为false
} else {
row.children.map((item) => {
this.$refs.multipleTable.toggleRowSelection(item, false);
item.check = false;
item.dataIdModelList.map(model => {
model.check = false;
})
});
row.check = false;
}
// console.log(this.multipleSelection, row);
}
//功能按钮选中
if (!row.check) { //如果没有选中
row.dataIdModelList.map(item => {
item.check = true;
})
row.check = true;
} else {
row.dataIdModelList.map(item => {
item.check = false;
})
row.check = false;
}
},
selectAll(selection) {
if (this.dataScopeForm.dataTargetId == "") {
this.$notify({
title: '提示',
message: '请选择需授权的接受名称',
type: 'warning'
});
this.$refs.multipleTable.clearSelection()
return;
}
var that = this;
let dom = document.querySelector(".isCheck>div>label");
if (!dom.className.includes("is-checked")) {
// 全选
that.setSelectAll(that.$refs.multipleTable.data,true)
} else {
// 取消全选
that.setSelectAll(that.$refs.multipleTable.data,false)
}
},
// 设置全选状态的递归函数,arr为递归数组,bool为操作状态(布尔型)
setSelectAll(arr,bool) {
var that = this
arr.forEach(function(item,index) {
item.check = bool
that.$refs.multipleTable.toggleRowSelection(item, bool); //行变选中状态
if(item.children && item.children.length) {
that.setSelectAll(item.children,bool)
}else {
item.dataIdModelList.map(model => {
model.check = bool;
})
}
})
}
},
created: function () {
this.targetIdQuery();
this.getDataScopeData();
}
});
</script>
<style>
/* 去掉全选按钮 */
.el-table .disabledCheck .cell .el-checkbox__inner {
display: none !important;
}
.el-table .disabledCheck .cell::before {
content: '选择';
text-align: center;
line-height: 37px;
}
.el-scrollbar .el-scrollbar__wrap {
overflow-x: hidden;
}
</style>
Tip
开发者根据提供的代码模版灵活修改成任意业务场景,主要的依据是 ·权限· 的多对多思路;
接口参考 datascope/data/saveBatch.do ,具体看 swagger-ui.html 描述
常见问题
超级管理员如何获取所有权限
答:使用 power.indexOf(‘super’) >- 1 例:
<el-link v-if="power.indexOf('cms:content:view')>-1 || power.indexOf('super')>-1" type="primary" :underline="false" @click="save(scope.row)">查看</el-link>
业务层数据权限开启失败
一般产生场景是: 有事务 + 两次相同查询,导致第二次查询走缓存没有被mybatis拦截器拦截;
解决方案: 避免在业务类上使用事务注解,应具体到操作数据的业务方法上
组织机构插件手册
提供基本部门、岗位、员工信息管理,可以快速开发OA、ERP等企业业务系统
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-morganization</artifactId>
<version>当前版本</version>
</dependency>
动图演示

业务开发
可以根据实际业务将组织机构的数据与其他业务数据进行关联
Tip
默认每张表的
create_by_ 与update_by_ 可以获取到员工信息
获取当前登陆的员工信息
/organization/organization/getOrganizationBySession.do 获取当前登陆员工的组织机构信息
常见问题
分配了内容权限,看不到文章数据
- 设置内容权限后,员工只能看到分配部门权限下所有员工发布的文章数据,查看员工是否勾选了其他部门
- 查看当前角色是否有相对应的栏目权限
导入导出插件手册
低代码实现快速excel、word数据的导入导出功能,基于apache的poi与docx4j进行二次开发,提供了excel、word的导入导出功能。通常用于列表数据展示页的导入导出
Tip
目前只支持
docxxlsx文件的导入导出
效果展示

配置说明:defaults是一些必要参数的默认值,用户需根据自己实际情况自行配置,如progress_status改为终审通过,content_style根据在自定义字典中的模板类型数据值做相应调整,在这之前要确保栏目已经绑定模板。
Tip
配置好后需要在对应的业务页面中使用
导入,导出组件完整导入导出的功能
word导入导出



导出配置
SELECT content_title as docFileName, content_details as docFileContent FROM `cms_content` WHERE id in ({});
导入配置
[{"table":"cms_content","id":"snow","columns":{"docFileName":"content_title","docFileContent":"CONTENT_DETAILS","categoryId":"category_id"},"defaults":{"del":"0","content_display":"0","progress_status":"草稿"}}]
配置项介绍具体查看表单下的提示
Tip
导入默认对create_date(当前时间)、content_datetime(当前时间)、content_type(‘’)做了处理
excel导入导出


导出配置
SELECT content_title as '标题',content_author as '作者', create_date as '发布时间',category_id as '所属栏目' FROM `cms_content` WHERE id in ({});
Tip
通过查询指定需要展示在excel中的字段
导入配置
[{"table":"cms_content","id":"snow","columns":{"标题":"CONTENT_TITLE","作者":"CONTENT_AUTHOR","所属栏目":"category_id"},"defaults":{"del":"0","content_display":"0","progress_status":"草稿"}}]
业务开发
配置导入导出
最核心的导入导出配置管理。
导出采用SQL配置,只接受id为参数,前端需要组织好需要导出的数据id数组。
导入采用JSON配置,支持一对一多表导入。
导入导出前端组件
首先在cms>content>list.ftl页面引入组件
<#include "/impexp/components/ms-imp.ftl">
<#include "/impexp/components/ms-exp.ftl">
...
<el-header class="ms-header" height="50px">
<el-col >
<@shiro.hasPermission name="cms:content:save">
<el-button style="margin-right: 8px"
v-if="(hasPermission('cms:content:save') && leaf) || (form.categoryId==undefined)"
type="primary" class="el-icon-plus" size="default" @click="openForm()">新增</el-button>
</@shiro.hasPermission>
<@shiro.hasPermission name="cms:content:del">
<el-button style="margin-right: 8px"
v-if="hasPermission('cms:content:del') && leaf"
type="danger" class="el-icon-delete" size="default" @click="del(selectionList)"
:disabled="!selectionList.length">删除</el-button>
</@shiro.hasPermission>
<ms-imp style="float: right"
id="word文章"
type="word"
:limit=1
:multiple="true"
img-dir="/upload/1/cms/content/editor/"
:columns="{categoryId:form.categoryId}"
:disabled="!isLeaf"
@imp-success="list" >
</ms-imp>
<ms-imp style="float: right"
id="excel文章"
type="excel"
:limit=1
:multiple="true"
img-dir="/upload/1/cms/content/editor/"
:columns="{categoryId:form.categoryId}"
:disabled="!isLeaf"
@imp-success="list" >
</ms-imp>
<ms-exp style="float: right;margin-right: 8px"
id="word文章" type="word"
:ids="ids"
:disabled="!selectionList.length" >
</ms-exp>
<ms-exp style="float: right;margin-right: 8px"
id="excel文章" type="excel"
:ids="ids"
:disabled="!selectionList.length" >
</ms-exp>
</el-col>
</el-header>
导入组件
在页面添加组件标签,可以根据参数进行调整
<ms-imp style="margin-right: 8px"
id="word文章"
type="word"
limit="1"
:multiple="true"
img-dir="/upload/1/cms/content/editor/"
:columns="{categoryId:form.categoryId}"
:disabled="!isLeaf"
@imp-success="list" >
</ms-imp>
| 参数 | 是否必填 | 说明 |
|---|---|---|
| id | 是 | 导入导出配置名称 |
| type | 是 | 导入类型 支持word excel |
| multiple | 否 | true批量导入\false逐条导入 |
| img-dir | 否 | 图片存放路径,推荐存放在upload文件夹下 |
| disabled | 否 | 禁用状态 true禁用\false启用 |
| columns | 否 | json参数,只有word有效果,excel无效,columns的参数key必须与json配置的columns的key一致 |
| imp-success | 否 | 导入成功调用方法,例如:list(),刷新当前页面数据 |
| limit | 否 | 限制导入的文件数量 |
Tip
columns根据excel列名与表字段对应,注意excel导入组件不支持columns属性
导出组件
<ms-exp style="margin-right: 8px"
id="(word|excel)文章" type="word|excel"
:ids="ids"
:disabled="!selectionList.length" >
</ms-exp>
| 参数 | 是否必填 | 说明 |
|---|---|---|
| id | 是 | 导入导出配置名称 |
| ids | 是 | 用于传递导出sql配置的参数 |
| type | 是 | 导出类型支持word excel |
| disabled | 否 | 禁用状态 true禁用\false启用 |
获取ids参考代码
...
//el-table选中数据
handleSelectionChange: function (val) {
//组织ids
that.ids = [];
val.forEach(row => that.ids.push(row.id));
},
...
Tip
需要先在Vue data中定义ids变量
工具类wordUtil
/**
* word通用工具,需要依赖poi与docx4j
* 1、poi的转换处理比较麻烦,稳定性不是很好,例如:word2html 结果再转回word格式有差异
* 2、docx4j处理比较方便,推荐使用,兼容性好
* 3、poi与docx4j都存在jar包版本冲突问题,需注意pom.xml
*/
/**
* 基于docx4j,html转换word
* 参考:https://github.com/plutext/docx4j-ImportXHTML/blob/master/src/samples/java/org/docx4j/samples/XhtmlToDocxAndBack.java
*
* @param content html内容
* @param docPath doc生成路径
* @param imgPath 图片资源路径
* @throws JAXBException
* @throws Docx4JException
*/
public static void html2Docx(String content, String docPath, String imgPath)
/**
* 基于docx4j,word转换html
* 参考:https://github.com/plutext/docx4j-ImportXHTML/blob/master/src/samples/java/org/docx4j/samples/DocxToXhtmlAndBack.java
*
* @param docPath doc文档路径
* @param imgPath 图片保存资源路径
* @return 返回html内容 null:生成临时文件失败
* @throws Exception
*/
public static String docx2Html(String docPath, String imgPath)
/**
* 基于poi,word转换html,推荐使用docx4j
* 导入word,核心就是将word文件转html存入数据库
* 流程:
* 1、通过第三方工具xdocreport与poi
* 2、word转html
* 3、读取html的内容
* 临时文件生成再当前项目temp文件夹;
*
* @param wordPath word路径
* @param uploadDir 保存文件夹的目录存放路径,null资源文件夹采用base64位显示 ,例如:图片最终地址 baseDir/imgDir/文件名称
* @param imgDir word中的图片存放路径,当baseDir==null,imgDir参数无效
* @return 返回html内容
*/
public static String word2Html(String wordPath, String uploadDir, String imgDir)
/**
* 针对老的word进行图片资源处理
*/
static class ImageConverter extends WordToHtmlConverter {
public ImageConverter(Document document) {
super(document);
}
@Override
protected void processImageWithoutPicturesManager(Element currentBlock, boolean inlined, Picture picture) {
Element imgNode = currentBlock.getOwnerDocument().createElement("img");
StringBuffer sb = new StringBuffer();
sb.append(Base64.getMimeEncoder().encodeToString(picture.getRawContent()));
sb.insert(0, "data:" + picture.getMimeType() + ";base64,");
imgNode.setAttribute("src", sb.toString());
currentBlock.appendChild(imgNode);
}
}
常见问题
导入的word文档,内容为空
由于导入导出插件使用的都是最新的依赖,目前word导入只支持docx格式,需使用最新版office,由doc更改文件扩展名为docx是无效的
导出(导入)excel设置列标题
列标题默认为导入导出配置中的docFileName和docFileContent,按需修改即可;比如设置为 标题和内容

敏感词插件手册
将内容中出现的敏感词或易错词替换成指定的内容,例如:毛泽东 可替换 *** 或 毛主席
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-wordfilter</artifactId>
<version>当前版本</version>
</dependency>
依赖 sensitive-word 开源项目
感谢 https://github.com/houbb/sensitive-word
业务开发
使用方式
主要提供三种使用方式:
- 注解@SensitiveWord
- 工具类 SensitiveWordsUtil.find()
- 工具类 SensitiveWordsUtil.replace()
注解@SensitiveWord
通用AOP过滤不来的方法,通过业务层代码通过在方法上加上 @SensitiveWord 注解达到过滤效果
@SensitiveWord
public ResultData 方法(ProgressLogEntity progressLog) {}
Tip
方法形参上必须存在
BaseEntity类型参数才有效,这里ProgressLogEntity是BaseEntity子类
工具类 SensitiveWordsUtil.find()
业务代码中通过工具类灵活的对内容进行敏感词检测
//关键字配置
String content = "滚出去";
List<String> sensitiveWords = SensitiveWordsUtil.find(content);
//sensitiveWords 返回当前文本中敏感词集合,有空的情况,需要空判断处理
工具类 SensitiveWordsUtil.replace()
业务代码中通过工具类灵活的对内容进行敏感词过滤
//关键字配置
String content = "滚出去";
content = SensitiveWordsUtil.replace(content)
//content 就是过滤之后的内容
拦截所有的业务数据
需要修改通用的AOP
修改net.mingsoft.wordfilter.aop.SensitiveWordAop 增加下面两个拦截配置
@Before("execution(* net.mingsoft..*Action.save(..))")
public void save(JoinPoint joinPoint) throws IllegalAccessException {
startReplace(joinPoint);
}
@Before("execution(* net.mingsoft..*Action.update(..))")
public void update(JoinPoint joinPoint) throws IllegalAccessException {
startReplace(joinPoint);
}
Tip
具体会拦截
save、update方法,不需要额外编写代码
前端拦截提示
页面增加以下代码
data: function () {
return {
// 是否开启敏感词检测
isEnableWord: false,
}
}
methods: {
// 保存前先检查是否存在敏感词
checkSensitiveWord: function() {
var that = this;
if (that.isEnableWord) {
// 存储需要检测字段数据
var data = {
contentTitle: that.form.contentTitle,
};
ms.http.post(ms.manager + "/wordfilter/sensitiveWords/check.do", data, {
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
if (res.result && res.data.length > 0) {
that.$refs.msAi.sensitiveSave(res.data);
}
else {
// 改成自己需要调用的方法
that.save();
}
})
} else {
// 改成自己需要调用的方法
that.save();
}
}
}
create: function () {
var that = this
ms.mdiy.config("安全设置", "isEnableWord", true).then(function (res) {
if (res.result && res.data === 'true') {
that.isEnableWord = true;
}
});
}
Tip
注意需要引入百度AI页面业务开发
如果检测出来还需要调用原页面某个方法,需要这样处理
<ms-ai ref="msAi" @custom-event="save" :content="form.contentDetails"></ms-ai>
Tip
其中save为您自定义的方法名称
常见问题
敏感词插件如何关闭
在安全设置->账号安全中关闭即可

敏感词检测出来,但是在替换词管理中没有找到
由于我们加载了第三方库,有些敏感词可能不在我们自己库中
- 您可以在替换词中添加您需要敏感词,替换掉第三方库中的敏感词(推荐这样处理)
- 把第三方词库文件放在本地处理,com.github.houbb:sensitive-word下的sensitive_word_dict.txt放在当前项目的webapp下,在根据您的需求进行调整
百度AI手册
基于百度AI的文本纠错功能进行文本的纠错提示
Tip
事先需要注册百度ai平台账号,并创建应用 具体参考百度官方文档 https://ai.baidu.com/ai-doc/NLP/4k6z5cykb
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-baidu</artifactId>
</dependency>
业务开发
前端组件引入
通过前端组件方式调用百度纠错接口,在需要检测的页面头部引入组件文件
<#include "/component/ms-ai.ftl">
在页面中调用组件
<ms-ai :content="需要检测的内容"></ms-ai>
渲染后会在页面上出现 错词检测 的按钮效果

Tip
基于vue实现的简单组件,开发者可以根据需求进行二次开发。
百度AI配置
错词检测

常见问题
一起来发现,团队会定期整理并更新文档~
Tip
有问题可以通过评论方式提交,如果没有看到评论列表请尝试刷新页面
IP黑白名单手册
通用的ip拦截插件

系统拥有自动防御功能,在系统受到攻击次数累加到最小限制时,会在设定时间内禁止用户再访问系统;如果在一天内达到最大攻击次数,会将访问ip永久黑名单,只能修改黑名单数据解禁,在一天内的多次攻击会重置这个倒计时;
受到黑名单管理的ip发出的任何访问系统的请求都会被系统拦截,前提是必须开启黑名单;
在白名单管理中的ip不会被拦截,所有请求都会越过ip黑名单的拦截,包括自动防御,请谨慎添加信任的ip,如果不想要白名单生效只需要在配置中关闭即可。
Tip
自动防御功能不可被关闭,测试阶段如果触发过于频繁,可以临时开启白名单放行。
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-ip</artifactId>
</dependency>
演示

Tip
如果修改错误,导致无法访问系统后台,可以通过修改数据库表
mdiy_config的ip黑白名单配置数据的blackSwitch:false参数为false,需要重启系统生效。
常见问题
没有达到最小攻击次数却被禁止访问系统了
确认一下ip是否在黑名单中,这里有两种情况,一是一开始ip就存在于黑名单中,二是最大攻击次数比最小攻击次数设置的值还要小,导致达到最大攻击次数ip被加入黑名单
🏆 全文检索
版本约定
https://docs.spring.io/spring-data/elasticsearch/reference/elasticsearch/versions.html
安装es8版本(springboot3)
采用docker环境配置
安装Elasticsearch版本
docker pull docker.1ms.run/elasticsearch:8.15.5
docker run -d \
--name es8 \
--privileged=true \
-p 9200:9200 \
-p 9300:9300 \
-e TZ=Asia/Shanghai \
-e ES_JAVA_OPTS="-Xms1g -Xmx1g" \
-e "discovery.type=single-node" \
docker.1ms.run/elasticsearch:8.15.5
Tip
请使用8.15.5版本,es版本不同差异比较大,版本不兼容会导致es功能无法正常使用;
安装IK分词器
进入Elasticsearch容器
elasticsearch-plugin install https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.15.5.zip
Tip
这里可能由于网络原因,失败多重复执行几次,直到安装成功。 安装后需要重启es服务才生效
安装后重启es服务,通过ip:9200/_cat/plugins查看安装的es插件

安全证书
由于es8默认开启了安全验证,配置安全证书参考es官方文档
本地测试功能可以先把安全关闭(下图的security相关改为false)后重启es服务,直接通过http://ip:9200 能看到es信息说明已关闭安全

密码设置
参考官方文档进行密码设置
es设置了访问用户和密码后,访问9200时就需要登录认证,来验证设置的用户和密码

输入设置的用户密码后,可以看到es服务的信息

es7版本(springboot2)
安装Elasticsearch
docker run -e TZ=Asia/Shanghai -e ES_JAVA_OPTS="-Xms1g -Xmx1g" -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --name es \
--privileged=true elasticsearch:7.9.0
Tip
建议这里通过网络固定好
ip,具体参考 docker网络
安装IK分词器
进入Elasticsearch容器
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.9.0/elasticsearch-analysis-ik-7.9.0.zip
Tip
这里可能由于网络原因,失败多重复执行几次,直到安装成功。 安装后需要重启es服务才生效
安装kibana
可视化管理es容器
docker run -d --name es-kibana -e “I18N_LOCALE=zh-CN” --link=es:elasticsearch -p 5601:5601 kibana:7.9.0
访问地址:http://ip:5601/app/dev_tools#/console
Tip
请使用7.9.0版本,es版本不同差异比较大,版本不兼容会导致es功能无法正常使用;如果7.9.0版本不行,可以尝试使用7.17.23版本
开发配置
依赖
依赖ms-elasticsearch的方式
方式1、将ms-elasticsearch打包成jar, 同步到本地的maven库;
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-elasticsearch</artifactId>
</dependency>
方式2、直接将ms-elasticsearch源代码复制到项目中;
application.yml
必须先安装环境,ip根据实际情况编写
# es索引名称配置,多个项目使用同一es服务时,请区分设置各项目的索引名称
ms:
elasticsearch:
index-name: cms-gov
# ES服务器链接
spring:
elasticsearch:
uris: [ localhost:9200 ]
data:
elasticsearch:
repositories:
enabled: true
es各搜索简介
联想搜索:返回与输入结果相近的词条集合
- 前缀匹配联想
可实现类似百度搜索的提示词功能


- 关键字匹配
类似下图的效果,高亮且不局限于前缀匹配


Tip
一般可以用在用户搜索输入的时候,类似百度的搜索框,自动补全用户可能想输入的词条或以下拉列表显示
通用搜索、高亮搜索
正常搜索文章使用通用或高亮搜索,返回文章详细信息,两个接口之间没有本质区别;搜索时会自动检测是否有站群环境
高亮前缀如:"<span style='color=red'>",高亮后缀如:"</span>"
搜索功能基本相同,高亮搜索会额外聚合关键字文章在栏目下的数量,具体参考聚合结果
热词搜索
返回词条搜索频率及词条,可指定范围时间内返回词条个数;热词会随用户搜索自动统计增长,不需要人为干预

聚合搜索
根据字段词条分组展示,以相同词条为一组展示同词条的个数和词条内容
如下图,统计各栏目下的文章数量

业务开发
基本步骤:
- 配置字典
ES索引增加索引配置 - 使用注解,在需要同步es的方法上面使用注解
- 创建模型Bean,创建业务es文档模型
- 创建Service,实现对es的curd
- 创建拦截Aop,创建业务es同步aop
- 创建搜索action,创建控制层对外部提供查询接口
Tip
下面代码片段截取自政务版本的内容搜索
字典配置
通过配置字典ES索引来增加es索引,特别注意字典的数据值的json格式
{"name":"GovESContentBean","url":"/gov/es/sync.do"}
name:模型bean的名称,具体开发参考模型Bean,必须与ESBaseBean的子类上的@Component名称一致;
url:es同步请求的方法,具体开发参考 同步方法
注解
在对应的业务代码使用
@ESSave、@ESDelete
在对应的方法上进行注解,提供给aop进行拦截,
Tip
主要提供AOP拦截,在AOP中进行ES库的同步,所有涉及数据更新的地方都需要使用此注解
实例
...
//保存方法
@ESSave
public ResultData save(@ModelAttribute ContentEntity content) {
contentBiz.saveEntity(_product);
return ResultData.build().success(_product);
}
//更新方法
@ESSave
public ResultData update(@ModelAttribute ContentEntity content) {
contentBiz.updateEntity(_product);
return ResultData.build().success(_product);
}
//删除方法
@ESDelete
public ResultData delete(@RequestBody List<ContentEntity> contents,HttpServletResponse response, HttpServletRequest request) {
int[] ids = new int[contents.size()];
for(int i = 0;i<contents.size();i++){
ids[i] =Integer.parseInt(contents.get(i).getId()) ;
}
contentBiz.delete(ids);
return ResultData.build().success();
}
...
模型Bean
创建业务中需要的ES文档模型 public class *Bean extends ESBaseBean,业务Bean继承ESBaseBean
注解参考
es文档定义,indexName可以定义不同的es库,注意:高版本es容器已经废弃了type的设置
@Document(indexName = "cms",type = "content")
es关键id注解
@Id
es关键字定义,例如:我是中国人,只能通过我是中国人搜索到,
@Field(type = FieldType.Keyword)
es关键字定义,分词搜索,例如:我是中国人,可以通过我是、中国人、中国、都能搜索到,
@Field(type = FieldType.Keyword, analyzer = "ik_max_word")
es长文本关键字定义,推荐加上analyzer = "ik_max_word"
@Field(type = FieldType.Text, analyzer = "ik_max_word")
es数值定义
@Field(type = FieldType.Integer )
es日期定义
@Field(type = FieldType.Date)
范例
以文章为例,创建es文档模型bean,只需要定义需要存入es库的字段
@Document(indexName = "es-cms")
@Component("ESContentBean")
public class ESContentBean extends ESBaseBean {
/**
* 存储文章作者
*/
@Field(type = FieldType.Keyword)
private String author;
/**
* 存储自定义扩张模型
*/
@Field(type = FieldType.Object)
private Map<Object, Object> MDiyModel;
@Field(type = FieldType.Keyword)
private String typeId;
@Field(type = FieldType.Text)
private String litPic;
@Field(type = FieldType.Keyword)
private String flag;
/**
* 存储文章发布到
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer="ik_smart")
private String styles;
/**
* 存储类型
*/
private String type;
/**
* 存储栏目父ID集合
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer="ik_smart")
private String parentIds;
/**
* 站点ID
*/
@Field(type = FieldType.Keyword)
private String appId;
/**
* 文章副标题
*/
@Field(type = FieldType.Text)
private String shortTitle;
/**
* 栏目副标题
*/
@Field(type = FieldType.Text)
private String typeShortTitle;
/**
* 存储栏目标题
*/
@Field(type = FieldType.Text)
private String typeTitle;
/**
* 自定义排序
*/
@Field(type = FieldType.Integer)
private Integer sort;
@Field(type = FieldType.Text)
private String descrip;
/**
* 文章审批进度
*/
@Field(type = FieldType.Keyword)
private String progressStatus;
@Field(type = FieldType.Date,format = DateFormat.custom, pattern="yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
private Date date;
/**
* 链接地址
*/
@Field(type = FieldType.Text,index=false)
private String url;
// .. 省略 set get 方法
}
同步方法
通过业务代码查询出所有的数据再通过 `Service` 提供的方法同步到es库
...
@Autowired
private IContentBiz contentBiz;
@Autowired
private IModelBiz modelBiz;
@Autowired
private ICategoryBiz categoryBiz;
@Autowired
private IESContentService esContentService;
/**
* 同步es文章
* @return
*/
@ApiOperation(value = "同步es文章接口")
@PostMapping("/sync")
@RequiresPermissions("es:sync")
@ResponseBody
public ResultData sync() {
// 获取栏目列表
LambdaQueryWrapper<CategoryEntity> categoryWrapper = new LambdaQueryWrapper<>();
categoryWrapper.ne(CategoryEntity::getCategoryType, CategoryTypeEnum.LINK.toString());
List<CategoryEntity> categoryList = categoryBiz.list(categoryWrapper);
for (CategoryEntity category : categoryList) {
// 获取文章列表
LambdaQueryWrapper<ContentEntity> contentWrapper = new LambdaQueryWrapper<>();
contentWrapper.eq(ContentEntity::getCategoryId, category.getId());
List<ContentEntity> contentList = contentBiz.list(contentWrapper);
boolean hasModel = false;
ModelEntity model = null;
if (StringUtils.isNotBlank(category.getMdiyModelId())) {
hasModel = true;
// 获取模型实体
model = modelBiz.getById(category.getMdiyModelId());
}
List<ESContentBean> beanList = new ArrayList<>();
for (ContentEntity content : contentList) {
ESContentBean esContentBean = new ESContentBean();
ESContentBeanUtil.fixESContentBean(esContentBean, content, category);
if (hasModel) {
//配置link_id Map
Map<String, String> modelMap = new HashMap<>(1);
modelMap.put("link_id", content.getId());
List<Map<String, Object>> modelFieldList = contentBiz.queryBySQL(
model.getModelTableName(),
null ,
modelMap, null, null, null, null, null);
// 防止NP
if (CollUtil.isNotEmpty(modelFieldList)) {
// 模型字段MAP
Map<String, Object> objectMap = modelFieldList.get(0);
for (String key : objectMap.keySet()) {
// 类型为BigInteger时需要转换为long防止es类型构造器转换错误
if (objectMap.get(key) instanceof BigInteger) {
objectMap.put(key, ((BigInteger) objectMap.get(key)).longValue());
}
}
esContentBean.setMDiyModel(objectMap);
}
}
beanList.add(esContentBean);
}
if (CollUtil.isNotEmpty(beanList)) {
try {
esContentService.saveAll(beanList);
}catch (DataAccessResourceFailureException e) {
return ResultData.build().error("未找到当前ES信息,请检查当前ES链接是否正常");
}catch (NoSuchIndexException e) {
return ResultData.build().error("未找到当前ES索引信息,请检查是否创建索引");
}
}
}
return ResultData.build().success().msg("全部同步完成!");
}
...
Service继承ElasticsearchRepository
继承后能进行简单的增删改查操作,具体可以参考CrudRepository
@Service("IESContentService")
@ConditionalOnClass({ Client.class, ElasticsearchRepository.class })
public interface IESContentService extends ElasticsearchRepository<ESContentBean, String> {
}
Tip
基于spring boot data 快速实现ES的CURD
AOP拦截
创建AOP public class *Aop extends ESBaseAop {},需要进行es全文检索的数据,通过aop的方式同步到es库,业务Aop继承ESBaseAop
实例
通过拦截的方式将模型Bean的内容同步到es库
@Component("esContentAop")
@Aspect
public class ESContentAop extends ESBaseAop {
@Autowired
private IESContentService esContentService;
@Autowired
private ICategoryBiz categoryBiz;
//保存更新同步
@Override
public void save(JoinPoint joinPoint) {
try {
BaseEntity obj = this.getType(joinPoint, BaseEntity.class, true);
ESContentBean bean = new ESContentBean();
if(obj instanceof ContentEntity){
ContentEntity content = (ContentEntity)obj;
CategoryEntity category = categoryBiz.getById(content.getCategoryId());
bean.setTitle(content.getContentTitle());
bean.setContent(content.getContentDetails());
bean.setAuthor(content.getContentAuthor());
bean.setDate(content.getContentDatetime());
bean.setTypeId(content.getCategoryId());
bean.setDescrip(content.getContentDescription());
bean.setSort(content.getContentSort());
bean.setLitPic(content.getContentImg());
bean.setFlag(content.getContentType());
bean.setUrl(category.getCategoryPath();
// 有appId则存放
AppEntity app = BasicUtil.getWebsiteApp();
if (app != null) {
// 查询时会自动拼接appID,也就是当前session,所以这里可以直接设置
bean.setAppId(app.getId());
bean.setStyles(content.getContentStyle());
}
}
LOG.info("保存es库");
esContentService.save(bean);
} catch (Exception e) {
e.printStackTrace();
}
}
//删除同步
@Override
public void delete(JoinPoint joinPoint, ResultData result) {
try {
//注意!!!默认接收切入方法第一个参数
List<BaseEntity> arrayList = this.getType(joinPoint, ArrayList.class, true);
BaseEntity obj ;
obj = this.getType(joinPoint, BaseEntity.class, true);
if (arrayList != null) {
obj = arrayList.get(0);
}
// 单个删除
if(ObjectUtil.isNotEmpty(obj)){
esContentService.deleteById(obj.getId());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Tip
所有涉及到文字内容变化的控制层都需要别拦截到,否则会出现es库数据不同步的问题产生
搜索Action
创建 *Action ,根据业务编写对应的控制层,调用ESUtils的方法进行搜索
/**
* 高亮搜索,保留搜索字段keyword、order、orderBy、pageNo、pageSize,其余字段根据实际业务调整
*
* @param keyword 关键字
* @return 内容
*/
@ApiOperation(value = "高亮搜索接口")
@ApiImplicitParams({
@ApiImplicitParam(name = "keyword", value = "搜索关键字", required = true, paramType = "query"),
@ApiImplicitParam(name = "order", value = "排序方式", required = false, paramType = "query"),
@ApiImplicitParam(name = "orderBy", value = "排序的字段", required = false, paramType = "query"),
@ApiImplicitParam(name = "pageNo", value = "页数", required = false, paramType = "query", dataType = "Integer"),
@ApiImplicitParam(name = "pageSize", value = "页数大小", required = false, paramType = "query", dataType = "Integer"),
@ApiImplicitParam(name = "styles", value = "皮肤,例:outsite、insite", required = false, paramType = "query"),
@ApiImplicitParam(name = "typeId", value = "所属栏目(用于搜索指定栏目)", required = false, paramType = "query"),
@ApiImplicitParam(name = "parentIds", value = "父栏目ID(用于搜索所有子栏目)", required = false, paramType = "query"),
@ApiImplicitParam(name = "fields", value = "搜索关键字字段,值输入ES索引字段值,多个用逗号隔开 例:content,title", required = false, paramType = "query"),
@ApiImplicitParam(name = "searchMethod", value = "搜索方式,不传默认分词搜索(传值参考SearchMethodEnum)", required = false, paramType = "query"),
@ApiImplicitParam(name = "rangeField", value = "范围字段,需要传开始范围和结束范围才能生效,可以对时间进行范围筛选,值填写对应es索引的索引字段", required = false, paramType = "query"),
@ApiImplicitParam(name = "start", value = "开始范围 时间格式:yyyy-MM-dd HH:mm:ss 例:2021-08-06 12:25:36", required = false, paramType = "query"),
@ApiImplicitParam(name = "end", value = "结束范围 时间格式:yyyy-MM-dd HH:mm:ss 例:2021-08-06 12:25:36", required = false, paramType = "query"),
@ApiImplicitParam(name = "noflag", value = "不等于的文章类型,多个逗号隔开", required = false, paramType = "query"),
})
@PostMapping("/highlightSearch")
@ResponseBody
public ResultData highlightSearch(String keyword) {
ESSearchBean esSearchBean = new ESSearchBean();
String noflag = BasicUtil.getString("noflag");
Map<String, Object> notEquals = new HashMap<>();
if (StringUtils.isNotBlank(noflag)) {
List<String> collect = Arrays.stream(noflag.split(",")).collect(Collectors.toList());
// es flag字段
notEquals.put("flag", collect);
}
// 过滤条件
String filterStr = BasicUtil.getString("filter");
Map<Object,Object> filter = new HashMap<>();
if (StringUtils.isNotBlank(filterStr)) {
try {
filter = JSONUtil.toBean(filterStr, Map.class);
} catch (Exception e) {
LOG.debug("过滤条件转json失败");
e.printStackTrace();
}
}
//如果有站群插件,需要根据站群插件过滤
if (BasicUtil.getWebsiteApp() != null) {
// 站点过滤
LambdaQueryWrapper<AppEntity> wrapper = new QueryWrapper<AppEntity>().lambda();
wrapper.like(AppEntity::getAppUrl, BasicUtil.getDomain());
AppEntity appEntity = appBiz.getOne(wrapper, false);
filter.put("net", appEntity.getId());
}
// 设置基本过滤字段
String styles = BasicUtil.getString("styles");
if (StringUtils.isNotBlank(styles)) {
filter.put("styles", styles);
}
//审核过滤
filter.put("progressStatus", "终审通过");
esSearchBean.setFilterWhere(filter);
esSearchBean.setKeyword(keyword);
// 设置排序
String order = BasicUtil.getString("order", "desc");
String orderBy = BasicUtil.getString("orderBy", "sort");
esSearchBean.setOrder(order);
esSearchBean.setOrderBy(orderBy);
//设置分页
Integer pageNo = BasicUtil.getInt("pageNo", 1);
Integer pageSize = BasicUtil.getInt("pageSize", 10);
esSearchBean.setPageNo(pageNo);
esSearchBean.setPageSize(pageSize);
//设置高亮搜索配置
Map<String, Object> highlightConfig = new HashMap<>();
//设置高亮样式
highlightConfig.put("preTags", "<span style='color:red'>");
highlightConfig.put("postTags", "</span>");
//设置搜索字段
String author = BasicUtil.getString("author");
String content = BasicUtil.getString("content");
String title = BasicUtil.getString("title");
LinkedHashMap<String, Float> searchFields = new LinkedHashMap<>();
//设置搜索字段,数值f标识用于权重排序,一般多字段搜索使用,数值越大该字段的权重越大,当searchMethod等于geo_function_search生效
if (StringUtils.isNotBlank(author)) {
searchFields.put("author", 5f);
}
if (StringUtils.isNotBlank(title)) {
searchFields.put("title", 10f);
}
if (StringUtils.isNotBlank(content)) {
searchFields.put("content", 5f);
}
// 默认设置搜索标题
if (CollUtil.isEmpty(searchFields)) {
searchFields.put("title", 10f);
}
// 设置搜索方式,默认使用分词搜索
String searchMethod = BasicUtil.getString("searchMethod", "");
return esService.search(ESContentBean.class, searchFields, highlightConfig,
notEquals, esSearchBean, searchMethod);
}
Tip
注意代码中的
filter.put与searchFields.put根据实际业务字段调整,searchMethod=should_search可以匹配完整标题注意:fields参数里可以填写es库中已有的字段,如果传参为 title,content 表示搜索标题、文章中带有关键字的数据;styles参数为outsite或者insite
返回格式参考
{
"result": true,
"code": 200,
"data": {
"total": 1,
"rows": [ //记录列表
{
"contentKeyword": "<span style='color:red'>国</span><span style='color:red'>内</span>ms",
"sort": 0,
"id": "1346299650146922497",
"title": "[【网站】<span style='color:red'>国</span><span style='color:red'>内</span> 前6名 Java开源CMS建站系统]",
"content": "[下面我们开始分享一下开源中<span style='color:red'>国</span>中最火的Java开源CMS建站系统]",
"typeId": "1392359380113481730",
"parentIds": "1382233650262241282",
"date": "2017-08-29 20:04:31",
"author" : "",
"net" : "1",
"typeId" : "1458331507951800321",
"litPic" : """[{"url":"blob:http://192.168.0.118:8080/5e548893-1b5f-46cd-8193-fc7c48b2cc7f","name":"1637824298771.jpg","path":"/upload/1/cms/content/1637906304084.jpg","uid":1637906304036,"status":"success"}]""",
"typeTitle" : "早间新闻",
"flag" : "c",
"type" : "content",
"descrip": "-",
"url" : """/xinwen/zaojianxinwen\c1458332611057946626.html"""
}
]
}
}
联想搜索\热词搜索
/es/suggestSearch.do 根据输入的关键字显示相关的关键词
/es/hotSearch.do 搜索指定时间访问的热词搜索及搜索量
Tip
根据搜索的结果,通过前端技术处理方式进行跳转
搜索页面
可以通过swagger接口快速调试,前端页面通过异步请求处理。
页面跳转(通用组件)
这里的代码只是用于封装参数,做页面跳转,不是es搜索
<!--注意script中id与下面定义component要一致-->
<script type="text/x-template" id="ms-search">
<el-form :model="esSearchForm" ref="esSearchForm" label-width="120px" size="mini">
<el-row>
<el-col :span='6'>
<el-form-item label="文章标题" prop="esSearchForm.contentTitle">
<el-input v-model="esSearchForm.contentTitle" clearable placeholder="请输入文章标题(支持模糊查询)"></el-input>
</el-form-item>
</el-col>
<el-col :span='6'>
<!--下拉选择框-->
<el-form-item label="栏目" prop="esSearchForm.categoryId">
<el-select v-model="esSearchForm.categoryId"
:style="{width: '100%'}"
:filterable="false"
:disabled="false"
:multiple="false" :clearable="true"
placeholder="请选择栏目">
<el-option v-for='item in categoryList' :key="item.categoryTitle" :value="item.id"
:label="item.categoryTitle"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span='12'>
<el-form-item label="评价时间" prop="esSearchForm.contentTime">
<el-date-picker
class="ms-datetimerange"
size="mini"
v-model="esSearchForm.contentTimes"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
align="right"
format="yyyy-MM-dd HH:mm:ss"
value-format="yyyy-MM-dd HH:mm:ss"
>
</el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col style="text-align: right;padding: 8px 0">
<el-button type="primary" icon="el-icon-search" size="mini" @click="search()">查询</el-button>
</el-col>
</el-row>
</el-form>
</script>
<script>
Vue.component('ms-search',{
template: "#ms-search",
name: "ms-search",
props:{},
data: function (){
return {
esSearchForm:{
contentTitle: null,
contentTimes:null,
categoryId:null,
},
categoryList: [],// 栏目集合
}
},
methods: {
queryCategory:function () {
var that = this;
ms.http.post(ms.base+"/cms/category/list.do",{
leaf: true,
pageSize: 20,
}).then(function (res) {
if (res.result){
that.categoryList = res.data.rows
}
})
},
search:function () {
var form = document.createElement("form");
form.setAttribute("method", "post");
var input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'content_title');
input.setAttribute('value', this.esSearchForm.contentTitle);
form.append(input);
if (this.esSearchForm.categoryId){
input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'categoryIds');
input.setAttribute('value', this.esSearchForm.categoryId);
form.append(input);
}
input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'style');
input.setAttribute('value', '{ms:global.template/}');
form.append(input);
if (this.esSearchForm.contentTimes){
input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'startTime');
input.setAttribute('value', this.esSearchForm.contentTimes[0]);
form.append(input);
input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'endTime');
input.setAttribute('value', this.esSearchForm.contentTimes[1]);
form.append(input);
}
form.setAttribute("action","/mcms/search.do");
document.body.appendChild(form);
form.submit();
form.remove();
},
},
created() {
var that = this;
/*不需要es搜索将下面代码注释即可*/
<!--es的热词-->
ms.http.post(ms.base+"/es/hotSearch.do", {
field: 'title'
}).then(function(res){
if(res.data && res.data.length>0){
that.hotSearch = res.data;
}
}).catch(function (err) {
console.log(err)
})
that.queryCategory();
}
})
</script>
在head中引入
注意需要include标签引入才能使用ms-search标签渲染
<!--这里根据文件位置 相对路径引用-->
<#include "ms-search.htm" />
...
<ms-search></ms-search>
...
搜索模板代码 search.htm
在这个模板中处理es搜索逻辑,包括数据渲染
...
<div class="w-search-keyword-warp">
<span class="w-search-label"> 搜索关键词:</span>
<span class="w-search-keyword"> {ms:search.content_title/}</span>
</div>
...
... 搜索列表展示 ...
<!--分页 注意page-size必须和script中的分页数pageSize对应-->
<div class="search-limit-div">
<el-pagination background
@current-change="handleCurrentChange"
page-size="5"
pager-count="3"
:current-page.sync="pageCur"
layout="prev, pager, next,jumper,total"
:total="contentCount">
</el-pagination>
</div>
<script>
var app = new Vue({
el: '#app',
component(){
},
data: {
searchForm: {
docName:'文章',// 索引名称
fields: 'title,content',// 匹配哪些字段,逗号分隔
keyword: "{ms:search.content_title/}",// 匹配关键字
// 范围查询 不传参数 默认给空串 导致查不到
<#if search.categoryIds?has_content>
filter: {// 过滤条件
typeId: '{ms:search.categoryIds/}',// typeId只支持单个栏目id
},
</#if>
<#if search.startTime?has_content && search.endTime?has_content>
rangeFields: [ // 范围查询条件,支持多个范围条件
{
type: 'date',
field: 'date',
start: '{ms:search.startTime/}',// 必须是yyyy-MM-dd HH:mm:ss的格式
end: '{ms:search.endTime/}',// 必须是yyyy-MM-dd HH:mm:ss的格式
},
],
</#if>
orderBy: 'date',// 按文章时间排序
order: 'desc',// 倒序
pageSize: 5,// 一页显示数
pageNo: 1// 页码(页面初始化默认第一页)
},
hotSearchKeywords: [],// 热词
pageCur: ${(page.cur)!1},// 当前页数
pageSize: ${(page.size)!20},// 每页文章条数
pageTotal: ${(page.total)!0},// 页数总数
contentCount: ${(page.rcount)!0},// 内容总数
contentList: [],// 搜索数据
},
watch: {
},
methods: {
handleCurrentChange:function(val) {
var that = this;
// create函数中已经JSON化filter和rangeFiled为字符串,此处无需再转
if (val) {
that.searchForm.pageNo = val;
}
<!--es通用搜索-->
ms.http.post(ms.base+"/es/search.do",that.searchForm).then(function (res){
that.contentCount = res.data.total;
var content;
// 由于在es环境下无法用标签获取文章相关值,在这里预先对文章图片做处理
for (var i = 0;i<res.data.rows.length;i++){
content = res.data.rows[i];
if(content.litPic){
content.litPic = JSON.parse(content.litPic);
if(content.litPic.length > 0 ){
res.data.rows[i].litPic = content.litPic[0].url;
}
}
}
that.contentList = res.data.rows
}).catch(function (err){
console.log(err)
})
},
switchShow:function(arr){
var that = this
arr.forEach(function(x){
let e = that.$el.querySelector("#key_"+x)
if(e){
e.style.display=getComputedStyle(e,null).display=='none'?'flex':'none'
}
})
},
show:function(arr){
var that = this
arr.forEach(function(x){
let e = that.$el.querySelector("#key_"+x)
if(e){
e.style.display='flex'
}
})
},
hide:function(arr){
var that = this
arr.forEach(function(x){
let e = that.$el.querySelector("#key_"+x)
if(e){
e.style.display='none'
}
})
}
},
created(){
var that = this;
that.searchForm.rangeFields = JSON.stringify(that.searchForm.rangeFields);
that.searchForm.filter = JSON.stringify(that.searchForm.filter);
<!--es通用搜索,支持高亮搜索,不使用es的情况下将下面代码全部注释即可-->
//ms.http.post(ms.base+"/cms/es/search/highlightSearch.do",{
ms.http.post(ms.base+"/es/search.do",that.searchForm).then(function(res){
that.contentCount = res.data.total;
var content;
// 由于在es环境下无法用标签获取文章相关值,在这里预先对文章图片做处理
for (var i = 0;i<res.data.rows.length;i++){
content = res.data.rows[i];
if(content.litPic){
content.litPic = JSON.parse(content.litPic);
if(content.litPic.length > 0 ){
res.data.rows[i].litPic = content.litPic[0].url;
}
}
}
that.contentList = res.data.rows
}).catch(function (err){
console.log(err)
})
}
})
</script>
Tip
可以根据本页面代码来实现其他数据的全文搜索
常见问题
手动put创建索引导致查询失效问题
项目启动时,会自动创建es索引,不需要手动创建;
手动创建es索引导致,会影响索引settings和mapping,导致查询为空


删除索引,再创建es库并同步
es查询时差问题
通过系统接口查询es数据不会有时差问题
直接访问es服务器执行查询时,返回的时间格式是utc格式,实际系统查询无时差问题
批量同步数据?
通过读取数据再迭代调用Service进行保存操作
@Autowired
private IESContentService esContentService;
/**
* 同步es文章
* @return
*/
@PostMapping("/sync")
@RequiresPermissions("es:sync")
@ResponseBody
public ResultData sync() {
// 没有索引需要先去创建索引
if (!esService.existDoc(esService.getBeanClass())){
return ResultData.build().error("当前不存在es索引,请先创建es索引");
}
// 获取栏目列表
LambdaQueryWrapper<CategoryEntity> categoryWrapper = new LambdaQueryWrapper<>();
categoryWrapper.eq(CategoryEntity::getCategoryType, CategoryTypeEnum.LIST.toString());
List<CategoryEntity> categoryList = categoryBiz.list(categoryWrapper);
for (CategoryEntity category : categoryList) {
// 获取文章列表
LambdaQueryWrapper<ContentEntity> contentWrapper = new LambdaQueryWrapper<>();
contentWrapper.eq(ContentEntity::getCategoryId, category.getId());
List<ContentEntity> contentList = contentBiz.list(contentWrapper);
boolean hasModel = false;
ModelEntity model = null;
if (category.getMdiyModelId() != null) {
hasModel = true;
// 获取模型实体
model = modelBiz.getById(category.getMdiyModelId());
}
for (ContentEntity content : contentList) {
ESContentBean esContentBean = new ESContentBean();
ESContentBeanUtil.fixESContentBean(esContentBean, content, category);
esContentBean.setId(content.getId());
//将需要同步到es库的字段逐个赋值
esContentBean.setTitle(content.getContentTitle());
esContentBean.setContent(content.getContentDetails());
esContentBean.setAuthor(content.getContentAuthor());
esContentBean.setDate(content.getContentDatetime());
esContentBean.setTypeId(content.getCategoryId());
esContentBean.setDescrip(content.getContentDescription());
esContentBean.setSort(content.getContentSort());
esContentBean.setLitPic(content.getContentImg());
esContentBean.setFlag(content.getContentType());
esContentBean.setUrl(category.getCategoryPath()
try {
esContentService.save(esContentBean);
}catch (DataAccessResourceFailureException e) {
return ResultData.build().error("未找到当前ES信息,请检查当前ES链接是否正常");
}
}
}
return ResultData.build().success("全部同步完成!");
}
Tip
service保存时,如果已经存在的数据es则会进行更新操作
怎么快速查看es?
通过http://ip:5601/app/dev_tools#/console,常用指令
#索引名:cms
#查询索引内所有数据
GET cms/_search?pretty
#查询索引内数据数量
GET cms/_count?pretty
#查询es索引mapping
GET cms/_mapping
#删除索引
DELETE cms
#查看分词内容
GET cms/_analyze
{
"field": "title",
"text": "快速查看es"
}
提示createTime错误?
直接同步数据没有设定字段的数据结构从而给定了默认的text字段类型及默认结构,需先创建es索引再进行同步。 若已存在索引需删除重新创建
搜索结果返回的等待时间过久
一般分为两种情况:
1.一直没有搜索结果返回,出现这种情况一般是服务端出错,需要结合日志进行排查,例如es服务器宕机情况。
2.等待很久才返回结果,出现这种情况一般是es服务的问题,可能是分词插件出错导致es无法正常提供检索服务。
es搜索返回500
- 系统设置》ES搜索设置中开启es
- ES引擎设置,先创建es库,再同步es库
es同步时出现报错 cluster_block_exception index has read-only-allow-delete block
这类问题通常是存储空间不够,索引自动变为只读只删,需要增加存储大小然后重启,如果硬盘足够就需要检查es数据存储目录,考虑修改配置文件
es同步报No qualifying bean of type ‘net.mingsoft.gov.service.IESContentService’ avaliable
-
yml中 设置spring.data.elasticsearch.repositories.enabled 为true
-
如果在MSApplication使用了@EnableElasticsearchRepositories,需要加上net.mingsoft.elasticsearch.service和net.mingsoft.gov.service
用某个词搜索不出指定文章的情况
系统是对于搜索是默认粒度拆分,一些并未在分词库里的行业术语不会在搜索的时候被拆分出来进行匹配;
官方推荐了一种分词库录入词不足的解决方案,添加分词热更新配置,具体查看ik分词官方文档链接https://github.com/medcl/elasticsearch-analysis-ik#%E7%83%AD%E6%9B%B4%E6%96%B0-ik-%E5%88%86%E8%AF%8D%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95
聚合搜索报错
异常信息如下
Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [interests] in order to load field data by uninverting the inverted index. Note that this can use significant memory
对text类型属性进行聚合,需要对字段设置成keyword;参考ESContentBean中typeId的字段
聚合搜索结果结构说明
/cms/es/search/highlightSearch.do接口返回结果中的agg结构说明:
“[父栏目id集合(参考category_parent_ids字段)]-[文章所属栏目id]-[文章所属栏目名称]”: 文章所属栏目下相关文章数的统计
es安全设置
打印插件手册
可以通过打印插件增加打印模板,配合标签模板,快捷方便的添加各种打印模板,如:自定义回单打印、自定义票据打印
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-print</artifactId>
</dependency>
版本更新说明
每天都在改变、从未停止过….
业务开发
待补充
常见问题
投票问卷插件手册
可以快速发布问卷调查
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-qa</artifactId>
</dependency>
版本更新说明
每天都在改变、从未停止过….
业务开发
Tip
调查问卷插件带有专属标签供用户在模板中快速使用
使用问卷
VUE3使用代码片段
...省略本文档必须引入资源文件...
{@ms:qa "问卷调查" /}
<script>
ms.base = "{ms:global.contextpath/}";
ms.manager = "{ms:global.contextpath/}";
/**
* 封装vue创建过程,方便初始化组件
* @param obj
* @returns vue实例
* @private
*/
function _Vue(obj) {
var app = Vue.createApp(obj);
app.config.globalProperties.ms = ms;
app.use(ElementPlus,{
locale: ElementPlusLocaleZhCn
});
app.use(MsElForm);
app.component('ms-qa', MsQa);
return app.mount(obj.el);
}
</script>
...
<div id="app" v-cloak >
...
<ms-qa></ms-qa>
...
</div>
<script>
// vue实例的名称必须是form
var form = new _Vue({
el: '#app',
})
</script>
...
Tip
其中
问卷调查为问卷的名称
导入问卷

Tip
自定义模型JSON通过平台的代码生成器获取
插件展示


地址访问
点击自定义页面。新增页面,选择模板绑定,填写完成后,点击列表页对应访问地址

常见问题
问卷页面空白
模版没有引入element ui资源;页面使用的是element的组件,必须要引入element的资源,不然无法渲染
工作流插件手册
轻量级工作流插件,可快速为已有业务模块或全新业务场景配置和添加自定义审批流程
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-flow</artifactId>
</dependency>
依赖 warm-flow 开源项目 感谢 https://github.com/dromara/warm-flow
功能演示
流程定义
用于新增工作流流程,可设置流程条件、监听器、类别

点击操作栏设计流程,进行流程设计


保存流程后,点击列表页中操作栏发布流程

在栏目管理中绑定流程

添加业务数据、提交审核
在绑定了流程的栏目下,新增数据,并选择文章类型为推荐

列表点击操作栏提交审核

待办任务
根据设计的流程图 在数据提交审核后,把审核任务分配给了对应的审核人;在流程管理 > 待办任务中进行查看

点击办理进入办理页面,未设置审批表单路径,不会展示录入数据

设置了审批表单路径,那么左侧会展示表单录入数据

已办任务
在流程管理 > 已办任务中进行查看

审批记录可以看到一条任务的办理详情

流程进度可以看到流程的审批情况及办理节点信息

版本更新说明
每天都在改变、从未停止过….
业务开发
新增业务流程配置
以文章和角色两种场景来演示
新增流程类别字典

新增流程配置
新闻文章审批配置
角色审批配置
表单项流程配置json结构说明
JSON结构说明
| 参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| tableName | 是 | 业务表名 | 无 |
| statusField | 否 | 自定义状态字段名,如有该业务表有自己的状态字段时,请填写对应表字段 | FLOW_STATUS |
| statusOptions | 否 | 自定义初始(新增)与完成(审批通过)的流程状态,不设置默认会提供一套状态 | 无 |
| fields | 否 | 条件字段配置。不填写时,设置的互斥网关将走最右侧的分支 | 无 |
| updateStrategy | 否 | 决定业务数据在审批中的更新策略(reject,continue,rollback) reject: 禁止更新流程中的业务数据 continue: 允许更新,并以新数据继续执行流程 rollback: 允许更新,终止当前流程,回滚到待提交状态 | reject |
fields字段说明
| 参数 | 是否必填 | 示例值 | 说明 |
|---|---|---|---|
| field | 是 | CONTENT_TITLE | 数据库中字段名 |
| name | 是 | 文章标题 | 条件选择下拉展示名称 |
| type | 是 | input | 条件选择类型,条件会根据type来生成选择控件 input: 输入框 select: 下拉框 date: 日期选择框 time: 时间选择框 radio: 下拉框 checkbox: 下拉框 switch: 开关 |
| multiple | 否 | false,在type为select、radio、checkbox时需要 | 字段值是否支持多选 |
| options | 否 | [{“value”:“选项1”,“label”:“选项1”}] | 当type为select、radio、checkbox时,options为下拉框的选项,只有以上类型才与需要此字段 |
| fmt | 否 | YYYY-MM-DD HH:mm:ss | 当type为date时,fmt为日期时间格式 |
{
"tableName": "CMS_CONTENT",
"statusField": "PROGRESS_STATUS",
"statusOptions": {
"0": "草稿",
"1": "审批中",
"8": "终审通过"
},
"fields": [{
"field": "CONTENT_DATETIME",
"name": "发布时间",
"type": "date",
"fmt": "YYYY-MM-DD HH:mm:ss"
},{
"field": "CONTENT_TYPE",
"name": "文章类型",
"key": "value",
"type": "select",
"multiple": "false",
"options": [
{
"value": "c",
"label": "推荐"
},
{
"value": "f",
"label": "幻灯"
},
{
"value": "h",
"label": "头条"
},
{
"value": "p",
"label": "图片"
}]
},{
"field": "CATEGORY_ID",
"name": "所属栏目",
"type": "select",
"multiple": "false",
"options": [{
"value": "1499198773946273794",
"label": "文化"
}, {
"value": "1546662562244468738",
"label": "动漫"
}, {
"value": "1546662842281369601",
"label": "综艺"
}]
}]
}
流程设计效果展示,通过下拉选择的方式简单设置条件


审批表单
介绍:在办理审批时,用于展示业务数据详情;审批表单路径为一个控制层视图请求或路由(如 /basic/role/form.do),一般办理审批时,相较于新增数据表单,审批表单禁止修改,按钮隐藏
设置了审批表单,在办理审批时,左侧会根据配置的路径展示业务数据
没有设置审批表单,在办理审批时,不会展示业务数据
审批表单方案1:复用业务数据表单
通用办理会传递一个isReadonly=true参数,业务表单接收参数并根据isReadonly去实现审批表单效果
...
<el-header class="ms-header ms-tr" height="50px" >
<el-button type="primary" class="iconfont icon-baocun" size="default" @click="save()" :loading="saveDisabled" v-if="!isReadonly">保存
</el-button>
<el-button size="default" class="iconfont icon-fanhui" plain @click="back()" v-if="!isReadonly">返回</el-button>
</el-header>
...
<el-form ref="form" :model="form" :rules="rules" label-width="100px" size="default" :disabled="isReadonly">
...
</el-form>
...
<!--vue js-->
data: function () {
return {
...
isReadonly: false, // 是否只读
...
}
}
created: function () {
this.isReadonly = ms.util.getParameter("isReadonly");
}
审批表单方案2:自定义表单
创建一个新的表单页,单独用来展示审批时的业务数据,需要对应新增控制层视图请求或路由
监听器配置
在流程办理的过程中,可以通过监听器监听流程办理过程中的不同时期,达到增强的目的,需要二次开发
参考文档流程监听器
流程绑定
业务分类独立流程图场景
场景说明:当一张表的业务数据有分类,分类是独立表,不同分类下的业务数据通过各自的流程图执行流程,这类场景下流程绑定在分类数据上;如 文章 栏目场景,不同栏目下的文章走不同的审批流程;
特点:业务数据可以通过分类清晰地知道业务数据是否有审批配置,独立流程图,流程图版本更稳定;但是需要对分类表、分类业务做处理
分类表添加流程编码字段
ALTER TABLE YOUR_TABLE
ADD COLUMN FLOW_CODE VARCHAR(255) DEFAULT '' COMMENT '流程编码';
业务分类数据控制层新增、更新方法添加@EnableFlow注解
| 注解参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| category | 是 | 流程类别的字典名称 | 无 |
前端分类数据表单页使用绑定流程分类组件<ms-flow-select-category>

<ms-flow-select-category>组件参数说明
| 组件参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| dataId | 是 | 当前业务数据id | 无 |
| formData | 是 | 当前表单提交的业务数据对象 | 无 |
| category | 否 | 查询指定类型的业务流程数据,为空时查询全部,值来源:流程类别字典名称 | 无 |
<ms-flow-select-category>集成代码示例
...
<!-- 头部添加组件引入 -->
<#include "flow/components/ms-flow-select-category.ftl"/>
...
...
<!-- 组件使用 -->
<ms-flow-select-category v-if="!form.id || form.leaf" :form-data="form" :data-id="form.id" category="栏目文章"></ms-flow-select-category>
...
<!-- vue js -->
var categoryForm = Vue.defineComponent({
components:{
...
// 注册组件
MsFlowSelectCategory
...
},
...
})
业务无分类场景
场景说明:一张表的业务数据共用一个流程,在提交时通过flowCode参数去指定流程
业务数据
业务数据添加流程字段
-- 如果业务数据原本有审批状态字段,执行下面的sql 注意 要在流程配置json的statusField中需要配置业务表中的审批字段
ALTER TABLE YOUR_TABLE
ADD COLUMN FLOW_INSTANCE_ID varchar(25) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '流程实例id' AFTER DEL,
ADD COLUMN FLOW_NODE_CODE varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '节点编码' AFTER FLOW_INSTANCE_ID,
ADD COLUMN FLOW_NODE_NAME varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '流程节点名称' AFTER FLOW_NODE_CODE,
ADD COLUMN FLOW_NODE_TYPE int NULL DEFAULT NULL COMMENT '节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关)' AFTER FLOW_NODE_NAME;
-- 如果新产生审批的业务,执行下面的sql
ALTER TABLE YOUR_TABLE
ADD COLUMN FLOW_STATUS varchar(25) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '流程状态' AFTER DEL,
ADD COLUMN FLOW_INSTANCE_ID varchar(25) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '流程实例id' AFTER FLOW_STATUS,
ADD COLUMN FLOW_NODE_CODE varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '节点编码' AFTER FLOW_INSTANCE_ID,
ADD COLUMN FLOW_NODE_NAME varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '流程节点名称' AFTER FLOW_NODE_CODE,
ADD COLUMN FLOW_NODE_TYPE int NULL DEFAULT NULL COMMENT '节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关)' AFTER FLOW_NODE_NAME;
有审批属性的业务实体对象继承BaseFlowEntity,并忽略flowStatus字段

新产生审批业务实体对象继承BaseFlowEntity
业务数据控制层使用@FlowStatus注解

在业务数据的新增和更新方法上添加@FlowStatus注解,在新增、更新业务数据时,会自动设置流程状态值,业务实体必须要继承BaseFlowEntity
| 注解参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| category | 是 | 流程类别的字典名称 | 无 |
| typeField | 否 | 业务实体中分类字段名,值为分类实体中的id,如ContentEntity中的categoryId | 空串 |
功能介绍:
- 新增场景
- 业务流程为发布状态时,新增业务数据审批状态为0,如果流程配置json有配置statusOptions,那么为0对应的值;否则为0,页面上通过
ms-flow-status格式化0为待提交 - 业务流程为未发布状态时,新增业务数据审批状态为8,如果流程配置json有配置statusOptions,那么为8对应的值;否则为8,页面上通过
ms-flow-status格式化8为已完成
- 业务流程为发布状态时,新增业务数据审批状态为0,如果流程配置json有配置statusOptions,那么为0对应的值;否则为0,页面上通过
- 编辑场景,根据流程配置json中的updateStrategy决定处理方式
- reject(默认),禁止更新流程中的业务数据
- continue,允许更新,并以新数据继续执行流程
- rollback,允许更新,终止当前流程,回滚到待提交状态
业务数据列表页提交审批、撤回组件ms-flow-action

组件参数说明
| 组件参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| dataId | 是 | 当前业务数据id | 无 |
| flowCode | 是 | 流程编码 | 无 |
| actionType | 否 | 当前业务操作类型,支持类型 submit revoke | submit |
| title | 否 | 操作按钮名称 | submit情况默认为提交审批,revoke情况默认为撤回 |
| flowDataTitle | 否 | 业务数据标题,用于待办页面显示,提交操作生效 | 无 |
| callback | 否 | 提交后回调方法 | 无 |
...
<!-- 头部添加组件引入 -->
<#include "flow/components/ms-flow-action.ftl"/>
...
<!--操作列展示-->
<!--提交审批 -->
<ms-flow-action :data-id="scope.row.id"
:flow-Code="getFlowCodeFromCategory"
@callback="list"
:title="scope.row.progressStatus == '审批不通过' ? '重新提交':'提交审批'"
v-if="
ms.util.includes(ms.managerPermissions,'flow:'+getFlowType()+':submit') &&
(scope.row.progressStatus == '待提交' ||
scope.row.progressStatus == '审批不通过' ||
scope.row.progressStatus == '撤销')"
></ms-flow-action>
<!--撤销-->
<ms-flow-action :data-id="scope.row.id"
:flow-Code="getFlowCodeFromCategory"
@callback="list"
action-type="revoke"
v-if="
ms.util.includes(ms.managerPermissions,'flow:'+getFlowType()+':revoke') &&
scope.row.progressStatus == '审批中'"
></ms-flow-action>
<!--vue js-->
components: {
// 注册组件
MsFlowAction,
},
method: {
// 在分类场景下 选中有流程的分类时 获取到该分类绑定流程的flowCode
getFlowCodeFromCategory(){
return 'role_approval'
},
// 获取业务数据对应的流程类别字典的字典值
getFlowType(){
return 'role';
}
}
提交、撤回权限添加

权限结构:“flow:” + 业务对应流程类别字典的数据值 + “:submit|:revoke”
业务数据列表审批状态格式化组件ms-flow-status

流程默认有一套流程状态的处理,如果没有自定义流程状态的需求,可以直接使用默认的
组件参数说明
| 组件参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| flowStatus | 是 | 当前业务数据的审批状态值 | 无 |
...
<!-- 头部添加组件引入 -->
<#include "flow/components/ms-flow-status.ftl">
...
<el-table-column label="审批状态" align="left" show-overflow-tooltip>
<template #default="scope">
<!-- 注意:scope.row.xxx 这个属性 优先为流程配置json中的statusField,默认为flowStatus -->
<ms-flow-status :flow-status="scope.row.progressStatus"></ms-flow-status>
</template>
</el-table-column>
<!--vue js-->
components: {
// 注册组件
MsFlowStatus,
},
<!------组件------>
// 通过flowStatusOptions进行格式化处理 不在flowStatusOptions中的值,原值显示
flowStatusOptions: // (0待提交 1审批中 8已完成 2 审批通过-在审批日志中使用)
[{
value: 0,
label: '待提交'
}, {
value: 1,
label: '审批中'
},{
value: 2,
label: '审批通过'
}, {
value: 8,
label: '已完成'
}],
业务数据列表流程进度组件ms-flow-chart

流程进度组件参数说明
| 组件参数 | 是否必填 | 说明 | 默认值 |
|---|---|---|---|
| insId | 是 | 当前业务数据的流程实例ID | 无 |
...
<!-- 头部添加组件引入 -->
<#include "flow/components/ms-flow-chart.ftl">
...
<!--操作列展示-->
<ms-flow-chart :ins-id="scope.row.flowInstanceId"
v-if="
scope.row.progressStatus != '待提交' &&
scope.row.flowInstanceId "">
</ms-flow-chart>
<!--vue js-->
components: {
// 注册组件
MsFlowChart,
},
业务数据列表增加审批记录按钮

<!--操作列展示-->
<@shiro.hasPermission name="flow:task:done">
<el-link type="primary" :underline="false" @click="showDoneList(scope.row.id,scope.row.flowInstanceId)">审批记录</el-link>
</@shiro.hasPermission>
<!--vue js-->
showDoneList:function (id,instanceId){
var url = "/flow/task/done/doneList.do?dataId="+id+"&backUrl="+encodeURIComponent("/basic/role/index.do")
if (instanceId){
url += "&instanceId="+instanceId
}
ms.util.openSystemUrl(url)
},
抄送
定义:抄送功能,是指流程执行过程中,节点可设置抄送人,节点执行完成时,将节点的审批结果进行抄送,抄送的通知方式需要自行实现
流程图设计

监听器中处理节点抄送人
抄送消息内容和抄送通知方式按需处理

流程设计器接口权限控制
shiroConfig中进行接口权限控制
常见问题
分类数据权限场景下,提交、撤回流程权限说明
场景说明: 由于提交、撤回是通用接口,方法内部通过业务逻辑去判断流程权限,权限结构:"flow" + 业务对应流程类别字典的字典value值 + "submit|revoke";
在文章、栏目场景,如果要通过栏目去限制权限(如 A栏目下用户张三只有提交审核权限,B栏目下用户张三只有撤回审核权限),此时走通用接口无法满足,需要新增接口方法去鉴权,然后调用提交、撤回的业务层方法
然后在通用的接口方法中 对需要做数据权限的流程做排除,不能走通用接口去提交或撤回
专题专栏
快速创建和管理相关主题内容数据。
实现以下专题效果

使用流程
- 在后台管理-专题管理-专题列表中添加专题数据

- 点击专题操作栏中的添加板块按钮,增加板块

- 默认展示第一个专题数据,点击需要引入文章数据板块

- 点击未引用,点击需要引入的文章,点击引用。

- 点击内容管理-静态化-生成栏目-选择专题

版本更新说明
每天都在改变、从未停止过….
业务开发
专题列表 ms:topicchannel
适用模版
首页模版、列表模版、内容模版、自定义页面模版
格式
{ms:topicchannel 参数1=值1 参数2=值2}
${field.*}
{/ms:topicchannel}
参数
| 参数名称 | 类型 | 必须 | 示例值 | 默认值 | 描述 |
|---|---|---|---|---|---|
| type | 字符串 | 否 | self | self: 取当前指定typeid本身; | |
| typeid | 整型 | 否 | >0 | 所有父级专题 | typeid有值时,取所指专题的板块 |
| size | 整型 | 否 | >0 | 所有专题 | 返回既定条件下的专题或者板块个数,不使用则默认返回全部 |
| orderby | 字符串 | 否 | date 更新时间 sort 排序 | id | 专题排序,sort排序需要在自定义顺序设置才能看到效果 |
| order | 字符串 | 否 | 升序 | desc:按照倒序排列,asc:按照正序排列 |
Tip
order参数必须要与orderby一起使用才有效 属性类型为字符串,使用需要使用引号 例如 type=“son” order=“desc”
输出字段
具体输出字段参考文章列表标签
topicchannel范例
下面列举一些常用的使用场景
在首页展示全部专题
{ms:topicchannel}
<a href="{ms:global.html/}${field.typelink}">
${field.typetitle}
</a>
{/ms:topicchannel}
Tip
如果在专题模版中使用,则会获取当前专题的子板块且子版块不支持超链接点击(子版块不允许取${field.typelink}值)。
展示指定专题
{ms:topicchannel typeid="1" type='self'}
<a href="{ms:global.html/}${field.typelink}">
${field.typetitle}
</a>
{/ms:topicchannel}
展示专题下板块
{ms:topicchannel}
+${field.typetitle}<br>
{ms:topicchannel }
++${field.typetitle}<br>
{/ms:topicchannel}
{/ms:topicchannel}
Tip
其他用法,大体可以参考栏目列表标签
板块列表 ms:topicarclist
读取板块引入的文章列表数据
适用模版
首页模版、专题列表模版
Tip
主页面模版使用:如果不设置 typeid 会取出所有的专题下的数据,推荐增加 typeid 值,且相同引用的文章数据只会展示一次;
列表模版使用: 可以不设置 typeid 参数,默认为当前访问的栏目 ID,建议指定相关板块的ID。
格式
{ms:topiclist 参数1=值1 参数2=值2}
${field.*}
{/ms:topiclist}
Tip
可以嵌套在
topicchannel里面来实现专题动态获取文章列表
参数
| 名称 | 类型 | 必须 | 示列值 | 默认值 | 描述 |
|---|---|---|---|---|---|
| typeid | 整型 | 否 | >0 | 无 | 专题或者板块ID,在列表模板默认获取当前专题ID |
| size | 整型 | 否 | >0 | 20 | 返回文档列表总数,默认为 20 条 |
| orderby | 字符串 | 否 | date | content_datetime | 根据时间排序:date,根据更新时间排序:updatedate,根据文章自定义顺序排序:sort,根据文章点击数排序:hit(如果点击量增加了,需要重新生成排序才会变化),不填则显示默认顺序,支持多个参数值,以英文逗号分隔,例如:orderby=‘sort,date’(多个参数值,仅5.4.2及以上版本支持) |
| order | 字符串 | 否 | asc | desc | desc:按照倒序(降序)排列,asc:按照正序(升序)排列,必须与 orderby 一起使用,默认为创建时间; |
输出字段
具体输出字段参考文章列表标签
Tip
不支持获取文章自定义模型字段数据。
topicarclist范例
基础使用格式
获取 typeid=62 下面size=5 五篇 文章
{ms:topiclist size=5 typeid=62}
<img src="{@ms:file field.litpic/}" />
<a href="{ms:global.html/}${field.link}" target="_self">${field.title}</a>
{/ms:topiclist}
Tip
其他用法,大体可以参考文章列表标签 ,需要把arclist改成topiclist。
常见问题
领导信箱
领导信箱功能旨在构建群众与政府紧密沟通的渠道,同时将市民的建议、咨询、投诉及举报等信息汇集到网上集中办理,提高政府各职能部门的办事效率和公共服务质量。
可实现以下效果
信箱分类列表页面:
表单页面:

依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-leader-box</artifactId>
</dependency>
使用流程
-
在后台管理-领导信箱管理-领导信箱分类中添加信箱分类数据;分类共两层栏目,第一层为具体的分类名称;第二层为该分类下具体的机构(领导|部门|单位),用来接收用户的信箱留言
如图,第一层为省长信箱,第二层为钟书记、李书记。

-
模板绑定说明,建议第一层分类绑定的模板为列表页面,用来展示该分类下具体的机构;第二层绑定模板为具体的信箱留言页面,通过绑定不同的模板,可以满足不同领导不同的留言界面

-
绑定模板后,点击分类管理-一键静态化 ,点击后生成领导信箱分类的静态页面

Tip
如果在模板开发阶段可通过预览功能快速查看模板效果(需在静态化配置开启动态访问)
4.信件管理权限分配说明,首先在角色管理中添加角色,并分配领导信箱相关权限
然后在信箱权限管理,通过角色绑定具体的信箱分类,进而精细化控制角色可以访问哪些信箱下的留言数据
例如:“我的信箱“实现功能设置如下:李书记角色 绑定 李书记的信箱,并且对应的管理员绑定李书记角色,当登录李书记角色绑定的管理员账号时,即可管理李书记的信箱,其他未授权的信箱则不可见。

Tip
如果该角色需对信箱留言进行回复,还需具备权限信件评论相关权限。
-
用户通过前台提交的信箱留言可在 领导信箱管理-信件管理看到

管理员可通过查看详情查看具体的信件内容,点击回复按钮对信件进行回复
回复后需要选择公示才在前台页面展示。

Tip
在使用这个功能之前用户需要具备领导信箱留言权限
- 通过信箱评论配置可以控制用户对信件的评论

版本更新说明
每天都在改变、从未停止过….
业务开发
领导信箱分类列表 ms:leaderchannel
适用模版
首页模版、列表模版、内容模版、自定义页面模版
格式
{ms:leaderchannel 参数1=值1 参数2=值2}
${field.*}
{/ms:leaderchannel}
参数(用法与栏目一致)
| 参数名称 | 类型 | 必须 | 示例值 | 默认值 | 描述 |
|---|---|---|---|---|---|
| type | 字符串 | 否 | self | self: 取当前指定typeid本身; | |
| typeid | 字符串 | 否 | >0 | typeid有值时,取所指信箱分类信箱 | |
| size | 整型 | 否 | >0 | 所有专题 | 返回既定条件下的信箱分类个数,不使用则默认返回全部 |
| orderby | 字符串 | 否 | date 更新时间 sort 排序 | id | 信箱分类排序,sort排序需要在自定义顺序设置才能看到效果 |
| order | 字符串 | 否 | 升序 | desc:按照倒序排列,asc:按照正序排列 |
Tip
order参数必须要与orderby一起使用才有效 属性类型为字符串,使用需要使用引号 例如 type=“son” order=“desc”
输出字段
具体输出字段参考栏目列表标签
leaderchannel范例
下面列举一些常用的使用场景
获取指定的信箱分类信息
{ms:leaderchannel typeid="1" type='self'}
<a href="{ms:global.html/}${field.typelink}">
${field.typetitle}
</a>
{/ms:leaderchannel}
Tip
如果在领导信息分类绑定的模板中使用,则可以不需要指定typeid)。
获取扩展模型信息
{ms:leaderchannel typeid="1" type='self' tableName='模型表名'}
<a href="{ms:global.html/}${field.typelink}">
${field.typetitle}
${field.模型字段}
</a>
{/ms:leaderchannel}
Tip
其他用法,大体可以参考栏目列表标签
模板开发
默认提供4个前台模板
- leader-index 信箱列表页面,展示分类下的信件留言入口,可用于领导信箱分类第一层模板绑定

- leader-form 信箱留言表单页面 可用于领导信箱分类第二层模板绑定

- leader-list 信箱公示列表页面 可在自定义页面绑定

- leader-detail 信箱留言详情页面 可在自定义页面绑定

Tip
领导信箱分类数据可以通过标签获取,但信箱留言数据以及回复数据需要通过接口动态获取
信箱详情模板(leader-form默认提供的模板)
详情模板开发步骤介绍,如果leaded-box表提供的字段不能满足需求,可通过模型扩展字段。首先在需要扩展的信箱分类绑定信箱自定义模型
再调整模板写法(关键代码)
<!-- 引入自定义js -->
<script src="${base}/static/mdiy/index.js"></script>
<div id="app" v-cloak >
...
<!-- leader-box 表基本字段省略 -->
<!-- 模型部分 会自动渲染代码生成器的表单 -->
<ms-mdiy-form v-if="modelName!=null&&modelName!=''" style="height: auto; !important;" ref="modelForm" type="leaderbox" :model-name="modelName"></ms-mdiy-form>
...
</div>
<script>
// vue实例的名称必须是form
var form = new Vue({
el: '#app',
})
data:{
···
modelName:"自定义模型名称", // 自定义模型名称
leaderBoxModel:{} // 存储模型对象
···
}
methods:{
···
save:function (){
···
let data = JSON.parse(JSON.stringify(that.form));
// 有模型数据传递模型数据到后台
if (that.leaderBoxModel) {
// 获取模型数据
data.modelData= that.leaderBoxModel.getFormData()
data.modelData = JSON.stringify(data.modelData);
}
···
}
}
···
</script>
常见问题
分保密级
通用控制前台数据展示插件,提供精细化、动态化且与业务逻辑解耦的权限控制能力。核心目标是让开发者无需在业务代码中硬编码权限判断逻辑,即可实现如“不同用户查看同一张数据表的不同数据行”的需求。
依赖
<dependency>
<groupId>net.mingsoft</groupId>
<artifactId>ms-secret</artifactId>
</dependency>
功能演示
后台演示
业务数据权限值设置,业务数据权限组限制单选

业务人员权限值设置,业务人员权限值可多选,能查看未设置权限和已有权限值的业务数据

前台数据展示
全部业务数据

未登录情况下,看到的业务数据

登录拥有全部权限的会员账号后

版本更新说明
每天都在改变、从未停止过….
业务开发
前端开发
在业务表单中使用<ms-secret>组件
| 组件参数 | 是否必填 | 说明 |
|---|---|---|
| id | 是 | 当前业务数据id |
| table | 是 | 存储当前业务数据的数据库表名称 |
| multiple | 否 | 权限值是否支持多选,业务数据权限推荐单选 |
| secretId | 否 | 业务表单场景必须,列表页不需要 |
下面以文章表单为例
...
<!-- 添加组件引入 -->
<#include "secret/components/ms-secret.ftl">
<template type="text/x-template" id="content-form">
<div id="form" v-cloak>
<el-header class="ms-header ms-tr" height="50px">
...
...
<!-- 组件使用 -->
<ms-secret :id="form.id" :table="'cms_content'" v-model:secret-id="secretId" ref="secretRef"></ms-secret>
...
<!-- vue js -->
...
components:{
...
// 增加组件注册
MsSecret,
},
data: function () {
return {
...
// 增加密级值变量定义
secretId: "",
...
}
},
methods:{
// 保存、更新方法
saveOrUpdate(){
...
// 设置密级值 默认参数名为secret
data.secret = that.secretId;
ms.http.post(url, data).then(function (res) {
...
}
}
后端开发
添加_SECRET字段
ALTER TABLE YOUR_TABLE
ADD COLUMN _SECRET VARCHAR(255) DEFAULT '' COMMENT '权限值';
使用SecretData注解
注解会把前端传递的secretId参数进行保存到业务表记录中,接受的前端参数名称默认为secret,可通过SecretData注解的secret属性去自定义
@SecretData(tableName = "YOUR_TABLE",secret = "mySecret")
public ResultData save(){
...
return ResultData.build().success(yourData);
}
@SecretData(tableName = "YOUR_TABLE")
public ResultData update(){
...
return ResultData.build().success(yourData);
}
控制数据展示
添加权限配置
控制是否开启对应业务的权限
按需修改配置模型json模版的title,然后导入到自定义配置中
{
"searchJson": "[\n]\n",
"field": "[\n {\n \"model\":\"secretEnable\",\n \"key\":\"SECRET_ENABLE\",\n \"field\":\"SECRET_ENABLE\",\n \"javaType\":\"Boolean\",\n \"jdbcType\":\"VARCHAR\",\n \"name\":\"启用分保\",\n \"type\":\"switch\",\n \"length\":\"11\",\n \"isShow\":false,\n \"isNoRepeat\":false,\n \"isSearch\":false\n }\n ,{\n \"model\":\"secretErrorUrl\",\n \"key\":\"SECRET_ERROR_URL\",\n \"field\":\"SECRET_ERROR_URL\",\n \"javaType\":\"String\",\n \"jdbcType\":\"VARCHAR\",\n \"name\":\"无分保权限URL\",\n \"type\":\"input\",\n \"length\":\"255\",\n \"isShow\":true,\n \"isNoRepeat\":false,\n \"isSearch\":false\n }\n]\n\n",
"html": "\n<template id=\"custom-model\">\n <el-form ref=\"form\" :model=\"form\" :rules=\"rules\" label-width=\"120px\" label-position=\"right\" size=\"default\" :disabled=\"disabled\" v-loading=\"loading\">\n <!--启用分保-->\n \n <el-form-item label=\"启用分保\" prop=\"secretEnable\">\n <el-switch v-model=\"form.secretEnable\"\n :disabled=\"false\">\n </el-switch>\n <div class=\"ms-form-tip\">\n启用分保后,需要通过接口获取数据;设置了分保的文章需要会员(管理员)登录并拥有对应分保等级才能访问文章 </div>\n </el-form-item>\n \n <!--无分保权限URL-->\n\n\t <el-form-item label=\"无分保权限URL\" prop=\"secretErrorUrl\">\n\t <el-input\n v-model=\"form.secretErrorUrl\"\n :disabled=\"false\"\n :readonly=\"false\"\n :style=\"{width: '100%'}\"\n :clearable=\"true\"\n placeholder=\"请输入无分保权限URL\">\n </el-input>\n <div class=\"ms-form-tip\">\n绝对路径URL,如http://localhost:8080/ms/login.do </div>\n\t </el-form-item> \n </el-form>\n</template>\n",
"title": "文章分保配置",
"script": "var custom_model = Vue.component(\"custom-model\",{\n el: '#custom-model',\n data:function() {\n return {\n\t\t\tloading:false,\n disabled:false,\n modelId:0,\n modelName: \"分保配置\",\n //表单数据\n form: {\n linkId:0,\n // 启用分保\n secretEnable:false,\n // 无分保权限URL\n secretErrorUrl:'',\n },\n\n rules:{\n // 无分保权限URL\n secretErrorUrl: [{\"min\":0,\"max\":255,\"message\":\"无密级权限URL长度必须为0-255\"}],\n },\n }\n },\n watch:{\n \n //启用分保 \n \"form.secretEnable\":function(nev,old){\n if(typeof(nev)=='string') {\n this.form.secretEnable = (nev=='true');\n } else if(typeof(nev)=='undefined') {\n this.form.secretEnable = false;\n } \n },\n },\n components:{\n },\n computed:{\n },\n methods: {\n \tlink:function(e,field,binds){\n \t\tlet that = this;\n binds.forEach(function(item){\n \t\t\t\tms.http.post(ms.manager+'/project/form/link.do', {id:that.modelId,field:item.field,value:e}).then(function (res) {\n if(res.result && res.data) {\n that.form[ms.util.camelCaseString(item.field)]=res.data[0][item.target];\n }else{\n that.$notify({\n title: '失败',\n message: res.msg,\n type: 'warning'\n });\n }\n })\n\n });\n \t},\n update: function (row) {\n var that = this;\n ms.http.post(ms.manager+\"/gov/securityConfig/update.do\", row).then(function (data) {\n if (data.result) {\n that.$notify({\n title: '成功',\n message: '更新成功',\n type: 'success'\n });\n\n } else {\n that.$notify({\n title: '失败',\n message: data.msg,\n type: 'warning'\n });\n }\n });\n }, validate:function(){\n var b = false\n this.$refs.form.validate(function(valid){\n b = valid;\n });\n return b;\n },\n getFormData() {\n var that = this;\n var form = JSON.parse(JSON.stringify(that.form));\n form.modelId = that.modelId;\n return form;\n },\n save:function(callback) {\n var that = this;\n var url = this.formURL.save.url;\n if (that.form.id > 0) {\n url = this.formURL.update.url;\n }\n this.$refs.form.validate(function(valid) {\n if (valid) {\n var form = JSON.parse(JSON.stringify(that.form));\n form.modelId = that.modelId;\n ms.http.post(url, form).then(function (res) {\n if(callback) {\n callback(res);\n }\n }).catch(function(err){\n callback(err.response.data);\n });\n } else{\n callback({\n result:false,msg:'请检查表单输入项'\n });\n }\n })\n },\n //获取当前分保配置\n get:function(id) {\n var that = this;\n that.loading = true;\n ms.http.get(this.formURL.get.url, Object.assign({\"modelId\":that.modelId},this.formURL.get.params)).then(function (res) {\n if(res.result&&res.data){\n that.form = res.data;\n that.loading = false;\n } else {\n that.loading = false;\n }\n }).catch(function (err) {\n console.log(err);\n that.loading = false;\n });\n },\n\n },\n created:function() {\n var that = this;\n //渲染create\n that.get(this.form.linkId);\n }\n});",
"sql": "\n-- SECURITY_CONFIG\nCREATE TABLE `{model}SECURITY_CONFIG` (\n `id` varchar(25) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,\n `SECRET_ENABLE` VARCHAR(11) DEFAULT NULL COMMENT '启用分保',\n `SECRET_ERROR_URL` VARCHAR(255) DEFAULT NULL COMMENT '无分保权限URL',\n `LINK_ID` VARCHAR(30) DEFAULT NULL,\n `CREATE_DATE` DATETIME DEFAULT NULL COMMENT '创建时间',\n `CREATE_BY` VARCHAR(50) DEFAULT NULL COMMENT '创建人',\n `UPDATE_DATE` DATETIME DEFAULT NULL COMMENT '修改时间',\n `UPDATE_BY` VARCHAR(50) DEFAULT NULL COMMENT '修改人',\n `DEL` INT(1) DEFAULT 0 COMMENT '删除标记',\n PRIMARY KEY (`ID`) USING BTREE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='分保配置';\n",
"tableName": "SECURITY_CONFIG"
}
点击启用分保即可

开启权限控制
// web层文章数据展示接口为例
public ResultData list(){
// 使用注意 DataScopeUtil.start开启权限后,必须紧跟着需要控制的查询方法
...
if (ConfigUtil.getBoolean("文章分保配置","secretEnable", false)){
SecretUtil.addSecretParam(map);
DataScopeUtil.start(map.get(SecretUtil.MANAGER_ID).toString(),map.get(SecretUtil.PEOPLE_ID).toString(), DataScopeEnum.CONTENT_SECRET.toString(),true,map.get(SecretUtil.SECRET).toString());
}
List<CategoryBean> articleList = contentBiz.queryIdsByCategoryIdForParser(content);
...
}