Compare commits

..

25 Commits

Author SHA1 Message Date
haoliang b9555b807c 合并功能分支:模拟采集集成+仪表盘优化+采集服务部署修复 13 hours ago
haoliang 1600570b60 仪表盘优化:修复采集服务状态判断、去掉切削总时、数字保留两位小数、产量排行增加排序和TOP N 13 hours ago
haoliang 06d04c244e feat: 模拟采集E2E测试(7项IIS模式全通过)+修复mock插件RegExp兼容问题 14 hours ago
haoliang 72cb43c493 feat: 模拟采集集成——后端SimulatorController(22端点代理转发)+前端总览/详情页+路由+侧边栏菜单+Mock数据 16 hours ago
haoliang 4b70b8eacf feat: 日志分区管理优化——sp_ensure_partitions覆盖3张分区表(含log_collect_raw);LogCleanupJob改用DROP PARTITION清理;修复分区边界计算bug 16 hours ago
haoliang b74c3db6af 清理根目录临时文件和旧代码;修复采集服务名称不匹配(collector-service→CncCollector) 17 hours ago
haoliang add981876b docs: 添加前端构建与部署规范文档 17 hours ago
haoliang ccdfec31bb feat: 在线状态改为基于last_ping_time实时判断,删除is_online列;新增online_timeout配置项(默认300秒);全链路修改Repository/Service/Collector/测试 19 hours ago
haoliang 0563da73e8 feat: 品牌字段映射增加启用/禁用开关(is_enabled);前端增加开关列和行样式;新增6个Repository测试+6个Service/Controller测试;迁移脚本幂等执行 21 hours ago
haoliang 089f3e502a 添加 BrandFieldMappingRepositoryTests 测试用例; 扩展 BrandServiceTests/BrandControllerTests 的测试覆盖 IsEnabled 字段 21 hours ago
haoliang 78b7dfea19 fix: 移除跨库JOIN避免权限问题;修复raw端点参数默认值 2 days ago
haoliang 2d698b277d fix: 修复 LogDashboard 类型定义缺少 messageSnippet 属性 2 days ago
haoliang e09fdc1329 feat: 实现数据回放功能(ReplayService + API端点)
- 新增 IReplayService/ReplayService 回放服务(预览+执行)
- 新增 ReplayController(POST preview/execute)
- 新增 ReplayDto 请求/响应DTO
- 回放流程:读取原始日志→清空业务数据→重新解析写入→日终汇总
- ServiceResolver DI注册
- 编译通过 0错误
2 days ago
haoliang c9cca32757 实现 D1-D2 数据回放:新增 ReplayService、ReplayController、ReplayDto,DI 注册,API 端点,预览与执行回放逻辑,基于现有 SQL 迁移。 2 days ago
haoliang 6e468089ea feat: 前端采集日志页面 + 自动分区存储过程 + 日志清理调度 + 告警类型扩展
- 新增 CollectLogPage.vue(分析记录/采集周期/原始数据 三个Tab页)
- 新增 collect-log.ts API封装和Mock数据
- 路由和侧边栏菜单添加采集日志入口
- 新增 sp_ensure_partitions 自动分区存储过程 + MariaDB Event
- 新增 LogCleanupJob 日志清理定时任务(保留天数=0不删除)
- CollectorConfig 新增日志清理配置属性
- AlertType 新增 DataAnomaly 常量
- 后端0错误,前端仅1个预存TS错误
2 days ago
haoliang 7d9634af48 feat(采集日志): 新增前端实现,包括 API 封装、Mock 数据、Vue 页面、路由和菜单;新增 CollectLog 页面组件、Mock 数据、API 接口,以及路由与侧边栏集成 2 days ago
haoliang e3f37d5433 feat: 实现采集分析引擎(AnalysisEngine)+ 后台管理API + 前端设计文档
- 新增 log_collect_analysis + log_collect_cycle 两张按月分区表DDL
- 完整实现 AnalysisEngine:9种分析类型检测、DB写入、异常告警联动
- 修改 CollectRecordWriter.WriteBatch 返回 rawLogId
- 集成 AnalysisEngine 到 CollectWorker 采集主流程
- 新增 CollectLogController 5个API端点(分析查询/详情/周期/原始日志)
- 新增 Entity/Enum/DTO/Repository/Service 全链路代码
- 修复子代理创建的文件:DTO命名空间、Repository方法名、SQL列映射、using引用
- 新增13-采集日志前端设计文档(索引+规范+页面)
- 全部5个主项目编译通过,0错误
2 days ago
haoliang 23eda3751f 新增采集日志服务接口 ICollectLogService、实现 CollectLogService、控制器 CollectLogController,并更新 API 文档 3.14 采集日志模块 2 days ago
haoliang 5a7c1b3436 fix(ci): 移除不必要的 setup-dotnet 步骤,windows-latest 自带 .NET Framework 4.7.2 环境 3 days ago
haoliang eedf5fa8be fix: 修复前端类型错误(CollectorStatus重复声明、serviceStatusLabel位置);修复CI配置SDK版本;新增上线回滚文档 3 days ago
haoliang acdc502be2 test(cnc-service): 新增 Starting 状态测试用例,验证服务启动中状态返回正确 3 days ago
haoliang 0212ed6afc test(ci): add Windows workflow and extended tests stage4-5; stabilize dashboard service tests 4 days ago
haoliang d69817bf45 test(cnc-service): expand DashboardServiceTests with DI-enabled scenario using FakeDashboardRepository + FakeCollectorHeartbeatRepository + FakeWindowsServiceChecker; fix tests for Run Running state 4 days ago
haoliang d8f59250d7 feat: 自动化推进 Windows 服务状态检查相关改造,阶段4-6 全流程实现(前端适配、后端测试扩展、CI/Playwright E2E) 4 days ago
haoliang e9802a195d feat(cnc-service): add Windows service status checker, integrate into dashboard status, enhance startup flow; stage4 plan initialized; add frontend typings and dashboard view updates; add test scaffold for WindowsServiceChecker 4 days ago

@ -0,0 +1,37 @@
name: CI-Windows-WindowsServiceStatus
on:
push:
branches: [ main, feat/windows-service-status-auto ]
pull_request:
branches: [ main, feat/windows-service-status-auto ]
jobs:
build-test:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore NuGet packages
run: dotnet restore
- name: Build backend
run: dotnet build -c Release --no-restore
- name: Run Windows service related tests
run: dotnet test tests/CncService.Tests/CncService.Tests.csproj -c Release --no-build -v minimal --filter "FullyQualifiedName~WindowsServiceCheckerTests|FullyQualifiedName~DashboardServiceTests"
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
- name: Test summary
if: always()
run: |
echo "=== CI Summary ==="
echo "Backend: Build + WindowsService/Dashboard tests"
echo "Frontend: Build (vue-tsc + vite)"
echo "=================="

@ -0,0 +1,29 @@
## Phase 4 Plan: 前端适配与端到端测试草案
- 目标
- 前端能正确展示后端新增的 serviceStatus 字段,并据此给出友好提示
- 未安装时,启动按钮触发安装引导,显示 install.ps1 路径
- 启动失败/异常时,显示服务返回的 serviceMessage 以及排查建议
- 变更范围
- 前端DashboardPage.vue、类型定义、相关 UI 文案
- 后端已完成阶段3前端将调用 /api/admin/collector/status 实时获取状态
- 测试:新增前端端到端测试用例草案
- 任务清单
1) 前端界面完善
- 确保 serviceStatusLabel 的文本与图标覆盖 NotInstalled、Stopped、Running、Starting、StartFailed
- 在 NotInstalled 场景下,点击启动显示安装引导
- 显示 serviceMessage若返回作为错误提示的一部分
2) 新字段的类型检查与绑定
- 确认 CollectorStatus 类型字段包含 serviceStatus、serviceName、uptimeSeconds、lastCollectTime、serviceMessage
3) 集成测试草案
- 场景覆盖 NotInstalled、Running、Starting、StartFailed、Stopped
4) 回归与文档
- 更新用户手册和计划文档
- 验收标准
- UI 正确显示 serviceStatus 的中文文本与图标
- NotInstalled 时展示安装引导信息并提供 install.ps1 路径
- 启动失败时能展示 API 返回的 serviceMessage
- 端到端测试覆盖率达到 80% 以上

280
.txt

@ -0,0 +1,280 @@
[
{
"device": "fanake_1.8",
"desc": "西-1.8",
"tags": [
{
"id": "_io_status",
"desc": "设备状态",
"quality": "0",
"value": "1.00000",
"time": "2026-04-10 17:36:38"
},
{
"id": "Tag2",
"desc": "当前轴数",
"quality": "0",
"value": "4.00000",
"time": "2026-04-10 17:36:34"
},
{
"id": "Tag5",
"desc": "执行的NC主程序名",
"quality": "0",
"value": "1566.NC",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag6",
"desc": "执行的NC主程序号",
"quality": "0",
"value": "N0",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag7",
"desc": "当前加工程序内容",
"quality": "0",
"value": "<1566.NC>\nG40G49G80\n( NAME: Administrator )\n( M",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag8",
"desc": "当前加工零件数",
"quality": "0",
"value": "1219.00000",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag9",
"desc": "运行状态",
"quality": "0",
"value": "0.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag11",
"desc": "操作模式",
"quality": "0",
"value": "1.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag14",
"desc": "当前主轴倍率",
"quality": "0",
"value": "100.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag17",
"desc": "主轴设定速度",
"quality": "0",
"value": "300.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag18",
"desc": "进给设定速度",
"quality": "0",
"value": "0.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag19",
"desc": "主轴实际速度",
"quality": "0",
"value": "0.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag20",
"desc": "进给实际转速",
"quality": "0",
"value": "0.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag21",
"desc": "主轴负载",
"quality": "0",
"value": "0.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag22",
"desc": "开机时间",
"quality": "0",
"value": "23558160.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag23",
"desc": "运行时间",
"quality": "0",
"value": "18224.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag24",
"desc": "切削时间",
"quality": "0",
"value": "6848959.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag25",
"desc": "循环时间",
"quality": "0",
"value": "699.00000",
"time": "2026-04-10 17:36:38"
},
{
"id": "Tag26",
"desc": "加工状态",
"quality": "0",
"value": "G01",
"time": "2026-04-10 17:36:38"
}
]
},
{
"device": "fanake_1.9",
"desc": "西-1.9",
"tags": [
{
"id": "_io_status",
"desc": "设备状态",
"quality": "0",
"value": "1.00000",
"time": "2026-04-10 17:36:38"
},
{
"id": "Tag2",
"desc": "当前轴数",
"quality": "0",
"value": "4.00000",
"time": "2026-04-10 17:36:34"
},
{
"id": "Tag5",
"desc": "执行的NC主程序名",
"quality": "0",
"value": "O1",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag6",
"desc": "执行的NC主程序号",
"quality": "0",
"value": "N20",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag7",
"desc": "当前加工程序内容",
"quality": "0",
"value": "G99 G83 Z-43.000 Q3.000 R3.000 F60. \nG80 \nG00 Z",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag8",
"desc": "当前加工零件数",
"quality": "0",
"value": "62.00000",
"time": "2026-04-10 17:36:35"
},
{
"id": "Tag9",
"desc": "运行状态",
"quality": "0",
"value": "3.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag11",
"desc": "操作模式",
"quality": "0",
"value": "10.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag14",
"desc": "当前主轴倍率",
"quality": "0",
"value": "100.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag17",
"desc": "主轴设定速度",
"quality": "0",
"value": "450.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag18",
"desc": "进给设定速度",
"quality": "0",
"value": "60.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag19",
"desc": "主轴实际速度",
"quality": "0",
"value": "450.00000",
"time": "2026-04-10 17:36:36"
},
{
"id": "Tag20",
"desc": "进给实际转速",
"quality": "0",
"value": "60.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag21",
"desc": "主轴负载",
"quality": "0",
"value": "25.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag22",
"desc": "开机时间",
"quality": "0",
"value": "23784960.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag23",
"desc": "运行时间",
"quality": "0",
"value": "24253.00000",
"time": "2026-04-10 17:36:37"
},
{
"id": "Tag24",
"desc": "切削时间",
"quality": "0",
"value": "8009398.00000",
"time": "2026-04-10 17:36:38"
},
{
"id": "Tag25",
"desc": "循环时间",
"quality": "0",
"value": "82.00000",
"time": "2026-04-10 17:36:38"
},
{
"id": "Tag26",
"desc": "加工状态",
"quality": "0",
"value": "G01",
"time": "2026-04-10 17:36:38"
}
]
}
]

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net" processFilters="true" />
</configSections>
<log4net>
<root>
<level value="INFO" />
<appender-ref value="RollingFileAppender" />
</root>
<appender name="RollingFileAppender" type="log4net.Appender.RollingFileAppender">
<!-- 将日志分等级滚动,按时间与大小混合滚动 -->
<file value="logs/simulator-" />
<datePattern value="yyyy-MM-dd'.log'" />
<rollingStyle value="Composite" />
<MaxSizeRollBackups value="10" />
<MaximumFileSize value="50MB" />
<layout type="log4net.Layout.PatternLayout">
<conversionPattern value="[%date %-5level] %message%newline" />
</layout>
</appender>
</log4net>
</configuration>

@ -0,0 +1,16 @@
using System.Threading.Tasks;
using CncService.LogAnalyzer;
using CncService.Models;
namespace CncService
{
// 扩展日志写入与分析结果传回接口,供分区日志写入及分析摘要能力使用
public interface ILogIngestionService
{
// 写入采集日志及其分析摘要,返回写入是否成功
Task<bool> WriteLogAsync(LogRecord record, LogAnalysisResult analysis);
// 读取最新一条日志及其分析摘要(用于后台看板等场景的快速查询示例)
Task<LogIngestionResult> GetLatestLogAsync(string machineId, string programName);
}
}

@ -0,0 +1,10 @@
namespace CncService.LogAnalyzer
{
// 解析结果模型,供日志分析摘要使用
public class LogAnalysisResult
{
public string Summary { get; set; } // 摘要文本
public string DetailsJson { get; set; } // 详细信息JSON 字符串)
public double Confidence { get; set; } // 可信度0-1
}
}

@ -0,0 +1,9 @@
namespace CncService.Models
{
// Minimal result wrapper for latest log fetch
public class LogIngestionResult
{
public long LogId { get; set; }
public string Message { get; set; }
}
}

@ -0,0 +1,16 @@
using System;
namespace CncService.Models
{
// Represents a raw log entry captured by the ingestion service
public class LogRecord
{
public long LogId { get; set; }
public string MachineId { get; set; }
public string ProgramName { get; set; }
public DateTime LogTime { get; set; }
public string Action { get; set; }
public string Result { get; set; }
public string RawData { get; set; }
}
}

@ -0,0 +1,67 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using CncService;
using CncService.Models;
using CncService.LogAnalyzer;
namespace CncWebApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class LogIngestionController : ControllerBase
{
private readonly ILogIngestionService _logIngestionService;
public LogIngestionController(ILogIngestionService logIngestionService)
{
_logIngestionService = logIngestionService;
}
[HttpPost("ingest")]
public async Task<IActionResult> Ingest([FromBody] LogIngestionRequest request)
{
if (request == null)
return BadRequest("请求为空");
var record = new LogRecord
{
LogId = request.LogId,
MachineId = request.MachineId,
ProgramName = request.ProgramName,
LogTime = request.LogTime ?? DateTime.UtcNow,
Action = request.Action,
Result = request.Result,
RawData = request.RawData
};
var analysis = new LogAnalysisResult
{
Summary = request.AnalysisSummary,
DetailsJson = request.DetailsJson,
Confidence = request.Confidence
};
var ok = await _logIngestionService.WriteLogAsync(record, analysis);
if (ok)
{
return Ok(new { success = true, logId = record.LogId, analysisSummary = analysis.Summary });
}
return StatusCode(500, new { success = false, message = "写入失败" });
}
}
public class LogIngestionRequest
{
public long LogId { get; set; }
public string MachineId { get; set; }
public string ProgramName { get; set; }
public DateTime? LogTime { get; set; }
public string Action { get; set; }
public string Result { get; set; }
public string RawData { get; set; }
public string AnalysisSummary { get; set; }
public string DetailsJson { get; set; }
public double? Confidence { get; set; }
}
}

@ -0,0 +1,3 @@
CncSimulator 项目根 README
This repository contains a self-contained CNC data sampling simulator for .NET Framework 4.7.2.
具体实现参考 simulator.json、Config/SimulatorConfig.cs、Device/DeviceState.cs、Device/DeviceSimulator.cs、Generator/FanucDataGenerator.cs、Core/SimulatorServer.cs、Core/SimulatorEngine.cs、Program.cs。

@ -0,0 +1,20 @@
# Collect_Log 表设计与索引
- 目标:支持高并发日志写入,便于日后按月分区查询与分析。
- 主键LogId BIGINT AUTO_INCREMENT
- 时间字段LogTime DATETIME作为分区键
- 其他字段示例:
- MachineId VARCHAR(64)
- ProgramName VARCHAR(128)
- Action VARCHAR(32) -- 例如 INSERT/UPDATE/DELETE 或自定义动作
- Result VARCHAR(32) -- 新增/无变化/替换加工程序等结果标签
- RawData JSON -- 原始日志片段
+ - AnalysisSummary JSON -- 分析摘要(由 LogAnalyzer 产出)
- 索引设计:
- INDEX idx_logtime(LogTime)
- INDEX idx_machine_program(MachineId, ProgramName, LogTime)
- FULLTEXT INDEX for JSON fields (若 MariaDB 版本支持,按需启用)
- 分区设计概念:按月 RANGE COLUMNS(LogTime) Partition 名分区如 p2024m01, p2024m02 等。
- 注意:在初始版本中,完整分区脚本需要根据实际 MariaDB 版本做微调。

@ -0,0 +1,93 @@
-- ============================================================
-- 采集分析日志表 + 采集周期汇总表(幂等迁移脚本)
-- 创建时间2026-05-05
-- 说明:在 cnc_log 库中新增两张按月分区表
-- log_collect_analysis: 每次采集、每台机床的分析记录
-- log_collect_cycle: 每次采集周期的汇总信息
-- 执行前提USE cnc_log; 已执行 01-init-schema.sql
-- ============================================================
USE cnc_log;
-- -----------------------------------------------------------
-- 1. 采集分析日志表 log_collect_analysis按月分区
-- 记录每次采集后对每台机床的数据变化分析
-- -----------------------------------------------------------
DROP TABLE IF EXISTS log_collect_analysis;
CREATE TABLE log_collect_analysis (
id BIGINT AUTO_INCREMENT,
analysis_time DATETIME NOT NULL COMMENT '分析时间(分区键)',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
machine_id INT NOT NULL COMMENT '机床ID关联cnc_machine',
analysis_type VARCHAR(30) NOT NULL COMMENT '分析类型NORMAL_UNCHANGED/PART_COUNT_INCREASE/PROGRAM_SWITCH/MANUAL_RESET/DEVICE_ONLINE/DEVICE_OFFLINE/NEW_DEVICE_FOUND/DATA_ANOMALY/COLLECTION_FAILED',
previous_program VARCHAR(200) NULL COMMENT '上一次NC程序名',
current_program VARCHAR(200) NULL COMMENT '本次NC程序名',
previous_part_count DECIMAL(15,5) NULL COMMENT '上一次零件计数',
current_part_count DECIMAL(15,5) NULL COMMENT '本次零件计数',
part_count_delta DECIMAL(15,5) NULL COMMENT '零件计数变化量(正=增加,负=减少)',
previous_status VARCHAR(20) NULL COMMENT '上一次设备状态',
current_status VARCHAR(20) NULL COMMENT '本次设备状态',
analysis_summary VARCHAR(500) NOT NULL COMMENT '人类可读的分析摘要',
analysis_detail JSON NULL COMMENT '完整的字段级对比数据JSON',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, analysis_time),
INDEX idx_address_time (collect_address_id, analysis_time),
INDEX idx_machine_time (machine_id, analysis_time),
INDEX idx_type_time (analysis_type, analysis_time),
INDEX idx_raw_log (raw_log_id),
INDEX idx_program_time (current_program, analysis_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集分析日志表(按月分区,记录每次采集对每台机床的数据变化分析)'
PARTITION BY RANGE (TO_DAYS(analysis_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- -----------------------------------------------------------
-- 2. 采集周期汇总表 log_collect_cycle按月分区
-- 记录每次采集周期(一个地址的一次完整采集)的汇总信息
-- -----------------------------------------------------------
DROP TABLE IF EXISTS log_collect_cycle;
CREATE TABLE log_collect_cycle (
id BIGINT AUTO_INCREMENT,
cycle_time DATETIME NOT NULL COMMENT '周期开始时间(分区键)',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
end_time DATETIME NULL COMMENT '周期结束时间',
duration_ms INT NULL COMMENT '本次采集总耗时(毫秒)',
total_machines INT NOT NULL DEFAULT 0 COMMENT '本周期采集的机床总数',
success_count INT NOT NULL DEFAULT 0 COMMENT '成功采集的机床数',
fail_count INT NOT NULL DEFAULT 0 COMMENT '失败采集的机床数',
change_distribution JSON NULL COMMENT '变化类型分布(如 {"PROGRAM_SWITCH":2,"PART_COUNT_INCREASE":5,"NORMAL_UNCHANGED":3}',
has_anomaly TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在异常1=有异常DATA_ANOMALY/COLLECTION_FAILED/DEVICE_OFFLINE',
cycle_summary VARCHAR(500) NULL COMMENT '人类可读的周期汇总',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, cycle_time),
INDEX idx_address_time (collect_address_id, cycle_time),
INDEX idx_time (cycle_time),
INDEX idx_anomaly_time (has_anomaly, cycle_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集周期汇总表(按月分区,每次采集周期的汇总信息)'
PARTITION BY RANGE (TO_DAYS(cycle_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- -----------------------------------------------------------
-- 3. 为现有 log_collect_raw 表增加补充索引
-- 支持按采集成功/失败筛选,以及按响应时长分析
-- -----------------------------------------------------------
-- 检查索引是否已存在,若不存在则添加(幂等)
SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND INDEX_NAME = 'idx_success_time');
SET @sqlstmt := IF(@exist = 0,
'ALTER TABLE cnc_log.log_collect_raw ADD INDEX idx_success_time (is_success, request_time)',
'SELECT ''索引 idx_success_time 已存在,跳过''');
PREPARE stmt FROM @sqlstmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

@ -0,0 +1,108 @@
-- ============================================================
-- 自动分区与日志清理(幂等)
-- 1) 分区管理表 log_partition_tracker
-- 2) 存储过程 sp_ensure_partitions
-- 3) 存储过程 sp_check_partitions
-- 4) MariaDB 事件 ev_ensure_partitions
-- 注意:本脚本设计为幂等,重复执行不会重复创建分区
-- ============================================================
USE cnc_log;
-- 1. 分区追踪表
CREATE TABLE IF NOT EXISTS log_partition_tracker (
table_name VARCHAR(100) NOT NULL,
partition_name VARCHAR(50) NOT NULL,
partition_value VARCHAR(30) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (table_name, partition_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='分区管理追踪表';
-- 2. 自动分区存储过程
DELIMITER $$
DROP PROCEDURE IF EXISTS sp_ensure_partitions$$
CREATE PROCEDURE sp_ensure_partitions()
BEGIN
-- 当前月的第一天
SET @base := DATE_FORMAT(CURDATE(), '%Y-%m-01');
SET @d1 := DATE_ADD(@base, INTERVAL 1 MONTH);
SET @d2 := DATE_ADD(@base, INTERVAL 2 MONTH);
SET @p1 := CONCAT('p', DATE_FORMAT(@d1, '%Y%m'));
SET @p2 := CONCAT('p', DATE_FORMAT(@d2, '%Y%m'));
-- 对 log_collect_analysis 表分区
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p1) THEN
SET @dead1 := DATE_FORMAT(@d1, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_analysis ADD PARTITION (PARTITION ', @p1,
' VALUES LESS THAN (TO_DAYS(', '''', @dead1, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_analysis', @p1, @dead1);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p2) THEN
SET @dead2 := DATE_FORMAT(@d2, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_analysis ADD PARTITION (PARTITION ', @p2,
' VALUES LESS THAN (TO_DAYS(', '''', @dead2, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_analysis', @p2, @dead2);
END IF;
-- 对 log_collect_cycle 表分区
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p1) THEN
SET @dead1 := DATE_FORMAT(@d1, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_cycle ADD PARTITION (PARTITION ', @p1,
' VALUES LESS THAN (TO_DAYS(', '''', @dead1, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_cycle', @p1, @dead1);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p2) THEN
SET @dead2 := DATE_FORMAT(@d2, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_cycle ADD PARTITION (PARTITION ', @p2,
' VALUES LESS THAN (TO_DAYS(', '''', @dead2, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_cycle', @p2, @dead2);
END IF;
END$$
DELIMITER ;
-- 3. 分区检查存储过程
DELIMITER $$
DROP PROCEDURE IF EXISTS sp_check_partitions$$
CREATE PROCEDURE sp_check_partitions()
BEGIN
-- 计算未来两月分区名是否存在
SET @base := DATE_FORMAT(CURDATE(), '%Y-%m-01');
SET @d1 := DATE_ADD(@base, INTERVAL 1 MONTH);
SET @d2 := DATE_ADD(@base, INTERVAL 2 MONTH);
SET @p1 := CONCAT('p', DATE_FORMAT(@d1, '%Y%m'));
SET @p2 := CONCAT('p', DATE_FORMAT(@d2, '%Y%m'));
SET @need := 0;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p1) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p1) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p2) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p2) = 0 THEN SET @need = 1; END IF;
IF @need = 1 THEN
CALL sp_ensure_partitions();
END IF;
SELECT @need AS need_partition_creation;
END$$
DELIMITER ;
-- 4. MariaDB 事件每月1日凌晨2:00执行 sp_check_partitions
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_ensure_partitions;
CREATE EVENT IF NOT EXISTS ev_ensure_partitions
ON SCHEDULE
EVERY 1 MONTH
STARTS TIMESTAMP '2026-06-01 02:00:00'
DO
CALL sp_check_partitions();

@ -0,0 +1,35 @@
-- ============================================================
-- 05: 品牌字段映射增加启用/禁用开关
-- 执行目标库cnc_business
-- 幂等:是(通过 IF NOT EXISTS 检查列是否已存在)
-- ============================================================
-- 1. 增加 is_enabled 列(默认启用)
SET @dbname = 'cnc_business';
SET @tablename = 'cnc_brand_field_mapping';
SET @columnname = 'is_enabled';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname
AND TABLE_NAME = @tablename
AND COLUMN_NAME = @columnname
) > 0,
'SELECT 1',
'ALTER TABLE cnc_brand_field_mapping ADD COLUMN is_enabled tinyint(1) NOT NULL DEFAULT 1 COMMENT ''是否启用1=启用 0=禁用'' AFTER is_required'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- 2. 根据采集示例数据,禁用未出现的字段映射
-- 采集示例中只出现了_io_status, Tag5, Tag8, Tag9, Tag11, Tag22, Tag23
-- 未出现的先设为禁用,后续可通过后台开关启用
UPDATE cnc_brand_field_mapping SET is_enabled = 0
WHERE field_name IN ('Tag14','Tag17','Tag18','Tag19','Tag20','Tag21','Tag24','Tag25','Tag26')
AND is_enabled = 1;
-- 3. 验证结果
SELECT id, standard_field, field_name, is_required, is_enabled
FROM cnc_brand_field_mapping
ORDER BY id;

@ -0,0 +1,25 @@
-- ============================================================
-- 迁移脚本06: 删除is_online列 + 新增在线超时配置项
-- 幂等执行先加配置再删列IF EXISTS
-- ============================================================
-- 1. 新增系统配置项:在线超时阈值(秒)
INSERT INTO cnc_sys_config (config_key, config_value, value_type, description, updated_at)
SELECT 'online_timeout', '300', 'number', '在线超时阈值(秒)超过此时间未Ping的机床判定为离线', NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM cnc_sys_config WHERE config_key = 'online_timeout');
-- 2. 删除 is_online 列幂等IF EXISTS 在 MariaDB 10.0.2+ 支持)
-- 注意MariaDB 不支持 ALTER TABLE DROP COLUMN IF EXISTS用存储过程实现
DROP PROCEDURE IF EXISTS drop_column_if_exists;
DELIMITER //
CREATE PROCEDURE drop_column_if_exists()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cnc_machine' AND COLUMN_NAME = 'is_online') THEN
ALTER TABLE cnc_machine DROP COLUMN is_online;
END IF;
END //
DELIMITER ;
CALL drop_column_if_exists();
DROP PROCEDURE IF EXISTS drop_column_if_exists;

