Vue和Vue-Element-Admin(四):vue-element-admin的用户登录集成

Web开发与Vue框架 同时被 2 个专栏收录
10 篇文章 5 订阅
23 篇文章 17 订阅

Vue-element-admin使用mock.js数据进行用户和权限的验证,使用该框架开发的首要事情就是用户集成改造,使用本地测试环境的用户和角色信息完成登录验证;

目录

 一、vue-element-admin的登录逻辑

二、后端改造--用户查询接口

1、表和model

2、mapper

 3、service和Impi

4、controller

5、filter拦截器 

三、用户登录集成的前端改造


 一、vue-element-admin的登录逻辑

在/views/login目录中index.vue是登录界面,SocialSignin.vue是第三方登录页面,auth-redirect.vue没看懂;

login
| components
| | SocialSignin.vue
| auth-redirect.vue
| index.vue

 index.vue中的登录按钮触发handleLogin()这个methods,调用store/user.js中login这个action实现状态的更改,login()这个action再调用api/user/login的接口请求,从后端拿用户数据,如下:

// hadleLogin方法完成用户登录动作
handleLogin() {
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          this.loading = true
          this.$store.dispatch('user/login', this.loginForm)
            .then(() => {
              this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
              this.loading = false
            })
            .catch(() => {
              this.loading = false
            })
        } else {
          console.log('error submit!!')
          return false
        }
      })
    }

//store/user.js中login这个action的代码:

const actions = {
  // user login
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username.trim(), password: password }).then(response => {
        const { data } = response
        commit('SET_TOKEN', data.token)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  }
}

// api/user/login的接口请求代码:

import request from '@/utils/request'

export function login(data) {
  return request({
    url: '/vue-element-admin/user/login',
    method: 'post',
    data
  })
}

mock/user.js定义了/vue-element-admin/user/login这个rest请求的返回数据,定义了admin和editor这2个角色和token内容,定义了post请求(参数是loginname)的返回是一个R(code+tokens信息)嵌套json,code是请求的响应码,tokens是用户的单一状态信息(全局维护~),user/getInfo方法根据token参数得到用户和角色信息,后端需要提供相应接口;


const tokens = {
  admin: {
    token: 'admin-token'
  },
  editor: {
    token: 'editor-token'
  }
}

const users = {
  'admin-token': {
    roles: ['admin'],
    introduction: 'I am a super administrator',
    avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
    name: 'Super Admin'
  },
  'editor-token': {
    roles: ['editor'],
    introduction: 'I am an editor',
    avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
    name: 'Normal Editor'
  }
}

