若依Vue系统中的权限管理部分的功能都集中在了系统管理菜单模块中,如下图所示。其中权限部分主要涉及到了用户管理、角色管理、菜单管理、部门管理这四个部分。

一、若依Vue系统中的权限分类

根据观察,若依Vue系统中的权限分为以下几类

菜单权限:用户登录系统之后能看到哪些菜单

按钮权限:用户在一个页面上能看到哪些按钮,比如新增、删除等按钮

接口权限:用户带着认证信息请求后端接口,是否有权限访问,该接口和前端页面上的按钮一一对应

数据权限:用户有权限访问后端某个接口,但是不同的用户相同的接口相同的入参,根据权限大小不同,返回的结果应当不一样——权限大的能够看到的数据更多。

1.菜单权限

这个比较好理解,拥有不同权限的用户登录系统之后看到的菜单是不一样的,从新建菜单到给一个用户分配菜单权限,上一篇文章已经讲过,不赘述。

用户登录之后会请求后端的com.ruoyi.web.controller.system.SysLoginController#getRouters接口获取登录用户的菜单数据:

select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time

from sys_menu m

left join sys_role_menu rm on m.menu_id = rm.menu_id

left join sys_user_role ur on rm.role_id = ur.role_id

left join sys_role ro on ur.role_id = ro.role_id

left join sys_user u on ur.user_id = u.user_id

where u.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = 0 AND ro.status = 0

order by m.parent_id, m.order_num

菜单类型(M目录 C菜单 F按钮);菜单状态(0显示 1隐藏)

这是典型的用户-角色-菜单模型。

前端会根据该接口返回的数据渲染出不同的菜单。

2.按钮权限

新增按钮权限和新增菜单差不多,下图是我在新闻列表页面上新增了一个按钮叫做新闻新增,该按钮的权限分配和菜单的权限分配方法是一样的。

3.接口权限

每一个按钮基本上都会对应着一个后端的接口,前端会根据权限标志显示或者隐藏按钮,但是如果用户不点击按钮,直接通过http请求工具请求后端咋办?所以接口权限也是要有的,该权限和按钮上权限完全一致。

若依系统使用了SpringSecurity框架,接口权限都是基于注解@PreAuthorize实现的,比如,用户管理页面中的修改用户按钮对应的后端接口长这个样子

@PreAuthorize("@ss.hasPermi('system:user:edit')")

@Log(title = "用户管理", businessType = BusinessType.UPDATE)

@PutMapping

public AjaxResult edit(@Validated @RequestBody SysUser user)

{

...

}

和其对应的前端按钮权限标志一样

如果没有权限访问接口,则会返回类似如下信息:

{

"msg": "请求访问:/system/user/list,认证失败,无法访问系统资源",

"code": 401

}

4.数据权限

用户有权限访问后端某个接口,但是不同的用户相同的接口相同的入参,根据权限大小不同,返回的结果应当不一样——权限大的能够看到的数据更多。

体现在若依Vue系统中,举个例子,现在若以系统中有两个用户,一个是超级管理员admin,一个是普通用户kdyzm

它们两者均有用户管理、菜单管理、角色管理权限,所以它们能够看到相应的菜单并作出相应的操作,比如删除、新增、修改等。这里有个问题,kdyzm应当只能看到一部分数据,而超级管理员应当能够看到所有数据,在若依系统中,是通过对角色数据权限修改进行控制的。

所以,相同的权限,超级管理员能够看到的用户数量和普通用户kdyzm能够看到的用户数量是不一样的。

超级管理员看到的用户管理页面:

普通用户kdyzm看到的用户管理页面:

二、各种类型权限实现原理

1.菜单权限

菜单权限很简单,实际上就是简单的用户-角色-菜单模型,那么菜单是什么时候加载的呢?ruoyi-ui\src\permission.js,加载的逻辑在这个文件中。

permission.js文件中设置了导航守卫,每次路由发生变化的时候就会触发router.beforeEach的回调函数。