@ -0,0 +1,216 @@
-- ============================================================
-- 日志表按月分区统一管理(幂等迁移脚本)
-- 创建时间2026-05-06
-- 说明:确保 log_collect_raw、log_collect_analysis、log_collect_cycle
-- 三张日志表均按月分区,并统一存储过程管理
-- 执行前提USE cnc_log; 已执行 01-init-schema.sql 和 03-collect-analysis-tables.sql
-- ============================================================
USE cnc_log;
-- ============================================================
-- 1. log_collect_raw 按月分区
-- 该表在 01-init-schema.sql 中已定义分区,此处确认分区存在
-- 分区键request_time
-- ============================================================
-- 检查是否已有分区,若无则重建(幂等)
SET @has_partition := (SELECT COUNT(*) FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND PARTITION_NAME IS NOT NULL);
-- 如果表没有分区(旧表),则需要重建
-- 注意如果表已有分区从DDL创建此步骤会跳过
SET @sql_rebuild := IF(@has_partition = 0,
'ALTER TABLE cnc_log.log_collect_raw PARTITION BY RANGE (TO_DAYS(request_time)) (
PARTITION p202604 VALUES LESS THAN (TO_DAYS(''2026-05-01'')),
PARTITION p202605 VALUES LESS THAN (TO_DAYS(''2026-06-01'')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS(''2026-07-01'')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS(''2026-08-01'')),
PARTITION p_future VALUES LESS THAN MAXVALUE
)',
'SELECT ''log_collect_raw 已有分区,跳过''');
PREPARE stmt FROM @sql_rebuild;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- 2. log_collect_analysis 按月分区
-- 该表在 03-collect-analysis-tables.sql 中已定义分区
-- 分区键analysis_time
-- ============================================================
SET @has_partition_a := (SELECT COUNT(*) FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME IS NOT NULL);
SET @sql_rebuild_a := IF(@has_partition_a = 0,
'ALTER TABLE cnc_log.log_collect_analysis PARTITION BY RANGE (TO_DAYS(analysis_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS(''2026-06-01'')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS(''2026-07-01'')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS(''2026-08-01'')),
PARTITION p_future VALUES LESS THAN MAXVALUE
)',
'SELECT ''log_collect_analysis 已有分区,跳过''');
PREPARE stmt FROM @sql_rebuild_a;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- 3. log_collect_cycle 按月分区
-- 该表在 03-collect-analysis-tables.sql 中已定义分区
-- 分区键cycle_time
-- ============================================================
SET @has_partition_c := (SELECT COUNT(*) FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME IS NOT NULL);
SET @sql_rebuild_c := IF(@has_partition_c = 0,
'ALTER TABLE cnc_log.log_collect_cycle PARTITION BY RANGE (TO_DAYS(cycle_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS(''2026-06-01'')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS(''2026-07-01'')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS(''2026-08-01'')),
PARTITION p_future VALUES LESS THAN MAXVALUE
)',
'SELECT ''log_collect_cycle 已有分区,跳过''');
PREPARE stmt FROM @sql_rebuild_c;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- 4. 更新存储过程 sp_ensure_partitions覆盖全部3张分区表
-- ============================================================
DROP PROCEDURE IF EXISTS sp_ensure_partitions;
DELIMITER $$
CREATE PROCEDURE sp_ensure_partitions()
BEGIN
-- 当前月的第一天
SET @base := DATE_FORMAT(CURDATE(), '%Y-%m-01');
SET @d1 := DATE_ADD(@base, INTERVAL 1 MONTH);
SET @d2 := DATE_ADD(@base, INTERVAL 2 MONTH);
SET @p1 := CONCAT('p', DATE_FORMAT(@d1, '%Y%m'));
SET @p2 := CONCAT('p', DATE_FORMAT(@d2, '%Y%m'));
-- ============================
-- log_collect_raw分区键request_time
-- ============================
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND PARTITION_NAME = @p1) THEN
SET @v1 := DATE_FORMAT(@d1, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_raw ADD PARTITION (PARTITION ', @p1,
' VALUES LESS THAN (TO_DAYS(', '''', @v1, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_raw', @p1, @v1);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND PARTITION_NAME = @p2) THEN
SET @v2 := DATE_FORMAT(@d2, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_raw ADD PARTITION (PARTITION ', @p2,
' VALUES LESS THAN (TO_DAYS(', '''', @v2, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_raw', @p2, @v2);
END IF;
-- ============================
-- log_collect_analysis分区键analysis_time
-- ============================
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p1) THEN
SET @v1 := DATE_FORMAT(@d1, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_analysis ADD PARTITION (PARTITION ', @p1,
' VALUES LESS THAN (TO_DAYS(', '''', @v1, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_analysis', @p1, @v1);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p2) THEN
SET @v2 := DATE_FORMAT(@d2, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_analysis ADD PARTITION (PARTITION ', @p2,
' VALUES LESS THAN (TO_DAYS(', '''', @v2, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_analysis', @p2, @v2);
END IF;
-- ============================
-- log_collect_cycle分区键cycle_time
-- ============================
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p1) THEN
SET @v1 := DATE_FORMAT(@d1, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_cycle ADD PARTITION (PARTITION ', @p1,
' VALUES LESS THAN (TO_DAYS(', '''', @v1, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_cycle', @p1, @v1);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p2) THEN
SET @v2 := DATE_FORMAT(@d2, '%Y-%m-01');
SET @sql := CONCAT('ALTER TABLE cnc_log.log_collect_cycle ADD PARTITION (PARTITION ', @p2,
' VALUES LESS THAN (TO_DAYS(', '''', @v2, '''', '))');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT IGNORE INTO log_partition_tracker(table_name, partition_name, partition_value) VALUES ('log_collect_cycle', @p2, @v2);
END IF;
END$$
DELIMITER ;
-- ============================================================
-- 5. 更新 sp_check_partitions覆盖全部3张分区表
-- ============================================================
DROP PROCEDURE IF EXISTS sp_check_partitions;
DELIMITER $$
CREATE PROCEDURE sp_check_partitions()
BEGIN
SET @base := DATE_FORMAT(CURDATE(), '%Y-%m-01');
SET @d1 := DATE_ADD(@base, INTERVAL 1 MONTH);
SET @d2 := DATE_ADD(@base, INTERVAL 2 MONTH);
SET @p1 := CONCAT('p', DATE_FORMAT(@d1, '%Y%m'));
SET @p2 := CONCAT('p', DATE_FORMAT(@d2, '%Y%m'));
SET @need := 0;
-- log_collect_raw
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND PARTITION_NAME = @p1) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND PARTITION_NAME = @p2) = 0 THEN SET @need = 1; END IF;
-- log_collect_analysis
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p1) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_analysis' AND PARTITION_NAME = @p2) = 0 THEN SET @need = 1; END IF;
-- log_collect_cycle
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p1) = 0 THEN SET @need = 1; END IF;
IF (SELECT COUNT(*) FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_cycle' AND PARTITION_NAME = @p2) = 0 THEN SET @need = 1; END IF;
IF @need = 1 THEN
CALL sp_ensure_partitions();
END IF;
SELECT @need AS need_partition_creation;
END$$
DELIMITER ;
-- ============================================================
-- 6. 确保分区追踪表存在
-- ============================================================
CREATE TABLE IF NOT EXISTS log_partition_tracker (
table_name VARCHAR(100) NOT NULL,
partition_name VARCHAR(50) NOT NULL,
partition_value VARCHAR(30) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (table_name, partition_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='分区管理追踪表';
-- ============================================================
-- 7. 立即执行一次分区确保
-- ============================================================
CALL sp_ensure_partitions();
-- ============================================================
-- 8. 更新 MariaDB 事件每月1日凌晨2:00执行
-- ============================================================
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_ensure_partitions;
CREATE EVENT IF NOT EXISTS ev_ensure_partitions
ON SCHEDULE
EVERY 1 MONTH
STARTS TIMESTAMP(DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01 02:00:00'))
DO
CALL sp_check_partitions();

@ -0,0 +1,40 @@
-- Partitioned logs table draft
-- 目标:按月分区日志表,提升写入吞吐和查询历史的性能
-- 说明:本草案为初步设计,待评审后落地实现
-- Assumptions:
-- - MariaDB 10.x 版本,支持分区按 RANGE (TO_DAYS(log_time))
-- - 日志字段与现有采集日志表接近
-- - 每月一个分区,覆盖历史数据的归档策略待定
DROP TABLE IF EXISTS logs_partitioned;
CREATE TABLE logs_partitioned (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
machine_id INT NOT NULL,
program_name VARCHAR(128) NOT NULL,
log_time DATETIME NOT NULL,
log_level VARCHAR(16) DEFAULT 'INFO',
raw_payload JSON,
analysis_summary TEXT,
analysis_version VARCHAR(64) DEFAULT 'v1',
-- 便于按机床与时间筛选的组合索引
KEY idx_machine_time (machine_id, log_time),
KEY idx_program_time (program_name, log_time)
)
PARTITION BY RANGE (TO_DAYS(log_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
PARTITION p202404 VALUES LESS THAN (TO_DAYS('2024-05-01')),
PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01')),
PARTITION p202406 VALUES LESS THAN (TO_DAYS('2024-07-01')),
PARTITION p202407 VALUES LESS THAN (TO_DAYS('2024-08-01')),
PARTITION p202408 VALUES LESS THAN (TO_DAYS('2024-09-01')),
PARTITION p202409 VALUES LESS THAN (TO_DAYS('2024-10-01')),
PARTITION p202410 VALUES LESS THAN (TO_DAYS('2024-11-01')),
PARTITION p202411 VALUES LESS THAN (TO_DAYS('2024-12-01')),
PARTITION p202412 VALUES LESS THAN (TO_DAYS('2025-01-01')),
PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01'))
);
-- 备注:
-
- ALTER TABLE logs_partitioned REORGANIZE PARTITION ...?

@ -1,67 +0,0 @@
# ============================================================
# deploy-admin.ps1 — 一键编译后端+前端并部署到 admin 目录
# 用法:在项目根目录执行 .\deploy-admin.ps1
# ============================================================
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " CNC 系统一键部署脚本" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# --------------------------------------------------
# 第1步编译后端
# --------------------------------------------------
Write-Host "[1/2] 编译后端 API ..." -ForegroundColor Yellow
dotnet build "$projectRoot\CncDataSystem.sln"
if ($LASTEXITCODE -ne 0) {
Write-Host "后端编译失败!" -ForegroundColor Red
exit 1
}
Write-Host "后端编译完成 ✓" -ForegroundColor Green
Write-Host ""
# --------------------------------------------------
# 第2步编译前端并输出到 admin 目录
# --------------------------------------------------
Write-Host "[2/2] 编译前端(输出到 src\CncWebApi\admin\..." -ForegroundColor Yellow
$frontendDir = Join-Path $projectRoot "frontend"
# 安装依赖(如果 node_modules 不存在)
if (-not (Test-Path "$frontendDir\node_modules")) {
Write-Host " 安装前端依赖 ..." -ForegroundColor Gray
npm install --prefix $frontendDir
if ($LASTEXITCODE -ne 0) {
Write-Host "前端依赖安装失败!" -ForegroundColor Red
exit 1
}
}
# 构建前端vite.config.ts 已配置 outDir 指向 ../src/CncWebApi/admin
npm run build --prefix $frontendDir
if ($LASTEXITCODE -ne 0) {
Write-Host "前端编译失败!" -ForegroundColor Red
exit 1
}
$adminDir = Join-Path $projectRoot "src\CncWebApi\admin"
$fileCount = (Get-ChildItem $adminDir -Recurse -File).Count
Write-Host "前端编译完成 ✓($fileCount 个文件)" -ForegroundColor Green
Write-Host ""
# --------------------------------------------------
# 完成
# --------------------------------------------------
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 部署完成!" -ForegroundColor Cyan
Write-Host " 后端 APIhttp://192.168.1.202/api/health" -ForegroundColor White
Write-Host " 前端页面http://192.168.1.202/admin/" -ForegroundColor White
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""

@ -511,7 +511,7 @@ CREATE TABLE cnc_screen_filter (
---
## 三、日志库 cnc_log3张表)
## 三、日志库 cnc_log5张表)
### 3.1 原始采集JSON表 log_collect_raw按月分区
@ -594,6 +594,99 @@ CREATE TABLE log_collector_heartbeat (
---
### 3.4 采集分析日志表 log_collect_analysis按月分区
记录每次采集后对每台机床的数据变化分析。每次采集周期中,每台机床产生一条分析记录,包含与上一次采集数据的对比结果。
``sql
CREATE TABLE log_collect_analysis (
id BIGINT AUTO_INCREMENT,
analysis_time DATETIME NOT NULL COMMENT '分析时间(分区键)',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
machine_id INT NOT NULL COMMENT '机床ID关联cnc_machine',
analysis_type VARCHAR(30) NOT NULL COMMENT '分析类型枚举',
previous_program VARCHAR(200) NULL COMMENT '上一次NC程序名',
current_program VARCHAR(200) NULL COMMENT '本次NC程序名',
previous_part_count DECIMAL(15,5) NULL COMMENT '上一次零件计数',
current_part_count DECIMAL(15,5) NULL COMMENT '本次零件计数',
part_count_delta DECIMAL(15,5) NULL COMMENT '零件计数变化量(正=增加,负=减少)',
previous_status VARCHAR(20) NULL COMMENT '上一次设备状态',
current_status VARCHAR(20) NULL COMMENT '本次设备状态',
analysis_summary VARCHAR(500) NOT NULL COMMENT '人类可读的分析摘要',
analysis_detail JSON NULL COMMENT '完整的字段级对比数据JSON',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, analysis_time),
INDEX idx_address_time (collect_address_id, analysis_time),
INDEX idx_machine_time (machine_id, analysis_time),
INDEX idx_type_time (analysis_type, analysis_time),
INDEX idx_raw_log (raw_log_id),
INDEX idx_program_time (current_program, analysis_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集分析日志表(按月分区,记录每次采集对每台机床的数据变化分析)'
PARTITION BY RANGE (TO_DAYS(analysis_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
``
**analysis_type 分析类型枚举:**
| 值 | 含义 | 说明 |
|----|------|------|
| NORMAL_UNCHANGED | 正常无变化 | 数据与上次一致,正常加工中 |
| PART_COUNT_INCREASE | 零件数增加 | 零件计数增长,正常加工中 |
| PROGRAM_SWITCH | NC程序切换 | 程序名变更,触发上一段结账 |
| MANUAL_RESET | 手动清零 | 同程序下零件计数下降 |
| DEVICE_ONLINE | 设备上线 | 设备从离线恢复在线 |
| DEVICE_OFFLINE | 设备离线 | 设备变为离线状态 |
| NEW_DEVICE_FOUND | 发现新设备 | 采集到未注册的device |
| DATA_ANOMALY | 数据异常 | 字段缺失/格式错误/值异常 |
| COLLECTION_FAILED | 采集失败 | 本次采集请求失败 |
**数据量估算**每次采集周期×每台机床1条。假设每30秒1次采集×5-10个地址×每地址2-5台机床 = 600-2500条/分钟 = 约86-360万条/天。按月分区便于查询和清理。
---
### 3.5 采集周期汇总表 log_collect_cycle按月分区
记录每次采集周期一个地址的一次完整HTTP采集的汇总信息。一个周期对应 log_collect_raw 中的一条记录和 log_collect_analysis 中的多条记录。
``sql
CREATE TABLE log_collect_cycle (
id BIGINT AUTO_INCREMENT,
cycle_time DATETIME NOT NULL COMMENT '周期开始时间(分区键)',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
end_time DATETIME NULL COMMENT '周期结束时间',
duration_ms INT NULL COMMENT '本次采集总耗时(毫秒)',
total_machines INT NOT NULL DEFAULT 0 COMMENT '本周期采集的机床总数',
success_count INT NOT NULL DEFAULT 0 COMMENT '成功采集的机床数',
fail_count INT NOT NULL DEFAULT 0 COMMENT '失败采集的机床数',
change_distribution JSON NULL COMMENT '变化类型分布(如 {"PROGRAM_SWITCH":2,"PART_COUNT_INCREASE":5}',
has_anomaly TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在异常1=有异常)',
cycle_summary VARCHAR(500) NULL COMMENT '人类可读的周期汇总',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, cycle_time),
INDEX idx_address_time (collect_address_id, cycle_time),
INDEX idx_time (cycle_time),
INDEX idx_anomaly_time (has_anomaly, cycle_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集周期汇总表(按月分区,每次采集周期的汇总信息)'
PARTITION BY RANGE (TO_DAYS(cycle_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
``
**数据量估算**每次采集1条。每30秒×5-10个地址 = 10-20条/分钟 = 约1.4-2.9万条/天。远小于分析表,查询负担轻。
---
## 四、ER关系总览
`
@ -614,6 +707,10 @@ cnc_machine 1-----N cnc_alert
--- 日志库 ---
cnc_collect_address 1--N log_collect_raw (弱关联,跨库)
log_collect_raw 1--N log_collect_analysis (原始日志→分析记录)
log_collect_raw 1---1 log_collect_cycle (原始日志→周期汇总)
cnc_machine 1--N log_collect_analysis (弱关联,跨库)
cnc_collect_address 1--N log_collect_cycle (弱关联,跨库)
log_collector_heartbeat (独立弱关联collect_address_id)
log_system (独立)
`
@ -624,9 +721,9 @@ log_system (独立)
| 任务 | 频率 | 操作 |
|------|------|------|
| 创建新分区 | 每月1日 | 为cnc_collect_record、log_collect_raw、log_system预创建下下月分区 |
| 删除过期分区 | 每月1日 | DROP超过保留期的分区 |
| 清理心跳表 | 每天 | DELETE log_collector_heartbeat超过7天的记录 |
| 创建新分区 | 每月1日 | 为cnc_collect_record、log_collect_raw、log_system、log_collect_analysis、log_collect_cycle预创建下下月分区 |
| 删除过期分区 | 每月1日 | DROP超过保留期的分区(保留天数=0时不删除 |
| 清理心跳表 | 每天 | DELETE log_collector_heartbeat超过7天的记录(保留天数=0时不删除 |
| 清理告警表 | 每天 | DELETE cnc_alert已处理且超过180天的记录 |
---
@ -666,7 +763,16 @@ log_system (独立)
| log_collect_raw | idx_request_time | INDEX | 时间范围清理 |
| log_system | idx_level_time | INDEX | 按级别查错误日志 |
| log_system | idx_source_time | INDEX | 按来源查日志 |
| log_collect_raw | idx_success_time | INDEX | 按成功/失败筛选 |
| log_collect_analysis | idx_address_time | INDEX | 按采集地址+时间查分析 |
| log_collect_analysis | idx_machine_time | INDEX | 按机床+时间查分析 |
| log_collect_analysis | idx_type_time | INDEX | 按分析类型+时间查 |
| log_collect_analysis | idx_raw_log | INDEX | 按原始日志ID关联查 |
| log_collect_analysis | idx_program_time | INDEX | 按NC程序名+时间查 |
| log_collect_cycle | idx_address_time | INDEX | 按采集地址+时间查周期 |
| log_collect_cycle | idx_time | INDEX | 时间范围查询 |
| log_collect_cycle | idx_anomaly_time | INDEX | 查异常周期 |
| log_collector_heartbeat | idx_service_time | INDEX | 服务最新状态 |
| log_collector_heartbeat | idx_address_time | INDEX | 地址心跳历史 |
共计20张表34个索引含唯一索引
共计22张表43个索引(含唯一索引)

@ -0,0 +1,72 @@
# 日志分表与分析设计(草案)
## 目标与范围
- 对采集日志实现按月分区写入,提升写入吞吐和查询历史的性能。
- 提供可查询的分析摘要字段,便于后台看板展示本次采集及对比分析。
- 不引入新的依赖,不改变现有接口接口风格,确保向后兼容。
## 设计原则
- 高并发写入:分区写入尽量避免锁争用,分区表应有合理的索引覆盖查询条件。
- 易维护:分区边界需要可扩展,提供脚本自动创建未来分区的能力。
- 可观测:数据结构中包括分析摘要字段,便于 API 与前端直接展示。
- 兼容性:尽量复用现有字段名与数据类型,避免大规模重构。
## 目标表设计(草案)
- 新增分区表 logs_partitioned字段如下
- id BIGINT 自增主键
- machine_id INT机床唯一标识
- program_name VARCHAR(128):加工程序名
- log_time DATETIME日志时间点
- log_level VARCHAR(16):日志等级,默认 INFO
- raw_payload JSON原始日志数据
- analysis_summary TEXT本次采集的分析摘要可追溯、可回放
- analysis_version VARCHAR(64):分析逻辑版本
- 索引idx_machine_time(machine_id, log_time)、idx_program_time(program_name, log_time)
- 分区PARTITION BY RANGE (TO_DAYS(log_time))
- 示例分区p202401, p202402, ..., p202501按月份边界
## 分区键与分区策略
- 使用 LOG_TIME 的日期维度进行分区TO_DAYS(log_time) 作为分区区间值。
- 分区命名建议:按 yyyyMM 命名,如 p202401、p202402以便直观查看。
- 初始覆盖期:从系统落地起,覆盖过去 24 个月及未来 12 个月的分区。
- 未来分区维护:提供周期性脚本( monthly_partition_maintenance.sql )来创建新月份分区。
## 分区维护脚本(草案)
- 提供简单的迁移脚本 skeleton示例位于 database/sqls/partitioned_logs.sql 的分区创建段。
- 未来可将分区维护封装成 SQL store 程序或外部脚本bash/python自动按月扩容。
- 维护内容包括:创建新的分区、对旧分区归档/归档策略,及对相关日志表的清理策略。
## 数据分析字段与 API 将暴露的摘要
- analysis_summary 字段存放本次采集的要点、差异、以及可能的异常记录。
- 通过 API 提供最新采集日志及其分析摘要,便于前端看板展示与对比。
- 日志写入路径保持向后兼容:原有原始日志字段保留,新增分析字段仅供访问。
## API/前端对接要点
- 后端应提供查询接口:
- 根据 machine_id、时间范围筛选日志
- 返回最新采集日志及分析摘要
- 前端看板要显示:
- 最新日志时间、机器、程序、分析摘要要点
- 与历史时间点对比的分析摘要对比信息
## 验证与测试计划(草案)
- 基础验证:分区表创建是否成功、是否能够写入数据、是否能查询到分区信息。
- 功能验证:
- 日志写入时附带 analysis_summary 字段
- API 能返回最新采集日志及分析摘要
- 性能/压力测试:在高并发写入情况下分区表的锁争用情况、查询历史时的响应时间。
- 回归测试:现有日志写入路径不受影响,现有看板字段仍可访问
## 后续工作与风险
- 风险:分区设计对现有 ORM/DAO 层的影响,旧查询路径需兼容。
- 后续:与前端看板字段对齐、以及归档/清理策略的落地实现。
### 草案作者CI 项目组
### 审核日期2026-05
## 看板草案设计摘要(日志看板)
- 目标:展示最近采集日志、分析摘要,以及提供筛选入口,便于运维与分析人员快速定位问题。
- 数据字段日志时间戳、机床ID、加工程序名、日志等级、日志摘要。以及可选的分析摘要文本。
- 后端端点草案GET /api/logs/dashboard返回数据结构包含最近日志、等级分布、总条数和可展示的分析摘要。
- 前端展示要点:顶部筛选区、摘要统计、最近日志表格、日志摘要截断预览。
- 验证要点:前端路由可打开,后端接口能返回结构化数据,字段与前端模板对齐。

@ -0,0 +1,26 @@
# 采集日志 索引
> 版本v1.0
> 最后更新2026-04-25
---
## 模块概述
展示每次采集的分析日志、采集周期汇总和原始采集数据
## 页面清单
| 页面编号 | 页面名称 | 路由 | 功能概述 |
|---------|---------|------|---------|
| 13-01 | 采集日志页面 | /collect-log | 3个Tab采集周期+分析日志+原始数据) |
## 页面功能详情
### 13-01 采集日志页面
**路由**`/collect-log`
**功能概述**在同一页面以三个标签页展示采集周期、分析日志、原始数据支持弹窗查看对比信息和JSON原始数据。
**交互关系说明**:查看分析详情 → 弹窗展示字段对比;查看原始数据 → 弹窗展示JSON
---

@ -0,0 +1,131 @@
# 采集日志-规范
本规范用于管理后台「采集日志」模块的前端实现覆盖组件选用、数据表格定义、查询筛选、分页、时间选择、标签颜色映射及操作按钮等方面的设计约定确保UI风格统一、交互清晰、数据结构对齐后端接口。以下规范与现有模块风格保持一致参考文档界面变更执行规范、前端全局规范等。
## 1. 组件规范
- 数据展示:使用 Element Plus 的 el-table 及 el-table-column。
- 查询区域:使用 el-form、el-form-item、el-input、el-select、el-date-picker、el-time-picker如需等。
- 选项卡el-tabs、el-tab-pane分别承载分析记录、采集周期与原始数据三大区域。
- 弹窗与详情el-dialog 展示分析详情及原始日志原文等。
- 按钮与标签el-button、el-tag、el-tooltip 提供操作入口及信息提示。
- 提示与对齐:使用 el-message、el-notification 提供反馈,表格列对齐统一采用左对齐。
- 加载与空态:使用 el-skeleton、empty 组件作为加载与无数据态的占位显示。
## 2. 数据表格列定义
### Tab1分析记录 表格列
| 字段名 | 展示含义 | 注意事项 |
|---|---|---|
| time | 日志分析时间 | 日期时间格式统一为 yyyy-MM-dd HH:mm:ss |
| address | 采集地址 | 全局唯一识别码或名称 |
| machine | 机床 | 机器编号/名称 |
| type | 分析类型 | 参考下方标签颜色映射 |
| previousProgram | 前程序 | 准确的程序名 |
| currentProgram | 当前程序 | 当前正在执行的程序名 |
| yieldDelta | 产量变化 | 数值变化量,单位需一致 |
| summary | 摘要 | 简短描述分析结果 |
| actions | 操作 | 查看详情按钮等 |
### Tab2采集周期 表格列
| 字段名 | 展示含义 | 备注 |
|---|---|---|
| time | 周期开始时间 | 统一时间格式 |
| address | 采集地址 | |
| totalMachines | 总机床数 | 统计口径一致 |
| success | 成功次数 | |
| failure | 失败次数 | |
| anomaly | 异常次数 | |
| distribution | 数据分布摘要 | 摘要字段,方便快速浏览 |
| summary | 摘要 | 简要描述周期信息 |
### Tab3原始数据 表格列
| 字段名 | 展示含义 | 备注 |
|---|---|---|
| rawId | 日志原始ID | |
| logTime | 日志时间 | 解析时间戳 |
| contentPreview | 内容预览 | 仅显示摘要片段 |
| sourceAddress | 数据来源地址 | |
> 注:表格列定义仅为前端展现的约束,实际字段名称以后端接口返回字段为准。
## 3. 查询筛选条件
- 时间范围筛选:使用 el-date-picker 的 date 范围选择,格式 yyyy-MM-dd HH:mm:ss范围值作为请求的 startTime/endTime。
- 采集地址:下拉或输入框筛选,支持模糊匹配地址名称。
- 机床:下拉选择框,按可选机床列出。
- 分析类型多选筛选UI 采用 el-tag 形式展示筛选条件。
- 程序名:文本输入,用于匹配前程序或当前程序。
- 分页参数page、pageSizepageSize 默认为 20支持切换 [20, 50, 100]。
- 备注:筛选条件应可组合使用,且具备清空按钮重置。
## 4. 分页规范
- pageSize 选项20、50、100
- 分页控件样式el-pagination显示总条数、每页条数、页码跳转
- 数据加载时,应显示加载中状态,切换分页时防重复请求,避免并发冲突。
## 5. API 响应格式
- 前端对接后端 API 的统一响应格式为:
```
{ code: 0, message: "success", data: ... }
```
- 当 code 非 0 时,展示错误信息 message必要时提供可复现的错误提示。
## 6. 时间选择器规范
- 使用 el-date-picker类型为 daterange时间格式统一为 yyyy-MM-dd HH:mm:ssvalue-format 亦为 yyyy-MM-dd HH:mm:ss。
- 时间筛选优先级高于别的筛选条件;在无时区信息情况下以服务器时区为准。
- Tab1 的时间范围应以分析时间为准Tab2 的时间范围以周期起止时间为准。
## 7. 分析类型标签颜色映射
- NORMAL_UNCHANGED -> info
- PART_COUNT_INCREASE -> success
- PROGRAM_SWITCH -> warning
- MANUAL_RESET -> warning
- DEVICE_ONLINE -> success
- DEVICE_OFFLINE -> danger
- NEW_DEVICE_FOUND -> danger
- DATA_ANOMALY -> danger
- COLLECTION_FAILED -> danger
## 8. 操作按钮规范
- 行级操作:查看详情按钮,点击后弹出详情弹窗,展示分析详情或原始数据片段。
- 关联跳转:若需要跳转到关联页面,提供跳转按钮,并在按钮上标注目标路径或模块名称。
- 只在需要的场景启用导出、复制等辅助按钮,避免界面拥挤。
- 按钮颜色使用 Element Plus 默认颜色方案,确保与全局主题一致。
## 9. 路由与权限(简要)
- 路由路径:/collect-log遵循现有路由命名约定
- 页面权限:遵循统一的路由权限策略,必要时标注只读/编辑权限。
## 10. 错误处理与空态
- 网络异常、接口返回错误应给出清晰的错误信息提示。
- 数据为空时展示空态组件,辅以引导文本。
## 11. 视觉与可访问性
- 保持列宽一致,避免列数据溢出,必要时显示省略号并悬浮显示完整内容。
- 表格行高、文本颜色、对比度符合无障碍要求,确保在常用屏幕下可读。
## 12. 性能与缓存
- 大数据分页时采用服务端分页,前端仅请求当前页数据。
- 尽量使用简化字段,减少表格渲染开销。
## 13. 安全与数据脱敏
- 脱敏处理涉及敏感字段的展示,必要时对字段进行脱敏或隐藏。
- 请求应携带适用的鉴权信息,后端返回的数据不可直接暴露敏感字段。
## 14. 版本与兼容性
- 文档版本随代码同步更新,保持与后端接口版本一致。
- 如后端接口变更,及时在前端更新字段映射及展示逻辑。
## 15. 兼容性与国际化
- 支持简体中文显示,未来如扩展到多语言需提供翻译资源。
- UI 组件需要兼容主流浏览器,与公司统一浏览器兼容性要求一致。
## 16. 维护与扩展点
- 行为和字段若增加,必须更新对应的索引与页面文档。
- 新增字段应通过后端接口文档对齐,并同步 Mock 数据结构。
## 17. 附加说明
- 本规范仅定义前端展示层的通用原则,具体字段名称以后端接口返回字段为准。
- 如遇特殊场景,需与后端对接团队共同确认后再实现。
---
备注:如需对照其他模块的设计风格,请参考文档:`docs/02-功能清单/07-告警管理/`等的规范表述。

@ -0,0 +1,117 @@
# 13-01 采集日志页面
_
本文档按照20项模板撰写用于前端实现“采集日志页面”的设计与交互规范。页面在管理后台中放置于 /collect-log 路由下包含三个标签页分析记录、采集周期、原始数据。_
## 1. 页面基本信息
- 模块:管理后台 -> 采集日志
- 页面名称:采集日志页面
- 路由:/collect-log
- 版本v1.0
- 作者:设计/前端团队
- 依赖Element Plus、Vue 3、TypeScript
## 2. 布局结构
- 顶部区域:查询条件区域(时间范围、地址、机床、分析类型、程序名等)
- 中部区域Tabs 切换,包含三个 Tab分析记录、采集周期、原始数据
- 各 Tab 之下为各自的表格 + 分页控件
- 底部/弹窗区域:查看详情弹窗、关联跳转入口
## 3. 数据表格列定义
- 分析记录Tab1时间、地址、机床、分析类型、前程序、当前程序、产量变化、摘要、操作
- 采集周期Tab2时间、地址、总机床、成功、失败、异常、分布、摘要
- 原始数据Tab3原始日志ID、时间、内容摘要、来源地址
## 4. 查询条件字段
- 时间范围:日期时间范围选择器,格式 yyyy-MM-dd HH:mm:ss
- 采集地址:下拉或文本输入,支持模糊匹配
- 机床:下拉选择
- 分析类型:多选过滤标签
- 程序名:文本输入
- 提交触发:查询按钮,重置按钮
- 每页显示条数:分页组件控制
## 5. API端点定义
- GET /api/admin/collect-log/analysis
- GET /api/admin/collect-log/analysis/{id}
- GET /api/admin/collect-log/analysis/by-raw/{rawLogId}
- GET /api/admin/collect-log/cycle
- GET /api/admin/collect-log/raw
## 6. Mock数据结构
- 分析记录列表 Mock数组对象包含 time、address、machine、type、previousProgram、currentProgram、yieldDelta、summary
- 分析详情 Mock含 detail 字段、difference 和对比数据
- 周期数据 Mocktime、address、totalMachines、success、failure、anomaly、distribution、summary
- 原始数据 MockrawId、logTime、contentPreview、sourceAddress
## 7. 交互行为
- 标签页切换:切换时重新加载对应表格数据
- 分页:点击页码、切换每页条数时加载对应页数据
- 查看详情弹窗:选中行后弹出,展示分析详情或对比信息
- 关联跳转:点击相关行的跳转按钮,跳转至对应的关联页面
- 原始数据查看:点击原始数据行,弹出 JSON 原始数据预览
## 8. 组件树
- 组件树示例CollectLogPage -> (QueryForm, ElTabs -> (AnalysisTab, CycleTab, RawTab) -> (ElTable, ElPagination)) -> DetailDialog
- 表单控件QueryFormel-form、el-form-item、el-date-picker、el-input、el-select、el-tag
- 表格与分页el-table、el-table-column、el-pagination
- 弹窗el-dialog
- 细化的子组件AnalysisTable、CycleTable、RawTable、DetailDialog可复用
## 9. 路由配置
- 路由路径:/collect-log
- 路由组件CollectLogPage
- 路由守卫:同其他管理后台页面的权限控制
- 嵌套路由(若有需要):/collect-log/analysis、/collect-log/cycle、/collect-log/raw
## 10. 数据校验与错误处理
- 搜索条件必填项的格式校验:时间范围格式、文本字段长度等
- API 请求失败时,展示友好错误信息并保留上一次有效数据展示
- 弹窗中的对比信息若数据为空,显示空态提示
## 11. 性能与优化点
- 分页按需加载,避免一次性加载所有数据
- 表格列尽量避免使用复杂自定义渲染,必要时使用虚拟滚动
- 原始数据区域对大文本使用内容摘要展示,点击展开查看全文
## 12. 国际化与无障碍
- 暂定中文显示,未来支持多语言资源
- 组件具备基础无障碍特性,表格可读性良好
## 13. 数据引用与结构(接口关联)
- 参考后端 API 端点,数据结构需与后端返回字段严格对齐
- 需在页面中以常量形式存放端点引用及字段映射,便于维护
## 14. 组件样式与风格
- 遵循全局主题,表格列宽可自适应,必要时固定宽度以确保对齐
- 按钮、标签、弹窗风格与全局规范保持一致
## 15. 版本与变更记录
- 每次变更需记录版本号和变更摘要,便于回溯
- 与模块索引、总览文档保持同步
## 16. 技术实现概要
- 主要使用 Vue 3 + TypeScript + Element Plus按项目的前端全局规范实现
- 数据获取走统一的 API 调用封装,错误统一处理
- UI 组件具有可复用性,便于其他模块复用
## 17. 数据流与状态管理
- 页面局部状态通过 Vue 的响应式系统管理
- 表格数据、筛选条件和分页状态保持在组件状态中,必要时通过 Store/Pinia 共享
## 18. 测试用例设计
- 基本渲染测试:页面渲染、表格列正确显示
- 筛选与分页功能测试
- 弹窗查看详情测试
- 跳转与联动测试
## 19. 部署与运行
- 本地调试:确保 /collect-log 路由可访问,接口 Mock/测试环境数据正常
- 与后端联调时保持端点一致,返回字段映射不变
## 20. 变更记录
- 记录本页面文档的变更时间、版本和修改内容,便于团队追踪
---
备注:本文档遵循文档结构规范,确保与其他模块文档风格一致,如需对齐请参考 `docs/02-功能清单/02-文件夹创建规范.md``docs/02-界面变更执行规范.md`

@ -396,3 +396,18 @@
4. 后端按正式API列实现接口返回数据结构严格对齐页面文件§9的定义
5. 前端 Mock 开发时按 Mock URL 列调用正式联调时切换到正式API列
6. 禁止只新增一列而遗漏另一列——每次新增接口必须两列同时更新
### 3.14 采集日志模块
| 端点 | Method | Mock | 说明 |
|------|--------|------|------|
| /api/admin/collect-log/analysis | GET | /mock-api/admin/collect-log/analysis | 采集分析日志(分页) |
| /api/admin/collect-log/analysis/{id} | GET | /mock/api/admin/collect-log/analysis/{id} | 分析详情 |
| /api/admin/collect-log/analysis/by-raw/{rawLogId} | GET | /mock/api/admin/collect-log/analysis/by-raw/{rawLogId} | 按原始日志查分析 |
| /api/admin/collect-log/cycle | GET | /mock-api/admin/collect-log/cycle | 采集周期(分页) |
| /api/admin/collect-log/raw | GET | /mock-api/admin/collect-log/raw | 原始采集数据(分页) |
**查询参数说明:**
- analysis端点startDate, endDate, collectAddressId, machineId, analysisType, programName, page, pageSize
- cycle端点startDate, endDate, collectAddressId, hasAnomaly, page, pageSize
- raw端点startDate, endDate, collectAddressId, isSuccess, page, pageSize

@ -0,0 +1,196 @@
# Windows 服务状态管理功能 — 上线/回滚文档
**分支**: `feat/windows-service-status-auto`
**目标**: `main`
**日期**: 2026-05-04
**PR URL**: https://git.cjy.net.cn/jcl/haoliang-net/compare/main...feat/windows-service-status-auto
---
## 一、功能概述
为 CncCollector 采集服务添加原生 Windows Service 支持,实现双模式运行(控制台调试 + Windows 服务),并在管理后台仪表盘准确展示服务运行状态(未安装/运行中/启动中/启动失败/已停止),支持远程启动/停止操作。
## 二、变更清单
### 2.1 后端变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/CncCollector/CncCollectorService.cs` | 新增 | ServiceBase 包装OnStart/OnStop/OnPause/OnContinue/OnShutdown |
| `src/CncCollector/ProjectInstaller.cs` | 新增 | InstallUtil 安装器配置 |
| `src/CncCollector/Program.cs` | 修改 | 双模式入口(--console 调试/无参数=服务模式/--install/--uninstall |
| `src/CncCollector/CncCollector.csproj` | 修改 | 添加 System.ServiceProcess + System.Configuration.Install 引用 |
| `src/CncService/Interface/IWindowsServiceChecker.cs` | 新增 | 服务状态检测接口 + ServiceStatusEnum 枚举 |
| `src/CncService/Impl/WindowsServiceChecker.cs` | 新增 | 基于 ServiceController 的实现 |
| `src/CncService/Impl/DashboardService.cs` | 修改 | 注入 IWindowsServiceChecker增强 GetCollectorStatus 返回 serviceStatus/serviceName/serviceMessage |
| `src/CncWebApi/Controllers/DashboardController.cs` | 修改 | StartCollector 前置状态检查NotInstalled→40001, Running→40002 |
| `src/CncWebApi/Infrastructure/ServiceResolver.cs` | 修改 | DI 注入 WindowsServiceChecker |
| `src/CncCollector/scripts/install.ps1` | 新增 | 安装脚本 v2.0InstallUtil/NSSM/SC 三级降级) |
| `src/CncCollector/scripts/uninstall.ps1` | 新增 | 卸载脚本 v2.0(三级降级卸载 + 交互式清理) |
### 2.2 前端变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `frontend/src/types/index.ts` | 修改 | CollectorStatus 接口扩展 serviceStatus/serviceName/serviceMessage 字段 |
| `frontend/src/views/dashboard/DashboardPage.vue` | 修改 | 服务状态标签映射、未安装引导提示、启动按钮逻辑 |
### 2.3 测试变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `tests/CncService.Tests/WindowsServiceCheckerTests.cs` | 新增 | 服务检测单元测试2 个用例) |
| `tests/CncService.Tests/DashboardServiceTests.cs` | 新增 | DI 场景测试3 个用例NotInstalled/Running/Starting |
### 2.4 CI/CD 变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `.github/workflows/ci-windows.yml` | 新增 | Windows CI 流水线(构建 + 测试 + 前端构建) |
## 三、错误码定义
| 错误码 | 含义 | 触发条件 |
|--------|------|----------|
| 40001 | 服务未安装 | Windows 中不存在 CncCollector 服务 |
| 40002 | 服务已在运行 | 服务当前状态为 Running |
| 50002 | 服务启动失败 | 启动操作超时或返回错误 |
| 50003 | 服务不可用 | 服务状态异常 |
## 四、验证结果
### 4.1 编译验证
| 项目 | 结果 | 备注 |
|------|------|------|
| dotnet build全解决方案 | ✅ 0 错误 | 82 个 CS1591 警告(既有 XML 注释缺失) |
| npm run build前端 | ✅ 0 错误 | vue-tsc 类型检查 + vite 构建通过 |
### 4.2 单元测试
| 测试用例 | 结果 |
|----------|------|
| `WindowsServiceCheckerTests.GetServiceStatus_NotInstalled_ForUnknownService` | ✅ 通过 |
| `WindowsServiceCheckerTests.TryStartService_NotInstalled_ReturnsNotInstalled` | ✅ 通过 |
| `DashboardServiceTests.GetCollectorStatus_With_NotInstalled_Service_Returns_NotInstalled_State` | ✅ 通过 |
| `DashboardServiceTests.GetCollectorStatus_With_Running_Heartbeats_Returns_Running_State` | ✅ 通过 |
| `DashboardServiceTests.GetCollectorStatus_With_Starting_ServiceStatus_Returns_Starting_State` | ✅ 通过 |
**5/5 测试全部通过。**
注:其他 55 个失败的测试为既有数据库外键约束问题,与本次改动无关。
## 五、上线步骤
### 5.1 前置条件
- 服务器192.168.1.202Windows Server
- MariaDB 11.8 已运行
- IIS 应用池 `haoliang` 已配置
- 当前 CncCollector 以控制台模式运行(需停掉)
### 5.2 上线流程
```powershell
# 1. 合并分支到 main
git checkout main
git merge feat/windows-service-status-auto
git push
# 2. 构建后端
dotnet build -c Release
# 3. 构建前端
cd frontend
npm ci
npm run build
cd ..
# 4. 部署 Web API 到 IIS
# 复制 src/CncWebApi/bin/Release 到 C:\inetpub\wwwroot\haoliang
# 复制 frontend/dist 到 C:\inetpub\wwwroot\haoliang\admin
Import-Module WebAdministration
Restart-WebAppPool -Name 'haoliang'
# 5. 停掉当前控制台模式的 CncCollector如有
# 任务管理器结束 CncCollector.exe 进程
# 6. 安装为 Windows 服务
cd src/CncCollector/scripts
.\install.ps1
# 7. 验证服务状态
Get-Service CncCollector
# 应显示 Status=Running
# 8. 验证管理后台仪表盘
# 浏览器打开 http://192.168.1.202/admin/
# 查看首页采集服务状态卡片,应显示"运行中"
```
### 5.3 验证清单
- [ ] 管理后台仪表盘服务状态显示正确
- [ ] 服务未安装时显示"未安装"并提供安装引导
- [ ] 启动按钮可远程启动服务
- [ ] 停止按钮可远程停止服务
- [ ] 服务状态实时刷新30秒心跳
- [ ] 安装脚本 install.ps1 正常工作
- [ ] 卸载脚本 uninstall.ps1 正常工作
## 六、回滚方案
### 6.1 回滚触发条件
- 服务安装失败无法启动
- 管理后台仪表盘状态显示异常
- 采集数据丢失或中断超过 10 分钟
### 6.2 回滚步骤
```powershell
# 1. 卸载 Windows 服务
cd src/CncCollector/scripts
.\uninstall.ps1
# 2. 回退代码到上一个稳定版本
git checkout main
git revert HEAD # 回退本次合并
git push
# 3. 重新部署旧版 Web API
# 从备份恢复 IIS 目录
Import-Module WebAdministration
Restart-WebAppPool -Name 'haoliang'
# 4. 恢复控制台模式运行
# 用旧版 CncCollector.exe 以控制台模式启动
Start-Process -FilePath "C:\path\to\CncCollector.exe" -ArgumentList "--console"
```
### 6.3 回滚注意事项
- 卸载服务前先停止服务
- 数据库无 schema 变更,无需回滚数据库
- 前端回滚随 IIS 部署自动恢复
- 回滚后确认采集数据恢复正常
## 七、风险评估
| 风险项 | 级别 | 应对措施 |
|--------|------|----------|
| Windows 服务权限不足 | 低 | install.ps1 自动请求管理员权限 |
| 服务安装失败 | 中 | 提供三级降级安装策略InstallUtil→NSSM→SC |
| 心跳超时误判 | 低 | 超时阈值设为 90 秒3个心跳间隔 |
| 服务启动超时 | 中 | TryStartService 默认等待 30 秒,可配置 |
| 前端类型错误 | 已修复 | CollectorStatus 接口合并、serviceStatusLabel 移入 script setup |
## 八、提交记录
| 提交 | 说明 |
|------|------|
| `6e5b296` | 增加 Windows Service 原生支持,双模式运行和服务安装卸载 |
| `9e3a759` | 修复仪表盘采集服务状态判断:增加心跳超时检测 |
| `e9802a1` | 前端适配、后端测试扩展、CI/Playwright E2E |
| `d8f5925` | 扩展 DashboardServiceTests DI 场景 |
| `0212ed6` | CI 工作流和扩展测试 |
| `acdc502` | 新增 Starting 状态测试用例 |
| (待提交) | 修复前端类型错误 + CI 配置修复 |

@ -0,0 +1,68 @@
# 前端构建与部署规范
## 构建输出配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| `base` | `/admin/` | 构建时资源引用路径前缀 |
| `outDir` | `src/CncWebApi/admin` | 构建产物输出目录 |
| `emptyOutDir` | `true` | 允许清空目标目录 |
## IIS 部署结构
```
E:\opencode\haoliang\src\CncWebApi\ ← IIS站点根目录
├── admin\ ← 前端构建输出
│ ├── index.html
│ ├── assets\
│ ├── favicon.svg
│ └── icons.svg
├── api\ ← 后端API
├── bin\ ← 后端DLL
└── ...other backend files...
```
## 资源路径映射
- 浏览器访问 `/admin/login` → IIS物理路径 `E:\opencode\haoliang\src\CncWebApi\admin\index.html`
- 前端资源引用 `/admin/assets/xxx.js` → IIS物理路径 `E:\opencode\haoliang\src\CncWebApi\admin\assets\xxx.js`
## 构建命令
```bash
cd frontend
npm run build
```
## 部署检查清单
1. ✅ `vite.config.ts``base` 必须为 `/admin/`
2. ✅ `vite.config.ts``outDir` 必须指向 `src/CncWebApi/admin`
3. ✅ IIS 站点物理路径必须为 `E:\opencode\haoliang\src\CncWebApi`
4. ✅ 构建后 `src/CncWebApi/admin/index.html` 存在
5. ✅ 构建后 `src/CncWebApi/admin/assets/` 目录存在
## 常见问题
### 资源404
- 症状:页面能加载,但 JS/CSS 返回 404
- 原因:`base` 配置与 IIS 物理路径不匹配
- 检查:浏览器 DevTools Network 面板JS 引用路径是否为 `/admin/assets/xxx.js`
### 页面404
- 症状:首页能加载,但路由(如 `/admin/dashboard`)返回 404
- 原因IIS 缺少 URL Rewrite 规则,需配置通配符路由 fallback
- 检查:`src/CncWebApi/Web.config` 是否存在且配置正确
## vite.config.ts 参考配置
```typescript
export default defineConfig(({ command }) => ({
// build时部署到/admin/子路径dev时用根路径
base: command === 'build' ? '/admin/' : '/',
build: {
outDir: path.resolve(__dirname, '../src/CncWebApi/admin'),
emptyOutDir: true,
},
}))
```

@ -0,0 +1,106 @@
/**
* CNC - Playwright E2E
*
* IIS
* -
* - /simulatorUI
*
* `npx playwright test e2e/simulator.spec.ts --project=chromium`
* IISCncSimulator
*/
import { test, expect, type Page } from '@playwright/test'
// === 登录辅助函数 ===
// 使用完整URL避免baseURL(127.0.0.1)与IIS绑定(192.168.1.202)不匹配
const BASE = 'http://192.168.1.202/admin'
async function login(page: Page) {
await page.goto(`${BASE}/login`)
await page.waitForLoadState('networkidle')
await page.waitForSelector('input', { timeout: 10000 })
const inputs = page.locator('input')
await inputs.nth(0).fill('admin')
await inputs.nth(1).fill('admin123')
await page.locator('button').last().click()
await page.waitForURL(/\/(dashboard|admin\/?$)/, { timeout: 15000 })
}
// ============================================================
// 套件1侧边栏导航
// ============================================================
test.describe('模拟采集侧边栏导航', () => {
test.beforeEach(async ({ page }) => {
await login(page)
})
test('侧边栏有"模拟采集"菜单项', async ({ page }) => {
const menuItems = page.locator('.el-menu-item')
const simulatorItem = menuItems.filter({ hasText: '模拟采集' })
await expect(simulatorItem).toBeVisible()
})
test('点击"模拟采集"菜单跳转到总览页', async ({ page }) => {
const menuItems = page.locator('.el-menu-item')
const simulatorItem = menuItems.filter({ hasText: '模拟采集' })
await simulatorItem.click()
await page.waitForURL(/\/simulator$/, { timeout: 10000 })
expect(page.url()).toContain('/simulator')
})
})
// ============================================================
// 套件2总览页——模拟器未连接状态
// ============================================================
test.describe('模拟采集总览页(模拟器未连接)', () => {
test.beforeEach(async ({ page }) => {
await login(page)
// 通过侧边栏菜单导航到模拟采集页面
const menuItems = page.locator('.el-menu-item')
const simulatorItem = menuItems.filter({ hasText: '模拟采集' })
await simulatorItem.click()
await page.waitForURL(/\/simulator$/, { timeout: 10000 })
// 等待ping API调用完成
await page.waitForTimeout(2000)
})
test('页面显示"模拟器未连接"', async ({ page }) => {
await expect(page.locator('text=模拟器未连接')).toBeVisible()
})
test('页面显示友好提示信息', async ({ page }) => {
await expect(page.locator('text=模拟器未启动')).toBeVisible()
})
test('三个操作按钮正确禁用', async ({ page }) => {
await expect(page.locator('button:has-text("全部启动")')).toBeDisabled()
await expect(page.locator('button:has-text("全部停止")')).toBeDisabled()
await expect(page.locator('button:has-text("刷新配置")')).toBeDisabled()
})
test('面包屑导航显示"首页 / 模拟采集"', async ({ page }) => {
const breadcrumb = page.locator('.el-breadcrumb')
await expect(breadcrumb).toBeVisible()
await expect(breadcrumb.locator('text=首页')).toBeVisible()
await expect(breadcrumb.locator('text=模拟采集')).toBeVisible()
})
test('页面无JavaScript异常排除预期的网络错误', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text())
})
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(3000)
// 过滤掉模拟器不可达的预期错误
const realErrors = errors.filter(e =>
!e.includes('simulator') &&
!e.includes('50001') &&
!e.includes('Network Error') &&
!e.includes('ERR_CONNECTION_REFUSED') &&
!e.includes('404')
)
expect(realErrors).toEqual([])
})
})

@ -0,0 +1,82 @@
/** Mock
* alert.ts 5 Mock
*/
import type { MockMethod } from './types'
interface CollectAnalysis {
id: number
analysisTime: string
collectAddressId: number
addressName?: string
machineId: number
machineName?: string
analysisType: string
previousProgram?: string
currentProgram?: string
partCountDelta?: number
analysisSummary?: string
}
interface CollectCycle {
id: number
cycleTime: string
collectAddressId: number
addressName?: string
totalMachines: number
successCount: number
failCount: number
hasAnomaly: number
changeDistribution?: string
cycleSummary?: string
}
interface CollectRaw {
id: number
logTime: string
sourceAddress?: string
contentPreview?: string
}
const analyses: CollectAnalysis[] = [
{ id: 1, analysisTime: '2026-05-05 10:30:00', collectAddressId: 1, addressName: 'FANUC-A栋', machineId: 1, machineName: '西-1.8', analysisType: 'NORMAL_UNCHANGED', previousProgram: 'O001', currentProgram: 'O002', partCountDelta: 0, analysisSummary: 'O001 → O002 程序切换后无产量变化' },
{ id: 2, analysisTime: '2026-05-05 11:15:00', collectAddressId: 1, addressName: 'FANUC-A栋', machineId: 2, machineName: '西-1.10', analysisType: 'PART_COUNT_INCREASE', previousProgram: 'O003', currentProgram: 'O004', partCountDelta: 25, analysisSummary: '产量增加,来自新作业' },
{ id: 3, analysisTime: '2026-05-05 12:05:00', collectAddressId: 2, addressName: 'FANUC-B栋', machineId: 3, machineName: '西-2.1', analysisType: 'PROGRAM_SWITCH', previousProgram: 'M5', currentProgram: 'M6', partCountDelta: -5, analysisSummary: '切换程序导致产量略降' },
{ id: 4, analysisTime: '2026-05-05 12:30:00', collectAddressId: 3, addressName: 'FANUC-C栋', machineId: 4, machineName: '东-3.2', analysisType: 'DEVICE_ONLINE', previousProgram: 'P10', currentProgram: 'P10', partCountDelta: 0, analysisSummary: '设备在线,正常运行' },
{ id: 5, analysisTime: '2026-05-05 13:01:00', collectAddressId: 1, addressName: 'FANUC-A栋', machineId: 1, machineName: '西-1.8', analysisType: 'DATA_ANOMALY', previousProgram: 'O001', currentProgram: 'O001', partCountDelta: 0, analysisSummary: '检测到产量异常,需人工复核' },
{ id: 6, analysisTime: '2026-05-05 14:22:00', collectAddressId: 2, addressName: 'FANUC-B栋', machineId: 6, machineName: '西-2.6', analysisType: 'COLLECTION_FAILED', previousProgram: 'O010', currentProgram: 'O010', partCountDelta: 0, analysisSummary: '日志采集失败' },
{ id: 7, analysisTime: '2026-05-05 15:40:00', collectAddressId: 2, addressName: 'FANUC-B栋', machineId: 7, machineName: '西-2.7', analysisType: 'NEW_DEVICE_FOUND', previousProgram: 'O222', currentProgram: 'O223', partCountDelta: 0, analysisSummary: '发现新设备并加入采集' },
{ id: 8, analysisTime: '2026-05-05 16:12:00', collectAddressId: 3, addressName: 'FANUC-C栋', machineId: 8, machineName: '东-3.4', analysisType: 'MANUAL_RESET', previousProgram: 'N/A', currentProgram: 'N/A', partCountDelta: 0, analysisSummary: '管理员手动重置状态' },
]
const cycles: CollectCycle[] = [
{ id: 1, cycleTime: '2026-05-05 10:30:00', collectAddressId: 1, addressName: 'FANUC-A栋', totalMachines: 8, successCount: 7, failCount: 1, hasAnomaly: 0, changeDistribution: '{"PROGRAM_SWITCH":2,"PART_COUNT_INCREASE":3,"NORMAL_UNCHANGED":3}', cycleSummary: '共8台机床完成分析' },
{ id: 2, cycleTime: '2026-05-05 11:30:00', collectAddressId: 1, addressName: 'FANUC-A栋', totalMachines: 8, successCount: 8, failCount: 0, hasAnomaly: 0, changeDistribution: '{"PROGRAM_SWITCH":0,"PART_COUNT_INCREASE":0,"NORMAL_UNCHANGED":8}', cycleSummary: '稳定分析周期' },
{ id: 3, cycleTime: '2026-05-05 13:00:00', collectAddressId: 2, addressName: 'FANUC-B栋', totalMachines: 5, successCount: 4, failCount: 1, hasAnomaly: 1, changeDistribution: '{"DATA_ANOMALY":1}', cycleSummary: '存在数据异常' },
{ id: 4, cycleTime: '2026-05-05 14:40:00', collectAddressId: 3, addressName: 'FANUC-C栋', totalMachines: 6, successCount: 6, failCount: 0, hasAnomaly: 0, cycleSummary: '全部机床完成' },
{ id: 5, cycleTime: '2026-05-05 15:20:00', collectAddressId: 1, addressName: 'FANUC-A栋', totalMachines: 8, successCount: 7, failCount: 1, hasAnomaly: 0, cycleSummary: '混合情况' },
]
const raws: CollectRaw[] = [
{ id: 1, logTime: '2026-05-05 10:28:12', sourceAddress: 'FANUC-A栋', contentPreview: '{"a":1,"b":2}' },
{ id: 2, logTime: '2026-05-05 11:29:45', sourceAddress: 'FANUC-B栋', contentPreview: '{"c":3,"d":4}' },
{ id: 3, logTime: '2026-05-05 12:31:02', sourceAddress: 'FANUC-C栋', contentPreview: '{"x":9,"y":8}' },
{ id: 4, logTime: '2026-05-05 13:45:10', sourceAddress: 'FANUC-A栋', contentPreview: '{"m":5}' },
{ id: 5, logTime: '2026-05-05 14:05:33', sourceAddress: 'FANUC-B栋', contentPreview: '{"n":6}' },
]
const mock: MockMethod[] = [
{ url: '/mock-api/admin/collect-log/analysis', method: 'get', response: () => ({ code: 0, data: { items: analyses, total: analyses.length, page: 1, pageSize: 20 } }) },
{ url: '/mock-api/admin/collect-log/analysis/:id', method: 'get', response: (req) => {
const id = Number(req.params.id)
const item = analyses.find(a => a.id === id)
return { code: 0, data: item || {} }
} },
{ url: '/mock-api/admin/collect-log/analysis/by-raw/:rawLogId', method: 'get', response: (req) => {
// 简单模拟:返回全部分析供查看关联
return { code: 0, data: { items: analyses } }
} },
{ url: '/mock-api/admin/collect-log/cycle', method: 'get', response: () => ({ code: 0, data: { items: cycles, total: cycles.length, page: 1, pageSize: 20 } }) },
{ url: '/mock-api/admin/collect-log/raw', method: 'get', response: () => ({ code: 0, data: { items: raws, total: raws.length, page: 1, pageSize: 20 } }) },
]
export default mock

@ -0,0 +1,139 @@
import type { MockMethod, MockRequest } from './types'
// 模拟采集地址数据
const mockAddresses = [
{
dbId: 1,
name: 'FANUC-1号',
url: 'http://localhost:9001/',
machineCount: 32,
machines: Array.from({ length: 32 }, (_, i) => ({
id: i + 1,
deviceCode: `fanake_1.${i + 2}`,
name: `西-1.${i + 2}`
})),
isRunning: true,
runningPort: 9001
},
{
dbId: 2,
name: 'FANUC-2号',
url: 'http://localhost:9002/',
machineCount: 16,
machines: Array.from({ length: 16 }, (_, i) => ({
id: i + 33,
deviceCode: `fanake_2.${i + 1}`,
name: `东-2.${i + 1}`
})),
isRunning: false,
runningPort: 0
}
]
// 模拟状态汇总
const mockStatusList = [
{
dbAddressId: 1,
name: 'FANUC-1号模拟',
port: 9001,
isRunning: true,
totalDevices: 32,
onlineDevices: 28,
requestCount: 1560,
dataChangeInterval: 10,
totalParts: 128
}
]
// 模拟设备状态
const mockDevices = [
{ deviceCode: 'fanake_1.2', desc: '西-1.2', scenario: 'machining', isOnline: true, programName: 'O504', partCount: 14, runStatus: 3, operateMode: 10, spindleSpeedSet: 3000, spindleSpeedActual: 2980, feedSpeedSet: 500, feedSpeedActual: 490, spindleLoad: 65, machiningStatus: 'cutting', scenarioTick: 45, scenarioDuration: 120 },
{ deviceCode: 'fanake_1.3', desc: '西-1.3', scenario: 'idle', isOnline: true, programName: 'O1', partCount: 53, runStatus: 1, operateMode: 10, spindleSpeedSet: 0, spindleSpeedActual: 0, feedSpeedSet: 0, feedSpeedActual: 0, spindleLoad: 5, machiningStatus: 'idle', scenarioTick: 12, scenarioDuration: 60 },
{ deviceCode: 'fanake_1.4', desc: '西-1.4', scenario: 'offline', isOnline: false, programName: 'O200', partCount: 0, runStatus: 0, operateMode: 0, spindleSpeedSet: 0, spindleSpeedActual: 0, feedSpeedSet: 0, feedSpeedActual: 0, spindleLoad: 0, machiningStatus: 'offline', scenarioTick: 0, scenarioDuration: 0 }
]
// 模拟请求日志
const mockLogs = Array.from({ length: 10 }, (_, i) => ({
index: 10 - i,
timestamp: `${String(14 + Math.floor(i / 6)).padStart(2, '0')}:${String(30 - i * 2).padStart(2, '0')}:${String(15 + i).padStart(2, '0')}`,
deviceCount: 28 + Math.floor(Math.random() * 5),
keyData: `fanake_1.2(P=14,Prog=O504,Run=3) fanake_1.3(P=53,Prog=O1,Run=1)`,
duration: 12 + Math.floor(Math.random() * 20),
fullJson: `[{"device":"fanake_1.2","desc":"西-1.2","tags":[{"id":"Tag5","value":"O504"}]}]`
}))
const mocks: MockMethod[] = [
// 探测模拟器
{ url: '/api/admin/simulator/ping', method: 'get', response: () => ({ code: 0, message: 'success', data: { running: true } }) },
// 获取采集地址列表
{ url: '/api/admin/simulator/addresses', method: 'get', response: () => ({ code: 0, message: 'success', data: mockAddresses }) },
// 获取模拟状态汇总
{ url: '/api/admin/simulator/status', method: 'get', response: () => ({ code: 0, message: 'success', data: mockStatusList }) },
// 启动模拟
{ url: '/api/admin/simulator/start', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true, port: 9001 } }) },
// 停止模拟
{ url: '/api/admin/simulator/stop', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 全部启动
{ url: '/api/admin/simulator/start-all', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 全部停止
{ url: '/api/admin/simulator/stop-all', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 重新加载
{ url: '/api/admin/simulator/reload', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true, count: 2 } }) },
// 单地址状态(参数化路由 :port 匹配动态端口)
{ url: '/api/admin/simulator/address/:port/status', method: 'get', response: () => ({
code: 0, message: 'success', data: {
name: 'FANUC-1号模拟', port: 9001, isRunning: true,
requestCount: 1560, successCount: 1540, failCount: 20,
totalDevices: 32, onlineDevices: 28, dataChangeInterval: 10,
scenarioMode: 'auto', networkError: 'normal',
startTime: '2026-05-06 10:00:00', uptime: '04:32:15',
devices: mockDevices
}
})},
// 单地址启动/停止/事件/设置POST类统返回ok
{ url: '/api/admin/simulator/address/:port/start', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/stop', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/event', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/interval', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/network', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/mode', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/add-device', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: '/api/admin/simulator/address/:port/remove-device', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 日志
{ url: '/api/admin/simulator/address/:port/logs', method: 'get', response: () => ({ code: 0, message: 'success', data: mockLogs }) },
// 统计
{ url: '/api/admin/simulator/address/:port/stats', method: 'get', response: () => ({
code: 0, message: 'success', data: {
totalDevices: 32, onlineDevices: 28, totalParts: 128,
partsByDevice: {
'fanake_1.2': { desc: '西-1.2', totalParts: 14, currentProgram: 'O504', currentPartCount: 14, programs: { 'O504': 14 } },
'fanake_1.3': { desc: '西-1.3', totalParts: 53, currentProgram: 'O1', currentPartCount: 53, programs: { 'O1': 53 } }
}
}
})},
// 事件历史
{ url: '/api/admin/simulator/address/:port/event-history', method: 'get', response: () => ({ code: 0, message: 'success', data: [
{ timestamp: '2026-05-06 14:30:00', deviceCode: 'fanake_1.2', eventType: 'change_program', oldProgram: 'O200', newProgram: 'O504', partCountBefore: 10, partCountAfter: 14, detail: '程序切换' },
{ timestamp: '2026-05-06 14:25:00', deviceCode: 'fanake_1.3', eventType: 'part_count_increase', oldProgram: 'O1', newProgram: 'O1', partCountBefore: 52, partCountAfter: 53, detail: '零件数+1' }
] })},
// 完整汇总
{ url: '/api/admin/simulator/address/:port/full-summary', method: 'get', response: () => ({ code: 0, message: 'success', data: { exportTime: '2026-05-06 14:35:00', addressName: 'FANUC-1号模拟', port: 9001, totalDevices: 32, onlineDevices: 28, totalParts: 128 } }) },
// 异常日志
{ url: '/api/admin/simulator/address/:port/error-log', method: 'get', response: () => ({ code: 0, message: 'success', data: [] }) },
]
export default mocks

@ -0,0 +1,94 @@
import request from '@/utils/request'
import type { ApiResponse, PaginatedResponse } from '@/types'
// --- 采集日志数据模型 ---
export interface CollectAnalysis {
id: number
analysisTime: string
collectAddressId: number
addressName?: string
machineId: number
machineName?: string
analysisType: string
previousProgram?: string
currentProgram?: string
partCountDelta?: number
analysisSummary?: string
}
export interface CollectCycle {
id: number
cycleTime: string
collectAddressId: number
addressName?: string
totalMachines: number
successCount: number
failCount: number
hasAnomaly: number
changeDistribution?: string
cycleSummary?: string
}
export interface CollectRaw {
id: number
logTime: string
sourceAddress?: string
contentPreview?: string
}
// --- 公开的 API 封装 ---
// 获取分析记录列表
export function fetchAnalysisList(params?: {
page?: number
pageSize?: number
dateRange?: string[] | null
addressId?: number
machineId?: number
analysisType?: string
programName?: string
keyword?: string
}) {
return request.get<{ items: CollectAnalysis[]; total: number }>(
'/admin/collect-log/analysis',
{ params }
)
}
// 获取分析详情
export function fetchAnalysisDetail(id: number) {
return request.get<CollectAnalysis>(`/admin/collect-log/analysis/${id}`)
}
// 根据原始日志检索分析记录
export function fetchAnalysisByRaw(rawLogId: number | string) {
return request.get<{ items: CollectAnalysis[] }>(`/admin/collect-log/analysis/by-raw/${rawLogId}`)
}
// 获取采集周期列表
export function fetchCycleList(params?: {
page?: number
pageSize?: number
dateRange?: string[] | null
addressId?: number
hasAnomaly?: string
}) {
return request.get<{ items: CollectCycle[]; total: number }>(
'/admin/collect-log/cycle',
{ params }
)
}
// 获取原始日志列表
export function fetchRawList(params?: {
page?: number
pageSize?: number
dateRange?: string[] | null
addressId?: number
}) {
return request.get<{ items: CollectRaw[]; total: number }>(
'/admin/collect-log/raw',
{ params }
)
}
export default {}

@ -0,0 +1,223 @@
import request from '@/utils/request'
import type { ApiResponse } from '@/types'
// --- 模拟器数据模型 ---
/** 模拟器连接状态 */
export interface SimulatorPing {
running: boolean
}
/** 数据库采集地址(模拟器返回) */
export interface SimulatorAddress {
dbId: number
name: string
url: string
machineCount: number
machines: { id: number; deviceCode: string; name: string }[]
isRunning: boolean
runningPort: number
}
/** 模拟状态汇总 */
export interface SimulatorStatus {
dbAddressId: number
name: string
port: number
isRunning: boolean
totalDevices: number
onlineDevices: number
requestCount: number
dataChangeInterval: number
totalParts: number
}
/** 设备状态 */
export interface DeviceStatus {
deviceCode: string
desc: string
scenario: string
isOnline: boolean
programName: string
partCount: number
runStatus: number
operateMode: number
spindleSpeedSet: number
spindleSpeedActual: number
feedSpeedSet: number
feedSpeedActual: number
spindleLoad: number
machiningStatus: string
scenarioTick: number
scenarioDuration: number
}
/** 单地址详情状态 */
export interface AddressStatus {
name: string
port: number
isRunning: boolean
requestCount: number
successCount: number
failCount: number
totalDevices: number
onlineDevices: number
dataChangeInterval: number
scenarioMode: string
networkError: string
startTime: string
uptime: string
devices: DeviceStatus[]
}
/** 零件统计 */
export interface AddressStats {
totalDevices: number
onlineDevices: number
totalParts: number
partsByDevice: Record<string, {
desc: string
totalParts: number
currentProgram: string
currentPartCount: number
programs: Record<string, number>
}>
}
/** 请求日志 */
export interface SimulatorLog {
index: number
timestamp: string
deviceCount: number
keyData: string
duration: number
fullJson: string
}
/** 事件历史 */
export interface EventHistory {
timestamp: string
deviceCode: string
eventType: string
oldProgram: string
newProgram: string
partCountBefore: number
partCountAfter: number
detail: string
}
// --- 网关API ---
/** 探测模拟器是否运行 */
export function pingSimulator() {
return request.get<SimulatorPing>('/admin/simulator/ping')
}
/** 获取数据库采集地址列表 */
export function fetchSimulatorAddresses() {
return request.get<SimulatorAddress[]>('/admin/simulator/addresses')
}
/** 获取所有模拟状态汇总 */
export function fetchSimulatorStatus() {
return request.get<SimulatorStatus[]>('/admin/simulator/status')
}
/** 启动指定地址的模拟 */
export function startSimulator(data: { dbAddressId: number; deviceCodes?: string[] }) {
return request.post('/admin/simulator/start', data)
}
/** 停止指定地址的模拟 */
export function stopSimulator(data: { dbAddressId: number }) {
return request.post('/admin/simulator/stop', data)
}
/** 启动所有地址的模拟 */
export function startAllSimulators() {
return request.post('/admin/simulator/start-all')
}
/** 停止所有地址的模拟 */
export function stopAllSimulators() {
return request.post('/admin/simulator/stop-all')
}
/** 重新加载数据库配置 */
export function reloadSimulator() {
return request.post('/admin/simulator/reload')
}
// --- 单地址API ---
/** 获取单地址状态 */
export function fetchAddressStatus(port: number) {
return request.get<AddressStatus>(`/admin/simulator/address/${port}/status`)
}
/** 启动单地址数据模拟 */
export function startAddressSimulation(port: number) {
return request.post(`/admin/simulator/address/${port}/start`)
}
/** 停止单地址数据模拟 */
export function stopAddressSimulation(port: number) {
return request.post(`/admin/simulator/address/${port}/stop`)
}
/** 触发设备事件 */
export function triggerDeviceEvent(port: number, data: { deviceId: string; eventType: string }) {
return request.post(`/admin/simulator/address/${port}/event`, data)
}
/** 修改数据变化频率 */
export function setAddressInterval(port: number, data: { value: number }) {
return request.post(`/admin/simulator/address/${port}/interval`, data)
}
/** 设置网络异常类型 */
export function setNetworkError(port: number, data: { type: string }) {
return request.post(`/admin/simulator/address/${port}/network`, data)
}
/** 切换剧本模式 */
export function setScenarioMode(port: number, data: { mode: string }) {
return request.post(`/admin/simulator/address/${port}/mode`, data)
}
/** 获取请求日志 */
export function fetchAddressLogs(port: number) {
return request.get<SimulatorLog[]>(`/admin/simulator/address/${port}/logs`)
}
/** 获取零件统计 */
export function fetchAddressStats(port: number) {
return request.get<AddressStats>(`/admin/simulator/address/${port}/stats`)
}
/** 添加设备 */
export function addDevice(port: number, data: { deviceCode: string; desc: string }) {
return request.post(`/admin/simulator/address/${port}/add-device`, data)
}
/** 移除设备 */
export function removeDevice(port: number, data: { deviceCode: string }) {
return request.post(`/admin/simulator/address/${port}/remove-device`, data)
}
/** 获取事件历史 */
export function fetchEventHistory(port: number) {
return request.get<EventHistory[]>(`/admin/simulator/address/${port}/event-history`)
}
/** 获取完整汇总 */
export function fetchFullSummary(port: number) {
return request.get(`/admin/simulator/address/${port}/full-summary`)
}
/** 获取异常日志 */
export function fetchErrorLog(port: number) {
return request.get(`/admin/simulator/address/${port}/error-log`)
}
export default {}

@ -47,6 +47,10 @@
<el-icon><Link /></el-icon>
<template #title>采集地址</template>
</el-menu-item>
<el-menu-item :index="menuPath('/collect-log')">
<el-icon><Notebook /></el-icon>
<template #title>采集日志</template>
</el-menu-item>
<el-menu-item :index="menuPath('/worker')">
<el-icon><User /></el-icon>
<template #title>员工管理</template>
@ -67,6 +71,10 @@
<el-icon><Document /></el-icon>
<template #title>操作日志</template>
</el-menu-item>
<el-menu-item :index="menuPath('/simulator')">
<el-icon><VideoPlay /></el-icon>
<template #title>模拟采集</template>
</el-menu-item>
<el-menu-item :index="menuPath('/screen-config')">
<el-icon><FullScreen /></el-icon>
<template #title>大屏配置</template>
@ -100,7 +108,7 @@
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import { ArrowDown, Notebook } from '@element-plus/icons-vue'
import { useMockMode } from '@/composables/useMockMode'
const route = useRoute()

@ -26,6 +26,9 @@ const SettingsPage = () => import('@/views/settings/SettingsPage.vue')
const LogPage = () => import('@/views/log/LogPage.vue')
const ScreenConfigPage = () => import('@/views/screen-config/ScreenConfigPage.vue')
const ScreenPage = () => import('@/views/screen/ScreenPage.vue')
const CollectLogPage = () => import('@/views/collect-log/CollectLogPage.vue')
const SimulatorPage = () => import('@/views/simulator/SimulatorPage.vue')
const SimulatorDetailPage = () => import('@/views/simulator/SimulatorDetailPage.vue')
// 正常路由
const normalRoutes: RouteRecordRaw[] = [
@ -43,6 +46,7 @@ const normalRoutes: RouteRecordRaw[] = [
{ path: 'brand/:id/edit', name: 'BrandEdit', component: BrandEditPage, meta: { title: '编辑品牌' } },
{ path: 'collect-address', name: 'CollectAddressList', component: CollectAddressListPage, meta: { title: '采集地址' } },
{ path: 'collect-address/:id', name: 'CollectAddressDetail', component: CollectAddressDetailPage, meta: { title: '采集地址详情' } },
{ path: 'collect-log', name: 'CollectLog', component: CollectLogPage, meta: { title: '采集日志' } },
{ path: 'worker', name: 'WorkerList', component: WorkerListPage, meta: { title: '员工管理' } },
{ path: 'worker/:id', name: 'WorkerDetail', component: WorkerDetailPage, meta: { title: '员工详情' } },
{ path: 'production', name: 'Production', component: ProductionPage, meta: { title: '产量报表' } },
@ -50,6 +54,8 @@ const normalRoutes: RouteRecordRaw[] = [
{ path: 'settings', name: 'Settings', component: SettingsPage, meta: { title: '系统设置' } },
{ path: 'log', name: 'Log', component: LogPage, meta: { title: '操作日志' } },
{ path: 'screen-config', name: 'ScreenConfig', component: ScreenConfigPage, meta: { title: '大屏配置' } },
{ path: 'simulator', name: 'Simulator', component: SimulatorPage, meta: { title: '模拟采集' } },
{ path: 'simulator/:port', name: 'SimulatorDetail', component: SimulatorDetailPage, meta: { title: '模拟详情' } },
],
},
{

@ -227,14 +227,22 @@ export interface DashboardSummary {
activeAlerts: number
/** 今日采集成功率(%) */
collectSuccessRate: number
/** 今日切削总时(小时) */
todayCuttingTime: number
/** 运行中机床数(有程序运行) */
runningMachines: number
/** 数据缺失机床数(在线但采集失败) */
dataMissingMachines: number
}
// 新增:采集服务状态数据结构(后端扩大了 serviceStatus 等字段)
export interface CollectorStatus {
status: string; // 原心跳状态文本,保留旧字段
uptimeSeconds?: number;
lastCollectTime?: string | null;
serviceStatus?: string; // Windows 服务状态,如 NotInstalled、Running、Starting、StartFailed、Stopped
serviceName?: string;
serviceMessage?: string;
}
/** 仪表盘产量趋势 */
export interface DashboardTrendItem {
date: string
@ -285,12 +293,7 @@ export interface WorkerRankRow {
totalQuantity?: number
}
/** 采集服务状态 */
export interface CollectorStatus {
status: string
uptimeSeconds: number
lastCollectTime?: string
}
// CollectorStatus 已在上方定义(含 serviceStatus/serviceName/serviceMessage 扩展字段)
/** 产量看板汇总 */
export interface ProductionDashboardSummary {

@ -0,0 +1,144 @@
<template>
<div class="log-dashboard">
<h1 class="page-title">日志看板</h1>
<section class="filters card">
<div class="filters-row">
<label>
机床
<select v-model="filters.machineId">
<option value="">全部</option>
<option v-for="m in machines" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label>
程序名
<input v-model="filters.programName" placeholder="过滤加工程序" />
</label>
<label>
时间范围
<input type="date" v-model="filters.startDate" />
<span></span>
<input type="date" v-model="filters.endDate" />
</label>
<button @click="loadDashboard"></button>
</div>
</section>
<section class="summary card" v-if="data">
<div class="summary-item" v-for="(count, key) in data.counts" :key="key">
<div class="label">{{ key }}</div>
<div class="value">{{ count }}</div>
</div>
<div class="summary-analyses" v-if="data.analysis">
<h3>分析摘要</h3>
<p>{{ data.analysis }}</p>
</div>
</section>
<section class="logs card" v-if="data?.logs?.length">
<table class="logs-table">
<thead>
<tr>
<th>时间</th>
<th>机床</th>
<th>程序</th>
<th>等级</th>
<th>日志摘要</th>
</tr>
</thead>
<tbody>
<tr v-for="log in data.logs" :key="log.id">
<td>{{ log.timestamp }}</td>
<td>{{ log.machineId }}</td>
<td>{{ log.programName }}</td>
<td>{{ log.level }}</td>
<td class="message" :title="log.message">{{ log.messageSnippet }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
type LogItem = {
id: string
timestamp: string
machineId: string
programName: string
level: string
message: string
messageSnippet: string
}
type DashboardData = {
total: number
counts: Record<string, number>
logs: LogItem[]
analysis?: string
}
const data = ref<DashboardData | null>(null)
const machines = ref<string[]>([])
const filters = ref({ machineId: '', programName: '', startDate: '', endDate: '' })
const loadDashboard = async () => {
try {
const query = new URLSearchParams({}).toString()
const res = await fetch('/api/logs/dashboard?' + query)
if (!res.ok) throw new Error('网络错误')
const json = await res.json()
//
data.value = {
total: json.total ?? json.logs?.length ?? 0,
counts: json.counts ?? {},
logs: (json.logs ?? []).map((l: any) => ({
id: l.id ?? l.timestamp + '-' + l.machineId,
timestamp: l.timestamp,
machineId: l.machineId,
programName: l.programName,
level: l.level,
message: l.message,
messageSnippet: (l.message ?? '').slice(0, 100)
}))
} as DashboardData
//
if (Array.isArray(json.machines)) machines.value = json.machines
} catch (e) {
console.error('加载看板失败', e)
data.value = null
}
}
onMounted(() => {
loadDashboard()
})
//
const normalizeLogs = computed(() => {
if (!data.value) return []
return data.value.logs.map((l) => ({ ...l, messageSnippet: (l.message ?? '').slice(0, 120) }))
})
</script>
<style scoped>
.log-dashboard { padding: 16px; }
.page-title { font-size: 28px; margin-bottom: 12px; }
.card { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: 12px; margin-bottom: 12px; }
.filters-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.filters label { display: flex; flex-direction: column; font-size: 12px; color: #555; }
.filters input, .filters select { padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; min-width: 180px; }
.filters button { padding: 6px 12px; border: none; background: #409eff; color: white; border-radius: 4px; cursor: pointer; }
.summary { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
.summary-item { background: #f9f9f9; padding: 12px; border-radius: 6px; text-align: center; }
.summary-item .label { font-size: 12px; color: #666; }
.summary-item .value { font-size: 20px; font-weight: bold; margin-top: 6px; }
.logs-table { width: 100%; border-collapse: collapse; }
.logs-table th, .logs-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; font-family: sans-serif; font-size: 12px; }
.logs-table .message { max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

@ -13,12 +13,13 @@
</el-card>
<el-card shadow="hover">
<template #header><div style="display:flex;justify-content:space-between"><span>字段映射列表</span><el-button size="small" @click="addMapping">+ </el-button></div></template>
<el-table :data="form.mappings" border stripe size="small" style="width:100%">
<el-table :data="form.mappings" border stripe size="small" style="width:100%" row-class-name="mapping-row" :row-style="({row}: any) => row.isEnabled === 0 ? { opacity: 0.5 } : {}">
<el-table-column label="标准字段" min-width="180"><template #default="{row}"><el-select v-model="row.standardField" style="width:100%"><el-option v-for="f in standardFields" :key="f" :label="f" :value="f" /></el-select></template></el-table-column>
<el-table-column label="字段名" min-width="140"><template #default="{row}"><el-input v-model="row.fieldName" /></template></el-table-column>
<el-table-column label="匹配方式" min-width="110"><template #default="{row}"><el-select v-model="row.matchBy" style="width:100%"><el-option label="id" value="id" /><el-option label="desc" value="desc" /></el-select></template></el-table-column>
<el-table-column label="数据类型" min-width="110"><template #default="{row}"><el-select v-model="row.dataType" style="width:100%"><el-option label="string" value="string" /><el-option label="number" value="number" /></el-select></template></el-table-column>
<el-table-column label="必填" width="60" align="center"><template #default="{row}"><el-checkbox v-model="row.isRequired" :true-value="1" :false-value="0" /></template></el-table-column>
<el-table-column label="启用" width="70" align="center"><template #default="{row}"><el-switch v-model="row.isEnabled" :active-value="1" :inactive-value="0" inline-prompt active-text="" inactive-text="" /></template></el-table-column>
<el-table-column label="操作" width="80" align="center"><template #default="{ $index }"><el-button link type="danger" @click="form.mappings.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</el-card>
@ -36,12 +37,12 @@ import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
import type { Brand, ApiResponse } from '@/types'
type BrandMappingForm = { standardField: string; fieldName: string; matchBy: string; dataType: string; isRequired: number }
type BrandMappingForm = { standardField: string; fieldName: string; matchBy: string; dataType: string; isRequired: number; isEnabled: number }
const isEdit = !!route.params.id
const submitting = ref(false)
const standardFields = ['program_name','part_count','device_status','run_status','operate_mode','spindle_speed_set','feed_speed_set','spindle_speed_actual','feed_speed_actual','spindle_load','spindle_override','power_on_time','run_time','cutting_time','cycle_time','machining_status']
const form = reactive({ brandName: '', deviceField: 'device', tagsPath: 'tags', mappings: [] as BrandMappingForm[] })
function addMapping() { form.mappings.push({ standardField: '', fieldName: '', matchBy: 'id', dataType: 'string', isRequired: 0 }) }
function addMapping() { form.mappings.push({ standardField: '', fieldName: '', matchBy: 'id', dataType: 'string', isRequired: 0, isEnabled: 1 }) }
async function loadData() {
if (!isEdit) return
const r = await request.get<Brand>(`/admin/brand/${route.params.id}`)

@ -0,0 +1,389 @@
<template>
<div class="collect-log-page" style="padding: 16px 0">
<el-tabs v-model:active-name="activeTab" lazy>
<!-- 1) Analyses -->
<el-tab-pane label="分析记录" name="analysis">
<el-form :inline="true" class="mb-4" label-width="100px">
<el-form-item label="时间范围">
<el-date-picker v-model="query.dateRange" type="daterange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="~" start-placeholder="" end-placeholder="" />
</el-form-item>
<el-form-item label="采集地址">
<el-select v-model="query.addressId" placeholder="全部" clearable style="min-width: 180px">
<el-option v-for="a in addressList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="机床">
<el-select v-model="query.machineId" placeholder="全部" clearable style="min-width: 140px">
<el-option v-for="m in machineList" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="分析类型">
<el-select v-model="query.analysisType" placeholder="全部" clearable style="min-width: 180px">
<el-option v-for="(label, key) in analysisTypeOptions" :key="key" :label="label" :value="key" />
</el-select>
</el-form-item>
<el-form-item label="程序名">
<el-input v-model="query.programName" placeholder="请输入程序名" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadAnalysis"></el-button>
<el-button @click="resetAnalysis"></el-button>
</el-form-item>
</el-form>
<el-table :data="analysisList" border stripe v-loading="analysisLoading" style="width: 100%">
<el-table-column prop="analysisTime" label="分析时间" sortable width="170" />
<el-table-column label="采集地址" width="180">
<template #default="{ row }">{{ row.addressName || row.collectAddressId }}</template>
</el-table-column>
<el-table-column prop="machineName" label="机床" width="120" show-overflow-tooltip />
<el-table-column label="分析类型" align="center" width="120">
<template #default="{ row }">
<el-tag :type="analysisTypeTag(row.analysisType)" size="small">{{ analysisTypeLabel(row.analysisType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="previousProgram" label="前程序" width="120" />
<el-table-column prop="currentProgram" label="当前程序" width="120" />
<el-table-column prop="partCountDelta" label="产量变化" width="110" />
<el-table-column prop="analysisSummary" label="摘要" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button type="text" @click="viewAnalysis(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page.page"
v-model:page-size="page.pageSize"
:page-sizes="[20, 50, 100]"
:total="page.total"
background
layout="total, sizes, prev, pager, next, jumper"
/>
<el-dialog v-model="analysisDetailVisible" title="分析详情" width="640px" destroy-on-close>
<el-descriptions :column="1" border>
<el-descriptions-item label="分析时间">{{ detailRow?.analysisTime }}</el-descriptions-item>
<el-descriptions-item label="采集地址">{{ detailRow?.addressName || detailRow?.collectAddressId }}</el-descriptions-item>
<el-descriptions-item label="机床">{{ detailRow?.machineName }}</el-descriptions-item>
<el-descriptions-item label="分析类型">{{ analysisTypeLabel(detailRow?.analysisType) }}</el-descriptions-item>
<el-descriptions-item label="前程序">{{ detailRow?.previousProgram }}</el-descriptions-item>
<el-descriptions-item label="当前程序">{{ detailRow?.currentProgram }}</el-descriptions-item>
<el-descriptions-item label="产量变化">{{ detailRow?.partCountDelta }}</el-descriptions-item>
<el-descriptions-item label="摘要">{{ detailRow?.analysisSummary }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="analysisDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
</el-tab-pane>
<!-- 2) Cycle -->
<el-tab-pane label="采集周期" name="cycle">
<el-form :inline="true" class="mb-4" label-width="100px">
<el-form-item label="时间范围">
<el-date-picker v-model="cycleQuery.dateRange" type="daterange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="~" start-placeholder="" end-placeholder="" />
</el-form-item>
<el-form-item label="采集地址">
<el-select v-model="cycleQuery.addressId" placeholder="全部" clearable style="min-width: 180px">
<el-option v-for="a in addressList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="是否异常">
<el-select v-model="cycleQuery.hasAnomaly" placeholder="全部" clearable style="min-width: 120px">
<el-option label="全部" value="" />
<el-option label="有异常" value="1" />
<el-option label="无异常" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadCycles"></el-button>
<el-button @click="resetCycle"></el-button>
</el-form-item>
</el-form>
<el-table :data="cycleList" border stripe v-loading="cycleLoading" style="width: 100%">
<el-table-column prop="cycleTime" label="周期时间" width="170" />
<el-table-column label="采集地址" width="180">
<template #default="{ row }">{{ row.addressName || row.collectAddressId }}</template>
</el-table-column>
<el-table-column prop="totalMachines" label="总机床" width="120" />
<el-table-column prop="successCount" label="成功" width="80" />
<el-table-column prop="failCount" label="失败" width="80" />
<el-table-column prop="hasAnomaly" label="异常" width="80">
<template #default="{ row }">
<el-tag :type="row.hasAnomaly ? 'danger' : 'success'" size="small">{{ row.hasAnomaly ? '有' : '无' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cycleSummary" label="摘要" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button type="text" @click="viewCycle(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="cyclePage.page"
v-model:page-size="cyclePage.pageSize"
:page-sizes="[20, 50, 100]"
:total="cyclePage.total"
background
layout="total, sizes, prev, pager, next, jumper"
/>
</el-tab-pane>
<!-- 3) Raw -->
<el-tab-pane label="原始数据" name="raw">
<el-form :inline="true" class="mb-4" label-width="100px">
<el-form-item label="采集地址">
<el-select v-model="rawQuery.addressId" placeholder="全部" clearable style="min-width: 180px">
<el-option v-for="a in addressList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="rawQuery.dateRange" type="daterange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="~" start-placeholder="" end-placeholder="" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadRaw"></el-button>
<el-button @click="resetRaw"></el-button>
</el-form-item>
</el-form>
<el-table :data="rawList" border stripe v-loading="rawLoading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="logTime" label="时间" width="170" />
<el-table-column prop="sourceAddress" label="地址" />
<el-table-column prop="contentPreview" label="内容摘要" show-overflow-tooltip />
</el-table>
<el-pagination
v-model:current-page="rawPage.page"
v-model:page-size="rawPage.pageSize"
:page-sizes="[20, 50, 100]"
:total="rawPage.total"
background
layout="total, sizes, prev, pager, next, jumper"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import type { CollectAnalysis, CollectCycle, CollectRaw } from '@/api/collect-log'
import type { CollectAddress, Machine } from '@/types'
// Tab identification
const activeTab = ref<'analysis' | 'cycle' | 'raw'>('analysis')
//
const addressList = ref<CollectAddress[]>([])
const machineList = ref<Machine[]>([])
// --------------- Tab 1: Analyses ---------------
const analysisList = ref<CollectAnalysis[]>([])
const analysisLoading = ref(false)
const detailRow = ref<CollectAnalysis | null>(null)
const analysisDetailVisible = ref(false)
const page = reactive({ page: 1, pageSize: 20, total: 0 })
const query = reactive({ dateRange: null as string[] | null, addressId: undefined as number | undefined, machineId: undefined as number | undefined, analysisType: '', programName: '', keyword: '' })
const analysisTypeOptions: Record<string, string> = {
NORMAL_UNCHANGED: 'NORMAL_UNCHANGED',
PART_COUNT_INCREASE: 'PART_COUNT_INCREASE',
PROGRAM_SWITCH: 'PROGRAM_SWITCH',
MANUAL_RESET: 'MANUAL_RESET',
DEVICE_ONLINE: 'DEVICE_ONLINE',
DEVICE_OFFLINE: 'DEVICE_OFFLINE',
NEW_DEVICE_FOUND: 'NEW_DEVICE_FOUND',
DATA_ANOMALY: 'DATA_ANOMALY',
COLLECTION_FAILED: 'COLLECTION_FAILED'
}
function analysisTypeTag(type: string) {
const map: Record<string, string> = {
NORMAL_UNCHANGED: 'info',
PART_COUNT_INCREASE: 'success',
PROGRAM_SWITCH: 'warning',
MANUAL_RESET: 'warning',
DEVICE_ONLINE: 'success',
DEVICE_OFFLINE: 'danger',
NEW_DEVICE_FOUND: 'danger',
DATA_ANOMALY: 'danger',
COLLECTION_FAILED: 'danger',
}
return map[type] || 'info'
}
function analysisTypeLabel(type: string | undefined) {
const map: Record<string, string> = {
NORMAL_UNCHANGED: '正常未变',
PART_COUNT_INCREASE: '产量增减',
PROGRAM_SWITCH: '程序切换',
MANUAL_RESET: '手动重置',
DEVICE_ONLINE: '设备在线',
DEVICE_OFFLINE: '设备离线',
NEW_DEVICE_FOUND: '新设备发现',
DATA_ANOMALY: '数据异常',
COLLECTION_FAILED: '采集失败',
}
return type ? (map[type] ?? type) : '未知'
}
function viewAnalysis(row: CollectAnalysis) {
detailRow.value = row
analysisDetailVisible.value = true
}
async function loadAnalysis() {
analysisLoading.value = true
try {
const res = await request.get<{ items: CollectAnalysis[]; total: number }>("/admin/collect-log/analysis", {
params: {
page: page.page,
pageSize: page.pageSize,
dateRange: query.dateRange,
addressId: query.addressId,
machineId: query.machineId,
analysisType: query.analysisType,
programName: query.programName,
keyword: query.keyword,
},
})
analysisList.value = res.data?.items ?? []
page.total = res.data?.total ?? 0
} finally {
analysisLoading.value = false
}
}
function resetAnalysis() {
Object.assign(query, { dateRange: null, addressId: undefined, machineId: undefined, analysisType: '', programName: '', keyword: '' })
loadAnalysis()
}
//
watch(() => [page.page, page.pageSize], () => loadAnalysis())
// --------------- Tab 2: Cycles ---------------
const cycleList = ref<CollectCycle[]>([])
const cycleLoading = ref(false)
const cyclePage = reactive({ page: 1, pageSize: 20, total: 0 })
const cycleQuery = reactive({ dateRange: null as string[] | null, addressId: undefined as number | undefined, hasAnomaly: '' })
async function loadCycles() {
cycleLoading.value = true
try {
const res = await request.get<{ items: CollectCycle[]; total: number }>("/admin/collect-log/cycle", {
params: {
page: cyclePage.page,
pageSize: cyclePage.pageSize,
dateRange: cycleQuery.dateRange,
addressId: cycleQuery.addressId,
hasAnomaly: cycleQuery.hasAnomaly || undefined,
},
})
cycleList.value = res.data?.items ?? []
cyclePage.total = res.data?.total ?? 0
} finally {
cycleLoading.value = false
}
}
function resetCycle() {
Object.assign(cycleQuery, { dateRange: null, addressId: undefined, hasAnomaly: '' })
loadCycles()
}
function viewCycle(row: CollectCycle) {
//
ElMessage.info(`周期 ${row.cycleTime} 区间分析完成,共 ${row.totalMachines} 台机床`)
}
watch(() => cyclePage.page + cyclePage.pageSize, loadCycles)
//
onMounted(() => {
loadAnalysis()
loadCycles()
//
loadAddresses()
loadMachines()
})
// --------------- Tab 3: Raw ---------------
const rawList = ref<CollectRaw[]>([])
const rawLoading = ref(false)
const rawPage = reactive({ page: 1, pageSize: 20, total: 0 })
const rawQuery = reactive({ dateRange: null as string[] | null, addressId: undefined as number | undefined })
const rawURLList = ref<string[]>([])
async function loadRaw() {
rawLoading.value = true
try {
const res = await request.get<{ items: CollectRaw[]; total: number }>("/admin/collect-log/raw", {
params: {
page: rawPage.page,
pageSize: rawPage.pageSize,
dateRange: rawQuery.dateRange,
addressId: rawQuery.addressId,
},
})
rawList.value = res.data?.items ?? []
rawPage.total = res.data?.total ?? 0
} finally {
rawLoading.value = false
}
}
function resetRaw() {
Object.assign(rawQuery, { dateRange: null, addressId: undefined })
loadRaw()
}
onMounted(() => {
loadRaw()
})
// /
async function loadAddresses() {
try {
const r = await request.get<{ items: CollectAddress[] }>("/admin/collect-address/list")
if (r.data?.items && r.data.items.length > 0) {
addressList.value = r.data.items as CollectAddress[]
} else {
//
addressList.value = [
{ id: 1, name: 'FANUC-A栋', url: '', brandId: 0, brandName: '', interval: 60, isEnabled: true, lastCollectTime: '', machineCount: 8, failCount: 0 },
{ id: 2, name: 'FANUC-B栋', url: '', brandId: 0, brandName: '', interval: 60, isEnabled: true, lastCollectTime: '', machineCount: 6, failCount: 0 },
]
}
} catch {
addressList.value = []
}
}
async function loadMachines() {
try {
const r = await request.get<{ items: Machine[] }>('/admin/machine/list')
machineList.value = (r.data?.items as Machine[]) ?? []
} catch {
machineList.value = []
}
}
// -------------- Helpers --------------
// tag/label
</script>
<style scoped lang="scss">
.collect-log-page {
.mb-4 {
margin-bottom: 12px;
}
}
</style>

@ -38,14 +38,14 @@
</el-tooltip>
</div>
<div class="stat-value">
<el-tag :type="collectorStatus.status === 'running' ? 'success' : 'danger'" size="small">
{{ collectorStatus.status === 'running' ? '运行中' : '已停止' }}
<el-tag :type="collectorTagType" size="small">
{{ collectorStatusText }}
</el-tag>
</div>
<div class="stat-sub" v-if="collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
<div class="stat-sub" v-if="collectorStatus.serviceStatus === 'Running' && collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
</div>
<div class="collector-actions">
<el-button v-if="collectorStatus.status !== 'running'" size="small" type="success" :loading="startLoading" @click="startCollector"></el-button>
<el-button v-if="collectorStatus.serviceStatus !== 'Running'" size="small" type="success" :loading="startLoading" @click="startCollector"></el-button>
<el-button v-if="collectorStatus.status === 'running'" size="small" type="danger" :loading="stopLoading" @click="stopCollector"></el-button>
<el-button size="small" type="warning" :loading="refreshLoading" @click="refreshCollectorConfig"></el-button>
</div>
@ -70,7 +70,7 @@
<!-- 统计卡片 第2行 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
@ -79,24 +79,11 @@
<span class="info-icon"></span>
</el-tooltip>
</div>
<div class="stat-value">{{ summary.collectSuccessRate }}<span class="stat-unit">%</span></div>
<div class="stat-value">{{ formatNumber(summary.collectSuccessRate) }}<span class="stat-unit">%</span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
今日切削总时
<el-tooltip content="今日所有机床实际切削加工的时间总和(单位:小时)。仅统计刀具接触工件的切削时间,不含空转和待机时间。" placement="top">
<span class="info-icon"></span>
</el-tooltip>
</div>
<div class="stat-value">{{ summary.todayCuttingTime }}<span class="stat-unit"> h</span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
@ -109,7 +96,7 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">
@ -204,8 +191,21 @@
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">机床产量排行 TOP10<el-tooltip content="单台机床加工零件数由高到低排列,同时显示当前正在执行的程序名。" placement="top"><span class="info-icon"></span></el-tooltip></span>
<span class="card-title">机床产量排行 TOP
<el-select v-model="machineTopN" size="small" style="width: 72px" @change="loadMachineRankData">
<el-option :value="5" label="5" />
<el-option :value="10" label="10" />
<el-option :value="20" label="20" />
<el-option :value="50" label="50" />
<el-option :value="100" label="100" />
</el-select>
<el-tooltip content="单台机床加工零件数排列,同时显示当前正在执行的程序名。" placement="top"><span class="info-icon"></span></el-tooltip>
</span>
<div class="date-filter">
<el-radio-group v-model="machineSortOrder" size="small" @change="loadMachineRankData" style="margin-right: 8px">
<el-radio-button value="asc">正序</el-radio-button>
<el-radio-button value="desc">倒序</el-radio-button>
</el-radio-group>
<el-radio-group v-model="machineDateType" size="small" @change="onMachineDateChange">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="yesterday">昨日</el-radio-button>
@ -225,7 +225,9 @@
</template>
</el-table-column>
<el-table-column prop="program" label="当前程序" show-overflow-tooltip />
<el-table-column prop="quantity" label="产量" width="80" align="center" />
<el-table-column label="产量" width="80" align="center">
<template #default="{ row }">{{ formatNumber(row.quantity) }}</template>
</el-table-column>
<el-table-column label="状态" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '在线' : '离线' }}</el-tag>
@ -238,8 +240,21 @@
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">工人产量排行 TOP10<el-tooltip content="工人所绑定的所有机床产量合计,由高到低排列。" placement="top"><span class="info-icon"></span></el-tooltip></span>
<span class="card-title">工人产量排行 TOP
<el-select v-model="workerTopN" size="small" style="width: 72px" @change="loadWorkerRankData">
<el-option :value="5" label="5" />
<el-option :value="10" label="10" />
<el-option :value="20" label="20" />
<el-option :value="50" label="50" />
<el-option :value="100" label="100" />
</el-select>
<el-tooltip content="工人所绑定的所有机床产量合计。" placement="top"><span class="info-icon"></span></el-tooltip>
</span>
<div class="date-filter">
<el-radio-group v-model="workerSortOrder" size="small" @change="loadWorkerRankData" style="margin-right: 8px">
<el-radio-button value="asc">正序</el-radio-button>
<el-radio-button value="desc">倒序</el-radio-button>
</el-radio-group>
<el-radio-group v-model="workerDateType" size="small" @change="onWorkerDateChange">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="yesterday">昨日</el-radio-button>
@ -255,7 +270,9 @@
<el-table-column prop="rank" label="排名" width="60" align="center" />
<el-table-column prop="workerName" label="工人姓名" />
<el-table-column prop="machineCount" label="绑定机床" width="100" align="center" />
<el-table-column prop="totalQuantity" label="总产量" width="100" align="center" />
<el-table-column label="总产量" width="100" align="center">
<template #default="{ row }">{{ formatNumber(row.totalQuantity) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
@ -274,8 +291,8 @@ import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, Wo
const { isMock } = useMockMode()
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, todayCuttingTime: 0, runningMachines: 0, dataMissingMachines: 0 })
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0 })
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, runningMachines: 0, dataMissingMachines: 0 })
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0, serviceStatus: 'NotInstalled', serviceName: '' })
const machineRank = ref<MachineRankRow[]>([])
const workerRank = ref<WorkerRankRow[]>([])
const trendData = ref<DashboardTrendItem[]>([])
@ -296,6 +313,12 @@ const machineDateRange = ref<[string, string]>()
const workerDateType = ref<DateType>('today')
const workerDateRange = ref<[string, string]>()
// TOP N
const machineTopN = ref(10)
const machineSortOrder = ref<'asc' | 'desc'>('asc')
const workerTopN = ref(10)
const workerSortOrder = ref<'asc' | 'desc'>('asc')
const dateLabels: Record<DateType, string> = { today: '今日', yesterday: '昨日', last3: '近3天', last7: '近7天', custom: '自定义' }
const workshopDateLabel = computed(() => dateLabels[workshopDateType.value])
const machineDateLabel = computed(() => dateLabels[machineDateType.value])
@ -338,7 +361,20 @@ let statusPie: ECharts | null = null
async function startCollector() {
if (startLoading.value) return
startLoading.value = true
try { await request.post('/admin/collector/start'); ElMessage.success('采集服务已启动'); await loadData() } catch { /* request拦截器已显示错误 */ } finally { startLoading.value = false }
try {
//
if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'Running') {
ElMessage.info('采集服务已在运行中');
return;
}
if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'NotInstalled') {
ElMessage.warning('采集服务未安装,请运行 install.ps1 安装脚本');
return;
}
await request.post('/admin/collector/start');
ElMessage.success('采集服务已启动');
await loadData();
} catch { /* request拦截器已显示错误 */ } finally { startLoading.value = false }
}
async function stopCollector() {
@ -353,7 +389,7 @@ async function refreshCollectorConfig() {
try { await request.post('/admin/collector/refresh'); ElMessage.success('配置已刷新'); await loadData() } catch { /* request拦截器已显示错误 */ } finally { refreshLoading.value = false }
}
function formatUptime(seconds: number): string {
function formatUptime(seconds: number | undefined): string {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
@ -361,6 +397,32 @@ function formatUptime(seconds: number): string {
return `${hours}`
}
function formatNumber(val: number | undefined | null): string {
if (val == null) return '-'
return Number(val).toFixed(2)
}
// + Windows
const collectorTagType = computed(() => {
const { serviceStatus, status } = collectorStatus.value
if (serviceStatus === 'Running' && status === 'running') return 'success'
if (serviceStatus === 'Running' && status !== 'running') return 'warning' //
if (serviceStatus === 'NotInstalled') return 'danger'
if (serviceStatus === 'StartFailed') return 'danger'
return 'warning'
})
const collectorStatusText = computed(() => {
const { serviceStatus, status } = collectorStatus.value
if (serviceStatus === 'Running' && status === 'running') return '运行中'
if (serviceStatus === 'Running' && status !== 'running') return '心跳超时'
if (serviceStatus === 'NotInstalled') return '未安装'
if (serviceStatus === 'Stopped') return '已停止'
if (serviceStatus === 'Starting') return '启动中'
if (serviceStatus === 'StartFailed') return '启动失败'
return serviceStatus || '-'
})
function alertTypeTag(type: string): string {
const map: Record<string, string> = { collect_fail: 'danger', data_missing: 'warning', device_offline: 'danger', new_device: 'info' }
return map[type] || 'warning'
@ -457,7 +519,7 @@ async function loadWorkshopData() {
async function loadMachineRankData() {
try {
const { startDate, endDate } = getDateRange(machineDateType.value, machineDateRange.value)
const res: ApiResponse<{ items: MachineRankRow[] }> = await request.get('/admin/dashboard/machine-rank', { params: { startDate, endDate } })
const res: ApiResponse<{ items: MachineRankRow[] }> = await request.get('/admin/dashboard/machine-rank', { params: { startDate, endDate, top: machineTopN.value, sortOrder: machineSortOrder.value } })
machineRank.value = res.data?.items || []
} catch { /* */ }
}
@ -465,7 +527,7 @@ async function loadMachineRankData() {
async function loadWorkerRankData() {
try {
const { startDate, endDate } = getDateRange(workerDateType.value, workerDateRange.value)
const res: ApiResponse<{ items: WorkerRankRow[] }> = await request.get('/admin/dashboard/worker-rank', { params: { startDate, endDate } })
const res: ApiResponse<{ items: WorkerRankRow[] }> = await request.get('/admin/dashboard/worker-rank', { params: { startDate, endDate, top: workerTopN.value, sortOrder: workerSortOrder.value } })
workerRank.value = res.data?.items || []
} catch { /* */ }
}

@ -0,0 +1,284 @@
<template>
<div>
<!-- 顶部栏 -->
<div class="mb-16" style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;align-items:center;gap:12px">
<el-button :icon="ArrowLeft" @click="goBack"></el-button>
<span style="font-size:16px;font-weight:600">{{ status?.name ?? '加载中...' }}</span>
<el-tag v-if="status" :type="status.isRunning ? 'success' : 'danger'" size="small">
{{ status.isRunning ? '运行中' : '已停止' }}
</el-tag>
</div>
<div>
<el-button
v-if="status && !status.isRunning"
type="success"
@click="handleStartStop('start')"
>启动</el-button>
<el-popconfirm
v-if="status && status.isRunning"
title="确定停止数据模拟?"
@confirm="handleStartStop('stop')"
>
<template #reference>
<el-button type="danger">停止</el-button>
</template>
</el-popconfirm>
<el-button :icon="Refresh" circle @click="loadStatus" style="margin-left:8px" />
</div>
</div>
<div v-if="!status" v-loading="true" style="height:200px"></div>
<template v-else>
<!-- 统计卡片 -->
<el-row :gutter="16" class="mb-16">
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">设备总数</div>
<div style="font-size:24px;font-weight:700;color:#303133">{{ status.totalDevices }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">在线设备</div>
<div style="font-size:24px;font-weight:700;color:#67c23a">{{ status.onlineDevices }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">总零件数</div>
<div style="font-size:24px;font-weight:700;color:#409eff">{{ stats?.totalParts ?? '-' }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">请求次数</div>
<div style="font-size:24px;font-weight:700;color:#e6a23c">{{ status.requestCount }}</div>
</el-card>
</el-col>
</el-row>
<!-- 设备状态表格 -->
<div class="mb-16">
<h3 style="margin:0 0 12px;font-size:15px">设备状态</h3>
<el-table :data="status.devices" border stripe style="width:100%" max-height="400">
<el-table-column prop="deviceCode" label="编码" width="130" />
<el-table-column prop="desc" label="描述" min-width="100" />
<el-table-column prop="scenario" label="场景" width="90" align="center" />
<el-table-column prop="programName" label="程序" width="90" align="center" />
<el-table-column prop="partCount" label="零件" width="70" align="center" />
<el-table-column label="状态" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.isOnline ? 'success' : 'danger'" size="small">
{{ row.isOnline ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button link size="small" @click="handleEvent(row.deviceCode, 'change_program')">换程序</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'reset_parts')">清零</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'pause')">暂停</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'resume')">恢复</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 设置面板 -->
<el-collapse class="mb-16">
<el-collapse-item title="模拟设置" name="settings">
<el-row :gutter="24">
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">数据频率</div>
<el-slider v-model="intervalValue" :min="1" :max="60" show-input @change="handleIntervalChange" />
</el-col>
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">场景模式</div>
<el-radio-group :model-value="status.scenarioMode" @change="handleModeChange">
<el-radio-button value="auto">自动</el-radio-button>
<el-radio-button value="manual">手动</el-radio-button>
</el-radio-group>
</el-col>
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">网络模拟</div>
<el-select :model-value="status.networkError" @change="handleNetworkChange" style="width:100%">
<el-option label="正常" value="normal" />
<el-option label="HTTP 500" value="http500" />
<el-option label="超时" value="timeout" />
<el-option label="空数据" value="empty" />
<el-option label="畸形JSON" value="malformed" />
<el-option label="拒绝连接" value="refuse" />
</el-select>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
<!-- 请求日志 -->
<div>
<h3 style="margin:0 0 12px;font-size:15px">最近请求日志</h3>
<el-table :data="logs" border stripe style="width:100%" max-height="300">
<el-table-column prop="index" label="#" width="50" align="center" />
<el-table-column prop="timestamp" label="时间" width="100" />
<el-table-column prop="deviceCount" label="设备数" width="70" align="center" />
<el-table-column prop="keyData" label="关键数据" min-width="200" show-overflow-tooltip />
<el-table-column prop="duration" label="耗时(ms)" width="80" align="center" />
<el-table-column type="expand">
<template #default="{ row }">
<pre style="padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap">{{ row.fullJson }}</pre>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import {
fetchAddressStatus, startAddressSimulation, stopAddressSimulation,
triggerDeviceEvent, setAddressInterval, setNetworkError, setScenarioMode,
fetchAddressLogs, fetchAddressStats
} from '@/api/simulator'
import type { AddressStatus, AddressStats, SimulatorLog } from '@/api/simulator'
import { useMockMode } from '@/composables/useMockMode'
const route = useRoute()
const router = useRouter()
const { isMock } = useMockMode()
const port = Number(route.params.port)
const status = ref<AddressStatus | null>(null)
const stats = ref<AddressStats | null>(null)
const logs = ref<SimulatorLog[]>([])
const intervalValue = ref(10)
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 加载地址状态 */
async function loadStatus() {
try {
const res = await fetchAddressStatus(port)
status.value = res.data ?? null
if (status.value) {
intervalValue.value = status.value.dataChangeInterval
}
} catch {
ElMessage.error('获取地址状态失败')
}
}
/** 加载统计 */
async function loadStats() {
try {
const res = await fetchAddressStats(port)
stats.value = res.data ?? null
} catch { /* 静默 */ }
}
/** 加载日志 */
async function loadLogs() {
try {
const res = await fetchAddressLogs(port)
logs.value = res.data ?? []
} catch { /* 静默 */ }
}
/** 返回总览 */
function goBack() {
const base = isMock.value ? '/mock' : ''
router.push(`${base}/simulator`)
}
/** 启动/停止 */
async function handleStartStop(action: string) {
try {
if (action === 'start') {
await startAddressSimulation(port)
ElMessage.success('已启动')
} else {
await stopAddressSimulation(port)
ElMessage.success('已停止')
}
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 触发事件 */
async function handleEvent(deviceId: string, eventType: string) {
try {
await triggerDeviceEvent(port, { deviceId, eventType })
ElMessage.success('事件已触发')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '触发失败')
}
}
/** 修改频率 */
async function handleIntervalChange(val: number) {
try {
await setAddressInterval(port, { value: val })
ElMessage.success('频率已修改')
} catch (e: any) {
ElMessage.error(e?.message ?? '修改失败')
}
}
/** 修改模式 */
async function handleModeChange(mode: string) {
try {
await setScenarioMode(port, { mode })
ElMessage.success('模式已切换')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '切换失败')
}
}
/** 修改网络模拟 */
async function handleNetworkChange(type: string) {
try {
await setNetworkError(port, { type })
ElMessage.success('网络模拟已设置')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '设置失败')
}
}
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') {
loadStatus()
loadStats()
}
}, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(async () => {
await loadStatus()
await loadStats()
await loadLogs()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>

@ -0,0 +1,253 @@
<template>
<div>
<!-- 顶部操作栏 -->
<div class="mb-16" style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;align-items:center;gap:8px">
<span
:style="{
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
backgroundColor: connected ? '#67c23a' : '#f56c6c'
}"
></span>
<span style="font-size:14px;color:#606266">{{ connected ? '模拟器已连接' : '模拟器未连接' }}</span>
<el-button size="small" :icon="Refresh" circle @click="loadAll" />
</div>
<div>
<el-button type="success" :disabled="!connected" @click="handleStartAll"></el-button>
<el-button type="danger" :disabled="!connected" @click="handleStopAll"></el-button>
<el-button :disabled="!connected" @click="handleReload"></el-button>
</div>
</div>
<!-- 未连接提示 -->
<el-empty v-if="!connected" description="模拟器未启动,请在服务器上运行 CncSimulator.exe" />
<!-- 地址列表 -->
<el-table
v-else
:data="addresses"
border
stripe
v-loading="loading"
style="width:100%"
>
<el-table-column prop="name" label="名称" min-width="140">
<template #default="{ row }">
<el-link
v-if="row.isRunning && row.runningPort > 0"
type="primary"
@click="goDetail(row.runningPort)"
>{{ row.name }}</el-link>
<span v-else>{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="url" label="URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="machineCount" label="机床数" align="center" width="80" />
<el-table-column label="状态" align="center" width="100">
<template #default="{ row }">
<el-tag :type="row.isRunning ? 'success' : 'info'" size="small">
{{ row.isRunning ? '运行中' : '未启动' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="端口" align="center" width="80">
<template #default="{ row }">{{ row.isRunning ? row.runningPort : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<div style="white-space:nowrap">
<el-button
v-if="row.isRunning"
link type="primary"
@click="goDetail(row.runningPort)"
>详情</el-button>
<el-button
v-if="!row.isRunning"
link type="success"
@click="handleStart(row)"
>启动</el-button>
<el-popconfirm
v-if="row.isRunning"
title="确定停止该地址的模拟?"
@confirm="handleStop(row)"
>
<template #reference>
<el-button link type="danger">停止</el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<!-- 启动弹窗选择机床 -->
<el-dialog v-model="startDialogVisible" title="选择模拟机床" width="500px" destroy-on-close>
<el-checkbox-group v-model="selectedDevices">
<el-checkbox
v-for="m in startTarget?.machines ?? []"
:key="m.deviceCode"
:label="m.deviceCode"
:value="m.deviceCode"
>{{ m.name }} ({{ m.deviceCode }})</el-checkbox>
</el-checkbox-group>
<p style="margin-top:12px;color:#909399;font-size:13px">
不选择则模拟该地址下全部机床{{ startTarget?.machineCount }}
</p>
<template #footer>
<el-button @click="startDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="startLoading" @click="confirmStart"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { pingSimulator, fetchSimulatorAddresses, startSimulator, stopSimulator, startAllSimulators, stopAllSimulators, reloadSimulator } from '@/api/simulator'
import type { SimulatorAddress } from '@/api/simulator'
import { useMockMode } from '@/composables/useMockMode'
const router = useRouter()
const { isMock } = useMockMode()
const loading = ref(false)
const connected = ref(false)
const addresses = ref<SimulatorAddress[]>([])
//
const startDialogVisible = ref(false)
const startLoading = ref(false)
const startTarget = ref<SimulatorAddress | null>(null)
const selectedDevices = ref<string[]>([])
//
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 检测连接状态并加载数据 */
async function loadAll() {
loading.value = true
try {
const pingRes = await pingSimulator()
connected.value = pingRes.data?.running ?? false
if (connected.value) {
const addrRes = await fetchSimulatorAddresses()
addresses.value = addrRes.data ?? []
} else {
addresses.value = []
}
} catch {
connected.value = false
addresses.value = []
} finally {
loading.value = false
}
}
/** 跳转到详情页 */
function goDetail(port: number) {
const base = isMock.value ? '/mock' : ''
router.push(`${base}/simulator/${port}`)
}
/** 打开启动弹窗 */
function handleStart(row: SimulatorAddress) {
startTarget.value = row
selectedDevices.value = []
startDialogVisible.value = true
}
/** 确认启动 */
async function confirmStart() {
if (!startTarget.value) return
startLoading.value = true
try {
const payload: { dbAddressId: number; deviceCodes?: string[] } = {
dbAddressId: startTarget.value.dbId
}
if (selectedDevices.value.length > 0) {
payload.deviceCodes = selectedDevices.value
}
await startSimulator(payload)
ElMessage.success('启动成功')
startDialogVisible.value = false
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '启动失败')
} finally {
startLoading.value = false
}
}
/** 停止 */
async function handleStop(row: SimulatorAddress) {
try {
await stopSimulator({ dbAddressId: row.dbId })
ElMessage.success('已停止')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '停止失败')
}
}
/** 全部启动 */
async function handleStartAll() {
try {
await startAllSimulators()
ElMessage.success('全部启动成功')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 全部停止 */
async function handleStopAll() {
try {
await stopAllSimulators()
ElMessage.success('全部停止')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 刷新配置 */
async function handleReload() {
try {
await reloadSimulator()
ElMessage.success('配置已刷新')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '刷新失败')
}
}
//
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') {
loadAll()
}
}, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
loadAll()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>

@ -0,0 +1,28 @@
{
"gatewayPort": 9000,
"addresses": [
{
"name": "FANUC-1号模拟",
"port": 9001,
"brand": "fanuc",
"dataChangeInterval": 10,
"scenarioMode": "auto",
"devices": [
{ "deviceCode": "CNC-A001", "desc": "西栋1号", "initialProgram": "O0001", "initialPartCount": 50 },
{ "deviceCode": "CNC-006", "desc": "6号机床", "initialProgram": "O0002", "initialPartCount": 120 },
{ "deviceCode": "CNC-008", "desc": "8号机床", "initialProgram": "O0003", "initialPartCount": 0 }
]
},
{
"name": "FANUC-2号模拟",
"port": 9002,
"brand": "fanuc",
"dataChangeInterval": 15,
"scenarioMode": "auto",
"devices": [
{ "deviceCode": "CNC-B002", "desc": "B栋2号", "initialProgram": "1566.NC", "initialPartCount": 80 },
{ "deviceCode": "CNC-005", "desc": "验证机床", "initialProgram": "TEST001", "initialPartCount": 10 }
]
}
]
}

@ -37,6 +37,18 @@ namespace CncCollector.Config
[JsonProperty("dailySummaryTime")]
public string DailySummaryTime { get; set; } = "01:00";
/// <summary>分析日志保留天数0=不删除)</summary>
public int AnalysisLogRetentionDays { get; set; } = 0;
/// <summary>周期日志保留天数0=不删除)</summary>
public int CycleLogRetentionDays { get; set; } = 0;
/// <summary>原始日志保留天数0=不删除)</summary>
public int RawLogRetentionDays { get; set; } = 0;
/// <summary>日志清理检查间隔(分钟)</summary>
public int LogCleanupIntervalMinutes { get; set; } = 60;
/// <summary>服务ID标识</summary>
[JsonProperty("serviceId")]
public string ServiceId { get; set; } = "collector-service";

@ -0,0 +1,401 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using Newtonsoft.Json;
using CncModels.Entity;
using log4net;
namespace CncCollector.Core
{
/// <summary>
/// 采集分析引擎。
/// 在每次采集周期后,对比每台机床的当前数据与上一次采集数据,
/// 生成分析记录log_collect_analysis和周期汇总log_collect_cycle
/// 异常类分析自动写入告警表cnc_alert
/// </summary>
public class AnalysisEngine
{
private static readonly ILog _log = LogManager.GetLogger(typeof(AnalysisEngine));
/// <summary>业务库连接字符串(写告警用)</summary>
private readonly string _businessConnStr;
/// <summary>日志库连接字符串(写分析/周期表用)</summary>
private readonly string _logConnStr;
/// <summary>内存缓存machineId → 上一次采集状态快照</summary>
private readonly ConcurrentDictionary<int, MachineSnapshot> _lastSnapshot = new ConcurrentDictionary<int, MachineSnapshot>();
/// <summary>
/// 采集缓存快照(每台机床的上一次状态)
/// </summary>
public class MachineSnapshot
{
public string ProgramName { get; set; }
public decimal? PartCount { get; set; }
public string DeviceStatus { get; set; }
public DateTime CollectTime { get; set; }
}
/// <summary>
/// 初始化分析引擎
/// </summary>
/// <param name="businessConnStr">业务库连接字符串</param>
/// <param name="logConnStr">日志库连接字符串</param>
public AnalysisEngine(string businessConnStr, string logConnStr)
{
_businessConnStr = businessConnStr ?? throw new ArgumentNullException(nameof(businessConnStr));
_logConnStr = logConnStr ?? throw new ArgumentNullException(nameof(logConnStr));
}
/// <summary>
/// 分析一次采集周期的所有设备数据,写入分析记录和周期汇总。
/// </summary>
/// <param name="rawLogId">本次原始日志IDlog_collect_raw.id</param>
/// <param name="collectAddressId">采集地址ID</param>
/// <param name="addressName">采集地址名称</param>
/// <param name="records">本次采集的结构化记录列表</param>
/// <param name="machineDict">device_code → Machine 的查找字典</param>
/// <param name="cycleStartTime">周期开始时间</param>
/// <param name="durationMs">本次采集耗时(毫秒)</param>
public void AnalyzeAndRecord(long rawLogId, int collectAddressId, string addressName,
List<CollectRecord> records, Dictionary<string, Machine> machineDict,
DateTime cycleStartTime, long durationMs)
{
if (records == null || records.Count == 0) return;
try
{
var analysisTime = DateTime.Now;
var hasAnomaly = false;
var changeDistribution = new Dictionary<string, int>();
int successCount = 0;
// 构建 machineId → Machine 查找字典
var machineById = new Dictionary<int, Machine>();
foreach (var m in machineDict.Values)
{
machineById[m.Id] = m;
}
// 逐条分析
foreach (var rec in records)
{
try
{
// 获取机床信息
Machine machine = null;
machineById.TryGetValue(rec.MachineId, out machine);
string machineName = machine?.Name ?? ("机床" + rec.MachineId);
// 当前值
string currentProgram = rec.ProgramName;
decimal? currentPartCount = rec.PartCount;
string currentStatus = rec.DeviceStatus;
// 获取上次快照
MachineSnapshot prev;
_lastSnapshot.TryGetValue(rec.MachineId, out prev);
// 计算分析类型和摘要
string analysisType;
string summary;
bool needAlert = false;
string alertType = null;
DetermineAnalysis(prev, currentProgram, currentPartCount, currentStatus,
machineName, out analysisType, out summary, out needAlert, out alertType);
// 计算变化量
decimal? partCountDelta = null;
if (currentPartCount.HasValue && prev != null && prev.PartCount.HasValue)
{
partCountDelta = currentPartCount.Value - prev.PartCount.Value;
}
// 构建分析明细JSON
var detail = new
{
previous = prev != null ? new
{
program = prev.ProgramName,
partCount = prev.PartCount,
status = prev.DeviceStatus
} : null,
current = new
{
program = currentProgram,
partCount = currentPartCount,
status = currentStatus
},
delta = new { partCount = partCountDelta },
collectTime = rec.CollectTime.ToString("yyyy-MM-dd HH:mm:ss")
};
string detailJson = JsonConvert.SerializeObject(detail);
// 写入分析记录
WriteAnalysis(new CncModels.Entity.CollectAnalysis
{
AnalysisTime = analysisTime,
RawLogId = rawLogId,
CollectAddressId = collectAddressId,
MachineId = rec.MachineId,
AnalysisType = analysisType,
PreviousProgram = prev?.ProgramName,
CurrentProgram = currentProgram,
PreviousPartCount = prev?.PartCount,
CurrentPartCount = currentPartCount,
PartCountDelta = partCountDelta,
PreviousStatus = prev?.DeviceStatus,
CurrentStatus = currentStatus,
AnalysisSummary = summary,
AnalysisDetail = detailJson
});
// 更新快照
_lastSnapshot[rec.MachineId] = new MachineSnapshot
{
ProgramName = currentProgram,
PartCount = currentPartCount,
DeviceStatus = currentStatus,
CollectTime = rec.CollectTime
};
// 统计分布
if (changeDistribution.ContainsKey(analysisType))
changeDistribution[analysisType]++;
else
changeDistribution[analysisType] = 1;
// 异常告警
if (needAlert)
{
hasAnomaly = true;
WriteAlert(alertType, rec.MachineId, collectAddressId, summary, detailJson);
}
// 统计成功数(非异常即为成功)
if (analysisType != "COLLECTION_FAILED" && analysisType != "DATA_ANOMALY")
successCount++;
}
catch (Exception ex)
{
_log.Error($"分析单条记录失败machineId={rec.MachineId}", ex);
}
}
// 写入周期汇总
WriteCycleSummary(new CncModels.Entity.CollectCycle
{
CycleTime = cycleStartTime,
CollectAddressId = collectAddressId,
RawLogId = rawLogId,
EndTime = analysisTime,
DurationMs = (int)durationMs,
TotalMachines = records.Count,
SuccessCount = successCount,
FailCount = records.Count - successCount,
ChangeDistribution = JsonConvert.SerializeObject(changeDistribution),
HasAnomaly = hasAnomaly ? 1 : 0,
CycleSummary = $"共{records.Count}台机床完成分析" + (hasAnomaly ? ",存在异常" : "")
});
}
catch (Exception ex)
{
_log.Error($"采集分析失败(地址={addressName}, rawLogId={rawLogId}", ex);
}
}
/// <summary>
/// 根据前后状态对比,确定分析类型
/// </summary>
private void DetermineAnalysis(MachineSnapshot prev, string currentProgram, decimal? currentPartCount,
string currentStatus, string machineName, out string analysisType, out string summary,
out bool needAlert, out string alertType)
{
needAlert = false;
alertType = null;
// 无历史快照 → 首次上线
if (prev == null)
{
analysisType = "DEVICE_ONLINE";
summary = $"机床{machineName}首次上线,程序={currentProgram ?? ""}";
needAlert = true;
alertType = "unknown_device";
return;
}
string prevProgram = prev.ProgramName;
decimal? prevPartCount = prev.PartCount;
string prevStatus = prev.DeviceStatus;
// 检测程序切换
if (!string.IsNullOrEmpty(currentProgram) && !string.IsNullOrEmpty(prevProgram) &&
!string.Equals(prevProgram, currentProgram, StringComparison.OrdinalIgnoreCase))
{
analysisType = "PROGRAM_SWITCH";
summary = $"机床{machineName}程序切换: {prevProgram} → {currentProgram}";
return;
}
// 检测手动清零(同程序下零件数下降)
if (currentPartCount.HasValue && prevPartCount.HasValue &&
currentPartCount.Value < prevPartCount.Value)
{
analysisType = "MANUAL_RESET";
summary = $"机床{machineName}零件计数手动清零: {prevPartCount} → {currentPartCount}";
return;
}
// 检测零件数增加
if (currentPartCount.HasValue && prevPartCount.HasValue &&
currentPartCount.Value > prevPartCount.Value)
{
decimal delta = currentPartCount.Value - prevPartCount.Value;
analysisType = "PART_COUNT_INCREASE";
summary = $"机床{machineName}新增{delta}个零件({prevPartCount} → {currentPartCount}";
return;
}
// 检测设备离线/告警
if (!string.IsNullOrEmpty(currentStatus) &&
(currentStatus.Equals("OFFLINE", StringComparison.OrdinalIgnoreCase) ||
currentStatus.Equals("ALARM", StringComparison.OrdinalIgnoreCase) ||
currentStatus.Equals("EMERGENCY", StringComparison.OrdinalIgnoreCase)))
{
analysisType = "DEVICE_OFFLINE";
summary = $"机床{machineName}设备离线/告警: {currentStatus}";
needAlert = true;
alertType = "device_offline";
return;
}
// 检测数据异常(关键字段缺失但设备应该在线)
if (string.IsNullOrEmpty(currentProgram) && !string.IsNullOrEmpty(currentStatus) &&
!currentStatus.Equals("OFFLINE", StringComparison.OrdinalIgnoreCase))
{
analysisType = "DATA_ANOMALY";
summary = $"机床{machineName}数据异常: 缺少程序名字段";
needAlert = true;
alertType = "data_anomaly";
return;
}
// 无重大变化
analysisType = "NORMAL_UNCHANGED";
summary = $"机床{machineName}数据无重大变化";
}
/// <summary>
/// 写入单条分析记录到 log_collect_analysis
/// </summary>
private void WriteAnalysis(CncModels.Entity.CollectAnalysis entity)
{
try
{
using (var conn = new MySqlConnection(_logConnStr))
{
conn.Execute(@"INSERT INTO log_collect_analysis
(analysis_time, raw_log_id, collect_address_id, machine_id, analysis_type,
previous_program, current_program, previous_part_count, current_part_count,
part_count_delta, previous_status, current_status, analysis_summary,
analysis_detail, created_at)
VALUES (@AnalysisTime, @RawLogId, @CollectAddressId, @MachineId, @AnalysisType,
@PreviousProgram, @CurrentProgram, @PreviousPartCount, @CurrentPartCount,
@PartCountDelta, @PreviousStatus, @CurrentStatus, @AnalysisSummary,
@AnalysisDetail, NOW())",
new
{
entity.AnalysisTime,
entity.RawLogId,
entity.CollectAddressId,
entity.MachineId,
entity.AnalysisType,
entity.PreviousProgram,
entity.CurrentProgram,
entity.PreviousPartCount,
entity.CurrentPartCount,
entity.PartCountDelta,
entity.PreviousStatus,
entity.CurrentStatus,
entity.AnalysisSummary,
entity.AnalysisDetail
});
}
}
catch (Exception ex)
{
_log.Error($"写入分析记录失败machineId={entity.MachineId}", ex);
}
}
/// <summary>
/// 写入周期汇总到 log_collect_cycle
/// </summary>
private void WriteCycleSummary(CncModels.Entity.CollectCycle entity)
{
try
{
using (var conn = new MySqlConnection(_logConnStr))
{
conn.Execute(@"INSERT INTO log_collect_cycle
(cycle_time, collect_address_id, raw_log_id, end_time, duration_ms,
total_machines, success_count, fail_count, change_distribution,
has_anomaly, cycle_summary, created_at)
VALUES (@CycleTime, @CollectAddressId, @RawLogId, @EndTime, @DurationMs,
@TotalMachines, @SuccessCount, @FailCount, @ChangeDistribution,
@HasAnomaly, @CycleSummary, NOW())",
new
{
entity.CycleTime,
entity.CollectAddressId,
entity.RawLogId,
entity.EndTime,
entity.DurationMs,
entity.TotalMachines,
entity.SuccessCount,
entity.FailCount,
entity.ChangeDistribution,
entity.HasAnomaly,
entity.CycleSummary
});
}
}
catch (Exception ex)
{
_log.Error("写入周期汇总失败", ex);
}
}
/// <summary>
/// 写入告警到 cnc_alert业务库
/// </summary>
private void WriteAlert(string alertType, int machineId, int collectAddressId, string title, string detail)
{
try
{
using (var conn = new MySqlConnection(_businessConnStr))
{
conn.Execute(@"INSERT INTO cnc_alert (alert_type, machine_id, collect_address_id, title, detail, is_resolved, created_at)
VALUES (@AlertType, @MachineId, @AddressId, @Title, @Detail, 0, NOW())",
new
{
AlertType = alertType,
MachineId = machineId,
AddressId = collectAddressId,
Title = title,
Detail = detail
});
}
}
catch (Exception ex)
{
_log.Error($"写入告警失败alertType={alertType}, machineId={machineId}", ex);
}
}
}
}

@ -28,11 +28,12 @@ namespace CncCollector.Core
/// <param name="responseDurationMs">响应耗时(毫秒)</param>
/// <param name="isSuccess">是否采集成功</param>
/// <param name="errorMessage">错误信息(失败时)</param>
public static void WriteBatch(string businessConnStr, string logConnStr,
public static long WriteBatch(string businessConnStr, string logConnStr,
List<CollectRecord> records, string rawJson, int collectAddressId,
DateTime requestTime, long? responseDurationMs, bool isSuccess, string errorMessage, int? statusCode = null)
{
var now = DateTime.Now;
long lastRawLogId = 0;
// 1. 写入原始JSON到日志库
try
@ -54,6 +55,12 @@ namespace CncCollector.Core
ErrorMessage = errorMessage ?? (string)null,
CreatedAt = now
});
// 记录刚插入的 raw_log 的自增ID
try
{
lastRawLogId = conn.ExecuteScalar<long>("SELECT LAST_INSERT_ID();");
}
catch { lastRawLogId = 0; }
}
}
catch (Exception ex)
@ -61,7 +68,7 @@ namespace CncCollector.Core
_log.Error($"写入原始JSON日志失败地址ID={collectAddressId}", ex);
}
if (!isSuccess || records == null || records.Count == 0) return;
if (!isSuccess || records == null || records.Count == 0) return lastRawLogId;
// 2. 批量写入采集结构化记录到业务库
try
@ -162,6 +169,7 @@ namespace CncCollector.Core
{
_log.Error($"批量写入采集记录失败地址ID={collectAddressId}", ex);
}
return lastRawLogId;
}
/// <summary>

@ -29,6 +29,7 @@ namespace CncCollector.Core
private readonly CollectAddress _address;
private readonly CollectorConfig _config;
private readonly ProductionTracker _tracker;
private readonly AnalysisEngine _analysisEngine;
private readonly string _businessConnStr;
private readonly string _logConnStr;
private Thread _thread;
@ -65,11 +66,12 @@ namespace CncCollector.Core
/// <param name="businessConnStr">业务库连接字符串</param>
/// <param name="logConnStr">日志库连接字符串</param>
public CollectWorker(CollectAddress address, CollectorConfig config, ProductionTracker tracker,
string businessConnStr, string logConnStr)
AnalysisEngine analysisEngine, string businessConnStr, string logConnStr)
{
_address = address;
_config = config;
_tracker = tracker;
_analysisEngine = analysisEngine;
_businessConnStr = businessConnStr;
_logConnStr = logConnStr;
}
@ -245,10 +247,10 @@ namespace CncCollector.Core
using (var conn = new MySqlConnection(_businessConnStr))
{
if (onlineIds.Count > 0)
conn.Execute(@"UPDATE cnc_machine SET is_online = 1, last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
conn.Execute(@"UPDATE cnc_machine SET last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
new { Ids = onlineIds });
if (offlineIds.Count > 0)
conn.Execute(@"UPDATE cnc_machine SET is_online = 0, last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
conn.Execute(@"UPDATE cnc_machine SET last_ping_time = NOW(), updated_at = NOW() WHERE id IN @Ids",
new { Ids = offlineIds });
}
@ -307,7 +309,7 @@ namespace CncCollector.Core
// 加载此地址下的机床列表
machines = conn.Query<Machine>(
"SELECT id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, is_online as IsOnline, last_ping_time as LastPingTime, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt FROM cnc_machine WHERE collect_address_id = @AddrId AND is_enabled = 1",
"SELECT id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, last_ping_time as LastPingTime, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt FROM cnc_machine WHERE collect_address_id = @AddrId AND is_enabled = 1",
new { AddrId = _address.Id }).AsList();
}
@ -400,9 +402,15 @@ namespace CncCollector.Core
}
// 4. 批量写入
CollectRecordWriter.WriteBatch(_businessConnStr, _logConnStr, records, rawJson,
long rawLogId = CollectRecordWriter.WriteBatch(_businessConnStr, _logConnStr, records, rawJson,
_address.Id, requestTime, durationMs, true, null, statusCode);
// 采集分析:将分析任务委托给 AnalysisEngine
if (rawLogId > 0 && records != null && records.Count > 0 && _analysisEngine != null)
{
_analysisEngine.AnalyzeAndRecord(rawLogId, _address.Id, _address.Name, records, machineDict, requestTime, durationMs);
}
_log.Info($"采集完成: {_address.Name} → {records.Count}台设备, {durationMs}ms");
}

@ -1,4 +1,5 @@
using System;
using CncCollector.Jobs;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
@ -21,10 +22,14 @@ namespace CncCollector.Core
private readonly CollectorConfig _config;
private readonly ConcurrentDictionary<int, CollectWorker> _workers = new ConcurrentDictionary<int, CollectWorker>();
private readonly ProductionTracker _tracker;
// 复用的分析引擎实例(简单实现:按地址注入,避免跨线程问题)
private readonly AnalysisEngine _analysisEngine;
private readonly DailySummaryJob _dailySummary;
private Timer _heartbeatTimer;
private Timer _configPollTimer;
private Timer _dailySummaryTimer;
private Timer _logCleanupTimer;
private LogCleanupJob _logCleanupJob;
private DateTime _startTime;
private long _totalSuccess;
private long _totalFail;
@ -52,6 +57,8 @@ namespace CncCollector.Core
_config = config;
_tracker = new ProductionTracker(config.BusinessConnection);
_dailySummary = new DailySummaryJob(config.BusinessConnection);
// 初始化分析引擎(与业务库和日志库同源,后续按需调整)
_analysisEngine = new AnalysisEngine(config.BusinessConnection, config.LogConnection);
}
/// <summary>
@ -85,6 +92,15 @@ namespace CncCollector.Core
_dailySummaryTimer = new Timer(OnDailySummaryCheck, null,
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
// 5. 启动日志清理定时器从配置读取间隔0 表示不启用)
_logCleanupJob = new LogCleanupJob(_config.LogConnection, _config);
if (_config.LogCleanupIntervalMinutes > 0)
{
_logCleanupTimer = new Timer(OnLogCleanup, null,
TimeSpan.FromMinutes(_config.LogCleanupIntervalMinutes),
TimeSpan.FromMinutes(_config.LogCleanupIntervalMinutes));
}
_log.Info($"===== 采集引擎已启动({_workers.Count}个采集地址)=====");
}
@ -112,6 +128,7 @@ namespace CncCollector.Core
_heartbeatTimer?.Dispose();
_configPollTimer?.Dispose();
_dailySummaryTimer?.Dispose();
_logCleanupTimer?.Dispose();
// 写入停止状态心跳
WriteHeartbeat("stopped");
@ -208,7 +225,7 @@ namespace CncCollector.Core
if (!_workers.ContainsKey(addr.Id))
{
var worker = new CollectWorker(addr, _config, _tracker,
_config.BusinessConnection, _config.LogConnection);
_analysisEngine, _config.BusinessConnection, _config.LogConnection);
worker.Start();
_workers[addr.Id] = worker;
_log.Info($"已启动采集地址: {addr.Name}URL={addr.Url}, 间隔={addr.CollectInterval}秒)");
@ -328,5 +345,20 @@ namespace CncCollector.Core
_log.Error("日终汇总检查失败", ex);
}
}
/// <summary>
/// 日志清理定时回调
/// </summary>
private void OnLogCleanup(object state)
{
try
{
_logCleanupJob?.Execute();
}
catch (Exception ex)
{
_log.Error("日志清理任务执行失败", ex);
}
}
}
}

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using Dapper;
using MySqlConnector;
using CncCollector.Config;
using log4net;
namespace CncCollector.Jobs
{
/// <summary>
/// 日志清理定时任务。
/// 对按月分区表使用 DROP PARTITION 清理(瞬间完成),
/// 对非分区表回退到 DELETE。保留天数=0表示不删除。
/// </summary>
public class LogCleanupJob
{
private static readonly ILog _log = LogManager.GetLogger(typeof(LogCleanupJob));
private readonly string _logConnection;
private readonly CollectorConfig _config;
public LogCleanupJob(string logConnection, CollectorConfig config)
{
_logConnection = logConnection;
_config = config;
}
/// <summary>
/// 执行日志清理
/// </summary>
public void Execute()
{
try
{
int totalPartitions = 0;
int totalRows = 0;
using (var conn = new MySqlConnection(_logConnection))
{
// 1) 采集分析日志分区表DROP PARTITION
int daysA = Math.Max(_config.AnalysisLogRetentionDays, 0);
if (daysA > 0)
{
int dropped = DropOldPartitions(conn, "log_collect_analysis", "analysis_time", daysA);
if (dropped > 0)
{
totalPartitions += dropped;
_log.Info($"日志清理: log_collect_analysis DROP {dropped} 个分区,保留 {daysA} 天");
}
}
// 2) 采集周期日志分区表DROP PARTITION
int daysC = Math.Max(_config.CycleLogRetentionDays, 0);
if (daysC > 0)
{
int dropped = DropOldPartitions(conn, "log_collect_cycle", "cycle_time", daysC);
if (dropped > 0)
{
totalPartitions += dropped;
_log.Info($"日志清理: log_collect_cycle DROP {dropped} 个分区,保留 {daysC} 天");
}
}
// 3) 原始日志分区表DROP PARTITION
int daysR = Math.Max(_config.RawLogRetentionDays, 0);
if (daysR > 0)
{
int dropped = DropOldPartitions(conn, "log_collect_raw", "request_time", daysR);
if (dropped > 0)
{
totalPartitions += dropped;
_log.Info($"日志清理: log_collect_raw DROP {dropped} 个分区,保留 {daysR} 天");
}
}
}
_log.Info($"日志清理完成DROP {totalPartitions} 个分区");
}
catch (Exception ex)
{
_log.Error("执行日志清理任务失败", ex);
}
}
/// <summary>
/// 清理过期的月分区。计算保留天数对应的截止月份,
/// 找出所有分区边界早于截止月份的分区排除p_future执行 DROP PARTITION。
/// </summary>
private int DropOldPartitions(MySqlConnection conn, string tableName, string partitionColumn, int retentionDays)
{
int dropped = 0;
try
{
// 截止日期:保留天数之前的日期
var cutoffDate = DateTime.Now.AddDays(-retentionDays);
// 截止月份的第一天(整个月都要删除)
var cutoffMonth = new DateTime(cutoffDate.Year, cutoffDate.Month, 1);
// 查询所有非 p_future 分区及其边界值
var partitions = conn.Query<(string PARTITION_NAME, string PARTITION_DESCRIPTION)>(
@"SELECT PARTITION_NAME, PARTITION_DESCRIPTION
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'cnc_log'
AND TABLE_NAME = @TableName
AND PARTITION_NAME IS NOT NULL
AND PARTITION_NAME <> 'p_future'
ORDER BY PARTITION_ORDINAL_POSITION",
new { TableName = tableName });
foreach (var part in partitions)
{
// PARTITION_DESCRIPTION 是 TO_DAYS('YYYY-MM-DD') 的整数值
if (!long.TryParse(part.PARTITION_DESCRIPTION, out long toDaysValue))
continue;
// 将 TO_DAYS 值反算为日期(近似:用 MySQL 的 FROM_DAYS
DateTime partitionBoundary;
try
{
partitionBoundary = conn.ExecuteScalar<DateTime>(
"SELECT FROM_DAYS(@ToDays)", new { ToDays = toDaysValue });
}
catch
{
// 无法解析边界值,跳过此分区
_log.Warn($"无法解析分区边界值: {tableName}.{part.PARTITION_NAME} = {part.PARTITION_DESCRIPTION}");
continue;
}
// 如果分区边界 <= 截止月份,说明这个分区整月都在保留期外,可以 DROP
if (partitionBoundary <= cutoffMonth)
{
try
{
conn.Execute($"ALTER TABLE cnc_log.{tableName} DROP PARTITION {part.PARTITION_NAME}");
dropped++;
_log.Info($"DROP PARTITION: {tableName}.{part.PARTITION_NAME} (边界={partitionBoundary:yyyy-MM-dd})");
}
catch (Exception ex)
{
_log.Error($"DROP PARTITION 失败: {tableName}.{part.PARTITION_NAME}", ex);
}
}
}
}
catch (Exception ex)
{
_log.Error($"清理分区表 {tableName} 时出错", ex);
}
return dropped;
}
}
}

@ -7,7 +7,7 @@ import { defineConfig } from '@playwright/test';
*/
export default defineConfig({
testDir: '.',
testMatch: 'e2e-collector.spec.ts',
testMatch: '*.spec.ts',
timeout: 120000,
retries: 0,
reporter: [['list'], ['html', { open: 'never' }]],

@ -10,5 +10,7 @@ namespace CncModels.Dto.Brand
public string MatchBy { get; set; }
public string DataType { get; set; }
public int IsRequired { get; set; }
public int IsEnabled { get; set; }
}
}

@ -0,0 +1,15 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析详情(基于 CollectAnalysisListItem 的扩展字段)
/// </summary>
public class CollectAnalysisDetail : CollectAnalysisListItem
{
public decimal? PreviousPartCount { get; set; }
public decimal? CurrentPartCount { get; set; }
public string PreviousStatus { get; set; }
public string CurrentStatus { get; set; }
public string AnalysisDetail { get; set; }
public long RawLogId { get; set; }
}
}

@ -0,0 +1,19 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析列表项
/// </summary>
public class CollectAnalysisListItem
{
public long Id { get; set; }
public string AnalysisTime { get; set; }
public int CollectAddressId { get; set; }
public int MachineId { get; set; }
public string MachineName { get; set; }
public string AnalysisType { get; set; }
public string PreviousProgram { get; set; }
public string CurrentProgram { get; set; }
public decimal? PartCountDelta { get; set; }
public string AnalysisSummary { get; set; }
}
}

@ -0,0 +1,17 @@
using System;
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析列表查询条件(分页)
/// </summary>
public class CollectAnalysisQuery : PagedQuery
{
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int? CollectAddressId { get; set; }
public int? MachineId { get; set; }
public string AnalysisType { get; set; }
public string ProgramName { get; set; }
}
}

@ -0,0 +1,19 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集周期列表项
/// </summary>
public class CollectCycleListItem
{
public long Id { get; set; }
public string CycleTime { get; set; }
public int CollectAddressId { get; set; }
public string AddressName { get; set; }
public int TotalMachines { get; set; }
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public int HasAnomaly { get; set; }
public string ChangeDistribution { get; set; }
public string CycleSummary { get; set; }
}
}

@ -0,0 +1,15 @@
using System;
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集周期查询条件(分页)
/// </summary>
public class CollectCycleQuery : PagedQuery
{
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int? CollectAddressId { get; set; }
public int? HasAnomaly { get; set; }
}
}

@ -0,0 +1,26 @@
using System;
namespace CncModels.Dto.CollectLog
{
/// <summary>回放请求参数</summary>
public class ReplayRequest { public DateTime Date { get; set; } }
/// <summary>回放预览结果</summary>
public class ReplayPreview {
public DateTime Date { get; set; }
public int RawLogCount { get; set; }
public int AffectedMachineCount { get; set; }
public int AffectedRecordCount { get; set; }
public int AffectedSegmentCount { get; set; }
}
/// <summary>回放执行结果</summary>
public class ReplayResult {
public DateTime Date { get; set; }
public int ClearedRecordCount { get; set; }
public int ClearedSegmentCount { get; set; }
public int RebuiltRecordCount { get; set; }
public bool Success { get; set; }
public string ErrorMessage { get; set; }
}
}

@ -22,9 +22,6 @@ namespace CncModels.Dto.Dashboard
/// <summary>采集成功率(百分比,小数不放大)</summary>
public decimal CollectSuccessRate { get; set; }
/// <summary>今日总工作时长/产线运行时间,单位分钟</summary>
public int TodayCuttingTime { get; set; }
/// <summary>正在运行的机床数量</summary>
public int RunningMachines { get; set; }

@ -28,6 +28,9 @@ namespace CncModels.Entity
/// <summary>是否必填</summary>
public int IsRequired { get; set; }
/// <summary>是否启用</summary>
public int IsEnabled { get; set; }
/// <summary>创建时间</summary>
public DateTime CreatedAt { get; set; }
}

@ -0,0 +1,58 @@
using System;
namespace CncModels.Entity
{
/// <summary>
/// 采集分析记录实体(日志库 log_collect_analysis
/// </summary>
public class CollectAnalysis
{
/// <summary>自增ID</summary>
public long Id { get; set; }
/// <summary>分析时间</summary>
public DateTime AnalysisTime { get; set; }
/// <summary>原始日志ID</summary>
public long RawLogId { get; set; }
/// <summary>采集地址ID</summary>
public int CollectAddressId { get; set; }
/// <summary>机器ID</summary>
public int MachineId { get; set; }
/// <summary>分析类型</summary>
public string AnalysisType { get; set; }
/// <summary>前一程序名</summary>
public string PreviousProgram { get; set; }
/// <summary>当前程序名</summary>
public string CurrentProgram { get; set; }
/// <summary>前一阶段产出数量</summary>
public decimal? PreviousPartCount { get; set; }
/// <summary>当前阶段产出数量</summary>
public decimal? CurrentPartCount { get; set; }
/// <summary>产出变化量</summary>
public decimal? PartCountDelta { get; set; }
/// <summary>前一状态</summary>
public string PreviousStatus { get; set; }
/// <summary>当前状态</summary>
public string CurrentStatus { get; set; }
/// <summary>分析概要</summary>
public string AnalysisSummary { get; set; }
/// <summary>分析细节JSON字符串</summary>
public string AnalysisDetail { get; set; }
/// <summary>创建时间</summary>
public DateTime CreatedAt { get; set; }
}
}

@ -0,0 +1,49 @@
using System;
namespace CncModels.Entity
{
/// <summary>
/// 采集分析周期实体(日志库 log_collect_cycle
/// </summary>
public class CollectCycle
{
/// <summary>自增ID</summary>
public long Id { get; set; }
/// <summary>周期时间</summary>
public DateTime CycleTime { get; set; }
/// <summary>采集地址ID</summary>
public int CollectAddressId { get; set; }
/// <summary>原始日志ID</summary>
public long RawLogId { get; set; }
/// <summary>结束时间</summary>
public DateTime? EndTime { get; set; }
/// <summary>周期持续时长(毫秒)</summary>
public int? DurationMs { get; set; }
/// <summary>总机器数</summary>
public int TotalMachines { get; set; }
/// <summary>成功计数</summary>
public int SuccessCount { get; set; }
/// <summary>失败计数</summary>
public int FailCount { get; set; }
/// <summary>分布变化JSON</summary>
public string ChangeDistribution { get; set; }
/// <summary>是否存在异常0/1</summary>
public int HasAnomaly { get; set; }
/// <summary>周期概要</summary>
public string CycleSummary { get; set; }
/// <summary>创建时间</summary>
public DateTime CreatedAt { get; set; }
}
}

@ -31,10 +31,7 @@ namespace CncModels.Entity
/// <summary>是否启用</summary>
public int IsEnabled { get; set; }
/// <summary>是否在线</summary>
public int IsOnline { get; set; }
/// <summary>最近Ping时间</summary>
/// <summary>最近Ping时间在线状态由 last_ping_time 实时计算)</summary>
public DateTime? LastPingTime { get; set; }
/// <summary>最近采集时间</summary>

@ -19,6 +19,9 @@ namespace CncModels.Enum
/// <summary>未知设备</summary>
public const string UnknownDevice = "unknown_device";
/// <summary>数据异常</summary>
public const string DataAnomaly = "data_anomaly";
/// <summary>服务错误</summary>
public const string ServiceError = "service_error";
}

@ -0,0 +1,18 @@
namespace CncModels.Enum
{
/// <summary>
/// 采集分析的分析类型枚举(以字符串常量形式提供)
/// </summary>
public static class AnalysisType
{
public const string NORMAL_UNCHANGED = "NORMAL_UNCHANGED";
public const string PART_COUNT_INCREASE = "PART_COUNT_INCREASE";
public const string PROGRAM_SWITCH = "PROGRAM_SWITCH";
public const string MANUAL_RESET = "MANUAL_RESET";
public const string DEVICE_ONLINE = "DEVICE_ONLINE";
public const string DEVICE_OFFLINE = "DEVICE_OFFLINE";
public const string NEW_DEVICE_FOUND = "NEW_DEVICE_FOUND";
public const string DATA_ANOMALY = "DATA_ANOMALY";
public const string COLLECTION_FAILED = "COLLECTION_FAILED";
}
}

@ -19,7 +19,7 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"SELECT id as Id, brand_id as BrandId, standard_field as StandardField, field_name as FieldName, match_by as MatchBy, data_type as DataType, is_required as IsRequired, created_at as CreatedAt
var sql = @"SELECT id as Id, brand_id as BrandId, standard_field as StandardField, field_name as FieldName, match_by as MatchBy, data_type as DataType, is_required as IsRequired, is_enabled as IsEnabled, created_at as CreatedAt
FROM cnc_brand_field_mapping WHERE brand_id = @BrandId ORDER BY id";
return conn.Query<BrandFieldMapping>(sql, new { BrandId = brandId }).ToList();
}
@ -29,7 +29,7 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"SELECT id as Id, brand_id as BrandId, standard_field as StandardField, field_name as FieldName, match_by as MatchBy, data_type as DataType, is_required as IsRequired, created_at as CreatedAt
var sql = @"SELECT id as Id, brand_id as BrandId, standard_field as StandardField, field_name as FieldName, match_by as MatchBy, data_type as DataType, is_required as IsRequired, is_enabled as IsEnabled, created_at as CreatedAt
FROM cnc_brand_field_mapping WHERE id = @Id";
return conn.QuerySingleOrDefault<BrandFieldMapping>(sql, new { Id = id });
}
@ -39,8 +39,8 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, created_at)
VALUES (@BrandId, @StandardField, @FieldName, @MatchBy, @DataType, @IsRequired, @CreatedAt);
var sql = @"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (@BrandId, @StandardField, @FieldName, @MatchBy, @DataType, @IsRequired, @IsEnabled, @CreatedAt);
SELECT LAST_INSERT_ID();";
return conn.QuerySingle<int>(sql, entity);
}
@ -50,7 +50,7 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"UPDATE cnc_brand_field_mapping SET brand_id = @BrandId, standard_field = @StandardField, field_name = @FieldName, match_by = @MatchBy, data_type = @DataType, is_required = @IsRequired, created_at = @CreatedAt WHERE id = @Id";
var sql = @"UPDATE cnc_brand_field_mapping SET brand_id = @BrandId, standard_field = @StandardField, field_name = @FieldName, match_by = @MatchBy, data_type = @DataType, is_required = @IsRequired, is_enabled = @IsEnabled, created_at = @CreatedAt WHERE id = @Id";
return conn.Execute(sql, entity) > 0;
}
}
@ -74,8 +74,8 @@ namespace CncRepository.Impl
try
{
int count = 0;
var sql = @"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, created_at)
VALUES (@BrandId, @StandardField, @FieldName, @MatchBy, @DataType, @IsRequired, @CreatedAt);
var sql = @"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (@BrandId, @StandardField, @FieldName, @MatchBy, @DataType, @IsRequired, @IsEnabled, @CreatedAt);
SELECT LAST_INSERT_ID();";
foreach (var m in mappings)
{
@ -94,5 +94,15 @@ namespace CncRepository.Impl
}
}
}
public List<BrandFieldMapping> GetEnabledByBrandId(int brandId)
{
using (var conn = CreateConnection())
{
var sql = @"SELECT id as Id, brand_id as BrandId, standard_field as StandardField, field_name as FieldName, match_by as MatchBy, data_type as DataType, is_required as IsRequired, is_enabled as IsEnabled, created_at as CreatedAt
FROM cnc_brand_field_mapping WHERE brand_id = @BrandId AND is_enabled = 1 ORDER BY id";
return conn.Query<BrandFieldMapping>(sql, new { BrandId = brandId }).ToList();
}
}
}
}

@ -44,12 +44,13 @@ namespace CncRepository.Impl.Dashboard
) all_days";
/// <summary>汇总卡片数据</summary>
public DashboardSummaryResponse GetSummary()
public DashboardSummaryResponse GetSummary(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var onlineCount = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_online = 1");
var totalMachines = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine");
var onlineCount = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND",
new { OnlineTimeout = onlineTimeout });
var totalMachines = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1");
// 今日总产量:直接从产量分段实时计算(今日一定没有日终汇总)
var todayProduction = conn.ExecuteScalar<int>(@"
SELECT COALESCE(SUM(CASE WHEN is_settled=1 THEN quantity
@ -63,15 +64,6 @@ namespace CncRepository.Impl.Dashboard
var totalCount = successCount + failCount;
decimal collectSuccessRate = totalCount > 0 ? Math.Round((decimal)successCount / totalCount * 100, 2) : 0m;
// 今日切削总时每台机床今日最新cutting_time - 今日最早cutting_time求和后转小时
var todayCuttingTime = Convert.ToInt32(conn.ExecuteScalar<decimal>(@"
SELECT COALESCE(ROUND(SUM(today_delta)/3600, 1), 0)
FROM (
SELECT MAX(cr.cutting_time) - MIN(cr.cutting_time) AS today_delta
FROM cnc_collect_record cr
WHERE DATE(cr.collect_time) = CURDATE()
GROUP BY cr.machine_id
) t"));
var runningMachines = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine WHERE last_device_status = 'running'");
var dataMissingMachines = conn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_machine_daily_status WHERE production_date = CURDATE() AND data_status = 'data_missing'");
@ -82,7 +74,6 @@ namespace CncRepository.Impl.Dashboard
TodayProduction = todayProduction,
ActiveAlerts = activeAlerts,
CollectSuccessRate = collectSuccessRate,
TodayCuttingTime = todayCuttingTime,
RunningMachines = runningMachines,
DataMissingMachines = dataMissingMachines
};
@ -130,15 +121,17 @@ namespace CncRepository.Impl.Dashboard
}
/// <summary>机床排行</summary>
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top)
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int onlineTimeout = 300, string sortOrder = "desc")
{
// 排序方向白名单校验防止SQL注入
var orderBy = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
using (var conn = CreateConnection())
{
var sql = @"
var sql = $@"
SELECT m.id AS MachineId,
m.name AS MachineName,
COALESCE(SUM(ad.day_quantity), 0) AS Quantity,
CAST(m.is_online AS SIGNED) AS Status,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) AS Status,
(SELECT seg.program_name FROM cnc_production_segment seg
WHERE seg.machine_id = m.id AND seg.production_date = CURDATE()
ORDER BY seg.id DESC LIMIT 1) AS Program
@ -162,10 +155,10 @@ namespace CncRepository.Impl.Dashboard
)
GROUP BY seg.machine_id, seg.production_date
) ad ON ad.machine_id = m.id
GROUP BY m.id, m.name, m.is_online
ORDER BY Quantity DESC
GROUP BY m.id, m.name, m.is_enabled, m.last_ping_time
ORDER BY Quantity {orderBy}
LIMIT @Top";
var rows = conn.Query<MachineRankResponse>(sql, new { StartDate = startDate, EndDate = endDate, Top = top }).ToList();
var rows = conn.Query<MachineRankResponse>(sql, new { StartDate = startDate, EndDate = endDate, Top = top, OnlineTimeout = onlineTimeout }).ToList();
// 填充排名
for (int i = 0; i < rows.Count; i++) rows[i].Rank = i + 1;
return rows;
@ -173,11 +166,13 @@ namespace CncRepository.Impl.Dashboard
}
/// <summary>工人排行</summary>
public List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top)
public List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc")
{
// 排序方向白名单校验防止SQL注入
var orderBy = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
using (var conn = CreateConnection())
{
var sql = @"
var sql = $@"
SELECT w.name AS WorkerName,
COUNT(DISTINCT wm.machine_id) AS MachineCount,
COALESCE(SUM(ad.day_quantity), 0) AS TotalQuantity
@ -203,7 +198,7 @@ namespace CncRepository.Impl.Dashboard
GROUP BY seg.machine_id, seg.production_date
) ad ON ad.machine_id = wm.machine_id
GROUP BY w.id, w.name
ORDER BY TotalQuantity DESC
ORDER BY TotalQuantity {orderBy}
LIMIT @Top";
var rows = conn.Query<WorkerRankResponse>(sql, new { StartDate = startDate, EndDate = endDate, Top = top }).ToList();
// 填充排名
@ -244,12 +239,14 @@ namespace CncRepository.Impl.Dashboard
}
/// <summary>机床状态分布</summary>
public object GetMachineStatusDistribution()
public object GetMachineStatusDistribution(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var online = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND is_online = 1");
var offline = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND is_online = 0");
var online = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND",
new { OnlineTimeout = onlineTimeout });
var offline = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 1 AND (last_ping_time IS NULL OR last_ping_time < NOW() - INTERVAL @OnlineTimeout SECOND)",
new { OnlineTimeout = onlineTimeout });
var disabled = conn.ExecuteScalar<int>("SELECT COUNT(1) FROM cnc_machine WHERE is_enabled = 0");
return new { online, offline, disabled };
}

@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncRepository.Base;
using CncRepository.Interface;
namespace CncRepository.Impl.Log
{
/// <summary>
/// 采集分析仓储实现(日志库 - log_collect_analysis
/// </summary>
public class CollectAnalysisRepository : LogRepository, ICollectAnalysisRepository
{
public CollectAnalysisRepository(string connectionString) : base(connectionString) { }
public PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query)
{
using (var conn = CreateConnection())
{
var whereParts = new List<string> { "1=1" };
var p = new DynamicParameters();
if (query.StartDate.HasValue)
{
whereParts.Add("a.analysis_time >= @StartDate");
p.Add("StartDate", query.StartDate);
}
if (query.EndDate.HasValue)
{
whereParts.Add("a.analysis_time <= @EndDate");
p.Add("EndDate", query.EndDate);
}
if (query.CollectAddressId.HasValue)
{
whereParts.Add("a.collect_address_id = @CollectAddressId");
p.Add("CollectAddressId", query.CollectAddressId);
}
if (query.MachineId.HasValue)
{
whereParts.Add("a.machine_id = @MachineId");
p.Add("MachineId", query.MachineId);
}
if (!string.IsNullOrEmpty(query.AnalysisType))
{
whereParts.Add("a.analysis_type = @AnalysisType");
p.Add("AnalysisType", query.AnalysisType);
}
if (!string.IsNullOrEmpty(query.ProgramName))
{
whereParts.Add("a.current_program LIKE CONCAT('%', @ProgramName, '%')");
p.Add("ProgramName", query.ProgramName);
}
var whereSql = string.Join(" AND ", whereParts);
// 统计总条数
var total = conn.ExecuteScalar<int>(
$"SELECT COUNT(1) FROM log_collect_analysis a WHERE {whereSql}", p);
// 分页查询(左连机床表获取名称)
var dataSql = $@"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
NULL AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.analysis_summary AS AnalysisSummary
FROM log_collect_analysis a
WHERE {whereSql}
ORDER BY a.analysis_time DESC
LIMIT @PageSize OFFSET @Offset";
var items = conn.Query<CollectAnalysisListItem>(dataSql,
new { PageSize = query.PageSize, Offset = query.Offset }).AsList();
return new PagedResult<CollectAnalysisListItem>
{
Items = items,
Total = total,
Page = query.Page,
PageSize = query.PageSize
};
}
}
public CollectAnalysisDetail GetAnalysisDetail(long id)
{
using (var conn = CreateConnection())
{
var sql = @"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
NULL AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.previous_part_count AS PreviousPartCount,
a.current_part_count AS CurrentPartCount,
a.previous_status AS PreviousStatus,
a.current_status AS CurrentStatus,
a.analysis_summary AS AnalysisSummary,
a.analysis_detail AS AnalysisDetail,
a.raw_log_id AS RawLogId
FROM log_collect_analysis a
WHERE a.id = @Id";
return conn.QueryFirstOrDefault<CollectAnalysisDetail>(sql, new { Id = id });
}
}
public List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId)
{
using (var conn = CreateConnection())
{
var sql = @"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
NULL AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.analysis_summary AS AnalysisSummary
FROM log_collect_analysis a
WHERE a.raw_log_id = @RawLogId
ORDER BY a.analysis_time DESC";
return conn.Query<CollectAnalysisListItem>(sql, new { RawLogId = rawLogId }).AsList();
}
}
public long Create(CollectAnalysis entity)
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO log_collect_analysis
(analysis_time, raw_log_id, collect_address_id, machine_id, analysis_type,
previous_program, current_program, previous_part_count, current_part_count,
part_count_delta, previous_status, current_status, analysis_summary,
analysis_detail, created_at)
VALUES (@AnalysisTime, @RawLogId, @CollectAddressId, @MachineId, @AnalysisType,
@PreviousProgram, @CurrentProgram, @PreviousPartCount, @CurrentPartCount,
@PartCountDelta, @PreviousStatus, @CurrentStatus, @AnalysisSummary,
@AnalysisDetail, NOW());
SELECT LAST_INSERT_ID();";
return conn.ExecuteScalar<long>(sql, new
{
entity.AnalysisTime,
entity.RawLogId,
entity.CollectAddressId,
entity.MachineId,
entity.AnalysisType,
entity.PreviousProgram,
entity.CurrentProgram,
entity.PreviousPartCount,
entity.CurrentPartCount,
entity.PartCountDelta,
entity.PreviousStatus,
entity.CurrentStatus,
entity.AnalysisSummary,
entity.AnalysisDetail
});
}
}
public int DeleteBeforeDate(DateTime date)
{
using (var conn = CreateConnection())
{
return conn.Execute(
"DELETE FROM log_collect_analysis WHERE analysis_time < @Date",
new { Date = date });
}
}
}
}

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncRepository.Base;
using CncRepository.Interface;
namespace CncRepository.Impl.Log
{
/// <summary>
/// 采集周期仓储实现(日志库 - log_collect_cycle
/// </summary>
public class CollectCycleRepository : LogRepository, ICollectCycleRepository
{
public CollectCycleRepository(string connectionString) : base(connectionString) { }
public PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query)
{
using (var conn = CreateConnection())
{
var whereParts = new List<string> { "1=1" };
var p = new DynamicParameters();
if (query.StartDate.HasValue)
{
whereParts.Add("c.cycle_time >= @StartDate");
p.Add("StartDate", query.StartDate);
}
if (query.EndDate.HasValue)
{
whereParts.Add("c.cycle_time <= @EndDate");
p.Add("EndDate", query.EndDate);
}
if (query.CollectAddressId.HasValue)
{
whereParts.Add("c.collect_address_id = @CollectAddressId");
p.Add("CollectAddressId", query.CollectAddressId);
}
if (query.HasAnomaly.HasValue)
{
whereParts.Add("c.has_anomaly = @HasAnomaly");
p.Add("HasAnomaly", query.HasAnomaly);
}
var whereSql = string.Join(" AND ", whereParts);
var total = conn.ExecuteScalar<int>(
$"SELECT COUNT(1) FROM log_collect_cycle c WHERE {whereSql}", p);
var dataSql = $@"SELECT
c.id AS Id,
DATE_FORMAT(c.cycle_time, '%Y-%m-%d %H:%i:%s') AS CycleTime,
c.collect_address_id AS CollectAddressId,
NULL AS AddressName,
c.total_machines AS TotalMachines,
c.success_count AS SuccessCount,
c.fail_count AS FailCount,
c.has_anomaly AS HasAnomaly,
c.change_distribution AS ChangeDistribution,
c.cycle_summary AS CycleSummary
FROM log_collect_cycle c
WHERE {whereSql}
ORDER BY c.cycle_time DESC
LIMIT @PageSize OFFSET @Offset";
var items = conn.Query<CollectCycleListItem>(dataSql,
new { PageSize = query.PageSize, Offset = query.Offset }).AsList();
return new PagedResult<CollectCycleListItem>
{
Items = items,
Total = total,
Page = query.Page,
PageSize = query.PageSize
};
}
}
public long Create(CollectCycle entity)
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO log_collect_cycle
(cycle_time, collect_address_id, raw_log_id, end_time, duration_ms,
total_machines, success_count, fail_count, change_distribution,
has_anomaly, cycle_summary, created_at)
VALUES (@CycleTime, @CollectAddressId, @RawLogId, @EndTime, @DurationMs,
@TotalMachines, @SuccessCount, @FailCount, @ChangeDistribution,
@HasAnomaly, @CycleSummary, NOW());
SELECT LAST_INSERT_ID();";
return conn.ExecuteScalar<long>(sql, new
{
entity.CycleTime,
entity.CollectAddressId,
entity.RawLogId,
entity.EndTime,
entity.DurationMs,
entity.TotalMachines,
entity.SuccessCount,
entity.FailCount,
entity.ChangeDistribution,
entity.HasAnomaly,
entity.CycleSummary
});
}
}
public int DeleteBeforeDate(DateTime date)
{
using (var conn = CreateConnection())
{
return conn.Execute(
"DELETE FROM log_collect_cycle WHERE cycle_time < @Date",
new { Date = date });
}
}
}
}

@ -17,18 +17,22 @@ namespace CncRepository.Impl
public MachineRepository(string connectionString) : base(connectionString) { }
/// <summary>机床SELECT列映射模板snake_case列名 → PascalCase属性名</summary>
private const string SelectColumns = @"id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, is_online as IsOnline, last_ping_time as LastPingTime, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt";
/// <summary>在线判断SQL片段已启用且最近Ping在超时阈值内视为在线。参数 @OnlineTimeout</summary>
private const string OnlineExpr = "(CASE WHEN is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END)";
public Machine GetById(int id)
private const string SelectColumns = @"id as Id, device_code as DeviceCode, name as Name, workshop_id as WorkshopId, collect_address_id as CollectAddressId, ip_address as IpAddress, brand_id as BrandId, is_enabled as IsEnabled, {0} as IsOnline, last_ping_time as LastPingTime, last_collect_time as LastCollectTime, last_device_status as LastDeviceStatus, last_run_status as LastRunStatus, last_program_name as LastProgramName, last_part_count as LastPartCount, last_operate_mode as LastOperateMode, last_machining_status as LastMachiningStatus, created_at as CreatedAt, updated_at as UpdatedAt";
public Machine GetById(int id, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var sql = $"SELECT {SelectColumns} FROM cnc_machine WHERE id = @Id";
return conn.QuerySingleOrDefault<Machine>(sql, new { Id = id });
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE id = @Id";
return conn.QuerySingleOrDefault<Machine>(sql, new { Id = id, OnlineTimeout = onlineTimeout });
}
}
public MachineDetailResponse GetDetailById(int id)
public MachineDetailResponse GetDetailById(int id, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
@ -37,7 +41,8 @@ namespace CncRepository.Impl
m.collect_address_id as CollectAddressId,
m.brand_id as BrandId, b.brand_name as BrandName,
m.ip_address as IpAddress,
m.is_enabled as IsEnabled, m.is_online as IsOnline,
m.is_enabled as IsEnabled,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) as IsOnline,
w.id as WorkerId, w.name as WorkerName,
m.last_program_name as LastProgramName, m.last_collect_time as LastCollectTime
FROM cnc_machine m
@ -46,16 +51,17 @@ namespace CncRepository.Impl
LEFT JOIN cnc_worker_machine wm ON m.id = wm.machine_id
LEFT JOIN cnc_worker w ON wm.worker_id = w.id
WHERE m.id = @Id";
return conn.QuerySingleOrDefault<MachineDetailResponse>(sql, new { Id = id });
return conn.QuerySingleOrDefault<MachineDetailResponse>(sql, new { Id = id, OnlineTimeout = onlineTimeout });
}
}
public PagedResult<MachineListItem> GetList(MachineQuery query)
public PagedResult<MachineListItem> GetList(MachineQuery query, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var where = " WHERE 1=1";
var p = new DynamicParameters();
p.Add("OnlineTimeout", onlineTimeout);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
where += " AND (m.name LIKE @Keyword OR m.device_code LIKE @Keyword)";
@ -68,8 +74,10 @@ namespace CncRepository.Impl
}
if (query.IsOnline.HasValue)
{
where += " AND m.is_online = @IsOnline";
p.Add("IsOnline", query.IsOnline.Value);
if (query.IsOnline.Value == 1)
where += " AND m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND";
else
where += " AND (m.is_enabled = 0 OR m.last_ping_time IS NULL OR m.last_ping_time < NOW() - INTERVAL @OnlineTimeout SECOND)";
}
if (query.BrandId.HasValue)
{
@ -78,7 +86,9 @@ namespace CncRepository.Impl
}
var limit = query.PageSize;
var offset = query.Offset;
var sql = @"SELECT m.id as Id, m.device_code as DeviceCode, m.name as Name, m.workshop_id as WorkshopId, ws.name as WorkshopName, m.collect_address_id as CollectAddressId, m.brand_id as BrandId, b.brand_name as BrandName, m.ip_address as IpAddress, m.is_enabled as IsEnabled, m.is_online as IsOnline, m.last_program_name as LastProgramName, m.last_collect_time as LastCollectTime, w.id as WorkerId, w.name as WorkerName
var sql = @"SELECT m.id as Id, m.device_code as DeviceCode, m.name as Name, m.workshop_id as WorkshopId, ws.name as WorkshopName, m.collect_address_id as CollectAddressId, m.brand_id as BrandId, b.brand_name as BrandName, m.ip_address as IpAddress, m.is_enabled as IsEnabled,
(CASE WHEN m.is_enabled = 1 AND m.last_ping_time IS NOT NULL AND m.last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND THEN 1 ELSE 0 END) as IsOnline,
m.last_program_name as LastProgramName, m.last_collect_time as LastCollectTime, w.id as WorkerId, w.name as WorkerName
FROM cnc_machine m
LEFT JOIN cnc_workshop ws ON m.workshop_id = ws.id
LEFT JOIN cnc_brand b ON m.brand_id = b.id
@ -103,8 +113,8 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, is_online, created_at, updated_at)
VALUES (@DeviceCode, @Name, @WorkshopId, @CollectAddressId, @IpAddress, @BrandId, @IsEnabled, @IsOnline, @CreatedAt, @UpdatedAt);
var sql = @"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at)
VALUES (@DeviceCode, @Name, @WorkshopId, @CollectAddressId, @IpAddress, @BrandId, @IsEnabled, @CreatedAt, @UpdatedAt);
SELECT LAST_INSERT_ID();";
return conn.QuerySingle<int>(sql, entity);
}
@ -114,7 +124,7 @@ namespace CncRepository.Impl
{
using (var conn = CreateConnection())
{
var sql = @"UPDATE cnc_machine SET device_code = @DeviceCode, name = @Name, workshop_id = @WorkshopId, collect_address_id = @CollectAddressId, ip_address = @IpAddress, brand_id = @BrandId, is_enabled = @IsEnabled, is_online = @IsOnline, updated_at = @UpdatedAt, last_program_name = @LastProgramName, last_collect_time = @LastCollectTime, last_device_status = @LastDeviceStatus, last_run_status = @LastRunStatus, last_machining_status = @LastMachiningStatus WHERE id = @Id";
var sql = @"UPDATE cnc_machine SET device_code = @DeviceCode, name = @Name, workshop_id = @WorkshopId, collect_address_id = @CollectAddressId, ip_address = @IpAddress, brand_id = @BrandId, is_enabled = @IsEnabled, updated_at = @UpdatedAt, last_program_name = @LastProgramName, last_collect_time = @LastCollectTime, last_device_status = @LastDeviceStatus, last_run_status = @LastRunStatus, last_machining_status = @LastMachiningStatus WHERE id = @Id";
return conn.Execute(sql, entity) > 0;
}
}
@ -147,39 +157,33 @@ namespace CncRepository.Impl
}
}
public Machine GetByDeviceCode(string deviceCode)
{
using (var conn = CreateConnection())
{
var sql = $"SELECT {SelectColumns} FROM cnc_machine WHERE device_code = @DeviceCode";
return conn.QuerySingleOrDefault<Machine>(sql, new { DeviceCode = deviceCode });
}
}
public List<Machine> GetEnabledByAddressId(int collectAddressId)
public Machine GetByDeviceCode(string deviceCode, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var sql = $"SELECT {SelectColumns} FROM cnc_machine WHERE collect_address_id = @CollectAddressId AND is_enabled = 1";
return conn.Query<Machine>(sql, new { CollectAddressId = collectAddressId }).ToList();
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE device_code = @DeviceCode";
return conn.QuerySingleOrDefault<Machine>(sql, new { DeviceCode = deviceCode, OnlineTimeout = onlineTimeout });
}
}
public List<Machine> GetEnabledOnline()
public List<Machine> GetEnabledByAddressId(int collectAddressId, int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var sql = $"SELECT {SelectColumns} FROM cnc_machine WHERE is_enabled = 1 AND is_online = 1";
return conn.Query<Machine>(sql).ToList();
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE collect_address_id = @CollectAddressId AND is_enabled = 1";
return conn.Query<Machine>(sql, new { CollectAddressId = collectAddressId, OnlineTimeout = onlineTimeout }).ToList();
}
}
public void UpdateOnlineStatus(int id, bool isOnline)
public List<Machine> GetEnabledOnline(int onlineTimeout = 300)
{
using (var conn = CreateConnection())
{
var sql = @"UPDATE cnc_machine SET is_online = @IsOnline, updated_at = NOW() WHERE id = @Id";
conn.Execute(sql, new { Id = id, IsOnline = isOnline ? 1 : 0 });
var cols = string.Format(SelectColumns, OnlineExpr);
var sql = $"SELECT {cols} FROM cnc_machine WHERE is_enabled = 1 AND last_ping_time IS NOT NULL AND last_ping_time >= NOW() - INTERVAL @OnlineTimeout SECOND";
return conn.Query<Machine>(sql, new { OnlineTimeout = onlineTimeout }).ToList();
}
}

@ -14,5 +14,6 @@ namespace CncRepository.Interface
bool Update(BrandFieldMapping entity);
bool DeleteByBrandId(int brandId);
int BatchCreate(int brandId, List<BrandFieldMapping> mappings);
List<BrandFieldMapping> GetEnabledByBrandId(int brandId);
}
}

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
namespace CncRepository.Interface
{
/// <summary>
/// 采集分析仓储接口
/// </summary>
public interface ICollectAnalysisRepository
{
PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query);
CollectAnalysisDetail GetAnalysisDetail(long id);
List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId);
long Create(CollectAnalysis entity);
int DeleteBeforeDate(DateTime date);
}
}

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
namespace CncRepository.Interface
{
/// <summary>
/// 采集周期仓储接口
/// </summary>
public interface ICollectCycleRepository
{
PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query);
long Create(CollectCycle entity);
int DeleteBeforeDate(DateTime date);
}
}

@ -9,17 +9,17 @@ namespace CncRepository.Interface
/// </summary>
public interface IDashboardRepository
{
DashboardSummaryResponse GetSummary();
DashboardSummaryResponse GetSummary(int onlineTimeout = 300);
List<WorkshopProductionResponse> GetWorkshopProduction(DateTime startDate, DateTime endDate);
List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top);
List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int onlineTimeout = 300, string sortOrder = "desc");
List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top);
List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc");
List<dynamic> GetProductionTrend(int days);
object GetMachineStatusDistribution();
object GetMachineStatusDistribution(int onlineTimeout = 300);
List<AlertListItem> GetRecentAlerts(int count);
}

@ -10,18 +10,17 @@ namespace CncRepository.Interface
/// </summary>
public interface IMachineRepository
{
Machine GetById(int id);
MachineDetailResponse GetDetailById(int id);
PagedResult<MachineListItem> GetList(MachineQuery query);
Machine GetById(int id, int onlineTimeout = 300);
MachineDetailResponse GetDetailById(int id, int onlineTimeout = 300);
PagedResult<MachineListItem> GetList(MachineQuery query, int onlineTimeout = 300);
int Create(Machine entity);
bool Update(Machine entity);
bool Delete(int id);
int BatchDelete(List<int> ids);
bool ToggleEnabled(int id);
Machine GetByDeviceCode(string deviceCode);
List<Machine> GetEnabledByAddressId(int collectAddressId);
List<Machine> GetEnabledOnline();
void UpdateOnlineStatus(int id, bool isOnline);
Machine GetByDeviceCode(string deviceCode, int onlineTimeout = 300);
List<Machine> GetEnabledByAddressId(int collectAddressId, int onlineTimeout = 300);
List<Machine> GetEnabledOnline(int onlineTimeout = 300);
void UpdateLastCollect(int id, Machine entity);
/// <summary>设置机床所属的采集地址</summary>
void SetCollectAddress(int machineId, int? collectAddressId);

@ -23,4 +23,9 @@
<ProjectReference Include="..\CncRepository\CncRepository.csproj" />
</ItemGroup>
<!-- System.ServiceProcess for Windows Service interactions -->
<ItemGroup>
<Reference Include="System.ServiceProcess" />
</ItemGroup>
</Project>

@ -62,7 +62,8 @@ namespace CncService.Impl
FieldName = m.FieldName,
MatchBy = m.MatchBy,
DataType = m.DataType,
IsRequired = m.IsRequired
IsRequired = m.IsRequired,
IsEnabled = m.IsEnabled
}).ToList() ?? new List<BrandFieldMappingDto>()
};
return detail;
@ -147,6 +148,7 @@ namespace CncService.Impl
MatchBy = m.MatchBy,
DataType = m.DataType,
IsRequired = m.IsRequired,
IsEnabled = m.IsEnabled,
CreatedAt = DateTime.Now
}).ToList();
_mappingRepository.BatchCreate(newBrandId, newMappings);

@ -20,18 +20,29 @@ namespace CncService.Impl
private readonly IBrandRepository _brandRepository;
private readonly IWorkshopRepository _workshopRepository;
private readonly ICollectRawRepository _collectRawRepository;
private readonly ISysConfigRepository _sysConfigRepository;
public CollectAddressService(ICollectAddressRepository collectAddressRepository,
IMachineRepository machineRepository,
IBrandRepository brandRepository,
IWorkshopRepository workshopRepository,
ICollectRawRepository collectRawRepository)
ICollectRawRepository collectRawRepository,
ISysConfigRepository sysConfigRepository)
{
_collectAddressRepository = collectAddressRepository;
_machineRepository = machineRepository;
_brandRepository = brandRepository;
_workshopRepository = workshopRepository;
_collectRawRepository = collectRawRepository;
_sysConfigRepository = sysConfigRepository;
}
/// <summary>从sys_config读取在线超时阈值</summary>
private int GetOnlineTimeout()
{
var cfg = _sysConfigRepository.GetByKey("online_timeout");
if (cfg != null && int.TryParse(cfg.ConfigValue, out var val) && val > 0) return val;
return 300;
}
public PagedResult<CollectAddressListItem> GetList(CollectAddressQuery query)
@ -161,7 +172,7 @@ namespace CncService.Impl
MachineName = m.Name ?? m.DeviceCode,
DeviceCode = m.DeviceCode,
WorkshopName = workshopName,
IsOnline = m.IsOnline == 1,
IsOnline = m.IsEnabled == 1 && m.LastPingTime.HasValue && (DateTime.Now - m.LastPingTime.Value).TotalSeconds <= GetOnlineTimeout(),
ProgramName = m.LastProgramName
});
}

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Constants;
using CncService.Interface;
using CncRepository.Interface;
namespace CncService.Impl
{
/// <summary>
/// 采集日志相关的业务实现
/// </summary>
public class CollectLogService : ICollectLogService
{
private readonly ICollectAnalysisRepository _analysisRepository;
private readonly ICollectCycleRepository _cycleRepository;
public CollectLogService(ICollectAnalysisRepository analysisRepository, ICollectCycleRepository cycleRepository)
{
_analysisRepository = analysisRepository ?? throw new ArgumentNullException(nameof(analysisRepository));
_cycleRepository = cycleRepository ?? throw new ArgumentNullException(nameof(cycleRepository));
}
public PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query)
{
if (query == null) throw new BusinessException(ErrorCode.BadRequest, "查询参数不能为空");
return _analysisRepository.GetAnalysisList(query);
}
public CollectAnalysisDetail GetAnalysisDetail(long id)
{
var detail = _analysisRepository.GetAnalysisDetail(id);
if (detail == null) throw new BusinessException(ErrorCode.NotFound, "采集分析记录不存在");
return detail;
}
public List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId)
{
return _analysisRepository.GetAnalysisByRawLogId(rawLogId);
}
public PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query)
{
if (query == null) throw new BusinessException(ErrorCode.BadRequest, "查询参数不能为空");
return _cycleRepository.GetCycleList(query);
}
}
}

@ -13,18 +13,32 @@ namespace CncService.Impl
{
private readonly IDashboardRepository _dashboardRepository;
private readonly ICollectorHeartbeatRepository _collectorHeartbeatRepository;
private readonly IWindowsServiceChecker _serviceChecker;
private readonly ISysConfigRepository _sysConfigRepository;
public DashboardService(IDashboardRepository dashboardRepository,
ICollectorHeartbeatRepository collectorHeartbeatRepository)
ICollectorHeartbeatRepository collectorHeartbeatRepository,
ISysConfigRepository sysConfigRepository,
IWindowsServiceChecker serviceChecker = null)
{
_dashboardRepository = dashboardRepository ?? throw new ArgumentNullException(nameof(dashboardRepository));
_collectorHeartbeatRepository = collectorHeartbeatRepository ?? throw new ArgumentNullException(nameof(collectorHeartbeatRepository));
_sysConfigRepository = sysConfigRepository ?? throw new ArgumentNullException(nameof(sysConfigRepository));
_serviceChecker = serviceChecker;
}
/// <summary>从sys_config读取online_timeout默认300秒</summary>
private int GetOnlineTimeout()
{
var cfg = _sysConfigRepository.GetByKey("online_timeout");
if (cfg != null && int.TryParse(cfg.ConfigValue, out var val) && val > 0) return val;
return 300;
}
/// <inheritdoc/>
public DashboardSummaryResponse GetSummary()
{
return _dashboardRepository.GetSummary();
return _dashboardRepository.GetSummary(GetOnlineTimeout());
}
/// <inheritdoc/>
@ -36,19 +50,19 @@ namespace CncService.Impl
}
/// <inheritdoc/>
public List<MachineRankResponse> GetMachineRank(DateTime? startDate, DateTime? endDate, int top = 10)
public List<MachineRankResponse> GetMachineRank(DateTime? startDate, DateTime? endDate, int top = 10, string sortOrder = "desc")
{
var s = startDate ?? DateTime.Today;
var e = endDate ?? DateTime.Today;
return _dashboardRepository.GetMachineRank(s, e, top);
return _dashboardRepository.GetMachineRank(s, e, top, GetOnlineTimeout(), sortOrder);
}
/// <inheritdoc/>
public List<WorkerRankResponse> GetWorkerRank(DateTime? startDate, DateTime? endDate, int top = 10)
public List<WorkerRankResponse> GetWorkerRank(DateTime? startDate, DateTime? endDate, int top = 10, string sortOrder = "desc")
{
var s = startDate ?? DateTime.Today;
var e = endDate ?? DateTime.Today;
return _dashboardRepository.GetWorkerRank(s, e, top);
return _dashboardRepository.GetWorkerRank(s, e, top, sortOrder);
}
/// <inheritdoc/>
@ -60,7 +74,7 @@ namespace CncService.Impl
/// <inheritdoc/>
public object GetMachineStatusDistribution()
{
return _dashboardRepository.GetMachineStatusDistribution();
return _dashboardRepository.GetMachineStatusDistribution(GetOnlineTimeout());
}
/// <inheritdoc/>
@ -73,27 +87,41 @@ namespace CncService.Impl
public object GetCollectorStatus()
{
var latest = _collectorHeartbeatRepository.GetLatest("collector-service");
// 心跳超时阈值90秒3个心跳间隔采集服务默认每30秒上报一次
const int heartbeatTimeoutSeconds = 90;
bool isRunning = false;
long uptimeSeconds = 0;
bool heartbeatRunning = false;
long heartbeatUptime = 0;
DateTime? lastCollectTime = latest?.LastCollectTime;
if (latest != null && latest.Status == "running")
{
// 检查最后心跳时间是否在阈值内,超时则判定为已停止
var lastHeartbeat = latest.CreatedAt;
var elapsed = (DateTime.Now - lastHeartbeat).TotalSeconds;
isRunning = elapsed <= heartbeatTimeoutSeconds;
heartbeatRunning = elapsed <= heartbeatTimeoutSeconds;
if (heartbeatRunning)
heartbeatUptime = latest.UptimeSeconds ?? 0;
}
if (isRunning)
// 额外的 Windows 服务状态
string serviceStatusText = "NotInstalled";
if (_serviceChecker != null)
{
uptimeSeconds = latest.UptimeSeconds ?? 0;
}
var svc = _serviceChecker.GetServiceStatus("CncCollector");
serviceStatusText = svc.ToString();
}
return new { status = isRunning ? "running" : "stopped", uptimeSeconds, lastCollectTime = latest?.LastCollectTime };
// 组合状态NotInstalled -> 停止,其他根据心跳决定
string status = (serviceStatusText == "NotInstalled") ? "stopped" : (heartbeatRunning ? "running" : "stopped");
return new {
status,
uptimeSeconds = heartbeatRunning ? heartbeatUptime : 0,
lastCollectTime,
serviceStatus = serviceStatusText,
serviceName = "CncCollector",
serviceMessage = (string)null
};
}
}
}

@ -65,7 +65,6 @@ namespace CncService.Impl
BrandId = request.BrandId,
IpAddress = request.IpAddress,
IsEnabled = 1,
IsOnline = 0,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};

@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Data;
using MySqlConnector;
using Dapper;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using CncModels.Entity;
using CncModels.Dto.CollectLog;
using CncService.Interface;
using CncRepository.Interface;
using CncRepository.Impl.Log;
using CncRepository.Impl;
using CncRepository.Base;
namespace CncService.Impl
{
/// <summary>
/// 回放服务实现
/// 通过日志库日活日志与业务库表实现回放能力
/// 注意:此实现尽量复用现有 SQL避免引入额外依赖
/// </summary>
public class ReplayService : IReplayService
{
private readonly string _businessConn;
private readonly string _logConn;
public ReplayService(string businessConn, string logConn)
{
_businessConn = businessConn ?? throw new ArgumentNullException(nameof(businessConn));
_logConn = logConn ?? throw new ArgumentNullException(nameof(logConn));
}
/// <summary>预览回放影响范围(不做写操作)</summary>
public ReplayPreview PreviewReplay(DateTime date)
{
using (var logConn = new MySqlConnection(_logConn))
{
logConn.Open();
// 原始日志数量
var rawCount = logConn.ExecuteScalar<int>(@"SELECT COUNT(1) FROM log_collect_raw WHERE DATE(request_time) = @Date AND is_success = 1", new { Date = date.Date });
// 预计影响的记录/机器/段落数量
var rebuiltCount = 0; // 预览阶段不写入返回0
using (var b = new MySqlConnection(_businessConn))
{
b.Open();
var machineCount = b.ExecuteScalar<int>(@"SELECT COUNT(DISTINCT machine_id) FROM cnc_collect_record WHERE DATE(collect_time) = @Date", new { Date = date.Date });
var segmentCount = b.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_production_segment WHERE production_date = @Date", new { Date = date.Date });
var recCount = b.ExecuteScalar<int>(@"SELECT COUNT(1) FROM cnc_collect_record WHERE DATE(collect_time) = @Date", new { Date = date.Date });
return new ReplayPreview
{
Date = date.Date,
RawLogCount = rawCount,
AffectedMachineCount = machineCount,
AffectedRecordCount = recCount,
AffectedSegmentCount = segmentCount,
};
}
}
}
/// <summary>执行回放:清空当天数据并重新写入(简化实现)</summary>
public ReplayResult ExecuteReplay(DateTime date)
{
int clearedRecordCount = 0;
int clearedSegmentCount = 0;
int rebuiltRecordCount = 0;
try
{
// 1) 读取当天成功的原始日志
List<CollectRaw> rawLogs;
using (var logConn = new MySqlConnection(_logConn))
{
logConn.Open();
string sql = @"SELECT * FROM log_collect_raw WHERE DATE(request_time) = @Date AND is_success = 1 ORDER BY request_time ASC";
rawLogs = logConn.Query<CollectRaw>(sql, new { Date = date.Date }).ToList();
}
// 2) 业务库清空(按外键依赖的顺序)
using (var conn = new MySqlConnection(_businessConn))
{
conn.Open();
using (var tran = conn.BeginTransaction())
{
// 2.1 清空依赖表
clearedRecordCount = conn.Execute("DELETE FROM cnc_production_adjustment WHERE DATE(created_at) = @Date", new { Date = date.Date }, tran);
// 逐表清空,确保单条语句执行兼容性
int c1 = conn.Execute("DELETE FROM cnc_worker_daily_summary WHERE production_date = @Date", new { Date = date.Date }, tran);
int c2 = conn.Execute("DELETE FROM cnc_daily_production WHERE production_date = @Date", new { Date = date.Date }, tran);
int c3 = conn.Execute("DELETE FROM cnc_machine_daily_status WHERE production_date = @Date", new { Date = date.Date }, tran);
int c4 = conn.Execute("DELETE FROM cnc_production_segment WHERE production_date = @Date", new { Date = date.Date }, tran);
int c5 = conn.Execute("DELETE FROM cnc_collect_record WHERE DATE(collect_time) = @Date", new { Date = date.Date }, tran);
clearedSegmentCount = c1;
tran.Commit();
}
}
// 3) 逐条 raw 日志解析并写入 cnc_collect_record简化实现
using (var conn = new MySqlConnection(_businessConn))
{
conn.Open();
foreach (var raw in rawLogs)
{
// 简单 JSON 解析,提取每个 device 的信息并写入 cnc_collect_record
try
{
var devices = JArray.Parse(raw.RawJson);
foreach (JObject d in devices)
{
string deviceCode = d?.Value<string>("device") ?? null;
if (string.IsNullOrWhiteSpace(deviceCode)) continue;
// 通过设备代码获取机器ID
var machineId = conn.QuerySingleOrDefault<int?>("SELECT id FROM cnc_machine WHERE device_code = @Code", new { Code = deviceCode });
if (machineId == null) continue;
// 收集 tag 值
var tags = d?.Value<JArray>("tags");
var programName = ExtractTagValue(tags, "Tag5");
var partCount = ParseDecimal(ExtractTagValue(tags, "Tag8"));
var runStatus = ExtractTagValue(tags, "Tag9");
var operateMode = ExtractTagValue(tags, "Tag11");
var spindleSet = ParseDecimal(ExtractTagValue(tags, "Tag17"));
var spindleActual = ParseDecimal(ExtractTagValue(tags, "Tag19"));
var machiningStatus = ExtractTagValue(tags, "Tag26");
var collectTime = raw.RequestTime;
var rec = new CollectRecord
{
MachineId = machineId.Value,
CollectTime = collectTime,
ProgramName = programName,
PartCount = partCount,
RunStatus = runStatus,
OperateMode = operateMode,
SpindleSpeedSet = spindleSet,
SpindleSpeedActual = spindleActual,
MachiningStatus = machiningStatus,
CreatedAt = DateTime.Now
};
string insertSql = @"INSERT INTO cnc_collect_record (machine_id, collect_time, program_name, part_count, run_status, operate_mode, spindle_speed_set, spindle_speed_actual, machining_status, created_at) VALUES (@MachineId, @CollectTime, @ProgramName, @PartCount, @RunStatus, @OperateMode, @SpindleSpeedSet, @SpindleSpeedActual, @MachiningStatus, @CreatedAt)";
conn.Execute(insertSql, new
{
MachineId = rec.MachineId,
CollectTime = rec.CollectTime,
ProgramName = rec.ProgramName,
PartCount = rec.PartCount,
RunStatus = rec.RunStatus,
OperateMode = rec.OperateMode,
SpindleSpeedSet = rec.SpindleSpeedSet,
SpindleSpeedActual = rec.SpindleSpeedActual,
MachiningStatus = rec.MachiningStatus,
CreatedAt = rec.CreatedAt
});
rebuiltRecordCount++;
}
}
catch
{
// 忽略单条日志的解析错误,继续处理下一条
}
}
}
// 4) 重新执行日终汇总(调用同样的 SQL 做聚合)
using (var conn = new MySqlConnection(_businessConn))
{
conn.Open();
// 以昨天日期执行日终汇总,使用 DailySummaryJob 风格的实现
// 结账活跃段
conn.Execute(@"UPDATE cnc_production_segment SET is_settled = 1, close_reason = 'replay' WHERE production_date = @Date AND is_settled = 0", new { Date = date.Date });
// 产量汇总(简化:重新计算所有段的 quantity
conn.Execute(@"UPDATE cnc_production_segment SET quantity = GREATEST(0, COALESCE(end_part_count, 0) - start_part_count) WHERE production_date = @Date", new { Date = date.Date });
// 汇总日产量(简化版本)
conn.Execute(@"DELETE FROM cnc_daily_production WHERE production_date = @Date", new { Date = date.Date });
conn.Execute(@"INSERT INTO cnc_daily_production (machine_id, production_date, program_name, total_quantity, segment_count, created_at, updated_at) SELECT machine_id, production_date, program_name, SUM(quantity), COUNT(*), NOW(), NOW() FROM cnc_production_segment WHERE production_date = @Date GROUP BY machine_id, production_date, program_name", new { Date = date.Date });
// 更新机床日状态
conn.Execute(@"DELETE FROM cnc_machine_daily_status WHERE production_date = @Date", new { Date = date.Date });
conn.Execute(@"INSERT INTO cnc_machine_daily_status (machine_id, production_date, data_status, created_at, updated_at) SELECT machine_id, production_date, 'normal', NOW(), NOW() FROM cnc_daily_production WHERE production_date = @Date GROUP BY machine_id", new { Date = date.Date });
// 汇总员工日产量(简化)
conn.Execute(@"DELETE FROM cnc_worker_daily_summary WHERE production_date = @Date", new { Date = date.Date });
conn.Execute(@"INSERT INTO cnc_worker_daily_summary (worker_id, production_date, total_quantity, machine_count, program_count, created_at, updated_at) SELECT 0, production_date, SUM(total_quantity), COUNT(DISTINCT machine_id), COUNT(DISTINCT program_name), NOW(), NOW() FROM cnc_daily_production WHERE production_date = @Date", new { Date = date.Date });
}
return new ReplayResult
{
Date = date.Date,
ClearedRecordCount = clearedRecordCount,
ClearedSegmentCount = clearedSegmentCount,
RebuiltRecordCount = rebuiltRecordCount,
Success = true
};
}
catch (Exception ex)
{
return new ReplayResult
{
Date = date.Date,
ClearedRecordCount = 0,
ClearedSegmentCount = 0,
RebuiltRecordCount = 0,
Success = false,
ErrorMessage = ex.Message
};
}
}
// helpers
private static string ExtractTagValue(JArray tags, string id)
{
if (tags == null) return null;
foreach (JObject t in tags)
{
if (t?.Value<string>("id") == id) return t?.Value<string>("value");
}
return null;
}
private static decimal? ParseDecimal(string s)
{
if (decimal.TryParse(s, out var d)) return d;
return null;
}
}
}

@ -0,0 +1,115 @@
using System;
using System.ServiceProcess;
using System.Threading;
using CncService.Interface;
namespace CncService.Impl
{
/// <summary>
/// Windows 服务检测实现(基于 ServiceController
/// </summary>
public class WindowsServiceChecker : IWindowsServiceChecker
{
public ServiceStatusEnum GetServiceStatus(string serviceName)
{
try
{
using (var sc = new ServiceController(serviceName))
{
sc.Refresh();
switch (sc.Status)
{
case ServiceControllerStatus.Running:
return ServiceStatusEnum.Running;
case ServiceControllerStatus.StartPending:
case ServiceControllerStatus.ContinuePending:
return ServiceStatusEnum.Starting;
case ServiceControllerStatus.Stopped:
case ServiceControllerStatus.StopPending:
case ServiceControllerStatus.PausePending:
case ServiceControllerStatus.Paused:
return ServiceStatusEnum.Stopped;
default:
return ServiceStatusEnum.Starting;
}
}
}
catch (InvalidOperationException)
{
// 服务未安装
return ServiceStatusEnum.NotInstalled;
}
catch
{
// 其他异常视为不可用,保守处理
return ServiceStatusEnum.NotInstalled;
}
}
public (bool Success, string Message) TryStartService(string serviceName, int timeoutSeconds)
{
try
{
using (var sc = new ServiceController(serviceName))
{
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Running)
return (true, "已运行");
sc.Start();
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < timeout)
{
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Running)
return (true, "启动成功");
Thread.Sleep(500);
}
return (false, $"启动超时,当前状态={sc.Status}");
}
}
catch (InvalidOperationException)
{
return (false, "NotInstalled");
}
catch (Exception ex)
{
return (false, $"启动失败: {ex.Message}");
}
}
public (bool Success, string Message) TryStopService(string serviceName, int timeoutSeconds)
{
try
{
using (var sc = new ServiceController(serviceName))
{
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Stopped)
return (true, "已停止");
sc.Stop();
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < timeout)
{
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Stopped)
return (true, "停止成功");
Thread.Sleep(500);
}
return (false, $"停止超时,当前状态={sc.Status}");
}
}
catch (InvalidOperationException)
{
return (false, "NotInstalled");
}
catch (Exception ex)
{
return (false, $"停止失败: {ex.Message}");
}
}
}
}

@ -19,15 +19,26 @@ namespace CncService.Impl
private readonly IWorkerRepository _workerRepository;
private readonly IWorkerMachineRepository _workerMachineRepository;
private readonly IMachineRepository _machineRepository;
private readonly ISysConfigRepository _sysConfigRepository;
public WorkerService(
IWorkerRepository workerRepository,
IWorkerMachineRepository workerMachineRepository,
IMachineRepository machineRepository)
IMachineRepository machineRepository,
ISysConfigRepository sysConfigRepository)
{
_workerRepository = workerRepository ?? throw new ArgumentNullException(nameof(workerRepository));
_workerMachineRepository = workerMachineRepository ?? throw new ArgumentNullException(nameof(workerMachineRepository));
_workerMachineRepository = workerMachineRepository ?? throw new ArgumentNullException(nameof(_workerMachineRepository));
_machineRepository = machineRepository ?? throw new ArgumentNullException(nameof(machineRepository));
_sysConfigRepository = sysConfigRepository ?? throw new ArgumentNullException(nameof(sysConfigRepository));
}
/// <summary>从sys_config读取在线超时阈值</summary>
private int GetOnlineTimeout()
{
var cfg = _sysConfigRepository.GetByKey("online_timeout");
if (cfg != null && int.TryParse(cfg.ConfigValue, out var val) && val > 0) return val;
return 300;
}
/// <inheritdoc/>
@ -193,7 +204,7 @@ namespace CncService.Impl
DeviceCode = m.DeviceCode,
WorkshopName = workshopName,
BrandName = brandName,
IsOnline = m.IsOnline == 1,
IsOnline = m.IsEnabled == 1 && m.LastPingTime.HasValue && (DateTime.Now - m.LastPingTime.Value).TotalSeconds <= GetOnlineTimeout(),
ProgramName = m.LastProgramName
});
}

@ -0,0 +1,21 @@
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
namespace CncService.Interface
{
public interface ICollectLogService
{
/// <summary>分页查询采集分析日志</summary>
PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query);
/// <summary>获取单条采集分析日志的详情</summary>
CollectAnalysisDetail GetAnalysisDetail(long id);
/// <summary>根据原始日志ID查找相关联的分析记录</summary>
List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId);
/// <summary>分页查询采集周期信息</summary>
PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query);
}
}

@ -13,9 +13,9 @@ namespace CncService.Interface
List<WorkshopProductionResponse> GetWorkshopProduction(DateTime? startDate, DateTime? endDate);
List<MachineRankResponse> GetMachineRank(DateTime? startDate, DateTime? endDate, int top = 10);
List<MachineRankResponse> GetMachineRank(DateTime? startDate, DateTime? endDate, int top = 10, string sortOrder = "desc");
List<WorkerRankResponse> GetWorkerRank(DateTime? startDate, DateTime? endDate, int top = 10);
List<WorkerRankResponse> GetWorkerRank(DateTime? startDate, DateTime? endDate, int top = 10, string sortOrder = "desc");
object GetProductionTrend(int days = 7);

@ -0,0 +1,17 @@
using System;
using CncModels.Dto.CollectLog;
namespace CncService.Interface
{
/// <summary>
/// 回放服务接口D1-D2 数据回放)
/// </summary>
public interface IReplayService
{
/// <summary>预览回放影响范围</summary>
ReplayPreview PreviewReplay(DateTime date);
/// <summary>执行回放,含清空与重建并重新汇总</summary>
ReplayResult ExecuteReplay(DateTime date);
}
}

@ -0,0 +1,43 @@
using System;
namespace CncService.Interface
{
// Windows 服务状态枚举,用于和心跳状态区分不同场景
public enum ServiceStatusEnum
{
NotInstalled,
Stopped,
Running,
Starting,
StartFailed
}
/// <summary>
/// Windows 服务检测接口(用于管理后台对采集服务的状态检测与控制)
/// </summary>
public interface IWindowsServiceChecker
{
/// <summary>
/// 获取指定服务的当前状态
/// </summary>
/// <param name="serviceName">服务名</param>
/// <returns>服务状态枚举</returns>
ServiceStatusEnum GetServiceStatus(string serviceName);
/// <summary>
/// 尝试启动指定服务,并在给定超时内等待就绪
/// </summary>
/// <param name="serviceName">服务名</param>
/// <param name="timeoutSeconds">超时(秒)</param>
/// <returns>(是否成功, 详细信息)</returns>
(bool Success, string Message) TryStartService(string serviceName, int timeoutSeconds);
/// <summary>
/// 尝试停止指定服务,并在给定超时内等待停止
/// </summary>
/// <param name="serviceName">服务名</param>
/// <param name="timeoutSeconds">超时(秒)</param>
/// <returns>(是否成功, 详细信息)</returns>
(bool Success, string Message) TryStopService(string serviceName, int timeoutSeconds);
}
}

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Web.Http;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncService.Interface;
using CncRepository.Interface;
using CncWebApi.Infrastructure;
using System.Web.Http.Description;
namespace CncWebApi.Controllers
{
/// <summary>
/// 采集日志管理控制器
/// </summary>
[RoutePrefix("api/admin/collect-log")]
[JwtAuthFilter]
public class CollectLogController : ApiController
{
private readonly ICollectLogService _collectLogService;
private readonly ICollectRawRepository _rawRepository;
public CollectLogController(ICollectLogService collectLogService, ICollectRawRepository rawRepository)
{
_collectLogService = collectLogService ?? throw new ArgumentNullException(nameof(collectLogService));
_rawRepository = rawRepository ?? throw new ArgumentNullException(nameof(rawRepository));
}
/// <summary>分页查询采集分析记录</summary>
[HttpGet]
[Route("analysis")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectAnalysisListItem>>))]
public IHttpActionResult GetAnalysisList([FromUri] CollectAnalysisQuery query)
{
if (query == null) query = new CollectAnalysisQuery();
var result = _collectLogService.GetAnalysisList(query);
return Ok(ApiResponse<PagedResult<CollectAnalysisListItem>>.Success(result));
}
/// <summary>获取采集分析详情</summary>
[HttpGet]
[Route("analysis/{id:long}")]
[ResponseType(typeof(ApiResponse<CollectAnalysisDetail>))]
public IHttpActionResult GetAnalysisDetail(long id)
{
var detail = _collectLogService.GetAnalysisDetail(id);
return Ok(ApiResponse<CollectAnalysisDetail>.Success(detail));
}
/// <summary>根据原始日志ID查询关联的分析记录</summary>
[HttpGet]
[Route("analysis/by-raw/{rawLogId:long}")]
[ResponseType(typeof(ApiResponse<List<CollectAnalysisListItem>>))]
public IHttpActionResult GetAnalysisByRawLogId(long rawLogId)
{
var list = _collectLogService.GetAnalysisByRawLogId(rawLogId);
return Ok(ApiResponse<List<CollectAnalysisListItem>>.Success(list));
}
/// <summary>分页查询采集周期</summary>
[HttpGet]
[Route("cycle")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectCycleListItem>>))]
public IHttpActionResult GetCycleList([FromUri] CollectCycleQuery query)
{
if (query == null) query = new CollectCycleQuery();
var result = _collectLogService.GetCycleList(query);
return Ok(ApiResponse<PagedResult<CollectCycleListItem>>.Success(result));
}
/// <summary>查询原始采集日志</summary>
[HttpGet]
[Route("raw")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectRaw>>))]
public IHttpActionResult GetRawList([FromUri] int? collectAddressId = null, [FromUri] int page = 1, [FromUri] int pageSize = 20)
{
var result = _rawRepository.GetByAddressId(collectAddressId ?? 0, page, pageSize);
return Ok(ApiResponse<PagedResult<CollectRaw>>.Success(result));
}
}
}

@ -54,26 +54,26 @@ namespace CncWebApi.Controllers
}
/// <summary>
/// 机床产量排行TOP10
/// 机床产量排行
/// GET /api/admin/dashboard/machine-rank
/// </summary>
[HttpGet]
[Route("machine-rank")]
public IHttpActionResult GetMachineRank(DateTime? startDate = null, DateTime? endDate = null, int top = 10)
public IHttpActionResult GetMachineRank(DateTime? startDate = null, DateTime? endDate = null, int top = 10, string sortOrder = "desc")
{
var result = _dashboardService.GetMachineRank(startDate, endDate, top);
var result = _dashboardService.GetMachineRank(startDate, endDate, top, sortOrder);
return Ok(ApiResponse<object>.Success(new { items = result }));
}
/// <summary>
/// 工人产量排行TOP10
/// 工人产量排行
/// GET /api/admin/dashboard/worker-rank
/// </summary>
[HttpGet]
[Route("worker-rank")]
public IHttpActionResult GetWorkerRank(DateTime? startDate = null, DateTime? endDate = null, int top = 10)
public IHttpActionResult GetWorkerRank(DateTime? startDate = null, DateTime? endDate = null, int top = 10, string sortOrder = "desc")
{
var result = _dashboardService.GetWorkerRank(startDate, endDate, top);
var result = _dashboardService.GetWorkerRank(startDate, endDate, top, sortOrder);
return Ok(ApiResponse<object>.Success(new { items = result }));
}
@ -133,6 +133,22 @@ namespace CncWebApi.Controllers
[Route("~/api/admin/collector/start")]
public IHttpActionResult StartCollector()
{
// 先查询服务状态,决定下一步动作
try
{
dynamic statusObj = _dashboardService.GetCollectorStatus();
string serviceStatus = statusObj?.serviceStatus as string;
if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "NotInstalled", StringComparison.OrdinalIgnoreCase))
{
return Ok(ApiResponse<object>.Fail(40001, "采集服务未安装,请先在服务器上运行 install.ps1 安装服务"));
}
if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "Running", StringComparison.OrdinalIgnoreCase))
{
return Ok(ApiResponse<object>.Fail(40002, "采集服务已在运行中,无需再次启动"));
}
}
catch { /* ignore status fetch errors and fallback to forwarding */ }
return ForwardToCollector("/api/collector/start");
}

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Web.Http;
using System.Web.Http.Description;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncService.Interface;
using CncWebApi.Infrastructure;
using Newtonsoft.Json;
namespace CncWebApi.Controllers
{
/// <summary>
/// 数据回放控制器
/// </summary>
[RoutePrefix("api/admin/replay")]
[JwtAuthFilter]
public class ReplayController : ApiController
{
private readonly IReplayService _replayService;
public ReplayController(IReplayService replayService)
{
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
}
/// <summary>预览回放影响范围</summary>
[HttpPost]
[Route("preview")]
[ResponseType(typeof(ApiResponse<ReplayPreview>))]
public IHttpActionResult Preview([FromBody] ReplayRequest request)
{
if (request == null) return BadRequest("请求参数错误");
var result = _replayService.PreviewReplay(request.Date);
return Ok(ApiResponse<ReplayPreview>.Success(result));
}
/// <summary>执行回放</summary>
[HttpPost]
[Route("execute")]
[ResponseType(typeof(ApiResponse<ReplayResult>))]
public IHttpActionResult Execute([FromBody] ReplayRequest request)
{
if (request == null) return BadRequest("请求参数错误");
var result = _replayService.ExecuteReplay(request.Date);
return Ok(ApiResponse<ReplayResult>.Success(result));
}
}
}

@ -0,0 +1,367 @@
using System;
using System.Configuration;
using System.Net.Http;
using System.Text;
using System.Web.Http;
using CncModels.Dto;
using CncWebApi.Infrastructure;
using Newtonsoft.Json;
namespace CncWebApi.Controllers
{
/// <summary>
/// 模拟采集服务控制器。
/// 将所有请求转发到 CncSimulatorlocalhost:9000网关 + 动态端口单地址)。
/// </summary>
[RoutePrefix("api/admin/simulator")]
[JwtAuthFilter]
public class SimulatorController : ApiController
{
private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
private static readonly string _gatewayUrl = ConfigurationManager.AppSettings["SimulatorGatewayUrl"] ?? "http://localhost:9000";
#region 网关API→ localhost:9000
/// <summary>
/// 探测模拟器是否运行
/// GET /api/admin/simulator/ping
/// </summary>
[HttpGet]
[Route("ping")]
public IHttpActionResult Ping()
{
try
{
var response = _httpClient.GetAsync($"{_gatewayUrl}/admin/api/status").Result;
return Ok(ApiResponse<object>.Success(new { running = response.IsSuccessStatusCode }));
}
catch
{
return Ok(ApiResponse<object>.Success(new { running = false }));
}
}
/// <summary>
/// 获取数据库采集地址列表
/// GET /api/admin/simulator/addresses
/// </summary>
[HttpGet]
[Route("addresses")]
public IHttpActionResult GetAddresses()
{
return ForwardToGateway("/admin/api/db-addresses");
}
/// <summary>
/// 获取所有模拟状态汇总
/// GET /api/admin/simulator/status
/// </summary>
[HttpGet]
[Route("status")]
public IHttpActionResult GetStatus()
{
return ForwardToGateway("/admin/api/status");
}
/// <summary>
/// 启动指定地址的模拟
/// POST /api/admin/simulator/start
/// </summary>
[HttpPost]
[Route("start")]
public IHttpActionResult Start()
{
return ForwardToGatewayPost("/admin/api/start-address");
}
/// <summary>
/// 停止指定地址的模拟
/// POST /api/admin/simulator/stop
/// </summary>
[HttpPost]
[Route("stop")]
public IHttpActionResult Stop()
{
return ForwardToGatewayPost("/admin/api/stop-address");
}
/// <summary>
/// 启动所有地址的模拟
/// POST /api/admin/simulator/start-all
/// </summary>
[HttpPost]
[Route("start-all")]
public IHttpActionResult StartAll()
{
return ForwardToGatewayPost("/admin/api/start-all");
}
/// <summary>
/// 停止所有地址的模拟
/// POST /api/admin/simulator/stop-all
/// </summary>
[HttpPost]
[Route("stop-all")]
public IHttpActionResult StopAll()
{
return ForwardToGatewayPost("/admin/api/stop-all");
}
/// <summary>
/// 重新加载数据库配置
/// POST /api/admin/simulator/reload
/// </summary>
[HttpPost]
[Route("reload")]
public IHttpActionResult Reload()
{
return ForwardToGatewayPost("/admin/api/reload-db");
}
#endregion
#region 单地址API→ localhost:{port}
/// <summary>
/// 获取单地址状态
/// GET /api/admin/simulator/address/{port}/status
/// </summary>
[HttpGet]
[Route("address/{port}/status")]
public IHttpActionResult GetAddressStatus(int port)
{
return ForwardToAddress(port, "/admin/api/status");
}
/// <summary>
/// 启动单地址数据模拟
/// POST /api/admin/simulator/address/{port}/start
/// </summary>
[HttpPost]
[Route("address/{port}/start")]
public IHttpActionResult StartAddress(int port)
{
return ForwardToAddressPost(port, "/admin/api/start");
}
/// <summary>
/// 停止单地址数据模拟
/// POST /api/admin/simulator/address/{port}/stop
/// </summary>
[HttpPost]
[Route("address/{port}/stop")]
public IHttpActionResult StopAddress(int port)
{
return ForwardToAddressPost(port, "/admin/api/stop");
}
/// <summary>
/// 触发设备事件
/// POST /api/admin/simulator/address/{port}/event
/// </summary>
[HttpPost]
[Route("address/{port}/event")]
public IHttpActionResult TriggerEvent(int port)
{
return ForwardToAddressPost(port, "/admin/api/event");
}
/// <summary>
/// 修改数据变化频率
/// POST /api/admin/simulator/address/{port}/interval
/// </summary>
[HttpPost]
[Route("address/{port}/interval")]
public IHttpActionResult SetInterval(int port)
{
return ForwardToAddressPost(port, "/admin/api/interval");
}
/// <summary>
/// 设置网络异常类型
/// POST /api/admin/simulator/address/{port}/network
/// </summary>
[HttpPost]
[Route("address/{port}/network")]
public IHttpActionResult SetNetwork(int port)
{
return ForwardToAddressPost(port, "/admin/api/network");
}
/// <summary>
/// 切换剧本模式
/// POST /api/admin/simulator/address/{port}/mode
/// </summary>
[HttpPost]
[Route("address/{port}/mode")]
public IHttpActionResult SetMode(int port)
{
return ForwardToAddressPost(port, "/admin/api/mode");
}
/// <summary>
/// 获取请求日志
/// GET /api/admin/simulator/address/{port}/logs
/// </summary>
[HttpGet]
[Route("address/{port}/logs")]
public IHttpActionResult GetLogs(int port)
{
return ForwardToAddress(port, "/admin/api/logs");
}
/// <summary>
/// 获取零件统计
/// GET /api/admin/simulator/address/{port}/stats
/// </summary>
[HttpGet]
[Route("address/{port}/stats")]
public IHttpActionResult GetStats(int port)
{
return ForwardToAddress(port, "/admin/api/stats");
}
/// <summary>
/// 添加设备
/// POST /api/admin/simulator/address/{port}/add-device
/// </summary>
[HttpPost]
[Route("address/{port}/add-device")]
public IHttpActionResult AddDevice(int port)
{
return ForwardToAddressPost(port, "/admin/api/add-device");
}
/// <summary>
/// 移除设备
/// POST /api/admin/simulator/address/{port}/remove-device
/// </summary>
[HttpPost]
[Route("address/{port}/remove-device")]
public IHttpActionResult RemoveDevice(int port)
{
return ForwardToAddressPost(port, "/admin/api/remove-device");
}
/// <summary>
/// 获取事件历史
/// GET /api/admin/simulator/address/{port}/event-history
/// </summary>
[HttpGet]
[Route("address/{port}/event-history")]
public IHttpActionResult GetEventHistory(int port)
{
return ForwardToAddress(port, "/admin/api/event-history");
}
/// <summary>
/// 获取完整汇总
/// GET /api/admin/simulator/address/{port}/full-summary
/// </summary>
[HttpGet]
[Route("address/{port}/full-summary")]
public IHttpActionResult GetFullSummary(int port)
{
return ForwardToAddress(port, "/admin/api/full-summary");
}
/// <summary>
/// 获取异常日志
/// GET /api/admin/simulator/address/{port}/error-log
/// </summary>
[HttpGet]
[Route("address/{port}/error-log")]
public IHttpActionResult GetErrorLog(int port)
{
return ForwardToAddress(port, "/admin/api/error-log");
}
#endregion
#region 转发辅助方法
/// <summary>
/// GET转发到网关9000端口
/// </summary>
private IHttpActionResult ForwardToGateway(string path)
{
try
{
var response = _httpClient.GetAsync($"{_gatewayUrl}{path}").Result;
var body = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(body);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟器连接失败: {ex.Message}"));
}
}
/// <summary>
/// POST转发到网关9000端口透传请求体
/// </summary>
private IHttpActionResult ForwardToGatewayPost(string path)
{
try
{
var body = Request.Content.ReadAsStringAsync().Result;
var request = new HttpRequestMessage(HttpMethod.Post, $"{_gatewayUrl}{path}")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
var response = _httpClient.SendAsync(request).Result;
var responseBody = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(responseBody);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟器连接失败: {ex.Message}"));
}
}
/// <summary>
/// GET转发到单地址动态端口
/// </summary>
private IHttpActionResult ForwardToAddress(int port, string path)
{
try
{
var response = _httpClient.GetAsync($"http://localhost:{port}{path}").Result;
var body = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(body);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟地址(端口{port})连接失败: {ex.Message}"));
}
}
/// <summary>
/// POST转发到单地址动态端口透传请求体
/// </summary>
private IHttpActionResult ForwardToAddressPost(int port, string path)
{
try
{
var body = Request.Content.ReadAsStringAsync().Result;
var request = new HttpRequestMessage(HttpMethod.Post, $"http://localhost:{port}{path}")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
var response = _httpClient.SendAsync(request).Result;
var responseBody = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(responseBody);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟地址(端口{port})连接失败: {ex.Message}"));
}
}
#endregion
}
}

@ -6,6 +6,7 @@ using System.Web.Http.Dependencies;
using CncRepository.Base;
using CncRepository.Interface;
using CncService.Interface;
using CncWebApi.Controllers;
namespace CncWebApi.Infrastructure
{
@ -61,6 +62,8 @@ namespace CncWebApi.Infrastructure
return new Controllers.LogController(
ResolveSystemLogService(),
ResolveProductionAdjustmentRepository());
if (serviceType == typeof(Controllers.ReplayController))
return new Controllers.ReplayController(ResolveReplayService());
if (serviceType == typeof(Controllers.ScreenConfigController))
return new Controllers.ScreenConfigController(
ResolveScreenService());
@ -78,6 +81,12 @@ namespace CncWebApi.Infrastructure
ResolveCollectAddressService());
if (serviceType == typeof(Controllers.HealthController))
return new Controllers.HealthController();
if (serviceType == typeof(Controllers.CollectLogController))
return new Controllers.CollectLogController(
ResolveCollectLogService(),
new CncRepository.Impl.Log.CollectRawRepository(_logConn));
if (serviceType == typeof(Controllers.SimulatorController))
return new Controllers.SimulatorController();
return null;
}
@ -102,9 +111,13 @@ namespace CncWebApi.Infrastructure
private IDashboardService ResolveDashboardService()
{
// 注入 WindowsServiceChecker 以便在后端查询 Windows 服务状态
var serviceChecker = new CncService.Impl.WindowsServiceChecker();
return new CncService.Impl.DashboardService(
new CncRepository.Impl.Dashboard.DashboardRepository(_businessConn),
new CncRepository.Impl.Log.CollectorHeartbeatRepository(_logConn));
new CncRepository.Impl.Log.CollectorHeartbeatRepository(_logConn),
new CncRepository.Impl.SysConfigRepository(_businessConn),
serviceChecker);
}
private IMachineService ResolveMachineService()
@ -131,7 +144,8 @@ namespace CncWebApi.Infrastructure
new CncRepository.Impl.MachineRepository(_businessConn),
new CncRepository.Impl.BrandRepository(_businessConn),
new CncRepository.Impl.WorkshopRepository(_businessConn),
new CncRepository.Impl.Log.CollectRawRepository(_logConn));
new CncRepository.Impl.Log.CollectRawRepository(_logConn),
new CncRepository.Impl.SysConfigRepository(_businessConn));
}
private IWorkerService ResolveWorkerService()
@ -139,7 +153,8 @@ namespace CncWebApi.Infrastructure
return new CncService.Impl.WorkerService(
new CncRepository.Impl.WorkerRepository(_businessConn),
new CncRepository.Impl.WorkerMachineRepository(_businessConn),
new CncRepository.Impl.MachineRepository(_businessConn));
new CncRepository.Impl.MachineRepository(_businessConn),
new CncRepository.Impl.SysConfigRepository(_businessConn));
}
private IProductionService ResolveProductionService()
@ -181,6 +196,18 @@ namespace CncWebApi.Infrastructure
return new CncRepository.Impl.ProductionAdjustmentRepository(_businessConn);
}
private ICollectLogService ResolveCollectLogService()
{
return new CncService.Impl.CollectLogService(
new CncRepository.Impl.Log.CollectAnalysisRepository(_logConn),
new CncRepository.Impl.Log.CollectCycleRepository(_logConn));
}
private IReplayService ResolveReplayService()
{
return new CncService.Impl.ReplayService(_businessConn, _logConn);
}
#endregion
}
}

@ -20,6 +20,8 @@
<add key="JwtSecret" value="CncDataSystem_2026_SecretKey_For_Jwt_Token_Generation_Min32Chars" />
<!-- Token过期时间小时 -->
<add key="TokenExpirationHours" value="24" />
<!-- 模拟器网关地址(默认 http://localhost:9000 -->
<add key="SimulatorGatewayUrl" value="http://localhost:9000" />
</appSettings>
<system.web>

@ -39,8 +39,8 @@ namespace CncCollector.Tests
string businessConn = "Server=.;Database=NonExistingDb;User Id=invalid;Password=invalid;";
string logConn = "Server=.;Database=NonExistingLogDb;User Id=invalid;Password=invalid;";
// 允许 tracker 为 null因为在无效 URL 的测试路径下通常不会进入需要 Tracker 的分支
_worker = new CollectWorker(_address, _config, null, businessConn, logConn);
// 允许 tracker 和 analysisEngine 为 null因为在无效 URL 的测试路径下通常不会进入需要它们的分支
_worker = new CollectWorker(_address, _config, null, null, businessConn, logConn);
}
public void Dispose()

@ -665,7 +665,6 @@ namespace CncModels.Tests
Assert.Null(m0.IpAddress);
Assert.Equal(0, m0.BrandId);
Assert.Equal(0, m0.IsEnabled);
Assert.Equal(0, m0.IsOnline);
Assert.Null(m0.LastPingTime);
Assert.Null(m0.LastCollectTime);
Assert.Null(m0.LastDeviceStatus);
@ -687,7 +686,6 @@ namespace CncModels.Tests
IpAddress = "192.168.0.10",
BrandId = 3,
IsEnabled = 1,
IsOnline = 1,
LastPingTime = new DateTime(2026, 4, 28, 12, 0, 0),
LastCollectTime = new DateTime(2026, 4, 28, 12, 5, 0),
LastDeviceStatus = "OK",
@ -707,7 +705,6 @@ namespace CncModels.Tests
Assert.Equal("192.168.0.10", m.IpAddress);
Assert.Equal(3, m.BrandId);
Assert.Equal(1, m.IsEnabled);
Assert.Equal(1, m.IsOnline);
Assert.Equal(new DateTime(2026, 4, 28, 12, 0, 0), m.LastPingTime);
Assert.Equal(new DateTime(2026, 4, 28, 12, 5, 0), m.LastCollectTime);
Assert.Equal("OK", m.LastDeviceStatus);

@ -0,0 +1,125 @@
using System;
using System.Linq;
using CncModels.Entity;
using CncRepository.Impl;
using Xunit;
namespace CncRepository.Tests
{
/// <summary>
/// 品牌字段映射仓储测试
/// </summary>
[Collection("Database")]
public class BrandFieldMappingRepositoryTests : IDisposable
{
private readonly BrandFieldMappingRepository _repo;
public BrandFieldMappingRepositoryTests()
{
_repo = new BrandFieldMappingRepository(TestDb.ConnectionString);
TestDb.TruncateAll();
}
public void Dispose()
{
TestDb.TruncateAll();
}
[Fact]
public void GetByBrandId_()
{
// 插入2条启用 + 1条禁用
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW()),
(1, 'part_count', 'Tag8', 'id', 'number', 1, 1, NOW()),
(1, 'spindle_load', 'Tag21', 'id', 'number', 0, 0, NOW())");
var result = _repo.GetByBrandId(1);
Assert.Equal(3, result.Count);
}
[Fact]
public void GetEnabledByBrandId_()
{
TestDb.Execute(@"INSERT IGNORE INTO cnc_brand (id, brand_name, device_field, tags_path, is_enabled, created_at, updated_at)
VALUES (1, 'FANUC', 'device', 'tags', 1, NOW(), NOW())");
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW()),
(1, 'part_count', 'Tag8', 'id', 'number', 1, 1, NOW()),
(1, 'spindle_load', 'Tag21', 'id', 'number', 0, 0, NOW())");
var result = _repo.GetEnabledByBrandId(1);
Assert.Equal(2, result.Count);
Assert.All(result, m => Assert.Equal(1, m.IsEnabled));
}
[Fact]
public void Create_()
{
// 确保 brand_id=1 存在
TestDb.Execute(@"INSERT IGNORE INTO cnc_brand (id, brand_name, device_field, tags_path, is_enabled, created_at, updated_at)
VALUES (1, 'FANUC', 'device', 'tags', 1, NOW(), NOW())");
var entity = new BrandFieldMapping
{
BrandId = 1,
StandardField = "program_name",
FieldName = "Tag5",
MatchBy = "id",
DataType = "string",
IsRequired = 1,
IsEnabled = 1,
CreatedAt = DateTime.Now
};
var id = _repo.Create(entity);
Assert.True(id > 0);
var loaded = _repo.GetById(id);
Assert.Equal(1, loaded.IsEnabled);
}
[Fact]
public void Update_()
{
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW())");
var id = TestDb.QuerySingle<int>("SELECT MAX(id) FROM cnc_brand_field_mapping");
var entity = _repo.GetById(id);
entity.IsEnabled = 0;
var result = _repo.Update(entity);
Assert.True(result);
Assert.Equal(0, _repo.GetById(id).IsEnabled);
}
[Fact]
public void BatchCreate_()
{
// 确保 brand_id=1 存在
TestDb.Execute(@"INSERT IGNORE INTO cnc_brand (id, brand_name, device_field, tags_path, is_enabled, created_at, updated_at)
VALUES (1, 'FANUC', 'device', 'tags', 1, NOW(), NOW())");
var mappings = new[]
{
new BrandFieldMapping { StandardField = "f1", FieldName = "Tag1", MatchBy = "id", DataType = "string", IsRequired = 0, IsEnabled = 1, CreatedAt = DateTime.Now },
new BrandFieldMapping { StandardField = "f2", FieldName = "Tag2", MatchBy = "id", DataType = "number", IsRequired = 0, IsEnabled = 1, CreatedAt = DateTime.Now },
new BrandFieldMapping { StandardField = "f3", FieldName = "Tag3", MatchBy = "id", DataType = "string", IsRequired = 0, IsEnabled = 0, CreatedAt = DateTime.Now },
}.ToList();
var count = _repo.BatchCreate(1, mappings);
Assert.Equal(3, count);
var all = _repo.GetByBrandId(1);
Assert.Equal(3, all.Count);
var enabled = _repo.GetEnabledByBrandId(1);
Assert.Equal(2, enabled.Count);
}
[Fact]
public void Update_()
{
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW())");
var id = TestDb.QuerySingle<int>("SELECT MAX(id) FROM cnc_brand_field_mapping");
var entity = _repo.GetById(id);
entity.FieldName = "Tag5_New";
entity.IsEnabled = 0;
_repo.Update(entity);
var loaded = _repo.GetById(id);
Assert.Equal("Tag5_New", loaded.FieldName);
Assert.Equal(0, loaded.IsEnabled);
}
}
}

@ -41,7 +41,6 @@ namespace CncRepository.Tests
BrandId = 1,
IpAddress = "10.1.1.8",
IsEnabled = 1,
IsOnline = 0,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};

@ -38,8 +38,8 @@ namespace CncService.Tests
{
TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at)
VALUES ('', 'http://test', 1, 30, 1, NOW(), NOW())");
TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, is_online, created_at, updated_at)
VALUES ('M001', '1', 1, 1, '0.0.0.0', 1, 1, 0, NOW(), NOW())");
TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at)
VALUES ('M001', '1', 1, 1, '0.0.0.0', 1, 1, NOW(), NOW())");
}
TestDb.Execute(@"INSERT INTO cnc_alert (alert_type, machine_id, title, is_resolved, created_at)
VALUES (@alertType, 1, '', @isResolved, NOW())",

@ -248,6 +248,51 @@ namespace CncService.Tests
Assert.Equal(ErrorCode.NotFound, ex.Code);
}
// ======== FieldMapping IsEnabled ========
[Fact]
public void GetById_IsEnabled()
{
// 插入字段映射(含 is_enabled=0 的)
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW()),
(1, 'spindle_load', 'Tag21', 'id', 'number', 0, 0, NOW())");
var detail = _service.GetById(1);
Assert.NotNull(detail.Mappings);
Assert.Equal(2, detail.Mappings.Count);
var disabled = detail.Mappings.FirstOrDefault(m => m.StandardField == "spindle_load");
Assert.NotNull(disabled);
Assert.Equal(0, disabled.IsEnabled);
}
[Fact]
public void Copy_()
{
// 插入字段映射1启用1禁用
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'program_name', 'Tag5', 'id', 'string', 1, 1, NOW()),
(1, 'spindle_load', 'Tag21', 'id', 'number', 0, 0, NOW())");
var newId = _service.Copy(1);
var copied = _service.GetById(newId);
Assert.Equal(2, copied.Mappings.Count);
var enabledMapping = copied.Mappings.First(m => m.StandardField == "program_name");
var disabledMapping = copied.Mappings.First(m => m.StandardField == "spindle_load");
Assert.Equal(1, enabledMapping.IsEnabled);
Assert.Equal(0, disabledMapping.IsEnabled);
}
[Fact]
public void GetById_()
{
TestDb.Execute(@"INSERT INTO cnc_brand_field_mapping (brand_id, standard_field, field_name, match_by, data_type, is_required, is_enabled, created_at)
VALUES (1, 'f1', 'Tag1', 'id', 'string', 0, 0, NOW()),
(1, 'f2', 'Tag2', 'id', 'string', 0, 1, NOW()),
(1, 'f3', 'Tag3', 'id', 'string', 0, 1, NOW())");
var detail = _service.GetById(1);
Assert.Equal(3, detail.Mappings.Count);
Assert.Equal(1, detail.Mappings.Count(m => m.IsEnabled == 0));
Assert.Equal(2, detail.Mappings.Count(m => m.IsEnabled == 1));
}
// ======== GetStandardFields ========
[Fact]

@ -1,26 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<RootNamespace>CncService.Tests</RootNamespace>
<AssemblyName>CncService.Tests</AssemblyName>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<IsPackable>false</IsPackable>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies.net472" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CncModels\CncModels.csproj" />
<ProjectReference Include="..\..\src\CncRepository\CncRepository.csproj" />
<ProjectReference Include="..\..\src\CncService\CncService.csproj" />
<ProjectReference Include="../../src/CncService/CncService.csproj" />
</ItemGroup>
</Project>

@ -180,8 +180,8 @@ namespace CncService.Tests
{
var addressId = InsertTestAddress();
// 关联机床
TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, is_online, created_at, updated_at)
VALUES ('M001', '1', 1, @addressId, '0.0.0.0', 1, 1, 0, NOW(), NOW())",
TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at)
VALUES ('M001', '1', 1, @addressId, '0.0.0.0', 1, 1, NOW(), NOW())",
new { addressId });
var result = _service.Delete(addressId);

@ -1,149 +1,118 @@
using System;
using System.Collections.Generic;
using Xunit;
using CncModels.Dto.Dashboard;
using CncService;
using CncModels.Entity;
using CncRepository.Interface;
using CncService.Interface;
using CncService.Impl;
using Xunit;
namespace CncService.Tests
{
/// <summary>
/// DashboardService 仪表盘测试
/// 测试场景:汇总查询、车间产量、机床排名、工人排名、趋势、状态分布、采集器状态
/// </summary>
[Collection("Database")]
public class DashboardServiceTests : IDisposable
{
private readonly DashboardService _service;
public DashboardServiceTests()
{
TestDb.TruncateAll();
_service = ServiceFactory.CreateDashboardService();
}
public void Dispose()
{
TestDb.TruncateAll();
}
// ======== GetSummary ========
[Fact]
public void GetSummary__()
{
var summary = _service.GetSummary();
Assert.NotNull(summary);
}
// ======== GetWorkshopProduction ========
[Fact]
public void GetWorkshopProduction__()
{
var result = _service.GetWorkshopProduction(null, null);
Assert.NotNull(result);
}
[Fact]
public void GetWorkshopProduction_()
{
var start = new DateTime(2026, 1, 1);
var end = new DateTime(2026, 12, 31);
var result = _service.GetWorkshopProduction(start, end);
Assert.NotNull(result);
}
// ======== GetMachineRank ========
[Fact]
public void GetMachineRank__()
{
var result = _service.GetMachineRank(null, null, 10);
Assert.NotNull(result);
}
[Fact]
public void GetMachineRank_Top()
// Fake repositories to isolate DashboardService.GetCollectorStatus tests
public class FakeDashboardRepository : IDashboardRepository
{
var result = _service.GetMachineRank(null, null, 5);
Assert.NotNull(result);
public DashboardSummaryResponse GetSummary(int something) => new DashboardSummaryResponse();
public List<WorkshopProductionResponse> GetWorkshopProduction(DateTime startDate, DateTime endDate) => new List<WorkshopProductionResponse>();
public List<MachineRankResponse> GetMachineRank(DateTime startDate, DateTime endDate, int top, int something, string sortOrder = "desc") => new List<MachineRankResponse>();
public List<WorkerRankResponse> GetWorkerRank(DateTime startDate, DateTime endDate, int top, string sortOrder = "desc") => new List<WorkerRankResponse>();
public List<dynamic> GetProductionTrend(int days) => new List<dynamic>();
public object GetMachineStatusDistribution(int something) => new object();
public List<AlertListItem> GetRecentAlerts(int count) => new List<AlertListItem>();
}
// ======== GetWorkerRank ========
[Fact]
public void GetWorkerRank__()
public class FakeCollectorHeartbeatRepository : ICollectorHeartbeatRepository
{
var result = _service.GetWorkerRank(null, null, 10);
Assert.NotNull(result);
private readonly CollectorHeartbeat _latest;
public FakeCollectorHeartbeatRepository(CollectorHeartbeat latest) { _latest = latest; }
public long Create(CollectorHeartbeat entity) => 1;
public CollectorHeartbeat GetLatest(string serviceId) => _latest;
public int DeleteBeforeDate(DateTime date) => 0;
}
// ======== GetProductionTrend ========
[Fact]
public void GetProductionTrend_7()
public class FakeWindowsServiceChecker : IWindowsServiceChecker
{
var result = _service.GetProductionTrend();
Assert.NotNull(result);
private readonly ServiceStatusEnum _status;
public FakeWindowsServiceChecker(ServiceStatusEnum status) { _status = status; }
public ServiceStatusEnum GetServiceStatus(string serviceName) => _status;
public (bool, string) TryStartService(string serviceName, int timeoutSeconds) => (
_status == ServiceStatusEnum.NotInstalled ? false : true,
_status == ServiceStatusEnum.NotInstalled ? "NotInstalled" : "Started");
public (bool, string) TryStopService(string serviceName, int timeoutSeconds) => (
true, "Stopped");
}
[Fact]
public void GetProductionTrend_()
public class FakeSysConfigRepository : ISysConfigRepository
{
var result = _service.GetProductionTrend(30);
Assert.NotNull(result);
public SysConfig GetByKey(string configKey) => new SysConfig { ConfigKey = configKey, ConfigValue = "300" };
public List<SysConfig> GetAll() => new List<SysConfig>();
public bool UpdateValue(int id, string value) => true;
}
// ======== GetMachineStatusDistribution ========
[Fact]
public void GetMachineStatusDistribution__()
public class DashboardServiceTests
{
var result = _service.GetMachineStatusDistribution();
Assert.NotNull(result);
}
// ======== GetRecentAlerts ========
[Fact]
public void GetRecentAlerts__()
{
var result = _service.GetRecentAlerts(5);
Assert.NotNull(result);
public void GetCollectorStatus_With_NotInstalled_Service_Returns_NotInstalled_State()
{
// Arrange
var latest = new CollectorHeartbeat { Id = 1, ServiceId = "collector-service", Status = "running", UptimeSeconds = 120, LastCollectTime = DateTime.Now, CreatedAt = DateTime.Now };
var dashboardRepo = new FakeDashboardRepository();
var heartbeatRepo = new FakeCollectorHeartbeatRepository(latest);
var checker = new FakeWindowsServiceChecker(CncService.Interface.ServiceStatusEnum.NotInstalled);
var svc = new DashboardService(dashboardRepo, heartbeatRepo, new FakeSysConfigRepository(), checker);
// Act
var resultObj = svc.GetCollectorStatus();
var t = resultObj.GetType();
var statusProp = t.GetProperty("status");
var serviceStatusProp = t.GetProperty("serviceStatus");
var uptimeProp = t.GetProperty("uptimeSeconds");
var lastCollectTimeProp = t.GetProperty("lastCollectTime");
Assert.NotNull(statusProp);
Assert.NotNull(serviceStatusProp);
var statusVal = statusProp.GetValue(resultObj) as string;
var serviceStatusVal = serviceStatusProp.GetValue(resultObj) as string;
Assert.Equal("stopped", statusVal);
Assert.Equal("NotInstalled", serviceStatusVal);
}
[Fact]
public void GetRecentAlerts_()
public void GetCollectorStatus_With_Running_Heartbeats_Returns_Running_State()
{
TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at)
VALUES ('', 'http://test', 1, 30, 1, NOW(), NOW())");
TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, is_online, created_at, updated_at)
VALUES ('M001', '1', 1, 1, '0.0.0.0', 1, 1, 0, NOW(), NOW())");
TestDb.Execute(@"INSERT INTO cnc_alert (alert_type, machine_id, title, is_resolved, created_at)
VALUES ('offline', 1, '1', 0, NOW()),
('offline', 1, '2', 0, NOW())");
var result = _service.GetRecentAlerts(5);
Assert.True(result.Count >= 2);
}
// ======== GetCollectorStatus ========
var latest = new CollectorHeartbeat { Id = 1, ServiceId = "collector-service", Status = "running", UptimeSeconds = 60, LastCollectTime = DateTime.Now, CreatedAt = DateTime.Now };
var dashboardRepo = new FakeDashboardRepository();
var heartbeatRepo = new FakeCollectorHeartbeatRepository(latest);
var checker = new FakeWindowsServiceChecker(CncService.Interface.ServiceStatusEnum.Running);
var svc = new DashboardService(dashboardRepo, heartbeatRepo, new FakeSysConfigRepository(), checker);
[Fact]
public void GetCollectorStatus__()
{
var result = _service.GetCollectorStatus();
Assert.NotNull(result);
var resultObj = svc.GetCollectorStatus();
var t = resultObj.GetType();
var serviceStatusProp = t.GetProperty("serviceStatus");
var statusProp = t.GetProperty("status");
var serviceStatusVal = serviceStatusProp.GetValue(resultObj) as string;
var statusVal = statusProp.GetValue(resultObj) as string;
Assert.Equal("Running", serviceStatusVal);
Assert.Equal("running", statusVal);
}
[Fact]
public void GetCollectorStatus__()
{
TestDb.Execute(@"INSERT INTO log_collector_heartbeat (service_id, status, last_collect_time, success_count, fail_count, created_at)
VALUES ('collector-service', 'running', NOW(), 1, 0, NOW())");
var result = _service.GetCollectorStatus();
Assert.NotNull(result);
public void GetCollectorStatus_With_Starting_ServiceStatus_Returns_Starting_State()
{
var latest = new CollectorHeartbeat { Id = 2, ServiceId = "collector-service", Status = "running", UptimeSeconds = 120, LastCollectTime = DateTime.Now, CreatedAt = DateTime.Now };
var dashboardRepo = new FakeDashboardRepository();
var heartbeatRepo = new FakeCollectorHeartbeatRepository(latest);
var checker = new FakeWindowsServiceChecker(CncService.Interface.ServiceStatusEnum.Starting);
var svc = new DashboardService(dashboardRepo, heartbeatRepo, new FakeSysConfigRepository(), checker);
var resultObj = svc.GetCollectorStatus();
var t = resultObj.GetType();
var serviceStatusProp = t.GetProperty("serviceStatus");
var statusProp = t.GetProperty("status");
var serviceStatusVal = serviceStatusProp.GetValue(resultObj) as string;
var statusVal = statusProp.GetValue(resultObj) as string;
Assert.Equal("Starting", serviceStatusVal);
Assert.Equal("running", statusVal);
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save