diff --git a/Api/CollectorApiServer.cs b/Api/CollectorApiServer.cs new file mode 100644 index 0000000..bd00463 --- /dev/null +++ b/Api/CollectorApiServer.cs @@ -0,0 +1,133 @@ +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/App.config b/App.config new file mode 100644 index 0000000..258414c --- /dev/null +++ b/App.config @@ -0,0 +1,25 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/CncSimulator.csproj b/CncSimulator.csproj new file mode 100644 index 0000000..7e99188 --- /dev/null +++ b/CncSimulator.csproj @@ -0,0 +1,19 @@ + + + Exe + net472 + enable + + + + + + + + + + Always + + + + diff --git a/Config/ConfigLoader.cs b/Config/ConfigLoader.cs new file mode 100644 index 0000000..ff7f4f4 --- /dev/null +++ b/Config/ConfigLoader.cs @@ -0,0 +1,133 @@ +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 new file mode 100644 index 0000000..3fad6ea --- /dev/null +++ b/Config/SimulatorConfig.cs @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..e75dd47 --- /dev/null +++ b/Core/CollectRecordWriter.cs @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..40c836d --- /dev/null +++ b/Core/CollectWorker.cs @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..6f64d63 --- /dev/null +++ b/Core/CollectorEngine.cs @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..e6d6192 --- /dev/null +++ b/Core/DailySummaryJob.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..03acf16 --- /dev/null +++ b/Core/DataParser.cs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..be34136 --- /dev/null +++ b/Core/LogRecorder.cs @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..cc00d1f --- /dev/null +++ b/Core/ProductionTracker.cs @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..749edae --- /dev/null +++ b/Core/SimulatorEngine.cs @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..bbb0866 --- /dev/null +++ b/Core/SimulatorServer.cs @@ -0,0 +1,149 @@ +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 new file mode 100644 index 0000000..8edeae0 --- /dev/null +++ b/Device/DeviceSimulator.cs @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..ab0003c --- /dev/null +++ b/Device/DeviceState.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..eb40789 --- /dev/null +++ b/Device/ScenarioPlayer.cs @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..0af4e0f --- /dev/null +++ b/Generator/FanucDataGenerator.cs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..bfff025 --- /dev/null +++ b/Generator/IBrandGenerator.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..a2949ef --- /dev/null +++ b/InstallUtil.InstallLog @@ -0,0 +1,28 @@ + +正在运行事务处理安装。 + +正在开始安装的“安装”阶段。 +查看日志文件的内容以获得 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 new file mode 100644 index 0000000..f789faa --- /dev/null +++ b/Program.cs @@ -0,0 +1,61 @@ +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/README.md b/README.md new file mode 100644 index 0000000..3e2e5b3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +CncSimulator 项目根 README +This repository contains a self-contained CNC data sampling simulator for .NET Framework 4.7.2. +具体实现参考 simulator.json、Config/SimulatorConfig.cs、Device/DeviceState.cs、Device/DeviceSimulator.cs、Generator/FanucDataGenerator.cs、Core/SimulatorServer.cs、Core/SimulatorEngine.cs、Program.cs。 diff --git a/_test_pages.js b/_test_pages.js new file mode 100644 index 0000000..823d828 --- /dev/null +++ b/_test_pages.js @@ -0,0 +1,23 @@ +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/frontend/src/types/index.ts b/frontend/src/types/index.ts index 731c413..9523038 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -235,6 +235,16 @@ export interface DashboardSummary { dataMissingMachines: number } +// 新增:采集服务状态数据结构(后端扩大了 serviceStatus 等字段) +export interface CollectorStatus { + status: string; // 原心跳状态文本,保留旧字段 + uptimeSeconds?: number; + lastCollectTime?: string | null; + serviceStatus?: string; // Windows 服务状态,如 NotInstalled、Running、Starting、StartFailed、Stopped + serviceName?: string; + serviceMessage?: string; +} + /** 仪表盘产量趋势 */ export interface DashboardTrendItem { date: string diff --git a/frontend/src/views/dashboard/DashboardPage.vue b/frontend/src/views/dashboard/DashboardPage.vue index 87831bf..1bfdf6f 100644 --- a/frontend/src/views/dashboard/DashboardPage.vue +++ b/frontend/src/views/dashboard/DashboardPage.vue @@ -38,14 +38,14 @@
- - {{ collectorStatus.status === 'running' ? '运行中' : '已停止' }} + + {{ serviceStatusLabel(collectorStatus.serviceStatus) }}
-
运行 {{ formatUptime(collectorStatus.uptimeSeconds) }}
+
运行 {{ formatUptime(collectorStatus.uptimeSeconds) }}
-
- 启动采集 +
+ 启动采集 停止采集 刷新配置
@@ -275,7 +275,7 @@ import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, Wo const { isMock } = useMockMode() const summary = ref({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, todayCuttingTime: 0, runningMachines: 0, dataMissingMachines: 0 }) -const collectorStatus = ref({ status: 'stopped', uptimeSeconds: 0 }) +const collectorStatus = ref({ status: 'stopped', uptimeSeconds: 0, serviceStatus: 'NotInstalled', serviceName: '' }) const machineRank = ref([]) const workerRank = ref([]) const trendData = ref([]) @@ -338,7 +338,20 @@ let statusPie: ECharts | null = null async function startCollector() { if (startLoading.value) return startLoading.value = true - try { await request.post('/admin/collector/start'); ElMessage.success('采集服务已启动'); await loadData() } catch { /* request拦截器已显示错误 */ } finally { startLoading.value = false } + try { + // 在发起启动前,若已安装且正在运行,前端应给出友好提示 + if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'Running') { + ElMessage.info('采集服务已在运行中'); + return; + } + if (collectorStatus.value.serviceStatus && collectorStatus.value.serviceStatus === 'NotInstalled') { + ElMessage.warning('采集服务未安装,请运行 install.ps1 安装脚本'); + return; + } + await request.post('/admin/collector/start'); + ElMessage.success('采集服务已启动'); + await loadData(); + } catch { /* request拦截器已显示错误 */ } finally { startLoading.value = false } } async function stopCollector() { @@ -615,3 +628,14 @@ onUnmounted(() => { } } +// 映射 Windows 服务状态为可读文本 +function serviceStatusLabel(status: string | undefined): string { + switch ((status || '').toString()) { + case 'NotInstalled': return '未安装'; + case 'Running': return '运行中'; + case 'Starting': return '启动中'; + case 'StartFailed': return '启动失败'; + case 'Stopped': return '已停止'; + default: return status || '-'; + } +} diff --git a/simulator.json b/simulator.json new file mode 100644 index 0000000..914d815 --- /dev/null +++ b/simulator.json @@ -0,0 +1,28 @@ +{ + "gatewayPort": 9000, + "addresses": [ + { + "name": "FANUC-1号模拟", + "port": 9001, + "brand": "fanuc", + "dataChangeInterval": 10, + "scenarioMode": "auto", + "devices": [ + { "deviceCode": "CNC-A001", "desc": "西栋1号", "initialProgram": "O0001", "initialPartCount": 50 }, + { "deviceCode": "CNC-006", "desc": "6号机床", "initialProgram": "O0002", "initialPartCount": 120 }, + { "deviceCode": "CNC-008", "desc": "8号机床", "initialProgram": "O0003", "initialPartCount": 0 } + ] + }, + { + "name": "FANUC-2号模拟", + "port": 9002, + "brand": "fanuc", + "dataChangeInterval": 15, + "scenarioMode": "auto", + "devices": [ + { "deviceCode": "CNC-B002", "desc": "B栋2号", "initialProgram": "1566.NC", "initialPartCount": 80 }, + { "deviceCode": "CNC-005", "desc": "验证机床", "initialProgram": "TEST001", "initialPartCount": 10 } + ] + } + ] +} diff --git a/src/CncCollector/scripts/playwright.config.ts b/src/CncCollector/scripts/playwright.config.ts index 0b4def3..5e340fa 100644 --- a/src/CncCollector/scripts/playwright.config.ts +++ b/src/CncCollector/scripts/playwright.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from '@playwright/test'; */ export default defineConfig({ testDir: '.', - testMatch: 'e2e-collector.spec.ts', + testMatch: '*.spec.ts', timeout: 120000, retries: 0, reporter: [['list'], ['html', { open: 'never' }]], diff --git a/src/CncService/CncService.csproj b/src/CncService/CncService.csproj index 2423637..cef2d3d 100644 --- a/src/CncService/CncService.csproj +++ b/src/CncService/CncService.csproj @@ -23,4 +23,9 @@ + + + + + diff --git a/src/CncService/Impl/DashboardService.cs b/src/CncService/Impl/DashboardService.cs index 0402ff5..fd492f2 100644 --- a/src/CncService/Impl/DashboardService.cs +++ b/src/CncService/Impl/DashboardService.cs @@ -13,12 +13,15 @@ namespace CncService.Impl { private readonly IDashboardRepository _dashboardRepository; private readonly ICollectorHeartbeatRepository _collectorHeartbeatRepository; + private readonly IWindowsServiceChecker _serviceChecker; public DashboardService(IDashboardRepository dashboardRepository, - ICollectorHeartbeatRepository collectorHeartbeatRepository) + ICollectorHeartbeatRepository collectorHeartbeatRepository, + IWindowsServiceChecker serviceChecker = null) { _dashboardRepository = dashboardRepository ?? throw new ArgumentNullException(nameof(dashboardRepository)); _collectorHeartbeatRepository = collectorHeartbeatRepository ?? throw new ArgumentNullException(nameof(collectorHeartbeatRepository)); + _serviceChecker = serviceChecker; } /// @@ -73,27 +76,41 @@ namespace CncService.Impl public object GetCollectorStatus() { var latest = _collectorHeartbeatRepository.GetLatest("collector-service"); - // 心跳超时阈值:90秒(3个心跳间隔,采集服务默认每30秒上报一次) const int heartbeatTimeoutSeconds = 90; - bool isRunning = false; - long uptimeSeconds = 0; + bool heartbeatRunning = false; + long heartbeatUptime = 0; + DateTime? lastCollectTime = latest?.LastCollectTime; if (latest != null && latest.Status == "running") { - // 检查最后心跳时间是否在阈值内,超时则判定为已停止 var lastHeartbeat = latest.CreatedAt; var elapsed = (DateTime.Now - lastHeartbeat).TotalSeconds; - isRunning = elapsed <= heartbeatTimeoutSeconds; + heartbeatRunning = elapsed <= heartbeatTimeoutSeconds; + if (heartbeatRunning) + heartbeatUptime = latest.UptimeSeconds ?? 0; + } - if (isRunning) - { - uptimeSeconds = latest.UptimeSeconds ?? 0; - } + // 额外的 Windows 服务状态 + string serviceStatusText = "NotInstalled"; + if (_serviceChecker != null) + { + var svc = _serviceChecker.GetServiceStatus("collector-service"); + serviceStatusText = svc.ToString(); } - return new { status = isRunning ? "running" : "stopped", uptimeSeconds, lastCollectTime = latest?.LastCollectTime }; + // 组合状态:NotInstalled -> 停止,其他根据心跳决定 + string status = (serviceStatusText == "NotInstalled") ? "stopped" : (heartbeatRunning ? "running" : "stopped"); + + return new { + status, + uptimeSeconds = heartbeatRunning ? heartbeatUptime : 0, + lastCollectTime, + serviceStatus = serviceStatusText, + serviceName = "collector-service", + serviceMessage = (string)null + }; } } } diff --git a/src/CncService/Impl/WindowsServiceChecker.cs b/src/CncService/Impl/WindowsServiceChecker.cs new file mode 100644 index 0000000..8bec113 --- /dev/null +++ b/src/CncService/Impl/WindowsServiceChecker.cs @@ -0,0 +1,110 @@ +using System; +using System.ServiceProcess; +using System.Threading; +using CncService.Interface; + +namespace CncService.Impl +{ + /// + /// Windows 服务检测实现(基于 ServiceController) + /// + public class WindowsServiceChecker : IWindowsServiceChecker + { + public ServiceStatusEnum GetServiceStatus(string serviceName) + { + try + { + using (var sc = new ServiceController(serviceName)) + { + sc.Refresh(); + switch (sc.Status) + { + case ServiceControllerStatus.Running: + return ServiceStatusEnum.Running; + case ServiceControllerStatus.StartPending: + case ServiceControllerStatus.ContinuePending: + default: + // 启动中的状态或未知状态视作 Starting + return ServiceStatusEnum.Starting; + } + } + } + catch (InvalidOperationException) + { + // 服务未安装 + return ServiceStatusEnum.NotInstalled; + } + catch + { + // 其他异常视为不可用,保守处理 + return ServiceStatusEnum.NotInstalled; + } + } + + public (bool Success, string Message) TryStartService(string serviceName, int timeoutSeconds) + { + try + { + using (var sc = new ServiceController(serviceName)) + { + sc.Refresh(); + if (sc.Status == ServiceControllerStatus.Running) + return (true, "已运行"); + + sc.Start(); + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + sc.Refresh(); + if (sc.Status == ServiceControllerStatus.Running) + return (true, "启动成功"); + Thread.Sleep(500); + } + return (false, $"启动超时,当前状态={sc.Status}"); + } + } + catch (InvalidOperationException) + { + return (false, "NotInstalled"); + } + catch (Exception ex) + { + return (false, $"启动失败: {ex.Message}"); + } + } + + public (bool Success, string Message) TryStopService(string serviceName, int timeoutSeconds) + { + try + { + using (var sc = new ServiceController(serviceName)) + { + sc.Refresh(); + if (sc.Status == ServiceControllerStatus.Stopped) + return (true, "已停止"); + + sc.Stop(); + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + sc.Refresh(); + if (sc.Status == ServiceControllerStatus.Stopped) + return (true, "停止成功"); + Thread.Sleep(500); + } + return (false, $"停止超时,当前状态={sc.Status}"); + } + } + catch (InvalidOperationException) + { + return (false, "NotInstalled"); + } + catch (Exception ex) + { + return (false, $"停止失败: {ex.Message}"); + } + } + } +} diff --git a/src/CncService/Interface/IWindowsServiceChecker.cs b/src/CncService/Interface/IWindowsServiceChecker.cs new file mode 100644 index 0000000..69da050 --- /dev/null +++ b/src/CncService/Interface/IWindowsServiceChecker.cs @@ -0,0 +1,43 @@ +using System; + +namespace CncService.Interface +{ + // Windows 服务状态枚举,用于和心跳状态区分不同场景 + public enum ServiceStatusEnum + { + NotInstalled, + Stopped, + Running, + Starting, + StartFailed + } + + /// + /// Windows 服务检测接口(用于管理后台对采集服务的状态检测与控制) + /// + public interface IWindowsServiceChecker + { + /// + /// 获取指定服务的当前状态 + /// + /// 服务名 + /// 服务状态枚举 + ServiceStatusEnum GetServiceStatus(string serviceName); + + /// + /// 尝试启动指定服务,并在给定超时内等待就绪 + /// + /// 服务名 + /// 超时(秒) + /// (是否成功, 详细信息) + (bool Success, string Message) TryStartService(string serviceName, int timeoutSeconds); + + /// + /// 尝试停止指定服务,并在给定超时内等待停止 + /// + /// 服务名 + /// 超时(秒) + /// (是否成功, 详细信息) + (bool Success, string Message) TryStopService(string serviceName, int timeoutSeconds); + } +} diff --git a/src/CncWebApi/Controllers/DashboardController.cs b/src/CncWebApi/Controllers/DashboardController.cs index 9457b85..c149058 100644 --- a/src/CncWebApi/Controllers/DashboardController.cs +++ b/src/CncWebApi/Controllers/DashboardController.cs @@ -133,6 +133,22 @@ namespace CncWebApi.Controllers [Route("~/api/admin/collector/start")] public IHttpActionResult StartCollector() { + // 先查询服务状态,决定下一步动作 + try + { + dynamic statusObj = _dashboardService.GetCollectorStatus(); + string serviceStatus = statusObj?.serviceStatus as string; + if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "NotInstalled", StringComparison.OrdinalIgnoreCase)) + { + return Ok(ApiResponse.Fail(40001, "采集服务未安装,请先在服务器上运行 install.ps1 安装服务")); + } + if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "Running", StringComparison.OrdinalIgnoreCase)) + { + return Ok(ApiResponse.Fail(40002, "采集服务已在运行中,无需再次启动")); + } + } + catch { /* ignore status fetch errors and fallback to forwarding */ } + return ForwardToCollector("/api/collector/start"); } diff --git a/src/CncWebApi/Infrastructure/ServiceResolver.cs b/src/CncWebApi/Infrastructure/ServiceResolver.cs index 2cf7cb3..25f4551 100644 --- a/src/CncWebApi/Infrastructure/ServiceResolver.cs +++ b/src/CncWebApi/Infrastructure/ServiceResolver.cs @@ -102,9 +102,12 @@ namespace CncWebApi.Infrastructure private IDashboardService ResolveDashboardService() { + // 注入 WindowsServiceChecker 以便在后端查询 Windows 服务状态 + var serviceChecker = new CncService.Impl.WindowsServiceChecker(); return new CncService.Impl.DashboardService( new CncRepository.Impl.Dashboard.DashboardRepository(_businessConn), - new CncRepository.Impl.Log.CollectorHeartbeatRepository(_logConn)); + new CncRepository.Impl.Log.CollectorHeartbeatRepository(_logConn), + serviceChecker); } private IMachineService ResolveMachineService() diff --git a/test-date-debug.js b/test-date-debug.js new file mode 100644 index 0000000..d7c1b16 --- /dev/null +++ b/test-date-debug.js @@ -0,0 +1,50 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + // 登录 + await page.goto('http://127.0.0.1/admin/login'); + await page.waitForTimeout(500); + await page.fill('input[type="text"]', 'admin'); + await page.fill('input[type="password"]', 'admin123'); + await page.click('button:has-text("登录")'); + await page.waitForTimeout(2000); + + // 导航到产量报表 + await page.goto('http://127.0.0.1/admin/production'); + await page.waitForTimeout(3000); + + // 获取el-date-picker内部真实值 + const result = await page.evaluate(() => { + const inputs = document.querySelectorAll('.el-date-editor input'); + const startInput = inputs[0]; + const endInput = inputs[1]; + + // 获取Vue组件实例 + const pickerEl = document.querySelector('.el-date-editor'); + const vueInstance = pickerEl?.__vue__; + + return { + startInputValue: startInput?.value, + endInputValue: endInput?.value, + startInputType: startInput?.type, + vueModelValue: vueInstance ? JSON.stringify(vueInstance.modelValue || vueInstance.$props?.modelValue) : 'no vue instance', + // 直接读input的placeholder + startPlaceholder: startInput?.placeholder, + endPlaceholder: endInput?.placeholder, + }; + }); + + console.log('日期选择器状态:', JSON.stringify(result, null, 2)); + + // 检查raw HTML + const rawHtml = await page.evaluate(() => { + const picker = document.querySelector('.el-date-editor'); + return picker?.outerHTML?.substring(0, 500); + }); + console.log('\nraw HTML:', rawHtml); + + await browser.close(); +})(); diff --git a/test-production-full.js b/test-production-full.js new file mode 100644 index 0000000..d343078 --- /dev/null +++ b/test-production-full.js @@ -0,0 +1,279 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const results = []; + + function log(category, name, pass, detail) { + const status = pass ? '✅' : '❌'; + console.log(`${status} [${category}] ${name}: ${detail}`); + results.push({ category, name, pass, detail }); + } + + // === 登录 === + await page.goto('http://127.0.0.1/admin/login'); + await page.waitForTimeout(500); + await page.fill('input[type="text"]', 'admin'); + await page.fill('input[type="password"]', 'admin123'); + await page.click('button:has-text("登录")'); + await page.waitForTimeout(2000); + + // === 导航到产量报表 === + await page.goto('http://127.0.0.1/admin/production'); + await page.waitForTimeout(3000); + + // ===================== + // 1. 页面基本加载 + // ===================== + console.log('\n========== 1. 页面基本加载 =========='); + + const title = await page.title(); + log('页面', '页面标题', title.length > 0, `标题: ${title}`); + + const url = page.url(); + log('页面', 'URL正确', url.includes('production'), `URL: ${url}`); + + // ===================== + // 2. 日期选择器 + // ===================== + console.log('\n========== 2. 日期选择器 =========='); + + const dateInputs = await page.$$eval('.el-date-editor input', els => els.map(e => e.value)); + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`; + log('日期', '默认日期是今天', dateInputs[0] === todayStr && dateInputs[1] === todayStr, `开始=${dateInputs[0]}, 结束=${dateInputs[1]}, 今天=${todayStr}`); + + // ===================== + // 3. 汇总卡片 + // ===================== + console.log('\n========== 3. 汇总卡片 =========='); + + const summaryCards = await page.evaluate(() => { + const cards = document.querySelectorAll('.el-card'); + const results = []; + for (const card of cards) { + const text = card.textContent.trim(); + if (text.includes('总产量') || text.includes('运行机床') || text.includes('切削总时') || text.includes('平均产量')) { + results.push(text.replace(/\n/g, ' ').substring(0, 80)); + } + } + return results; + }); + log('汇总', '总产量卡片有数据', summaryCards.some(c => c.includes('总产量')), summaryCards.filter(c => c.includes('总产量')).join(' | ') || '未找到'); + log('汇总', '运行机床卡片', summaryCards.some(c => c.includes('运行机床')), summaryCards.filter(c => c.includes('运行机床')).join(' | ') || '未找到'); + log('汇总', '切削总时卡片', summaryCards.some(c => c.includes('切削总时')), summaryCards.filter(c => c.includes('切削总时')).join(' | ') || '未找到'); + log('汇总', '平均产量卡片', summaryCards.some(c => c.includes('平均产量')), summaryCards.filter(c => c.includes('平均产量')).join(' | ') || '未找到'); + + // ===================== + // 4. 筛选控件 + // ===================== + console.log('\n========== 4. 筛选控件 =========='); + + // 车间下拉 + const workshopOptions = await page.evaluate(() => { + const sel = document.querySelectorAll('.el-select'); + // 第一个是车间 + return sel.length; + }); + log('筛选', '下拉控件存在', workshopOptions >= 3, `找到${workshopOptions}个下拉`); + + // 查询按钮 + const queryBtn = await page.$('button:has-text("查询")'); + log('筛选', '查询按钮存在', queryBtn !== null, queryBtn ? '存在' : '不存在'); + + // 重置按钮 + const resetBtn = await page.$('button:has-text("重置")'); + log('筛选', '重置按钮存在', resetBtn !== null, resetBtn ? '存在' : '不存在'); + + // ===================== + // 5. 数据表格 + // ===================== + console.log('\n========== 5. 数据表格 =========='); + + const tableHeaders = await page.$$eval('.el-table__header th .cell', els => els.map(e => e.textContent.trim())); + log('表格', '列头完整', tableHeaders.length >= 7, `列头: ${tableHeaders.join(', ')}`); + + const expectedHeaders = ['日期', '机床', '程序名', '产量', '运行时间', '切削时间', '日状态']; + expectedHeaders.forEach(h => { + log('表格', `列头含"${h}"`, tableHeaders.includes(h), tableHeaders.includes(h) ? '存在' : `缺失! 现有: ${tableHeaders.join(',')}`); + }); + + // 检查表格数据 + const tableRows = await page.$$eval('.el-table__body tr', trs => + trs.slice(0, 5).map(tr => { + const cells = tr.querySelectorAll('td .cell'); + return Array.from(cells).map(c => c.textContent.trim()); + }) + ); + log('表格', '有数据行', tableRows.length > 0, `${tableRows.length}行`); + + if (tableRows.length > 0) { + // 检查每列是否有数据 + const dateCol = tableRows.map(r => r[0]).filter(v => v && v !== ''); + log('表格', '日期列有数据', dateCol.length > 0, `${dateCol.length}/${tableRows.length}行有日期, 样例: ${dateCol[0]}`); + + const machineCol = tableRows.map(r => r[1]).filter(v => v && v !== ''); + log('表格', '机床列有数据', machineCol.length > 0, `${machineCol.length}/${tableRows.length}行有机床, 样例: ${machineCol[0]}`); + + const programCol = tableRows.map(r => r[2]).filter(v => v && v !== ''); + log('表格', '程序名列有数据', programCol.length > 0, `${programCol.length}/${tableRows.length}行有程序名, 样例: ${programCol[0]}`); + + const qtyCol = tableRows.map(r => r[3]).filter(v => v && v !== '' && v !== '-'); + log('表格', '产量列有数据', qtyCol.length > 0, `${qtyCol.length}/${tableRows.length}行有产量, 样例: ${qtyCol.slice(0, 3).join(',')}`); + + const statusCol = tableRows.map(r => r[6]).filter(v => v && v !== ''); + log('表格', '日状态列有数据', statusCol.length > 0, `${statusCol.length}/${tableRows.length}行有状态, 样例: ${statusCol.slice(0, 3).join(',')}`); + + // 打印前3行完整数据 + console.log('\n 前3行完整数据:'); + tableRows.slice(0, 3).forEach((row, i) => console.log(` 行${i+1}: ${JSON.stringify(row)}`)); + } + + // ===================== + // 6. 分页 + // ===================== + console.log('\n========== 6. 分页 =========='); + + const pagination = await page.$('.el-pagination'); + log('分页', '分页组件存在', pagination !== null, pagination ? '存在' : '不存在'); + + const totalText = await page.evaluate(() => { + const total = document.querySelector('.el-pagination__total'); + return total ? total.textContent.trim() : '未找到'; + }); + log('分页', '总数显示', totalText !== '未找到', totalText); + + // ===================== + // 7. 操作按钮 + // ===================== + console.log('\n========== 7. 操作按钮 =========='); + + const adjustBtns = await page.$$('button:has-text("修正")'); + log('操作', '修正按钮存在', adjustBtns.length > 0, `${adjustBtns.length}个修正按钮`); + + const historyBtns = await page.$$('button:has-text("修正历史")'); + log('操作', '修正历史按钮存在', historyBtns.length > 0, `${historyBtns.length}个修正历史按钮`); + + // ===================== + // 8. 交互测试:点击修正 + // ===================== + console.log('\n========== 8. 交互测试:修正 =========='); + + if (adjustBtns.length > 0) { + await adjustBtns[0].click(); + await page.waitForTimeout(1000); + + const dialog = await page.$('.el-dialog'); + const dialogVisible = dialog && await dialog.isVisible(); + log('交互', '点击修正弹出弹窗', dialogVisible, dialogVisible ? '弹窗可见' : '弹窗不可见'); + + if (dialogVisible) { + const dialogTitle = await page.evaluate(() => { + const t = document.querySelector('.el-dialog__title'); + return t ? t.textContent.trim() : '无标题'; + }); + log('交互', '弹窗标题', true, dialogTitle); + + // 检查弹窗内的表单元素 + const dialogInputs = await page.$$eval('.el-dialog input', els => els.map(e => ({ type: e.type, placeholder: e.placeholder, value: e.value }))); + log('交互', '弹窗表单元素', dialogInputs.length > 0, `${JSON.stringify(dialogInputs)}`); + + // 关闭弹窗 + const closeBtn = await page.$('.el-dialog__headerbtn'); + if (closeBtn) { await closeBtn.click(); await page.waitForTimeout(500); } + } + } + + // ===================== + // 9. 交互测试:点击修正历史 + // ===================== + console.log('\n========== 9. 交互测试:修正历史 =========='); + + if (historyBtns.length > 0) { + await historyBtns[0].click(); + await page.waitForTimeout(1000); + + const dialog = await page.$('.el-dialog'); + const dialogVisible = dialog && await dialog.isVisible(); + log('交互', '点击修正历史弹出弹窗', dialogVisible, dialogVisible ? '弹窗可见' : '弹窗不可见'); + + if (dialogVisible) { + const dialogTitle = await page.evaluate(() => { + const t = document.querySelector('.el-dialog__title'); + return t ? t.textContent.trim() : '无标题'; + }); + log('交互', '弹窗标题', true, dialogTitle); + + // 关闭 + const closeBtn = await page.$('.el-dialog__headerbtn'); + if (closeBtn) { await closeBtn.click(); await page.waitForTimeout(500); } + } + } + + // ===================== + // 10. 交互测试:重置按钮 + // ===================== + console.log('\n========== 10. 交互测试:重置 =========='); + + if (resetBtn) { + await resetBtn.click(); + await page.waitForTimeout(3000); + + const dateAfterReset = await page.$$eval('.el-date-editor input', els => els.map(e => e.value)); + log('交互', '重置后日期变化', true, `重置后: ${dateAfterReset.join(' - ')}`); + + const rowsAfterReset = await page.$$eval('.el-table__body tr', trs => trs.length); + log('交互', '重置后有数据', rowsAfterReset > 0, `${rowsAfterReset}行`); + } + + // ===================== + // 11. 交互测试:查询按钮 + // ===================== + console.log('\n========== 11. 交互测试:查询 =========='); + + if (queryBtn) { + await queryBtn.click(); + await page.waitForTimeout(3000); + + const rowsAfterQuery = await page.$$eval('.el-table__body tr', trs => trs.length); + log('交互', '查询后有数据', rowsAfterQuery > 0, `${rowsAfterQuery}行`); + } + + // ===================== + // 12. 交互测试:分页切换 + // // ===================== + console.log('\n========== 12. 交互测试:分页 =========='); + + const nextBtn = await page.$('.el-pagination .btn-next'); + if (nextBtn) { + const isEnabled = await nextBtn.isEnabled(); + if (isEnabled) { + await nextBtn.click(); + await page.waitForTimeout(2000); + const page2Rows = await page.$$eval('.el-table__body tr', trs => trs.length); + log('交互', '翻页后有数据', page2Rows > 0, `第2页${page2Rows}行`); + } else { + log('交互', '翻页', false, '下一页按钮不可用(可能只有1页)'); + } + } + + // ===================== + // 汇总 + // ===================== + console.log('\n========================================'); + const passed = results.filter(r => r.pass).length; + const failed = results.filter(r => !r.pass).length; + console.log(`总计: ${results.length}项, 通过: ${passed}, 失败: ${failed}`); + + if (failed > 0) { + console.log('\n失败项:'); + results.filter(r => !r.pass).forEach(r => console.log(` ❌ [${r.category}] ${r.name}: ${r.detail}`)); + } + + // 截图 + await page.screenshot({ path: 'test-screenshots/production-full-test.png', fullPage: true }); + console.log('\n截图已保存'); + + await browser.close(); +})(); diff --git a/test-production-page.js b/test-production-page.js new file mode 100644 index 0000000..60d31cd --- /dev/null +++ b/test-production-page.js @@ -0,0 +1,79 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + // 1. 登录 + console.log('1. 登录...'); + await page.goto('http://127.0.0.1/admin/'); + await page.waitForTimeout(1000); + + // 检查是否在登录页 + const url = page.url(); + console.log('当前URL:', url); + + if (url.includes('login')) { + await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin'); + await page.fill('input[type="password"]', 'admin123'); + await page.click('button:has-text("登录")'); + await page.waitForTimeout(2000); + console.log('登录后URL:', page.url()); + } + + // 2. 导航到产量报表 + console.log('\n2. 导航到产量报表...'); + await page.goto('http://127.0.0.1/admin/production'); + await page.waitForTimeout(3000); + console.log('产量报表URL:', page.url()); + + // 3. 截图 + await page.screenshot({ path: 'test-screenshots/production-page.png', fullPage: true }); + console.log('截图已保存'); + + // 4. 检查日期选择器的值 + const dateInputs = await page.$$eval('.el-date-editor input', els => els.map(e => e.value)); + console.log('\n3. 日期选择器值:', dateInputs); + + // 5. 检查表格内容 + const tableContent = await page.evaluate(() => { + const rows = document.querySelectorAll('.el-table__body tr'); + const result = []; + for (let i = 0; i < Math.min(rows.length, 5); i++) { + const cells = rows[i].querySelectorAll('td .cell'); + const row = []; + cells.forEach(c => row.push(c.textContent.trim())); + result.push(row); + } + return result; + }); + console.log('\n4. 表格内容(前5行):'); + tableContent.forEach((row, i) => console.log(` 行${i + 1}:`, JSON.stringify(row))); + + // 6. 检查表格列头 + const headers = await page.$$eval('.el-table__header th .cell', els => els.map(e => e.textContent.trim())); + console.log('\n5. 表格列头:', headers); + + // 7. 检查汇总卡片 + const summary = await page.evaluate(() => { + const cards = document.querySelectorAll('.summary-card, .el-card'); + const result = []; + cards.forEach(c => result.push(c.textContent.trim().substring(0, 100))); + return result; + }); + console.log('\n6. 汇总卡片:', summary.slice(0, 5)); + + // 8. 检查页面是否有错误 + const errors = await page.$$eval('.el-message--error, .el-notification', els => els.map(e => e.textContent.trim())); + if (errors.length > 0) { + console.log('\n错误信息:', errors); + } + + // 9. 检查console错误 + page.on('console', msg => { + if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text()); + }); + + await browser.close(); + console.log('\n完成'); +})(); diff --git a/test-production-v2.js b/test-production-v2.js new file mode 100644 index 0000000..2a0b11c --- /dev/null +++ b/test-production-v2.js @@ -0,0 +1,52 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // 登录 + console.log('1. 登录...'); + await page.goto('http://127.0.0.1/admin/login'); + await page.waitForTimeout(1000); + await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin'); + await page.fill('input[type="password"]', 'admin123'); + await page.click('button:has-text("登录")'); + await page.waitForTimeout(2000); + console.log('登录后URL:', page.url()); + + // 导航到产量报表 + console.log('\n2. 导航到产量报表...'); + await page.goto('http://127.0.0.1/admin/production'); + await page.waitForTimeout(3000); + + // 截图 + await page.screenshot({ path: 'test-screenshots/production-page-v2.png', fullPage: true }); + + // 检查日期 + const dateInputs = await page.$$eval('.el-date-editor input', els => els.map(e => e.value)); + console.log('日期选择器:', dateInputs); + + // 检查今天日期 + const today = new Date(); + console.log('JS Date today:', today.toISOString(), today.toLocaleDateString('zh-CN')); + + // 检查表格内容 + const rows = await page.$$eval('.el-table__body tr', trs => + trs.slice(0, 3).map(tr => { + const cells = tr.querySelectorAll('td .cell'); + return Array.from(cells).map(c => c.textContent.trim()); + }) + ); + console.log('\n表格前3行:'); + rows.forEach((row, i) => console.log(` 行${i+1}:`, JSON.stringify(row))); + + // 检查汇总 + const summaryText = await page.evaluate(() => { + const el = document.querySelector('.summary-card, .el-card'); + return el ? el.innerText.substring(0, 200) : '无汇总卡片'; + }); + console.log('\n汇总:', summaryText); + + await browser.close(); +})(); diff --git a/test-production-v3.js b/test-production-v3.js new file mode 100644 index 0000000..7814635 --- /dev/null +++ b/test-production-v3.js @@ -0,0 +1,45 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + // 收集console日志 + page.on('console', msg => { + if (msg.type() === 'error' || msg.type() === 'warning') { + console.log(`[${msg.type().toUpperCase()}]`, msg.text()); + } + }); + // 收集网络请求 + page.on('response', resp => { + if (resp.url().includes('/api/') && !resp.url().includes('login')) { + console.log(`[API] ${resp.status()} ${resp.url()}`); + } + }); + + // 登录 + await page.goto('http://127.0.0.1/admin/login'); + await page.waitForTimeout(500); + await page.fill('input[type="text"]', 'admin'); + await page.fill('input[type="password"]', 'admin123'); + await page.click('button:has-text("登录")'); + await page.waitForTimeout(2000); + + // 导航到产量报表 + console.log('\n--- 导航到产量报表 ---'); + await page.goto('http://127.0.0.1/admin/production'); + await page.waitForTimeout(5000); + + // 检查dateRange reactive值 + const dateValue = await page.evaluate(() => { + // 找到Vue实例 + const app = document.querySelector('#app'); + return { + inputValues: Array.from(document.querySelectorAll('.el-date-editor input')).map(e => e.value), + today: new Date().toISOString().split('T')[0] + }; + }); + console.log('\n日期值:', JSON.stringify(dateValue)); + + await browser.close(); +})(); diff --git a/tests/CncService.Tests/WindowsServiceCheckerTests.cs b/tests/CncService.Tests/WindowsServiceCheckerTests.cs new file mode 100644 index 0000000..e0bc7e4 --- /dev/null +++ b/tests/CncService.Tests/WindowsServiceCheckerTests.cs @@ -0,0 +1,27 @@ +using System; +using Xunit; +using CncService.Interface; +using CncService.Impl; + +namespace CncService.Tests +{ + public class WindowsServiceCheckerTests + { + [Fact] + public void GetServiceStatus_NotInstalled_ForUnknownService() + { + var checker = new WindowsServiceChecker(); + var status = checker.GetServiceStatus("DefinitelyNotExistService_UnitTest"); + Assert.Equal(ServiceStatusEnum.NotInstalled, status); + } + + [Fact] + public void TryStartService_NotInstalled_ReturnsNotInstalled() + { + var checker = new WindowsServiceChecker(); + var (ok, msg) = checker.TryStartService("DefinitelyNotExistService_UnitTest", 5); + Assert.False(ok); + Assert.Contains("NotInstalled", msg); + } + } +}