module.exports = [
  // user login
  {
    url: '/vue-element-admin/user/login',
    type: 'post',
    response: config => {
      const { username } = config.body
      const token = tokens[username]

      // mock error
      if (!token) {
        return {
          code: 60204,
          message: 'Account and password are incorrect.'
        }
      }

      return {
        code: 20000,
        data: token
      }
    }
  }
]

 逻辑并没有结束,前后端分离的路由逻辑写在框架里面,可以发现在登录页已定义redirect(http://localhost:9527/#/login?redirect=%2Fdashboard),因为涉及到路由的跳转就要验证权限问题,在根目录下有个全局的permission.js负责全局的导航守卫,里面定义了首页登录成功后需要在vuex中取权限,vuex再次发起user/getInfo请求得到对应用户的权限(role),如果有权限才会跳转,没有权限报错;

import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
const whiteList = ['/login', '/auth-redirect'] // no redirect whitelist

//导航守卫,在router之前,拿到to和from以及下一步动作next
router.beforeEach(async(to, from, next) => {
  // 开启进度条
  NProgress.start()
  // 拿title
  document.title = getPageTitle(to.meta.title)
  // 判断浏览器是否持有token
  const hasToken = getToken()
 // 有token若是登录页就放行,有token不是登录页就验证是否有权限,有就放行,没有的话在vuex中拿,vuex发起user/getinfo请求拿权限,并记住想要跳转到的页面,也就是redirect的位置;
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
    } else {
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          const {roles} = await store.dispatch('user/getInfo')
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
          router.addRoutes(accessRoutes)
          next({...to, replace: true})
        } catch (error) {
          await store.dispatch('user/resetToken')
          Message.error(error || 'Has Error')
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      }
    }
  } else {
    //没有token的话白名单就放行,不是的话就跳到登录页记录要去的页面;
    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

所以能够得到大致的流程,前端在登录页发起login请求,在路由守卫时候取vuex中的token(login请求后端返给前端的加密串),再次发起user/info请求从后端得到用户的权限信息,虽然token里面的加密信息附带了用户的所有信息,但是前端不做解密,登录后前端每次向后端请求数据,都会携带token头,然后后端根据token头再去验证并返回前端相应的信息; 

二、后端改造--用户查询接口

本地测试首先解决跨域问题:集成中的CORS(跨域资源共享)问题

还有前端调试问题:使用Vscode在chrome中调试vue

需要完成model、mapper、service、Impi、filter(请求过滤器)、co模块

后端完成后的git地址:后端代码

1、表和model

基本的用户登录模块至少需要4个表:user表(用户基本信息)、role表(用户权限信息)、auth表(用户密码和tokens信息)、user和role的维表,在idea的h2数据库建3个相应的表,参考之前博文:基于springboot的web开发配置,使用springboot、mybatisPlus、h2、lambok、swagger系列配置完成后端接口的开发;

// user表
create table LT_SYS_USER
(
	USER_ID VARCHAR(20) not null
		primary key,
	USER_CODE VARCHAR(64),
	USER_NAME VARCHAR(100) not null,
	USER_NAME_EN VARCHAR(100),
	ORGAN_ID VARCHAR(20),
	ORGAN_CODE VARCHAR(64),
	ORGAN_NAME VARCHAR(100),
	EMAIL TEXT,
	MOBILE VARCHAR(100),
	PHONE VARCHAR(100),
	SEX INT,
	AVATAR TEXT,
	SIGN VARCHAR(200),
	USER_SORT INT,
	STATUS INT not null,
	CREATE_BY VARCHAR(64),
	CREATE_DATE DATETIME,
	UPDATE_BY VARCHAR(64),
	UPDATE_DATE DATETIME,
	REMARKS TEXT,
	DELETED VARCHAR(5) not null,
	CORP_ID VARCHAR(64),
	REG_DATE DATETIME
);
// role角色表
create table LT_SYS_ROLE
(
	ROLE_ID VARCHAR(20) not null
		primary key,
	ROLE_CODE VARCHAR(64) not null,
	ROLE_NAME VARCHAR(100) not null,
	ROLE_TYPE VARCHAR(100),
	ROLE_SORT INT,
	DATA_SCOPE INT,
	BIZ_SCOPE VARCHAR(255),
	DELETED TINYINT not null,
	CREATE_BY VARCHAR(64) not null,
	CREATE_DATE DATETIME not null,
	UPDATE_BY VARCHAR(64),
	UPDATE_DATE DATETIME,
	REMARKS TEXT,
	CORP_ID VARCHAR(64)
);

//auth权限表
create table LT_SYS_USER_AUTH
(
	AUTH_ID VARCHAR(20) not null
		primary key,
	USER_ID VARCHAR(20) not null,
	LOGIN_NAME VARCHAR(100) not null,
	PASSWD VARCHAR(100) not null,
	TOKEN TEXT,
	EXPIRE_TIME DATETIME,
	DINGTALK_OPENID LONGTEXT,
	WELINK_OPENID VARCHAR(100),
	WX_OPENID VARCHAR(100),
	MOBILE_IMEI VARCHAR(100),
	USER_TYPE VARCHAR(16),
	PWD_SECURITY_LEVEL INT,
	PWD_UPDATE_DATE DATETIME,
	PWD_UPDATE_RECORD TEXT,
	PWD_QUEST_UPDATE_DATE DATETIME,
	LAST_LOGIN_IP VARCHAR(100),
	LAST_LOGIN_DATE DATETIME,
	FREEZE_DATE DATETIME,
	FREEZE_CAUSE VARCHAR(200),
	USER_WEIGHT INT,
	CREATE_BY VARCHAR(64),
	CREATE_DATE DATETIME,
	UPDATE_BY VARCHAR(64),
	UPDATE_DATE DATETIME,
	REMARKS TEXT,
	DELETED INT default 0,
	STATUS VARCHAR(100)
);

// USER和role维表
create table LT_SYS_USER_ROLE
(
	USER_ID VARCHAR(20) not null,
	ROLE_ID VARCHAR(20) not null,
	primary key (USER_ID, ROLE_ID)
);

comment on column LT_SYS_USER_ROLE.USER_ID is '用户id';
comment on column LT_SYS_USER_ROLE.ROLE_ID is '角色id';

2、mapper

3个表分别插入测试数据后,开始写后端restAPI查询接口,首先model/目录下创建userModel文件夹,model/userModel下新建3个类User.java,Role.java,Auth.java使用lambok和swagger注解提供构造器和接口使用的便利;然后在mapper/userMapper目录下创建对应的接口UserMapper和UserAuthMapper等,这里使用mybatisplus的继承basemapper再自定义一些查询;

// usermapper接口
package com.example.testspring.mapper.userMapper;
import com.example.testspring.model.userModel.Role;
import com.example.testspring.model.userModel.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.testspring.model.userModel.UserRole;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
@Mapper
public interface UserMapper extends BaseMapper<User>{
    User getByIdLazy(String userId);
    User findById(String userId);
    boolean deleteRoleByUserId(String userId);
    boolean insertRolesBatch(@Param("list") List<UserRole> list);}

//RoleMapper 接口
package com.example.testspring.mapper.userMapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.testspring.model.userModel.Role;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;
@Mapper
public interface RoleMapper extends BaseMapper<Role> {
    List<Role> getListByUserId(String userId);
    Role findById(String roleId);
}

//AuthMapper
package com.example.testspring.mapper.userMapper;
import com.example.testspring.model.userModel.Auth;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AuthMapper extends  BaseMapper<Auth>{
   // Auth getByLoginName(String loginName);
}

 3、service和Impi

然后在service/userService目录下创建对应的实例化类UserService和AuthService等,Impi是类实现,写不下;

// UserService 
package com.example.testspring.service.userService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.testspring.mapper.userMapper.RoleMapper;
import com.example.testspring.mapper.userMapper.UserMapper;
import com.example.testspring.model.userModel.Role;
import com.example.testspring.model.userModel.User;
import com.example.testspring.model.userModel.UserRole;
import com.example.testspring.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Autowired
    private UserMapper UserMapper;
    @Autowired
    private RoleMapper RoleMapper;
    @Override
    public User getUserAndRolesById(String userId) {
        User ltSysUser = UserMapper.findById(userId);
        List<Role> roles = RoleMapper.getListByUserId(userId);
        ltSysUser.setRoles(roles);
        return ltSysUser;
    }

    @Override
    public boolean saveRoleIdsByUserId(String userId, List<String> roleIds) {
        UserMapper.deleteRoleByUserId(userId);
        List<UserRole> list = roleIds.stream().map(roleId -> {
            return new UserRole(roleId, userId);
        }).collect(Collectors.toList());
        return UserMapper.insertRolesBatch(list);
    }
}

