Fms-Java版开发实录:20 项目增删改查

Fms-Java版开发实录:20 项目增删改查

这个基本上就是老套路了,之前在Django里写过一遍,这次看看有什么新的想法可以在开发过程中冒出来

这个基本上就是老套路了,之前在Django里写过一遍,这次看看有什么新的想法可以在开发过程中冒出来。上次设计的URL可以参看这里

准备工作

准备工作自然是创建DaoService层,还有templates/pms/project/目录了,我准备继承JPARepository了,这个功能比CrudRepository强一些。之后就来慢慢按照控制器-页面的逻辑编写。

列表控制器和页面

在开始编写之前,突然发现之前有点傻…

项目列表控制器

我在Admin代码里写了一些很丑陋的代码,就是每个控制器都去获取User,然后传给narbar.html用来判断是否要显示管理员菜单,今天我突然开悟了,可以用@ModelAttribute啊:

@Controller
@RequestMapping("/pms/project")
public class ProjectController {

    private final ProjectService projectService;

    @Autowired
    public ProjectController(UserService userService, ProjectService projectService) {
        this.projectService = projectService;
    }

    //在所有方法执行之前放进判断显示导航条的方法
    @ModelAttribute
    public void getCurrentUser(Model model) {
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        model.addAttribute("isAdmin", user.hasRole("ADMIN"));
    }
    
    @GetMapping("/list")
    public String projectList(Model model) {
        List<Project> projectList = projectService.findAllProjects();
        model.addAttribute("projects", projectService.findAllProjects());
        model.addAttribute("count", projectList.size());
        return "pms/project/projectList";
    }

}

顺便我就把AdminController也给修改了,代码就不放了。

项目列表页

列表页就好弄多了,这里我突然想到,可以使用面包屑导航,就添加到页面的前半部分里了,还是使用一个迭代来渲染所有的项目。

<body>
<main class="container">
    <nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb" class="mt-3">
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="/home" th:href="@{/home}" class="link-secondary">首页</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list" th:href="@{/pms/project/list}" class="link-secondary">项目管理</a></li>
        </ol>
    </nav>
    <p>
        <a class="btn btn-primary" href="/pms/project/add" th:href="@{/pms/project/add}"><i class="fas fa-plus"></i>&nbsp;新增项目</a>
    </p>
    <table class="table table-hover">
        <thead>
        <tr>
            <th>#</th>
            <th>项目名称</th>
            <th>项目经理</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="project, control:${projects}">
            <td th:text="${control.count}"></td>
            <td><a th:href="${project.absoluteUrl()}">[[${project.projectName}]]</a></td>
            <td th:text="${project.projectManager}"></td>
        </tr>
        </tbody>
    </table>
</main>

新增项目控制器与页面

这个就要做一个添加新项目的表单了。这里涉及到添加时间,看看能不能找到一个方法来添加选择时间的输入框,以及如何将输入的时间转换成时间类并写入数据库。

新增项目控制器

这个又是老套路了,GET请求用新对象,POST请求组装对象,都要用@ModelAttribute注解。

// 新增项目页
@GetMapping("/add")
public String projectAddPage(@ModelAttribute("project") Project project) {
    return "pms/project/projectAdd";
}

// 接受POST请求控制器
@PostMapping("/add")
public String projectAddPage(@Valid @ModelAttribute("project") Project project, BindingResult bindingResult, RedirectAttributes
        attributes) {
    if (bindingResult.hasErrors()) {
        return "pms/project/projectAdd";
    } else {
        projectService.save(project);
        attributes.addFlashAttribute("created", true);
        return "redirect:/pms/project/list";
    }
}

这里还有一招比较有意思,就是无需拼接URL参数,直接在重定向的时候放入一个参数,使用RedirectAttributes.addFlashAttribute方法即可。

新增项目页面

对应的页面我采用了Bootstrap - checkout模板,不过暂时还没想好侧边栏到底放什么东西,表单部分如下:

<div class="col-md-7 col-lg-8">
            <h4 class="mb-3">项目信息</h4>
            <form class="needs-validation" action="/pms/project/add" th:action="@{/pms/project/add}" method="post"
                  th:object="${project}">
                <div class="row g-3 mb-3">
                    <div class="col-sm-6">
                        <label for="projectName" class="form-label">项目名称</label>
                        <input type="text" class="form-control" id="projectName"
                               th:field="*{projectName}"
                               th:classappend="${#fields.hasErrors('projectName')}? 'is-invalid'" required>
                        <div id="nameError" th:if="${#fields.hasErrors('projectName')}"
                             th:errors="*{projectName}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="projectAddress" class="form-label">项目地址</label>
                        <input type="text" class="form-control" id="projectAddress" th:field="*{projectAddress}"
                               th:classappend="${#fields.hasErrors('projectAddress')}? 'is-invalid'" required>
                        <div id="addressError" th:if="${#fields.hasErrors('projectAddress')}"
                             th:errors="*{projectAddress}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="projectManager" class="form-label">项目经理</label>
                        <input type="text" class="form-control" id="projectManager" th:field="*{projectManager}"
                               th:classappend="${#fields.hasErrors('projectManager')}? 'is-invalid'" required>
                        <div id="managerError" th:if="${#fields.hasErrors('projectManager')}"
                             th:errors="*{projectManager}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-12">
                        <label for="projectDescription" class="form-label">项目描述</label>
                        <textarea class="form-control" id="projectDescription" th:field="*{projectDescription}" rows="3"
                                  th:classappend="${#fields.hasErrors('projectDescription')}? 'is-invalid'" required>
                        </textarea>
                        <div id="descriptionError" th:if="${#fields.hasErrors('projectDescription')}"
                             th:errors="*{projectDescription}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-6">
                        <label for="startTime" class="form-label">开始时间</label>
                        <input type="date" class="form-control" id="startTime" th:field="*{startTime}"
                               th:classappend="${#fields.hasErrors('startTime')}? 'is-invalid'">
                        <div id="startTimeError" th:if="${#fields.hasErrors('startTime')}"
                             th:errors="*{startTime}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-6">
                        <label for="completeTime" class="form-label">结束时间</label>
                        <input type="date" class="form-control" id="completeTime" th:field="*{completeTime}"
                               th:classappend="${#fields.hasErrors('completeTime')}? 'is-invalid'">
                        <div id="completeTimeError" th:if="${#fields.hasErrors('completeTime')}"
                             th:errors="*{completeTime}"
                             class="invalid-feedback"></div>
                    </div>

                </div>
                <div class="text-center">
                    <button class="btn btn-primary btn-lg" type="submit"><i class="fas fa-check"></i> 提交</button>
                    <a class="btn btn-secondary btn-lg" th:href="@{/pms/project/list}" href="/pms/project/list"><i class="fas fa-undo-alt"></i> 返回</a>
                </div>
            </form>
        </div>

这一段其实在编辑的时候也一样可以使用,也懒得提取成一个单独的页面了,就这么先放着。

解决时间输入的问题

细心的你会发现上边我使用了input type="date"的输入框,这个在Chrome浏览器中会展示成如下的样子:
fms-project-add
通过调试工具,可以知道实际发送给后端的数据是类似于2021-04-27这样的日期,但是会在验证Bean的时候会提示错误,其实就是无法把字符串形式的日期转换成Date对象,后来经过寻找资料,发现了一个解决方式,就是在Project的字段上加上一个特别的注解:

    @Temporal(TemporalType.DATE)
    @Column(name = "start_time")
    @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE)
    private Date startTime;

    @Temporal(TemporalType.DATE)
    @Column(name = "complete_time")
    @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE)
    private Date completeTime;

这个注解是由Spring提供的,其中的pattern就是使用ISO格式的字符串转换的匹配模式,后边可以指定采用ISO格式,这么配置之后,就可以正确的处理日期了。

