springboot+vue项目:工具箱

常用账号管理:工作相关账号、游戏账号、各平台账号

加班调休管理:公司没有对应的系统,需要自己记录加班调休情况。

待办事项:方便记录待办,以提醒还有哪些事情没有办理。

 

待实现功能:

1、点击侧边栏菜单,实现PageMain所在位置的内容(页面)更换(已完成)

2、在AccountPage页面实现查询、新增、编辑、删除功能。(已完成)

3、在AccountPage页面实现分页 + 关键字模糊查询功能。(已完成)

4、将数据库中的密码全部加密,前端进行转换显示明文密码。

5、实现用户登录、退出登录功能。

6、实现多表联查功能。

7、实现批量导入,模糊查询结果导出(已完成)

8、发现新问题,同一局域网,A、B两台电脑都可以访问项目,A进行CURD操作时,B的页面数据不会实时更新。(使用WebSocket解决)

 

 

001 || 环境

  • JDK:1.8
  • MySQL:8.0.37
  • Maven:3.9.8
  • SpringBoot:2.7.18
  • MyBatisPlus:3.5.4
  • Vue:2.7.16
  • ElementUI:2.15.14

 

 

002 || 功能介绍

(1)首页:目前只有使用ElementUi制作的轮播图

(2)账号管理:

  • mybatisplus的第三方插件对数据库中account数据的分页
  • 根据名称、账号、备注进行关键字模糊查询结果展示。
  • 每条记录的编辑(遮罩层表单更新)、删除(逻辑删除)
  • 单条记录通过遮罩层表单进行添加
  • 根据csv文件进行数据的批量导入,只需要准备name、username、password、comment四列
  • 可以进行批量导出csv文件,也可以对模糊查询之后的结果集进行导出(csv文件)

(3)加班调休

  • 使用vue的timeline(时间线)显示加班调休的历史记录,可通过遮罩层表单进行调休/加班的记录,登记之后,剩余可用调休时长会变更。

(4)加密解密

  • 使用jasypt对输入框中的内容进行加密/解密,点击赋值,可将下方结果复制到剪贴板。点击清空,可以同时清空输入框和结果的内容。

(5)日期时间

  • 显示日历、当前系统时间、距离下班/下次上班时间。

 

 

 

 

003 || 配置

 

Maven依赖

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.harley</groupId>
    <artifactId>AccountManager</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- Database -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.15</version>
        </dependency>
        <!-- mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.4</version>
        </dependency>
        <!-- Development Tools -->
        <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>

        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Security and Encryption -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>
        <!-- 代码生成器 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.7</version>
        </dependency>
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
        </dependency>
        <!-- 加密解密 -->
        <dependency>
            <groupId>com.github.ulisesbocchio</groupId>
            <artifactId>jasypt-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        <!-- csv导入导出 -->
        <dependency>
            <groupId>net.sf.supercsv</groupId>
            <artifactId>super-csv</artifactId>
            <version>2.4.0</version> <!-- 确保选择稳定版本 -->
        </dependency>
    </dependencies>

</project>

 

application.yaml

application.yml
 server:
  port: 10086

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/accountmanager?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: '!QAZ2wsx'
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai

#logging:
#  level:
#    root: debug
#    org.springframework: debug
#    com.baomidou.mybatisplus: debug

mybatis-plus:
  mapper-location: classpath:mapper/*.xml
  global-config:
    # 配置逻辑删除
    db-config:
      # 删除为0
      logic-delete-value: 0
      # 存在为1
      logic-not-delete-value: 1


jasypt:
  encryptor:
    password: harley

 

 

004 || 数据库

DDL
 -- 创建数据库
create database accountmanager;

-- 切换数据库
use accountmanager;


-- 账号表
create table account
(
    id int auto_increment comment 'PK' primary key,
    name varchar(300) null comment '名称',
    username varchar(100) not null comment '用户名',
    password varchar(200) not null comment '密码',
    comment text  null comment '备注',
    deleted int default 1 null comment '逻辑删除标志(0:删除,1:存在)',
    update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
    create_time datetime default CURRENT_TIMESTAMP null comment '创建时间'
)
comment '账号信息表' collate = utf8mb4_bin;


-- 加班调休表
create table `overtime_and_leave` (
  `id` int not null auto_increment,
  `user_id` int not null, -- 用户id
  `type` enum('overtime', 'leave') not null, -- 类型:加班或调休
  `date` date not null, -- 日期
  `duration` decimal(5,2) not null, -- 时长(小时)
  `created_at` timestamp default current_timestamp, -- 创建时间
  primary key (`id`)
);

 

 

005 || 代码生成器

直接启动main方法,根据提示输入数据库链接、用户名、密码、作者、包名、模块名、表名......可自动生成entity、mapper、mapper.xml、service、serviceImpl、controller代码

com.harley.common.CodeGenerator
package com.harley.common;

import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import com.baomidou.mybatisplus.generator.config.OutputFile;

import java.util.Collections;
import java.util.Scanner;

/**
 * @author harley
 * @since 2024-12-09 22:00:02
 */
public class CodeGenerator {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        // 获取数据库连接信息
        System.out.println("请输入数据库URL:");
        String dbUrl = scanner.nextLine();
        System.out.println("请输入数据库用户名:");
        String dbUsername = scanner.nextLine();
        System.out.println("请输入数据库密码:");
        String dbPassword = scanner.nextLine();

        // 获取其他配置信息
        System.out.println("请输入作者名字:");
        String author = scanner.nextLine();
        System.out.println("请输入父包名(例如:com.yourcompany):");
        String parentPackage = scanner.nextLine();
        System.out.println("请输入模块名(例如:module-name):");
        String moduleName = scanner.nextLine();
        System.out.println("请输入需要生成的表名(多个表以逗号分隔):");
        String tableNames = scanner.nextLine();

        FastAutoGenerator.create(dbUrl, dbUsername, dbPassword)
                .globalConfig(builder -> {
                    builder.author(author) // 设置作者名
                            .outputDir(System.getProperty("user.dir") + "/src/main/java") // 设置输出目录
                    .dateType(DateType.ONLY_DATE);
                })
                .packageConfig(builder -> {
                    builder.parent(parentPackage) // 设置父包名
                            .moduleName(moduleName) // 设置模块名
                            .pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + "/src/main/resources/mapper")); // 设置mapper.xml生成路径
                })
                .strategyConfig(builder -> {
                    builder.addInclude(tableNames.split(",")) // 设置需要生成的表名
                            .entityBuilder() // 实体类生成策略
                            .enableLombok() // 开启 Lombok 模型
                            .logicDeleteColumnName("deleted") // 逻辑删除字段名
                            .controllerBuilder() // 控制器生成策略
                            .enableRestStyle() // REST 风格控制器
                            .enableHyphenStyle() // 使用连字符命名风格
                            .serviceBuilder() // Service 生成策略
                            .formatServiceFileName("%sService") // 格式化 service 文件名
                            .formatServiceImplFileName("%sServiceImpl") // 格式化 serviceImpl 文件名
                            .mapperBuilder() // Mapper 生成策略
                            .enableBaseColumnList() // 启用 BaseColumnList
                            .enableBaseResultMap(); // 启用 BaseResultMap
                })
                .templateEngine(new FreemarkerTemplateEngine()) // 使用 FreeMarker 引擎,默认是 Velocity
                .execute(); // 执行代码生成

        System.out.println("代码生成完成!");
        scanner.close();
    }

}

 

 

006 || 账号管理

分页 + 模糊查询:详见 https://www.cnblogs.com/houhuilinblogs/p/18244117

批量导入、导出

(1)Config

com.harley.config.MybatisPlusConfig
 package com.harley.config;