//RoseService 
package com.example.testspring.service.userService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.testspring.mapper.userMapper.RoleMapper;
import com.example.testspring.model.userModel.Role;
import com.example.testspring.service.RoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
@Service

public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {
    @Autowired
    private RoleMapper roleMapper;
    @Override
    public List<Role> getListByUserId(String userId) {
        return roleMapper.getListByUserId(userId);
    }

}
//AuthService AuthService 类
package com.example.testspring.service.userService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.testspring.mapper.userMapper.AuthMapper;
import com.example.testspring.model.userModel.Auth;
import com.example.testspring.service.AuthService;
import com.example.testspring.utils.SecurityUtil;
import org.springframework.stereotype.Service;

import java.util.List;
@Service
public class AuthServiceImpl extends ServiceImpl <AuthMapper, Auth> implements AuthService {
    @Override
    public Auth getByLoginName(String loginName) {
        LambdaQueryWrapper<Auth> queryWrapper = Wrappers.<Auth>lambdaQuery()
                .eq(Auth::getLoginName, loginName);
        List<Auth> ltSysUserAuthList = this.baseMapper.selectList(queryWrapper);
        if (CollectionUtils.isEmpty(ltSysUserAuthList)) {
            return null;
        }
        else if (ltSysUserAuthList.size() > 1) {
            log.error("用户账号下包含多个用户信息,请检查数据!");
        }
        return ltSysUserAuthList.get(0);
    }

