joins = plainSelect.getJoins();
+ if (CollectionUtils.isNotEmpty(joins)) {
+ processJoins(joins);
+ }
+ }
+
+ /**
+ * 处理where条件内的子查询
+ *
+ * 支持如下:
+ * 1. in
+ * 2. =
+ * 3. >
+ * 4. <
+ * 5. >=
+ * 6. <=
+ * 7. <>
+ * 8. EXISTS
+ * 9. NOT EXISTS
+ *
+ * 前提条件:
+ * 1. 子查询必须放在小括号中
+ * 2. 子查询一般放在比较操作符的右边
+ *
+ * @param where where 条件
+ */
+ protected void processWhereSubSelect(Expression where) {
+ if (where == null) {
+ return;
+ }
+ if (where instanceof FromItem) {
+ processFromItem((FromItem) where);
+ return;
+ }
+ if (where.toString().indexOf("SELECT") > 0) {
+ // 有子查询
+ if (where instanceof BinaryExpression) {
+ // 比较符号 , and , or , 等等
+ BinaryExpression expression = (BinaryExpression) where;
+ processWhereSubSelect(expression.getLeftExpression());
+ processWhereSubSelect(expression.getRightExpression());
+ } else if (where instanceof InExpression) {
+ // in
+ InExpression expression = (InExpression) where;
+ ItemsList itemsList = expression.getRightItemsList();
+ if (itemsList instanceof SubSelect) {
+ processSelectBody(((SubSelect) itemsList).getSelectBody());
+ }
+ } else if (where instanceof ExistsExpression) {
+ // exists
+ ExistsExpression expression = (ExistsExpression) where;
+ processWhereSubSelect(expression.getRightExpression());
+ } else if (where instanceof NotExpression) {
+ // not exists
+ NotExpression expression = (NotExpression) where;
+ processWhereSubSelect(expression.getExpression());
+ } else if (where instanceof Parenthesis) {
+ Parenthesis expression = (Parenthesis) where;
+ processWhereSubSelect(expression.getExpression());
+ }
+ }
+ }
+
+ protected void processSelectItem(SelectItem selectItem) {
+ if (selectItem instanceof SelectExpressionItem) {
+ SelectExpressionItem selectExpressionItem = (SelectExpressionItem) selectItem;
+ if (selectExpressionItem.getExpression() instanceof SubSelect) {
+ processSelectBody(((SubSelect) selectExpressionItem.getExpression()).getSelectBody());
+ } else if (selectExpressionItem.getExpression() instanceof Function) {
+ processFunction((Function) selectExpressionItem.getExpression());
+ }
+ }
+ }
+
+ /**
+ * 处理函数
+ *
支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)
+ *
fixed gitee pulls/141
+ *
+ * @param function 函数
+ */
+ protected void processFunction(Function function) {
+ ExpressionList parameters = function.getParameters();
+ if (parameters != null) {
+ parameters.getExpressions().forEach(expression -> {
+ if (expression instanceof SubSelect) {
+ processSelectBody(((SubSelect) expression).getSelectBody());
+ } else if (expression instanceof Function) {
+ processFunction((Function) expression);
+ }
+ });
+ }
+ }
+
+ /**
+ * 处理子查询等
+ */
+ protected void processFromItem(FromItem fromItem) {
+ if (fromItem instanceof SubJoin) {
+ SubJoin subJoin = (SubJoin) fromItem;
+ if (subJoin.getJoinList() != null) {
+ processJoins(subJoin.getJoinList());
+ }
+ if (subJoin.getLeft() != null) {
+ processFromItem(subJoin.getLeft());
+ }
+ } else if (fromItem instanceof SubSelect) {
+ SubSelect subSelect = (SubSelect) fromItem;
+ if (subSelect.getSelectBody() != null) {
+ processSelectBody(subSelect.getSelectBody());
+ }
+ } else if (fromItem instanceof ValuesList) {
+ logger.debug("Perform a subquery, if you do not give us feedback");
+ } else if (fromItem instanceof LateralSubSelect) {
+ LateralSubSelect lateralSubSelect = (LateralSubSelect) fromItem;
+ if (lateralSubSelect.getSubSelect() != null) {
+ SubSelect subSelect = lateralSubSelect.getSubSelect();
+ if (subSelect.getSelectBody() != null) {
+ processSelectBody(subSelect.getSelectBody());
+ }
+ }
+ }
+ }
+
+ /**
+ * 处理 joins
+ *
+ * @param joins join 集合
+ */
+ private void processJoins(List joins) {
+ //对于 on 表达式写在最后的 join,需要记录下前面多个 on 的表名
+ Deque tables = new LinkedList<>();
+ for (Join join : joins) {
+ // 处理 on 表达式
+ FromItem fromItem = join.getRightItem();
+ if (fromItem instanceof Table) {
+ Table fromTable = (Table) fromItem;
+ // 获取 join 尾缀的 on 表达式列表
+ Collection originOnExpressions = join.getOnExpressions();
+ // 正常 join on 表达式只有一个,立刻处理
+ if (originOnExpressions.size() == 1) {
+ processJoin(join);
+ continue;
+ }
+ tables.push(fromTable);
+ // 尾缀多个 on 表达式的时候统一处理
+ if (originOnExpressions.size() > 1) {
+ Collection onExpressions = new LinkedList<>();
+ for (Expression originOnExpression : originOnExpressions) {
+ Table currentTable = tables.poll();
+ onExpressions.add(builderExpression(originOnExpression, currentTable));
+ }
+ join.setOnExpressions(onExpressions);
+ }
+ } else {
+ // 处理右边连接的子表达式
+ processFromItem(fromItem);
+ }
+ }
+ }
+
+ /**
+ * 处理联接语句
+ */
+ protected void processJoin(Join join) {
+ if (join.getRightItem() instanceof Table) {
+ Table fromTable = (Table) join.getRightItem();
+ Expression originOnExpression = CollUtil.getFirst(join.getOnExpressions());
+ originOnExpression = builderExpression(originOnExpression, fromTable);
+ join.setOnExpressions(CollUtil.newArrayList(originOnExpression));
+ }
+ }
+
+ /**
+ * 处理条件
+ */
+ protected Expression builderExpression(Expression currentExpression, Table table) {
+ // 获得 Table 对应的数据权限条件
+ Expression equalsTo = buildDataPermissionExpression(table);
+ if (equalsTo == null) { // 如果没条件,则返回 currentExpression 默认
+ return currentExpression;
+ }
+
+ // 表达式为空,则直接返回 equalsTo
+ if (currentExpression == null) {
+ return equalsTo;
+ }
+ // 如果表达式为 Or,则需要 (currentExpression) AND equalsTo
+ if (currentExpression instanceof OrExpression) {
+ return new AndExpression(new Parenthesis(currentExpression), equalsTo);
+ }
+ // 如果表达式为 And,则直接返回 currentExpression AND equalsTo
+ return new AndExpression(currentExpression, equalsTo);
+ }
+
+ /**
+ * 构建指定表的数据权限的 Expression 过滤条件
+ *
+ * @param table 表
+ * @return Expression 过滤条件
+ */
+ private Expression buildDataPermissionExpression(Table table) {
+ // 生成条件
+ Expression allExpression = null;
+ for (DataPermissionRule rule : ContextHolder.getRules()) {
+ // 判断表名是否匹配
+ if (!rule.getTableNames().contains(table.getName())) {
+ continue;
+ }
+ // 单条规则的条件
+ String tableName = MyBatisUtils.getTableName(table);
+ Expression oneExpress = rule.getExpression(tableName, table.getAlias());
+ // 拼接到 allExpression 中
+ allExpression = allExpression == null ? oneExpress
+ : new AndExpression(allExpression, oneExpress);
+ }
+
+ // 如果条件非空,说明已经重写了
+ if (allExpression != null) {
+ ContextHolder.setRewrite(true);
+ }
+ return allExpression;
+ }
+
+ /**
+ * 判断 SQL 是否重写。如果没有重写,则添加到 {@link MappedStatementCache} 中
+ *
+ * @param ms MappedStatement
+ */
+ private void addMappedStatementCache(MappedStatement ms) {
+ if (ContextHolder.getRewrite()) {
+ return;
+ }
+ // 有重写,进行添加
+ mappedStatementCache.addNoRewritable(ms, ContextHolder.getRules());
+ }
+
+ /**
+ * SQL 解析上下文,方便透传 {@link DataPermissionRule} 规则
+ *
+ * @author 芋道源码
+ */
+ static final class ContextHolder {
+
+ /**
+ * 该 {@link MappedStatement} 对应的规则
+ */
+ private static final ThreadLocal> RULES = new TransmittableThreadLocal<>();
+ /**
+ * SQL 是否进行重写
+ */
+ private static final ThreadLocal REWRITE = new TransmittableThreadLocal<>();
+
+ public static void init(List rules) {
+ RULES.set(rules);
+ REWRITE.set(false);
+ }
+
+ public static void clear() {
+ RULES.remove();
+ REWRITE.remove();
+ }
+
+ public static boolean getRewrite() {
+ return REWRITE.get();
+ }
+
+ public static void setRewrite(boolean rewrite) {
+ REWRITE.set(rewrite);
+ }
+
+ public static List getRules() {
+ return RULES.get();
+ }
+
+ }
+
+ /**
+ * {@link MappedStatement} 缓存
+ * 目前主要用于,记录 {@link DataPermissionRule} 是否对指定 {@link MappedStatement} 无效
+ * 如果无效,则可以避免 SQL 的解析,加快速度
+ *
+ * @author 芋道源码
+ */
+ static final class MappedStatementCache {
+
+ /**
+ * 指定数据权限规则,对指定 MappedStatement 无需重写(不生效)的缓存
+ *
+ * value:{@link MappedStatement#getId()} 编号
+ */
+ @Getter
+ private final Map, Set> noRewritableMappedStatements = new ConcurrentHashMap<>();
+
+ /**
+ * 判断是否无需重写
+ * ps:虽然有点中文式英语,但是容易读懂即可
+ *
+ * @param ms MappedStatement
+ * @param rules 数据权限规则数组
+ * @return 是否无需重写
+ */
+ public boolean noRewritable(MappedStatement ms, List rules) {
+ // 如果规则为空,说明无需重写
+ if (CollUtil.isEmpty(rules)) {
+ return true;
+ }
+ // 任一规则不在 noRewritableMap 中,则说明可能需要重写
+ for (DataPermissionRule rule : rules) {
+ Set mappedStatementIds = noRewritableMappedStatements.get(rule.getClass());
+ if (!CollUtil.contains(mappedStatementIds, ms.getId())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 添加无需重写的 MappedStatement
+ *
+ * @param ms MappedStatement
+ * @param rules 数据权限规则数组
+ */
+ public void addNoRewritable(MappedStatement ms, List rules) {
+ for (DataPermissionRule rule : rules) {
+ Set mappedStatementIds = noRewritableMappedStatements.get(rule.getClass());
+ if (CollUtil.isNotEmpty(mappedStatementIds)) {
+ mappedStatementIds.add(ms.getId());
+ } else {
+ noRewritableMappedStatements.put(rule.getClass(), SetUtils.asSet(ms.getId()));
+ }
+ }
+ }
+
+ /**
+ * 清空缓存
+ * 目前主要提供给单元测试
+ */
+ public void clear() {
+ noRewritableMappedStatements.clear();
+ }
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/package-info.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/package-info.java
new file mode 100644
index 000000000..20daa85c5
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * 基于部门的数据权限规则
+ *
+ * @author 芋道源码
+ */
+package cn.iocoder.yudao.framework.datapermission.core.dept;
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRule.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRule.java
new file mode 100644
index 000000000..4f7d18d8b
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRule.java
@@ -0,0 +1,186 @@
+package cn.iocoder.yudao.framework.datapermission.core.dept.rule;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.datapermission.core.dept.service.DeptDataPermissionFrameworkService;
+import cn.iocoder.yudao.framework.datapermission.core.dept.service.dto.DeptDataPermissionRespDTO;
+import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.LongValue;
+import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
+import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
+import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
+import net.sf.jsqlparser.expression.operators.relational.InExpression;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 基于部门的 {@link DataPermissionRule} 数据权限规则实现
+ *
+ * 注意,使用 DeptDataPermissionRule 时,需要保证表中有 dept_id 部门编号的字段,可自定义。
+ *
+ * 实际业务场景下,会存在一个经典的问题?当用户修改部门时,冗余的 dept_id 是否需要修改?
+ * 1. 一般情况下,dept_id 不进行修改,则会导致用户看到之前的数据。【yudao-admin-server 采用该方案】
+ * 2. 部分情况下,希望该用户还是能看到之前的数据,则有两种方式解决:【需要你改造该 DeptDataPermissionRule 的实现代码】
+ * 1)编写洗数据的脚本,将 dept_id 修改成新部门的编号;【建议】
+ * 最终过滤条件是 WHERE dept_id = ?
+ * 2)洗数据的话,可能涉及的数据量较大,也可以采用 user_id 进行过滤的方式,此时需要获取到 dept_id 对应的所有 user_id 用户编号;
+ * 最终过滤条件是 WHERE user_id IN (?, ?, ? ...)
+ * 3)想要保证原 dept_id 和 user_id 都可以看的到,此时使用 dept_id 和 user_id 一起过滤;
+ * 最终过滤条件是 WHERE dept_id = ? OR user_id IN (?, ?, ? ...)
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Slf4j
+public class DeptDataPermissionRule implements DataPermissionRule {
+
+ private static final String DEPT_COLUMN_NAME = "dept_id";
+ private static final String USER_COLUMN_NAME = "user_id";
+
+ private final DeptDataPermissionFrameworkService deptDataPermissionService;
+
+ /**
+ * 基于部门的表字段配置
+ * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。
+ *
+ * key:表名
+ * value:字段名
+ */
+ private final Map deptColumns = new HashMap<>();
+ /**
+ * 基于用户的表字段配置
+ * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。
+ *
+ * key:表名
+ * value:字段名
+ */
+ private final Map userColumns = new HashMap<>();
+ /**
+ * 所有表名,是 {@link #deptColumns} 和 {@link #userColumns} 的合集
+ */
+ private final Set TABLE_NAMES = new HashSet<>();
+
+ @Override
+ public Set getTableNames() {
+ return TABLE_NAMES;
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ // 只有有登陆用户的情况下,才进行数据权限的处理
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ if (loginUser == null) {
+ return null;
+ }
+
+ // 获得数据权限
+ DeptDataPermissionRespDTO deptDataPermission = deptDataPermissionService.getDeptDataPermission(loginUser);
+ if (deptDataPermission == null) {
+ log.error("[getExpression][LoginUser({}) 获取数据权限为 null]", JsonUtils.toJsonString(loginUser));
+ throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 未返回数据权限",
+ loginUser.getId(), tableName, tableAlias.getName()));
+ }
+
+ // 情况一,如果是 ALL 可查看全部,则无需拼接条件
+ if (deptDataPermission.getAll()) {
+ return null;
+ }
+
+ // 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限
+ if (CollUtil.isEmpty(deptDataPermission.getDeptIds())
+ && Boolean.FALSE.equals(deptDataPermission.getSelf())) {
+ return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
+ }
+
+ // 情况三,拼接 Dept 和 User 的条件,最后组合
+ Expression deptExpression = this.buildDeptExpression(tableName,tableAlias, deptDataPermission.getDeptIds());
+ Expression userExpression = this.buildUserExpression(tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId());
+ if (deptExpression == null && userExpression == null) {
+ log.error("[getExpression][LoginUser({}) Table({}/{}) DeptDataPermission({}) 构建的条件为空]",
+ JsonUtils.toJsonString(loginUser), tableName, tableAlias, JsonUtils.toJsonString(deptDataPermission));
+ throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 构建的条件为空",
+ loginUser.getId(), tableName, tableAlias.getName()));
+ }
+ if (deptExpression == null) {
+ return userExpression;
+ }
+ if (userExpression == null) {
+ return deptExpression;
+ }
+ // 目前,如果有指定部门 + 可查看自己,采用 OR 条件。即,WHERE dept_id IN ? OR user_id = ?
+ return new OrExpression(deptExpression, userExpression);
+ }
+
+ private Expression buildDeptExpression(String tableName, Alias tableAlias, Set deptIds) {
+ // 如果不存在配置,则无需作为条件
+ String columnName = deptColumns.get(tableName);
+ if (StrUtil.isEmpty(columnName)) {
+ return null;
+ }
+ // 如果为空,则无条件
+ if (CollUtil.isEmpty(deptIds)) {
+ return null;
+ }
+ // 拼接条件
+ return new InExpression(MyBatisUtils.buildColumn(tableName, tableAlias, columnName),
+ new ExpressionList(CollectionUtils.convertList(deptIds, LongValue::new)));
+ }
+
+ private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) {
+ // 如果不查看自己,则无需作为条件
+ if (Boolean.FALSE.equals(self)) {
+ return null;
+ }
+ String columnName = userColumns.get(tableName);
+ if (StrUtil.isEmpty(columnName)) {
+ return null;
+ }
+ // 拼接条件
+ return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));
+ }
+
+ // ==================== 添加配置 ====================
+
+ public void addDeptColumn(Class extends BaseDO> entityClass) {
+ addDeptColumn(entityClass, DEPT_COLUMN_NAME);
+ }
+
+ public void addDeptColumn(Class extends BaseDO> entityClass, String columnName) {
+ String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();
+ addDeptColumn(tableName, columnName);
+ }
+
+ public void addDeptColumn(String tableName, String columnName) {
+ deptColumns.put(tableName, columnName);
+ TABLE_NAMES.add(tableName);
+ }
+
+ public void addUserColumn(Class extends BaseDO> entityClass) {
+ addUserColumn(entityClass, USER_COLUMN_NAME);
+ }
+
+ public void addUserColumn(Class extends BaseDO> entityClass, String columnName) {
+ String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();
+ addUserColumn(tableName, columnName);
+ }
+
+ public void addUserColumn(String tableName, String columnName) {
+ userColumns.put(tableName, columnName);
+ TABLE_NAMES.add(tableName);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleCustomizer.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleCustomizer.java
new file mode 100644
index 000000000..5341ee5e4
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleCustomizer.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.framework.datapermission.core.dept.rule;
+
+/**
+ * {@link DeptDataPermissionRule} 的自定义配置接口
+ *
+ * @author 芋道源码
+ */
+@FunctionalInterface
+public interface DeptDataPermissionRuleCustomizer {
+
+ /**
+ * 自定义该权限规则
+ * 1. 调用 {@link DeptDataPermissionRule#addDeptColumn(Class, String)} 方法,配置基于 dept_id 的过滤规则
+ * 2. 调用 {@link DeptDataPermissionRule#addUserColumn(Class, String)} 方法,配置基于 user_id 的过滤规则
+ *
+ * @param rule 权限规则
+ */
+ void customize(DeptDataPermissionRule rule);
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/DeptDataPermissionFrameworkService.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/DeptDataPermissionFrameworkService.java
new file mode 100644
index 000000000..3ee616755
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/DeptDataPermissionFrameworkService.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.framework.datapermission.core.dept.service;
+
+import cn.iocoder.yudao.framework.datapermission.core.dept.service.dto.DeptDataPermissionRespDTO;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+
+/**
+ * 基于部门的数据权限 Framework Service 接口
+ * 目前的实现类是 SysPermissionServiceImpl 类
+ *
+ * @author 芋道源码
+ */
+public interface DeptDataPermissionFrameworkService {
+
+ /**
+ * 获得登陆用户的部门数据权限
+ *
+ * @param loginUser 登陆用户
+ * @return 部门数据权限
+ */
+ DeptDataPermissionRespDTO getDeptDataPermission(LoginUser loginUser);
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/dto/DeptDataPermissionRespDTO.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/dto/DeptDataPermissionRespDTO.java
new file mode 100644
index 000000000..897fb226a
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/dept/service/dto/DeptDataPermissionRespDTO.java
@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.framework.datapermission.core.dept.service.dto;
+
+import lombok.Data;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 部门的数据权限 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+public class DeptDataPermissionRespDTO {
+
+ /**
+ * 是否可查看全部数据
+ */
+ private Boolean all;
+ /**
+ * 是否可查看自己的数据
+ */
+ private Boolean self;
+ /**
+ * 可查看的部门编号数组
+ */
+ private Set deptIds;
+
+ public DeptDataPermissionRespDTO() {
+ this.all = false;
+ this.self = false;
+ this.deptIds = new HashSet<>();
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRule.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRule.java
new file mode 100644
index 000000000..2bccde85f
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRule.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.framework.datapermission.core.rule;
+
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+
+import java.util.Set;
+
+/**
+ * 数据权限规则接口
+ * 通过实现接口,自定义数据规则。例如说,
+ *
+ * @author 芋道源码
+ */
+public interface DataPermissionRule {
+
+ /**
+ * 返回需要生效的表名数组
+ * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据
+ *
+ * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得
+ *
+ * @return 表名数组
+ */
+ Set getTableNames();
+
+ /**
+ * 根据表名和别名,生成对应的 WHERE / OR 过滤条件
+ *
+ * @param tableName 表名
+ * @param tableAlias 别名,可能为空
+ * @return 过滤条件 Expression 表达式
+ */
+ Expression getExpression(String tableName, Alias tableAlias);
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactory.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactory.java
new file mode 100644
index 000000000..166dfea6c
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactory.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.framework.datapermission.core.rule;
+
+import java.util.List;
+
+/**
+ * {@link DataPermissionRule} 工厂接口
+ * 作为 {@link DataPermissionRule} 的容器,提供管理能力
+ *
+ * @author 芋道源码
+ */
+public interface DataPermissionRuleFactory {
+
+ /**
+ * 获得所有数据权限规则数组
+ *
+ * @return 数据权限规则数组
+ */
+ List getDataPermissionRules();
+
+ /**
+ * 获得指定 Mapper 的数据权限规则数组
+ *
+ * @param mappedStatementId 指定 Mapper 的编号
+ * @return 数据权限规则数组
+ */
+ List getDataPermissionRule(String mappedStatementId);
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java
new file mode 100644
index 000000000..eaa6e6aed
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java
@@ -0,0 +1,62 @@
+package cn.iocoder.yudao.framework.datapermission.core.rule;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
+import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 默认的 DataPermissionRuleFactoryImpl 实现类
+ * 支持通过 {@link DataPermissionContextHolder} 过滤数据权限
+ *
+ * @author 芋道源码
+ */
+@RequiredArgsConstructor
+public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory {
+
+ /**
+ * 数据权限规则数组
+ */
+ private final List rules;
+
+ @Override
+ public List getDataPermissionRules() {
+ return rules;
+ }
+
+ @Override // mappedStatementId 参数,暂时没有用。以后,可以基于 mappedStatementId + DataPermission 进行缓存
+ public List getDataPermissionRule(String mappedStatementId) {
+ // 1. 无数据权限
+ if (CollUtil.isEmpty(rules)) {
+ return Collections.emptyList();
+ }
+ // 2. 未配置,则默认开启
+ DataPermission dataPermission = DataPermissionContextHolder.get();
+ if (dataPermission == null) {
+ return rules;
+ }
+ // 3. 已配置,但禁用
+ if (!dataPermission.enable()) {
+ return Collections.emptyList();
+ }
+
+ // 4. 已配置,只选择部分规则
+ if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) {
+ return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass()))
+ .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
+ }
+ // 5. 已配置,只排除部分规则
+ if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) {
+ return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass()))
+ .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
+ }
+ // 6. 已配置,全部规则
+ return rules;
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/package-info.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/package-info.java
new file mode 100644
index 000000000..831aa7c62
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * 基于 JSqlParser 解析 SQL,增加数据权限的 WHERE 条件
+ */
+package cn.iocoder.yudao.framework.datapermission;
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/resources/META-INF/spring.factories b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/resources/META-INF/spring.factories
new file mode 100644
index 000000000..1a4c029c9
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,3 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+ cn.iocoder.yudao.framework.datapermission.config.YudaoDataPermissionAutoConfiguration,\
+ cn.iocoder.yudao.framework.datapermission.config.YudaoDeptDataPermissionAutoConfiguration
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java
new file mode 100644
index 000000000..ba97ede2f
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java
@@ -0,0 +1,108 @@
+package cn.iocoder.yudao.framework.datapermission.core.aop;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import org.aopalliance.intercept.MethodInvocation;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link DataPermissionAnnotationInterceptor} 的单元测试
+ *
+ * @author 芋道源码
+ */
+public class DataPermissionAnnotationInterceptorTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private DataPermissionAnnotationInterceptor interceptor;
+
+ @Mock
+ private MethodInvocation methodInvocation;
+
+ @BeforeEach
+ public void setUp() {
+ interceptor.getDataPermissionCache().clear();
+ }
+
+ @Test // 无 @DataPermission 注解
+ public void testInvoke_none() throws Throwable {
+ // 参数
+ mockMethodInvocation(TestNone.class);
+
+ // 调用
+ Object result = interceptor.invoke(methodInvocation);
+ // 断言
+ assertEquals("none", result);
+ assertEquals(1, interceptor.getDataPermissionCache().size());
+ assertTrue(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable());
+ }
+
+ @Test // 在 Method 上有 @DataPermission 注解
+ public void testInvoke_method() throws Throwable {
+ // 参数
+ mockMethodInvocation(TestMethod.class);
+
+ // 调用
+ Object result = interceptor.invoke(methodInvocation);
+ // 断言
+ assertEquals("method", result);
+ assertEquals(1, interceptor.getDataPermissionCache().size());
+ assertFalse(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable());
+ }
+
+ @Test // 在 Class 上有 @DataPermission 注解
+ public void testInvoke_class() throws Throwable {
+ // 参数
+ mockMethodInvocation(TestClass.class);
+
+ // 调用
+ Object result = interceptor.invoke(methodInvocation);
+ // 断言
+ assertEquals("class", result);
+ assertEquals(1, interceptor.getDataPermissionCache().size());
+ assertFalse(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable());
+ }
+
+ private void mockMethodInvocation(Class> clazz) throws Throwable {
+ Object targetObject = clazz.newInstance();
+ Method method = targetObject.getClass().getMethod("echo");
+ when(methodInvocation.getThis()).thenReturn(targetObject);
+ when(methodInvocation.getMethod()).thenReturn(method);
+ when(methodInvocation.proceed()).then(invocationOnMock -> method.invoke(targetObject));
+ }
+
+ static class TestMethod {
+
+ @DataPermission(enable = false)
+ public String echo() {
+ return "method";
+ }
+
+ }
+
+ @DataPermission(enable = false)
+ static class TestClass {
+
+ public String echo() {
+ return "class";
+ }
+
+ }
+
+ static class TestNone {
+
+ public String echo() {
+ return "none";
+ }
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionContextHolderTest.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionContextHolderTest.java
new file mode 100644
index 000000000..688b92d9f
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/aop/DataPermissionContextHolderTest.java
@@ -0,0 +1,66 @@
+package cn.iocoder.yudao.framework.datapermission.core.aop;
+
+import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+
+/**
+ * {@link DataPermissionContextHolder} 的单元测试
+ *
+ * @author 芋道源码
+ */
+class DataPermissionContextHolderTest {
+
+ @BeforeEach
+ public void setUp() {
+ DataPermissionContextHolder.clear();
+ }
+
+ @Test
+ public void testGet() {
+ // mock 方法
+ DataPermission dataPermission01 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission01);
+ DataPermission dataPermission02 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission02);
+
+ // 调用
+ DataPermission result = DataPermissionContextHolder.get();
+ // 断言
+ assertSame(result, dataPermission02);
+ }
+
+ @Test
+ public void testPush() {
+ // 调用
+ DataPermission dataPermission01 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission01);
+ DataPermission dataPermission02 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission02);
+ // 断言
+ DataPermission first = DataPermissionContextHolder.getAll().get(0);
+ DataPermission second = DataPermissionContextHolder.getAll().get(1);
+ assertSame(dataPermission01, first);
+ assertSame(dataPermission02, second);
+ }
+
+ @Test
+ public void testRemove() {
+ // mock 方法
+ DataPermission dataPermission01 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission01);
+ DataPermission dataPermission02 = mock(DataPermission.class);
+ DataPermissionContextHolder.add(dataPermission02);
+
+ // 调用
+ DataPermission result = DataPermissionContextHolder.remove();
+ // 断言
+ assertSame(result, dataPermission02);
+ assertEquals(1, DataPermissionContextHolder.getAll().size());
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java
new file mode 100644
index 000000000..36bf5c076
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java
@@ -0,0 +1,190 @@
+package cn.iocoder.yudao.framework.datapermission.core.db;
+
+import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
+import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;
+import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRuleFactory;
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.LongValue;
+import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
+import net.sf.jsqlparser.schema.Column;
+import org.apache.ibatis.executor.Executor;
+import org.apache.ibatis.executor.statement.StatementHandler;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+
+import java.sql.Connection;
+import java.util.*;
+
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link DataPermissionDatabaseInterceptor} 的单元测试
+ * 主要测试 {@link DataPermissionDatabaseInterceptor#beforePrepare(StatementHandler, Connection, Integer)}
+ * 和 {@link DataPermissionDatabaseInterceptor#beforeUpdate(Executor, MappedStatement, Object)}
+ * 以及在这个过程中,ContextHolder 和 MappedStatementCache
+ *
+ * @author 芋道源码
+ */
+public class DataPermissionDatabaseInterceptorTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private DataPermissionDatabaseInterceptor interceptor;
+
+ @Mock
+ private DataPermissionRuleFactory ruleFactory;
+
+ @BeforeEach
+ public void setUp() {
+ // 清理上下文
+ DataPermissionDatabaseInterceptor.ContextHolder.clear();
+ // 清空缓存
+ interceptor.getMappedStatementCache().clear();
+ }
+
+ @Test // 不存在规则,且不匹配
+ public void testBeforeQuery_withoutRule() {
+ try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) {
+ // 准备参数
+ MappedStatement mappedStatement = mock(MappedStatement.class);
+ BoundSql boundSql = mock(BoundSql.class);
+
+ // 调用
+ interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql);
+ // 断言
+ pluginUtilsMock.verify(never(), () -> PluginUtils.mpBoundSql(boundSql));
+ }
+ }
+
+ @Test // 存在规则,且不匹配
+ public void testBeforeQuery_withMatchRule() {
+ try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) {
+ // 准备参数
+ MappedStatement mappedStatement = mock(MappedStatement.class);
+ BoundSql boundSql = mock(BoundSql.class);
+ // mock 方法(数据权限)
+ when(ruleFactory.getDataPermissionRule(same(mappedStatement.getId())))
+ .thenReturn(singletonList(new DeptDataPermissionRule()));
+ // mock 方法(MPBoundSql)
+ PluginUtils.MPBoundSql mpBs = mock(PluginUtils.MPBoundSql.class);
+ pluginUtilsMock.when(() -> PluginUtils.mpBoundSql(same(boundSql))).thenReturn(mpBs);
+ // mock 方法(SQL)
+ String sql = "select * from t_user where id = 1";
+ when(mpBs.sql()).thenReturn(sql);
+ // 针对 ContextHolder 和 MappedStatementCache 暂时不 mock,主要想校验过程中,数据是否正确
+
+ // 调用
+ interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql);
+ // 断言
+ verify(mpBs, times(1)).sql(
+ eq("SELECT * FROM t_user WHERE id = 1 AND dept_id = 100"));
+ // 断言缓存
+ assertTrue(interceptor.getMappedStatementCache().getNoRewritableMappedStatements().isEmpty());
+ }
+ }
+
+ @Test // 存在规则,但不匹配
+ public void testBeforeQuery_withoutMatchRule() {
+ try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) {
+ // 准备参数
+ MappedStatement mappedStatement = mock(MappedStatement.class);
+ BoundSql boundSql = mock(BoundSql.class);
+ // mock 方法(数据权限)
+ when(ruleFactory.getDataPermissionRule(same(mappedStatement.getId())))
+ .thenReturn(singletonList(new DeptDataPermissionRule()));
+ // mock 方法(MPBoundSql)
+ PluginUtils.MPBoundSql mpBs = mock(PluginUtils.MPBoundSql.class);
+ pluginUtilsMock.when(() -> PluginUtils.mpBoundSql(same(boundSql))).thenReturn(mpBs);
+ // mock 方法(SQL)
+ String sql = "select * from t_role where id = 1";
+ when(mpBs.sql()).thenReturn(sql);
+ // 针对 ContextHolder 和 MappedStatementCache 暂时不 mock,主要想校验过程中,数据是否正确
+
+ // 调用
+ interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql);
+ // 断言
+ verify(mpBs, times(1)).sql(
+ eq("SELECT * FROM t_role WHERE id = 1"));
+ // 断言缓存
+ assertFalse(interceptor.getMappedStatementCache().getNoRewritableMappedStatements().isEmpty());
+ }
+ }
+
+ @Test
+ public void testAddNoRewritable() {
+ // 准备参数
+ MappedStatement ms = mock(MappedStatement.class);
+ List rules = singletonList(new DeptDataPermissionRule());
+ // mock 方法
+ when(ms.getId()).thenReturn("selectById");
+
+ // 调用
+ interceptor.getMappedStatementCache().addNoRewritable(ms, rules);
+ // 断言
+ Map, Set> noRewritableMappedStatements =
+ interceptor.getMappedStatementCache().getNoRewritableMappedStatements();
+ assertEquals(1, noRewritableMappedStatements.size());
+ assertEquals(SetUtils.asSet("selectById"), noRewritableMappedStatements.get(DeptDataPermissionRule.class));
+ }
+
+ @Test
+ public void testNoRewritable() {
+ // 准备参数
+ MappedStatement ms = mock(MappedStatement.class);
+ // mock 方法
+ when(ms.getId()).thenReturn("selectById");
+ // mock 数据
+ List rules = singletonList(new DeptDataPermissionRule());
+ interceptor.getMappedStatementCache().addNoRewritable(ms, rules);
+
+ // 场景一,rules 为空
+ assertTrue(interceptor.getMappedStatementCache().noRewritable(ms, null));
+ // 场景二,rules 非空,可重写
+ assertFalse(interceptor.getMappedStatementCache().noRewritable(ms, singletonList(new EmptyDataPermissionRule())));
+ // 场景三,rule 非空,不可重写
+ assertTrue(interceptor.getMappedStatementCache().noRewritable(ms, rules));
+ }
+
+ private static class DeptDataPermissionRule implements DataPermissionRule {
+
+ private static final String COLUMN = "dept_id";
+
+ @Override
+ public Set getTableNames() {
+ return SetUtils.asSet("t_user");
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN);
+ LongValue value = new LongValue(100L);
+ return new EqualsTo(column, value);
+ }
+
+ }
+
+ private static class EmptyDataPermissionRule implements DataPermissionRule {
+
+ @Override
+ public Set getTableNames() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ return null;
+ }
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java
new file mode 100644
index 000000000..8c0772f1a
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java
@@ -0,0 +1,370 @@
+package cn.iocoder.yudao.framework.datapermission.core.db;
+
+import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;
+import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRuleFactory;
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.LongValue;
+import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
+import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
+import net.sf.jsqlparser.expression.operators.relational.InExpression;
+import net.sf.jsqlparser.schema.Column;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * {@link DataPermissionDatabaseInterceptor} 的单元测试
+ * 主要复用了 MyBatis Plus 的 TenantLineInnerInterceptorTest 的单元测试
+ * 不过它的单元测试不是很规范,考虑到是复用的,所以暂时不进行修改~
+ *
+ * @author 芋道源码
+ */
+public class DataPermissionDatabaseInterceptorTest2 extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private DataPermissionDatabaseInterceptor interceptor;
+
+ @Mock
+ private DataPermissionRuleFactory ruleFactory;
+
+ @BeforeEach
+ public void setUp() {
+ // 租户的数据权限规则
+ DataPermissionRule tenantRule = new DataPermissionRule() {
+
+ private static final String COLUMN = "tenant_id";
+
+ @Override
+ public Set getTableNames() {
+ return asSet("entity", "entity1", "entity2", "t1", "t2", // 支持 MyBatis Plus 的单元测试
+ "t_user", "t_role"); // 满足自己的单元测试
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN);
+ LongValue value = new LongValue(1L);
+ return new EqualsTo(column, value);
+ }
+
+ };
+ // 部门的数据权限规则
+ DataPermissionRule deptRule = new DataPermissionRule() {
+
+ private static final String COLUMN = "dept_id";
+
+ @Override
+ public Set getTableNames() {
+ return asSet("t_user"); // 满足自己的单元测试
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN);
+ ExpressionList values = new ExpressionList(new LongValue(10L),
+ new LongValue(20L));
+ return new InExpression(column, values);
+ }
+
+ };
+ // 设置到上下文,保证
+ DataPermissionDatabaseInterceptor.ContextHolder.init(Arrays.asList(tenantRule, deptRule));
+ }
+
+ @Test
+ void delete() {
+ assertSql("delete from entity where id = ?",
+ "DELETE FROM entity WHERE id = ? AND tenant_id = 1");
+ }
+
+ @Test
+ void update() {
+ assertSql("update entity set name = ? where id = ?",
+ "UPDATE entity SET name = ? WHERE id = ? AND tenant_id = 1");
+ }
+
+ @Test
+ void selectSingle() {
+ // 单表
+ assertSql("select * from entity where id = ?",
+ "SELECT * FROM entity WHERE id = ? AND tenant_id = 1");
+
+ assertSql("select * from entity where id = ? or name = ?",
+ "SELECT * FROM entity WHERE (id = ? OR name = ?) AND tenant_id = 1");
+
+ assertSql("SELECT * FROM entity WHERE (id = ? OR name = ?)",
+ "SELECT * FROM entity WHERE (id = ? OR name = ?) AND tenant_id = 1");
+
+ /* not */
+ assertSql("SELECT * FROM entity WHERE not (id = ? OR name = ?)",
+ "SELECT * FROM entity WHERE NOT (id = ? OR name = ?) AND tenant_id = 1");
+ }
+
+ @Test
+ void selectSubSelectIn() {
+ /* in */
+ assertSql("SELECT * FROM entity e WHERE e.id IN (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id IN (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+ // 在最前
+ assertSql("SELECT * FROM entity e WHERE e.id IN " +
+ "(select e1.id from entity1 e1 where e1.id = ?) and e.id = ?",
+ "SELECT * FROM entity e WHERE e.id IN " +
+ "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ? AND e.tenant_id = 1");
+ // 在最后
+ assertSql("SELECT * FROM entity e WHERE e.id = ? and e.id IN " +
+ "(select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id = ? AND e.id IN " +
+ "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+ // 在中间
+ assertSql("SELECT * FROM entity e WHERE e.id = ? and e.id IN " +
+ "(select e1.id from entity1 e1 where e1.id = ?) and e.id = ?",
+ "SELECT * FROM entity e WHERE e.id = ? AND e.id IN " +
+ "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ? AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectSubSelectEq() {
+ /* = */
+ assertSql("SELECT * FROM entity e WHERE e.id = (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectSubSelectInnerNotEq() {
+ /* inner not = */
+ assertSql("SELECT * FROM entity e WHERE not (e.id = (select e1.id from entity1 e1 where e1.id = ?))",
+ "SELECT * FROM entity e WHERE NOT (e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1)) AND e.tenant_id = 1");
+
+ assertSql("SELECT * FROM entity e WHERE not (e.id = (select e1.id from entity1 e1 where e1.id = ?) and e.id = ?)",
+ "SELECT * FROM entity e WHERE NOT (e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ?) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectSubSelectExists() {
+ /* EXISTS */
+ assertSql("SELECT * FROM entity e WHERE EXISTS (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE EXISTS (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+
+
+ /* NOT EXISTS */
+ assertSql("SELECT * FROM entity e WHERE NOT EXISTS (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE NOT EXISTS (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectSubSelect() {
+ /* >= */
+ assertSql("SELECT * FROM entity e WHERE e.id >= (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id >= (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+
+ /* <= */
+ assertSql("SELECT * FROM entity e WHERE e.id <= (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id <= (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+
+ /* <> */
+ assertSql("SELECT * FROM entity e WHERE e.id <> (select e1.id from entity1 e1 where e1.id = ?)",
+ "SELECT * FROM entity e WHERE e.id <> (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectFromSelect() {
+ assertSql("SELECT * FROM (select e.id from entity e WHERE e.id = (select e1.id from entity1 e1 where e1.id = ?))",
+ "SELECT * FROM (SELECT e.id FROM entity e WHERE e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1)");
+ }
+
+ @Test
+ void selectBodySubSelect() {
+ assertSql("select t1.col1,(select t2.col2 from t2 t2 where t1.col1=t2.col1) from t1 t1",
+ "SELECT t1.col1, (SELECT t2.col2 FROM t2 t2 WHERE t1.col1 = t2.col1 AND t2.tenant_id = 1) FROM t1 t1 WHERE t1.tenant_id = 1");
+ }
+
+ @Test
+ void selectLeftJoin() {
+ // left join
+ assertSql("SELECT * FROM entity e " +
+ "left join entity1 e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+
+ assertSql("SELECT * FROM entity e " +
+ "left join entity1 e1 on e1.id = e.id " +
+ "WHERE (e.id = ? OR e.name = ?)",
+ "SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectRightJoin() {
+ // right join
+ assertSql("SELECT * FROM entity e " +
+ "right join entity1 e1 on e1.id = e.id",
+ "SELECT * FROM entity e " +
+ "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE e.tenant_id = 1");
+
+ assertSql("SELECT * FROM entity e " +
+ "right join entity1 e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM entity e " +
+ "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectLeftJoinMultipleTrailingOn() {
+ // 多个 on 尾缀的
+ assertSql("SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 " +
+ "LEFT JOIN entity2 e2 ON e2.id = e1.id " +
+ "ON e1.id = e.id " +
+ "WHERE (e.id = ? OR e.NAME = ?)",
+ "SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 " +
+ "LEFT JOIN entity2 e2 ON e2.id = e1.id AND e2.tenant_id = 1 " +
+ "ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.NAME = ?) AND e.tenant_id = 1");
+
+ assertSql("SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 " +
+ "LEFT JOIN with_as_A e2 ON e2.id = e1.id " +
+ "ON e1.id = e.id " +
+ "WHERE (e.id = ? OR e.NAME = ?)",
+ "SELECT * FROM entity e " +
+ "LEFT JOIN entity1 e1 " +
+ "LEFT JOIN with_as_A e2 ON e2.id = e1.id " +
+ "ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.NAME = ?) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectInnerJoin() {
+ // inner join
+ assertSql("SELECT * FROM entity e " +
+ "inner join entity1 e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM entity e " +
+ "INNER JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+
+ assertSql("SELECT * FROM entity e " +
+ "inner join entity1 e1 on e1.id = e.id " +
+ "WHERE (e.id = ? OR e.name = ?)",
+ "SELECT * FROM entity e " +
+ "INNER JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+
+ // 垃圾 inner join todo
+// assertSql("SELECT * FROM entity,entity1 " +
+// "WHERE entity.id = entity1.id",
+// "SELECT * FROM entity e " +
+// "INNER JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+// "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+ }
+
+ @Test
+ void selectWithAs() {
+ assertSql("with with_as_A as (select * from entity) select * from with_as_A",
+ "WITH with_as_A AS (SELECT * FROM entity WHERE tenant_id = 1) SELECT * FROM with_as_A");
+ }
+
+ private void assertSql(String sql, String targetSql) {
+ assertEquals(targetSql, interceptor.parserSingle(sql, null));
+ }
+
+ // ========== 额外的测试 ==========
+
+ @Test
+ public void testSelectSingle() {
+ // 单表
+ assertSql("select * from t_user where id = ?",
+ "SELECT * FROM t_user WHERE id = ? AND tenant_id = 1 AND dept_id IN (10, 20)");
+
+ assertSql("select * from t_user where id = ? or name = ?",
+ "SELECT * FROM t_user WHERE (id = ? OR name = ?) AND tenant_id = 1 AND dept_id IN (10, 20)");
+
+ assertSql("SELECT * FROM t_user WHERE (id = ? OR name = ?)",
+ "SELECT * FROM t_user WHERE (id = ? OR name = ?) AND tenant_id = 1 AND dept_id IN (10, 20)");
+
+ /* not */
+ assertSql("SELECT * FROM t_user WHERE not (id = ? OR name = ?)",
+ "SELECT * FROM t_user WHERE NOT (id = ? OR name = ?) AND tenant_id = 1 AND dept_id IN (10, 20)");
+ }
+
+ @Test
+ public void testSelectLeftJoin() {
+ // left join
+ assertSql("SELECT * FROM t_user e " +
+ "left join t_role e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM t_user e " +
+ "LEFT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+
+ // 条件 e.id = ? OR e.name = ? 带括号
+ assertSql("SELECT * FROM t_user e " +
+ "left join t_role e1 on e1.id = e.id " +
+ "WHERE (e.id = ? OR e.name = ?)",
+ "SELECT * FROM t_user e " +
+ "LEFT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+ }
+
+ @Test
+ public void testSelectRightJoin() {
+ // right join
+ assertSql("SELECT * FROM t_user e " +
+ "right join t_role e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM t_user e " +
+ "RIGHT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+
+ // 条件 e.id = ? OR e.name = ? 带括号
+ assertSql("SELECT * FROM t_user e " +
+ "right join t_role e1 on e1.id = e.id " +
+ "WHERE (e.id = ? OR e.name = ?)",
+ "SELECT * FROM t_user e " +
+ "RIGHT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+ }
+
+ @Test
+ public void testSelectInnerJoin() {
+ // inner join
+ assertSql("SELECT * FROM t_user e " +
+ "inner join entity1 e1 on e1.id = e.id " +
+ "WHERE e.id = ? OR e.name = ?",
+ "SELECT * FROM t_user e " +
+ "INNER JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+
+ // 条件 e.id = ? OR e.name = ? 带括号
+ assertSql("SELECT * FROM t_user e " +
+ "inner join t_role e1 on e1.id = e.id " +
+ "WHERE (e.id = ? OR e.name = ?)",
+ "SELECT * FROM t_user e " +
+ "INNER JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+ "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)");
+
+ // 垃圾 inner join todo
+// assertSql("SELECT * FROM entity,entity1 " +
+// "WHERE entity.id = entity1.id",
+// "SELECT * FROM entity e " +
+// "INNER JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " +
+// "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1");
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleTest.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleTest.java
new file mode 100644
index 000000000..2953e58ff
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/dept/rule/DeptDataPermissionRuleTest.java
@@ -0,0 +1,221 @@
+package cn.iocoder.yudao.framework.datapermission.core.dept.rule;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ReflectUtil;
+import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
+import cn.iocoder.yudao.framework.datapermission.core.dept.service.DeptDataPermissionFrameworkService;
+import cn.iocoder.yudao.framework.datapermission.core.dept.service.dto.DeptDataPermissionRespDTO;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link DeptDataPermissionRule} 的单元测试
+ *
+ * @author 芋道源码
+ */
+class DeptDataPermissionRuleTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private DeptDataPermissionRule rule;
+
+ @Mock
+ private DeptDataPermissionFrameworkService deptDataPermissionFrameworkService;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ public void setUp() {
+ // 清空 rule
+ rule.getTableNames().clear();
+ ((Map) ReflectUtil.getFieldValue(rule, "deptColumns")).clear();
+ ((Map) ReflectUtil.getFieldValue(rule, "deptColumns")).clear();
+ }
+
+ @Test // 无 LoginUser
+ public void testGetExpression_noLoginUser() {
+ // 准备参数
+ String tableName = randomString();
+ Alias tableAlias = new Alias(randomString());
+ // mock 方法
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertNull(expression);
+ }
+
+ @Test // 无数据权限时
+ public void testGetExpression_noDeptDataPermission() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+
+ // 调用
+ NullPointerException exception = assertThrows(NullPointerException.class,
+ () -> rule.getExpression(tableName, tableAlias));
+ // 断言
+ assertEquals("LoginUser(1) Table(t_user/u) 未返回数据权限", exception.getMessage());
+ }
+ }
+
+ @Test // 全部数据权限
+ public void testGetExpression_allDeptDataPermission() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO().setAll(true);
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertNull(expression);
+ }
+ }
+
+ @Test // 即不能查看部门,又不能查看自己,则说明 100% 无权限
+ public void testGetExpression_noDept_noSelf() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertEquals("null = null", expression.toString());
+ }
+ }
+
+ @Test // 拼接 Dept 和 User 的条件(字段都不符合)
+ public void testGetExpression_noDeptColumn_noSelfColumn() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
+ .setDeptIds(SetUtils.asSet(10L, 20L)).setSelf(true);
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+
+ // 调用
+ NullPointerException exception = assertThrows(NullPointerException.class,
+ () -> rule.getExpression(tableName, tableAlias));
+ // 断言
+ assertEquals("LoginUser(1) Table(t_user/u) 构建的条件为空", exception.getMessage());
+ }
+ }
+
+ @Test // 拼接 Dept 和 User 的条件(self 符合)
+ public void testGetExpression_noDeptColumn_yesSelfColumn() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
+ .setSelf(true);
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+ // 添加 user 字段配置
+ rule.addUserColumn("t_user", "id");
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertEquals("u.id = 1", expression.toString());
+ }
+ }
+
+ @Test // 拼接 Dept 和 User 的条件(dept 符合)
+ public void testGetExpression_yesDeptColumn_noSelfColumn() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
+ .setDeptIds(CollUtil.newLinkedHashSet(10L, 20L));
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+ // 添加 dept 字段配置
+ rule.addDeptColumn("t_user", "dept_id");
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertEquals("u.dept_id IN (10, 20)", expression.toString());
+ }
+ }
+
+ @Test // 拼接 Dept 和 User 的条件(dept + self 符合)
+ public void testGetExpression_yesDeptColumn_yesSelfColumn() {
+ try (MockedStatic securityFrameworkUtilsMock
+ = mockStatic(SecurityFrameworkUtils.class)) {
+ // 准备参数
+ String tableName = "t_user";
+ Alias tableAlias = new Alias("u");
+ // mock 方法(LoginUser)
+ LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L));
+ securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
+ // mock 方法(DeptDataPermissionRespDTO)
+ DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
+ .setDeptIds(CollUtil.newLinkedHashSet(10L, 20L)).setSelf(true);
+ when(deptDataPermissionFrameworkService.getDeptDataPermission(same(loginUser))).thenReturn(deptDataPermission);
+ // 添加 user 字段配置
+ rule.addUserColumn("t_user", "id");
+ // 添加 dept 字段配置
+ rule.addDeptColumn("t_user", "dept_id");
+
+ // 调用
+ Expression expression = rule.getExpression(tableName, tableAlias);
+ // 断言
+ assertEquals("u.dept_id IN (10, 20) OR u.id = 1", expression.toString());
+ }
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java
new file mode 100644
index 000000000..17dddc929
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java
@@ -0,0 +1,145 @@
+package cn.iocoder.yudao.framework.datapermission.core.rule;
+
+import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
+import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder;
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.expression.Expression;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Spy;
+import org.springframework.core.annotation.AnnotationUtils;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link DataPermissionRuleFactoryImpl} 单元测试
+ *
+ * @author 芋道源码
+ */
+class DataPermissionRuleFactoryImplTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private DataPermissionRuleFactoryImpl dataPermissionRuleFactory;
+
+ @Spy
+ private List rules = Arrays.asList(new DataPermissionRule01(),
+ new DataPermissionRule02());
+
+ @BeforeEach
+ public void setUp() {
+ DataPermissionContextHolder.clear();
+ }
+
+ @Test
+ public void testGetDataPermissionRule_02() {
+ // 准备参数
+ String mappedStatementId = randomString();
+
+ // 调用
+ List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId);
+ // 断言
+ assertSame(rules, result);
+ }
+
+ @Test
+ public void testGetDataPermissionRule_03() {
+ // 准备参数
+ String mappedStatementId = randomString();
+ // mock 方法
+ DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass03.class, DataPermission.class));
+
+ // 调用
+ List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId);
+ // 断言
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetDataPermissionRule_04() {
+ // 准备参数
+ String mappedStatementId = randomString();
+ // mock 方法
+ DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass04.class, DataPermission.class));
+
+ // 调用
+ List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId);
+ // 断言
+ assertEquals(1, result.size());
+ assertEquals(DataPermissionRule01.class, result.get(0).getClass());
+ }
+
+ @Test
+ public void testGetDataPermissionRule_05() {
+ // 准备参数
+ String mappedStatementId = randomString();
+ // mock 方法
+ DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass05.class, DataPermission.class));
+
+ // 调用
+ List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId);
+ // 断言
+ assertEquals(1, result.size());
+ assertEquals(DataPermissionRule02.class, result.get(0).getClass());
+ }
+
+ @Test
+ public void testGetDataPermissionRule_06() {
+ // 准备参数
+ String mappedStatementId = randomString();
+ // mock 方法
+ DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass06.class, DataPermission.class));
+
+ // 调用
+ List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId);
+ // 断言
+ assertSame(rules, result);
+ }
+
+ @DataPermission(enable = false)
+ static class TestClass03 {}
+
+ @DataPermission(includeRules = DataPermissionRule01.class)
+ static class TestClass04 {}
+
+ @DataPermission(excludeRules = DataPermissionRule01.class)
+ static class TestClass05 {}
+
+ @DataPermission
+ static class TestClass06 {}
+
+ static class DataPermissionRule01 implements DataPermissionRule {
+
+ @Override
+ public Set getTableNames() {
+ return null;
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ return null;
+ }
+
+ }
+
+ static class DataPermissionRule02 implements DataPermissionRule {
+
+ @Override
+ public Set getTableNames() {
+ return null;
+ }
+
+ @Override
+ public Expression getExpression(String tableName, Alias tableAlias) {
+ return null;
+ }
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-job/pom.xml b/yudao-framework/yudao-spring-boot-starter-job/pom.xml
index f54794731..4a1cd04a6 100644
--- a/yudao-framework/yudao-spring-boot-starter-job/pom.xml
+++ b/yudao-framework/yudao-spring-boot-starter-job/pom.xml
@@ -12,7 +12,10 @@
jar
${artifactId}
- 定时任务,基于 Quartz 拓展
+ 任务拓展
+ 1. 定时任务,基于 Quartz 拓展
+ 2. 异步任务,基于 Spring Async 拓展
+
https://github.com/YunaiV/ruoyi-vue-pro
diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java
new file mode 100644
index 000000000..05b82b552
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.framework.quartz.config;
+
+import com.alibaba.ttl.TtlRunnable;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * 异步任务 Configuration
+ */
+@Configuration
+@EnableAsync
+public class YudaoAsyncAutoConfiguration {
+
+ @Bean
+ public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() {
+ return new BeanPostProcessor() {
+
+ @Override
+ public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
+ if (!(bean instanceof ThreadPoolTaskExecutor)) {
+ return bean;
+ }
+ // 修改提交的任务,接入 TransmittableThreadLocal
+ ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean;
+ executor.setTaskDecorator(TtlRunnable::get);
+ return executor;
+ }
+
+ };
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoQuartzAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoQuartzAutoConfiguration.java
index 86c47fd0b..144e4773d 100644
--- a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoQuartzAutoConfiguration.java
+++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoQuartzAutoConfiguration.java
@@ -6,6 +6,9 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
+/**
+ * 定时任务 Configuration
+ */
@Configuration
@EnableScheduling // 开启 Spring 自带的定时任务
public class YudaoQuartzAutoConfiguration {
diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/package-info.java b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/package-info.java
index 69d681245..cfd237b21 100644
--- a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/package-info.java
+++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/package-info.java
@@ -1,5 +1,7 @@
/**
- * 定时任务,采用 Quartz 实现进程内的任务执行。
+ * 1. 定时任务,采用 Quartz 实现进程内的任务执行。
* 考虑到高可用,使用 Quartz 自带的 MySQL 集群方案。
+ *
+ * 2. 异步任务,采用 Spring Async 异步执行。
*/
package cn.iocoder.yudao.framework.quartz;
diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/resources/META-INF/spring.factories b/yudao-framework/yudao-spring-boot-starter-job/src/main/resources/META-INF/spring.factories
index 553b6e309..cecc4094c 100644
--- a/yudao-framework/yudao-spring-boot-starter-job/src/main/resources/META-INF/spring.factories
+++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/resources/META-INF/spring.factories
@@ -1,2 +1,3 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
- cn.iocoder.yudao.framework.quartz.config.YudaoQuartzAutoConfiguration
+ cn.iocoder.yudao.framework.quartz.config.YudaoQuartzAutoConfiguration,\
+ cn.iocoder.yudao.framework.quartz.config.YudaoAsyncAutoConfiguration
diff --git a/yudao-admin-server/src/main/java/cn/iocoder/yudao/adminserver/framework/async/《芋道 Spring Boot 异步任务入门》.md b/yudao-framework/yudao-spring-boot-starter-job/《芋道 Spring Boot 异步任务入门》.md
similarity index 100%
rename from yudao-admin-server/src/main/java/cn/iocoder/yudao/adminserver/framework/async/《芋道 Spring Boot 异步任务入门》.md
rename to yudao-framework/yudao-spring-boot-starter-job/《芋道 Spring Boot 异步任务入门》.md
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/config/YudaoMQAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/config/YudaoMQAutoConfiguration.java
index 8b3366dd7..3def221f3 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/config/YudaoMQAutoConfiguration.java
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/config/YudaoMQAutoConfiguration.java
@@ -1,20 +1,21 @@
package cn.iocoder.yudao.framework.mq.config;
import cn.hutool.system.SystemUtil;
+import cn.iocoder.yudao.framework.mq.core.RedisMQTemplate;
+import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessageListener;
import cn.iocoder.yudao.framework.mq.core.stream.AbstractStreamMessageListener;
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
-import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.Consumer;
import org.springframework.data.redis.connection.stream.ObjectRecord;
import org.springframework.data.redis.connection.stream.ReadOffset;
import org.springframework.data.redis.connection.stream.StreamOffset;
import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;
@@ -31,18 +32,30 @@ import java.util.List;
@Slf4j
public class YudaoMQAutoConfiguration {
+ @Bean
+ public RedisMQTemplate redisMQTemplate(StringRedisTemplate redisTemplate,
+ List interceptors) {
+ RedisMQTemplate redisMQTemplate = new RedisMQTemplate(redisTemplate);
+ // 添加拦截器
+ interceptors.forEach(redisMQTemplate::addInterceptor);
+ return redisMQTemplate;
+ }
+
+ // ========== 消费者相关 ==========
+
/**
* 创建 Redis Pub/Sub 广播消费的容器
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
- RedisConnectionFactory factory, List> listeners) {
+ RedisMQTemplate redisMQTemplate, List> listeners) {
// 创建 RedisMessageListenerContainer 对象
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
// 设置 RedisConnection 工厂。
- container.setConnectionFactory(factory);
+ container.setConnectionFactory(redisMQTemplate.getRedisTemplate().getRequiredConnectionFactory());
// 添加监听器
listeners.forEach(listener -> {
+ listener.setRedisMQTemplate(redisMQTemplate);
container.addMessageListener(listener, new ChannelTopic(listener.getChannel()));
log.info("[redisMessageListenerContainer][注册 Channel({}) 对应的监听器({})]",
listener.getChannel(), listener.getClass().getName());
@@ -57,7 +70,8 @@ public class YudaoMQAutoConfiguration {
*/
@Bean(initMethod = "start", destroyMethod = "stop")
public StreamMessageListenerContainer> redisStreamMessageListenerContainer(
- RedisTemplate redisTemplate, List> listeners) {
+ RedisMQTemplate redisMQTemplate, List> listeners) {
+ RedisTemplate redisTemplate = redisMQTemplate.getRedisTemplate();
// 第一步,创建 StreamMessageListenerContainer 容器
// 创建 options 配置
StreamMessageListenerContainer.StreamMessageListenerContainerOptions> containerOptions =
@@ -66,19 +80,18 @@ public class YudaoMQAutoConfiguration {
.targetType(String.class) // 目标类型。统一使用 String,通过自己封装的 AbstractStreamMessageListener 去反序列化
.build();
// 创建 container 对象
- StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(
- redisTemplate.getRequiredConnectionFactory(), containerOptions);
+ StreamMessageListenerContainer> container =
+ StreamMessageListenerContainer.create(redisTemplate.getRequiredConnectionFactory(), containerOptions);
// 第二步,注册监听器,消费对应的 Stream 主题
String consumerName = buildConsumerName();
-// String consumerName = "110";
listeners.forEach(listener -> {
// 创建 listener 对应的消费者分组
try {
redisTemplate.opsForStream().createGroup(listener.getStreamKey(), listener.getGroup());
} catch (Exception ignore) {}
// 设置 listener 对应的 redisTemplate
- listener.setRedisTemplate(redisTemplate);
+ listener.setRedisMQTemplate(redisMQTemplate);
// 创建 Consumer 对象
Consumer consumer = Consumer.from(listener.getGroup(), consumerName);
// 设置 Consumer 消费进度,以最小消费进度为准
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/RedisMQTemplate.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/RedisMQTemplate.java
new file mode 100644
index 000000000..8a31feda7
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/RedisMQTemplate.java
@@ -0,0 +1,87 @@
+package cn.iocoder.yudao.framework.mq.core;
+
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
+import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessage;
+import cn.iocoder.yudao.framework.mq.core.stream.AbstractStreamMessage;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.data.redis.connection.stream.RecordId;
+import org.springframework.data.redis.connection.stream.StreamRecords;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Redis MQ 操作模板类
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+public class RedisMQTemplate {
+
+ @Getter
+ private final RedisTemplate redisTemplate;
+ /**
+ * 拦截器数组
+ */
+ @Getter
+ private final List interceptors = new ArrayList<>();
+
+ /**
+ * 发送 Redis 消息,基于 Redis pub/sub 实现
+ *
+ * @param message 消息
+ */
+ public void send(T message) {
+ try {
+ sendMessageBefore(message);
+ // 发送消息
+ redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message));
+ } finally {
+ sendMessageAfter(message);
+ }
+ }
+
+ /**
+ * 发送 Redis 消息,基于 Redis Stream 实现
+ *
+ * @param message 消息
+ * @return 消息记录的编号对象
+ */
+ public RecordId send(T message) {
+ try {
+ sendMessageBefore(message);
+ // 发送消息
+ return redisTemplate.opsForStream().add(StreamRecords.newRecord()
+ .ofObject(JsonUtils.toJsonString(message)) // 设置内容
+ .withStreamKey(message.getStreamKey())); // 设置 stream key
+ } finally {
+ sendMessageAfter(message);
+ }
+ }
+
+ /**
+ * 添加拦截器
+ *
+ * @param interceptor 拦截器
+ */
+ public void addInterceptor(RedisMessageInterceptor interceptor) {
+ interceptors.add(interceptor);
+ }
+
+ private void sendMessageBefore(AbstractRedisMessage message) {
+ // 正序
+ interceptors.forEach(interceptor -> interceptor.sendMessageBefore(message));
+ }
+
+ private void sendMessageAfter(AbstractRedisMessage message) {
+ // 倒序
+ for (int i = interceptors.size() - 1; i >= 0; i--) {
+ interceptors.get(i).sendMessageAfter(message);
+ }
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/interceptor/RedisMessageInterceptor.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/interceptor/RedisMessageInterceptor.java
new file mode 100644
index 000000000..11d8e1337
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/interceptor/RedisMessageInterceptor.java
@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.framework.mq.core.interceptor;
+
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
+
+/**
+ * {@link AbstractRedisMessage} 消息拦截器
+ * 通过拦截器,作为插件机制,实现拓展。
+ * 例如说,多租户场景下的 MQ 消息处理
+ *
+ * @author 芋道源码
+ */
+public interface RedisMessageInterceptor {
+
+ default void sendMessageBefore(AbstractRedisMessage message) {
+ }
+
+ default void sendMessageAfter(AbstractRedisMessage message) {
+ }
+
+ default void consumeMessageBefore(AbstractRedisMessage message) {
+ }
+
+ default void consumeMessageAfter(AbstractRedisMessage message) {
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/message/AbstractRedisMessage.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/message/AbstractRedisMessage.java
new file mode 100644
index 000000000..f02e89d6f
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/message/AbstractRedisMessage.java
@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.framework.mq.core.message;
+
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Redis 消息抽象基类
+ *
+ * @author 芋道源码
+ */
+@Data
+public abstract class AbstractRedisMessage {
+
+ /**
+ * 头
+ */
+ private Map headers = new HashMap<>();
+
+ public String getHeader(String key) {
+ return headers.get(key);
+ }
+
+ public void addHeader(String key, String value) {
+ headers.put(key, value);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessage.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessage.java
new file mode 100644
index 000000000..fbc2a2826
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessage.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.framework.mq.core.pubsub;
+
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+/**
+ * Redis Channel Message 抽象类
+ *
+ * @author 芋道源码
+ */
+public abstract class AbstractChannelMessage extends AbstractRedisMessage {
+
+ /**
+ * 获得 Redis Channel
+ *
+ * @return Channel
+ */
+ @JsonIgnore // 避免序列化。原因是,Redis 发布 Channel 消息的时候,已经会指定。
+ public abstract String getChannel();
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessageListener.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessageListener.java
index 9905a08ed..8585aafe6 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessageListener.java
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/AbstractChannelMessageListener.java
@@ -2,11 +2,16 @@ package cn.iocoder.yudao.framework.mq.core.pubsub;
import cn.hutool.core.util.TypeUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.mq.core.RedisMQTemplate;
+import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
+import lombok.Setter;
import lombok.SneakyThrows;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import java.lang.reflect.Type;
+import java.util.List;
/**
* Redis Pub/Sub 监听器抽象类,用于实现广播消费
@@ -15,7 +20,7 @@ import java.lang.reflect.Type;
*
* @author 芋道源码
*/
-public abstract class AbstractChannelMessageListener implements MessageListener {
+public abstract class AbstractChannelMessageListener implements MessageListener {
/**
* 消息类型
@@ -25,6 +30,11 @@ public abstract class AbstractChannelMessageListener i
* Redis Channel
*/
private final String channel;
+ /**
+ * RedisMQTemplate
+ */
+ @Setter
+ private RedisMQTemplate redisMQTemplate;
@SneakyThrows
protected AbstractChannelMessageListener() {
@@ -44,7 +54,13 @@ public abstract class AbstractChannelMessageListener i
@Override
public final void onMessage(Message message, byte[] bytes) {
T messageObj = JsonUtils.parseObject(message.getBody(), messageType);
- this.onMessage(messageObj);
+ try {
+ consumeMessageBefore(messageObj);
+ // 消费消息
+ this.onMessage(messageObj);
+ } finally {
+ consumeMessageAfter(messageObj);
+ }
}
/**
@@ -68,4 +84,20 @@ public abstract class AbstractChannelMessageListener i
return (Class) type;
}
+ private void consumeMessageBefore(AbstractRedisMessage message) {
+ assert redisMQTemplate != null;
+ List interceptors = redisMQTemplate.getInterceptors();
+ // 正序
+ interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message));
+ }
+
+ private void consumeMessageAfter(AbstractRedisMessage message) {
+ assert redisMQTemplate != null;
+ List interceptors = redisMQTemplate.getInterceptors();
+ // 倒序
+ for (int i = interceptors.size() - 1; i >= 0; i--) {
+ interceptors.get(i).consumeMessageAfter(message);
+ }
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/ChannelMessage.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/ChannelMessage.java
deleted file mode 100644
index ff55f8b01..000000000
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/pubsub/ChannelMessage.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package cn.iocoder.yudao.framework.mq.core.pubsub;
-
-import com.fasterxml.jackson.annotation.JsonIgnore;
-
-/**
- * Redis Channel Message 接口
- *
- * @author 芋道源码
- */
-public interface ChannelMessage {
-
- /**
- * 获得 Redis Channel
- *
- * @return Channel
- */
- @JsonIgnore // 避免序列化
- String getChannel();
-
-}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/StreamMessage.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessage.java
similarity index 53%
rename from yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/StreamMessage.java
rename to yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessage.java
index 30b38c62d..29ea833f3 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/StreamMessage.java
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessage.java
@@ -1,13 +1,14 @@
package cn.iocoder.yudao.framework.mq.core.stream;
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
- * Redis Stream Message 接口
+ * Redis Stream Message 抽象类
*
* @author 芋道源码
*/
-public interface StreamMessage {
+public abstract class AbstractStreamMessage extends AbstractRedisMessage {
/**
* 获得 Redis Stream Key
@@ -15,6 +16,6 @@ public interface StreamMessage {
* @return Channel
*/
@JsonIgnore // 避免序列化
- String getStreamKey();
+ public abstract String getStreamKey();
}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessageListener.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessageListener.java
index 612b5a029..1c4d91606 100644
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessageListener.java
+++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/stream/AbstractStreamMessageListener.java
@@ -2,15 +2,18 @@ package cn.iocoder.yudao.framework.mq.core.stream;
import cn.hutool.core.util.TypeUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.mq.core.RedisMQTemplate;
+import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.stream.ObjectRecord;
-import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.stream.StreamListener;
import java.lang.reflect.Type;
+import java.util.List;
/**
* Redis Stream 监听器抽象类,用于实现集群消费
@@ -19,7 +22,7 @@ import java.lang.reflect.Type;
*
* @author 芋道源码
*/
-public abstract class AbstractStreamMessageListener
+public abstract class AbstractStreamMessageListener
implements StreamListener> {
/**
@@ -39,10 +42,10 @@ public abstract class AbstractStreamMessageListener
@Getter
private String group;
/**
- *
+ * RedisMQTemplate
*/
@Setter
- private RedisTemplate redisTemplate;
+ private RedisMQTemplate redisMQTemplate;
@SneakyThrows
protected AbstractStreamMessageListener() {
@@ -54,14 +57,20 @@ public abstract class AbstractStreamMessageListener
public void onMessage(ObjectRecord message) {
// 消费消息
T messageObj = JsonUtils.parseObject(message.getValue(), messageType);
- this.onMessage(messageObj);
- // ack 消息消费完成
- redisTemplate.opsForStream().acknowledge(group, message);
- // TODO 芋艿:需要额外考虑以下几个点:
- // 1. 处理异常的情况
- // 2. 发送日志;以及事务的结合
- // 3. 消费日志;以及通用的幂等性
- // 4. 消费失败的重试,https://zhuanlan.zhihu.com/p/60501638
+ try {
+ consumeMessageBefore(messageObj);
+ // 消费消息
+ this.onMessage(messageObj);
+ // ack 消息消费完成
+ redisMQTemplate.getRedisTemplate().opsForStream().acknowledge(group, message);
+ // TODO 芋艿:需要额外考虑以下几个点:
+ // 1. 处理异常的情况
+ // 2. 发送日志;以及事务的结合
+ // 3. 消费日志;以及通用的幂等性
+ // 4. 消费失败的重试,https://zhuanlan.zhihu.com/p/60501638
+ } finally {
+ consumeMessageAfter(messageObj);
+ }
}
/**
@@ -85,4 +94,20 @@ public abstract class AbstractStreamMessageListener
return (Class) type;
}
+ private void consumeMessageBefore(AbstractRedisMessage message) {
+ assert redisMQTemplate != null;
+ List interceptors = redisMQTemplate.getInterceptors();
+ // 正序
+ interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message));
+ }
+
+ private void consumeMessageAfter(AbstractRedisMessage message) {
+ assert redisMQTemplate != null;
+ List interceptors = redisMQTemplate.getInterceptors();
+ // 倒序
+ for (int i = interceptors.size() - 1; i >= 0; i--) {
+ interceptors.get(i).consumeMessageAfter(message);
+ }
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/util/RedisMessageUtils.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/util/RedisMessageUtils.java
deleted file mode 100644
index 57c925fa7..000000000
--- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/core/util/RedisMessageUtils.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package cn.iocoder.yudao.framework.mq.core.util;
-
-import cn.iocoder.yudao.framework.mq.core.pubsub.ChannelMessage;
-import cn.iocoder.yudao.framework.mq.core.stream.StreamMessage;
-import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
-import org.springframework.data.redis.connection.stream.RecordId;
-import org.springframework.data.redis.connection.stream.StreamRecords;
-import org.springframework.data.redis.core.RedisTemplate;
-
-/**
- * Redis 消息工具类
- *
- * @author 芋道源码
- */
-public class RedisMessageUtils {
-
- /**
- * 发送 Redis 消息,基于 Redis pub/sub 实现
- *
- * @param redisTemplate Redis 操作模板
- * @param message 消息
- */
- public static void sendChannelMessage(RedisTemplate, ?> redisTemplate, T message) {
- redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message));
- }
-
- /**
- * 发送 Redis 消息,基于 Redis Stream 实现
- *
- * @param redisTemplate Redis 操作模板
- * @param message 消息
- * @return 消息记录的编号对象
- */
- public static RecordId sendStreamMessage(RedisTemplate redisTemplate, T message) {
- return redisTemplate.opsForStream().add(StreamRecords.newRecord()
- .ofObject(JsonUtils.toJsonString(message)) // 设置内容
- .withStreamKey(message.getStreamKey())); // 设置 stream key
- }
-
-}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/dataobject/BaseDO.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/dataobject/BaseDO.java
index 15d99a3d8..f63f054de 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/dataobject/BaseDO.java
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/dataobject/BaseDO.java
@@ -10,9 +10,11 @@ import java.util.Date;
/**
* 基础实体对象
+ *
+ * @author 芋道源码
*/
@Data
-public class BaseDO implements Serializable {
+public abstract class BaseDO implements Serializable {
/**
* 创建时间
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
index 4335577d7..88d6e86ab 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
@@ -33,7 +33,7 @@ public interface BaseMapperX extends BaseMapper {
}
default Integer selectCount(String field, Object value) {
- return selectCount(new QueryWrapper().eq(field, value));
+ return selectCount(new QueryWrapper().eq(field, value)).intValue();
}
default List selectList() {
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java
index 3c455e893..16d3b29b1 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java
@@ -4,9 +4,16 @@ import cn.hutool.core.collection.CollectionUtil;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import net.sf.jsqlparser.expression.Alias;
+import net.sf.jsqlparser.schema.Column;
+import net.sf.jsqlparser.schema.Table;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.List;
import java.util.stream.Collectors;
/**
@@ -14,6 +21,8 @@ import java.util.stream.Collectors;
*/
public class MyBatisUtils {
+ private static final String MYSQL_ESCAPE_CHARACTER = "`";
+
public static Page buildPage(PageParam pageParam) {
return buildPage(pageParam, null);
}
@@ -30,4 +39,46 @@ public class MyBatisUtils {
return page;
}
+ /**
+ * 将拦截器添加到链中
+ * 由于 MybatisPlusInterceptor 不支持添加拦截器,所以只能全量设置
+ *
+ * @param interceptor 链
+ * @param inner 拦截器
+ * @param index 位置
+ */
+ public static void addInterceptor(MybatisPlusInterceptor interceptor, InnerInterceptor inner, int index) {
+ List inners = new ArrayList<>(interceptor.getInterceptors());
+ inners.add(index, inner);
+ interceptor.setInterceptors(inners);
+ }
+
+ /**
+ * 获得 Table 对应的表名
+ *
+ * 兼容 MySQL 转义表名 `t_xxx`
+ *
+ * @param table 表
+ * @return 去除转移字符后的表名
+ */
+ public static String getTableName(Table table) {
+ String tableName = table.getName();
+ if (tableName.startsWith(MYSQL_ESCAPE_CHARACTER) && tableName.endsWith(MYSQL_ESCAPE_CHARACTER)) {
+ tableName = tableName.substring(1, tableName.length() - 1);
+ }
+ return tableName;
+ }
+
+ /**
+ * 构建 Column 对象
+ *
+ * @param tableName 表名
+ * @param tableAlias 别名
+ * @param column 字段名
+ * @return Column 对象
+ */
+ public static Column buildColumn(String tableName, Alias tableAlias, String column) {
+ return new Column(tableAlias != null ? tableAlias.getName() + "." + column : column);
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-redis/src/main/java/cn/iocoder/yudao/framework/redis/core/RedisKeyDefine.java b/yudao-framework/yudao-spring-boot-starter-redis/src/main/java/cn/iocoder/yudao/framework/redis/core/RedisKeyDefine.java
index 1167bfa26..ba4fccb66 100644
--- a/yudao-framework/yudao-spring-boot-starter-redis/src/main/java/cn/iocoder/yudao/framework/redis/core/RedisKeyDefine.java
+++ b/yudao-framework/yudao-spring-boot-starter-redis/src/main/java/cn/iocoder/yudao/framework/redis/core/RedisKeyDefine.java
@@ -98,4 +98,16 @@ public class RedisKeyDefine {
this(memo, keyTemplate, keyType, valueType, timeoutType, Duration.ZERO);
}
+ /**
+ * 格式化 Key
+ *
+ * 注意,内部采用 {@link String#format(String, Object...)} 实现
+ *
+ * @param args 格式化的参数
+ * @return Key
+ */
+ public String formatKey(Object... args) {
+ return String.format(keyTemplate, args);
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoSecurityAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoSecurityAutoConfiguration.java
index c0fa53ec7..684cfed9a 100644
--- a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoSecurityAutoConfiguration.java
+++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoSecurityAutoConfiguration.java
@@ -1,15 +1,18 @@
package cn.iocoder.yudao.framework.security.config;
import cn.iocoder.yudao.framework.security.core.aop.PreAuthenticatedAspect;
+import cn.iocoder.yudao.framework.security.core.context.TransmittableThreadLocalSecurityContextHolderStrategy;
import cn.iocoder.yudao.framework.security.core.filter.JWTAuthenticationTokenFilter;
import cn.iocoder.yudao.framework.security.core.handler.AccessDeniedHandlerImpl;
import cn.iocoder.yudao.framework.security.core.handler.AuthenticationEntryPointImpl;
import cn.iocoder.yudao.framework.security.core.handler.LogoutSuccessHandlerImpl;
import cn.iocoder.yudao.framework.security.core.service.SecurityAuthFrameworkService;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
+import org.springframework.beans.factory.config.MethodInvokingFactoryBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
@@ -85,4 +88,17 @@ public class YudaoSecurityAutoConfiguration {
return new JWTAuthenticationTokenFilter(securityProperties, securityFrameworkService, globalExceptionHandler);
}
+ /**
+ * 声明调用 {@link SecurityContextHolder#setStrategyName(String)} 方法,
+ * 设置使用 {@link TransmittableThreadLocalSecurityContextHolderStrategy} 作为 Security 的上下文策略
+ */
+ @Bean
+ public MethodInvokingFactoryBean securityContextHolderMethodInvokingFactoryBean() {
+ MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean();
+ methodInvokingFactoryBean.setTargetClass(SecurityContextHolder.class);
+ methodInvokingFactoryBean.setTargetMethod("setStrategyName");
+ methodInvokingFactoryBean.setArguments(TransmittableThreadLocalSecurityContextHolderStrategy.class.getName());
+ return methodInvokingFactoryBean;
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java
index 0090e6633..3833ee62d 100644
--- a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java
+++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.framework.security.core;
+import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -28,14 +29,6 @@ public class LoginUser implements UserDetails {
* 关联 {@link UserTypeEnum}
*/
private Integer userType;
- /**
- * 部门编号
- */
- private Long deptId;
- /**
- * 角色编号数组
- */
- private Set roleIds;
/**
* 最后更新时间
*/
@@ -53,21 +46,39 @@ public class LoginUser implements UserDetails {
* 状态
*/
private Integer status;
+ /**
+ * 租户编号
+ */
+ private Long tenantId;
-
+ // ========== UserTypeEnum.ADMIN 独有字段 ==========
+ // TODO 芋艿:可以通过定义一个 Map exts 的方式,去除管理员的字段。不过这样会导致系统比较复杂,所以暂时不去掉先;
+ /**
+ * 角色编号数组
+ */
+ private Set roleIds;
+ /**
+ * 部门编号
+ */
+ private Long deptId;
/**
* 所属岗位
*/
private Set postIds;
-
/**
* group 目前指岗位代替
*/
// TODO jason:这个字段,改成 postCodes 明确更好哈
private List groups;
-
- // TODO @芋艿:怎么去掉 deptId
+ // ========== 上下文 ==========
+ /**
+ * 上下文字段,不进行持久化
+ *
+ * 1. 用于基于 LoginUser 维度的临时缓存
+ */
+ @JsonIgnore
+ private Map context;
@Override
@JsonIgnore// 避免序列化
@@ -113,4 +124,17 @@ public class LoginUser implements UserDetails {
return true; // 返回 true,不依赖 Spring Security 判断
}
+ // ========== 上下文 ==========
+
+ public void setContext(String key, Object value) {
+ if (context == null) {
+ context = new HashMap<>();
+ }
+ context.put(key, value);
+ }
+
+ public T getContext(String key, Class type) {
+ return MapUtil.get(context, key, type);
+ }
+
}
diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java
new file mode 100644
index 000000000..5e46daa1e
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java
@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.framework.security.core.context;
+
+import com.alibaba.ttl.TransmittableThreadLocal;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.util.Assert;
+
+/**
+ * 基于 TransmittableThreadLocal 实现的 Security Context 持有者策略
+ * 目的是,避免 @Async 等异步执行时,原生 ThreadLocal 的丢失问题
+ *
+ * @author 芋道源码
+ */
+public class TransmittableThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
+
+ /**
+ * 使用 TransmittableThreadLocal 作为上下文
+ */
+ private static final ThreadLocal contextHolder = new TransmittableThreadLocal<>();
+
+ @Override
+ public void clearContext() {
+ contextHolder.remove();
+ }
+
+ @Override
+ public SecurityContext getContext() {
+ SecurityContext ctx = contextHolder.get();
+ if (ctx == null) {
+ ctx = createEmptyContext();
+ contextHolder.set(ctx);
+ }
+ return ctx;
+ }
+
+ @Override
+ public void setContext(SecurityContext context) {
+ Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
+ contextHolder.set(context);
+ }
+
+ @Override
+ public SecurityContext createEmptyContext() {
+ return new SecurityContextImpl();
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/enums/DataScopeEnum.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/enums/DataScopeEnum.java
index ac3396ce3..c67a526d4 100644
--- a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/enums/DataScopeEnum.java
+++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/enums/DataScopeEnum.java
@@ -15,14 +15,16 @@ import lombok.Getter;
public enum DataScopeEnum {
ALL(1), // 全部数据权限
+
DEPT_CUSTOM(2), // 指定部门数据权限
DEPT_ONLY(3), // 部门数据权限
DEPT_AND_CHILD(4), // 部门及以下数据权限
- DEPT_SELF(5); // 仅本人数据权限
+
+ SELF(5); // 仅本人数据权限
/**
* 范围
*/
- private final Integer score;
+ private final Integer scope;
}
diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java
index ba81dca34..f49f8faa5 100644
--- a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java
+++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.framework.security.core.util;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import org.springframework.lang.Nullable;
@@ -11,6 +12,7 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
+import java.util.Objects;
import java.util.Set;
/**
@@ -93,14 +95,17 @@ public class SecurityFrameworkUtils {
loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置到上下文
- //何时调用 SecurityContextHolder.clearContext. spring security filter 应该会调用 clearContext
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号;
// 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息
WebFrameworkUtils.setLoginUserId(request, loginUser.getId());
WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType());
// TODO @jason:使用 userId 会不会更合适哈?
- org.activiti.engine.impl.identity.Authentication.setAuthenticatedUserId(loginUser.getUsername());
+ // TODO @芋艿:activiti 需要使用 ttl 上下文
+ // TODO @jason:清理问题
+ if (Objects.equals(UserTypeEnum.ADMIN.getValue(), loginUser.getUserType())) {
+ org.activiti.engine.impl.identity.Authentication.setAuthenticatedUserId(loginUser.getUsername());
+ }
}
}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/pom.xml b/yudao-framework/yudao-spring-boot-starter-tenant/pom.xml
new file mode 100644
index 000000000..f7010cf9b
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/pom.xml
@@ -0,0 +1,61 @@
+
+
+
+ yudao-framework
+ cn.iocoder.boot
+ ${revision}
+
+ 4.0.0
+ yudao-spring-boot-starter-tenant
+ jar
+
+ ${artifactId}
+ 多租户
+ https://github.com/YunaiV/ruoyi-vue-pro
+
+
+
+ cn.iocoder.boot
+ yudao-common
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-security
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-mybatis
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-redis
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-job
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-mq
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/TenantProperties.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/TenantProperties.java
new file mode 100644
index 000000000..33390f631
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/TenantProperties.java
@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.Set;
+
+/**
+ * 多租户配置
+ *
+ * @author 芋道源码
+ */
+@ConfigurationProperties(prefix = "yudao.tenant")
+@Data
+public class TenantProperties {
+
+// /**
+// * 租户是否开启
+// */
+// private static final Boolean ENABLE_DEFAULT = true;
+//
+// /**
+// * 是否开启
+// */
+// private Boolean enable = ENABLE_DEFAULT;
+ /**
+ * 需要多租户的表
+ *
+ * 由于多租户并不作为 yudao 项目的重点功能,更多是扩展性的功能,所以采用正向配置需要多租户的表。
+ * 如果需要,你可以改成 ignoreTables 来取消部分不需要的表
+ */
+ private Set tables;
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantDatabaseAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantDatabaseAutoConfiguration.java
new file mode 100644
index 000000000..1c7d8a7b3
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantDatabaseAutoConfiguration.java
@@ -0,0 +1,30 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 多租户针对 DB 的自动配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+@EnableConfigurationProperties(TenantProperties.class)
+public class YudaoTenantDatabaseAutoConfiguration {
+
+ @Bean
+ public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
+ MybatisPlusInterceptor interceptor) {
+ TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
+ // 添加到 interceptor 中
+ // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
+ MyBatisUtils.addInterceptor(interceptor, inner, 0);
+ return inner;
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantJobAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantJobAutoConfiguration.java
new file mode 100644
index 000000000..89b58f86e
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantJobAutoConfiguration.java
@@ -0,0 +1,43 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import cn.hutool.core.annotation.AnnotationUtil;
+import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
+import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
+import cn.iocoder.yudao.framework.tenant.core.job.TenantJobHandlerDecorator;
+import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 多租户针对 Job 的自动配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class YudaoTenantJobAutoConfiguration {
+
+ @Bean
+ @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
+ public BeanPostProcessor jobHandlerBeanPostProcessor(TenantFrameworkService tenantFrameworkService) {
+ return new BeanPostProcessor() {
+
+ @Override
+ public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
+ if (!(bean instanceof JobHandler)) {
+ return bean;
+ }
+ // 有 TenantJob 注解的情况下,才会进行处理
+ if (!AnnotationUtil.hasAnnotation(bean.getClass(), TenantJob.class)) {
+ return bean;
+ }
+
+ // 使用 TenantJobHandlerDecorator 装饰
+ return new TenantJobHandlerDecorator(tenantFrameworkService, (JobHandler) bean);
+ }
+
+ };
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantMQAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantMQAutoConfiguration.java
new file mode 100644
index 000000000..6a465b91e
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantMQAutoConfiguration.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import cn.iocoder.yudao.framework.tenant.core.mq.TenantRedisMessageInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 多租户针对 MQ 的自动配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class YudaoTenantMQAutoConfiguration {
+
+ @Bean
+ public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
+ return new TenantRedisMessageInterceptor();
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantSecurityAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantSecurityAutoConfiguration.java
new file mode 100644
index 000000000..b5dbd0000
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantSecurityAutoConfiguration.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
+import cn.iocoder.yudao.framework.tenant.core.security.TenantSecurityWebFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 多租户针对 Web 的自动配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class YudaoTenantSecurityAutoConfiguration {
+
+ @Bean
+ public FilterRegistrationBean tenantSecurityWebFilter() {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+ registrationBean.setFilter(new TenantSecurityWebFilter());
+ registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);
+ return registrationBean;
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantWebAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantWebAutoConfiguration.java
new file mode 100644
index 000000000..c2830a79a
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantWebAutoConfiguration.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.framework.tenant.config;
+
+import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
+import cn.iocoder.yudao.framework.tenant.core.web.TenantContextWebFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 多租户针对 Web 的自动配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class YudaoTenantWebAutoConfiguration {
+
+ @Bean
+ public FilterRegistrationBean tenantContextWebFilter() {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+ registrationBean.setFilter(new TenantContextWebFilter());
+ registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);
+ return registrationBean;
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/context/TenantContextHolder.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/context/TenantContextHolder.java
new file mode 100644
index 000000000..cf5a94141
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/context/TenantContextHolder.java
@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.framework.tenant.core.context;
+
+import com.alibaba.ttl.TransmittableThreadLocal;
+
+/**
+ * 多租户上下文 Holder
+ *
+ * @author 芋道源码
+ */
+public class TenantContextHolder {
+
+ private static final ThreadLocal TENANT_ID = new TransmittableThreadLocal<>();
+
+ /**
+ * 获得租户编号。
+ *
+ * @return 租户编号
+ */
+ public static Long getTenantId() {
+ return TENANT_ID.get();
+ }
+
+ /**
+ * 获得租户编号。如果不存在,则抛出 NullPointerException 异常
+ *
+ * @return 租户编号
+ */
+ public static Long getRequiredTenantId() {
+ Long tenantId = getTenantId();
+ if (tenantId == null) {
+ throw new NullPointerException("TenantContextHolder 不存在租户编号");
+ }
+ return tenantId;
+ }
+
+ public static void setTenantId(Long tenantId) {
+ TENANT_ID.set(tenantId);
+ }
+
+ public static void clear() {
+ TENANT_ID.remove();
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantBaseDO.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantBaseDO.java
new file mode 100644
index 000000000..f4f0ea50a
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantBaseDO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.framework.tenant.core.db;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 拓展多租户的 BaseDO 基类
+ *
+ * @author 芋道源码
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public abstract class TenantBaseDO extends BaseDO {
+
+ /**
+ * 多租户编号
+ */
+ private Long tenantId;
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java
new file mode 100644
index 000000000..283bd4497
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.framework.tenant.core.db;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
+import lombok.AllArgsConstructor;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.StringValue;
+
+/**
+ * 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+public class TenantDatabaseInterceptor implements TenantLineHandler {
+
+ private final TenantProperties properties;
+
+ @Override
+ public Expression getTenantId() {
+ return new StringValue(TenantContextHolder.getRequiredTenantId().toString());
+ }
+
+ @Override
+ public boolean ignoreTable(String tableName) {
+ // 如果实体类继承 TenantBaseDO 类,则是多租户表,不进行忽略
+ TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
+ if (tableInfo != null && TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {
+ return false;
+ }
+ // 不包含,说明要过滤
+ return !CollUtil.contains(properties.getTables(), tableName);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJob.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJob.java
new file mode 100644
index 000000000..fd2ecada9
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJob.java
@@ -0,0 +1,14 @@
+package cn.iocoder.yudao.framework.tenant.core.job;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 多租户 Job 注解
+ */
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface TenantJob {
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerDecorator.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerDecorator.java
new file mode 100644
index 000000000..25a6e016d
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerDecorator.java
@@ -0,0 +1,58 @@
+package cn.iocoder.yudao.framework.tenant.core.job;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService;
+import lombok.AllArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 多租户 JobHandler 装饰器
+ * 任务执行时,会按照租户逐个执行 Job 的逻辑
+ *
+ * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+public class TenantJobHandlerDecorator implements JobHandler {
+
+ private final TenantFrameworkService tenantFrameworkService;
+ /**
+ * 被装饰的 Job
+ */
+ private final JobHandler jobHandler;
+
+ @Override
+ public final String execute(String param) throws Exception {
+ // 获得租户列表
+ List tenantIds = tenantFrameworkService.getTenantIds();
+ if (CollUtil.isEmpty(tenantIds)) {
+ return null;
+ }
+
+ // 逐个租户,执行 Job
+ Map results = new ConcurrentHashMap<>();
+ tenantIds.parallelStream().forEach(tenantId -> { // TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况
+ try {
+ // 设置租户
+ TenantContextHolder.setTenantId(tenantId);
+ // 执行 Job
+ String result = jobHandler.execute(param);
+ // 添加结果
+ results.put(tenantId, result);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ } finally {
+ TenantContextHolder.clear();
+ }
+ });
+ return JsonUtils.toJsonString(results);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerInvoker.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerInvoker.java
new file mode 100644
index 000000000..5127150a3
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/job/TenantJobHandlerInvoker.java
@@ -0,0 +1,14 @@
+package cn.iocoder.yudao.framework.tenant.core.job;
+
+import cn.iocoder.yudao.framework.quartz.core.handler.JobHandlerInvoker;
+
+/**
+ * 多租户 JobHandlerInvoker 拓展实现类
+ *
+ * @author 芋道源码
+ */
+public class TenantJobHandlerInvoker extends JobHandlerInvoker {
+
+
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/TenantRedisMessageInterceptor.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/TenantRedisMessageInterceptor.java
new file mode 100644
index 000000000..15c72f992
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/TenantRedisMessageInterceptor.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.framework.tenant.core.mq;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
+import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+
+/**
+ * 多租户 {@link AbstractRedisMessage} 拦截器
+ *
+ * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中
+ * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中
+ *
+ * @author 芋道源码
+ */
+public class TenantRedisMessageInterceptor implements RedisMessageInterceptor {
+
+ private static final String HEADER_TENANT_ID = "tenant-id";
+
+ @Override
+ public void sendMessageBefore(AbstractRedisMessage message) {
+ Long tenantId = TenantContextHolder.getTenantId();
+ if (tenantId != null) {
+ message.addHeader(HEADER_TENANT_ID, tenantId.toString());
+ }
+ }
+
+ @Override
+ public void consumeMessageBefore(AbstractRedisMessage message) {
+ String tenantIdStr = message.getHeader(HEADER_TENANT_ID);
+ if (StrUtil.isNotEmpty(tenantIdStr)) {
+ TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr));
+ }
+ }
+
+ @Override
+ public void consumeMessageAfter(AbstractRedisMessage message) {
+ // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况
+ TenantContextHolder.clear();
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefine.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefine.java
new file mode 100644
index 000000000..c23bc712e
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefine.java
@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.framework.tenant.core.redis;
+
+import cn.hutool.core.util.ArrayUtil;
+import cn.iocoder.yudao.framework.redis.core.RedisKeyDefine;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+
+import java.time.Duration;
+
+/**
+ * 多租户拓展的 RedisKeyDefine 实现类
+ *
+ * 由于 Redis 不同于 MySQL 有 column 字段,所以无法通过类似 WHERE tenant_id = ? 的方式过滤
+ * 所以需要通过在 Redis Key 上增加后缀的方式,进行租户之间的隔离。具体的步骤是:
+ * 1. 假设 Redis Key 是 user:%d,示例是 user:1;对应到多租户的 Redis Key 是 user:%d:%d,
+ * 2. 在 Redis DAO 中,需要使用 {@link #formatKey(Object...)} 方法,进行 Redis Key 的格式化
+ *
+ * 注意,大多数情况下,并不用使用 TenantRedisKeyDefine 实现。主要的使用场景,是 Redis Key 可能存在冲突的情况。
+ * 例如说,租户 1 和 2 都有一个手机号作为 Key,则他们会存在冲突的问题
+ *
+ * @author 芋道源码
+ */
+public class TenantRedisKeyDefine extends RedisKeyDefine {
+
+ /**
+ * 多租户的 KEY 模板
+ */
+ private static final String KEY_TEMPLATE_SUFFIX = ":%d";
+
+ public TenantRedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class> valueType, Duration timeout) {
+ super(memo, buildKeyTemplate(keyTemplate), keyType, valueType, timeout);
+ }
+
+ public TenantRedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class> valueType, TimeoutTypeEnum timeoutType) {
+ super(memo, buildKeyTemplate(keyTemplate), keyType, valueType, timeoutType);
+ }
+
+ private static String buildKeyTemplate(String keyTemplate) {
+ return keyTemplate + KEY_TEMPLATE_SUFFIX;
+ }
+
+ @Override
+ public String formatKey(Object... args) {
+ args = ArrayUtil.append(args, TenantContextHolder.getRequiredTenantId());
+ return super.formatKey(args);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java
new file mode 100644
index 000000000..0801427da
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java
@@ -0,0 +1,50 @@
+package cn.iocoder.yudao.framework.tenant.core.security;
+
+import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Objects;
+
+/**
+ * 多租户 Security Web 过滤器
+ * 校验用户访问的租户,是否是其所在的租户,避免越权问题
+ *
+ * @author 芋道源码
+ */
+@Slf4j
+public class TenantSecurityWebFilter extends OncePerRequestFilter {
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ LoginUser user = SecurityFrameworkUtils.getLoginUser();
+ assert user != null; // shouldNotFilter 已经校验
+ if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
+ log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
+ user.getTenantId(), user.getId(), user.getUserType(),
+ TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
+ ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
+ "您无权访问该租户的数据"));
+ return;
+ }
+ // 继续过滤
+ chain.doFilter(request, response);
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ return SecurityFrameworkUtils.getLoginUser() == null;
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/service/TenantFrameworkService.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/service/TenantFrameworkService.java
new file mode 100644
index 000000000..a36e612b6
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/service/TenantFrameworkService.java
@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.framework.tenant.core.service;
+
+import java.util.List;
+
+/**
+ * Tenant 框架 Service 接口,定义获取租户信息
+ *
+ * @author 芋道源码
+ */
+public interface TenantFrameworkService {
+
+ /**
+ * 获得所有租户
+ *
+ * @return 租户编号数组
+ */
+ List getTenantIds();
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/web/TenantContextWebFilter.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/web/TenantContextWebFilter.java
new file mode 100644
index 000000000..ec159e0ec
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/web/TenantContextWebFilter.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.framework.tenant.core.web;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 多租户 Context Web 过滤器
+ * 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。
+ *
+ * @author 芋道源码
+ */
+public class TenantContextWebFilter extends OncePerRequestFilter {
+
+ private static final String HEADER_TENANT_ID = "tenant-id";
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ // 设置
+ String tenantId = request.getHeader(HEADER_TENANT_ID);
+ if (StrUtil.isNotEmpty(tenantId)) {
+ TenantContextHolder.setTenantId(Long.valueOf(tenantId));
+ }
+ try {
+ chain.doFilter(request, response);
+ } finally {
+ // 清理
+ TenantContextHolder.clear();
+ }
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/package-info.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/package-info.java
new file mode 100644
index 000000000..aa22cdb95
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/package-info.java
@@ -0,0 +1,17 @@
+/**
+ * 多租户,支持如下层面:
+ * 1. DB:基于 MyBatis Plus 多租户的功能实现。
+ * 2. Redis:通过在 Redis Key 上拼接租户编号的方式,进行隔离。
+ * 3. Web:请求 HTTP API 时,解析 Header 的 tenant-id 租户编号,添加到租户上下文。
+ * 4. Security:校验当前登陆的用户,是否越权访问其它租户的数据。
+ * 5. Job:在 JobHandler 执行任务时,会按照每个租户,都独立并行执行一次。
+ * 6. MQ:在 Producer 发送消息时,Header 带上 tenant-id 租户编号;在 Consumer 消费消息时,将 Header 的 tenant-id 租户编号,添加到租户上下文。
+ * 7. Async:异步需要保证 ThreadLocal 的传递性,通过使用阿里开源的 TransmittableThreadLocal 实现。相关的改造点,可见:
+ * 1)Spring Async:
+ * {@link cn.iocoder.yudao.framework.quartz.config.YudaoAsyncAutoConfiguration#threadPoolTaskExecutorBeanPostProcessor()}
+ * 2)Spring Security:
+ * TransmittableThreadLocalSecurityContextHolderStrategy
+ * 和 YudaoSecurityAutoConfiguration#securityContextHolderMethodInvokingFactoryBean() 方法
+ *
+ */
+package cn.iocoder.yudao.framework.tenant;
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/main/resources/META-INF/spring.factories b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/resources/META-INF/spring.factories
new file mode 100644
index 000000000..159dfcfee
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,6 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+ cn.iocoder.yudao.framework.tenant.config.YudaoTenantDatabaseAutoConfiguration,\
+ cn.iocoder.yudao.framework.tenant.config.YudaoTenantWebAutoConfiguration,\
+ cn.iocoder.yudao.framework.tenant.config.YudaoTenantJobAutoConfiguration,\
+ cn.iocoder.yudao.framework.tenant.config.YudaoTenantMQAutoConfiguration,\
+ cn.iocoder.yudao.framework.tenant.config.YudaoTenantSecurityAutoConfiguration
diff --git a/yudao-framework/yudao-spring-boot-starter-tenant/src/test/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefineTest.java b/yudao-framework/yudao-spring-boot-starter-tenant/src/test/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefineTest.java
new file mode 100644
index 000000000..d456e011c
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-tenant/src/test/java/cn/iocoder/yudao/framework/tenant/core/redis/TenantRedisKeyDefineTest.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.framework.tenant.core.redis;
+
+import cn.iocoder.yudao.framework.redis.core.RedisKeyDefine;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TenantRedisKeyDefineTest {
+
+ @Test
+ public void testFormatKey() {
+ Long tenantId = 30L;
+ TenantContextHolder.setTenantId(tenantId);
+ // 准备参数
+ TenantRedisKeyDefine define = new TenantRedisKeyDefine("", "user:%d:%d", RedisKeyDefine.KeyTypeEnum.HASH,
+ Object.class, RedisKeyDefine.TimeoutTypeEnum.FIXED);
+ Long userId = 10L;
+ Integer userType = 1;
+
+ // 调用
+ String key = define.formatKey(userId, userType);
+ // 断言
+ assertEquals("user:10:1:30", key);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-test/pom.xml b/yudao-framework/yudao-spring-boot-starter-test/pom.xml
index 9500403d9..0b211f3cd 100644
--- a/yudao-framework/yudao-spring-boot-starter-test/pom.xml
+++ b/yudao-framework/yudao-spring-boot-starter-test/pom.xml
@@ -22,6 +22,10 @@
+
+ org.mockito
+ mockito-inline
+
org.springframework.boot
spring-boot-starter-test
diff --git a/yudao-user-server/pom.xml b/yudao-user-server/pom.xml
index e7b1cd82d..03dbfd699 100644
--- a/yudao-user-server/pom.xml
+++ b/yudao-user-server/pom.xml
@@ -91,6 +91,14 @@
test
+
+
+
+ junit
+ junit
+ test
+
+
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/config/AsyncConfiguration.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/config/AsyncConfiguration.java
deleted file mode 100644
index ed271220c..000000000
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/config/AsyncConfiguration.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package cn.iocoder.yudao.userserver.framework.async.config;
-
-import org.springframework.context.annotation.Configuration;
-import org.springframework.scheduling.annotation.EnableAsync;
-
-@Configuration
-@EnableAsync
-public class AsyncConfiguration {
-}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/package-info.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/package-info.java
deleted file mode 100644
index a06351522..000000000
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/package-info.java
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * 异步执行,基于 Spring @Async 实现
- */
-package cn.iocoder.yudao.userserver.framework.async;
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/《芋道 Spring Boot 异步任务入门》.md b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/《芋道 Spring Boot 异步任务入门》.md
deleted file mode 100644
index 5822b838c..000000000
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/framework/async/《芋道 Spring Boot 异步任务入门》.md
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.http b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.http
index 1ce9eea62..26bd55aaf 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.http
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.http
@@ -1,11 +1,11 @@
-### 请求 /system/user/profile/get 接口 => 没有权限
-GET {{userServerUrl}}/system/user/profile/get
+### 请求 /member/user/profile/get 接口 => 没有权限
+GET {{userServerUrl}}/member/user/profile/get
Authorization: Bearer test245
-### 请求 /system/user/profile/revise-nickname 接口 成功
-PUT {{userServerUrl}}/system/user/profile/update-nickname?nickName=yunai222
+### 请求 /member/user/profile/revise-nickname 接口 成功
+PUT {{userServerUrl}}/member/user/profile/update-nickname?nickName=yunai222
Authorization: Bearer test245
-### 请求 /system/user/profile/get-user-info 接口 成功
-GET {{userServerUrl}}/system/user/profile/get-user-info?id=245
-Authorization: Bearer test245
\ No newline at end of file
+### 请求 /member/user/profile/get-user-info 接口 成功
+GET {{userServerUrl}}/member/user/profile/get-user-info?id=245
+Authorization: Bearer test245
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.java
index 870d33718..c0778184a 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/SysUserProfileController.java
@@ -5,6 +5,9 @@ import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated;
import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
+import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserUpdateMobileReqVO;
+import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
+import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
@@ -13,16 +16,18 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
+import javax.validation.Valid;
import java.io.IOException;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.userserver.modules.member.enums.MbrErrorCodeConstants.FILE_IS_EMPTY;
@Api(tags = "用户个人中心")
@RestController
-@RequestMapping("/system/user/profile")
+@RequestMapping("/member/user/profile")
@Validated
@Slf4j
public class SysUserProfileController {
@@ -30,11 +35,14 @@ public class SysUserProfileController {
@Resource
private MbrUserService userService;
+ @Resource
+ private SysSmsCodeService smsCodeService;
+
@PutMapping("/update-nickname")
@ApiOperation("修改用户昵称")
@PreAuthenticated
- public CommonResult updateNickname(@RequestParam("nickName") String nickName) {
- userService.updateNickname(getLoginUserId(), nickName);
+ public CommonResult updateNickname(@RequestParam("nickname") String nickname) {
+ userService.updateNickname(getLoginUserId(), nickname);
return success(true);
}
@@ -49,12 +57,24 @@ public class SysUserProfileController {
return success(avatar);
}
- @GetMapping("/get-user-info")
- @ApiOperation("获取用户头像与昵称")
+ @GetMapping("/get")
+ @ApiOperation("获得基本信息")
@PreAuthenticated
public CommonResult getUserInfo() {
return success(userService.getUserInfo(getLoginUserId()));
}
+ @PostMapping("/update-mobile")
+ @ApiOperation(value = "修改用户手机")
+ @PreAuthenticated
+ public CommonResult updateMobile(@RequestBody @Valid MbrUserUpdateMobileReqVO reqVO) {
+ // 校验验证码
+ // TODO @宋天:统一到 userService.updateMobile 方法里
+ smsCodeService.useSmsCode(reqVO.getMobile(),SysSmsSceneEnum.CHANGE_MOBILE_BY_SMS.getScene(), reqVO.getCode(),getClientIP());
+
+ userService.updateMobile(getLoginUserId(), reqVO);
+ return success(true);
+ }
+
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserInfoRespVO.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserInfoRespVO.java
index e46bd410f..697c4085d 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserInfoRespVO.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserInfoRespVO.java
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
public class MbrUserInfoRespVO {
@ApiModelProperty(value = "用户昵称", required = true, example = "芋艿")
- private String nickName;
+ private String nickname;
@ApiModelProperty(value = "用户头像", required = true, example = "/infra/file/get/35a12e57-4297-4faa-bf7d-7ed2f211c952")
private String avatar;
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserUpdateMobileReqVO.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserUpdateMobileReqVO.java
new file mode 100644
index 000000000..df1980b89
--- /dev/null
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/controller/user/vo/MbrUserUpdateMobileReqVO.java
@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.userserver.modules.member.controller.user.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Length;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.Pattern;
+
+@ApiModel("修改手机 Request VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class MbrUserUpdateMobileReqVO {
+
+ @ApiModelProperty(value = "手机验证码", required = true, example = "1024")
+ @NotEmpty(message = "手机验证码不能为空")
+ @Length(min = 4, max = 6, message = "手机验证码长度为 4-6 位")
+ @Pattern(regexp = "^[0-9]+$", message = "手机验证码必须都是数字")
+ private String code;
+
+ @ApiModelProperty(value = "手机号",required = true,example = "15823654487")
+ @NotBlank(message = "手机号不能为空")
+ // TODO @宋天:手机校验,直接使用 @Mobile 哈
+ @Length(min = 8, max = 11, message = "手机号码长度为 8-11 位")
+ @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
+ private String mobile;
+
+}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/convert/user/UserProfileConvert.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/convert/user/UserProfileConvert.java
new file mode 100644
index 000000000..6f9d16691
--- /dev/null
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/convert/user/UserProfileConvert.java
@@ -0,0 +1,15 @@
+package cn.iocoder.yudao.userserver.modules.member.convert.user;
+
+import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
+import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserInfoRespVO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+@Mapper
+public interface UserProfileConvert {
+
+ UserProfileConvert INSTANCE = Mappers.getMapper(UserProfileConvert.class);
+
+ MbrUserInfoRespVO convert(MbrUserDO bean);
+
+}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/MbrUserService.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/MbrUserService.java
index 6b6a36a8e..e33978bfe 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/MbrUserService.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/MbrUserService.java
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.userserver.modules.member.service.user;
import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserInfoRespVO;
import cn.iocoder.yudao.framework.common.validation.Mobile;
+import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserUpdateMobileReqVO;
import java.io.InputStream;
@@ -50,9 +51,9 @@ public interface MbrUserService {
/**
* 修改用户昵称
* @param userId 用户id
- * @param nickName 用户新昵称
+ * @param nickname 用户新昵称
*/
- void updateNickname(Long userId, String nickName);
+ void updateNickname(Long userId, String nickname);
/**
* 修改用户头像
@@ -64,9 +65,17 @@ public interface MbrUserService {
/**
* 根据用户id,获取用户头像与昵称
+ *
* @param userId 用户id
* @return 用户响应实体类
*/
MbrUserInfoRespVO getUserInfo(Long userId);
+ /**
+ * 修改手机
+ * @param userId 用户id
+ * @param reqVO 请求实体
+ */
+ void updateMobile(Long userId, MbrUserUpdateMobileReqVO reqVO);
+
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/impl/MbrUserServiceImpl.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/impl/MbrUserServiceImpl.java
index f340c5fe9..442da4147 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/impl/MbrUserServiceImpl.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/member/service/user/impl/MbrUserServiceImpl.java
@@ -6,8 +6,11 @@ import cn.iocoder.yudao.coreservice.modules.infra.service.file.InfFileCoreServic
import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserInfoRespVO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserUpdateMobileReqVO;
+import cn.iocoder.yudao.userserver.modules.member.convert.user.UserProfileConvert;
import cn.iocoder.yudao.userserver.modules.member.dal.mysql.user.MbrUserMapper;
import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
+import cn.iocoder.yudao.userserver.modules.system.service.auth.SysAuthService;
import com.google.common.annotations.VisibleForTesting;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -40,6 +43,9 @@ public class MbrUserServiceImpl implements MbrUserService {
@Resource
private PasswordEncoder passwordEncoder;
+ @Resource
+ private SysAuthService sysAuthService;
+
@Override
public MbrUserDO getUserByMobile(String mobile) {
return userMapper.selectByMobile(mobile);
@@ -80,15 +86,15 @@ public class MbrUserServiceImpl implements MbrUserService {
}
@Override
- public void updateNickname(Long userId, String nickName) {
+ public void updateNickname(Long userId, String nickname) {
MbrUserDO user = this.checkUserExists(userId);
// 仅当新昵称不等于旧昵称时进行修改
- if (nickName.equals(user.getNickname())){
+ if (nickname.equals(user.getNickname())){
return;
}
MbrUserDO userDO = new MbrUserDO();
userDO.setId(user.getId());
- userDO.setNickname(nickName);
+ userDO.setNickname(nickname);
userMapper.updateById(userDO);
}
@@ -110,10 +116,20 @@ public class MbrUserServiceImpl implements MbrUserService {
public MbrUserInfoRespVO getUserInfo(Long userId) {
MbrUserDO user = this.checkUserExists(userId);
// 拼接返回结果
- MbrUserInfoRespVO userResp = new MbrUserInfoRespVO();
- userResp.setNickName(user.getNickname());
- userResp.setAvatar(user.getAvatar());
- return userResp;
+ return UserProfileConvert.INSTANCE.convert(user);
+ }
+
+ @Override
+ public void updateMobile(Long userId, MbrUserUpdateMobileReqVO reqVO) {
+ // 检测用户是否存在
+ MbrUserDO userDO = checkUserExists(userId);
+ // 检测手机与验证码是否匹配
+ // TODO @宋天:修改手机的时候。应该要校验,老手机 + 老手机 code;新手机 + 新手机 code
+ sysAuthService.checkIfMobileMatchCodeAndDeleteCode(userDO.getMobile(),reqVO.getCode());
+ // 更新用户手机
+ // TODO @宋天:更新的时候,单独创建对象。直接全量更新,会可能导致属性覆盖。可以看看打印出来的 SQL 哈
+ userDO.setMobile(reqVO.getMobile());
+ userMapper.updateById(userDO);
}
@VisibleForTesting
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/SysAuthController.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/SysAuthController.java
index 6d18d53be..9e6b9f450 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/SysAuthController.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/SysAuthController.java
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.userserver.modules.system.controller.auth;
import cn.iocoder.yudao.coreservice.modules.system.service.social.SysSocialService;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated;
import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.*;
+import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
import cn.iocoder.yudao.userserver.modules.system.service.auth.SysAuthService;
import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
import com.alibaba.fastjson.JSON;
@@ -38,7 +40,6 @@ public class SysAuthController {
@Resource
private SysSocialService socialService;
-
@PostMapping("/login")
@ApiOperation("使用手机 + 密码登录")
public CommonResult login(@RequestBody @Valid SysAuthLoginReqVO reqVO) {
@@ -56,16 +57,49 @@ public class SysAuthController {
}
@PostMapping("/send-sms-code")
- @ApiOperation("发送手机验证码")
+ @ApiOperation(value = "发送手机验证码",notes = "不检测该手机号是否已被注册")
public CommonResult sendSmsCode(@RequestBody @Valid SysAuthSendSmsReqVO reqVO) {
smsCodeService.sendSmsCode(reqVO.getMobile(), reqVO.getScene(), getClientIP());
return success(true);
}
+ @PostMapping("/send-sms-new-code")
+ @ApiOperation(value = "发送手机验证码",notes = "检测该手机号是否已被注册,用于修改手机时使用")
+ public CommonResult sendSmsNewCode(@RequestBody @Valid SysAuthSendSmsReqVO reqVO) {
+ smsCodeService.sendSmsNewCode(reqVO);
+ return success(true);
+ }
+
+ @GetMapping("/send-sms-code-login")
+ @ApiOperation(value = "向已登录用户发送验证码",notes = "修改手机时验证原手机号使用")
+ public CommonResult sendSmsCodeLogin() {
+ smsCodeService.sendSmsCodeLogin(getLoginUserId());
+ return success(true);
+ }
+
@PostMapping("/reset-password")
@ApiOperation(value = "重置密码", notes = "用户忘记密码时使用")
+ @PreAuthenticated
public CommonResult resetPassword(@RequestBody @Valid MbrAuthResetPasswordReqVO reqVO) {
- return null;
+ authService.resetPassword(reqVO);
+ return success(true);
+ }
+
+ @PostMapping("/update-password")
+ @ApiOperation(value = "修改用户密码",notes = "用户修改密码时使用")
+ @PreAuthenticated
+ public CommonResult updatePassword(@RequestBody @Valid MbrAuthUpdatePasswordReqVO reqVO) {
+ authService.updatePassword(getLoginUserId(), reqVO);
+ return success(true);
+ }
+
+ @PostMapping("/check-sms-code")
+ @ApiOperation(value = "校验验证码是否正确")
+ @PreAuthenticated
+ public CommonResult checkSmsCode(@RequestBody @Valid SysAuthSmsLoginReqVO reqVO) {
+ // TODO @宋天:check 的时候,不应该使用 useSmsCode 哈,这样验证码就直接被使用了。另外,check 开头的方法,更多是校验的逻辑,不会有 update 数据的动作。这点,在方法命名上,也是要注意的
+ smsCodeService.useSmsCode(reqVO.getMobile(),SysSmsSceneEnum.CHECK_CODE_BY_SMS.getScene(),reqVO.getCode(),getClientIP());
+ return success(true);
}
// ========== 社交登录相关 ==========
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/vo/MbrAuthUpdatePasswordReqVO.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/vo/MbrAuthUpdatePasswordReqVO.java
new file mode 100644
index 000000000..b5cc0c785
--- /dev/null
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/controller/auth/vo/MbrAuthUpdatePasswordReqVO.java
@@ -0,0 +1,30 @@
+package cn.iocoder.yudao.userserver.modules.system.controller.auth.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Length;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+
+@ApiModel("修改密码 Request VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class MbrAuthUpdatePasswordReqVO {
+
+ @ApiModelProperty(value = "用户旧密码", required = true, example = "123456")
+ @NotBlank(message = "旧密码不能为空")
+ @Length(min = 4, max = 16, message = "密码长度为 4-16 位")
+ private String oldPassword;
+
+ @ApiModelProperty(value = "新密码", required = true, example = "buzhidao")
+ @NotEmpty(message = "新密码不能为空")
+ @Length(min = 4, max = 16, message = "密码长度为 4-16 位")
+ private String password;
+}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/SysErrorCodeConstants.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/SysErrorCodeConstants.java
index f24be9cc6..e7c104afb 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/SysErrorCodeConstants.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/SysErrorCodeConstants.java
@@ -23,7 +23,10 @@ public interface SysErrorCodeConstants {
ErrorCode USER_SMS_CODE_NOT_CORRECT = new ErrorCode(1005001003, "验证码不正确");
ErrorCode USER_SMS_CODE_EXCEED_SEND_MAXIMUM_QUANTITY_PER_DAY = new ErrorCode(1005001004, "超过每日短信发送数量");
ErrorCode USER_SMS_CODE_SEND_TOO_FAST = new ErrorCode(1005001005, "短信发送过于频率");
+ ErrorCode USER_SMS_CODE_IS_EXISTS = new ErrorCode(1005001006, "手机号已被使用");
// ========== 用户模块 1005002000 ==========
ErrorCode USER_NOT_EXISTS = new ErrorCode(1005002001, "用户不存在");
+ ErrorCode USER_CODE_FAILED = new ErrorCode(1005002002, "验证码不匹配");
+ ErrorCode USER_PASSWORD_FAILED = new ErrorCode(1005002003, "密码校验失败");
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/sms/SysSmsSceneEnum.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/sms/SysSmsSceneEnum.java
index 6f5ce3daa..c2156d218 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/sms/SysSmsSceneEnum.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/enums/sms/SysSmsSceneEnum.java
@@ -17,7 +17,9 @@ public enum SysSmsSceneEnum implements IntArrayValuable {
LOGIN_BY_SMS(1, "手机号登陆"),
CHANGE_MOBILE_BY_SMS(2, "更换手机号"),
- ;
+ FORGET_MOBILE_BY_SMS(3, "忘记密码"),
+ CHECK_CODE_BY_SMS(4, "审核验证码"),
+ ;
public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(SysSmsSceneEnum::getScene).toArray();
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/SysAuthService.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/SysAuthService.java
index 628f95c80..33664d351 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/SysAuthService.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/SysAuthService.java
@@ -63,4 +63,23 @@ public interface SysAuthService extends SecurityAuthFrameworkService {
*/
void socialBind(Long userId, @Valid MbrAuthSocialBindReqVO reqVO);
+ /**
+ * 修改用户密码
+ * @param userId 用户id
+ * @param userReqVO 用户请求实体类
+ */
+ void updatePassword(Long userId, @Valid MbrAuthUpdatePasswordReqVO userReqVO);
+
+ /**
+ * 忘记密码
+ * @param userReqVO 用户请求实体类
+ */
+ void resetPassword(MbrAuthResetPasswordReqVO userReqVO);
+
+ /**
+ * 检测手机与验证码是否匹配
+ * @param phone 手机号
+ * @param code 验证码
+ */
+ void checkIfMobileMatchCodeAndDeleteCode(String phone,String code);
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/impl/SysAuthServiceImpl.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/impl/SysAuthServiceImpl.java
index 2394cd03a..f4193b35b 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/impl/SysAuthServiceImpl.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/auth/impl/SysAuthServiceImpl.java
@@ -15,15 +15,18 @@ import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.userserver.modules.member.dal.mysql.user.MbrUserMapper;
import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.*;
import cn.iocoder.yudao.userserver.modules.system.convert.auth.SysAuthConvert;
import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
import cn.iocoder.yudao.userserver.modules.system.service.auth.SysAuthService;
import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
+import com.google.common.annotations.VisibleForTesting;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthUser;
import org.springframework.context.annotation.Lazy;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
@@ -32,14 +35,17 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
+import javax.validation.Valid;
import java.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
import static cn.iocoder.yudao.userserver.modules.system.enums.SysErrorCodeConstants.*;
/**
@@ -65,6 +71,13 @@ public class SysAuthServiceImpl implements SysAuthService {
private SysUserSessionCoreService userSessionCoreService;
@Resource
private SysSocialService socialService;
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+ @Resource
+ private PasswordEncoder passwordEncoder;
+ @Resource
+ private MbrUserMapper userMapper;
+
private static final UserTypeEnum userTypeEnum = UserTypeEnum.MEMBER;
@Override
@@ -200,12 +213,12 @@ public class SysAuthServiceImpl implements SysAuthService {
}
reqDTO.setUsername(mobile);
reqDTO.setUserAgent(ServletUtils.getUserAgent());
- reqDTO.setUserIp(ServletUtils.getClientIP());
+ reqDTO.setUserIp(getClientIP());
reqDTO.setResult(loginResult.getResult());
loginLogCoreService.createLoginLog(reqDTO);
// 更新最后登录时间
if (user != null && Objects.equals(SysLoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {
- userService.updateUserLogin(user.getId(), ServletUtils.getClientIP());
+ userService.updateUserLogin(user.getId(), getClientIP());
}
}
@@ -266,6 +279,69 @@ public class SysAuthServiceImpl implements SysAuthService {
this.createLogoutLog(loginUser.getId(), loginUser.getUsername());
}
+ @Override
+ public void updatePassword(Long userId, @Valid MbrAuthUpdatePasswordReqVO reqVO) {
+ // 检验旧密码
+ MbrUserDO userDO = checkOldPassword(userId, reqVO.getOldPassword());
+
+ // 更新用户密码
+ // TODO @宋天:不要更新整个对象哈
+ userDO.setPassword(passwordEncoder.encode(reqVO.getPassword()));
+ userMapper.updateById(userDO);
+ }
+
+ @Override
+ public void resetPassword(MbrAuthResetPasswordReqVO reqVO) {
+ // 根据验证码取出手机号,并查询用户
+ String mobile = stringRedisTemplate.opsForValue().get(reqVO.getCode());
+ MbrUserDO userDO = userMapper.selectByMobile(mobile);
+ if (userDO == null){
+ throw exception(USER_NOT_EXISTS);
+ }
+ // TODO @芋艿 这一步没必要检验验证码与手机是否匹配,因为是根据验证码去redis中查找手机号,然后根据手机号查询用户
+ // 也就是说 即便黑客以其他方式将验证码发送到自己手机上,最终还是会根据手机号查询用户然后进行重置密码的操作,不存在安全问题
+
+ // TODO @宋天:这块微信在讨论下哈~~~
+
+ // 校验验证码
+ smsCodeService.useSmsCode(userDO.getMobile(), SysSmsSceneEnum.FORGET_MOBILE_BY_SMS.getScene(), reqVO.getCode(),getClientIP());
+
+ // 更新密码
+ userDO.setPassword(passwordEncoder.encode(reqVO.getPassword()));
+ userMapper.updateById(userDO);
+ }
+
+ @Override
+ public void checkIfMobileMatchCodeAndDeleteCode(String phone, String code) {
+ // 检验用户手机与验证码是否匹配
+ String mobile = stringRedisTemplate.opsForValue().get(code);
+ if (!phone.equals(mobile)){
+ throw exception(USER_CODE_FAILED);
+ }
+ // 销毁redis中此验证码
+ stringRedisTemplate.delete(code);
+ }
+
+ /**
+ * 校验旧密码
+ *
+ * @param id 用户 id
+ * @param oldPassword 旧密码
+ * @return MbrUserDO 用户实体
+ */
+ @VisibleForTesting
+ public MbrUserDO checkOldPassword(Long id, String oldPassword) {
+ MbrUserDO user = userMapper.selectById(id);
+ if (user == null) {
+ throw exception(USER_NOT_EXISTS);
+ }
+ // 参数:未加密密码,编码后的密码
+ if (!passwordEncoder.matches(oldPassword,user.getPassword())) {
+ throw exception(USER_PASSWORD_FAILED);
+ }
+ return user;
+ }
+
private void createLogoutLog(Long userId, String username) {
SysLoginLogCreateReqDTO reqDTO = new SysLoginLogCreateReqDTO();
reqDTO.setLogType(SysLoginLogTypeEnum.LOGOUT_SELF.getType());
@@ -274,7 +350,7 @@ public class SysAuthServiceImpl implements SysAuthService {
reqDTO.setUserType(userTypeEnum.getValue());
reqDTO.setUsername(username);
reqDTO.setUserAgent(ServletUtils.getUserAgent());
- reqDTO.setUserIp(ServletUtils.getClientIP());
+ reqDTO.setUserIp(getClientIP());
reqDTO.setResult(SysLoginResultEnum.SUCCESS.getResult());
loginLogCoreService.createLoginLog(reqDTO);
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/SysSmsCodeService.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/SysSmsCodeService.java
index 5ee81b67c..6e9c3c7b3 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/SysSmsCodeService.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/SysSmsCodeService.java
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.userserver.modules.system.service.sms;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.validation.Mobile;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.SysAuthSendSmsReqVO;
import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
/**
@@ -20,6 +21,12 @@ public interface SysSmsCodeService {
*/
void sendSmsCode(@Mobile String mobile, Integer scene, String createIp);
+ /**
+ * 发送短信验证码,并检测手机号是否已被注册
+ * @param reqVO 请求实体
+ */
+ void sendSmsNewCode(SysAuthSendSmsReqVO reqVO);
+
/**
* 验证短信验证码,并进行使用
* 如果正确,则将验证码标记成已使用
@@ -32,4 +39,9 @@ public interface SysSmsCodeService {
*/
void useSmsCode(@Mobile String mobile, Integer scene, String code, String usedIp);
+ /**
+ * 根据用户id发送验证码
+ * @param userId 用户id
+ */
+ void sendSmsCodeLogin(Long userId);
}
diff --git a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/impl/SysSmsCodeServiceImpl.java b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/impl/SysSmsCodeServiceImpl.java
index 6ad132aa5..a5796c6fc 100644
--- a/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/impl/SysSmsCodeServiceImpl.java
+++ b/yudao-user-server/src/main/java/cn/iocoder/yudao/userserver/modules/system/service/sms/impl/SysSmsCodeServiceImpl.java
@@ -1,20 +1,27 @@
package cn.iocoder.yudao.userserver.modules.system.service.sms.impl;
import cn.hutool.core.map.MapUtil;
+import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
import cn.iocoder.yudao.coreservice.modules.system.service.sms.SysSmsCoreService;
+import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.SysAuthSendSmsReqVO;
import cn.iocoder.yudao.userserver.modules.system.dal.dataobject.sms.SysSmsCodeDO;
import cn.iocoder.yudao.userserver.modules.system.dal.mysql.sms.SysSmsCodeMapper;
+import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsTemplateCodeConstants;
import cn.iocoder.yudao.userserver.modules.system.framework.sms.SmsCodeProperties;
import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.Date;
+import java.util.concurrent.TimeUnit;
import static cn.hutool.core.util.RandomUtil.randomInt;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
import static cn.iocoder.yudao.userserver.modules.system.enums.SysErrorCodeConstants.*;
/**
@@ -26,22 +33,52 @@ import static cn.iocoder.yudao.userserver.modules.system.enums.SysErrorCodeConst
@Validated
public class SysSmsCodeServiceImpl implements SysSmsCodeService {
+ /**
+ * 验证码 + 手机 在redis中存储的有效时间,单位:分钟
+ */
+ private static final Long CODE_TIME = 10L;
+
@Resource
private SmsCodeProperties smsCodeProperties;
@Resource
private SysSmsCodeMapper smsCodeMapper;
+ @Resource
+ private MbrUserService mbrUserService;
+
@Resource
private SysSmsCoreService smsCoreService;
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
@Override
public void sendSmsCode(String mobile, Integer scene, String createIp) {
// 创建验证码
String code = this.createSmsCode(mobile, scene, createIp);
// 发送验证码
+ // TODO @宋天:这里可以拓展下 SysSmsSceneEnum,支持设置对应的短信模板编号(不同场景的短信文案是不同的)、是否要校验手机号已经注册。这样 Controller 就可以收口成一个接口了。相当于说,不同场景,不同策略
smsCoreService.sendSingleSmsToMember(mobile, null, SysSmsTemplateCodeConstants.USER_SMS_LOGIN,
MapUtil.of("code", code));
+
+ // 存储手机号与验证码到redis,用于标记
+ // TODO @宋天:SysSmsCodeDO 表应该足够,无需增加额外的 redis 存储哇
+ // TODO @宋天:Redis 相关的操作,不要散落到业务层,而是写一个它对应的 RedisDAO。这样,实现业务与技术的解耦
+ // TODO @宋天:直接使用 code 作为 key,会存在 2 个问题:1)code 可能会冲突,多个手机号之间;2)缺少前缀。例如说 sms_code_${code}
+ stringRedisTemplate.opsForValue().set(code,mobile,CODE_TIME, TimeUnit.MINUTES);
+ }
+
+ @Override
+ public void sendSmsNewCode(SysAuthSendSmsReqVO reqVO) {
+ // 检测手机号是否已被使用
+ MbrUserDO userByMobile = mbrUserService.getUserByMobile(reqVO.getMobile());
+ if (userByMobile != null){
+ throw exception(USER_SMS_CODE_IS_EXISTS);
+ }
+
+ // 发送短信
+ this.sendSmsCode(reqVO.getMobile(),reqVO.getScene(),getClientIP());
}
private String createSmsCode(String mobile, Integer scene, String ip) {
@@ -91,4 +128,14 @@ public class SysSmsCodeServiceImpl implements SysSmsCodeService {
.used(true).usedTime(new Date()).usedIp(usedIp).build());
}
+ @Override
+ public void sendSmsCodeLogin(Long userId) {
+ MbrUserDO user = mbrUserService.getUser(userId);
+ if (user == null){
+ throw exception(USER_NOT_EXISTS);
+ }
+ // 发送验证码
+ this.sendSmsCode(user.getMobile(),SysSmsSceneEnum.CHANGE_MOBILE_BY_SMS.getScene(), getClientIP());
+ }
+
}
diff --git a/yudao-user-server/src/main/resources/application-dev.yaml b/yudao-user-server/src/main/resources/application-dev.yaml
index 858e97739..8419e1976 100644
--- a/yudao-user-server/src/main/resources/application-dev.yaml
+++ b/yudao-user-server/src/main/resources/application-dev.yaml
@@ -63,6 +63,11 @@ spring:
--- #################### 定时任务相关配置 ####################
+# Quartz 配置项,对应 QuartzProperties 配置类
+spring:
+ quartz:
+ auto-startup: false # 无需使用 Quartz,交给 yudao-admin-server 环境
+
--- #################### 配置中心相关配置 ####################
# Apollo 配置中心
diff --git a/yudao-user-server/src/main/resources/application-local.yaml b/yudao-user-server/src/main/resources/application-local.yaml
index c39a7cb1b..dc1b90712 100644
--- a/yudao-user-server/src/main/resources/application-local.yaml
+++ b/yudao-user-server/src/main/resources/application-local.yaml
@@ -63,6 +63,11 @@ spring:
--- #################### 定时任务相关配置 ####################
+# Quartz 配置项,对应 QuartzProperties 配置类
+spring:
+ quartz:
+ auto-startup: false # 无需使用 Quartz,交给 yudao-admin-server 环境
+
--- #################### 配置中心相关配置 ####################
# Apollo 配置中心
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/BaseDbAndRedisUnitTest.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/BaseDbAndRedisUnitTest.java
new file mode 100644
index 000000000..2669ef49c
--- /dev/null
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/BaseDbAndRedisUnitTest.java
@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.userserver;
+
+import cn.iocoder.yudao.framework.datasource.config.YudaoDataSourceAutoConfiguration;
+import cn.iocoder.yudao.framework.mybatis.config.YudaoMybatisAutoConfiguration;
+import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
+import cn.iocoder.yudao.userserver.config.RedisTestConfiguration;
+import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure;
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+import org.redisson.spring.starter.RedissonAutoConfiguration;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.jdbc.Sql;
+
+/**
+ * 依赖内存 DB + Redis 的单元测试
+ *
+ * 相比 {@link BaseDbUnitTest} 来说,额外增加了内存 Redis
+ *
+ * @author 芋道源码
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbAndRedisUnitTest.Application.class)
+@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件
+@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB
+public class BaseDbAndRedisUnitTest {
+
+ @Import({
+ // DB 配置类
+ YudaoDataSourceAutoConfiguration.class, // 自己的 DB 配置类
+ DataSourceAutoConfiguration.class, // Spring DB 自动配置类
+ DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类
+ DruidDataSourceAutoConfigure.class, // Druid 自动配置类
+ // MyBatis 配置类
+ YudaoMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类
+ MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类
+ // Redis 配置类
+ RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer
+ RedisAutoConfiguration.class, // Spring Redis 自动配置类
+ YudaoRedisAutoConfiguration.class, // 自己的 Redis 配置类
+ RedissonAutoConfiguration.class, // Redisson 自动高配置类
+ })
+ public static class Application {
+ }
+
+}
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/config/RedisTestConfiguration.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/config/RedisTestConfiguration.java
new file mode 100644
index 000000000..7164efd87
--- /dev/null
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/config/RedisTestConfiguration.java
@@ -0,0 +1,30 @@
+package cn.iocoder.yudao.userserver.config;
+
+import com.github.fppt.jedismock.RedisServer;
+import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
+
+import java.io.IOException;
+
+@Configuration(proxyBeanMethods = false)
+@Lazy(false) // 禁止延迟加载
+@EnableConfigurationProperties(RedisProperties.class)
+public class RedisTestConfiguration {
+
+ /**
+ * 创建模拟的 Redis Server 服务器
+ */
+ @Bean
+ public RedisServer redisServer(RedisProperties properties) throws IOException {
+ RedisServer redisServer = new RedisServer(properties.getPort());
+ // TODO 芋艿:一次执行多个单元测试时,貌似创建多个 spring 容器,导致不进行 stop。这样,就导致端口被占用,无法启动。。。
+ try {
+ redisServer.start();
+ } catch (Exception ignore) {}
+ return redisServer;
+ }
+
+}
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/controller/SysUserProfileControllerTest.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/controller/SysUserProfileControllerTest.java
new file mode 100644
index 000000000..3f32cc82c
--- /dev/null
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/controller/SysUserProfileControllerTest.java
@@ -0,0 +1,60 @@
+package cn.iocoder.yudao.userserver.modules.member.controller;
+
+import cn.iocoder.yudao.userserver.modules.member.controller.user.SysUserProfileController;
+import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
+import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * {@link SysUserProfileController} 的单元测试类
+ *
+ * @author 宋天
+ */
+// TODO @宋天:controller 的单测可以不写哈,因为收益太低了。未来我们做 qa 自动化测试
+public class SysUserProfileControllerTest {
+
+ private MockMvc mockMvc;
+
+ @InjectMocks
+ private SysUserProfileController sysUserProfileController;
+
+ @Mock
+ private MbrUserService userService;
+
+ @Mock
+ private SysSmsCodeService smsCodeService;
+
+ @Before // TODO @宋天:使用 junit5 哈
+ public void setup() {
+ // 初始化
+ MockitoAnnotations.openMocks(this);
+
+ // 构建mvc环境
+ mockMvc = MockMvcBuilders.standaloneSetup(sysUserProfileController).build();
+ }
+
+ @Test
+ public void testUpdateMobile_success() throws Exception {
+ //模拟接口调用
+ this.mockMvc.perform(post("/system/user/profile/update-mobile")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{\"mobile\":\"15819844280\",\"code\":\"123456\"}}"))
+ .andExpect(status().isOk())
+ .andDo(MockMvcResultHandlers.print());
+// TODO @宋天:方法的结尾,不用空行哈
+ }
+
+
+}
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/service/MbrUserServiceImplTest.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/service/MbrUserServiceImplTest.java
index 3639c25db..05571ba2e 100644
--- a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/service/MbrUserServiceImplTest.java
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/member/service/MbrUserServiceImplTest.java
@@ -2,51 +2,64 @@ package cn.iocoder.yudao.userserver.modules.member.service;
import cn.iocoder.yudao.coreservice.modules.infra.service.file.InfFileCoreService;
import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
-import cn.iocoder.yudao.coreservice.modules.system.dal.dataobject.user.SysUserDO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
-import cn.iocoder.yudao.userserver.BaseDbUnitTest;
+import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
+import cn.iocoder.yudao.userserver.BaseDbAndRedisUnitTest;
import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserInfoRespVO;
+import cn.iocoder.yudao.userserver.modules.member.controller.user.vo.MbrUserUpdateMobileReqVO;
import cn.iocoder.yudao.userserver.modules.member.dal.mysql.user.MbrUserMapper;
import cn.iocoder.yudao.userserver.modules.member.service.user.impl.MbrUserServiceImpl;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.SysAuthSendSmsReqVO;
+import cn.iocoder.yudao.userserver.modules.system.enums.sms.SysSmsSceneEnum;
+import cn.iocoder.yudao.userserver.modules.system.service.auth.impl.SysAuthServiceImpl;
+import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
-import org.springframework.http.MediaType;
-import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.util.Assert;
import javax.annotation.Resource;
-import java.io.*;
+import java.io.ByteArrayInputStream;
import java.util.function.Consumer;
-import static cn.hutool.core.util.RandomUtil.randomBytes;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.hutool.core.util.RandomUtil.*;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
+
+// TODO @芋艿:单测的 review,等逻辑都达成一致后
/**
* {@link MbrUserServiceImpl} 的单元测试类
*
* @author 宋天
*/
-@Import(MbrUserServiceImpl.class)
-public class MbrUserServiceImplTest extends BaseDbUnitTest {
+@Import({MbrUserServiceImpl.class, YudaoRedisAutoConfiguration.class})
+public class MbrUserServiceImplTest extends BaseDbAndRedisUnitTest {
@Resource
private MbrUserServiceImpl mbrUserService;
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
@Resource
private MbrUserMapper userMapper;
+ @MockBean
+ private SysAuthServiceImpl authService;
+
@MockBean
private InfFileCoreService fileCoreService;
@MockBean
private PasswordEncoder passwordEncoder;
+ @MockBean
+ private SysSmsCodeService sysSmsCodeService;
+
@Test
public void testUpdateNickName_success(){
// mock 数据
@@ -94,6 +107,36 @@ public class MbrUserServiceImplTest extends BaseDbUnitTest {
assertEquals(avatar, str);
}
+ @Test
+ public void updateMobile_success(){
+ // mock数据
+ String oldMobile = randomNumbers(11);
+ MbrUserDO userDO = randomMbrUserDO();
+ userDO.setMobile(oldMobile);
+ userMapper.insert(userDO);
+
+ // 验证旧手机
+ sysSmsCodeService.sendSmsCodeLogin(userDO.getId());
+
+ // 验证旧手机验证码是否正确
+ sysSmsCodeService.useSmsCode(oldMobile,SysSmsSceneEnum.CHANGE_MOBILE_BY_SMS.getScene(),"123","1.1.1.1");
+ // 验证新手机
+ SysAuthSendSmsReqVO smsReqVO = new SysAuthSendSmsReqVO();
+ smsReqVO.setMobile(oldMobile);
+ smsReqVO.setScene(SysSmsSceneEnum.CHANGE_MOBILE_BY_SMS.getScene());
+ sysSmsCodeService.sendSmsNewCode(smsReqVO);
+
+ // 更新手机号
+ String newMobile = randomNumbers(11);
+ String code = randomNumbers(4);
+ MbrUserUpdateMobileReqVO reqVO = new MbrUserUpdateMobileReqVO();
+ reqVO.setMobile(newMobile);
+ reqVO.setCode(code);
+ mbrUserService.updateMobile(userDO.getId(),reqVO);
+
+ assertEquals(mbrUserService.getUser(userDO.getId()).getMobile(),newMobile);
+ }
+
// ========== 随机对象 ==========
@SafeVarargs
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/controller/SysAuthControllerTest.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/controller/SysAuthControllerTest.java
new file mode 100644
index 000000000..599ebaab6
--- /dev/null
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/controller/SysAuthControllerTest.java
@@ -0,0 +1,75 @@
+package cn.iocoder.yudao.userserver.modules.system.controller;
+
+import cn.iocoder.yudao.coreservice.modules.system.service.social.SysSocialService;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.SysAuthController;
+import cn.iocoder.yudao.userserver.modules.system.service.auth.SysAuthService;
+import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.springframework.http.HttpHeaders.AUTHORIZATION;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * {@link SysAuthController} 的单元测试类
+ *
+ * @author 宋天
+ */
+public class SysAuthControllerTest {
+
+ private MockMvc mockMvc;
+
+ @InjectMocks
+ private SysAuthController sysAuthController;
+
+ @Mock
+ private SysAuthService authService;
+ @Mock
+ private SysSmsCodeService smsCodeService;
+ @Mock
+ private SysSocialService socialService;
+
+
+ @Before
+ public void setup() {
+ // 初始化
+ MockitoAnnotations.openMocks(this);
+
+ // 构建mvc环境
+ mockMvc = MockMvcBuilders.standaloneSetup(sysAuthController).build();
+ }
+
+ @Test
+ public void testResetPassword_success() throws Exception {
+ //模拟接口调用
+ this.mockMvc.perform(post("/reset-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"password\":\"1123\",\"code\":\"123456\"}}"))
+ .andExpect(status().isOk())
+ .andDo(MockMvcResultHandlers.print());
+
+ }
+
+ @Test
+ public void testUpdatePassword_success() throws Exception {
+ //模拟接口调用
+ this.mockMvc.perform(post("/update-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"password\":\"1123\",\"code\":\"123456\",\"oldPassword\":\"1123\"}}"))
+ .andExpect(status().isOk())
+ .andDo(MockMvcResultHandlers.print());
+
+ }
+
+
+
+}
diff --git a/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/service/SysAuthServiceTest.java b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/service/SysAuthServiceTest.java
new file mode 100644
index 000000000..c96425bda
--- /dev/null
+++ b/yudao-user-server/src/test/java/cn/iocoder/yudao/userserver/modules/system/service/SysAuthServiceTest.java
@@ -0,0 +1,130 @@
+package cn.iocoder.yudao.userserver.modules.system.service;
+
+import cn.iocoder.yudao.coreservice.modules.member.dal.dataobject.user.MbrUserDO;
+import cn.iocoder.yudao.coreservice.modules.system.service.auth.SysUserSessionCoreService;
+import cn.iocoder.yudao.coreservice.modules.system.service.logger.SysLoginLogCoreService;
+import cn.iocoder.yudao.coreservice.modules.system.service.social.SysSocialService;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
+import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
+import cn.iocoder.yudao.userserver.BaseDbAndRedisUnitTest;
+import cn.iocoder.yudao.userserver.modules.member.dal.mysql.user.MbrUserMapper;
+import cn.iocoder.yudao.userserver.modules.member.service.user.MbrUserService;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.MbrAuthResetPasswordReqVO;
+import cn.iocoder.yudao.userserver.modules.system.controller.auth.vo.MbrAuthUpdatePasswordReqVO;
+import cn.iocoder.yudao.userserver.modules.system.service.auth.SysAuthService;
+import cn.iocoder.yudao.userserver.modules.system.service.auth.impl.SysAuthServiceImpl;
+import cn.iocoder.yudao.userserver.modules.system.service.sms.SysSmsCodeService;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.hutool.core.util.RandomUtil.randomNumbers;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+// TODO @芋艿:单测的 review,等逻辑都达成一致后
+/**
+ * {@link SysAuthService} 的单元测试类
+ *
+ * @author 宋天
+ */
+@Import({SysAuthServiceImpl.class, YudaoRedisAutoConfiguration.class})
+public class SysAuthServiceTest extends BaseDbAndRedisUnitTest {
+
+ @MockBean
+ private AuthenticationManager authenticationManager;
+ @MockBean
+ private MbrUserService userService;
+ @MockBean
+ private SysSmsCodeService smsCodeService;
+ @MockBean
+ private SysLoginLogCoreService loginLogCoreService;
+ @MockBean
+ private SysUserSessionCoreService userSessionCoreService;
+ @MockBean
+ private SysSocialService socialService;
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+ @MockBean
+ private PasswordEncoder passwordEncoder;
+ @Resource
+ private MbrUserMapper mbrUserMapper;
+ @Resource
+ private SysAuthServiceImpl authService;
+
+ @Test
+ public void testUpdatePassword_success(){
+ // 准备参数
+ MbrUserDO userDO = randomMbrUserDO();
+ mbrUserMapper.insert(userDO);
+
+ // 新密码
+ String newPassword = randomString();
+
+ // 请求实体
+ MbrAuthUpdatePasswordReqVO reqVO = MbrAuthUpdatePasswordReqVO.builder()
+ .oldPassword(userDO.getPassword())
+ .password(newPassword)
+ .build();
+
+ // 测试桩
+ // 这两个相等是为了返回ture这个结果
+ when(passwordEncoder.matches(reqVO.getOldPassword(),reqVO.getOldPassword())).thenReturn(true);
+ when(passwordEncoder.encode(newPassword)).thenReturn(newPassword);
+
+ // 更新用户密码
+ authService.updatePassword(userDO.getId(),reqVO);
+ assertEquals(mbrUserMapper.selectById(userDO.getId()).getPassword(),newPassword);
+ }
+
+ @Test
+ public void testResetPassword_success(){
+ // 准备参数
+ MbrUserDO userDO = randomMbrUserDO();
+ mbrUserMapper.insert(userDO);
+
+ // 随机密码
+ String password = randomNumbers(11);
+ // 随机验证码
+ String code = randomNumbers(4);
+
+ MbrAuthResetPasswordReqVO reqVO = MbrAuthResetPasswordReqVO.builder()
+ .password(password)
+ .code(code)
+ .build();
+ // 放入code+手机号
+ stringRedisTemplate.opsForValue().set(code,userDO.getMobile(),10, TimeUnit.MINUTES);
+
+ // mock
+ when(passwordEncoder.encode(password)).thenReturn(password);
+
+ // 更新用户密码
+ authService.resetPassword(reqVO);
+ assertEquals(mbrUserMapper.selectById(userDO.getId()).getPassword(),password);
+ }
+
+
+ // ========== 随机对象 ==========
+
+ @SafeVarargs
+ private static MbrUserDO randomMbrUserDO(Consumer... consumers) {
+ Consumer consumer = (o) -> {
+ o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
+ o.setPassword(randomString());
+ };
+ return randomPojo(MbrUserDO.class, ArrayUtils.append(consumer, consumers));
+ }
+
+
+}
diff --git a/yudao-vue-ui/.hbuilderx/launch.json b/yudao-vue-ui/.hbuilderx/launch.json
new file mode 100644
index 000000000..07c1d5fa5
--- /dev/null
+++ b/yudao-vue-ui/.hbuilderx/launch.json
@@ -0,0 +1,16 @@
+{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
+ // launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
+ "version": "0.0",
+ "configurations": [{
+ "default" :
+ {
+ "launchtype" : "local"
+ },
+ "h5" :
+ {
+ "launchtype" : "local"
+ },
+ "type" : "uniCloud"
+ }
+ ]
+}
diff --git a/yudao-vue-ui/App.vue b/yudao-vue-ui/App.vue
new file mode 100644
index 000000000..a26228403
--- /dev/null
+++ b/yudao-vue-ui/App.vue
@@ -0,0 +1,79 @@
+
+
+
diff --git a/yudao-vue-ui/api/member/userProfile.js b/yudao-vue-ui/api/member/userProfile.js
new file mode 100644
index 000000000..2effa1eb7
--- /dev/null
+++ b/yudao-vue-ui/api/member/userProfile.js
@@ -0,0 +1,23 @@
+import { request } from '@/common/js/request.js'
+
+// 获得用户的基本信息
+export function getUserInfo() {
+ return request({
+ url: 'member/user/profile/get',
+ method: 'get'
+ })
+}
+
+// 修改
+export function updateNickname(nickname) {
+ return request({
+ url: 'member/user/profile/update-nickname',
+ method: 'post',
+ header: {
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+ data: {
+ nickname
+ }
+ })
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/api/system/auth.js b/yudao-vue-ui/api/system/auth.js
new file mode 100644
index 000000000..428655591
--- /dev/null
+++ b/yudao-vue-ui/api/system/auth.js
@@ -0,0 +1,34 @@
+import { request } from '@/common/js/request.js'
+
+// 手机号 + 密码登陆
+export function login(mobile, password) {
+ return request({
+ url: 'login',
+ method: 'post',
+ data: {
+ mobile, password
+ }
+ })
+}
+
+// 手机号 + 验证码登陆
+export function smsLogin(mobile, code) {
+ return request({
+ url: 'sms-login',
+ method: 'post',
+ data: {
+ mobile, code
+ }
+ })
+}
+
+// 发送手机验证码
+export function sendSmsCode(mobile, scene) {
+ return request({
+ url: 'send-sms-code',
+ method: 'post',
+ data: {
+ mobile, scene
+ }
+ })
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/common/css/common.css b/yudao-vue-ui/common/css/common.css
new file mode 100644
index 000000000..2f22c237e
--- /dev/null
+++ b/yudao-vue-ui/common/css/common.css
@@ -0,0 +1,182 @@
+/* #ifndef APP-PLUS-NVUE */
+view,
+scroll-view,
+swiper,
+swiper-item,
+cover-view,
+cover-image,
+icon,
+text,
+rich-text,
+progress,
+button,
+checkbox,
+form,
+input,
+label,
+radio,
+slider,
+switch,
+textarea,
+navigator,
+audio,
+camera,
+image,
+video {
+ box-sizing: border-box;
+}
+image{
+ display: block;
+}
+text{
+ line-height: 1;
+ /* font-family: Helvetica Neue, Helvetica, sans-serif; */
+}
+button{
+ padding: 0;
+ margin: 0;
+ background-color: rgba(0,0,0,0) !important;
+}
+button:after{
+ border: 0;
+}
+.bottom-fill{
+ height: constant(safe-area-inset-bottom);
+ height: env(safe-area-inset-bottom);
+}
+.fix-bot{
+ box-sizing: content-box;
+ padding-bottom: constant(safe-area-inset-bottom);
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+/* 边框 */
+.round{
+ position: relative;
+ border-radius: 100rpx;
+}
+.round:after{
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 200%;
+ height: 200%;
+ transform: scale(.5) translate(-50%,-50%);
+ border: 1px solid #878787;
+ border-radius: 100rpx;
+ box-sizing: border-box;
+}
+.b-b:after{
+ position: absolute;
+ z-index: 3;
+ left: 0;
+ top: auto;
+ bottom: 0;
+ right: 0;
+ height: 0;
+ content: '';
+ transform: scaleY(.5);
+ border-bottom: 1px solid #e0e0e0;
+}
+.b-t:before{
+ position: absolute;
+ z-index: 3;
+ left: 0;
+ top: 0;
+ right: 0;
+ height: 0;
+ content: '';
+ transform: scaleY(.5);
+ border-bottom: 1px solid #e5e5e5;
+}
+.b-r:after{
+ position: absolute;
+ z-index: 3;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 0;
+ content: '';
+ transform: scaleX(.5);
+ border-right: 1px solid #e5e5e5;
+}
+.b-l:before{
+ position: absolute;
+ z-index: 3;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 0;
+ content: '';
+ transform: scaleX(.5);
+ border-left: 1px solid #e5e5e5;
+}
+.b-b, .b-t, .b-l, .b-r{
+ position: relative;
+}
+/* 点击态 */
+.hover-gray {
+ background: #fafafa !important;
+}
+.hover-dark {
+ background: #f0f0f0 !important;
+}
+
+.hover-opacity {
+ opacity: 0.7;
+}
+
+/* #endif */
+
+.clamp {
+ /* #ifdef APP-PLUS-NVUE */
+ lines: 1;
+ /* #endif */
+ /* #ifndef APP-PLUS-NVUE */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ /* #endif */
+}
+.clamp2 {
+ /* #ifdef APP-PLUS-NVUE */
+ lines: 2;
+ /* #endif */
+ /* #ifndef APP-PLUS-NVUE */
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ /* #endif */
+}
+
+/* 布局 */
+.row{
+ /* #ifndef APP-PLUS-NVUE */
+ display:flex;
+ /* #endif */
+ flex-direction:row;
+ align-items: center;
+}
+.column{
+ /* #ifndef APP-PLUS-NVUE */
+ display:flex;
+ /* #endif */
+ flex-direction: column;
+}
+.center{
+ /* #ifndef APP-PLUS-NVUE */
+ display:flex;
+ /* #endif */
+ align-items: center;
+ justify-content: center;
+}
+.fill{
+ flex: 1;
+}
+/* input */
+.placeholder{
+ color: #999 !important;
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/common/css/icon.css b/yudao-vue-ui/common/css/icon.css
new file mode 100644
index 000000000..653ba4d0c
--- /dev/null
+++ b/yudao-vue-ui/common/css/icon.css
@@ -0,0 +1,271 @@
+@font-face {
+ font-family: "mix-icon";
+ font-weight: normal;
+ font-style: normal;
+ src: url('https://at.alicdn.com/t/font_1913318_2ui3nitf38x.ttf') format('truetype'); // TODO 芋艿: icon 怎么搞?
+}
+
+.mix-icon {
+ font-family: "mix-icon" !important;
+ font-size: 16px;
+ font-style: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-fanhui:before {
+ content: "\e7d5";
+}
+
+.icon-shoujihaoma:before {
+ content: "\e7ec";
+}
+
+.icon-close:before {
+ content: "\e60f";
+}
+
+.icon-xingbie-nv:before {
+ content: "\e60e";
+}
+
+.icon-wuliuyunshu:before {
+ content: "\e7ed";
+}
+
+.icon-jingpin:before {
+ content: "\e608";
+}
+
+.icon-zhangdanmingxi01:before {
+ content: "\e637";
+}
+
+.icon-tixian1:before {
+ content: "\e625";
+}
+
+.icon-chongzhi:before {
+ content: "\e605";
+}
+
+.icon-wodezhanghu_zijinjilu:before {
+ content: "\e615";
+}
+
+.icon-tixian:before {
+ content: "\e6ab";
+}
+
+.icon-qianbao:before {
+ content: "\e6c4";
+}
+
+.icon-guanbi1:before {
+ content: "\e61a";
+}
+
+.icon-daipingjia:before {
+ content: "\e604";
+}
+
+.icon-daifahuo:before {
+ content: "\e6bd";
+}
+
+.icon-yue:before {
+ content: "\e600";
+}
+
+.icon-wxpay:before {
+ content: "\e602";
+}
+
+.icon-alipay:before {
+ content: "\e603";
+}
+
+.icon-tishi:before {
+ content: "\e662";
+}
+
+.icon-shoucang-1:before {
+ content: "\e607";
+}
+
+.icon-gouwuche:before {
+ content: "\e657";
+}
+
+.icon-shoucang:before {
+ content: "\e645";
+}
+
+.icon-home:before {
+ content: "\e60c";
+}
+
+/* .icon-bangzhu1:before {
+ content: "\e63d"; // 帮助
+} */
+
+.icon-xingxing:before {
+ content: "\e70b";
+}
+
+.icon-shuxiangliebiao:before {
+ content: "\e635";
+}
+
+.icon-hengxiangliebiao:before {
+ content: "\e636";
+}
+
+.icon-guanbi2:before {
+ content: "\e7be";
+}
+
+.icon-down:before {
+ content: "\e65c";
+}
+
+.icon-arrow-top:before {
+ content: "\e63e";
+}
+
+.icon-xiaoxi:before {
+ content: "\e634";
+}
+
+.icon-saoma:before {
+ content: "\e655";
+}
+
+.icon-dizhi1:before {
+ content: "\e618";
+}
+
+.icon-ditu-copy:before {
+ content: "\e609";
+}
+
+.icon-lajitong:before {
+ content: "\e682";
+}
+
+.icon-bianji:before {
+ content: "\e60d"; // 编辑
+}
+
+.icon-yanzhengma1:before {
+ content: "\e613";
+}
+
+.icon-yanjing:before {
+ content: "\e65b";
+}
+
+.icon-mima:before {
+ content: "\e628";
+}
+
+.icon-biyan:before {
+ content: "\e633";
+}
+
+.icon-iconfontweixin:before {
+ content: "\e611";
+}
+
+.icon-shouye:before {
+ content: "\e626";
+}
+
+.icon-daifukuan:before {
+ content: "\e68f";
+}
+
+.icon-pinglun-copy:before {
+ content: "\e612";
+}
+
+.icon-lishijilu:before {
+ content: "\e6b9";
+}
+
+.icon-shoucang_xuanzhongzhuangtai:before {
+ content: "\e6a9";
+}
+
+.icon-share:before {
+ content: "\e656";
+}
+
+.icon-shezhi1:before {
+ content: "\e61d";
+}
+
+.icon-shouhoutuikuan:before {
+ content: "\e631";
+}
+
+.icon-dizhi:before {
+ content: "\e614";
+}
+
+.icon-yishouhuo:before {
+ content: "\e71a";
+}
+
+.icon-xuanzhong:before {
+ content: "\e632";
+}
+
+.icon-xiangzuo:before {
+ content: "\e653";
+}
+
+.icon-iconfontxingxing:before {
+ content: "\e6b0";
+}
+
+.icon-jia2:before {
+ content: "\e60a";
+}
+
+.icon-sousuo:before {
+ content: "\e7ce";
+}
+
+.icon-xiala:before {
+ content: "\e644";
+}
+
+.icon-xia:before {
+ content: "\e62d";
+}
+
+.icon--jianhao:before {
+ content: "\e60b";
+}
+
+.icon-you:before {
+ content: "\e606";
+}
+
+.icon-yk_yuanquan:before {
+ content: "\e601";
+}
+
+.icon-xing:before {
+ content: "\e627";
+}
+
+.icon-guanbi:before {
+ content: "\e71d";
+}
+
+.icon-loading:before {
+ content: "\e646";
+}
+
diff --git a/yudao-vue-ui/common/js/request.js b/yudao-vue-ui/common/js/request.js
new file mode 100644
index 000000000..d78866e4e
--- /dev/null
+++ b/yudao-vue-ui/common/js/request.js
@@ -0,0 +1,59 @@
+import store from '@/store'
+import { msg, getAuthToken } from './util'
+
+const BASE_URL = 'http://127.0.0.1:28080/api/';
+
+export const request = (options) => {
+ return new Promise((resolve, reject) => {
+ // 发起请求
+ const authToken = getAuthToken();
+ uni.request({
+ url: BASE_URL + options.url,
+ method: options.method || 'GET',
+ data: options.data || {},
+ header: {
+ ...options.header,
+ 'Authorization': authToken ? `Bearer ${authToken}` : ''
+ }
+ }).then(res => {
+ res = res[1];
+ const statusCode = res.statusCode;
+ if (statusCode !== 200) {
+ msg('请求失败,请重试');
+ return;
+ }
+
+ const code = res.data.code;
+ const message = res.data.msg;
+ // Token 过期,引导重新登陆
+ if (code === 401) {
+ msg('登录信息已过期,请重新登录');
+ store.commit('logout');
+ // reject('无效的登录信息');
+ return;
+ }
+ // 系统异常
+ if (code === 500) {
+ msg('系统异常,请稍后重试');
+ reject(new Error(message));
+ return;
+ }
+ // 其它失败情况
+ if (code > 0) {
+ msg(message);
+ // 提供 code + msg,可以基于 code 做进一步的处理。当然,一般情况下是不需要的。
+ // 不需要的场景:手机登录时,密码不正确;
+ // 需要的场景:微信登录时,未绑定手机,后端会返回一个 code 码,前端需要基于它跳转到绑定手机界面;
+ reject({
+ 'code': code,
+ 'msg': message
+ });
+ return;
+ }
+ // 处理成功,则只返回成功的 data 数据,不返回 code 和 msg
+ resolve(res.data.data);
+ }).catch((err) => {
+ reject(err);
+ })
+ })
+}
diff --git a/yudao-vue-ui/common/js/util.js b/yudao-vue-ui/common/js/util.js
new file mode 100644
index 000000000..688265537
--- /dev/null
+++ b/yudao-vue-ui/common/js/util.js
@@ -0,0 +1,136 @@
+let _debounceTimeout = null,
+ _throttleRunning = false
+
+/**
+ * 防抖
+ * 参考文章 https://juejin.cn/post/6844903669389885453
+ *
+ * @param {Function} 执行函数
+ * @param {Number} delay 延时ms
+ */
+export const debounce = (fn, delay=500) => {
+ clearTimeout(_debounceTimeout);
+ _debounceTimeout = setTimeout(() => {
+ fn();
+ }, delay);
+}
+
+/**
+ * 节流
+ * 参考文章 https://juejin.cn/post/6844903669389885453
+ *
+ * @param {Function} 执行函数
+ * @param {Number} delay 延时ms
+ */
+export const throttle = (fn, delay=500) => {
+ if(_throttleRunning){
+ return;
+ }
+ _throttleRunning = true;
+ fn();
+ setTimeout(() => {
+ _throttleRunning = false;
+ }, delay);
+}
+
+/**
+ * toast 提示
+ *
+ * @param {String} title 标题
+ * @param {Object} param 拓展参数
+ * @param {Integer} param.duration 持续时间
+ * @param {Boolean} param.mask 是否遮罩
+ * @param {Boolean} param.icon 图标
+ */
+export const msg = (title = '', param={}) => {
+ if (!title) {
+ return;
+ }
+ uni.showToast({
+ title,
+ duration: param.duration || 1500,
+ mask: param.mask || false,
+ icon: param.icon || 'none' // TODO 芋艿:是否要区分下 error 的提示,或者专门的封装
+ });
+}
+
+/**
+ * 检查登录
+ *
+ * @param {Boolean} options.nav 如果未登陆,是否跳转到登陆页。默认为 true
+ * @return {Boolean} 是否登陆
+ */
+export const isLogin = (options = {}) => {
+ const token = getAuthToken();
+ if (token) {
+ return true;
+ }
+ // 若 nav 不为 false,则进行跳转登陆页
+ if (options.nav !== false) {
+ uni.navigateTo({
+ url: '/pages/auth/login'
+ })
+ }
+ return false;
+}
+
+/**
+ * 获得认证 Token
+ *
+ * @return 认证 Token
+ */
+export const getAuthToken = () => {
+ return uni.getStorageSync('token');
+}
+
+/**
+ * 校验参数
+ *
+ * @param {String} 字符串
+ * @param {String} 数据的类型。例如说 mobile 手机号、tel 座机 TODO 芋艿:是否组件里解决
+ */
+export const checkStr = (str, type) => {
+ switch (type) {
+ case 'mobile': //手机号码
+ return /^1[3|4|5|6|7|8|9][0-9]{9}$/.test(str);
+ case 'tel': //座机
+ return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
+ case 'card': //身份证
+ return /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(str);
+ case 'mobileCode': //6位数字验证码
+ return /^[0-9]{6}$/.test(str)
+ case 'pwd': //密码以字母开头,长度在6~18之间,只能包含字母、数字和下划线
+ return /^([a-zA-Z0-9_]){6,18}$/.test(str)
+ case 'payPwd': //支付密码 6位纯数字
+ return /^[0-9]{6}$/.test(str)
+ case 'postal': //邮政编码
+ return /[1-9]\d{5}(?!\d)/.test(str);
+ case 'QQ': //QQ号
+ return /^[1-9][0-9]{4,9}$/.test(str);
+ case 'email': //邮箱
+ return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
+ case 'money': //金额(小数点2位)
+ return /^\d*(?:\.\d{0,2})?$/.test(str);
+ case 'URL': //网址
+ return /(http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?/.test(str)
+ case 'IP': //IP
+ return /((?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d))/.test(str);
+ case 'date': //日期时间
+ return /^(\d{4})\-(\d{2})\-(\d{2}) (\d{2})(?:\:\d{2}|:(\d{2}):(\d{2}))$/.test(str) || /^(\d{4})\-(\d{2})\-(\d{2})$/
+ .test(str)
+ case 'number': //数字
+ return /^[0-9]$/.test(str);
+ case 'english': //英文
+ return /^[a-zA-Z]+$/.test(str);
+ case 'chinese': //中文
+ return /^[\\u4E00-\\u9FA5]+$/.test(str);
+ case 'lower': //小写
+ return /^[a-z]+$/.test(str);
+ case 'upper': //大写
+ return /^[A-Z]+$/.test(str);
+ case 'HTML': //HTML标记
+ return /<("[^"]*"|'[^']*'|[^'">])*>/.test(str);
+ default:
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/common/mixin/mixin.js b/yudao-vue-ui/common/mixin/mixin.js
new file mode 100644
index 000000000..701d70222
--- /dev/null
+++ b/yudao-vue-ui/common/mixin/mixin.js
@@ -0,0 +1,96 @@
+// import {request} from '@/common/js/request'
+
+export default{
+ data() {
+ return {
+ page: 0, // 页码
+ pageNum: 6, // 每页加载数据量
+ loadingType: 1, // 加载类型。0 加载前;1 加载中;2 没有更多
+ isLoading: false, // 刷新数据
+ loaded: false, // 加载完毕
+ }
+ },
+ methods: {
+ /**
+ * 打印日志,方便调试
+ *
+ * @param {Object} data 数据
+ */
+ log(data) {
+ console.log(JSON.parse(JSON.stringify(data)))
+ },
+
+ /**
+ * navigatorTo 跳转页面
+ *
+ * @param {String} url
+ * @param {Object} options 可选参数
+ * @param {Boolean} options.login 是否检测登录
+ */
+ navTo(url, options={}) {
+ this.$util.throttle(() => {
+ if (!url) {
+ return;
+ }
+ // 如果需要登陆,并且未登陆,则跳转到登陆界面
+ if ((~url.indexOf('login=1') || options.login) && !this.$store.getters.hasLogin){
+ url = '/pages/auth/login';
+ }
+ // 跳转到指定 url 地址
+ uni.navigateTo({
+ url
+ })
+ }, 300)
+ },
+
+ /**
+ * $request云函数请求 TODO 芋艿:需要改成自己的
+ * @param {String} module
+ * @param {String} operation
+ * @param {Boolean} data 请求参数
+ * @param {Boolean} ext 附加参数
+ * @param {Boolean} ext.showLoading 是否显示loading状态,默认不显示
+ * @param {Boolean} ext.hideLoading 是否关闭loading状态,默认关闭
+ * @param {Boolean} ext.login 未登录拦截
+ * @param {Boolean} ext.setLoaded 加载完成是设置页面加载完毕
+ */
+ $request(module, operation, data={}, ext={}){
+ if(ext.login && !this.$util.isLogin()){
+ return;
+ }
+ if(ext.showLoading){
+ this.isLoading = true;
+ }
+ return new Promise((resolve, reject)=> {
+ request(module, operation, data, ext).then(result => {
+ if(ext.hideLoading !== false){
+ this.isLoading = false;
+ }
+ setTimeout(()=>{
+ if(this.setLoaded !== false){
+ this.loaded = true;
+ }
+ }, 100)
+ this.$refs.confirmBtn && this.$refs.confirmBtn.stop();
+ resolve(result);
+ }).catch((err) => {
+ reject(err);
+ })
+ })
+ },
+ imageOnLoad(data, key){ // TODO 芋艿:需要改成自己的
+ setTimeout(()=>{
+ this.$set(data, 'loaded', true);
+ }, 100)
+ },
+ showPopup(key){ // TODO 芋艿:需要改成自己的
+ this.$util.throttle(()=>{
+ this.$refs[key].open();
+ }, 200)
+ },
+ hidePopup(key){ // TODO 芋艿:需要改成自己的
+ this.$refs[key].close();
+ },
+ stopPrevent(){}, // TODO 芋艿:需要改成自己的
+ },
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/jyf-parser/jyf-parser.vue b/yudao-vue-ui/components/jyf-parser/jyf-parser.vue
new file mode 100644
index 000000000..01484f9d2
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/jyf-parser.vue
@@ -0,0 +1,630 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/jyf-parser/libs/CssHandler.js b/yudao-vue-ui/components/jyf-parser/libs/CssHandler.js
new file mode 100644
index 000000000..8000377d1
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/libs/CssHandler.js
@@ -0,0 +1,97 @@
+const cfg = require('./config.js'),
+ isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+
+function CssHandler(tagStyle) {
+ var styles = Object.assign(Object.create(null), cfg.userAgentStyles);
+ for (var item in tagStyle)
+ styles[item] = (styles[item] ? styles[item] + ';' : '') + tagStyle[item];
+ this.styles = styles;
+}
+CssHandler.prototype.getStyle = function(data) {
+ this.styles = new parser(data, this.styles).parse();
+}
+CssHandler.prototype.match = function(name, attrs) {
+ var tmp, matched = (tmp = this.styles[name]) ? tmp + ';' : '';
+ if (attrs.class) {
+ var items = attrs.class.split(' ');
+ for (var i = 0, item; item = items[i]; i++)
+ if (tmp = this.styles['.' + item])
+ matched += tmp + ';';
+ }
+ if (tmp = this.styles['#' + attrs.id])
+ matched += tmp + ';';
+ return matched;
+}
+module.exports = CssHandler;
+
+function parser(data, init) {
+ this.data = data;
+ this.floor = 0;
+ this.i = 0;
+ this.list = [];
+ this.res = init;
+ this.state = this.Space;
+}
+parser.prototype.parse = function() {
+ for (var c; c = this.data[this.i]; this.i++)
+ this.state(c);
+ return this.res;
+}
+parser.prototype.section = function() {
+ return this.data.substring(this.start, this.i);
+}
+// 状态机
+parser.prototype.Space = function(c) {
+ if (c == '.' || c == '#' || isLetter(c)) {
+ this.start = this.i;
+ this.state = this.Name;
+ } else if (c == '/' && this.data[this.i + 1] == '*')
+ this.Comment();
+ else if (!cfg.blankChar[c] && c != ';')
+ this.state = this.Ignore;
+}
+parser.prototype.Comment = function() {
+ this.i = this.data.indexOf('*/', this.i) + 1;
+ if (!this.i) this.i = this.data.length;
+ this.state = this.Space;
+}
+parser.prototype.Ignore = function(c) {
+ if (c == '{') this.floor++;
+ else if (c == '}' && !--this.floor) this.state = this.Space;
+}
+parser.prototype.Name = function(c) {
+ if (cfg.blankChar[c]) {
+ this.list.push(this.section());
+ this.state = this.NameSpace;
+ } else if (c == '{') {
+ this.list.push(this.section());
+ this.Content();
+ } else if (c == ',') {
+ this.list.push(this.section());
+ this.Comma();
+ } else if (!isLetter(c) && (c < '0' || c > '9') && c != '-' && c != '_')
+ this.state = this.Ignore;
+}
+parser.prototype.NameSpace = function(c) {
+ if (c == '{') this.Content();
+ else if (c == ',') this.Comma();
+ else if (!cfg.blankChar[c]) this.state = this.Ignore;
+}
+parser.prototype.Comma = function() {
+ while (cfg.blankChar[this.data[++this.i]]);
+ if (this.data[this.i] == '{') this.Content();
+ else {
+ this.start = this.i--;
+ this.state = this.Name;
+ }
+}
+parser.prototype.Content = function() {
+ this.start = ++this.i;
+ if ((this.i = this.data.indexOf('}', this.i)) == -1) this.i = this.data.length;
+ var content = this.section();
+ for (var i = 0, item; item = this.list[i++];)
+ if (this.res[item]) this.res[item] += ';' + content;
+ else this.res[item] = content;
+ this.list = [];
+ this.state = this.Space;
+}
diff --git a/yudao-vue-ui/components/jyf-parser/libs/MpHtmlParser.js b/yudao-vue-ui/components/jyf-parser/libs/MpHtmlParser.js
new file mode 100644
index 000000000..8911e36d3
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/libs/MpHtmlParser.js
@@ -0,0 +1,534 @@
+/**
+ * html 解析器
+ * @tutorial https://github.com/jin-yufeng/Parser
+ * @version 20200719
+ * @author JinYufeng
+ * @listens MIT
+ */
+const cfg = require('./config.js'),
+ blankChar = cfg.blankChar,
+ CssHandler = require('./CssHandler.js'),
+ windowWidth = uni.getSystemInfoSync().windowWidth;
+var emoji;
+
+function MpHtmlParser(data, options = {}) {
+ this.attrs = {};
+ this.CssHandler = new CssHandler(options.tagStyle, windowWidth);
+ this.data = data;
+ this.domain = options.domain;
+ this.DOM = [];
+ this.i = this.start = this.audioNum = this.imgNum = this.videoNum = 0;
+ options.prot = (this.domain || '').includes('://') ? this.domain.split('://')[0] : 'http';
+ this.options = options;
+ this.state = this.Text;
+ this.STACK = [];
+ // 工具函数
+ this.bubble = () => {
+ for (var i = this.STACK.length, item; item = this.STACK[--i];) {
+ if (cfg.richOnlyTags[item.name]) {
+ if (item.name == 'table' && !Object.hasOwnProperty.call(item, 'c')) item.c = 1;
+ return false;
+ }
+ item.c = 1;
+ }
+ return true;
+ }
+ this.decode = (val, amp) => {
+ var i = -1,
+ j, en;
+ while (1) {
+ if ((i = val.indexOf('&', i + 1)) == -1) break;
+ if ((j = val.indexOf(';', i + 2)) == -1) break;
+ if (val[i + 1] == '#') {
+ en = parseInt((val[i + 2] == 'x' ? '0' : '') + val.substring(i + 2, j));
+ if (!isNaN(en)) val = val.substr(0, i) + String.fromCharCode(en) + val.substr(j + 1);
+ } else {
+ en = val.substring(i + 1, j);
+ if (cfg.entities[en] || en == amp)
+ val = val.substr(0, i) + (cfg.entities[en] || '&') + val.substr(j + 1);
+ }
+ }
+ return val;
+ }
+ this.getUrl = url => {
+ if (url[0] == '/') {
+ if (url[1] == '/') url = this.options.prot + ':' + url;
+ else if (this.domain) url = this.domain + url;
+ } else if (this.domain && url.indexOf('data:') != 0 && !url.includes('://'))
+ url = this.domain + '/' + url;
+ return url;
+ }
+ this.isClose = () => this.data[this.i] == '>' || (this.data[this.i] == '/' && this.data[this.i + 1] == '>');
+ this.section = () => this.data.substring(this.start, this.i);
+ this.parent = () => this.STACK[this.STACK.length - 1];
+ this.siblings = () => this.STACK.length ? this.parent().children : this.DOM;
+}
+MpHtmlParser.prototype.parse = function() {
+ if (emoji) this.data = emoji.parseEmoji(this.data);
+ for (var c; c = this.data[this.i]; this.i++)
+ this.state(c);
+ if (this.state == this.Text) this.setText();
+ while (this.STACK.length) this.popNode(this.STACK.pop());
+ return this.DOM;
+}
+// 设置属性
+MpHtmlParser.prototype.setAttr = function() {
+ var name = this.attrName.toLowerCase(),
+ val = this.attrVal;
+ if (cfg.boolAttrs[name]) this.attrs[name] = 'T';
+ else if (val) {
+ if (name == 'src' || (name == 'data-src' && !this.attrs.src)) this.attrs.src = this.getUrl(this.decode(val, 'amp'));
+ else if (name == 'href' || name == 'style') this.attrs[name] = this.decode(val, 'amp');
+ else if (name.substr(0, 5) != 'data-') this.attrs[name] = val;
+ }
+ this.attrVal = '';
+ while (blankChar[this.data[this.i]]) this.i++;
+ if (this.isClose()) this.setNode();
+ else {
+ this.start = this.i;
+ this.state = this.AttrName;
+ }
+}
+// 设置文本节点
+MpHtmlParser.prototype.setText = function() {
+ var back, text = this.section();
+ if (!text) return;
+ text = (cfg.onText && cfg.onText(text, () => back = true)) || text;
+ if (back) {
+ this.data = this.data.substr(0, this.start) + text + this.data.substr(this.i);
+ let j = this.start + text.length;
+ for (this.i = this.start; this.i < j; this.i++) this.state(this.data[this.i]);
+ return;
+ }
+ if (!this.pre) {
+ // 合并空白符
+ var tmp = [];
+ for (let i = text.length, c; c = text[--i];)
+ if (!blankChar[c] || (!blankChar[tmp[0]] && (c = ' '))) tmp.unshift(c);
+ text = tmp.join('');
+ }
+ this.siblings().push({
+ type: 'text',
+ text: this.decode(text)
+ });
+}
+// 设置元素节点
+MpHtmlParser.prototype.setNode = function() {
+ var node = {
+ name: this.tagName.toLowerCase(),
+ attrs: this.attrs
+ },
+ close = cfg.selfClosingTags[node.name];
+ this.attrs = {};
+ if (!cfg.ignoreTags[node.name]) {
+ // 处理属性
+ var attrs = node.attrs,
+ style = this.CssHandler.match(node.name, attrs, node) + (attrs.style || ''),
+ styleObj = {};
+ if (attrs.id) {
+ if (this.options.compress & 1) attrs.id = void 0;
+ else if (this.options.useAnchor) this.bubble();
+ }
+ if ((this.options.compress & 2) && attrs.class) attrs.class = void 0;
+ switch (node.name) {
+ case 'a':
+ case 'ad': // #ifdef APP-PLUS
+ case 'iframe':
+ // #endif
+ this.bubble();
+ break;
+ case 'font':
+ if (attrs.color) {
+ styleObj['color'] = attrs.color;
+ attrs.color = void 0;
+ }
+ if (attrs.face) {
+ styleObj['font-family'] = attrs.face;
+ attrs.face = void 0;
+ }
+ if (attrs.size) {
+ var size = parseInt(attrs.size);
+ if (size < 1) size = 1;
+ else if (size > 7) size = 7;
+ var map = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
+ styleObj['font-size'] = map[size - 1];
+ attrs.size = void 0;
+ }
+ break;
+ case 'embed':
+ // #ifndef APP-PLUS
+ var src = node.attrs.src || '',
+ type = node.attrs.type || '';
+ if (type.includes('video') || src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8'))
+ node.name = 'video';
+ else if (type.includes('audio') || src.includes('.m4a') || src.includes('.wav') || src.includes('.mp3') || src.includes(
+ '.aac'))
+ node.name = 'audio';
+ else break;
+ if (node.attrs.autostart)
+ node.attrs.autoplay = 'T';
+ node.attrs.controls = 'T';
+ // #endif
+ // #ifdef APP-PLUS
+ this.bubble();
+ break;
+ // #endif
+ case 'video':
+ case 'audio':
+ if (!attrs.id) attrs.id = node.name + (++this[`${node.name}Num`]);
+ else this[`${node.name}Num`]++;
+ if (node.name == 'video') {
+ if (this.videoNum > 3)
+ node.lazyLoad = 1;
+ if (attrs.width) {
+ styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px');
+ attrs.width = void 0;
+ }
+ if (attrs.height) {
+ styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px');
+ attrs.height = void 0;
+ }
+ }
+ attrs.source = [];
+ if (attrs.src) {
+ attrs.source.push(attrs.src);
+ attrs.src = void 0;
+ }
+ this.bubble();
+ break;
+ case 'td':
+ case 'th':
+ if (attrs.colspan || attrs.rowspan)
+ for (var k = this.STACK.length, item; item = this.STACK[--k];)
+ if (item.name == 'table') {
+ item.c = void 0;
+ break;
+ }
+ }
+ if (attrs.align) {
+ styleObj['text-align'] = attrs.align;
+ attrs.align = void 0;
+ }
+ // 压缩 style
+ var styles = style.split(';');
+ style = '';
+ for (var i = 0, len = styles.length; i < len; i++) {
+ var info = styles[i].split(':');
+ if (info.length < 2) continue;
+ let key = info[0].trim().toLowerCase(),
+ value = info.slice(1).join(':').trim();
+ if (value.includes('-webkit') || value.includes('-moz') || value.includes('-ms') || value.includes('-o') || value.includes(
+ 'safe'))
+ style += `;${key}:${value}`;
+ else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import'))
+ styleObj[key] = value;
+ }
+ if (node.name == 'img') {
+ if (attrs.src && !attrs.ignore) {
+ if (this.bubble())
+ attrs.i = (this.imgNum++).toString();
+ else attrs.ignore = 'T';
+ }
+ if (attrs.ignore) {
+ style += ';-webkit-touch-callout:none';
+ styleObj['max-width'] = '100%';
+ }
+ var width;
+ if (styleObj.width) width = styleObj.width;
+ else if (attrs.width) width = attrs.width.includes('%') ? attrs.width : attrs.width + 'px';
+ if (width) {
+ styleObj.width = width;
+ attrs.width = '100%';
+ if (parseInt(width) > windowWidth) {
+ styleObj.height = '';
+ if (attrs.height) attrs.height = void 0;
+ }
+ }
+ if (styleObj.height) {
+ attrs.height = styleObj.height;
+ styleObj.height = '';
+ } else if (attrs.height && !attrs.height.includes('%'))
+ attrs.height += 'px';
+ }
+ for (var key in styleObj) {
+ var value = styleObj[key];
+ if (!value) continue;
+ if (key.includes('flex') || key == 'order' || key == 'self-align') node.c = 1;
+ // 填充链接
+ if (value.includes('url')) {
+ var j = value.indexOf('(');
+ if (j++ != -1) {
+ while (value[j] == '"' || value[j] == "'" || blankChar[value[j]]) j++;
+ value = value.substr(0, j) + this.getUrl(value.substr(j));
+ }
+ }
+ // 转换 rpx
+ else if (value.includes('rpx'))
+ value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px');
+ else if (key == 'white-space' && value.includes('pre') && !close)
+ this.pre = node.pre = true;
+ style += `;${key}:${value}`;
+ }
+ style = style.substr(1);
+ if (style) attrs.style = style;
+ if (!close) {
+ node.children = [];
+ if (node.name == 'pre' && cfg.highlight) {
+ this.remove(node);
+ this.pre = node.pre = true;
+ }
+ this.siblings().push(node);
+ this.STACK.push(node);
+ } else if (!cfg.filter || cfg.filter(node, this) != false)
+ this.siblings().push(node);
+ } else {
+ if (!close) this.remove(node);
+ else if (node.name == 'source') {
+ var parent = this.parent();
+ if (parent && (parent.name == 'video' || parent.name == 'audio') && node.attrs.src)
+ parent.attrs.source.push(node.attrs.src);
+ } else if (node.name == 'base' && !this.domain) this.domain = node.attrs.href;
+ }
+ if (this.data[this.i] == '/') this.i++;
+ this.start = this.i + 1;
+ this.state = this.Text;
+}
+// 移除标签
+MpHtmlParser.prototype.remove = function(node) {
+ var name = node.name,
+ j = this.i;
+ // 处理 svg
+ var handleSvg = () => {
+ var src = this.data.substring(j, this.i + 1);
+ if (!node.attrs.xmlns) src = ' xmlns="http://www.w3.org/2000/svg"' + src;
+ var i = j;
+ while (this.data[j] != '<') j--;
+ src = this.data.substring(j, i).replace("viewbox", "viewBox") + src;
+ var parent = this.parent();
+ if (node.attrs.width == '100%' && parent && (parent.attrs.style || '').includes('inline'))
+ parent.attrs.style = 'width:300px;max-width:100%;' + parent.attrs.style;
+ this.siblings().push({
+ name: 'img',
+ attrs: {
+ src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+ style: (/vertical[^;]+/.exec(node.attrs.style) || []).shift(),
+ ignore: 'T'
+ }
+ })
+ }
+ if (node.name == 'svg' && this.data[j] == '/') return handleSvg(this.i++);
+ while (1) {
+ if ((this.i = this.data.indexOf('', this.i + 1)) == -1) {
+ if (name == 'pre' || name == 'svg') this.i = j;
+ else this.i = this.data.length;
+ return;
+ }
+ this.start = (this.i += 2);
+ while (!blankChar[this.data[this.i]] && !this.isClose()) this.i++;
+ if (this.section().toLowerCase() == name) {
+ // 代码块高亮
+ if (name == 'pre') {
+ this.data = this.data.substr(0, j + 1) + cfg.highlight(this.data.substring(j + 1, this.i - 5), node.attrs) + this.data
+ .substr(this.i - 5);
+ return this.i = j;
+ } else if (name == 'style')
+ this.CssHandler.getStyle(this.data.substring(j + 1, this.i - 7));
+ else if (name == 'title')
+ this.DOM.title = this.data.substring(j + 1, this.i - 7);
+ if ((this.i = this.data.indexOf('>', this.i)) == -1) this.i = this.data.length;
+ if (name == 'svg') handleSvg();
+ return;
+ }
+ }
+}
+// 节点出栈处理
+MpHtmlParser.prototype.popNode = function(node) {
+ // 空白符处理
+ if (node.pre) {
+ node.pre = this.pre = void 0;
+ for (let i = this.STACK.length; i--;)
+ if (this.STACK[i].pre)
+ this.pre = true;
+ }
+ var siblings = this.siblings(),
+ len = siblings.length,
+ childs = node.children;
+ if (node.name == 'head' || (cfg.filter && cfg.filter(node, this) == false))
+ return siblings.pop();
+ var attrs = node.attrs;
+ // 替换一些标签名
+ if (cfg.blockTags[node.name]) node.name = 'div';
+ else if (!cfg.trustTags[node.name]) node.name = 'span';
+ // 去除块标签前后空串
+ if (node.name == 'div' || node.name == 'p' || node.name[0] == 't') {
+ if (len > 1 && siblings[len - 2].text == ' ')
+ siblings.splice(--len - 1, 1);
+ if (childs.length && childs[childs.length - 1].text == ' ')
+ childs.pop();
+ }
+ // 处理列表
+ if (node.c && (node.name == 'ul' || node.name == 'ol')) {
+ if ((node.attrs.style || '').includes('list-style:none')) {
+ for (let i = 0, child; child = childs[i++];)
+ if (child.name == 'li')
+ child.name = 'div';
+ } else if (node.name == 'ul') {
+ var floor = 1;
+ for (let i = this.STACK.length; i--;)
+ if (this.STACK[i].name == 'ul') floor++;
+ if (floor != 1)
+ for (let i = childs.length; i--;)
+ childs[i].floor = floor;
+ } else {
+ for (let i = 0, num = 1, child; child = childs[i++];)
+ if (child.name == 'li') {
+ child.type = 'ol';
+ child.num = ((num, type) => {
+ if (type == 'a') return String.fromCharCode(97 + (num - 1) % 26);
+ if (type == 'A') return String.fromCharCode(65 + (num - 1) % 26);
+ if (type == 'i' || type == 'I') {
+ num = (num - 1) % 99 + 1;
+ var one = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
+ ten = ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
+ res = (ten[Math.floor(num / 10) - 1] || '') + (one[num % 10 - 1] || '');
+ if (type == 'i') return res.toLowerCase();
+ return res;
+ }
+ return num;
+ })(num++, attrs.type) + '.';
+ }
+ }
+ }
+ // 处理表格的边框
+ if (node.name == 'table') {
+ var padding = attrs.cellpadding,
+ spacing = attrs.cellspacing,
+ border = attrs.border;
+ if (node.c) {
+ this.bubble();
+ attrs.style = (attrs.style || '') + ';display:table';
+ if (!padding) padding = 2;
+ if (!spacing) spacing = 2;
+ }
+ if (border) attrs.style = `border:${border}px solid gray;${attrs.style || ''}`;
+ if (spacing) attrs.style = `border-spacing:${spacing}px;${attrs.style || ''}`;
+ if (border || padding || node.c)
+ (function f(ns) {
+ for (var i = 0, n; n = ns[i]; i++) {
+ if (n.type == 'text') continue;
+ var style = n.attrs.style || '';
+ if (node.c && n.name[0] == 't') {
+ n.c = 1;
+ style += ';display:table-' + (n.name == 'th' || n.name == 'td' ? 'cell' : (n.name == 'tr' ? 'row' : 'row-group'));
+ }
+ if (n.name == 'th' || n.name == 'td') {
+ if (border) style = `border:${border}px solid gray;${style}`;
+ if (padding) style = `padding:${padding}px;${style}`;
+ } else f(n.children || []);
+ if (style) n.attrs.style = style;
+ }
+ })(childs)
+ if (this.options.autoscroll) {
+ var table = Object.assign({}, node);
+ node.name = 'div';
+ node.attrs = {
+ style: 'overflow:scroll'
+ }
+ node.children = [table];
+ }
+ }
+ this.CssHandler.pop && this.CssHandler.pop(node);
+ // 自动压缩
+ if (node.name == 'div' && !Object.keys(attrs).length && childs.length == 1 && childs[0].name == 'div')
+ siblings[len - 1] = childs[0];
+}
+// 状态机
+MpHtmlParser.prototype.Text = function(c) {
+ if (c == '<') {
+ var next = this.data[this.i + 1],
+ isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ if (isLetter(next)) {
+ this.setText();
+ this.start = this.i + 1;
+ this.state = this.TagName;
+ } else if (next == '/') {
+ this.setText();
+ if (isLetter(this.data[++this.i + 1])) {
+ this.start = this.i + 1;
+ this.state = this.EndTag;
+ } else this.Comment();
+ } else if (next == '!' || next == '?') {
+ this.setText();
+ this.Comment();
+ }
+ }
+}
+MpHtmlParser.prototype.Comment = function() {
+ var key;
+ if (this.data.substring(this.i + 2, this.i + 4) == '--') key = '-->';
+ else if (this.data.substring(this.i + 2, this.i + 9) == '[CDATA[') key = ']]>';
+ else key = '>';
+ if ((this.i = this.data.indexOf(key, this.i + 2)) == -1) this.i = this.data.length;
+ else this.i += key.length - 1;
+ this.start = this.i + 1;
+ this.state = this.Text;
+}
+MpHtmlParser.prototype.TagName = function(c) {
+ if (blankChar[c]) {
+ this.tagName = this.section();
+ while (blankChar[this.data[this.i]]) this.i++;
+ if (this.isClose()) this.setNode();
+ else {
+ this.start = this.i;
+ this.state = this.AttrName;
+ }
+ } else if (this.isClose()) {
+ this.tagName = this.section();
+ this.setNode();
+ }
+}
+MpHtmlParser.prototype.AttrName = function(c) {
+ if (c == '=' || blankChar[c] || this.isClose()) {
+ this.attrName = this.section();
+ if (blankChar[c])
+ while (blankChar[this.data[++this.i]]);
+ if (this.data[this.i] == '=') {
+ while (blankChar[this.data[++this.i]]);
+ this.start = this.i--;
+ this.state = this.AttrValue;
+ } else this.setAttr();
+ }
+}
+MpHtmlParser.prototype.AttrValue = function(c) {
+ if (c == '"' || c == "'") {
+ this.start++;
+ if ((this.i = this.data.indexOf(c, this.i + 1)) == -1) return this.i = this.data.length;
+ this.attrVal = this.section();
+ this.i++;
+ } else {
+ for (; !blankChar[this.data[this.i]] && !this.isClose(); this.i++);
+ this.attrVal = this.section();
+ }
+ this.setAttr();
+}
+MpHtmlParser.prototype.EndTag = function(c) {
+ if (blankChar[c] || c == '>' || c == '/') {
+ var name = this.section().toLowerCase();
+ for (var i = this.STACK.length; i--;)
+ if (this.STACK[i].name == name) break;
+ if (i != -1) {
+ var node;
+ while ((node = this.STACK.pop()).name != name) this.popNode(node);
+ this.popNode(node);
+ } else if (name == 'p' || name == 'br')
+ this.siblings().push({
+ name,
+ attrs: {}
+ });
+ this.i = this.data.indexOf('>', this.i);
+ this.start = this.i + 1;
+ if (this.i == -1) this.i = this.data.length;
+ else this.state = this.Text;
+ }
+}
+module.exports = MpHtmlParser;
diff --git a/yudao-vue-ui/components/jyf-parser/libs/config.js b/yudao-vue-ui/components/jyf-parser/libs/config.js
new file mode 100644
index 000000000..1cfc111b5
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/libs/config.js
@@ -0,0 +1,93 @@
+/* 配置文件 */
+// #ifdef MP-WEIXIN
+const canIUse = wx.canIUse('editor'); // 高基础库标识,用于兼容
+// #endif
+module.exports = {
+ // 出错占位图
+ errorImg: null,
+ // 过滤器函数
+ filter: null,
+ // 代码高亮函数
+ highlight: null,
+ // 文本处理函数
+ onText: null,
+ // 实体编码列表
+ entities: {
+ quot: '"',
+ apos: "'",
+ semi: ';',
+ nbsp: '\xA0',
+ ensp: '\u2002',
+ emsp: '\u2003',
+ ndash: '–',
+ mdash: '—',
+ middot: '·',
+ lsquo: '‘',
+ rsquo: '’',
+ ldquo: '“',
+ rdquo: '”',
+ bull: '•',
+ hellip: '…'
+ },
+ blankChar: makeMap(' ,\xA0,\t,\r,\n,\f'),
+ boolAttrs: makeMap('allowfullscreen,autoplay,autostart,controls,ignore,loop,muted'),
+ // 块级标签,将被转为 div
+ blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,section' + (
+ // #ifdef MP-WEIXIN
+ canIUse ? '' :
+ // #endif
+ ',pre')),
+ // 将被移除的标签
+ ignoreTags: makeMap(
+ 'area,base,canvas,frame,input,link,map,meta,param,script,source,style,svg,textarea,title,track,wbr'
+ // #ifdef MP-WEIXIN
+ + (canIUse ? ',rp' : '')
+ // #endif
+ // #ifndef APP-PLUS
+ + ',iframe'
+ // #endif
+ ),
+ // 只能被 rich-text 显示的标签
+ richOnlyTags: makeMap('a,colgroup,fieldset,legend,table'
+ // #ifdef MP-WEIXIN
+ + (canIUse ? ',bdi,bdo,caption,rt,ruby' : '')
+ // #endif
+ ),
+ // 自闭合的标签
+ selfClosingTags: makeMap(
+ 'area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'
+ ),
+ // 信任的标签
+ trustTags: makeMap(
+ 'a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'
+ // #ifdef MP-WEIXIN
+ + (canIUse ? ',bdi,bdo,caption,pre,rt,ruby' : '')
+ // #endif
+ // #ifdef APP-PLUS
+ + ',embed,iframe'
+ // #endif
+ ),
+ // 默认的标签样式
+ userAgentStyles: {
+ address: 'font-style:italic',
+ big: 'display:inline;font-size:1.2em',
+ blockquote: 'background-color:#f6f6f6;border-left:3px solid #dbdbdb;color:#6c6c6c;padding:5px 0 5px 10px',
+ caption: 'display:table-caption;text-align:center',
+ center: 'text-align:center',
+ cite: 'font-style:italic',
+ dd: 'margin-left:40px',
+ mark: 'background-color:yellow',
+ pre: 'font-family:monospace;white-space:pre;overflow:scroll',
+ s: 'text-decoration:line-through',
+ small: 'display:inline;font-size:0.8em',
+ u: 'text-decoration:underline'
+ }
+}
+
+function makeMap(str) {
+ var map = Object.create(null),
+ list = str.split(',');
+ for (var i = list.length; i--;)
+ map[list[i]] = true;
+ return map;
+}
diff --git a/yudao-vue-ui/components/jyf-parser/libs/handler.wxs b/yudao-vue-ui/components/jyf-parser/libs/handler.wxs
new file mode 100644
index 000000000..d3b1aaabe
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/libs/handler.wxs
@@ -0,0 +1,22 @@
+var inline = {
+ abbr: 1,
+ b: 1,
+ big: 1,
+ code: 1,
+ del: 1,
+ em: 1,
+ i: 1,
+ ins: 1,
+ label: 1,
+ q: 1,
+ small: 1,
+ span: 1,
+ strong: 1,
+ sub: 1,
+ sup: 1
+}
+module.exports = {
+ use: function(item) {
+ return !item.c && !inline[item.name] && (item.attrs.style || '').indexOf('display:inline') == -1
+ }
+}
diff --git a/yudao-vue-ui/components/jyf-parser/libs/trees.vue b/yudao-vue-ui/components/jyf-parser/libs/trees.vue
new file mode 100644
index 000000000..8232aac14
--- /dev/null
+++ b/yudao-vue-ui/components/jyf-parser/libs/trees.vue
@@ -0,0 +1,500 @@
+
+
+
+
+
+
+
+
+
+ {{n.text}}
+
+ \n
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{n.num}}
+
+ █
+
+ █
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.css b/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.css
new file mode 100644
index 000000000..72bf106c7
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.css
@@ -0,0 +1,55 @@
+/* 下拉刷新区域 */
+.mescroll-downwarp {
+ position: absolute;
+ top: -100%;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ text-align: center;
+}
+
+/* 下拉刷新--内容区,定位于区域底部 */
+.mescroll-downwarp .downwarp-content {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ min-height: 60rpx;
+ padding: 20rpx 0;
+ text-align: center;
+}
+
+/* 下拉刷新--提示文本 */
+.mescroll-downwarp .downwarp-tip {
+ display: inline-block;
+ font-size: 28rpx;
+ vertical-align: middle;
+ margin-left: 16rpx;
+ /* color: gray; 已在style设置color,此处删去*/
+}
+
+/* 下拉刷新--旋转进度条 */
+.mescroll-downwarp .downwarp-progress {
+ display: inline-block;
+ width: 32rpx;
+ height: 32rpx;
+ border-radius: 50%;
+ border: 2rpx solid gray;
+ border-bottom-color: transparent !important; /*已在style设置border-color,此处需加 !important*/
+ vertical-align: middle;
+}
+
+/* 旋转动画 */
+.mescroll-downwarp .mescroll-rotate {
+ animation: mescrollDownRotate 0.6s linear infinite;
+}
+
+@keyframes mescrollDownRotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.vue b/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.vue
new file mode 100644
index 000000000..9fd1567fa
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-down.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+ {{downText}}
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty.vue b/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty.vue
new file mode 100644
index 000000000..fad535685
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty1.vue b/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty1.vue
new file mode 100644
index 000000000..08a3e58cb
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-empty1.vue
@@ -0,0 +1,95 @@
+
+
+
+
+ {{ tip }}
+ {{ option.btnText }}
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-top.vue b/yudao-vue-ui/components/mescroll-uni/components/mescroll-top.vue
new file mode 100644
index 000000000..5115fd8d8
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-top.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.css b/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.css
new file mode 100644
index 000000000..cbf48cd23
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.css
@@ -0,0 +1,47 @@
+/* 上拉加载区域 */
+.mescroll-upwarp {
+ box-sizing: border-box;
+ min-height: 110rpx;
+ padding: 30rpx 0;
+ text-align: center;
+ clear: both;
+}
+
+/*提示文本 */
+.mescroll-upwarp .upwarp-tip,
+.mescroll-upwarp .upwarp-nodata {
+ display: inline-block;
+ font-size: 28rpx;
+ vertical-align: middle;
+ /* color: gray; 已在style设置color,此处删去*/
+}
+
+.mescroll-upwarp .upwarp-tip {
+ margin-left: 16rpx;
+}
+
+/*旋转进度条 */
+.mescroll-upwarp .upwarp-progress {
+ display: inline-block;
+ width: 32rpx;
+ height: 32rpx;
+ border-radius: 50%;
+ border: 2rpx solid gray;
+ border-bottom-color: transparent !important; /*已在style设置border-color,此处需加 !important*/
+ vertical-align: middle;
+}
+
+/* 旋转动画 */
+.mescroll-upwarp .mescroll-rotate {
+ animation: mescrollUpRotate 0.6s linear infinite;
+}
+
+@keyframes mescrollUpRotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.vue b/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.vue
new file mode 100644
index 000000000..11c2e1fb1
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/components/mescroll-up.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ {{ mOption.textLoading }}
+
+
+ {{ mOption.textNoMore }}
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-body.css b/yudao-vue-ui/components/mescroll-uni/mescroll-body.css
new file mode 100644
index 000000000..084ba8f84
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-body.css
@@ -0,0 +1,14 @@
+.mescroll-body {
+ position: relative; /* 下拉刷新区域相对自身定位 */
+ height: auto; /* 不可固定高度,否则overflow:hidden导致无法滑动; 同时使设置的最小高生效,实现列表不满屏仍可下拉*/
+ overflow: hidden; /* 遮住顶部下拉刷新区域 */
+ box-sizing: border-box; /* 避免设置padding出现双滚动条的问题 */
+}
+
+/* 适配 iPhoneX */
+@supports (bottom: constant(safe-area-inset-bottom)) or (bottom: env(safe-area-inset-bottom)) {
+ .mescroll-safearea {
+ padding-bottom: constant(safe-area-inset-bottom);
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-body.vue b/yudao-vue-ui/components/mescroll-uni/mescroll-body.vue
new file mode 100644
index 000000000..695800b30
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-body.vue
@@ -0,0 +1,344 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ mescroll.optUp.textLoading }}
+
+
+
+
+
+ 国云网络提供技术支持
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-mixins.js b/yudao-vue-ui/components/mescroll-uni/mescroll-mixins.js
new file mode 100644
index 000000000..a37973926
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-mixins.js
@@ -0,0 +1,65 @@
+// mescroll-body 和 mescroll-uni 通用
+
+// import MescrollUni from "./mescroll-uni.vue";
+// import MescrollBody from "./mescroll-body.vue";
+
+const MescrollMixin = {
+ // components: { // 非H5端无法通过mixin注册组件, 只能在main.js中注册全局组件或具体界面中注册
+ // MescrollUni,
+ // MescrollBody
+ // },
+ data() {
+ return {
+ mescroll: null //mescroll实例对象
+ }
+ },
+ // 注册系统自带的下拉刷新 (配置down.native为true时生效, 还需在pages配置enablePullDownRefresh:true;详请参考mescroll-native的案例)
+ onPullDownRefresh(){
+ this.mescroll && this.mescroll.onPullDownRefresh();
+ },
+ // 注册列表滚动事件,用于判定在顶部可下拉刷新,在指定位置可显示隐藏回到顶部按钮 (此方法为页面生命周期,无法在子组件中触发, 仅在mescroll-body生效)
+ onPageScroll(e) {
+ this.mescroll && this.mescroll.onPageScroll(e);
+ },
+ // 注册滚动到底部的事件,用于上拉加载 (此方法为页面生命周期,无法在子组件中触发, 仅在mescroll-body生效)
+ onReachBottom() {
+ this.mescroll && this.mescroll.onReachBottom();
+ },
+ methods: {
+ // mescroll组件初始化的回调,可获取到mescroll对象
+ mescrollInit(mescroll) {
+ this.mescroll = mescroll;
+ this.mescrollInitByRef(); // 兼容字节跳动小程序
+ },
+ // 以ref的方式初始化mescroll对象 (兼容字节跳动小程序: http://www.mescroll.com/qa.html?v=20200107#q26)
+ mescrollInitByRef() {
+ if(!this.mescroll || !this.mescroll.resetUpScroll){
+ let mescrollRef = this.$refs.mescrollRef;
+ if(mescrollRef) this.mescroll = mescrollRef.mescroll
+ }
+ },
+ // 下拉刷新的回调 (mixin默认resetUpScroll)
+ downCallback() {
+ if(this.mescroll.optUp.use){
+ this.mescroll.resetUpScroll()
+ }else{
+ setTimeout(()=>{
+ this.mescroll.endSuccess();
+ }, 500)
+ }
+ },
+ // 上拉加载的回调
+ upCallback() {
+ // mixin默认延时500自动结束加载
+ setTimeout(()=>{
+ this.mescroll.endErr();
+ }, 500)
+ }
+ },
+ mounted() {
+ this.mescrollInitByRef(); // 兼容字节跳动小程序, 避免未设置@init或@init此时未能取到ref的情况
+ }
+
+}
+
+export default MescrollMixin;
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-uni-option.js b/yudao-vue-ui/components/mescroll-uni/mescroll-uni-option.js
new file mode 100644
index 000000000..bd22e46e8
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-uni-option.js
@@ -0,0 +1,33 @@
+// 全局配置
+// mescroll-body 和 mescroll-uni 通用
+const GlobalOption = {
+ down: {
+ // 其他down的配置参数也可以写,这里只展示了常用的配置:
+ textInOffset: '下拉刷新', // 下拉的距离在offset范围内的提示文本
+ textOutOffset: '释放更新', // 下拉的距离大于offset范围的提示文本
+ textLoading: '加载中 ...', // 加载中的提示文本
+ offset: 80, // 在列表顶部,下拉大于80px,松手即可触发下拉刷新的回调
+ native: false // 是否使用系统自带的下拉刷新; 默认false; 仅在mescroll-body生效 (值为true时,还需在pages配置enablePullDownRefresh:true;详请参考mescroll-native的案例)
+ },
+ up: {
+ // 其他up的配置参数也可以写,这里只展示了常用的配置:
+ textLoading: '加载中 ...', // 加载中的提示文本
+ textNoMore: '- 我也是有底线的 -', // 没有更多数据的提示文本
+ offset: 80, // 距底部多远时,触发upCallback
+ toTop: {
+ // 回到顶部按钮,需配置src才显示
+ src: "http://www.mescroll.com/img/mescroll-totop.png?v=1", // 图片路径 (建议放入static目录, 如 /static/img/mescroll-totop.png )
+ offset: 1000, // 列表滚动多少距离才显示回到顶部按钮,默认1000px
+ right: 20, // 到右边的距离, 默认20 (支持"20rpx", "20px", "20%"格式的值, 纯数字则默认单位rpx)
+ bottom: 120, // 到底部的距离, 默认120 (支持"20rpx", "20px", "20%"格式的值, 纯数字则默认单位rpx)
+ width: 72 // 回到顶部图标的宽度, 默认72 (支持"20rpx", "20px", "20%"格式的值, 纯数字则默认单位rpx)
+ },
+ empty: {
+ use: true, // 是否显示空布局
+ icon: "/static/empty/hamster.png", // 图标路径 (建议放入static目录, 如 /static/img/mescroll-empty.png )
+ tip: '~ 空空如也 ~' // 提示
+ }
+ }
+}
+
+export default GlobalOption
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-uni.css b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.css
new file mode 100644
index 000000000..39438cdf4
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.css
@@ -0,0 +1,36 @@
+.mescroll-uni-warp{
+ height: 100%;
+}
+
+.mescroll-uni-content{
+ height: 100%;
+}
+
+.mescroll-uni {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 200rpx;
+ overflow-y: auto;
+ box-sizing: border-box; /* 避免设置padding出现双滚动条的问题 */
+}
+
+/* 定位的方式固定高度 */
+.mescroll-uni-fixed{
+ z-index: 1;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: auto; /* 使right生效 */
+ height: auto; /* 使bottom生效 */
+}
+
+/* 适配 iPhoneX */
+@supports (bottom: constant(safe-area-inset-bottom)) or (bottom: env(safe-area-inset-bottom)) {
+ .mescroll-safearea {
+ padding-bottom: constant(safe-area-inset-bottom);
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+}
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-uni.js b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.js
new file mode 100644
index 000000000..241a7d61e
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.js
@@ -0,0 +1,788 @@
+/* mescroll
+ * version 1.3.0
+ * 2020-07-10 wenju
+ * http://www.mescroll.com
+ */
+
+export default function MeScroll(options, isScrollBody) {
+ let me = this;
+ me.version = '1.3.0'; // mescroll版本号
+ me.options = options || {}; // 配置
+ me.isScrollBody = isScrollBody || false; // 滚动区域是否为原生页面滚动; 默认为scroll-view
+
+ me.isDownScrolling = false; // 是否在执行下拉刷新的回调
+ me.isUpScrolling = false; // 是否在执行上拉加载的回调
+ let hasDownCallback = me.options.down && me.options.down.callback; // 是否配置了down的callback
+
+ // 初始化下拉刷新
+ me.initDownScroll();
+ // 初始化上拉加载,则初始化
+ me.initUpScroll();
+
+ // 自动加载
+ setTimeout(function() { // 待主线程执行完毕再执行,避免new MeScroll未初始化,在回调获取不到mescroll的实例
+ // 自动触发下拉刷新 (只有配置了down的callback才自动触发下拉刷新)
+ if ((me.optDown.use || me.optDown.native) && me.optDown.auto && hasDownCallback) {
+ if (me.optDown.autoShowLoading) {
+ me.triggerDownScroll(); // 显示下拉进度,执行下拉回调
+ } else {
+ me.optDown.callback && me.optDown.callback(me); // 不显示下拉进度,直接执行下拉回调
+ }
+ }
+ // 自动触发上拉加载
+ if(!me.isUpAutoLoad){ // 部分小程序(头条小程序)emit是异步, 会导致isUpAutoLoad判断有误, 先延时确保先执行down的callback,再执行up的callback
+ setTimeout(function(){
+ me.optUp.use && me.optUp.auto && !me.isUpAutoLoad && me.triggerUpScroll();
+ },100)
+ }
+ }, 30); // 需让me.optDown.inited和me.optUp.inited先执行
+}
+
+/* 配置参数:下拉刷新 */
+MeScroll.prototype.extendDownScroll = function(optDown) {
+ // 下拉刷新的配置
+ MeScroll.extend(optDown, {
+ use: true, // 是否启用下拉刷新; 默认true
+ auto: true, // 是否在初始化完毕之后自动执行下拉刷新的回调; 默认true
+ native: false, // 是否使用系统自带的下拉刷新; 默认false; 仅mescroll-body生效 (值为true时,还需在pages配置enablePullDownRefresh:true;详请参考mescroll-native的案例)
+ autoShowLoading: false, // 如果设置auto=true(在初始化完毕之后自动执行下拉刷新的回调),那么是否显示下拉刷新的进度; 默认false
+ isLock: false, // 是否锁定下拉刷新,默认false;
+ offset: 80, // 在列表顶部,下拉大于80px,松手即可触发下拉刷新的回调
+ startTop: 100, // scroll-view快速滚动到顶部时,此时的scroll-top可能大于0, 此值用于控制最大的误差
+ inOffsetRate: 1, // 在列表顶部,下拉的距离小于offset时,改变下拉区域高度比例;值小于1且越接近0,高度变化越小,表现为越往下越难拉
+ outOffsetRate: 0.2, // 在列表顶部,下拉的距离大于offset时,改变下拉区域高度比例;值小于1且越接近0,高度变化越小,表现为越往下越难拉
+ bottomOffset: 20, // 当手指touchmove位置在距离body底部20px范围内的时候结束上拉刷新,避免Webview嵌套导致touchend事件不执行
+ minAngle: 45, // 向下滑动最少偏移的角度,取值区间 [0,90];默认45度,即向下滑动的角度大于45度则触发下拉;而小于45度,将不触发下拉,避免与左右滑动的轮播等组件冲突;
+ textInOffset: '下拉刷新', // 下拉的距离在offset范围内的提示文本
+ textOutOffset: '释放更新', // 下拉的距离大于offset范围的提示文本
+ textLoading: '加载中 ...', // 加载中的提示文本
+ bgColor: "transparent", // 背景颜色 (建议在pages.json中再设置一下backgroundColorTop)
+ textColor: "gray", // 文本颜色 (当bgColor配置了颜色,而textColor未配置时,则textColor会默认为白色)
+ inited: null, // 下拉刷新初始化完毕的回调
+ inOffset: null, // 下拉的距离进入offset范围内那一刻的回调
+ outOffset: null, // 下拉的距离大于offset那一刻的回调
+ onMoving: null, // 下拉过程中的回调,滑动过程一直在执行; rate下拉区域当前高度与指定距离的比值(inOffset: rate<1; outOffset: rate>=1); downHight当前下拉区域的高度
+ beforeLoading: null, // 准备触发下拉刷新的回调: 如果return true,将不触发showLoading和callback回调; 常用来完全自定义下拉刷新, 参考案例【淘宝 v6.8.0】
+ showLoading: null, // 显示下拉刷新进度的回调
+ afterLoading: null, // 显示下拉刷新进度的回调之后,马上要执行的代码 (如: 在wxs中使用)
+ beforeEndDownScroll: null, // 准备结束下拉的回调. 返回结束下拉的延时执行时间,默认0ms; 常用于结束下拉之前再显示另外一小段动画,才去隐藏下拉刷新的场景, 参考案例【dotJump】
+ endDownScroll: null, // 结束下拉刷新的回调
+ afterEndDownScroll: null, // 结束下拉刷新的回调,马上要执行的代码 (如: 在wxs中使用)
+ callback: function(mescroll) {
+ // 下拉刷新的回调;默认重置上拉加载列表为第一页
+ mescroll.resetUpScroll();
+ }
+ })
+}
+
+/* 配置参数:上拉加载 */
+MeScroll.prototype.extendUpScroll = function(optUp) {
+ // 上拉加载的配置
+ MeScroll.extend(optUp, {
+ use: true, // 是否启用上拉加载; 默认true
+ auto: true, // 是否在初始化完毕之后自动执行上拉加载的回调; 默认true
+ isLock: false, // 是否锁定上拉加载,默认false;
+ isBoth: true, // 上拉加载时,如果滑动到列表顶部是否可以同时触发下拉刷新;默认true,两者可同时触发;
+ callback: null, // 上拉加载的回调;function(page,mescroll){ }
+ page: {
+ num: 0, // 当前页码,默认0,回调之前会加1,即callback(page)会从1开始
+ size: 10, // 每页数据的数量
+ time: null // 加载第一页数据服务器返回的时间; 防止用户翻页时,后台新增了数据从而导致下一页数据重复;
+ },
+ noMoreSize: 5, // 如果列表已无数据,可设置列表的总数量要大于等于5条才显示无更多数据;避免列表数据过少(比如只有一条数据),显示无更多数据会不好看
+ offset: 80, // 距底部多远时,触发upCallback
+ textLoading: '加载中 ...', // 加载中的提示文本
+ textNoMore: '-- 我也是有底线的 --', // 没有更多数据的提示文本
+ bgColor: "transparent", // 背景颜色 (建议在pages.json中再设置一下backgroundColorBottom)
+ textColor: "gray", // 文本颜色 (当bgColor配置了颜色,而textColor未配置时,则textColor会默认为白色)
+ inited: null, // 初始化完毕的回调
+ showLoading: null, // 显示加载中的回调
+ showNoMore: null, // 显示无更多数据的回调
+ hideUpScroll: null, // 隐藏上拉加载的回调
+ errDistance: 60, // endErr的时候需往上滑动一段距离,使其往下滑动时再次触发onReachBottom,仅mescroll-body生效
+ toTop: {
+ // 回到顶部按钮,需配置src才显示
+ src: null, // 图片路径,默认null (绝对路径或网络图)
+ offset: 1000, // 列表滚动多少距离才显示回到顶部按钮,默认1000
+ duration: 300, // 回到顶部的动画时长,默认300ms (当值为0或300则使用系统自带回到顶部,更流畅; 其他值则通过step模拟,部分机型可能不够流畅,所以非特殊情况不建议修改此项)
+ btnClick: null, // 点击按钮的回调
+ onShow: null, // 是否显示的回调
+ zIndex: 9990, // fixed定位z-index值
+ left: null, // 到左边的距离, 默认null. 此项有值时,right不生效. (支持20, "20rpx", "20px", "20%"格式的值, 其中纯数字则默认单位rpx)
+ right: 20, // 到右边的距离, 默认20 (支持20, "20rpx", "20px", "20%"格式的值, 其中纯数字则默认单位rpx)
+ bottom: 120, // 到底部的距离, 默认120 (支持20, "20rpx", "20px", "20%"格式的值, 其中纯数字则默认单位rpx)
+ safearea: false, // bottom的偏移量是否加上底部安全区的距离, 默认false, 需要适配iPhoneX时使用 (具体的界面如果不配置此项,则取本vue的safearea值)
+ width: 72, // 回到顶部图标的宽度, 默认72 (支持20, "20rpx", "20px", "20%"格式的值, 其中纯数字则默认单位rpx)
+ radius: "50%" // 圆角, 默认"50%" (支持20, "20rpx", "20px", "20%"格式的值, 其中纯数字则默认单位rpx)
+ },
+ empty: {
+ use: true, // 是否显示空布局
+ icon: null, // 图标路径
+ tip: '~ 暂无相关数据 ~', // 提示
+ btnText: '', // 按钮
+ btnClick: null, // 点击按钮的回调
+ onShow: null, // 是否显示的回调
+ fixed: false, // 是否使用fixed定位,默认false; 配置fixed为true,以下的top和zIndex才生效 (transform会使fixed失效,最终会降级为absolute)
+ top: "100rpx", // fixed定位的top值 (完整的单位值,如 "10%"; "100rpx")
+ zIndex: 99 // fixed定位z-index值
+ },
+ onScroll: false // 是否监听滚动事件
+ })
+}
+
+/* 配置参数 */
+MeScroll.extend = function(userOption, defaultOption) {
+ if (!userOption) return defaultOption;
+ for (let key in defaultOption) {
+ if (userOption[key] == null) {
+ let def = defaultOption[key];
+ if (def != null && typeof def === 'object') {
+ userOption[key] = MeScroll.extend({}, def); // 深度匹配
+ } else {
+ userOption[key] = def;
+ }
+ } else if (typeof userOption[key] === 'object') {
+ MeScroll.extend(userOption[key], defaultOption[key]); // 深度匹配
+ }
+ }
+ return userOption;
+}
+
+/* 简单判断是否配置了颜色 (非透明,非白色) */
+MeScroll.prototype.hasColor = function(color) {
+ if(!color) return false;
+ let c = color.toLowerCase();
+ return c != "#fff" && c != "#ffffff" && c != "transparent" && c != "white"
+}
+
+/* -------初始化下拉刷新------- */
+MeScroll.prototype.initDownScroll = function() {
+ let me = this;
+ // 配置参数
+ me.optDown = me.options.down || {};
+ if(!me.optDown.textColor && me.hasColor(me.optDown.bgColor)) me.optDown.textColor = "#fff"; // 当bgColor有值且textColor未设置,则textColor默认白色
+ me.extendDownScroll(me.optDown);
+
+ // 如果是mescroll-body且配置了native,则禁止自定义的下拉刷新
+ if(me.isScrollBody && me.optDown.native){
+ me.optDown.use = false
+ }else{
+ me.optDown.native = false // 仅mescroll-body支持,mescroll-uni不支持
+ }
+
+ me.downHight = 0; // 下拉区域的高度
+
+ // 在页面中加入下拉布局
+ if (me.optDown.use && me.optDown.inited) {
+ // 初始化完毕的回调
+ setTimeout(function() { // 待主线程执行完毕再执行,避免new MeScroll未初始化,在回调获取不到mescroll的实例
+ me.optDown.inited(me);
+ }, 0)
+ }
+}
+
+/* 列表touchstart事件 */
+MeScroll.prototype.touchstartEvent = function(e) {
+ if (!this.optDown.use) return;
+
+ this.startPoint = this.getPoint(e); // 记录起点
+ this.startTop = this.getScrollTop(); // 记录此时的滚动条位置
+ this.startAngle = 0; // 初始角度
+ this.lastPoint = this.startPoint; // 重置上次move的点
+ this.maxTouchmoveY = this.getBodyHeight() - this.optDown.bottomOffset; // 手指触摸的最大范围(写在touchstart避免body获取高度为0的情况)
+ this.inTouchend = false; // 标记不是touchend
+}
+
+/* 列表touchmove事件 */
+MeScroll.prototype.touchmoveEvent = function(e) {
+ if (!this.optDown.use) return;
+ let me = this;
+
+ let scrollTop = me.getScrollTop(); // 当前滚动条的距离
+ let curPoint = me.getPoint(e); // 当前点
+
+ let moveY = curPoint.y - me.startPoint.y; // 和起点比,移动的距离,大于0向下拉,小于0向上拉
+
+ // 向下拉 && 在顶部
+ // mescroll-body,直接判定在顶部即可
+ // scroll-view在滚动时不会触发touchmove,当触顶/底/左/右时,才会触发touchmove
+ // scroll-view滚动到顶部时,scrollTop不一定为0,也有可能大于0; 在iOS的APP中scrollTop可能为负数,不一定和startTop相等
+ if (moveY > 0 && (
+ (me.isScrollBody && scrollTop <= 0)
+ ||
+ (!me.isScrollBody && (scrollTop <= 0 || (scrollTop <= me.optDown.startTop && scrollTop === me.startTop)) )
+ )) {
+ // 可下拉的条件
+ if (!me.inTouchend && !me.isDownScrolling && !me.optDown.isLock && (!me.isUpScrolling || (me.isUpScrolling &&
+ me.optUp.isBoth))) {
+
+ // 下拉的初始角度是否在配置的范围内
+ if(!me.startAngle) me.startAngle = me.getAngle(me.lastPoint, curPoint); // 两点之间的角度,区间 [0,90]
+ if (me.startAngle < me.optDown.minAngle) return; // 如果小于配置的角度,则不往下执行下拉刷新
+
+ // 如果手指的位置超过配置的距离,则提前结束下拉,避免Webview嵌套导致touchend无法触发
+ if (me.maxTouchmoveY > 0 && curPoint.y >= me.maxTouchmoveY) {
+ me.inTouchend = true; // 标记执行touchend
+ me.touchendEvent(); // 提前触发touchend
+ return;
+ }
+
+ me.preventDefault(e); // 阻止默认事件
+
+ let diff = curPoint.y - me.lastPoint.y; // 和上次比,移动的距离 (大于0向下,小于0向上)
+
+ // 下拉距离 < 指定距离
+ if (me.downHight < me.optDown.offset) {
+ if (me.movetype !== 1) {
+ me.movetype = 1; // 加入标记,保证只执行一次
+ me.optDown.inOffset && me.optDown.inOffset(me); // 进入指定距离范围内那一刻的回调,只执行一次
+ me.isMoveDown = true; // 标记下拉区域高度改变,在touchend重置回来
+ }
+ me.downHight += diff * me.optDown.inOffsetRate; // 越往下,高度变化越小
+
+ // 指定距离 <= 下拉距离
+ } else {
+ if (me.movetype !== 2) {
+ me.movetype = 2; // 加入标记,保证只执行一次
+ me.optDown.outOffset && me.optDown.outOffset(me); // 下拉超过指定距离那一刻的回调,只执行一次
+ me.isMoveDown = true; // 标记下拉区域高度改变,在touchend重置回来
+ }
+ if (diff > 0) { // 向下拉
+ me.downHight += diff * me.optDown.outOffsetRate; // 越往下,高度变化越小
+ } else { // 向上收
+ me.downHight += diff; // 向上收回高度,则向上滑多少收多少高度
+ }
+ }
+
+ me.downHight = Math.round(me.downHight) // 取整
+ let rate = me.downHight / me.optDown.offset; // 下拉区域当前高度与指定距离的比值
+ me.optDown.onMoving && me.optDown.onMoving(me, rate, me.downHight); // 下拉过程中的回调,一直在执行
+ }
+ }
+
+ me.lastPoint = curPoint; // 记录本次移动的点
+}
+
+/* 列表touchend事件 */
+MeScroll.prototype.touchendEvent = function(e) {
+ if (!this.optDown.use) return;
+ // 如果下拉区域高度已改变,则需重置回来
+ if (this.isMoveDown) {
+ if (this.downHight >= this.optDown.offset) {
+ // 符合触发刷新的条件
+ this.triggerDownScroll();
+ } else {
+ // 不符合的话 则重置
+ this.downHight = 0;
+ this.endDownScrollCall(this);
+ }
+ this.movetype = 0;
+ this.isMoveDown = false;
+ } else if (!this.isScrollBody && this.getScrollTop() === this.startTop) { // scroll-view到顶/左/右/底的滑动事件
+ let isScrollUp = this.getPoint(e).y - this.startPoint.y < 0; // 和起点比,移动的距离,大于0向下拉,小于0向上拉
+ // 上滑
+ if (isScrollUp) {
+ // 需检查滑动的角度
+ let angle = this.getAngle(this.getPoint(e), this.startPoint); // 两点之间的角度,区间 [0,90]
+ if (angle > 80) {
+ // 检查并触发上拉
+ this.triggerUpScroll(true);
+ }
+ }
+ }
+}
+
+/* 根据点击滑动事件获取第一个手指的坐标 */
+MeScroll.prototype.getPoint = function(e) {
+ if (!e) {
+ return {
+ x: 0,
+ y: 0
+ }
+ }
+ if (e.touches && e.touches[0]) {
+ return {
+ x: e.touches[0].pageX,
+ y: e.touches[0].pageY
+ }
+ } else if (e.changedTouches && e.changedTouches[0]) {
+ return {
+ x: e.changedTouches[0].pageX,
+ y: e.changedTouches[0].pageY
+ }
+ } else {
+ return {
+ x: e.clientX,
+ y: e.clientY
+ }
+ }
+}
+
+/* 计算两点之间的角度: 区间 [0,90]*/
+MeScroll.prototype.getAngle = function(p1, p2) {
+ let x = Math.abs(p1.x - p2.x);
+ let y = Math.abs(p1.y - p2.y);
+ let z = Math.sqrt(x * x + y * y);
+ let angle = 0;
+ if (z !== 0) {
+ angle = Math.asin(y / z) / Math.PI * 180;
+ }
+ return angle
+}
+
+/* 触发下拉刷新 */
+MeScroll.prototype.triggerDownScroll = function() {
+ if (this.optDown.beforeLoading && this.optDown.beforeLoading(this)) {
+ //return true则处于完全自定义状态
+ } else {
+ this.showDownScroll(); // 下拉刷新中...
+ !this.optDown.native && this.optDown.callback && this.optDown.callback(this); // 执行回调,联网加载数据
+ }
+}
+
+/* 显示下拉进度布局 */
+MeScroll.prototype.showDownScroll = function() {
+ this.isDownScrolling = true; // 标记下拉中
+ if (this.optDown.native) {
+ uni.startPullDownRefresh(); // 系统自带的下拉刷新
+ this.showDownLoadingCall(0); // 仍触发showLoading,因为上拉加载用到
+ } else{
+ this.downHight = this.optDown.offset; // 更新下拉区域高度
+ this.showDownLoadingCall(this.downHight); // 下拉刷新中...
+ }
+}
+
+MeScroll.prototype.showDownLoadingCall = function(downHight) {
+ this.optDown.showLoading && this.optDown.showLoading(this, downHight); // 下拉刷新中...
+ this.optDown.afterLoading && this.optDown.afterLoading(this, downHight); // 下拉刷新中...触发之后马上要执行的代码
+}
+
+/* 显示系统自带的下拉刷新时需要处理的业务 */
+MeScroll.prototype.onPullDownRefresh = function() {
+ this.isDownScrolling = true; // 标记下拉中
+ this.showDownLoadingCall(0); // 仍触发showLoading,因为上拉加载用到
+ this.optDown.callback && this.optDown.callback(this); // 执行回调,联网加载数据
+}
+
+/* 结束下拉刷新 */
+MeScroll.prototype.endDownScroll = function() {
+ if (this.optDown.native) { // 结束原生下拉刷新
+ this.isDownScrolling = false;
+ this.endDownScrollCall(this);
+ uni.stopPullDownRefresh();
+ return
+ }
+ let me = this;
+ // 结束下拉刷新的方法
+ let endScroll = function() {
+ me.downHight = 0;
+ me.isDownScrolling = false;
+ me.endDownScrollCall(me);
+ if(!me.isScrollBody){
+ me.setScrollHeight(0) // scroll-view重置滚动区域,使数据不满屏时仍可检查触发翻页
+ me.scrollTo(0,0) // scroll-view需重置滚动条到顶部,避免startTop大于0时,对下拉刷新的影响
+ }
+ }
+ // 结束下拉刷新时的回调
+ let delay = 0;
+ if (me.optDown.beforeEndDownScroll) delay = me.optDown.beforeEndDownScroll(me); // 结束下拉刷新的延时,单位ms
+ if (typeof delay === 'number' && delay > 0) {
+ setTimeout(endScroll, delay);
+ } else {
+ endScroll();
+ }
+}
+
+MeScroll.prototype.endDownScrollCall = function() {
+ this.optDown.endDownScroll && this.optDown.endDownScroll(this);
+ this.optDown.afterEndDownScroll && this.optDown.afterEndDownScroll(this);
+}
+
+/* 锁定下拉刷新:isLock=ture,null锁定;isLock=false解锁 */
+MeScroll.prototype.lockDownScroll = function(isLock) {
+ if (isLock == null) isLock = true;
+ this.optDown.isLock = isLock;
+}
+
+/* 锁定上拉加载:isLock=ture,null锁定;isLock=false解锁 */
+MeScroll.prototype.lockUpScroll = function(isLock) {
+ if (isLock == null) isLock = true;
+ this.optUp.isLock = isLock;
+}
+
+/* -------初始化上拉加载------- */
+MeScroll.prototype.initUpScroll = function() {
+ let me = this;
+ // 配置参数
+ me.optUp = me.options.up || {use: false}
+ if(!me.optUp.textColor && me.hasColor(me.optUp.bgColor)) me.optUp.textColor = "#fff"; // 当bgColor有值且textColor未设置,则textColor默认白色
+ me.extendUpScroll(me.optUp);
+
+ if (me.optUp.use === false) return; // 配置不使用上拉加载时,则不初始化上拉布局
+ me.optUp.hasNext = true; // 如果使用上拉,则默认有下一页
+ me.startNum = me.optUp.page.num + 1; // 记录page开始的页码
+
+ // 初始化完毕的回调
+ if (me.optUp.inited) {
+ setTimeout(function() { // 待主线程执行完毕再执行,避免new MeScroll未初始化,在回调获取不到mescroll的实例
+ me.optUp.inited(me);
+ }, 0)
+ }
+}
+
+/*滚动到底部的事件 (仅mescroll-body生效)*/
+MeScroll.prototype.onReachBottom = function() {
+ if (this.isScrollBody && !this.isUpScrolling) { // 只能支持下拉刷新的时候同时可以触发上拉加载,否则滚动到底部就需要上滑一点才能触发onReachBottom
+ if (!this.optUp.isLock && this.optUp.hasNext) {
+ this.triggerUpScroll();
+ }
+ }
+}
+
+/*列表滚动事件 (仅mescroll-body生效)*/
+MeScroll.prototype.onPageScroll = function(e) {
+ if (!this.isScrollBody) return;
+
+ // 更新滚动条的位置 (主要用于判断下拉刷新时,滚动条是否在顶部)
+ this.setScrollTop(e.scrollTop);
+
+ // 顶部按钮的显示隐藏
+ if (e.scrollTop >= this.optUp.toTop.offset) {
+ this.showTopBtn();
+ } else {
+ this.hideTopBtn();
+ }
+}
+
+/*列表滚动事件*/
+MeScroll.prototype.scroll = function(e, onScroll) {
+ // 更新滚动条的位置
+ this.setScrollTop(e.scrollTop);
+ // 更新滚动内容高度
+ this.setScrollHeight(e.scrollHeight);
+
+ // 向上滑还是向下滑动
+ if (this.preScrollY == null) this.preScrollY = 0;
+ this.isScrollUp = e.scrollTop - this.preScrollY > 0;
+ this.preScrollY = e.scrollTop;
+
+ // 上滑 && 检查并触发上拉
+ this.isScrollUp && this.triggerUpScroll(true);
+
+ // 顶部按钮的显示隐藏
+ if (e.scrollTop >= this.optUp.toTop.offset) {
+ this.showTopBtn();
+ } else {
+ this.hideTopBtn();
+ }
+
+ // 滑动监听
+ this.optUp.onScroll && onScroll && onScroll()
+}
+
+/* 触发上拉加载 */
+MeScroll.prototype.triggerUpScroll = function(isCheck) {
+ if (!this.isUpScrolling && this.optUp.use && this.optUp.callback) {
+ // 是否校验在底部; 默认不校验
+ if (isCheck === true) {
+ let canUp = false;
+ // 还有下一页 && 没有锁定 && 不在下拉中
+ if (this.optUp.hasNext && !this.optUp.isLock && !this.isDownScrolling) {
+ if (this.getScrollBottom() <= this.optUp.offset) { // 到底部
+ canUp = true; // 标记可上拉
+ }
+ }
+ if (canUp === false) return;
+ }
+ this.showUpScroll(); // 上拉加载中...
+ this.optUp.page.num++; // 预先加一页,如果失败则减回
+ this.isUpAutoLoad = true; // 标记上拉已经自动执行过,避免初始化时多次触发上拉回调
+ this.num = this.optUp.page.num; // 把最新的页数赋值在mescroll上,避免对page的影响
+ this.size = this.optUp.page.size; // 把最新的页码赋值在mescroll上,避免对page的影响
+ this.time = this.optUp.page.time; // 把最新的页码赋值在mescroll上,避免对page的影响
+ this.optUp.callback(this); // 执行回调,联网加载数据
+ }
+}
+
+/* 显示上拉加载中 */
+MeScroll.prototype.showUpScroll = function() {
+ this.isUpScrolling = true; // 标记上拉加载中
+ this.optUp.showLoading && this.optUp.showLoading(this); // 回调
+}
+
+/* 显示上拉无更多数据 */
+MeScroll.prototype.showNoMore = function() {
+ this.optUp.hasNext = false; // 标记无更多数据
+ this.optUp.showNoMore && this.optUp.showNoMore(this); // 回调
+}
+
+/* 隐藏上拉区域**/
+MeScroll.prototype.hideUpScroll = function() {
+ this.optUp.hideUpScroll && this.optUp.hideUpScroll(this); // 回调
+}
+
+/* 结束上拉加载 */
+MeScroll.prototype.endUpScroll = function(isShowNoMore) {
+ if (isShowNoMore != null) { // isShowNoMore=null,不处理下拉状态,下拉刷新的时候调用
+ if (isShowNoMore) {
+ this.showNoMore(); // isShowNoMore=true,显示无更多数据
+ } else {
+ this.hideUpScroll(); // isShowNoMore=false,隐藏上拉加载
+ }
+ }
+ this.isUpScrolling = false; // 标记结束上拉加载
+}
+
+/* 重置上拉加载列表为第一页
+ *isShowLoading 是否显示进度布局;
+ * 1.默认null,不传参,则显示上拉加载的进度布局
+ * 2.传参true, 则显示下拉刷新的进度布局
+ * 3.传参false,则不显示上拉和下拉的进度 (常用于静默更新列表数据)
+ */
+MeScroll.prototype.resetUpScroll = function(isShowLoading) {
+ if (this.optUp && this.optUp.use) {
+ let page = this.optUp.page;
+ this.prePageNum = page.num; // 缓存重置前的页码,加载失败可退回
+ this.prePageTime = page.time; // 缓存重置前的时间,加载失败可退回
+ page.num = this.startNum; // 重置为第一页
+ page.time = null; // 重置时间为空
+ if (!this.isDownScrolling && isShowLoading !== false) { // 如果不是下拉刷新触发的resetUpScroll并且不配置列表静默更新,则显示进度;
+ if (isShowLoading == null) {
+ this.removeEmpty(); // 移除空布局
+ this.showUpScroll(); // 不传参,默认显示上拉加载的进度布局
+ } else {
+ this.showDownScroll(); // 传true,显示下拉刷新的进度布局,不清空列表
+ }
+ }
+ this.isUpAutoLoad = true; // 标记上拉已经自动执行过,避免初始化时多次触发上拉回调
+ this.num = page.num; // 把最新的页数赋值在mescroll上,避免对page的影响
+ this.size = page.size; // 把最新的页码赋值在mescroll上,避免对page的影响
+ this.time = page.time; // 把最新的页码赋值在mescroll上,避免对page的影响
+ this.optUp.callback && this.optUp.callback(this); // 执行上拉回调
+ }
+}
+
+/* 设置page.num的值 */
+MeScroll.prototype.setPageNum = function(num) {
+ this.optUp.page.num = num - 1;
+}
+
+/* 设置page.size的值 */
+MeScroll.prototype.setPageSize = function(size) {
+ this.optUp.page.size = size;
+}
+
+/* 联网回调成功,结束下拉刷新和上拉加载
+ * dataSize: 当前页的数据量(必传)
+ * totalPage: 总页数(必传)
+ * systime: 服务器时间 (可空)
+ */
+MeScroll.prototype.endByPage = function(dataSize, totalPage, systime) {
+ let hasNext;
+ if (this.optUp.use && totalPage != null) hasNext = this.optUp.page.num < totalPage; // 是否还有下一页
+ this.endSuccess(dataSize, hasNext, systime);
+}
+
+/* 联网回调成功,结束下拉刷新和上拉加载
+ * dataSize: 当前页的数据量(必传)
+ * totalSize: 列表所有数据总数量(必传)
+ * systime: 服务器时间 (可空)
+ */
+MeScroll.prototype.endBySize = function(dataSize, totalSize, systime) {
+ let hasNext;
+ if (this.optUp.use && totalSize != null) {
+ let loadSize = (this.optUp.page.num - 1) * this.optUp.page.size + dataSize; // 已加载的数据总数
+ hasNext = loadSize < totalSize; // 是否还有下一页
+ }
+ this.endSuccess(dataSize, hasNext, systime);
+}
+
+/* 联网回调成功,结束下拉刷新和上拉加载
+ * dataSize: 当前页的数据个数(不是所有页的数据总和),用于上拉加载判断是否还有下一页.如果不传,则会判断还有下一页
+ * hasNext: 是否还有下一页,布尔类型;用来解决这个小问题:比如列表共有20条数据,每页加载10条,共2页.如果只根据dataSize判断,则需翻到第三页才会知道无更多数据,如果传了hasNext,则翻到第二页即可显示无更多数据.
+ * systime: 服务器时间(可空);用来解决这个小问题:当准备翻下一页时,数据库新增了几条记录,此时翻下一页,前面的几条数据会和上一页的重复;这里传入了systime,那么upCallback的page.time就会有值,把page.time传给服务器,让后台过滤新加入的那几条记录
+ */
+MeScroll.prototype.endSuccess = function(dataSize, hasNext, systime) {
+ let me = this;
+ // 结束下拉刷新
+ if (me.isDownScrolling) me.endDownScroll();
+
+ // 结束上拉加载
+ if (me.optUp.use) {
+ let isShowNoMore; // 是否已无更多数据
+ if (dataSize != null) {
+ let pageNum = me.optUp.page.num; // 当前页码
+ let pageSize = me.optUp.page.size; // 每页长度
+ // 如果是第一页
+ if (pageNum === 1) {
+ if (systime) me.optUp.page.time = systime; // 设置加载列表数据第一页的时间
+ }
+ if (dataSize < pageSize || hasNext === false) {
+ // 返回的数据不满一页时,则说明已无更多数据
+ me.optUp.hasNext = false;
+ if (dataSize === 0 && pageNum === 1) {
+ // 如果第一页无任何数据且配置了空布局
+ isShowNoMore = false;
+ me.showEmpty();
+ } else {
+ // 总列表数少于配置的数量,则不显示无更多数据
+ let allDataSize = (pageNum - 1) * pageSize + dataSize;
+ if (allDataSize < me.optUp.noMoreSize) {
+ isShowNoMore = false;
+ } else {
+ isShowNoMore = true;
+ }
+ me.removeEmpty(); // 移除空布局
+ }
+ } else {
+ // 还有下一页
+ isShowNoMore = false;
+ me.optUp.hasNext = true;
+ me.removeEmpty(); // 移除空布局
+ }
+ }
+
+ // 隐藏上拉
+ me.endUpScroll(isShowNoMore);
+ }
+}
+
+/* 回调失败,结束下拉刷新和上拉加载 */
+MeScroll.prototype.endErr = function(errDistance) {
+ // 结束下拉,回调失败重置回原来的页码和时间
+ if (this.isDownScrolling) {
+ let page = this.optUp.page;
+ if (page && this.prePageNum) {
+ page.num = this.prePageNum;
+ page.time = this.prePageTime;
+ }
+ this.endDownScroll();
+ }
+ // 结束上拉,回调失败重置回原来的页码
+ if (this.isUpScrolling) {
+ this.optUp.page.num--;
+ this.endUpScroll(false);
+ // 如果是mescroll-body,则需往回滚一定距离
+ if(this.isScrollBody && errDistance !== 0){ // 不处理0
+ if(!errDistance) errDistance = this.optUp.errDistance; // 不传,则取默认
+ this.scrollTo(this.getScrollTop() - errDistance, 0) // 往上回滚的距离
+ }
+ }
+}
+
+/* 显示空布局 */
+MeScroll.prototype.showEmpty = function() {
+ this.optUp.empty.use && this.optUp.empty.onShow && this.optUp.empty.onShow(true)
+}
+
+/* 移除空布局 */
+MeScroll.prototype.removeEmpty = function() {
+ this.optUp.empty.use && this.optUp.empty.onShow && this.optUp.empty.onShow(false)
+}
+
+/* 显示回到顶部的按钮 */
+MeScroll.prototype.showTopBtn = function() {
+ if (!this.topBtnShow) {
+ this.topBtnShow = true;
+ this.optUp.toTop.onShow && this.optUp.toTop.onShow(true);
+ }
+}
+
+/* 隐藏回到顶部的按钮 */
+MeScroll.prototype.hideTopBtn = function() {
+ if (this.topBtnShow) {
+ this.topBtnShow = false;
+ this.optUp.toTop.onShow && this.optUp.toTop.onShow(false);
+ }
+}
+
+/* 获取滚动条的位置 */
+MeScroll.prototype.getScrollTop = function() {
+ return this.scrollTop || 0
+}
+
+/* 记录滚动条的位置 */
+MeScroll.prototype.setScrollTop = function(y) {
+ this.scrollTop = y;
+}
+
+/* 滚动到指定位置 */
+MeScroll.prototype.scrollTo = function(y, t) {
+ this.myScrollTo && this.myScrollTo(y, t) // scrollview需自定义回到顶部方法
+}
+
+/* 自定义scrollTo */
+MeScroll.prototype.resetScrollTo = function(myScrollTo) {
+ this.myScrollTo = myScrollTo
+}
+
+/* 滚动条到底部的距离 */
+MeScroll.prototype.getScrollBottom = function() {
+ return this.getScrollHeight() - this.getClientHeight() - this.getScrollTop()
+}
+
+/* 计步器
+ star: 开始值
+ end: 结束值
+ callback(step,timer): 回调step值,计步器timer,可自行通过window.clearInterval(timer)结束计步器;
+ t: 计步时长,传0则直接回调end值;不传则默认300ms
+ rate: 周期;不传则默认30ms计步一次
+ * */
+MeScroll.prototype.getStep = function(star, end, callback, t, rate) {
+ let diff = end - star; // 差值
+ if (t === 0 || diff === 0) {
+ callback && callback(end);
+ return;
+ }
+ t = t || 300; // 时长 300ms
+ rate = rate || 30; // 周期 30ms
+ let count = t / rate; // 次数
+ let step = diff / count; // 步长
+ let i = 0; // 计数
+ let timer = setInterval(function() {
+ if (i < count - 1) {
+ star += step;
+ callback && callback(star, timer);
+ i++;
+ } else {
+ callback && callback(end, timer); // 最后一次直接设置end,避免计算误差
+ clearInterval(timer);
+ }
+ }, rate);
+}
+
+/* 滚动容器的高度 */
+MeScroll.prototype.getClientHeight = function(isReal) {
+ let h = this.clientHeight || 0
+ if (h === 0 && isReal !== true) { // 未获取到容器的高度,可临时取body的高度 (可能会有误差)
+ h = this.getBodyHeight()
+ }
+ return h
+}
+MeScroll.prototype.setClientHeight = function(h) {
+ this.clientHeight = h;
+}
+
+/* 滚动内容的高度 */
+MeScroll.prototype.getScrollHeight = function() {
+ return this.scrollHeight || 0;
+}
+MeScroll.prototype.setScrollHeight = function(h) {
+ this.scrollHeight = h;
+}
+
+/* body的高度 */
+MeScroll.prototype.getBodyHeight = function() {
+ return this.bodyHeight || 0;
+}
+MeScroll.prototype.setBodyHeight = function(h) {
+ this.bodyHeight = h;
+}
+
+/* 阻止浏览器默认滚动事件 */
+MeScroll.prototype.preventDefault = function(e) {
+ // 小程序不支持e.preventDefault, 已在wxs中禁止
+ // app的bounce只能通过配置pages.json的style.app-plus.bounce为"none"来禁止, 或使用renderjs禁止
+ // cancelable:是否可以被禁用; defaultPrevented:是否已经被禁用
+ if (e && e.cancelable && !e.defaultPrevented) e.preventDefault()
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/mescroll-uni.vue b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.vue
new file mode 100644
index 000000000..88925a61a
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mescroll-uni.vue
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{downText}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ mescroll.optUp.textLoading }}
+
+
+ {{ mescroll.optUp.textNoMore }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-comp.js b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-comp.js
new file mode 100644
index 000000000..f3763794e
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-comp.js
@@ -0,0 +1,23 @@
+/**
+ * mescroll-body写在子组件时,需通过mescroll的mixins补充子组件缺少的生命周期:
+ * 当一个页面只有一个mescroll-body写在子组件时, 则使用mescroll-comp.js (参考 mescroll-comp 案例)
+ * 当一个页面有多个mescroll-body写在子组件时, 则使用mescroll-more.js (参考 mescroll-more 案例)
+ */
+const MescrollCompMixin = {
+ // 因为子组件无onPageScroll和onReachBottom的页面生命周期,需在页面传递进到子组件
+ onPageScroll(e) {
+ let item = this.$refs["mescrollItem"];
+ if(item && item.mescroll) item.mescroll.onPageScroll(e);
+ },
+ onReachBottom() {
+ let item = this.$refs["mescrollItem"];
+ if(item && item.mescroll) item.mescroll.onReachBottom();
+ },
+ // 当down的native: true时, 还需传递此方法进到子组件
+ onPullDownRefresh(){
+ let item = this.$refs["mescrollItem"];
+ if(item && item.mescroll) item.mescroll.onPullDownRefresh();
+ }
+}
+
+export default MescrollCompMixin;
diff --git a/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more-item.js b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more-item.js
new file mode 100644
index 000000000..2549158ce
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more-item.js
@@ -0,0 +1,51 @@
+/**
+ * mescroll-more-item的mixins, 仅在多个 mescroll-body 写在子组件时使用 (参考 mescroll-more 案例)
+ */
+const MescrollMoreItemMixin = {
+ // 支付宝小程序不支持props的mixin,需写在具体的页面中
+ // #ifndef MP-ALIPAY
+ props:{
+ i: Number, // 每个tab页的专属下标
+ index: { // 当前tab的下标
+ type: Number,
+ default(){
+ return 0
+ }
+ }
+ },
+ // #endif
+ data() {
+ return {
+ downOption:{
+ auto:false // 不自动加载
+ },
+ upOption:{
+ auto:false // 不自动加载
+ },
+ isInit: false // 当前tab是否已初始化
+ }
+ },
+ watch:{
+ // 监听下标的变化
+ index(val){
+ if (this.i === val && !this.isInit) {
+ this.isInit = true; // 标记为true
+ this.mescroll && this.mescroll.triggerDownScroll();
+ }
+ }
+ },
+ methods: {
+ // mescroll组件初始化的回调,可获取到mescroll对象 (覆盖mescroll-mixins.js的mescrollInit, 为了标记isInit)
+ mescrollInit(mescroll) {
+ this.mescroll = mescroll;
+ this.mescrollInitByRef && this.mescrollInitByRef(); // 兼容字节跳动小程序
+ // 自动加载当前tab的数据
+ if(this.i === this.index){
+ this.isInit = true; // 标记为true
+ this.mescroll.triggerDownScroll();
+ }
+ },
+ }
+}
+
+export default MescrollMoreItemMixin;
diff --git a/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more.js b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more.js
new file mode 100644
index 000000000..142aa75d8
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/mixins/mescroll-more.js
@@ -0,0 +1,56 @@
+/**
+ * mescroll-body写在子组件时,需通过mescroll的mixins补充子组件缺少的生命周期:
+ * 当一个页面只有一个mescroll-body写在子组件时, 则使用mescroll-comp.js (参考 mescroll-comp 案例)
+ * 当一个页面有多个mescroll-body写在子组件时, 则使用mescroll-more.js (参考 mescroll-more 案例)
+ */
+const MescrollMoreMixin = {
+ data() {
+ return {
+ tabIndex: 0 // 当前tab下标
+ }
+ },
+ // 因为子组件无onPageScroll和onReachBottom的页面生命周期,需在页面传递进到子组件
+ onPageScroll(e) {
+ let mescroll = this.getMescroll(this.tabIndex);
+ mescroll && mescroll.onPageScroll(e);
+ },
+ onReachBottom() {
+ let mescroll = this.getMescroll(this.tabIndex);
+ mescroll && mescroll.onReachBottom();
+ },
+ // 当down的native: true时, 还需传递此方法进到子组件
+ onPullDownRefresh(){
+ let mescroll = this.getMescroll(this.tabIndex);
+ mescroll && mescroll.onPullDownRefresh();
+ },
+ methods:{
+ // 根据下标获取对应子组件的mescroll
+ getMescroll(i){
+ if(!this.mescrollItems) this.mescrollItems = [];
+ if(!this.mescrollItems[i]) {
+ // v-for中的refs
+ let vForItem = this.$refs["mescrollItem"];
+ if(vForItem){
+ this.mescrollItems[i] = vForItem[i]
+ }else{
+ // 普通的refs,不可重复
+ this.mescrollItems[i] = this.$refs["mescrollItem"+i];
+ }
+ }
+ let item = this.mescrollItems[i]
+ return item ? item.mescroll : null
+ },
+ // 切换tab,恢复滚动条位置
+ tabChange(i){
+ let mescroll = this.getMescroll(i);
+ if(mescroll){
+ // 延时(比$nextTick靠谱一些),确保元素已渲染
+ setTimeout(()=>{
+ mescroll.scrollTo(mescroll.getScrollTop(),0)
+ },30)
+ }
+ }
+ }
+}
+
+export default MescrollMoreMixin;
diff --git a/yudao-vue-ui/components/mescroll-uni/wxs/bounce.js b/yudao-vue-ui/components/mescroll-uni/wxs/bounce.js
new file mode 100644
index 000000000..4fe179f9a
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/wxs/bounce.js
@@ -0,0 +1,23 @@
+// bounce: iOS橡皮筋,Android半月弧,h5浏览器下拉背景等效果, 适用于h5和renderjs (下拉刷新时禁止)
+const bounce = {
+ // false: 禁止bounce; true:允许bounce
+ setBounce: function(isBounce){
+ window.$isMescrollBounce = isBounce
+ }
+}
+
+// 引入即自动初始化 (仅初始化一次)
+if(window && window.$isMescrollBounce == null){
+ // 是否允许bounce, 默认允许
+ window.$isMescrollBounce = true
+ // 每次点击时重置bounce
+ window.addEventListener('touchstart', function(){
+ window.$isMescrollBounce = true
+ }, {passive: true})
+ // 滑动中标记是否禁止bounce (如:下拉刷新时禁止)
+ window.addEventListener('touchmove', function(e){
+ !window.$isMescrollBounce && e.preventDefault() // 禁止bounce
+ }, {passive: false})
+}
+
+export default bounce;
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/wxs/mixins.js b/yudao-vue-ui/components/mescroll-uni/wxs/mixins.js
new file mode 100644
index 000000000..7f49ff72d
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/wxs/mixins.js
@@ -0,0 +1,102 @@
+// 定义在wxs (含renderjs) 逻辑层的数据和方法, 与视图层相互通信
+const WxsMixin = {
+ data() {
+ return {
+ // 传入wxs视图层的数据 (响应式)
+ wxsProp: {
+ optDown:{}, // 下拉刷新的配置
+ scrollTop:0, // 滚动条的距离
+ bodyHeight:0, // body的高度
+ isDownScrolling:false, // 是否正在下拉刷新中
+ isUpScrolling:false, // 是否正在上拉加载中
+ isScrollBody:true, // 是否为mescroll-body滚动
+ isUpBoth:true, // 上拉加载时,是否同时可以下拉刷新
+ t: 0 // 数据更新的标记 (只有数据更新了,才会触发wxs的Observer)
+ },
+
+ // 标记调用wxs视图层的方法
+ callProp: {
+ callType: '', // 方法名
+ t: 0 // 数据更新的标记 (只有数据更新了,才会触发wxs的Observer)
+ },
+
+ // 不用wxs的平台使用此处的wxsBiz对象,抹平wxs的写法 (微信小程序和APP使用的wxsBiz对象是./wxs/wxs.wxs)
+ // #ifndef MP-WEIXIN || APP-PLUS || H5
+ wxsBiz: {
+ //注册列表touchstart事件,用于下拉刷新
+ touchstartEvent: e=> {
+ this.mescroll.touchstartEvent(e);
+ },
+ //注册列表touchmove事件,用于下拉刷新
+ touchmoveEvent: e=> {
+ this.mescroll.touchmoveEvent(e);
+ },
+ //注册列表touchend事件,用于下拉刷新
+ touchendEvent: e=> {
+ this.mescroll.touchendEvent(e);
+ },
+ propObserver(){}, // 抹平wxs的写法
+ callObserver(){} // 抹平wxs的写法
+ },
+ // #endif
+
+ // 不用renderjs的平台使用此处的renderBiz对象,抹平renderjs的写法 (app 和 h5 使用的renderBiz对象是./wxs/renderjs.js)
+ // #ifndef APP-PLUS || H5
+ renderBiz: {
+ propObserver(){} // 抹平renderjs的写法
+ }
+ // #endif
+ }
+ },
+ methods: {
+ // wxs视图层调用逻辑层的回调
+ wxsCall(msg){
+ if(msg.type === 'setWxsProp'){
+ // 更新wxsProp数据 (值改变才触发更新)
+ this.wxsProp = {
+ optDown: this.mescroll.optDown,
+ scrollTop: this.mescroll.getScrollTop(),
+ bodyHeight: this.mescroll.getBodyHeight(),
+ isDownScrolling: this.mescroll.isDownScrolling,
+ isUpScrolling: this.mescroll.isUpScrolling,
+ isUpBoth: this.mescroll.optUp.isBoth,
+ isScrollBody:this.mescroll.isScrollBody,
+ t: Date.now()
+ }
+ }else if(msg.type === 'setLoadType'){
+ // 设置inOffset,outOffset的状态
+ this.downLoadType = msg.downLoadType
+ }else if(msg.type === 'triggerDownScroll'){
+ // 主动触发下拉刷新
+ this.mescroll.triggerDownScroll();
+ }else if(msg.type === 'endDownScroll'){
+ // 结束下拉刷新
+ this.mescroll.endDownScroll();
+ }else if(msg.type === 'triggerUpScroll'){
+ // 主动触发上拉加载
+ this.mescroll.triggerUpScroll(true);
+ }
+ }
+ },
+ mounted() {
+ // #ifdef MP-WEIXIN || APP-PLUS || H5
+ // 配置主动触发wxs显示加载进度的回调
+ this.mescroll.optDown.afterLoading = ()=>{
+ this.callProp = {callType: "showLoading", t: Date.now()} // 触发wxs的方法 (值改变才触发更新)
+ }
+ // 配置主动触发wxs隐藏加载进度的回调
+ this.mescroll.optDown.afterEndDownScroll = ()=>{
+ this.callProp = {callType: "endDownScroll", t: Date.now()} // 触发wxs的方法 (值改变才触发更新)
+ setTimeout(()=>{
+ if(this.downLoadType === 4 || this.downLoadType === 0){
+ this.callProp = {callType: "clearTransform", t: Date.now()} // 触发wxs的方法 (值改变才触发更新)
+ }
+ },320)
+ }
+ // 初始化wxs的数据
+ this.wxsCall({type: 'setWxsProp'})
+ // #endif
+ }
+}
+
+export default WxsMixin;
diff --git a/yudao-vue-ui/components/mescroll-uni/wxs/renderjs.js b/yudao-vue-ui/components/mescroll-uni/wxs/renderjs.js
new file mode 100644
index 000000000..207f38857
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/wxs/renderjs.js
@@ -0,0 +1,92 @@
+// 使用renderjs直接操作window对象,实现动态控制app和h5的bounce
+// bounce: iOS橡皮筋,Android半月弧,h5浏览器下拉背景等效果 (下拉刷新时禁止)
+// https://uniapp.dcloud.io/frame?id=renderjs
+
+// 与wxs的me实例一致
+var me = {}
+
+// 初始化window对象的touch事件 (仅初始化一次)
+if(window && !window.$mescrollRenderInit){
+ window.$mescrollRenderInit = true
+
+
+ window.addEventListener('touchstart', function(e){
+ if (me.disabled()) return;
+ me.startPoint = me.getPoint(e); // 记录起点
+ }, {passive: true})
+
+
+ window.addEventListener('touchmove', function(e){
+ if (me.disabled()) return;
+ if (me.getScrollTop() > 0) return; // 需在顶部下拉,才禁止bounce
+
+ var curPoint = me.getPoint(e); // 当前点
+ var moveY = curPoint.y - me.startPoint.y; // 和起点比,移动的距离,大于0向下拉,小于0向上拉
+ // 向下拉
+ if (moveY > 0) {
+ // 可下拉的条件
+ if (!me.isDownScrolling && !me.optDown.isLock && (!me.isUpScrolling || (me.isUpScrolling && me.isUpBoth))) {
+
+ // 只有touch在mescroll的view上面,才禁止bounce
+ var el = e.target;
+ var isMescrollTouch = false;
+ while (el && el.tagName && el.tagName !== 'UNI-PAGE-BODY' && el.tagName != "BODY") {
+ var cls = el.classList;
+ if (cls && cls.contains('mescroll-render-touch')) {
+ isMescrollTouch = true
+ break;
+ }
+ el = el.parentNode; // 继续检查其父元素
+ }
+ // 禁止bounce (不会对swiper和iOS侧滑返回造成影响)
+ if (isMescrollTouch && e.cancelable && !e.defaultPrevented) e.preventDefault();
+ }
+ }
+ }, {passive: false})
+}
+
+/* 获取滚动条的位置 */
+me.getScrollTop = function() {
+ return me.scrollTop || 0
+}
+
+/* 是否禁用下拉刷新 */
+me.disabled = function(){
+ return !me.optDown || !me.optDown.use || me.optDown.native
+}
+
+/* 根据点击滑动事件获取第一个手指的坐标 */
+me.getPoint = function(e) {
+ if (!e) {
+ return {x: 0,y: 0}
+ }
+ if (e.touches && e.touches[0]) {
+ return {x: e.touches[0].pageX,y: e.touches[0].pageY}
+ } else if (e.changedTouches && e.changedTouches[0]) {
+ return {x: e.changedTouches[0].pageX,y: e.changedTouches[0].pageY}
+ } else {
+ return {x: e.clientX,y: e.clientY}
+ }
+}
+
+/**
+ * 监听逻辑层数据的变化 (实时更新数据)
+ */
+function propObserver(wxsProp) {
+ me.optDown = wxsProp.optDown
+ me.scrollTop = wxsProp.scrollTop
+ me.isDownScrolling = wxsProp.isDownScrolling
+ me.isUpScrolling = wxsProp.isUpScrolling
+ me.isUpBoth = wxsProp.isUpBoth
+}
+
+/* 导出模块 */
+const renderBiz = {
+ data() {
+ return {
+ propObserver: propObserver,
+ }
+ }
+}
+
+export default renderBiz;
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mescroll-uni/wxs/wxs.wxs b/yudao-vue-ui/components/mescroll-uni/wxs/wxs.wxs
new file mode 100644
index 000000000..52c3f7fb1
--- /dev/null
+++ b/yudao-vue-ui/components/mescroll-uni/wxs/wxs.wxs
@@ -0,0 +1,267 @@
+// 使用wxs处理交互动画, 提高性能, 同时避免小程序bounce对下拉刷新的影响
+// https://uniapp.dcloud.io/frame?id=wxs
+// https://developers.weixin.qq.com/miniprogram/dev/framework/view/interactive-animation.html
+
+// 模拟mescroll实例, 与mescroll.js的写法尽量保持一致
+var me = {}
+
+// ------ 自定义下拉刷新动画 start ------
+
+/* 下拉过程中的回调,滑动过程一直在执行 (rate<1为inOffset; rate>1为outOffset) */
+me.onMoving = function (ins, rate, downHight){
+ ins.requestAnimationFrame(function () {
+ ins.selectComponent('.mescroll-wxs-content').setStyle({
+ transform: 'translateY(' + downHight + 'px)',
+ transition: ''
+ })
+ // 环形进度条
+ var progress = ins.selectComponent('.mescroll-wxs-progress')
+ progress && progress.setStyle({transform: 'rotate(' + 360 * rate + 'deg)'})
+ })
+}
+
+/* 显示下拉刷新进度 */
+me.showLoading = function (ins){
+ me.downHight = me.optDown.offset
+ ins.requestAnimationFrame(function () {
+ ins.selectComponent('.mescroll-wxs-content').setStyle({
+ transform: 'translateY(' + me.downHight + 'px)',
+ transition: 'transform 300ms'
+ })
+ })
+}
+
+/* 结束下拉 */
+me.endDownScroll = function (ins){
+ me.downHight = 0;
+ me.isDownScrolling = false;
+ ins.requestAnimationFrame(function () {
+ ins.selectComponent('.mescroll-wxs-content').setStyle({
+ transform: 'translateY(0)', // 不可以写空串,否则scroll-view渲染不完整 (延时350ms会调clearTransform置空)
+ transition: 'transform 300ms'
+ })
+ })
+}
+
+/* 结束下拉动画执行完毕后, 清除transform和transition, 避免对列表内容样式造成影响, 如: h5的list-msg示例下拉进度条漏出来等 */
+me.clearTransform = function (ins){
+ ins.requestAnimationFrame(function () {
+ ins.selectComponent('.mescroll-wxs-content').setStyle({
+ transform: '',
+ transition: ''
+ })
+ })
+}
+
+// ------ 自定义下拉刷新动画 end ------
+
+/**
+ * 监听逻辑层数据的变化 (实时更新数据)
+ */
+function propObserver(wxsProp) {
+ me.optDown = wxsProp.optDown
+ me.scrollTop = wxsProp.scrollTop
+ me.bodyHeight = wxsProp.bodyHeight
+ me.isDownScrolling = wxsProp.isDownScrolling
+ me.isUpScrolling = wxsProp.isUpScrolling
+ me.isUpBoth = wxsProp.isUpBoth
+ me.isScrollBody = wxsProp.isScrollBody
+ me.startTop = wxsProp.scrollTop // 及时更新touchstart触发的startTop, 避免scroll-view快速惯性滚动到顶部取值不准确
+}
+
+/**
+ * 监听逻辑层数据的变化 (调用wxs的方法)
+ */
+function callObserver(callProp, oldValue, ins) {
+ if (me.disabled()) return;
+ if(callProp.callType){
+ // 逻辑层(App Service)的style已失效,需在视图层(Webview)设置style
+ if(callProp.callType === 'showLoading'){
+ me.showLoading(ins)
+ }else if(callProp.callType === 'endDownScroll'){
+ me.endDownScroll(ins)
+ }else if(callProp.callType === 'clearTransform'){
+ me.clearTransform(ins)
+ }
+ }
+}
+
+/**
+ * touch事件
+ */
+function touchstartEvent(e, ins) {
+ if (me.disabled()) return true;
+
+ me.downHight = 0; // 下拉的距离
+ me.startPoint = me.getPoint(e); // 记录起点
+ me.startTop = me.getScrollTop(); // 记录此时的滚动条位置
+ me.startAngle = 0; // 初始角度
+ me.lastPoint = me.startPoint; // 重置上次move的点
+ me.maxTouchmoveY = me.getBodyHeight() - me.optDown.bottomOffset; // 手指触摸的最大范围(写在touchstart避免body获取高度为0的情况)
+ me.inTouchend = false; // 标记不是touchend
+
+ me.callMethod(ins, {type: 'setWxsProp'}) // 同步更新wxsProp的数据 (小程序是异步的,可能touchmove先执行,才到propObserver; h5和app是同步)
+}
+
+function touchmoveEvent(e, ins) {
+ var isPrevent = true // false表示不往上冒泡,相当于调用了同时调用了stopPropagation和preventDefault (对小程序生效, h5和app无效)
+
+ if (me.disabled()) return isPrevent;
+
+ var scrollTop = me.getScrollTop(); // 当前滚动条的距离
+ var curPoint = me.getPoint(e); // 当前点
+
+ var moveY = curPoint.y - me.startPoint.y; // 和起点比,移动的距离,大于0向下拉,小于0向上拉
+
+ // 向下拉 && 在顶部
+ // mescroll-body,直接判定在顶部即可
+ // scroll-view在滚动时不会触发touchmove,当触顶/底/左/右时,才会触发touchmove
+ // scroll-view滚动到顶部时,scrollTop不一定为0,也有可能大于0; 在iOS的APP中scrollTop可能为负数,不一定和startTop相等
+ if (moveY > 0 && (
+ (me.isScrollBody && scrollTop <= 0)
+ ||
+ (!me.isScrollBody && (scrollTop <= 0 || (scrollTop <= me.optDown.startTop && scrollTop === me.startTop)) )
+ )) {
+ // 可下拉的条件
+ if (!me.inTouchend && !me.isDownScrolling && !me.optDown.isLock && (!me.isUpScrolling || (me.isUpScrolling &&
+ me.isUpBoth))) {
+
+ // 下拉的角度是否在配置的范围内
+ if(!me.startAngle) me.startAngle = me.getAngle(me.lastPoint, curPoint); // 两点之间的角度,区间 [0,90]
+ if (me.startAngle < me.optDown.minAngle) return isPrevent; // 如果小于配置的角度,则不往下执行下拉刷新
+
+ // 如果手指的位置超过配置的距离,则提前结束下拉,避免Webview嵌套导致touchend无法触发
+ if (me.maxTouchmoveY > 0 && curPoint.y >= me.maxTouchmoveY) {
+ me.inTouchend = true; // 标记执行touchend
+ touchendEvent(e, ins); // 提前触发touchend
+ return isPrevent;
+ }
+
+ isPrevent = false // 小程序是return false
+
+ var diff = curPoint.y - me.lastPoint.y; // 和上次比,移动的距离 (大于0向下,小于0向上)
+
+ // 下拉距离 < 指定距离
+ if (me.downHight < me.optDown.offset) {
+ if (me.movetype !== 1) {
+ me.movetype = 1; // 加入标记,保证只执行一次
+ // me.optDown.inOffset && me.optDown.inOffset(me); // 进入指定距离范围内那一刻的回调,只执行一次
+ me.callMethod(ins, {type: 'setLoadType', downLoadType: 1})
+ me.isMoveDown = true; // 标记下拉区域高度改变,在touchend重置回来
+ }
+ me.downHight += diff * me.optDown.inOffsetRate; // 越往下,高度变化越小
+
+ // 指定距离 <= 下拉距离
+ } else {
+ if (me.movetype !== 2) {
+ me.movetype = 2; // 加入标记,保证只执行一次
+ // me.optDown.outOffset && me.optDown.outOffset(me); // 下拉超过指定距离那一刻的回调,只执行一次
+ me.callMethod(ins, {type: 'setLoadType', downLoadType: 2})
+ me.isMoveDown = true; // 标记下拉区域高度改变,在touchend重置回来
+ }
+ if (diff > 0) { // 向下拉
+ me.downHight += diff * me.optDown.outOffsetRate; // 越往下,高度变化越小
+ } else { // 向上收
+ me.downHight += diff; // 向上收回高度,则向上滑多少收多少高度
+ }
+ }
+
+ me.downHight = Math.round(me.downHight) // 取整
+ var rate = me.downHight / me.optDown.offset; // 下拉区域当前高度与指定距离的比值
+ // me.optDown.onMoving && me.optDown.onMoving(me, rate, me.downHight); // 下拉过程中的回调,一直在执行
+ me.onMoving(ins, rate, me.downHight)
+ }
+ }
+
+ me.lastPoint = curPoint; // 记录本次移动的点
+
+ return isPrevent // false表示不往上冒泡,相当于调用了同时调用了stopPropagation和preventDefault (对小程序生效, h5和app无效)
+}
+
+function touchendEvent(e, ins) {
+ if (me.disabled()) return true;
+ // 如果下拉区域高度已改变,则需重置回来
+ if (me.isMoveDown) {
+ if (me.downHight >= me.optDown.offset) {
+ // 符合触发刷新的条件
+ me.downHight = me.optDown.offset; // 更新下拉区域高度
+ // me.triggerDownScroll();
+ me.callMethod(ins, {type: 'triggerDownScroll'})
+ } else {
+ // 不符合的话 则重置
+ me.downHight = 0;
+ // me.optDown.endDownScroll && me.optDown.endDownScroll(me);
+ me.callMethod(ins, {type: 'endDownScroll'})
+ }
+ me.movetype = 0;
+ me.isMoveDown = false;
+ } else if (!me.isScrollBody && me.getScrollTop() === me.startTop) { // scroll-view到顶/左/右/底的滑动事件
+ var isScrollUp = me.getPoint(e).y - me.startPoint.y < 0; // 和起点比,移动的距离,大于0向下拉,小于0向上拉
+ // 上滑
+ if (isScrollUp) {
+ // 需检查滑动的角度
+ var angle = me.getAngle(me.getPoint(e), me.startPoint); // 两点之间的角度,区间 [0,90]
+ if (angle > 80) {
+ // 检查并触发上拉
+ // me.triggerUpScroll(true);
+ me.callMethod(ins, {type: 'triggerUpScroll'})
+ }
+ }
+ }
+ me.callMethod(ins, {type: 'setWxsProp'}) // 同步更新wxsProp的数据 (小程序是异步的,可能touchmove先执行,才到propObserver; h5和app是同步)
+}
+
+/* 是否禁用下拉刷新 */
+me.disabled = function(){
+ return !me.optDown || !me.optDown.use || me.optDown.native
+}
+
+/* 根据点击滑动事件获取第一个手指的坐标 */
+me.getPoint = function(e) {
+ if (!e) {
+ return {x: 0,y: 0}
+ }
+ if (e.touches && e.touches[0]) {
+ return {x: e.touches[0].pageX,y: e.touches[0].pageY}
+ } else if (e.changedTouches && e.changedTouches[0]) {
+ return {x: e.changedTouches[0].pageX,y: e.changedTouches[0].pageY}
+ } else {
+ return {x: e.clientX,y: e.clientY}
+ }
+}
+
+/* 计算两点之间的角度: 区间 [0,90]*/
+me.getAngle = function (p1, p2) {
+ var x = Math.abs(p1.x - p2.x);
+ var y = Math.abs(p1.y - p2.y);
+ var z = Math.sqrt(x * x + y * y);
+ var angle = 0;
+ if (z !== 0) {
+ angle = Math.asin(y / z) / Math.PI * 180;
+ }
+ return angle
+}
+
+/* 获取滚动条的位置 */
+me.getScrollTop = function() {
+ return me.scrollTop || 0
+}
+
+/* 获取body的高度 */
+me.getBodyHeight = function() {
+ return me.bodyHeight || 0;
+}
+
+/* 调用逻辑层的方法 */
+me.callMethod = function(ins, param) {
+ if(ins) ins.callMethod('wxsCall', param)
+}
+
+/* 导出模块 */
+module.exports = {
+ propObserver: propObserver,
+ callObserver: callObserver,
+ touchstartEvent: touchstartEvent,
+ touchmoveEvent: touchmoveEvent,
+ touchendEvent: touchendEvent
+}
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mix-action-sheet/mix-action-sheet.vue b/yudao-vue-ui/components/mix-action-sheet/mix-action-sheet.vue
new file mode 100644
index 000000000..5bd13983b
--- /dev/null
+++ b/yudao-vue-ui/components/mix-action-sheet/mix-action-sheet.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ {{ data.title }}
+
+
+ {{ item.text }}
+
+
+ 取消
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-button/mix-button.vue b/yudao-vue-ui/components/mix-button/mix-button.vue
new file mode 100644
index 000000000..9acfc14a2
--- /dev/null
+++ b/yudao-vue-ui/components/mix-button/mix-button.vue
@@ -0,0 +1,154 @@
+
+
+
+
+ {{ text }}
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-code/mix-code.vue b/yudao-vue-ui/components/mix-code/mix-code.vue
new file mode 100644
index 000000000..d34da9d01
--- /dev/null
+++ b/yudao-vue-ui/components/mix-code/mix-code.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ {{ timeDown > 0 ? '重新获取 ' + timeDown + 's' : '获取验证码' }}
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-empty/mix-empty.vue b/yudao-vue-ui/components/mix-empty/mix-empty.vue
new file mode 100644
index 000000000..248c86295
--- /dev/null
+++ b/yudao-vue-ui/components/mix-empty/mix-empty.vue
@@ -0,0 +1,209 @@
+
+
+
+
+ {{ hasLogin ? '空空如也' : '先去登录嘛' }}
+ 别忘了买点什么犒赏一下自己哦
+
+ {{ hasLogin ? '随便逛逛' : '去登录' }}
+
+
+
+
+ 找不到您的收货地址哦,先去添加一个吧~
+
+
+
+
+
+
+ 收藏夹空空的,先去逛逛吧~
+
+ 随便逛逛
+
+
+
+
+ {{ text }}
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-icon-loading/mix-icon-loading.vue b/yudao-vue-ui/components/mix-icon-loading/mix-icon-loading.vue
new file mode 100644
index 000000000..7daa641ed
--- /dev/null
+++ b/yudao-vue-ui/components/mix-icon-loading/mix-icon-loading.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-list-cell/mix-list-cell.vue b/yudao-vue-ui/components/mix-list-cell/mix-list-cell.vue
new file mode 100644
index 000000000..643a86608
--- /dev/null
+++ b/yudao-vue-ui/components/mix-list-cell/mix-list-cell.vue
@@ -0,0 +1,117 @@
+
+
+
+
+ {{ title }}
+ {{ tips }}
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-load-more/mix-load-more.vue b/yudao-vue-ui/components/mix-load-more/mix-load-more.vue
new file mode 100644
index 000000000..1cfdaabd9
--- /dev/null
+++ b/yudao-vue-ui/components/mix-load-more/mix-load-more.vue
@@ -0,0 +1,60 @@
+
+
+
+
+ {{ textList[status] }}
+
+
+
+ 国云网络提供技术支持
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yudao-vue-ui/components/mix-loading/mix-loading.vue b/yudao-vue-ui/components/mix-loading/mix-loading.vue
new file mode 100644
index 000000000..04f26ed82
--- /dev/null
+++ b/yudao-vue-ui/components/mix-loading/mix-loading.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-modal/mix-modal.vue b/yudao-vue-ui/components/mix-modal/mix-modal.vue
new file mode 100644
index 000000000..df57bc987
--- /dev/null
+++ b/yudao-vue-ui/components/mix-modal/mix-modal.vue
@@ -0,0 +1,105 @@
+
+
+
+ {{ title }}
+
+ {{ text }}
+
+
+
+ {{ cancelText }}
+
+
+ {{ confirmText }}
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-nav-bar/mix-nav-bar.vue b/yudao-vue-ui/components/mix-nav-bar/mix-nav-bar.vue
new file mode 100644
index 000000000..8a869bc92
--- /dev/null
+++ b/yudao-vue-ui/components/mix-nav-bar/mix-nav-bar.vue
@@ -0,0 +1,139 @@
+
+
+
+
+ {{ item.name }}
+ {{ counts[index] }}
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-number-box/mix-number-box.vue b/yudao-vue-ui/components/mix-number-box/mix-number-box.vue
new file mode 100644
index 000000000..63a3aa7fd
--- /dev/null
+++ b/yudao-vue-ui/components/mix-number-box/mix-number-box.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-price-view/mix-price-view.vue b/yudao-vue-ui/components/mix-price-view/mix-price-view.vue
new file mode 100644
index 000000000..182ee26ef
--- /dev/null
+++ b/yudao-vue-ui/components/mix-price-view/mix-price-view.vue
@@ -0,0 +1,53 @@
+
+
+ ¥
+ {{ priceArr[0] }}
+ .{{ priceArr[1] }}
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-timeline/mix-timeline.vue b/yudao-vue-ui/components/mix-timeline/mix-timeline.vue
new file mode 100644
index 000000000..b4c41a60f
--- /dev/null
+++ b/yudao-vue-ui/components/mix-timeline/mix-timeline.vue
@@ -0,0 +1,137 @@
+
+
+
+
+ {{ item.time | date('H:i') }}
+ {{ item.time | date('m/d') }}
+
+
+
+
+
+ {{ item.title }}
+ {{ item.tip }}
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/mix-upload-image/mix-upload-image.vue b/yudao-vue-ui/components/mix-upload-image/mix-upload-image.vue
new file mode 100644
index 000000000..b1881656b
--- /dev/null
+++ b/yudao-vue-ui/components/mix-upload-image/mix-upload-image.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+ {{item.progress}}%
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/number-keyboard/number-keyboard.vue b/yudao-vue-ui/components/number-keyboard/number-keyboard.vue
new file mode 100644
index 000000000..b4d9a66fd
--- /dev/null
+++ b/yudao-vue-ui/components/number-keyboard/number-keyboard.vue
@@ -0,0 +1,186 @@
+
+
+
+ {{num.number}}
+
+ 0
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/pay-password-keyboard/pay-password-keyboard.vue b/yudao-vue-ui/components/pay-password-keyboard/pay-password-keyboard.vue
new file mode 100644
index 000000000..8719a8581
--- /dev/null
+++ b/yudao-vue-ui/components/pay-password-keyboard/pay-password-keyboard.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+ 输入支付密码
+
+
+
+
+
+ 重置密码
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/uni-popup/popup.js b/yudao-vue-ui/components/uni-popup/popup.js
new file mode 100644
index 000000000..2a7f22f08
--- /dev/null
+++ b/yudao-vue-ui/components/uni-popup/popup.js
@@ -0,0 +1,25 @@
+import message from './message.js';
+// 定义 type 类型:弹出类型:top/bottom/center
+const config = {
+ // 顶部弹出
+ top:'top',
+ // 底部弹出
+ bottom:'bottom',
+ // 居中弹出
+ center:'center',
+ // 消息提示
+ message:'top',
+ // 对话框
+ dialog:'center',
+ // 分享
+ share:'bottom',
+}
+
+export default {
+ data(){
+ return {
+ config:config
+ }
+ },
+ mixins: [message],
+}
diff --git a/yudao-vue-ui/components/uni-popup/uni-popup.vue b/yudao-vue-ui/components/uni-popup/uni-popup.vue
new file mode 100644
index 000000000..8acb1698b
--- /dev/null
+++ b/yudao-vue-ui/components/uni-popup/uni-popup.vue
@@ -0,0 +1,302 @@
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/bindingx.js b/yudao-vue-ui/components/uni-swipe-action-item/bindingx.js
new file mode 100644
index 000000000..50b92416f
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/bindingx.js
@@ -0,0 +1,245 @@
+const BindingX = uni.requireNativePlugin('bindingx');
+const dom = uni.requireNativePlugin('dom');
+const animation = uni.requireNativePlugin('animation');
+
+export default {
+ data() {
+ return {
+ right: 0,
+ button: [],
+ preventGesture: false
+ }
+ },
+
+ watch: {
+ show(newVal) {
+ if (!this.position || JSON.stringify(this.position) === '{}') return;
+ if (this.autoClose) return
+ if (this.isInAnimation) return
+ if (newVal) {
+ this.open()
+ } else {
+ this.close()
+ }
+ },
+ },
+ created() {
+ if (this.swipeaction.children !== undefined) {
+ this.swipeaction.children.push(this)
+ }
+ },
+ mounted() {
+ this.boxSelector = this.getEl(this.$refs['selector-box-hock']);
+ this.selector = this.getEl(this.$refs['selector-content-hock']);
+ this.buttonSelector = this.getEl(this.$refs['selector-button-hock']);
+ this.position = {}
+ this.x = 0
+ setTimeout(() => {
+ this.getSelectorQuery()
+ }, 200)
+ },
+ beforeDestroy() {
+ if (this.timing) {
+ BindingX.unbind({
+ token: this.timing.token,
+ eventType: 'timing'
+ })
+ }
+ if (this.eventpan) {
+ BindingX.unbind({
+ token: this.eventpan.token,
+ eventType: 'pan'
+ })
+ }
+ this.swipeaction.children.forEach((item, index) => {
+ if (item === this) {
+ this.swipeaction.children.splice(index, 1)
+ }
+ })
+ },
+ methods: {
+ onClick(index, item) {
+ this.$emit('click', {
+ content: item,
+ index
+ })
+ },
+ touchstart(e) {
+ if (this.isInAnimation) return
+ if (this.stop) return
+ this.stop = true
+ if (this.autoClose) {
+ this.swipeaction.closeOther(this)
+ }
+ let endWidth = this.right
+ let boxStep = `(x+${this.x})`
+ let pageX = `${boxStep}> ${-endWidth} && ${boxStep} < 0?${boxStep}:(x+${this.x} < 0? ${-endWidth}:0)`
+
+ let props = [{
+ element: this.selector,
+ property: 'transform.translateX',
+ expression: pageX
+ }]
+
+ let left = 0
+ for (let i = 0; i < this.options.length; i++) {
+ let buttonSelectors = this.getEl(this.$refs['button-hock'][i]);
+ if (this.button.length === 0 || !this.button[i] || !this.button[i].width) return
+ let moveMix = endWidth - left
+ left += this.button[i].width
+ let step = `(${this.x}+x)/${endWidth}`
+ let moveX = `(${step}) * ${moveMix}`
+ let pageButtonX = `${moveX}&& (x+${this.x} > ${-endWidth})?${moveX}:${-moveMix}`
+ props.push({
+ element: buttonSelectors,
+ property: 'transform.translateX',
+ expression: pageButtonX
+ })
+ }
+
+ this.eventpan = this._bind(this.boxSelector, props, 'pan', (e) => {
+ if (e.state === 'end') {
+ this.x = e.deltaX + this.x;
+ if (this.x < -endWidth) {
+ this.x = -endWidth
+ }
+ if (this.x > 0) {
+ this.x = 0
+ }
+ this.stop = false
+ this.bindTiming();
+ }
+ })
+ },
+ touchend(e) {
+ this.$nextTick(() => {
+ if (this.isopen && !this.isDrag && !this.isInAnimation) {
+ this.close()
+ }
+ })
+ },
+ bindTiming() {
+ if (this.isopen) {
+ this.move(this.x, -this.right)
+ } else {
+ this.move(this.x, -40)
+ }
+ },
+ move(left, value) {
+ if (left >= value) {
+ this.close()
+ } else {
+ this.open()
+ }
+ },
+ /**
+ * 开启swipe
+ */
+ open() {
+ this.animation(true)
+ },
+ /**
+ * 关闭swipe
+ */
+ close() {
+ this.animation(false)
+ },
+ /**
+ * 开启关闭动画
+ * @param {Object} type
+ */
+ animation(type) {
+ this.isDrag = true
+ let endWidth = this.right
+ let time = 200
+ this.isInAnimation = true;
+
+ let exit = `t>${time}`;
+ let translate_x_expression = `easeOutExpo(t,${this.x},${type?(-endWidth-this.x):(-this.x)},${time})`
+ let props = [{
+ element: this.selector,
+ property: 'transform.translateX',
+ expression: translate_x_expression
+ }]
+
+ let left = 0
+ for (let i = 0; i < this.options.length; i++) {
+ let buttonSelectors = this.getEl(this.$refs['button-hock'][i]);
+ if (this.button.length === 0 || !this.button[i] || !this.button[i].width) return
+ let moveMix = endWidth - left
+ left += this.button[i].width
+ let step = `${this.x}/${endWidth}`
+ let moveX = `(${step}) * ${moveMix}`
+ let pageButtonX = `easeOutExpo(t,${moveX},${type ? -moveMix + '-' + moveX: 0 + '-' + moveX},${time})`
+ props.push({
+ element: buttonSelectors,
+ property: 'transform.translateX',
+ expression: pageButtonX
+ })
+ }
+
+ this.timing = BindingX.bind({
+ eventType: 'timing',
+ exitExpression: exit,
+ props: props
+ }, (e) => {
+ if (e.state === 'end' || e.state === 'exit') {
+ this.x = type ? -endWidth : 0
+ this.isInAnimation = false;
+
+ this.isopen = this.isopen || false
+ if (this.isopen !== type) {
+ this.$emit('change', type)
+ }
+ this.isopen = type
+ this.isDrag = false
+ }
+ });
+ },
+ /**
+ * 绑定 BindingX
+ * @param {Object} anchor
+ * @param {Object} props
+ * @param {Object} fn
+ */
+ _bind(anchor, props, eventType, fn) {
+ return BindingX.bind({
+ anchor,
+ eventType,
+ props
+ }, (e) => {
+ typeof(fn) === 'function' && fn(e)
+ });
+ },
+ /**
+ * 获取ref
+ * @param {Object} el
+ */
+ getEl(el) {
+ return el.ref
+ },
+ /**
+ * 获取节点信息
+ */
+ getSelectorQuery() {
+ dom.getComponentRect(this.$refs['selector-content-hock'], (data) => {
+ if (this.position.content) return
+ this.position.content = data.size
+ })
+ for (let i = 0; i < this.options.length; i++) {
+ dom.getComponentRect(this.$refs['button-hock'][i], (data) => {
+ if (!this.button) {
+ this.button = []
+ }
+ if (this.options.length === this.button.length) return
+ this.button.push(data.size)
+ this.right += data.size.width
+ if (this.autoClose) return
+ if (this.show) {
+ this.open()
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/index.wxs b/yudao-vue-ui/components/uni-swipe-action-item/index.wxs
new file mode 100644
index 000000000..24c94bb08
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/index.wxs
@@ -0,0 +1,204 @@
+/**
+ * 监听页面内值的变化,主要用于动态开关swipe-action
+ * @param {Object} newValue
+ * @param {Object} oldValue
+ * @param {Object} ownerInstance
+ * @param {Object} instance
+ */
+function sizeReady(newValue, oldValue, ownerInstance, instance) {
+ var state = instance.getState()
+ state.position = JSON.parse(newValue)
+ if (!state.position || state.position.length === 0) return
+ var show = state.position[0].show
+ state.left = state.left || state.position[0].left;
+ // 通过用户变量,开启或关闭
+ if (show) {
+ openState(true, instance, ownerInstance)
+ } else {
+ openState(false, instance, ownerInstance)
+ }
+}
+
+/**
+ * 开始触摸操作
+ * @param {Object} e
+ * @param {Object} ins
+ */
+function touchstart(e, ins) {
+ var instance = e.instance;
+ var state = instance.getState();
+ var pageX = e.touches[0].pageX;
+ // 开始触摸时移除动画类
+ instance.removeClass('ani');
+ var owner = ins.selectAllComponents('.button-hock')
+ for (var i = 0; i < owner.length; i++) {
+ owner[i].removeClass('ani');
+ }
+ // state.position = JSON.parse(instance.getDataset().position);
+ state.left = state.left || state.position[0].left;
+ // 获取最终按钮组的宽度
+ state.width = pageX - state.left;
+ ins.callMethod('closeSwipe')
+}
+
+/**
+ * 开始滑动操作
+ * @param {Object} e
+ * @param {Object} ownerInstance
+ */
+function touchmove(e, ownerInstance) {
+ var instance = e.instance;
+ var disabled = instance.getDataset().disabled
+ var state = instance.getState()
+ // fix by mehaotian, TODO 兼容 app-vue 获取dataset为字符串 , h5 获取 为 undefined 的问题,待框架修复
+ disabled = (typeof(disabled) === 'string' ? JSON.parse(disabled) : disabled) || false;
+
+ if (disabled) return
+ var pageX = e.touches[0].pageX;
+ move(pageX - state.width, instance, ownerInstance)
+}
+
+/**
+ * 结束触摸操作
+ * @param {Object} e
+ * @param {Object} ownerInstance
+ */
+function touchend(e, ownerInstance) {
+ var instance = e.instance;
+ var disabled = instance.getDataset().disabled
+ var state = instance.getState()
+
+ // fix by mehaotian, TODO 兼容 app-vue 获取dataset为字符串 , h5 获取 为 undefined 的问题,待框架修复
+ disabled = (typeof(disabled) === 'string' ? JSON.parse(disabled) : disabled) || false;
+
+ if (disabled) return
+ // 滑动过程中触摸结束,通过阙值判断是开启还是关闭
+ // fixed by mehaotian 定时器解决点击按钮,touchend 触发比 click 事件时机早的问题 ,主要是 ios13
+ moveDirection(state.left, -40, instance, ownerInstance)
+}
+
+/**
+ * 设置移动距离
+ * @param {Object} value
+ * @param {Object} instance
+ * @param {Object} ownerInstance
+ */
+function move(value, instance, ownerInstance) {
+ var state = instance.getState()
+ // 获取可滑动范围
+ var x = Math.max(-state.position[1].width, Math.min((value), 0));
+ state.left = x;
+ instance.setStyle({
+ transform: 'translateX(' + x + 'px)',
+ '-webkit-transform': 'translateX(' + x + 'px)'
+ })
+ // 折叠按钮动画
+ buttonFold(x, instance, ownerInstance)
+}
+
+/**
+ * 移动方向判断
+ * @param {Object} left
+ * @param {Object} value
+ * @param {Object} ownerInstance
+ * @param {Object} ins
+ */
+function moveDirection(left, value, ins, ownerInstance) {
+ var state = ins.getState()
+ var position = state.position
+ var isopen = state.isopen
+ if (!position[1].width) {
+ openState(false, ins, ownerInstance)
+ return
+ }
+ // 如果已经是打开状态,进行判断是否关闭,还是保留打开状态
+ if (isopen) {
+ if (-left <= position[1].width) {
+ openState(false, ins, ownerInstance)
+ } else {
+ openState(true, ins, ownerInstance)
+ }
+ return
+ }
+ // 如果是关闭状态,进行判断是否打开,还是保留关闭状态
+ if (left <= value) {
+ openState(true, ins, ownerInstance)
+ } else {
+ openState(false, ins, ownerInstance)
+ }
+}
+
+/**
+ * 设置按钮移动距离
+ * @param {Object} value
+ * @param {Object} instance
+ * @param {Object} ownerInstance
+ */
+function buttonFold(value, instance, ownerInstance) {
+ var ins = ownerInstance.selectAllComponents('.button-hock');
+ var state = instance.getState();
+ var position = state.position;
+ var arr = [];
+ var w = 0;
+ for (var i = 0; i < ins.length; i++) {
+ if (!ins[i].getDataset().button) return
+ var btnData = JSON.parse(ins[i].getDataset().button)
+
+ // fix by mehaotian TODO 在 app-vue 中,字符串转对象,需要转两次,这里先这么兼容
+ if (typeof(btnData) === 'string') {
+ btnData = JSON.parse(btnData)
+ }
+
+ var button = btnData[i] && btnData[i].width || 0
+ w += button
+ arr.push(-w)
+ // 动态计算按钮组每个按钮的折叠动画移动距离
+ var distance = arr[i - 1] + value * (arr[i - 1] / position[1].width)
+ if (i != 0) {
+ ins[i].setStyle({
+ transform: 'translateX(' + distance + 'px)',
+ })
+ }
+ }
+}
+
+/**
+ * 开启状态
+ * @param {Boolean} type
+ * @param {Object} ins
+ * @param {Object} ownerInstance
+ */
+function openState(type, ins, ownerInstance) {
+ var state = ins.getState()
+ var position = state.position
+ if (state.isopen === undefined) {
+ state.isopen = false
+ }
+ // 只有状态有改变才会通知页面改变状态
+ if (state.isopen !== type) {
+ // 通知页面,已经打开
+ ownerInstance.callMethod('change', {
+ open: type
+ })
+ }
+ // 设置打开和移动状态
+ state.isopen = type
+
+
+ // 添加动画类
+ ins.addClass('ani');
+ var owner = ownerInstance.selectAllComponents('.button-hock')
+ for (var i = 0; i < owner.length; i++) {
+ owner[i].addClass('ani');
+ }
+ // 设置最终移动位置
+ move(type ? -position[1].width : 0, ins, ownerInstance)
+
+}
+
+module.exports = {
+ sizeReady: sizeReady,
+ touchstart: touchstart,
+ touchmove: touchmove,
+ touchend: touchend
+}
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/mpalipay.js b/yudao-vue-ui/components/uni-swipe-action-item/mpalipay.js
new file mode 100644
index 000000000..2b494a4b8
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/mpalipay.js
@@ -0,0 +1,160 @@
+export default {
+ data() {
+ return {
+ isshow: false,
+ viewWidth: 0,
+ buttonWidth: 0,
+ disabledView: false,
+ x: 0,
+ transition: false
+ }
+ },
+ watch: {
+ show(newVal) {
+ if (this.autoClose) return
+ if (newVal) {
+ this.open()
+ } else {
+ this.close()
+ }
+ },
+ },
+ created() {
+ if (this.swipeaction.children !== undefined) {
+ this.swipeaction.children.push(this)
+ }
+ },
+ beforeDestroy() {
+ this.swipeaction.children.forEach((item, index) => {
+ if (item === this) {
+ this.swipeaction.children.splice(index, 1)
+ }
+ })
+ },
+ mounted() {
+ this.isopen = false
+ this.transition = true
+ setTimeout(() => {
+ this.getQuerySelect()
+ }, 50)
+
+ },
+ methods: {
+ onClick(index, item) {
+ this.$emit('click', {
+ content: item,
+ index
+ })
+ },
+ touchstart(e) {
+ let {
+ pageX,
+ pageY
+ } = e.changedTouches[0]
+ this.transition = false
+ this.startX = pageX
+ if (this.autoClose) {
+ this.swipeaction.closeOther(this)
+ }
+ },
+ touchmove(e) {
+ let {
+ pageX,
+ } = e.changedTouches[0]
+ this.slide = this.getSlide(pageX)
+ if (this.slide === 0) {
+ this.disabledView = false
+ }
+
+ },
+ touchend(e) {
+ this.stop = false
+ this.transition = true
+ if (this.isopen) {
+ if (this.moveX === -this.buttonWidth) {
+ this.close()
+ return
+ }
+ this.move()
+ } else {
+ if (this.moveX === 0) {
+ this.close()
+ return
+ }
+ this.move()
+ }
+ },
+ open() {
+ this.x = this.moveX
+ this.$nextTick(() => {
+ this.x = -this.buttonWidth
+ this.moveX = this.x
+
+ if(!this.isopen){
+ this.isopen = true
+ this.$emit('change', true)
+ }
+ })
+ },
+ close() {
+ this.x = this.moveX
+ this.$nextTick(() => {
+ this.x = 0
+ this.moveX = this.x
+ if(this.isopen){
+ this.isopen = false
+ this.$emit('change', false)
+ }
+ })
+ },
+ move() {
+ if (this.slide === 0) {
+ this.open()
+ } else {
+ this.close()
+ }
+ },
+ onChange(e) {
+ let x = e.detail.x
+ this.moveX = x
+ if (x >= this.buttonWidth) {
+ this.disabledView = true
+ this.$nextTick(() => {
+ this.x = this.buttonWidth
+ })
+ }
+ },
+ getSlide(x) {
+ if (x >= this.startX) {
+ this.startX = x
+ return 1
+ } else {
+ this.startX = x
+ return 0
+ }
+
+ },
+ getQuerySelect() {
+ const query = uni.createSelectorQuery().in(this);
+ query.selectAll('.viewWidth-hook').boundingClientRect(data => {
+
+ this.viewWidth = data[0].width
+ this.buttonWidth = data[1].width
+ this.transition = false
+ this.$nextTick(() => {
+ this.transition = true
+ })
+
+ if (!this.buttonWidth) {
+ this.disabledView = true
+ }
+
+ if (this.autoClose) return
+ if (this.show) {
+ this.open()
+ }
+ }).exec();
+
+ }
+ }
+}
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/mpother.js b/yudao-vue-ui/components/uni-swipe-action-item/mpother.js
new file mode 100644
index 000000000..3534b813a
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/mpother.js
@@ -0,0 +1,158 @@
+// #ifdef APP-NVUE
+const dom = weex.requireModule('dom');
+// #endif
+export default {
+ data() {
+ return {
+ uniShow: false,
+ left: 0
+ }
+ },
+ computed: {
+ moveLeft() {
+ return `translateX(${this.left}px)`
+ }
+ },
+ watch: {
+ show(newVal) {
+ if (!this.position || JSON.stringify(this.position) === '{}') return;
+ if (this.autoClose) return
+ if (newVal) {
+ this.$emit('change', true)
+ this.open()
+ } else {
+ this.$emit('change', false)
+ this.close()
+ }
+ }
+ },
+ mounted() {
+ this.position = {}
+ if (this.swipeaction.children !== undefined) {
+ this.swipeaction.children.push(this)
+ }
+ setTimeout(() => {
+ this.getSelectorQuery()
+ }, 100)
+ },
+ beforeDestoy() {
+ this.swipeaction.children.forEach((item, index) => {
+ if (item === this) {
+ this.swipeaction.children.splice(index, 1)
+ }
+ })
+ },
+ methods: {
+ onClick(index, item) {
+ this.$emit('click', {
+ content: item,
+ index
+ })
+ this.close()
+ },
+ touchstart(e) {
+ const {
+ pageX
+ } = e.touches[0]
+ if (this.disabled) return
+ const left = this.position.content.left
+ if (this.autoClose) {
+ this.swipeaction.closeOther(this)
+ }
+ this.width = pageX - left
+ if (this.isopen) return
+ if (this.uniShow) {
+ this.uniShow = false
+ this.isopen = true
+ this.openleft = this.left + this.position.button.width
+ }
+ },
+ touchmove(e, index) {
+ if (this.disabled) return
+ const {
+ pageX
+ } = e.touches[0]
+ this.setPosition(pageX)
+ },
+ touchend() {
+ if (this.disabled) return
+ if (this.isopen) {
+ this.move(this.openleft, 0)
+ return
+ }
+ this.move(this.left, -40)
+ },
+ setPosition(x, y) {
+ if (!this.position.button.width) {
+ return
+ }
+ // this.left = x - this.width
+ this.setValue(x - this.width)
+ },
+ setValue(value) {
+ // 设置最大最小值
+ this.left = Math.max(-this.position.button.width, Math.min(parseInt(value), 0))
+ this.position.content.left = this.left
+ if (this.isopen) {
+ this.openleft = this.left + this.position.button.width
+ }
+ },
+ move(left, value) {
+ if (left >= value) {
+ this.$emit('change', false)
+ this.close()
+ } else {
+ this.$emit('change', true)
+ this.open()
+ }
+ },
+ open() {
+ this.uniShow = true
+ this.left = -this.position.button.width
+ this.setValue(-this.position.button.width)
+ },
+ close() {
+ this.uniShow = true
+ this.setValue(0)
+ setTimeout(() => {
+ this.uniShow = false
+ this.isopen = false
+ }, 300)
+ },
+ getSelectorQuery() {
+ // #ifndef APP-NVUE
+ const views = uni.createSelectorQuery()
+ .in(this)
+ views
+ .selectAll('.selector-query-hock')
+ .boundingClientRect(data => {
+ this.position.content = data[1]
+ this.position.button = data[0]
+ if (this.autoClose) return
+ if (this.show) {
+ this.open()
+ } else {
+ this.close()
+ }
+ })
+ .exec()
+ // #endif
+ // #ifdef APP-NVUE
+ dom.getComponentRect(this.$refs['selector-content-hock'], (data) => {
+ if (this.position.content) return
+ this.position.content = data.size
+ })
+ dom.getComponentRect(this.$refs['selector-button-hock'], (data) => {
+ if (this.position.button) return
+ this.position.button = data.size
+ if (this.autoClose) return
+ if (this.show) {
+ this.open()
+ } else {
+ this.close()
+ }
+ })
+ // #endif
+ }
+ }
+}
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/mpwxs.js b/yudao-vue-ui/components/uni-swipe-action-item/mpwxs.js
new file mode 100644
index 000000000..f9d281344
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/mpwxs.js
@@ -0,0 +1,97 @@
+export default {
+ data() {
+ return {
+ position: [],
+ button: []
+ }
+ },
+ computed: {
+ pos() {
+ return JSON.stringify(this.position)
+ },
+ btn() {
+ return JSON.stringify(this.button)
+ }
+ },
+ watch: {
+ show(newVal) {
+ if (this.autoClose) return
+ let valueObj = this.position[0]
+ if (!valueObj) {
+ this.init()
+ return
+ }
+ valueObj.show = newVal
+ this.$set(this.position, 0, valueObj)
+ }
+ },
+ created() {
+ if (this.swipeaction.children !== undefined) {
+ this.swipeaction.children.push(this)
+ }
+ },
+ mounted() {
+ this.init()
+
+ },
+ beforeDestroy() {
+ this.swipeaction.children.forEach((item, index) => {
+ if (item === this) {
+ this.swipeaction.children.splice(index, 1)
+ }
+ })
+ },
+ methods: {
+ init() {
+
+ setTimeout(() => {
+ this.getSize()
+ this.getButtonSize()
+ }, 50)
+ },
+ closeSwipe(e) {
+ if (!this.autoClose) return
+ this.swipeaction.closeOther(this)
+ },
+
+ change(e) {
+ this.$emit('change', e.open)
+ let valueObj = this.position[0]
+ if (valueObj.show !== e.open) {
+ valueObj.show = e.open
+ this.$set(this.position, 0, valueObj)
+ }
+ },
+ onClick(index, item) {
+ this.$emit('click', {
+ content: item,
+ index
+ })
+ },
+ appTouchStart(){},
+ appTouchEnd(){},
+ getSize() {
+ const views = uni.createSelectorQuery().in(this)
+ views
+ .selectAll('.selector-query-hock')
+ .boundingClientRect(data => {
+ if (this.autoClose) {
+ data[0].show = false
+ } else {
+ data[0].show = this.show
+ }
+ this.position = data
+ })
+ .exec()
+ },
+ getButtonSize() {
+ const views = uni.createSelectorQuery().in(this)
+ views
+ .selectAll('.button-hock')
+ .boundingClientRect(data => {
+ this.button = data
+ })
+ .exec()
+ }
+ }
+}
diff --git a/yudao-vue-ui/components/uni-swipe-action-item/uni-swipe-action-item.vue b/yudao-vue-ui/components/uni-swipe-action-item/uni-swipe-action-item.vue
new file mode 100644
index 000000000..af962d6ab
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action-item/uni-swipe-action-item.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/uni-swipe-action/uni-swipe-action.vue b/yudao-vue-ui/components/uni-swipe-action/uni-swipe-action.vue
new file mode 100644
index 000000000..c8b656c11
--- /dev/null
+++ b/yudao-vue-ui/components/uni-swipe-action/uni-swipe-action.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/uni-transition/uni-transition.vue b/yudao-vue-ui/components/uni-transition/uni-transition.vue
new file mode 100644
index 000000000..212ea1e14
--- /dev/null
+++ b/yudao-vue-ui/components/uni-transition/uni-transition.vue
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/components/version-update/base-cloud-mobile.scss b/yudao-vue-ui/components/version-update/base-cloud-mobile.scss
new file mode 100644
index 000000000..b64871653
--- /dev/null
+++ b/yudao-vue-ui/components/version-update/base-cloud-mobile.scss
@@ -0,0 +1,1250 @@
+div,a,img,span,page,view,navigator,image,text,input,textarea,button,form{
+ box-sizing: border-box;
+}
+
+a{
+ text-decoration: none;
+ color: $main;
+}
+
+form{
+ display: block;
+ width: 100%;
+}
+
+image{will-change: transform}
+
+input,textarea,form{
+ width: 100%;
+ height: auto;
+ min-height: 10px;
+ display: block;
+ font-size: inherit;
+}
+
+button{
+ color: inherit;
+ line-height: inherit;
+ margin: 0;
+ background-color: transparent;
+ padding: 0;
+ -webkit-border-radius: 0;
+ -moz-border-radius: 0;
+ border-radius: 0;
+ &:after{
+ display: none;
+ }
+}
+
+switch .uni-switch-input{
+ margin-right: 0;
+}
+.wx-switch-input,.uni-switch-input{width:42px !important;height:22px !important;}
+ .wx-switch-input::before,.uni-switch-input::before{width:40px !important;height: 20px !important;}
+ .wx-switch-input::after,.uni-switch-input::after{width: 20px !important;height: 20px !important;}
+
+
+/**背景颜色**/
+.bg{
+ background-color: $main;
+ color: $mainInverse;
+}
+.gradualBg{
+ background-image: $mainGradual;
+ color: $mainGradualInverse ;
+}
+.grayBg{
+ background-color: #f7f7f7;
+ color: #30302f;
+}
+.whiteBg{
+ background-color: #fff;
+ color: #000;
+}
+.blackBg{
+ background-color: #000;
+ color: #fff;
+}
+.orangeBg{
+ background-color: $orange;
+ color: #fff;
+}
+.redBg{
+ background-color: $red;
+ color: #fff;
+}
+.yellowBg{
+ background-color: $yellow;
+ color: #000;
+}
+.greenBg{
+ background-color: $green;
+ color: #fff;
+}
+.brownBg{
+ background-color: $brown;
+ color: #fff;
+}
+.blueBg{
+ background-color: $blue;
+ color: #fff;
+}
+.purpleBg{
+ background-color: $purple;
+ color: #fff;
+}
+
+/* 文字颜色 */
+.main{
+ color: $main;
+}
+.green{
+ color: $green;
+}
+.red{
+ color: $red;
+}
+.yellow{
+ color: $yellow;
+}
+.black{
+ color: $black;
+}
+.white{
+ color: $white;
+}
+.gray{
+ color: $gray;
+}
+.grey{
+ color: $grey;
+}
+.orange{
+ color: $orange;
+}
+.brown{
+ color: $brown;
+}
+.blue{
+ color: $blue;
+}
+.purple{
+ color: $purple;
+}
+
+.hoverMain{
+ &:hover{
+ color: $main;
+ }
+}
+
+.hoverGreen{
+ &:hover{
+ color: $green;
+ }
+}
+
+.hoverRed{
+ &:hover{
+ color: $red;
+ }
+}
+
+.hoverBlue{
+ &:hover{
+ color: $blue;
+ }
+}
+
+.hoverGray{
+ &:hover{
+ color: $gray;
+ }
+}
+
+.hoverWhite{
+ &:hover{
+ color: $white;
+ }
+}
+
+.hoverBlack{
+ &:hover{
+ color: $black;
+ }
+}
+
+.hoverOrange{
+ &:hover{
+ color: $orange;
+ }
+}
+
+.hoverYellow{
+ &:hover{
+ color: $yellow;
+ }
+}
+
+.hoverBrown{
+ &:hover{
+ color: $brown;
+ }
+}
+
+.hoverPurple{
+ &:hover{
+ color: $purple;
+ }
+}
+
+/* 宽度 高度 */
+$w:0;
+@while $w <= 500 {
+ @if $w <= 100 {
+ .w#{$w}p{
+ width: $w*1%;
+ }
+ .h#{$w}p{
+ height: $w*1%;
+ }
+ @if $w == 100 {
+ .100p{
+ width: 100%;
+ height: 100%;
+ }
+ .ww{
+ width: 100vw;
+ }
+ .hh{
+ height: 100vh;
+ }
+ }
+ }
+ .w#{$w}{
+ width: $w*2upx;
+ }
+ .h#{$w}{
+ height: $w*2upx;
+ }
+ @if $w < 10 {
+ $w : $w + 1 ;
+ } @else{
+ $w : $w + 5 ;
+ }
+}
+
+
+/* 字号 */
+@for $fz from 12 through 100 {
+ .fz#{$fz}{
+ font-size: $fz*2upx !important;
+ }
+}
+
+/* 边距 - 覆盖顺序是小的尺寸覆盖大的尺寸 少的方向覆盖多的方向 */
+$s : 0 ;
+@while $s <= 500 {
+ .pd#{$s}{
+ padding: $s*2upx!important;
+ }
+ .m#{$s}{
+ margin: $s*2upx!important;
+ }
+ @if $s == 15 {
+ .pd{
+ padding: 30upx!important;
+ }
+ .m{
+ margin: 30upx!important;
+ }
+ }
+ @if $s < 10 {
+ $s : $s + 1 ;
+ } @else if($s < 100){
+ $s : $s + 5 ;
+ } @else if($s < 300){
+ $s : $s + 10 ;
+ } @else{
+ $s : $s + 50 ;
+ }
+}
+
+$s : 0 ;
+@while $s <= 500 {
+ .ptb#{$s}{
+ padding-top: $s*2upx!important;
+ padding-bottom: $s*2upx!important;
+ }
+ .plr#{$s}{
+ padding-left: $s*2upx!important;
+ padding-right: $s*2upx!important;
+ }
+ .mtb#{$s}{
+ margin-top: $s*2upx!important;
+ margin-bottom: $s*2upx!important;
+ }
+ .mlr#{$s}{
+ margin-left: $s*2upx!important;
+ margin-right: $s*2upx!important;
+ }
+ @if $s == 15 {
+ .ptb{
+ padding-top: 30upx!important;
+ padding-bottom: 30upx!important;
+ }
+ .plr{
+ padding-left: 30upx!important;
+ padding-right: 30upx!important;
+ }
+
+ .mlr{
+ margin-left: 30upx!important;
+ margin-right: 30upx!important;
+ }
+ .mtb{
+ margin-top: 30upx!important;
+ margin-bottom: 30upx!important;
+ }
+ }
+ @if $s < 10 {
+ $s : $s + 1 ;
+ } @else if($s < 100){
+ $s : $s + 5 ;
+ } @else if($s < 300){
+ $s : $s + 10 ;
+ } @else{
+ $s : $s + 50 ;
+ }
+}
+
+$s : 0 ;
+@while $s <= 500 {
+ .pl#{$s}{
+ padding-left: $s*2upx!important;
+ }
+ .pr#{$s}{
+ padding-right: $s*2upx!important;
+ }
+ .pt#{$s}{
+ padding-top: $s*2upx!important;
+ }
+ .pb#{$s}{
+ padding-bottom: $s*2upx!important;
+ }
+ .ml#{$s}{
+ margin-left: $s*2upx!important;
+ }
+ .mr#{$s}{
+ margin-right: $s*2upx!important;
+ }
+ .mt#{$s}{
+ margin-top: $s*2upx!important;
+ }
+ .mb#{$s}{
+ margin-bottom: $s*2upx!important;
+ }
+ @if $s == 15 {
+ .pt{
+ padding-top: 30upx!important;
+ }
+ .pb{
+ padding-bottom: 30upx!important;
+ }
+ .pl{
+ padding-left: 30upx!important;
+ }
+ .pr{
+ padding-right: 30upx!important;
+ }
+ .mt{
+ margin-top: 30upx!important;
+ }
+ .mr{
+ margin-right: 30upx!important;
+ }
+ .ml{
+ margin-left: 30upx!important;
+ }
+ .mb{
+ margin-bottom: 30upx!important;
+ }
+ }
+ @if $s < 10 {
+ $s : $s + 1 ;
+ } @else if($s < 100){
+ $s : $s + 5 ;
+ } @else if($s < 300){
+ $s : $s + 10 ;
+ } @else{
+ $s : $s + 50 ;
+ }
+}
+
+
+
+/* 文字溢出隐藏 */
+.clip{
+ width: 100%;
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ @for $i from 2 through 10{
+ &.c#{$i}{
+ -webkit-line-clamp: $i;
+ }
+ }
+}
+
+.cut{
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* 价格 */
+.price{
+ font-size: inherit ;
+ &:before{
+ content: "¥";
+ font-size: 70%;
+ color: inherit;
+ font-weight: normal;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif ;
+ }
+ .point{
+ display: inline-block;
+ font-size: 70%;
+ font-weight: inherit;
+ letter-spacing: 1px;
+ color: inherit;
+ }
+ &.noPrefix{
+ &:before{
+ content: '';
+ }
+ }
+}
+
+/* 布局 */
+.grid,.gridNoPd,.gridSmPd,.gridNoMb{
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-wrap: wrap;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ width: 100%;
+ padding: 20upx 20upx 0 20upx;
+ >.item,>image,>view,>navigator,>text,>button{
+ width: 50%;
+ padding: 0 10upx;
+ margin-bottom: 20upx;
+ }
+ @for $i from 1 through 50{
+ &.g#{$i}{
+ >.item,>image,>view,>navigator,>text,>button{
+ width: 100%/$i;
+ }
+ }
+ }
+}
+
+.gridNoMb{
+ >.item,>image,>view,>navigator,>text,>button{
+ margin-bottom: 0;
+ }
+}
+
+.gridNoPd{
+ padding: 0;
+ >.item,>image,>view,>navigator,>text,>button{
+ padding: 0;
+ margin-bottom: 0;
+ }
+}
+.gridSmPd{
+ padding: 0;
+ >.item,>image,>view,>navigator,>text,>button{
+ padding-right: 0;
+ &:first-child{
+ padding-left: 0;
+ padding-right: 10upx;
+ }
+ }
+}
+
+/* flex布局 */
+.flex{
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ &.t{
+ align-items: flex-start;
+ }
+ &.b{
+ align-items: flex-end;
+ }
+ &.cv{ //垂直方向铺满
+ align-items: stretch;
+ }
+ &.bk{ //水平方向铺满
+ flex-direction: column;
+ }
+ &.lt{
+ justify-content: flex-start;
+ }
+ &.ct{
+ justify-content: center;
+ }
+ &.rt{
+ justify-content: flex-end;
+ }
+ &.ar{
+ justify-content: space-around;
+ }
+ &.av{
+ >.item,view,button,navigator,image,text{
+ flex:1;
+ }
+ }
+}
+
+/* 定位布局 */
+.father{
+ position: relative;
+}
+.abs,.fixed{
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1;
+ &image{
+ width: 100%;
+ height: 100%;
+ }
+ &.top{
+ bottom: auto;
+ }
+ &.bottom{
+ top: auto;
+ }
+ &.left{
+ right: auto;
+ }
+ &.right{
+ left: auto;
+ }
+}
+@for $i from 0 through 20 {
+ .z#{$i}{
+ z-index: $i !important;
+ }
+}
+
+@for $i from 1 through 200 {
+ .top-#{$i}{
+ bottom: auto;
+ top: $i * -2upx;
+ }
+ .left-#{$i}{
+ right: auto;
+ left: $i * -2upx;
+ }
+ .bottom-#{$i}{
+ top: auto;
+ bottom: $i * -2upx;
+ }
+ .right-#{$i}{
+ left: auto;
+ right: $i * -2upx;
+ }
+ .top#{$i}{
+ bottom: auto;
+ top: $i * 2upx;
+ }
+ .left#{$i}{
+ right: auto;
+ left: $i * 2upx;
+ }
+ .bottom#{$i}{
+ top: auto;
+ bottom: $i * 2upx;
+ }
+ .right#{$i}{
+ left: auto;
+ right: $i * 2upx;
+ }
+ .top-#{$i}p{
+ bottom: auto;
+ top: $i * -1%;
+ }
+ .left-#{$i}p{
+ right: auto;
+ left: $i * -1%;
+ }
+ .bottom-#{$i}p{
+ top: auto;
+ bottom: $i * -1%;
+ }
+ .right-#{$i}p{
+ left: auto;
+ right: $i * -1%;
+ }
+ .top#{$i}p{
+ bottom: auto;
+ top: $i * 1%;
+ }
+ .left#{$i}p{
+ right: auto;
+ left: $i * 1%;
+ }
+ .bottom#{$i}p{
+ top: auto;
+ bottom: $i * 1%;
+ }
+ .right#{$i}p{
+ left: auto;
+ right: $i * 1%;
+ }
+}
+
+.fixed{
+ position: fixed;
+}
+
+/* fix-auto布局 */
+.fixAuto,.fixAutoNoPd,.fixAutoSmPd{
+ display: table;
+ width: 100%;
+ padding: 20upx 10upx;
+ >.item,>view,>image,>navigator,>text,>button{
+ vertical-align: top;
+ padding: 0 10upx;
+ display: table-cell ;
+ }
+ &.middle{
+ >.item,>view,>image,>navigator,>text{
+ vertical-align: middle;
+ }
+ }
+ &.bottom{
+ >.item,>view,>image,>navigator,>text{
+ vertical-align: bottom;
+ }
+ }
+}
+.fixAutoSmPd{
+ padding: 0;
+ >.item,>view,>image,>navigator,>text{
+ padding-right: 0;
+ &:first-child{
+ padding-left: 0;
+ padding-right: 10upx;
+ }
+ }
+}
+.fixAutoNoPd{
+ padding: 0;
+ >.item,>view,>image,>navigator,>text{
+ padding: 0;
+ }
+}
+
+/* 浮动组件 */
+.clear{
+ &:after{
+ content: "";
+ clear: both;
+ height: 0;
+ display: block;
+ visibility: hidden;
+ }
+}
+.fl{
+ float: left;
+}
+.fr{
+ float: right;
+}
+
+/* 按钮样式类 */
+.btn,.roundBtn{
+ cursor: pointer;
+ display: inline-block;
+ text-align: center;
+ padding: 8upx 24upx;
+ background-color: $main;
+ color: $mainInverse ;
+ font-size: 28upx;
+ border: 1px solid $main;
+ -webkit-border-radius: 8upx;
+ -moz-border-radius: 8upx;
+ border-radius: 8upx;
+ transition: 0.4s;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ &.gradualBg{
+ border-color: transparent;
+ background-image: $mainGradual;
+ color: $mainGradualInverse;
+
+ }
+ &.blackBg{
+ background-color: $black;
+ border-color: $black;
+ color: #fff;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba($black , 0.4);
+ }
+ }
+ &.greenBg{
+ background-color: $green;
+ border-color: $green;
+ color: #fff;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba($green , 0.4);
+ }
+ }
+ &.grayBg{
+ border-color: rgba(#30302f,0.2);
+ background-color: #f7f7f7;
+ color: #30302f;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba( #30302f , 0.2);
+ }
+ }
+ &.whiteBg{
+ border-color: rgba(#fff,0.2);
+ background-color: #fff;
+ color: #000;
+ }
+
+ &.orangeBg{
+ border-color: $orange;
+ background-color: $orange;
+ color: #fff;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba( $orange , 0.4);
+ }
+ }
+ &.redBg{
+ border-color: $red;
+ background-color: $red;
+ color: #fff;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba( $red , 0.4);
+ }
+ }
+ &.yellowBg{
+ border-color: $yellow;
+ background-color: $yellow;
+ color: #000;
+ &.shadow{
+ box-shadow: 0px 2px 9px -1px rgba( $yellow , 0.4);
+ }
+ }
+
+ &.line{
+ background-color: transparent;
+ background-image: none;
+ color: $main;
+ &.blackBg{
+ color: $black;
+ }
+ &.greenBg{
+ color: $green;
+ }
+ &.yellowBg{
+ color: $yellow;
+ }
+ &.grayBg{
+ color: #30302f;
+ }
+ &.whiteBg{
+ border-color: rgba(#fff,0.7);
+ color: #fff;
+ }
+ &.orangeBg{
+ color: $orange;
+ }
+ &.redBg{
+ color: $red;
+ }
+ }
+ &+.btn,&+.roundBtn{
+ margin-left: 20upx;
+ }
+ &.block{
+ margin: 0;
+ padding: 20upx 24upx;
+ display: block;
+ width: 100%;
+ &+.btn{
+ margin-left: 0;
+ }
+ }
+ &:hover{
+ -webkit-transform: scale(0.99);
+ -moz-transform: scale(0.99);
+ -ms-transform: scale(0.99);
+ -o-transform: scale(0.99);
+ transform: scale(0.99);
+ opacity: 0.8;
+ }
+ &.disabled{
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1);
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+}
+
+[class^="tag"] , [class*=" tag"]{
+ display: inline-block;
+ font-size: 24upx;
+ padding: 4upx 14upx;
+ border-radius: 4upx;
+ margin-right: 6upx;
+ margin-left: 6upx;
+}
+.tag{
+ background-color: rgba($main,0.2);
+ color: $main;
+}
+.tagBlue{
+ background-color: rgba($blue,0.2);
+ color: $blue;
+}
+.tagGray{
+ background-color: rgba($gray,0.2);
+ color: $gray;
+}
+
+.tagGradual{
+ background-image: linear-gradient(to top right,rgba($main,0.2),rgba($main,0.1));
+ color: $main;
+}
+
+.tagBlack{
+ background-color: rgba($black,0.2);
+ color: $black;
+}
+.tagGreen{
+ background-color: rgba($green,0.2);
+ color: $green;
+}
+
+.tagWhite{
+ background-color: rgba($white,0.2);
+ color: $white;
+}
+
+.tagOrange{
+ background-color: rgba($orange,0.2);
+ color: $orange;
+}
+.tagRed{
+ background-color: rgba($red,0.2);
+ color: $red;
+}
+.tagYellow{
+ background-color: rgba($yellow,0.2);
+ color: $yellow;
+}
+
+/* 边线(实线、虚线) */
+.bdn{
+ border: none;
+}
+.bd{
+ border: 1px solid $borderColor;
+ &.dashed{
+ border-style: dashed;
+ }
+}
+.bt{
+ border-top:1px solid $borderColor;
+ &.dashed{
+ border-top-style: dashed;
+ }
+}
+.bb{
+ border-bottom:1px solid $borderColor;
+ &.dashed{
+ border-bottom-style: dashed;
+ }
+}
+.bl{
+ border-left:1px solid $borderColor;
+ &.dashed{
+ border-left-style: dashed;
+ }
+}
+.br{
+ border-right: 1px solid $borderColor;
+ &.dashed{
+ border-right-style: dashed;
+ }
+}
+
+$b:2;
+@while $b <= 10 {
+ .bd#{$b}{
+ border: #{$b}px solid $borderColor;
+ &.dashed{
+ border-style: dashed;
+ }
+ }
+ .bt#{$b}{
+ border-top:#{$b}px solid $borderColor;
+ &.dashed{
+ border-top-style: dashed;
+ }
+ }
+ .bb#{$b}{
+ border-bottom:#{$b}px solid $borderColor;
+ &.dashed{
+ border-bottom-style: dashed;
+ }
+ }
+ .bl#{$b}{
+ border-left:#{$b}px solid $borderColor;
+ &.dashed{
+ border-left-style: dashed;
+ }
+ }
+ .br#{$b}{
+ border-right: #{$b}px solid $borderColor;
+ &.dashed{
+ border-right-style: dashed;
+ }
+ }
+ $b : $b + 1 ;
+}
+
+/* 边线颜色 */
+.mainBd{
+ border-color: $main;
+}
+.greenBd{
+ border-color: $green;
+}
+.redBd{
+ border-color: $red;
+}
+.yellowBd{
+ border-color:$yellow ;
+}
+.blackBd{
+ border-color: $black;
+}
+.whiteBd{
+ border-color:$white ;
+}
+.grayBd{
+ border-color:$gray;
+}
+.greyBd{
+ border-color:$grey;
+}
+.orangeBd{
+ border-color:$orange;
+}
+
+/* 圆角 */
+.radius,.rds{
+ -webkit-border-radius: 100%!important;
+ -moz-border-radius: 100%!important;
+ border-radius: 100%!important;
+}
+
+$r:0;
+@while $r <= 50{
+ .rds#{$r},&.radius#{$r}{
+ -webkit-border-radius:$r*2upx!important;
+ -moz-border-radius:$r*2upx!important;
+ border-radius:$r*2upx!important;
+ }
+ $r : $r + 1;
+}
+
+.rdsTl,.radiusTopLeft{
+ border-top-left-radius:100%!important;
+}
+.rdsTr,.radiusTopRight{
+ border-top-right-radius: 100%!important;
+}
+.rdsBl,.radiusBottomLeft{
+ border-bottom-left-radius: 100%!important;
+}
+.rdsBr,.radiusBottomRight{
+ border-bottom-right-radius: 100%!important;
+}
+
+$r:0;
+@while $r <= 50{
+ .rdsTl#{$r},.radiusTopLeft#{$r}{
+ border-top-left-radius: $r*2upx!important;
+ }
+ .rdsTr#{$r},.radiusTopRight#{$r}{
+ border-top-right-radius: $r*2upx!important;
+ }
+ .rdsBl#{$r},.radiusBottomLeft#{$r}{
+ border-bottom-left-radius: $r*2upx!important;
+ }
+ .rdsBr#{$r},.radiusBottomRight#{$r}{
+ border-bottom-right-radius: $r*2upx!important;
+ }
+ $r : $r + 1;
+}
+
+/* 正方形&长方形 */
+[class^="square"] , [class*=" square"]{
+ width: 100%;
+ position: relative;
+ height: auto;
+ >.item,>image,>view,>navigator,>text,>button{
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+$p : 200 ;
+@while $p > 0 {
+ .square#{$p}{
+ padding-top: $p*1%;
+ }
+ @if $p == 100 {
+ .square{
+ padding-top: 100%;
+ }
+ }
+ $p : $p - 5 ;
+}
+
+
+
+/* 阴影 */
+.shadow{
+ box-shadow: 0px 2px 9px -1px rgba(0, 0, 0, 0.1);
+}
+
+/* 遮罩层 */
+.wrapper-top,.wt{
+ background-image: linear-gradient(rgba(0,0,0,0.3) , rgba(0,0,0,0.02));
+}
+.wrapper-bottom,.wb{
+ background-image: linear-gradient( rgba(0,0,0,0.02) , rgba(0,0,0,0.3) );
+}
+
+[class^="wp"],[class*=" wp"] {
+ z-index: 10;
+}
+
+/* 透明度 */
+@for $op from 0 through 10 {
+ .op#{$op}{
+ opacity: $op * 0.1!important;
+ }
+ .wp#{$op}{
+ background-color: rgba(#000,$op*0.1);
+ }
+ @if $op == 5 {
+ .wp{
+ background-color: rgba(#000,0.5);
+ }
+ }
+}
+
+/* 分割线 */
+[class*=" split"],[class^="split"] {
+ position: relative;
+ &:before{
+ content:"";
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 50%;
+ border-left: 1px solid $borderColor;
+ }
+}
+
+$s:10;
+@while $s <= 100 {
+ .split#{$s}{
+ &:before{
+ height: #{$s*2}upx;
+ margin-top: -#{$s}upx;
+ }
+ }
+ @if $s == 10 {
+ .split{
+ &:before{
+ height: 20upx;
+ margin-top: -10upx;
+ }
+ }
+ }
+ $s:$s+2;
+}
+
+.hover,[class^="hover"],[class*=" hover"]{
+ transition: all 0.4s;
+ cursor: pointer;
+ &:hover{
+ opacity: 0.8 !important;
+ }
+}
+
+
+
+.statusBar{
+ height: var(--status-bar-height);
+}
+
+.winBottom{
+ height: var(--windown-bottom);
+}
+
+.safeBottom{
+ padding-bottom: constant(safe-area-inset-bottom);
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+.disabled{
+ opacity:0.8;
+ cursor: not-allowed;
+}
+
+
+
+.grid,.gridNoMb,.gridNoPd{
+ >.btn,>.roundBtn{
+ &+.btn,&+.roundBtn{
+ margin-left: 0 ;
+ }
+ }
+}
+
+.roundBtn{
+ -webkit-border-radius: 100upx;
+ -moz-border-radius: 100upx;
+ border-radius: 100upx;
+}
+
+
+
+ /* 位置 */
+ .text-center,.tc{
+ text-align: center!important;
+ }
+ .text-left,.tl{
+ text-align: left!important;
+ }
+ .text-right,.tr{
+ text-align: right!important;
+ }
+ .text-justify,.tj{
+ text-align: justify!important;
+ }
+ .text-bold,.bold{
+ font-weight: bold!important;
+ }
+ .text-normal,.normal{
+ font-weight: normal!important;
+ }
+ .break{
+ white-space: normal;
+ word-break: break-all;
+ }
+ .noBreak{
+ white-space: nowrap;
+ word-break: keep-all;
+ }
+ .inline{
+ display: inline-block;
+ }
+ .block{
+ display: block;
+ width: 100%;
+ }
+ .none{
+ display: none;
+ }
+ .center-block{
+ margin: 0 auto;
+ display: block;
+ }
+ .hidden{
+ overflow: hidden;
+ }
+ .hiddenX{
+ overflow-x: hidden;
+ }
+ .hiddenY{
+ overflow-y: hidden;
+ }
+ .auto{
+ overflow: auto;
+ }
+ .autoX{
+ overflow-x: auto;
+ }
+ .autoY{
+ overflow-y: auto;
+ }
+ .showInMb{
+ display: block;
+ }
+ .showInPc{
+ display: none;
+ }
+ table{
+ width: 100%;
+ border-collapse: collapse;
+ border-spacing: 0;
+ border: 1px solid #e6e6e6;
+ thead{
+ tr{
+ background-color: #f2f2f2;
+ th{
+ color: #8799a3;
+ width: 1%;
+ }
+ }
+ }
+ tr{
+ background-color: #fff;
+ transition: all 0.4s;
+ td,th{
+ border: 1px solid #e6e6e6;
+ overflow: hidden;
+ -o-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ word-wrap: break-word;
+ padding: 5px 10px;
+ height: 28px;
+ line-height: 28px;
+ &.autoWidth{
+ width: auto;
+ }
+ }
+ &:hover{
+ background-color: #f2f2f2;
+ }
+ }
+ }
\ No newline at end of file
diff --git a/yudao-vue-ui/components/version-update/static/airship.png b/yudao-vue-ui/components/version-update/static/airship.png
new file mode 100644
index 000000000..f46ad4961
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/airship.png differ
diff --git a/yudao-vue-ui/components/version-update/static/cloudLeft.png b/yudao-vue-ui/components/version-update/static/cloudLeft.png
new file mode 100644
index 000000000..0d86534a6
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/cloudLeft.png differ
diff --git a/yudao-vue-ui/components/version-update/static/cloudRight.png b/yudao-vue-ui/components/version-update/static/cloudRight.png
new file mode 100644
index 000000000..46e5491bc
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/cloudRight.png differ
diff --git a/yudao-vue-ui/components/version-update/static/login-wave.png b/yudao-vue-ui/components/version-update/static/login-wave.png
new file mode 100644
index 000000000..ae9ae90aa
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/login-wave.png differ
diff --git a/yudao-vue-ui/components/version-update/static/shipAir.png b/yudao-vue-ui/components/version-update/static/shipAir.png
new file mode 100644
index 000000000..96e827fe4
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/shipAir.png differ
diff --git a/yudao-vue-ui/components/version-update/static/shipGas.png b/yudao-vue-ui/components/version-update/static/shipGas.png
new file mode 100644
index 000000000..df396f696
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/shipGas.png differ
diff --git a/yudao-vue-ui/components/version-update/static/smallCloud.png b/yudao-vue-ui/components/version-update/static/smallCloud.png
new file mode 100644
index 000000000..885bcdcf3
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/smallCloud.png differ
diff --git a/yudao-vue-ui/components/version-update/static/star.png b/yudao-vue-ui/components/version-update/static/star.png
new file mode 100644
index 000000000..63c80454a
Binary files /dev/null and b/yudao-vue-ui/components/version-update/static/star.png differ
diff --git a/yudao-vue-ui/components/version-update/version-update.vue b/yudao-vue-ui/components/version-update/version-update.vue
new file mode 100644
index 000000000..5f36aeb7d
--- /dev/null
+++ b/yudao-vue-ui/components/version-update/version-update.vue
@@ -0,0 +1,1811 @@
+/**
+* BaseCloud APP更新检测组件
+v1.0.4
+*/
+
+
+
+ 版本{{version}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{title}} {{ updateData.version ? 'v' + updateData.version : ''}}
+
+
+
+
+ {{ progress }}
+
+ %
+
+
+
+
+ 版本更新中,不要离开...
+
+
+
+
+ 版本升级成功
+
+ 更新已完成,请重启应用体验新版
+
+
+
+
+
+
+ {{ defaultContent }}
+
+
+ {{ index + 1 }}.{{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/index.html b/yudao-vue-ui/index.html
new file mode 100644
index 000000000..b61f63ec8
--- /dev/null
+++ b/yudao-vue-ui/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/main.js b/yudao-vue-ui/main.js
new file mode 100644
index 000000000..5ae4dcce2
--- /dev/null
+++ b/yudao-vue-ui/main.js
@@ -0,0 +1,50 @@
+import App from './App'
+
+import uView from '@/uni_modules/uview-ui'
+Vue.use(uView)
+
+// 全局 Mixin
+import mixin from './common/mixin/mixin'
+Vue.mixin(mixin)
+
+// 全局 Util
+import {
+ msg,
+ isLogin,
+ debounce,
+ throttle,
+ prePage,
+ date
+} from '@/common/js/util'
+Vue.prototype.$util = {
+ msg,
+ isLogin,
+ debounce,
+ throttle,
+ prePage,
+ date
+}
+
+// 全局 Store
+import store from './store'
+Vue.prototype.$store = store
+
+// #ifndef VUE3
+import Vue from 'vue'
+Vue.config.productionTip = false
+App.mpType = 'app'
+const app = new Vue({
+ ...App
+})
+app.$mount()
+// #endif
+
+// #ifdef VUE3
+import { createSSRApp } from 'vue'
+export function createApp() {
+ const app = createSSRApp(App)
+ return {
+ app
+ }
+}
+// #endif
\ No newline at end of file
diff --git a/yudao-vue-ui/manifest.json b/yudao-vue-ui/manifest.json
new file mode 100644
index 000000000..dd17511b8
--- /dev/null
+++ b/yudao-vue-ui/manifest.json
@@ -0,0 +1,72 @@
+{
+ "name" : "ruoyi-vue-ui",
+ "appid" : "__UNI__764D04C",
+ "description" : "",
+ "versionName" : "1.0.0",
+ "versionCode" : "100",
+ "transformPx" : false,
+ /* 5+App特有相关 */
+ "app-plus" : {
+ "usingComponents" : true,
+ "nvueStyleCompiler" : "uni-app",
+ "compilerVersion" : 3,
+ "splashscreen" : {
+ "alwaysShowBeforeRender" : true,
+ "waiting" : true,
+ "autoclose" : true,
+ "delay" : 0
+ },
+ /* 模块配置 */
+ "modules" : {},
+ /* 应用发布信息 */
+ "distribute" : {
+ /* android打包配置 */
+ "android" : {
+ "permissions" : [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ /* ios打包配置 */
+ "ios" : {},
+ /* SDK配置 */
+ "sdkConfigs" : {}
+ }
+ },
+ /* 快应用特有相关 */
+ "quickapp" : {},
+ /* 小程序特有相关 */
+ "mp-weixin" : {
+ "appid" : "",
+ "setting" : {
+ "urlCheck" : false
+ },
+ "usingComponents" : true
+ },
+ "mp-alipay" : {
+ "usingComponents" : true
+ },
+ "mp-baidu" : {
+ "usingComponents" : true
+ },
+ "mp-toutiao" : {
+ "usingComponents" : true
+ },
+ "uniStatistics" : {
+ "enable" : false
+ },
+ "vueVersion" : "2"
+}
diff --git a/yudao-vue-ui/pages.json b/yudao-vue-ui/pages.json
new file mode 100644
index 000000000..2d6a635d9
--- /dev/null
+++ b/yudao-vue-ui/pages.json
@@ -0,0 +1,60 @@
+{
+ "easycom": {
+ "^u-(.*)": "@/uni_modules/uview-ui/components/u-$1/u-$1.vue"
+ },
+ "pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
+ {
+ "path": "pages/index/index",
+ "style": {
+ "navigationBarTitleText": "uni-app"
+ }
+ }, {
+ "path": "pages/tabbar/user",
+ "style": {
+ "navigationBarTitleText": "我的",
+ "navigationStyle": "custom"
+ }
+ }, {
+ "path": "pages/auth/login",
+ "style": {
+ "navigationBarTitleText": "登录",
+ "navigationStyle":"custom",
+ "app-plus": {
+ "animationType": "slide-in-bottom"
+ }
+ }
+ }, {
+ "path" : "pages/set/userInfo",
+ "style" : {
+ "navigationBarTitleText": "个人资料"
+ }
+ }
+ ],
+ "globalStyle": {
+ "navigationBarTextStyle": "black",
+ "navigationBarTitleText": "uni-app",
+ "navigationBarBackgroundColor": "#F8F8F8",
+ "backgroundColor": "#F8F8F8"
+ },
+ "tabBar": {
+ "color": "#666",
+ "selectedColor": "#FF5A5F",
+ "borderStyle": "black",
+ "list": [{
+ "text": "首页",
+ "pagePath": "pages/index/index",
+ "iconPath": "static/tarbar/index.png",
+ "selectedIconPath": "static/tarbar/index-active.png"
+ }, {
+ "text": "商品",
+ "pagePath": "pages/product/list",
+ "iconPath": "static/tarbar/product.png",
+ "selectedIconPath": "static/tarbar/product-active.png"
+ }, {
+ "text": "我的",
+ "pagePath": "pages/tabbar/user",
+ "iconPath": "static/tarbar/ucenter.png",
+ "selectedIconPath": "static/tarbar/ucenter-active.png"
+ }]
+ }
+}
diff --git a/yudao-vue-ui/pages/auth/login.vue b/yudao-vue-ui/pages/auth/login.vue
new file mode 100644
index 000000000..449bb548e
--- /dev/null
+++ b/yudao-vue-ui/pages/auth/login.vue
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+
+
+ 请认真阅读并同意
+ 《用户服务协议》
+ 《隐私权政策》
+
+
+
+ LOGIN
+ 手机登录/注册
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 账号密码登录
+ 免密登录
+
+
+
+
+
+
+ 快捷登录
+
+
+
+
+
+
+
+
+
+
+
+ 微信登录
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/auth/mixin/login-app-wx.js b/yudao-vue-ui/pages/auth/mixin/login-app-wx.js
new file mode 100644
index 000000000..834ff87ac
--- /dev/null
+++ b/yudao-vue-ui/pages/auth/mixin/login-app-wx.js
@@ -0,0 +1,73 @@
+export default{
+ // #ifdef APP-PLUS
+ methods: {
+ /**
+ * 微信App登录
+ * "openId": "o0yywwGWxtBCvBuE8vH4Naof0cqU",
+ * "nickName": "S .",
+ * "gender": 1,
+ * "city": "临沂",
+ * "province": "山东",
+ * "country": "中国",
+ * "avatarUrl": "http://thirdwx.qlogo.cn/mmopen/vi_32/xqpCtHRBBmdlf201Fykhtx7P7JcicIbgV3Weic1oOvN6iaR3tEbuu74f2fkKQWXvzK3VDgNTZzgf0g8FqPvq8LCNQ/132",
+ * "unionId": "oYqy4wmMcs78x9P-tsyMeM3MQ1PU"
+ */
+ loginByWxApp(userInfoData){
+ if(!this.agreement){
+ this.$util.msg('请阅读并同意用户服务及隐私协议');
+ return;
+ }
+ this.$util.throttle(async ()=>{
+ let [err, res] = await uni.login({
+ provider: 'weixin'
+ })
+ if(err){
+ console.log(err);
+ return;
+ }
+ uni.getUserInfo({
+ provider: 'weixin',
+ success: async res=>{
+ const response = await this.$request('user', 'loginByWeixin', {
+ userInfo: res.userInfo,
+ }, {
+ showLoading: true
+ });
+ if(response.status === 0){
+ this.$util.msg(response.msg);
+ return;
+ }
+ if(response.hasBindMobile && response.data.token){
+ this.loginSuccessCallBack({
+ token: response.data.token,
+ tokenExpired: response.data.tokenExpired
+ });
+ }else{
+ this.navTo('/pages/auth/bindMobile?data='+JSON.stringify(response.data))
+ }
+ plus.oauth.getServices(oauthRes=>{
+ oauthRes[0].logout(logoutRes => {
+ console.log(logoutRes);
+ }, error => {
+ console.log(error);
+ })
+ })
+ },
+ fail(err) {
+ console.log(err);
+ }
+ })
+ })
+ }
+ }
+ // #endif
+}
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/auth/mixin/login-mp-wx.js b/yudao-vue-ui/pages/auth/mixin/login-mp-wx.js
new file mode 100644
index 000000000..1f4438a86
--- /dev/null
+++ b/yudao-vue-ui/pages/auth/mixin/login-mp-wx.js
@@ -0,0 +1,81 @@
+export default{
+ // #ifdef MP-WEIXIN
+ data(){
+ return {
+ mpCodeTimer: 0,
+ mpWxCode: '',
+ }
+ },
+ computed: {
+ timerIdent(){
+ return this.$store.state.timerIdent;
+ }
+ },
+ watch: {
+ timerIdent(){
+ this.mpCodeTimer ++;
+ if(this.mpCodeTimer % 30 === 0){
+ this.getMpWxCode();
+ }
+ }
+ },
+ onShow(){
+ this.getMpWxCode();
+ },
+ methods: {
+ //微信小程序登录
+ mpWxGetUserInfo(){
+ if(!this.agreement){
+ this.$util.msg('请阅读并同意用户服务及隐私协议');
+ return;
+ }
+
+ this.$util.throttle(()=>{
+ uni.getUserProfile({
+ desc: '用于展示您的头像及昵称',
+ success: async profileRes=> {
+ const res = await this.$request('user', 'loginByWeixin', {
+ code: this.mpWxCode,
+ ...profileRes.userInfo
+ }, {
+ showLoading: true
+ });
+ if(res.status === 0){
+ this.$util.msg(res.msg);
+ return;
+ }
+ if(res.hasBindMobile && res.data.token){
+ this.loginSuccessCallBack({
+ token: res.data.token,
+ tokenExpired: res.data.tokenExpired
+ });
+ }else{
+ this.navTo('/pages/auth/bindMobile?data='+JSON.stringify(res.data))
+ }
+ console.log(res)
+ }
+ })
+ })
+ },
+ //获取code
+ getMpWxCode(){
+ uni.login({
+ provider: 'weixin',
+ success: res=> {
+ this.mpWxCode = res.code;
+ }
+ })
+ },
+
+ }
+ // #endif
+}
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/index/index.vue b/yudao-vue-ui/pages/index/index.vue
new file mode 100644
index 000000000..ec0ec2608
--- /dev/null
+++ b/yudao-vue-ui/pages/index/index.vue
@@ -0,0 +1,52 @@
+
+
+
+
+ {{title}}
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/set/cutImage/cut.js b/yudao-vue-ui/pages/set/cutImage/cut.js
new file mode 100644
index 000000000..f6be4b9cd
--- /dev/null
+++ b/yudao-vue-ui/pages/set/cutImage/cut.js
@@ -0,0 +1,633 @@
+(function(global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global.weCropper = factory());
+}(this, (function() {
+ 'use strict';
+ var device = void 0;
+ var TOUCH_STATE = ['touchstarted', 'touchmoved', 'touchended'];
+
+ function firstLetterUpper(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ }
+
+ function setTouchState(instance) {
+ for (var _len = arguments.length, arg = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ arg[_key - 1] = arguments[_key];
+ }
+
+ TOUCH_STATE.forEach(function(key, i) {
+ if (arg[i] !== undefined) {
+ instance[key] = arg[i];
+ }
+ });
+ }
+
+ function validator(instance, o) {
+ Object.defineProperties(instance, o);
+ }
+
+ function getDevice() {
+ if (!device) {
+ device = wx.getSystemInfoSync();
+ }
+ return device;
+ }
+
+ var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function(obj) {
+ return typeof obj;
+ } : function(obj) {
+ return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" :
+ typeof obj;
+ };
+
+
+
+
+ var classCallCheck = function(instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError("Cannot call a class as a function");
+ }
+ };
+
+ var createClass = function() {
+ function defineProperties(target, props) {
+ for (var i = 0; i < props.length; i++) {
+ var descriptor = props[i];
+ descriptor.enumerable = descriptor.enumerable || false;
+ descriptor.configurable = true;
+ if ("value" in descriptor) descriptor.writable = true;
+ Object.defineProperty(target, descriptor.key, descriptor);
+ }
+ }
+
+ return function(Constructor, protoProps, staticProps) {
+ if (protoProps) defineProperties(Constructor.prototype, protoProps);
+ if (staticProps) defineProperties(Constructor, staticProps);
+ return Constructor;
+ };
+ }();
+
+
+
+
+ var slicedToArray = function() {
+ function sliceIterator(arr, i) {
+ var _arr = [];
+ var _n = true;
+ var _d = false;
+ var _e = undefined;
+
+ try {
+ for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) {
+ _arr.push(_s.value);
+
+ if (i && _arr.length === i) break;
+ }
+ } catch (err) {
+ _d = true;
+ _e = err;
+ } finally {
+ try {
+ if (!_n && _i["return"]) _i["return"]();
+ } finally {
+ if (_d) throw _e;
+ }
+ }
+
+ return _arr;
+ }
+
+ return function(arr, i) {
+ if (Array.isArray(arr)) {
+ return arr;
+ } else if (Symbol.iterator in Object(arr)) {
+ return sliceIterator(arr, i);
+ } else {
+ throw new TypeError("Invalid attempt to destructure non-iterable instance");
+ }
+ };
+ }();
+
+ var tmp = {};
+
+ var DEFAULT = {
+ id: {
+ default: 'cropper',
+ get: function get$$1() {
+ return tmp.id;
+ },
+ set: function set$$1(value) {
+ if (typeof value !== 'string') {}
+ tmp.id = value;
+ }
+ },
+ width: {
+ default: 750,
+ get: function get$$1() {
+ return tmp.width;
+ },
+ set: function set$$1(value) {
+ tmp.width = value;
+ }
+ },
+ height: {
+ default: 750,
+ get: function get$$1() {
+ return tmp.height;
+ },
+ set: function set$$1(value) {
+ tmp.height = value;
+ }
+ },
+ scale: {
+ default: 2.5,
+ get: function get$$1() {
+ return tmp.scale;
+ },
+ set: function set$$1(value) {
+ tmp.scale = value;
+ }
+ },
+ zoom: {
+ default: 5,
+ get: function get$$1() {
+ return tmp.zoom;
+ },
+ set: function set$$1(value) {
+ tmp.zoom = value;
+ }
+ },
+ src: {
+ default: 'cropper',
+ get: function get$$1() {
+ return tmp.src;
+ },
+ set: function set$$1(value) {
+ tmp.src = value;
+ }
+ },
+ cut: {
+ default: {},
+ get: function get$$1() {
+ return tmp.cut;
+ },
+ set: function set$$1(value) {
+ tmp.cut = value;
+ }
+ },
+ onReady: {
+ default: null,
+ get: function get$$1() {
+ return tmp.ready;
+ },
+ set: function set$$1(value) {
+ tmp.ready = value;
+ }
+ },
+ onBeforeImageLoad: {
+ default: null,
+ get: function get$$1() {
+ return tmp.beforeImageLoad;
+ },
+ set: function set$$1(value) {
+ tmp.beforeImageLoad = value;
+ }
+ },
+ onImageLoad: {
+ default: null,
+ get: function get$$1() {
+ return tmp.imageLoad;
+ },
+ set: function set$$1(value) {
+ tmp.imageLoad = value;
+ }
+ },
+ onBeforeDraw: {
+ default: null,
+ get: function get$$1() {
+ return tmp.beforeDraw;
+ },
+ set: function set$$1(value) {
+ tmp.beforeDraw = value;
+ }
+ }
+ };
+ function prepare() {
+ var self = this;
+
+ var _getDevice = getDevice(),
+ windowWidth = _getDevice.windowWidth;
+
+ self.attachPage = function() {
+ var pages = getCurrentPages();
+ var pageContext = pages[pages.length - 1];
+ pageContext.wecropper = self;
+ };
+
+ self.createCtx = function() {
+ var id = self.id;
+
+ if (id) {
+ self.ctx = wx.createCanvasContext(id);
+ }
+ };
+
+ self.deviceRadio = windowWidth / 750;
+ self.deviceRadio = self.deviceRadio.toFixed(2)
+ }
+ function observer() {
+ var self = this;
+
+ var EVENT_TYPE = ['ready', 'beforeImageLoad', 'beforeDraw', 'imageLoad'];
+
+ self.on = function(event, fn) {
+ if (EVENT_TYPE.indexOf(event) > -1) {
+ if (typeof fn === 'function') {
+ event === 'ready' ? fn(self) : self['on' + firstLetterUpper(event)] = fn;
+ }
+ }
+ return self;
+ };
+ }
+ function methods() {
+ var self = this;
+
+ var deviceRadio = self.deviceRadio;
+
+ var boundWidth = self.width;
+ var boundHeight = self.height;
+ var _self$cut = self.cut,
+ _self$cut$x = _self$cut.x,
+ x = _self$cut$x === undefined ? 0 : _self$cut$x,
+ _self$cut$y = _self$cut.y,
+ y = _self$cut$y === undefined ? 0 : _self$cut$y,
+ _self$cut$width = _self$cut.width,
+ width = _self$cut$width === undefined ? boundWidth : _self$cut$width,
+ _self$cut$height = _self$cut.height,
+ height = _self$cut$height === undefined ? boundHeight : _self$cut$height;
+
+
+ self.updateCanvas = function() {
+ if (self.croperTarget) {
+
+
+ self.ctx.drawImage(self.croperTarget, self.imgLeft, self.imgTop, self.scaleWidth, self.scaleHeight);
+ }
+ typeof self.onBeforeDraw === 'function' && self.onBeforeDraw(self.ctx, self);
+
+ self.setBoundStyle();
+ self.ctx.draw();
+ return self;
+ };
+
+ self.pushOrign = function(src) {
+ self.src = src;
+
+ typeof self.onBeforeImageLoad === 'function' && self.onBeforeImageLoad(self.ctx, self);
+
+ uni.getImageInfo({
+ src: src,
+ success: function success(res) {
+ var innerAspectRadio = res.width / res.height;
+ self.croperTarget = res.path || src;
+ if (innerAspectRadio < width / height) {
+ self.rectX = x;
+ self.baseWidth = width;
+ self.baseHeight = width / innerAspectRadio;
+ self.rectY = y - Math.abs((height - self.baseHeight) / 2);
+ } else {
+ self.rectY = y;
+ self.baseWidth = height * innerAspectRadio;
+ self.baseHeight = height;
+ self.rectX = x - Math.abs((width - self.baseWidth) / 2);
+ }
+
+ self.imgLeft = self.rectX;
+ self.imgTop = self.rectY;
+ self.scaleWidth = self.baseWidth;
+ self.scaleHeight = self.baseHeight;
+
+ self.updateCanvas();
+
+ typeof self.onImageLoad === 'function' && self.onImageLoad(self.ctx, self);
+ }
+ });
+
+ self.update();
+ return self;
+ };
+
+ self.getCropperImage = function() {
+ for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
+ args[_key] = arguments[_key];
+ }
+
+ var id = self.id;
+
+ var ARG_TYPE = toString.call(args[0]);
+ switch (ARG_TYPE) {
+ case '[object Object]':
+ var _args$0$quality = args[0].quality,
+ quality = _args$0$quality === undefined ? 10 : _args$0$quality;
+
+ uni.canvasToTempFilePath({
+ canvasId: id,
+ x: x,
+ y: y,
+ fileType: "jpg",
+ width: width,
+ height: height,
+ destWidth: width * quality / (deviceRadio * 10),
+ destHeight: height * quality / (deviceRadio * 10),
+ success: function success(res) {
+ typeof args[args.length - 1] === 'function' && args[args.length - 1](res.tempFilePath);
+ }
+ });
+ break;
+ case '[object Function]':
+ uni.canvasToTempFilePath({
+ canvasId: id,
+ x: x,
+ y: y,
+ fileType: "jpg",
+ width: width,
+ height: height,
+ destWidth: width,
+ destHeight: height,
+ success: function success(res) {
+
+ typeof args[args.length - 1] === 'function' && args[args.length - 1](res.tempFilePath);
+ }
+ });
+ break;
+ }
+
+ return self;
+ };
+ }
+
+ function update() {
+ var self = this;
+ if (!self.src) return;
+
+ self.__oneTouchStart = function(touch) {
+ self.touchX0 = touch.x;
+ self.touchY0 = touch.y;
+ };
+
+ self.__oneTouchMove = function(touch) {
+ var xMove = void 0,
+ yMove = void 0;
+ if (self.touchended) {
+ return self.updateCanvas();
+ }
+ xMove = touch.x - self.touchX0;
+ yMove = touch.y - self.touchY0;
+
+ var imgLeft = self.rectX + xMove;
+ var imgTop = self.rectY + yMove;
+
+ self.outsideBound(imgLeft, imgTop);
+
+ self.updateCanvas();
+ };
+
+ self.__twoTouchStart = function(touch0, touch1) {
+ var xMove = void 0,
+ yMove = void 0,
+ oldDistance = void 0;
+
+ self.touchX1 = self.rectX + self.scaleWidth / 2;
+ self.touchY1 = self.rectY + self.scaleHeight / 2;
+
+ xMove = touch1.x - touch0.x;
+ yMove = touch1.y - touch0.y;
+ oldDistance = Math.sqrt(xMove * xMove + yMove * yMove);
+
+ self.oldDistance = oldDistance;
+ };
+
+ self.__twoTouchMove = function(touch0, touch1) {
+ var xMove = void 0,
+ yMove = void 0,
+ newDistance = void 0;
+ var scale = self.scale,
+ zoom = self.zoom;
+
+ xMove = touch1.x - touch0.x;
+ yMove = touch1.y - touch0.y;
+ newDistance = Math.sqrt(xMove * xMove + yMove * yMove
+
+ // 使用0.005的缩放倍数具有良好的缩放体验
+ );
+ self.newScale = self.oldScale + 0.001 * zoom * (newDistance - self.oldDistance);
+
+ // 设定缩放范围
+ self.newScale <= 1 && (self.newScale = 1);
+ self.newScale >= scale && (self.newScale = scale);
+
+ self.scaleWidth = self.newScale * self.baseWidth;
+ self.scaleHeight = self.newScale * self.baseHeight;
+ var imgLeft = self.touchX1 - self.scaleWidth / 2;
+ var imgTop = self.touchY1 - self.scaleHeight / 2;
+
+ self.outsideBound(imgLeft, imgTop);
+
+ self.updateCanvas();
+ };
+
+ self.__xtouchEnd = function() {
+ self.oldScale = self.newScale;
+ self.rectX = self.imgLeft;
+ self.rectY = self.imgTop;
+ };
+ }
+ var handle = {
+ touchStart: function touchStart(e) {
+ var self = this;
+ var _e$touches = slicedToArray(e.touches, 2),
+ touch0 = _e$touches[0],
+ touch1 = _e$touches[1];
+
+ if (!touch0.x) {
+ touch0.x = touch0.clientX;
+ touch0.y = touch0.clientY;
+ if (touch1) {
+ touch1.x = touch1.clientX;
+ touch1.y = touch1.clientY;
+ }
+ }
+
+ setTouchState(self, true, null, null);
+ self.__oneTouchStart(touch0);
+ if (e.touches.length >= 2) {
+ self.__twoTouchStart(touch0, touch1);
+ }
+ },
+
+
+ touchMove: function touchMove(e) {
+ var self = this;
+
+ var _e$touches2 = slicedToArray(e.touches, 2),
+ touch0 = _e$touches2[0],
+ touch1 = _e$touches2[1];
+ if (!touch0.x) {
+ touch0.x = touch0.clientX;
+ touch0.y = touch0.clientY;
+ if (touch1) {
+ touch1.x = touch1.clientX;
+ touch1.y = touch1.clientY;
+ }
+ }
+ setTouchState(self, null, true);
+ if (e.touches.length === 1) {
+ self.__oneTouchMove(touch0);
+ }
+ if (e.touches.length >= 2) {
+ self.__twoTouchMove(touch0, touch1);
+ }
+ },
+ touchEnd: function touchEnd(e) {
+ var self = this;
+
+ setTouchState(self, false, false, true);
+ self.__xtouchEnd();
+ }
+ };
+ function cut() {
+ var self = this;
+ var deviceRadio = self.deviceRadio;
+
+ var boundWidth = self.width;
+ var boundHeight = self.height;
+ var _self$cut = self.cut,
+ _self$cut$x = _self$cut.x,
+ x = _self$cut$x === undefined ? 0 : _self$cut$x,
+ _self$cut$y = _self$cut.y,
+ y = _self$cut$y === undefined ? 0 : _self$cut$y,
+ _self$cut$width = _self$cut.width,
+ width = _self$cut$width === undefined ? boundWidth : _self$cut$width,
+ _self$cut$height = _self$cut.height,
+ height = _self$cut$height === undefined ? boundHeight : _self$cut$height;
+
+
+ self.outsideBound = function(imgLeft, imgTop) {
+ self.imgLeft = imgLeft >= x ? x : self.scaleWidth + imgLeft - x <= width ? x + width - self.scaleWidth : imgLeft;
+
+ self.imgTop = imgTop >= y ? y : self.scaleHeight + imgTop - y <= height ? y + height - self.scaleHeight : imgTop;
+ };
+
+ self.setBoundStyle = function() {
+ var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
+ _ref$color = _ref.color,
+ color = _ref$color === undefined ? '#04b00f' : _ref$color,
+ _ref$mask = _ref.mask,
+ mask = _ref$mask === undefined ? 'rgba(0, 0, 0, 0.5)' : _ref$mask,
+ _ref$lineWidth = _ref.lineWidth,
+ lineWidth = _ref$lineWidth === undefined ? 1 : _ref$lineWidth;
+
+ self.ctx.beginPath();
+ self.ctx.setFillStyle(mask);
+ self.ctx.fillRect(0, 0, x, boundHeight);
+ self.ctx.fillRect(x, 0, width, y);
+ self.ctx.fillRect(x, y + height, width, boundHeight - y - height);
+ self.ctx.fillRect(x + width, 0, boundWidth - x - width, boundHeight);
+ self.ctx.fill();
+ self.ctx.beginPath();
+ self.ctx.setStrokeStyle(color);
+ self.ctx.setLineWidth(lineWidth);
+ self.ctx.moveTo(x - lineWidth, y + 10 - lineWidth);
+ self.ctx.lineTo(x - lineWidth, y - lineWidth);
+ self.ctx.lineTo(x + 10 - lineWidth, y - lineWidth);
+ self.ctx.stroke();
+ self.ctx.beginPath();
+ self.ctx.setStrokeStyle(color);
+ self.ctx.setLineWidth(lineWidth);
+ self.ctx.moveTo(x - lineWidth, y + height - 10 + lineWidth);
+ self.ctx.lineTo(x - lineWidth, y + height + lineWidth);
+ self.ctx.lineTo(x + 10 - lineWidth, y + height + lineWidth);
+ self.ctx.stroke();
+ self.ctx.beginPath();
+ self.ctx.setStrokeStyle(color);
+ self.ctx.setLineWidth(lineWidth);
+ self.ctx.moveTo(x + width - 10 + lineWidth, y - lineWidth);
+ self.ctx.lineTo(x + width + lineWidth, y - lineWidth);
+ self.ctx.lineTo(x + width + lineWidth, y + 10 - lineWidth);
+ self.ctx.stroke();
+ self.ctx.beginPath();
+ self.ctx.setStrokeStyle(color);
+ self.ctx.setLineWidth(lineWidth);
+ self.ctx.moveTo(x + width + lineWidth, y + height - 10 + lineWidth);
+ self.ctx.lineTo(x + width + lineWidth, y + height + lineWidth);
+ self.ctx.lineTo(x + width - 10 + lineWidth, y + height + lineWidth);
+ self.ctx.stroke();
+ };
+ }
+
+ var __version__ = '1.1.4';
+
+ var weCropper = function() {
+ function weCropper(params) {
+ classCallCheck(this, weCropper);
+
+ var self = this;
+ var _default = {};
+
+ validator(self, DEFAULT);
+
+ Object.keys(DEFAULT).forEach(function(key) {
+ _default[key] = DEFAULT[key].default;
+ });
+ Object.assign(self, _default, params);
+
+ self.prepare();
+ self.attachPage();
+ self.createCtx();
+ self.observer();
+ self.cutt();
+ self.methods();
+ self.init();
+ self.update();
+
+ return self;
+ }
+
+ createClass(weCropper, [{
+ key: 'init',
+ value: function init() {
+ var self = this;
+ var src = self.src;
+
+
+ self.version = __version__;
+
+ typeof self.onReady === 'function' && self.onReady(self.ctx, self);
+
+ if (src) {
+ self.pushOrign(src);
+ }
+ setTouchState(self, false, false, false);
+
+ self.oldScale = 1;
+ self.newScale = 1;
+
+ return self;
+ }
+ }]);
+ return weCropper;
+ }();
+
+ Object.assign(weCropper.prototype, handle);
+
+
+ weCropper.prototype.prepare = prepare;
+ weCropper.prototype.observer = observer;
+ weCropper.prototype.methods = methods;
+ weCropper.prototype.cutt = cut;
+ weCropper.prototype.update = update;
+
+ return weCropper;
+
+})));
diff --git a/yudao-vue-ui/pages/set/cutImage/cut.vue b/yudao-vue-ui/pages/set/cutImage/cut.vue
new file mode 100644
index 000000000..3e82853c3
--- /dev/null
+++ b/yudao-vue-ui/pages/set/cutImage/cut.vue
@@ -0,0 +1,223 @@
+
+
+
+
+ 裁剪
+
+
+
+
+
+
+
+ 重选
+ 确定
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/set/userInfo.vue b/yudao-vue-ui/pages/set/userInfo.vue
new file mode 100644
index 000000000..4d46ed8a8
--- /dev/null
+++ b/yudao-vue-ui/pages/set/userInfo.vue
@@ -0,0 +1,271 @@
+
+
+
+ 头像
+
+
+
+
+
+
+
+ 昵称
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/pages/tabbar/user.vue b/yudao-vue-ui/pages/tabbar/user.vue
new file mode 100644
index 000000000..785061a63
--- /dev/null
+++ b/yudao-vue-ui/pages/tabbar/user.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+ {{ userInfo.nickname }}
+ 普通会员
+
+
+
+ 点击注册/登录
+
+
+
+
+
+
+
+
+
+
+
+
+ 待付款
+ {{ orderCount.c0 }}
+
+
+
+ 待发货
+ {{ orderCount.c1 }}
+
+
+
+ 待收货
+ {{ orderCount.c2 }}
+
+
+
+ 待评价
+ {{ orderCount.c3 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/static/backgroud/user.jpg b/yudao-vue-ui/static/backgroud/user.jpg
new file mode 100644
index 000000000..a57073374
Binary files /dev/null and b/yudao-vue-ui/static/backgroud/user.jpg differ
diff --git a/yudao-vue-ui/static/icon/arc.png b/yudao-vue-ui/static/icon/arc.png
new file mode 100644
index 000000000..c7ef6df0d
Binary files /dev/null and b/yudao-vue-ui/static/icon/arc.png differ
diff --git a/yudao-vue-ui/static/icon/default-avatar.png b/yudao-vue-ui/static/icon/default-avatar.png
new file mode 100644
index 000000000..09fea4fc8
Binary files /dev/null and b/yudao-vue-ui/static/icon/default-avatar.png differ
diff --git a/yudao-vue-ui/static/logo.png b/yudao-vue-ui/static/logo.png
new file mode 100644
index 000000000..b5771e209
Binary files /dev/null and b/yudao-vue-ui/static/logo.png differ
diff --git a/yudao-vue-ui/static/tarbar/index-active.png b/yudao-vue-ui/static/tarbar/index-active.png
new file mode 100644
index 000000000..b1d65c731
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/index-active.png differ
diff --git a/yudao-vue-ui/static/tarbar/index.png b/yudao-vue-ui/static/tarbar/index.png
new file mode 100644
index 000000000..7f9a4a09e
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/index.png differ
diff --git a/yudao-vue-ui/static/tarbar/logo.png b/yudao-vue-ui/static/tarbar/logo.png
new file mode 100644
index 000000000..b5771e209
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/logo.png differ
diff --git a/yudao-vue-ui/static/tarbar/product-active.png b/yudao-vue-ui/static/tarbar/product-active.png
new file mode 100644
index 000000000..a107caa80
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/product-active.png differ
diff --git a/yudao-vue-ui/static/tarbar/product.png b/yudao-vue-ui/static/tarbar/product.png
new file mode 100644
index 000000000..8c212a02f
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/product.png differ
diff --git a/yudao-vue-ui/static/tarbar/ucenter-active.png b/yudao-vue-ui/static/tarbar/ucenter-active.png
new file mode 100644
index 000000000..f5eb065a0
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/ucenter-active.png differ
diff --git a/yudao-vue-ui/static/tarbar/ucenter.png b/yudao-vue-ui/static/tarbar/ucenter.png
new file mode 100644
index 000000000..17ab66add
Binary files /dev/null and b/yudao-vue-ui/static/tarbar/ucenter.png differ
diff --git a/yudao-vue-ui/store/index.js b/yudao-vue-ui/store/index.js
new file mode 100644
index 000000000..becb06d49
--- /dev/null
+++ b/yudao-vue-ui/store/index.js
@@ -0,0 +1,65 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+// import {request} from '@/common/js/request'
+import { getUserInfo } from '@/api/member/userProfile.js'
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+ state: {
+ openExamine: false, // 是否开启审核状态。用于小程序、App 等审核时,关闭部分功能。TODO 芋艿:暂时没找到刷新的地方
+ token: '', // 用户身份 Token
+ userInfo: {}, // 用户基本信息
+ timerIdent: false, // 全局 1s 定时器,只在全局开启一个,所有需要定时执行的任务监听该值即可,无需额外开启 TODO 芋艿:需要看看
+ },
+ getters: {
+ hasLogin(state){
+ return !!state.token;
+ }
+ },
+ mutations: {
+ // 更新 state 的通用方法
+ setStateAttr(state, param) {
+ if (param instanceof Array) {
+ for(let item of param){
+ state[item.key] = item.val;
+ }
+ } else {
+ state[param.key] = param.val;
+ }
+ },
+ // 更新token
+ setToken(state, data) {
+ // 设置 Token
+ const { token } = data;
+ state.token = token;
+ uni.setStorageSync('token', token);
+
+ // 加载用户信息
+ this.dispatch('obtainUserInfo');
+ },
+ // 退出登录
+ logout(state) {
+ // 清空 Token
+ state.token = '';
+ uni.removeStorageSync('token');
+ // 清空用户信息 TODO 芋艿:这里 setTimeout 的原因是,上面可能还有一些 request 请求。后续得优化下
+ setTimeout(()=>{
+ state.userInfo = {};
+ }, 1100)
+ },
+ },
+ actions: {
+ // 获得用户基本信息
+ async obtainUserInfo({state, commit}) {
+ const data = await getUserInfo();
+ commit('setStateAttr', {
+ key: 'userInfo',
+ val: data
+ });
+ }
+ }
+})
+
+
+export default store
diff --git a/yudao-vue-ui/uni.scss b/yudao-vue-ui/uni.scss
new file mode 100644
index 000000000..8daa39f8c
--- /dev/null
+++ b/yudao-vue-ui/uni.scss
@@ -0,0 +1,13 @@
+@import '@/uni_modules/uview-ui/theme.scss';
+
+.u-button--success {
+ background-color: #5ac725 !important; // TODO 芋艿:莫名不行
+}
+.u-button--primary {
+ background-color: #3c9cff !important; // TODO 芋艿:莫名不行
+}
+.u-button--error {
+ background-color: #f56c6c !important; // TODO 芋艿:莫名不行
+}
+
+$base-color: #ff536f;
diff --git a/yudao-vue-ui/uni_modules/uview-ui/LICENSE b/yudao-vue-ui/uni_modules/uview-ui/LICENSE
new file mode 100644
index 000000000..8e39eada8
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 www.uviewui.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/yudao-vue-ui/uni_modules/uview-ui/README.md b/yudao-vue-ui/uni_modules/uview-ui/README.md
new file mode 100644
index 000000000..1dca4a8f6
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/README.md
@@ -0,0 +1,105 @@
+
+
+
+uView
+多平台快速开发的UI框架
+
+
+## 说明
+
+uView UI,是[uni-app](https://uniapp.dcloud.io/)生态优秀的UI框架,全面的组件和便捷的工具会让您信手拈来,如鱼得水
+
+## 特性
+
+- 兼容安卓,iOS,微信小程序,H5,QQ小程序,百度小程序,支付宝小程序,头条小程序
+- 60+精选组件,功能丰富,多端兼容,让您快速集成,开箱即用
+- 众多贴心的JS利器,让您飞镖在手,召之即来,百步穿杨
+- 众多的常用页面和布局,让您专注逻辑,事半功倍
+- 详尽的文档支持,现代化的演示效果
+- 按需引入,精简打包体积
+
+
+## 安装
+
+```bash
+# npm方式安装
+npm i uview-ui
+```
+
+## 快速上手
+
+1. `main.js`引入uView库
+```js
+// main.js
+import uView from 'uview-ui';
+Vue.use(uView);
+```
+
+2. `App.vue`引入基础样式(注意style标签需声明scss属性支持)
+```css
+/* App.vue */
+
+```
+
+3. `uni.scss`引入全局scss变量文件
+```css
+/* uni.scss */
+@import "uview-ui/theme.scss";
+```
+
+4. `pages.json`配置easycom规则(按需引入)
+
+```js
+// pages.json
+{
+ "easycom": {
+ // npm安装的方式不需要前面的"@/",下载安装的方式需要"@/"
+ // npm安装方式
+ "^u-(.*)": "uview-ui/components/u-$1/u-$1.vue"
+ // 下载安装方式
+ // "^u-(.*)": "@/uview-ui/components/u-$1/u-$1.vue"
+ },
+ // 此为本身已有的内容
+ "pages": [
+ // ......
+ ]
+}
+```
+
+请通过[快速上手](https://www.uviewui.com/components/quickstart.html)了解更详细的内容
+
+## 使用方法
+配置easycom规则后,自动按需引入,无需`import`组件,直接引用即可。
+
+```html
+
+
+
+```
+
+请通过[快速上手](https://www.uviewui.com/components/quickstart.html)了解更详细的内容
+
+## 链接
+
+- [官方文档](https://www.uviewui.com/)
+- [更新日志](https://www.www.uviewui.com/components/changelog.html)
+- [升级指南](https://www.uviewui.com/components/changelog.html)
+- [关于我们](https://www.uviewui.com/cooperation/about.html)
+
+## 预览
+
+您可以通过**微信**扫码,查看最佳的演示效果。
+
+
+
+
+## 捐赠uView的研发
+
+uView文档和源码全部开源免费,如果您认为uView帮到了您的开发工作,您可以捐赠uView的研发工作,捐赠无门槛,哪怕是一杯可乐也好(相信这比打赏主播更有意义)。
+
+
+
+## 版权信息
+uView遵循[MIT](https://en.wikipedia.org/wiki/MIT_License)开源协议,意味着您无需支付任何费用,也无需授权,即可将uView应用到您的产品中。
diff --git a/yudao-vue-ui/uni_modules/uview-ui/changelog.md b/yudao-vue-ui/uni_modules/uview-ui/changelog.md
new file mode 100644
index 000000000..bfc76e4b4
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/changelog.md
@@ -0,0 +1,55 @@
+## 2.0.5(2021-11-25)
+## [点击加群交流反馈:232041042](https://jq.qq.com/?_wv=1027&k=KnbeceDU)
+
+# uView2.0重磅发布,利剑出鞘,一统江湖
+
+1. calendar在vue下显示异常问题。
+2. form组件labelPosition和errorType参数无效的问题
+3. input组件inputAlign无效的问题
+4. 其他一些修复
+## 2.0.4(2021-11-23)
+## [点击加群交流反馈:232041042](https://jq.qq.com/?_wv=1027&k=KnbeceDU)
+
+# uView2.0重磅发布,利剑出鞘,一统江湖
+
+0. input组件缺失@confirm事件,以及subfix和prefix无效问题
+1. component.scss文件样式在vue下干扰全局布局问题
+2. 修复subsection在vue环境下表现异常的问题
+3. tag组件的bgColor等参数无效的问题
+4. upload组件不换行的问题
+5. 其他的一些修复处理
+## 2.0.3(2021-11-16)
+## [点击加群交流反馈:1129077272](https://jq.qq.com/?_wv=1027&k=KnbeceDU)
+
+# uView2.0重磅发布,利剑出鞘,一统江湖
+
+1. uView2.0已实现全面兼容nvue
+2. uView2.0对1.x进行了架构重构,细节和性能都有极大提升
+3. 目前uView2.0为公测阶段,相关细节可能会有变动
+4. 我们写了一份与1.x的对比指南,详见[对比1.x](https://www.uviewui.com/components/diff1.x.html)
+5. 处理modal的confirm回调事件拼写错误问题
+6. 处理input组件@input事件参数错误问题
+7. 其他一些修复
+## 2.0.2(2021-11-16)
+## [点击加群交流反馈:1129077272](https://jq.qq.com/?_wv=1027&k=KnbeceDU)
+
+# uView2.0重磅发布,利剑出鞘,一统江湖
+
+1. uView2.0已实现全面兼容nvue
+2. uView2.0对1.x进行了架构重构,细节和性能都有极大提升
+3. 目前uView2.0为公测阶段,相关细节可能会有变动
+4. 我们写了一份与1.x的对比指南,详见[对比1.x](https://www.uviewui.com/components/diff1.x.html)
+5. 修复input组件formatter参数缺失问题
+6. 优化loading-icon组件的scss写法问题,防止不兼容新版本scss
+## 2.0.0(2020-11-15)
+## [点击加群交流反馈:1129077272](https://jq.qq.com/?_wv=1027&k=KnbeceDU)
+
+# uView2.0重磅发布,利剑出鞘,一统江湖
+
+1. uView2.0已实现全面兼容nvue
+2. uView2.0对1.x进行了架构重构,细节和性能都有极大提升
+3. 目前uView2.0为公测阶段,相关细节可能会有变动
+4. 我们写了一份与1.x的对比指南,详见[对比1.x](https://www.uviewui.com/components/diff1.x.html)
+5. 修复input组件formatter参数缺失问题
+
+
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u--form/u--form.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u--form/u--form.vue
new file mode 100644
index 000000000..fc6856c68
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u--form/u--form.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u--image/u--image.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u--image/u--image.vue
new file mode 100644
index 000000000..50c4d57f9
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u--image/u--image.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u--input/u--input.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u--input/u--input.vue
new file mode 100644
index 000000000..f1ab9b25d
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u--input/u--input.vue
@@ -0,0 +1,67 @@
+
+ $emit('change', e)"
+ @input="e => $emit('input', e)"
+ @confirm="e => $emit('confirm', e)"
+ @clear="$emit('clear')"
+ @click="$emit('click')"
+ >
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u--text/u--text.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u--text/u--text.vue
new file mode 100644
index 000000000..3aa326275
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u--text/u--text.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u--textarea/u--textarea.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u--textarea/u--textarea.vue
new file mode 100644
index 000000000..c56baf699
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u--textarea/u--textarea.vue
@@ -0,0 +1,47 @@
+
+ $emit('input', e)"
+ @keyboardheightchange="$emit('keyboardheightchange')"
+ >
+
+
+
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/props.js b/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/props.js
new file mode 100644
index 000000000..a8ae4c9db
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/props.js
@@ -0,0 +1,54 @@
+export default {
+ props: {
+ // 操作菜单是否展示 (默认false)
+ show: {
+ type: Boolean,
+ default: uni.$u.props.actionSheet.show
+ },
+ // 标题
+ title: {
+ type: String,
+ default: uni.$u.props.actionSheet.title
+ },
+ // 选项上方的描述信息
+ description: {
+ type: String,
+ default: uni.$u.props.actionSheet.description
+ },
+ // 数据
+ actions: {
+ type: Array,
+ default: uni.$u.props.actionSheet.actions
+ },
+ // 取消按钮的文字,不为空时显示按钮
+ cancelText: {
+ type: String,
+ default: uni.$u.props.actionSheet.cancelText
+ },
+ // 点击某个菜单项时是否关闭弹窗
+ closeOnClickAction: {
+ type: Boolean,
+ default: uni.$u.props.actionSheet.closeOnClickAction
+ },
+ // 处理底部安全区(默认true)
+ safeAreaInsetBottom: {
+ type: Boolean,
+ default: uni.$u.props.actionSheet.safeAreaInsetBottom
+ },
+ // 小程序的打开方式
+ openType: {
+ type: String,
+ default: uni.$u.props.actionSheet.openType
+ },
+ // 点击遮罩是否允许关闭 (默认true)
+ closeOnClickOverlay: {
+ type: Boolean,
+ default: uni.$u.props.actionSheet.closeOnClickOverlay
+ },
+ // 是否显示圆角 (默认false)
+ round: {
+ type: Boolean,
+ default: uni.$u.props.actionSheet.round
+ }
+ }
+}
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/u-action-sheet.vue b/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/u-action-sheet.vue
new file mode 100644
index 000000000..a8c6ea74d
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u-action-sheet/u-action-sheet.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
+ {{description}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{cancelText}}
+
+
+
+
+
+
+
+
diff --git a/yudao-vue-ui/uni_modules/uview-ui/components/u-album/props.js b/yudao-vue-ui/uni_modules/uview-ui/components/u-album/props.js
new file mode 100644
index 000000000..75cdb37d5
--- /dev/null
+++ b/yudao-vue-ui/uni_modules/uview-ui/components/u-album/props.js
@@ -0,0 +1,59 @@
+export default {
+ props: {
+ // 图片地址,Array|Array