#
Junjie
2026-03-07 bf64e8016283b18c04d5392dd9c002b921021af2
#
5个文件已添加
14个文件已修改
4775 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/WatchStationColorController.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/RedisKeyType.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/plugin/FakeProcess.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/plugin/NormalProcess.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/plugin/XiaosongProcess.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/utils/WmsOperateUtils.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/DevpCard.js 645 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 562 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MonitorCardKit.js 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MonitorWorkbench.js 661 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchCrnCard.js 419 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchDualCrnCard.js 576 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchRgvCard.js 386 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/watch/stationColorConfig.js 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/config/config.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/deviceLogs/deviceLogs.html 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console.html 794 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/stationColorConfig.html 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/WatchStationColorController.java
New file
@@ -0,0 +1,152 @@
package com.zy.asrs.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/watch/stationColor")
public class WatchStationColorController {
    @Autowired
    private RedisUtil redisUtil;
    @GetMapping("/config/auth")
    @ManagerAuth
    public R getConfig() {
        return R.ok(buildResponseData(loadStoredColorMap()));
    }
    @PostMapping("/config/save/auth")
    @ManagerAuth
    public R saveConfig(@RequestBody Map<String, Object> payload) {
        Map<String, String> storedMap = loadStoredColorMap();
        Map<String, String> mergedMap = new LinkedHashMap<>(storedMap);
        Object itemsObj = payload == null ? null : payload.get("items");
        if (!(itemsObj instanceof List)) {
            return R.error("请传入颜色配置列表");
        }
        List<?> items = (List<?>) itemsObj;
        Set<String> allowedStatuses = getDefaultColorMap().keySet();
        for (Object obj : items) {
            if (!(obj instanceof Map)) {
                continue;
            }
            Map<?, ?> item = (Map<?, ?>) obj;
            String status = item.get("status") == null ? null : String.valueOf(item.get("status")).trim();
            String color = item.get("color") == null ? null : String.valueOf(item.get("color")).trim();
            if (status == null || status.isEmpty() || !allowedStatuses.contains(status)) {
                continue;
            }
            mergedMap.put(status, normalizeColor(color, getDefaultColorMap().get(status)));
        }
        redisUtil.set(RedisKeyType.WATCH_STATION_COLOR_CONFIG.key, JSON.toJSONString(mergedMap));
        return R.ok(buildResponseData(mergedMap));
    }
    private Map<String, Object> buildResponseData(Map<String, String> storedMap) {
        Map<String, String> defaults = getDefaultColorMap();
        List<Map<String, String>> items = new ArrayList<>();
        for (Map<String, String> meta : getColorMetaList()) {
            String status = meta.get("status");
            Map<String, String> item = new LinkedHashMap<>(meta);
            item.put("defaultColor", defaults.get(status));
            item.put("color", normalizeColor(storedMap.get(status), defaults.get(status)));
            items.add(item);
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("items", items);
        data.put("defaults", defaults);
        return data;
    }
    private Map<String, String> loadStoredColorMap() {
        Map<String, String> result = new LinkedHashMap<>(getDefaultColorMap());
        Object object = redisUtil.get(RedisKeyType.WATCH_STATION_COLOR_CONFIG.key);
        if (object == null) {
            return result;
        }
        try {
            JSONObject jsonObject;
            if (object instanceof JSONObject) {
                jsonObject = (JSONObject) object;
            } else {
                jsonObject = JSON.parseObject(String.valueOf(object));
            }
            if (jsonObject == null) {
                return result;
            }
            for (String status : getDefaultColorMap().keySet()) {
                String color = jsonObject.getString(status);
                if (color != null) {
                    result.put(status, normalizeColor(color, getDefaultColorMap().get(status)));
                }
            }
        } catch (Exception ignore) {
        }
        return result;
    }
    private Map<String, String> getDefaultColorMap() {
        Map<String, String> defaults = new LinkedHashMap<>();
        defaults.put("site-auto", "#78FF81");
        defaults.put("site-auto-run", "#FA51F6");
        defaults.put("site-auto-id", "#C4C400");
        defaults.put("site-auto-run-id", "#30BFFC");
        defaults.put("site-enable-in", "#18C7B8");
        defaults.put("site-unauto", "#B8B8B8");
        defaults.put("machine-pakin", "#30BFFC");
        defaults.put("machine-pakout", "#97B400");
        defaults.put("site-run-block", "#E69138");
        return defaults;
    }
    private List<Map<String, String>> getColorMetaList() {
        List<Map<String, String>> list = new ArrayList<>();
        list.add(buildMeta("site-auto", "自动", "站点自动待命时的颜色。"));
        list.add(buildMeta("site-auto-run", "自动 + 有物", "站点自动运行且有物,但还没有工作号时的颜色。"));
        list.add(buildMeta("site-auto-id", "自动 + 工作号", "站点自动且存在工作号,但当前无物时的颜色。"));
        list.add(buildMeta("site-auto-run-id", "自动 + 有物 + 工作号", "普通运行中的站点颜色,未命中入库/出库范围时使用。"));
        list.add(buildMeta("site-enable-in", "启动入库", "工作号为 9998 或站点带启动入库标记时的颜色。"));
        list.add(buildMeta("machine-pakin", "入库任务", "工作号命中入库范围时的颜色。"));
        list.add(buildMeta("machine-pakout", "出库任务", "工作号命中出库范围时的颜色。"));
        list.add(buildMeta("site-run-block", "运行堵塞", "站点处于运行堵塞时的颜色。"));
        list.add(buildMeta("site-unauto", "非自动", "站点非自动时的颜色。"));
        return list;
    }
    private Map<String, String> buildMeta(String status, String name, String desc) {
        Map<String, String> map = new LinkedHashMap<>();
        map.put("status", status);
        map.put("name", name);
        map.put("desc", desc);
        return map;
    }
    private String normalizeColor(String color, String fallback) {
        if (color == null) {
            return fallback;
        }
        String value = color.trim();
        if (value.matches("^#[0-9a-fA-F]{6}$")) {
            return value.toUpperCase();
        }
        if (value.matches("^#[0-9a-fA-F]{3}$")) {
            return ("#" + value.charAt(1) + value.charAt(1) + value.charAt(2) + value.charAt(2) + value.charAt(3) + value.charAt(3)).toUpperCase();
        }
        if (value.matches("^0x[0-9a-fA-F]{6}$")) {
            return ("#" + value.substring(2)).toUpperCase();
        }
        return fallback;
    }
}
src/main/java/com/zy/core/enums/RedisKeyType.java
@@ -55,6 +55,7 @@
    CRN_OUT_TASK_COMPLETE_STATION_INFO("crn_out_task_complete_station_info_"),
    WATCH_CIRCLE_STATION_("watch_circle_station_"),
    WATCH_STATION_COLOR_CONFIG("watch_station_color_config"),
    STATION_CYCLE_LOAD_RESERVE("station_cycle_load_reserve"),
    CURRENT_CIRCLE_TASK_CRN_NO("current_circle_task_crn_no_"),
src/main/java/com/zy/core/plugin/FakeProcess.java
@@ -496,7 +496,7 @@
                        // 1. 首先查询是否有已完成的异步响应
                        String response = wmsOperateUtils.queryAsyncInTaskResponse(barcode, stationIdVal);
                        if (response != null) {
                        if (!Cools.isEmpty(response)) {
                            // 2. 有响应结果,处理响应
                            if (response.equals("FAILED") || response.startsWith("ERROR:")) {
                                // 请求失败,重新发起异步请求
src/main/java/com/zy/core/plugin/NormalProcess.java
@@ -149,7 +149,7 @@
                        // 1. 首先查询是否有已完成的异步响应
                        String response = wmsOperateUtils.queryAsyncInTaskResponse(barcode, stationIdVal);
                        if (response != null) {
                        if (!Cools.isEmpty(response)) {
                            // 2. 有响应结果,处理响应
                            if (response.equals("FAILED") || response.startsWith("ERROR:")) {
                                // 请求失败,重新发起异步请求
src/main/java/com/zy/core/plugin/XiaosongProcess.java
@@ -176,7 +176,7 @@
                        // 1. 首先查询是否有已完成的异步响应
                        String response = wmsOperateUtils.queryAsyncInTaskResponse(barcode, stationIdVal);
                        if (response != null) {
                        if (!Cools.isEmpty(response)) {
                            // 2. 有响应结果,处理响应
                            if (response.equals("FAILED") || response.startsWith("ERROR:")) {
                                // 请求失败,重新发起异步请求
src/main/java/com/zy/core/utils/WmsOperateUtils.java
@@ -103,7 +103,7 @@
                    .setTimeout(30, TimeUnit.SECONDS)
                    .build()
                    .doPost();
            if (response != null) {
            if (!Cools.isEmpty(response)) {
                JSONObject jsonObject = JSON.parseObject(response);
                if (jsonObject.getInteger("code") == 200) {
                    result = 1;
src/main/resources/application.yml
@@ -1,6 +1,6 @@
# 系统版本信息
app:
  version: 1.0.4.7
  version: 1.0.4.8
  version-type: dev  # prd 或 dev
server:
src/main/webapp/components/DevpCard.js
@@ -1,49 +1,63 @@
Vue.component("devp-card", {
  template: `
    <div>
        <div style="display: flex;margin-bottom: 10px;">
            <div style="width: 100%;">输送监控</div>
            <div style="width: 100%;text-align: right;display: flex;"><el-input size="mini" v-model="searchStationId" placeholder="请输入站号"></el-input><el-button @click="getDevpStateInfo" size="mini">查询</el-button></div>
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">输送监控</div>
        <div class="mc-search">
          <input class="mc-input" v-model="searchStationId" placeholder="请输入站号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getDevpStateInfo">查询</button>
        </div>
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
            </div>
            <div v-if="showControl" style="display: flex;justify-content: space-between;flex-wrap: wrap;">
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.stationId" placeholder="站号"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.taskNo" placeholder="工作号"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.targetStationId" placeholder="目标站"></el-input></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommand()" size="mini">下发</el-button></div>
                <div style="margin-bottom: 10px;"><el-button @click="resetCommand()" size="mini">复位</el-button></div>
            </div>
      </div>
      <div v-if="!readOnly" class="mc-control-toggle">
        <button type="button" class="mc-btn mc-btn-ghost" @click="openControl">
          {{ showControl ? '收起控制中心' : '打开控制中心' }}
        </button>
      </div>
      <div v-if="showControl" class="mc-control">
        <div class="mc-control-grid">
          <label class="mc-field">
            <span class="mc-field-label">站号</span>
            <input class="mc-input" v-model="controlParam.stationId" placeholder="例如 101" />
          </label>
          <label class="mc-field">
            <span class="mc-field-label">工作号</span>
            <input class="mc-input" v-model="controlParam.taskNo" placeholder="输入工作号" />
          </label>
          <label class="mc-field mc-span-2">
            <span class="mc-field-label">目标站</span>
            <input class="mc-input" v-model="controlParam.targetStationId" placeholder="输入目标站号" />
          </label>
          <div class="mc-action-row">
            <button type="button" class="mc-btn" @click="controlCommand">下发</button>
            <button type="button" class="mc-btn mc-btn-soft" @click="resetCommand">复位</button>
          </div>
        </div>
        <div style="max-height: 55vh; overflow:auto;">
          <el-collapse v-model="activeNames" accordion>
            <el-collapse-item v-for="(item) in displayStationList" :name="item.stationId">
            <template slot="title">
                <div style="width: 100%;display: flex;">
                   <div style="width: 50%;">{{ item.stationId }}站</div>
                   <div style="width: 50%;text-align: right;">
                      <el-tag v-if="item.autoing" type="success" size="small">自动</el-tag>
                      <el-tag v-else type="warning" size="small">手动</el-tag>
                   </div>
                </div>
            </template>
            <el-descriptions border direction="vertical">
                <el-descriptions-item label="编号">{{ item.stationId }}</el-descriptions-item>
                <el-descriptions-item label="工作号">{{ item.taskNo }}</el-descriptions-item>
                <el-descriptions-item label="目标站">{{ item.targetStaNo }}</el-descriptions-item>
                <el-descriptions-item label="模式">{{ item.autoing ? '自动' : '手动' }}</el-descriptions-item>
                <el-descriptions-item label="有物">{{ item.loading ? '有' : '无' }}</el-descriptions-item>
                <el-descriptions-item label="可入">{{ item.inEnable ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="可出">{{ item.outEnable ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="空板信号">{{ item.emptyMk ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="满板信号">{{ item.fullPlt ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="运行阻塞">{{ item.runBlock ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="启动入库">{{ item.enableIn ? 'Y' : 'N' }}</el-descriptions-item>
                <el-descriptions-item label="托盘高度">{{ item.palletHeight }}</el-descriptions-item>
                <el-descriptions-item label="条码">
      </div>
      <div class="mc-collapse">
        <div
          v-for="item in displayStationList"
          :key="item.stationId"
          :class="['mc-item', { 'is-open': isActive(item.stationId) }]"
        >
          <button type="button" class="mc-head" @click="toggleItem(item)">
            <div class="mc-head-main">
              <div class="mc-head-title">{{ item.stationId }}站</div>
              <div class="mc-head-subtitle">任务 {{ orDash(item.taskNo) }} | 目标站 {{ orDash(item.targetStaNo) }}</div>
            </div>
            <div class="mc-head-right">
              <span :class="['mc-badge', 'is-' + getStatusTone(item)]">{{ getStatusLabel(item) }}</span>
              <span class="mc-chevron">{{ isActive(item.stationId) ? '▾' : '▸' }}</span>
            </div>
          </button>
          <div v-if="isActive(item.stationId)" class="mc-body">
            <div class="mc-detail-grid">
              <div v-for="entry in buildDetailEntries(item)" :key="entry.label" class="mc-detail-cell">
                <div class="mc-detail-label">{{ entry.label }}</div>
                <div v-if="entry.type === 'barcode'" class="mc-detail-value mc-code">
                  <el-popover v-if="item.barcode" placement="top" width="460" trigger="hover">
                    <div style="text-align: center;">
                      <img
@@ -53,135 +67,287 @@
                      />
                      <div style="margin-top: 4px; font-size: 12px; word-break: break-all;">{{ item.barcode }}</div>
                    </div>
                    <span slot="reference" @click.stop="handleBarcodeClick(item)" style="cursor: pointer; color: #409EFF;">{{ item.barcode }}</span>
                    <span
                      slot="reference"
                      @click.stop="handleBarcodeClick(item)"
                      style="cursor: pointer; color: #4677a4; font-weight: 600;"
                    >{{ entry.value }}</span>
                  </el-popover>
                  <span v-else @click.stop="handleBarcodeClick(item)" style="cursor: pointer; color: #409EFF;">-</span>
                </el-descriptions-item>
                <el-descriptions-item label="重量">{{ item.weight }}</el-descriptions-item>
                <el-descriptions-item label="任务可写区">{{ item.taskWriteIdx }}</el-descriptions-item>
                <el-descriptions-item label="故障代码">{{ item.error }}</el-descriptions-item>
                <el-descriptions-item label="故障信息">{{ item.errorMsg }}</el-descriptions-item>
                <el-descriptions-item label="扩展数据">{{ item.extend }}</el-descriptions-item>
            </el-descriptions>
            </el-collapse-item>
          </el-collapse>
                  <span
                    v-else
                    @click.stop="handleBarcodeClick(item)"
                    style="cursor: pointer; color: #4677a4; font-weight: 600;"
                  >{{ entry.value }}</span>
                </div>
                <div v-else class="mc-detail-value" :class="{ 'mc-code': entry.code }">{{ entry.value }}</div>
              </div>
            </div>
          </div>
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:8px;">
          <el-pagination
            small
            @current-change="handlePageChange"
            @size-change="handleSizeChange"
            :current-page="currentPage"
            :page-size="pageSize"
            :page-sizes="[10,20,50,100]"
            layout="total, prev, pager, next"
            :total="stationList.length">
          </el-pagination>
        </div>
        <div v-if="displayStationList.length === 0" class="mc-empty">当前没有可展示的站点数据</div>
      </div>
      <div class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
      </div>
    </div>
    `,
  `,
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
    param: { type: Object, default: function () { return {}; } },
    items: { type: Array, default: null },
    autoRefresh: { type: Boolean, default: true },
    readOnly: { type: Boolean, default: false }
  },
  data() {
  data: function () {
    return {
      stationList: [],
      fullStationList: [],
      activeNames: "",
      searchStationId: "",
      showControl: false,
      controlParam: {
        stationId: "",
        taskNo: "",
        targetStationId: "",
        targetStationId: ""
      },
      barcodePreviewCache: {},
      pageSize: 25,
      pageSize: 12,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getDevpStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayStationList() {
      const start = (this.currentPage - 1) * this.pageSize;
      const end = start + this.pageSize;
      return this.stationList.slice(start, end);
    sourceList: function () {
      return Array.isArray(this.items) ? this.items : this.stationList;
    },
    filteredStationList: function () {
      var keyword = String(this.searchStationId || "").trim();
      if (!keyword) {
        return this.sourceList;
      }
      return this.sourceList.filter(function (item) {
        return String(item.stationId) === keyword;
      });
    },
    displayStationList: function () {
      var start = (this.currentPage - 1) * this.pageSize;
      return this.filteredStationList.slice(start, start + this.pageSize);
    },
    totalPages: function () {
      return Math.max(1, Math.ceil(this.filteredStationList.length / this.pageSize) || 1);
    }
  },
  watch: {
    param: {
      handler(newVal, oldVal) {
        if (newVal && newVal.stationId && newVal.stationId != 0) {
          this.activeNames = newVal.stationId;
          this.searchStationId = newVal.stationId;
        }
      },
      deep: true, // 深度监听嵌套属性
      immediate: true, // 立即触发一次(可选)
    items: function () {
      this.afterDataRefresh();
    },
    param: {
      deep: true,
      immediate: true,
      handler: function (newVal) {
        if (newVal && newVal.stationId && newVal.stationId !== 0) {
          this.focusStation(newVal.stationId);
        }
      }
    }
  },
  created: function () {
    MonitorCardKit.ensureStyles();
    if (this.autoRefresh) {
      this.timer = setInterval(this.getDevpStateInfo, 1000);
    }
  },
  beforeDestroy: function () {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  methods: {
    handlePageChange(page) {
    orDash: function (value) {
      return MonitorCardKit.orDash(value);
    },
    getStatusLabel: function (item) {
      return item && item.autoing ? "自动" : "手动";
    },
    getStatusTone: function (item) {
      return MonitorCardKit.statusTone(this.getStatusLabel(item));
    },
    isActive: function (stationId) {
      return String(this.activeNames) === String(stationId);
    },
    toggleItem: function (item) {
      var next = String(item.stationId);
      this.activeNames = this.activeNames === next ? "" : next;
    },
    focusStation: function (stationId) {
      this.searchStationId = String(stationId);
      var index = this.filteredStationList.findIndex(function (item) {
        return String(item.stationId) === String(stationId);
      });
      this.currentPage = index >= 0 ? Math.floor(index / this.pageSize) + 1 : 1;
      this.activeNames = String(stationId);
    },
    afterDataRefresh: function () {
      if (this.currentPage > this.totalPages) {
        this.currentPage = this.totalPages;
      }
      if (this.activeNames) {
        var exists = this.filteredStationList.some(function (item) {
          return String(item.stationId) === String(this.activeNames);
        }, this);
        if (!exists) {
          this.activeNames = "";
        }
      }
    },
    handlePageChange: function (page) {
      if (page < 1 || page > this.totalPages) {
        return;
      }
      this.currentPage = page;
    },
    handleSizeChange(size) {
      this.pageSize = size;
      this.currentPage = 1;
    getDevpStateInfo: function () {
      if (this.$root && this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          url: "/console/latest/data/station",
          data: {}
        }));
      }
    },
    getBarcodePreview(barcode) {
      const value = String(barcode || "").trim();
    setStationList: function (res) {
      if (res && res.code === 200) {
        this.stationList = res.data || [];
        this.afterDataRefresh();
      }
    },
    openControl: function () {
      this.showControl = !this.showControl;
    },
    buildDetailEntries: function (item) {
      return [
        { label: "编号", value: this.orDash(item.stationId) },
        { label: "工作号", value: this.orDash(item.taskNo) },
        { label: "目标站", value: this.orDash(item.targetStaNo) },
        { label: "模式", value: item.autoing ? "自动" : "手动" },
        { label: "有物", value: MonitorCardKit.yesNo(item.loading) },
        { label: "可入", value: MonitorCardKit.yesNo(item.inEnable) },
        { label: "可出", value: MonitorCardKit.yesNo(item.outEnable) },
        { label: "空板信号", value: MonitorCardKit.yesNo(item.emptyMk) },
        { label: "满板信号", value: MonitorCardKit.yesNo(item.fullPlt) },
        { label: "运行阻塞", value: MonitorCardKit.yesNo(item.runBlock) },
        { label: "启动入库", value: MonitorCardKit.yesNo(item.enableIn) },
        { label: "托盘高度", value: this.orDash(item.palletHeight) },
        { label: "条码", value: this.orDash(item.barcode), code: true, type: "barcode" },
        { label: "重量", value: this.orDash(item.weight) },
        { label: "任务可写区", value: this.orDash(item.taskWriteIdx) },
        { label: "故障代码", value: this.orDash(item.error) },
        { label: "故障信息", value: this.orDash(item.errorMsg) },
        { label: "扩展数据", value: this.orDash(item.extend) }
      ];
    },
    postControl: function (url, payload) {
      $.ajax({
        url: baseUrl + url,
        headers: {
          token: localStorage.getItem("token")
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(payload),
        success: function (res) {
          if (res && res.code === 200) {
            MonitorCardKit.showMessage(this, res.msg || "操作成功", "success");
          } else {
            MonitorCardKit.showMessage(this, (res && res.msg) || "操作失败", "warning");
          }
        }.bind(this)
      });
    },
    handleBarcodeClick: function (item) {
      if (this.readOnly || !item || item.stationId == null) {
        return;
      }
      this.$prompt("请输入新的条码值(可留空清空)", "修改条码", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        inputValue: item.barcode || "",
        inputPlaceholder: "请输入条码"
      }).then(function (result) {
        this.updateStationBarcode(item.stationId, result && result.value == null ? "" : String(result.value).trim());
      }.bind(this)).catch(function () {});
    },
    updateStationBarcode: function (stationId, barcode) {
      $.ajax({
        url: baseUrl + "/station/command/barcode",
        headers: {
          token: localStorage.getItem("token")
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify({
          stationId: stationId,
          barcode: barcode
        }),
        success: function (res) {
          if (res && res.code === 200) {
            this.syncLocalBarcode(stationId, barcode);
            MonitorCardKit.showMessage(this, "条码修改成功", "success");
          } else {
            MonitorCardKit.showMessage(this, (res && res.msg) || "条码修改失败", "warning");
          }
        }.bind(this)
      });
    },
    syncLocalBarcode: function (stationId, barcode) {
      var update = function (list) {
        if (!list || !list.length) {
          return;
        }
        list.forEach(function (item) {
          if (item.stationId == stationId) {
            item.barcode = barcode;
          }
        });
      };
      update(this.stationList);
      if (Array.isArray(this.items)) {
        update(this.items);
      }
    },
    getBarcodePreview: function (barcode) {
      var value = String(barcode || "").trim();
      if (!value) {
        return "";
      }
      if (this.barcodePreviewCache[value]) {
        return this.barcodePreviewCache[value];
      }
      const encodeResult = this.encodeCode128(value);
      var encodeResult = this.encodeCode128(value);
      if (!encodeResult) {
        return "";
      }
      const svg = this.buildCode128Svg(encodeResult, value);
      const dataUrl = "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg);
      var svg = this.buildCode128Svg(encodeResult, value);
      var dataUrl = "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg);
      this.$set(this.barcodePreviewCache, value, dataUrl);
      return dataUrl;
    },
    encodeCode128(value) {
    encodeCode128: function (value) {
      if (!value) {
        return null;
      }
      const isNumeric = /^\d+$/.test(value);
      var isNumeric = /^\d+$/.test(value);
      if (isNumeric && value.length % 2 === 0) {
        return this.encodeCode128C(value);
      }
      return this.encodeCode128B(value);
    },
    encodeCode128B(value) {
      const codes = [104];
      for (let i = 0; i < value.length; i++) {
        const code = value.charCodeAt(i) - 32;
    encodeCode128B: function (value) {
      var codes = [104];
      for (var i = 0; i < value.length; i++) {
        var code = value.charCodeAt(i) - 32;
        if (code < 0 || code > 94) {
          return null;
        }
@@ -189,64 +355,62 @@
      }
      return this.buildCode128Pattern(codes);
    },
    encodeCode128C(value) {
    encodeCode128C: function (value) {
      if (value.length % 2 !== 0) {
        return null;
      }
      const codes = [105];
      for (let i = 0; i < value.length; i += 2) {
      var codes = [105];
      for (var i = 0; i < value.length; i += 2) {
        codes.push(parseInt(value.substring(i, i + 2), 10));
      }
      return this.buildCode128Pattern(codes);
    },
    buildCode128Pattern(codes) {
      const patterns = this.getCode128Patterns();
      let checksum = codes[0];
      for (let i = 1; i < codes.length; i++) {
    buildCode128Pattern: function (codes) {
      var patterns = this.getCode128Patterns();
      var checksum = codes[0];
      for (var i = 1; i < codes.length; i++) {
        checksum += codes[i] * i;
      }
      const checkCode = checksum % 103;
      const fullCodes = codes.concat([checkCode, 106]);
      let bars = "";
      for (let i = 0; i < fullCodes.length; i++) {
        const code = fullCodes[i];
        if (patterns[code] == null) {
      var checkCode = checksum % 103;
      var fullCodes = codes.concat([checkCode, 106]);
      var bars = "";
      for (var j = 0; j < fullCodes.length; j++) {
        if (patterns[fullCodes[j]] == null) {
          return null;
        }
        bars += patterns[code];
        bars += patterns[fullCodes[j]];
      }
      bars += "11";
      return bars;
    },
    buildCode128Svg(bars, text) {
      const quietModules = 20;
      const modules = quietModules * 2 + bars.split("").reduce((sum, n) => sum + parseInt(n, 10), 0);
      const moduleWidth = modules > 300 ? 1 : 2;
      const width = modules * moduleWidth;
      const barTop = 10;
      const barHeight = 110;
      let x = quietModules * moduleWidth;
      let black = true;
      let rects = "";
      for (let i = 0; i < bars.length; i++) {
        const w = parseInt(bars[i], 10) * moduleWidth;
    buildCode128Svg: function (bars, text) {
      var quietModules = 20;
      var modules = quietModules * 2 + bars.split("").reduce(function (sum, n) {
        return sum + parseInt(n, 10);
      }, 0);
      var moduleWidth = modules > 300 ? 1 : 2;
      var width = modules * moduleWidth;
      var barTop = 10;
      var barHeight = 110;
      var x = quietModules * moduleWidth;
      var black = true;
      var rects = "";
      for (var i = 0; i < bars.length; i++) {
        var w = parseInt(bars[i], 10) * moduleWidth;
        if (black) {
          rects += '<rect x="' + x + '" y="' + barTop + '" width="' + w + '" height="' + barHeight + '" fill="#000" shape-rendering="crispEdges" />';
        }
        x += w;
        black = !black;
      }
      return (
        '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="145" viewBox="0 0 ' + width + ' 145">' +
      return '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="145" viewBox="0 0 ' + width + ' 145">' +
        '<rect width="100%" height="100%" fill="#fff" />' +
        rects +
        '<text x="' + (width / 2) + '" y="136" text-anchor="middle" font-family="monospace" font-size="14" fill="#111">' +
        this.escapeXml(text) +
        "</text>" +
        "</svg>"
      );
        "</text></svg>";
    },
    getCode128Patterns() {
    getCode128Patterns: function () {
      return [
        "212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213",
        "221312", "231212", "112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132",
@@ -261,7 +425,7 @@
        "114131", "311141", "411131", "211412", "211214", "211232", "2331112"
      ];
    },
    escapeXml(text) {
    escapeXml: function (text) {
      return String(text)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
@@ -269,170 +433,11 @@
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&apos;");
    },
    getDevpStateInfo() {
      if (this.readOnly) {
          // Frontend filtering for readOnly mode
          if (this.searchStationId == "") {
              this.stationList = this.fullStationList;
          } else {
              this.stationList = this.fullStationList.filter(item => item.stationId == this.searchStationId);
              this.currentPage = 1;
          }
      } else if (this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          "url": "/console/latest/data/station",
          "data": {}
        }));
      }
    controlCommand: function () {
      this.postControl("/station/command/move", this.controlParam);
    },
    setStationList(res) {
      let that = this;
      if (res.code == 200) {
        let list = res.data;
        that.fullStationList = list;
        if (that.searchStationId == "") {
          that.stationList = list;
        } else {
          let tmp = [];
          list.forEach((item) => {
            if (item.stationId == that.searchStationId) {
              tmp.push(item);
            }
          });
          that.stationList = tmp;
          that.currentPage = 1;
        }
      }
    },
    handleBarcodeClick(item) {
      if (this.readOnly || !item || item.stationId == null) {
        return;
      }
      let that = this;
      $.ajax({
        url: baseUrl + "/openapi/getFakeSystemRunStatus",
        headers: {
          token: localStorage.getItem("token"),
        },
        method: "get",
        success: (res) => {
          if (res.code !== 200 || !res.data || !res.data.isFake || !res.data.running) {
            that.$message({
              message: "仅仿真模式运行中可修改条码",
              type: "warning",
            });
            return;
          }
          that.$prompt("请输入新的条码值(可留空清空)", "修改条码", {
            confirmButtonText: "确定",
            cancelButtonText: "取消",
            inputValue: item.barcode || "",
            inputPlaceholder: "请输入条码",
          }).then(({ value }) => {
            that.updateStationBarcode(item.stationId, value == null ? "" : String(value).trim());
          }).catch(() => {});
        },
      });
    },
    updateStationBarcode(stationId, barcode) {
      let that = this;
      $.ajax({
        url: baseUrl + "/station/command/barcode",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify({
          stationId: stationId,
          barcode: barcode,
        }),
        success: (res) => {
          if (res.code == 200) {
            that.syncLocalBarcode(stationId, barcode);
            that.$message({
              message: "条码修改成功",
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg || "条码修改失败",
              type: "warning",
            });
          }
        },
      });
    },
    syncLocalBarcode(stationId, barcode) {
      let updateFn = (list) => {
        if (!list || list.length === 0) {
          return;
        }
        list.forEach((row) => {
          if (row.stationId == stationId) {
            row.barcode = barcode;
          }
        });
      };
      updateFn(this.stationList);
      updateFn(this.fullStationList);
    },
    openControl() {
      this.showControl = !this.showControl;
    },
    controlCommand() {
      let that = this;
      //下发命令
      $.ajax({
        url: baseUrl + "/station/command/move",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    },
    resetCommand() {
      let that = this;
      //下发命令
      $.ajax({
        url: baseUrl + "/station/command/reset",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    },
  },
    resetCommand: function () {
      this.postControl("/station/command/reset", this.controlParam);
    }
  }
});
src/main/webapp/components/MapCanvas.js
@@ -2,7 +2,7 @@
  template: `
    <div style="width: 100%; height: 100%; position: relative;">
      <div ref="pixiView" style="position: absolute; inset: 0;"></div>
      <div style="position: absolute; top: 12px; left: 14px; z-index: 30; pointer-events: none; max-width: 52%;">
      <div :style="cycleCapacityPanelStyle()">
        <div style="display: flex; flex-direction: column; gap: 6px; align-items: flex-start;">
          <div v-for="item in cycleCapacity.loopList"
               :key="'loop-' + item.loopNo"
@@ -24,14 +24,31 @@
        <div :style="mapToolFpsStyle()">FPS {{ mapFps }}</div>
        <button type="button" @click="toggleMapToolPanel" :style="mapToolToggleStyle(showMapToolPanel)">{{ showMapToolPanel ? '收起操作' : '地图操作' }}</button>
        <div v-show="showMapToolPanel" :style="mapToolBarStyle()">
          <button type="button" @click="toggleStationDirection" :style="mapToolButtonStyle(showStationDirection)">{{ showStationDirection ? '隐藏站点方向' : '显示站点方向' }}</button>
          <button type="button" @click="rotateMap" :style="mapToolButtonStyle(false)">旋转</button>
          <button type="button" @click="toggleMirror" :style="mapToolButtonStyle(mapMirrorX)">{{ mapMirrorX ? '取消镜像' : '镜像' }}</button>
          <div :style="mapToolRowStyle()">
            <button type="button" @click="toggleStationDirection" :style="mapToolButtonStyle(showStationDirection)">{{ showStationDirection ? '隐藏站点方向' : '显示站点方向' }}</button>
            <button type="button" @click="rotateMap" :style="mapToolButtonStyle(false)">旋转</button>
            <button type="button" @click="toggleMirror" :style="mapToolButtonStyle(mapMirrorX)">{{ mapMirrorX ? '取消镜像' : '镜像' }}</button>
          </div>
          <div :style="mapToolRowStyle()">
            <button type="button" @click="openStationColorConfigPage" :style="mapToolButtonStyle(false)">站点颜色</button>
          </div>
          <div v-if="levList && levList.length > 1" :style="mapToolFloorSectionStyle()">
            <div :style="mapToolSectionLabelStyle()">楼层</div>
            <div :style="mapToolFloorListStyle()">
              <button
                v-for="floor in levList"
                :key="'tool-floor-' + floor"
                type="button"
                @click="selectFloorFromTool(floor)"
                :style="mapToolFloorButtonStyle(currentLev == floor)"
              >{{ floor }}F</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  `,
  props: ['lev', 'crnParam', 'rgvParam', 'devpParam', 'highlightOnParamChange'],
  props: ['lev', 'levList', 'crnParam', 'rgvParam', 'devpParam', 'stationTaskRange', 'highlightOnParamChange', 'viewportPadding', 'hudPadding'],
  data() {
    return {
      map: [],
@@ -63,6 +80,10 @@
      pixiDevpTextureMap: new Map(),
      pixiCrnColorTextureMap: new Map(),
      pixiRgvColorTextureMap: new Map(),
      shelfChunkList: [],
      shelfChunkSize: 2048,
      shelfCullPadding: 160,
      shelfCullRaf: null,
      crnList: [],
      dualCrnList: [],
      rgvList: [],
@@ -111,7 +132,18 @@
      hoverLoopNo: null,
      hoverLoopStationIdSet: new Set(),
      loopHighlightColor: 0xfff34d,
      stationDirectionColor: 0xff5a36
      stationDirectionColor: 0xff5a36,
      stationStatusColors: {
        'site-auto': 0x78ff81,
        'site-auto-run': 0xfa51f6,
        'site-auto-id': 0xc4c400,
        'site-auto-run-id': 0x30bffc,
        'site-enable-in': 0x18c7b8,
        'site-unauto': 0xb8b8b8,
        'machine-pakin': 0x30bffc,
        'machine-pakout': 0x97b400,
        'site-run-block': 0xe69138
      }
    }
  },
    mounted() {
@@ -119,6 +151,7 @@
    this.createMap();
    this.startContainerResizeObserve();
    this.loadMapTransformConfig();
    this.loadStationColorConfig();
    this.loadLocList();
    this.connectWs();
    
@@ -138,6 +171,8 @@
    if (this.timer) { clearInterval(this.timer); }
    if (this.hoverRaf) { cancelAnimationFrame(this.hoverRaf); this.hoverRaf = null; }
    if (this.shelfCullRaf) { cancelAnimationFrame(this.shelfCullRaf); this.shelfCullRaf = null; }
    if (window.gsap && this.pixiApp && this.pixiApp.stage) { window.gsap.killTweensOf(this.pixiApp.stage.position); }
    if (this.pixiApp) { this.pixiApp.destroy(true, { children: true }); }
    if (this.containerResizeObserver) { this.containerResizeObserver.disconnect(); this.containerResizeObserver = null; }
    window.removeEventListener('resize', this.resizeToContainer);
@@ -147,6 +182,14 @@
  watch: {
    lev(newLev) {
      if (newLev != null) { this.changeFloor(newLev); }
    },
    viewportPadding: {
      deep: true,
      handler(newVal, oldVal) {
        if (this.mapContentSize && this.mapContentSize.width > 0 && this.mapContentSize.height > 0) {
          this.adjustStageForViewportPadding(oldVal, newVal);
        }
      }
    },
    crnParam: {
      deep: true,
@@ -192,17 +235,65 @@
    }
  },
  methods: {
    cycleCapacityPanelStyle() {
      const hud = this.hudPadding || {};
      const left = Math.max(14, Number(hud.left) || 0);
      const rightReserve = 220;
      return {
        position: 'absolute',
        top: '12px',
        left: left + 'px',
        zIndex: 30,
        pointerEvents: 'none',
        maxWidth: 'calc(100% - ' + (left + rightReserve) + 'px)'
      };
    },
    mapToolBarStyle() {
      return {
        display: 'flex',
        flexDirection: 'column',
        gap: '8px',
        alignItems: 'center',
        alignItems: 'stretch',
        padding: '7px',
        borderRadius: '14px',
        background: 'rgba(255, 255, 255, 0.72)',
        border: '1px solid rgba(160, 180, 205, 0.3)',
        boxShadow: '0 8px 20px rgba(37, 64, 97, 0.08)',
        backdropFilter: 'blur(4px)'
      };
    },
    mapToolRowStyle() {
      return {
        display: 'flex',
        gap: '8px',
        alignItems: 'center',
        justifyContent: 'flex-end',
        flexWrap: 'wrap'
      };
    },
    mapToolFloorSectionStyle() {
      return {
        display: 'flex',
        flexDirection: 'column',
        gap: '4px',
        paddingTop: '6px',
        borderTop: '1px solid rgba(160, 180, 205, 0.22)'
      };
    },
    mapToolSectionLabelStyle() {
      return {
        color: '#6a7f95',
        fontSize: '10px',
        lineHeight: '14px',
        textAlign: 'right'
      };
    },
    mapToolFloorListStyle() {
      return {
        display: 'flex',
        flexDirection: 'column',
        gap: '4px',
        alignItems: 'stretch'
      };
    },
    mapToolFpsStyle() {
@@ -252,8 +343,30 @@
        whiteSpace: 'nowrap'
      };
    },
    mapToolFloorButtonStyle(active) {
      return {
        appearance: 'none',
        border: '1px solid ' + (active ? 'rgba(96, 132, 170, 0.36)' : 'rgba(160, 180, 205, 0.3)'),
        background: active ? 'rgba(235, 243, 251, 0.96)' : 'rgba(255, 255, 255, 0.88)',
        color: active ? '#27425c' : '#4d647d',
        minWidth: '44px',
        height: '26px',
        padding: '0 10px',
        borderRadius: '8px',
        fontSize: '11px',
        lineHeight: '26px',
        cursor: 'pointer',
        fontWeight: '700',
        boxShadow: active ? '0 4px 12px rgba(37, 64, 97, 0.08)' : 'none',
        whiteSpace: 'nowrap'
      };
    },
    toggleMapToolPanel() {
      this.showMapToolPanel = !this.showMapToolPanel;
    },
    selectFloorFromTool(lev) {
      if (lev == null || lev === this.currentLev) { return; }
      this.$emit('switch-lev', lev);
    },
    createMap() {
      this.pixiApp = new PIXI.Application({ backgroundColor: 0xF5F7F9, antialias: false, powerPreference: 'high-performance', autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2) });
@@ -270,9 +383,8 @@
      this.objectsContainer2 = new PIXI.Container();
      this.tracksContainer = new PIXI.ParticleContainer(10000, { scale: true, position: true, rotation: false, uvs: false, alpha: false });
      this.tracksGraphics = new PIXI.Graphics();
      this.shelvesContainer = new PIXI.ParticleContainer(10000, { scale: true, position: true, rotation: false, uvs: false, alpha: false });
      this.shelvesContainer = new PIXI.Container();
      this.tracksContainer.autoResize = true;
      this.shelvesContainer.autoResize = true;
      this.mapRoot = new PIXI.Container();
      this.pixiApp.stage.addChild(this.mapRoot);
      this.mapRoot.addChild(this.tracksGraphics);
@@ -320,6 +432,7 @@
          const dx = globalPos.x - mouseDownPoint[0];
          const dy = globalPos.y - mouseDownPoint[1];
          this.pixiApp.stage.position.set(stageOriginalPos[0] + dx, stageOriginalPos[1] + dy);
          this.scheduleShelfChunkCulling();
        }
      });
      this.pixiApp.renderer.plugins.interaction.on('pointerup', () => { touchBlank = false; });
@@ -345,7 +458,8 @@
        const newPosX = sx - worldX * newZoomX;
        const newPosY = sy - worldY * newZoomY;
        this.pixiApp.stage.setTransform(newPosX, newPosY, newZoomX, newZoomY, 0, 0, 0, 0, 0);
          this.scheduleAdjustLabels();
        this.scheduleAdjustLabels();
        this.scheduleShelfChunkCulling();
      });
      //*******************缩放画布*******************
@@ -388,6 +502,67 @@
      const rect = this.pixiApp.view ? this.pixiApp.view.getBoundingClientRect() : null;
      return { width: rect ? rect.width : 0, height: rect ? rect.height : 0 };
    },
    getViewportPadding() {
      return this.normalizeViewportPadding(this.viewportPadding);
    },
    normalizeViewportPadding(padding) {
      const source = padding || {};
      const normalize = (value) => {
        const num = Number(value);
        return isFinite(num) && num > 0 ? num : 0;
      };
      return {
        top: normalize(source.top),
        right: normalize(source.right),
        bottom: normalize(source.bottom),
        left: normalize(source.left)
      };
    },
    getViewportCenter(viewport, padding) {
      const normalized = this.normalizeViewportPadding(padding);
      const availableW = Math.max(1, viewport.width - normalized.left - normalized.right);
      const availableH = Math.max(1, viewport.height - normalized.top - normalized.bottom);
      return {
        x: normalized.left + availableW / 2,
        y: normalized.top + availableH / 2
      };
    },
    adjustStageForViewportPadding(oldPadding, newPadding) {
      if (!this.pixiApp || !this.pixiApp.stage) { return; }
      const viewport = this.getViewportSize();
      if (viewport.width <= 0 || viewport.height <= 0) { return; }
      const prevCenter = this.getViewportCenter(viewport, oldPadding);
      const nextCenter = this.getViewportCenter(viewport, newPadding);
      const deltaX = nextCenter.x - prevCenter.x;
      const deltaY = nextCenter.y - prevCenter.y;
      if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) {
        return;
      }
      const targetX = this.pixiApp.stage.position.x + deltaX;
      const targetY = this.pixiApp.stage.position.y + deltaY;
      if (window.gsap) {
        window.gsap.killTweensOf(this.pixiApp.stage.position);
        window.gsap.to(this.pixiApp.stage.position, {
          x: targetX,
          y: targetY,
          duration: 0.18,
          ease: 'power1.out',
          onUpdate: () => {
            this.scheduleAdjustLabels();
            this.scheduleShelfChunkCulling();
          },
          onComplete: () => {
            this.scheduleAdjustLabels();
            this.scheduleShelfChunkCulling();
          }
        });
        return;
      }
      this.pixiApp.stage.position.x = targetX;
      this.pixiApp.stage.position.y = targetY;
      this.scheduleAdjustLabels();
      this.scheduleShelfChunkCulling();
    },
    resizeToContainer() {
      const w = this.$el.clientWidth || 0;
      const h = this.$el.clientHeight || 0;
@@ -419,7 +594,7 @@
      this.objectsContainer2.removeChildren();
      if (this.tracksContainer) { this.tracksContainer.removeChildren(); }
      if (this.tracksGraphics) { this.tracksGraphics.clear(); }
      if (this.shelvesContainer) { this.shelvesContainer.removeChildren(); }
      this.clearShelfChunks();
      this.crnList = [];
      this.dualCrnList = [];
      this.rgvList = [];
@@ -448,7 +623,7 @@
      this.objectsContainer2.removeChildren();
      if (this.tracksContainer) { this.tracksContainer.removeChildren(); }
      if (this.tracksGraphics) { this.tracksGraphics.clear(); }
      if (this.shelvesContainer) { this.shelvesContainer.removeChildren(); }
      this.clearShelfChunks();
      this.crnList = [];
      this.dualCrnList = [];
      this.rgvList = [];
@@ -573,15 +748,12 @@
            this.collectTrackItem(val);
            continue;
          }
          if (val.type === 'shelf') { continue; }
          let sprite = this.getSprite(val, (e) => {
            //回调
          });
          if (sprite == null) { continue; }
          if (sprite._kind === 'shelf') {
            this.shelvesContainer.addChild(sprite);
          } else {
            this.objectsContainer.addChild(sprite);
          }
          this.objectsContainer.addChild(sprite);
          this.pixiStageList[index][idx] = sprite;
        }
      });
@@ -698,6 +870,7 @@
        }
      }
      this.mapContentSize = { width: contentW, height: contentH };
      this.buildShelfChunks(map, contentW, contentH);
      this.applyMapTransform(true);
      this.map = map;
      this.isSwitchingFloor = false;
@@ -743,7 +916,6 @@
      if (!sites) { return; }
      sites.forEach((item) => {
        let id = item.siteId != null ? item.siteId : item.stationId;
        let status = item.siteStatus != null ? item.siteStatus : item.stationStatus;
        let workNo = item.workNo != null ? item.workNo : item.taskNo;
        if (id == null) { return; }
        let sta = this.pixiStaMap.get(parseInt(id));
@@ -754,7 +926,7 @@
          sta.statusObj = null;
          if (sta.textObj.parent !== sta) { sta.addChild(sta.textObj); sta.textObj.position.set(sta.width / 2, sta.height / 2); }
        }
        this.setStationBaseColor(sta, this.getStationStatusColor(status));
        this.setStationBaseColor(sta, this.getStationStatusColor(this.resolveStationStatus(item)));
      });
    },
    getCrnInfo() {
@@ -1285,15 +1457,47 @@
      return brightness > 150 ? '#000000' : '#ffffff';
    },
    getStationStatusColor(status) {
      if (status === "site-auto") { return 0x78ff81; }
      if (status === "site-auto-run") { return 0xfa51f6; }
      if (status === "site-auto-id") { return 0xc4c400; }
      if (status === "site-auto-run-id") { return 0x30bffc; }
      if (status === "site-unauto") { return 0xb8b8b8; }
      if (status === "machine-pakin") { return 0x30bffc; }
      if (status === "machine-pakout") { return 0x97b400; }
      if (status === "site-run-block") { return 0xe69138; }
      return 0xb8b8b8;
      const colorMap = this.stationStatusColors || this.getDefaultStationStatusColors();
      if (status && colorMap[status] != null) { return colorMap[status]; }
      return colorMap['site-unauto'] != null ? colorMap['site-unauto'] : 0xb8b8b8;
    },
    resolveStationStatus(item) {
      const status = item && (item.siteStatus != null ? item.siteStatus : item.stationStatus);
      const taskNo = this.parseStationTaskNo(item && (item.workNo != null ? item.workNo : item.taskNo));
      const autoing = !!(item && item.autoing);
      const loading = !!(item && item.loading);
      const runBlock = !!(item && item.runBlock);
      const enableIn = !!(item && item.enableIn);
      if (taskNo === 9998 || enableIn) { return 'site-enable-in'; }
      if (autoing && loading && taskNo > 0 && !runBlock) {
        const taskClass = this.getStationTaskClass(taskNo);
        if (taskClass) { return taskClass; }
      }
      if (status) { return status; }
      if (autoing && loading && taskNo > 0 && runBlock) { return 'site-run-block'; }
      if (autoing && loading && taskNo > 0) { return 'site-auto-run-id'; }
      if (autoing && loading) { return 'site-auto-run'; }
      if (autoing && taskNo > 0) { return 'site-auto-id'; }
      if (autoing) { return 'site-auto'; }
      return 'site-unauto';
    },
    parseStationTaskNo(value) {
      const taskNo = parseInt(value, 10);
      return isNaN(taskNo) ? 0 : taskNo;
    },
    getStationTaskClass(taskNo) {
      if (!(taskNo > 0)) { return null; }
      const range = this.stationTaskRange || {};
      if (this.isTaskNoInRange(taskNo, range.inbound)) { return 'machine-pakin'; }
      if (this.isTaskNoInRange(taskNo, range.outbound)) { return 'machine-pakout'; }
      return null;
    },
    isTaskNoInRange(taskNo, range) {
      if (!range) { return false; }
      const start = parseInt(range.start, 10);
      const end = parseInt(range.end, 10);
      if (isNaN(start) || isNaN(end)) { return false; }
      return taskNo >= start && taskNo <= end;
    },
    getCrnStatusColor(status) {
      if (status === "machine-auto") { return 0x21BA45; }
@@ -1837,6 +2041,135 @@
        }
      }
    },
    clearShelfChunks() {
      if (this.shelfCullRaf) {
        cancelAnimationFrame(this.shelfCullRaf);
        this.shelfCullRaf = null;
      }
      this.shelfChunkList = [];
      if (!this.shelvesContainer) { return; }
      const children = this.shelvesContainer.removeChildren();
      children.forEach((child) => {
        if (child && typeof child.destroy === 'function') {
          child.destroy({ children: true, texture: true, baseTexture: true });
        }
      });
    },
    buildShelfChunks(map, contentW, contentH) {
      this.clearShelfChunks();
      if (!this.pixiApp || !this.pixiApp.renderer || !this.shelvesContainer || !Array.isArray(map)) { return; }
      const chunkSize = Math.max(256, parseInt(this.shelfChunkSize, 10) || 2048);
      const chunkMap = new Map();
      for (let r = 0; r < map.length; r++) {
        const row = map[r];
        if (!row) { continue; }
        for (let c = 0; c < row.length; c++) {
          const cell = row[c];
          if (!cell || cell.type !== 'shelf' || cell.type === 'merge') { continue; }
          const startChunkX = Math.floor(cell.posX / chunkSize);
          const endChunkX = Math.floor((cell.posX + Math.max(1, cell.width) - 0.01) / chunkSize);
          const startChunkY = Math.floor(cell.posY / chunkSize);
          const endChunkY = Math.floor((cell.posY + Math.max(1, cell.height) - 0.01) / chunkSize);
          for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
            for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
              const key = chunkX + ',' + chunkY;
              let list = chunkMap.get(key);
              if (!list) {
                list = [];
                chunkMap.set(key, list);
              }
              list.push(cell);
            }
          }
        }
      }
      const chunkList = [];
      chunkMap.forEach((cells, key) => {
        const keyParts = key.split(',');
        const chunkX = parseInt(keyParts[0], 10) || 0;
        const chunkY = parseInt(keyParts[1], 10) || 0;
        const chunkLeft = chunkX * chunkSize;
        const chunkTop = chunkY * chunkSize;
        const chunkWidth = Math.max(1, Math.min(chunkSize, contentW - chunkLeft));
        const chunkHeight = Math.max(1, Math.min(chunkSize, contentH - chunkTop));
        const graphics = new PIXI.Graphics();
        graphics.beginFill(0xb6e2e2);
        graphics.lineStyle(1, 0xffffff, 1);
        for (let i = 0; i < cells.length; i++) {
          const cell = cells[i];
          graphics.drawRect(cell.posX - chunkLeft, cell.posY - chunkTop, cell.width, cell.height);
        }
        graphics.endFill();
        const texture = this.pixiApp.renderer.generateTexture(
          graphics,
          PIXI.SCALE_MODES.LINEAR,
          1,
          new PIXI.Rectangle(0, 0, chunkWidth, chunkHeight)
        );
        graphics.destroy(true);
        const sprite = new PIXI.Sprite(texture);
        sprite.position.set(chunkLeft, chunkTop);
        sprite._chunkBounds = {
          x: chunkLeft,
          y: chunkTop,
          width: chunkWidth,
          height: chunkHeight
        };
        this.shelvesContainer.addChild(sprite);
        chunkList.push(sprite);
      });
      this.shelfChunkList = chunkList;
      this.updateVisibleShelfChunks();
    },
    getViewportLocalBounds(padding) {
      if (!this.mapRoot || !this.pixiApp) { return null; }
      const viewport = this.getViewportSize();
      const pad = Math.max(0, Number(padding) || 0);
      const points = [
        new PIXI.Point(-pad, -pad),
        new PIXI.Point(viewport.width + pad, -pad),
        new PIXI.Point(-pad, viewport.height + pad),
        new PIXI.Point(viewport.width + pad, viewport.height + pad)
      ];
      let minX = Infinity;
      let minY = Infinity;
      let maxX = -Infinity;
      let maxY = -Infinity;
      points.forEach((point) => {
        const local = this.mapRoot.toLocal(point);
        if (local.x < minX) { minX = local.x; }
        if (local.y < minY) { minY = local.y; }
        if (local.x > maxX) { maxX = local.x; }
        if (local.y > maxY) { maxY = local.y; }
      });
      if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) { return null; }
      return { minX: minX, minY: minY, maxX: maxX, maxY: maxY };
    },
    updateVisibleShelfChunks() {
      if (!this.shelfChunkList || this.shelfChunkList.length === 0) { return; }
      const localBounds = this.getViewportLocalBounds(this.shelfCullPadding);
      if (!localBounds) { return; }
      for (let i = 0; i < this.shelfChunkList.length; i++) {
        const sprite = this.shelfChunkList[i];
        const bounds = sprite && sprite._chunkBounds;
        if (!bounds) { continue; }
        const visible = bounds.x < localBounds.maxX &&
          bounds.x + bounds.width > localBounds.minX &&
          bounds.y < localBounds.maxY &&
          bounds.y + bounds.height > localBounds.minY;
        if (sprite.visible !== visible) {
          sprite.visible = visible;
        }
      }
    },
    scheduleShelfChunkCulling() {
      if (this.shelfCullRaf) { return; }
      this.shelfCullRaf = requestAnimationFrame(() => {
        this.shelfCullRaf = null;
        this.updateVisibleShelfChunks();
      });
    },
    findIndexByOffsets(offsets, sizes, value) {
      if (!offsets || !sizes || offsets.length === 0) { return -1; }
      for (let i = 0; i < offsets.length; i++) {
@@ -2084,6 +2417,23 @@
      this.applyMapTransform(true);
      this.saveMapTransformConfig();
    },
    openStationColorConfigPage() {
      if (typeof window === 'undefined') { return; }
      const url = (typeof baseUrl !== 'undefined' ? baseUrl : '') + '/views/watch/stationColorConfig.html';
      const layerInstance = (window.top && window.top.layer) || window.layer;
      if (layerInstance && typeof layerInstance.open === 'function') {
        layerInstance.open({
          type: 2,
          title: '站点颜色配置',
          maxmin: true,
          area: ['980px', '760px'],
          shadeClose: false,
          content: url
        });
        return;
      }
      window.open(url, '_blank');
    },
    parseRotation(value) {
      const num = parseInt(value, 10);
      if (!isFinite(num)) { return 0; }
@@ -2095,6 +2445,98 @@
      if (value == null) { return false; }
      const str = String(value).toLowerCase();
      return str === '1' || str === 'true' || str === 'y';
    },
    getDefaultStationStatusColors() {
      return {
        'site-auto': 0x78ff81,
        'site-auto-run': 0xfa51f6,
        'site-auto-id': 0xc4c400,
        'site-auto-run-id': 0x30bffc,
        'site-enable-in': 0x18c7b8,
        'site-unauto': 0xb8b8b8,
        'machine-pakin': 0x30bffc,
        'machine-pakout': 0x97b400,
        'site-run-block': 0xe69138
      };
    },
    parseColorConfigValue(value, fallback) {
      if (typeof value === 'number' && isFinite(value)) {
        return value;
      }
      const str = String(value == null ? '' : value).trim();
      if (!str) { return fallback; }
      if (/^#[0-9a-fA-F]{6}$/.test(str)) { return parseInt(str.slice(1), 16); }
      if (/^#[0-9a-fA-F]{3}$/.test(str)) {
        const expanded = str.charAt(1) + str.charAt(1) + str.charAt(2) + str.charAt(2) + str.charAt(3) + str.charAt(3);
        return parseInt(expanded, 16);
      }
      if (/^0x[0-9a-fA-F]{6}$/i.test(str)) { return parseInt(str.slice(2), 16); }
      if (/^[0-9]+$/.test(str)) {
        const num = parseInt(str, 10);
        return isNaN(num) ? fallback : num;
      }
      return fallback;
    },
    loadStationColorConfig() {
      if (!window.$ || typeof baseUrl === 'undefined') { return; }
      $.ajax({
        url: baseUrl + "/watch/stationColor/config/auth",
        headers: { 'token': localStorage.getItem('token') },
        dataType: 'json',
        method: 'GET',
        success: (res) => {
          if (!res || res.code !== 200 || !res.data) {
            if (res && res.code === 403) { parent.location.href = baseUrl + "/login"; }
            return;
          }
          this.applyStationColorConfigPayload(res.data);
        }
      });
    },
    applyStationColorConfigPayload(data) {
      const defaults = this.getDefaultStationStatusColors();
      const nextColors = Object.assign({}, defaults);
      const items = Array.isArray(data.items) ? data.items : [];
      items.forEach((item) => {
        if (!item || !item.status || defaults[item.status] == null) { return; }
        nextColors[item.status] = this.parseColorConfigValue(item.color, defaults[item.status]);
      });
      this.stationStatusColors = nextColors;
    },
    buildMissingMapConfigList(byCode) {
      const createList = [];
      if (!byCode[this.mapConfigCodes.rotate]) {
        createList.push({
          name: '地图旋转',
          code: this.mapConfigCodes.rotate,
          value: String(this.mapRotation || 0),
          type: 1,
          status: 1,
          selectType: 'map'
        });
      }
      if (!byCode[this.mapConfigCodes.mirror]) {
        createList.push({
          name: '地图镜像',
          code: this.mapConfigCodes.mirror,
          value: this.mapMirrorX ? '1' : '0',
          type: 1,
          status: 1,
          selectType: 'map'
        });
      }
      return createList;
    },
    createMapConfigs(createList) {
      if (!window.$ || typeof baseUrl === 'undefined' || !Array.isArray(createList) || createList.length === 0) { return; }
      createList.forEach((cfg) => {
        $.ajax({
          url: baseUrl + "/config/add/auth",
          headers: { 'token': localStorage.getItem('token') },
          method: 'POST',
          data: cfg
        });
      });
    },
    loadMapTransformConfig() {
      if (!window.$ || typeof baseUrl === 'undefined') { return; }
@@ -2120,45 +2562,11 @@
          if (mirrorCfg && mirrorCfg.value != null) {
            this.mapMirrorX = this.parseMirror(mirrorCfg.value);
          }
          if (rotateCfg == null || mirrorCfg == null) {
            this.createMapTransformConfigIfMissing(rotateCfg, mirrorCfg);
          }
          this.createMapConfigs(this.buildMissingMapConfigList(byCode));
          if (this.mapContentSize && this.mapContentSize.width > 0) {
            this.applyMapTransform(true);
          }
        }
      });
    },
    createMapTransformConfigIfMissing(rotateCfg, mirrorCfg) {
      if (!window.$ || typeof baseUrl === 'undefined') { return; }
      const createList = [];
      if (!rotateCfg) {
        createList.push({
          name: '地图旋转',
          code: this.mapConfigCodes.rotate,
          value: String(this.mapRotation || 0),
          type: 1,
          status: 1,
          selectType: 'map'
        });
      }
      if (!mirrorCfg) {
        createList.push({
          name: '地图镜像',
          code: this.mapConfigCodes.mirror,
          value: this.mapMirrorX ? '1' : '0',
          type: 1,
          status: 1,
          selectType: 'map'
        });
      }
      createList.forEach((cfg) => {
        $.ajax({
          url: baseUrl + "/config/add/auth",
          headers: { 'token': localStorage.getItem('token') },
          method: 'POST',
          data: cfg
        });
      });
    },
    saveMapTransformConfig() {
@@ -2193,15 +2601,20 @@
      const viewport = this.getViewportSize();
      const vw = viewport.width;
      const vh = viewport.height;
      let scale = Math.min(vw / contentW, vh / contentH) * 0.95;
      const padding = this.getViewportPadding();
      const availableW = Math.max(1, vw - padding.left - padding.right);
      const availableH = Math.max(1, vh - padding.top - padding.bottom);
      let scale = Math.min(availableW / contentW, availableH / contentH) * 0.95;
      if (!isFinite(scale) || scale <= 0) { scale = 1; }
      const baseW = this.mapContentSize.width || contentW;
      const baseH = this.mapContentSize.height || contentH;
      const mirrorX = this.mapMirrorX ? -1 : 1;
      const scaleX = scale * mirrorX;
      const scaleY = scale;
      const posX = (vw / 2) - (baseW / 2) * scaleX;
      const posY = (vh / 2) - (baseH / 2) * scaleY;
      const centerX = padding.left + availableW / 2;
      const centerY = padding.top + availableH / 2;
      const posX = centerX - (baseW / 2) * scaleX;
      const posY = centerY - (baseH / 2) * scaleY;
      this.pixiApp.stage.setTransform(posX, posY, scaleX, scaleY, 0, 0, 0, 0, 0);
    },
    applyMapTransform(fitToView) {
@@ -2215,6 +2628,7 @@
      this.mapRoot.scale.set(1, 1);
      if (fitToView) { this.fitStageToContent(); }
      this.scheduleAdjustLabels();
      this.scheduleShelfChunkCulling();
    },
    scheduleAdjustLabels() {
      if (this.adjustLabelTimer) { clearTimeout(this.adjustLabelTimer); }
@@ -2226,22 +2640,6 @@
    }
  }
});
src/main/webapp/components/MonitorCardKit.js
New file
@@ -0,0 +1,147 @@
(function (global) {
  if (global.MonitorCardKit) {
    return;
  }
  function ensureStyles() {
    if (document.getElementById("monitor-card-kit-style")) {
      return;
    }
    var style = document.createElement("style");
    style.id = "monitor-card-kit-style";
    style.textContent = [
      "watch-crn-card,watch-dual-crn-card,devp-card,watch-rgv-card{display:block;width:100%;min-width:0;min-height:0;flex:1 1 auto;}",
      ".mc-root{display:flex;flex-direction:column;width:100%;height:100%;min-width:0;min-height:0;color:#375067;font-size:12px;}",
      ".mc-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:10px;}",
      ".mc-title{flex:1;font-size:14px;font-weight:700;color:#22384f;}",
      ".mc-search{display:flex;gap:8px;flex:1;}",
      ".mc-input,.mc-select{width:100%;height:34px;padding:0 11px;border-radius:10px;border:1px solid rgba(219,228,236,.96);background:rgba(255,255,255,.84);box-sizing:border-box;color:#334155;outline:none;}",
      ".mc-input:focus,.mc-select:focus{border-color:rgba(112,148,190,.62);box-shadow:0 0 0 3px rgba(112,148,190,.1);}",
      ".mc-btn{height:34px;padding:0 12px;border-radius:10px;border:none;background:#6f95bd;color:#fff;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;}",
      ".mc-btn.mc-btn-ghost{background:rgba(255,255,255,.88);color:#46607a;border:1px solid rgba(219,228,236,.96);}",
      ".mc-btn.mc-btn-soft{background:rgba(233,239,245,.92);color:#46607a;border:1px solid rgba(210,220,231,.98);}",
      ".mc-control-toggle{margin-bottom:8px;}",
      ".mc-control{margin-bottom:10px;padding:12px;border:1px solid rgba(223,232,240,.94);border-radius:14px;background:rgba(248,251,253,.88);}",
      ".mc-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;}",
      ".mc-field{display:flex;flex-direction:column;gap:5px;}",
      ".mc-field.mc-span-2{grid-column:1 / -1;}",
      ".mc-field-label{font-size:11px;font-weight:700;color:#72859a;}",
      ".mc-action-row{display:flex;flex-wrap:wrap;gap:8px;grid-column:1 / -1;padding-top:8px;margin-top:2px;border-top:1px dashed rgba(216,226,235,.92);}",
      ".mc-collapse{flex:1;min-height:0;overflow:auto;padding-right:2px;}",
      ".mc-item{border:1px solid rgba(223,232,240,.92);border-radius:14px;background:rgba(255,255,255,.72);margin-bottom:10px;overflow:hidden;}",
      ".mc-item.is-open{border-color:rgba(132,166,201,.48);box-shadow:0 10px 18px rgba(148,163,184,.08);}",
      ".mc-head{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border:none;background:transparent;cursor:pointer;text-align:left;color:inherit;}",
      ".mc-head-main{min-width:0;flex:1;}",
      ".mc-head-title{font-size:13px;font-weight:700;color:#27425c;line-height:1.35;}",
      ".mc-head-subtitle{margin-top:3px;font-size:11px;color:#7c8d9f;line-height:1.35;}",
      ".mc-head-right{display:flex;align-items:center;gap:8px;flex-shrink:0;}",
      ".mc-badge{padding:4px 8px;border-radius:999px;font-size:10px;font-weight:700;}",
      ".mc-badge.is-success{background:rgba(82,177,126,.12);color:#2d7650;}",
      ".mc-badge.is-working{background:rgba(111,149,189,.12);color:#3f6286;}",
      ".mc-badge.is-warning{background:rgba(214,162,94,.14);color:#9b6a24;}",
      ".mc-badge.is-danger{background:rgba(207,126,120,.14);color:#a14e4a;}",
      ".mc-badge.is-muted{background:rgba(148,163,184,.14);color:#748397;}",
      ".mc-chevron{font-size:14px;color:#6f8194;line-height:1;}",
      ".mc-body{padding:0 14px 14px;border-top:1px solid rgba(232,238,244,.92);}",
      ".mc-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px;}",
      ".mc-detail-cell{padding:10px 12px;border-radius:12px;background:rgba(247,250,252,.9);border:1px solid rgba(232,238,244,.96);}",
      ".mc-detail-label{font-size:11px;color:#7c8d9f;}",
      ".mc-detail-value{margin-top:4px;font-size:12px;color:#31485f;line-height:1.45;word-break:break-all;}",
      ".mc-inline-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;}",
      ".mc-link{padding:0;border:none;background:transparent;color:#4677a4;font-size:12px;font-weight:600;cursor:pointer;}",
      ".mc-empty{padding:24px 10px;text-align:center;color:#8b9aad;}",
      ".mc-footer{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:10px;color:#708396;font-size:11px;}",
      ".mc-page-btn{height:30px;padding:0 10px;border-radius:8px;border:1px solid rgba(219,228,236,.96);background:rgba(255,255,255,.86);color:#46607a;cursor:pointer;}",
      ".mc-page-btn[disabled]{opacity:.45;cursor:not-allowed;}",
      ".mc-code{font-family:monospace;}",
      "@media (max-width: 1100px){.mc-control-grid{grid-template-columns:1fr;}.mc-field.mc-span-2{grid-column:auto;}.mc-toolbar{flex-direction:column;align-items:stretch;}.mc-search{width:100%;}.mc-detail-grid{grid-template-columns:1fr;}}"
    ].join("");
    document.head.appendChild(style);
  }
  function showMessage(vm, message, type) {
    if (!message) {
      return;
    }
    if (vm && typeof vm.$message === "function") {
      vm.$message({
        message: message,
        type: type === "danger" ? "error" : (type || "info")
      });
      return;
    }
    if (vm && vm.$root && typeof vm.$root.showPageMessage === "function") {
      vm.$root.showPageMessage(message, type === "danger" ? "error" : type);
      return;
    }
    if (global.ELEMENT && typeof global.ELEMENT.Message === "function") {
      global.ELEMENT.Message({
        message: message,
        type: type === "danger" ? "error" : (type || "info")
      });
      return;
    }
    if (global.layer && typeof global.layer.msg === "function") {
      var iconMap = { success: 1, danger: 2, warning: 0 };
      global.layer.msg(message, {
        icon: iconMap[type] != null ? iconMap[type] : 0,
        time: 1800
      });
      return;
    }
    console[type === "danger" ? "error" : "log"](message);
  }
  function orDash(value) {
    return value == null || value === "" ? "-" : String(value);
  }
  function yesNo(value) {
    if (value === true || value === "Y" || value === "y" || value === 1 || value === "1") {
      return "Y";
    }
    if (value === false || value === "N" || value === "n" || value === 0 || value === "0") {
      return "N";
    }
    return value ? "Y" : "N";
  }
  function deviceStatusLabel(status, fallbackManual) {
    var normalized = String(status || "").toUpperCase();
    if (normalized === "AUTO") {
      return "自动";
    }
    if (normalized === "WORKING") {
      return "作业中";
    }
    if (normalized === "ERROR") {
      return "故障";
    }
    return fallbackManual || "离线";
  }
  function statusTone(label) {
    if (label === "自动") {
      return "success";
    }
    if (label === "作业中") {
      return "working";
    }
    if (label === "手动") {
      return "warning";
    }
    if (label === "故障" || label === "报警") {
      return "danger";
    }
    return "muted";
  }
  global.MonitorCardKit = {
    ensureStyles: ensureStyles,
    showMessage: showMessage,
    orDash: orDash,
    yesNo: yesNo,
    deviceStatusLabel: deviceStatusLabel,
    statusTone: statusTone
  };
})(window);
src/main/webapp/components/MonitorWorkbench.js
New file
@@ -0,0 +1,661 @@
Vue.component("monitor-workbench", {
  template: `
    <div class="wb-root">
      <div class="wb-tabs" role="tablist">
        <button
          v-for="tab in tabs"
          :key="tab.key"
          type="button"
          :class="['wb-tab', { 'is-active': activeCard === tab.key }]"
          @click="changeTab(tab.key)"
        >{{ tab.label }}</button>
      </div>
      <div class="wb-toolbar">
        <input
          class="wb-input"
          :value="currentSearch"
          :placeholder="currentSearchPlaceholder()"
          @input="updateSearch($event.target.value)"
        />
        <div class="wb-toolbar-actions">
          <button type="button" class="wb-btn wb-btn-ghost" @click="refreshCurrent">刷新</button>
          <button type="button" class="wb-btn" @click="toggleControl">
            {{ currentShowControl ? '收起操作' : '展开操作' }}
          </button>
        </div>
      </div>
      <div class="wb-main">
        <div class="wb-side">
          <div class="wb-list-card">
            <div class="wb-side-title">设备选择</div>
            <div class="wb-list">
              <button
                v-for="item in filteredList"
                :key="activeCard + '-' + getItemId(activeCard, item)"
                type="button"
                :class="['wb-list-item', { 'is-active': isSelected(activeCard, item) }]"
                @click="selectItem(activeCard, item)"
              >
                <span :class="['wb-badge', 'is-' + getStatusTone(activeCard, item)]">{{ getStatusLabel(activeCard, item) }}</span>
                <div class="wb-list-main">
                  <div class="wb-list-title">{{ getItemTitle(activeCard, item) }}</div>
                  <div class="wb-list-meta">{{ getItemMeta(activeCard, item) }}</div>
                </div>
              </button>
              <div v-if="filteredList.length === 0" class="wb-empty">当前没有可展示的数据</div>
            </div>
          </div>
          <div v-if="currentShowControl" class="wb-control-card">
            <div class="wb-side-title">快捷操作</div>
            <div class="wb-control-target">{{ controlTargetText }}</div>
            <div class="wb-control-subtitle">{{ controlPanelHint }}</div>
            <div v-if="activeCard === 'crn'" class="wb-form-grid">
              <label class="wb-field">
                <span class="wb-field-label">堆垛机号</span>
                <input class="wb-input" v-model="controlForms.crn.crnNo" placeholder="1" />
              </label>
              <label class="wb-field">
                <span class="wb-field-label">源库位</span>
                <input class="wb-input" v-model="controlForms.crn.sourceLocNo" placeholder="源点" />
              </label>
              <label class="wb-field wb-field-span-2">
                <span class="wb-field-label">目标库位</span>
                <input class="wb-input" v-model="controlForms.crn.targetLocNo" placeholder="目标点" />
              </label>
              <div class="wb-action-row">
                <button type="button" class="wb-btn wb-btn-primary" @click="submitControl('transport')">取放货</button>
                <button type="button" class="wb-btn wb-btn-ghost" @click="submitControl('move')">移动</button>
                <button type="button" class="wb-btn wb-btn-soft" @click="submitControl('taskComplete')">完成</button>
              </div>
            </div>
            <div v-else-if="activeCard === 'dualCrn'" class="wb-form-grid">
              <label class="wb-field">
                <span class="wb-field-label">堆垛机号</span>
                <input class="wb-input" v-model="controlForms.dualCrn.crnNo" placeholder="2" />
              </label>
              <label class="wb-field">
                <span class="wb-field-label">工位</span>
                <select class="wb-select" v-model="controlForms.dualCrn.station">
                  <option :value="1">工位1</option>
                  <option :value="2">工位2</option>
                </select>
              </label>
              <label class="wb-field">
                <span class="wb-field-label">源库位</span>
                <input class="wb-input" v-model="controlForms.dualCrn.sourceLocNo" placeholder="源点" />
              </label>
              <label class="wb-field">
                <span class="wb-field-label">目标库位</span>
                <input class="wb-input" v-model="controlForms.dualCrn.targetLocNo" placeholder="目标点" />
              </label>
              <div class="wb-action-row">
                <button type="button" class="wb-btn wb-btn-primary" @click="submitControl('transport')">取放货</button>
                <button type="button" class="wb-btn wb-btn-ghost" @click="submitControl('pickup')">取货</button>
                <button type="button" class="wb-btn wb-btn-ghost" @click="submitControl('putdown')">放货</button>
                <button type="button" class="wb-btn wb-btn-ghost" @click="submitControl('move')">移动</button>
                <button type="button" class="wb-btn wb-btn-soft" @click="submitControl('taskComplete')">完成</button>
              </div>
            </div>
            <div v-else-if="activeCard === 'devp'" class="wb-form-grid">
              <label class="wb-field">
                <span class="wb-field-label">站号</span>
                <input class="wb-input" v-model="controlForms.devp.stationId" placeholder="101" />
              </label>
              <label class="wb-field">
                <span class="wb-field-label">工作号</span>
                <input class="wb-input" v-model="controlForms.devp.taskNo" placeholder="工作号" />
              </label>
              <label class="wb-field wb-field-span-2">
                <span class="wb-field-label">目标站</span>
                <input class="wb-input" v-model="controlForms.devp.targetStationId" placeholder="目标站号" />
              </label>
              <div class="wb-action-row">
                <button type="button" class="wb-btn wb-btn-primary" @click="submitControl('move')">下发</button>
                <button type="button" class="wb-btn wb-btn-soft" @click="submitControl('reset')">复位</button>
                <button
                  v-if="selectedItem"
                  type="button"
                  class="wb-btn wb-btn-ghost"
                  @click="editBarcode"
                >条码</button>
              </div>
            </div>
            <div v-else-if="activeCard === 'rgv'" class="wb-form-grid">
              <label class="wb-field">
                <span class="wb-field-label">RGV号</span>
                <input class="wb-input" v-model="controlForms.rgv.rgvNo" placeholder="1" />
              </label>
              <label class="wb-field">
                <span class="wb-field-label">源点</span>
                <input class="wb-input" v-model="controlForms.rgv.sourcePos" placeholder="源点" />
              </label>
              <label class="wb-field wb-field-span-2">
                <span class="wb-field-label">目标点</span>
                <input class="wb-input" v-model="controlForms.rgv.targetPos" placeholder="目标点" />
              </label>
              <div class="wb-action-row">
                <button type="button" class="wb-btn wb-btn-primary" @click="submitControl('transport')">取放货</button>
                <button type="button" class="wb-btn wb-btn-ghost" @click="submitControl('move')">移动</button>
                <button type="button" class="wb-btn wb-btn-soft" @click="submitControl('taskComplete')">完成</button>
              </div>
            </div>
          </div>
        </div>
        <div class="wb-detail-panel">
          <div class="wb-detail" v-if="selectedItem">
            <div class="wb-detail-header">
              <div>
                <div class="wb-section-title">{{ getItemTitle(activeCard, selectedItem) }}</div>
                <div class="wb-detail-subtitle">{{ getItemMeta(activeCard, selectedItem) }}</div>
              </div>
              <div class="wb-detail-actions">
                <button
                  v-if="activeCard === 'dualCrn'"
                  type="button"
                  class="wb-link"
                  @click="editDualTask(1)"
                >工位1任务号</button>
                <button
                  v-if="activeCard === 'dualCrn'"
                  type="button"
                  class="wb-link"
                  @click="editDualTask(2)"
                >工位2任务号</button>
              </div>
            </div>
            <div class="wb-detail-grid">
              <div v-for="entry in detailEntries" :key="entry.label" class="wb-detail-cell">
                <div class="wb-detail-label">{{ entry.label }}</div>
                <div class="wb-detail-value">{{ entry.value }}</div>
              </div>
            </div>
          </div>
          <div class="wb-detail wb-detail-empty" v-else>
            <div class="wb-empty">请先从左侧选择一个设备</div>
          </div>
        </div>
      </div>
      <div v-if="noticeMessage" :class="['wb-notice', 'is-' + noticeType]">{{ noticeMessage }}</div>
    </div>
  `,
  props: {
    activeCard: { type: String, default: "crn" },
    crnParam: { type: Object, default: function () { return {}; } },
    dualCrnParam: { type: Object, default: function () { return {}; } },
    devpParam: { type: Object, default: function () { return {}; } },
    rgvParam: { type: Object, default: function () { return {}; } },
    crnList: { type: Array, default: function () { return []; } },
    dualCrnList: { type: Array, default: function () { return []; } },
    stationList: { type: Array, default: function () { return []; } },
    rgvList: { type: Array, default: function () { return []; } }
  },
  data() {
    return {
      tabs: [
        { key: "crn", label: "堆垛机" },
        { key: "dualCrn", label: "双工位" },
        { key: "devp", label: "输送站" },
        { key: "rgv", label: "RGV" }
      ],
      searchMap: {
        crn: "",
        dualCrn: "",
        devp: "",
        rgv: ""
      },
      selectedIdMap: {
        crn: null,
        dualCrn: null,
        devp: null,
        rgv: null
      },
      showControlMap: {
        crn: false,
        dualCrn: false,
        devp: false,
        rgv: false
      },
      controlForms: {
        crn: { crnNo: "", sourceLocNo: "", targetLocNo: "" },
        dualCrn: { crnNo: "", sourceLocNo: "", targetLocNo: "", station: 1 },
        devp: { stationId: "", taskNo: "", targetStationId: "" },
        rgv: { rgvNo: "", sourcePos: "", targetPos: "" }
      },
      noticeMessage: "",
      noticeType: "info",
      noticeTimer: null
    };
  },
  computed: {
    currentSearch() {
      return this.searchMap[this.activeCard] || "";
    },
    currentShowControl() {
      return !!this.showControlMap[this.activeCard];
    },
    currentList() {
      return this.getListByType(this.activeCard);
    },
    filteredList() {
      const keyword = String(this.currentSearch || "").trim().toLowerCase();
      if (!keyword) { return this.currentList; }
      return this.currentList.filter((item) => this.matchesKeyword(this.activeCard, item, keyword));
    },
    selectedItem() {
      return this.getSelectedItem(this.activeCard);
    },
    detailEntries() {
      return this.buildDetailEntries(this.activeCard, this.selectedItem);
    },
    controlPanelTitle() {
      if (this.activeCard === "crn") { return "堆垛机控制"; }
      if (this.activeCard === "dualCrn") { return "双工位控制"; }
      if (this.activeCard === "devp") { return "输送站控制"; }
      if (this.activeCard === "rgv") { return "RGV控制"; }
      return "控制操作";
    },
    controlPanelHint() {
      if (this.activeCard === "crn") { return "先确认设备号,再填写源库位和目标库位。"; }
      if (this.activeCard === "dualCrn") { return "先选择工位,再下发取货、放货或移动指令。"; }
      if (this.activeCard === "devp") { return "用于站点下发、复位和条码维护。"; }
      if (this.activeCard === "rgv") { return "用于轨道车取放货、移动和任务完成。"; }
      return "";
    },
    controlTargetText() {
      if (!this.selectedItem) { return "未选中设备"; }
      return "当前目标: " + this.getItemTitle(this.activeCard, this.selectedItem);
    }
  },
  watch: {
    activeCard: {
      immediate: true,
      handler(type) {
        this.ensureSelection(type);
      }
    },
    crnList() { this.ensureSelection("crn"); },
    dualCrnList() { this.ensureSelection("dualCrn"); },
    stationList() { this.ensureSelection("devp"); },
    rgvList() { this.ensureSelection("rgv"); },
    crnParam: {
      deep: true,
      handler(v) { this.applyExternalFocus("crn", v && v.crnNo); }
    },
    dualCrnParam: {
      deep: true,
      handler(v) { this.applyExternalFocus("dualCrn", v && v.crnNo); }
    },
    devpParam: {
      deep: true,
      handler(v) { this.applyExternalFocus("devp", v && v.stationId); }
    },
    rgvParam: {
      deep: true,
      handler(v) { this.applyExternalFocus("rgv", v && v.rgvNo); }
    }
  },
  beforeDestroy() {
    if (this.noticeTimer) {
      clearTimeout(this.noticeTimer);
      this.noticeTimer = null;
    }
  },
  methods: {
    changeTab(type) {
      if (type === this.activeCard) { return; }
      this.$emit("change-tab", type);
    },
    updateSearch(value) {
      this.$set(this.searchMap, this.activeCard, value);
      this.ensureSelection(this.activeCard);
    },
    refreshCurrent() {
      this.$emit("refresh-request", this.activeCard);
    },
    toggleControl() {
      this.$set(this.showControlMap, this.activeCard, !this.currentShowControl);
      if (this.currentShowControl && this.selectedItem) {
        this.hydrateControlForm(this.activeCard, this.selectedItem);
      }
    },
    getListByType(type) {
      if (type === "crn") { return this.crnList || []; }
      if (type === "dualCrn") { return this.dualCrnList || []; }
      if (type === "devp") { return this.stationList || []; }
      if (type === "rgv") { return this.rgvList || []; }
      return [];
    },
    getItemId(type, item) {
      if (!item) { return null; }
      if (type === "crn" || type === "dualCrn") { return item.crnNo; }
      if (type === "devp") { return item.stationId; }
      if (type === "rgv") { return item.rgvNo; }
      return null;
    },
    getSelectedItem(type) {
      const list = this.filteredListForType(type);
      if (!list.length) { return null; }
      const selectedId = this.selectedIdMap[type];
      for (let i = 0; i < list.length; i++) {
        if (String(this.getItemId(type, list[i])) === String(selectedId)) {
          return list[i];
        }
      }
      return list[0];
    },
    filteredListForType(type) {
      const keyword = String(this.searchMap[type] || "").trim().toLowerCase();
      const list = this.getListByType(type);
      if (!keyword) { return list; }
      return list.filter((item) => this.matchesKeyword(type, item, keyword));
    },
    ensureSelection(type) {
      const list = this.filteredListForType(type);
      if (!list.length) {
        this.$set(this.selectedIdMap, type, null);
        return;
      }
      const currentId = this.selectedIdMap[type];
      const exists = list.some((item) => String(this.getItemId(type, item)) === String(currentId));
      if (!exists) {
        this.$set(this.selectedIdMap, type, this.getItemId(type, list[0]));
      }
    },
    applyExternalFocus(type, rawId) {
      if (rawId == null || rawId === "" || rawId === 0) { return; }
      this.$set(this.selectedIdMap, type, rawId);
      if (this.activeCard === type) {
        const item = this.getSelectedItem(type);
        if (item) { this.hydrateControlForm(type, item); }
      }
    },
    selectItem(type, item) {
      this.$set(this.selectedIdMap, type, this.getItemId(type, item));
      this.hydrateControlForm(type, item);
    },
    hydrateControlForm(type, item) {
      if (!item) { return; }
      if (type === "crn") {
        this.controlForms.crn.crnNo = this.orEmpty(item.crnNo);
      } else if (type === "dualCrn") {
        this.controlForms.dualCrn.crnNo = this.orEmpty(item.crnNo);
      } else if (type === "devp") {
        this.controlForms.devp.stationId = this.orEmpty(item.stationId);
        this.controlForms.devp.taskNo = this.orEmpty(item.taskNo);
        this.controlForms.devp.targetStationId = this.orEmpty(item.targetStaNo);
      } else if (type === "rgv") {
        this.controlForms.rgv.rgvNo = this.orEmpty(item.rgvNo);
      }
    },
    matchesKeyword(type, item, keyword) {
      const fields = [];
      if (type === "crn" || type === "dualCrn") {
        fields.push(item.crnNo, item.taskNo, item.taskNoTwo, item.locNo, item.sourceLocNo, item.status, item.mode);
      } else if (type === "devp") {
        fields.push(item.stationId, item.taskNo, item.targetStaNo, item.barcode, item.errorMsg, item.extend);
      } else if (type === "rgv") {
        fields.push(item.rgvNo, item.taskNo, item.trackSiteNo, item.status, item.mode, item.alarm);
      }
      return fields.some((field) => String(field == null ? "" : field).toLowerCase().indexOf(keyword) !== -1);
    },
    currentSearchPlaceholder() {
      if (this.activeCard === "crn") { return "搜索堆垛机号"; }
      if (this.activeCard === "dualCrn") { return "搜索双工位堆垛机号"; }
      if (this.activeCard === "devp") { return "搜索站号 / 条码"; }
      if (this.activeCard === "rgv") { return "搜索RGV号"; }
      return "搜索";
    },
    getItemTitle(type, item) {
      if (!item) { return "-"; }
      if (type === "crn") { return item.crnNo + "号堆垛机"; }
      if (type === "dualCrn") { return item.crnNo + "号双工位堆垛机"; }
      if (type === "devp") { return item.stationId + "号站点"; }
      if (type === "rgv") { return item.rgvNo + "号RGV"; }
      return "-";
    },
    getItemMeta(type, item) {
      if (!item) { return "-"; }
      if (type === "crn") { return "任务 " + this.orDash(item.workNo) + " | 目标 " + this.orDash(item.locNo); }
      if (type === "dualCrn") { return "工位1 " + this.orDash(item.taskNo) + " | 工位2 " + this.orDash(item.taskNoTwo); }
      if (type === "devp") { return "任务 " + this.orDash(item.taskNo) + " | 目标站 " + this.orDash(item.targetStaNo); }
      if (type === "rgv") { return "轨道位 " + this.orDash(item.trackSiteNo) + " | 任务 " + this.orDash(item.taskNo); }
      return "-";
    },
    getStatusLabel(type, item) {
      if (!item) { return "未知"; }
      if (type === "devp") { return item.autoing ? "自动" : "手动"; }
      const status = String(item.deviceStatus || "").toUpperCase();
      if (status === "AUTO") { return "自动"; }
      if (status === "WORKING") { return "作业中"; }
      if (status === "ERROR") { return "故障"; }
      return "离线";
    },
    getStatusTone(type, item) {
      const label = this.getStatusLabel(type, item);
      if (label === "自动") { return "success"; }
      if (label === "作业中") { return "working"; }
      if (label === "手动") { return "warning"; }
      if (label === "故障") { return "danger"; }
      return "muted";
    },
    isSelected(type, item) {
      return String(this.getItemId(type, item)) === String(this.selectedIdMap[type]);
    },
    buildDetailEntries(type, item) {
      if (!item) { return []; }
      if (type === "crn") {
        return [
          { label: "编号", value: this.orDash(item.crnNo) },
          { label: "工作号", value: this.orDash(item.workNo) },
          { label: "模式", value: this.orDash(item.mode) },
          { label: "状态", value: this.orDash(item.status) },
          { label: "源库位", value: this.orDash(item.sourceLocNo) },
          { label: "目标库位", value: this.orDash(item.locNo) },
          { label: "是否有物", value: this.yesNo(item.loading) },
          { label: "任务接收", value: this.orDash(item.taskReceive) },
          { label: "列", value: this.orDash(item.bay) },
          { label: "层", value: this.orDash(item.lev) },
          { label: "货叉定位", value: this.orDash(item.forkOffset) },
          { label: "载货台定位", value: this.orDash(item.liftPos) },
          { label: "走行定位", value: this.orDash(item.walkPos) },
          { label: "走行速度", value: this.orDash(item.xspeed) },
          { label: "升降速度", value: this.orDash(item.yspeed) },
          { label: "叉牙速度", value: this.orDash(item.zspeed) },
          { label: "称重数据", value: this.orDash(item.weight) },
          { label: "条码数据", value: this.orDash(item.barcode) },
          { label: "故障代码", value: this.orDash(item.warnCode) },
          { label: "故障描述", value: this.orDash(item.alarm) }
        ];
      }
      if (type === "dualCrn") {
        return [
          { label: "模式", value: this.orDash(item.mode) },
          { label: "异常码", value: this.orDash(item.warnCode) },
          { label: "工位1任务号", value: this.orDash(item.taskNo) },
          { label: "工位2任务号", value: this.orDash(item.taskNoTwo) },
          { label: "工位1状态", value: this.orDash(item.status) },
          { label: "工位2状态", value: this.orDash(item.statusTwo) },
          { label: "工位1有物", value: this.yesNo(item.loading) },
          { label: "工位2有物", value: this.yesNo(item.loadingTwo) },
          { label: "列", value: this.orDash(item.bay) },
          { label: "层", value: this.orDash(item.lev) },
          { label: "载货台定位", value: this.orDash(item.liftPos) },
          { label: "走行定位", value: this.orDash(item.walkPos) },
          { label: "走行速度", value: this.orDash(item.xspeed) },
          { label: "升降速度", value: this.orDash(item.yspeed) },
          { label: "叉牙速度", value: this.orDash(item.zspeed) },
          { label: "扩展数据", value: this.orDash(item.extend) }
        ];
      }
      if (type === "devp") {
        return [
          { label: "编号", value: this.orDash(item.stationId) },
          { label: "工作号", value: this.orDash(item.taskNo) },
          { label: "目标站", value: this.orDash(item.targetStaNo) },
          { label: "模式", value: item.autoing ? "自动" : "手动" },
          { label: "有物", value: this.yesNo(item.loading) },
          { label: "可入", value: this.yesNo(item.inEnable) },
          { label: "可出", value: this.yesNo(item.outEnable) },
          { label: "空板信号", value: this.yesNo(item.emptyMk) },
          { label: "满板信号", value: this.yesNo(item.fullPlt) },
          { label: "运行阻塞", value: this.yesNo(item.runBlock) },
          { label: "启动入库", value: this.yesNo(item.enableIn) },
          { label: "托盘高度", value: this.orDash(item.palletHeight) },
          { label: "条码", value: this.orDash(item.barcode) },
          { label: "重量", value: this.orDash(item.weight) },
          { label: "任务可写区", value: this.orDash(item.taskWriteIdx) },
          { label: "故障代码", value: this.orDash(item.error) },
          { label: "故障信息", value: this.orDash(item.errorMsg) }
        ];
      }
      if (type === "rgv") {
        return [
          { label: "编号", value: this.orDash(item.rgvNo) },
          { label: "工作号", value: this.orDash(item.taskNo) },
          { label: "模式", value: this.orDash(item.mode) },
          { label: "状态", value: this.orDash(item.status) },
          { label: "轨道位", value: this.orDash(item.trackSiteNo) },
          { label: "是否有物", value: this.yesNo(item.loading) },
          { label: "故障代码", value: this.orDash(item.warnCode) },
          { label: "故障描述", value: this.orDash(item.alarm) },
          { label: "扩展数据", value: this.orDash(item.extend) }
        ];
      }
      return [];
    },
    submitControl(action) {
      const config = this.getControlConfig(this.activeCard, action);
      if (!config) { return; }
      $.ajax({
        url: baseUrl + config.url,
        headers: { token: localStorage.getItem("token") },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(config.payload),
        success: (res) => {
          if (res && res.code === 200) {
            this.showNotice(res.msg || "操作成功", "success");
            this.$emit("refresh-request", this.activeCard);
          } else {
            this.showNotice((res && res.msg) || "操作失败", "warning");
          }
        },
        error: () => {
          this.showNotice("请求失败", "danger");
        }
      });
    },
    getControlConfig(type, action) {
      if (type === "crn") {
        return {
          url: { transport: "/crn/command/take", move: "/crn/command/move", taskComplete: "/crn/command/taskComplete" }[action],
          payload: this.controlForms.crn
        };
      }
      if (type === "dualCrn") {
        return {
          url: {
            transport: "/dualcrn/command/take",
            pickup: "/dualcrn/command/pick",
            putdown: "/dualcrn/command/put",
            move: "/dualcrn/command/move",
            taskComplete: "/dualcrn/command/taskComplete"
          }[action],
          payload: this.controlForms.dualCrn
        };
      }
      if (type === "devp") {
        return {
          url: { move: "/station/command/move", reset: "/station/command/reset" }[action],
          payload: this.controlForms.devp
        };
      }
      if (type === "rgv") {
        return {
          url: { transport: "/rgv/command/transport", move: "/rgv/command/move", taskComplete: "/rgv/command/taskComplete" }[action],
          payload: this.controlForms.rgv
        };
      }
      return null;
    },
    editBarcode() {
      const item = this.selectedItem;
      if (!item || item.stationId == null) { return; }
      const barcode = window.prompt("请输入新的条码值(可留空清空)", item.barcode || "");
      if (barcode === null) { return; }
      $.ajax({
        url: baseUrl + "/station/command/barcode",
        headers: { token: localStorage.getItem("token") },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify({ stationId: item.stationId, barcode: String(barcode).trim() }),
        success: (res) => {
          if (res && res.code === 200) {
            this.showNotice("条码修改成功", "success");
            this.$emit("refresh-request", "devp");
          } else {
            this.showNotice((res && res.msg) || "条码修改失败", "warning");
          }
        },
        error: () => { this.showNotice("条码修改失败", "danger"); }
      });
    },
    editDualTask(station) {
      const item = this.selectedItem;
      if (!item || item.crnNo == null) { return; }
      const currentValue = station === 1 ? item.taskNo : item.taskNoTwo;
      const value = window.prompt("请输入工位" + station + "任务号", currentValue == null ? "" : String(currentValue));
      if (value === null) { return; }
      if (!/^\d+$/.test(String(value).trim())) {
        this.showNotice("任务号必须是非负整数", "warning");
        return;
      }
      $.ajax({
        url: baseUrl + "/dualcrn/command/updateTaskNo",
        headers: { token: localStorage.getItem("token") },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify({ crnNo: item.crnNo, station: station, taskNo: Number(value) }),
        success: (res) => {
          if (res && res.code === 200) {
            this.showNotice("任务号更新成功", "success");
            this.$emit("refresh-request", "dualCrn");
          } else {
            this.showNotice((res && res.msg) || "任务号更新失败", "warning");
          }
        },
        error: () => { this.showNotice("任务号更新失败", "danger"); }
      });
    },
    showNotice(message, type) {
      this.noticeMessage = message;
      this.noticeType = type || "info";
      if (this.noticeTimer) { clearTimeout(this.noticeTimer); }
      this.noticeTimer = setTimeout(() => {
        this.noticeMessage = "";
        this.noticeTimer = null;
      }, 2200);
    },
    yesNo(value) {
      if (value === true || value === "Y" || value === "y" || value === 1 || value === "1") { return "Y"; }
      if (value === false || value === 0 || value === "0") { return "N"; }
      return value ? "Y" : "N";
    },
    orDash(value) {
      return value == null || value === "" ? "-" : String(value);
    },
    orEmpty(value) {
      return value == null ? "" : String(value);
    }
  }
});
src/main/webapp/components/WatchCrnCard.js
@@ -1,96 +1,86 @@
Vue.component("watch-crn-card", {
  template: `
    <div>
        <div style="display: flex;margin-bottom: 10px;">
            <div style="width: 100%;">堆垛机监控</div>
            <div style="width: 100%;text-align: right;display: flex;"><el-input size="mini" v-model="searchCrnNo" placeholder="请输入堆垛机号"></el-input><el-button @click="getCrnStateInfo" size="mini">查询</el-button></div>
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">堆垛机监控</div>
        <div class="mc-search">
          <input class="mc-input" v-model="searchCrnNo" placeholder="请输入堆垛机号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getCrnStateInfo">查询</button>
        </div>
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
      </div>
      <div v-if="!readOnly" class="mc-control-toggle">
        <button type="button" class="mc-btn mc-btn-ghost" @click="openControl">
          {{ showControl ? '收起控制中心' : '打开控制中心' }}
        </button>
      </div>
      <div v-if="showControl" class="mc-control">
        <div class="mc-control-grid">
          <label class="mc-field">
            <span class="mc-field-label">堆垛机号</span>
            <input class="mc-input" v-model="controlParam.crnNo" placeholder="例如 1" />
          </label>
          <label class="mc-field">
            <span class="mc-field-label">源库位</span>
            <input class="mc-input" v-model="controlParam.sourceLocNo" placeholder="输入源点" />
          </label>
          <label class="mc-field mc-span-2">
            <span class="mc-field-label">目标库位</span>
            <input class="mc-input" v-model="controlParam.targetLocNo" placeholder="输入目标点" />
          </label>
          <div class="mc-action-row">
            <button type="button" class="mc-btn" @click="controlCommandTransport">取放货</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="controlCommandMove">移动</button>
            <button type="button" class="mc-btn mc-btn-soft" @click="controlCommandTaskComplete">任务完成</button>
          </div>
        </div>
      </div>
      <div class="mc-collapse">
        <div
          v-for="item in displayCrnList"
          :key="item.crnNo"
          :class="['mc-item', { 'is-open': isActive(item.crnNo) }]"
        >
          <button type="button" class="mc-head" @click="toggleItem(item)">
            <div class="mc-head-main">
              <div class="mc-head-title">{{ item.crnNo }}号堆垛机</div>
              <div class="mc-head-subtitle">工作号 {{ orDash(item.workNo) }} | 目标 {{ orDash(item.locNo) }}</div>
            </div>
            <div v-if="showControl" style="display: flex;justify-content: space-between;flex-wrap: wrap;">
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.crnNo" placeholder="堆垛机号"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.sourceLocNo" placeholder="源点"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.targetLocNo" placeholder="目标点"></el-input></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandTransport()" size="mini">取放货</el-button></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandMove()" size="mini">移动</el-button></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandTaskComplete()" size="mini">任务完成</el-button></div>
            <div class="mc-head-right">
              <span :class="['mc-badge', 'is-' + getStatusTone(item)]">{{ getStatusLabel(item) }}</span>
              <span class="mc-chevron">{{ isActive(item.crnNo) ? '▾' : '▸' }}</span>
            </div>
          </button>
          <div v-if="isActive(item.crnNo)" class="mc-body">
            <div class="mc-detail-grid">
              <div v-for="entry in buildDetailEntries(item)" :key="entry.label" class="mc-detail-cell">
                <div class="mc-detail-label">{{ entry.label }}</div>
                <div class="mc-detail-value">{{ entry.value }}</div>
              </div>
            </div>
          </div>
        </div>
        <div style="max-height: 55vh; overflow:auto;">
          <el-collapse v-model="activeNames" accordion>
            <el-collapse-item v-for="(item) in displayCrnList" :name="item.crnNo">
            <template slot="title">
                <div style="width: 100%;display: flex;">
                   <div style="width: 50%;">{{ item.crnNo }}号堆垛机</div>
                   <div style="width: 50%;text-align: right;">
                      <el-tag v-if="item.deviceStatus == 'AUTO'" type="success" size="small">自动</el-tag>
                      <el-tag v-else-if="item.deviceStatus == 'WORKING'" size="small">作业中</el-tag>
                      <el-tag v-else-if="item.deviceStatus == 'ERROR'" type="danger" size="small">故障</el-tag>
                      <el-tag v-else type="warning" size="small">离线</el-tag>
                   </div>
                </div>
            </template>
            <el-descriptions border direction="vertical">
                <el-descriptions-item label="编号">{{ item.crnNo }}</el-descriptions-item>
                <el-descriptions-item label="工作号">{{ item.workNo }}</el-descriptions-item>
                <el-descriptions-item label="模式">{{ item.mode }}</el-descriptions-item>
                <el-descriptions-item label="状态">{{ item.status }}</el-descriptions-item>
                <el-descriptions-item label="源库位">{{ item.sourceLocNo }}</el-descriptions-item>
                <el-descriptions-item label="目标库位">{{ item.locNo }}</el-descriptions-item>
                <el-descriptions-item label="是否有物">{{ item.loading }}</el-descriptions-item>
                <el-descriptions-item label="任务接收">{{ item.taskReceive }}</el-descriptions-item>
                <el-descriptions-item label="列">{{ item.bay }}</el-descriptions-item>
                <el-descriptions-item label="层">{{ item.lev }}</el-descriptions-item>
                <el-descriptions-item label="货叉定位">{{ item.forkOffset }}</el-descriptions-item>
                <el-descriptions-item label="载货台定位">{{ item.liftPos }}</el-descriptions-item>
                <el-descriptions-item label="走行在定位">{{ item.walkPos }}</el-descriptions-item>
                <el-descriptions-item label="走行速度(m/min)">{{ item.xspeed }}</el-descriptions-item>
                <el-descriptions-item label="升降速度(m/min)">{{ item.yspeed }}</el-descriptions-item>
                <el-descriptions-item label="叉牙速度(m/min)">{{ item.zspeed }}</el-descriptions-item>
                <el-descriptions-item label="走行距离(Km)">{{ item.xdistance }}</el-descriptions-item>
                <el-descriptions-item label="升降距离(Km)">{{ item.ydistance }}</el-descriptions-item>
                <el-descriptions-item label="走行时长(H)">{{ item.xduration }}</el-descriptions-item>
                <el-descriptions-item label="升降时长(H)">{{ item.yduration }}</el-descriptions-item>
                <el-descriptions-item label="称重数据">{{ item.weight }}</el-descriptions-item>
                <el-descriptions-item label="条码数据">{{ item.barcode }}</el-descriptions-item>
                <el-descriptions-item label="故障代码">{{ item.warnCode }}</el-descriptions-item>
                <el-descriptions-item label="故障描述">{{ item.alarm }}</el-descriptions-item>
                <el-descriptions-item label="扩展数据">{{ item.extend }}</el-descriptions-item>
            </el-descriptions>
            </el-collapse-item>
          </el-collapse>
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:8px;">
          <el-pagination
            @current-change="handlePageChange"
            @size-change="handleSizeChange"
            :current-page="currentPage"
            :page-size="pageSize"
            :page-sizes="[10,20,50,100]"
            layout="total, prev, pager, next"
            :total="crnList.length">
          </el-pagination>
        </div>
        <div v-if="displayCrnList.length === 0" class="mc-empty">当前没有可展示的堆垛机数据</div>
      </div>
      <div class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
      </div>
    </div>
    `,
  `,
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
    param: { type: Object, default: function () { return {}; } },
    items: { type: Array, default: null },
    autoRefresh: { type: Boolean, default: true },
    readOnly: { type: Boolean, default: false }
  },
  data() {
  data: function () {
    return {
      crnList: [],
      activeNames: "",
@@ -99,159 +89,176 @@
      controlParam: {
        crnNo: "",
        sourceLocNo: "",
        targetLocNo: "",
        targetLocNo: ""
      },
      pageSize: 25,
      pageSize: 12,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getCrnStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayCrnList() {
      const start = (this.currentPage - 1) * this.pageSize;
      const end = start + this.pageSize;
      return this.crnList.slice(start, end);
    sourceList: function () {
      return Array.isArray(this.items) ? this.items : this.crnList;
    },
    filteredCrnList: function () {
      var keyword = String(this.searchCrnNo || "").trim();
      if (!keyword) {
        return this.sourceList;
      }
      return this.sourceList.filter(function (item) {
        return String(item.crnNo) === keyword;
      });
    },
    displayCrnList: function () {
      var start = (this.currentPage - 1) * this.pageSize;
      return this.filteredCrnList.slice(start, start + this.pageSize);
    },
    totalPages: function () {
      return Math.max(1, Math.ceil(this.filteredCrnList.length / this.pageSize) || 1);
    }
  },
  watch: {
    param: {
      handler(newVal, oldVal) {
        if (newVal && newVal.crnNo && newVal.crnNo != 0) {
          this.activeNames = newVal.crnNo;
          this.searchCrnNo = newVal.crnNo;
          const idx = this.crnList.findIndex(i => i.crnNo == newVal.crnNo);
          if (idx >= 0) { this.currentPage = Math.floor(idx / this.pageSize) + 1; }
        }
      },
      deep: true, // 深度监听嵌套属性
      immediate: true, // 立即触发一次(可选)
    items: function () {
      this.afterDataRefresh();
    },
    param: {
      deep: true,
      immediate: true,
      handler: function (newVal) {
        if (newVal && newVal.crnNo && newVal.crnNo !== 0) {
          this.focusCrn(newVal.crnNo);
        }
      }
    }
  },
  created: function () {
    MonitorCardKit.ensureStyles();
    if (this.autoRefresh) {
      this.timer = setInterval(this.getCrnStateInfo, 1000);
    }
  },
  beforeDestroy: function () {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  methods: {
    handlePageChange(page) {
    orDash: function (value) {
      return MonitorCardKit.orDash(value);
    },
    getStatusLabel: function (item) {
      return MonitorCardKit.deviceStatusLabel(item && item.deviceStatus);
    },
    getStatusTone: function (item) {
      return MonitorCardKit.statusTone(this.getStatusLabel(item));
    },
    isActive: function (crnNo) {
      return String(this.activeNames) === String(crnNo);
    },
    toggleItem: function (item) {
      var next = String(item.crnNo);
      this.activeNames = this.activeNames === next ? "" : next;
    },
    focusCrn: function (crnNo) {
      this.searchCrnNo = String(crnNo);
      var index = this.filteredCrnList.findIndex(function (item) {
        return String(item.crnNo) === String(crnNo);
      });
      if (index >= 0) {
        this.currentPage = Math.floor(index / this.pageSize) + 1;
      } else {
        this.currentPage = 1;
      }
      this.activeNames = String(crnNo);
    },
    afterDataRefresh: function () {
      if (this.currentPage > this.totalPages) {
        this.currentPage = this.totalPages;
      }
      if (this.activeNames) {
        var exists = this.filteredCrnList.some(function (item) {
          return String(item.crnNo) === String(this.activeNames);
        }, this);
        if (!exists) {
          this.activeNames = "";
        }
      }
    },
    handlePageChange: function (page) {
      if (page < 1 || page > this.totalPages) {
        return;
      }
      this.currentPage = page;
    },
    handleSizeChange(size) {
      this.pageSize = size;
      this.currentPage = 1;
    },
    getCrnStateInfo() {
      if (this.$root.sendWs) {
    getCrnStateInfo: function () {
      if (this.$root && this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          "url": "/crn/table/crn/state",
          "data": {}
          url: "/crn/table/crn/state",
          data: {}
        }));
      }
    },
    setCrnList(res) {
      let that = this;
      if (res.code == 200) {
        let list = res.data;
        if (that.searchCrnNo == "") {
          that.crnList = list;
        } else {
          let tmp = [];
          list.forEach((item) => {
            if (item.crnNo == that.searchCrnNo) {
              tmp.push(item);
            }
          });
          that.crnList = tmp;
          that.currentPage = 1;
        }
    setCrnList: function (res) {
      if (res && res.code === 200) {
        this.crnList = res.data || [];
        this.afterDataRefresh();
      }
    },
    openControl() {
    openControl: function () {
      this.showControl = !this.showControl;
    },
    controlCommandTransport() {
      let that = this;
      //取放货
    buildDetailEntries: function (item) {
      return [
        { label: "编号", value: this.orDash(item.crnNo) },
        { label: "工作号", value: this.orDash(item.workNo) },
        { label: "模式", value: this.orDash(item.mode) },
        { label: "状态", value: this.orDash(item.status) },
        { label: "源库位", value: this.orDash(item.sourceLocNo) },
        { label: "目标库位", value: this.orDash(item.locNo) },
        { label: "是否有物", value: MonitorCardKit.yesNo(item.loading) },
        { label: "任务接收", value: this.orDash(item.taskReceive) },
        { label: "列", value: this.orDash(item.bay) },
        { label: "层", value: this.orDash(item.lev) },
        { label: "货叉定位", value: this.orDash(item.forkOffset) },
        { label: "载货台定位", value: this.orDash(item.liftPos) },
        { label: "走行定位", value: this.orDash(item.walkPos) },
        { label: "走行速度", value: this.orDash(item.xspeed) },
        { label: "升降速度", value: this.orDash(item.yspeed) },
        { label: "叉牙速度", value: this.orDash(item.zspeed) },
        { label: "称重数据", value: this.orDash(item.weight) },
        { label: "条码数据", value: this.orDash(item.barcode) },
        { label: "故障代码", value: this.orDash(item.warnCode) },
        { label: "故障描述", value: this.orDash(item.alarm) },
        { label: "扩展数据", value: this.orDash(item.extend) }
      ];
    },
    postControl: function (url, payload) {
      $.ajax({
        url: baseUrl + "/crn/command/take",
        url: baseUrl + url,
        headers: {
          token: localStorage.getItem("token"),
          token: localStorage.getItem("token")
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
        data: JSON.stringify(payload),
        success: function (res) {
          if (res && res.code === 200) {
            MonitorCardKit.showMessage(this, res.msg || "操作成功", "success");
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
            MonitorCardKit.showMessage(this, (res && res.msg) || "操作失败", "warning");
          }
        },
        }.bind(this)
      });
    },
    controlCommandMove() {
      let that = this;
      $.ajax({
        url: baseUrl + "/crn/command/move",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandTransport: function () {
      this.postControl("/crn/command/take", this.controlParam);
    },
    controlCommandTaskComplete() {
      let that = this;
      $.ajax({
        url: baseUrl + "/crn/command/taskComplete",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandMove: function () {
      this.postControl("/crn/command/move", this.controlParam);
    },
  },
    controlCommandTaskComplete: function () {
      this.postControl("/crn/command/taskComplete", this.controlParam);
    }
  }
});
src/main/webapp/components/WatchDualCrnCard.js
@@ -1,128 +1,99 @@
Vue.component("watch-dual-crn-card", {
  template: `
    <div>
        <div style="display: flex;margin-bottom: 10px;">
            <div style="width: 100%;">双工位堆垛机监控</div>
            <div style="width: 100%;text-align: right;display: flex;">
              <el-input size="mini" v-model="searchCrnNo" placeholder="请输入堆垛机号"></el-input>
              <el-button @click="getDualCrnStateInfo" size="mini">查询</el-button>
            </div>
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">双工位堆垛机监控</div>
        <div class="mc-search">
          <input class="mc-input" v-model="searchCrnNo" placeholder="请输入堆垛机号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getDualCrnStateInfo">查询</button>
        </div>
        <div style="margin-bottom: 10px;" v-if="!readOnly">
          <div style="margin-bottom: 5px;">
            <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
            <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
          </div>
          <div v-if="showControl" style="display: flex;justify-content: space-between;flex-wrap: wrap;">
            <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.crnNo" placeholder="堆垛机号"></el-input></div>
            <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.sourceLocNo" placeholder="源点"></el-input></div>
            <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.targetLocNo" placeholder="目标点"></el-input></div>
            <div style="margin-bottom: 10px;width: 33%;">
              <el-select size="mini" v-model="controlParam.station" placeholder="工位">
                <el-option :label="'工位1'" :value="1"></el-option>
                <el-option :label="'工位2'" :value="2"></el-option>
              </el-select>
            </div>
            <div style="margin-bottom: 10px;"><el-button @click="controlCommandTransport()" size="mini">取放货</el-button></div>
            <div style="margin-bottom: 10px;"><el-button @click="controlCommandPickup()" size="mini">取货</el-button></div>
            <div style="margin-bottom: 10px;"><el-button @click="controlCommandPutdown()" size="mini">放货</el-button></div>
            <div style="margin-bottom: 10px;"><el-button @click="controlCommandMove()" size="mini">移动</el-button></div>
            <div style="margin-bottom: 10px;"><el-button @click="controlCommandTaskComplete()" size="mini">任务完成</el-button></div>
      </div>
      <div v-if="!readOnly" class="mc-control-toggle">
        <button type="button" class="mc-btn mc-btn-ghost" @click="openControl">
          {{ showControl ? '收起控制中心' : '打开控制中心' }}
        </button>
      </div>
      <div v-if="showControl" class="mc-control">
        <div class="mc-control-grid">
          <label class="mc-field">
            <span class="mc-field-label">堆垛机号</span>
            <input class="mc-input" v-model="controlParam.crnNo" placeholder="例如 2" />
          </label>
          <label class="mc-field">
            <span class="mc-field-label">工位</span>
            <select class="mc-select" v-model="controlParam.station">
              <option :value="1">工位1</option>
              <option :value="2">工位2</option>
            </select>
          </label>
          <label class="mc-field">
            <span class="mc-field-label">源库位</span>
            <input class="mc-input" v-model="controlParam.sourceLocNo" placeholder="输入源点" />
          </label>
          <label class="mc-field">
            <span class="mc-field-label">目标库位</span>
            <input class="mc-input" v-model="controlParam.targetLocNo" placeholder="输入目标点" />
          </label>
          <div class="mc-action-row">
            <button type="button" class="mc-btn" @click="controlCommandTransport">取放货</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="controlCommandPickup">取货</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="controlCommandPutdown">放货</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="controlCommandMove">移动</button>
            <button type="button" class="mc-btn mc-btn-soft" @click="controlCommandTaskComplete">任务完成</button>
          </div>
        </div>
        <div style="max-height: 55vh; overflow:auto;">
          <el-collapse v-model="activeNames" accordion>
            <el-collapse-item v-for="(item) in displayCrnList" :name="item.crnNo">
              <template slot="title">
                <div style="width: 100%;display: flex;">
                  <div style="width: 50%;">{{ item.crnNo }}号双工位堆垛机</div>
                  <div style="width: 50%;text-align: right;">
                    <el-tag v-if="item.deviceStatus == 'AUTO'" type="success" size="small">自动</el-tag>
                    <el-tag v-else-if="item.deviceStatus == 'WORKING'" size="small">作业中</el-tag>
                    <el-tag v-else-if="item.deviceStatus == 'ERROR'" type="danger" size="small">故障</el-tag>
                    <el-tag v-else type="warning" size="small">离线</el-tag>
                  </div>
                </div>
              </template>
              <el-descriptions border direction="vertical">
                <el-descriptions-item label="模式">{{ item.mode }}</el-descriptions-item>
                <el-descriptions-item label="异常码">{{ item.warnCode }}</el-descriptions-item>
                <el-descriptions-item label="工位1任务号">
                  <span v-if="readOnly">{{ item.taskNo }}</span>
                  <el-button
                    v-else
                    type="text"
                    size="mini"
                    style="padding:0;"
                    @click.stop="editTaskNo(item, 1)"
                  >{{ item.taskNo }}</el-button>
                </el-descriptions-item>
                <el-descriptions-item label="工位2任务号">
                  <span v-if="readOnly">{{ item.taskNoTwo }}</span>
                  <el-button
                    v-else
                    type="text"
                    size="mini"
                    style="padding:0;"
                    @click.stop="editTaskNo(item, 2)"
                  >{{ item.taskNoTwo }}</el-button>
                </el-descriptions-item>
                <el-descriptions-item label="设备工位1任务号">{{ item.deviceTaskNo }}</el-descriptions-item>
                <el-descriptions-item label="设备工位2任务号">{{ item.deviceTaskNoTwo }}</el-descriptions-item>
                <el-descriptions-item label="工位1状态">{{ item.status }}</el-descriptions-item>
                <el-descriptions-item label="工位2状态">{{ item.statusTwo }}</el-descriptions-item>
                <el-descriptions-item label="工位1是否有物">{{ item.loading }}</el-descriptions-item>
                <el-descriptions-item label="工位2是否有物">{{ item.loadingTwo }}</el-descriptions-item>
                <el-descriptions-item label="工位1货叉定位">{{ item.forkOffset }}</el-descriptions-item>
                <el-descriptions-item label="工位2货叉定位">{{ item.forkOffsetTwo }}</el-descriptions-item>
                <el-descriptions-item label="工位1任务接收">{{ item.taskReceive }}</el-descriptions-item>
                <el-descriptions-item label="工位2任务接收">{{ item.taskReceiveTwo }}</el-descriptions-item>
                <el-descriptions-item label="工位1下发数据">{{ item.taskSend }}</el-descriptions-item>
                <el-descriptions-item label="工位2下发数据">{{ item.taskSendTwo }}</el-descriptions-item>
                <el-descriptions-item label="列">{{ item.bay }}</el-descriptions-item>
                <el-descriptions-item label="层">{{ item.lev }}</el-descriptions-item>
                <el-descriptions-item label="载货台定位">{{ item.liftPos }}</el-descriptions-item>
                <el-descriptions-item label="走行在定位">{{ item.walkPos }}</el-descriptions-item>
                <el-descriptions-item label="走行速度(m/min)">{{ item.xspeed }}</el-descriptions-item>
                <el-descriptions-item label="升降速度(m/min)">{{ item.yspeed }}</el-descriptions-item>
                <el-descriptions-item label="叉牙速度(m/min)">{{ item.zspeed }}</el-descriptions-item>
                <el-descriptions-item label="走行距离(Km)">{{ item.xdistance }}</el-descriptions-item>
                <el-descriptions-item label="升降距离(Km)">{{ item.ydistance }}</el-descriptions-item>
                <el-descriptions-item label="走行时长(H)">{{ item.xduration }}</el-descriptions-item>
                <el-descriptions-item label="升降时长(H)">{{ item.yduration }}</el-descriptions-item>
                <el-descriptions-item label="扩展数据">{{ item.extend }}</el-descriptions-item>
              </el-descriptions>
            </el-collapse-item>
          </el-collapse>
      </div>
      <div class="mc-collapse">
        <div
          v-for="item in displayCrnList"
          :key="item.crnNo"
          :class="['mc-item', { 'is-open': isActive(item.crnNo) }]"
        >
          <button type="button" class="mc-head" @click="toggleItem(item)">
            <div class="mc-head-main">
              <div class="mc-head-title">{{ item.crnNo }}号双工位堆垛机</div>
              <div class="mc-head-subtitle">工位1 {{ orDash(item.taskNo) }} | 工位2 {{ orDash(item.taskNoTwo) }}</div>
            </div>
            <div class="mc-head-right">
              <span :class="['mc-badge', 'is-' + getStatusTone(item)]">{{ getStatusLabel(item) }}</span>
              <span class="mc-chevron">{{ isActive(item.crnNo) ? '▾' : '▸' }}</span>
            </div>
          </button>
          <div v-if="isActive(item.crnNo)" class="mc-body">
            <div class="mc-inline-actions" v-if="!readOnly">
              <button type="button" class="mc-link" @click.stop="editTaskNo(item, 1)">编辑工位1任务号</button>
              <button type="button" class="mc-link" @click.stop="editTaskNo(item, 2)">编辑工位2任务号</button>
            </div>
            <div class="mc-detail-grid">
              <div v-for="entry in buildDetailEntries(item)" :key="entry.label" class="mc-detail-cell">
                <div class="mc-detail-label">{{ entry.label }}</div>
                <div class="mc-detail-value">{{ entry.value }}</div>
              </div>
            </div>
          </div>
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:8px;">
          <el-pagination
            @current-change="handlePageChange"
            @size-change="handleSizeChange"
            :current-page="currentPage"
            :page-size="pageSize"
            :page-sizes="[10,20,50,100]"
            layout="total, prev, pager, next"
            :total="crnList.length">
          </el-pagination>
        </div>
        <div v-if="displayCrnList.length === 0" class="mc-empty">当前没有可展示的双工位堆垛机数据</div>
      </div>
      <div class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
      </div>
    </div>
  `,
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
    param: { type: Object, default: function () { return {}; } },
    items: { type: Array, default: null },
    autoRefresh: { type: Boolean, default: true },
    readOnly: { type: Boolean, default: false }
  },
  data() {
  data: function () {
    return {
      crnList: [],
      activeNames: "",
@@ -132,251 +103,212 @@
        crnNo: "",
        sourceLocNo: "",
        targetLocNo: "",
        station: 1,
        station: 1
      },
      pageSize: 25,
      pageSize: 12,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getDualCrnStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayCrnList() {
      const start = (this.currentPage - 1) * this.pageSize;
      const end = start + this.pageSize;
      return this.crnList.slice(start, end);
    sourceList: function () {
      return Array.isArray(this.items) ? this.items : this.crnList;
    },
    filteredCrnList: function () {
      var keyword = String(this.searchCrnNo || "").trim();
      if (!keyword) {
        return this.sourceList;
      }
      return this.sourceList.filter(function (item) {
        return String(item.crnNo) === keyword;
      });
    },
    displayCrnList: function () {
      var start = (this.currentPage - 1) * this.pageSize;
      return this.filteredCrnList.slice(start, start + this.pageSize);
    },
    totalPages: function () {
      return Math.max(1, Math.ceil(this.filteredCrnList.length / this.pageSize) || 1);
    }
  },
  watch: {
    items: function () {
      this.afterDataRefresh();
    },
    param: {
      handler(newVal) {
        if (newVal && newVal.crnNo && newVal.crnNo != 0) {
          this.activeNames = newVal.crnNo;
          this.searchCrnNo = newVal.crnNo;
          const idx = this.crnList.findIndex(i => i.crnNo == newVal.crnNo);
          if (idx >= 0) { this.currentPage = Math.floor(idx / this.pageSize) + 1; }
        }
      },
      deep: true,
      immediate: true,
    },
      handler: function (newVal) {
        if (newVal && newVal.crnNo && newVal.crnNo !== 0) {
          this.focusCrn(newVal.crnNo);
        }
      }
    }
  },
  created: function () {
    MonitorCardKit.ensureStyles();
    if (this.autoRefresh) {
      this.timer = setInterval(this.getDualCrnStateInfo, 1000);
    }
  },
  beforeDestroy: function () {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  methods: {
    handlePageChange(page) {
    orDash: function (value) {
      return MonitorCardKit.orDash(value);
    },
    getStatusLabel: function (item) {
      return MonitorCardKit.deviceStatusLabel(item && item.deviceStatus);
    },
    getStatusTone: function (item) {
      return MonitorCardKit.statusTone(this.getStatusLabel(item));
    },
    isActive: function (crnNo) {
      return String(this.activeNames) === String(crnNo);
    },
    toggleItem: function (item) {
      var next = String(item.crnNo);
      this.activeNames = this.activeNames === next ? "" : next;
    },
    focusCrn: function (crnNo) {
      this.searchCrnNo = String(crnNo);
      var index = this.filteredCrnList.findIndex(function (item) {
        return String(item.crnNo) === String(crnNo);
      });
      this.currentPage = index >= 0 ? Math.floor(index / this.pageSize) + 1 : 1;
      this.activeNames = String(crnNo);
    },
    afterDataRefresh: function () {
      if (this.currentPage > this.totalPages) {
        this.currentPage = this.totalPages;
      }
      if (this.activeNames) {
        var exists = this.filteredCrnList.some(function (item) {
          return String(item.crnNo) === String(this.activeNames);
        }, this);
        if (!exists) {
          this.activeNames = "";
        }
      }
    },
    handlePageChange: function (page) {
      if (page < 1 || page > this.totalPages) {
        return;
      }
      this.currentPage = page;
    },
    handleSizeChange(size) {
      this.pageSize = size;
      this.currentPage = 1;
    getDualCrnStateInfo: function () {
      if (this.$root && this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          url: "/dualcrn/table/crn/state",
          data: {}
        }));
      }
    },
    openControl() {
    setDualCrnList: function (res) {
      if (res && res.code === 200) {
        this.crnList = res.data || [];
        this.afterDataRefresh();
      }
    },
    openControl: function () {
      this.showControl = !this.showControl;
    },
    editTaskNo(item, station) {
      let that = this;
      const isStationOne = station === 1;
      const fieldName = isStationOne ? "taskNo" : "taskNoTwo";
      const stationName = isStationOne ? "工位1" : "工位2";
      const currentTaskNo = item[fieldName] == null ? "" : String(item[fieldName]);
      that.$prompt("请输入" + stationName + "任务号", "编辑任务号", {
    buildDetailEntries: function (item) {
      return [
        { label: "模式", value: this.orDash(item.mode) },
        { label: "异常码", value: this.orDash(item.warnCode) },
        { label: "工位1任务号", value: this.orDash(item.taskNo) },
        { label: "工位2任务号", value: this.orDash(item.taskNoTwo) },
        { label: "设备工位1任务号", value: this.orDash(item.deviceTaskNo) },
        { label: "设备工位2任务号", value: this.orDash(item.deviceTaskNoTwo) },
        { label: "工位1状态", value: this.orDash(item.status) },
        { label: "工位2状态", value: this.orDash(item.statusTwo) },
        { label: "工位1是否有物", value: MonitorCardKit.yesNo(item.loading) },
        { label: "工位2是否有物", value: MonitorCardKit.yesNo(item.loadingTwo) },
        { label: "工位1货叉定位", value: this.orDash(item.forkOffset) },
        { label: "工位2货叉定位", value: this.orDash(item.forkOffsetTwo) },
        { label: "工位1任务接收", value: this.orDash(item.taskReceive) },
        { label: "工位2任务接收", value: this.orDash(item.taskReceiveTwo) },
        { label: "工位1下发数据", value: this.orDash(item.taskSend) },
        { label: "工位2下发数据", value: this.orDash(item.taskSendTwo) },
        { label: "列", value: this.orDash(item.bay) },
        { label: "层", value: this.orDash(item.lev) },
        { label: "载货台定位", value: this.orDash(item.liftPos) },
        { label: "走行定位", value: this.orDash(item.walkPos) },
        { label: "走行速度", value: this.orDash(item.xspeed) },
        { label: "升降速度", value: this.orDash(item.yspeed) },
        { label: "叉牙速度", value: this.orDash(item.zspeed) },
        { label: "扩展数据", value: this.orDash(item.extend) }
      ];
    },
    postControl: function (url, payload) {
      $.ajax({
        url: baseUrl + url,
        headers: {
          token: localStorage.getItem("token")
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(payload),
        success: function (res) {
          if (res && res.code === 200) {
            MonitorCardKit.showMessage(this, res.msg || "操作成功", "success");
          } else {
            MonitorCardKit.showMessage(this, (res && res.msg) || "操作失败", "warning");
          }
        }.bind(this)
      });
    },
    editTaskNo: function (item, station) {
      var currentValue = station === 1 ? item.taskNo : item.taskNoTwo;
      this.$prompt("请输入工位" + station + "任务号", "编辑任务号", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        inputValue: currentTaskNo,
        inputValue: currentValue == null ? "" : String(currentValue),
        inputPattern: /^\d+$/,
        inputErrorMessage: "任务号必须是非负整数",
      }).then(({ value }) => {
        const taskNo = Number(value);
        inputErrorMessage: "任务号必须是非负整数"
      }).then(function (result) {
        $.ajax({
          url: baseUrl + "/dualcrn/command/updateTaskNo",
          headers: {
            token: localStorage.getItem("token"),
            token: localStorage.getItem("token")
          },
          contentType: "application/json",
          method: "post",
          data: JSON.stringify({
            crnNo: item.crnNo,
            station: station,
            taskNo: taskNo,
            taskNo: Number(result.value)
          }),
          success: (res) => {
            if (res.code == 200) {
              item[fieldName] = taskNo;
              that.$message({
                message: stationName + "任务号更新成功",
                type: "success",
              });
              that.getDualCrnStateInfo();
          success: function (res) {
            if (res && res.code === 200) {
              MonitorCardKit.showMessage(this, "任务号更新成功", "success");
            } else {
              that.$message({
                message: res.msg,
                type: "warning",
              });
              MonitorCardKit.showMessage(this, (res && res.msg) || "任务号更新失败", "warning");
            }
          },
          }.bind(this)
        });
      }).catch(() => {});
      }.bind(this)).catch(function () {});
    },
    getDualCrnStateInfo() {
      if (this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          "url": "/dualcrn/table/crn/state",
          "data": {}
        }));
      }
    controlCommandTransport: function () {
      this.postControl("/dualcrn/command/take", this.controlParam);
    },
    controlCommandTransport() {
      let that = this;
      $.ajax({
        url: baseUrl + "/dualcrn/command/take",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandPickup: function () {
      this.postControl("/dualcrn/command/pick", this.controlParam);
    },
    controlCommandPickup() {
      let that = this;
      $.ajax({
        url: baseUrl + "/dualcrn/command/pick",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandPutdown: function () {
      this.postControl("/dualcrn/command/put", this.controlParam);
    },
    controlCommandPutdown() {
      let that = this;
      $.ajax({
        url: baseUrl + "/dualcrn/command/put",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandMove: function () {
      this.postControl("/dualcrn/command/move", this.controlParam);
    },
    controlCommandMove() {
      let that = this;
      $.ajax({
        url: baseUrl + "/dualcrn/command/move",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    },
    controlCommandTaskComplete() {
      let that = this;
      $.ajax({
        url: baseUrl + "/dualcrn/command/taskComplete",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    },
    setDualCrnList(res) {
      let that = this;
      if (res.code == 200) {
        let list = res.data;
        if (that.searchCrnNo == "") {
          that.crnList = list;
        } else {
          let tmp = [];
          list.forEach((item) => {
            if (item.crnNo == that.searchCrnNo) {
              tmp.push(item);
            }
          });
          that.crnList = tmp;
          that.currentPage = 1;
        }
      }
    },
  },
    controlCommandTaskComplete: function () {
      this.postControl("/dualcrn/command/taskComplete", this.controlParam);
    }
  }
});
src/main/webapp/components/WatchRgvCard.js
@@ -1,83 +1,86 @@
Vue.component("watch-rgv-card", {
  template: `
    <div>
        <div style="display: flex;margin-bottom: 10px;">
            <div style="width: 100%;">RGV监控</div>
            <div style="width: 100%;text-align: right;display: flex;">
              <el-input size="mini" v-model="searchRgvNo" placeholder="请输入RGV号"></el-input>
              <el-button @click="getRgvStateInfo" size="mini">查询</el-button>
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">RGV监控</div>
        <div class="mc-search">
          <input class="mc-input" v-model="searchRgvNo" placeholder="请输入RGV号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getRgvStateInfo">查询</button>
        </div>
      </div>
      <div v-if="!readOnly" class="mc-control-toggle">
        <button type="button" class="mc-btn mc-btn-ghost" @click="openControl">
          {{ showControl ? '收起控制中心' : '打开控制中心' }}
        </button>
      </div>
      <div v-if="showControl" class="mc-control">
        <div class="mc-control-grid">
          <label class="mc-field">
            <span class="mc-field-label">RGV号</span>
            <input class="mc-input" v-model="controlParam.rgvNo" placeholder="例如 1" />
          </label>
          <label class="mc-field">
            <span class="mc-field-label">源点</span>
            <input class="mc-input" v-model="controlParam.sourcePos" placeholder="输入源点" />
          </label>
          <label class="mc-field mc-span-2">
            <span class="mc-field-label">目标点</span>
            <input class="mc-input" v-model="controlParam.targetPos" placeholder="输入目标点" />
          </label>
          <div class="mc-action-row">
            <button type="button" class="mc-btn" @click="controlCommandTransport">取放货</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="controlCommandMove">移动</button>
            <button type="button" class="mc-btn mc-btn-soft" @click="controlCommandTaskComplete">任务完成</button>
          </div>
        </div>
      </div>
      <div class="mc-collapse">
        <div
          v-for="item in displayRgvList"
          :key="item.rgvNo"
          :class="['mc-item', { 'is-open': isActive(item.rgvNo) }]"
        >
          <button type="button" class="mc-head" @click="toggleItem(item)">
            <div class="mc-head-main">
              <div class="mc-head-title">{{ item.rgvNo }}号RGV</div>
              <div class="mc-head-subtitle">轨道位 {{ orDash(item.trackSiteNo) }} | 任务 {{ orDash(item.taskNo) }}</div>
            </div>
        </div>
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
            <div class="mc-head-right">
              <span :class="['mc-badge', 'is-' + getStatusTone(item)]">{{ getStatusLabel(item) }}</span>
              <span class="mc-chevron">{{ isActive(item.rgvNo) ? '▾' : '▸' }}</span>
            </div>
            <div v-if="showControl" style="display: flex;justify-content: space-between;flex-wrap: wrap;">
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.rgvNo" placeholder="RGV号"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.sourcePos" placeholder="源点"></el-input></div>
                <div style="margin-bottom: 10px;width: 33%;"><el-input size="mini" v-model="controlParam.targetPos" placeholder="目标点"></el-input></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandTransport()" size="mini">取放货</el-button></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandMove()" size="mini">移动</el-button></div>
                <div style="margin-bottom: 10px;"><el-button @click="controlCommandTaskComplete()" size="mini">任务完成</el-button></div>
          </button>
          <div v-if="isActive(item.rgvNo)" class="mc-body">
            <div class="mc-detail-grid">
              <div v-for="entry in buildDetailEntries(item)" :key="entry.label" class="mc-detail-cell">
                <div class="mc-detail-label">{{ entry.label }}</div>
                <div class="mc-detail-value">{{ entry.value }}</div>
              </div>
            </div>
          </div>
        </div>
        <div style="max-height: 55vh; overflow:auto;">
          <el-collapse v-model="activeNames" accordion>
            <el-collapse-item v-for="(item) in displayRgvList" :name="item.rgvNo">
            <template slot="title">
                <div style="width: 100%;display: flex;">
                   <div style="width: 50%;">{{ item.rgvNo }}号RGV</div>
                   <div style="width: 50%;text-align: right;">
                      <el-tag v-if="item.deviceStatus === 'AUTO'" type="success" size="small">自动</el-tag>
                      <el-tag v-else-if="item.deviceStatus === 'WORKING'" size="small">作业中</el-tag>
                      <el-tag v-else-if="item.deviceStatus === 'ERROR'" type="danger" size="small">报警</el-tag>
                      <el-tag v-else type="warning" size="small">离线</el-tag>
                   </div>
                </div>
            </template>
            <el-descriptions border direction="vertical">
                <el-descriptions-item label="编号">{{ item.rgvNo }}</el-descriptions-item>
                <el-descriptions-item label="工作号">{{ item.taskNo }}</el-descriptions-item>
                <el-descriptions-item label="模式">{{ item.mode }}</el-descriptions-item>
                <el-descriptions-item label="状态">{{ item.status }}</el-descriptions-item>
                <el-descriptions-item label="轨道位">{{ item.trackSiteNo }}</el-descriptions-item>
                <el-descriptions-item label="是否有物">{{ item.loading }}</el-descriptions-item>
                <el-descriptions-item label="故障代码">{{ item.warnCode }}</el-descriptions-item>
                <el-descriptions-item label="故障描述">{{ item.alarm }}</el-descriptions-item>
                <el-descriptions-item label="扩展数据">{{ item.extend }}</el-descriptions-item>
            </el-descriptions>
            </el-collapse-item>
          </el-collapse>
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:8px;">
          <el-pagination
            @current-change="handlePageChange"
            @size-change="handleSizeChange"
            :current-page="currentPage"
            :page-size="pageSize"
            :page-sizes="[10,20,50,100]"
            layout="total, prev, pager, next"
            :total="rgvList.length">
          </el-pagination>
        </div>
        <div v-if="displayRgvList.length === 0" class="mc-empty">当前没有可展示的RGV数据</div>
      </div>
      <div class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
      </div>
    </div>
    `,
  `,
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
    param: { type: Object, default: function () { return {}; } },
    items: { type: Array, default: null },
    autoRefresh: { type: Boolean, default: true },
    readOnly: { type: Boolean, default: false }
  },
  data() {
  data: function () {
    return {
      rgvList: [],
      activeNames: "",
@@ -88,155 +91,158 @@
        sourcePos: "",
        targetPos: ""
      },
      pageSize: 25,
      pageSize: 12,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getRgvStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayRgvList() {
      const start = (this.currentPage - 1) * this.pageSize;
      const end = start + this.pageSize;
      return this.rgvList.slice(start, end);
    sourceList: function () {
      return Array.isArray(this.items) ? this.items : this.rgvList;
    },
    filteredRgvList: function () {
      var keyword = String(this.searchRgvNo || "").trim();
      if (!keyword) {
        return this.sourceList;
      }
      return this.sourceList.filter(function (item) {
        return String(item.rgvNo) === keyword;
      });
    },
    displayRgvList: function () {
      var start = (this.currentPage - 1) * this.pageSize;
      return this.filteredRgvList.slice(start, start + this.pageSize);
    },
    totalPages: function () {
      return Math.max(1, Math.ceil(this.filteredRgvList.length / this.pageSize) || 1);
    }
  },
  watch: {
    param: {
      handler(newVal) {
        if (newVal && newVal.rgvNo && newVal.rgvNo != 0) {
          this.activeNames = newVal.rgvNo;
          this.searchRgvNo = newVal.rgvNo;
          const idx = this.rgvList.findIndex(i => i.rgvNo == newVal.rgvNo);
          if (idx >= 0) { this.currentPage = Math.floor(idx / this.pageSize) + 1; }
        }
      },
      deep: true,
      immediate: true
    items: function () {
      this.afterDataRefresh();
    },
    param: {
      deep: true,
      immediate: true,
      handler: function (newVal) {
        if (newVal && newVal.rgvNo && newVal.rgvNo !== 0) {
          this.focusRgv(newVal.rgvNo);
        }
      }
    }
  },
  created: function () {
    MonitorCardKit.ensureStyles();
    if (this.autoRefresh) {
      this.timer = setInterval(this.getRgvStateInfo, 1000);
    }
  },
  beforeDestroy: function () {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  methods: {
    handlePageChange(page) {
    orDash: function (value) {
      return MonitorCardKit.orDash(value);
    },
    getStatusLabel: function (item) {
      return MonitorCardKit.deviceStatusLabel(item && item.deviceStatus);
    },
    getStatusTone: function (item) {
      return MonitorCardKit.statusTone(this.getStatusLabel(item));
    },
    isActive: function (rgvNo) {
      return String(this.activeNames) === String(rgvNo);
    },
    toggleItem: function (item) {
      var next = String(item.rgvNo);
      this.activeNames = this.activeNames === next ? "" : next;
    },
    focusRgv: function (rgvNo) {
      this.searchRgvNo = String(rgvNo);
      var index = this.filteredRgvList.findIndex(function (item) {
        return String(item.rgvNo) === String(rgvNo);
      });
      this.currentPage = index >= 0 ? Math.floor(index / this.pageSize) + 1 : 1;
      this.activeNames = String(rgvNo);
    },
    afterDataRefresh: function () {
      if (this.currentPage > this.totalPages) {
        this.currentPage = this.totalPages;
      }
      if (this.activeNames) {
        var exists = this.filteredRgvList.some(function (item) {
          return String(item.rgvNo) === String(this.activeNames);
        }, this);
        if (!exists) {
          this.activeNames = "";
        }
      }
    },
    handlePageChange: function (page) {
      if (page < 1 || page > this.totalPages) {
        return;
      }
      this.currentPage = page;
    },
    handleSizeChange(size) {
      this.pageSize = size;
      this.currentPage = 1;
    },
    getRgvStateInfo() {
      if (this.$root.sendWs) {
    getRgvStateInfo: function () {
      if (this.$root && this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          "url": "/rgv/table/rgv/state",
          "data": {}
          url: "/rgv/table/rgv/state",
          data: {}
        }));
      }
    },
    setRgvList(res) {
      let that = this;
      if (res.code == 200) {
        let list = res.data || [];
        if (that.searchRgvNo == "") {
          that.rgvList = list;
        } else {
          let tmp = [];
          list.forEach((item) => {
            if (item.rgvNo == that.searchRgvNo) {
              tmp.push(item);
            }
          });
          that.rgvList = tmp;
          that.currentPage = 1;
        }
    setRgvList: function (res) {
      if (res && res.code === 200) {
        this.rgvList = res.data || [];
        this.afterDataRefresh();
      }
    },
    openControl() {
    openControl: function () {
      this.showControl = !this.showControl;
    },
    controlCommandTransport() {
      let that = this;
    buildDetailEntries: function (item) {
      return [
        { label: "编号", value: this.orDash(item.rgvNo) },
        { label: "工作号", value: this.orDash(item.taskNo) },
        { label: "模式", value: this.orDash(item.mode) },
        { label: "状态", value: this.orDash(item.status) },
        { label: "轨道位", value: this.orDash(item.trackSiteNo) },
        { label: "是否有物", value: MonitorCardKit.yesNo(item.loading) },
        { label: "故障代码", value: this.orDash(item.warnCode) },
        { label: "故障描述", value: this.orDash(item.alarm) },
        { label: "扩展数据", value: this.orDash(item.extend) }
      ];
    },
    postControl: function (url, payload) {
      $.ajax({
        url: baseUrl + "/rgv/command/transport",
        url: baseUrl + url,
        headers: {
          token: localStorage.getItem("token"),
          token: localStorage.getItem("token")
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
        data: JSON.stringify(payload),
        success: function (res) {
          if (res && res.code === 200) {
            MonitorCardKit.showMessage(this, res.msg || "操作成功", "success");
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
            MonitorCardKit.showMessage(this, (res && res.msg) || "操作失败", "warning");
          }
        },
        }.bind(this)
      });
    },
    controlCommandMove() {
      let that = this;
      $.ajax({
        url: baseUrl + "/rgv/command/move",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandTransport: function () {
      this.postControl("/rgv/command/transport", this.controlParam);
    },
    controlCommandTaskComplete() {
      let that = this;
      $.ajax({
        url: baseUrl + "/rgv/command/taskComplete",
        headers: {
          token: localStorage.getItem("token"),
        },
        contentType: "application/json",
        method: "post",
        data: JSON.stringify(that.controlParam),
        success: (res) => {
          if (res.code == 200) {
            that.$message({
              message: res.msg,
              type: "success",
            });
          } else {
            that.$message({
              message: res.msg,
              type: "warning",
            });
          }
        },
      });
    controlCommandMove: function () {
      this.postControl("/rgv/command/move", this.controlParam);
    },
  },
    controlCommandTaskComplete: function () {
      this.postControl("/rgv/command/taskComplete", this.controlParam);
    }
  }
});
src/main/webapp/static/js/watch/stationColorConfig.js
New file
@@ -0,0 +1,127 @@
var app = new Vue({
    el: '#app',
    data: {
        loading: false,
        saving: false,
        items: [],
        predefineColors: [
            '#78FF81',
            '#FA51F6',
            '#C4C400',
            '#30BFFC',
            '#18C7B8',
            '#97B400',
            '#E69138',
            '#B8B8B8',
            '#FF6B6B',
            '#FFD166',
            '#06D6A0',
            '#118AB2'
        ]
    },
    mounted: function () {
        this.reloadData();
    },
    methods: {
        reloadData: function () {
            var that = this;
            this.loading = true;
            $.ajax({
                url: baseUrl + '/watch/stationColor/config/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                success: function (res) {
                    that.loading = false;
                    if (res.code === 200) {
                        var items = (res.data && res.data.items) ? res.data.items : [];
                        that.items = items.map(function (item) {
                            return {
                                status: item.status,
                                name: item.name,
                                desc: item.desc,
                                color: that.normalizeColor(item.color || item.defaultColor),
                                defaultColor: that.normalizeColor(item.defaultColor)
                            };
                        });
                    } else if (res.code === 403) {
                        top.location.href = baseUrl + '/';
                    } else {
                        that.$message.error(res.msg || '加载站点颜色配置失败');
                    }
                },
                error: function () {
                    that.loading = false;
                    that.$message.error('加载站点颜色配置失败');
                }
            });
        },
        resetDefaults: function () {
            this.items = this.items.map(function (item) {
                return Object.assign({}, item, {
                    color: item.defaultColor
                });
            });
            this.$message.success('已恢复默认颜色');
        },
        applyDefaultColor: function (item) {
            item.color = item.defaultColor;
        },
        handleColorInput: function (item) {
            var normalized = this.normalizeColor(item.color);
            if (normalized !== String(item.color || '').trim().toUpperCase()) {
                this.$message.warning('颜色格式已自动修正为十六进制');
            }
            item.color = normalized;
        },
        saveConfig: function () {
            var that = this;
            this.saving = true;
            $.ajax({
                url: baseUrl + '/watch/stationColor/config/save/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'POST',
                contentType: 'application/json;charset=UTF-8',
                dataType: 'json',
                data: JSON.stringify({
                    items: this.items.map(function (item) {
                        return {
                            status: item.status,
                            color: that.normalizeColor(item.color)
                        };
                    })
                }),
                success: function (res) {
                    that.saving = false;
                    if (res.code === 200) {
                        that.$message.success('站点颜色配置已保存');
                        that.reloadData();
                    } else if (res.code === 403) {
                        top.location.href = baseUrl + '/';
                    } else {
                        that.$message.error(res.msg || '保存站点颜色配置失败');
                    }
                },
                error: function () {
                    that.saving = false;
                    that.$message.error('保存站点颜色配置失败');
                }
            });
        },
        normalizeColor: function (color) {
            var value = String(color || '').trim();
            if (!value) {
                return '#B8B8B8';
            }
            if (/^#[0-9a-fA-F]{6}$/.test(value)) {
                return value.toUpperCase();
            }
            if (/^#[0-9a-fA-F]{3}$/.test(value)) {
                return ('#' + value.charAt(1) + value.charAt(1) + value.charAt(2) + value.charAt(2) + value.charAt(3) + value.charAt(3)).toUpperCase();
            }
            if (/^0x[0-9a-fA-F]{6}$/.test(value)) {
                return ('#' + value.substring(2)).toUpperCase();
            }
            return '#B8B8B8';
        }
    }
});
src/main/webapp/views/config/config.html
@@ -67,4 +67,4 @@
</body>
</html>
</html>
src/main/webapp/views/deviceLogs/deviceLogs.html
@@ -206,10 +206,11 @@
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script src="../../static/vue/js/vue.min.js"></script>
<script src="../../static/vue/element/element.js"></script>
<script src="../../components/MonitorCardKit.js"></script>
<script src="../../components/WatchCrnCard.js"></script>
<script src="../../components/WatchRgvCard.js"></script>
<script src="../../components/WatchDualCrnCard.js"></script>
<script src="../../components/DevpCard.js"></script>
<script type="text/javascript" src="../../static/js/deviceLogs/deviceLogs.js" charset="utf-8"></script>
</body>
</html>
</html>
src/main/webapp/views/watch/console.html
@@ -4,8 +4,8 @@
                <meta charset="UTF-8">
                <title>WCS控制中心</title>
                <link rel="stylesheet" href="../../static/css/animate.min.css">
                <link rel="stylesheet" href="../../static/vue/element/element.css">
                <link rel="stylesheet" href="../../static/css/watch/console_vue.css">
                <link rel="stylesheet" href="../../static/vue/element/element.css">
                <style>
                    html, body, #app {
                        width: 100%;
@@ -13,11 +13,503 @@
                        margin: 0;
                        overflow: hidden;
                    }
                    body {
                        background: linear-gradient(180deg, #eef4f8 0%, #e7edf4 100%);
                    }
                    #app {
                        position: relative;
                    }
                    .monitor-shell {
                        position: relative;
                        width: 100%;
                        height: 100%;
                        overflow: hidden;
                    }
                    .monitor-map {
                        width: 100%;
                        height: 100%;
                    }
                    .monitor-panel-wrap {
                        position: absolute;
                        top: 18px;
                        left: 18px;
                        bottom: 18px;
                        z-index: 40;
                        pointer-events: none;
                    }
                    .monitor-panel {
                        width: min(max(360px, 30vw), calc(100vw - 92px));
                        max-width: calc(100vw - 92px);
                        height: calc(100vh - 36px);
                        display: flex;
                        flex-direction: column;
                        border-radius: 20px;
                        border: 1px solid rgba(255, 255, 255, 0.42);
                        background: rgba(248, 251, 253, 0.94);
                        box-shadow: 0 10px 24px rgba(88, 110, 136, 0.08);
                        overflow: hidden;
                        pointer-events: auto;
                        transform-origin: left center;
                        will-change: transform, opacity;
                        backface-visibility: hidden;
                        contain: layout paint style;
                        transition: transform .26s cubic-bezier(0.22, 1, 0.36, 1), opacity .2s ease, box-shadow .2s ease, border-color .2s ease;
                    }
                    .monitor-panel.is-collapsed {
                        opacity: 0;
                        transform: translate3d(calc(-100% - 14px), 0, 0);
                        border-color: transparent;
                        box-shadow: 0 6px 16px rgba(88, 110, 136, 0.02);
                        pointer-events: none;
                    }
                    .monitor-panel-header {
                        padding: 14px 16px 10px;
                        border-bottom: 1px solid rgba(226, 232, 240, 0.72);
                        background: rgba(255, 255, 255, 0.24);
                    }
                    .monitor-panel-title {
                        font-size: 15px;
                        font-weight: 600;
                        color: #243447;
                        line-height: 1.2;
                    }
                    .monitor-panel-desc {
                        margin-top: 4px;
                        font-size: 12px;
                        color: #6b7b8d;
                    }
                    .monitor-panel-body {
                        flex: 1;
                        min-height: 0;
                        padding: 12px;
                        overflow: hidden;
                    }
                    .monitor-panel-body {
                        flex: 1;
                        display: flex;
                        flex-direction: column;
                        gap: 10px;
                    }
                    .monitor-card-host {
                        flex: 1;
                        min-height: 0;
                        display: flex;
                        align-items: stretch;
                        width: 100%;
                    }
                    .wb-root {
                        position: relative;
                        flex: 1;
                        min-width: 0;
                        display: flex;
                        flex-direction: column;
                        height: 100%;
                        gap: 10px;
                        color: #395066;
                    }
                    .wb-main {
                        flex: 1;
                        min-height: 0;
                        display: flex;
                        gap: 10px;
                    }
                    .wb-side {
                        flex: 0 0 38%;
                        min-width: 220px;
                        max-width: 44%;
                        display: flex;
                        flex-direction: column;
                        gap: 10px;
                        min-height: 0;
                    }
                    .wb-side-title {
                        font-size: 11px;
                        font-weight: 700;
                        letter-spacing: 0.06em;
                        text-transform: uppercase;
                        color: #7d8fa2;
                        margin-bottom: 8px;
                    }
                    .wb-list-card,
                    .wb-detail-panel {
                        min-height: 0;
                        display: flex;
                        flex-direction: column;
                    }
                    .wb-detail-panel {
                        flex: 1 1 62%;
                        min-width: 0;
                    }
                    .wb-tabs {
                        display: grid;
                        grid-template-columns: repeat(4, 1fr);
                        gap: 6px;
                        padding: 6px;
                        border-radius: 14px;
                        background: rgba(242, 246, 250, 0.78);
                        border: 1px solid rgba(224, 232, 239, 0.9);
                    }
                    .wb-tab {
                        height: 34px;
                        border: none;
                        border-radius: 10px;
                        background: transparent;
                        color: #73869b;
                        font-size: 12px;
                        font-weight: 600;
                        cursor: pointer;
                        transition: all .16s ease;
                    }
                    .wb-tab.is-active {
                        background: rgba(255, 255, 255, 0.92);
                        color: #24405c;
                        box-shadow: 0 8px 16px rgba(148, 163, 184, 0.08);
                    }
                    .wb-toolbar {
                        display: flex;
                        align-items: center;
                        gap: 8px;
                    }
                    .wb-toolbar-actions {
                        display: flex;
                        gap: 8px;
                        flex-shrink: 0;
                    }
                    .wb-input,
                    .wb-select {
                        width: 100%;
                        height: 36px;
                        padding: 0 12px;
                        border-radius: 10px;
                        border: 1px solid rgba(224, 232, 239, 0.96);
                        background: rgba(255, 255, 255, 0.76);
                        color: #334155;
                        box-sizing: border-box;
                        outline: none;
                        transition: border-color .16s ease, box-shadow .16s ease, background .16s ease;
                    }
                    .wb-input:focus,
                    .wb-select:focus {
                        border-color: rgba(128, 168, 208, 0.66);
                        box-shadow: 0 0 0 3px rgba(128, 168, 208, 0.1);
                        background: rgba(255, 255, 255, 0.92);
                    }
                    .wb-btn {
                        height: 36px;
                        padding: 0 14px;
                        border: none;
                        border-radius: 10px;
                        background: #6f95bd;
                        color: #fff;
                        font-size: 12px;
                        font-weight: 600;
                        cursor: pointer;
                        box-shadow: 0 6px 14px rgba(111, 149, 189, 0.18);
                        transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease, background .16s ease;
                    }
                    .wb-btn:hover {
                        transform: translateY(-1px);
                    }
                    .wb-btn-primary {
                        background: linear-gradient(135deg, #5e89b4 0%, #6f95bd 100%);
                        box-shadow: 0 10px 20px rgba(111, 149, 189, 0.22);
                    }
                    .wb-btn.wb-btn-ghost {
                        background: rgba(255, 255, 255, 0.76);
                        color: #4c6177;
                        border: 1px solid rgba(224, 232, 239, 0.96);
                        box-shadow: none;
                    }
                    .wb-btn.wb-btn-soft {
                        background: rgba(230, 237, 244, 0.92);
                        color: #4c6177;
                        border: 1px solid rgba(210, 221, 232, 0.98);
                        box-shadow: none;
                    }
                    .wb-control-card,
                    .wb-list-card,
                    .wb-detail {
                        border-radius: 16px;
                        border: 1px solid rgba(224, 232, 239, 0.92);
                        background: rgba(255, 255, 255, 0.62);
                        box-shadow: 0 8px 18px rgba(148, 163, 184, 0.06);
                    }
                    .wb-list-card {
                        flex: 1;
                        padding: 10px 8px 8px;
                    }
                    .wb-control-card {
                        padding: 14px;
                        background: linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(246, 250, 253, 0.78) 100%);
                        overflow: auto;
                    }
                    .wb-control-subtitle {
                        margin-top: 8px;
                        margin-bottom: 10px;
                        font-size: 11px;
                        line-height: 1.45;
                        color: #6f8194;
                    }
                    .wb-control-target {
                        padding: 7px 9px;
                        border-radius: 12px;
                        background: rgba(234, 241, 247, 0.96);
                        border: 1px solid rgba(214, 224, 234, 0.96);
                        color: #43607c;
                        font-size: 11px;
                        font-weight: 700;
                        line-height: 1.4;
                    }
                    .wb-form-grid {
                        display: grid;
                        grid-template-columns: 1fr;
                        gap: 8px;
                    }
                    .wb-field {
                        display: flex;
                        flex-direction: column;
                        gap: 5px;
                    }
                    .wb-field-label {
                        font-size: 11px;
                        font-weight: 700;
                        letter-spacing: 0.02em;
                        color: #6d8197;
                    }
                    .wb-action-row {
                        display: flex;
                        flex-wrap: wrap;
                        gap: 8px;
                        margin-top: 2px;
                        padding-top: 8px;
                        border-top: 1px dashed rgba(216, 226, 235, 0.92);
                    }
                    .wb-section-title {
                        font-size: 13px;
                        font-weight: 700;
                        color: #28425d;
                        margin-bottom: 10px;
                    }
                    .wb-list {
                        flex: 1;
                        min-height: 0;
                        padding: 6px;
                        overflow: auto;
                    }
                    .wb-list-item {
                        width: 100%;
                        display: flex;
                        flex-direction: column;
                        align-items: flex-start;
                        justify-content: flex-start;
                        gap: 7px;
                        padding: 10px;
                        margin-bottom: 8px;
                        border: 1px solid transparent;
                        border-radius: 12px;
                        background: rgba(248, 250, 252, 0.72);
                        cursor: pointer;
                        text-align: left;
                        color: inherit;
                    }
                    .wb-list-item:last-child {
                        margin-bottom: 0;
                    }
                    .wb-list-item.is-active {
                        border-color: rgba(135, 166, 198, 0.38);
                        background: rgba(236, 243, 249, 0.94);
                    }
                    .wb-list-main {
                        min-width: 0;
                    }
                    .wb-list-title {
                        font-size: 12px;
                        font-weight: 700;
                        color: #27425c;
                        line-height: 1.35;
                    }
                    .wb-list-meta {
                        margin-top: 4px;
                        font-size: 11px;
                        color: #7b8b9c;
                        line-height: 1.4;
                        display: -webkit-box;
                        -webkit-line-clamp: 2;
                        -webkit-box-orient: vertical;
                        overflow: hidden;
                    }
                    .wb-badge {
                        flex-shrink: 0;
                        padding: 3px 8px;
                        border-radius: 999px;
                        font-size: 10px;
                        font-weight: 700;
                    }
                    .wb-badge.is-success {
                        background: rgba(82, 177, 126, 0.12);
                        color: #2d7650;
                    }
                    .wb-badge.is-working {
                        background: rgba(111, 149, 189, 0.12);
                        color: #3f6286;
                    }
                    .wb-badge.is-warning {
                        background: rgba(214, 162, 94, 0.14);
                        color: #9b6a24;
                    }
                    .wb-badge.is-danger {
                        background: rgba(207, 126, 120, 0.14);
                        color: #a14e4a;
                    }
                    .wb-badge.is-muted {
                        background: rgba(148, 163, 184, 0.14);
                        color: #748397;
                    }
                    .wb-empty {
                        padding: 28px 12px;
                        text-align: center;
                        color: #8b9aad;
                        font-size: 12px;
                    }
                    .wb-detail {
                        flex: 1;
                        min-height: 0;
                        padding: 12px;
                        overflow: auto;
                    }
                    .wb-detail-empty {
                        display: flex;
                        align-items: center;
                        justify-content: center;
                    }
                    .wb-detail-header {
                        display: flex;
                        align-items: flex-start;
                        justify-content: space-between;
                        gap: 10px;
                        margin-bottom: 12px;
                    }
                    .wb-detail-subtitle {
                        margin-top: 4px;
                        font-size: 12px;
                        color: #7c8c9d;
                    }
                    .wb-detail-actions {
                        display: flex;
                        flex-wrap: wrap;
                        gap: 8px;
                    }
                    .wb-link {
                        padding: 0;
                        border: none;
                        background: transparent;
                        color: #4677a4;
                        font-size: 12px;
                        cursor: pointer;
                    }
                    .wb-detail-grid {
                        display: grid;
                        grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
                        gap: 8px;
                    }
                    .wb-detail-cell {
                        padding: 10px 12px;
                        border-radius: 12px;
                        background: rgba(247, 250, 252, 0.86);
                        border: 1px solid rgba(233, 239, 244, 0.96);
                    }
                    .wb-detail-label {
                        font-size: 11px;
                        color: #8090a2;
                    }
                    .wb-detail-value {
                        margin-top: 5px;
                        font-size: 13px;
                        color: #31485f;
                        word-break: break-all;
                    }
                    .wb-notice {
                        position: absolute;
                        right: 18px;
                        bottom: 18px;
                        padding: 10px 14px;
                        border-radius: 12px;
                        font-size: 12px;
                        font-weight: 600;
                        color: #fff;
                        box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
                    }
                    .wb-notice.is-success {
                        background: rgba(82, 177, 126, 0.92);
                    }
                    .wb-notice.is-warning {
                        background: rgba(214, 162, 94, 0.92);
                    }
                    .wb-notice.is-danger {
                        background: rgba(207, 126, 120, 0.92);
                    }
                    .floor-switch-button {
                        min-width: 44px;
                        height: 30px;
                        padding: 0 12px;
                        border: 1px solid rgba(185, 197, 210, 0.84);
                        border-radius: 999px;
                        background: rgba(255, 255, 255, 0.82);
                        color: #4b6177;
                        font-size: 12px;
                        font-weight: 700;
                        cursor: pointer;
                    }
                    .floor-switch-button.is-active {
                        border-color: rgba(111, 149, 189, 0.4);
                        background: rgba(236, 243, 249, 0.94);
                        color: #27425c;
                    }
                    .monitor-panel-toggle {
                        position: absolute;
                        left: 0;
                        top: 50%;
                        margin-left: 0;
                        transform: translateY(-50%);
                        width: 30px;
                        min-height: 108px;
                        padding: 10px 4px;
                        border: 1px solid rgba(148, 163, 184, 0.22);
                        border-left: none;
                        border-radius: 0 14px 14px 0;
                        background: rgba(255, 255, 255, 0.96);
                        color: #3e5974;
                        box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
                        cursor: pointer;
                        pointer-events: auto;
                        display: inline-flex;
                        flex-direction: column;
                        align-items: center;
                        justify-content: center;
                        gap: 10px;
                        font-size: 12px;
                        line-height: 1;
                        white-space: nowrap;
                        backface-visibility: hidden;
                        transition: left .26s cubic-bezier(0.22, 1, 0.36, 1), transform .26s cubic-bezier(0.22, 1, 0.36, 1), box-shadow .18s ease, background .18s ease;
                    }
                    .monitor-panel-toggle.is-panel-open {
                        left: calc(100% + 10px);
                    }
                    .monitor-panel-toggle i {
                        font-size: 14px;
                    }
                    .monitor-panel-toggle span {
                        writing-mode: vertical-rl;
                        text-orientation: mixed;
                        letter-spacing: 0.08em;
                        user-select: none;
                    }
                </style>
                <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
                <script type="text/javascript" src="../../static/layui/layui.js"></script>
                <script type="text/javascript" src="../../static/js/handlebars/handlebars-v4.5.3.js"></script>
                <script type="text/javascript" src="../../static/js/common.js"></script>
            <script type="text/javascript" src="../../static/js/common.js"></script>
            <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
            <script type="text/javascript" src="../../static/vue/element/element.js"></script>
            <script src="../../static/js/gsap.min.js"></script>
@@ -25,42 +517,43 @@
        </head>
    <body>
        <div id="app">
            <div style="display: flex;margin-left: 20px;">
                <div style="width: 20%;height: 60vh;margin-right: 20px;margin-top: 30px;">
                    <el-tabs type="border-card" v-model="activateCard" @tab-click="handleCardClick">
                        <el-tab-pane label="堆垛机" name="crn">
                            <watch-crn-card ref="watchCrnCard" :param="crnParam"></watch-crn-card>
                        </el-tab-pane>
                        <el-tab-pane label="双工位堆垛机" name="dualCrn">
                            <watch-dual-crn-card ref="watchDualCrnCard" :param="dualCrnParam"></watch-dual-crn-card>
                        </el-tab-pane>
                        <el-tab-pane label="输送站" name="devp">
                            <devp-card ref="devpCard" :param="devpParam"></devp-card>
                        </el-tab-pane>
                        <el-tab-pane label="RGV" name="rgv">
                            <watch-rgv-card ref="watchRgvCard" :param="rgvParam"></watch-rgv-card>
                        </el-tab-pane>
                        <!-- <el-tab-pane label="地图配置" name="mapSetting">
                            <map-setting-card :param="mapSettingParam"></map-setting-card>
                        </el-tab-pane> -->
                    </el-tabs>
                </div>
            <div class="monitor-shell" ref="monitorShell">
                <map-canvas :lev="currentLev" :lev-list="levList" :crn-param="crnParam" :rgv-param="rgvParam" :devp-param="devpParam" :station-task-range="stationTaskRange" :viewport-padding="mapViewportPadding" :hud-padding="mapHudPadding" @switch-lev="switchLev" @crn-click="openCrn" @dual-crn-click="openDualCrn" @station-click="openSite" @rgv-click="openRgv" class="monitor-map"></map-canvas>
                <map-canvas :lev="currentLev" :crn-param="crnParam" :rgv-param="rgvParam" :devp-param="devpParam" @crn-click="openCrn" @dual-crn-click="openDualCrn" @station-click="openSite" @rgv-click="openRgv" style="width: 80%; height: 100vh;"></map-canvas>
                <div style="position: absolute;top: 15px;left: 50%;display: flex;">
                    <div v-if="levList.length > 1" v-for="(lev,index) in levList" :key="index" style="margin-right: 10px;">
                        <el-button :type="currentLev == lev ? 'primary' : ''" @click="switchLev(lev)" size="mini">{{ lev }}F</el-button>
                <div class="monitor-panel-wrap" ref="monitorPanelWrap">
                    <div class="monitor-panel" ref="monitorPanel" :class="{ 'is-collapsed': panelCollapsed }">
                        <div class="monitor-panel-header">
                            <div class="monitor-panel-title">监控工作台</div>
                            <div class="monitor-panel-desc">围绕地图做操作,设备点击后自动切换到对应面板</div>
                        </div>
                        <div class="monitor-panel-body">
                            <div class="wb-tabs" role="tablist">
                                <button type="button" :class="['wb-tab', { 'is-active': activateCard === 'crn' }]" @click="handleWorkbenchTabChange('crn')">堆垛机</button>
                                <button type="button" :class="['wb-tab', { 'is-active': activateCard === 'dualCrn' }]" @click="handleWorkbenchTabChange('dualCrn')">双工位</button>
                                <button type="button" :class="['wb-tab', { 'is-active': activateCard === 'devp' }]" @click="handleWorkbenchTabChange('devp')">输送站</button>
                                <button type="button" :class="['wb-tab', { 'is-active': activateCard === 'rgv' }]" @click="handleWorkbenchTabChange('rgv')">RGV</button>
                            </div>
                            <div class="monitor-card-host">
                                <watch-crn-card v-if="activateCard === 'crn'" ref="watchCrnCard" :param="crnParam" :items="crnStateList" :auto-refresh="false"></watch-crn-card>
                                <watch-dual-crn-card v-else-if="activateCard === 'dualCrn'" ref="watchDualCrnCard" :param="dualCrnParam" :items="dualCrnStateList" :auto-refresh="false"></watch-dual-crn-card>
                                <devp-card v-else-if="activateCard === 'devp'" ref="devpCard" :param="devpParam" :items="stationStateList" :auto-refresh="false"></devp-card>
                                <watch-rgv-card v-else ref="watchRgvCard" :param="rgvParam" :items="rgvStateList" :auto-refresh="false"></watch-rgv-card>
                            </div>
                        </div>
                    </div>
                    <button class="monitor-panel-toggle" :class="{ 'is-panel-open': !panelCollapsed }" ref="monitorToggle" @click="toggleMonitorPanel">
                        <i>{{ panelCollapsed ? '>' : '<' }}</i>
                        <span>{{ panelCollapsed ? '展开面板' : '收起面板' }}</span>
                    </button>
                </div>
            </div>
        </div>
        <script src="../../components/MonitorCardKit.js"></script>
        <script src="../../components/WatchCrnCard.js"></script>
        <script src="../../components/WatchDualCrnCard.js"></script>
        <script src="../../components/DevpCard.js"></script>
        <script src="../../components/MapSettingCard.js"></script>
        <script src="../../components/WatchRgvCard.js"></script>
        <script src="../../components/MapCanvas.js"></script>
        <script>
@@ -74,6 +567,22 @@
                    systemStatus: true,//系统运行状态
                    consoleInterval: null,//定时器存储变量
                    rgvPosition: [],
                    panelCollapsed: false,
                    mapViewportPadding: {
                        top: 0,
                        right: 0,
                        bottom: 0,
                        left: 0
                    },
                    mapHudPadding: {
                        left: 14
                    },
                    stationTaskRange: {
                        inbound: null,
                        outbound: null
                    },
                    panelTransitionTimer: null,
                    panelPollTimer: null,
                    activateCard: 'crn',
                    crnParam: {
                        crnNo: 0
@@ -81,15 +590,16 @@
                    dualCrnParam: {
                        crnNo: 0
                    },
                    mapSettingParam: {
                        zoom: 70
                    },
                    devpParam: {
                        stationId: 0
                    },
                    rgvParam: {
                        rgvNo: 0
                    },
                    crnStateList: [],
                    dualCrnStateList: [],
                    stationStateList: [],
                    rgvStateList: [],
                    locMastData: [],//库位数据
                    wsReconnectTimer: null,
                    wsReconnectAttempts: 0,
@@ -100,10 +610,20 @@
                    this.init()
                },
                mounted() {
                    this.$nextTick(() => {
                        this.updateMapViewportPadding();
                    });
                    window.addEventListener('resize', this.updateMapViewportPadding);
                    this.panelPollTimer = setInterval(() => {
                        this.refreshWorkbench(this.activateCard);
                    }, 1000);
                },
                beforeDestroy() {
                    if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
                    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { try { ws.close(); } catch (e) {} }
                    window.removeEventListener('resize', this.updateMapViewportPadding);
                    if (this.panelTransitionTimer) { clearTimeout(this.panelTransitionTimer); this.panelTransitionTimer = null; }
                    if (this.panelPollTimer) { clearInterval(this.panelPollTimer); this.panelPollTimer = null; }
                },
                watch: {
@@ -114,12 +634,13 @@
                            ws.send(data);
                        }
                    },
                    webSocketOnOpen() {
                        console.log("WebSocket连接成功");
                        if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
                        this.wsReconnectAttempts = 0;
                        this.getMap();
                    },
                        webSocketOnOpen() {
                            console.log("WebSocket连接成功");
                            if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
                            this.wsReconnectAttempts = 0;
                            this.getMap();
                            this.refreshWorkbench(this.activateCard);
                        },
                    webSocketOnError() {
                        console.log("WebSocket连接发生错误");
                        this.scheduleWsReconnect();
@@ -128,27 +649,23 @@
                        console.log("WebSocket连接关闭");
                        this.scheduleWsReconnect();
                    },
                    webSocketOnMessage(e) {
                        const result = JSON.parse(e.data);
                        if (result.url == "/crn/table/crn/state") {
                             if(this.$refs.watchCrnCard) {
                                 this.$refs.watchCrnCard.setCrnList(JSON.parse(result.data));
                             }
                        } else if (result.url == "/dualcrn/table/crn/state") {
                             if(this.$refs.watchDualCrnCard) {
                                 this.$refs.watchDualCrnCard.setDualCrnList(JSON.parse(result.data));
                             }
                        } else if (result.url == "/console/latest/data/station") {
                             if(this.$refs.devpCard) {
                                 this.$refs.devpCard.setStationList(JSON.parse(result.data));
                             }
                        } else if (result.url == "/rgv/table/rgv/state") {
                             if(this.$refs.watchRgvCard) {
                                 this.$refs.watchRgvCard.setRgvList(JSON.parse(result.data));
                             }
                        } else if (result.url == "/basMap/lev/" + this.currentLev + "/auth") {
                            // 地图数据
                             let res = JSON.parse(result.data);
                        webSocketOnMessage(e) {
                            const result = JSON.parse(e.data);
                            if (result.url == "/crn/table/crn/state") {
                                 const res = JSON.parse(result.data);
                                 this.crnStateList = res && res.code === 200 ? (res.data || []) : [];
                            } else if (result.url == "/dualcrn/table/crn/state") {
                                 const res = JSON.parse(result.data);
                                 this.dualCrnStateList = res && res.code === 200 ? (res.data || []) : [];
                            } else if (result.url == "/console/latest/data/station") {
                                 const res = JSON.parse(result.data);
                                 this.stationStateList = res && res.code === 200 ? (res.data || []) : [];
                            } else if (result.url == "/rgv/table/rgv/state") {
                                 const res = JSON.parse(result.data);
                                 this.rgvStateList = res && res.code === 200 ? (res.data || []) : [];
                            } else if (result.url == "/basMap/lev/" + this.currentLev + "/auth") {
                                // 地图数据
                                 let res = JSON.parse(result.data);
                             if (res.code === 200) {
                                 this.map = res.data;
                             }
@@ -165,7 +682,31 @@
                        this.getSystemRunningStatus() //获取系统运行状态
                        this.getLevList() //获取地图层级列表
                        this.getStationTaskRange() // 获取入库/出库工作号范围
                        this.getLocMastData() //获取库位数据
                    },
                    getStationTaskRange() {
                        this.fetchWrkLastnoRange(1, 'inbound');
                        this.fetchWrkLastnoRange(101, 'outbound');
                    },
                    fetchWrkLastnoRange(id, key) {
                        $.ajax({
                            url: baseUrl + "/wrkLastno/" + id + "/auth",
                            headers: {
                                'token': localStorage.getItem('token')
                            },
                            method: "get",
                            success: (res) => {
                                if (!res || res.code !== 200 || !res.data) { return; }
                                const data = res.data;
                                this.stationTaskRange = Object.assign({}, this.stationTaskRange, {
                                    [key]: {
                                        start: data.sNo,
                                        end: data.eNo
                                    }
                                });
                            }
                        });
                    },
                    connectWs() {
                        if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return; }
@@ -204,40 +745,138 @@
                        this.currentLev = lev;
                        this.getMap()
                        this.getLocMastData()
                        this.refreshWorkbench(this.activateCard)
                    },
                    handleWorkbenchTabChange(type) {
                        this.activateCard = type;
                        this.refreshWorkbench(type);
                    },
                    refreshWorkbench(type) {
                        if (!type) { return; }
                        if (type === 'crn') {
                            this.sendWs(JSON.stringify({ url: "/crn/table/crn/state", data: {} }));
                        } else if (type === 'dualCrn') {
                            this.sendWs(JSON.stringify({ url: "/dualcrn/table/crn/state", data: {} }));
                        } else if (type === 'devp') {
                            this.sendWs(JSON.stringify({ url: "/console/latest/data/station", data: {} }));
                        } else if (type === 'rgv') {
                            this.sendWs(JSON.stringify({ url: "/rgv/table/rgv/state", data: {} }));
                        }
                    },
                    updateMapViewportPadding() {
                        const shell = this.$refs.monitorShell;
                        const panelWrap = this.$refs.monitorPanelWrap;
                        if (!shell) { return; }
                        const shellRect = shell.getBoundingClientRect();
                        let leftPadding = 0;
                        let hudLeft = 14;
                        if (!this.panelCollapsed && this.$refs.monitorPanel && panelWrap) {
                            const wrapRect = panelWrap.getBoundingClientRect();
                            const panelWidth = this.$refs.monitorPanel.offsetWidth || this.$refs.monitorPanel.getBoundingClientRect().width || 0;
                            const panelLeft = Math.max(0, Math.ceil(wrapRect.left - shellRect.left));
                            const panelBaseRight = panelLeft + Math.ceil(panelWidth);
                            const overlapCompensation = Math.min(56, panelWidth * 0.18);
                            leftPadding = Math.max(0, Math.ceil(panelBaseRight - overlapCompensation));
                            hudLeft = Math.max(14, Math.ceil(panelBaseRight + 34));
                        } else {
                            leftPadding = 0;
                            hudLeft = 14;
                        }
                        this.mapViewportPadding = {
                            top: 0,
                            right: 0,
                            bottom: 0,
                            left: leftPadding
                        };
                        this.mapHudPadding = {
                            left: hudLeft
                        };
                    },
                    scheduleMapViewportPaddingUpdate(delay) {
                        if (this.panelTransitionTimer) {
                            clearTimeout(this.panelTransitionTimer);
                            this.panelTransitionTimer = null;
                        }
                        this.panelTransitionTimer = setTimeout(() => {
                            this.panelTransitionTimer = null;
                            this.updateMapViewportPadding();
                        }, delay == null ? 0 : delay);
                    },
                    toggleMonitorPanel() {
                        this.panelCollapsed = !this.panelCollapsed;
                        this.scheduleMapViewportPaddingUpdate(0);
                    },
                    openCrn(id) {
                        this.panelCollapsed = false;
                        this.crnParam.crnNo = id;
                        this.activateCard = 'crn';
                        this.scheduleMapViewportPaddingUpdate(0);
                        this.refreshWorkbench('crn');
                    },
                    openDualCrn(id) {
                        this.panelCollapsed = false;
                        this.dualCrnParam.crnNo = id;
                        this.activateCard = 'dualCrn';
                        this.scheduleMapViewportPaddingUpdate(0);
                        this.refreshWorkbench('dualCrn');
                    },
                    openRgv(id) {
                        this.panelCollapsed = false;
                        this.rgvParam.rgvNo = id;
                        this.activateCard = 'rgv';
                        this.scheduleMapViewportPaddingUpdate(0);
                        this.refreshWorkbench('rgv');
                    },
                    openSite(id) {
                        this.panelCollapsed = false;
                        this.devpParam.stationId = id;
                        this.activateCard = 'devp';
                        this.scheduleMapViewportPaddingUpdate(0);
                        this.refreshWorkbench('devp');
                    },
                    systemSwitch() {
                        // 系统开关
                        let that = this
                        if (this.systemStatus) {
                            this.$prompt('请输入口令,并停止WCS系统', '提示', {
                                confirmButtonText: '确定',
                                cancelButtonText: '取消',
                            }).then(({
                                value
                            }) => {
                                that.doSwitch(0, value)
                            }).catch(() => {
                            });
                            const password = window.prompt('请输入口令,并停止WCS系统', '');
                            if (password === null) {
                                return;
                            }
                            this.doSwitch(0, password);
                        } else {
                            this.doSwitch(1)
                        }
                    },
                    showPageMessage(message, type) {
                        if (!message) {
                            return;
                        }
                        if (typeof this.$message === 'function') {
                            this.$message({
                                message: message,
                                type: type || 'info'
                            });
                            return;
                        }
                        if (window.ELEMENT && typeof window.ELEMENT.Message === 'function') {
                            window.ELEMENT.Message({
                                message: message,
                                type: type || 'info'
                            });
                            return;
                        }
                        if (window.layer && typeof window.layer.msg === 'function') {
                            const iconMap = {
                                success: 1,
                                error: 2,
                                warning: 0
                            };
                            window.layer.msg(message, {
                                icon: iconMap[type] != null ? iconMap[type] : 0,
                                time: 1800
                            });
                            return;
                        }
                        console[type === 'error' ? 'error' : 'log'](message);
                    },
                    doSwitch(operatorType, password) {
                        let that = this
@@ -267,10 +906,7 @@
                                } else if (res.code === 403) {
                                    parent.location.href = baseUrl + "/login";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                    that.showPageMessage(res.msg, 'error');
                                }
                            }
                        });
@@ -300,10 +936,7 @@
                                } else if (res.code === 403) {
                                    parent.location.href = baseUrl + "/login";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                    that.showPageMessage(res.msg, 'error');
                                }
                            }
                        });
@@ -381,9 +1014,6 @@
                            return false;
                        }
                    },
                    handleCardClick(tab, event) {
                    },
                    //获取库位数据
                    getLocMastData() {
                        let that = this;
@@ -422,8 +1052,8 @@
                            return locInfo.row1 + '-' + locInfo.bay1;
                        }
                        return '';
                    },
                }
                        },
                    }
            })
            </script>
        </body>
src/main/webapp/views/watch/stationColorConfig.html
New file
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>站点颜色配置</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        html, body {
            height: 100%;
            margin: 0;
            background:
                radial-gradient(circle at top left, rgba(70, 136, 214, 0.14), transparent 34%),
                radial-gradient(circle at bottom right, rgba(37, 198, 178, 0.12), transparent 28%),
                linear-gradient(180deg, #eef4fa 0%, #e8eef5 100%);
        }
        body {
            font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
        }
        [v-cloak] {
            display: none;
        }
        #app {
            height: 100%;
            padding: 22px;
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }
        .page-hero {
            padding: 22px 24px;
            border-radius: 20px;
            border: 1px solid rgba(255, 255, 255, 0.78);
            background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(244, 249, 255, 0.82));
            box-shadow: 0 18px 50px rgba(48, 74, 104, 0.08);
        }
        .page-title {
            font-size: 26px;
            font-weight: 700;
            color: #223548;
            letter-spacing: 0.01em;
        }
        .page-subtitle {
            margin-top: 8px;
            max-width: 860px;
            font-size: 13px;
            line-height: 1.8;
            color: #66788c;
        }
        .page-panel {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
            border-radius: 22px;
            border: 1px solid rgba(221, 231, 242, 0.92);
            background: rgba(251, 253, 255, 0.88);
            box-shadow: 0 20px 48px rgba(49, 76, 106, 0.08);
            overflow: hidden;
        }
        .panel-toolbar {
            padding: 18px 22px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            border-bottom: 1px solid rgba(224, 233, 244, 0.9);
            background: rgba(255, 255, 255, 0.72);
        }
        .panel-tip {
            font-size: 12px;
            color: #74879b;
            line-height: 1.8;
        }
        .color-grid {
            flex: 1;
            min-height: 0;
            padding: 20px 22px 24px;
            overflow: auto;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
            grid-auto-rows: max-content;
            align-content: start;
            gap: 16px;
        }
        .color-card {
            position: relative;
            border-radius: 18px;
            border: 1px solid rgba(223, 232, 242, 0.94);
            background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.92));
            box-shadow: 0 12px 28px rgba(76, 101, 130, 0.07);
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 10px;
            overflow: visible;
        }
        .color-card::after {
            content: "";
            position: absolute;
            top: -30px;
            right: -20px;
            width: 120px;
            height: 120px;
            border-radius: 50%;
            background: rgba(255,255,255,0.42);
            pointer-events: none;
        }
        .color-card-head {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 14px;
        }
        .color-name {
            font-size: 16px;
            font-weight: 700;
            color: #24374a;
        }
        .color-status {
            margin-top: 4px;
            font-size: 12px;
            color: #7a8da2;
            word-break: break-all;
        }
        .color-chip {
            width: 58px;
            height: 58px;
            border-radius: 18px;
            border: 1px solid rgba(89, 109, 134, 0.18);
            box-shadow: inset 0 0 0 1px rgba(255,255,255,0.46);
            flex-shrink: 0;
        }
        .color-desc {
            font-size: 12px;
            line-height: 1.8;
            color: #66798d;
        }
        .color-editor {
            display: flex;
            align-items: center;
            gap: 12px;
        }
        .color-picker-inline {
            flex-shrink: 0;
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 0 10px;
            height: 40px;
            border-radius: 12px;
            border: 1px solid rgba(214, 225, 238, 0.95);
            background: rgba(247, 250, 255, 0.9);
            color: #567089;
            font-size: 12px;
            font-weight: 600;
        }
        .color-actions {
            display: flex;
            justify-content: flex-end;
        }
        .color-default {
            font-size: 12px;
            color: #7890a8;
        }
        .color-meta {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
        }
        .empty-state {
            padding: 60px 20px;
            text-align: center;
            color: #7b8c9f;
            font-size: 14px;
        }
        .footer-note {
            padding: 0 22px 18px;
            font-size: 12px;
            color: #8696a8;
        }
        .el-color-picker__trigger {
            width: 52px;
            height: 40px;
            padding: 0;
            border-radius: 12px;
            border-color: rgba(211, 223, 237, 0.95);
            background: #fff;
        }
        .el-input__inner {
            border-radius: 12px;
            height: 40px;
            line-height: 40px;
        }
        .el-button + .el-button {
            margin-left: 10px;
        }
        @media (max-width: 900px) {
            #app {
                padding: 14px;
            }
            .page-hero,
            .panel-toolbar,
            .color-grid,
            .footer-note {
                padding-left: 16px;
                padding-right: 16px;
            }
            .color-editor,
            .color-meta {
                flex-wrap: wrap;
            }
        }
    </style>
</head>
<body>
<div id="app" v-cloak>
    <div class="page-hero">
        <div class="page-title">站点颜色配置</div>
        <div class="page-subtitle">
            单独维护监控地图里站点状态的显示颜色。颜色通过调色盘任意选择,配置保存在 Redis 中;
            未配置时默认使用当前地图代码里的颜色值。
        </div>
    </div>
    <div class="page-panel">
        <div class="panel-toolbar">
            <div class="panel-tip">
                建议让“启动入库 / 入库任务 / 出库任务 / 堵塞”保持明显区分,避免现场值守时误判。
            </div>
            <div>
                <el-button size="small" @click="reloadData" :loading="loading">刷新</el-button>
                <el-button size="small" @click="resetDefaults">恢复默认</el-button>
                <el-button type="primary" size="small" @click="saveConfig" :loading="saving">保存配置</el-button>
            </div>
        </div>
        <div v-if="items.length" class="color-grid">
            <div v-for="item in items" :key="item.status" class="color-card">
                <div class="color-card-head">
                    <div>
                        <div class="color-name">{{ item.name }}</div>
                        <div class="color-status">{{ item.status }}</div>
                    </div>
                    <div class="color-chip" :style="{ backgroundColor: item.color }"></div>
                </div>
                <div class="color-desc">{{ item.desc }}</div>
                <div class="color-editor">
                    <div class="color-picker-inline">
                        <el-color-picker
                            v-model="item.color"
                            :predefine="predefineColors"
                        ></el-color-picker>
                        <span>调色盘</span>
                    </div>
                    <el-input
                        v-model="item.color"
                        @change="handleColorInput(item)"
                        placeholder="#FFFFFF"
                    ></el-input>
                </div>
                <div class="color-meta">
                    <div class="color-default">默认值:{{ item.defaultColor }}</div>
                    <div class="color-actions">
                        <el-button size="mini" @click="applyDefaultColor(item)">恢复默认</el-button>
                    </div>
                </div>
            </div>
        </div>
        <div v-else class="empty-state">暂无站点颜色配置项</div>
        <div class="footer-note">保存后,新打开的监控地图会直接读取 Redis 配置;已打开页面刷新后即可生效。</div>
    </div>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/watch/stationColorConfig.js" charset="utf-8"></script>
</body>
</html>