#
Junjie
9 小时以前 75775c96801e548ecf3368865124c33a03e32dcf
#
3个文件已修改
3个文件已添加
1943 ■■■■■ 已修改文件
src/main/java/com/zy/system/controller/DashboardController.java 596 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/messages.properties 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/zh-CN/messages.properties 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/dashboard/dashboard.js 396 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/dashboard/dashboard.html 863 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/index.html 78 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/DashboardController.java
New file
@@ -0,0 +1,596 @@
package com.zy.system.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.ai.entity.AiChatSession;
import com.zy.ai.entity.LlmCallLog;
import com.zy.ai.entity.LlmRouteConfig;
import com.zy.ai.mapper.AiChatSessionMapper;
import com.zy.ai.service.LlmCallLogService;
import com.zy.ai.service.LlmRouteConfigService;
import com.zy.asrs.entity.*;
import com.zy.asrs.service.*;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.SlaveType;
import com.zy.core.enums.WrkStsType;
import com.zy.core.model.protocol.CrnProtocol;
import com.zy.core.model.protocol.DualCrnProtocol;
import com.zy.core.model.protocol.RgvProtocol;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.properties.SystemProperties;
import com.zy.core.thread.CrnThread;
import com.zy.core.thread.DualCrnThread;
import com.zy.core.thread.RgvThread;
import com.zy.core.thread.StationThread;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.text.SimpleDateFormat;
import java.util.*;
@Slf4j
@RestController
@RequestMapping("/dashboard")
public class DashboardController {
    private static final Set<Long> MANUAL_TASK_STATUS = new HashSet<>(Arrays.asList(6L, 106L, 506L));
    private static final Set<Long> COMPLETED_TASK_STATUS = new HashSet<>(Arrays.asList(9L, 10L, 109L, 110L, 509L));
    private static final Set<Long> NEW_TASK_STATUS = new HashSet<>(Arrays.asList(1L, 101L, 501L));
    @Autowired
    private WrkMastService wrkMastService;
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private BasDualCrnpService basDualCrnpService;
    @Autowired
    private BasRgvService basRgvService;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private LlmRouteConfigService llmRouteConfigService;
    @Autowired
    private LlmCallLogService llmCallLogService;
    @Autowired
    private AiChatSessionMapper aiChatSessionMapper;
    @GetMapping("/summary/auth")
    @ManagerAuth(memo = "系统仪表盘统计")
    public R summary() {
        Map<String, Object> tasks = buildTaskStats();
        Map<String, Object> devices = buildDeviceStats();
        Map<String, Object> ai = buildAiStats();
        Map<String, Object> overview = new LinkedHashMap<>();
        overview.put("systemRunning", Boolean.TRUE.equals(SystemProperties.WCS_RUNNING_STATUS.get()));
        overview.put("taskTotal", getNestedLong(tasks, "overview", "total"));
        overview.put("taskRunning", getNestedLong(tasks, "overview", "running"));
        overview.put("deviceTotal", getNestedLong(devices, "overview", "total"));
        overview.put("deviceOnline", getNestedLong(devices, "overview", "online"));
        overview.put("deviceAlarm", getNestedLong(devices, "overview", "alarm"));
        overview.put("aiTokenTotal", getNestedLong(ai, "overview", "tokenTotal"));
        overview.put("aiCallTotal", getNestedLong(ai, "overview", "llmCallTotal"));
        overview.put("generatedAt", formatDateTime(new Date()));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("overview", overview);
        result.put("tasks", tasks);
        result.put("devices", devices);
        result.put("ai", ai);
        return R.ok(result);
    }
    private Map<String, Object> buildTaskStats() {
        Map<String, Object> result = new LinkedHashMap<>();
        long total = safeCount(wrkMastService.count(new QueryWrapper<WrkMast>()));
        long running = 0L;
        long manual = 0L;
        long completed = 0L;
        long newCreated = 0L;
        long inbound = 0L;
        long outbound = 0L;
        long move = 0L;
        List<Map<String, Object>> statusStats = new ArrayList<>();
        try {
            List<Map<String, Object>> grouped = wrkMastService.listMaps(new QueryWrapper<WrkMast>()
                    .select("wrk_sts", "count(*) as total")
                    .groupBy("wrk_sts"));
            for (Map<String, Object> row : grouped) {
                Long wrkSts = toLong(row.get("wrk_sts"));
                long count = toLong(row.get("total"));
                if (count <= 0) {
                    continue;
                }
                if (isInboundTask(wrkSts)) {
                    inbound += count;
                } else if (isMoveTask(wrkSts)) {
                    move += count;
                } else {
                    outbound += count;
                }
                if (isManualTask(wrkSts)) {
                    manual += count;
                } else if (isCompletedTask(wrkSts)) {
                    completed += count;
                } else if (isNewTask(wrkSts)) {
                    newCreated += count;
                } else {
                    running += count;
                }
                statusStats.add(metric(resolveTaskStatusName(wrkSts), count));
            }
        } catch (Exception e) {
            log.warn("dashboard task group stats load failed: {}", safeMessage(e));
        }
        statusStats.sort((a, b) -> Long.compare(toLong(b.get("value")), toLong(a.get("value"))));
        if (statusStats.size() > 8) {
            statusStats = new ArrayList<>(statusStats.subList(0, 8));
        }
        List<Map<String, Object>> directionStats = new ArrayList<>();
        directionStats.add(metric("入库任务", inbound));
        directionStats.add(metric("出库任务", outbound));
        directionStats.add(metric("移库任务", move));
        List<Map<String, Object>> stageStats = new ArrayList<>();
        stageStats.add(metric("执行中", running));
        stageStats.add(metric("待人工", manual));
        stageStats.add(metric("已完成", completed));
        stageStats.add(metric("新建", newCreated));
        List<Map<String, Object>> recentTasks = new ArrayList<>();
        try {
            List<Map<String, Object>> rows = wrkMastService.listMaps(new QueryWrapper<WrkMast>()
                    .select("wrk_no", "wrk_sts", "sta_no", "source_sta_no", "loc_no", "source_loc_no",
                            "crn_no", "dual_crn_no", "rgv_no", "barcode", "appe_time", "modi_time")
                    .orderByDesc("modi_time")
                    .orderByDesc("wrk_no")
                    .last("limit 8"));
            for (Map<String, Object> row : rows) {
                Long wrkSts = toLong(row.get("wrk_sts"));
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("wrkNo", toLong(row.get("wrk_no")));
                item.put("taskType", resolveTaskDirectionName(wrkSts));
                item.put("status", resolveTaskStatusName(wrkSts));
                item.put("source", formatSiteAndLoc(row.get("source_sta_no"), row.get("source_loc_no")));
                item.put("target", formatSiteAndLoc(row.get("sta_no"), row.get("loc_no")));
                item.put("device", formatTaskDevice(row));
                item.put("barcode", toText(row.get("barcode")));
                item.put("updateTime", formatDateTime(firstNonNull(row.get("modi_time"), row.get("appe_time"))));
                recentTasks.add(item);
            }
        } catch (Exception e) {
            log.warn("dashboard recent tasks load failed: {}", safeMessage(e));
        }
        Map<String, Object> overview = new LinkedHashMap<>();
        overview.put("total", total);
        overview.put("running", running);
        overview.put("manual", manual);
        overview.put("completed", completed);
        overview.put("newCreated", newCreated);
        overview.put("inbound", inbound);
        overview.put("outbound", outbound);
        overview.put("move", move);
        result.put("overview", overview);
        result.put("directionStats", directionStats);
        result.put("stageStats", stageStats);
        result.put("statusStats", statusStats);
        result.put("recentTasks", recentTasks);
        return result;
    }
    private Map<String, Object> buildDeviceStats() {
        Map<String, Object> result = new LinkedHashMap<>();
        long stationTotal = basStationService.count(new QueryWrapper<BasStation>().eq("status", 1));
        long crnTotal = basCrnpService.count(new QueryWrapper<BasCrnp>().eq("status", 1));
        long dualCrnTotal = basDualCrnpService.count(new QueryWrapper<BasDualCrnp>().eq("status", 1));
        long rgvTotal = basRgvService.count(new QueryWrapper<BasRgv>().eq("status", 1));
        Set<Integer> onlineStationIds = new HashSet<>();
        long stationBusy = 0L;
        long stationAlarm = 0L;
        for (DeviceConfig cfg : listDeviceConfig(SlaveType.Devp)) {
            StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, cfg.getDeviceNo());
            if (stationThread == null) {
                continue;
            }
            List<StationProtocol> statusList = stationThread.getStatus();
            if (statusList == null) {
                continue;
            }
            for (StationProtocol protocol : statusList) {
                if (protocol == null || protocol.getStationId() == null) {
                    continue;
                }
                if (onlineStationIds.add(protocol.getStationId())) {
                    if (protocol.getTaskNo() != null && protocol.getTaskNo() > 0) {
                        stationBusy++;
                    }
                    if ((protocol.getError() != null && protocol.getError() > 0) || protocol.isRunBlock()) {
                        stationAlarm++;
                    }
                }
            }
        }
        long crnOnline = 0L;
        long crnBusy = 0L;
        long crnAlarm = 0L;
        for (DeviceConfig cfg : listDeviceConfig(SlaveType.Crn)) {
            CrnThread thread = (CrnThread) SlaveConnection.get(SlaveType.Crn, cfg.getDeviceNo());
            if (thread == null || thread.getStatus() == null) {
                continue;
            }
            CrnProtocol protocol = thread.getStatus();
            crnOnline++;
            if (protocol.getTaskNo() != null && protocol.getTaskNo() > 0) {
                crnBusy++;
            }
            if (protocol.getAlarm() != null && protocol.getAlarm() > 0) {
                crnAlarm++;
            }
        }
        long dualCrnOnline = 0L;
        long dualCrnBusy = 0L;
        long dualCrnAlarm = 0L;
        for (DeviceConfig cfg : listDeviceConfig(SlaveType.DualCrn)) {
            DualCrnThread thread = (DualCrnThread) SlaveConnection.get(SlaveType.DualCrn, cfg.getDeviceNo());
            if (thread == null || thread.getStatus() == null) {
                continue;
            }
            DualCrnProtocol protocol = thread.getStatus();
            dualCrnOnline++;
            if ((protocol.getTaskNo() != null && protocol.getTaskNo() > 0)
                    || (protocol.getTaskNoTwo() != null && protocol.getTaskNoTwo() > 0)) {
                dualCrnBusy++;
            }
            if (protocol.getAlarm() != null && protocol.getAlarm() > 0) {
                dualCrnAlarm++;
            }
        }
        long rgvOnline = 0L;
        long rgvBusy = 0L;
        long rgvAlarm = 0L;
        for (DeviceConfig cfg : listDeviceConfig(SlaveType.Rgv)) {
            RgvThread thread = (RgvThread) SlaveConnection.get(SlaveType.Rgv, cfg.getDeviceNo());
            if (thread == null || thread.getStatus() == null) {
                continue;
            }
            RgvProtocol protocol = thread.getStatus();
            rgvOnline++;
            if (protocol.getTaskNo() != null && protocol.getTaskNo() > 0) {
                rgvBusy++;
            }
            if (protocol.getAlarm() != null && protocol.getAlarm() > 0) {
                rgvAlarm++;
            }
        }
        List<Map<String, Object>> typeStats = new ArrayList<>();
        typeStats.add(deviceMetric("输送站点", stationTotal, onlineStationIds.size(), stationBusy, stationAlarm));
        typeStats.add(deviceMetric("堆垛机", crnTotal, crnOnline, crnBusy, crnAlarm));
        typeStats.add(deviceMetric("双工位堆垛机", dualCrnTotal, dualCrnOnline, dualCrnBusy, dualCrnAlarm));
        typeStats.add(deviceMetric("RGV", rgvTotal, rgvOnline, rgvBusy, rgvAlarm));
        long total = stationTotal + crnTotal + dualCrnTotal + rgvTotal;
        long online = onlineStationIds.size() + crnOnline + dualCrnOnline + rgvOnline;
        long busy = stationBusy + crnBusy + dualCrnBusy + rgvBusy;
        long alarm = stationAlarm + crnAlarm + dualCrnAlarm + rgvAlarm;
        Map<String, Object> overview = new LinkedHashMap<>();
        overview.put("total", total);
        overview.put("online", online);
        overview.put("offline", Math.max(total - online, 0L));
        overview.put("busy", busy);
        overview.put("alarm", alarm);
        overview.put("onlineRate", total <= 0 ? 0D : round2((double) online * 100D / (double) total));
        result.put("overview", overview);
        result.put("typeStats", typeStats);
        return result;
    }
    private Map<String, Object> buildAiStats() {
        Map<String, Object> result = new LinkedHashMap<>();
        long tokenTotal = 0L;
        long promptTokenTotal = 0L;
        long completionTokenTotal = 0L;
        long askCount = 0L;
        long sessionCount = 0L;
        try {
            List<AiChatSession> sessions = aiChatSessionMapper.selectList(new QueryWrapper<AiChatSession>()
                    .select("id", "sum_prompt_tokens", "sum_completion_tokens", "sum_total_tokens", "ask_count"));
            sessionCount = sessions == null ? 0L : sessions.size();
            if (sessions != null) {
                for (AiChatSession session : sessions) {
                    promptTokenTotal += safeCount(session == null ? null : session.getSumPromptTokens());
                    completionTokenTotal += safeCount(session == null ? null : session.getSumCompletionTokens());
                    tokenTotal += safeCount(session == null ? null : session.getSumTotalTokens());
                    askCount += safeCount(session == null ? null : session.getAskCount());
                }
            }
        } catch (Exception e) {
            log.warn("dashboard ai session stats load failed: {}", safeMessage(e));
        }
        List<LlmRouteConfig> routes = Collections.emptyList();
        try {
            routes = llmRouteConfigService.list(new QueryWrapper<LlmRouteConfig>()
                    .orderBy(true, true, "priority")
                    .orderBy(true, true, "id"));
        } catch (Exception e) {
            log.warn("dashboard ai route stats load failed: {}", safeMessage(e));
        }
        Date now = new Date();
        long routeTotal = 0L;
        long enabledRouteCount = 0L;
        long coolingRouteCount = 0L;
        long availableRouteCount = 0L;
        long disabledRouteCount = 0L;
        List<Map<String, Object>> routeList = new ArrayList<>();
        if (routes != null) {
            routeTotal = routes.size();
            for (LlmRouteConfig route : routes) {
                boolean enabled = route != null && route.getStatus() != null && route.getStatus() == 1;
                boolean cooling = enabled && route.getCooldownUntil() != null && route.getCooldownUntil().after(now);
                if (enabled) {
                    enabledRouteCount++;
                } else {
                    disabledRouteCount++;
                }
                if (cooling) {
                    coolingRouteCount++;
                } else if (enabled) {
                    availableRouteCount++;
                }
                if (route != null) {
                    Map<String, Object> item = new LinkedHashMap<>();
                    item.put("name", defaultText(route.getName(), "未命名路由"));
                    item.put("model", defaultText(route.getModel(), "-"));
                    item.put("priority", route.getPriority());
                    item.put("statusText", enabled ? (cooling ? "冷却中" : "可用") : "已禁用");
                    item.put("statusType", enabled ? (cooling ? "warning" : "success") : "info");
                    item.put("successCount", safeCount(route.getSuccessCount()));
                    item.put("failCount", safeCount(route.getFailCount()));
                    item.put("lastUsedTime", formatDateTime(route.getLastUsedTime()));
                    item.put("lastError", defaultText(route.getLastError(), ""));
                    routeList.add(item);
                }
            }
        }
        long llmCallTotal = 0L;
        long successCallTotal = 0L;
        long failCallTotal = 0L;
        String lastCallTime = "";
        try {
            llmCallTotal = safeCount(llmCallLogService.count(new QueryWrapper<LlmCallLog>()));
            successCallTotal = safeCount(llmCallLogService.count(new QueryWrapper<LlmCallLog>().eq("success", 1)));
            failCallTotal = safeCount(llmCallLogService.count(new QueryWrapper<LlmCallLog>().eq("success", 0)));
            List<Map<String, Object>> latestLog = llmCallLogService.listMaps(new QueryWrapper<LlmCallLog>()
                    .select("create_time")
                    .orderByDesc("id")
                    .last("limit 1"));
            if (latestLog != null && !latestLog.isEmpty()) {
                lastCallTime = formatDateTime(latestLog.get(0).get("create_time"));
            }
        } catch (Exception e) {
            log.warn("dashboard ai log stats load failed: {}", safeMessage(e));
        }
        Map<String, Object> overview = new LinkedHashMap<>();
        overview.put("tokenTotal", tokenTotal);
        overview.put("promptTokenTotal", promptTokenTotal);
        overview.put("completionTokenTotal", completionTokenTotal);
        overview.put("askCount", askCount);
        overview.put("sessionCount", sessionCount);
        overview.put("routeTotal", routeTotal);
        overview.put("enabledRouteCount", enabledRouteCount);
        overview.put("coolingRouteCount", coolingRouteCount);
        overview.put("availableRouteCount", availableRouteCount);
        overview.put("disabledRouteCount", disabledRouteCount);
        overview.put("llmCallTotal", llmCallTotal);
        overview.put("successCallTotal", successCallTotal);
        overview.put("failCallTotal", failCallTotal);
        overview.put("lastCallTime", lastCallTime);
        List<Map<String, Object>> routeStats = new ArrayList<>();
        routeStats.add(metric("可用", availableRouteCount));
        routeStats.add(metric("冷却中", coolingRouteCount));
        routeStats.add(metric("已禁用", disabledRouteCount));
        result.put("overview", overview);
        result.put("routeStats", routeStats);
        result.put("routeList", routeList);
        return result;
    }
    private List<DeviceConfig> listDeviceConfig(SlaveType type) {
        try {
            return deviceConfigService.list(new QueryWrapper<DeviceConfig>()
                    .eq("device_type", String.valueOf(type)));
        } catch (Exception e) {
            log.warn("dashboard device config load failed, type={}, msg={}", type, safeMessage(e));
            return Collections.emptyList();
        }
    }
    private boolean isInboundTask(Long wrkSts) {
        return wrkSts != null && wrkSts > 0 && wrkSts < 100;
    }
    private boolean isMoveTask(Long wrkSts) {
        return wrkSts != null && wrkSts >= 500;
    }
    private boolean isManualTask(Long wrkSts) {
        return wrkSts != null && MANUAL_TASK_STATUS.contains(wrkSts);
    }
    private boolean isCompletedTask(Long wrkSts) {
        return wrkSts != null && COMPLETED_TASK_STATUS.contains(wrkSts);
    }
    private boolean isNewTask(Long wrkSts) {
        return wrkSts != null && NEW_TASK_STATUS.contains(wrkSts);
    }
    private String resolveTaskDirectionName(Long wrkSts) {
        if (isInboundTask(wrkSts)) {
            return "入库任务";
        }
        if (isMoveTask(wrkSts)) {
            return "移库任务";
        }
        return "出库任务";
    }
    private String resolveTaskStatusName(Long wrkSts) {
        if (wrkSts == null) {
            return "未定义状态";
        }
        try {
            return WrkStsType.query(wrkSts).desc;
        } catch (Exception ignore) {
            return "状态" + wrkSts;
        }
    }
    private Map<String, Object> metric(String name, long value) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("name", name);
        item.put("value", value);
        return item;
    }
    private Map<String, Object> deviceMetric(String name, long total, long online, long busy, long alarm) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("name", name);
        item.put("total", total);
        item.put("online", online);
        item.put("offline", Math.max(total - online, 0L));
        item.put("busy", busy);
        item.put("alarm", alarm);
        return item;
    }
    @SuppressWarnings("unchecked")
    private long getNestedLong(Map<String, Object> source, String key, String nestedKey) {
        if (source == null) {
            return 0L;
        }
        Object value = source.get(key);
        if (!(value instanceof Map)) {
            return 0L;
        }
        return toLong(((Map<String, Object>) value).get(nestedKey));
    }
    private Object firstNonNull(Object first, Object second) {
        return first != null ? first : second;
    }
    private long safeCount(Number value) {
        return value == null ? 0L : value.longValue();
    }
    private long toLong(Object value) {
        if (value == null) {
            return 0L;
        }
        if (value instanceof Number) {
            return ((Number) value).longValue();
        }
        try {
            return Long.parseLong(String.valueOf(value));
        } catch (Exception e) {
            return 0L;
        }
    }
    private double round2(double value) {
        return Math.round(value * 100D) / 100D;
    }
    private String formatSiteAndLoc(Object stationNo, Object locNo) {
        String site = toText(stationNo);
        String loc = toText(locNo);
        if (site.isEmpty() && loc.isEmpty()) {
            return "-";
        }
        if (site.isEmpty()) {
            return loc;
        }
        if (loc.isEmpty()) {
            return "站点" + site;
        }
        return "站点" + site + " / " + loc;
    }
    private String formatTaskDevice(Map<String, Object> row) {
        long crnNo = toLong(row.get("crn_no"));
        long dualCrnNo = toLong(row.get("dual_crn_no"));
        long rgvNo = toLong(row.get("rgv_no"));
        List<String> parts = new ArrayList<>();
        if (crnNo > 0) {
            parts.add("堆垛机#" + crnNo);
        }
        if (dualCrnNo > 0) {
            parts.add("双工位#" + dualCrnNo);
        }
        if (rgvNo > 0) {
            parts.add("RGV#" + rgvNo);
        }
        return parts.isEmpty() ? "-" : String.join(" / ", parts);
    }
    private String formatDateTime(Object value) {
        if (value == null) {
            return "";
        }
        Date date = null;
        if (value instanceof Date) {
            date = (Date) value;
        } else if (value instanceof Number) {
            date = new Date(((Number) value).longValue());
        }
        if (date == null) {
            return String.valueOf(value);
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
    }
    private String toText(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private String defaultText(String value, String fallback) {
        return value == null || value.trim().isEmpty() ? fallback : value.trim();
    }
    private String safeMessage(Exception e) {
        if (e == null || e.getMessage() == null) {
            return "";
        }
        return e.getMessage();
    }
}
src/main/resources/i18n/en-US/messages.properties
@@ -90,8 +90,8 @@
index.fakeRunning=Simulation Running
index.fakeStopped=Simulation Stopped
index.licenseExpiring=License Expiring Soon
index.homeTab=Control Center
index.homeGroup=Real-time Monitoring
index.homeTab=System Dashboard
index.homeGroup=System Overview
index.profileGroup=Account Center
index.versionLoading=Version loading...
index.licenseExpireAt=The license will expire on {0}. Remaining validity: {1} day(s).
src/main/resources/i18n/zh-CN/messages.properties
@@ -11,7 +11,7 @@
common.profile=基本资料
common.logout=退出登录
common.closeOtherTabs=关闭其他页签
common.backHome=返回控制中心
common.backHome=返回仪表盘
common.aiAssistant=AI助手
common.workPage=工作页面
common.businessPage=业务页面
@@ -90,8 +90,8 @@
index.fakeRunning=仿真运行中
index.fakeStopped=仿真未运行
index.licenseExpiring=许可证即将过期
index.homeTab=控制中心
index.homeGroup=实时监控
index.homeTab=系统仪表盘
index.homeGroup=系统概览
index.profileGroup=账户中心
index.versionLoading=版本加载中...
index.licenseExpireAt=许可证将于 {0} 过期,剩余有效期:{1} 天。
src/main/webapp/static/js/dashboard/dashboard.js
New file
@@ -0,0 +1,396 @@
(function () {
  "use strict";
  var REFRESH_SECONDS = 30;
  function cloneMetricList(source) {
    return Array.isArray(source) ? source : [];
  }
  new Vue({
    el: "#app",
    data: function () {
      return {
        loading: true,
        refreshing: false,
        countdown: REFRESH_SECONDS,
        countdownTimer: null,
        resizeHandler: null,
        charts: {
          taskDirection: null,
          taskStage: null,
          deviceType: null,
          aiRoute: null
        },
        overview: {
          systemRunning: false,
          generatedAt: "",
          taskTotal: 0,
          taskRunning: 0,
          deviceTotal: 0,
          deviceOnline: 0,
          deviceAlarm: 0,
          aiTokenTotal: 0,
          aiCallTotal: 0
        },
        tasks: {
          overview: {
            running: 0,
            manual: 0,
            completed: 0,
            newCreated: 0
          },
          directionStats: [],
          stageStats: [],
          statusStats: [],
          recentTasks: []
        },
        devices: {
          overview: {
            total: 0,
            online: 0,
            offline: 0,
            busy: 0,
            alarm: 0,
            onlineRate: 0
          },
          typeStats: []
        },
        ai: {
          overview: {
            tokenTotal: 0,
            askCount: 0,
            llmCallTotal: 0,
            successCallTotal: 0,
            failCallTotal: 0,
            sessionCount: 0,
            availableRouteCount: 0,
            lastCallTime: ""
          },
          routeStats: [],
          routeList: []
        }
      };
    },
    mounted: function () {
      var self = this;
      this.$nextTick(function () {
        self.initCharts();
        self.loadDashboard(false);
        self.startAutoRefresh();
        self.bindResize();
      });
    },
    beforeDestroy: function () {
      this.clearTimers();
      this.unbindResize();
      this.disposeCharts();
    },
    methods: {
      loadDashboard: function (manual) {
        var self = this;
        if (this.refreshing) {
          return;
        }
        this.refreshing = true;
        $.ajax({
          url: baseUrl + "/dashboard/summary/auth",
          method: "GET",
          headers: { token: localStorage.getItem("token") },
          success: function (res) {
            if (res && res.code === 200) {
              self.applyData(res.data || {});
              self.countdown = REFRESH_SECONDS;
              return;
            }
            self.$message.error((res && res.msg) || "仪表盘数据加载失败");
          },
          error: function () {
            if (manual) {
              self.$message.error("仪表盘数据加载失败,请检查接口状态");
            }
          },
          complete: function () {
            self.loading = false;
            self.refreshing = false;
          }
        });
      },
      applyData: function (payload) {
        this.overview = payload.overview || this.overview;
        this.tasks = payload.tasks || this.tasks;
        this.devices = payload.devices || this.devices;
        this.ai = payload.ai || this.ai;
        this.updateCharts();
      },
      initCharts: function () {
        this.disposeCharts();
        this.charts.taskDirection = echarts.init(this.$refs.taskDirectionChart);
        this.charts.taskStage = echarts.init(this.$refs.taskStageChart);
        this.charts.deviceType = echarts.init(this.$refs.deviceTypeChart);
        this.charts.aiRoute = echarts.init(this.$refs.aiRouteChart);
      },
      updateCharts: function () {
        if (!this.charts.taskDirection || !this.charts.taskStage || !this.charts.deviceType || !this.charts.aiRoute) {
          return;
        }
        this.charts.taskDirection.setOption(this.buildTaskDirectionOption());
        this.charts.taskStage.setOption(this.buildTaskStageOption());
        this.charts.deviceType.setOption(this.buildDeviceTypeOption());
        this.charts.aiRoute.setOption(this.buildAiRouteOption());
        this.resizeCharts();
      },
      buildTaskDirectionOption: function () {
        var data = cloneMetricList(this.tasks.directionStats);
        return {
          color: ["#1f6fb2", "#f59a4a", "#2fa38e"],
          tooltip: {
            trigger: "item",
            formatter: "{b}<br/>{c} ({d}%)"
          },
          legend: {
            bottom: 0,
            itemWidth: 10,
            itemHeight: 10,
            textStyle: { color: "#60778d", fontSize: 12 }
          },
          series: [{
            type: "pie",
            radius: ["48%", "72%"],
            center: ["50%", "46%"],
            data: data,
            label: {
              color: "#40596f",
              formatter: "{b}\n{c}"
            },
            labelLine: {
              lineStyle: { color: "#c6d5e3" }
            }
          }]
        };
      },
      buildTaskStageOption: function () {
        var data = cloneMetricList(this.tasks.stageStats);
        return {
          color: ["#1f6fb2"],
          grid: {
            left: 48,
            right: 18,
            top: 24,
            bottom: 28
          },
          tooltip: {
            trigger: "axis",
            axisPointer: { type: "shadow" }
          },
          xAxis: {
            type: "category",
            data: data.map(function (item) { return item.name; }),
            axisLine: { lineStyle: { color: "#d4deea" } },
            axisTick: { show: false },
            axisLabel: { color: "#63798f", fontSize: 12 }
          },
          yAxis: {
            type: "value",
            splitLine: { lineStyle: { color: "#edf2f7" } },
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#8699ad", fontSize: 12 }
          },
          series: [{
            type: "bar",
            barWidth: 32,
            data: data.map(function (item) { return item.value; }),
            itemStyle: {
              borderRadius: [8, 8, 0, 0],
              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{
                offset: 0,
                color: "#3388d2"
              }, {
                offset: 1,
                color: "#1f6fb2"
              }])
            },
            label: {
              show: true,
              position: "top",
              color: "#36506d",
              fontWeight: 600
            }
          }]
        };
      },
      buildDeviceTypeOption: function () {
        var data = cloneMetricList(this.devices.typeStats);
        return {
          color: ["#2fa38e", "#d8e2ec"],
          legend: {
            right: 0,
            top: 0,
            itemWidth: 10,
            itemHeight: 10,
            textStyle: { color: "#60778d", fontSize: 12 },
            data: ["在线", "离线"]
          },
          grid: {
            left: 76,
            right: 12,
            top: 42,
            bottom: 18
          },
          tooltip: {
            trigger: "axis",
            axisPointer: { type: "shadow" }
          },
          xAxis: {
            type: "value",
            splitLine: { lineStyle: { color: "#edf2f7" } },
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#8398ab", fontSize: 12 }
          },
          yAxis: {
            type: "category",
            data: data.map(function (item) { return item.name; }),
            axisLine: { show: false },
            axisTick: { show: false },
            axisLabel: { color: "#60778d", fontSize: 12 }
          },
          series: [{
            name: "在线",
            type: "bar",
            stack: "device",
            barWidth: 18,
            data: data.map(function (item) { return item.online; }),
            itemStyle: {
              borderRadius: [9, 0, 0, 9]
            }
          }, {
            name: "离线",
            type: "bar",
            stack: "device",
            barWidth: 18,
            data: data.map(function (item) { return item.offline; }),
            itemStyle: {
              borderRadius: [0, 9, 9, 0]
            }
          }]
        };
      },
      buildAiRouteOption: function () {
        var data = cloneMetricList(this.ai.routeStats);
        return {
          color: ["#2fa38e", "#f59a4a", "#c8d4e1"],
          tooltip: {
            trigger: "item",
            formatter: "{b}<br/>{c} ({d}%)"
          },
          graphic: [{
            type: "text",
            left: "center",
            top: "39%",
            style: {
              text: this.formatNumber(this.ai.overview.availableRouteCount || 0),
              fill: "#1f3142",
              fontSize: 28,
              fontWeight: 700
            }
          }, {
            type: "text",
            left: "center",
            top: "55%",
            style: {
              text: "可用路由",
              fill: "#7c8fa4",
              fontSize: 12
            }
          }],
          legend: {
            bottom: 0,
            itemWidth: 10,
            itemHeight: 10,
            textStyle: { color: "#60778d", fontSize: 12 }
          },
          series: [{
            type: "pie",
            radius: ["56%", "76%"],
            center: ["50%", "43%"],
            avoidLabelOverlap: false,
            label: { show: false },
            labelLine: { show: false },
            data: data
          }]
        };
      },
      formatNumber: function (value) {
        var num = Number(value || 0);
        if (!isFinite(num)) {
          return "0";
        }
        return num.toLocaleString("zh-CN");
      },
      displayText: function (value, fallback) {
        return value == null || value === "" ? (fallback || "") : value;
      },
      startAutoRefresh: function () {
        var self = this;
        this.clearTimers();
        this.countdownTimer = setInterval(function () {
          if (self.countdown <= 1) {
            self.countdown = REFRESH_SECONDS;
            self.loadDashboard(false);
            return;
          }
          self.countdown -= 1;
        }, 1000);
      },
      clearTimers: function () {
        if (this.countdownTimer) {
          clearInterval(this.countdownTimer);
          this.countdownTimer = null;
        }
      },
      bindResize: function () {
        var self = this;
        this.resizeHandler = function () {
          self.resizeCharts();
        };
        window.addEventListener("resize", this.resizeHandler);
      },
      unbindResize: function () {
        if (this.resizeHandler) {
          window.removeEventListener("resize", this.resizeHandler);
          this.resizeHandler = null;
        }
      },
      resizeCharts: function () {
        var key;
        for (key in this.charts) {
          if (this.charts.hasOwnProperty(key) && this.charts[key]) {
            this.charts[key].resize();
          }
        }
      },
      disposeCharts: function () {
        var key;
        for (key in this.charts) {
          if (this.charts.hasOwnProperty(key) && this.charts[key]) {
            this.charts[key].dispose();
            this.charts[key] = null;
          }
        }
      },
      openMonitor: function () {
        if (window.parent && window.parent.index && typeof window.parent.index.loadView === "function") {
          window.parent.index.loadView({
            menuPath: "/views/watch/console.html",
            menuName: "监控工作台"
          });
          return;
        }
        window.open(baseUrl + "/views/watch/console.html", "_blank");
      }
    }
  });
}());
src/main/webapp/views/dashboard/dashboard.html
New file
@@ -0,0 +1,863 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>系统仪表盘</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css" />
  <style>
    :root {
      --bg-main: #eef3f7;
      --panel-bg: rgba(255, 255, 255, 0.92);
      --panel-border: rgba(203, 216, 228, 0.92);
      --panel-shadow: 0 18px 38px rgba(34, 61, 92, 0.08);
      --text-main: #1f3142;
      --text-sub: #6d7f92;
      --accent: #1f6fb2;
      --accent-2: #2fa38e;
      --accent-3: #f59a4a;
      --accent-4: #de5c5c;
    }
    [v-cloak] {
      display: none;
    }
    * {
      box-sizing: border-box;
    }
    html,
    body {
      margin: 0;
      min-height: 100%;
      background:
        radial-gradient(900px 340px at -10% -10%, rgba(31, 111, 178, 0.16), transparent 54%),
        radial-gradient(780px 320px at 110% 0%, rgba(47, 163, 142, 0.14), transparent 58%),
        linear-gradient(180deg, #f3f7fb 0%, #edf2f6 100%);
      color: var(--text-main);
      font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
    }
    body {
      padding: 16px;
    }
    .dashboard-shell {
      max-width: 1680px;
      margin: 0 auto;
    }
    .hero {
      position: relative;
      overflow: hidden;
      border-radius: 24px;
      padding: 22px 24px 20px;
      background:
        radial-gradient(400px 180px at 0% 0%, rgba(255, 255, 255, 0.16), transparent 60%),
        linear-gradient(135deg, #0f4c81 0%, #1e6aa3 48%, #239a87 100%);
      box-shadow: 0 22px 42px rgba(18, 57, 92, 0.18);
      color: #fff;
    }
    .hero::after {
      content: "";
      position: absolute;
      right: -60px;
      top: -70px;
      width: 240px;
      height: 240px;
      border-radius: 50%;
      background: rgba(255, 255, 255, 0.08);
      filter: blur(6px);
    }
    .hero-main {
      position: relative;
      z-index: 1;
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 18px;
      flex-wrap: wrap;
    }
    .hero-copy {
      min-width: 0;
      max-width: 760px;
    }
    .hero-eyebrow {
      font-size: 12px;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      opacity: 0.82;
    }
    .hero-title {
      margin: 8px 0 0;
      font-size: 30px;
      line-height: 1.15;
      font-weight: 700;
    }
    .hero-desc {
      margin: 10px 0 0;
      max-width: 720px;
      font-size: 14px;
      line-height: 1.7;
      color: rgba(255, 255, 255, 0.88);
    }
    .hero-stat-grid {
      position: relative;
      z-index: 1;
      margin-top: 14px;
      display: grid;
      grid-template-columns: 1fr;
      gap: 12px;
    }
    .hero-stat-row {
      display: grid;
      gap: 8px;
    }
    .hero-row-head {
      display: flex;
      justify-content: space-between;
      align-items: baseline;
      gap: 12px;
    }
    .hero-row-kicker {
      font-size: 11px;
      color: rgba(255, 255, 255, 0.78);
      letter-spacing: 0.14em;
      text-transform: uppercase;
      font-weight: 700;
    }
    .hero-row-note {
      font-size: 11px;
      color: rgba(255, 255, 255, 0.62);
      line-height: 1.4;
    }
    .hero-status-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 10px;
    }
    .hero-metric-grid {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 10px;
    }
    .hero-actions {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
      justify-content: flex-end;
      text-align: right;
    }
    .hero-meta,
    .summary-card {
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      gap: 6px;
      min-height: 78px;
      padding: 10px 14px;
      border-radius: 16px;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.08) 100%);
      border: 1px solid rgba(255, 255, 255, 0.18);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
      backdrop-filter: blur(4px);
      min-width: 0;
    }
    .hero-meta-label,
    .summary-card .label {
      font-size: 10px;
      color: rgba(255, 255, 255, 0.72);
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }
    .hero-meta-value,
    .summary-card .value {
      margin-top: 4px;
      font-size: 18px;
      line-height: 1.15;
      font-weight: 700;
      color: #fff;
      word-break: break-word;
    }
    .hero-meta-desc,
    .summary-card .desc {
      margin-top: 4px;
      font-size: 11px;
      line-height: 1.35;
      color: rgba(255, 255, 255, 0.84);
    }
    .hero-actions .el-button {
      min-width: 120px;
      height: 40px;
      padding: 0 18px;
      border-radius: 12px;
      font-size: 13px;
      box-shadow: 0 8px 16px rgba(16, 53, 86, 0.12);
    }
    .dashboard-main {
      display: grid;
      grid-template-columns: minmax(0, 1.22fr) minmax(380px, 0.86fr);
      gap: 16px;
      margin-top: 16px;
      align-items: start;
    }
    .dashboard-column {
      display: flex;
      flex-direction: column;
      gap: 16px;
      min-width: 0;
    }
    .panel {
      border-radius: 22px;
      background:
        linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 252, 255, 0.92) 100%);
      border: 1px solid var(--panel-border);
      box-shadow: var(--panel-shadow);
      padding: 18px;
      min-height: 0;
    }
    .panel-header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 12px;
      margin-bottom: 14px;
    }
    .panel-title {
      margin: 0;
      font-size: 18px;
      font-weight: 700;
      color: var(--text-main);
    }
    .panel-desc {
      margin-top: 6px;
      font-size: 12px;
      color: var(--text-sub);
      line-height: 1.6;
    }
    .panel-kicker {
      font-size: 11px;
      color: #88a0b9;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      font-weight: 700;
    }
    .mini-grid {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 10px;
      margin-bottom: 14px;
    }
    .mini-card {
      padding: 12px 12px 10px;
      border-radius: 16px;
      background: #f7fafc;
      border: 1px solid #e4edf5;
    }
    .mini-card .mini-label {
      font-size: 11px;
      color: #7a8fa6;
    }
    .mini-card .mini-value {
      margin-top: 8px;
      font-size: 24px;
      line-height: 1.05;
      font-weight: 700;
      color: #213547;
    }
    .mini-card .mini-hint {
      margin-top: 8px;
      font-size: 11px;
      color: #92a2b3;
    }
    .task-mini-running {
      background: linear-gradient(180deg, rgba(31, 111, 178, 0.09) 0%, rgba(31, 111, 178, 0.02) 100%);
      border-color: rgba(31, 111, 178, 0.16);
    }
    .task-mini-manual {
      background: linear-gradient(180deg, rgba(245, 154, 74, 0.12) 0%, rgba(245, 154, 74, 0.03) 100%);
      border-color: rgba(245, 154, 74, 0.18);
    }
    .task-mini-completed {
      background: linear-gradient(180deg, rgba(47, 163, 142, 0.11) 0%, rgba(47, 163, 142, 0.03) 100%);
      border-color: rgba(47, 163, 142, 0.18);
    }
    .task-mini-new {
      background: linear-gradient(180deg, rgba(151, 110, 204, 0.10) 0%, rgba(151, 110, 204, 0.03) 100%);
      border-color: rgba(151, 110, 204, 0.18);
    }
    .chart-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 12px;
    }
    .chart-card {
      border-radius: 18px;
      background: #fbfdff;
      border: 1px solid #e5edf6;
      padding: 12px;
    }
    .chart-title {
      font-size: 13px;
      font-weight: 700;
      color: #31506f;
      margin-bottom: 8px;
    }
    .chart-box {
      width: 100%;
      height: 280px;
    }
    .panel-device .mini-grid,
    .panel-ai .mini-grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    .status-flow {
      margin-top: 12px;
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }
    .status-chip {
      min-width: 144px;
      padding: 10px 12px;
      border-radius: 14px;
      background: #f5f8fb;
      border: 1px solid #e2eaf2;
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 10px;
    }
    .status-chip-name {
      font-size: 12px;
      color: #5f7488;
      line-height: 1.5;
    }
    .status-chip-value {
      font-size: 18px;
      font-weight: 700;
      color: #203647;
      white-space: nowrap;
    }
    .device-chart-box,
    .ai-chart-box {
      width: 100%;
      height: 250px;
    }
    .type-list,
    .route-list {
      display: flex;
      flex-direction: column;
      gap: 10px;
      margin-top: 14px;
    }
    .type-row,
    .route-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 12px 14px;
      border-radius: 16px;
      background: #f9fbfd;
      border: 1px solid #e2eaf3;
    }
    .type-row-main,
    .route-row-main {
      min-width: 0;
      flex: 1;
    }
    .type-row-name,
    .route-row-name {
      font-size: 14px;
      font-weight: 700;
      color: #28425c;
      line-height: 1.4;
    }
    .type-row-desc,
    .route-row-desc {
      margin-top: 4px;
      font-size: 12px;
      color: #8092a5;
      line-height: 1.6;
    }
    .type-row-side {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
      justify-content: flex-end;
    }
    .route-row-side {
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      gap: 6px;
      text-align: right;
    }
    .route-extra {
      font-size: 12px;
      color: #7d90a4;
      line-height: 1.5;
    }
    .route-error {
      margin-top: 6px;
      font-size: 12px;
      color: #c15b5b;
      line-height: 1.5;
      background: rgba(222, 92, 92, 0.08);
      border-radius: 12px;
      padding: 8px 10px;
    }
    .recent-panel {
      min-height: 100%;
    }
    .recent-table {
      border-radius: 16px;
      overflow: hidden;
      border: 1px solid #e5edf6;
    }
    .loading-mask {
      position: fixed;
      inset: 0;
      z-index: 90;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(241, 246, 251, 0.72);
      backdrop-filter: blur(3px);
    }
    .loading-card {
      min-width: 240px;
      padding: 18px 22px;
      border-radius: 18px;
      background: rgba(255, 255, 255, 0.92);
      border: 1px solid #d8e4ef;
      box-shadow: 0 18px 34px rgba(38, 60, 87, 0.12);
      text-align: center;
      color: #36506d;
    }
    .loading-title {
      margin-top: 12px;
      font-size: 14px;
      font-weight: 600;
    }
    .loading-desc {
      margin-top: 6px;
      font-size: 12px;
      color: #7e92a7;
    }
    @media (max-width: 1360px) {
      .hero-copy {
        max-width: none;
      }
      .dashboard-main {
        grid-template-columns: 1fr;
      }
    }
    @media (max-width: 1080px) {
      body {
        padding: 12px;
      }
      .mini-grid,
      .chart-grid {
        grid-template-columns: 1fr;
      }
      .hero {
        padding: 18px;
      }
      .hero-title {
        font-size: 26px;
      }
      .hero-status-grid,
      .hero-metric-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      .hero-actions {
        justify-content: flex-start;
        text-align: left;
      }
      .panel {
        padding: 16px;
      }
      .panel-device .mini-grid,
      .panel-ai .mini-grid {
        grid-template-columns: 1fr;
      }
    }
    @media (max-width: 640px) {
      .hero-actions .el-button {
        width: 100%;
      }
      .hero-status-grid,
      .hero-metric-grid {
        grid-template-columns: 1fr;
      }
      .status-chip,
      .type-row,
      .route-row {
        flex-direction: column;
        align-items: flex-start;
      }
      .type-row-side,
      .route-row-side {
        align-items: flex-start;
        text-align: left;
      }
    }
  </style>
