Browse Source

新增大屏,新增大屏管理

hou 1 tuần trước cách đây
mục cha
commit
2bcb117234
27 tập tin đã thay đổi với 2730 bổ sung và 972 xóa
  1. 54 0
      db/mysql/mes_screen.sql
  2. 30 0
      db/mysql/mes_screen_manage_menu.sql
  3. 25 4
      src/main/java/com/jeesite/modules/interceptor/MesClientAccessLogInterceptor.java
  4. 13 0
      src/main/java/com/jeesite/modules/mes/dao/MesScreenDao.java
  5. 79 0
      src/main/java/com/jeesite/modules/mes/entity/MesScreen.java
  6. 48 0
      src/main/java/com/jeesite/modules/mes/service/MesScreenService.java
  7. 1 3
      src/main/java/com/jeesite/modules/mes/web/MesMaterialController.java
  8. 344 0
      src/main/java/com/jeesite/modules/mes/web/MesProductController.java
  9. 77 0
      src/main/java/com/jeesite/modules/mes/web/MesScreenController.java
  10. 3 0
      src/main/resources/config/application.yml
  11. 5 0
      src/main/resources/mappings/modules/mes/MesScreenDao.xml
  12. 274 0
      src/main/resources/static/screen/exec-screen.css
  13. 316 0
      src/main/resources/static/screen/exec-screen.js
  14. 264 0
      src/main/resources/static/screen/kpi-screen.css
  15. 342 0
      src/main/resources/static/screen/kpi-screen.js
  16. 56 0
      src/main/resources/static/screen/screen-fullscreen.js
  17. 17 5
      src/main/resources/views/modules/mes/mesMaterialCheckRecordList.html
  18. 150 0
      src/main/resources/views/modules/mes/mesScreen10.html
  19. 84 0
      src/main/resources/views/modules/mes/mesScreen11.html
  20. 385 960
      src/main/resources/views/modules/mes/mesScreen4.html
  21. 1 0
      src/main/resources/views/modules/mes/mesScreen5.html
  22. 1 0
      src/main/resources/views/modules/mes/mesScreen6.html
  23. 1 0
      src/main/resources/views/modules/mes/mesScreen7.html
  24. 1 0
      src/main/resources/views/modules/mes/mesScreen8.html
  25. 1 0
      src/main/resources/views/modules/mes/mesScreen9.html
  26. 87 0
      src/main/resources/views/modules/mes/mesScreenForm.html
  27. 71 0
      src/main/resources/views/modules/mes/mesScreenList.html

+ 54 - 0
db/mysql/mes_screen.sql

@@ -0,0 +1,54 @@
+-- 大屏目录:名称、地址、说明,供大屏管理新增和删除
+
+CREATE TABLE IF NOT EXISTS `mes_screen` (
+  `id` varchar(64) NOT NULL,
+  `title` varchar(100) DEFAULT '' COMMENT '名称',
+  `screen_url` varchar(255) DEFAULT '' COMMENT '地址',
+  `remark` varchar(255) DEFAULT '' COMMENT '说明',
+  `sort_no` int(11) DEFAULT 30 COMMENT '排序',
+  `status` char(1) DEFAULT '0' COMMENT '状态(0正常 1删除 2停用)',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_date` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_date` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大屏目录';
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051001', '综合数据大屏', '/mes/mesProduct/screen', '车间总览,看产量、工位和报警。', 10, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051001');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051002', '生产运营看板', '/mes/mesProduct/screen10', '现场远看。达成率、直通率、OEE、班次报工和分段完工。', 20, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051002');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051003', '生产简报', '/mes/mesProduct/screen11', '领导近看。今天完成多少、质量是否稳、近七日走势,以及该过问的事项。', 30, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051003');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051004', '返修区看板', '/mes/mesProduct/screen9', '待返修、待检查、今日完成和返修清单。', 40, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051004');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051005', 'GP12不良问题点看板', '/mes/mesProduct/screen8', 'GP12 检验项目、判定标准和不良问题点。', 50, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051005');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051006', '加工记录(OP010-OP100)', '/mes/mesProduct/screen5', '前段工位加工数量,默认工位可在屏上调整。', 60, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051006');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051007', '加工记录(OP110-OP190)', '/mes/mesProduct/screen6', '中段工位加工数量,默认工位可在屏上调整。', 70, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051007');
+
+INSERT INTO `mes_screen` (`id`, `title`, `screen_url`, `remark`, `sort_no`, `status`, `create_by`, `create_date`, `update_by`, `update_date`)
+SELECT '2099051008', '加工记录(OP200-OP360)', '/mes/mesProduct/screen7', '后段工位加工数量,默认工位可在屏上调整。', 80, '0', 'system', NOW(), 'system', NOW()
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `mes_screen` WHERE `id` = '2099051008');
+
+UPDATE `js_sys_menu`
+SET `menu_href` = '/mes/mesScreen/list',
+    `permission` = 'mes:mesScreen:view,mes:mesScreen:edit',
+    `update_by` = 'system',
+    `update_date` = NOW()
+WHERE `menu_code` = '2099051000000000001';

+ 30 - 0
db/mysql/mes_screen_manage_menu.sql

@@ -0,0 +1,30 @@
+-- 生产线管理 / 大屏管理:集中打开各块大屏
+-- 挂在「生产线管理」下,授权给已经能看「MES大屏」的角色
+
+DELETE FROM `js_sys_role_menu` WHERE `menu_code` = '2099051000000000001';
+DELETE FROM `js_sys_menu` WHERE `menu_code` = '2099051000000000001';
+
+INSERT INTO `js_sys_menu`
+(`menu_code`, `parent_code`, `parent_codes`, `tree_sort`, `tree_sorts`, `tree_leaf`, `tree_level`,
+ `tree_names`, `menu_name`, `menu_type`, `menu_href`, `menu_target`, `menu_icon`, `menu_color`, `menu_title`,
+ `permission`, `weight`, `is_show`, `sys_code`, `module_codes`, `component`, `params`, `status`,
+ `create_by`, `create_date`, `update_by`, `update_date`, `remarks`)
+SELECT
+'2099051000000000001', p.`menu_code`, CONCAT(p.`parent_codes`, p.`menu_code`, ','),
+400, CONCAT(p.`tree_sorts`, '0000000400,'), '1', p.`tree_level` + 1,
+CONCAT(p.`tree_names`, '/大屏管理'), '大屏管理', '1',
+'/mes/mesScreen/list', '', 'fa fa-desktop', '', '',
+'mes:mesScreen:view,mes:mesScreen:edit', 40, '1', 'default', 'core', NULL, NULL, '0',
+'system', NOW(), 'system', NOW(), '集中打开各块大屏'
+FROM `js_sys_menu` p
+WHERE p.`menu_code` = '1680784143702568960';
+
+INSERT INTO `js_sys_role_menu` (`role_code`, `menu_code`)
+SELECT rm.`role_code`, '2099051000000000001'
+FROM `js_sys_role_menu` rm
+WHERE rm.`menu_code` = '1915638797464936448'
+  AND NOT EXISTS (
+    SELECT 1 FROM `js_sys_role_menu` x
+    WHERE x.`role_code` = rm.`role_code`
+      AND x.`menu_code` = '2099051000000000001'
+  );

+ 25 - 4
src/main/java/com/jeesite/modules/interceptor/MesClientAccessLogInterceptor.java

@@ -19,7 +19,8 @@ import com.jeesite.modules.sys.utils.LogUtils;
 import com.jeesite.modules.sys.utils.UserUtils;
 
 /**
- * 记录工位/客户端等免登录接口访问日志(操作人标记为「未登录客户端」)。
+ * 记录工位/客户端等免登录接口访问日志。
+ * 操作账号优先取请求参数 ucode,没有则标记为 client / 「未登录客户端」。
  */
 @Component
 public class MesClientAccessLogInterceptor implements HandlerInterceptor {
@@ -150,9 +151,7 @@ public class MesClientAccessLogInterceptor implements HandlerInterceptor {
 				start = startObj;
 			}
 			long cost = start > 0 ? (System.currentTimeMillis() - start) : 0L;
-			User client = new User();
-			client.setUserCode(USER_CODE);
-			client.setUserName(USER_NAME);
+			User client = resolveClientUser(request);
 			LogUtils.saveLog(client, request, handler, ex, resolveTitle(request), LOG_TYPE, cost);
 		} catch (Exception e) {
 			logger.debug("记录客户端访问日志失败: {}", e.getMessage());
@@ -161,6 +160,28 @@ public class MesClientAccessLogInterceptor implements HandlerInterceptor {
 		}
 	}
 