router.beforeEach((to, from, next) => {

NProgress.start()

if (getToken()) {

/* has token*/

if (to.path === '/login') {

next({ path: '/' })

NProgress.done()

} else {

if (store.getters.roles.length === 0) {

// 判断当前用户是否已拉取完user_info信息

store.dispatch('GetInfo').then(res => {

// 拉取user_info

const roles = res.roles

store.dispatch('GenerateRoutes', { roles }).then(accessRoutes => {

// 根据roles权限生成可访问的路由表

router.addRoutes(accessRoutes) // 动态添加可访问路由表

next({ ...to, replace: true }) // hack方法 确保addRoutes已完成

})

}).catch(err => {

store.dispatch('LogOut').then(() => {

Message.error(err)

next({ path: '/' })

})

})

} else {

next()

}

}

} else {

// 没有token

if (whiteList.indexOf(to.path) !== -1) {

// 在免登录白名单,直接进入

next()

} else {

next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页

NProgress.done()

}

}

})

注意if (store.getters.roles.length === 0) {这段逻辑,可以看出,如果不刷新当前页面,就算给用户添加了新的菜单权限,用户也看不到新的菜单。

2.按钮权限

按钮权限设置上和菜单权限基本上是一样的,是附着于页面中的细粒度权限。按钮权限体现在如果用户没有相应的权限,则看不到相关的按钮。这个是咋实现的呢?

先看下系统管理下的菜单管理中的修改、新增和删除按钮前端vue代码

type="text"

icon="el-icon-edit"

@click="handleUpdate(scope.row)"

v-hasPermi="['system:menu:edit']"

>修改

size="mini"

type="text"

icon="el-icon-plus"

@click="handleAdd(scope.row)"

v-hasPermi="['system:menu:add']"

>新增

size="mini"

type="text"

icon="el-icon-delete"

@click="handleDelete(scope.row)"

v-hasPermi="['system:menu:remove']"

>删除

el-button上有个属性v-hasPermi,这实际上是vue的自定义指令,属性值就是创建按钮的时候定义的那个权限标志。其定义在src/directive/permission/index.js文件

import hasRole from './hasRole'

import hasPermi from './hasPermi'

const install = function(Vue) {

Vue.directive('hasRole', hasRole)

Vue.directive('hasPermi', hasPermi)

}

if (window.Vue) {

window['hasRole'] = hasRole

window['hasPermi'] = hasPermi

Vue.use(install); // eslint-disable-line

}

export default install

其具体实现逻辑就在同目录的hasPermi.js文件中

import store from '@/store'

export default {

inserted(el, binding, vnode) {

const { value } = binding

const all_permission = "*:*:*";

const permissions = store.getters && store.getters.permissions

if (value && value instanceof Array && value.length > 0) {

const permissionFlag = value

const hasPermissions = permissions.some(permission => {

return all_permission === permission || permissionFlag.includes(permission)

})

if (!hasPermissions) {

el.parentNode && el.parentNode.removeChild(el)

}

} else {

throw new Error(`请设置操作权限标签值`)

}

}

}

注意代码el.parentNode && el.parentNode.removeChild(el),可以看到,如果没有按钮权限,则会将按钮本身从dom中移除。

3.接口权限

接口权限和前端的按钮权限一一对应。为的是防止用户绕过按钮直接请求后端接口获取数据。在若依Vue系统中,是使用SpringSecurity的注解@PreAuthorize实现的。

虽然只是一个注解,但是它是SpringSecurity+JWT集成的结晶~这个之后再细谈。

4.数据权限

数据权限实现的关键在于com.ruoyi.framework.aspectj.DataScopeAspect类。该类是一个切面类,凡是加上com.ruoyi.common.annotation.DataScope注解的方法,在执行的时候都会被它拦截。

该切面定义了五种权限范围

name

code

desc

DATA_SCOPE_ALL

1

全部数据权限

DATA_SCOPE_CUSTOM

2

自定数据权限

DATA_SCOPE_DEPT

3

部门数据权限

DATA_SCOPE_DEPT_AND_CHILD

4

部门及以下数据权限

DATA_SCOPE_SELF

5

仅本人数据权限

该切面的核心逻辑是“拼SQL”,方法执行之前,会给参数的一个params属性添加一个dataScope键值对,key为"dataScope",值为AND (" + sqlString.substring(4) + ")"样式的一段SQL,这段SQL会根据当前用户所在的部门以及当前用户角色的权限范围发生变化。

StringBuilder sqlString = new StringBuilder();

for (SysRole role : user.getRoles())

{

String dataScope = role.getDataScope();

if (DATA_SCOPE_ALL.equals(dataScope))

{

sqlString = new StringBuilder();

break;

}

else if (DATA_SCOPE_CUSTOM.equals(dataScope))

{

sqlString.append(StringUtils.format(

" OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,

role.getRoleId()));

}

else if (DATA_SCOPE_DEPT.equals(dataScope))

{

sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));

}

