这个基本上就是老套路了,之前在Django
里写过一遍,这次看看有什么新的想法可以在开发过程中冒出来。上次设计的URL
可以参看这里。
准备工作
准备工作自然是创建Dao
和Service
层,还有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> 新增项目</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
浏览器中会展示成如下的样子:
通过调试工具,可以知道实际发送给后端的数据是类似于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> 删除项目</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"
,要注意这个input
的name
不要是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
会解析整个字符串。
删除功能
删除功能很简单,只需要把删除功能设置为只有ADMIN
或SUPERUSER
才能访问即可,我把功能放在了详情页的右侧,看一下现在的详情页:
刚才的详情页中已经包含了模态框的部分,这里只要配上控制器就行了:
//删除项目
@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
错误,正好被错误页面渲染出来,所以也懒得用检测用户身份的判断了。
总结
应该说剩下的部分,都是重复我们目前编写的增删改查功能而已,只不过查询的时候需要进行的计算更多。但是这次编写,还是有一些重要的经验如下:
- 判断当前用户是否为管理员,以及以后需要判断用户的语句,使用一个
@ModelAtrribute
注解在一个方法上即可,非常方便。 - 在增删改之后,都可以使用
RedirectAttributes
来给列表页传入一个属性,让列表页展示刚才都做了什么,这个功能和Django
的message
功能非常相似。这里就在增删改成功返回之后,都在列表页进行了展示。 - 日期和时间的设置,可以采用
@DateTimeFormat
注解来进行转换。
到这里Project
的基础增删改查部分写完了,不过那些聚合计算的方法都还没有编写,这个就留到以后了,之后的工作是继续把骨架撘起来,然后一层一层汇总计算。下一个编写的是预算条目。