Compare commits
2 Commits
9e3a759646
...
d8f59250d7
| Author | SHA1 | Date |
|---|---|---|
|
|
d8f59250d7 | 4 days ago |
|
|
e9802a195d | 4 days ago |
@ -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,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 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,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,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,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');
|
||||
}
|
||||
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
namespace CncService.Interface
|
||||
{
|
||||
// Windows 服务状态枚举,用于和心跳状态区分不同场景
|
||||
public enum ServiceStatusEnum
|
||||
{
|
||||
NotInstalled,
|
||||
Stopped,
|
||||
Running,
|
||||
Starting,
|
||||
StartFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows 服务检测接口(用于管理后台对采集服务的状态检测与控制)
|
||||
/// </summary>
|
||||
public interface IWindowsServiceChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定服务的当前状态
|
||||
/// </summary>
|
||||
/// <param name="serviceName">服务名</param>
|
||||
/// <returns>服务状态枚举</returns>
|
||||
ServiceStatusEnum GetServiceStatus(string serviceName);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试启动指定服务,并在给定超时内等待就绪
|
||||
/// </summary>
|
||||
/// <param name="serviceName">服务名</param>
|
||||
/// <param name="timeoutSeconds">超时(秒)</param>
|
||||
/// <returns>(是否成功, 详细信息)</returns>
|
||||
(bool Success, string Message) TryStartService(string serviceName, int timeoutSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试停止指定服务,并在给定超时内等待停止
|
||||
/// </summary>
|
||||
/// <param name="serviceName">服务名</param>
|
||||
/// <param name="timeoutSeconds">超时(秒)</param>
|
||||
/// <returns>(是否成功, 详细信息)</returns>
|
||||
(bool Success, string Message) TryStopService(string serviceName, int timeoutSeconds);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,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();
|
||||
})();
|
||||
@ -1,26 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RootNamespace>CncService.Tests</RootNamespace>
|
||||
<AssemblyName>CncService.Tests</AssemblyName>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<IsPackable>false</IsPackable>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies.net472" Version="1.0.3" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" Version="2.8.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CncModels\CncModels.csproj" />
|
||||
<ProjectReference Include="..\..\src\CncRepository\CncRepository.csproj" />
|
||||
<ProjectReference Include="..\..\src\CncService\CncService.csproj" />
|
||||
<ProjectReference Include="../../src/CncService/CncService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using CncService.Interface;
|
||||
using CncService.Impl;
|
||||
|
||||
namespace CncService.Tests
|
||||
{
|
||||
public class WindowsServiceCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetServiceStatus_NotInstalled_ForUnknownService()
|
||||
{
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) return;
|
||||
var checker = new WindowsServiceChecker();
|
||||
var status = checker.GetServiceStatus("DefinitelyNotExistService_UnitTest");
|
||||
Assert.Equal(ServiceStatusEnum.NotInstalled, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryStartService_NotInstalled_ReturnsNotInstalled()
|
||||
{
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) return;
|
||||
var checker = new WindowsServiceChecker();
|
||||
var (ok, msg) = checker.TryStartService("DefinitelyNotExistService_UnitTest", 5);
|
||||
Assert.False(ok);
|
||||
Assert.Contains("NotInstalled", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue