avatar


24.RuoYi(若依)快速上手

《基于Java的后端开发入门》的前几章,我们讨论了SpringBoot。SpringBoot,其实还是基于Spring的,但是封装了很多内容,可以 简化工程配置和依赖管理等

RuoYi的封装程度比SpringBoot的封装程度还要高,用户登录、权限控制、菜单管理等都已经有了。

我们可以在RuoYi的基础上进行二次开发。

概述

官网:http://www.ruoyi.vip/
官方文档:http://doc.ruoyi.vip

通过官网,我们可以看到RuoYi有三个版本:

  • 若依管理系统
    • 基于SpringBoot
    • 前后端不分离
  • Vue前端分离版
    • 基于SpringBoot
    • 前后端分离
  • Cloud微服务版
    • 基于SpringCloud的权限管理系统
    • 前后端分离

本文讨论的是Vue前端分离版

启动

步骤

  1. 通过RuoYi官网提供的地址下载
  2. 通过IDEA导入项目
  3. 创建数据库,ry-vue
  4. 执行sql文件夹中的sql文件
  5. 修改application-druid.yml的MySQL连接信息
  6. 修改application.yml的Redis连接信息
  7. 修改logback.xmllog.path
  8. 启动ruoyi-admin中的启动类RuoYiAppliacation
  9. 启动前端项目
    进入到ruoyi-ui的目录中执行npm install安装依赖
    执行npm run dev启动前端项目

application-druid.ymlapplication.ymllogback.xml位于目录RuoYi-Vue/ruoyi-admin/src/main/resources/

问题解决

npm install

执行npm install,可能会有如下报错:

1
2
3
4
5
6
7
8
npm ERR! code EEXIST
npm ERR! syscall mkdir
npm ERR! path /Users/kaka/.npm/_cacache/content-v2/sha512/42/6c
npm ERR! errno EEXIST
npm ERR! Invalid response body while trying to fetch https://registry.npmjs.org/lint-staged: EACCES: permission denied, mkdir '/Users/kaka/.npm/_cacache/content-v2/sha512/42/6c'
npm ERR! File exists: /Users/kaka/.npm/_cacache/content-v2/sha512/42/6c
npm ERR! Remove the existing file and try again, or run npm
npm ERR! with --force to overwrite files recklessly.

使用sudo可以解决,sudo npm install

npm run dev

执行npm run dev,可能会有如下报错:

1
2
3
4
5
6
7
8
9
10
95% emitting CompressionPlugin ERROR  Error: error:0308010C:digital envelope routines::unsupported
Error: error:0308010C:digital envelope routines::unsupported
at new Hash (node:internal/crypto/hash:69:19)
at Object.createHash (node:crypto:133:10)
at /Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/compression-webpack-plugin/dist/index.js:243:42
at CompressionPlugin.compress (/Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/compression-webpack-plugin/dist/index.js:284:9)
at /Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/compression-webpack-plugin/dist/index.js:305:12
at _next1 (eval at create (/Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:14:17)
at eval (eval at create (/Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:33:1)
at /Users/kaka/Desktop/RuoYi-Vue/ruoyi-ui/node_modules/copy-webpack-plugin/dist/index.js:91:9

这是因为在Node的18版本中,默认使用了OpenSSL 3.0及以上的版本,而OpenSSL3.0对允许算法和密钥大小增加了严格的限制,我们可以通过设置NODE_OPTIONS环境变量来强制使用旧版本。

修改package.json的如下部分的"dev": "vue-cli-service serve",

1
2
3
4
5
6
7
"scripts": {
"dev": "vue-cli-service serve",
"build:prod": "vue-cli-service build",
"build:stage": "vue-cli-service build --mode staging",
"preview": "node build/index.js --preview",
"lint": "eslint --ext .js,.vue src"
},

对于MacOS和Linux,修改为:

1
"dev": "export NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",

对于Windows,修改为:

1
set NODE_OPTIONS=--openssl-legacy-provider

结构

代码结构

  • ruoyi-admin:web模块,存放controller
  • ruoyi—common:公共模块,存放工具类
  • ruoyi-framwork:框架模块,存放一些第三方框架代码和配置
  • ruoyi-generator:代码生成器模块
  • ruoyi-quartz:定时任务模块
  • ruoyi-system:系统模块,存放domain,mapper,service
  • ruoyi-ui:前端项目

表结构

  • gen_table:代码生成器,表信息
  • gen_table_column:代码生成器,列信息
  • sys_config:系统配置表
  • sys_dept:部门表
  • sys_dict_data:字典目录表
  • sys_dict_type:字典类型表
  • sys_job:定时任务表
  • sys_job_log:任务日志表
  • sys_logininfor:登录信息表
  • sys_menu:菜单表
  • sys_notice:系统通知表
  • sys_oper_log:执行日志表
  • sys_post:岗位表
  • sys_role:角色表
  • sys_role_dept:角色和部门关系表
  • sys_role_menu:角色和菜单关系表
  • sys_user:用户表
  • sys_user_post:用户和岗位关系表
  • sys_user_role:用户和角色关系表

配置文件

项目的配置文件都在ruoyi-adminresources中:

  • i18n:处理国际化。
  • META-INF:此文件包含有关JAR内容的元数据。
  • mybatis:mybatis配置信息。
  • application.yml:项目的配置信息。
  • application-druid.yml:数据库连接信息。
  • banner.txt:启动时候的banner图标信息。
  • logback.xml:日志配置信息。

岗位管理(部分源码解读)

本文以系统管理 -> 岗位管理为例,讨论和CRUD相关的部分源码。

分页

源码

获取岗位列表相关的代码如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取岗位列表
*/
@PreAuthorize("@ss.hasPermi('system:post:list')")
@GetMapping("/list")
public TableDataInfo list(SysPost post)
{
startPage();
List<SysPost> list = postService.selectPostList(post);
return getDataTable(list);
}
  • @PreAuthorize是SpringSecurity中的注解。

startPage

startPage(),这个处理分页。
我们点击startPage(),会发现最后调用了PageUtilsstartPage()方法。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.ruoyi.common.utils;

import com.github.pagehelper.PageHelper;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.utils.sql.SqlUtil;

/**
* 分页工具类
*
* @author ruoyi
*/
public class PageUtils extends PageHelper
{
/**
* 设置请求分页数据
*/
public static void startPage()
{
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}

/**
* 清理分页的线程变量
*/
public static void clearPage()
{
PageHelper.clearPage();
}
}

分页方法通过PageHelper.startPage()实现,关于PageHelper,可以参考《12.MyBatis》的分页插件部分。

TableSupport.buildPageRequest

再点进PageDomain pageDomain = TableSupport.buildPageRequest();TableSupport.buildPageRequest();,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.ruoyi.common.core.page;

import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.utils.ServletUtils;

/**
* 表格数据处理
*
* @author ruoyi
*/
public class TableSupport
{
【部分代码略】

/**
* 封装分页对象
*/
public static PageDomain getPageDomain()
{
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(Convert.toInt(ServletUtils.getParameter(PAGE_NUM), 1));
pageDomain.setPageSize(Convert.toInt(ServletUtils.getParameter(PAGE_SIZE), 10));
pageDomain.setOrderByColumn(ServletUtils.getParameter(ORDER_BY_COLUMN));
pageDomain.setIsAsc(ServletUtils.getParameter(IS_ASC));
pageDomain.setReasonable(ServletUtils.getParameterToBool(REASONABLE));
return pageDomain;
}

public static PageDomain buildPageRequest()
{
return getPageDomain();
}
}

pageDomain中的很多属性来自ServletUtils.getParameter(XXX)。点进ServletUtils.getParameter,会发现最后还是通过HttpServletRequest获取参数。

关于HttpServletRequest,可以参考《13.Servlet、Filter和Listener》

getDataTable

点进getDataTable(),该部分再组装返回的报文,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.ruoyi.common.core.controller;

【部分代码略】

/**
* web层通用数据处理
*
* @author ruoyi
*/
public class BaseController
{

【部分代码略】

/**
* 响应请求分页数据
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
protected TableDataInfo getDataTable(List<?> list)
{
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal());
return rspData;
}

【部分代码略】

}

操作记录

导出,示例代码:

1
2
3
4
5
6
7
8
9
@Log(title = "岗位管理", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:post:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, SysPost post)
{
List<SysPost> list = postService.selectPostList(post);
ExcelUtil<SysPost> util = new ExcelUtil<SysPost>(SysPost.class);
util.exportExcel(response, list, "岗位数据");
}

注意@Log(title = "岗位管理", businessType = BusinessType.EXPORT),我们找到com.ruoyi.framework.aspectj.LogAspect,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package com.ruoyi.framework.aspectj;

【部分代码略】

/**
* 操作日志记录处理
*
* @author ruoyi
*/
@Aspect
@Component
public class LogAspect
{

【部分代码略】

/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult)
{
handleLog(joinPoint, controllerLog, null, jsonResult);
}

/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e)
{
handleLog(joinPoint, controllerLog, e, null);
}

protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult)
{
try
{
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();

// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
// 请求的地址
String ip = IpUtils.getIpAddr();
operLog.setOperIp(ip);
operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
if (loginUser != null)
{
operLog.setOperName(loginUser.getUsername());
}

if (e != null)
{
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间
operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
}
catch (Exception exp)
{
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
}
finally
{
TIME_THREADLOCAL.remove();
}
}

【部分代码略】

}

handleLog方法即记录日志的方法,其中绝大部分代码都在组装operLog实例,我们关注最后一行,以异步的方式记录操作日志到数据库。

1
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

客户管理(代码生成器)

我们以客户管理为例,讨论代码生成器的用法。

假设存在一张表如下:

1
2
3
4
5
6
7
CREATE TABLE `customer` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(255) NOT NULL COMMENT '名称',
`phone` varchar(255) DEFAULT NULL COMMENT '手机号',
`age` int(11) DEFAULT NULL COMMENT '年龄',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='客户表';

生成代码

依次点击系统工具代码生成,再点击导入

生成代码-1

在弹出框中选择我们需要的表,点击确定

生成代码-2

点击编辑,可以修改我们需要生成的功能的一些信息。

生成代码-3

设置功能名为"客户管理",隶属于"系统管理"。

生成代码-4

然后可以点击预览进行查看,点击生成代码下载文件。

生成代码-5

复制

解压后的代码如下:

复制-1

我们把文件复制到对应的位置

  • ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/CustomerController.java
  • ruoyi-system/src/main/java/com/ruoyi/system/domain/Customer.java
  • ruoyi-system/src/main/java/com/ruoyi/system/mapper/CustomerMapper.java
  • ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CustomerServiceImpl.java
  • ruoyi-system/src/main/java/com/ruoyi/system/service/ICustomerService.java
  • ruoyi-system/src/main/resources/mapper/system/CustomerMapper.xml
  • ruoyi-ui/src/api/system/customer.js
  • ruoyi-ui/src/views/system/customer/index.vue

执行customerMenu.sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 菜单 SQL
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理', '1', '1', 'customer', 'system/customer/index', 1, 0, 'C', '0', '0', 'system:customer:list', '#', 'admin', sysdate(), '', null, '客户管理菜单');

-- 按钮父菜单ID
SELECT @parentId := LAST_INSERT_ID();

-- 按钮 SQL
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理查询', @parentId, '1', '#', '', 1, 0, 'F', '0', '0', 'system:customer:query', '#', 'admin', sysdate(), '', null, '');

insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理新增', @parentId, '2', '#', '', 1, 0, 'F', '0', '0', 'system:customer:add', '#', 'admin', sysdate(), '', null, '');

insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理修改', @parentId, '3', '#', '', 1, 0, 'F', '0', '0', 'system:customer:edit', '#', 'admin', sysdate(), '', null, '');

insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理删除', @parentId, '4', '#', '', 1, 0, 'F', '0', '0', 'system:customer:remove', '#', 'admin', sysdate(), '', null, '');

insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values('客户管理导出', @parentId, '5', '#', '', 1, 0, 'F', '0', '0', 'system:customer:export', '#', 'admin', sysdate(), '', null, '');

最后,我们重启项目,功能已经实现。

界面修改

我们一般是某一个管理系统需要用到RuoYi,而且不希望在界面上有太多的RuoYi的标志,在这里讨论一下如何修改界面。

首页

首页位于src/views/index.vue,修改该文件即可修改首页内容。

若依官网

在主页菜单栏,最后一项有一个"若依官网"。
可以通过菜单管理删除该功能,在删除之前,需要先在角色管理中删除若依官网的权限。

右上角

在顶部右上角,有几个导航栏。

找到src/layout/components/Navbar.vue,按需注释即可。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
<div class="navbar">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />

<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-if="!topNav"/>
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav"/>

<div class="right-menu">
<template v-if="device!=='mobile'">
<search id="header-search" class="right-menu-item" />

<el-tooltip content="源码地址" effect="dark" placement="bottom">
<ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
</el-tooltip>

<el-tooltip content="文档地址" effect="dark" placement="bottom">
<ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
</el-tooltip>

<screenfull id="screenfull" class="right-menu-item hover-effect" />

<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>

</template>

【部分代码略】

</div>
</div>
</template>

【部分代码略】

背景和头像

修改如下的两个文件:

  • src/assets/images/login-background.jpg
  • src/assets/images/profile.jpg

标题

全局搜索若依管理系统若依后台管理系统ruoyi.vip,都替换。
替换完成后,需要重新执行npm run dev才能生效。

Logo

找到src/layout/components/Sidebar/Logo.vue,讲如下代码的logo: logoImg修改成logo: false去除Logo,或者替换assets/logo/logo.png,替换Logo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import logoImg from '@/assets/logo/logo.png'
import variables from '@/assets/styles/variables.scss'

export default {
name: 'SidebarLogo',
props: {
collapse: {
type: Boolean,
required: true
}
},
computed: {
variables() {
return variables;
},
sideTheme() {
return this.$store.state.settings.sideTheme
}
},
data() {
return {
title: process.env.VUE_APP_TITLE,
logo: logoImg
}
}
}

部署

后端

修改配置

  • logback.xml文件中的路径建议改为./logs
  • application.yml中的profile需要改为服务器存在的真实路径。

打包:
后端打包

部署步骤:

  1. 将打包后的jar文件放在任意一个位置;
  2. 在服务器上和jar同一个目录下新建一个config目录,将项目里的application-druid.ymlapplication.yml或者其他yml配置文件复制出来,放入config目录。
    (在《21.SpringBoot [1/3]》的"多环境"的"外部配置文件"部分,我们讨论过配置文件的优先级,这种方法的优先级最高。)
  3. 执行命令启动:nohup java -jar ruoyi-admin.jar &
    也可以利用ry.sh脚本,将ry.sh放在jar同级目录下
    启动:./ry.sh start
    停止:./ry.sh stop
    重启:./ry.sh restart
    状态:./ry.sh status

最后我们执行curl http://127.0.0.1:8080,会收到如下返回:

1
欢迎使用RuoYi后台管理框架,当前版本:v3.8.6,请通过前端地址访问。

有些资料会讨论通过War包部署,RuoYi(前后端分离)本身就是基于SpringBoot的,通过War包部署,绝对不是行业主流,我们不讨论。

前端

前端打包命令:

1
npm run build:prod

如果又出现了如下的错误:

1
ERROR  Error: error:0308010C:digital envelope routines::unsupported

参数上文,将"build:prod": "vue-cli-service build",修改为:

1
"build:prod": "export NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",

打包后会得到一个目录dist,将其传输到服务器上。

nginx.conf进行如下的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
listen 80;
server_name localhost;

location / {
root /root/dist;
try_files $uri $uri/ /index.html;
index index.html index.htm;
}

location /prod-api/{
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:8080/;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
  • root /root/dist;,配置的前端资源的地址
  • proxy_pass http://localhost:8080/;,配置的是后端服务的地址

如果报类似如下的错误:

1
2
3
4
5
6
7
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
[crit] 17192#0: *3 stat() "/root/dist/index.html" failed (13: Permission denied), client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"

可以在nginx.conf的增加如下的配置:

1
user root;

nginx默认通过nobody用户启动,user root;的含义是通过root用户启动。

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/10824
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

评论区