</head>
<body>
<div id="app" class="dashboard-shell" v-cloak>
  <section class="hero">
    <div class="hero-main">
      <div class="hero-copy">
        <div class="hero-eyebrow">WCS Dashboard</div>
        <h1 class="hero-title">系统仪表盘</h1>
      </div>
      <div class="hero-actions">
        <el-button size="small" plain @click="openMonitor">打开监控画面</el-button>
        <el-button size="small" type="primary" :loading="refreshing" @click="loadDashboard(true)">立即刷新</el-button>
      </div>
    </div>
    <div class="hero-stat-grid">
      <div class="hero-stat-row">
        <div class="hero-row-head">
          <div class="hero-row-kicker">状态概览</div>
          <div class="hero-row-note">系统与刷新节奏</div>
        </div>
        <div class="hero-status-grid">
          <div class="hero-meta">
            <div class="hero-meta-label">系统状态</div>
            <div class="hero-meta-value">{{ overview.systemRunning ? '运行中' : '已暂停' }}</div>
            <div class="hero-meta-desc">WCS 主服务当前状态</div>
          </div>
          <div class="hero-meta">
            <div class="hero-meta-label">最近刷新</div>
            <div class="hero-meta-value">{{ displayText(overview.generatedAt, '-') }}</div>
            <div class="hero-meta-desc">最近一次聚合数据生成时间</div>
          </div>
          <div class="hero-meta">
            <div class="hero-meta-label">自动刷新</div>
            <div class="hero-meta-value">{{ countdown }}s 后刷新</div>
            <div class="hero-meta-desc">页面自动更新倒计时</div>
          </div>
        </div>
      </div>
      <div class="hero-stat-row">
        <div class="hero-row-head">
          <div class="hero-row-kicker">核心指标</div>
          <div class="hero-row-note">任务、设备与 AI 总览</div>
        </div>
        <div class="hero-metric-grid">
          <div class="summary-card">
            <div class="label">任务总数</div>
            <div class="value">{{ formatNumber(overview.taskTotal) }}</div>
            <div class="desc">当前执行中 {{ formatNumber(overview.taskRunning) }}</div>
          </div>
          <div class="summary-card">
            <div class="label">在线设备</div>
            <div class="value">{{ formatNumber(overview.deviceOnline) }}</div>
            <div class="desc">总设备 {{ formatNumber(overview.deviceTotal) }},告警 {{ formatNumber(overview.deviceAlarm) }}</div>
          </div>
          <div class="summary-card">
            <div class="label">AI 累计 Tokens</div>
            <div class="value">{{ formatNumber(overview.aiTokenTotal) }}</div>
            <div class="desc">按 AI 会话累计统计</div>
          </div>
          <div class="summary-card">
            <div class="label">LLM 调用次数</div>
            <div class="value">{{ formatNumber(overview.aiCallTotal) }}</div>
            <div class="desc">最近一轮运行情况已纳入下方 AI 区域</div>
          </div>
        </div>
      </div>
    </div>
  </section>
  <div class="dashboard-main">
    <div class="dashboard-column">
      <section class="panel panel-task">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Task</div>
            <h2 class="panel-title">任务态势</h2>
            <div class="panel-desc">从任务类型、执行阶段和最近流转记录快速判断当前作业压力。</div>
          </div>
        </div>
        <div class="mini-grid">
          <div class="mini-card task-mini-running">
            <div class="mini-label">执行中</div>
            <div class="mini-value">{{ formatNumber(tasks.overview.running) }}</div>
            <div class="mini-hint">当前正在流转的任务</div>
          </div>
          <div class="mini-card task-mini-manual">
            <div class="mini-label">待人工</div>
            <div class="mini-value">{{ formatNumber(tasks.overview.manual) }}</div>
            <div class="mini-hint">需人工关注或回滚</div>
          </div>
          <div class="mini-card task-mini-completed">
            <div class="mini-label">已完成</div>
            <div class="mini-value">{{ formatNumber(tasks.overview.completed) }}</div>
            <div class="mini-hint">已经完成或落账</div>
          </div>
          <div class="mini-card task-mini-new">
            <div class="mini-label">新建</div>
            <div class="mini-value">{{ formatNumber(tasks.overview.newCreated) }}</div>
            <div class="mini-hint">刚进入调度流程</div>
          </div>
        </div>
        <div class="chart-grid">
          <div class="chart-card">
            <div class="chart-title">任务类型分布</div>
            <div ref="taskDirectionChart" class="chart-box"></div>
          </div>
          <div class="chart-card">
            <div class="chart-title">任务阶段概览</div>
            <div ref="taskStageChart" class="chart-box"></div>
          </div>
        </div>
        <div class="status-flow">
          <div v-for="item in tasks.statusStats" :key="item.name" class="status-chip">
            <div class="status-chip-name">{{ item.name }}</div>
            <div class="status-chip-value">{{ formatNumber(item.value) }}</div>
          </div>
        </div>
      </section>
      <section class="panel recent-panel panel-recent">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Recent</div>
            <h2 class="panel-title">最近任务</h2>
            <div class="panel-desc">帮助快速判断任务是否堆积、是否被设备接手,以及最近的任务目标位置。</div>
          </div>
        </div>
        <div class="recent-table">
          <el-table
              :data="tasks.recentTasks"
              stripe
              size="mini"
              height="360"
              empty-text="暂无任务记录">
            <el-table-column prop="wrkNo" label="任务号" min-width="100"></el-table-column>
            <el-table-column prop="taskType" label="任务类型" min-width="110"></el-table-column>
            <el-table-column prop="status" label="状态" min-width="160" show-overflow-tooltip></el-table-column>
            <el-table-column prop="source" label="来源" min-width="170" show-overflow-tooltip></el-table-column>
            <el-table-column prop="target" label="目标" min-width="170" show-overflow-tooltip></el-table-column>
            <el-table-column prop="device" label="执行设备" min-width="180" show-overflow-tooltip></el-table-column>
            <el-table-column prop="barcode" label="条码" min-width="150" show-overflow-tooltip></el-table-column>
            <el-table-column prop="updateTime" label="最近更新时间" min-width="170"></el-table-column>
          </el-table>
        </div>
      </section>
    </div>
    <div class="dashboard-column">
      <section class="panel panel-device">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">Devices</div>
            <h2 class="panel-title">设备态势</h2>
            <div class="panel-desc">汇总输送站点、堆垛机、双工位堆垛机与 RGV 的在线、忙碌和告警情况。</div>
          </div>
          <el-tag size="small" type="info">在线率 {{ devices.overview.onlineRate || 0 }}%</el-tag>
        </div>
        <div class="mini-grid">
          <div class="mini-card">
            <div class="mini-label">设备总数</div>
            <div class="mini-value">{{ formatNumber(devices.overview.total) }}</div>
            <div class="mini-hint">已启用配置设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">在线设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.online) }}</div>
            <div class="mini-hint">实时连通设备数量</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">忙碌设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.busy) }}</div>
            <div class="mini-hint">当前承载任务的设备</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">告警设备</div>
            <div class="mini-value">{{ formatNumber(devices.overview.alarm) }}</div>
            <div class="mini-hint">含阻塞或报警状态</div>
          </div>
        </div>
        <div class="chart-card">
          <div class="chart-title">设备在线分布</div>
          <div ref="deviceTypeChart" class="device-chart-box"></div>
        </div>
        <div class="type-list">
          <div v-for="item in devices.typeStats" :key="item.name" class="type-row">
            <div class="type-row-main">
              <div class="type-row-name">{{ item.name }}</div>
              <div class="type-row-desc">在线 {{ formatNumber(item.online) }} / 总数 {{ formatNumber(item.total) }},离线 {{ formatNumber(item.offline) }}</div>
            </div>
            <div class="type-row-side">
              <el-tag size="mini" type="success">忙碌 {{ formatNumber(item.busy) }}</el-tag>
              <el-tag size="mini" :type="item.alarm > 0 ? 'danger' : 'info'">告警 {{ formatNumber(item.alarm) }}</el-tag>
            </div>
          </div>
        </div>
      </section>
      <section class="panel panel-ai">
        <div class="panel-header">
          <div>
            <div class="panel-kicker">AI</div>
            <h2 class="panel-title">AI 运行情况</h2>
            <div class="panel-desc">查看 AI 会话累计 Tokens、LLM 调用量,以及路由的可用与冷却状态。</div>
          </div>
          <el-tag size="small" type="success">可用路由 {{ formatNumber(ai.overview.availableRouteCount) }}</el-tag>
        </div>
        <div class="mini-grid">
          <div class="mini-card">
            <div class="mini-label">累计 Tokens</div>
            <div class="mini-value">{{ formatNumber(ai.overview.tokenTotal) }}</div>
            <div class="mini-hint">Prompt + Completion</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">提问轮次</div>
            <div class="mini-value">{{ formatNumber(ai.overview.askCount) }}</div>
            <div class="mini-hint">AI 对话累计轮次</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">LLM 调用</div>
            <div class="mini-value">{{ formatNumber(ai.overview.llmCallTotal) }}</div>
            <div class="mini-hint">成功 {{ formatNumber(ai.overview.successCallTotal) }} / 失败 {{ formatNumber(ai.overview.failCallTotal) }}</div>
          </div>
          <div class="mini-card">
            <div class="mini-label">会话数</div>
            <div class="mini-value">{{ formatNumber(ai.overview.sessionCount) }}</div>
            <div class="mini-hint">最近调用 {{ displayText(ai.overview.lastCallTime, '-') }}</div>
          </div>
        </div>
        <div class="chart-card">
          <div class="chart-title">AI 路由状态</div>
          <div ref="aiRouteChart" class="ai-chart-box"></div>
        </div>
        <div class="route-list" v-if="ai.routeList.length">
          <div v-for="route in ai.routeList.slice(0, 6)" :key="route.name + '-' + route.model + '-' + route.priority" class="route-row">
            <div class="route-row-main">
              <div class="route-row-name">{{ route.name }}</div>
              <div class="route-row-desc">模型 {{ displayText(route.model, '-') }},优先级 {{ displayText(route.priority, '-') }}</div>
              <div v-if="route.lastError" class="route-error">{{ route.lastError }}</div>
            </div>
            <div class="route-row-side">
              <el-tag size="mini" :type="route.statusType">{{ route.statusText }}</el-tag>
              <div class="route-extra">成功 {{ formatNumber(route.successCount) }} / 失败 {{ formatNumber(route.failCount) }}</div>
              <div class="route-extra">最近使用 {{ displayText(route.lastUsedTime, '-') }}</div>
            </div>
          </div>
        </div>
        <el-empty v-else description="暂无 AI 路由数据"></el-empty>
      </section>
    </div>
  </div>
  <div v-if="loading" class="loading-mask">
    <div class="loading-card">
      <i class="el-icon-loading" style="font-size: 26px;"></i>
      <div class="loading-title">正在加载仪表盘</div>
      <div class="loading-desc">汇总任务、设备与 AI 运行数据,请稍候...</div>
    </div>
  </div>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.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 type="text/javascript" src="../../static/js/echarts/echarts.min.js"></script>