import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor paginationInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 指定数据库类型
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 如果配置多个插件, 切记分页最后添加
        // 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
        return interceptor;
    }

}

(2)Entity

com.harley.entity.Account
package com.harley.entity;

import com.baomidou.mybatisplus.annotation.*;

import java.io.Serializable;
import java.util.Date;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;

/**
 * <p>
 * 账号信息表
 * </p>
 *
 * @author harley
 * @since 2024-12-10
 */
@Getter
@Setter
public class Account implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * PK
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
     * 名称
     */
    private String name;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    private String comment;
    // 是否删除(0:删除,1:存在)
    @TableLogic
    private Integer deleted;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;

}

(3)Mapper

com.harley.mapper.AccountMapper
package com.harley.mapper;

import com.harley.entity.Account;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * <p>
 * 账号信息表 Mapper 接口
 * </p>
 *
 * @author harley
 * @since 2024-12-10
 */

@Mapper
public interface AccountMapper extends BaseMapper<Account> {

}
src/main/resources/mapper/AccountMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.harley.mapper.AccountMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.harley.entity.Account">
        <id column="id" property="id" />
        <result column="name" property="name" />
        <result column="username" property="username" />
        <result column="password" property="password" />
        <result column="comment" property="comment" />
        <result column="deleted" property="deleted" />
        <result column="update_time" property="updateTime" />
        <result column="create_time" property="createTime" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, name, username, password, comment, deleted, update_time, create_time
    </sql>


</mapper>

(4)Service

com.harley.service.AccountService
 package com.harley.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.harley.entity.Account;
import com.baomidou.mybatisplus.extension.service.IService;


/**
 * <p>
 * 账号信息表 服务类
 * </p>
 *
 * @author harley
 * @since 2024-12-10
 */
public interface AccountService extends IService<Account> {

    IPage<Account> getRecordPage(Integer pageNum,Integer PageSize,String keyword);


}
com.harley.service.CsvImportService
 package com.harley.service;

import com.harley.entity.Account;

import java.io.InputStream;
import java.util.List;

public interface CsvImportService {

    List<Account> parseCsv(InputStream inputStream) throws Exception;

}
com.harley.service.CsvExportService
 package com.harley.service;

import com.harley.entity.Account;

import javax.servlet.http.HttpServletResponse;
import java.util.List;

public interface CsvExportService {
    void exportUsersToCsv(HttpServletResponse response, List<Account> accounts) throws Exception;
}

(5)ServiceImpl

com.harley.service.impl.AccountServiceImpl
 package com.harley.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.harley.entity.Account;
import com.harley.mapper.AccountMapper;
import com.harley.service.AccountService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


/**
 * <p>
 * 账号信息表 服务实现类
 * </p>
 *
 * @author harley
 * @since 2024-12-10
 */
@Service
public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> implements AccountService {

    @Autowired
    private AccountMapper accountMapper;


    @Override
    public IPage<Account> getRecordPage(Integer pageNum, Integer pageSize, String keyword) {

        Page<Account> page = new Page<>(pageNum, pageSize);
        LambdaQueryWrapper<Account> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.like(StringUtils.isNotBlank(keyword),Account::getName,keyword)
                        .or()
                .like(StringUtils.isNotBlank(keyword),Account::getUsername,keyword)
                .or()
                .like(StringUtils.isNotBlank(keyword),Account::getComment,keyword)
                .orderByDesc(Account::getUpdateTime);

        return accountMapper.selectPage(page,lambdaQueryWrapper);
    }
}

 

批量导入csv

com.harley.service.impl.CsvImportServiceImpl
 package com.harley.service.impl;

import com.harley.entity.Account;
import com.harley.service.CsvImportService;
import org.springframework.stereotype.Service;
import org.supercsv.cellprocessor.constraint.NotNull;
import org.supercsv.cellprocessor.ift.CellProcessor;
import org.supercsv.io.CsvBeanReader;
import org.supercsv.prefs.CsvPreference;

import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;


@Service
public class CsvImportServiceImpl implements CsvImportService {


    @Override
    public List<Account> parseCsv(InputStream inputStream) throws Exception {
        ArrayList<Account> accounts = new ArrayList<>();
        CellProcessor[] processors = getProcessors();

        try (CsvBeanReader beanReader = new CsvBeanReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8), CsvPreference.STANDARD_PREFERENCE)){

            String[] header = beanReader.getHeader(true);// 忽略第一行标题
            Account account;
            while((account = beanReader.read(Account.class,header,processors))!=null){
                accounts.add(account);
            }
        }
        return accounts;
    }


    private CellProcessor[] getProcessors() {
        return new CellProcessor[]{
                new NotNull(), // 名称
                new NotNull(), // 账号
                new NotNull(), // 密码
                new NotNull() // 备注
        };
    }
}

批量导出csv

com.harley.service.impl.CsvExportServiceImpl
 package com.harley.service.impl;

import com.harley.entity.Account;
import com.harley.service.CsvExportService;
import org.springframework.stereotype.Service;
import org.supercsv.io.CsvBeanWriter;
import org.supercsv.prefs.CsvPreference;

import javax.servlet.http.HttpServletResponse;
import java.util.List;


@Service
public class CsvExportServiceImpl implements CsvExportService {


    @Override
    public void exportUsersToCsv(HttpServletResponse response, List<Account> accounts) throws Exception {
        response.setContentType("text/csv; charset=UTF-8");
        response.setHeader("Content-Disposition","attachment;filename=accounts.csv");

        try (CsvBeanWriter beanWriter = new CsvBeanWriter(response.getWriter(),
                CsvPreference.STANDARD_PREFERENCE)){

            String[] header = new String[]{"name","username", "password", "comment"};

            for (Account account : accounts){
                beanWriter.write(account,header);
            }
        }
    }
}

(6)Controller

com.harley.controller.AccountController
package com.harley.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.harley.entity.Account;
import com.harley.service.AccountService;
import com.harley.service.CsvExportService;
import com.harley.service.CsvImportService;
import com.harley.utils.JasyptUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.util.List;

/**
 * <p>
 * 账号信息表 前端控制器
 * </p>
 *
 * @author harley
 * @since 2024-12-10
 */
@RestController
@RequestMapping("/account")
public class AccountController {

    @Autowired
    private AccountService accountService;
    @Autowired
    private JasyptUtils jasyptUtils;

    @Autowired
    private CsvImportService csvImportService;

    @Autowired
    private CsvExportService csvExportService;

    @GetMapping("/getRecordPage")
    public IPage<Account> getRecordPage(@RequestParam(defaultValue = "0") Integer pageNum,
                                        @RequestParam(defaultValue = "5") Integer pageSize,
                                        @RequestParam(value = "keyword", defaultValue = "", required = false) String keyword){

        return accountService.getRecordPage(pageNum,pageSize,keyword);
    }

    @PostMapping("/addAccount")
    public boolean addAccount(@RequestBody Account account){
        return accountService.save(account);
    }

    @GetMapping("/del")
    public boolean delAccount(@RequestParam Integer id){
        return accountService.removeById(id);
    }

    @PutMapping("/updateAccount/{id}")
    public String updateAccount(@PathVariable Integer id, @RequestBody Account account){
        account.setId(id);
        return accountService.updateById(account) ? "更新成功" : "更新失败";
    }


    @PostMapping("/import")
    public ResponseEntity<String> importCsv(@RequestParam("file") MultipartFile file){
        try {
            List<Account> accounts = csvImportService.parseCsv(file.getInputStream());
            accountService.saveBatch(accounts);
            return ResponseEntity.ok("Import successful");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Import failed: " + e.getMessage());
        }
    }


    @GetMapping("/export")
    public void exportCsv(@RequestParam(value = "keyword",defaultValue = "", required = false) String keyword,HttpServletResponse response) throws Exception {
        System.out.println(keyword);
        LambdaQueryWrapper<Account> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(StringUtils.isNotBlank(keyword),Account::getName,keyword)
                .or()
                .like(StringUtils.isNotBlank(keyword),Account::getUsername,keyword)
                .or()
                .like(StringUtils.isNotBlank(keyword),Account::getComment,keyword)
                .orderByDesc(Account::getUpdateTime);
        List<Account> accounts = accountService.list(queryWrapper);
        // 只会将该表所有数据都导出来的
        csvExportService.exportUsersToCsv(response, accounts);
    }

}

(7)AccountPage.vue

src/components/views/AccountPage.vue
<template>
  <div>
    <div class="page-main">
      <!-- 搜索框和按钮 -->
      <div class="search-bar">
        <el-input placeholder="请输入搜索内容" v-model="query.keyword" style="width:500px;" @keydown.enter.native.prevent="fetchAccounts" clearable>
          <template #append>
            <el-button type="primary" icon="el-icon-search" @click="fetchAccounts"></el-button>
          </template>
        </el-input>
        <el-tooltip content="新增" placement="top" style="margin-left: 30px;">
          <el-button size="mini" type="success" @click="showDialog('add',row=null)" circle><i class="el-icon-plus"></i></el-button>
        </el-tooltip>
        <el-tooltip content="批量导入" placement="top">
          <el-upload action="/api/account/import" :on-success="handleSuccess" :show-file-list="false">
            <el-button size="mini" type="primary" circle><i class="el-icon-upload"></i></el-button>
          </el-upload>
        </el-tooltip>
        <el-tooltip content="导出" placement="top">
          <el-button @click="handleExport" size="mini" type="primary" circle><i class="el-icon-download"></i></el-button>
        </el-tooltip>
      </div>
    </div>
    <el-table :data="accounts" style="width: 100%">
      <el-table-column prop="id" label="序号" v-if="false" width="auto"></el-table-column>
      <el-table-column prop="name" label="名称" width="400">
        <template slot-scope="scope">{{ scope.row.name || 'N/A'}}</template> // use || to handle null value
      </el-table-column>
      <el-table-column prop="username" label="账号" width="400"></el-table-column>
      <el-table-column prop="password" label="密码">
        <template slot-scope="scope">
          <div class="password-cell">
            <span :class="{ 'masked-password': !scope.row.showPassword }">{{ getPassword(scope.row) }}</span>
            <el-button type="text" @click="togglePasswordVisibility(scope.$index)">
              <i :class="[scope.row.showPassword? 'el-icon-view' : 'el-icon-lock']"></i>
            </el-button>
          </div>
        </template>
      </el-table-column>
      <el-table-column prop="comment" label="备注">
        <template slot-scope="scope">{{ scope.row.comment || 'N/A'}}</template>
      </el-table-column>
      <el-table-column prop="createTime" label="创建时间"></el-table-column>
      <el-table-column prop="updateTime" label="更新时间"></el-table-column>
      <el-table-column label="操作" width="200">
        <template slot-scope="scope">
          <el-tooltip content="编辑" placement="top">
            <el-button size="mini" type="primary" @click="showDialog('edit',scope.row)" icon="el-icon-edit" circle></el-button>
          </el-tooltip>
          <el-tooltip content="删除" placement="top">
            <el-button size="mini" type="danger" @click="deleteAccount(scope.row.id)" icon="el-icon-delete" circle></el-button>
          </el-tooltip>
        </template>
      </el-table-column>
    </el-table>
    <el-pagination
      style="text-align: center"
      :page-sizes="[5,10]"
      :current-page="query.pageNum"
      :page-size="query.pageSize"
      :total="query.total"
      layout="total, sizes, prev, pager, next, jumper"
      @current-change="handlePageNumChange"
      @size-change="handlePageSizeChange"
    >
  </el-pagination>
    <!-- 新增/编辑对话框 -->
    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="30%">
      <el-form :model="form" :rules="rules" ref="form" label-width="100px">
        <el-form-item label="编号" prop="id">
          <el-input v-model="form.id" disabled></el-input>
        </el-form-item>
        <el-form-item label="名称" prop="name">
          <el-input v-model="form.name"></el-input>
        </el-form-item>
        <el-form-item label="账号" prop="username">
          <el-input v-model="form.username"></el-input>
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input v-model="form.password"></el-input>
        </el-form-item>
        <el-form-item  label="备注" prop="comment">
          <el-input v-model="form.comment"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button> <!-- 点击取消按钮,遮罩层隐藏 -->
        <el-button type="primary" @click="handleSubmit">确 定</el-button>
      </span>
    </el-dialog>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  name: 'AccountPage',
  components: {
  },
  data() {
    return {
      accounts: [],
      editVDialogVisible: false,
      selectedAccount: null,
      dialogVisible: false, // 控制对话框的显示与隐藏,默认为隐藏
      dialogTitle: '', // 对话框标题
      form: {
        id: null,
        name: '',
        username: '',
        password: '',
        comment: '',
        createTime: '',
        updateTime: '',
      },// 表单数据
      rules: {
        name: [{ required: false, message: '请输入名称', trigger: 'blur' }],
        username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
        password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
        comment: [{ required: false, message: '请输入备注', trigger: 'blur' }],
      }, // 表单验证规则
      currentRow: null, // 当前编辑的行数据
      query: {
        pageNum: 0,
        pageSize: 10,
        total: 0,
        keyword: '', // 搜索关键字
      }, // 分页信息
    };
  },
  created() {
    this.fetchAccounts();
  },
  methods: {
    async fetchAccounts() {
      try {
        const response = await axios.get('/api/account/getRecordPage', {
          params: {
            pageNum: this.query.pageNum,
            pageSize: this.query.pageSize,
            keyword: this.query.keyword,
          }
        }); // 如果设置了代理,这里不需要完整的URL
        this.accounts = response.data.records.map(record => ({
          id: record.id,
          name: record.name,
          username: record.username,
          password: record.password,
          comment: record.comment,
          createTime: record.createTime,
          updateTime: record.updateTime,
          showPassword: false
        }));
        this.query.total = response.data.total;
        this.query.pageNum = response.data.current;
        this.query.pageSize = response.data.size;
      } catch (error) {
        console.error('There was an error fetching the accounts!', error);
      }
    },
    getPassword(row){
      return row.showPassword? row.password : '•'.repeat(8);
    },
    togglePasswordVisibility(index) {
      this.$set(this.accounts[index],'showPassword',!this.accounts[index].showPassword);
    },
    showDialog(type, row = null) {
      this.dialogVisible = true;
      this.dialogTitle = type === 'add' ? '新增记录' : '编辑记录';
      this.currentRow = row;

      if (type === 'add') {
        this.$refs.form && this.$refs.form.resetFields();
      } else if (row) {
        this.form = { ...row }; // 使用展开运算符复制对象以避免引用问题
      }
    },
    async deleteAccount(id){
      try{
        const response = await axios.get(`/api/account/del?id=${id}`);
        console.log('已删除: ' + response.data)
        await this.fetchAccounts();
      }catch(error){
        console.error('删除失败', error);
      }
    },
    handleSubmit() {
      if (this.dialogTitle === '新增记录') {
        this.$refs.form.validate((valid) => {
          if (valid) {
            axios.post('/api/account/addAccount', this.form)
              .then(response => {
                console.log('新增成功: ' + response.data);
                this.$message({
                  message: '新增账号',
                  type: 'success'
                });
                this.dialogVisible = false;
                this.accounts.push({ ...this.form }); // 使用展开运算符复制对象以避免引用问题
                this.fetchAccounts();
              })
              .catch(error => {
                console.error('新增账号失败', error);
                this.$message({
                  message: '新增账号失败',
                  type: 'error'
                });
            });
          } else {
            console.log('表单验证失败!!');
            return false;
          }
        });

      } else if(this.dialogTitle === '编辑记录'){
        this.$refs.form.validate((valid) => {
          if (valid) {
            axios.put(`/api/account/updateAccount/${this.form.id}`, this.form)
              .then(response => {
                console.log('更新成功: ' + response.data);
                this.$message({
                  message: response.data,
                  type: 'success'
                });
                this.dialogVisible = false;
                const index = this.accounts.findIndex(account => account.id === this.form.id);
                if (index !== -1) {
                  this.$set(this.accounts,index, { ...this.form });
                }
                this.fetchAccounts();
              })
              .catch(error => {
                console.error('更新失败', error);
                this.$message({
                  message: '更新失败',
                  type: 'error'
                });
            });
          } else {
            console.log('表单验证失败!!');
            return false;
          }
        });
      }
    },
    handlePageNumChange(val){
      console.log('当前页码: ' + val);
      this.query.pageNum = val;
      this.fetchAccounts();
    },
    handlePageSizeChange(val){
      console.log('每页条数: ' + val);
      this.query.pageSize = val;
      this.fetchAccounts();

    },
    handleExport(){
      window.location.href = `/api/account/export?keyword=${this.query.keyword}`;
      console.log('导出成功');
      this.fetchAccounts();
    },
    handleSuccess() {
      this.$message({
        message: '导入成功',
        type: 'success'
      });
      this.fetchAccounts();
    },
  }
};
</script>
<style scoped>
.search-bar{
  display: flex;
  align-items: center;
}
.search-bar .el-tooltip + .el-tooltip{
  margin-left: 30px;
}