    @Override
    public boolean updatePwdByUserId(String userId, String newPwd) {

        String entryptPassword = SecurityUtil.entryptPassword(newPwd);
        LambdaUpdateWrapper<Auth> wrapper = Wrappers.<Auth>lambdaUpdate()
                .eq(Auth::getUserId, userId).set(Auth::getPasswd, entryptPassword);
        return this.baseMapper.update(null, wrapper) > 0;
    }

}

然后在resoureces/mybatis/下创建对应的sql查询xml文件,保持跟mapper文件一一对应,可以参照github;

4、controller

最后,就是controller的restAPI,前端是/user/login这个post请求(参数是loginname和passwd,回传的是一个嵌套json(code+tokes)),因此本地要构造一个对应的API,还有user/info是get请求返回的是用户信息,以login为例,这里的controller需要tokenfilter过滤器来完成对请求的拦截

    @PostMapping("/login")
    @ApiImplicitParam(name = "req", value = "用户登陆信息", dataType = "LoginReq")
    public R login(@RequestBody @NotNull LoginReq req) throws Exception {
        String loginName = req.getLoginName();
        String passwd = req.getPasswd();
        Auth info = authService.getByLoginName(loginName);
        // 验证用户状态
        LambdaQueryWrapper<User> wrapper = Wrappers.<User>lambdaQuery().eq(User::getUserId, info.getUserId()).eq(User::getStatus, 0);
        User user = userService.getOne(wrapper);
        Assert.notNull(user, "用户已禁用, 请联系管理员");

        if (SecurityUtil.validatePassword(passwd, info.getPasswd())) {
            // 封装 Token
            String jwtToken = generateByUserInfo(info);
            return R.ok(jwtToken);
        }
        else {
            throw new LightUapException(LightErrorCode.FORBIDDEN);
        }
    }

    private String generateByUserInfo(Auth auth) throws Exception {
        String userId = auth.getUserId();
        User user = userService.getUserAndRolesById(userId);
        if (user == null) {
            throw new LightUapException("用户信息不存在,请联系管理员");
        }
        // 查询用户账户信息
        LightUserEntity userEntity = BeanConverter.convert(user, LightUserEntity.class);
        userEntity.setLoginName(auth.getLoginName());
        /**
         * 返回前台时,只返回当前用户的角色
         */
        List<Role> roles = user.getRoles();
        if (!CollectionUtils.isEmpty(roles) && roles.size() >= 1) {
            List<LightRoleEntity> collect = roles.stream().map(r -> r.setUsers(null)).map(r -> LightRoleEntity.builder()
                    .roleId(r.getRoleId()).roleCode(r.getRoleCode()).roleName(r.getRoleName()).roleType(r.getRoleType())
                    .build()).collect(Collectors.toList());
            userEntity.setRoles(collect);
            // 默认一个个角色为主要角色
            // userEntity.setRole(collect.get(0));
        }
        // 生成 token
        // 去除用户信息中的头像防止token过大
        userEntity.setAvatar("");
        String jwtToken = LightTokenUtil.createJwtDefaultExp(userEntity);
        return jwtToken;
    }

5、filter拦截器 

包括对于CORS的设置和对请求的拦截都可以写成配置类,这里的tokenInceptor完成了预检飞行和请求的分类,login请求放行,其他请求先拿到header后对token统一进行解析后,再进入API的controller逻辑;

