交付通:一个基于 SpringBoot 2.7 + EasyUI 1.11.5 + MyBatis-Plus 实现一个完整的 IT 工单管理系统
基于 SpringBoot 2.7 + EasyUI 1.11.5 + MyBatis-Plus 实现一个完整的 IT 工单管理系统
本文记录从零搭建一个以工单管理为核心的 IT 项目现场管理系统的全过程:技术选型、数据库设计、权限体系、工单状态联动、Excel 导入导出,以及开发过程中踩过的 6 个坑(含 EasyUI combobox 默认 POST 致 405、closed dialog 内下拉为空、Thymeleaf
[[ ]]冲突等)。代码基于 JDK8,可直接运行,文末附完整功能清单与启动方式。
项目预览:
一、为什么自己造一个轮子
市面上的工单系统要么太重(需整套 DevOps 平台),要么太轻(只支持 ticket 流转,缺少项目/合同/工时维度的关联)。现场交付团队的真实诉求其实很朴素:
- 一张工单要能挂到项目和合同上,便于按合同结算工时;
- 要能记录计划/实际起止时间、评估/确认工时、是否验收、财审编号这些交付特有字段;
- 进度变了状态要自动跟着变,不能让人工手动维护「状态」字段;
- 要能批量导入导出 Excel,现场人员习惯在表格里填数据;
- 权限要分管理员/项目经理/成员三级,但又不想为了这点需求引入完整的 Spring Security。
于是有了这个系统——交付通:基于 JDK8 + SpringBoot 2.7 + MyBatis-Plus 3.5 + EasyUI 1.11.5 + Thymeleaf + MySQL,前后端不分离,开箱即用。
二、技术选型
| 层次 | 技术 / 版本 | 选型理由 |
|---|---|---|
| JDK | 1.8 | 交付现场客户环境多为 JDK8,必须向下兼容 |
| 框架 | Spring Boot 2.7.18 | JDK8 能用的最后一个 Spring Boot 2.x 长期版本 |
| 持久层 | MyBatis-Plus 3.5.3.1 | 单表 CRUD 免手写,Lambda 条件构造器省心 |
| 数据库 | MySQL 5.7+ / 8.0 | 通用 |
| 连接池 | Druid 1.2.16 | 监控面板好用 |
| 模板引擎 | Thymeleaf | 与 Spring Boot 天然集成 |
| 前端 UI | jQuery EasyUI 1.11.5(本地化) | 后台管理系统的 datagrid/tabs/dialog 开箱即用,不需要前端工程化 |
| 密码加密 | spring-security-crypto 5.7.11 | 只引 BCrypt 工具包,不引 Security 过滤链 |
| Excel 读写 | Apache POI 4.1.2 | 4.1.2 是兼容 JDK8 的最后版本,5.x 需 JDK9+ |
| 构建 | Maven 3.6+ | 通用 |
重点说一下 EasyUI:在前后端不分离的后台项目里,EasyUI 的 datagrid + 分页 + combobox 组合开发效率极高,配合 Thymeleaf 几乎不用写多少前端代码就能出一个像样的管理后台。代价是要本地化资源、注意它的一些「坑」(后文详述)。
核心依赖 pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.16</version>
</dependency>
<!-- 只引 BCrypt 工具包,不含 Spring Security 过滤链 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>5.7.11</version>
</dependency>
<!-- Excel 导入导出:4.1.2 为兼容 JDK8 的最后版本,5.x 需 JDK9+ -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
</dependencies>
三、系统功能总览
系统以工单管理为核心,向外辐射项目、合同、成员、码值、参数六个模块,互相打通:
| 模块 | 核心能力 |
|---|---|
| 首页仪表盘 | 工单总数/进行中/已验收/工时合计 4 个统计卡 + 待办识别 + 类型分布图 + 即将到期列表 |
| 工单管理(核心) | 18 字段、类型 Tab、前两列冻结、多维筛选、详情弹窗、Excel 导入导出、进度状态联动 |
| 项目管理 | 项目编号/关联合同/项目经理/起止时间/状态,有工单禁止删除 |
| 合同管理 | 含「有效期开始/到期日」、状态过滤(下拉只返回有效合同),有工单禁止删除 |
| 成员管理 | 工号/角色/所属项目/联系方式,名下有工单禁止删除 |
| 码值管理 | sys_dict_type + sys_dict_item 双表,前端 fillCombobox 动态加载 |
| 参数管理 | sys_param 键值对,供各模块读取运行时配置 |
关联关系:工单 ↔ 项目 ↔ 合同 ↔ 成员 互相打通,下拉选择联动,删除有引用校验。
四、数据库设计
共 8 张表,所有表均含 create_time / update_time / deleted(逻辑删除)字段。
| 表名 | 说明 |
|---|---|
work_order |
工单(核心表,18 字段,含冗余 owner_name/contract_no/project_name) |
project |
项目 |
contract |
合同(含 start_date/end_date 有效期) |
member |
成员 |
sys_dict_type |
字典类型 |
sys_dict_item |
字典项(存 item_code,前端按 css_class 渲染徽章) |
sys_user |
系统用户(username + BCrypt password + role) |
sys_param |
系统参数(param_key 唯一) |
核心的 work_order 表结构(交付场景特有字段已标注):
CREATE TABLE IF NOT EXISTS `work_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`ticket_no` VARCHAR(50) NOT NULL COMMENT '工单编号',
`ticket_name` VARCHAR(200) NOT NULL COMMENT '工单名称',
`ticket_type` VARCHAR(20) DEFAULT '需求' COMMENT '类型: 需求/缺陷/运维/OA单',
`is_lead` TINYINT DEFAULT 0 COMMENT '是否主导: 0否 1是',
`owner_id` BIGINT DEFAULT NULL COMMENT '负责人ID',
`owner_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人姓名(冗余)',
`progress` INT DEFAULT 0 COMMENT '当前进度(0-100)',
`plan_start_date` DATE DEFAULT NULL COMMENT '计划开始时间',
`plan_end_date` DATE DEFAULT NULL COMMENT '计划完成时间',
`actual_start_date` DATE DEFAULT NULL COMMENT '实际开始时间',
`actual_end_date` DATE DEFAULT NULL COMMENT '实际完成时间',
`estimate_hours` DECIMAL(8,1) DEFAULT NULL COMMENT '评估工时(人/日)',
`confirm_hours` DECIMAL(8,1) DEFAULT NULL COMMENT '确认工时(人/日)', -- 交付结算用
`contract_id` BIGINT DEFAULT NULL COMMENT '合同ID',
`contract_no` VARCHAR(50) DEFAULT NULL COMMENT '合同编号(冗余)',
`project_id` BIGINT DEFAULT NULL COMMENT '关联项目ID',
`project_name` VARCHAR(200) DEFAULT NULL COMMENT '项目名称(冗余)',
`is_accept` TINYINT DEFAULT 0 COMMENT '是否验收: 0否 1是',
`finance_audit_no` VARCHAR(50) DEFAULT NULL COMMENT '财审编号', -- 交付特有
`status` VARCHAR(20) DEFAULT '待启动' COMMENT '状态: 待启动/进行中/已完成/已验收/已暂停/已取消',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ticket_no` (`ticket_no`),
KEY `idx_project` (`project_id`),
KEY `idx_contract` (`contract_id`),
KEY `idx_owner` (`owner_id`),
KEY `idx_type` (`ticket_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单表';
设计要点:
- 冗余字段:
owner_name/contract_no/project_name故意冗余存一份。工单列表高频展示这些名称,若每次都 JOIN 三张表性能差;冗余后单表查询即可,保存时由 Service 自动回填。 status不让人工随意维护,而是由进度和验收自动联动(见第六章)。- 建库建表脚本
schema.sql用CREATE TABLE IF NOT EXISTS,配合application.yml的spring.sql.init.mode=always,首次启动自动建库建表 + 插入示例数据(INSERT IGNORE,重启不重复不覆盖)。
五、权限体系:拦截器 + 注解 + 枚举(不引 Spring Security)
权限需求很简单:三级角色(管理员/项目经理/成员),不同模块不同操作权限。引入完整的 Spring Security 过滤链太重了,于是自写一套轻量方案。
5.1 角色枚举
public enum Role {
ADMIN("管理员"), // 全部权限
PM("项目经理"), // 工单/项目增删改查,合同/成员查看
MEMBER("成员"); // 仅查看
private final String label;
Role(String label) { this.label = label; }
}
5.2 角色注解 @RequireRole
@Target({ElementType.METHOD, ElementType.TYPE}) // 重点:必须同时含 METHOD 和 TYPE
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
Role[] value();
}
坑点:
@Target必须同时包含METHOD和TYPE。如果只写METHOD,把注解放在 Controller 类上时会编译报错;既要支持类级注解(模块默认权限),又要支持方法级注解(覆盖/放行),两个 Target 都不能少。
5.3 拦截器 LoginInterceptor
@Component
public class LoginInterceptor implements HandlerInterceptor {
public static final String SESSION_USER_KEY = "loginUser";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false);
SysUser user = session == null ? null : (SysUser) session.getAttribute(SESSION_USER_KEY);
// 1. 未登录:AJAX 返回 401 JSON,普通请求重定向登录页
if (user == null) {
String xhr = request.getHeader("X-Requested-With");
if ("XMLHttpRequest".equals(xhr) || request.getRequestURI().startsWith("/api/")) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或会话已过期\"}");
} else {
response.sendRedirect("/login");
}
return false;
}
// 2. 账号被禁用
if (user.getStatus() != null && user.getStatus() == 0) {
session.removeAttribute(SESSION_USER_KEY);
response.sendRedirect("/login?disabled=1");
return false;
}
// 3. 角色校验:方法级注解优先于类级注解
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
RequireRole rr = hm.getMethodAnnotation(RequireRole.class); // 先找方法级
if (rr == null) {
rr = hm.getBeanType().getAnnotation(RequireRole.class); // 再找类级
}
if (rr != null) {
Role current = Role.valueOf(user.getRole());
boolean ok = Arrays.asList(rr.value()).contains(current);
if (!ok) {
// 权限不足:AJAX 返回 403 JSON,普通请求 403 错误页
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}");
return false;
}
}
}
return true;
}
}
5.4 注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/api/login", "/easyui/**", "/css/**", "/js/**", "/error");
}
}
5.5 使用方式:类级 + 方法级配合
@Controller
@RequestMapping("/workorder")
public class WorkOrderController {
// 类级未标注 → 仅需登录,不限角色(工单所有人可看)
@GetMapping("/list")
@ResponseBody
public DataGrid<WorkOrder> list(...) { ... } // 所有人可调用
@PostMapping("/save")
@ResponseBody
@RequireRole({Role.ADMIN, Role.PM}) // 方法级限制 ADMIN+PM
public Result<?> save(WorkOrder workOrder) { ... }
@PostMapping("/import")
@ResponseBody
@RequireRole({Role.ADMIN, Role.PM}) // 导入限 ADMIN+PM
public Result<?> importWorkOrders(@RequestParam("file") MultipartFile file) { ... }
}
权限矩阵:
| 模块 | 查看 | 新增/编辑/删除 | 备注 |
|---|---|---|---|
| 首页仪表盘 | 全部角色 | — | 统计只读 |
| 工单管理 | 全部角色 | ADMIN + PM | 导入限 ADMIN+PM;导出/模板全部角色可用 |
| 项目/合同/成员 | ADMIN + PM | ADMIN + PM | 下拉 options 全部角色可用 |
| 码值/参数 | ADMIN | ADMIN | 取值接口全部角色可用 |
密码用 BCryptPasswordEncoder 加密存储,账号由 DataInitializer 首次启动自动创建(admin/admin123、pm/pm123、member/member123)。
六、工单核心:状态联动 + 冗余回填
工单的 status 字段不让用户手动维护,而是根据进度和是否验收自动联动。这是这套系统最贴合现场交付习惯的设计:
@Override
public boolean saveOrUpdateWorkOrder(WorkOrder wo) {
// 1. 回填负责人姓名(冗余字段)
if (wo.getOwnerId() != null) {
Member m = memberMapper.selectById(wo.getOwnerId());
if (m != null) wo.setOwnerName(m.getMemberName());
} else {
wo.setOwnerName(null);
}
// 2. 回填项目名
if (wo.getProjectId() != null) {
Project p = projectMapper.selectById(wo.getProjectId());
if (p != null) wo.setProjectName(p.getProjectName());
}
// 3. 回填合同编号
if (wo.getContractId() != null) {
Contract c = contractMapper.selectById(wo.getContractId());
if (c != null) wo.setContractNo(c.getContractNo());
}
// 4. 进度与状态、验收联动
Integer progress = wo.getProgress() == null ? 0 : wo.getProgress();
if (wo.getIsAccept() != null && wo.getIsAccept() == 1) {
// 已验收 → 进度强制 100、状态已验收
wo.setProgress(100);
wo.setStatus("已验收");
if (wo.getActualEndDate() == null) wo.setActualEndDate(LocalDate.now());
} else {
wo.setIsAccept(0);
if (progress >= 100) {
wo.setStatus("已完成");
} else if (progress > 0) {
wo.setStatus("进行中");
if (wo.getActualStartDate() == null) wo.setActualStartDate(LocalDate.now());
} else {
wo.setStatus("待启动");
wo.setActualStartDate(null);
}
}
return this.saveOrUpdate(wo);
}
联动规则:
| 条件 | 结果 |
|---|---|
| 勾选验收 | 进度→100、状态→已验收、自动回填实际完成时间 |
| 进度 = 100(未验收) | 状态→已完成 |
| 0 < 进度 < 100 | 状态→进行中、自动回填实际开始时间 |
| 进度 = 0 | 状态→待启动、清空实际开始时间 |
这样现场人员只需要填「进度」和「是否验收」两个直观的字段,状态、实际起止时间都自动维护,避免人工填错。
七、工单 Excel 导入导出
基于 Apache POI 实现,三个接口:
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /workorder/export |
按查询条件导出全部为 .xlsx | 全部角色 |
| GET | /workorder/template |
下载导入模板 | 全部角色 |
| POST | /workorder/import |
导入 Excel(MultipartFile) | ADMIN+PM |
7.1 导出:SXSSF 流式写入
导出用 SXSSFWorkbook(流式,窗口 100 行),适合大数据量,不会 OOM:
public static void writeExcel(List<WorkOrder> list, OutputStream out) throws IOException {
SXSSFWorkbook wb = new SXSSFWorkbook(100); // 窗口 100 行,超出写磁盘
try {
Sheet sheet = wb.createSheet("工单列表");
// ... 表头样式(蓝底白字)、正文样式 ...
Row head = sheet.createRow(0);
for (int i = 0; i < HEADERS.length; i++) {
Cell c = head.createCell(i);
c.setCellValue(HEADERS[i]);
c.setCellStyle(headStyle);
}
for (int r = 0; r < list.size(); r++) {
WorkOrder w = list.get(r);
Row row = sheet.createRow(r + 1);
setCell(row, 0, w.getTicketNo(), bodyStyle);
// ... 其余 17 个字段 ...
}
sheet.createFreezePane(0, 1); // 冻结表头
wb.write(out);
} finally {
wb.dispose(); // 清理临时文件
wb.close();
}
}
表头与字段一一对应,固定 18 列:
public static final String[] HEADERS = {
"工单编号", "工单名称", "类型", "是否主导", "负责人", "当前进度",
"计划开始", "计划完成", "实际开始", "实际完成",
"评估工时", "确认工时", "合同编号", "关联项目",
"是否验收", "财审编号", "状态", "备注"
};
7.2 导入:按表头文本匹配列 + 按编号 upsert
导入的关键设计:按表头文本匹配列索引,而不是固定列顺序。这样用户即使调整了列的顺序,导入依然能正确识别。
public static List<Map<String, String>> readExcel(InputStream in) throws IOException {
List<Map<String, String>> result = new ArrayList<>();
XSSFWorkbook wb = new XSSFWorkbook(in);
try {
Sheet sheet = wb.getSheetAt(0);
Row head = sheet.getRow(0);
// 建立列索引 → 字段 key 的映射(按表头文本匹配)
int[] colIndex = new int[FIELDS.length];
Arrays.fill(colIndex, -1);
for (int c = 0; c < head.getLastCellNum(); c++) {
String h = head.getCell(c).getStringCellValue().trim();
for (int i = 0; i < HEADERS.length; i++) {
if (HEADERS[i].equals(h)) { colIndex[i] = c; break; }
}
}
// 从第 2 行开始读,跳过空行
for (int r = 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
Map<String, String> map = new HashMap<>();
boolean hasData = false;
for (int i = 0; i < FIELDS.length; i++) {
String v = colIndex[i] >= 0 ? readCellAsString(row.getCell(colIndex[i])) : "";
map.put(FIELDS[i], v);
if (!v.isEmpty()) hasData = true;
}
if (hasData) result.add(map);
}
return result;
} finally { wb.close(); }
}
读取单元格时兼容数值/日期/布尔/公式四种类型:
private static String readCellAsString(Cell cell) {
switch (cell.getCellType()) {
case STRING: return cell.getStringCellValue();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue());
}
double d = cell.getNumericCellValue();
return d == Math.floor(d) ? String.valueOf((long) d) : BigDecimal.valueOf(d).toPlainString();
case BOOLEAN: return cell.getBooleanCellValue() ? "是" : "否";
case FORMULA: /* 先试字符串再试数值 */
default: return "";
}
}
Service 层导入逻辑:按工单编号 upsert(已存在则更新,否则新增),按姓名/编号/名称反查关联,状态交给 saveOrUpdateWorkOrder 自动联动:
@Override
public Map<String, Object> importWorkOrders(InputStream in) throws Exception {
List<Map<String, String>> rows = WorkOrderExcelHelper.readExcel(in);
int success = 0, fail = 0;
List<String> errors = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
int rowNum = i + 2; // Excel 行号(第1行表头)
Map<String, String> m = rows.get(i);
try {
String ticketNo = m.get("ticketNo");
if (ticketNo == null || ticketNo.trim().isEmpty())
throw new RuntimeException("工单编号不能为空");
WorkOrder wo = new WorkOrder();
// 工单编号已存在 → 视为更新
WorkOrder exist = this.getOne(new LambdaQueryWrapper<WorkOrder>()
.eq(WorkOrder::getTicketNo, ticketNo.trim()).last("LIMIT 1"));
if (exist != null) wo.setId(exist.getId());
wo.setTicketNo(ticketNo.trim());
wo.setTicketName(m.get("ticketName").trim());
wo.setProgress(parseIntSafe(m.get("progress"), 0));
wo.setIsAccept(parseYesNo(m.get("isAccept"))); // 是/否/1/0 → 1/0
// ... 日期、工时等字段 ...
// 反查负责人(按姓名)
String ownerName = emptyToNull(m.get("ownerName"));
if (ownerName != null) {
Member mem = memberMapper.selectOne(new LambdaQueryWrapper<Member>()
.eq(Member::getMemberName, ownerName).last("LIMIT 1"));
if (mem != null) wo.setOwnerId(mem.getId());
}
// 反查合同(按编号)、项目(按名称)同理 ...
this.saveOrUpdateWorkOrder(wo); // 复用状态联动逻辑
success++;
} catch (Exception e) {
fail++;
errors.add("第" + rowNum + "行:" + e.getMessage());
}
}
return Map.of("success", success, "fail", fail, "errors", errors);
}
导入体验:返回成功/失败行数 + 错误明细(精确到第几行什么原因),前端弹窗展示。匹配不到的人/项目/合同留空不报错,保证导入流程不中断。
7.3 模板:说明放独立 sheet
导入模板里,填写说明放在独立的 sheet2,而不是 sheet1 的末尾。这样导入读 sheet1 时不会被说明文字误判成数据行:
Sheet sheet = wb.createSheet("工单导入模板");
// ... 表头 + 一行示例 ...
Sheet noteSheet = wb.createSheet("填写说明"); // 独立 sheet
String[] notes = {
"【填写说明】",
"1. 工单编号、工单名称为必填项。",
"2. 类型请填写:需求 / 缺陷 / 运维 / OA单。",
"3. 是否主导、是否验收:填 是 或 否(也支持 1/0)。",
"4. 状态可不填,系统按进度与验收自动联动。",
"5. 工单编号若已存在则更新该条,不存在则新增。"
// ...
};
八、统一码值管理
码值用 sys_dict_type + sys_dict_item 双表管理,预置 6 类:work_order_status / ticket_type / project_status / contract_status / member_role / yes_no。
前端封装 fillCombobox(selector, type, includeAll) 统一加载下拉:
function fillCombobox(selector, type, includeAll) {
var data = loadDict(type); // AJAX 拉取字典项并缓存
if (includeAll) {
data = [{code: '', name: '全部'}].concat(data);
}
$(selector).combobox({
valueField: 'code',
textField: 'name',
data: data,
panelHeight: 'auto',
editable: false
});
}
字典项表里有个 css_class 字段,状态徽章的样式与字典联动——比如「进行中」渲染成蓝色徽章、「已验收」渲染成绿色徽章,前端通过 dictCssClass(type, code) 查字典项的 css_class 动态渲染:
function dictCssClass(type, code) {
var items = loadDict(type);
for (var i = 0; i < items.length; i++) {
if (items[i].code == code) return items[i].cssClass;
}
return '';
}
好处:状态颜色不用硬编码在前端,运维人员改字典项的 css_class 就能调整徽章样式。
九、踩坑实录(重点)
这一章是开发过程中真实遇到的坑,每一个都值得单独记一笔。
坑 1:EasyUI CDN 资源被拦截 → 本地化
最初图省事用 cdnjs.bootcdn 引 EasyUI,结果内网环境 CDN 被拦截,页面加载不出 datagrid($(...).datagrid is not a function)。
解决:把 EasyUI 1.11.5 整套资源下载到 src/main/resources/static/easyui/,引用路径统一改为 @{/easyui/themes/bootstrap/easyui.css}。注意 1.11.5 的 CSS 文件名是 easyui.css(非 easyui.min.css),自带 jQuery 4.0。本地化后无 CDN 依赖,内网可离线部署。
坑 2:EasyUI combobox 默认 POST 致 405
下拉选项接口 /xxx/options 最初写成 @GetMapping,前端 combobox 加载时报 405 Method Not Allowed。
排查发现:EasyUI 1.11.5 的 combobox method 默认是 "post"(不是 get!见 jquery.easyui.min.js 第 16446 行 method:"post")。combobox 用 url 自动加载、或 reload(url) 时都用 POST 请求,后端只写 @GetMapping 自然 405。
解决(双保险):
- HTML 声明式 combobox 显式加
method:'get'(覆盖默认):<select class="easyui-combobox" data-options="url:'/contract/options',method:'get',valueField:'id',textField:'text'"> - 后端
/options用@RequestMapping(同时支持 GET/POST):@RequestMapping("/options") @ResponseBody public List<Option> options() { ... }
坑 3:closed dialog 内 combobox 下拉为空
项目编辑弹窗里的「关联合同」下拉一直为空,但后端接口返回数据正常。
根因:EasyUI 在 closed:true 的 dialog 内,声明式 combobox 的 url 请求触发不可靠,dialog 没渲染完 combobox 就初始化了,导致 url 没发出去。
解决:在各页面 JS 的 $(function(){}) 里,对 dialog 内的 url 型 combobox 显式 reload 一次:
$(function(){
// dialog 内的 combobox 显式 reload,避免 closed 状态下 url 不触发
$('#f_contractId').combobox('reload', '/contract/options');
});
坑 4:Thymeleaf 把 [[ ]] 当内联表达式
EasyUI datagrid 的 frozenColumns:[[...]] / columns:[[...]] 是二维数组,写在模板的内联 <script> 里时,Thymeleaf 把 [[ ]] 当成内联表达式解析,报 Could not parse as expression。
解决(两个方案):
- 内联
<script>加th:inline="none":<script th:inline="none"> - 更彻底:把 JS 抽离成独立
.js文件,模板里只<script th:src="@{/js/xxx.js}">引用。独立 JS 文件不经过 Thymeleaf 解析,[[ ]]自然不会冲突。
本项目采用了方案 2,各页面 JS 全部抽离到 static/js/{workorder,project,contract,member,dict,param,index,main,login,common}.js。
坑 5:Apache POI 版本与 JDK8
最初用了 POI 5.x,编译报错。查文档发现 POI 5.x 需要 JDK9+,JDK8 能用的最后版本是 4.1.2。降回 4.1.2 后正常。
<!-- 4.1.2 为兼容 JDK8 的最后版本,5.x 需 JDK9+ -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
坑 6:改了模板浏览器不生效
明明改了 src/main/resources/templates/workorder/list.html,刷新浏览器还是旧页面。
根因:Thymeleaf cache:false 不缓存解析结果,但 Spring Boot 运行时从 target/classes/templates/ 读模板,不是 src/main/resources/templates/。只改源文件不重新编译,target 仍是旧文件。
解决:改了模板或静态资源后,执行 mvn resources:resources 把最新文件复制到 target,再刷新浏览器。改了 Java 类或 pom 依赖则需重新编译并重启应用。
# 只改模板/静态资源
mvn resources:resources
# 改了 Java 类
mvn clean compile # 然后重启应用
十、效果与功能清单
系统最终实现的功能:
- ✅ 登录与三级角色权限(自写拦截器 + 注解,不引 Spring Security)
- ✅ 首页仪表盘(统计卡 + 待办 + 类型分布图 + 即将到期列表)
- ✅ 工单管理(18 字段、Tab 分类、冻结前两列、多维筛选、详情弹窗)
- ✅ 工单进度与状态自动联动 + 冗余字段自动回填
- ✅ 工单 Excel 导入/导出/模板下载(POI 流式 + 按编号 upsert + 按姓名反查)
- ✅ 项目/合同/成员管理(关联打通 + 删除引用校验)
- ✅ 统一码值管理(双表 + 前端 fillCombobox + 状态徽章联动)
- ✅ 键值对参数管理
- ✅ EasyUI 本地化(无 CDN 依赖,内网可部署)
- ✅ 逻辑删除 + 自动填充时间
启动方式
# 1. 配置数据库连接(src/main/resources/application.yml)
# 2. 编译启动
mvn spring-boot:run
# 或打包运行
mvn clean package -DskipTests
java -jar target/it-field-mgmt.jar
# 3. 访问 http://localhost:9092
默认账号:admin/admin123(管理员)| pm/pm123(项目经理)| member/member123(成员)
数据库
it_field_mgmt无需手动创建,schema.sql首次启动自动建库建表,data.sql用INSERT IGNORE插入示例数据,重启不重复不覆盖。
十一、总结
这个系统的核心价值不在于功能多复杂,而在于贴合现场交付的真实场景:
- 工单字段设计:评估/确认工时、是否验收、财审编号这些交付特有字段,是通用工单系统没有的;
- 状态联动:进度和验收驱动状态,避免人工维护状态字段出错;
- 冗余字段:用空间换查询性能,工单列表高频展示名称不用 JOIN;
- Excel 导入导出:现场人员习惯表格操作,按编号 upsert + 按姓名反查的导入体验友好;
- 轻量权限:拦截器 + 注解 30 行代码搞定三级分权,不引 Spring Security;
- EasyUI 本地化:内网离线部署,无 CDN 依赖。
踩的 6 个坑里,最容易卡人的是 combobox 默认 POST 致 405 和 closed dialog 内下拉为空——这两个问题网上的资料要么没说清楚,要么解决方案不对症,本文给出的是实测有效的方案。
如果你也在做类似的现场交付管理系统,希望这篇能帮到你。完整代码思路已在上文呈现,按章节实现即可复刻。
系统还在不断完善中,目前还有协议缺陷。后续追加更新。
作者:抒言,2026年7月
更多推荐



所有评论(0)