至于给输入框配上一个第三方的时间日期插件,这个等我有空再做了。

详情页与控制器

详情页暂时只能做一个雏形,后期要在这里把项目所有的指标都展示出来,目前只能先做一部分,控制器很简单,只需要传入项目对象就可以了。先在ProjectService中添加一个方法:

    @Transactional
    public Project findById(int id) {
        return projectDao.findById(id).orElseThrow(() -> new RuntimeException("找不到项目 id=" + id));
    }

之后就弄一个简单的控制器就行:

    //详情页
    @RequestMapping("/detail/{id}")
    public String projectDetail(@PathVariable("id") int id, Model model) {
        Project project = projectService.findById(id);
        model.addAttribute("project", project);
        return "pms/project/projectDetail";
    }

详情页的设计,我苦于没有好灵感。决定拿Bootstrap - starter这个页面改改:

<main class="col-lg-8 mx-auto container">
    <nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb" class="mt-3">
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="/home" th:href="@{/home}" class="link-secondary">首页</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list" th:href="@{/pms/project/list}"
                                                               class="link-secondary">项目管理</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list"
                                                               th:href="${project.absoluteUrl()}"
                                                               class="link-secondary">[[${project.projectName}]]</a>
            </li>
        </ol>
    </nav>
    <h1 th:text="${project.projectName}">项目名称</h1>
    <p class="fs-5 col-md-8">
        项目位于[[${project.projectAddress}]]。项目经理为[[${project.projectManager}]]。[[${project.projectDescription}]]
    </p>
    <hr>

    <div class="row g-5">
        <div class="col-md-9">
            <h2>项目财务指标</h2>
            <table class="table table-sm table-striped">
                <thead>
                <tr>
                    <th>内容</th>
                    <th>金额</th>
                </tr>
                </thead>
                <tbody>
                <tr>
                    <td>总收入</td>
                    <td>112,000,000.00</td>
                </tr>
                <tr>
                    <td>总成本</td>
                    <td>89,000,000.00</td>
                </tr>
                </tbody>
            </table>
        </div>

        <div class="col-md-3">
            <h2>功能导航</h2>
            <ul class="list-group list-group-flush">
                <li class="list-group-item"><a href="/pms/project/detail" th:href="${project.editUrl()}"><i
                        class="fas fa-pencil-alt"></i> 编辑项目信息</a></li>
                <li class="list-group-item"><a href="" class="link-success"><i class="fas fa-chart-line"></i> 项目预算</a>
                </li>
                <li class="list-group-item"><a href="" class="link-success"><i class="fas fa-list"></i> 合同清单</a></li>
                <li class="list-group-item"><a href="" class="link-danger" data-bs-toggle="modal"
                                               data-bs-target="#deleteModal"><i class="fas fa-trash-alt"></i> &nbsp;删除项目</a>
                </li>
            </ul>
        </div>
    </div>

    <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteLabel" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="deleteLabel">删除项目</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    将删除[[${project.projectName}]]
                </div>
                <form class="modal-footer" method="post" action="/pms/project/delete" th:action="@{/pms/project/delete}">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="fas fa-undo"></i>
                        返回
                    </button>
                    <input type="hidden" name="deleteId" th:value="${project.id}">
                    <input type="hidden" name="name" th:value="${project.projectName}">
                    <button type="submit" class="btn btn-danger"><i class="fas fa-info-circle"></i>
                        确定删除
                    </button>
                </form>
            </div>
        </div>
    </div>
</main>

这里我在侧边栏做了功能,以及把删除的模态框都做好了。

修改页与控制器

这个就非常简单了,表单可以直接抄刚才编写好的新建项目,只要把内容渲染一下就行了。

GET控制器如下:

//编辑页的控制器
@GetMapping("/edit/{id}")
public String editPage(@PathVariable("id") int id, Model model) {
    Project project = projectService.findById(id);
    model.addAttribute("project", project);
    return "pms/project/projectEdit";
}