package com.example.testspring.utils;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.example.testspring.model.userModel.LightUserEntity;
import com.example.testspring.req.LightException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.util.WebUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.Base64;
import java.util.Enumeration;

public class TokenInterceptor extends HandlerInterceptorAdapter {

    /**
     * 根据请求不同对token进行处理
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)  throws Exception {
        // 预检请求,预检飞行
        if(CorsUtils.isCorsRequest(request) && "OPTIONS".equals(request.getMethod())){
            response.setCharacterEncoding( "UTF-8");
            response.setContentType( "application/json; charset=utf-8");
            response.setStatus(200);
            response.setHeader("Access-Control-Allow-Credentials","true");
            response.setHeader("Access-Control-Allow-Origin","http://localhost:9527");
            response.setHeader("Access-Control-Allow-Headers","x-requested-with, authorization, Content-Type, Authorization, credential, X-XSRF-TOKEN,token,username,client");
            response.setHeader("Access-Control-Expose-Headers","x-requested-with, authorization, Content-Type, Authorization, credential, X-XSRF-TOKEN,token,username,client");
            PrintWriter out = null ;
            try{
                JSONObject res = new JSONObject();
                res.put( "200", "sucess");
                out = response.getWriter();
                out.append(res.toString());
                return false;
            }
            catch (Exception e){
                e.printStackTrace();
                response.sendError( 500);
                return false;
            }
        }
        String accessToken = request.getHeader("Authorization");
        //System.out.println(request.getHeader("Authorization"));
        //String str=request.getParameter("Authorization");
        if (StringUtils.isNotBlank(accessToken)) {
            LightUserEntity subject = LightTokenUtil.getSubject(accessToken);
            request.getSession().setAttribute("USER_INFO", subject);
            return true;
        }
        throw new LightException("TOKEN不合法,访问拒绝");
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           ModelAndView modelAndView) throws Exception {
        super.postHandle(request, response, handler, modelAndView);
    }
}

最后,使用postman测试登录接口和info接口;

三、用户登录集成的前端改造

默认是mock数据,所以先修改.env.development中BASE_API地址为本地的后端地址;

VUE_APP_BASE_API = 'http://localhost:9090'

 测试登录集成完成:

  • 6
    点赞
  • 1
    评论
  • 31
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

<p> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> <strong>本课程是基于vue-element-admin实现的后台权限验证系统,解决在前后端分离场景下如何实现前端与后端的动态权限,同时还会讲解基于laravel7.x的restful api接口的开发以及如何在脚手架中进行封装调用</strong> </p> <p style="font-size:11pt;color:#494949;"> <strong>对于前后端将要讲解的知识,分别列举如下:</strong> </p> <p style="font-size:11pt;color:#494949;"> <strong>前端</strong>: </p> <p style="font-size:11pt;color:#494949;"> 1、学习如何使用后台集成框架vue-element-admin进行后台前端框架的搭建 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 2、学习如何在vue-element-admin中使用element-ui组件,学习到iconfont图标库的使用方法,学习到后台通用列表页面的制作方式 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 3、学习如何在前后端分离下,实现前端菜单的动态权限,如何进行请求的统一拦截 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 4、学习到前端项目的前后端联调,打包上线,如何在服务器层解决前后端分离引起的跨域问题 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 5、学习如何借助linux脚本实现代码自动上线部署 </p> <p style="font-size:11pt;color:#494949;"> <strong>后端:</strong> </p> <p style="font-size:11pt;color:#494949;"> 1、如何使用laravel编写restful api接口 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 2、如何使用composer进行项目依赖管理,laravel常用扩展的安装与使用,如dingo/api 以及repository </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 3、如何使用jwt进行实现后台用户认证机制 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 4、使用laravel扩展包的形式进行后台权限验证开发 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 5、如何编写Seeders帮助我们在新系统里实现管理员的初始化 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p> <p style="font-size:11pt;color:#494949;"> 6、如何使用git进行项目代码管理与部署 </p> <p style="font-size:11pt;color:#494949;"> &nbsp; </p>
©️2021 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值