</style>

导入时,准备的csv文件,注意第一行的标题列,需要按照如下进行配置

 

007 || 待办事项

勾选状态,则数据库中该待办的状态更改为completed(且待办事项加删除线),反之则为pending状态。

遗留问题:

  1. 勾选了两条待办,点击其中一个待办时,会造成另外一个勾选的待办状态变更。
  2. 切换到【账号管理】(其他菜单项)然后再切换回来,completed状态的待办前面的复选框并不是勾选状态。或者可以设计为鼠标悬浮到待办一行时,显示复选框,反之隐藏。

构想:

  1. 通过背景颜色来区分已完成的待办和未完成的待办

通过复选框对待办事项的状态进行改变的方式存在bug,暂时换成以下方式处理待办。

点击编辑按钮,即可对待办事项内容进行修改,同时编辑按钮会变成保存按钮,提交表单。将修改同步到数据库。

 

 

(1)数据库

-- auto-generated definition
create table todo(
    id char(32) default (replace(uuid(), _utf8mb4'-', _utf8mb4'')) not null primary key,
    description text,
    status enum ('pending', 'completed') default 'pending',
    created_at timestamp default CURRENT_TIMESTAMP,
    updated_at timestamp default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP
);

(2)实体类

com.harley.entity.Todo
 package com.harley.entity;

import java.io.Serializable;
import java.util.Date;

import lombok.Getter;
import lombok.Setter;

/**
 * <p>
 * 
 * </p>
 *
 * @author harley
 * @since 2024-12-27
 */
@Getter
@Setter
public class Todo implements Serializable {
    private static final long serialVersionUID = 1L;
    private String id;
    private String description;
    private String status;
    private Date createdAt;
    private Date updatedAt;
}

(3)Mapper

com.harley.mapper.TodoMapper
 package com.harley.mapper;

import com.harley.entity.Todo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * <p>
 *  Mapper 接口
 * </p>
 *
 * @author harley
 * @since 2024-12-27
 */
public interface TodoMapper extends BaseMapper<Todo> {

}
src/main/resources/mapper/TodoMapper.xml
 <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.harley.mapper.TodoMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.harley.entity.Todo">
        <id column="id" property="id" />
        <result column="description" property="description" />
        <result column="status" property="status" />
        <result column="created_at" property="createdAt" />
        <result column="updated_at" property="updatedAt" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, description, status, created_at, updated_at
    </sql>

</mapper>

(4)Service

com.harley.service.TodoService
 package com.harley.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.harley.entity.Todo;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author harley
 * @since 2024-12-27
 */
public interface TodoService extends IService<Todo> {

    IPage<Todo> getRecordPage(Integer pageNum, Integer pageSize, String keyword);

    void updateStatusById(String id,String status);

}

(5)ServiceImpl

com.harley.service.impl.TodoServiceImpl
package com.harley.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.harley.entity.Todo;
import com.harley.mapper.TodoMapper;
import com.harley.service.TodoService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author harley
 * @since 2024-12-27
 */
@Service
public class TodoServiceImpl extends ServiceImpl<TodoMapper, Todo> implements TodoService {

    @Autowired
    private TodoMapper todoMapper;

    @Override
    public IPage<Todo> getRecordPage(Integer pageNum, Integer pageSize, String keyword) {
        Page<Todo> page = new Page<>(pageNum,pageSize);
        LambdaQueryWrapper<Todo> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper
                .like(StringUtils.isNotBlank(keyword),Todo::getDescription,keyword)
                .orderByDesc(Todo::getCreatedAt);
        return todoMapper.selectPage(page, lambdaQueryWrapper);
    }

    @Override
    public void updateStatusById(String id, String status) {
        Todo todo = todoMapper.selectById(id);
        if(todo != null){
            todo.setStatus(status);
            todo.setUpdatedAt(new Date());
            todoMapper.updateById(todo);
        }
    }
}

(6)Controller

com.harley.controller.TodoController
package com.harley.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.harley.entity.Todo;
import com.harley.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;



/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author harley
 * @since 2024-12-27
 */
@RestController
@RequestMapping("/todo")
public class TodoController {


    @Autowired
    private TodoService todoService;

    @GetMapping("/getRecordPage")
    public IPage<Todo> getRecordPage(@RequestParam(defaultValue = "0") Integer pageNum,
                                     @RequestParam(defaultValue = "10") Integer pageSize,
                                     @RequestParam(value = "keyword", defaultValue = "", required = false) String keyword){

        return todoService.getRecordPage(pageNum,pageSize,keyword);
    }


    @PostMapping("/addTodo")
    public String addTodo(@RequestBody Todo todo){
        return todoService.save(todo)?"新增成功":"新增失败";
    }


    @GetMapping("/deleteTodo")
    public boolean deleteTodo(@RequestParam String id){
        return todoService.removeById(id);
    }

    @PutMapping("/updateStatus/{id}")
    public ResponseEntity<String> updateStatus(@PathVariable String id,@RequestBody Todo todo){
        todo.setId(id);
        return todoService.updateById(todo) ? ResponseEntity.ok("update successfully.") : ResponseEntity.ok("update failed.");
    }
}

(7)TodoPage.vue

src\components\views\ToDoPage.vue
<template>
    <div>
        <div class="page-main">
            <!-- 搜索框和按钮 -->
            <div class="search-bar">
                <el-form :model="form" ref="form" :rules="rules" label-width="80px">
                    <el-input placeholder="请输入待办事项" v-model="form.description" style="width:100%;" @keydown.enter.native.prevent="addTodo" clearable>
                    <template #append>
                        <el-button type="primary" icon="el-icon-plus" @click="addTodo">添加待办</el-button>
                    </template>
                    </el-input>
                </el-form>
            </div>
        </div>
        <el-table :data="records" style="width: 100%">
            <el-table-column label="序号" prop="id"  v-if="false" width="auto"></el-table-column>
            <el-table-column label="待办" prop="description">
                <template slot-scope="scope">
                    <span v-if="!scope.row.editable" :class="{ completed: scope.row.status === 'completed' }">{{ scope.row.description }}</span>
                    <el-input v-else v-model="scope.row.description" placeholder="请输入待办事项"></el-input>
                </template>
            </el-table-column>
            <el-table-column label="操作" width="400">
                <template slot-scope="scope">
                    <el-select v-model="scope.row.status" placeholder="请选择" size="mini" @change="updateStatus(scope.row)" style="width: 80px;">
                        <el-option type="success" label="待办" value="pending"></el-option>
                        <el-option label="完成" value="completed"></el-option>
                    </el-select>
                    <el-button type="primary" size="mini" style="margin-left: 10px;" @click="toggleEdit(scope.row)">
                        {{ scope.row.editable ? '保存' : '编辑' }}
                    </el-button>
                    <el-button type="danger" @click="deleteTodo(scope.row.id)" class="el-icon-delete" size="mini" style="margin-left: 10px;"></el-button>
                </template>
            </el-table-column>
        </el-table>
        <el-pagination
            style="text-align: center"
            :page-sizes="[5,10]"
            :current-page="query.pageNum"
            :page-size="query.pageSize"
            :total="query.total"
            layout="total, sizes, prev, pager, next, jumper"
            @current-change="handlePageNumChange"
            @size-change="handlePageSizeChange"
            >
        </el-pagination>
    </div>
</template>
<script>
import axios from 'axios'

export default {
    name: 'ToDoPage',
    data() {
        return {
            records: [],
             // 分页信息
            query: {
                pageNum: 0,
                pageSize: 10,
                total: 0,
                keyword: '', // 搜索关键字
            },
            form: {
                description: '',
            },
            rules: {
                description: [
                    { required: true, message: '请输入待办事项', trigger: 'blur' },
                ],
            },
            selectedRows: [],
        }
    },
    created() {
        this.getRecords();
    },
    mounted() {
        // this.initializeSelection();
    },
    methods: {
        async getRecords(){
            const response = await axios.get('/api/todo/getRecordPage', {
                params: {
                    pageNum: this.query.pageNum,
                    pageSize: this.query.pageSize,
                    keyword: this.query.keyword,
                }});
            this.records = response.data.records.map(record => ({
                id: record.id,
                description: record.description,
                status: record.status,
                editable: false
            }));
            this.query.total = response.data.total;
            this.query.pageNum = response.data.current;
            this.query.pageSize = response.data.size;
        },
        addTodo() {
            console.log(this.description);
            axios.post('/api/todo/addTodo',this.form)
            .then(response => {
                console.log('新增待办: ' + response.data);
                this.$message({
                  message: '新增待办',
                  type: 'success'
                })
                this.form.description = '';
                this.getRecords();
            })
        },
        // 分页
        handlePageNumChange(val){
            console.log('当前页码: ' + val);
            this.query.pageNum = val;
            this.getRecords();
        },
        handlePageSizeChange(val){
            console.log('每页条数: ' + val);
            this.query.pageSize = val;
            this.getRecords();
        },
        // handleSelectionChange(selection) {
        //     console.log('Selection changed: ', selection);

        //     this.records.forEach(record => {
        //         record._checked = selection.some(item => item.id === record.id);
        //         record.status = record._checked ? 'completed' : 'pending';
        //         console.log(`Record ID ${record.id} status updated to ${record.status}`);
        //     });

        //     // 准备要发送的数据
        //     const updateData = this.records.map(record => ({
        //         id: record.id,
        //         status: record.status
        //     }));

        //     console.log('Preparing to send update data:', updateData)
        //     // 发送批量更新请求到后端
        //     this.updateStatus(updateData);
        // },
        async updateStatus(row) {
            row.editable = false;
            try{
                console.log('Sending update request to server: ', row);
                await axios.put(`/api/todo/updateStatus/${row.id}`, row);
            }catch(error){
                console.error('Failed to update status: ', error);
            }
        },
        // handleRowSelection(row, checked) {
        //     this.$refs.multipleTable.toggleRowSelection(row,checked);
        // },
        // initializeSelection() {
        //     // 遍历所有记录,并自动选中status为'completed'的行
        //     this.records.forEach(row => {
        //         if (row.status === 'completed') {
        //             this.multipleTable.toggleRowSelection(row, true);
        //         }
        //     });
        // },
        deleteTodo(id){
            console.log('删除待办: ' + id);
            axios.get(`/api/todo/deleteTodo?id=${id}`)
            .then(response => {
                console.log('删除待办: ' + response.data);
                this.$message({
                  message: '删除待办',
                  type: 'success'
                })
                this.getRecords();
             })
        },
        toggleEdit(row) {
            if(row.editable){
                this.updateStatus(row)
            }else{
                row.editable = true;
            }
        },
    },
}
</script>
<style scoped>
.completed {
  text-decoration: line-through;
  color: gray;
}
</style>

 

 

008 || 加班/调休

(1)Entity

com.harley.entity.OvertimeAndLeave
package com.harley.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

/**
 * <p>
 * 
 * </p>
 *
 * @author harley
 * @since 2024-12-20
 */
@Data
@TableName("overtime_and_leave")
public class OvertimeAndLeave implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    private String type;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private Date date;

    private BigDecimal duration;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private Date createdAt;
}

(2)Mapper

com.harley.mapper.OvertimeAndLeaveMapper
 package com.harley.mapper;

import com.harley.entity.OvertimeAndLeave;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * <p>
 *  Mapper 接口
 * </p>
 *
 * @author harley
 * @since 2024-12-20
 */
public interface OvertimeAndLeaveMapper extends BaseMapper<OvertimeAndLeave> {

}
src/main/resources/mapper/OvertimeAndLeaveMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.harley.mapper.OvertimeAndLeaveMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.harley.entity.OvertimeAndLeave">
        <id column="id" property="id" />
        <result column="type" property="type" />
        <result column="date" property="date" />
        <result column="duration" property="duration" />
        <result column="created_at" property="createdAt" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, type, date, duration, created_at
    </sql>

</mapper>

(3)Service

com.harley.service.OvertimeAndLeaveService
 package com.harley.service;

import com.harley.entity.OvertimeAndLeave;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author harley
 * @since 2024-12-20
 */
public interface OvertimeAndLeaveService extends IService<OvertimeAndLeave> {

}

(4)ServiceImpl

com.harley.service.impl.OvertimeAndLeaveServiceImpl
 package com.harley.service.impl;

import com.harley.entity.OvertimeAndLeave;
import com.harley.mapper.OvertimeAndLeaveMapper;
import com.harley.service.OvertimeAndLeaveService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author harley
 * @since 2024-12-20
 */
@Service
public class OvertimeAndLeaveServiceImpl extends ServiceImpl<OvertimeAndLeaveMapper, OvertimeAndLeave> implements OvertimeAndLeaveService {


}

(5)Controller

com.harley.controller.OvertimeAndLeaveController
package com.harley.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.harley.entity.Account;
import com.harley.entity.OvertimeAndLeave;
import com.harley.service.OvertimeAndLeaveService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author harley
 * @since 2024-12-20
 */
@RestController
@RequestMapping("/overtime-and-leave")
public class OvertimeAndLeaveController {

    @Autowired
    private OvertimeAndLeaveService overtimeAndLeaveService;

    @GetMapping("/getTimeline")
    public List<OvertimeAndLeave> getTimeline() {
        LambdaQueryWrapper<OvertimeAndLeave> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.orderByDesc(OvertimeAndLeave::getDate);
        return overtimeAndLeaveService.list(lambdaQueryWrapper);
    }

