feat(cnc-service): add Windows service status checker, integrate into dashboard status, enhance startup flow; stage4 plan initialized; add frontend typings and dashboard view updates; add test scaffold for WindowsServiceChecker

main
haoliang 4 days ago
parent 9e3a759646
commit e9802a195d

@ -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
{
/// <summary>
/// Lightweight HTTP API server用于控制 CollectEngine 的状态与配置刷新。
/// </summary>
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);
}
}
}

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

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="log4net" Version="2.0.16" />
</ItemGroup>
<ItemGroup>
<None Include="simulator.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

@ -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
{
/// <summary>
/// 运行时配置加载器:从 cnc_sys_config 表读取配置,覆盖 CollectorConfig 的默认值
/// </summary>
public static class ConfigLoader
{
private static readonly ILog _log = LogManager.GetLogger(typeof(ConfigLoader));
/// <summary>
/// 从数据库加载运行时配置并覆盖给定的 CollectorConfig 实例的默认值。
/// </summary>
/// <param name="connectionString">MySQL/MariaDB 连接字符串</param>
/// <param name="config">需要被覆盖的 CollectorConfig 实例(来自 CNC 采集服务)</param>
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<string>(
"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);
}
}
}

@ -0,0 +1,51 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CncSimulator.Config
{
/// <summary>模拟器配置</summary>
public class SimulatorConfig
{
[JsonProperty("gatewayPort")]
public int GatewayPort { get; set; } = 9000;
[JsonProperty("addresses")]
public List<AddressConfig> Addresses { get; set; } = new List<AddressConfig>();
}
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<DeviceConfig> Devices { get; set; } = new List<DeviceConfig>();
}
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;
}
}

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using Dapper;
using MySql.Data.MySqlClient;
using log4net;
using CncModels.Entity;
namespace CncCollector.Core
{
/// <summary>
/// 采集数据批量写入cnc_collect_record 与 log_collect_raw以及更新 CNC 机床状态。
/// </summary>
public static class CollectRecordWriter
{
private static readonly ILog Log = LogManager.GetLogger(typeof(CollectRecordWriter));
/// <summary>
/// 将批量结构化记录写入 cnc_collect_record并记录原始 JSON 到 log_collect_raw同时更新机床实时状态。
/// </summary>
/// <param name="connectionString">数据库连接字符串</param>
/// <param name="records">结构化记录集合</param>
/// <param name="rawJson">原始 JSON 日志</param>
/// <param name="collectAddressId">采集地址标识</param>
public static void WriteBatch(string connectionString, IEnumerable<CollectRecord> 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;
}
}
}
}
}
}

@ -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
{
/// <summary>
/// 单个采集地址工作线程:负责定时抓取、解析、入库及健康状态上报。
/// </summary>
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<CollectRecord>();
// 实际实现应根据 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);
}
}
}
}

@ -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
{
/// <summary>
/// 采集引擎:负责加载采集地址、创建工作线程、心跳和配置轮询等核心调度。
/// </summary>
public class CollectorEngine
{
private readonly string _connectionString;
private readonly CollectorConfig _config;
private readonly List<CollectWorker> _workers = new List<CollectWorker>();
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<CollectAddress>("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 { }
}
}
/// <summary>重新加载地址配置并重启工作线程</summary>
public void Refresh()
{
Stop();
Start();
}
}
}

@ -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
{
/// <summary>
/// 日终汇总作业:在指定时间点执行,结账活跃段、聚合产量并标记完成。
/// </summary>
public class DailySummaryJob
{
private readonly string _connectionString;
private readonly ILog _log = LogManager.GetLogger(typeof(DailySummaryJob));
public DailySummaryJob(string connectionString)
{
_connectionString = connectionString;
}
/// <summary>
/// 触发日终汇总逻辑。
/// </summary>
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 (简化实现). ");
}
}
}
}