+	private User resolveClientUser(HttpServletRequest request) {
+		User client = new User();
+		String ucode = request.getParameter("ucode");
+		if (StringUtils.isBlank(ucode)) {
+			client.setUserCode(USER_CODE);
+			client.setUserName(USER_NAME);
+			return client;
+		}
+		ucode = ucode.trim();
+		client.setUserCode(ucode);
+		client.setUserName(USER_NAME);
+		try {
+			User realUser = UserUtils.get(ucode);
+			if (realUser != null && StringUtils.isNotBlank(realUser.getUserName())) {
+				client.setUserName(realUser.getUserName());
+			}
+		} catch (Exception ignored) {
+			// 免登录请求可能查不到用户,操作人员仍记为「未登录客户端」
+		}
+		return client;
+	}
+
 	private String resolveTitle(HttpServletRequest request) {
 		String uri = request.getRequestURI();
 		if (uri == null) {

+ 13 - 0
src/main/java/com/jeesite/modules/mes/dao/MesScreenDao.java

@@ -0,0 +1,13 @@
+package com.jeesite.modules.mes.dao;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mes.entity.MesScreen;
+
+/**
+ * 大屏目录DAO
+ */
+@MyBatisDao
+public interface MesScreenDao extends CrudDao<MesScreen> {
+
+}

+ 79 - 0
src/main/java/com/jeesite/modules/mes/entity/MesScreen.java

@@ -0,0 +1,79 @@
+package com.jeesite.modules.mes.entity;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+
+import com.jeesite.common.entity.DataEntity;
+import com.jeesite.common.mybatis.annotation.Column;
+import com.jeesite.common.mybatis.annotation.Table;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
+
+/**
+ * 大屏目录
+ */
+@Table(name="mes_screen", alias="a", label="大屏信息", columns={
+		@Column(name="id", attrName="id", label="id", isPK=true),
+		@Column(name="title", attrName="title", label="名称", queryType=QueryType.LIKE),
+		@Column(name="screen_url", attrName="screenUrl", label="地址"),
+		@Column(name="remark", attrName="remark", label="说明", queryType=QueryType.LIKE),
+		@Column(name="sort_no", attrName="sortNo", label="排序"),
+		@Column(name="status", attrName="status", label="状态", isUpdate=false),
+		@Column(name="create_by", attrName="createBy", label="创建人", isUpdate=false, isQuery=false),
+		@Column(name="create_date", attrName="createDate", label="创建时间", isUpdate=false, isQuery=false, isUpdateForce=true),
+		@Column(name="update_by", attrName="updateBy", label="更新人", isQuery=false),
+		@Column(name="update_date", attrName="updateDate", label="更新时间", isQuery=false, isUpdateForce=true),
+	}, orderBy="a.sort_no ASC, a.update_date DESC"
+)
+public class MesScreen extends DataEntity<MesScreen> {
+
+	private static final long serialVersionUID = 1L;
+	private String title;
+	private String screenUrl;
+	private String remark;
+	private Integer sortNo;
+
+	public MesScreen() {
+		this(null);
+	}
+
+	public MesScreen(String id) {
+		super(id);
+	}
+
+	@NotBlank(message="名称不能为空")
+	@Size(max=100, message="名称长度不能超过 100 个字符")
+	public String getTitle() {
+		return title;
+	}
+
+	public void setTitle(String title) {
+		this.title = title;
+	}
+
+	@NotBlank(message="地址不能为空")
+	@Size(max=255, message="地址长度不能超过 255 个字符")
+	public String getScreenUrl() {
+		return screenUrl;
+	}
+
+	public void setScreenUrl(String screenUrl) {
+		this.screenUrl = screenUrl;
+	}
+
+	@Size(max=255, message="说明长度不能超过 255 个字符")
+	public String getRemark() {
+		return remark;
+	}
+
+	public void setRemark(String remark) {
+		this.remark = remark;
+	}
+
+	public Integer getSortNo() {
+		return sortNo;
+	}
+
+	public void setSortNo(Integer sortNo) {
+		this.sortNo = sortNo;
+	}
+}

+ 48 - 0
src/main/java/com/jeesite/modules/mes/service/MesScreenService.java

@@ -0,0 +1,48 @@
+package com.jeesite.modules.mes.service;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.service.CrudService;
+import com.jeesite.modules.mes.dao.MesScreenDao;
+import com.jeesite.modules.mes.entity.MesScreen;
+
+/**
+ * 大屏目录Service
+ */
+@Service
+public class MesScreenService extends CrudService<MesScreenDao, MesScreen> {
+
+	@Override
+	public MesScreen get(MesScreen mesScreen) {
+		return super.get(mesScreen);
+	}
+
+	@Override
+	public Page<MesScreen> findPage(MesScreen mesScreen) {
+		return super.findPage(mesScreen);
+	}
+
+	@Override
+	public List<MesScreen> findList(MesScreen mesScreen) {
+		return super.findList(mesScreen);
+	}
+
+	@Override
+	@Transactional
+	public void save(MesScreen mesScreen) {
+		if (mesScreen.getSortNo() == null) {
+			mesScreen.setSortNo(30);
+		}
+		super.save(mesScreen);
+	}
+
+	@Override
+	@Transactional
+	public void delete(MesScreen mesScreen) {
+		super.delete(mesScreen);
+	}
+}

+ 1 - 3
src/main/java/com/jeesite/modules/mes/web/MesMaterialController.java

@@ -8,8 +8,6 @@ import com.alibaba.fastjson.JSONValidator;
 import com.jeesite.common.codec.EncodeUtils;
 import com.jeesite.common.lang.DateUtils;
 import com.jeesite.common.utils.excel.ExcelExport;
-import com.jeesite.modules.mes.entity.MesDevicePrevent;
-import com.jeesite.modules.mes.entity.MesNg;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
@@ -132,7 +130,7 @@ public class MesMaterialController extends BaseController {
 	public void exportData(MesMaterial mesMaterial, HttpServletResponse response) {
 		List<MesMaterial> list = mesMaterialService.findList(mesMaterial);
 		String fileName = "物料表" + DateUtils.getDate("yyyyMMddHHmmss") + ".xlsx";
-		try(ExcelExport ee = new ExcelExport("物料表", MesNg.class)){
+		try(ExcelExport ee = new ExcelExport("物料表", MesMaterial.class)){
 			ee.setDataList(list).write(response, fileName);
 		}
 	}

+ 344 - 0
src/main/java/com/jeesite/modules/mes/web/MesProductController.java

@@ -4,6 +4,7 @@ import java.text.DateFormat;
 import java.text.DecimalFormat;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import javax.servlet.http.HttpServletRequest;
@@ -91,6 +92,8 @@ public class MesProductController extends BaseController {
 	private MesRepairService mesRepairService;
 	@Autowired
 	private FileUploadService fileUploadService;
+	@Autowired
+	private MesPlanService mesPlanService;
 
 
 	/**
@@ -330,6 +333,21 @@ public class MesProductController extends BaseController {
 		return "modules/mes/mesScreen9";
 	}
 
+	@RequestMapping(value = "screen10")
+	public String screen10(Model model) {
+		return "modules/mes/mesScreen10";
+	}
+
+	@RequestMapping(value = "screen11")
+	public String screen11(Model model) {
+		return "modules/mes/mesScreen11";
+	}
+
+	@RequestMapping(value = "screenManage")
+	public String screenManage() {
+		return REDIRECT + adminPath + "/mes/mesScreen/list";
+	}
+
 	/**
 	 * 返修区大屏数据:汇总统计 + 待处理返修清单(数据来源 mes_repair)
 	 * state: 1=待返修 2=待检查 3=已完成 4=返修NG待处理
@@ -978,4 +996,330 @@ public class MesProductController extends BaseController {
 		resp.setData(map);
 		return resp;
 	}
+
+	/**
+	 * 生产运营看板:达成率、直通率、OEE、班次报工。
+	 * 统计日为当天 08:00 至次日 08:00;8 点前归入上一统计日。
+	 * 达成率 = 完工 / 班次计划;直通率 = 工位合格 / (合格 + 不良);
+	 * OEE = 时间稼动 × 性能稼动 × 合格率。时间稼动按未关闭报警占在用工位的比例折算。
+	 */
+	@RequestMapping(value = "screenKpiData")
+	@ResponseBody
+	public CommonResp screenKpiData() {
+		CommonResp<Map<String, Object>> resp = new CommonResp<>();
+		LocalDateTime now = LocalDateTime.now();
+		LocalDate day = now.getHour() < 8 ? now.toLocalDate().minusDays(1) : now.toLocalDate();
+		String dayStr = day.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+		String dayStart = dayStr + " 08:00:00";
+		String dayEnd = day.plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " 08:00:00";
+
+		List<MesPlan> todayPlans = loadPlans(dayStr);
+		long plan = sumPlanNums(todayPlans, null);
+		long actual = countFinished(dayStart, dayEnd, null);
+		long repair = countRepair(dayStart, dayEnd);
+		long ok = countCraftResult("OK", dayStart, dayEnd);
+		long ng = countCraftResult("NG", dayStart, dayEnd);
+		double achieve = ratio(actual, plan, false);
+		double fpy = firstPassRate(ok, ng, actual, repair);
+		double performance = plan <= 0 ? 0D : ratio(actual, plan, true);
+		long openAlarm = countOpenAlarm();
+		long stations = countStations();
+		double availability = availabilityRate(openAlarm, stations);
+		double oee = round1(availability * performance * fpy / 10000D);
+
+		MesShiftDataResp curShift = mesShiftService.getCurShift();
+		String curShiftId = curShift == null || curShift.getMesShift() == null ? "" : curShift.getMesShift().getId();
+		String curShiftTitle = curShift == null || curShift.getMesShift() == null ? "未排班" : curShift.getMesShift().getTitle();
+
+		MesShift shiftQuery = new MesShift();
+		shiftQuery.setStatus("0");
+		List<MesShift> shifts = mesShiftService.findList(shiftQuery);
+		if (shifts == null) {
+			shifts = ListUtils.newArrayList();
+		}
+		shifts.sort(Comparator.comparing(item -> hourMinute(item.getStart())));
+
+		List<Map<String, Object>> shiftRows = ListUtils.newArrayList();
+		double curShiftAchieve = 0D;
+		for (MesShift shift : shifts) {
+			String[] window = shiftWindow(day, shift);
+			if (window == null) {
+				continue;
+			}
+			long shiftPlan = sumPlanNums(todayPlans, shift.getId());
+			long shiftActual = countFinished(window[0], window[1], null);
+			long shiftOk = countCraftResult("OK", window[0], window[1]);
+			long shiftNg = countCraftResult("NG", window[0], window[1]);
+			long shiftRepair = countRepair(window[0], window[1]);
+			double shiftAchieve = ratio(shiftActual, shiftPlan, false);
+			double shiftFpy = firstPassRate(shiftOk, shiftNg, shiftActual, shiftRepair);
+			boolean current = StringUtils.equals(curShiftId, shift.getId());
+			if (current) {
+				curShiftAchieve = shiftAchieve;
+			}
+			Map<String, Object> row = MapUtils.newHashMap();
+			row.put("title", shift.getTitle());
+			row.put("range", window[2]);
+			row.put("plan", shiftPlan);
+			row.put("actual", shiftActual);
+			row.put("ok", shiftOk);
+			row.put("ng", shiftNg);
+			row.put("repair", shiftRepair);
+			row.put("achieve", shiftAchieve);
+			row.put("fpy", shiftFpy);
+			row.put("current", current);
+			shiftRows.add(row);
+		}
+
+		List<Map<String, Object>> productRows = buildProductRows(todayPlans, dayStart, dayEnd);
+		List<String> hourLabels = ListUtils.newArrayList();
+		List<Long> hourActual = ListUtils.newArrayList();
+		List<Long> hourPlan = ListUtils.newArrayList();
+		long hourTarget = plan <= 0 ? 0 : Math.round(plan / 12D);
+		LocalDateTime bucket = day.atTime(8, 0);
+		DateTimeFormatter hourFmt = DateTimeFormatter.ofPattern("HH:mm");
+		DateTimeFormatter fullFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+		for (int i = 0; i < 12; i++) {
+			LocalDateTime bucketEnd = bucket.plusHours(2);
+			hourLabels.add(bucket.format(hourFmt));
+			hourActual.add(countFinished(bucket.format(fullFmt), bucketEnd.format(fullFmt), null));
+			hourPlan.add(hourTarget);
+			bucket = bucketEnd;
+		}
+
+		List<String> trendLabels = ListUtils.newArrayList();
+		List<Double> trendAchieve = ListUtils.newArrayList();
+		List<Double> trendFpy = ListUtils.newArrayList();
+		List<Double> trendOee = ListUtils.newArrayList();
+		List<Long> trendOutput = ListUtils.newArrayList();
+		List<Long> trendPlanQty = ListUtils.newArrayList();
+		DateTimeFormatter trendFmt = DateTimeFormatter.ofPattern("M/d");
+		for (int i = 6; i >= 0; i--) {
+			LocalDate trendDay = day.minusDays(i);
+			String trendDayStr = trendDay.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+			String trendStart = trendDayStr + " 08:00:00";
+			String trendEnd = trendDay.plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " 08:00:00";
+			long trendPlan = i == 0 ? plan : sumPlanNums(loadPlans(trendDayStr), null);
+			long trendActual = i == 0 ? actual : countFinished(trendStart, trendEnd, null);
+			long trendOk = i == 0 ? ok : countCraftResult("OK", trendStart, trendEnd);
+			long trendNg = i == 0 ? ng : countCraftResult("NG", trendStart, trendEnd);
+			long trendRepair = i == 0 ? repair : countRepair(trendStart, trendEnd);
+			double trendAchieveRate = ratio(trendActual, trendPlan, false);
+			double trendFpyRate = firstPassRate(trendOk, trendNg, trendActual, trendRepair);
+			double trendPerformance = trendPlan <= 0 ? 0D : ratio(trendActual, trendPlan, true);
+			double trendAvailability = i == 0 ? availability : 100D;
+			trendLabels.add(trendDay.format(trendFmt));
+			trendAchieve.add(trendAchieveRate);
+			trendFpy.add(trendFpyRate);
+			trendOee.add(round1(trendAvailability * trendPerformance * trendFpyRate / 10000D));
+			trendOutput.add(trendActual);
+			trendPlanQty.add(trendPlan);
+		}
+
+		Map<String, Object> summary = MapUtils.newHashMap();
+		summary.put("plan", plan);
+		summary.put("actual", actual);
+		summary.put("ok", ok);
+		summary.put("ng", ng);
+		summary.put("repair", repair);
+		summary.put("openAlarm", openAlarm);
+
+		Map<String, Object> kpi = MapUtils.newHashMap();
+		kpi.put("achieve", achieve);
+		kpi.put("fpy", fpy);
+		kpi.put("oee", oee);
+		kpi.put("availability", availability);
+		kpi.put("performance", performance);
+		kpi.put("quality", fpy);
+		kpi.put("shiftAchieve", curShiftAchieve);
+
+		Map<String, Object> hours = MapUtils.newHashMap();
+		hours.put("labels", hourLabels);
+		hours.put("actual", hourActual);
+		hours.put("plan", hourPlan);
+
+		Map<String, Object> trend = MapUtils.newHashMap();
+		trend.put("labels", trendLabels);
+		trend.put("achieve", trendAchieve);
+		trend.put("fpy", trendFpy);
+		trend.put("oee", trendOee);
+		trend.put("output", trendOutput);
+		trend.put("planQty", trendPlanQty);
+
+		Map<String, Object> data = MapUtils.newHashMap();
+		data.put("day", dayStr);
+		data.put("window", "08:00-次日08:00");
+		data.put("shiftTitle", curShiftTitle);
+		data.put("summary", summary);
+		data.put("kpi", kpi);
+		data.put("hours", hours);
+		data.put("trend", trend);
+		data.put("shifts", shiftRows);
+		data.put("products", productRows);
+		resp.setData(data);
+		resp.setResult(Global.TRUE);
+		return resp;
+	}
+
+	private List<MesPlan> loadPlans(String day) {
+		MesPlan query = new MesPlan();
+		query.setStatus("0");
+		query.setDay(day);
+		List<MesPlan> plans = mesPlanService.findList(query);
+		return plans == null ? ListUtils.newArrayList() : plans;
+	}
+
+	private long sumPlanNums(List<MesPlan> plans, String shiftId) {
+		long sum = 0L;
+		for (MesPlan plan : plans) {
+			if (plan.getNums() == null) {
+				continue;
+			}
+			if (StringUtils.isNotEmpty(shiftId) && !StringUtils.equals(shiftId, plan.getShiftId())) {
+				continue;
+			}
+			sum += plan.getNums();
+		}
+		return sum;
+	}
+
+	private List<Map<String, Object>> buildProductRows(List<MesPlan> plans, String start, String end) {
+		Map<String, Map<String, Object>> grouped = new LinkedHashMap<>();
+		for (MesPlan plan : plans) {
+			String key = StringUtils.isNotEmpty(plan.getCateSn()) ? plan.getCateSn() : plan.getCateId();
+			if (StringUtils.isEmpty(key)) {
+				continue;
+			}
+			Map<String, Object> row = grouped.get(key);
+			if (row == null) {
+				row = MapUtils.newHashMap();
+				row.put("title", StringUtils.isNotEmpty(plan.getCateTitle()) ? plan.getCateTitle() : key);
+				row.put("sn", key);
+				row.put("plan", 0L);
+				grouped.put(key, row);
+			}
+			long planNums = row.get("plan") instanceof Number ? ((Number) row.get("plan")).longValue() : 0L;
+			row.put("plan", planNums + (plan.getNums() == null ? 0 : plan.getNums()));
+		}
+		List<Map<String, Object>> rows = ListUtils.newArrayList();
+		for (Map.Entry<String, Map<String, Object>> entry : grouped.entrySet()) {
+			Map<String, Object> row = entry.getValue();
+			long plan = ((Number) row.get("plan")).longValue();
+			long actual = countFinished(start, end, entry.getKey());
+			row.put("actual", actual);
+			row.put("achieve", ratio(actual, plan, false));
+			rows.add(row);
+			if (rows.size() >= 8) {
+				break;
+			}
+		}
+		return rows;
+	}
+
+	private long countFinished(String start, String end, String cate) {
+		MesProduct query = new MesProduct();
+		query.setState("1");
+		if (StringUtils.isNotEmpty(cate)) {
+			query.setCate(cate);
+		}
+		query.getSqlMap().getWhere()
+				.and("update_date", QueryType.GTE, start)
+				.and("update_date", QueryType.LT, end);
+		Long count = mesProductService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	private long countCraftResult(String content, String start, String end) {
+		MesProductRecord query = new MesProductRecord();
+		query.setCraft("100000");
+		query.setContent(content);
+		query.getSqlMap().getWhere()
+				.and("update_date", QueryType.GTE, start)
+				.and("update_date", QueryType.LT, end);
+		Long count = mesProductRecordService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	private long countRepair(String start, String end) {
+		MesRepair query = new MesRepair();
+		query.getSqlMap().getWhere()
+				.and("a.create_date", QueryType.GTE, start)
+				.and("a.create_date", QueryType.LT, end);
+		Long count = mesRepairService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	private long countOpenAlarm() {
+		MesProcessAlarm query = new MesProcessAlarm();
+		query.setState("0");
+		Long count = mesProcessAlarmService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	private long countStations() {
+		MesLineProcess query = new MesLineProcess();
+		query.setStatus("0");
+		query.getSqlMap().getWhere().and("pid", QueryType.IS_NULL, "");
+		Long count = mesLineProcessService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	private String[] shiftWindow(LocalDate day, MesShift shift) {
+		String startHm = hourMinute(shift.getStart());
+		String endHm = hourMinute(shift.getEnd());
+		if (StringUtils.isEmpty(startHm) || StringUtils.isEmpty(endHm)) {
+			return null;
+		}
+		LocalDate endDay = shift.getNext() != null && shift.getNext() == 1 ? day.plusDays(1) : day;
+		String start = day.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " " + startHm + ":00";
+		String end = endDay.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " " + endHm + ":00";
+		return new String[]{start, end, startHm + "-" + endHm};
+	}
+
+	private String hourMinute(String time) {
+		if (StringUtils.isEmpty(time)) {
+			return "";
+		}
+		String value = time.trim();
+		return value.length() >= 5 ? value.substring(0, 5) : value;
+	}
+
+	private double firstPassRate(long ok, long ng, long actual, long repair) {
+		if (ok + ng > 0) {
+			return ratio(ok, ok + ng, true);
+		}
+		if (actual <= 0) {
+			return 0D;
+		}
+		return ratio(Math.max(0L, actual - repair), actual, true);
+	}
+
+	private double availabilityRate(long openAlarm, long stations) {
+		if (stations <= 0 || openAlarm <= 0) {
+			return 100D;
+		}
+		double down = Math.min(100D, openAlarm * 100D / stations);
+		return round1(Math.max(0D, 100D - down));
+	}
+
+	private double ratio(long numerator, long denominator, boolean capAt100) {
+		if (denominator <= 0) {
+			return 0D;
+		}
+		double value = numerator * 100D / denominator;
+		if (value < 0) {
+			value = 0D;
+		}
+		if (capAt100 && value > 100D) {
+			value = 100D;
+		}
+		return round1(value);
+	}
+
+	private double round1(double value) {
+		if (Double.isNaN(value) || Double.isInfinite(value)) {
+			return 0D;
+		}
+		return Math.round(value * 10D) / 10D;
+	}
 }

+ 77 - 0
src/main/java/com/jeesite/modules/mes/web/MesScreenController.java

@@ -0,0 +1,77 @@
+package com.jeesite.modules.mes.web;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import com.jeesite.common.config.Global;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.entity.MesScreen;
+import com.jeesite.modules.mes.service.MesScreenService;
+
+/**
+ * 大屏管理
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/mes/mesScreen")
+public class MesScreenController extends BaseController {
+
+	@Autowired
+	private MesScreenService mesScreenService;
+
+	@ModelAttribute
+	public MesScreen get(String id, boolean isNewRecord) {
+		return mesScreenService.get(id, isNewRecord);
+	}
+
+	@RequiresPermissions("mes:mesScreen:view")
+	@RequestMapping(value = {"list", ""})
+	public String list(MesScreen mesScreen, Model model) {
+		model.addAttribute("mesScreen", mesScreen);
+		return "modules/mes/mesScreenList";
+	}
+
+	@RequiresPermissions("mes:mesScreen:view")
+	@RequestMapping(value = "listData")
+	@ResponseBody
+	public Page<MesScreen> listData(MesScreen mesScreen, HttpServletRequest request, HttpServletResponse response) {
+		mesScreen.setPage(new Page<>(request, response));
+		return mesScreenService.findPage(mesScreen);
+	}
+
+	@RequiresPermissions("mes:mesScreen:view")
+	@RequestMapping(value = "form")
+	public String form(MesScreen mesScreen, Model model) {
+		if (mesScreen.getIsNewRecord() && mesScreen.getSortNo() == null) {
+			mesScreen.setSortNo(30);
+		}
+		model.addAttribute("mesScreen", mesScreen);
+		return "modules/mes/mesScreenForm";
+	}
+
+	@RequiresPermissions("mes:mesScreen:edit")
+	@PostMapping(value = "save")
+	@ResponseBody
+	public String save(@Validated MesScreen mesScreen) {
+		mesScreenService.save(mesScreen);
+		return renderResult(Global.TRUE, text("保存大屏成功!"));
+	}
+
+	@RequiresPermissions("mes:mesScreen:edit")
+	@RequestMapping(value = "delete")
+	@ResponseBody
+	public String delete(MesScreen mesScreen) {
+		mesScreenService.delete(mesScreen);
+		return renderResult(Global.TRUE, text("删除大屏成功!"));
+	}
+}

+ 3 - 0
src/main/resources/config/application.yml

@@ -628,6 +628,9 @@ shiro:
     ${adminPath}/mes/mesProduct/screen7 = anon
     ${adminPath}/mes/mesProduct/screen8 = anon
     ${adminPath}/mes/mesProduct/screen9 = anon
+    ${adminPath}/mes/mesProduct/screen10 = anon
+    ${adminPath}/mes/mesProduct/screen11 = anon
+    ${adminPath}/mes/mesProduct/screenKpiData = anon
     ${adminPath}/mes/mesProduct/screenRepairData = anon
     ${adminPath}/mes/mesProduct/screenData = anon
     ${adminPath}/mes/mesProduct/screenData4 = anon

+ 5 - 0
src/main/resources/mappings/modules/mes/MesScreenDao.xml

@@ -0,0 +1,5 @@
+<?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.jeesite.modules.mes.dao.MesScreenDao">
+
+</mapper>

+ 274 - 0
src/main/resources/static/screen/exec-screen.css

@@ -0,0 +1,274 @@
+html,
+body {
+	width: 100%;
+	height: 100%;
+	margin: 0;
+	overflow: hidden;
+	background: #07111f;
+	color: #e7f3ff;
+	font-family: "Microsoft YaHei", Arial, sans-serif;
+}
+
+.exec-page {
+	height: 100%;
+	min-width: 1180px;
+	box-sizing: border-box;
+	padding: 16px 20px 14px;
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	background:
+		radial-gradient(1000px 360px at 50% -12%, rgba(28, 96, 170, 0.32), transparent 62%),
+		linear-gradient(180deg, #0c1d34 0%, #07111f 100%);
+}
+
+.exec-header {
+	height: 46px;
+	flex-shrink: 0;
+	display: grid;
+	grid-template-columns: 1fr auto 1fr;
+	align-items: center;
+}
+
+.exec-header h1 {
+	margin: 0;
+	font-size: 28px;
+	letter-spacing: 6px;
+	text-align: center;
+}
+
+.header-side {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.header-side.is-right {
+	justify-content: flex-end;
+}
+
+.meta-chip,
+.screen-time {
+	color: #9ec9ee;
+	font-size: 14px;
+}
+
+.meta-chip {
+	height: 28px;
+	padding: 0 12px;
+	border: 1px solid rgba(90, 168, 230, 0.32);
+	border-radius: 14px;
+	line-height: 28px;
+	background: rgba(8, 28, 54, 0.7);
+}
+
+.hero-grid,
+.main-grid,
+.bottom-grid {
+	min-height: 0;
+	display: grid;
+	gap: 12px;
+}
+
+.hero-grid {
+	flex-shrink: 0;
+	grid-template-columns: 1.15fr 1fr 1fr 1fr;
+}
+
+.main-grid {
+	flex: 1.25;
+	grid-template-columns: 1.7fr 0.9fr;
+}
+
+.bottom-grid {
+	flex: 0.85;
+	grid-template-columns: 1.15fr 0.85fr;
+}
+
+.hero-card,
+.panel {
+	border: 1px solid rgba(86, 156, 214, 0.28);
+	border-radius: 10px;
+	background: rgba(8, 28, 52, 0.82);
+	box-sizing: border-box;
+}
+
+.hero-card {
+	padding: 14px 18px 16px;
+}
+
+.hero-label,
+.panel-title {
+	color: #9ec9ee;
+	font-size: 14px;
+}
+
+.hero-num {
+	margin-top: 6px;
+	font-size: 40px;
+	font-weight: 700;
+	line-height: 1.05;
+	font-family: Arial, "Microsoft YaHei", sans-serif;
+}
+
+.hero-sub,
+.hero-foot {
+	margin-top: 8px;
+	color: #d5e8f8;
+	font-size: 14px;
+}
+
+.hero-foot {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.meter {
+	height: 8px;
+	margin-top: 12px;
+	border-radius: 4px;
+	background: rgba(255, 255, 255, 0.08);
+	overflow: hidden;
+}
+
+.meter i,
+.track i {
+	display: block;
+	height: 100%;
+	width: 0;
+	border-radius: 4px;
+	background: #3ec6ff;
+}
+
+.tag {
+	height: 22px;
+	padding: 0 8px;
+	border-radius: 11px;
+	font-style: normal;
+	font-size: 12px;
+	line-height: 22px;
+	color: #d7ecff;
+	background: rgba(255, 255, 255, 0.08);
+}
+
+.tag.is-good,
+.track i.is-good { background: #5ddea0; }
+.tag.is-good { color: #14301f; }
+.tag.is-ok,
+.track i.is-ok,
+.meter i.is-ok { background: #7fd4ff; }
+.tag.is-ok { color: #062033; }
+.tag.is-warn,
+.track i.is-warn,
+.meter i.is-warn { background: #ffd36a; }
+.tag.is-warn { color: #3a2a04; }
+.tag.is-bad,
+.track i.is-bad,
+.meter i.is-bad { background: #ef6b6b; }
+.tag.is-bad { color: #fff; }
+.meter i.is-good { background: #5ddea0; }
+
+.panel {
+	min-width: 0;
+	min-height: 0;
+	display: flex;
+	flex-direction: column;
+	padding: 12px 14px 10px;
+}
+
+.panel-title {
+	height: 24px;
+	flex-shrink: 0;
+	color: #d7ecff;
+	font-size: 15px;
+	font-weight: 700;
+}
+
+.chart-box,
+.bar-list,
+.attention-list {
+	flex: 1;
+	min-height: 0;
+}
+
+.chart-panel {
+	position: relative;
+}
+
+.chart-panel .chart-box {
+	position: absolute;
+	left: 14px;
+	right: 14px;
+	top: 36px;
+	bottom: 8px;
+}
+
+.attention-list,
+.bar-list {
+	margin: 8px 0 0;
+	padding: 0;
+	overflow: auto;
+	list-style: none;
+}
+
+.attention-list li {
+	margin: 0 0 12px;
+	padding-left: 14px;
+	position: relative;
+	color: #e7f3ff;
+	font-size: 15px;
+	line-height: 1.5;
+}
+
+.attention-list li:before {
+	content: "";
+	position: absolute;
+	left: 0;
+	top: 0.55em;
+	width: 6px;
+	height: 6px;
+	border-radius: 50%;
+	background: #ffd36a;
+}
+
+.attention-list li.is-ok:before {
+	background: #5ddea0;
+}
+
+.bar-row {
+	display: grid;
+	grid-template-columns: 120px 1fr 150px;
+	gap: 10px;
+	align-items: center;
+	margin-bottom: 12px;
+}
+
+.bar-name,
+.bar-meta {
+	overflow: hidden;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+	font-size: 14px;
+}
+
+.bar-name {
+	color: #fff;
+}
+
+.bar-meta {
+	color: #b7d4ec;
+	text-align: right;
+}
+
+.track {
+	height: 10px;
+	border-radius: 5px;
+	background: rgba(255, 255, 255, 0.08);
+	overflow: hidden;
+}
+
+.empty-tip {
+	color: #8eb0c8;
+	font-size: 14px;
+}

+ 316 - 0
src/main/resources/static/screen/exec-screen.js

@@ -0,0 +1,316 @@
+(function ($, window) {
+	"use strict";
+
+	var config = window.execScreenConfig || {};
+	var chart = null;
+	var refreshTimer = null;
+
+	function pad(num) {
+		return num < 10 ? "0" + num : String(num);
+	}
+
+	function updateClock() {
+		var now = new Date();
+		$("#screenTime").text(
+			now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
+			" " + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds())
+		);
+	}
+
+	function escapeHtml(text) {
+		return String(text == null ? "" : text)
+			.replace(/&/g, "&amp;")
+			.replace(/</g, "&lt;")
+			.replace(/>/g, "&gt;")
+			.replace(/"/g, "&quot;");
+	}
+
+	function num(value) {
+		var n = Number(value);
+		return isNaN(n) ? 0 : n;
+	}
+
+	function rateText(value) {
+		return num(value).toFixed(1) + "%";
+	}
+
+	function level(value) {
+		var n = num(value);
+		if (n >= 95) {
+			return "is-good";
+		}
+		if (n >= 85) {
+			return "is-ok";
+		}
+		if (n >= 70) {
+			return "is-warn";
+		}
+		return "is-bad";
+	}
+
+	function levelName(value) {
+		var name = level(value);
+		if (name === "is-good") {
+			return "正常";
+		}
+		if (name === "is-ok") {
+			return "基本正常";
+		}
+		if (name === "is-warn") {
+			return "偏慢";
+		}
+		return "明显落后";
+	}
+
+	function slowest(rows) {
+		var picked = null;
+		(rows || []).forEach(function (row) {
+			if (num(row.plan) <= 0 || num(row.achieve) >= 85) {
+				return;
+			}
+			if (!picked || num(row.achieve) < num(picked.achieve)) {
+				picked = row;
+			}
+		});
+		return picked;
+	}
+
+	function sum(list) {
+		var total = 0;
+		(list || []).forEach(function (item) {
+			total += num(item);
+		});
+		return total;
+	}
+
+	function average(list) {
+		if (!list || !list.length) {
+			return 0;
+		}
+		return sum(list) / list.length;
+	}
+
+	function renderHero(data) {
+		var summary = data.summary || {};
+		var kpi = data.kpi || {};
+		var trend = data.trend || {};
+		var plan = num(summary.plan);
+		var actual = num(summary.actual);
+		var gap = plan - actual;
+		var weekOut = sum(trend.output);
+		var weekPlan = sum(trend.planQty);
+		var weekAvg = average((trend.achieve || []).slice(0, -1));
+
+		$("#statDay").text("统计日 " + (data.day || "--"));
+		$("#shiftChip").text("当前班次 " + (data.shiftTitle || "未排班"));
+		$("#actualNum").text(actual);
+		$("#planText").text(plan > 0 ? ("计划 " + plan) : "今日未排计划");
+
+		if (plan <= 0) {
+			$("#gapLabel").text("计划差额");
+			$("#gapNum").text("--");
+			$("#achieveText").text("未排计划");
+			$("#achieveTag").removeClass("is-good is-ok is-warn is-bad").text("待排产");
+			$("#achieveBar").css("width", "0");
+		} else if (gap > 0) {
+			$("#gapLabel").text("还差");
+			$("#gapNum").text(gap);
+			$("#achieveText").text("达成 " + rateText(kpi.achieve));
+			paintAchieve(kpi.achieve);
+		} else if (gap < 0) {
+			$("#gapLabel").text("超出计划");
+			$("#gapNum").text(Math.abs(gap));
+			$("#achieveText").text("达成 " + rateText(kpi.achieve));
+			paintAchieve(kpi.achieve);
+		} else {
+			$("#gapLabel").text("差额");
+			$("#gapNum").text("0");
+			$("#achieveText").text("已完成计划");
+			paintAchieve(kpi.achieve);
+		}
+
+		var inspected = num(summary.ok) + num(summary.ng);
+		$("#fpyNum").text(inspected > 0 || actual > 0 ? rateText(kpi.fpy) : "--");
+		$("#qualityText").text("返修 " + num(summary.repair) + " · 不良 " + num(summary.ng) + " · 未关报警 " + num(summary.openAlarm));
+		$("#weekActual").text(weekOut);
+		$("#weekText").text("计划 " + weekPlan + (weekAvg ? (" · 前几日平均达成 " + weekAvg.toFixed(1) + "%") : ""));
+	}
+
+	function paintAchieve(value) {
+		var cls = level(value);
+		$("#achieveTag").removeClass("is-good is-ok is-warn is-bad").addClass(cls).text(levelName(value));
+		$("#achieveBar").css("width", Math.max(0, Math.min(num(value), 100)) + "%")
+			.removeClass("is-good is-ok is-warn is-bad").addClass(cls);
+	}
+
+	function renderAttention(data) {
+		var summary = data.summary || {};
+		var kpi = data.kpi || {};
+		var trend = data.trend || {};
+		var plan = num(summary.plan);
+		var items = [];
+
+		if (plan <= 0) {
+			items.push({ text: "今天还没有班次生产计划,完成情况无法和目标对比。", ok: false });
+		}
+
+		var history = (trend.achieve || []).slice(0, -1);
+		if (plan > 0 && history.length) {
+			var avg = average(history);
+			var diff = num(kpi.achieve) - avg;
+			if (diff <= -8) {
+				items.push({ text: "今天比前几日平均达成低 " + Math.abs(diff).toFixed(1) + " 个百分点,不是平时的水平。", ok: false });
+			}
+		}
+
+		if (num(kpi.fpy) > 0 && num(kpi.fpy) < 95) {
+			items.push({ text: "一次合格率 " + rateText(kpi.fpy) + ",低于 95%。", ok: false });
+		}
+		if (num(summary.actual) > 0 && num(summary.repair) * 100 / num(summary.actual) >= 2) {
+			items.push({ text: "返修 " + num(summary.repair) + " 件,约占完工的 " + (num(summary.repair) * 100 / num(summary.actual)).toFixed(1) + "%。", ok: false });
+		}
+		if (num(summary.openAlarm) > 0) {
+			items.push({ text: "还有 " + num(summary.openAlarm) + " 条报警没有关闭。", ok: false });
+		}
+
+		var slowProduct = slowest(data.products);
+		if (slowProduct) {
+			items.push({ text: slowProduct.title + "是最慢的产品,完成 " + num(slowProduct.actual) + " / " + num(slowProduct.plan) + "。", ok: false });
+		}
+		var slowShift = slowest(data.shifts);
+		if (slowShift) {
+			items.push({ text: slowShift.title + "是最慢的班次,完成 " + num(slowShift.actual) + " / " + num(slowShift.plan) + "。", ok: false });
+		}
+
+		if (!items.length) {
+			items.push({ text: "今天产量和质量都在正常范围,没有需要单独过问的事项。", ok: true });
+		}
+
+		$("#attentionList").html(items.map(function (item) {
+			return '<li class="' + (item.ok ? "is-ok" : "") + '">' + escapeHtml(item.text) + "</li>";
+		}).join(""));
+	}
+
+	function renderBars(target, rows, emptyText) {
+		if (!rows || !rows.length) {
+			$("#" + target).html('<div class="empty-tip">' + escapeHtml(emptyText) + "</div>");
+			return;
+		}
+		var html = rows.map(function (row) {
+			var plan = num(row.plan);
+			var actual = num(row.actual);
+			var rate = plan > 0 ? Math.max(0, Math.min(num(row.achieve), 100)) : 0;
+			var cls = plan > 0 ? level(row.achieve) : "";
+			var meta = plan > 0 ? (actual + " / " + plan) : String(actual);
+			return '<div class="bar-row">' +
+				'<div class="bar-name">' + escapeHtml(row.title || "--") + "</div>" +
+				'<div class="track"><i class="' + cls + '" style="width:' + rate + '%"></i></div>' +
+				'<div class="bar-meta">' + escapeHtml(meta) + "</div>" +
+				"</div>";
+		}).join("");
+		$("#" + target).html(html);
+	}
+
+	function renderChart(trend) {
+		trend = trend || {};
+		if (!chart) {
+			chart = echarts.init(document.getElementById("outputChart"));
+		}
+		chart.setOption({
+			tooltip: {
+				trigger: "axis",
+				backgroundColor: "rgba(8, 24, 46, 0.94)",
+				borderColor: "#3d7eb8",
+				textStyle: { color: "#fff" }
+			},
+			legend: {
+				top: 0,
+				right: 8,
+				textStyle: { color: "#c5ddf2", fontSize: 12 },
+				data: ["计划", "完工"]
+			},
+			grid: { left: 48, right: 18, top: 32, bottom: 28 },
+			xAxis: {
+				type: "category",
+				data: trend.labels || [],
+				axisLine: { lineStyle: { color: "#245074" } },
+				axisTick: { show: false },
+				axisLabel: { color: "#c5ddf2" }
+			},
+			yAxis: {
+				type: "value",
+				minInterval: 1,
+				axisLine: { lineStyle: { color: "#245074" } },
+				axisTick: { show: false },
+				axisLabel: { color: "#c5ddf2" },
+				splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } }
+			},
+			series: [
+				{
+					name: "计划",
+					type: "bar",
+					barWidth: 16,
+					data: trend.planQty || [],
+					itemStyle: { color: "rgba(127, 212, 255, 0.35)", normal: { color: "rgba(127, 212, 255, 0.35)" } }
+				},
+				{
+					name: "完工",
+					type: "bar",
+					barWidth: 16,
+					data: trend.output || [],
+					itemStyle: { color: "#3ec6ff", normal: { color: "#3ec6ff" } }
+				}
+			]
+		}, true);
+		setTimeout(function () {
+			if (chart) {
+				chart.resize();
+			}
+		}, 0);
+	}
+
+	function applyData(data) {
+		renderHero(data);
+		renderAttention(data);
+		renderChart(data.trend);
+		renderBars("productBars", data.products, "今天没有按产品拆开的计划");
+		renderBars("shiftBars", data.shifts, "今天没有班次数据");
+	}
+
+	function loadData() {
+		$.ajax({
+			url: config.apiUrl,
+			type: "GET",
+			dataType: "json",
+			timeout: 20000
+		}).done(function (res) {
+			if (res && (res.result === "true" || res.result === true) && res.data) {
+				applyData(res.data);
+				scheduleRefresh(config.refreshInterval || 60000);
+			} else {
+				scheduleRefresh(5000);
+			}
+		}).fail(function () {
+			scheduleRefresh(5000);
+		});
+	}
+
+	function scheduleRefresh(delay) {
+		if (refreshTimer) {
+			clearTimeout(refreshTimer);
+		}
+		refreshTimer = setTimeout(loadData, delay || 60000);
+	}
+
+	$(function () {
+		$("#screenTitle").text(config.title || "生产简报");
+		updateClock();
+		setInterval(updateClock, 1000);
+		loadData();
+		$(window).on("resize", function () {
+			if (chart) {
+				chart.resize();
+			}
+		});
+	});
+})(jQuery, window);

+ 264 - 0
src/main/resources/static/screen/kpi-screen.css

@@ -0,0 +1,264 @@
+html,
+body {
+	width: 100%;
+	height: 100%;
+	margin: 0;
+	overflow: hidden;
+	background: #061326;
+	font-family: "Microsoft YaHei", Arial, sans-serif;
+}
+
+.screen-page {
+	position: relative;
+	width: 1920px;
+	height: 1080px;
+	color: #fff;
+	overflow: hidden;
+}
+
+.screen-page .bg {
+	position: absolute;
+	inset: 0;
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+	z-index: 0;
+}
+
+.screen-content {
+	position: relative;
+	z-index: 1;
+	height: 100%;
+	padding: 18px 36px 16px;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.screen-header {
+	display: grid;
+	grid-template-columns: 1fr auto 1fr;
+	align-items: center;
+	height: 64px;
+	flex-shrink: 0;
+}
+
+.header-meta {
+	display: flex;
+	gap: 10px;
+}
+
+.meta-chip {
+	height: 32px;
+	padding: 0 14px;
+	border: 1px solid rgba(0, 143, 253, 0.4);
+	border-radius: 16px;
+	background: rgba(4, 24, 52, 0.72);
+	color: #9ad8ff;
+	font-size: 16px;
+	line-height: 32px;
+}
+
+.screen-title-new {
+	color: #fff;
+	font-size: 36px;
+	font-weight: 700;
+	letter-spacing: 6px;
+	text-align: center;
+	text-shadow: 0 0 18px rgba(0, 246, 255, 0.45);
+}
+
+.screen-time {
+	justify-self: end;
+	font-size: 20px;
+	color: #9ad8ff;
+}
+
+.summary-row {
+	display: grid;
+	grid-template-columns: repeat(6, 1fr);
+	gap: 12px;
+	flex-shrink: 0;
+}
+
+.summary-item {
+	height: 78px;
+	padding: 10px 16px;
+	border: 1px solid rgba(0, 143, 253, 0.34);
+	border-radius: 6px;
+	background: linear-gradient(180deg, rgba(5, 37, 78, 0.88), rgba(5, 25, 54, 0.76));
+	box-shadow: inset 0 0 24px rgba(0, 143, 253, 0.12);
+	box-sizing: border-box;
+}
+
+.summary-item.is-actual { border-color: rgba(0, 246, 255, 0.45); }
+.summary-item.is-ok { border-color: rgba(64, 222, 120, 0.45); }
+.summary-item.is-ng { border-color: rgba(255, 92, 92, 0.45); }
+.summary-item.is-repair { border-color: rgba(255, 184, 0, 0.45); }
+.summary-item.is-shift { border-color: rgba(120, 170, 255, 0.45); }
+
+.summary-label {
+	color: #9ad8ff;
+	font-size: 15px;
+}
+
+.summary-value {
+	margin-top: 4px;
+	font-size: 30px;
+	font-weight: 700;
+	line-height: 1.1;
+	font-family: Arial, sans-serif;
+}
+
+.summary-text {
+	font-size: 24px;
+	font-family: "Microsoft YaHei", sans-serif;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+.kpi-row {
+	display: grid;
+	grid-template-columns: repeat(4, 1fr);
+	gap: 12px;
+	height: 168px;
+	flex-shrink: 0;
+}
+
+.kpi-card,
+.panel {
+	border: 1px solid rgba(0, 143, 253, 0.34);
+	border-radius: 6px;
+	background: linear-gradient(180deg, rgba(5, 32, 68, 0.82), rgba(4, 18, 40, 0.72));
+	box-shadow: inset 0 0 30px rgba(0, 143, 253, 0.1);
+	box-sizing: border-box;
+}
+
+.kpi-card {
+	display: flex;
+	align-items: center;
+	padding: 6px 12px 6px 0;
+	min-width: 0;
+}
+
+.kpi-chart {
+	width: 168px;
+	height: 150px;
+	flex-shrink: 0;
+}
+
+.kpi-side {
+	flex: 1;
+	min-width: 0;
+}
+
+.kpi-name {
+	font-size: 20px;
+	font-weight: 700;
+}
+
+.kpi-desc,
+.kpi-extra,
+.kpi-factor {
+	margin-top: 6px;
+	color: #9ad8ff;
+	font-size: 14px;
+}
+
+.kpi-factor {
+	display: flex;
+	justify-content: space-between;
+	padding-right: 8px;
+}
+
+.kpi-factor b {
+	color: #fff;
+	font-family: Arial, sans-serif;
+}
+
+.chart-row {
+	display: grid;
+	grid-template-columns: 1.15fr 0.85fr;
+	gap: 12px;
+	height: 280px;
+	flex-shrink: 0;
+}
+
+.panel {
+	display: flex;
+	flex-direction: column;
+	min-width: 0;
+	min-height: 0;
+	padding: 10px 14px 8px;
+}
+
+.panel-title {
+	height: 28px;
+	color: #d8f4ff;
+	font-size: 18px;
+	font-weight: 700;
+	flex-shrink: 0;
+}
+
+.chart-box {
+	flex: 1;
+	min-height: 0;
+}
+
+.table-row {
+	display: grid;
+	grid-template-columns: 1.45fr 0.7fr;
+	gap: 12px;
+	flex: 1;
+	min-height: 0;
+}
+
+.table-wrap {
+	flex: 1;
+	min-height: 0;
+	overflow: hidden;
+}
+
+.kpi-table {
+	width: 100%;
+	border-collapse: collapse;
+	table-layout: fixed;
+}
+
+.kpi-table th,
+.kpi-table td {
+	height: 36px;
+	padding: 0 8px;
+	text-align: center;
+	font-size: 15px;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+.kpi-table th {
+	color: #7fd4ff;
+	font-weight: 600;
+	background: rgba(0, 143, 253, 0.12);
+}
+
+.kpi-table td {
+	color: #fff;
+	border-bottom: 1px solid rgba(0, 143, 253, 0.12);
+}
+
+.kpi-table tbody tr.is-current td {
+	background: rgba(64, 222, 120, 0.12);
+	color: #d9ffe6;
+}
+
+.kpi-table .is-good { color: #40de78; }
+.kpi-table .is-ok { color: #3ecbff; }
+.kpi-table .is-warn { color: #ffb800; }
+.kpi-table .is-bad { color: #ff5c5c; }
+
+.empty-row td {
+	color: #7aa8c8;
+}

+ 342 - 0
src/main/resources/static/screen/kpi-screen.js

@@ -0,0 +1,342 @@
+(function ($, window) {
+	"use strict";
+
+	var config = window.kpiScreenConfig || {};
+	var charts = {};
+	var refreshTimer = null;
+	var TEXT = "#d7ecff";
+	var AXIS = "#2A4E77";
+
+	function pad(num) {
+		return num < 10 ? "0" + num : String(num);
+	}
+
+	function updateClock() {
+		var now = new Date();
+		$("#screenTime").text(
+			now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
+			" " + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds())
+		);
+	}
+
+	function escapeHtml(text) {
+		return String(text == null ? "" : text)
+			.replace(/&/g, "&amp;")
+			.replace(/</g, "&lt;")
+			.replace(/>/g, "&gt;")
+			.replace(/"/g, "&quot;");
+	}
+
+	function num(value) {
+		var n = Number(value);
+		return isNaN(n) ? 0 : n;
+	}
+
+	function rateText(value) {
+		return num(value).toFixed(1) + "%";
+	}
+
+	function rateClass(value) {
+		var n = num(value);
+		if (n >= 95) {
+			return "is-good";
+		}
+		if (n >= 85) {
+			return "is-ok";
+		}
+		if (n >= 70) {
+			return "is-warn";
+		}
+		return "is-bad";
+	}
+
+	function rateColor(value) {
+		var level = rateClass(value);
+		if (level === "is-good") {
+			return "#40de78";
+		}
+		if (level === "is-ok") {
+			return "#3ecbff";
+		}
+		if (level === "is-warn") {
+			return "#ffb800";
+		}
+		return "#ff5c5c";
+	}
+
+	function chartOf(id) {
+		if (!charts[id]) {
+			charts[id] = echarts.init(document.getElementById(id));
+		}
+		return charts[id];
+	}
+
+	function renderGauge(id, value, neutral) {
+		var raw = num(value);
+		var ratio = Math.max(0, Math.min(raw, 100)) / 100;
+		var color = neutral ? "#7aa8c8" : rateColor(raw);
+		chartOf(id).setOption({
+			series: [{
+				type: "gauge",
+				min: 0,
+				max: 100,
+				startAngle: 210,
+				endAngle: -30,
+				radius: "92%",
+				center: ["50%", "58%"],
+				pointer: { show: false },
+				axisTick: { show: false },
+				splitLine: { show: false },
+				axisLabel: { show: false },
+				axisLine: {
+					lineStyle: {
+						width: 12,
+						color: [[ratio, color], [1, "rgba(255,255,255,0.12)"]]
+					}
+				},
+				detail: {
+					offsetCenter: [0, "6%"],
+					color: "#fff",
+					fontSize: 26,
+					fontWeight: 700,
+					formatter: function () {
+						return raw.toFixed(1) + "%";
+					}
+				},
+				data: [{ value: raw }]
+			}]
+		}, true);
+	}
+
+	function renderSummary(data) {
+		var summary = data.summary || {};
+		var kpi = data.kpi || {};
+		$("#statDay").text("统计日 " + (data.day || "--"));
+		$("#statWindow").text(data.window || "08:00-次日08:00");
+		$("#planCount").text(summary.plan || 0);
+		$("#actualCount").text(summary.actual || 0);
+		$("#okCount").text(summary.ok || 0);
+		$("#ngCount").text(summary.ng || 0);
+		$("#repairCount").text(summary.repair || 0);
+		$("#shiftTitle").text(data.shiftTitle || "未排班");
+		$("#achieveExtra").text(summary.plan ? ((summary.actual || 0) + " / " + summary.plan) : "未排班次计划");
+		$("#fpyExtra").text("合格 " + (summary.ok || 0) + " / 检验 " + ((summary.ok || 0) + (summary.ng || 0)));
+		$("#availabilityRate").text(rateText(kpi.availability));
+		$("#performanceRate").text(rateText(kpi.performance));
+		$("#qualityRate").text(rateText(kpi.quality));
+		$("#shiftDesc").text((data.shiftTitle || "当前班次") + "报工达成");
+		$("#alarmExtra").text("未关闭报警 " + (summary.openAlarm || 0));
+		renderGauge("achieveGauge", kpi.achieve, !summary.plan);
+		renderGauge("fpyGauge", kpi.fpy, !(summary.ok || summary.ng || summary.actual));
+		renderGauge("oeeGauge", kpi.oee, !summary.plan && !(summary.ok || summary.ng));
+		renderGauge("shiftGauge", kpi.shiftAchieve, !summary.plan);
+	}
+
+	function renderTrend(trend) {
+		trend = trend || {};
+		chartOf("trendChart").setOption({
+			tooltip: {
+				trigger: "axis",
+				backgroundColor: "rgba(8, 28, 58, 0.92)",
+				borderColor: "#008FFD",
+				textStyle: { color: "#fff" }
+			},
+			legend: {
+				top: 0,
+				right: 8,
+				textStyle: { color: TEXT, fontSize: 13 },
+				data: ["达成率", "直通率", "OEE"]
+			},
+			grid: { left: 46, right: 18, top: 36, bottom: 28 },
+			xAxis: {
+				type: "category",
+				data: trend.labels || [],
+				axisLine: { lineStyle: { color: AXIS } },
+				axisTick: { show: false },
+				axisLabel: { color: TEXT, fontSize: 13 }
+			},
+			yAxis: {
+				type: "value",
+				min: 0,
+				max: 100,
+				axisLine: { lineStyle: { color: AXIS } },
+				axisTick: { show: false },
+				axisLabel: { color: TEXT, formatter: "{value}%" },
+				splitLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } }
+			},
+			series: [
+				lineSeries("达成率", trend.achieve, "#3ecbff"),
+				lineSeries("直通率", trend.fpy, "#40de78"),
+				lineSeries("OEE", trend.oee, "#ffb800")
+			]
+		}, true);
+	}
+
+	function lineSeries(name, data, color) {
+		return {
+			name: name,
+			type: "line",
+			smooth: true,
+			symbol: "circle",
+			symbolSize: 7,
+			data: data || [],
+			itemStyle: { color: color },
+			lineStyle: { width: 2, color: color }
+		};
+	}
+
+	function renderHours(hours) {
+		hours = hours || {};
+		chartOf("hourChart").setOption({
+			tooltip: {
+				trigger: "axis",
+				backgroundColor: "rgba(8, 28, 58, 0.92)",
+				borderColor: "#008FFD",
+				textStyle: { color: "#fff" }
+			},
+			legend: {
+				top: 0,
+				right: 8,
+				textStyle: { color: TEXT, fontSize: 13 },
+				data: ["完工", "节拍目标"]
+			},
+			grid: { left: 42, right: 12, top: 36, bottom: 28 },
+			xAxis: {
+				type: "category",
+				data: hours.labels || [],
+				axisLine: { lineStyle: { color: AXIS } },
+				axisTick: { show: false },
+				axisLabel: { color: TEXT, fontSize: 12 }
+			},
+			yAxis: {
+				type: "value",
+				minInterval: 1,
+				axisLine: { lineStyle: { color: AXIS } },
+				axisTick: { show: false },
+				axisLabel: { color: TEXT },
+				splitLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } }
+			},
+			series: [
+				{
+					name: "完工",
+					type: "bar",
+					barWidth: 16,
+					data: hours.actual || [],
+					itemStyle: {
+						color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+							{ offset: 0, color: "#49e0ff" },
+							{ offset: 1, color: "#0b6cff" }
+						])
+					}
+				},
+				{
+					name: "节拍目标",
+					type: "line",
+					data: hours.plan || [],
+					symbol: "none",
+					itemStyle: { color: "#ffb800" },
+					lineStyle: { type: "dashed", width: 2, color: "#ffb800" }
+				}
+			]
+		}, true);
+	}
+
+	function renderShifts(rows) {
+		var html = "";
+		if (!rows || !rows.length) {
+			html = '<tr class="empty-row"><td colspan="9">暂无班次报工</td></tr>';
+		} else {
+			rows.forEach(function (row) {
+				html += '<tr class="' + (row.current ? "is-current" : "") + '">' +
+					"<td>" + escapeHtml(row.title) + (row.current ? " · 当前" : "") + "</td>" +
+					"<td>" + escapeHtml(row.range) + "</td>" +
+					"<td>" + num(row.plan) + "</td>" +
+					"<td>" + num(row.actual) + "</td>" +
+					"<td>" + num(row.ok) + "</td>" +
+					"<td>" + num(row.ng) + "</td>" +
+					"<td>" + num(row.repair) + "</td>" +
+					'<td class="' + rateClass(row.achieve) + '">' + rateText(row.achieve) + "</td>" +
+					'<td class="' + rateClass(row.fpy) + '">' + rateText(row.fpy) + "</td>" +
+					"</tr>";
+			});
+		}
+		$("#shiftBody").html(html);
+	}
+
+	function renderProducts(rows) {
+		var html = "";
+		if (!rows || !rows.length) {
+			html = '<tr class="empty-row"><td colspan="4">暂无产品计划</td></tr>';
+		} else {
+			rows.forEach(function (row) {
+				html += "<tr>" +
+					"<td>" + escapeHtml(row.title) + "</td>" +
+					"<td>" + num(row.plan) + "</td>" +
+					"<td>" + num(row.actual) + "</td>" +
+					'<td class="' + rateClass(row.achieve) + '">' + rateText(row.achieve) + "</td>" +
+					"</tr>";
+			});
+		}
+		$("#productBody").html(html);
+	}
+
+	function applyData(data) {
+		renderSummary(data);
+		renderTrend(data.trend);
+		renderHours(data.hours);
+		renderShifts(data.shifts);
+		renderProducts(data.products);
+	}
+
+	function loadData() {
+		$.ajax({
+			url: config.apiUrl,
+			type: "GET",
+			dataType: "json",
+			timeout: 20000
+		}).done(function (res) {
+			if (res && (res.result === "true" || res.result === true) && res.data) {
+				applyData(res.data);
+				scheduleRefresh(config.refreshInterval || 60000);
+			} else {
+				scheduleRefresh(5000);
+			}
+		}).fail(function () {
+			scheduleRefresh(5000);
+		});
+	}
+
+	function scheduleRefresh(delay) {
+		if (refreshTimer) {
+			clearTimeout(refreshTimer);
+		}
+		refreshTimer = setTimeout(loadData, delay || 60000);
+	}
+
+	function fitScreen() {
+		var width = $(window).width();
+		var height = $(window).height();
+		var scale = Math.min(width / 1920, height / 1080);
+		$("body").css({
+			transform: "scale(" + scale + ")",
+			transformOrigin: "0 0",
+			width: "1920px",
+			height: "1080px",
+			overflow: "hidden",
+			marginLeft: ((width - 1920 * scale) / 2) + "px",
+			marginTop: ((height - 1080 * scale) / 2) + "px"
+		});
+		Object.keys(charts).forEach(function (key) {
+			charts[key].resize();
+		});
+	}
+
+	$(function () {
+		$("#screenTitle").text(config.title || "生产运营看板");
+		updateClock();
+		setInterval(updateClock, 1000);
+		loadData();
+		fitScreen();
+		$(window).on("resize", fitScreen);
+	});
+})(jQuery, window);

+ 56 - 0
src/main/resources/static/screen/screen-fullscreen.js

@@ -0,0 +1,56 @@
+(function () {
+	var style = document.createElement("style");
+	style.textContent = [
+		".screen-fullscreen-btn{position:fixed;right:18px;bottom:18px;z-index:99999;",
+		"height:42px;padding:0 16px 0 14px;border:1px solid rgba(140,198,255,.62);",
+		"border-radius:21px;background:rgba(6,22,46,.86);color:#e7f4ff;",
+		"font-size:15px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;gap:8px;",
+		"box-shadow:0 6px 18px rgba(0,0,0,.28);font-family:Microsoft YaHei,Arial,sans-serif;}",
+		".screen-fullscreen-btn:hover{background:rgba(18,58,108,.94);}",
+		".screen-fullscreen-btn svg{width:16px;height:16px;display:block;fill:none;stroke:currentColor;stroke-width:1.8;}"
+	].join("");
+	document.head.appendChild(style);
+
+	var btn = document.createElement("button");
+	btn.type = "button";
+	btn.className = "screen-fullscreen-btn";
+	btn.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg><span class="fs-label">全屏</span>';
+	document.body.appendChild(btn);
+
+	function current() {
+		return document.fullscreenElement || document.webkitFullscreenElement || null;
+	}
+
+	function sync() {
+		var on = !!current();
+		var label = btn.querySelector(".fs-label");
+		label.textContent = on ? "退出全屏" : "全屏";
+		btn.title = on ? "退出全屏" : "全屏显示";
+	}
+
+	function enter() {
+		var el = document.documentElement;
+		var req = el.requestFullscreen || el.webkitRequestFullscreen;
+		if (req) {
+			req.call(el);
+		}
+	}
+
+	function exit() {
+		var leave = document.exitFullscreen || document.webkitExitFullscreen;
+		if (leave) {
+			leave.call(document);
+		}
+	}
+
+	btn.addEventListener("click", function () {
+		if (current()) {
+			exit();
+		} else {
+			enter();
+		}
+	});
+	document.addEventListener("fullscreenchange", sync);
+	document.addEventListener("webkitfullscreenchange", sync);
+	sync();
+})();

+ 17 - 5
src/main/resources/views/modules/mes/mesMaterialCheckRecordList.html

@@ -64,11 +64,23 @@ $('#btnExport').click(function(){
 		js.showMessage('${text("请先勾选要导出的来料检查记录")}');
 		return;
 	}
-	js.ajaxSubmit({
-		url:'${ctx}/mes/mesMaterialCheckRecord/exportData',
-		data:{ids: ids.join(',')},
-		downloadFile:true
-	});
+	// 不用 ajax:jeesite 会把 xlsx 当 JSON 解析,并把文件内容当成报错弹出来
+	var frameName = 'materialCheckExportFrame';
+	if ($('iframe[name="' + frameName + '"]').length === 0) {
+		$('body').append('<iframe name="' + frameName + '" style="display:none;"></iframe>');
+	}
+	var $form = $('<form method="post" style="display:none;"></form>');
+	$form.attr('action', '${ctx}/mes/mesMaterialCheckRecord/exportData');
+	$form.attr('target', frameName);
+	$form.append($('<input type="hidden" name="ids"/>').val(ids.join(',')));
+	var csrfParam = $('meta[name="csrf-param"]').attr('content');
+	var csrfToken = $('meta[name="csrf-token"]').attr('content');
+	if (csrfParam && csrfToken) {
+		$form.append($('<input type="hidden"/>').attr('name', csrfParam).val(csrfToken));
+	}
+	$('body').append($form);
+	$form[0].submit();
+	setTimeout(function(){ $form.remove(); }, 2000);
 });
 // 初始化DataGrid对象
 $('#dataGrid').dataGrid({

+ 150 - 0
src/main/resources/views/modules/mes/mesScreen10.html

@@ -0,0 +1,150 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<meta http-equiv="X-UA-Compatible" content="ie=edge">
+	<title>生产运营看板</title>
+	<link rel="stylesheet" href="${ctxStatic}/screen/kpi-screen.css?v=20260930">
+</head>
+<body>
+<div class="screen-page">
+	<img class="bg" src="${ctxStatic}/screen/imgs/bg3.png" alt="">
+	<div class="screen-content">
+		<div class="screen-header">
+			<div class="header-meta">
+				<span class="meta-chip" id="statDay">统计日 --</span>
+				<span class="meta-chip" id="statWindow">08:00-次日08:00</span>
+			</div>
+			<div class="screen-title-new" id="screenTitle">生产运营看板</div>
+			<div class="screen-time" id="screenTime"></div>
+		</div>
+
+		<div class="summary-row">
+			<div class="summary-item">
+				<div class="summary-label">计划产量</div>
+				<div class="summary-value" id="planCount">0</div>
+			</div>
+			<div class="summary-item is-actual">
+				<div class="summary-label">实际完工</div>
+				<div class="summary-value" id="actualCount">0</div>
+			</div>
+			<div class="summary-item is-ok">
+				<div class="summary-label">检验合格</div>
+				<div class="summary-value" id="okCount">0</div>
+			</div>
+			<div class="summary-item is-ng">
+				<div class="summary-label">检验不良</div>
+				<div class="summary-value" id="ngCount">0</div>
+			</div>
+			<div class="summary-item is-repair">
+				<div class="summary-label">返修件数</div>
+				<div class="summary-value" id="repairCount">0</div>
+			</div>
+			<div class="summary-item is-shift">
+				<div class="summary-label">当前班次</div>
+				<div class="summary-value summary-text" id="shiftTitle">--</div>
+			</div>
+		</div>
+
+		<div class="kpi-row">
+			<div class="kpi-card">
+				<div class="kpi-chart" id="achieveGauge"></div>
+				<div class="kpi-side">
+					<div class="kpi-name">生产达成率</div>
+					<div class="kpi-desc">完工 / 班次计划</div>
+					<div class="kpi-extra" id="achieveExtra">0 / 0</div>
+				</div>
+			</div>
+			<div class="kpi-card">
+				<div class="kpi-chart" id="fpyGauge"></div>
+				<div class="kpi-side">
+					<div class="kpi-name">直通率</div>
+					<div class="kpi-desc">工位一次合格占比</div>
+					<div class="kpi-extra" id="fpyExtra">合格 0 / 检验 0</div>
+				</div>
+			</div>
+			<div class="kpi-card">
+				<div class="kpi-chart" id="oeeGauge"></div>
+				<div class="kpi-side">
+					<div class="kpi-name">OEE</div>
+					<div class="kpi-factor"><span>时间稼动</span><b id="availabilityRate">0%</b></div>
+					<div class="kpi-factor"><span>性能稼动</span><b id="performanceRate">0%</b></div>
+					<div class="kpi-factor"><span>合格率</span><b id="qualityRate">0%</b></div>
+				</div>
+			</div>
+			<div class="kpi-card">
+				<div class="kpi-chart" id="shiftGauge"></div>
+				<div class="kpi-side">
+					<div class="kpi-name">班次达成</div>
+					<div class="kpi-desc" id="shiftDesc">当前班次报工</div>
+					<div class="kpi-extra" id="alarmExtra">未关闭报警 0</div>
+				</div>
+			</div>
+		</div>
+
+		<div class="chart-row">
+			<div class="panel">
+				<div class="panel-title">近七日达成率 / 直通率 / OEE</div>
+				<div class="chart-box" id="trendChart"></div>
+			</div>
+			<div class="panel">
+				<div class="panel-title">当日两小时完工</div>
+				<div class="chart-box" id="hourChart"></div>
+			</div>
+		</div>
+
+		<div class="table-row">
+			<div class="panel table-panel">
+				<div class="panel-title">班次报工</div>
+				<div class="table-wrap">
+					<table class="kpi-table">
+						<thead>
+						<tr>
+							<th>班次</th>
+							<th>时段</th>
+							<th>计划</th>
+							<th>完工</th>
+							<th>合格</th>
+							<th>不良</th>
+							<th>返修</th>
+							<th>达成率</th>
+							<th>直通率</th>
+						</tr>
+						</thead>
+						<tbody id="shiftBody"></tbody>
+					</table>
+				</div>
+			</div>
+			<div class="panel table-panel product-panel">
+				<div class="panel-title">产品达成</div>
+				<div class="table-wrap">
+					<table class="kpi-table">
+						<thead>
+						<tr>
+							<th>产品</th>
+							<th>计划</th>
+							<th>完工</th>
+							<th>达成率</th>
+						</tr>
+						</thead>
+						<tbody id="productBody"></tbody>
+					</table>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<script src="${ctxStatic}/jquery-1.11.3.min.js"></script>
+<script src="${ctxStatic}/echarts/4.2/echarts.min.js"></script>
+<script>
+	window.kpiScreenConfig = {
+		title: "生产运营看板",
+		refreshInterval: 60000,
+		apiUrl: "${ctx}/mes/mesProduct/screenKpiData"
+	};
+</script>
+<script src="${ctxStatic}/screen/kpi-screen.js?v=20260930"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
+</body>
+</html>

+ 84 - 0
src/main/resources/views/modules/mes/mesScreen11.html

@@ -0,0 +1,84 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<meta http-equiv="X-UA-Compatible" content="ie=edge">
+	<title>生产简报</title>
+	<link rel="stylesheet" href="${ctxStatic}/screen/exec-screen.css?v=202609303">
+</head>
+<body>
+<div class="exec-page">
+	<header class="exec-header">
+		<div class="header-side">
+			<span class="meta-chip" id="statDay">统计日 --</span>
+			<span class="meta-chip" id="shiftChip">当前班次 --</span>
+		</div>
+		<h1 id="screenTitle">生产简报</h1>
+		<div class="header-side is-right">
+			<span class="screen-time" id="screenTime"></span>
+		</div>
+	</header>
+
+	<section class="hero-grid">
+		<article class="hero-card">
+			<div class="hero-label">今日完工</div>
+			<div class="hero-num" id="actualNum">0</div>
+			<div class="hero-sub" id="planText">计划 0</div>
+		</article>
+		<article class="hero-card">
+			<div class="hero-label" id="gapLabel">还差</div>
+			<div class="hero-num" id="gapNum">0</div>
+			<div class="hero-foot">
+				<em class="tag" id="achieveTag">--</em>
+				<span id="achieveText">达成 --</span>
+			</div>
+			<div class="meter"><i id="achieveBar"></i></div>
+		</article>
+		<article class="hero-card">
+			<div class="hero-label">一次合格</div>
+			<div class="hero-num" id="fpyNum">--</div>
+			<div class="hero-sub" id="qualityText">返修 0 · 不良 0</div>
+		</article>
+		<article class="hero-card">
+			<div class="hero-label">近七日完工</div>
+			<div class="hero-num" id="weekActual">0</div>
+			<div class="hero-sub" id="weekText">计划 0 · 平均达成 --</div>
+		</article>
+	</section>
+
+	<section class="main-grid">
+		<div class="panel chart-panel">
+			<div class="panel-title">近七日产量</div>
+			<div class="chart-box" id="outputChart"></div>
+		</div>
+		<div class="panel">
+			<div class="panel-title">需要过问</div>
+			<ul class="attention-list" id="attentionList"></ul>
+		</div>
+	</section>
+
+	<section class="bottom-grid">
+		<div class="panel">
+			<div class="panel-title">产品完成情况</div>
+			<div class="bar-list" id="productBars"></div>
+		</div>
+		<div class="panel">
+			<div class="panel-title">班次完成情况</div>
+			<div class="bar-list" id="shiftBars"></div>
+		</div>
+	</section>
+</div>
+<script src="${ctxStatic}/jquery-1.11.3.min.js"></script>
+<script src="${ctxStatic}/echarts/4.2/echarts.min.js"></script>
+<script>
+	window.execScreenConfig = {
+		title: "生产简报",
+		refreshInterval: 60000,
+		apiUrl: "${ctx}/mes/mesProduct/screenKpiData"
+	};
+</script>
+<script src="${ctxStatic}/screen/exec-screen.js?v=202609303"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
+</body>
+</html>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 385 - 960
src/main/resources/views/modules/mes/mesScreen4.html


+ 1 - 0
src/main/resources/views/modules/mes/mesScreen5.html

@@ -187,5 +187,6 @@
 	};
 </script>
 <script src="${ctxStatic}/screen/oprno-record-screen.js?v=20250731"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
 </body>
 </html>

+ 1 - 0
src/main/resources/views/modules/mes/mesScreen6.html

@@ -187,5 +187,6 @@
 	};
 </script>
 <script src="${ctxStatic}/screen/oprno-record-screen.js?v=20250731"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
 </body>
 </html>

+ 1 - 0
src/main/resources/views/modules/mes/mesScreen7.html

@@ -187,5 +187,6 @@
 	};
 </script>
 <script src="${ctxStatic}/screen/oprno-record-screen.js?v=20250731"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
 </body>
 </html>

+ 1 - 0
src/main/resources/views/modules/mes/mesScreen8.html

@@ -50,5 +50,6 @@
 	};
 </script>
 <script src="${ctxStatic}/screen/gp12-issue-screen.js?v=202607153"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
 </body>
 </html>

+ 1 - 0
src/main/resources/views/modules/mes/mesScreen9.html

@@ -71,5 +71,6 @@
 	};
 </script>
 <script src="${ctxStatic}/screen/repair-screen.js?v=202607264"></script>
+<script src="${ctxStatic}/screen/screen-fullscreen.js?v=20261010"></script>
 </body>
 </html>

+ 87 - 0
src/main/resources/views/modules/mes/mesScreenForm.html

@@ -0,0 +1,87 @@
+<% layout('/layouts/default.html', {title: '大屏管理', libs: ['validate']}){ %>
+<div class="main-content">
+	<div class="box box-main">
+		<div class="box-header with-border">
+			<div class="box-title">
+				<i class="fa icon-note"></i> ${text(mesScreen.isNewRecord ? '新增大屏' : '编辑大屏')}
+			</div>
+			<div class="box-tools pull-right">
+				<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i></button>
+			</div>
+		</div>
+		<#form:form id="inputForm" model="${mesScreen}" action="${ctx}/mes/mesScreen/save" method="post" class="form-horizontal">
+			<div class="box-body">
+				<div class="form-unit">${text('基本信息')}</div>
+				<#form:hidden path="id"/>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">
+								<span class="required">*</span> ${text('名称')}:</label>
+							<div class="col-sm-6">
+								<#form:input path="title" maxlength="100" class="form-control required"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">
+								<span class="required">*</span> ${text('地址')}:</label>
+							<div class="col-sm-6">
+								<#form:input path="screenUrl" maxlength="255" class="form-control required" placeholder="/mes/mesProduct/screen10"/>
+								<div class="help-block">系统内页面填 /mes/mesProduct/screen10 这种相对地址,外部页面填完整网址。</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('说明')}:</label>
+							<div class="col-sm-6">
+								<#form:input path="remark" maxlength="255" class="form-control"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('排序')}:</label>
+							<div class="col-sm-2">
+								<#form:input path="sortNo" maxlength="8" class="form-control digits"/>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<div class="box-footer">
+				<div class="row">
+					<div class="col-sm-offset-2 col-sm-10">
+						<% if (hasPermi('mes:mesScreen:edit')){ %>
+							<button type="submit" class="btn btn-sm btn-primary" id="btnSubmit"><i class="fa fa-check"></i> ${text('保 存')}</button>&nbsp;
+						<% } %>
+						<button type="button" class="btn btn-sm btn-default" id="btnCancel" onclick="js.closeCurrentTabPage()"><i class="fa fa-reply-all"></i> ${text('关 闭')}</button>
+					</div>
+				</div>
+			</div>
+		</#form:form>
+	</div>
+</div>
+<% } %>
+<script>
+$("#inputForm").validate({
+	submitHandler: function(form){
+		js.ajaxSubmitForm($(form), function(data){
+			js.showMessage(data.message);
+			if(data.result == Global.TRUE){
+				js.closeCurrentTabPage(function(contentWindow){
+					contentWindow.page();
+				});
+			}
+		}, "json");
+	}
+});
+</script>

+ 71 - 0
src/main/resources/views/modules/mes/mesScreenList.html

@@ -0,0 +1,71 @@
+<% layout('/layouts/default.html', {title: '大屏管理', libs: ['dataGrid']}){ %>
+<div class="main-content">
+	<div class="box box-main">
+		<div class="box-header">
+			<div class="box-title">
+				<i class="fa fa-desktop"></i> ${text('大屏管理')}
+			</div>
+			<div class="box-tools pull-right">
+				<a href="#" class="btn btn-default" id="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></i> ${text('查询')}</a>
+				<% if(hasPermi('mes:mesScreen:edit')){ %>
+					<a href="${ctx}/mes/mesScreen/form" class="btn btn-default btnTool" title="${text('新增大屏')}"><i class="fa fa-plus"></i> ${text('新增')}</a>
+				<% } %>
+			</div>
+		</div>
+		<div class="box-body">
+			<#form:form id="searchForm" model="${mesScreen}" action="${ctx}/mes/mesScreen/listData" method="post" class="form-inline hide"
+					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
+				<div class="form-group">
+					<label class="control-label">${text('名称')}:</label>
+					<div class="control-inline">
+						<#form:input path="title" maxlength="100" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<button type="submit" class="btn btn-primary btn-sm">${text('查询')}</button>
+					<button type="reset" class="btn btn-default btn-sm">${text('重置')}</button>
+				</div>
+			</#form:form>
+			<table id="dataGrid"></table>
+			<div id="dataGridPage"></div>
+		</div>
+	</div>
+</div>
+<% } %>
+<script>
+function screenOpenUrl(url) {
+	if (!url) {
+		return "";
+	}
+	if (/^https?:\/\//i.test(url)) {
+		return url;
+	}
+	if (url.indexOf("/") === 0) {
+		return "${ctx}" + url;
+	}
+	return "${ctx}/" + url;
+}
+$('#dataGrid').dataGrid({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("名称")}', name:'title', index:'a.title', width:180, align:"left", frozen:true, formatter: function(val, obj, row, act){
+			return '<a href="${ctx}/mes/mesScreen/form?id='+row.id+'" class="btnList" data-title="${text("编辑大屏")}">'+(val||row.id)+'</a>';
+		}},
+		{header:'${text("地址")}', name:'screenUrl', index:'a.screen_url', width:280, align:"left"},
+		{header:'${text("说明")}', name:'remark', index:'a.remark', width:280, align:"left"},
+		{header:'${text("排序")}', name:'sortNo', index:'a.sort_no', width:70, align:"center"},
+		{header:'${text("操作")}', name:'actions', width:140, align:"center", formatter: function(val, obj, row, act){
+			var actions = [];
+			actions.push('<a href="'+screenOpenUrl(row.screenUrl)+'" class="btnList" target="_blank" title="${text("打开大屏")}"><i class="fa fa-desktop"></i></a>&nbsp;');
+			//<% if(hasPermi('mes:mesScreen:edit')){ %>
+				actions.push('<a href="${ctx}/mes/mesScreen/form?id='+row.id+'" class="btnList" title="${text("编辑大屏")}"><i class="fa fa-pencil"></i></a>&nbsp;');
+				actions.push('<a href="${ctx}/mes/mesScreen/delete?id='+row.id+'" class="btnList" title="${text("删除大屏")}" data-confirm="${text("确认要删除该大屏吗?")}"><i class="fa fa-trash-o"></i></a>&nbsp;');
+			//<% } %>
+			return actions.join('');
+		}}
+	],
+	ajaxSuccess: function(data){
+
+	}
+});
+</script>