    @GetMapping("/leave-balance")
    public Map<String, BigDecimal> getLeaveBalance() {
        // 计算总加班时长
        BigDecimal totalOvertime = overtimeAndLeaveService.list()
                .stream()
                .filter(ol -> "overtime".equals(ol.getType()))
                .map(OvertimeAndLeave::getDuration)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // 计算已使用调休时长
        BigDecimal usedLeave = overtimeAndLeaveService.list()
                .stream()
                .filter(ol -> "leave".equals(ol.getType()))
                .map(OvertimeAndLeave::getDuration)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        Map<String, BigDecimal> stringBigDecimalMap = new HashMap<>();
        stringBigDecimalMap.put("totalOvertime", totalOvertime);
        stringBigDecimalMap.put("usedLeave", usedLeave);
        return stringBigDecimalMap;
    }

    @PostMapping("/addRecord")
    public boolean addRecord(@RequestBody OvertimeAndLeave overtimeAndLeave){

        return overtimeAndLeaveService.save(overtimeAndLeave);
    }


}

(6)OvertimePage.vue

src/components/views/OvertimePage.vue
<template>
    <div id="app">
        <div slot="header">
            剩余可用调休时长{{ remainingLeave }} 小时<!-- 登记加班/调休 -->
            <el-tooltip content="登记加班/调休" placement="top">
                <el-button size="mini" type="primary" style="margin-left: 20px;" @click="showDialog = true" icon="el-icon-plus" circle></el-button>
            </el-tooltip>
        </div>
        <el-scrollbar class="custom-scrollbar" style="height: 666px;">
          <el-timeline>
            <el-timeline-item
              v-for="(activity, index) in activities"
              :key="index"
              :timestamp="getFormattedDate(activity.date)">
              <i :class="getIconClass(activity.type)"></i>
              {{ activity.type === 'overtime' ? '加班' : '调休' }} - {{ activity.duration }} 小时
            </el-timeline-item>
          </el-timeline>
        </el-scrollbar>
        <!-- 对话框 -->
        <el-dialog :visible.sync="showDialog" title="登记加班/调休">
        <el-form ref="form" :model="form">
            <el-form-item label="类型" :label-width="formLabelWidth">
            <el-select v-model="form.type" placeholder="请选择类型">
                <el-option label="加班" value="overtime"></el-option>
                <el-option label="调休" value="leave"></el-option>
            </el-select>
            </el-form-item>
            <el-form-item label="日期" :label-width="formLabelWidth">
            <el-date-picker v-model="form.date" type="date" placeholder="选择日期"></el-date-picker>
            </el-form-item>
            <el-form-item label="时长" :label-width="formLabelWidth">
            <el-input-number v-model="form.duration" :min="0.5" :step="0.5"></el-input-number>
            </el-form-item>
        </el-form>
        <span slot="footer" class="dialog-footer">
            <el-button @click="showDialog = false">取消</el-button>
            <el-button type="primary" @click="handleSubmit">提交</el-button>
        </span>
        </el-dialog>
    </div>
</template>

<script>
import axios from 'axios';

export default {
  name: 'OvertimePage',
  data() {
    return {
      activities: [],
      showDialog: false,
      form: {
        type: '',
        date: '',
        duration: 0
      },
      formLabelWidth: '120px',
      totalOvertime: 0.0,
      usedLeave: 0.0,
    };
  },
  computed: {
      remainingLeave() {
        return this.totalOvertime - this.usedLeave;
      },
  },
  methods: {
    async fetchActivities() {
      const response = axios.get('/api/overtime-and-leave/getTimeline');
      response.then(response => {
        this.activities = response.data;
      })
    },
    fetchLeaveBalance(){
      axios.get('/api/overtime-and-leave/leave-balance')
      .then(response => {
          this.totalOvertime = response.data.totalOvertime;
          this.usedLeave = response.data.usedLeave;
        })
      .catch(error => console.error('Error fetching leave balance:', error));
    },
    handleSubmit() {
      this.$refs['form'].validate((valid) => {
          if (valid) {
          axios.post('/api/overtime-and-leave/addRecord', {
              ...this.form,
              date: new Date(this.form.date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }).replace(/\//g, '-')
          },{
              headers: {
                'Content-Type': 'application/json'
                }
          })
              .then(response => {
              console.log('登记成功: ' + response.data);
              this.$message({
                  message: '登记成功',
                  type: 'success'
              });
              this.showDialog = false;
              this.fetchActivities();
              this.fetchLeaveBalance();
              })
              .catch(error => {
              console.error('登记失败', error);
              this.$message({
                  message: '登记失败',
                  type: 'error'
              });
              });
          } else {
          console.log('验证失败');
          return false;
          }
      });
    },
    getFormattedDate(dateString) {
      const options = { year: 'numeric', month: 'long', day: 'numeric' };
      const daysOfWeek = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
      const date = new Date(dateString);
      if (isNaN(date)) {
        return '无效日期';
      }
      const formattedDate = date.toLocaleDateString('zh-CN', options);
      const dayOfWeek = daysOfWeek[date.getDay()];
      return `${formattedDate} (${dayOfWeek})`;
    },
    getIconClass(type){
      if (type === 'overtime') {
        return 'el-icon-time'; // 或者 'el-icon-clock'
      } else if (type === 'leave') {
        return 'el-icon-sunny'; // 或者 'el-icon-date'
      }
      return ''; // 默认情况下不显示图标
    }
  },
  mounted() {
    this.fetchActivities();
    this.fetchLeaveBalance();
  }
};
</script>

<style scoped>
/* 自定义样式 */
.custom-scrollbar {
  width: 100% !important;
  overflow: hidden !important;
}

.custom-scrollbar .el-scrollbar__wrap {
  overflow-x: hidden !important; /* 强制隐藏水平滚动 */
  overflow-y: auto !important;              /* 保持垂直滚动 */
}

.custom-scrollbar .el-scrollbar__view {
  white-space: normal !important;
  word-break: break-all !important; /* 确保长单词和URL等能够换行 */
}

.custom-scrollbar * {
  box-sizing: border-box !important;
}

</style>

 

 

009 || 加密解密

(1)Utils

com.harley.utils.JasyptUtils
 package com.harley.utils;

import lombok.extern.slf4j.Slf4j;
import org.jasypt.encryption.StringEncryptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@Slf4j
@Service
public class JasyptUtils {

    @Autowired
    private StringEncryptor encryptor;

    public String encrypt(String str){

        System.out.println("============ origin Str : " + str);
        String encryptedStr = encryptor.encrypt(str);
        System.out.println("============ encrypted Str : " + encryptedStr);
        return encryptedStr;
    }

    public String decrypt(String deStr){
        System.out.println("============ decrypt deStr : " + deStr);
        String originStr = encryptor.decrypt(deStr);
        System.out.println("============ origin str : " + originStr);
        return originStr;
    }

}

(2)controller

com.harley.controller.TypeTransformController
 package com.harley.controller;


import com.harley.utils.JasyptUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;

@Controller
public class TypeTransformController {

    @Resource
    private JasyptUtils jasyptUtils;

    @ResponseBody
    @GetMapping(value = "/encrypt")
    public String encrypt(@RequestParam String str){
        System.out.println(str);
        return jasyptUtils.encrypt(str);
    }

    @ResponseBody
    @GetMapping(value = "/decrypt")
    public String decrypt(@RequestParam String str) {
        System.out.println("接收到"+str);
        return jasyptUtils.decrypt(str);
    }

}

(3)EncryptPage.vue