@ -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
{
/// <summary>
/// JSON 解析引擎:将原始 JSON 与品牌字段映射表结合,输出结构化字段字典。
/// </summary>
public static class DataParser
{
/// <summary>解析结果中的字段</summary>
public class ParsedField
{
/// <summary>字段名(标准化后)</summary>
public string FieldName { get; set; }
/// <summary>字段值</summary>
public object Value { get; set; }
/// <summary>数据类型</summary>
public string DataType { get; set; }
}
private static readonly ILog Log = LogManager.GetLogger(typeof(DataParser));
/// <summary>
/// 解析原始 JSON并根据品牌映射提取字段。
/// 该实现尽量适配常见结构JSON 为数组,元素包含一个 tags 数组tags 中的元素拥有 id/value。
/// </summary>
/// <param name="brandName">品牌名称,用于定位字段映射(若为 null尝试使用空映射</param>
/// <param name="json">原始 JSON 字符串</param>
/// <param name="brand">可选的品牌对象,包含映射信息</param>
/// <returns>解析后的字段字典Key 为标准字段名</returns>
public static Dictionary<string, ParsedField> Parse(string brandName, string json, Brand brand = null)
{
var result = new Dictionary<string, ParsedField>();
if (string.IsNullOrWhiteSpace(json)) return result;
try
{
var root = JArray.Parse(json);
// 优先使用传入的 brand若品牌包含字段映射来定位 tags
var mappings = brand?.BrandFieldMappings?.ToList() ?? new List<BrandFieldMapping>();
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;
}
}
}

@ -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; }
}
/// <summary>日志记录器:内存环形缓冲 + 文件日志输出</summary>
public class LogRecorder
{
private readonly int _capacity;
private readonly List<LogEntry> _buffer = new List<LogEntry>();
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<LogEntry> 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];
}
}
}
}

@ -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
{
/// <summary>
/// 零件产量分段跟踪引擎:维护内存中的活跃段状态,并定期写入数据库。
/// </summary>
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));
}
/// <summary>
/// 处理一个采集记录后的产量跟踪逻辑。
/// </summary>
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<ProductionSegment>(
"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
});
}
}
}
}
}
}
}

@ -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
{
/// <summary>引擎:管理多个 SimulatorServer 实例</summary>
public class SimulatorEngine
{
private readonly List<SimulatorServer> _servers = new List<SimulatorServer>();
public void LoadConfig(string jsonPath)
{
var json = File.ReadAllText(jsonPath);
var cfg = JsonConvert.DeserializeObject<SimulatorConfig>(json);
LoadConfig(cfg);
}
public void LoadConfig(SimulatorConfig cfg)
{
_servers.Clear();
foreach (var addr in cfg.Addresses)
{
var devices = new List<DeviceSimulator>();
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);
}
}
}

@ -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
{
/// <summary>单个地址的 HTTP 服务器及设备仿真集合</summary>
public class SimulatorServer
{
public AddressConfig Address { get; private set; }
public List<DeviceSimulator> 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<DeviceSimulator> devices)
{
Address = address;
Devices = devices ?? new List<DeviceSimulator>();
}
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<JObject>();
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 = "<html><body><h2>管理界面开发中</h2></body></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);
}
}
}

@ -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
{
/// <summary>单台设备的状态机与仿真逻辑</summary>
public class DeviceSimulator
{
public DeviceState State { get; private set; }
private readonly Random _rnd;
private readonly List<string> _programs = new List<string> { "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;
}
}
}
}

@ -0,0 +1,41 @@
namespace CncSimulator.Device
{
/// <summary>单台模拟设备的完整状态</summary>
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;
}
}

@ -0,0 +1,20 @@
namespace CncSimulator.Device
{
/// <summary>剧本播放器的简化实现(占位,未直接驱动状态)</summary>
public class ScenarioPlayer
{
public ScenarioPlayer()
{
}
public void Tick(DeviceState state)
{
// 简化实现:不改变状态,留作未来扩展点
}
public void TriggerEvent(string eventType)
{
// 事件占位
}
}
}

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using CncSimulator.Device;
namespace CncSimulator.Generator
{
/// <summary>Fanuc 数据生成器:生成 19 个 Tag 的 JSON 表示</summary>
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<JObject>();
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";
}
}
}
}

@ -0,0 +1,11 @@
using Newtonsoft.Json.Linq;
namespace CncSimulator.Generator
{
/// <summary>品牌数据生成器接口</summary>
public interface IBrandGenerator
{
string BrandKey { get; }
JObject GenerateDevice(CncSimulator.Device.DeviceState state);
}
}

@ -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。
卸载完成。