<script type="text/javascript" src="../../static/js/dashboard/dashboard.js"></script>
</body>
</html>
src/main/webapp/views/index.html
@@ -862,13 +862,15 @@
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script>
  var DASHBOARD_VIEW_VERSION = "20260316-hero-two-rows-flat-compact";
  var HOME_TAB_CONFIG = {
    title: "控制中心",
    url: baseUrl + "/views/watch/console.html",
    title: "系统仪表盘",
    url: baseUrl + "/views/dashboard/dashboard.html?layoutVersion=" + encodeURIComponent(DASHBOARD_VIEW_VERSION),
    home: true,
    group: "实时监控",
    group: "系统概览",
    menuKey: ""
  };
  var LEGACY_HOME_TAB_URL = baseUrl + "/views/watch/console.html";
  var PROFILE_TAB_CONFIG = {
    title: "基本资料",
    url: baseUrl + "/views/detail.html?resourceId=8",
@@ -891,7 +893,7 @@
    "common.profile": "基本资料",
    "common.logout": "退出登录",
    "common.closeOtherTabs": "关闭其他页签",
    "common.backHome": "返回控制中心",
    "common.backHome": "返回仪表盘",
    "common.aiAssistant": "AI助手",
    "common.workPage": "工作页面",
    "common.businessPage": "业务页面",
@@ -902,8 +904,8 @@
    "index.fakeRunning": "仿真运行中",
    "index.fakeStopped": "仿真未运行",
    "index.licenseExpiring": "许可证即将过期",
    "index.homeTab": "控制中心",
    "index.homeGroup": "实时监控",
    "index.homeTab": "系统仪表盘",
    "index.homeGroup": "系统概览",
    "index.profileGroup": "账户中心",
    "index.versionLoading": "Version loading...",
    "index.licenseExpireAt": "许可证将于 {0} 过期,剩余有效期:{1} 天。",
@@ -1257,12 +1259,16 @@
        };
      },
      normalizeStoredTab: function (tab) {
        var homeConfig = this.resolveHomeConfig();
        var resolvedUrl = this.resolveViewSrc(tab.url);
        var isLegacyHome = resolvedUrl === this.resolveViewSrc(LEGACY_HOME_TAB_URL);
        var isHome = !!tab.home || isLegacyHome;
        var created = this.createTab({
          title: this.translateTabTitle(tab.title),
          url: this.resolveViewSrc(tab.url),
          home: !!tab.home,
          group: this.tl(tab.group || ""),
          menuKey: tab.menuKey || ""
          title: isHome ? homeConfig.title : this.translateTabTitle(tab.title),
          url: isHome ? homeConfig.url : resolvedUrl,
          home: isHome,
          group: isHome ? homeConfig.group : this.tl(tab.group || ""),
          menuKey: isHome ? homeConfig.menuKey : (tab.menuKey || "")
        });
        created.loaded = false;
        return created;
@@ -1285,6 +1291,9 @@
              }
            }
            active = parsed ? parsed.activeTab : "";
            if (this.resolveViewSrc(active) === this.resolveViewSrc(LEGACY_HOME_TAB_URL)) {
              active = homeTab.name;
            }
          } catch (e) {
            tabs = [];
            active = "";
@@ -1628,6 +1637,43 @@
        }
        return "";
      },
      injectDashboardMenu: function (menus) {
        var homeConfig = this.resolveHomeConfig();
        var dashboardUrl = this.resolveViewSrc(homeConfig.url);
        var i;
        var j;
        var group;
        var item;
        for (i = 0; i < menus.length; i++) {
          group = menus[i];
          for (j = 0; j < group.subMenu.length; j++) {
            item = group.subMenu[j];
            if (item.url === dashboardUrl) {
              item.name = homeConfig.title;
              group.menu = homeConfig.group;
              group.menuCode = group.menuCode || "index";
              HOME_TAB_CONFIG.menuKey = item.tabKey || dashboardUrl;
              return menus;
            }
          }
        }
        HOME_TAB_CONFIG.menuKey = dashboardUrl;
        menus.unshift({
          menuId: "dashboard-home",
          menu: homeConfig.group,
          menuCode: "index",
          subMenu: [{
            id: "dashboard-home-tab",
            name: homeConfig.title,
            code: "dashboard/dashboard.html",
            url: dashboardUrl,
            tabKey: dashboardUrl
          }]
        });
        return menus;
      },
      normalizeMenuData: function (data) {
        var result = [];
        var i;
@@ -1659,7 +1705,7 @@
          });
        }
        return result;
        return this.injectDashboardMenu(result);
      },
      buildMenuSrc: function (code, resourceId) {
        var normalized = code || "";
@@ -1720,11 +1766,9 @@
        var syncVersion;
        var applyMenuState;
        if (!this.isHomeTabUrl(targetUrl)) {
          activeMenuKey = this.findMenuKeyByUrl(targetUrl);
          if (activeMenuKey) {
            groupIndex = this.findMenuGroupIndexByUrl(targetUrl);
          }
        activeMenuKey = this.findMenuKeyByUrl(targetUrl);
        if (activeMenuKey) {
          groupIndex = this.findMenuGroupIndexByUrl(targetUrl);
        }
        this.activeMenuKey = activeMenuKey;