diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 0000000..0a90777 --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -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 "==================" diff --git a/.sisyphus/plans/collector-start-status-enhancement-stage4.md b/.sisyphus/plans/collector-start-status-enhancement-stage4.md new file mode 100644 index 0000000..d465c18 --- /dev/null +++ b/.sisyphus/plans/collector-start-status-enhancement-stage4.md @@ -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% 以上 diff --git a/.txt b/.txt new file mode 100644 index 0000000..0096a9f --- /dev/null +++ b/.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" + } + ] + } +] \ No newline at end of file diff --git a/Api/CollectorApiServer.cs b/Api/CollectorApiServer.cs deleted file mode 100644 index bd00463..0000000 --- a/Api/CollectorApiServer.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Net; -using System.Text; -using System.Threading.Tasks; -using Newtonsoft.Json; -using log4net; -using CncCollector.Core; -using System.IO; - -namespace CncCollector.Api -{ - /// - /// Lightweight HTTP API server,用于控制 CollectEngine 的状态与配置刷新。 - /// - public class CollectorApiServer - { - private readonly CollectorEngine _engine; - private readonly string _apiKey; - private readonly int _port; - private readonly HttpListener _listener = new HttpListener(); - private readonly ILog _log = LogManager.GetLogger(typeof(CollectorApiServer)); - private Task _listenTask; - private bool _running; - - public CollectorApiServer(CollectorEngine engine, string apiKey, int port = 0) - { - _engine = engine; - _apiKey = apiKey; - _port = port > 0 ? port : 8080; - } - - public void Start() - { - var prefix = $"http://+:{_port}/api/collector/"; - _listener.Prefixes.Add(prefix); - _listener.Start(); - _running = true; - _log.Info("CollectorApiServer started on " + prefix); - _listenTask = Task.Run(() => ListenLoop()); - } - - public void Stop() - { - _running = false; - _listener.Stop(); - } - - private async Task ListenLoop() - { - while (_running) - { - try - { - var ctx = await _listener.GetContextAsync(); - _ = Task.Run(() => ProcessRequest(ctx)); - } - catch (HttpListenerException) - { - // 监听关闭 - break; - } - catch (Exception ex) - { - _log.Error("CollectorApiServer 处理请求异常", ex); - } - } - } - - private void ProcessRequest(HttpListenerContext ctx) - { - var req = ctx.Request; - var res = ctx.Response; - // 简单的 API-Key 认证 - if (!ValidateApiKey(req)) - { - res.StatusCode = 401; - WriteJson(res, new { code = 1, message = "Unauthorized" }); - return; - } - - string path = req.Url.AbsolutePath.ToLower(); - switch (req.HttpMethod) - { - case "GET": - if (path.EndsWith("/status")) - WriteJson(res, new { code = 0, message = "success", data = new { status = _engine.Status } }); - else - WriteJson(res, new { code = 0, message = "unknown endpoint" }); - break; - case "POST": - if (path.EndsWith("/start")) - { - _engine.Start(); - WriteJson(res, new { code = 0, message = "started" }); - } - else if (path.EndsWith("/stop")) - { - _engine.Stop(); - WriteJson(res, new { code = 0, message = "stopped" }); - } - else if (path.EndsWith("/refresh")) - { - _engine.Refresh(); - WriteJson(res, new { code = 0, message = "refreshed" }); - } - else - { - WriteJson(res, new { code = 0, message = "unknown endpoint" }); - } - break; - default: - res.StatusCode = 405; - WriteJson(res, new { code = 1, message = "method not allowed" }); - break; - } - } - - private bool ValidateApiKey(HttpListenerRequest req) - { - var header = req.Headers["X-Api-Key"]; - return !string.IsNullOrEmpty(_apiKey) && string.Equals(_apiKey, header); - } - - private void WriteJson(HttpListenerResponse res, object data) - { - var json = JsonConvert.SerializeObject(data); - var buffer = Encoding.UTF8.GetBytes(json); - res.ContentType = "application/json"; - res.ContentLength64 = buffer.Length; - res.OutputStream.Write(buffer, 0, buffer.Length); - } - } -} diff --git a/CncService/ILogIngestionService.cs b/CncService/ILogIngestionService.cs new file mode 100644 index 0000000..abd7403 --- /dev/null +++ b/CncService/ILogIngestionService.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using CncService.LogAnalyzer; +using CncService.Models; + +namespace CncService +{ + // 扩展日志写入与分析结果传回接口,供分区日志写入及分析摘要能力使用 + public interface ILogIngestionService + { + // 写入采集日志及其分析摘要,返回写入是否成功 + Task WriteLogAsync(LogRecord record, LogAnalysisResult analysis); + + // 读取最新一条日志及其分析摘要(用于后台看板等场景的快速查询示例) + Task GetLatestLogAsync(string machineId, string programName); + } +} diff --git a/CncService/LogAnalyzer/LogAnalysisResult.cs b/CncService/LogAnalyzer/LogAnalysisResult.cs new file mode 100644 index 0000000..bede13a --- /dev/null +++ b/CncService/LogAnalyzer/LogAnalysisResult.cs @@ -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) + } +} diff --git a/CncService/Models/LogIngestionResult.cs b/CncService/Models/LogIngestionResult.cs new file mode 100644 index 0000000..7499c82 --- /dev/null +++ b/CncService/Models/LogIngestionResult.cs @@ -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; } + } +} diff --git a/CncService/Models/LogRecord.cs b/CncService/Models/LogRecord.cs new file mode 100644 index 0000000..1bef61c --- /dev/null +++ b/CncService/Models/LogRecord.cs @@ -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; } + } +} diff --git a/CncSimulator.csproj b/CncSimulator.csproj deleted file mode 100644 index 7e99188..0000000 --- a/CncSimulator.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - Exe - net472 - enable - - - - - - - - - - Always - - - - diff --git a/CncWebApi/Controllers/LogIngestionController.cs b/CncWebApi/Controllers/LogIngestionController.cs new file mode 100644 index 0000000..db31df3 --- /dev/null +++ b/CncWebApi/Controllers/LogIngestionController.cs @@ -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 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; } + } +} diff --git a/Config/ConfigLoader.cs b/Config/ConfigLoader.cs deleted file mode 100644 index ff7f4f4..0000000 --- a/Config/ConfigLoader.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using Dapper; -using MySql.Data.MySqlClient; -using log4net; - -// 文件用途:从 CNC 系统配置表 cnc_sys_config 读取运行时配置,并覆盖默认 CollectorConfig 的设置 -// 设计目标:在独立进程的采集服务中,不依赖仓储层,直接从数据库加载运行时参数 -namespace CncCollector.Config -{ - /// - /// 运行时配置加载器:从 cnc_sys_config 表读取配置,覆盖 CollectorConfig 的默认值 - /// - public static class ConfigLoader - { - private static readonly ILog _log = LogManager.GetLogger(typeof(ConfigLoader)); - - /// - /// 从数据库加载运行时配置并覆盖给定的 CollectorConfig 实例的默认值。 - /// - /// MySQL/MariaDB 连接字符串 - /// 需要被覆盖的 CollectorConfig 实例(来自 CNC 采集服务) - public static void LoadRuntimeConfig(string connectionString, CncModels.Entity.CollectorConfig config) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - _log.Warn("配置加载失败:连接字符串为空"); - return; - } - - if (config == null) - { - _log.Warn("配置加载失败:传入的 CollectorConfig 为 null"); - return; - } - - try - { - using (var conn = new MySqlConnection(connectionString)) - { - conn.Open(); - // 需要加载的键集合 - var keys = new[] - { - "collector_api_port", - "collector_api_key", - "heartbeat_interval", - "config_poll_interval", - "daily_summary_time", - "collect_retry_count", - "collect_retry_interval", - "collect_fail_alert_threshold" - }; - - foreach (var key in keys) - { - var value = conn.QueryFirstOrDefault( - "SELECT config_value FROM cnc_sys_config WHERE config_key = @Key", - new { Key = key }); - if (value == null) continue; - Apply(config, key, value); - } - } - } - catch (Exception ex) - { - _log.Error("加载运行时配置异常", ex); - } - } - - private static void Apply(CncModels.Entity.CollectorConfig config, string key, string value) - { - try - { - switch (key) - { - case "collector_api_port": - SetIntProperty(config, nameof(config.ApiPort), value); - break; - case "collector_api_key": - SetStringProperty(config, nameof(config.ApiKey), value); - break; - case "heartbeat_interval": - SetIntProperty(config, nameof(config.HeartbeatInterval), value); - break; - case "config_poll_interval": - SetIntProperty(config, nameof(config.ConfigPollInterval), value); - break; - case "daily_summary_time": - SetTimeSpanProperty(config, nameof(config.DailySummaryTime), value); - break; - case "collect_retry_count": - SetIntProperty(config, nameof(config.CollectRetryCount), value); - break; - case "collect_retry_interval": - SetIntProperty(config, nameof(config.CollectRetryIntervalSeconds), value); - break; - case "collect_fail_alert_threshold": - SetIntProperty(config, nameof(config.CollectFailAlertThreshold), value); - break; - } - } - catch - { - // 忽略单项配置解析失败,继续加载其他配置 - } - } - - private static void SetIntProperty(CncModels.Entity.CollectorConfig obj, string propName, string value) - { - if (obj == null) return; - var p = obj.GetType().GetProperty(propName); - if (p == null || !p.CanWrite) return; - if (int.TryParse(value, out var v)) p.SetValue(obj, v); - } - - private static void SetStringProperty(CncModels.Entity.CollectorConfig obj, string propName, string value) - { - if (obj == null) return; - var p = obj.GetType().GetProperty(propName); - if (p == null || !p.CanWrite) return; - p.SetValue(obj, value); - } - - private static void SetTimeSpanProperty(CncModels.Entity.CollectorConfig obj, string propName, string value) - { - if (obj == null) return; - var p = obj.GetType().GetProperty(propName); - if (p == null || !p.CanWrite) return; - if (TimeSpan.TryParse(value, out var ts)) p.SetValue(obj, ts); - } - } -} diff --git a/Config/SimulatorConfig.cs b/Config/SimulatorConfig.cs deleted file mode 100644 index 3fad6ea..0000000 --- a/Config/SimulatorConfig.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace CncSimulator.Config -{ - /// 模拟器配置 - public class SimulatorConfig - { - [JsonProperty("gatewayPort")] - public int GatewayPort { get; set; } = 9000; - - [JsonProperty("addresses")] - public List Addresses { get; set; } = new List(); - } - - public class AddressConfig - { - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("port")] - public int Port { get; set; } - - [JsonProperty("brand")] - public string Brand { get; set; } = "fanuc"; - - [JsonProperty("dataChangeInterval")] - public int DataChangeInterval { get; set; } = 10; - - [JsonProperty("scenarioMode")] - public string ScenarioMode { get; set; } = "auto"; - - [JsonProperty("devices")] - public List Devices { get; set; } = new List(); - } - - public class DeviceConfig - { - [JsonProperty("deviceCode")] - public string DeviceCode { get; set; } - - [JsonProperty("desc")] - public string Desc { get; set; } - - [JsonProperty("initialProgram")] - public string InitialProgram { get; set; } = "O0001"; - - [JsonProperty("initialPartCount")] - public int InitialPartCount { get; set; } = 0; - } -} diff --git a/Core/CollectRecordWriter.cs b/Core/CollectRecordWriter.cs deleted file mode 100644 index e75dd47..0000000 --- a/Core/CollectRecordWriter.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Collections.Generic; -using Dapper; -using MySql.Data.MySqlClient; -using log4net; -using CncModels.Entity; - -namespace CncCollector.Core -{ - /// - /// 采集数据批量写入:cnc_collect_record 与 log_collect_raw,以及更新 CNC 机床状态。 - /// - public static class CollectRecordWriter - { - private static readonly ILog Log = LogManager.GetLogger(typeof(CollectRecordWriter)); - - /// - /// 将批量结构化记录写入 cnc_collect_record,并记录原始 JSON 到 log_collect_raw,同时更新机床实时状态。 - /// - /// 数据库连接字符串 - /// 结构化记录集合 - /// 原始 JSON 日志 - /// 采集地址标识 - public static void WriteBatch(string connectionString, IEnumerable records, string rawJson, int collectAddressId) - { - if (records == null) return; - using (var conn = new MySqlConnection(connectionString)) - { - conn.Open(); - using (var tran = conn.BeginTransaction()) - { - try - { - // 插入原始日志 - const string sqlLog = @"INSERT INTO log_collect_raw - (collect_address_id, request_time, response_time, response_duration, is_success, status_code, raw_json, error_message, created_at) - VALUES - (@CollectAddressId, @RequestTime, @ResponseTime, @ResponseDuration, @IsSuccess, @StatusCode, @RawJson, @ErrorMessage, NOW())"; - conn.Execute(sqlLog, new - { - CollectAddressId = collectAddressId, - RequestTime = DateTime.Now, - ResponseTime = DateTime.Now, - ResponseDuration = 0, - IsSuccess = 1, - StatusCode = 200, - RawJson = rawJson, - ErrorMessage = (string)null - }, tran); - - // 插入结构化记录(逐条,保持简单且具备可编译性) - const string sqlRec = @"INSERT INTO cnc_collect_record - (machine_id, collect_time, device_time, 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, extra_data, created_at) - VALUES - (@MachineId, @CollectTime, @DeviceTime, @ProgramName, @PartCount, @DeviceStatus, @RunStatus, @OperateMode, @SpindleSpeedSet, @FeedSpeedSet, @SpindleSpeedActual, @FeedSpeedActual, @SpindleLoad, @SpindleOverride, @PowerOnTime, @RunTime, @CuttingTime, @CycleTime, @MachiningStatus, @ExtraData, NOW())"; - foreach (var r in records) - { - r.CreatedAt = DateTime.Now; - conn.Execute(sqlRec, r, tran); - } - - // 简单的机床状态更新(按单条记录的最后一条) - foreach (var r in records) - { - const string sqlMach = @"UPDATE cnc_machine - SET last_collect_time = @CollectTime, last_device_status = @DeviceStatus, last_run_status = @RunStatus, last_program_name = @ProgramName, last_part_count = @PartCount, last_operate_mode = @OperateMode, last_machining_status = @MachiningStatus - WHERE machine_id = @MachineId"; - conn.Execute(sqlMach, new - { - MachineId = r.MachineId, - CollectTime = r.CollectTime, - DeviceStatus = r.DeviceStatus, - RunStatus = r.RunStatus, - ProgramName = r.ProgramName, - PartCount = r.PartCount, - OperateMode = r.OperateMode, - MachiningStatus = r.MachiningStatus - }, tran); - } - tran.Commit(); - } - catch (Exception ex) - { - tran.Rollback(); - Log.Error("批量写入采集记录失败", ex); - throw; - } - } - } - } - } -} diff --git a/Core/CollectWorker.cs b/Core/CollectWorker.cs deleted file mode 100644 index 40c836d..0000000 --- a/Core/CollectWorker.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.NetworkInformation; -using System.Threading; -using System.Threading.Tasks; -using MySql.Data.MySqlClient; -using log4net; -using CncModels.Entity; -using Newtonsoft.Json; -using System.Linq; - -namespace CncCollector.Core -{ - /// - /// 单个采集地址工作线程:负责定时抓取、解析、入库及健康状态上报。 - /// - public class CollectWorker - { - private readonly CollectAddress _address; - private readonly string _connectionString; - private readonly ProductionTracker _tracker; - private readonly string _apiKey; - private readonly ILog _log = LogManager.GetLogger(typeof(CollectWorker)); - private Thread _thread; - private volatile bool _stop; - - public CollectWorker(CollectAddress address, string connectionString, ProductionTracker tracker, string apiKey) - { - _address = address ?? throw new ArgumentNullException(nameof(address)); - _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); - _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); - _apiKey = apiKey; - } - - public void Start() - { - _stop = false; - _thread = new Thread(Run) - { - IsBackground = true, - Name = $"CollectWorker-{_address?.Id ?? 0}" - }; - _thread.Start(); - } - - public void Stop() - { - _stop = true; - _thread?.Join(); - } - - private void Run() - { - while (!_stop) - { - try - { - // 1) Ping 测试连通性 - if (!string.IsNullOrWhiteSpace(_address?.Url)) - { - try - { - var host = new Uri(_address.Url).Host; - var ping = new Ping(); - var reply = ping.Send(host, 1000); - // 写入简要在线状态 - // 实际实现:更新 cnc_machine.is_online 等字段 - } - catch { /* 忽略 Ping 失败带来的异常 */ } - } - - // 2) HTTP GET 采集 - if (!string.IsNullOrWhiteSpace(_address?.Url)) - { - using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }) - { - var resp = http.GetAsync(_address.Url).Result; - if (resp.IsSuccessStatusCode) - { - var json = resp.Content.ReadAsStringAsync().Result; - // 解析(品牌信息在实际实现中注入) - var parsed = Core.DataParser.Parse(_address.BrandName, json, null); - if (parsed != null && parsed.Count > 0) - { - // 将解析后的字段映射为 CollectRecord,简化实现:创建空记录集合以便调用写入 - var recs = new List(); - // 实际实现应根据 parsed 构造 CollectRecord 对象 - if (recs.Count > 0) - { - CollectRecordWriter.WriteBatch(_connectionString, recs, json, _address.Id); - } - // 产量跟踪(简化实现:尝试从第一个字段的值计算产量) - int produced = 0; - var first = parsed.Values.FirstOrDefault(); - if (first != null && first.Value != null) - { - if (first.Value is int iv) produced = iv; - else if (first.Value is decimal dv) produced = (int)dv; - else if (first.Value is long lv) produced = (int)lv; - } - _tracker?.Track(_address.MachineId, parsed.Values.Select(v => v.FieldName).FirstOrDefault() ?? string.Empty, produced, DateTime.Now); - } - } - } - } - } - catch (Exception ex) - { - _log.Error("CollectWorker 异常", ex); - } - - // 采集间隔,若未配置则 30 秒 - var interval = 30; - try - { - if (_address != null && _address.CollectInterval > 0) interval = _address.CollectInterval; - } - catch { } - Thread.Sleep(interval * 1000); - } - } - } -} diff --git a/Core/CollectorEngine.cs b/Core/CollectorEngine.cs deleted file mode 100644 index 6f64d63..0000000 --- a/Core/CollectorEngine.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Dapper; -using MySql.Data.MySqlClient; -using log4net; -using CncModels.Entity; -using CncModels.Enum; - -namespace CncCollector.Core -{ - /// - /// 采集引擎:负责加载采集地址、创建工作线程、心跳和配置轮询等核心调度。 - /// - public class CollectorEngine - { - private readonly string _connectionString; - private readonly CollectorConfig _config; - private readonly List _workers = new List(); - private readonly ProductionTracker _tracker; - private readonly DailySummaryJob _dailyJob; - private readonly ILog _log = LogManager.GetLogger(typeof(CollectorEngine)); - private Thread _pollThread; - private volatile bool _running; - - public CollectorEngine(string connectionString, CollectorConfig config) - { - _connectionString = connectionString; - _config = config; - _tracker = new ProductionTracker(connectionString); - _dailyJob = new DailySummaryJob(connectionString); - } - - public void Start() - { - _log.Info("CollectorEngine starting..."); - _running = true; - // 加载并启动地址采集 worker - LoadAddressesAndStartWorkers(); - // 启动配置轮询 - _pollThread = new Thread(PollLoop) { IsBackground = true, Name = "CollectorConfigPoll" }; - _pollThread.Start(); - // 简化心跳机制:直接输出日志 - } - - public void Stop() - { - _log.Info("CollectorEngine stopping..."); - _running = false; - foreach (var w in _workers) w.Stop(); - _workers.Clear(); - } - - public string Status => _running ? "Running" : "Stopped"; - - private void LoadAddressesAndStartWorkers() - { - using (var conn = new MySqlConnection(_connectionString)) - { - conn.Open(); - var addresses = conn.Query("SELECT * FROM cnc_collect_address WHERE is_enabled=1"); - foreach (var addr in addresses) - { - var w = new CollectWorker(addr, _connectionString, _tracker, _config.ApiKey); - w.Start(); - _workers.Add(w); - } - } - } - - private void PollLoop() - { - while (_running) - { - try - { - // 轮询配置变更(简化实现) - Thread.Sleep(30000); - } - catch { } - } - } - - /// 重新加载地址配置并重启工作线程 - public void Refresh() - { - Stop(); - Start(); - } - } -} diff --git a/Core/DailySummaryJob.cs b/Core/DailySummaryJob.cs deleted file mode 100644 index e6d6192..0000000 --- a/Core/DailySummaryJob.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Data; -using Dapper; -using MySql.Data.MySqlClient; -using log4net; -using CncModels.Enum; -using CncModels.Entity; - -namespace CncCollector.Core -{ - /// - /// 日终汇总作业:在指定时间点执行,结账活跃段、聚合产量并标记完成。 - /// - public class DailySummaryJob - { - private readonly string _connectionString; - private readonly ILog _log = LogManager.GetLogger(typeof(DailySummaryJob)); - - public DailySummaryJob(string connectionString) - { - _connectionString = connectionString; - } - - /// - /// 触发日终汇总逻辑。 - /// - public void Run(DateTime now) - { - using (var conn = new MySqlConnection(_connectionString)) - { - conn.Open(); - // 1) 结账所有活跃段 - const string sqlCloseAll = @"UPDATE cnc_production_segment SET end_time=@EndTime, close_reason=@Reason, is_settled=1 WHERE end_time IS NULL"; - conn.Execute(sqlCloseAll, new { EndTime = now, Reason = SegmentCloseReason.EndOfDay.ToString() }); - - // 2) 汇总逻辑(简化:省略复杂聚合,日志输出) - _log.Info("Daily summary executed: production segments closed and daily tables updated (简化实现). "); - } - } - } -} diff --git a/Core/DataParser.cs b/Core/DataParser.cs deleted file mode 100644 index 03acf16..0000000 --- a/Core/DataParser.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using CncModels.Entity; -using System.Linq; -using log4net; - -namespace CncCollector.Core -{ - /// - /// JSON 解析引擎:将原始 JSON 与品牌字段映射表结合,输出结构化字段字典。 - /// - public static class DataParser - { - /// 解析结果中的字段 - public class ParsedField - { - /// 字段名(标准化后) - public string FieldName { get; set; } - /// 字段值 - public object Value { get; set; } - /// 数据类型 - public string DataType { get; set; } - } - - private static readonly ILog Log = LogManager.GetLogger(typeof(DataParser)); - - /// - /// 解析原始 JSON,并根据品牌映射提取字段。 - /// 该实现尽量适配常见结构:JSON 为数组,元素包含一个 tags 数组,tags 中的元素拥有 id/value。 - /// - /// 品牌名称,用于定位字段映射(若为 null,尝试使用空映射) - /// 原始 JSON 字符串 - /// 可选的品牌对象,包含映射信息 - /// 解析后的字段字典,Key 为标准字段名 - public static Dictionary Parse(string brandName, string json, Brand brand = null) - { - var result = new Dictionary(); - if (string.IsNullOrWhiteSpace(json)) return result; - - try - { - var root = JArray.Parse(json); - // 优先使用传入的 brand(若品牌包含字段映射)来定位 tags - var mappings = brand?.BrandFieldMappings?.ToList() ?? new List(); - foreach (var item in root) - { - // 定位 tags 列表,默认字段路径为 "tags" - var tagsPath = brand?.TagsPath ?? "tags"; - var tagsToken = item.SelectToken(tagsPath); - if (tagsToken == null) continue; - if (tagsToken is JArray tags) - { - foreach (var map in mappings) - { - var tag = tags.FirstOrDefault(t => string.Equals(t["id"]?.ToString(), map.FieldName, StringComparison.OrdinalIgnoreCase)); - if (tag != null) - { - var raw = tag["value"]?.ToString(); - var value = ConvertValue(raw, map.DataType); - var field = new ParsedField - { - FieldName = map.StandardField, - Value = value, - DataType = map.DataType - }; - result[field.FieldName] = field; - } - } - } - // 解析到一个设备后就返回,简化实现 - break; - } - } - catch (Exception ex) - { - Log.Error("DataParser 解析异常", ex); - } - return result; - } - - private static object ConvertValue(string raw, string dataType) - { - if (string.IsNullOrWhiteSpace(raw)) return null; - if (string.Equals(dataType, "number", StringComparison.OrdinalIgnoreCase)) - { - if (decimal.TryParse(raw, out var d)) return d; - } - // 去除形如 1219.00000 的尾缀 - if (raw.EndsWith(".00000") && decimal.TryParse(raw.Replace(".00000", ""), out var d2)) - { - return d2; - } - return raw; - } - } -} diff --git a/Core/LogRecorder.cs b/Core/LogRecorder.cs deleted file mode 100644 index be34136..0000000 --- a/Core/LogRecorder.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using log4net; - -namespace CncSimulator.Core -{ - public class LogEntry - { - public DateTime Timestamp { get; set; } - public int DeviceCount { get; set; } - public string KeyData { get; set; } - public string FullJson { get; set; } - public long DurationMs { get; set; } - } - - /// 日志记录器:内存环形缓冲 + 文件日志输出 - public class LogRecorder - { - private readonly int _capacity; - private readonly List _buffer = new List(); - private readonly object _lock = new object(); - private static readonly ILog _logger = LogManager.GetLogger(typeof(LogRecorder)); - - public LogRecorder(int capacity = 200) - { - _capacity = capacity; - } - - public void Record(int deviceCount, string keyData, string fullJson, long durationMs) - { - var entry = new LogEntry - { - Timestamp = DateTime.Now, - DeviceCount = deviceCount, - KeyData = keyData, - FullJson = fullJson, - DurationMs = durationMs - }; - lock (_lock) - { - _buffer.Add(entry); - if (_buffer.Count > _capacity) _buffer.RemoveAt(0); - } - // 触发日志到文件输出 - _logger.Info($"[{entry.Timestamp:yyyy-MM-dd HH:mm:ss}] D={deviceCount} Key={keyData} Dur={durationMs}ms"); - } - - public List GetRecentLogs(int count) - { - lock (_lock) - { - return _buffer.Skip(Math.Max(0, _buffer.Count - count)).Take(count).ToList(); - } - } - - public LogEntry GetLatest() - { - lock (_lock) - { - return _buffer.Count == 0 ? null : _buffer[_buffer.Count - 1]; - } - } - } -} diff --git a/Core/ProductionTracker.cs b/Core/ProductionTracker.cs deleted file mode 100644 index cc00d1f..0000000 --- a/Core/ProductionTracker.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using Dapper; -using MySql.Data.MySqlClient; -using CncModels.Enum; -using CncModels.Entity; -using log4net; - -namespace CncCollector.Core -{ - /// - /// 零件产量分段跟踪引擎:维护内存中的活跃段状态,并定期写入数据库。 - /// - public class ProductionTracker - { - private readonly string _connectionString; - private readonly object _lock = new object(); - private static readonly ILog Log = LogManager.GetLogger(typeof(ProductionTracker)); - - public ProductionTracker(string connectionString) - { - _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); - } - - /// - /// 处理一个采集记录后的产量跟踪逻辑。 - /// - public void Track(int machineId, string programName, int partCount, DateTime collectTime) - { - lock (_lock) - { - using (var conn = new MySqlConnection(_connectionString)) - { - conn.Open(); - // 查找当前未结算的活跃段 - var active = conn.QueryFirstOrDefault( - "SELECT * FROM cnc_production_segment WHERE machine_id=@MachineId AND is_settled=0 AND end_time IS NULL", - new { MachineId = machineId }); - - if (active == null) - { - // 开新段 - const string sqlStart = @"INSERT INTO cnc_production_segment - (machine_id, program_name, production_date, start_time, start_part_count, is_settled) - VALUES - (@MachineId, @ProgramName, @ProductionDate, @StartTime, @StartPartCount, 0)"; - conn.Execute(sqlStart, new - { - MachineId = machineId, - ProgramName = programName, - ProductionDate = collectTime.Date, - StartTime = collectTime, - StartPartCount = partCount - }); - } - else - { - // 程序名变更则结账并开新段 - if (!string.Equals(active.ProgramName, programName, StringComparison.OrdinalIgnoreCase)) - { - const string sqlClose = @"UPDATE cnc_production_segment - SET end_time=@EndTime, end_part_count=@EndPartCount, is_settled=1, close_reason=@Reason - WHERE id=@Id"; - conn.Execute(sqlClose, new - { - EndTime = collectTime, - EndPartCount = active.EndPartCount ?? active.StartPartCount, - Id = active.Id, - Reason = SegmentCloseReason.ProgramChange.ToString() - }); - - const string sqlStart = @"INSERT INTO cnc_production_segment - (machine_id, program_name, production_date, start_time, start_part_count, is_settled) - VALUES - (@MachineId, @ProgramName, @ProductionDate, @StartTime, @StartPartCount, 0)"; - conn.Execute(sqlStart, new - { - MachineId = machineId, - ProgramName = programName, - ProductionDate = collectTime.Date, - StartTime = collectTime, - StartPartCount = partCount - }); - } - else - { - // 更新当前段的结束时间与结束时的部件数 - const string sqlUpdate = @"UPDATE cnc_production_segment - SET end_time=@EndTime, end_part_count=@EndPartCount - WHERE id=@Id"; - conn.Execute(sqlUpdate, new - { - EndTime = collectTime, - EndPartCount = partCount, - Id = active.Id - }); - } - } - } - } - } - } -} diff --git a/Core/SimulatorEngine.cs b/Core/SimulatorEngine.cs deleted file mode 100644 index 749edae..0000000 --- a/Core/SimulatorEngine.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Newtonsoft.Json; -using CncSimulator.Config; -using CncSimulator.Core; -using CncSimulator.Device; - -namespace CncSimulator.Core -{ - /// 引擎:管理多个 SimulatorServer 实例 - public class SimulatorEngine - { - private readonly List _servers = new List(); - - public void LoadConfig(string jsonPath) - { - var json = File.ReadAllText(jsonPath); - var cfg = JsonConvert.DeserializeObject(json); - LoadConfig(cfg); - } - - public void LoadConfig(SimulatorConfig cfg) - { - _servers.Clear(); - foreach (var addr in cfg.Addresses) - { - var devices = new List(); - foreach (var d in addr.Devices) - { - devices.Add(new DeviceSimulator(d, addr.DataChangeInterval)); - } - var server = new SimulatorServer(addr, devices); - _servers.Add(server); - } - } - - public void StartAll() - { - foreach (var s in _servers) s.Start(); - } - - public void StopAll() - { - foreach (var s in _servers) s.Stop(); - } - - public object GetStatus() - { - return _servers.Select(s => new - { - address = s.Address.Name, - port = s.Address.Port, - running = s.IsRunning, - totalDevices = s.TotalDeviceCount, - onlineDevices = s.OnlineDeviceCount - }).ToList(); - } - - public SimulatorServer FindByPort(int port) - { - return _servers.FirstOrDefault(s => s.Address.Port == port); - } - } -} diff --git a/Core/SimulatorServer.cs b/Core/SimulatorServer.cs deleted file mode 100644 index bbb0866..0000000 --- a/Core/SimulatorServer.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading; -using System.Timers; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using CncSimulator.Config; -using CncSimulator.Device; -using CncSimulator.Generator; -using CncSimulator.Core; - -namespace CncSimulator.Core -{ - /// 单个地址的 HTTP 服务器及设备仿真集合 - public class SimulatorServer - { - public AddressConfig Address { get; private set; } - public List Devices { get; private set; } - private readonly FanucDataGenerator _generator = new FanucDataGenerator(); - private readonly LogRecorder _logRecorder = new LogRecorder(); - private HttpListener _http; - private Timer _tickTimer; - public bool IsRunning { get; private set; } = false; - public int RequestCount { get; private set; } = 0; - public int TotalDeviceCount => Devices?.Count ?? 0; - public int OnlineDeviceCount => Devices?.Count(d => d.State?.IsOnline == true) ?? 0; - public DateTime StartTime { get; private set; } - - public SimulatorServer(AddressConfig address, List devices) - { - Address = address; - Devices = devices ?? new List(); - } - - public void Start() - { - if (IsRunning) return; - _http = new HttpListener(); - _http.Prefixes.Add($"http://+:{Address.Port}/"); - _http.Start(); - StartTime = DateTime.Now; - // 每个地址用一个定时器驱动数据变化 - _tickTimer = new Timer(Address.DataChangeInterval * 1000); - _tickTimer.Elapsed += (s, e) => TickDevices(); - _tickTimer.Start(); - // 简化的请求循环(阻塞模式) - var t = new Thread(HandleRequests) { IsBackground = true }; - t.Start(); - IsRunning = true; - } - - public void Stop() - { - if (!IsRunning) return; - _tickTimer?.Stop(); - _http?.Close(); - IsRunning = false; - } - - private void TickDevices() - { - foreach (var d in Devices) - { - d.Tick(); - } - } - - private void HandleRequests() - { - while (_http != null && _http.IsListening) - { - try - { - var ctx = _http.GetContext(); - ProcessContext(ctx); - } - catch (Exception) - { - // 忽略单次请求异常,继续监听 - } - } - } - - private void ProcessContext(HttpListenerContext ctx) - { - RequestCount++; - var req = ctx.Request; - var resp = ctx.Response; - resp.ContentType = "application/json"; - string path = req.Url.AbsolutePath.ToLower(); - if (path == "/" || path == "/data") - { - // 生成当前设备数据集合 - var sw = new System.Diagnostics.Stopwatch(); - sw.Start(); - var list = new List(); - foreach (var d in Devices) - { - var json = _generator.GenerateDevice(d.State); - if (json != null) list.Add(json); - } - var final = new JObject { ["devices"] = new JArray(list) }; - var payload = final.ToString(); - sw.Stop(); - _logRecorder.Record(Devices.Count, string.Join(" ", Devices.Select(v => v.State?.DeviceCode ?? "")), payload, sw.ElapsedMilliseconds); - WriteResponse(resp, payload); - } - else if (path.StartsWith("/admin")) - { - string html = "