页面就不放出来了,就是拿projectAdd.html页面改一下,记得埋入一个input type="hidden",要注意这个inputname不要是id,否则Spring在组装Project对象的时候会忽略掉。然后将POST地址修改为pms/project/edit/{id}即可。

之后就是POST控制器,从数据库中取出对应的id,然后进行修改即可,我写了个聊胜于无的小校验,就是判断路径中的id和埋入页面的id是否相同:其他代码都很简单:

//接受编辑
@RequestMapping("/edit/{id}")
public String acceptEdit(@Valid @ModelAttribute("project") Project project, BindingResult rs,
                         @PathVariable("id") int id, @RequestParam("hiddenId") int hiddenId,
                         RedirectAttributes attributes) {
    if (id != hiddenId) {
        throw new RuntimeException("POST请求附带信息与请求路径不符:" + id + '|' + hiddenId);
    }

    if (rs.hasErrors()) {
        return "pms/project/projectEdit";
    } else {
        Project projectFromDB = projectService.findById(id);
        projectFromDB.setProjectName(project.getProjectName());
        projectFromDB.setProjectAddress(project.getProjectAddress());
        projectFromDB.setProjectDescription(project.getProjectDescription());
        projectFromDB.setProjectManager(project.getProjectManager());
        projectFromDB.setCompleteTime(project.getCompleteTime());
        projectFromDB.setStartTime(project.getStartTime());
        projectService.save(projectFromDB);
        attributes.addFlashAttribute("updated", project.getProjectName());
        String url = "/pms/project/detail/" + id;
        return "redirect:" + url;
    }
}

这里的最后,我尝试了一下动态返回当前项目的详情页,发现是可以的,只要最后拼接的字符串是redirect:开头就可以,Spring会解析整个字符串。

删除功能

删除功能很简单,只需要把删除功能设置为只有ADMINSUPERUSER才能访问即可,我把功能放在了详情页的右侧,看一下现在的详情页:
fms-project-detail
刚才的详情页中已经包含了模态框的部分,这里只要配上控制器就行了:

//删除项目
@PostMapping("/delete")
public String deleteProject(@RequestParam("deleteId") int id, @RequestParam("name") String projectName, RedirectAttributes attributes) {
    projectService.deleteProject(id);
    attributes.addFlashAttribute("deleted", projectName);
    return "redirect:/pms/project/list";
}

其中使用了业务层新加的方法:

@Transactional
public void deleteProject(int id) {
    projectDao.deleteById(id);
}

这样就完成了项目的删除。还有就是要在Spring Security中配置上对于/pms/project/delete的保护,仅允许SUPERUSER使用:

.antMatchers("/pms/project/delete").hasAnyAuthority("ADMIN", "SUPERUSER")

如果普通用户删除,就会得到403错误,正好被错误页面渲染出来,所以也懒得用检测用户身份的判断了。

总结

应该说剩下的部分,都是重复我们目前编写的增删改查功能而已,只不过查询的时候需要进行的计算更多。但是这次编写,还是有一些重要的经验如下:

  1. 判断当前用户是否为管理员,以及以后需要判断用户的语句,使用一个@ModelAtrribute注解在一个方法上即可,非常方便。
  2. 在增删改之后,都可以使用RedirectAttributes来给列表页传入一个属性,让列表页展示刚才都做了什么,这个功能和Djangomessage功能非常相似。这里就在增删改成功返回之后,都在列表页进行了展示。
  3. 日期和时间的设置,可以采用@DateTimeFormat注解来进行转换。

到这里Project的基础增删改查部分写完了,不过那些聚合计算的方法都还没有编写,这个就留到以后了,之后的工作是继续把骨架撘起来,然后一层一层汇总计算。下一个编写的是预算条目。

LICENSED UNDER CC BY-NC-SA 4.0
Comment