基于 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='工单表';

设计要点

  1. 冗余字段owner_name/contract_no/project_name 故意冗余存一份。工单列表高频展示这些名称,若每次都 JOIN 三张表性能差;冗余后单表查询即可,保存时由 Service 自动回填。
  2. status 不让人工随意维护,而是由进度和验收自动联动(见第六章)。
  3. 建库建表脚本 schema.sqlCREATE TABLE IF NOT EXISTS,配合 application.ymlspring.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 必须同时包含 METHODTYPE。如果只写 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。

解决(双保险)

  1. HTML 声明式 combobox 显式加 method:'get'(覆盖默认):
    <select class="easyui-combobox" data-options="url:'/contract/options',method:'get',valueField:'id',textField:'text'">
    
  2. 后端 /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

解决(两个方案):

  1. 内联 <script>th:inline="none"<script th:inline="none">
  2. 更彻底:把 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.sqlINSERT IGNORE 插入示例数据,重启不重复不覆盖。


十一、总结

这个系统的核心价值不在于功能多复杂,而在于贴合现场交付的真实场景

  1. 工单字段设计:评估/确认工时、是否验收、财审编号这些交付特有字段,是通用工单系统没有的;
  2. 状态联动:进度和验收驱动状态,避免人工维护状态字段出错;
  3. 冗余字段:用空间换查询性能,工单列表高频展示名称不用 JOIN;
  4. Excel 导入导出:现场人员习惯表格操作,按编号 upsert + 按姓名反查的导入体验友好;
  5. 轻量权限:拦截器 + 注解 30 行代码搞定三级分权,不引 Spring Security;
  6. EasyUI 本地化:内网离线部署,无 CDN 依赖。

踩的 6 个坑里,最容易卡人的是 combobox 默认 POST 致 405closed dialog 内下拉为空——这两个问题网上的资料要么没说清楚,要么解决方案不对症,本文给出的是实测有效的方案。

如果你也在做类似的现场交付管理系统,希望这篇能帮到你。完整代码思路已在上文呈现,按章节实现即可复刻。

系统还在不断完善中,目前还有协议缺陷。后续追加更新。


作者:抒言,2026年7月

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