管理界面开发中

"; - WriteResponse(resp, html, contentType: "text/html"); - } - else if (path == "/admin/api/logs") - { - var logs = _logRecorder.GetRecentLogs(50); - var arr = new JArray(); - foreach (var l in logs) - { - arr.Add(new JObject - { - ["timestamp"] = l.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), - ["deviceCount"] = l.DeviceCount, - ["keyData"] = l.KeyData, - ["durationMs"] = l.DurationMs - }); - } - var payload = arr.ToString(); - WriteResponse(resp, payload); - } - else - { - WriteResponse(resp, "{}", contentType: "application/json"); - } - resp.Close(); - } - - private void WriteResponse(HttpListenerResponse resp, string content, string contentType = "application/json") - { - var buffer = System.Text.Encoding.UTF8.GetBytes(content); - resp.ContentType = contentType; - resp.ContentLength64 = buffer.Length; - resp.OutputStream.Write(buffer, 0, buffer.Length); - } - } -} diff --git a/Device/DeviceSimulator.cs b/Device/DeviceSimulator.cs deleted file mode 100644 index 8edeae0..0000000 --- a/Device/DeviceSimulator.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CncSimulator.Config; -using CncSimulator.Device; -using CncSimulator.Generator; - -namespace CncSimulator.Device -{ - /// 单台设备的状态机与仿真逻辑 - public class DeviceSimulator - { - public DeviceState State { get; private set; } - private readonly Random _rnd; - private readonly List _programs = new List { "O0001", "O0002", "1566.NC", "PART-A", "TEST-03" }; - private int _currentScenarioIndex = 0; - private int _tickCounter = 0; - - public DeviceSimulator(DeviceConfig cfg, int dataChangeInterval) - { - State = new DeviceState - { - DeviceCode = cfg.DeviceCode, - Desc = cfg.Desc, - ProgramName = cfg.InitialProgram ?? "O0001", - PartCount = cfg.InitialPartCount, - DataChangeInterval = dataChangeInterval - }; - // 使用不同种子避免同步 - _rnd = new Random(cfg.DeviceCode.GetHashCode()); - // 初始化一个随机偏移,确保场景起始不一致 - _currentScenarioIndex = _rnd.Next(0, _programs.Count); - } - - public void Tick() - { - if (!State.IsOnline) return; - // 简易定时器:每次 Tick 增加一个单位时间,依据当前场景更新数据 - _tickCounter++; - // 每次数据变化的间隔由 DataChangeInterval 决定 - if (_tickCounter < State.DataChangeInterval) return; - _tickCounter = 0; - - // 根据当前场景执行更新逻辑 - switch (State.CurrentScenario) - { - case "machining": - State.PartCount += 1; - State.RunStatus = 3; - State.OperateMode = 1; - State.MachiningStatus = "G01"; - break; - default: - break; - } - } - - public void SetScenario(string scenarioName, int duration) - { - State.CurrentScenario = scenarioName; - State.ScenarioDuration = duration; - State.ScenarioTick = 0; - } - - // 简化的场景更新接口,供 ScenarioPlayer 调用 - public void ApplyScenarioUpdate(string scenarioName, int duration, string programOverride = null) - { - SetScenario(scenarioName, duration); - if (!string.IsNullOrWhiteSpace(programOverride)) - { - State.ProgramName = programOverride; - } - } - } -} diff --git a/Device/DeviceState.cs b/Device/DeviceState.cs deleted file mode 100644 index ab0003c..0000000 --- a/Device/DeviceState.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace CncSimulator.Device -{ - /// 单台模拟设备的完整状态 - public class DeviceState - { - // 固定信息 - public string DeviceCode { get; set; } - public string Desc { get; set; } - - // 动态状态 - public string CurrentScenario { get; set; } = "idle"; - public bool IsOnline { get; set; } = true; - public string ProgramName { get; set; } = "O0001"; - public int PartCount { get; set; } = 0; - public int DeviceStatus { get; set; } = 1; // _io_status - public int RunStatus { get; set; } = 0; // 0=待机 1=运行 3=加工中 - public int OperateMode { get; set; } = 1; // 1=MEM 10=JOG - public decimal SpindleSpeedSet { get; set; } = 450; - public decimal FeedSpeedSet { get; set; } = 60; - public decimal SpindleSpeedActual { get; set; } = 0; - public decimal FeedSpeedActual { get; set; } = 0; - public decimal SpindleLoad { get; set; } = 0; - public decimal SpindleOverride { get; set; } = 100; - public decimal PowerOnTime { get; set; } = 0; - public decimal RunTime { get; set; } = 0; - public decimal CuttingTime { get; set; } = 0; - public decimal CycleTime { get; set; } = 0; - public string MachiningStatus { get; set; } = ""; - public string ProgramContent { get; set; } = ""; - - // 剧本控制 - public int ScenarioTick { get; set; } = 0; - public int ScenarioDuration { get; set; } = 10; - - // 上一次的part_count(用于检测手动清零) - public int LastPartCount { get; set; } = 0; - - // 累计数据变化间隔(用于递增时间字段) - public int DataChangeInterval { get; set; } = 10; - } -} diff --git a/Device/ScenarioPlayer.cs b/Device/ScenarioPlayer.cs deleted file mode 100644 index eb40789..0000000 --- a/Device/ScenarioPlayer.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CncSimulator.Device -{ - /// 剧本播放器的简化实现(占位,未直接驱动状态) - public class ScenarioPlayer - { - public ScenarioPlayer() - { - } - - public void Tick(DeviceState state) - { - // 简化实现:不改变状态,留作未来扩展点 - } - - public void TriggerEvent(string eventType) - { - // 事件占位 - } - } -} diff --git a/Generator/FanucDataGenerator.cs b/Generator/FanucDataGenerator.cs deleted file mode 100644 index 0af4e0f..0000000 --- a/Generator/FanucDataGenerator.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using CncSimulator.Device; - -namespace CncSimulator.Generator -{ - /// Fanuc 数据生成器:生成 19 个 Tag 的 JSON 表示 - public class FanucDataGenerator : IBrandGenerator - { - public string BrandKey => "fanuc"; - - public JObject GenerateDevice(DeviceState state) - { - if (state == null) return null; - var now = DateTime.Now; - var rnd = new Random(state.DeviceCode.GetHashCode()); - var tags = new List(); - string[] tagNames = new string[] {"_io_status","Tag2","Tag5","Tag6","Tag7","Tag8","Tag9","Tag11","Tag14","Tag17","Tag18","Tag19","Tag20","Tag21","Tag22","Tag23","Tag24","Tag25","Tag26"}; - for (int i = 0; i < tagNames.Length; i++) - { - int offset = rnd.Next(-5, 1); // -5 ~ 0 - string time = now.AddSeconds(offset).ToString("yyyy-MM-dd HH:mm:ss"); - string value = GetTagValue(i, state); - string desc = GetTagDesc(i, state); - tags.Add(new JObject - { - ["name"] = tagNames[i], - ["time"] = time, - ["value"] = value, - ["quality"] = "0", - ["desc"] = desc - }); - } - var deviceObj = new JObject - { - ["device"] = state.DeviceCode, - ["desc"] = state.Desc, - ["tags"] = new JArray(tags) - }; - return deviceObj; - } - - private string GetTagDesc(int index, DeviceState state) - { - return index switch - { - 0 => "IO状态", - 1 => "当前轴数", - 2 => "当前加工程序", - 3 => "主程序号", - 4 => "加工程序内容", - 5 => "加工零件数", - 6 => "运行状态", - 7 => "操作模式", - 8 => "当前主轴倍率", - 9 => "主轴设定速度", - 10 => "进给设定速度", - 11 => "主轴实际速度", - 12 => "进给实际转速", - 13 => "主轴负载", - 14 => "开机时间", - 15 => "运行时间", - 16 => "切削时间", - 17 => "循环时间", - 18 => state.MachiningStatus, - _ => "" - }; - } - - private string GetTagValue(int index, DeviceState state) - { - switch (index) - { - case 0: return $"{state.DeviceStatus}.00000"; // _io_status - case 1: return "4.00000"; // Tag2 - case 2: return state.ProgramName; // Tag5 - case 3: return "N0"; // Tag6 - case 4: return $"<{state.ProgramName}>\nG40G49G80\n( SIMULATOR )"; // Tag7 - case 5: return $"{state.PartCount}.00000"; // Tag8 - case 6: return $"{state.RunStatus}.00000"; // Tag9 - case 7: return $"{state.OperateMode}.00000"; // Tag11 - case 8: return state.SpindleOverride.ToString("0.00000"); // Tag14 - case 9: return state.SpindleSpeedSet.ToString("0.00000"); // Tag17 - case 10: return state.FeedSpeedSet.ToString("0.00000"); // Tag18 - case 11: return state.SpindleSpeedActual.ToString("0.00000"); // Tag19 - case 12: return state.FeedSpeedActual.ToString("0.00000"); // Tag20 - case 13: return state.SpindleLoad.ToString("0.00000"); // Tag21 - case 14: return state.PowerOnTime.ToString("0.00000"); // Tag22 - case 15: return state.RunTime.ToString("0.00000"); // Tag23 - case 16: return state.CuttingTime.ToString("0.00000"); // Tag24 - case 17: return state.CycleTime.ToString("0.00000"); // Tag25 - case 18: return state.MachiningStatus ?? ""; // Tag26 - default: return "0.00000"; - } - } - } -} diff --git a/Generator/IBrandGenerator.cs b/Generator/IBrandGenerator.cs deleted file mode 100644 index bfff025..0000000 --- a/Generator/IBrandGenerator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Newtonsoft.Json.Linq; - -namespace CncSimulator.Generator -{ - /// 品牌数据生成器接口 - public interface IBrandGenerator - { - string BrandKey { get; } - JObject GenerateDevice(CncSimulator.Device.DeviceState state); - } -} diff --git a/InstallUtil.InstallLog b/InstallUtil.InstallLog deleted file mode 100644 index a2949ef..0000000 --- a/InstallUtil.InstallLog +++ /dev/null @@ -1,28 +0,0 @@ - -正在运行事务处理安装。 - -正在开始安装的“安装”阶段。 -查看日志文件的内容以获得 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.exe 程序集的进度。 -该文件位于 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.InstallLog。 - -“安装”阶段已成功完成,正在开始“提交”阶段。 -查看日志文件的内容以获得 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.exe 程序集的进度。 -该文件位于 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.InstallLog。 - -“提交”阶段已成功完成。 - -已完成事务处理安装。 - - -正在开始卸载。 -查看日志文件的内容以获得 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.exe 程序集的进度。 -该文件位于 E:\opencode\haoliang\src\CncCollector\bin\CncCollector.InstallLog。 - -卸载完成。 - - -正在开始卸载。 -查看日志文件的内容以获得 C:\CncCollector\CncCollector.exe 程序集的进度。 -该文件位于 C:\CncCollector\CncCollector.InstallLog。 - -卸载完成。 diff --git a/Program.cs b/Program.cs deleted file mode 100644 index f789faa..0000000 --- a/Program.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Threading; -using log4net; -using log4net.Config; -using CncCollector.Config; -using CncCollector.Core; -using CncCollector.Api; - -namespace CncCollector -{ - /// - /// 主入口:启动采集引擎与管理 API。 - /// - internal class Program - { - private static readonly ILog Log = LogManager.GetLogger(typeof(Program)); - - static void Main(string[] args) - { - // 初始化日志 - XmlConfigurator.Configure(); - - // 假设默认配置:从配置文件加载,若失败使用硬编码默认值 - var defaultConfig = new CncModels.Entity.CollectorConfig - { - ApiPort = 9000, - ApiKey = "", - HeartbeatInterval = 60, - ConfigPollInterval = 30, - DailySummaryTime = TimeSpan.FromHours(1), - CollectRetryCount = 3, - CollectRetryIntervalSeconds = 5, - CollectFailAlertThreshold = 3 - }; - - // 数据库连接字符串,实际部署应改为配置文件读取 - string connectionString = "server=127.0.0.1;user=root;password=123456;database=cnc_business;SslMode=none"; - - // 从 DB 覆盖运行时配置 - try - { - ConfigLoader.LoadRuntimeConfig(connectionString, defaultConfig); - } - catch (Exception e) - { - Log.Error("加载运行时配置失败,使用默认配置", e); - } - - var engine = new CollectorEngine(connectionString, defaultConfig); - engine.Start(); - var api = new CollectorApiServer(engine, defaultConfig.ApiKey, defaultConfig.ApiPort); - api.Start(); - - Console.WriteLine("CNC 收集服务已启动,按任意键退出..."); - Console.ReadKey(); - - api.Stop(); - engine.Stop(); - } - } -} diff --git a/_test_pages.js b/_test_pages.js deleted file mode 100644 index 823d828..0000000 --- a/_test_pages.js +++ /dev/null @@ -1,23 +0,0 @@ -async (page) => { - const results = []; - const urls = [ - {name: 'C3.9 device detail', url: 'http://127.0.0.1/admin/machine/1'}, - {name: 'C4 brand', url: 'http://127.0.0.1/admin/brand'}, - {name: 'C5 collect-address', url: 'http://127.0.0.1/admin/collect-address'}, - {name: 'C6 worker', url: 'http://127.0.0.1/admin/worker'}, - {name: 'C7 production', url: 'http://127.0.0.1/admin/production'}, - {name: 'C8 alert', url: 'http://127.0.0.1/admin/alert'}, - {name: 'C9 settings', url: 'http://127.0.0.1/admin/settings'}, - {name: 'C10 log', url: 'http://127.0.0.1/admin/log'}, - {name: 'C11 screen-config', url: 'http://127.0.0.1/admin/screen-config'}, - ]; - for (const u of urls) { - await page.goto(u.url, {waitUntil: 'networkidle', timeout: 10000}).catch(() => {}); - const title = await page.title(); - const bodyText = await page.evaluate(() => document.body.innerText.substring(0, 200)); - const hasTable = await page.locator('table').count(); - const hasChart = await page.locator('canvas, svg').count(); - results.push(u.name + ': title=' + title + ', table=' + hasTable + ', chart=' + hasChart + ', bodyLen=' + bodyText.length); - } - return results.join('\n'); -} diff --git a/database/DDL/Collect_Log.md b/database/DDL/Collect_Log.md new file mode 100644 index 0000000..ad0bde3 --- /dev/null +++ b/database/DDL/Collect_Log.md @@ -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 版本做微调。 diff --git a/database/sqls/03-collect-analysis-tables.sql b/database/sqls/03-collect-analysis-tables.sql new file mode 100644 index 0000000..430652e --- /dev/null +++ b/database/sqls/03-collect-analysis-tables.sql @@ -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 '关联原始日志ID(log_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 '关联原始日志ID(log_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; diff --git a/database/sqls/04-auto-partition-and-cleanup.sql b/database/sqls/04-auto-partition-and-cleanup.sql new file mode 100644 index 0000000..3aae5a1 --- /dev/null +++ b/database/sqls/04-auto-partition-and-cleanup.sql @@ -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(); diff --git a/database/sqls/05-field-mapping-is-enabled.sql b/database/sqls/05-field-mapping-is-enabled.sql new file mode 100644 index 0000000..b7db021 --- /dev/null +++ b/database/sqls/05-field-mapping-is-enabled.sql @@ -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; diff --git a/database/sqls/06-online-timeout-config.sql b/database/sqls/06-online-timeout-config.sql new file mode 100644 index 0000000..270efea --- /dev/null +++ b/database/sqls/06-online-timeout-config.sql @@ -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; diff --git a/database/sqls/07-log-tables-partition.sql b/database/sqls/07-log-tables-partition.sql new file mode 100644 index 0000000..45bee05 --- /dev/null +++ b/database/sqls/07-log-tables-partition.sql @@ -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(); diff --git a/database/sqls/partitioned_logs.sql b/database/sqls/partitioned_logs.sql new file mode 100644 index 0000000..d3e408f --- /dev/null +++ b/database/sqls/partitioned_logs.sql @@ -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 ...? 进行滚动归档 diff --git a/deploy-admin.ps1 b/deploy-admin.ps1 deleted file mode 100644 index 7e9609a..0000000 --- a/deploy-admin.ps1 +++ /dev/null @@ -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 " 后端 API:http://192.168.1.202/api/health" -ForegroundColor White -Write-Host " 前端页面:http://192.168.1.202/admin/" -ForegroundColor White -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" diff --git a/docs/01-数据库设计.md b/docs/01-数据库设计.md index 01fe219..d15a363 100644 --- a/docs/01-数据库设计.md +++ b/docs/01-数据库设计.md @@ -511,7 +511,7 @@ CREATE TABLE cnc_screen_filter ( --- -## 三、日志库 cnc_log(3张表) +## 三、日志库 cnc_log(5张表) ### 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 '关联原始日志ID(log_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 '关联原始日志ID(log_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个索引(含唯一索引) diff --git a/docs/02-功能清单/01-采集日志/日志分表与分析设计.md b/docs/02-功能清单/01-采集日志/日志分表与分析设计.md new file mode 100644 index 0000000..d695e81 --- /dev/null +++ b/docs/02-功能清单/01-采集日志/日志分表与分析设计.md @@ -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,返回数据结构包含最近日志、等级分布、总条数和可展示的分析摘要。 +- 前端展示要点:顶部筛选区、摘要统计、最近日志表格、日志摘要截断预览。 +- 验证要点:前端路由可打开,后端接口能返回结构化数据,字段与前端模板对齐。 diff --git a/docs/02-功能清单/管理后台/13-采集日志/00-采集日志-索引.md b/docs/02-功能清单/管理后台/13-采集日志/00-采集日志-索引.md new file mode 100644 index 0000000..3f99510 --- /dev/null +++ b/docs/02-功能清单/管理后台/13-采集日志/00-采集日志-索引.md @@ -0,0 +1,26 @@ +# 采集日志 索引 + +> 版本:v1.0 +> 最后更新:2026-04-25 + +--- + +## 模块概述 + +展示每次采集的分析日志、采集周期汇总和原始采集数据 + +## 页面清单 + +| 页面编号 | 页面名称 | 路由 | 功能概述 | +|---------|---------|------|---------| +| 13-01 | 采集日志页面 | /collect-log | 3个Tab(采集周期+分析日志+原始数据) | + +## 页面功能详情 + +### 13-01 采集日志页面 +**路由**:`/collect-log` +**功能概述**:在同一页面以三个标签页展示采集周期、分析日志、原始数据,支持弹窗查看对比信息和JSON原始数据。 + +**交互关系说明**:查看分析详情 → 弹窗展示字段对比;查看原始数据 → 弹窗展示JSON + +--- diff --git a/docs/02-功能清单/管理后台/13-采集日志/01-采集日志-规范.md b/docs/02-功能清单/管理后台/13-采集日志/01-采集日志-规范.md new file mode 100644 index 0000000..764fa4b --- /dev/null +++ b/docs/02-功能清单/管理后台/13-采集日志/01-采集日志-规范.md @@ -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、pageSize,pageSize 默认为 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:ss,value-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-告警管理/`等的规范表述。 diff --git a/docs/02-功能清单/管理后台/13-采集日志/13-01-采集日志页面.md b/docs/02-功能清单/管理后台/13-采集日志/13-01-采集日志页面.md new file mode 100644 index 0000000..6644600 --- /dev/null +++ b/docs/02-功能清单/管理后台/13-采集日志/13-01-采集日志页面.md @@ -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 和对比数据 +- 周期数据 Mock:time、address、totalMachines、success、failure、anomaly、distribution、summary +- 原始数据 Mock:rawId、logTime、contentPreview、sourceAddress + +## 7. 交互行为 +- 标签页切换:切换时重新加载对应表格数据 +- 分页:点击页码、切换每页条数时加载对应页数据 +- 查看详情弹窗:选中行后弹出,展示分析详情或对比信息 +- 关联跳转:点击相关行的跳转按钮,跳转至对应的关联页面 +- 原始数据查看:点击原始数据行,弹出 JSON 原始数据预览 + +## 8. 组件树 +- 组件树示例:CollectLogPage -> (QueryForm, ElTabs -> (AnalysisTab, CycleTab, RawTab) -> (ElTable, ElPagination)) -> DetailDialog +- 表单控件(QueryForm):el-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`。 diff --git a/docs/03-API接口设计.md b/docs/03-API接口设计.md index 01285e1..bea4f8f 100644 --- a/docs/03-API接口设计.md +++ b/docs/03-API接口设计.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 diff --git a/docs/07-Windows服务状态功能-上线回滚文档.md b/docs/07-Windows服务状态功能-上线回滚文档.md new file mode 100644 index 0000000..76a5165 --- /dev/null +++ b/docs/07-Windows服务状态功能-上线回滚文档.md @@ -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.0(InstallUtil/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.202(Windows 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 配置修复 | diff --git a/docs/frontend-build-deploy.md b/docs/frontend-build-deploy.md new file mode 100644 index 0000000..6a7d753 --- /dev/null +++ b/docs/frontend-build-deploy.md @@ -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, + }, +})) +``` \ No newline at end of file diff --git a/frontend/e2e/simulator.spec.ts b/frontend/e2e/simulator.spec.ts new file mode 100644 index 0000000..7055548 --- /dev/null +++ b/frontend/e2e/simulator.spec.ts @@ -0,0 +1,106 @@ +/** + * CNC管理后台 - 模拟采集模块 Playwright E2E测试 + * + * 测试范围(IIS模式,真实后端,模拟器未启动): + * - 侧边栏导航:模拟采集菜单项可见可点击 + * - 总览页(/simulator):断连状态UI——未连接提示、按钮禁用 + * + * 运行方式:`npx playwright test e2e/simulator.spec.ts --project=chromium` + * 前提:IIS站点已部署最新前后端代码,无需启动CncSimulator + */ +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([]) + }) +}) diff --git a/frontend/mock/collect-log.ts b/frontend/mock/collect-log.ts new file mode 100644 index 0000000..845dd8b --- /dev/null +++ b/frontend/mock/collect-log.ts @@ -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 diff --git a/frontend/mock/simulator.ts b/frontend/mock/simulator.ts new file mode 100644 index 0000000..c513110 --- /dev/null +++ b/frontend/mock/simulator.ts @@ -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 diff --git a/frontend/src/api/collect-log.ts b/frontend/src/api/collect-log.ts new file mode 100644 index 0000000..a68a03e --- /dev/null +++ b/frontend/src/api/collect-log.ts @@ -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(`/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 {} diff --git a/frontend/src/api/simulator.ts b/frontend/src/api/simulator.ts new file mode 100644 index 0000000..4448c9c --- /dev/null +++ b/frontend/src/api/simulator.ts @@ -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 + }> +} + +/** 请求日志 */ +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('/admin/simulator/ping') +} + +/** 获取数据库采集地址列表 */ +export function fetchSimulatorAddresses() { + return request.get('/admin/simulator/addresses') +} + +/** 获取所有模拟状态汇总 */ +export function fetchSimulatorStatus() { + return request.get('/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(`/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(`/admin/simulator/address/${port}/logs`) +} + +/** 获取零件统计 */ +export function fetchAddressStats(port: number) { + return request.get(`/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(`/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 {} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 7c85e3b..2bf8e3f 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -47,6 +47,10 @@ + + + + @@ -67,6 +71,10 @@ + + + + @@ -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() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 33fca33..9477dba 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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: '模拟详情' } }, ], }, { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9523038..7354950 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -227,8 +227,6 @@ export interface DashboardSummary { activeAlerts: number /** 今日采集成功率(%) */ collectSuccessRate: number - /** 今日切削总时(小时) */ - todayCuttingTime: number /** 运行中机床数(有程序运行) */ runningMachines: number /** 数据缺失机床数(在线但采集失败) */ @@ -295,12 +293,7 @@ export interface WorkerRankRow { totalQuantity?: number } -/** 采集服务状态 */ -export interface CollectorStatus { - status: string - uptimeSeconds: number - lastCollectTime?: string -} +// CollectorStatus 已在上方定义(含 serviceStatus/serviceName/serviceMessage 扩展字段) /** 产量看板汇总 */ export interface ProductionDashboardSummary { diff --git a/frontend/src/views/Logs/LogDashboard.vue b/frontend/src/views/Logs/LogDashboard.vue new file mode 100644 index 0000000..782d4ce --- /dev/null +++ b/frontend/src/views/Logs/LogDashboard.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/views/brand/BrandEditPage.vue b/frontend/src/views/brand/BrandEditPage.vue index c53c4b6..ff4aaec 100644 --- a/frontend/src/views/brand/BrandEditPage.vue +++ b/frontend/src/views/brand/BrandEditPage.vue @@ -13,12 +13,13 @@ - + + @@ -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(`/admin/brand/${route.params.id}`) diff --git a/frontend/src/views/collect-log/CollectLogPage.vue b/frontend/src/views/collect-log/CollectLogPage.vue new file mode 100644 index 0000000..51c3505 --- /dev/null +++ b/frontend/src/views/collect-log/CollectLogPage.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/frontend/src/views/dashboard/DashboardPage.vue b/frontend/src/views/dashboard/DashboardPage.vue index 1bfdf6f..bc55f64 100644 --- a/frontend/src/views/dashboard/DashboardPage.vue +++ b/frontend/src/views/dashboard/DashboardPage.vue @@ -38,11 +38,11 @@
- - {{ serviceStatusLabel(collectorStatus.serviceStatus) }} + + {{ collectorStatusText }}
-
运行 {{ formatUptime(collectorStatus.uptimeSeconds) }}
+
运行 {{ formatUptime(collectorStatus.uptimeSeconds) }}
启动采集 @@ -70,7 +70,7 @@ - +
@@ -79,24 +79,11 @@
-
{{ summary.collectSuccessRate }}%
+
{{ formatNumber(summary.collectSuccessRate) }}%
- - -
-
- 今日切削总时 - - - -
-
{{ summary.todayCuttingTime }} h
-
-
-
- +
@@ -109,7 +96,7 @@
- +
@@ -204,8 +191,21 @@ - + + +