src/components/views/EncryptPage.vue
<template>
    <div class="encryption-tool">
        <el-row :gutter="20" style="margin-bottom: 10px;">
            <el-col :span="16">
                <el-input v-model="inputText" placeholder="请输入要加密/解密的内容"></el-input>
            </el-col>
            <el-col :span="1.5">
                <el-tooltip content="加密" placement="top">
                    <el-button type="primary" @click="encrypt">加密</el-button>
                </el-tooltip>
            </el-col>
            <el-col :span="1.5">
                <el-tooltip content="解密" placement="top">
                    <el-button type="success" @click="decrypt">解密</el-button>
                </el-tooltip>
            </el-col>
            <el-col :span="1.5">
                <el-tooltip content="点击复制结果到剪贴板" placement="top">
                    <el-button type="info" @click="copyToClipboard">复制</el-button>
                </el-tooltip>
            </el-col>
            <el-col :span="1.5">
                <el-tooltip content="清空输入框及结果" placement="top">
                    <el-button type="danger" @click="clear">清空</el-button>
                </el-tooltip>
            </el-col>
        </el-row>
        <el-row>
            <el-col :span="24">
                <el-input
                type="textarea"
                :rows="10"
                placeholder="加密或解密后的结果会显示在这里"
                v-model="outputText"
                readonly
                ></el-input>
            </el-col>
        </el-row>
    </div>
</template>
<script>
import axios from 'axios'

export default {
    name: 'EncryptPage',
    data() {
        return {
            inputText: '',
            outputText: ''
        }
    },
    methods: {
        encrypt() {
            axios.get(`/api/encrypt?str=${this.inputText}`).then(response => {
                this.outputText = response.data
            })

        },
        decrypt() {

            const finalInputText = encodeURIComponent(this.inputText);
            // this.inputText.replace("+","%2B").replace("/","%2F")
            console.log(finalInputText)
            axios.get(`/api/decrypt?str=${finalInputText}`).then(response => {
                this.outputText = response.data
            })
        },
        clear() {
            this.inputText = ''
            this.outputText = ''
        },
        copyToClipboard() {
            // 检查浏览器是否支持 Clipboard API
            if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
                // 使用 Clipboard API 复制文本
                navigator.clipboard.writeText(this.outputText).then(() => {
                this.$message({
                    message: '复制成功',
                    type: 'success'
                });
                }).catch(err => {
                this.$message.error('复制失败');
                console.error('Failed to copy: ', err);
                });
            } else {
                // 如果不支持 Clipboard API,则回退到其他方法
                this.fallbackCopyTextToClipboard(this.outputText);
            }
            },
        fallbackCopyTextToClipboard(text) {
            // 创建一个临时的 textarea 元素用于复制文本
            const textArea = document.createElement("textarea");
            textArea.value = text;
            // 将 textarea 移动到屏幕外
            textArea.style.position = 'fixed';
            textArea.style.top = '0';
            textArea.style.left = '0';
            textArea.style.width = '2em';
            textArea.style.height = '2em';
            textArea.style.padding = '0';
            textArea.style.border = 'none';
            textArea.style.outline = 'none';
            textArea.style.boxShadow = 'none';
            textArea.style.background = 'transparent';
            document.body.appendChild(textArea);
            textArea.focus();
            textArea.select();

            try {
                const successful = document.execCommand('copy');
                const msg = successful ? '成功' : '失败';
                this.$message({
                message: `复制 ${msg}`,
                type: successful ? 'success' : 'error'
                });
            } catch (err) {
                this.$message.error('复制失败');
                console.error('Fallback failed: ', err);
            }

            document.body.removeChild(textArea);
            },
    }
}
</script>
<style scoped>
.encryption-tool {
  padding: 20px;
}
</style>

 

 

010 || 日期时间

(1)CalendarPage.vue

src/components/views/CalendarPage.vue
<template>
    <!-- <h3>实时系统时间</h3> -->
    <!-- <p><span id="nowTime">{{ formattedTime }}, </span>{{ timeMessage }}</p> -->
  <div>
    <p><span id="nowTime">{{ formattedTime }}, </span>{{ timeMessage }}</p>
    <!-- <el-date-picker v-model="selectedDate" type="date" placeholder="选择日期时间"></el-date-picker> -->
    <!-- <p>已选日期:{{ selectedDate }}</p> -->
     <el-calendar>
      <template v-slot="{ date }">
      <div class="calendar-cell">
        <!-- 格里高利历日期 -->
        <span>{{ date.getDate() }}</span> <!-- 日期 -->
        <!-- 农历日期 -->
        <span v-if="getLunarDate(date)" class="lunar-date">{{ getLunarDate(date) }}</span> <!-- 农历日期 -->
      </div>
    </template>

     </el-calendar>
  </div>

</template>

<script>
import { format, intervalToDuration, setHours, setMinutes, setSeconds, addDays } from 'date-fns';
import Lunar from 'lunar';



export default {
  name: 'CalendarPage',
  data() {
    return {
      // selectedDate: new Date(),
      currentTime: new Date(),
      timeMessage: '',
    };
  },
  created() {
    // this.updateTimeMessage();
    // 每秒更新一次时间
    this.timer = setInterval(() => {
      this.currentTime = new Date();
      this.updateTimeMessage();
    }, 1000);
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    // 格式化时间字符串
    formattedTime() {
      // const options = { hour: '2-digit', minute: '2-digit', second: '2-digit' };
      // return this.currentTime.toLocaleTimeString('zh-CN', options);
      return format(this.currentTime, 'HH:mm:ss');
    }
  },
  methods: {
    // 更新当前时间的方法
    updateTime() {
      this.currentTime = new Date();
    },
    // 更新时间信息的方法
    updateTimeMessage() {
    const now = this.currentTime;
    const startOfWorkDay = setHours(setMinutes(setSeconds(new Date(), 0), 0), 9); // 9:00
    const endOfWorkDay = setHours(setMinutes(setSeconds(new Date(), 0), 0), 18); // 18:00
    const nextWorkDayStart = setHours(setMinutes(setSeconds(addDays(new Date(), 1), 0), 0), 9); // 第二天 9:00

    let duration;
    if (now < startOfWorkDay) {
      duration = intervalToDuration({ start: now, end: startOfWorkDay });
      this.timeMessage = `距离上班还有 ${this.formatDuration(duration)}`;
    } else if (now >= startOfWorkDay && now < endOfWorkDay) {
      duration = intervalToDuration({ start: now, end: endOfWorkDay });
      this.timeMessage = `距离下班还有 ${this.formatDuration(duration)}`;
    } else {
      duration = intervalToDuration({ start: now, end: nextWorkDayStart });
      this.timeMessage = `已下班, 距离下次(第二天9:00)上班还有 ${this.formatDuration(duration)}`;
    }
    },
    // 格式化时间间隔的方法
    formatDuration(duration) {
      const hours = String(duration.hours || 0).padStart(2, '0');
      const minutes = String(duration.minutes || 0).padStart(2, '0');
      const seconds = String(duration.seconds || 0).padStart(2, '0');

      return `${hours} 小时 ${minutes} 分钟 ${seconds} 秒`;
    },
    // 返回农历字符串表示形式
    getLunarDate(date) {
      const lunarDate = new Lunar(date);
      return lunarDate.toString(); // 返回农历字符串表示形式
    },

  }
};
</script>

<style scoped>
  #nowTime {
    font-size: 2em;
    font-weight: bold;
  }

  .lunar-date {
    /* 农历样式的自定义 */
    font-size: smaller;
    color: gray;
  }
</style>

 

 

 

用于在使用postman调用接口时,将返回内容按照表格的形式返回。

com.harley.utils.PrettyTable
 package com.harley.utils;

