做者:呂一明項目代碼:https://github.com/MarkerHub/...javascript
項目視頻:https://www.bilibili.com/vide...css
轉載請保留此引用,感謝!html
文章整體分爲2大部分,Java後端接口和vue前端頁面,比較長,由於不想分開發布,真正想你4小時學會,哈哈。前端
先看效果:vue
很少說,開始敲代碼。java
從零開始搭建一個項目骨架,最好選擇合適,熟悉的技術,而且在將來易拓展,適合微服務化體系等。因此通常以Springboot做爲咱們的框架基礎,這是離不開的了。node
而後數據層,咱們經常使用的是Mybatis,易上手,方便維護。可是單表操做比較困難,特別是添加字段或減小字段的時候,比較繁瑣,因此這裏我推薦使用Mybatis Plus(https://mp.baomidou.com/),爲簡化開發而生,只需簡單配置,便可快速進行 CRUD 操做,從而節省大量時間。mysql
做爲一個項目骨架,權限也是咱們不能忽略的,Shiro配置簡單,使用也簡單,因此使用Shiro做爲咱們的的權限。webpack
考慮到項目可能須要部署多臺,這時候咱們的會話等信息須要共享,Redis是如今主流的緩存中間件,也適合咱們的項目。ios
而後由於先後端分離,因此咱們使用jwt做爲咱們用戶身份憑證。
ok,咱們如今就開始搭建咱們的項目腳手架!
技術棧:
導圖:https://www.markerhub.com/map/131
這裏,咱們使用IDEA來開發咱們項目,新建步驟比較簡單,咱們就不截圖了。
開發工具與環境:
新建好的項目結構以下,SpringBoot版本使用的目前最新的2.2.6.RELEASE版本
pom的jar包導入以下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
接下來,咱們來整合mybatis plus,讓項目能完成基本的增刪改查操做。步驟很簡單:能夠去官網看看:https://mp.baomidou.com/guide...
第一步:導入jar包
pom中導入mybatis plus的jar包,由於後面會涉及到代碼生成,因此咱們還須要導入頁面模板引擎,這裏咱們用的是freemarker。
<!--mp--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.2.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!--mp代碼生成器--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.2.0</version> </dependency>
第二步:而後去寫配置文件
# DataSource Config spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: admin mybatis-plus: mapper-locations: classpath*:/mapper/**Mapper.xml
上面除了配置數據庫的信息,還配置了myabtis plus的mapper的xml文件的掃描路徑,這一步不要忘記了。
第三步:開啓mapper接口掃描,添加分頁插件
新建一個包:經過@mapperScan註解指定要變成實現類的接口所在的包,而後包下面的全部接口在編譯以後都會生成相應的實現類。PaginationInterceptor是一個分頁插件。
@Configuration @EnableTransactionManagement @MapperScan("com.markerhub.mapper") public class MybatisPlusConfig { @Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); return paginationInterceptor; } }
第四步:代碼生成
若是你沒再用其餘插件,那麼如今就已經可使用mybatis plus了,官方給咱們提供了一個代碼生成器,而後我寫上本身的參數以後,就能夠直接根據數據庫表信息生成entity、service、mapper等接口和實現類。
由於代碼比較長,就不貼出來了,在代碼倉庫上看哈!
首先我在數據庫中新建了一個user表:
CREATE TABLE `m_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(64) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `email` varchar(64) DEFAULT NULL, `password` varchar(64) DEFAULT NULL, `status` int(5) NOT NULL, `created` datetime DEFAULT NULL, `last_login` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `UK_USERNAME` (`username`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `m_blog` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `title` varchar(255) NOT NULL, `description` varchar(255) NOT NULL, `content` longtext, `created` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP, `status` tinyint(4) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4; INSERT INTO `vueblog`.`m_user` (`id`, `username`, `avatar`, `email`, `password`, `status`, `created`, `last_login`) VALUES ('1', 'markerhub', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', NULL, '96e79218965eb72c92a549dd5a330112', '0', '2020-04-20 10:44:01', NULL);
運行CodeGenerator的main方法,輸入表名:m_user,生成結果以下:
獲得:
簡潔!方便!通過上面的步驟,基本上咱們已經把mybatis plus框架集成到項目中了。
ps:額,注意一下m_blog表的代碼也生成一下哈。
在UserController中寫個測試:
@RestController @RequestMapping("/user") public class UserController { @Autowired UserService userService; @GetMapping("/{id}") public Object test(@PathVariable("id") Long id) { return userService.getById(id); } }
訪問:http://localhost:8080/user/1 得到結果以下,整合成功!
這裏咱們用到了一個Result的類,這個用於咱們的異步統一返回的結果封裝。通常來講,結果裏面有幾個要素必要的
因此可獲得封裝以下:
@Data public class Result implements Serializable { private String code; private String msg; private Object data; public static Result succ(Object data) { Result m = new Result(); m.setCode("0"); m.setData(data); m.setMsg("操做成功"); return m; } public static Result succ(String mess, Object data) { Result m = new Result(); m.setCode("0"); m.setData(data); m.setMsg(mess); return m; } public static Result fail(String mess) { Result m = new Result(); m.setCode("-1"); m.setData(null); m.setMsg(mess); return m; } public static Result fail(String mess, Object data) { Result m = new Result(); m.setCode("-1"); m.setData(data); m.setMsg(mess); return m; } }
考慮到後面可能須要作集羣、負載均衡等,因此就須要會話共享,而shiro的緩存和會話信息,咱們通常考慮使用redis來存儲這些數據,因此,咱們不只僅須要整合shiro,同時也須要整合redis。在開源的項目中,咱們找到了一個starter能夠快速整合shiro-redis,配置簡單,這裏也推薦你們使用。
而由於咱們須要作的是先後端分離項目的骨架,因此通常咱們會採用token或者jwt做爲跨域身份驗證解決方案。因此整合shiro的過程當中,咱們須要引入jwt的身份驗證過程。
那麼咱們就開始整合:
咱們使用一個shiro-redis-spring-boot-starter的jar包,具體教程能夠看官方文檔:https://github.com/alexxiyang/shiro-redis/blob/master/docs/README.md#spring-boot-starter
第一步:導入shiro-redis的starter包:還有jwt的工具包,以及爲了簡化開發,我引入了hutool工具包。
<dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis-spring-boot-starter</artifactId> <version>3.2.1</version> </dependency> <!-- hutool工具類--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.3.3</version> </dependency> <!-- jwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
第二步:編寫配置:
/** * shiro啓用註解攔截控制器 */ @Configuration public class ShiroConfig { @Autowired JwtFilter jwtFilter; @Bean public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); return sessionManager; } @Bean public DefaultWebSecurityManager securityManager(AccountRealm accountRealm, SessionManager sessionManager, RedisCacheManager redisCacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm); securityManager.setSessionManager(sessionManager); securityManager.setCacheManager(redisCacheManager); /* * 關閉shiro自帶的session,詳情見文檔 */ DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); return securityManager; } @Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition(); Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/**", "jwt"); // 主要經過註解方式校驗權限 chainDefinition.addPathDefinitions(filterMap); return chainDefinition; } @Bean("shiroFilterFactoryBean") public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroFilterChainDefinition shiroFilterChainDefinition) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", jwtFilter); shiroFilter.setFilters(filters); Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap(); shiroFilter.setFilterChainDefinitionMap(filterMap); return shiroFilter; } // 開啓註解代理(默認好像已經開啓,能夠不要) @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){ AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } @Bean public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); return creator; } }
上面ShiroConfig,咱們主要作了幾件事情:
那麼,接下來,咱們聊聊ShiroConfig中出現的AccountRealm,還有JwtFilter。
AccountRealm是shiro進行登陸或者權限校驗的邏輯所在,算是核心了,咱們須要重寫3個方法,分別是
咱們先來整體看看AccountRealm的代碼,而後逐個分析:
@Slf4j @Component public class AccountRealm extends AuthorizingRealm { @Autowired JwtUtils jwtUtils; @Autowired UserService userService; @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return null; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JwtToken jwt = (JwtToken) token; log.info("jwt----------------->{}", jwt); String userId = jwtUtils.getClaimByToken((String) jwt.getPrincipal()).getSubject(); User user = userService.getById(Long.parseLong(userId)); if(user == null) { throw new UnknownAccountException("帳戶不存在!"); } if(user.getStatus() == -1) { throw new LockedAccountException("帳戶已被鎖定!"); } AccountProfile profile = new AccountProfile(); BeanUtil.copyProperties(user, profile); log.info("profile----------------->{}", profile.toString()); return new SimpleAuthenticationInfo(profile, jwt.getCredentials(), getName()); } }
其實主要就是doGetAuthenticationInfo登陸認證這個方法,能夠看到咱們經過jwt獲取到用戶信息,判斷用戶的狀態,最後異常就拋出對應的異常信息,否者封裝成SimpleAuthenticationInfo返回給shiro。
接下來咱們逐步分析裏面出現的新類:
一、shiro默認supports的是UsernamePasswordToken,而咱們如今採用了jwt的方式,因此這裏咱們自定義一個JwtToken,來完成shiro的supports方法。
public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
二、JwtUtils是個生成和校驗jwt的工具類,其中有些jwt相關的密鑰信息是從項目配置文件中配置的:
@Component @ConfigurationProperties(prefix = "markerhub.jwt") public class JwtUtils { private String secret; private long expire; private String header; /** * 生成jwt token */ public String generateToken(long userId) { ... } // 獲取jwt的信息 public Claims getClaimByToken(String token) { ... } /** * token是否過時 * @return true:過時 */ public boolean isTokenExpired(Date expiration) { return expiration.before(new Date()); } }
三、而在AccountRealm咱們還用到了AccountProfile,這是爲了登陸成功以後返回的一個用戶信息的載體,
@Data public class AccountProfile implements Serializable { private Long id; private String username; private String avatar; }
第三步,ok,基本的校驗的路線完成以後,咱們須要少許的基本信息配置:
shiro-redis: enabled: true redis-manager: host: 127.0.0.1:6379 markerhub: jwt: # 加密祕鑰 secret: f4e2e52034348f86b67cde581c0f9eb5 # token有效時長,7天,單位秒 expire: 604800 header: token
第四步:另外,若是你項目有使用spring-boot-devtools,須要添加一個配置文件,在resources目錄下新建文件夾META-INF,而後新建文件spring-devtools.properties,這樣熱重啓時候纔不會報錯。
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
第五步:定義jwt的過濾器JwtFilter。
這個過濾器是咱們的重點,這裏咱們繼承的是Shiro內置的AuthenticatingFilter,一個能夠內置了能夠自動登陸方法的的過濾器,有些同窗繼承BasicHttpAuthenticationFilter也是能夠的。
咱們須要重寫幾個方法:
下面咱們看看整體的代碼:
@Component public class JwtFilter extends AuthenticatingFilter { @Autowired JwtUtils jwtUtils; @Override protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { // 獲取 token HttpServletRequest request = (HttpServletRequest) servletRequest; String jwt = request.getHeader("Authorization"); if(StringUtils.isEmpty(jwt)){ return null; } return new JwtToken(jwt); } @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { HttpServletRequest request = (HttpServletRequest) servletRequest; String token = request.getHeader("Authorization"); if(StringUtils.isEmpty(token)) { return true; } else { // 判斷是否已過時 Claims claim = jwtUtils.getClaimByToken(token); if(claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) { throw new ExpiredCredentialsException("token已失效,請從新登陸!"); } } // 執行自動登陸 return executeLogin(servletRequest, servletResponse); } @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpResponse = (HttpServletResponse) response; try { //處理登陸失敗的異常 Throwable throwable = e.getCause() == null ? e : e.getCause(); Result r = Result.fail(throwable.getMessage()); String json = JSONUtil.toJsonStr(r); httpResponse.getWriter().print(json); } catch (IOException e1) { } return false; } /** * 對跨域提供支持 */ @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = WebUtils.toHttp(request); HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); // 跨域時會首先發送一個OPTIONS請求,這裏咱們給OPTIONS請求直接返回正常狀態 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } }
那麼到這裏,咱們的shiro就已經完成整合進來了,而且使用了jwt進行身份校驗。
有時候不可避免服務器報錯的狀況,若是不配置異常處理機制,就會默認返回tomcat或者nginx的5XX頁面,對普通用戶來講,不太友好,用戶也不懂什麼狀況。這時候須要咱們程序員設計返回一個友好簡單的格式給前端。
處理辦法以下:經過使用@ControllerAdvice來進行統一異常處理,@ExceptionHandler(value = RuntimeException.class)來指定捕獲的Exception各個類型異常 ,這個異常的處理,是全局的,全部相似的異常,都會跑到這個地方處理。
步驟2、定義全局異常處理,@ControllerAdvice表示定義全局控制器異常處理,@ExceptionHandler表示針對性異常處理,可對每種異常針對性處理。
/** * 全局異常處理 */ @Slf4j @RestControllerAdvice public class GlobalExcepitonHandler { // 捕捉shiro的異常 @ResponseStatus(HttpStatus.UNAUTHORIZED) @ExceptionHandler(ShiroException.class) public Result handle401(ShiroException e) { return Result.fail(401, e.getMessage(), null); } /** * 處理Assert的異常 */ @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(value = IllegalArgumentException.class) public Result handler(IllegalArgumentException e) throws IOException { log.error("Assert異常:-------------->{}",e.getMessage()); return Result.fail(e.getMessage()); } /** * @Validated 校驗錯誤異常處理 */ @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(value = MethodArgumentNotValidException.class) public Result handler(MethodArgumentNotValidException e) throws IOException { log.error("運行時異常:-------------->",e); BindingResult bindingResult = e.getBindingResult(); ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get(); return Result.fail(objectError.getDefaultMessage()); } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(value = RuntimeException.class) public Result handler(RuntimeException e) throws IOException { log.error("運行時異常:-------------->",e); return Result.fail(e.getMessage()); } }
上面咱們捕捉了幾個異常:
當咱們表單數據提交的時候,前端的校驗咱們可使用一些相似於jQuery Validate等js插件實現,然後端咱們可使用Hibernate validatior來作校驗。
咱們使用springboot框架做爲基礎,那麼就已經自動集成了Hibernate validatior。
那麼用起來啥樣子的呢?
第一步:首先在實體的屬性上添加對應的校驗規則,好比:
@TableName("m_user") public class User implements Serializable { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Long id; @NotBlank(message = "暱稱不能爲空") private String username; @NotBlank(message = "郵箱不能爲空") @Email(message = "郵箱格式不正確") private String email; ... }
第二步 :這裏咱們使用@Validated註解方式,若是實體不符合要求,系統會拋出異常,那麼咱們的異常處理中就捕獲到MethodArgumentNotValidException。
/** * 測試實體校驗 * @param user * @return */ @PostMapping("/save") public Object testUser(@Validated @RequestBody User user) { return user.toString(); }
由於是先後端分析,因此跨域問題是避免不了的,咱們直接在後臺進行全局跨域處理:
/** * 解決跨域問題 */ @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600) .allowedHeaders("*"); } }
ok,由於咱們系統開發的接口比較簡單,因此我就不集成swagger2啦,也比較簡單而已。下面咱們就直接進入咱們的正題,進行編寫登陸接口。
登陸的邏輯其實很簡答,只須要接受帳號密碼,而後把用戶的id生成jwt,返回給前段,爲了後續的jwt的延期,因此咱們把jwt放在header上。具體代碼以下:
@RestController public class AccountController { @Autowired JwtUtils jwtUtils; @Autowired UserService userService; /** * 默認帳號密碼:markerhub / 111111 * */ @CrossOrigin @PostMapping("/login") public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) { User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername())); Assert.notNull(user, "用戶不存在"); if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) { return Result.fail("密碼錯誤!"); } String jwt = jwtUtils.generateToken(user.getId()); response.setHeader("Authorization", jwt); response.setHeader("Access-Control-Expose-Headers", "Authorization"); // 用戶能夠另外一個接口 return Result.succ(MapUtil.builder() .put("id", user.getId()) .put("username", user.getUsername()) .put("avatar", user.getAvatar()) .put("email", user.getEmail()) .map() ); } // 退出 @GetMapping("/logout") @RequiresAuthentication public Result logout() { SecurityUtils.getSubject().logout(); return Result.succ(null); } }
接口測試:
咱們的骨架已經完成,接下來,咱們就能夠添加咱們的業務接口了,下面我以一個簡單的博客列表、博客詳情頁爲例子開發:
@RestController public class BlogController { @Autowired BlogService blogService; @GetMapping("/blogs") public Result blogs(Integer currentPage) { if(currentPage == null || currentPage < 1) currentPage = 1; Page page = new Page(currentPage, 5) IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created")); return Result.succ(pageData); } @GetMapping("/blog/{id}") public Result detail(@PathVariable(name = "id") Long id) { Blog blog = blogService.getById(id); Assert.notNull(blog, "該博客已刪除!"); return Result.succ(blog); } @RequiresAuthentication @PostMapping("/blog/edit") public Result edit(@Validated @RequestBody Blog blog) { System.out.println(blog.toString()); Blog temp = null; if(blog.getId() != null) { temp = blogService.getById(blog.getId()); Assert.isTrue(temp.getUserId() == ShiroUtil.getProfile().getId(), "沒有權限編輯"); } else { temp = new Blog(); temp.setUserId(ShiroUtil.getProfile().getId()); temp.setCreated(LocalDateTime.now()); temp.setStatus(0); } BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "status"); blogService.saveOrUpdate(temp); return Result.succ("操做成功", null); } }
注意@RequiresAuthentication說明須要登陸以後才能訪問的接口,其餘須要權限的接口能夠添加shiro的相關注解。
接口比較簡單,咱們就很少說了,基本增刪改查而已。注意的是edit方法是須要登陸才能操做的受限資源。
接口測試:
好了,一篇文章搞定一個基本骨架,好像有點趕,可是基本的東西這裏已經有了。後面咱們就要去開發咱們的前端接口了。
項目代碼:https://github.com/MarkerHub/...
項目視頻:https://www.bilibili.com/vide...
接下來,咱們來完成vueblog前端的部分功能。可能會使用的到技術以下:
本項目實踐須要一點點vue的基礎,但願你對vue的一些指令有所瞭解,這樣咱們講解起來就簡單多了哈。
咱們先來看下咱們須要完成的項目長什麼樣子,考慮到不少同窗的樣式的掌握程度不夠,因此我儘可能使用了element-ui的原生組件的樣式來完成整個博客的界面。很少說,直接上圖:
在線體驗:https://markerhub.com:8083
萬丈高樓平地起,咱們下面一步一步來完成,首先咱們安裝vue的環境,我實踐的環境是windows 10哈。
一、首先咱們上node.js官網(https://nodejs.org/zh-cn/),下載最新的長期版本,直接運行安裝完成以後,咱們就已經具有了node和npm的環境啦。
安裝完成以後檢查下版本信息:
二、接下來,咱們安裝vue的環境
# 安裝淘寶npm npm install -g cnpm --registry=https://registry.npm.taobao.org # vue-cli 安裝依賴包 cnpm install --g vue-cli
# 打開vue的可視化管理工具界面 vue ui
上面咱們分別安裝了淘寶npm,cnpm是爲了提升咱們安裝依賴的速度。vue ui是@vue/cli3.0增長一個可視化項目管理工具,能夠運行項目、打包項目,檢查等操做。對於初學者來講,能夠少記一些命令,哈哈。
三、建立vueblog-vue項目
運行vue ui以後,會爲咱們打開一個http://localhost:8080 的頁面:
而後切換到【建立】,注意建立的目錄最好是和你運行vue ui同一級。這樣方便管理和切換。而後點擊按鈕【在此建立新羨慕】
下一步中,項目文件夾中輸入項目名稱「vueblog-vue」,其餘不用改,點擊下一步,選擇【手動】,再點擊下一步,如圖點擊按鈕,勾選上路由Router、狀態管理Vuex,去掉js的校驗。
下一步中,也選上【Use history mode for router】,點擊建立項目,而後彈窗中選擇按鈕【建立項目,不保存預設】,就進入項目建立啦。
稍等片刻以後,項目就初始化完成了。上面的步驟中,咱們建立了一個vue項目,而且安裝了Router、Vuex。這樣咱們後面就能夠直接使用。
咱們來看下整個vueblog-vue的項目結構
├── README.md 項目介紹 ├── index.html 入口頁面 ├── build 構建腳本目錄 │ ├── build-server.js 運行本地構建服務器,能夠訪問構建後的頁面 │ ├── build.js 生產環境構建腳本 │ ├── dev-client.js 開發服務器熱重載腳本,主要用來實現開發階段的頁面自動刷新 │ ├── dev-server.js 運行本地開發服務器 │ ├── utils.js 構建相關工具方法 │ ├── webpack.base.conf.js wabpack基礎配置 │ ├── webpack.dev.conf.js wabpack開發環境配置 │ └── webpack.prod.conf.js wabpack生產環境配置 ├── config 項目配置 │ ├── dev.env.js 開發環境變量 │ ├── index.js 項目配置文件 │ ├── prod.env.js 生產環境變量 │ └── test.env.js 測試環境變量 ├── mock mock數據目錄 │ └── hello.js ├── package.json npm包配置文件,裏面定義了項目的npm腳本,依賴包等信息 ├── src 源碼目錄 │ ├── main.js 入口js文件 │ ├── app.vue 根組件 │ ├── components 公共組件目錄 │ │ └── title.vue │ ├── assets 資源目錄,這裏的資源會被wabpack構建 │ │ └── images │ │ └── logo.png │ ├── routes 前端路由 │ │ └── index.js │ ├── store 應用級數據(state)狀態管理 │ │ └── index.js │ └── views 頁面目錄 │ ├── hello.vue │ └── notfound.vue ├── static 純靜態資源,不會被wabpack構建。 └── test 測試文件目錄(unit&e2e) └── unit 單元測試 ├── index.js 入口腳本 ├── karma.conf.js karma配置文件 └── specs 單測case目錄 └── Hello.spec.js
接下來咱們引入element-ui組件(https://element.eleme.cn),這樣咱們就能夠得到好看的vue組件,開發好看的博客界面。
命令很簡單:
# 切換到項目根目錄 cd vueblog-vue # 安裝element-ui cnpm install element-ui --save
而後咱們打開項目src目錄下的main.js,引入element-ui依賴。
import Element from 'element-ui' import "element-ui/lib/theme-chalk/index.css" Vue.use(Element)
這樣咱們就能夠愉快得在官網上選擇組件複製代碼到咱們項目中直接使用啦。
接下來,咱們來安裝axios(http://www.axios-js.com/),axios是一個基於 promise 的 HTTP 庫,這樣咱們進行先後端對接的時候,使用這個工具能夠提升咱們的開發效率。
安裝命令:
cnpm install axios --save
而後一樣咱們在main.js中全局引入axios。
import axios from 'axios' Vue.prototype.$axios = axios //
組件中,咱們就能夠經過this.$axios.get()來發起咱們的請求了哈。
接下來,咱們先定義好路由和頁面,由於咱們只是作一個簡單的博客項目,頁面比較少,因此咱們能夠直接先定義好,而後在慢慢開發,這樣須要用到連接的地方咱們就能夠直接可使用:
咱們在views文件夾下定義幾個頁面:
而後再路由中心配置:
import Vue from 'vue' import VueRouter from 'vue-router' import Login from '../views/Login.vue' import BlogDetail from '../views/BlogDetail.vue' import BlogEdit from '../views/BlogEdit.vue' Vue.use(VueRouter) const routes = [ { path: '/', name: 'Index', redirect: { name: 'Blogs' } }, { path: '/login', name: 'Login', component: Login }, { path: '/blogs', name: 'Blogs', // 懶加載 component: () => import('../views/Blogs.vue') }, { path: '/blog/add', // 注意放在 path: '/blog/:blogId'以前 name: 'BlogAdd', meta: { requireAuth: true }, component: BlogEdit }, { path: '/blog/:blogId', name: 'BlogDetail', component: BlogDetail }, { path: '/blog/:blogId/edit', name: 'BlogEdit', meta: { requireAuth: true }, component: BlogEdit } ]; const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
接下來咱們去開發咱們的頁面。其中,帶有meta:requireAuth: true說明是須要登陸字後才能訪問的受限資源,後面咱們路由權限攔截時候會用到。
接下來,咱們來搞一個登錄頁面,表單組件咱們直接在element-ui的官網上找就好了,登錄頁面就兩個輸入框和一個提交按鈕,相對簡單,而後咱們最好帶頁面的js校驗。emmm,我直接貼代碼了~~
<template> <div> <el-container> <el-header> <router-link to="/blogs"> <img src="https://www.markerhub.com/dist/images/logo/markerhub-logo.png" style="height: 60%; margin-top: 10px;"> </router-link> </el-header> <el-main> <el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm"> <el-form-item label="用戶名" prop="username"> <el-input type="text" maxlength="12" v-model="ruleForm.username"></el-input> </el-form-item> <el-form-item label="密碼" prop="password"> <el-input type="password" v-model="ruleForm.password" autocomplete="off"></el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="submitForm('ruleForm')">登陸</el-button> <el-button @click="resetForm('ruleForm')">重置</el-button> </el-form-item> </el-form> </el-main> </el-container> </div> </template> <script> export default { name: 'Login', data() { var validatePass = (rule, value, callback) => { if (value === '') { callback(new Error('請輸入密碼')); } else { callback(); } }; return { ruleForm: { password: '111111', username: 'markerhub' }, rules: { password: [ {validator: validatePass, trigger: 'blur'} ], username: [ {required: true, message: '請輸入用戶名', trigger: 'blur'}, {min: 3, max: 12, message: '長度在 3 到 12 個字符', trigger: 'blur'} ] } }; }, methods: { submitForm(formName) { const _this = this this.$refs[formName].validate((valid) => { if (valid) { // 提交邏輯 this.$axios.post('http://localhost:8081/login', this.ruleForm).then((res)=>{ const token = res.headers['authorization'] _this.$store.commit('SET_TOKEN', token) _this.$store.commit('SET_USERINFO', res.data.data) _this.$router.push("/blogs") }) } else { console.log('error submit!!'); return false; } }); }, resetForm(formName) { this.$refs[formName].resetFields(); } }, mounted() { this.$notify({ title: '看這裏:', message: '關注公衆號:MarkerHub,回覆【vueblog】,領取項目資料與源碼', duration: 1500 }); } } </script>
找不到啥好的方式講解了,以後先貼代碼,而後再講解。
上面代碼中,其實主要作了兩件事情
一、表單校驗
二、登陸按鈕的點擊登陸事件
表單校驗規則還好,比較固定寫法,查一下element-ui的組件就知道了,咱們來分析一下發起登陸以後的代碼:
const token = res.headers['authorization'] _this.$store.commit('SET_TOKEN', token) _this.$store.commit('SET_USERINFO', res.data.data) _this.$router.push("/blogs")
從返回的結果請求頭中獲取到token的信息,而後使用store提交token和用戶信息的狀態。完成操做以後,咱們調整到了/blogs路由,即博客列表頁面。
因此在store/index.js中,代碼是這樣的:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { token: '', userInfo: JSON.parse(sessionStorage.getItem("userInfo")) }, mutations: { SET_TOKEN: (state, token) => { state.token = token localStorage.setItem("token", token) }, SET_USERINFO: (state, userInfo) => { state.userInfo = userInfo sessionStorage.setItem("userInfo", JSON.stringify(userInfo)) }, REMOVE_INFO: (state) => { localStorage.setItem("token", '') sessionStorage.setItem("userInfo", JSON.stringify('')) state.userInfo = {} } }, getters: { getUser: state => { return state.userInfo } }, actions: {}, modules: {} })
存儲token,咱們用的是localStorage,存儲用戶信息,咱們用的是sessionStorage。畢竟用戶信息咱們不須要長久保存,保存了token信息,咱們隨時均可以初始化用戶信息。固然了由於本項目是個比較簡單的項目,考慮到初學者,因此不少相對複雜的封裝和功能我沒有作,固然了,學了這個項目以後,本身想再繼續深刻,完成能夠自行學習和改造哈。
點擊登陸按鈕發起登陸請求,成功時候返回了數據,若是是密碼錯誤,咱們是否是也應該彈窗消息提示。爲了讓這個錯誤彈窗能運用到全部的地方,因此我對axios作了個後置攔截器,就是返回數據時候,若是結果的code或者status不正常,那麼我對應彈窗提示。
在src目錄下建立一個文件axios.js(與main.js同級),定義axios的攔截:
import axios from 'axios' import Element from "element-ui"; import store from "./store"; import router from "./router"; axios.defaults.baseURL='http://localhost:8081' axios.interceptors.request.use(config => { console.log("前置攔截") // 能夠統一設置請求頭 return config }) axios.interceptors.response.use(response => { const res = response.data; console.log("後置攔截") // 當結果的code是否爲200的狀況 if (res.code === 200) { return response } else { // 彈窗異常信息 Element.Message({ message: response.data.msg, type: 'error', duration: 2 * 1000 }) // 直接拒絕往下面返回結果信息 return Promise.reject(response.data.msg) } }, error => { console.log('err' + error)// for debug if(error.response.data) { error.message = error.response.data.msg } // 根據請求狀態以爲是否登陸或者提示其餘 if (error.response.status === 401) { store.commit('REMOVE_INFO'); router.push({ path: '/login' }); error.message = '請從新登陸'; } if (error.response.status === 403) { error.message = '權限不足,沒法訪問'; } Element.Message({ message: error.message, type: 'error', duration: 3 * 1000 }) return Promise.reject(error) })
前置攔截,其實能夠統一爲全部須要權限的請求裝配上header的token信息,這樣不須要在使用是再配置,個人小項目比較小,因此,仍是免了吧~
而後再main.js中導入axios.js
import './axios.js' // 請求攔截
後端由於返回的實體是Result,succ時候code爲200,fail時候返回的是400,因此能夠根據這裏判斷結果是不是正常的。另外權限不足時候能夠經過請求結果的狀態碼來判斷結果是否正常。這裏都作了簡單的處理。
登陸異常時候的效果以下:
登陸完成以後直接進入博客列表頁面,而後加載博客列表的數據渲染出來。同時頁面頭部咱們須要把用戶的信息展現出來,由於不少地方都用到這個模塊,因此咱們把頁面頭部的用戶信息單獨抽取出來做爲一個組件。
那麼,咱們先來完成頭部的用戶信息,應該包含三部分信息:id,頭像、用戶名,而這些信息咱們是在登陸以後就已經存在了sessionStorage。所以,咱們能夠經過store的getters獲取到用戶信息。
看起來不是很複雜,咱們貼出代碼:
<template> <div class="m-content"> <h3>歡迎來到MarkerHub的博客</h3> <div class="block"> <el-avatar :size="50" :src="user.avatar"></el-avatar> <div>{{ user.username }}</div> </div> <div class="maction"> <el-link href="/blogs">主頁</el-link> <el-divider direction="vertical"></el-divider> <span> <el-link type="success" href="/blog/add" :disabled="!hasLogin">發表文章</el-link> </span> <el-divider direction="vertical"></el-divider> <span v-show="!hasLogin"> <el-link type="primary" href="/login">登錄</el-link> </span> <span v-show="hasLogin"> <el-link type="danger" @click="logout">退出</el-link> </span> </div> </div> </template> <script> export default { name: "Header", data() { return { hasLogin: false, user: { username: '請先登陸', avatar: "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" }, blogs: {}, currentPage: 1, total: 0 } }, methods: { logout() { const _this = this this.$axios.get('http://localhost:8081/logout', { headers: { "Authorization": localStorage.getItem("token") } }).then((res) => { _this.$store.commit('REMOVE_INFO') _this.$router.push('/login') }); } }, created() { if(this.$store.getters.getUser.username) { this.user.username = this.$store.getters.getUser.username this.user.avatar = this.$store.getters.getUser.avatar this.hasLogin = true } } } </script>
上面代碼created()中初始化用戶的信息,經過hasLogin的狀態來控制登陸和退出按鈕的切換,以及發表文章連接的disabled,這樣用戶的信息就能展現出來了。
而後這裏有個退出按鈕,在methods中有個logout()方法,邏輯比較簡單,直接訪問/logout,由於以前axios.js中咱們已經設置axios請求的baseURL,因此這裏咱們再也不須要連接的前綴了哈。由於是登陸以後才能訪問的受限資源,因此在header中帶上了Authorization。返回結果清楚store中的用戶信息和token信息,跳轉到登陸頁面。
而後須要頭部用戶信息的頁面只須要幾個步驟:
import Header from "@/components/Header"; data() { components: {Header} } # 而後模板中調用組件 <Header></Header>
接下來就是列表頁面,須要作分頁,列表咱們在element-ui中直接使用時間線組件來做爲咱們的列表樣式,仍是挺好看的。還有咱們的分頁組件。
須要幾部分信息:
<template> <div class="m-container"> <Header></Header> <div class="block"> <el-timeline> <el-timeline-item v-bind:timestamp="blog.created" placement="top" v-for="blog in blogs"> <el-card> <h4><router-link :to="{name: 'BlogDetail', params: {blogId: blog.id}}">{{blog.title}}</router-link></h4> <p>{{blog.description}}</p> </el-card> </el-timeline-item> </el-timeline> </div> <el-pagination class="mpage" background layout="prev, pager, next" :current-page=currentPage :page-size=pageSize @current-change=page :total="total"> </el-pagination> </div> </template> <script> import Header from "@/components/Header"; export default { name: "Blogs", components: {Header}, data() { return { blogs: {}, currentPage: 1, total: 0, pageSize: 5 } }, methods: { page(currentPage) { const _this = this this.$axios.get('http://localhost:8081/blogs?currentPage=' + currentPage).then((res) => { console.log(res.data.data.records) _this.blogs = res.data.data.records _this.currentPage = res.data.data.current _this.total = res.data.data.total _this.pageSize = res.data.data.size }) } }, mounted () { this.page(1); } } </script>
data()中直接定義博客列表blogs、以及一些分頁信息。methods()中定義分頁的調用接口page(currentPage),參數是須要調整的頁碼currentPage,獲得結果以後直接賦值便可。而後初始化時候,直接在mounted()方法中調用第一頁this.page(1)。完美。使用element-ui組件就是簡單快捷哈哈!
注意標題這裏咱們添加了連接,使用的是<router-link>標籤。
咱們點擊發表博客連接調整到/blog/add頁面,這裏咱們須要用到一個markdown編輯器,在vue組件中,比較好用的是mavon-editor,那麼咱們直接使用哈。先來安裝mavon-editor相關組件:
基於Vue的markdown編輯器mavon-editor
cnpm install mavon-editor --save
而後在main.js中全局註冊:
// 全局註冊 import Vue from 'vue' import mavonEditor from 'mavon-editor' import 'mavon-editor/dist/css/index.css' // use Vue.use(mavonEditor)
ok,那麼咱們去定義咱們的博客表單:
<template> <div class="m-container"> <Header></Header> <div class="m-content"> <el-form ref="editForm" status-icon :model="editForm" :rules="rules" label-width="80px"> <el-form-item label="標題" prop="title"> <el-input v-model="editForm.title"></el-input> </el-form-item> <el-form-item label="摘要" prop="description"> <el-input type="textarea" v-model="editForm.description"></el-input> </el-form-item> <el-form-item label="內容" prop="content"> <mavon-editor v-model="editForm.content"/> </el-form-item> <el-form-item> <el-button type="primary" @click="submitForm()">當即建立</el-button> <el-button>取消</el-button> </el-form-item> </el-form> </div> </div> </template> <script> import Header from "@/components/Header"; export default { name: "BlogEdit", components: {Header}, data() { return { editForm: { id: null, title: '', description: '', content: '' }, rules: { title: [ {required: true, message: '請輸入標題', trigger: 'blur'}, {min: 3, max: 50, message: '長度在 3 到 50 個字符', trigger: 'blur'} ], description: [ {required: true, message: '請輸入摘要', trigger: 'blur'} ] } } }, created() { const blogId = this.$route.params.blogId const _this = this if(blogId) { this.$axios.get('/blog/' + blogId).then((res) => { const blog = res.data.data _this.editForm.id = blog.id _this.editForm.title = blog.title _this.editForm.description = blog.description _this.editForm.content = blog.content }); } }, methods: { submitForm() { const _this = this this.$refs.editForm.validate((valid) => { if (valid) { this.$axios.post('/blog/edit', this.editForm, { headers: { "Authorization": localStorage.getItem("token") } }).then((res) => { _this.$alert('操做成功', '提示', { confirmButtonText: '肯定', callback: action => { _this.$router.push("/blogs") } }); }); } else { console.log('error submit!!'); return false; } }) } } } </script>
邏輯依然簡單,校驗表單,而後點擊按鈕提交表單,注意頭部加上Authorization信息,返回結果彈窗提示操做成功,而後跳轉到博客列表頁面。emm,和寫ajax沒啥區別。熟悉一下vue的一些指令使用便可。
而後由於編輯和添加是同一個頁面,因此有了create()方法,好比從編輯鏈接/blog/7/edit中獲取blogId爲7的這個id。而後回顯博客信息。獲取方式是const blogId = this.$route.params.blogId。
對了,mavon-editor由於已經全局註冊,因此咱們直接使用組件便可:
<mavon-editor v-model="editForm.content"/>
效果以下:
博客詳情中須要回顯博客信息,而後有個問題就是,後端傳過來的是博客內容是markdown格式的內容,咱們須要進行渲染而後顯示出來,這裏咱們使用一個插件markdown-it,用於解析md文檔,而後導入github-markdown-c,所謂md的樣式。
方法以下:
# 用於解析md文檔 cnpm install markdown-it --save # md樣式 cnpm install github-markdown-css
而後就能夠在須要渲染的地方使用:
<template> <div class="m-container"> <Header></Header> <div class="mblog"> <h2>{{ blog.title }}</h2> <el-link icon="el-icon-edit" v-if="ownBlog"><router-link :to="{name: 'BlogEdit', params: {blogId: blog.id}}">編輯</router-link></el-link> <el-divider></el-divider> <div class="content markdown-body" v-html="blog.content"></div> </div> </div> </template> <script> import 'github-markdown-css/github-markdown.css' // 而後添加樣式markdown-body import Header from "@/components/Header"; export default { name: "BlogDetail", components: { Header }, data() { return { blog: { userId: null, title: "", description: "", content: "" }, ownBlog: false } }, methods: { getBlog() { const blogId = this.$route.params.blogId const _this = this this.$axios.get('/blog/' + blogId).then((res) => { console.log(res) console.log(res.data.data) _this.blog = res.data.data var MarkdownIt = require('markdown-it'), md = new MarkdownIt(); var result = md.render(_this.blog.content); _this.blog.content = result // 判斷是不是本身的文章,可否編輯 _this.ownBlog = (_this.blog.userId === _this.$store.getters.getUser.id) }); } }, created() { this.getBlog() } } </script>
具體邏輯仍是挺簡單,初始化create()方法中調用getBlog()方法,請求博客詳情接口,返回的博客詳情content經過markdown-it工具進行渲染。
再導入樣式:
import 'github-markdown.css'
而後在content的div中添加class爲markdown-body便可哈。
效果以下:
另外標題下添加了個小小的編輯按鈕,經過ownBlog (判斷博文做者與登陸用戶是否同一人)來判斷按鈕是否顯示出來。
頁面已經開發完畢以後,咱們來控制一下哪些頁面是須要登陸以後才能跳轉的,若是未登陸訪問就直接重定向到登陸頁面,所以咱們在src目錄下定義一個js文件:
import router from "./router"; // 路由判斷登陸 根據路由配置文件的參數 router.beforeEach((to, from, next) => { if (to.matched.some(record => record.meta.requireAuth)) { // 判斷該路由是否須要登陸權限 const token = localStorage.getItem("token") console.log("------------" + token) if (token) { // 判斷當前的token是否存在 ; 登陸存入的token if (to.path === '/login') { } else { next() } } else { next({ path: '/login' }) } } else { next() } })
經過以前咱們再定義頁面路由時候的的meta信息,指定requireAuth: true,須要登陸才能訪問,所以這裏咱們在每次路由以前(router.beforeEach)判斷token的狀態,以爲是否須要跳轉到登陸頁面。
{ path: '/blog/add', // 注意放在 path: '/blog/:blogId'以前 name: 'BlogAdd', meta: { requireAuth: true }, component: BlogEdit }
而後咱們再main.js中import咱們的permission.js
import './permission.js' // 路由攔截
ok,基本全部頁面就已經開發完畢啦,css樣式信息我未貼出來,你們直接上github上clone下來查看。
好啦,項目先到這裏,花了3天半錄製了一套對應的視頻,記得去看,給我三連哇。