else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope))

{

sqlString.append(StringUtils.format(

" OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",

deptAlias, user.getDeptId(), user.getDeptId()));

}

else if (DATA_SCOPE_SELF.equals(dataScope))

{

if (StringUtils.isNotBlank(userAlias))

{

sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));

}

else

{

// 数据权限为仅本人且没有userAlias别名不查询任何数据

sqlString.append(" OR 1=0 ");

}

}

}

简单来说,这段代码的逻辑就是用户所在的部门权限越高,数据权限范围越大,查出来的结果集将会越大。

DataScope注解分别加到了部门列表查询、角色列表查询、用户列表查询的接口上,很明显,这几个接口需要根据不同的人查出不同的结果。

以用户列表查询为例,执行sql为

select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u

left join sys_dept d on u.dept_id = d.dept_id

where u.del_flag = '0'

AND u.user_name like concat('%', #{userName}, '%')

AND u.status = #{status}

AND u.phonenumber like concat('%', #{phonenumber}, '%')

AND date_format(u.create_time,'%y%m%d') >= date_format(#{params.beginTime},'%y%m%d')

AND date_format(u.create_time,'%y%m%d') <= date_format(#{params.endTime},'%y%m%d')

AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM sys_dept t WHERE find_in_set(#{deptId}, ancestors) ))

${params.dataScope}

其中,有这么一段代码

${params.dataScope}

实际上DataScopeAspect切面就只干了填充params的dataScope属性这么一件事情。

三、若依Vue系统SpringSecurity+JWT

若依Vue系统中从用户登录到后端接口权限校验,都是基于SpringSecurity+JWT实现的,其中,SpringSecurity是核心,jwt只是为了保证token合法性的一种手段(签名防止篡改)。spring security集成的相关代码在ruoyi-framework模块的com.ruoyi.framework.security包以及com.ruoyi.framework.config.SecurityConfig类中。

SecurityConfig是核心配置类,所有的配置均在该类中。

1.用户登录

用户登录的逻辑在方法com.ruoyi.web.controller.system.SysLoginController#login中,一个典型的登录请求体如下所示

{

"username": "admin",

"password": "admin123",

"code": "0",

"uuid": "a9fdbcbcb28748b796b5b77ad71bbb97"

}

username和password分别是用户名和密码,code为验证码,uuid为验证码的唯一标识。登录成功之后会返回前端一个jwt令牌

{

"msg": "操作成功",

"code": 200,

"token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjIzZjRhNjJjLTY5NzMtNDcxZS04ZmU4LWJmYWQ4YzllNWFkMiJ9.9d3iIaNq62CkjTXlxFOQgdDMOAZiu5tAsEn0cEuV23opT6PAqu_CiaN7kQY8_XhlQrHX5RgZ2bH7LpsiKLLcSw"

}

在登录方法中,做了以下几件事情

根据uuid获取redis中的验证码并对请求的验证码做验证

如果验证码没问题,则对用户名和密码进行校验

如果用户名和密码校验成功,则使用token作为key将用户信息保存到redis

使用jwt对token签名并返回前端

在整个过程中,会抛出一些自定义异常,比如

throw new CaptchaExpireException();

throw new CaptchaException();

throw new UserPasswordNotMatchException();

throw new CustomException(e.getMessage());

这些异常最终会被全局异常处理器处理掉:com.ruoyi.framework.web.exception.GlobalExceptionHandler

2.接口权限校验

前端请求完成登录接口之后会将token存储到cookie,key为Admin-Token,value是jwt令牌。登录逻辑:user.actions.Login。

之后,每次请求后端接口的时候都会带上Authentication Header

这实际上是通过axios的请求拦截器实现的:详情可见src/utils/request.js文件

带着Authentication Header的请求打到后端的时候会经过过滤器com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter,该过滤器做了以下几件事情

从请求头中取出jwt令牌,并对其进行jwt验签,验签若是成功,则取出原始token

根据token从redis中取出用户数据

将用户信息封装成UsernamePasswordAuthenticationToken对象,并将该对象填充到Spring Security上下文中

填充到SpringSecurity上下文才能让Controller接口上的@PreAuthorize注解发挥作用(存疑,这里若依作者并非使用原生的SpringSecurity提供的spel表达式,也没有用authorities,而是使用了PermissionService类)。

接着,Controller接口正式执行之前会进入com.ruoyi.framework.web.service.PermissionService#hasPermi方法判定权限,这里重新从redis中取出用户数据并进行权限校验,权限校验失败则不再执行接口中逻辑(存疑,这里并没有使用SpringSecurity上下文中的用户数据,那么JwtAuthenticationTokenFilter中的用户信息填充上下文中的代码是干啥用的)。

四、实战

上一篇文章讲解了如何创建一个菜单并创建页面,但是是个空页面

这篇文章将会讲解如何实现增删查改功能。

一切开始之前,新建表news,建表SQL如下

CREATE TABLE `news` (

`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',

`title` varchar(128) NOT NULL COMMENT '新闻标题',

`brief` varchar(256) DEFAULT NULL COMMENT '新闻概述',

`content` text COMMENT '新闻正文',

`create_time` datetime DEFAULT NULL,

`create_by` varchar(64) DEFAULT NULL,

`update_time` datetime DEFAULT NULL,

`update_by` varchar(64) DEFAULT NULL,

`delete_flag` tinyint(1) DEFAULT NULL,

PRIMARY KEY (`id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

1.前端页面修改

可以仿照角色管理的页面写,直接将角色管理页面的代码直接拷贝到news文件中,效果如下

没错,新闻列表的标题,角色管理的页面。。。

之后对页面中元素进行修改,使其和上面创建的表结构一致,修改后的页面样子

这是预期中的样子,但是内容还是角色管理页面的内容。

2.创建按钮权限

上一步已经完成了页面外观的改造,接下来需要修改页面内容了,首先需要把按钮权限给加上

按照这个样子添加按钮权限,之后把权限标志分配到前端页面中

3.使用代码生成代码

在系统工具-代码生成页面中生成news表对应的相关实体类、mapper、xml对象等,可以极大的简化开发过程。

4.准备后端接口

将上一步代码生成器生成的NewsController拿过来改一改就行,修改后的代码如下所示:

package com.ruoyi.web.controller.business;

import com.ruoyi.common.annotation.Log;

import com.ruoyi.common.core.controller.BaseController;

import com.ruoyi.common.core.domain.AjaxResult;

import com.ruoyi.common.core.page.TableDataInfo;

import com.ruoyi.common.enums.BusinessType;

import com.ruoyi.common.utils.SecurityUtils;

import com.ruoyi.common.utils.poi.ExcelUtil;

import com.ruoyi.system.domain.News;

import com.ruoyi.system.mapper.NewsMapper;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.security.access.prepost.PreAuthorize;

import org.springframework.validation.annotation.Validated;

import org.springframework.web.bind.annotation.*;

import java.util.Date;

import java.util.List;

/**

* @author kdyzm

*/

@RestController

@RequestMapping("/business/news")

public class NewsController extends BaseController {

@Autowired

private NewsMapper newsMapper;

/**

* 获取新闻列表

*/

@PreAuthorize("@ss.hasPermi('business:news:list')")

@GetMapping("/list")

public TableDataInfo list(News post) {

startPage();

List list = newsMapper.selectNewsList(post);

return getDataTable(list);

}

@Log(title = "新闻管理", businessType = BusinessType.EXPORT)

@PreAuthorize("@ss.hasPermi('business:news:export')")

@GetMapping("/export")

public AjaxResult export(News post) {

List list = newsMapper.selectNewsList(post);

ExcelUtil util = new ExcelUtil<>(News.class);

return util.exportExcel(list, "新闻数据");

}

/**

* 根据新闻编号获取详细信息

*/

@PreAuthorize("@ss.hasPermi('business:news:query')")

@GetMapping(value = "/{postId}")

public AjaxResult getInfo(@PathVariable Long postId) {

return AjaxResult.success(newsMapper.selectNewsById(postId));

}

/**

* 新增新闻

*/

@PreAuthorize("@ss.hasPermi('business:news:add')")

@Log(title = "新闻管理", businessType = BusinessType.INSERT)

@PostMapping

public AjaxResult add(@Validated @RequestBody News post) {

post.setCreateBy(SecurityUtils.getUsername());

post.setCreateTime(new Date());

return toAjax(newsMapper.insertNews(post));

}

/**

* 修改新闻

*/

@PreAuthorize("@ss.hasPermi('business:news:update')")

@Log(title = "新闻管理", businessType = BusinessType.UPDATE)

@PutMapping

public AjaxResult edit(@Validated @RequestBody News post) {

post.setUpdateBy(SecurityUtils.getUsername());

return toAjax(newsMapper.updateNews(post));

}

/**

* 删除新闻

*/

@PreAuthorize("@ss.hasPermi('business:news:delete')")

@Log(title = "新闻管理", businessType = BusinessType.DELETE)

@DeleteMapping("/{postIds}")

public AjaxResult remove(@PathVariable Long[] postIds) {

return toAjax(newsMapper.deleteNewsByIds(postIds));

}

}

5.修改前端页面请求地址

将生成的代码中的news.js文件放到api目录,并修改其中的接口路径与后端接口地址一一对应。

import request from '@/utils/request'

// 查询角色列表

export function listNews(query) {

return request({

url: '/business/news/list',

method: 'get',

params: query

})

}

// 查询角色详细

export function getNews(roleId) {

return request({

url: '/business/news/' + roleId,

method: 'get'

})

}

// 新增角色

export function addNews(data) {

return request({

url: '/business/news',

method: 'post',

data: data

})

}

// 修改角色

export function updateNews(data) {

return request({

url: '/business/news',

method: 'put',

data: data

})

}

// 删除角色

export function delNews(roleId) {

return request({

url: '/business/news/' + roleId,

method: 'delete'

})

}

// 导出角色

export function exportNews(query) {

return request({

url: '/business/news/export',

method: 'get',

params: query

})

}

然后修改页面中的请求地址使用这里的地址。

五、测试

1.超级管理员测试

超级管理员拥有最大权限,所有权限校验都会跳过对超级管理员的权限校验。这里先使用超级管理员进行测试可以规避权限问题,大体上先看看能否跑的通。

下面是演示超级管理员的CRUD操作。

2.普通用户测试

这里用用户kdyzm进行测试,在测试之前,先看下kdyzm的角色

可以看到该用户是运营角色,那么修改角色权限,只给查询、修改、新增权限,不给导出和删除权限,如下所示

这时候切换登录用户为kdyzm,看看新闻列表页面

可以看到,kdyzm在新闻列表页面中,看不到导出导出按钮和删除按钮,符合预期设想。

好了,若依Vue权限详解部分到此结束了,下一篇文章将会讲解若依代码生成器生成原理和代码分析

若依ajax返回数据,若依管理系统RuoYi-Vue(二):权限系统设计详解相关推荐

  1. 若依管理系统RuoYi-Vue(二):权限系统设计详解

    本篇文章试图讲解若依Vue系统中的权限设计原理以及实战,为什么是"试图"?因为这也是摸索着理解的,不一定准 若依Vue系统中的权限管理部分的功能都集中在了系统管理菜单模块中,如下图 ...

  2. ajax的结果处理,jQuery ajax返回数据的后续处理

    简述 在jQuery中ajax默认是异步请求,我们大多数也需要异步请求,比如: var temp; $.ajax({ type : "post", url : 'test.json ...

  3. [Python从零到壹] 十五.文本挖掘之数据预处理、Jieba工具和文本聚类万字详解

    欢迎大家来到"Python从零到壹",在这里我将分享约200篇Python系列文章,带大家一起去学习和玩耍,看看Python这个有趣的世界.所有文章都将结合案例.代码和作者的经验讲 ...

  4. 给Clouderamanager集群里安装基于Hive的大数据实时分析查询引擎工具Impala步骤(图文详解)...

    不多说,直接上干货! 这个很简单,在集群机器里,选择就是了,本来自带就有Impala的. 扩展博客 给Ambari集群里安装基于Hive的大数据实时分析查询引擎工具Impala步骤(图文详解) 欢迎大 ...

  5. Ajax叠加(Ajax返回数据用Ajax发出)

    最近在做人事管理系统的一个签到功能,首先是把部门当做参数,把参数用Ajax发送到数据库进行查询,然后以表格形式动态生成员工信息到返回页面的Div里,表格最后的一列是签到按钮,这时我想用JQuery继续 ...

  6. tp5 ajax 返回数据正常状态码却为500

    tp5 ajax 请求返回数据正常,请求状态码却是500 一直进error,不进success,原因未知 解决方法: 控制器内加入两行代码 error_reporting("E_ALL&qu ...

  7. ajax里数组添加数据,小笔记(一):ajax传递数组及将ajax返回数据赋值

    当使用ajax传递数据时,有可能传递多个数据,这是使用以下方法传递数据就会显得数据过多且混杂 $.ajax({ type:'post', url:url, data:{data:data,conten ...

  8. 外部调用ajax返回数据问题

    偶尔用到ajax返回成功success里面data数据问题,例如: $.ajax({                              type:'POST',                ...

  9. ajax返回数据输出成表,javascript代码实例教程-ajax请求返回Json格式数据如何循环输出成table形式...

    小宝典致力于为广大程序猿(媛)提供高品质的代码服务,请大家多多光顾小站,小宝典在此谢过. 首先,Ajax请求数据,(用的是Jquery的Ajax) [javascript] $(function(){ ...

最新文章

  1. (转)互斥对象锁和临界区锁性能比较 .
  2. 数字图像缩放之最近邻插值与双线性插值处理效果对比
  3. Spring的lazy-init详解
  4. 傲娇Android二三事之操蛋的开发日记(第一回)
  5. C++ 一个例子彻底搞清楚拷贝构造函数和赋值运算符重载的区别
  6. boost::mp11::mp_power_set相关用法的测试程序
  7. 阿里开源框架-2018年末
  8. C++基础--STL基本容器string,vector,list,deque,map
  9. 51Nod 1509加长棒
  10. 重磅消息,Redis开源作者宣布不再维护Redis项目!
  11. alsa的动态库安装在哪里_源码编译安装MySQL8.0.20
  12. Java代码整洁之道
  13. 标签系列三:spring 中property解释以及property标签里面的属性
  14. python绘制红色五角星_python绘制五角星
  15. 子组件改变父组件的两种方式
  16. php网站开发期末大作业,网页设计期末大作业报告..doc
  17. BitLocker恢复
  18. VR全景购物,2022年的购物新方式
  19. python依赖包turbojpeg(PyTurboJPEG)安装
  20. 设备安装CoreELEC系统,并配置遥控:实现低成本NAS影音播放器

热门文章

  1. 华为防火墙VRRP双机热备的配置
  2. vue移动端用什么数据可视化插件_前端必看的数据可视化入门指南
  3. 全加器在计算机的应用,如何用半加器实现全加器?
  4. 【技巧】搭建企业公司网站的实施步骤有哪些
  5. [图文教程]BIOS设置教程
  6. 微信小程序云函数安装wx-server-sdk依赖
  7. MFC应用程序无法正常启动(0x0150002)。请单击“确定”关闭应用程序
  8. [Camera]摄像头模组硬件
  9. 常规创业公司股权分配参考
  10. vue开发APP使用微信分享和QQ分享功能