import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class PrettyTable {

    private List<String> headers = new ArrayList<>();
    private List<List<String>> data = new ArrayList<>();

    public PrettyTable(String... headers) {
        this.headers.addAll(Arrays.asList(headers));
    }

    public void addRow(String... row) {
        data.add(Arrays.asList(row));
    }

    private int getMaxSize(int column) {
        int maxSize = headers.get(column).length();
        for (List<String> row : data) {
            if (row.get(column).length() > maxSize)
                maxSize = row.get(column).length();
        }
        return maxSize;
    }

    private String formatRow(List<String> row) {
        StringBuilder result = new StringBuilder();
        result.append("|");
        for (int i = 0; i < row.size(); i++) {
            result.append(StringUtils.center(row.get(i), getMaxSize(i) + 2));
            result.append("|");
        }
        result.append("\n");
        return result.toString();
    }

    private String formatRule() {
        StringBuilder result = new StringBuilder();
        result.append("+");
        for (int i = 0; i < headers.size(); i++) {
            for (int j = 0; j < getMaxSize(i) + 2; j++) {
                result.append("-");
            }
            result.append("+");
        }
        result.append("\n");
        return result.toString();
    }

    public String toString() {
        StringBuilder result = new StringBuilder();
        result.append("\r\n");
        result.append(formatRule());
        result.append(formatRow(headers));
        result.append(formatRule());
        for (List<String> row : data) {
            result.append(formatRow(row));
        }
        result.append(formatRule());
        return result.toString();
    }

}

 

011 || 前端

页面构图

(1)创建项目&安装模块

创建vue项目,并安装所需模块

# 使用vue-cli方式创建vue项目, 选择vue2.x
vue create account-vue
# 安装所需的模块
npm install element-ui axios date-dns lunar --save

(2)配置main.js

main.js中引入Element ui,并启用

import Vue from 'vue'
import App from './App.vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import '@/assets/styles/global.css'

Vue.use(ElementUI)

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app') // 挂载到id为app的元素上

console.log('Vue version:',Vue.version)
console.log('ElementUI version:',ElementUI.version)
console.log('欢迎使用由Harley开发的账号管理系统')

(3)配置路由

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:10086',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        },
      },
    }
  }
})

(4)配置global.css

/* src/assets/styles/global.css */
*{
  margin: 0;
  padding: 0;
}

(5)App.vue

<template>
  <div id="app">
     <PageIndex/>
  </div>
</template>

<script>
import PageIndex from './components/PageIndex.vue';

export default {
  name: 'App',
  components: {
    PageIndex
  },
  created() {
    document.title = '侯惠林的工具箱';
  }
}
</script>

<style>
#app {
  height: 100%;
}
</style>

 

src/components/PageIndex.page
 <template>
  <el-container style="height: 100%; border: 1px solid #eee">
      <el-aside :width="aside_width" style="background-color: rgb(238, 241, 246);height: 100%;margin:-1px 0 0 -1px;">
          <PageAside :isCollapse="isCollapse" @menu-change="handleMenuChange"/>
      </el-aside>
      <el-container style="height: 100%">
          <el-header style="text-align: right; font-size: 12px;height: 100%;border-bottom: rgba(168, 168, 168, 0.3) 1px solid;">
              <PageHeader @doCollapse="doCollapse" :collapseIcon="collapse_icon"/>
          </el-header>
          <el-main>
              <component :is="activeComponent" />
          </el-main>
      </el-container>
  </el-container>
</template>
<script>
import PageAside from './PageAside.vue'
import PageHeader from './PageHeader.vue'
import AccountPage from './views/AccountPage.vue'
import CalendarPage from './views/CalendarPage.vue';
import HomePage from './views/HomePage.vue';
import EncryptPage from './views/EncryptPage.vue';
import OvertimePage from './views/OvertimePage.vue';



export default {
  name: "PageIndex",
  components:{PageAside,PageHeader,AccountPage,CalendarPage,HomePage,EncryptPage,OvertimePage},
  data(){
    return {
      isCollapse: false,
      aside_width: '200px',
      collapse_icon: 'el-icon-s-fold',
      activeComponent: 'HomePage'
    }
  },
  methods:{
    doCollapse(){
      console.log("doCollapse隐藏侧边栏")
      this.isCollapse = !this.isCollapse
      if(!this.isCollapse){
        this.aside_width = '200px'
        this.collapse_icon = 'el-icon-s-fold'
      }else{
        this.aside_width = '64px'
        this.collapse_icon = 'el-icon-s-unfold'
      }
    },
    handleMenuChange(menuItem) {
      switch (menuItem) {
        case 'home':
          this.activeComponent = 'HomePage'
          break
        case 'calendar':
          this.activeComponent = 'CalendarPage'
          break
        case 'account':
          this.activeComponent = 'AccountPage'
          break
        case 'encrypt':
          this.activeComponent = 'EncryptPage'
          break
        case 'overtime':
          this.activeComponent = 'OvertimePage'
          break
        default:
          this.activeComponent = 'HomePage'
      }
    }
  },
  computed: {

  }
};
</script>
<style scoped>
.el-header {
  /* background-color: #B3C0D1; */
  color: #333;
  line-height: 60px;
}
.el-main{
  padding: 5px;
}
.el-aside {

  color: #333;
}
</style>

 

src/components/PageAside.page
 <!-- PageAside.vue -->
<template>
  <el-menu style="height: 100vh;" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b" :collapse="isCollapse" :collapse-transition="false" :default-active="activeMenu" @select="handleSelect">
      <el-menu-item index="home"><i class="el-icon-s-home"></i><span slot="title"> 首页</span></el-menu-item>
      <el-menu-item index="account"><i class="el-icon-user"></i><span slot="title"> 账号管理</span></el-menu-item>
      <el-menu-item index="overtime"><i class="el-icon-time"></i><span slot="title"> 加班调休</span></el-menu-item>
      <el-menu-item index="encrypt"><i class="el-icon-lock"></i><span slot="title"> 加密解密</span></el-menu-item>
      <el-menu-item index="calendar"><i class="el-icon-time"></i><span slot="title"> 日期时间</span></el-menu-item>
  </el-menu>
</template>

<script>

export default {
  name: 'PageAside',
  data() {
    return {
      activeMenu: 'home'
    }
  },
  props:{
      isCollapse: Boolean
  },
  methods: {
    handleSelect(key) {
      console.log(key);
      this.$emit('menu-change', key);
    },
  }
};
</script>

<style scoped>

</style>

 

src/components/PageHeader.vue
 <!-- PageHeader.vue -->
<template>
  <div style="display: flex;line-height: 60px;">
      <div style="margin-top: 8px;">
          <i :class="collapseIcon" style="font-size: 20px;cursor: pointer;" @click="collapse"></i>
      </div>
      <div style="flex: 1; text-align: center; font-size: 30px;">
          <!-- <span>欢迎来到账号管理系统</span> -->
      </div>
      <el-dropdown>
          <span style="cursor: pointer;">Harley</span><i class="el-icon-arrow-down" style="margin-left: 5px;cursor: pointer;"></i>
          <el-dropdown-menu slot="dropdown">
              <el-dropdown-item @click.native = "toUser">个人中心</el-dropdown-item>
              <el-dropdown-item @click.native = "logout">退出登录</el-dropdown-item>
          </el-dropdown-menu>
      </el-dropdown>    
  </div>
</template>

<script>

export default {
  name: 'PageHeader',
  props:{
      collapseIcon:String
  },
  methods:{
      toUser(){
          console.log('to_user')
      },
      logout(){
          console.log('log_out')
      },
      collapse(){
          // 将子组件的值传给父组件
          this.$emit('doCollapse')
      }
  }
};
</script>

<style scoped>

</style>

 

 

099 || Q&A

(1)数据库中是正常的datetime类型数据,在vue页面显示为2024-12-18T09:17:45.000+00:00

Spring Boot默认使用Jackson库来序列化和反序列化JSON数据。默认情况下Jackson会将日期序列化为ISO 8601格式。

可以在application.yml中进行如下配置,以显示类似于:2024-12-19 17:29:00 的时间格式

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai

 

posted @   HOUHUILIN  阅读(52)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示