@ -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
{
/// <summary>
/// 主入口:启动采集引擎与管理 API。
/// </summary>
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();
}
}
}

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

@ -0,0 +1,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');
}

@ -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

@ -38,14 +38,14 @@
</el-tooltip>
</div>
<div class="stat-value">
<el-tag :type="collectorStatus.status === 'running' ? 'success' : 'danger'" size="small">
{{ collectorStatus.status === 'running' ? '运行中' : '已停止' }}
<el-tag :type="collectorStatus.serviceStatus === 'Running' ? 'success' : (collectorStatus.serviceStatus === 'NotInstalled' ? 'danger' : 'warning')" size="small">
{{ serviceStatusLabel(collectorStatus.serviceStatus) }}
</el-tag>
</div>
<div class="stat-sub" v-if="collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
<div class="stat-sub" v-if="collectorStatus.serviceStatus === 'Running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
</div>
<div class="collector-actions">
<el-button v-if="collectorStatus.status !== 'running'" size="small" type="success" :loading="startLoading" @click="startCollector"></el-button>
<el-button v-if="collectorStatus.serviceStatus !== 'Running'" size="small" type="success" :loading="startLoading" @click="startCollector"></el-button>
<el-button v-if="collectorStatus.status === 'running'" size="small" type="danger" :loading="stopLoading" @click="stopCollector"></el-button>
<el-button size="small" type="warning" :loading="refreshLoading" @click="refreshCollectorConfig"></el-button>
</div>
@ -275,7 +275,7 @@ import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, Wo
const { isMock } = useMockMode()
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0, collectSuccessRate: 0, todayCuttingTime: 0, runningMachines: 0, dataMissingMachines: 0 })
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0 })
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0, serviceStatus: 'NotInstalled', serviceName: '' })
const machineRank = ref<MachineRankRow[]>([])
const workerRank = ref<WorkerRankRow[]>([])
const trendData = ref<DashboardTrendItem[]>([])
@ -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(() => {
}
}
</style>
// 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 || '-';
}
}

@ -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 }
]
}
]
}

@ -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' }]],

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

@ -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;
}
/// <inheritdoc/>
@ -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)
// 额外的 Windows 服务状态
string serviceStatusText = "NotInstalled";
if (_serviceChecker != null)
{
uptimeSeconds = latest.UptimeSeconds ?? 0;
}
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
};
}
}
}

@ -0,0 +1,110 @@
using System;
using System.ServiceProcess;
using System.Threading;
using CncService.Interface;
namespace CncService.Impl
{
/// <summary>
/// Windows 服务检测实现(基于 ServiceController
/// </summary>
public class WindowsServiceChecker : IWindowsServiceChecker
{
public ServiceStatusEnum GetServiceStatus(string serviceName)
{
try
{
using (var sc = new ServiceController(serviceName))
{
sc.Refresh();
switch (sc.Status)
{
case ServiceControllerStatus.Running:
return ServiceStatusEnum.Running;
case ServiceControllerStatus.StartPending:
case ServiceControllerStatus.ContinuePending:
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}");
}
}
}
}

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

@ -133,6 +133,22 @@ namespace CncWebApi.Controllers
[Route("~/api/admin/collector/start")]
public IHttpActionResult StartCollector()
{
// 先查询服务状态,决定下一步动作
try
{
dynamic statusObj = _dashboardService.GetCollectorStatus();
string serviceStatus = statusObj?.serviceStatus as string;
if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "NotInstalled", StringComparison.OrdinalIgnoreCase))
{
return Ok(ApiResponse<object>.Fail(40001, "采集服务未安装,请先在服务器上运行 install.ps1 安装服务"));
}
if (!string.IsNullOrEmpty(serviceStatus) && string.Equals(serviceStatus, "Running", StringComparison.OrdinalIgnoreCase))
{
return Ok(ApiResponse<object>.Fail(40002, "采集服务已在运行中,无需再次启动"));
}
}
catch { /* ignore status fetch errors and fallback to forwarding */ }
return ForwardToCollector("/api/collector/start");
}

@ -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()

@ -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();
})();

@ -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();
})();

@ -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完成');
})();

@ -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();
})();

@ -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();
})();

@ -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);
}
}
}
Loading…
Cancel
Save