Initial commit of Hanvon F710X Attendance Service

main
SYED MUSTUFA AHMED NAQVI 2026-07-18 11:17:50 +05:00
commit 6867cb90e7
45 changed files with 13060 additions and 0 deletions

363
.gitignore vendored Normal file
View File

@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

50
App.config Normal file
View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<connectionStrings>
<add name="HrmsDb"
connectionString="SERVER=192.168.90.147;PORT=3306;DATABASE=hrms;UID=utopia;PASSWORD=Utopia01;Connection Timeout=60;"
providerName="MySql.Data.MySqlClient" />
</connectionStrings>
<appSettings>
<add key="ATTENDANCE_SCOPE" value="CENTRAL" />
<!--<add key="ATTENDANCE_SCOPE" value="SITE" />-->
<add key="ATTENDANCE_JOB_INTERVAL_MINUTES" value="20" />
<add key="TEMPLATE_JOB_INTERVAL_MINUTES" value="240" />
<add key="TEMPLATE_SYNC_ENABLED" value="true" />
<add key="TRANSFER_MODE" value="DEVICE_TO_DB" />
<add key="DEVICE_SETTINGS_PATH" value="DeviceSettings.xml" />
<add key="TEMPLATE_VERIFY_AFTER_PUSH" value="true" />
<add key="TEMPLATE_DEVICE_DELAY_MS" value="0" />
<add key="TEMPLATE_AUTO_DISTRIBUTE_AFTER_DEVICE_TO_DB" value="false" />
<!-- Only machines with machine_type = HANVON are processed by this service -->
<add key="MACHINE_TYPES" value="HANVON" />
<!-- F710X uses HTTP web API (port 80). Set HDCP only for classic FaceID devices on 9922. -->
<add key="DEVICE_PROTOCOL" value="HTTP" />
<add key="DEVICE_HTTP_PORT" value="80" />
<add key="DEVICE_FACE_BACKUPNUM" value="50" />
<!-- Hanvon HDCP device type string used in DeviceInfo(...) when DEVICE_PROTOCOL=HDCP -->
<add key="DEVICE_TYPE" value="HW_HDCP" />
<!-- Device web UI / SDK auth (F710X) -->
<add key="DEVICE_USERNAME" value="admin" />
<add key="DEVICE_PASSWORD" value="12345678" />
<!-- Remove attendance from device only after successful DB batch insert -->
<add key="ATTENDANCE_DELETE_AFTER_INSERT" value="true" />
<add key="DB_CONNECTION_RETRY_ATTEMPTS" value="3" />
<add key="DB_CONNECTION_RETRY_DELAY_MS" value="2000" />
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.1.1" newVersion="4.0.1.1" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

41
Attendance.cs Normal file
View File

@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
class Attendance
{
private string acNo;
private DateTime checkTime;
private bool isProcessed;
private string machineId;
private string inOutTypeId;
private string machineIp;
private DateTime date;
public Attendance() {
}
public Attendance(string acNo, DateTime checkTime, bool isProcessed, string machineId, string inOutTypeId, string machineIp, DateTime date) {
this.AcNo = acNo;
this.CheckTime = checkTime;
this.IsProcessed = isProcessed;
this.MachineId = machineId;
this.InOutTypeId = inOutTypeId;
this.MachineIp = machineIp;
this.Date = date;
}
public string AcNo { get => acNo; set => acNo = value; }
public DateTime CheckTime { get => checkTime; set => checkTime = value; }
public bool IsProcessed { get => isProcessed; set => isProcessed = value; }
public string MachineId { get => machineId; set => machineId = value; }
public string InOutTypeId { get => inOutTypeId; set => inOutTypeId = value; }
public string MachineIp { get => machineIp; set => machineIp = value; }
public DateTime Date { get => date; set => date = value; }
}
}

31
AttendanceDAO.cs Normal file
View File

@ -0,0 +1,31 @@
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
class AttendanceDAO
{
public AttendanceDAO() {
}
public int Add(Attendance attendance, MySqlConnection connection)
{
string query = "REPLACE INTO `hrms`.`attendance_log` (`ac_no`, `checktime`, `processed`, `machine_id`, `in_out_type_id`, `machine_ip`, `date`) VALUES (@AcNo, @CHECKTIME, @isProcessed, @MachineId, @InOutTypeId, @MachineIP, @Getdate)";
MySqlCommand cmd = new MySqlCommand(query, connection);
cmd.Parameters.AddWithValue("@AcNo", attendance.AcNo);
cmd.Parameters.AddWithValue("@CHECKTIME", attendance.CheckTime);
cmd.Parameters.AddWithValue("@isProcessed", attendance.IsProcessed);
cmd.Parameters.AddWithValue("@machineId", attendance.MachineId);
cmd.Parameters.AddWithValue("@InOutTypeId", attendance.InOutTypeId);
cmd.Parameters.AddWithValue("@MachineIP", attendance.MachineIp);
cmd.Parameters.AddWithValue("@Getdate", attendance.Date);
return cmd.ExecuteNonQuery();
}
}
}

76
AttendanceMachine.cs Normal file
View File

@ -0,0 +1,76 @@
using System;
using System.Linq;
namespace HanvonF710XAttendanceService
{
class AttendanceMachine
{
private string machineId;
private string machineIp;
private int portNumber;
private string machineName;
private int siteId;
private string status;
private DateTime lastSyncDate;
public AttendanceMachine()
{
}
public AttendanceMachine(string machineId, string machineIp, int portNumber, string machineName, int siteId, string status, DateTime lastSyncDate)
{
this.MachineId = machineId;
this.MachineIp = machineIp;
this.PortNumber = portNumber;
this.MachineName = machineName;
this.SiteId = siteId;
this.Status = status;
this.LastSyncDate = lastSyncDate;
}
public string MachineId { get => machineId; set => machineId = value; }
public string MachineIp
{
get => machineIp;
set
{
// Normalize DB/config IP values that sometimes contain whitespace (e.g. "192.168. 0.50").
string ip = value ?? "";
if (ip.Length > 0)
{
ip = new string(ip.Trim().Where(c => !char.IsWhiteSpace(c)).ToArray());
}
machineIp = ip;
}
}
public int PortNumber { get => portNumber; set => portNumber = value; }
public string MachineName { get => machineName; set => machineName = value; }
public int SiteId { get => siteId; set => siteId = value; }
public string Status { get => status; set => status = value; }
public DateTime LastSyncDate { get => lastSyncDate; set => lastSyncDate = value; }
public string GetDeviceInfo()
{
// HDCP SDK DeviceInfo uses password for device auth (same credentials as device web UI).
string password = MachineScope.GetDevicePassword() ?? "";
return "DeviceInfo( dev_id = \"1\" dev_type = \"" + MachineScope.GetDeviceType()
+ "\" comm_type = \"ip\" ip_address = \"" + this.machineIp
+ "\", password = \"" + EscapeDeviceInfoValue(password)
+ "\", port_number = \"" + this.portNumber + "\")";
}
/// <summary>Safe for InternalLogs — password redacted.</summary>
public string GetDeviceInfoForLog()
{
return "DeviceInfo( dev_id = \"1\" dev_type = \"" + MachineScope.GetDeviceType()
+ "\" comm_type = \"ip\" ip_address = \"" + this.machineIp
+ "\", password = \"***\", port_number = \"" + this.portNumber + "\")";
}
private static string EscapeDeviceInfoValue(string value)
{
if (string.IsNullOrEmpty(value)) return "";
return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}
}

196
AttendanceMachineDAO.cs Normal file
View File

@ -0,0 +1,196 @@
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
class AttendanceMachineDAO
{
public void update(AttendanceMachine attendanceMachine, MySqlConnection connection) {
string query = "UPDATE `hrms`.`attendance_machine` SET `machine_ip` = @machine_ip, `port_number` = @port_number, `machine_name` = @machine_name, `site_id` = @site_id, `status` = @status, `last_sync_date` = @last_sync_date WHERE `machine_id` = @machine_id";
MySqlCommand cmd = new MySqlCommand(query, connection);
cmd.Parameters.AddWithValue("@machine_ip", attendanceMachine.MachineIp);
cmd.Parameters.AddWithValue("@port_number", attendanceMachine.PortNumber);
cmd.Parameters.AddWithValue("@machine_name", attendanceMachine.MachineName);
cmd.Parameters.AddWithValue("@site_id", attendanceMachine.SiteId);
cmd.Parameters.AddWithValue("@status", attendanceMachine.Status);
cmd.Parameters.AddWithValue("@last_sync_date", attendanceMachine.LastSyncDate);
cmd.Parameters.AddWithValue("@machine_id", attendanceMachine.MachineId);
cmd.ExecuteNonQuery();
}
public List<AttendanceMachine> getAttendanceMachines(MySqlConnection connection, String siteId) {
List<AttendanceMachine> attendanceMachines = null;
using (var cmd = new MySqlCommand())
{
cmd.Connection = connection;
cmd.CommandTimeout = 120;
string typeClause = MachineScope.BuildMachineTypeWhereClause(cmd);
cmd.CommandText = "SELECT * FROM hrms.attendance_machine WHERE site_id = @siteId AND " + typeClause;
cmd.Parameters.AddWithValue("@siteId", siteId);
using (var reader = cmd.ExecuteReader())
{
if (reader != null)
{
attendanceMachines = getAttendanceMachineList(reader);
}
}
}
return attendanceMachines ?? new List<AttendanceMachine>();
}
public List<AttendanceMachine> getAllAttendanceMachines(MySqlConnection connection)
{
List<AttendanceMachine> attendanceMachines = null;
using (var cmd = new MySqlCommand())
{
cmd.Connection = connection;
cmd.CommandTimeout = 120;
string typeClause = MachineScope.BuildMachineTypeWhereClause(cmd);
cmd.CommandText =
"SELECT machine_id, machine_ip, port_number, machine_name, site_id, status, last_sync_date " +
"FROM hrms.attendance_machine WHERE " + typeClause;
using (var reader = cmd.ExecuteReader())
{
if (reader != null)
{
attendanceMachines = getAttendanceMachineList(reader);
}
}
}
return attendanceMachines ?? new List<AttendanceMachine>();
}
/// <summary>Normalize IP for comparison (trim + remove spaces) so "192.168. 0.50" matches "192.168.0.50".</summary>
private static string NormalizeIp(string ip)
{
if (string.IsNullOrWhiteSpace(ip)) return "";
return new string(ip.Trim().Where(c => !char.IsWhiteSpace(c)).ToArray());
}
public bool TryGetAttendanceMachineByIp(MySqlConnection connection, string machineIp, out AttendanceMachine machine, out string machineType)
{
machine = null;
machineType = null;
if (string.IsNullOrWhiteSpace(machineIp)) return false;
string normalizedIp = NormalizeIp(machineIp);
using (var cmd = new MySqlCommand())
{
cmd.Connection = connection;
cmd.CommandTimeout = 60;
string typeClause = MachineScope.BuildMachineTypeWhereClause(cmd, "machine_type", "mtype");
cmd.CommandText =
"SELECT machine_id, machine_ip, port_number, machine_name, site_id, status, last_sync_date, machine_type " +
"FROM hrms.attendance_machine WHERE machine_ip = @machine_ip AND " + typeClause + " LIMIT 1";
cmd.Parameters.AddWithValue("@machine_ip", machineIp);
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
FillMachineFromReader(reader, out machine, out machineType);
return true;
}
}
}
using (var cmd = new MySqlCommand())
{
cmd.Connection = connection;
cmd.CommandTimeout = 120;
string typeClause = MachineScope.BuildMachineTypeWhereClause(cmd, "machine_type", "mtype2");
cmd.CommandText =
"SELECT machine_id, machine_ip, port_number, machine_name, site_id, status, last_sync_date, machine_type " +
"FROM hrms.attendance_machine WHERE " + typeClause;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string dbIp = reader.IsDBNull(1) ? "" : reader.GetString("machine_ip");
if (NormalizeIp(dbIp) == normalizedIp)
{
FillMachineFromReader(reader, out machine, out machineType);
return true;
}
}
}
}
return false;
}
private static void FillMachineFromReader(MySqlDataReader reader, out AttendanceMachine machine, out string machineType)
{
machine = new AttendanceMachine(
reader.IsDBNull(0) ? "" : reader.GetString("machine_id"),
reader.IsDBNull(1) ? "" : reader.GetString("machine_ip"),
reader.IsDBNull(2) ? default(int) : reader.GetInt16("port_number"),
reader.IsDBNull(3) ? "" : reader.GetString("machine_name"),
reader.IsDBNull(4) ? default(int) : reader.GetInt16("site_id"),
reader.IsDBNull(5) ? "" : reader.GetString("status"),
reader.IsDBNull(6) ? default(DateTime) : reader.GetDateTime("last_sync_date")
);
machineType = reader.IsDBNull(7) ? null : reader.GetString("machine_type");
}
private List<AttendanceMachine> getAttendanceMachineList(MySqlDataReader reader) {
List<AttendanceMachine> attendanceMachines = new List<AttendanceMachine>();
while (reader.Read()) {
attendanceMachines.Add(toAttendanceMachine(reader));
}
return attendanceMachines;
}
private AttendanceMachine toAttendanceMachine(MySqlDataReader reader) {
return new AttendanceMachine(
reader.IsDBNull(0) ? "" : reader.GetString("machine_id"),
reader.IsDBNull(1) ? "" : reader.GetString("machine_ip"),
reader.IsDBNull(2) ? default(int) : reader.GetInt16("port_number"),
reader.IsDBNull(3) ? "" : reader.GetString("machine_name"),
reader.IsDBNull(4) ? default(int) : reader.GetInt16("site_id"),
reader.IsDBNull(5) ? "" : reader.GetString("status"),
reader.IsDBNull(6) ? default(DateTime) : reader.GetDateTime("last_sync_date")
) ;
}
public void UpdateTotalEmpInMachines(string machine_id, int total_users,MySqlConnection connection)
{
string query = "UPDATE `hrms`.`attendance_machine` SET `total_users` ="+ total_users + " WHERE `machine_id` = '"+ machine_id+ "'";
MySqlCommand cmd = new MySqlCommand(query, connection);
cmd.ExecuteNonQuery();
}
public string CheckEmpInDB(string machine_id, int emp_cd, MySqlConnection connection)
{
string result = "FALSE";
string query = "Select * from attendance_machine_user a where a.machine_id='"+ machine_id + "' and a.serial_number = "+ emp_cd + "";
MySqlCommand cmd = new MySqlCommand(query, connection);
DataTable dt = new DataTable();
MySqlDataAdapter MySqlAdt = new MySqlDataAdapter();
MySqlAdt.SelectCommand = cmd;
MySqlAdt.Fill(dt);
if(dt.Rows.Count > 0)
{
result = "TRUE";
}
else
{
result = "FALSE";
}
return result;
}
}
}

View File

@ -0,0 +1,357 @@
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Linq;
namespace HanvonF710XAttendanceService
{
internal sealed class AttendanceMachineFaceTemplateDAO
{
public bool ExistsBySerialNo(MySqlConnection connection, string serialNo)
{
const string checkQuery = "SELECT COUNT(*) FROM attendance_machine_face_templates WHERE serial_no = @SerialNumber";
using (var cmd = new MySqlCommand(checkQuery, connection))
{
cmd.Parameters.AddWithValue("@SerialNumber", serialNo);
object result = cmd.ExecuteScalar();
if (result == null || result == DBNull.Value)
{
return false;
}
if (int.TryParse(result.ToString(), out int count))
{
return count > 0;
}
return false;
}
}
public int InsertTemplate(MySqlConnection connection, string serialNo, byte[] templateBytes, DateTime createdDate)
{
const string insertQuery =
"INSERT INTO attendance_machine_face_templates (serial_no, template, created_date) " +
"VALUES (@SerialNumber, @FaceTemplate, @created_date)";
using (var cmd = new MySqlCommand(insertQuery, connection))
{
cmd.Parameters.AddWithValue("@SerialNumber", serialNo);
cmd.Parameters.Add("@FaceTemplate", MySqlDbType.Blob).Value = templateBytes ?? Array.Empty<byte>();
cmd.Parameters.AddWithValue("@created_date", createdDate);
return cmd.ExecuteNonQuery();
}
}
public Dictionary<string, byte[]> GetActiveTemplatesBySerialNos(MySqlConnection connection, IReadOnlyList<string> serialNos)
{
var result = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
if (serialNos == null || serialNos.Count == 0)
{
return result;
}
// Chunk to keep query size reasonable.
const int chunkSize = 200;
for (int offset = 0; offset < serialNos.Count; offset += chunkSize)
{
var chunk = serialNos.Skip(offset).Take(chunkSize).ToArray();
var parameterNames = new List<string>(chunk.Length);
for (int i = 0; i < chunk.Length; i++)
{
parameterNames.Add("@SerialNumber" + i);
}
string inClause = string.Join(",", parameterNames);
string query =
$"SELECT serial_no, template FROM attendance_machine_face_templates " +
$"WHERE serial_no IN ({inClause}) AND is_active = 1";
using (var cmd = new MySqlCommand(query, connection))
{
for (int i = 0; i < chunk.Length; i++)
{
cmd.Parameters.AddWithValue(parameterNames[i], chunk[i]);
}
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var serial = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(serial))
{
continue;
}
byte[] blob = null;
if (!reader.IsDBNull(1))
{
blob = (byte[])reader["template"];
}
result[serial] = blob ?? Array.Empty<byte>();
}
}
}
}
return result;
}
/// <summary>All active template serial numbers (CENTRAL hub scope).</summary>
public List<string> GetActiveSerialNumbersAll(MySqlConnection connection)
{
var list = new List<string>();
const string query = "SELECT serial_no FROM hrms.attendance_machine_face_templates WHERE is_active = 1";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 120;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (reader.IsDBNull(0)) continue;
var s = reader.GetString(0);
if (!string.IsNullOrWhiteSpace(s))
{
list.Add(s.Trim());
}
}
}
}
return list;
}
/// <summary>
/// Serial numbers that have an active template and appear on scoped machines (SITE deployment).
/// </summary>
public List<string> GetActiveSerialNumbersForScopedMachines(MySqlConnection connection, IReadOnlyList<string> machineIds)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (machineIds == null || machineIds.Count == 0)
{
return new List<string>();
}
const int chunkSize = 80;
for (int offset = 0; offset < machineIds.Count; offset += chunkSize)
{
var chunk = machineIds.Skip(offset).Take(chunkSize).ToArray();
var parameterNames = new List<string>(chunk.Length);
for (int i = 0; i < chunk.Length; i++)
{
parameterNames.Add("@Mid" + i);
}
string inClause = string.Join(",", parameterNames);
string query =
"SELECT DISTINCT t.serial_no " +
"FROM hrms.attendance_machine_face_templates t " +
"INNER JOIN hrms.attendance_machine_user u ON u.serial_number = t.serial_no AND u.is_deleted = 0 " +
"WHERE t.is_active = 1 AND u.machine_id IN (" + inClause + ")";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 120;
for (int i = 0; i < chunk.Length; i++)
{
cmd.Parameters.AddWithValue(parameterNames[i], chunk[i]);
}
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (reader.IsDBNull(0)) continue;
var s = reader.GetString(0);
if (!string.IsNullOrWhiteSpace(s))
{
result.Add(s.Trim());
}
}
}
}
}
return result.ToList();
}
/// <summary>
/// Active templates for employees registered on a specific Nedo machine.
/// Registration-based target selection: attendance_machine_user (is_deleted=0) drives which employees are allowed.
/// </summary>
public Dictionary<string, byte[]> GetActiveTemplatesForRegisteredMachine(MySqlConnection connection, string machineId)
{
var result = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(machineId))
{
return result;
}
const string query =
"SELECT t.serial_no, t.template " +
"FROM hrms.attendance_machine_face_templates t " +
"INNER JOIN hrms.attendance_machine_user u ON u.serial_number = t.serial_no " +
"WHERE t.is_active = 1 AND u.is_deleted = 0 AND u.machine_id = @MachineId";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 120;
cmd.Parameters.AddWithValue("@MachineId", machineId);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (reader.IsDBNull(0)) continue;
var serial = reader.GetString(0);
if (string.IsNullOrWhiteSpace(serial)) continue;
byte[] blob = Array.Empty<byte>();
if (!reader.IsDBNull(1))
{
blob = (byte[])reader["template"];
}
result[serial.Trim()] = blob;
}
}
}
return result;
}
/// <summary>
/// For each serial_no, return machine_ids where the employee is registered (attendance_machine_user),
/// restricted to the allowedMachineIds list (scope) and to Nedo machines (exclude ZKTECO by machine_type).
/// </summary>
public Dictionary<string, List<string>> GetRegisteredTargetMachineIdsForSerialNos(
MySqlConnection connection,
IReadOnlyList<string> serialNos,
IReadOnlyList<string> allowedMachineIds)
{
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
if (serialNos == null || serialNos.Count == 0)
{
return result;
}
var allowedSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (allowedMachineIds != null)
{
foreach (var id in allowedMachineIds)
{
if (!string.IsNullOrWhiteSpace(id))
{
allowedSet.Add(id.Trim());
}
}
}
if (allowedSet.Count == 0)
{
return result;
}
const int serialChunkSize = 200;
var allowedList = allowedSet.ToList();
// Keep allowed-machine IN clause reasonably small.
const int allowedChunkSize = 100;
for (int offset = 0; offset < serialNos.Count; offset += serialChunkSize)
{
var serialChunk = serialNos.Skip(offset).Take(serialChunkSize).ToArray();
var serialParamNames = new List<string>(serialChunk.Length);
for (int i = 0; i < serialChunk.Length; i++)
{
serialParamNames.Add("@S" + i);
}
string serialInClause = string.Join(",", serialParamNames);
for (int ao = 0; ao < allowedList.Count; ao += allowedChunkSize)
{
var allowedChunk = allowedList.Skip(ao).Take(allowedChunkSize).ToArray();
var allowedParamNames = new List<string>(allowedChunk.Length);
for (int i = 0; i < allowedChunk.Length; i++)
{
allowedParamNames.Add("@A" + i);
}
string allowedInClause = string.Join(",", allowedParamNames);
const string query =
"SELECT u.serial_number, u.machine_id " +
"FROM hrms.attendance_machine_user u " +
"INNER JOIN hrms.attendance_machine m ON m.machine_id = u.machine_id " +
"WHERE u.is_deleted = 0 " +
"AND (m.machine_type IS NULL OR m.machine_type <> 'ZKTECO') " +
"AND u.serial_number IN ({SERIAL_IN}) " +
"AND u.machine_id IN ({ALLOWED_IN})";
string q = query
.Replace("{SERIAL_IN}", serialInClause)
.Replace("{ALLOWED_IN}", allowedInClause);
using (var cmd = new MySqlCommand())
{
cmd.Connection = connection;
cmd.CommandTimeout = 120;
string typeClause = MachineScope.BuildMachineTypeWhereClause(cmd, "m.machine_type", "mtreg" + offset + ao);
cmd.CommandText = q.Replace("AND (m.machine_type IS NULL OR m.machine_type <> 'ZKTECO')", "AND " + typeClause);
for (int i = 0; i < serialChunk.Length; i++)
{
cmd.Parameters.AddWithValue(serialParamNames[i], serialChunk[i]);
}
for (int i = 0; i < allowedChunk.Length; i++)
{
cmd.Parameters.AddWithValue(allowedParamNames[i], allowedChunk[i]);
}
using (var reader = cmd.ExecuteReader())
{
// temp set per serial to avoid duplicates from allowed-chunk partitioning
var serialToSet = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.IsDBNull(0) || reader.IsDBNull(1)) continue;
var serial = reader.GetString(0);
var mid = reader.GetString(1);
if (string.IsNullOrWhiteSpace(serial) || string.IsNullOrWhiteSpace(mid)) continue;
if (!serialToSet.TryGetValue(serial, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
serialToSet[serial] = set;
}
set.Add(mid.Trim());
}
foreach (var kvp in serialToSet)
{
if (!result.TryGetValue(kvp.Key, out var list))
{
list = new List<string>();
result[kvp.Key] = list;
}
foreach (var mid in kvp.Value)
{
if (!list.Contains(mid))
{
list.Add(mid);
}
}
}
}
}
}
}
return result;
}
}
}

29
AttendanceMachineUser.cs Normal file
View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
internal class AttendanceMachineUser
{
private string machineId;
private string serialNumber;
private string employeeName;
public AttendanceMachineUser(string machineId, string serialNumber, string employeeName)
{
this.MachineId = machineId;
this.SerialNumber = serialNumber;
this.EmployeeName = employeeName;
}
public string MachineId { get => machineId; set => machineId = value; }
public string SerialNumber { get => serialNumber; set => serialNumber = value; }
public string EmployeeName { get => employeeName; set => employeeName = value; }
}
}

128
AttendanceMachineUserDAO.cs Normal file
View File

@ -0,0 +1,128 @@
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
internal class AttendanceMachineUserDAO
{
public AttendanceMachineUserDAO() { }
public void Add(AttendanceMachineUser attendanceMachineUser, MySqlConnection connection)
{
// Ensure synced users are active and clear any pending deletion request.
const string query =
"REPLACE INTO `hrms`.`attendance_machine_user` " +
"(`machine_id`, `serial_number`, `employee_name`, `is_deletion_requested`, `is_deleted`, `updated_by`, `updated_at`) " +
"VALUES (@MachineId, @SerialNumber, @EmployeeName, 0, 0, @UpdatedBy, @UpdatedAt)";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.Parameters.AddWithValue("@MachineId", attendanceMachineUser.MachineId);
cmd.Parameters.AddWithValue("@SerialNumber", attendanceMachineUser.SerialNumber);
cmd.Parameters.AddWithValue("@EmployeeName", attendanceMachineUser.EmployeeName);
cmd.Parameters.AddWithValue("@UpdatedBy", "SERVICE");
cmd.Parameters.AddWithValue("@UpdatedAt", DateTime.Now);
cmd.ExecuteNonQuery();
}
}
public List<AttendanceMachineUser> getActiveEmployees(MySqlConnection connection)
{
const string query =
"SELECT machine_id, serial_number, employee_name " +
"FROM hrms.attendance_machine_user " +
"WHERE is_deleted = 0";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 120;
using (var reader = cmd.ExecuteReader())
{
return getDBUsers(reader);
}
}
}
public List<string> GetDeletionRequestedSerials(MySqlConnection connection, string machineId)
{
var serials = new List<string>();
const string query =
"SELECT serial_number " +
"FROM hrms.attendance_machine_user " +
"WHERE machine_id = @MachineId AND is_deletion_requested = 1 AND is_deleted = 0";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("@MachineId", machineId);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (!reader.IsDBNull(0))
{
var s = reader.GetString(0);
if (!string.IsNullOrWhiteSpace(s))
serials.Add(s.Trim());
}
}
}
}
return serials;
}
public int MarkUserDeleted(MySqlConnection connection, string machineId, string serialNumber)
{
const string query =
"UPDATE hrms.attendance_machine_user " +
"SET is_deleted = 1, is_deletion_requested = 0, updated_by = @UpdatedBy, updated_at = @UpdatedAt " +
"WHERE machine_id = @MachineId AND serial_number = @SerialNumber AND is_deleted = 0";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("@UpdatedBy", "SERVICE");
cmd.Parameters.AddWithValue("@UpdatedAt", DateTime.Now);
cmd.Parameters.AddWithValue("@MachineId", machineId);
cmd.Parameters.AddWithValue("@SerialNumber", serialNumber);
return cmd.ExecuteNonQuery();
}
}
public List<AttendanceMachineUser> getAllEmployees(MySqlConnection connection)
{
string query = "SELECT machine_id, serial_number, employee_name FROM hrms.attendance_machine_user";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 120;
using (var reader = cmd.ExecuteReader())
{
return getDBUsers(reader);
}
}
}
private List<AttendanceMachineUser> getDBUsers(MySqlDataReader reader)
{
List<AttendanceMachineUser> DbUsers = new List<AttendanceMachineUser>();
while (reader.Read())
{
DbUsers.Add(GetDbUsers(reader));
}
return DbUsers;
}
private AttendanceMachineUser GetDbUsers(MySqlDataReader reader)
{
return new AttendanceMachineUser(
reader.IsDBNull(0) ? "" : reader.GetString("machine_id"),
reader.IsDBNull(1) ? "" : reader.GetString("serial_number"),
reader.IsDBNull(1) ? "" : reader.GetString("serial_number")
);
}
}
}

14
DeviceSettings.xml Normal file
View File

@ -0,0 +1,14 @@
<DeviceSettings Mode="Multi" SiteId="2">
<!--<Device IpAddress="192.168.70.14" Enabled="true" />-->
<Device IpAddress="192.168.86.2" Enabled="true" />
<TemplateTransfer Enabled="false">
<Job Id="1"
Enabled="true"
SourceIp="192.168.86.7"
EmpIds="7044">
<Target Ip="192.168.86.4" />
</Job>
</TemplateTransfer>
</DeviceSettings>

207
DeviceSettingsConfig.cs Normal file
View File

@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml.Linq;
namespace HanvonF710XAttendanceService
{
internal sealed class DeviceSettingsConfig
{
public string Mode { get; private set; }
public int? SiteId { get; private set; }
public List<DeviceRule> Devices { get; } = new List<DeviceRule>();
public TemplateTransferConfig TemplateTransfer { get; private set; }
public static bool TryLoad(string path, out DeviceSettingsConfig config, out string error)
{
config = null;
error = null;
try
{
if (string.IsNullOrWhiteSpace(path))
{
error = "Device settings path is empty.";
return false;
}
if (!File.Exists(path))
{
config = new DeviceSettingsConfig();
return true;
}
var doc = XDocument.Load(path);
var root = doc.Root;
if (root == null || !string.Equals(root.Name.LocalName, "DeviceSettings", StringComparison.OrdinalIgnoreCase))
{
error = $"Invalid device settings root element. Expected <DeviceSettings>, found <{root?.Name.LocalName ?? "null"}>.";
return false;
}
var parsed = new DeviceSettingsConfig
{
Mode = (string)root.Attribute("Mode")
};
var rootSiteIdStr = (string)root.Attribute("SiteId") ?? (string)root.Attribute("SITE_ID") ?? (string)root.Attribute("SiteID");
if (!string.IsNullOrWhiteSpace(rootSiteIdStr) && int.TryParse(rootSiteIdStr.Trim(), out var rootSiteId))
{
parsed.SiteId = rootSiteId;
}
foreach (var deviceEl in root.Elements().Where(e => string.Equals(e.Name.LocalName, "Device", StringComparison.OrdinalIgnoreCase)))
{
var ip = (string)deviceEl.Attribute("IpAddress") ?? (string)deviceEl.Attribute("Ip") ?? "";
var enabledStr = (string)deviceEl.Attribute("Enabled");
if (string.IsNullOrWhiteSpace(ip) || string.IsNullOrWhiteSpace(enabledStr))
{
continue;
}
if (!TryParseBool(enabledStr, out var enabled))
{
continue;
}
int? siteId = null;
var siteIdStr = (string)deviceEl.Attribute("SiteId") ?? (string)deviceEl.Attribute("SITE_ID") ?? (string)deviceEl.Attribute("SiteID");
if (!string.IsNullOrWhiteSpace(siteIdStr) && int.TryParse(siteIdStr.Trim(), out var parsedSiteId))
{
siteId = parsedSiteId;
}
parsed.Devices.Add(new DeviceRule(ip.Trim(), enabled, siteId));
}
// If SiteId is not specified at root, but devices specify a single consistent SiteId, use it.
if (!parsed.SiteId.HasValue)
{
var distinct = parsed.Devices.Where(d => d.SiteId.HasValue).Select(d => d.SiteId.Value).Distinct().ToList();
if (distinct.Count == 1)
{
parsed.SiteId = distinct[0];
}
}
var templateTransferEl = root.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, "TemplateTransfer", StringComparison.OrdinalIgnoreCase));
if (templateTransferEl != null)
{
var ttEnabled = TryParseBool((string)templateTransferEl.Attribute("Enabled"), out var tt) && tt;
var ttConfig = new TemplateTransferConfig(ttEnabled);
foreach (var jobEl in templateTransferEl.Elements().Where(e => string.Equals(e.Name.LocalName, "Job", StringComparison.OrdinalIgnoreCase)))
{
var id = (string)jobEl.Attribute("Id") ?? "";
var enabled = TryParseBool((string)jobEl.Attribute("Enabled"), out var jEnabled) && jEnabled;
var sourceIp = (string)jobEl.Attribute("SourceIp") ?? (string)jobEl.Attribute("SourceIP") ?? "";
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sourceIp))
{
continue;
}
var targets = new List<string>();
foreach (var targetEl in jobEl.Elements().Where(e => string.Equals(e.Name.LocalName, "Target", StringComparison.OrdinalIgnoreCase)))
{
var tip = (string)targetEl.Attribute("Ip") ?? (string)targetEl.Attribute("IpAddress") ?? "";
if (!string.IsNullOrWhiteSpace(tip))
{
targets.Add(tip.Trim());
}
}
// Optional comma-separated list of employee IDs for DB_TO_DEVICE filtering
var empIdsAttr = (string)jobEl.Attribute("EmpIds") ?? (string)jobEl.Attribute("EmpIDs");
List<string> empIds = null;
if (!string.IsNullOrWhiteSpace(empIdsAttr))
{
empIds = empIdsAttr
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(v => v.Trim())
.Where(v => v.Length > 0)
.ToList();
}
ttConfig.Jobs.Add(new TemplateTransferJob(id.Trim(), enabled, sourceIp.Trim(), targets, empIds));
}
parsed.TemplateTransfer = ttConfig;
}
config = parsed;
return true;
}
catch (Exception ex)
{
error = $"Failed to load device settings: {ex.Message}";
return false;
}
}
public bool HasAnyAllowlistedDeviceIp()
=> Devices.Any(d => d.Enabled);
public HashSet<string> GetAllowlistedIps()
=> new HashSet<string>(Devices.Where(d => d.Enabled).Select(d => d.IpAddress), StringComparer.OrdinalIgnoreCase);
public HashSet<string> GetBlocklistedIps()
=> new HashSet<string>(Devices.Where(d => !d.Enabled).Select(d => d.IpAddress), StringComparer.OrdinalIgnoreCase);
private static bool TryParseBool(string value, out bool result)
{
if (string.IsNullOrWhiteSpace(value))
{
result = false;
return false;
}
return bool.TryParse(value.Trim(), out result);
}
}
internal sealed class DeviceRule
{
public DeviceRule(string ipAddress, bool enabled, int? siteId)
{
IpAddress = ipAddress ?? "";
Enabled = enabled;
SiteId = siteId;
}
public string IpAddress { get; }
public bool Enabled { get; }
public int? SiteId { get; }
}
internal sealed class TemplateTransferConfig
{
public TemplateTransferConfig(bool enabled)
{
Enabled = enabled;
}
public bool Enabled { get; }
public List<TemplateTransferJob> Jobs { get; } = new List<TemplateTransferJob>();
}
internal sealed class TemplateTransferJob
{
public TemplateTransferJob(string id, bool enabled, string sourceIp, List<string> targetIps, List<string> empIds)
{
Id = id ?? "";
Enabled = enabled;
SourceIp = sourceIp ?? "";
TargetIps = targetIps ?? new List<string>();
EmpIds = empIds ?? new List<string>();
}
public string Id { get; }
public bool Enabled { get; }
public string SourceIp { get; }
public List<string> TargetIps { get; }
public List<string> EmpIds { get; }
}
}

View File

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>HanvonF710XAttendanceService</RootNamespace>
<AssemblyName>HanvonF710XAttendanceService</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="BouncyCastle.Crypto, Version=1.8.5.0, Culture=neutral, PublicKeyToken=0e99375e54769942">
<HintPath>packages\BouncyCastle.1.8.5\lib\BouncyCastle.Crypto.dll</HintPath>
</Reference>
<Reference Include="Google.Protobuf, Version=3.11.4.0, Culture=neutral, PublicKeyToken=a7d26565bac4d604, processorArchitecture=MSIL">
<HintPath>packages\Google.Protobuf.3.11.4\lib\net45\Google.Protobuf.dll</HintPath>
</Reference>
<Reference Include="K4os.Compression.LZ4, Version=1.1.11.0, Culture=neutral, PublicKeyToken=2186fa9121ef231d, processorArchitecture=MSIL">
<HintPath>packages\K4os.Compression.LZ4.1.1.11\lib\net46\K4os.Compression.LZ4.dll</HintPath>
</Reference>
<Reference Include="K4os.Compression.LZ4.Streams, Version=1.1.11.0, Culture=neutral, PublicKeyToken=2186fa9121ef231d, processorArchitecture=MSIL">
<HintPath>packages\K4os.Compression.LZ4.Streams.1.1.11\lib\net46\K4os.Compression.LZ4.Streams.dll</HintPath>
</Reference>
<Reference Include="K4os.Hash.xxHash, Version=1.0.6.0, Culture=neutral, PublicKeyToken=32cd54395057cec3, processorArchitecture=MSIL">
<HintPath>packages\K4os.Hash.xxHash.1.0.6\lib\net46\K4os.Hash.xxHash.dll</HintPath>
</Reference>
<Reference Include="MySql.Data, Version=8.0.23.0, Culture=neutral, PublicKeyToken=c5687fc88969c44d, processorArchitecture=MSIL">
<HintPath>packages\MySql.Data.8.0.23\lib\net452\MySql.Data.dll</HintPath>
</Reference>
<Reference Include="Renci.SshNet, Version=2020.0.0.0, Culture=neutral, PublicKeyToken=1cee9f8bde3db106, processorArchitecture=MSIL">
<HintPath>packages\SSH.NET.2020.0.0\lib\net40\Renci.SshNet.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel" />
<Reference Include="System.ComponentModel.DataAnnotations" />
<Reference Include="System.Configuration" />
<Reference Include="System.Configuration.Install" />
<Reference Include="System.Core" />
<Reference Include="System.Management" />
<Reference Include="System.Memory, Version=4.0.1.1, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Memory.4.5.3\lib\netstandard2.0\System.Memory.dll</HintPath>
</Reference>
<Reference Include="System.Numerics" />
<Reference Include="System.Numerics.Vectors, Version=4.1.3.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Numerics.Vectors.4.4.0\lib\net46\System.Numerics.Vectors.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.CompilerServices.Unsafe, Version=4.0.4.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.CompilerServices.Unsafe.4.5.2\lib\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll</HintPath>
</Reference>
<Reference Include="System.Transactions" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" />
<Reference Include="System.ServiceProcess" />
<Reference Include="System.Xml" />
<Reference Include="Ubiety.Dns.Core, Version=2.2.1.0, Culture=neutral, PublicKeyToken=c5687fc88969c44d, processorArchitecture=MSIL">
<HintPath>packages\MySql.Data.8.0.23\lib\net452\Ubiety.Dns.Core.dll</HintPath>
</Reference>
<Reference Include="Zstandard.Net, Version=1.1.7.0, Culture=neutral, PublicKeyToken=c5687fc88969c44d, processorArchitecture=MSIL">
<HintPath>packages\MySql.Data.8.0.23\lib\net452\Zstandard.Net.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Attendance.cs" />
<Compile Include="AttendanceDAO.cs" />
<Compile Include="AttendanceMachine.cs" />
<Compile Include="AttendanceMachineDAO.cs" />
<Compile Include="AttendanceMachineFaceTemplateDAO.cs" />
<Compile Include="AttendanceMachineUser.cs" />
<Compile Include="AttendanceMachineUserDAO.cs" />
<Compile Include="DeviceSettingsConfig.cs" />
<Compile Include="LogService.cs" />
<Compile Include="ProjectInstaller.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="ProjectInstaller.Designer.cs">
<DependentUpon>ProjectInstaller.cs</DependentUpon>
</Compile>
<Compile Include="HanvonHttpApiClient.cs" />
<Compile Include="HanvonF710XWindowsService.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="HanvonF710XWindowsService.Designer.cs">
<DependentUpon>HanvonF710XWindowsService.cs</DependentUpon>
</Compile>
<Compile Include="MachineScope.cs" />
<Compile Include="TemplateParser.cs" />
<Compile Include="TemplateTransferRunner.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="DeviceSettings.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="ProjectInstaller.resx">
<DependentUpon>ProjectInstaller.cs</DependentUpon>
</EmbeddedResource>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.4.11626.88 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HanvonF710XAttendanceService", "HanvonF710XAttendanceService.csproj", "{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-4789-A012-3456789ABCDE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {198B6901-F16A-45C5-9B8E-DAD67C218E8B}
EndGlobalSection
EndGlobal

38
HanvonF710XWindowsService.Designer.cs generated Normal file
View File

@ -0,0 +1,38 @@
namespace HanvonF710XAttendanceService
{
partial class HanvonF710XWindowsService
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
components = new System.ComponentModel.Container();
this.ServiceName = "HanvonF710XAttendanceService";
}
#endregion
}
}

View File

@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.ServiceProcess;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Configuration;
namespace HanvonF710XAttendanceService
{
public partial class HanvonF710XWindowsService : ServiceBase
{
// Separate timers for attendance/users and template sync.
System.Timers.Timer timer = new System.Timers.Timer(); // attendance + users
System.Timers.Timer timer1 = new System.Timers.Timer(); // templates
// Independent locks/flags so jobs don't block each other.
private static readonly object _attendanceLock = new object();
private static readonly object _templateLock = new object();
private static bool _attendanceJobRunning = false;
private static bool _templateJobRunning = false;
public HanvonF710XWindowsService()
{
InitializeComponent();
}
/// <summary>Starts timers and jobs when running outside the Windows Service Control Manager.</summary>
public void StartForConsole(string[] args)
{
OnStart(args ?? Array.Empty<string>());
}
/// <summary>Stops timers and flushes logs when running in console/debug mode.</summary>
public void StopForConsole()
{
OnStop();
}
protected override void OnStart(string[] args)
{
// So the service finds HwDevComm.dll (and sibling DLLs) in the EXE folder
string exeDir = AppDomain.CurrentDomain.BaseDirectory;
try
{
Environment.CurrentDirectory = exeDir;
}
catch (Exception ex)
{
try { WriteInternalLog("OnStart set CurrentDirectory failed: " + ex.Message); } catch { }
}
// Start buffered, cross-process-safe logging.
try { LogService.Start(); } catch { }
WriteToFile("--Service is started at " + DateTime.Now);
WriteInternalLog("--Service is started at " + DateTime.Now);
if (Program.IsConsoleMode)
{
Console.WriteLine("--Service is started at " + DateTime.Now);
}
int attendanceMinutes = GetIntConfig("ATTENDANCE_JOB_INTERVAL_MINUTES", 20);
int templateMinutes = GetIntConfig("TEMPLATE_JOB_INTERVAL_MINUTES", 240);
double attendanceIntervalMs = Math.Max(1, attendanceMinutes) * 60 * 1000;
double templateIntervalMs = Math.Max(1, templateMinutes) * 60 * 1000;
timer.Elapsed += new ElapsedEventHandler(OnAttendanceElapsed);
timer.Interval = attendanceIntervalMs;
timer.AutoReset = true;
timer.Enabled = true;
timer1.Elapsed += new ElapsedEventHandler(OnTemplateElapsed);
timer1.Interval = templateIntervalMs;
timer1.AutoReset = true;
timer1.Enabled = true;
WriteInternalLog($"Attendance job interval: {attendanceMinutes} minutes");
WriteInternalLog($"Template job interval: {templateMinutes} minutes");
WriteToFile($"Attendance job interval: {attendanceMinutes} minutes");
WriteToFile($"Template job interval: {templateMinutes} minutes");
// Run both jobs once immediately on startup (non-blocking).
// Uses the same guarded execution path as the timer callbacks.
try
{
WriteInternalLog("--Startup trigger: running attendance job immediately at " + DateTime.Now);
Task.Run(() => RunAttendanceJob());
}
catch { }
try
{
int delaySec = 30;
WriteInternalLog("--Startup trigger: running template job after " + delaySec + "s at " + DateTime.Now);
Task.Run(async () =>
{
try
{
await Task.Delay(delaySec * 1000).ConfigureAwait(false);
RunTemplateJob();
}
catch { }
});
}
catch { }
}
protected override void OnStop()
{
WriteToFile("--Service is stopped at " + DateTime.Now);
try
{
timer.Enabled = false;
timer1.Enabled = false;
timer.Dispose();
timer1.Dispose();
}
catch { }
try { LogService.StopAndFlush(TimeSpan.FromSeconds(5)); } catch { }
}
private static int GetIntConfig(string key, int defaultValue)
{
try
{
var raw = ConfigurationManager.AppSettings[key];
if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
return int.TryParse(raw, out var v) && v > 0 ? v : defaultValue;
}
catch
{
return defaultValue;
}
}
// Attendance + machine-user job
private void OnAttendanceElapsed(object source, ElapsedEventArgs e)
{
RunAttendanceJob();
}
private void RunAttendanceJob()
{
if (!Monitor.TryEnter(_attendanceLock))
{
WriteInternalLog("--Attendance tick skipped (previous attendance job still running) at " + DateTime.Now);
return;
}
try
{
if (_attendanceJobRunning)
{
WriteInternalLog("--Attendance tick skipped (previous attendance job still running) at " + DateTime.Now);
return;
}
_attendanceJobRunning = true;
WriteSyncCycleHeader();
Program.BeginSyncCycle();
WriteInternalLog("--Attendance job started at " + DateTime.Now);
WriteAttendanceServiceLog("--Attendance job started at " + DateTime.Now);
try
{
syncMachineUsers();
}
catch (Exception ex)
{
WriteInternalLog("syncMachineUsers error: " + ex.Message);
WriteMachineUserServiceLog("syncMachineUsers error: " + ex.Message);
}
try
{
syncAttendance();
}
catch (Exception ex)
{
WriteInternalLog("syncAttendance error: " + ex.Message);
WriteAttendanceServiceLog("syncAttendance error: " + ex.Message);
}
WriteInternalLog("--Attendance job finished at " + DateTime.Now);
WriteAttendanceServiceLog("--Attendance job finished at " + DateTime.Now);
}
finally
{
try { Program.EndSyncCycle(); } catch { }
_attendanceJobRunning = false;
Monitor.Exit(_attendanceLock);
}
}
// Template job
private void OnTemplateElapsed(object source, ElapsedEventArgs e)
{
RunTemplateJob();
}
private void RunTemplateJob()
{
if (!Monitor.TryEnter(_templateLock))
{
WriteInternalLog("--Template tick skipped (previous template job still running) at " + DateTime.Now);
return;
}
try
{
if (_templateJobRunning)
{
WriteInternalLog("--Template tick skipped (previous template job still running) at " + DateTime.Now);
return;
}
_templateJobRunning = true;
WriteJobHeaderToSimpleLogs("TEMPLATE");
WriteInternalLog("--Template job started at " + DateTime.Now);
WriteSaveFaceTemplateLog("--Template job started at " + DateTime.Now);
WriteFaceTemplateTransferLog("--Template job started at " + DateTime.Now);
try
{
syncTemplatesIfEnabled();
}
catch (Exception ex)
{
WriteInternalLog("syncTemplatesIfEnabled error: " + ex.ToString());
WriteFaceTemplateTransferLog("syncTemplatesIfEnabled error: " + ex.ToString());
WriteSaveFaceTemplateLog("syncTemplatesIfEnabled error: " + ex.ToString());
}
WriteInternalLog("--Template job finished at " + DateTime.Now);
WriteSaveFaceTemplateLog("--Template job finished at " + DateTime.Now);
WriteFaceTemplateTransferLog("--Template job finished at " + DateTime.Now);
}
finally
{
_templateJobRunning = false;
Monitor.Exit(_templateLock);
}
}
public void syncAttendance()
{
WriteToFile("--Sync Attendance is recall at " + DateTime.Now);
WriteInternalLog("--Sync Attendance is recall at " + DateTime.Now);
WriteAttendanceServiceLog("--Sync Attendance is recall at " + DateTime.Now);
List<string> responses = Program.syncAttendance();
foreach (var response in responses)
{
WriteToFile(response);
WriteInternalLog(response);
// Attendance simple log: only machine-level results and connectivity.
if (IsAttendanceSimpleLine(response))
{
WriteAttendanceServiceLog(response);
}
}
}
public void syncMachineUsers()
{
WriteToFile("--Sync Machine users is recall at " + DateTime.Now);
WriteInternalLog("--Sync Machine users is recall at " + DateTime.Now);
WriteMachineUserServiceLog("--Sync Machine users is recall at " + DateTime.Now);
List<string> responses = Program.syncMachineUsers();
foreach (var response in responses)
{
WriteToFile(response);
WriteInternalLog(response);
// Machine user simple log: only machine totals and connectivity.
if (IsMachineUserSimpleLine(response))
{
WriteMachineUserServiceLog(response);
}
}
}
public void syncTemplatesIfEnabled()
{
WriteToFile("--Template sync is recall at " + DateTime.Now);
WriteInternalLog("--Template sync is recall at " + DateTime.Now);
// We'll route template logs into either SaveFaceTemplate or FaceTransfer logs based on TRANSFER_MODE.
List<string> responses = Program.syncTemplates();
foreach (var response in responses)
{
WriteToFile(response);
WriteInternalLog(response);
// SaveFaceTemplate logs: DEVICE_TO_DB and any "Serial number--" lines.
if (IsSaveFaceTemplateLine(response))
{
WriteSaveFaceTemplateLog(response);
}
// FaceTransfer logs: DB_TO_DEVICE / DEVICE_TO_DEVICE and connectivity issues.
if (IsFaceTransferLine(response))
{
WriteFaceTemplateTransferLog(response);
}
}
}
public void WriteToFile(string Message)
{
string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\Logs\\ServiceLog_" +
DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt";
LogService.EnqueueLine(filepath, Message);
}
private void WriteSyncCycleHeader()
{
string time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
string line1 = "========================================================";
string line2 = "SYNC CYCLE START";
string line3 = "Time: " + time;
string line4 = "=========================";
WriteToFile(line1);
WriteToFile(line2);
WriteToFile(line3);
WriteToFile(line4);
WriteInternalLog(line1);
WriteInternalLog(line2);
WriteInternalLog(line3);
WriteInternalLog(line4);
// Simple per-feature logs (keep InternalLogs unchanged).
WriteAttendanceServiceLog(line1);
WriteAttendanceServiceLog(line2);
WriteAttendanceServiceLog(line3);
WriteAttendanceServiceLog(line4);
WriteMachineUserServiceLog(line1);
WriteMachineUserServiceLog(line2);
WriteMachineUserServiceLog(line3);
WriteMachineUserServiceLog(line4);
}
// Ensures per-feature simple logs have a header even when a job doesn't call WriteSyncCycleHeader.
// Used by template job.
private void WriteJobHeaderToSimpleLogs(string jobName)
{
string time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
string line1 = "========================================================";
string line2 = "SYNC CYCLE START";
string line3 = "Time: " + time;
string line4 = "=========================";
string line5 = "JOB: " + jobName;
WriteSaveFaceTemplateLog(line1);
WriteSaveFaceTemplateLog(line2);
WriteSaveFaceTemplateLog(line3);
WriteSaveFaceTemplateLog(line4);
WriteSaveFaceTemplateLog(line5);
WriteFaceTemplateTransferLog(line1);
WriteFaceTemplateTransferLog(line2);
WriteFaceTemplateTransferLog(line3);
WriteFaceTemplateTransferLog(line4);
WriteFaceTemplateTransferLog(line5);
}
private static bool IsAttendanceSimpleLine(string line)
{
if (string.IsNullOrWhiteSpace(line)) return false;
// Keep only the machine summary lines.
if (line.IndexOf(" has ", StringComparison.OrdinalIgnoreCase) >= 0 &&
line.IndexOf(" records", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf(" is not connected", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
private static bool IsMachineUserSimpleLine(string line)
{
if (string.IsNullOrWhiteSpace(line)) return false;
// Keep only "MACHINE X -> N", "X removed from MACHINE Y", and connectivity lines.
if (line.IndexOf(" -> ", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf(" removed from ", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf(" is not connected", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
private static bool IsSaveFaceTemplateLine(string line)
{
if (string.IsNullOrWhiteSpace(line)) return false;
if (line.StartsWith("Serial number--", StringComparison.OrdinalIgnoreCase)) return true;
if (line.IndexOf("SaveTemplateDB", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("DEVICE_TO_DB", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
private static bool IsFaceTransferLine(string line)
{
if (string.IsNullOrWhiteSpace(line)) return false;
// Any transfer mode other than DEVICE_TO_DB and connectivity issues.
if (line.IndexOf("DB_TO_DEVICE", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("DEVICE_TO_DEVICE", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("[DB_TO_DEVICE]", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("TemplateDistribution", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("TemplateRegistrationTargets", StringComparison.OrdinalIgnoreCase) >= 0) return true;
// Peer distribution uses job id "TemplateDist:<ip>" in [TemplateJob ...] lines from GetEmployeeID on targets.
if (line.IndexOf("TemplateDist:", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf("SetEmployee", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (line.IndexOf(" is not connected", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
private static void AppendLineToDatedLog(string folderName, string filePrefix, string message)
{
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
string logsRoot = Path.Combine(baseDir, "Logs");
string folderPath = Path.Combine(logsRoot, folderName);
string datePart = DateTime.Now.ToString("yyyy-MM-dd");
string filePath = Path.Combine(folderPath, filePrefix + "_" + datePart + ".txt");
LogService.EnqueueLine(filePath, message);
}
private static void WriteAttendanceServiceLog(string message)
{
AppendLineToDatedLog("Attendance", "AttendanceServiceLogs", message);
}
private static void WriteMachineUserServiceLog(string message)
{
AppendLineToDatedLog("Machine User service", "MachineServiceLogs", message);
}
private static void WriteSaveFaceTemplateLog(string message)
{
AppendLineToDatedLog("SaveFaceTemplate logs", "SaveFaceTemplateLogs", message);
}
private static void WriteFaceTemplateTransferLog(string message)
{
AppendLineToDatedLog("FaceTransferLog", "FaceTransferLogs", message);
}
public void WriteInternalLog(string Message)
{
string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\InternalLogs\\InternalLog_" +
DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt";
LogService.EnqueueLine(filepath, Message);
}
}
}

396
HanvonHttpApiClient.cs Normal file
View File

@ -0,0 +1,396 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Net;
using System.Text;
using System.Web.Script.Serialization;
namespace HanvonF710XAttendanceService
{
/// <summary>
/// F710X / AI Biometric web API client (POST http://{ip}/api).
/// Auth: each request includes the device web UI password.
/// </summary>
internal sealed class HanvonHttpApiClient
{
private readonly string _baseUrl;
private readonly string _password;
private readonly int _timeoutMs;
private readonly JavaScriptSerializer _json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
public HanvonHttpApiClient(string machineIp, int httpPort = 80, string password = null, int timeoutMs = 60000)
{
if (string.IsNullOrWhiteSpace(machineIp)) throw new ArgumentException("machineIp required");
int port = httpPort > 0 ? httpPort : 80;
_baseUrl = "http://" + machineIp.Trim() + (port == 80 ? "" : ":" + port) + "/api";
_password = password ?? MachineScope.GetDevicePassword() ?? "";
_timeoutMs = timeoutMs > 0 ? timeoutMs : 60000;
}
public static HanvonHttpApiClient ForMachine(AttendanceMachine machine)
{
int httpPort = GetIntSetting("DEVICE_HTTP_PORT", 80);
return new HanvonHttpApiClient(machine.MachineIp, httpPort, MachineScope.GetDevicePassword());
}
public bool TryLogin(out string error)
{
error = null;
try
{
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "login" },
{ "username", MachineScope.GetDeviceUsername() },
{ "password", _password },
{ "rememberMe", false }
});
if (IsSuccess(resp)) return true;
error = GetMsg(resp) ?? "login failed";
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public List<HttpLogRecord> GetLogs(DateTime? from, DateTime? to, out string error)
{
error = null;
var all = new List<HttpLogRecord>();
try
{
int index = 0;
for (int guard = 0; guard < 10000; guard++)
{
var req = new Dictionary<string, object>
{
{ "cmd", "getlog" },
{ "password", _password },
{ "index", index }
};
if (from.HasValue) req["from"] = from.Value.ToString("yyyy-MM-dd HH:mm:ss");
if (to.HasValue) req["to"] = to.Value.ToString("yyyy-MM-dd HH:mm:ss");
var resp = Post(req);
if (!IsSuccess(resp))
{
error = GetMsg(resp) ?? "getlog failed";
return all;
}
var batch = ParseLogRecords(resp);
all.AddRange(batch);
int count = GetInt(resp, "count");
int toIdx = GetInt(resp, "to");
if (count <= 0 || toIdx <= 0 || count <= toIdx || batch.Count == 0)
{
break;
}
index = toIdx + 1;
}
return all;
}
catch (Exception ex)
{
error = ex.Message;
return all;
}
}
public List<HttpUserRecord> GetUserList(out string error)
{
error = null;
var all = new List<HttpUserRecord>();
try
{
int stn = 1;
for (int guard = 0; guard < 10000; guard++)
{
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "getuserlist" },
{ "password", _password },
{ "stn", stn }
});
if (!IsSuccess(resp))
{
error = GetMsg(resp) ?? "getuserlist failed";
return all;
}
var batch = ParseUserRecords(resp);
all.AddRange(batch);
int count = GetInt(resp, "count");
int toIdx = GetInt(resp, "to");
if (count <= 0 || batch.Count == 0 || (toIdx > 0 && toIdx >= count))
{
break;
}
stn = 0;
}
return all;
}
catch (Exception ex)
{
error = ex.Message;
return all;
}
}
public bool DeleteUsers(IEnumerable<string> enrollIds, out string error)
{
error = null;
try
{
var list = new List<object>();
foreach (var id in enrollIds)
{
if (string.IsNullOrWhiteSpace(id)) continue;
if (int.TryParse(id.Trim(), out int n)) list.Add(n);
else list.Add(id.Trim());
}
if (list.Count == 0) return true;
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "deleteusers" },
{ "password", _password },
{ "list", list }
});
if (IsSuccess(resp)) return true;
error = GetMsg(resp) ?? "deleteusers failed";
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool CleanLog(out string error)
{
error = null;
try
{
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "cleanlog" },
{ "password", _password }
});
if (IsSuccess(resp)) return true;
error = GetMsg(resp) ?? "cleanlog failed";
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool TryGetFaceRecord(int enrollId, out string recordBase64, out string name, out string error)
{
recordBase64 = null;
name = null;
error = null;
try
{
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "getuserinfo" },
{ "password", _password },
{ "enrollid", enrollId },
{ "backupnum", GetIntSetting("DEVICE_FACE_BACKUPNUM", 50) }
});
if (!IsSuccess(resp))
{
error = GetMsg(resp) ?? "getuserinfo failed";
return false;
}
name = GetString(resp, "name");
recordBase64 = GetString(resp, "record");
if (string.IsNullOrWhiteSpace(recordBase64))
{
error = "no face/record payload";
return false;
}
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private Dictionary<string, object> Post(Dictionary<string, object> payload)
{
string body = _json.Serialize(payload);
var req = (HttpWebRequest)WebRequest.Create(_baseUrl);
req.Method = "POST";
req.ContentType = "application/json; charset=utf-8";
req.Timeout = _timeoutMs;
req.ReadWriteTimeout = _timeoutMs;
req.KeepAlive = false;
byte[] bytes = Encoding.UTF8.GetBytes(body);
req.ContentLength = bytes.Length;
using (var stream = req.GetRequestStream())
{
stream.Write(bytes, 0, bytes.Length);
}
using (var resp = (HttpWebResponse)req.GetResponse())
using (var reader = new StreamReader(resp.GetResponseStream(), Encoding.UTF8))
{
string text = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(text))
{
return new Dictionary<string, object>();
}
return _json.Deserialize<Dictionary<string, object>>(text)
?? new Dictionary<string, object>();
}
}
private static bool IsSuccess(Dictionary<string, object> resp)
{
if (resp == null) return false;
if (!resp.TryGetValue("result", out object r) || r == null) return false;
if (r is bool b) return b;
return string.Equals(r.ToString(), "true", StringComparison.OrdinalIgnoreCase);
}
private static string GetMsg(Dictionary<string, object> resp)
{
return GetString(resp, "msg") ?? GetString(resp, "reason");
}
private static string GetString(Dictionary<string, object> resp, string key)
{
if (resp == null || !resp.TryGetValue(key, out object v) || v == null) return null;
return v.ToString();
}
private static int GetInt(Dictionary<string, object> resp, string key)
{
if (resp == null || !resp.TryGetValue(key, out object v) || v == null) return 0;
if (v is int i) return i;
if (v is long l) return (int)l;
if (v is decimal d) return (int)d;
int.TryParse(v.ToString(), out int n);
return n;
}
private static List<HttpLogRecord> ParseLogRecords(Dictionary<string, object> resp)
{
var list = new List<HttpLogRecord>();
if (resp == null || !resp.TryGetValue("record", out object rec) || rec == null) return list;
if (rec is ArrayList arr)
{
foreach (var item in arr) AddLog(list, item as Dictionary<string, object>);
}
else if (rec is object[] oa)
{
foreach (var item in oa) AddLog(list, item as Dictionary<string, object>);
}
return list;
}
private static void AddLog(List<HttpLogRecord> list, Dictionary<string, object> row)
{
if (row == null) return;
string enroll = GetString(row, "enrollid") ?? "";
string name = GetString(row, "name") ?? "";
string timeStr = GetString(row, "time") ?? "";
if (!DateTime.TryParse(timeStr, out DateTime time)) return;
string acNo = !string.IsNullOrWhiteSpace(name) ? name.Trim() : enroll.Trim();
if (string.IsNullOrWhiteSpace(acNo)) return;
list.Add(new HttpLogRecord
{
EnrollId = enroll,
Name = name,
AcNo = acNo,
CheckTime = time,
Mode = GetInt(row, "mode"),
InOut = GetInt(row, "inout"),
Event = GetInt(row, "event")
});
}
private static List<HttpUserRecord> ParseUserRecords(Dictionary<string, object> resp)
{
var list = new List<HttpUserRecord>();
if (resp == null || !resp.TryGetValue("record", out object rec) || rec == null) return list;
IEnumerable items = null;
if (rec is ArrayList arr) items = arr;
else if (rec is object[] oa) items = oa;
if (items == null) return list;
foreach (var item in items)
{
var row = item as Dictionary<string, object>;
if (row == null) continue;
string id = GetString(row, "id") ?? "";
string name = GetString(row, "name") ?? "";
string serial = !string.IsNullOrWhiteSpace(name) ? name.Trim() : id.Trim();
if (string.IsNullOrWhiteSpace(serial)) continue;
list.Add(new HttpUserRecord
{
DeviceUserId = id.Trim(),
Name = name.Trim(),
SerialNumber = serial
});
}
return list;
}
private static int GetIntSetting(string key, int defaultValue)
{
try
{
var raw = ConfigurationManager.AppSettings[key];
if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
return int.TryParse(raw.Trim(), out var v) && v > 0 ? v : defaultValue;
}
catch { return defaultValue; }
}
}
internal sealed class HttpLogRecord
{
public string EnrollId { get; set; }
public string Name { get; set; }
public string AcNo { get; set; }
public DateTime CheckTime { get; set; }
public int Mode { get; set; }
public int InOut { get; set; }
public int Event { get; set; }
}
internal sealed class HttpUserRecord
{
public string DeviceUserId { get; set; }
public string Name { get; set; }
public string SerialNumber { get; set; }
}
internal static class DeviceProtocol
{
public static bool UseHttp()
{
var p = ConfigurationManager.AppSettings["DEVICE_PROTOCOL"] ?? "HTTP";
return string.Equals(p.Trim(), "HTTP", StringComparison.OrdinalIgnoreCase)
|| string.Equals(p.Trim(), "WEB", StringComparison.OrdinalIgnoreCase)
|| string.Equals(p.Trim(), "API", StringComparison.OrdinalIgnoreCase);
}
}
}

51
INSTALL.txt Normal file
View File

@ -0,0 +1,51 @@
Hanvon F710X Attendance Service - Deployment Notes
==================================================
1. Build output folder: bin\Debug\ (or bin\Release\)
2. Copy Hanvon HDCP SDK native DLLs next to the EXE:
- HwDevComm.dll
- HDCP_Utils.dll
These ship with the Hanvon face-terminal SDK (HDCP protocol). F710/F710X is supported by this SDK.
3. Configure App.config (copied as HanvonF710XAttendanceService.exe.config):
- connectionStrings / HrmsDb
- ATTENDANCE_SCOPE = CENTRAL or SITE
- MACHINE_TYPES = HANVON,F710X,F710
- DEVICE_TYPE = HW_HDCP
- ATTENDANCE_DELETE_AFTER_INSERT = true
4. Configure DeviceSettings.xml for SITE scope and template transfer jobs.
5. Ensure attendance_machine rows use machine_type HANVON (or F710X/F710).
6. Install the Windows service (run elevated):
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\InstallUtil.exe HanvonF710XAttendanceService.exe
7. Start the service:
net start HanvonF710XAttendanceService
8. Uninstall:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\InstallUtil.exe /u HanvonF710XAttendanceService.exe
Log folders (created at runtime beside the EXE):
- InternalLogs\
- Logs\Attendance\
- Logs\Machine User service\
- Logs\SaveFaceTemplate logs\
- Logs\FaceTransferLog\
- TemplateRawLogs\
Jobs:
- Attendance + machine users: every 20 minutes (ATTENDANCE_JOB_INTERVAL_MINUTES)
- Templates: every 4 hours (TEMPLATE_JOB_INTERVAL_MINUTES)
- Both jobs run once on startup (templates after 30s delay)
Console / debug mode (no InstallUtil needed):
- From Visual Studio: F5 (Debug builds run as console when interactive)
- From command line:
HanvonF710XAttendanceService.exe --console
HanvonF710XAttendanceService.exe --debug
- Force Windows Service host even if interactive:
HanvonF710XAttendanceService.exe --service
- Press Enter or Ctrl+C to stop console mode.

170
LogService.cs Normal file
View File

@ -0,0 +1,170 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
internal static class LogService
{
private sealed class LogItem
{
public string FilePath { get; }
public string Line { get; }
public LogItem(string filePath, string line)
{
FilePath = filePath;
Line = line;
}
}
private static readonly ConcurrentQueue<LogItem> _queue = new ConcurrentQueue<LogItem>();
private static readonly object _startLock = new object();
private static CancellationTokenSource _cts;
private static Task _worker;
private static volatile bool _started;
// One global cross-process mutex to serialize file writes across multiple services.
private static readonly Mutex _globalMutex = new Mutex(false, @"Global\HanvonF710XAttendanceService_LogMutex");
public static void Start()
{
lock (_startLock)
{
if (_started) return;
_started = true;
_cts = new CancellationTokenSource();
_worker = Task.Run(() => WorkerLoop(_cts.Token));
}
}
public static void StopAndFlush(TimeSpan maxWait)
{
lock (_startLock)
{
if (!_started) return;
_cts.Cancel();
}
try { _worker?.Wait(maxWait); } catch { }
// Best-effort final flush
try { FlushOnce(maxItems: 5000, allowWait: true); } catch { }
}
public static void EnqueueLine(string filePath, string line)
{
if (string.IsNullOrWhiteSpace(filePath)) return;
if (line == null) line = "";
if (!_started)
{
// If service forgot to start the logger, still try to start it lazily.
Start();
}
_queue.Enqueue(new LogItem(filePath, line));
}
private static async Task WorkerLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
FlushOnce(maxItems: 500, allowWait: false);
}
catch
{
// swallow; keep loop alive
}
try
{
await Task.Delay(1000, token).ConfigureAwait(false);
}
catch
{
// ignore cancellation timing
}
}
}
private static void FlushOnce(int maxItems, bool allowWait)
{
if (_queue.IsEmpty) return;
var drained = new List<LogItem>(maxItems);
while (drained.Count < maxItems && _queue.TryDequeue(out var item))
{
drained.Add(item);
}
if (drained.Count == 0) return;
bool mutexTaken = false;
try
{
// Avoid blocking service operations; only background worker waits.
int waitMs = allowWait ? 2000 : 0;
mutexTaken = _globalMutex.WaitOne(waitMs);
if (!mutexTaken)
{
// Couldn't acquire lock; put back and try later.
for (int i = drained.Count - 1; i >= 0; i--)
{
_queue.Enqueue(drained[i]);
}
return;
}
foreach (var grp in drained.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase))
{
string filePath = grp.Key;
try
{
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
// Open with FileShare.ReadWrite so other processes can read while we write.
using (var fs = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
using (var sw = new StreamWriter(fs, Encoding.UTF8))
{
foreach (var li in grp)
{
sw.WriteLine(li.Line);
}
}
}
catch (IOException)
{
// If file is temporarily locked, re-enqueue and try next flush.
foreach (var li in grp)
{
_queue.Enqueue(li);
}
}
catch
{
// Drop lines on unexpected errors (best-effort logging).
}
}
}
finally
{
if (mutexTaken)
{
try { _globalMutex.ReleaseMutex(); } catch { }
}
}
}
}
}

39
MachineScope.cs Normal file
View File

@ -0,0 +1,39 @@
using MySql.Data.MySqlClient;
using System;
using System.Configuration;
namespace HanvonF710XAttendanceService
{
internal static class MachineScope
{
public const string MachineTypeHanvon = "HANVON";
public static string GetDeviceType()
{
return ConfigurationManager.AppSettings["DEVICE_TYPE"] ?? "HW_HDCP";
}
public static string GetDeviceUsername()
{
return ConfigurationManager.AppSettings["DEVICE_USERNAME"] ?? "admin";
}
public static string GetDevicePassword()
{
return ConfigurationManager.AppSettings["DEVICE_PASSWORD"] ?? "12345678";
}
public static bool IsHanvonMachineType(string machineType)
{
return !string.IsNullOrWhiteSpace(machineType)
&& string.Equals(machineType.Trim(), MachineTypeHanvon, StringComparison.OrdinalIgnoreCase);
}
public static string BuildMachineTypeWhereClause(MySqlCommand cmd, string columnName = "machine_type", string paramPrefix = "mt")
{
string p = "@" + paramPrefix;
cmd.Parameters.AddWithValue(p, MachineTypeHanvon);
return $"UPPER({columnName}) = {p}";
}
}
}

1209
Program.cs Normal file

File diff suppressed because it is too large Load Diff

60
ProjectInstaller.Designer.cs generated Normal file
View File

@ -0,0 +1,60 @@
namespace HanvonF710XAttendanceService
{
partial class ProjectInstaller
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.serviceProcessInstaller1 = new System.ServiceProcess.ServiceProcessInstaller();
this.serviceInstaller1 = new System.ServiceProcess.ServiceInstaller();
//
// serviceProcessInstaller1
//
this.serviceProcessInstaller1.Account = System.ServiceProcess.ServiceAccount.LocalSystem;
this.serviceProcessInstaller1.Password = null;
this.serviceProcessInstaller1.Username = null;
//
// serviceInstaller1
//
this.serviceInstaller1.Description = "Hanvon F710X attendance, user sync, and face template service";
this.serviceInstaller1.DisplayName = "Hanvon F710X Attendance Service";
this.serviceInstaller1.ServiceName = "HanvonF710XAttendanceService";
//
// ProjectInstaller
//
this.Installers.AddRange(new System.Configuration.Install.Installer[] {
this.serviceProcessInstaller1,
this.serviceInstaller1});
}
#endregion
private System.ServiceProcess.ServiceProcessInstaller serviceProcessInstaller1;
private System.ServiceProcess.ServiceInstaller serviceInstaller1;
}
}

19
ProjectInstaller.cs Normal file
View File

@ -0,0 +1,19 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Configuration.Install;
using System.Linq;
using System.Threading.Tasks;
namespace HanvonF710XAttendanceService
{
[RunInstaller(true)]
public partial class ProjectInstaller : System.Configuration.Install.Installer
{
public ProjectInstaller()
{
InitializeComponent();
}
}
}

129
ProjectInstaller.resx Normal file
View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="serviceProcessInstaller1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="serviceInstaller1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>196, 17</value>
</metadata>
<metadata name="$this.TrayLargeIcon" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
</root>

View File

@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("HanvonF710XAttendanceService")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("HanvonF710XAttendanceService")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("00db44af-5145-417f-9dc7-8490ad30ce49")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

95
TemplateParser.cs Normal file
View File

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace HanvonF710XAttendanceService
{
internal static class TemplateParser
{
// Mirrors UTS MainForm.removeReturn(...)
public static bool TryExtractTemplatePayloadFromGetEmployeeResponse(string getEmployeeResponse, out string payload)
{
payload = null;
if (string.IsNullOrWhiteSpace(getEmployeeResponse))
{
return false;
}
string[] splitData = getEmployeeResponse.Split('(');
for (int i = 0; i < splitData.Length; i++)
{
string s = splitData[i];
if (!s.Contains("Return"))
{
string updatedData = s.Replace("result=\"success\" ", "");
string[] updatedResult = updatedData.Split(')');
payload = updatedResult.Length > 0 ? updatedResult[0] : null;
payload = string.IsNullOrWhiteSpace(payload) ? null : payload;
return payload != null;
}
}
return false;
}
// Mirrors UTS MainForm.SetEmployeeInfo(...) string transformation
public static bool TryBuildSetEmployeeCommand(string templatePayloadOrGetEmployeeResponse, out string setEmployeeCommand)
{
setEmployeeCommand = null;
if (string.IsNullOrWhiteSpace(templatePayloadOrGetEmployeeResponse))
{
return false;
}
string[] splitData = templatePayloadOrGetEmployeeResponse.Split('(');
for (int i = 0; i < splitData.Length; i++)
{
string s = splitData[i];
if (!s.Contains("Return"))
{
string updatedData = s.Replace("result=\"success\" ", "");
string[] updatedResult = updatedData.Split(')');
var inner = updatedResult.Length > 0 ? updatedResult[0] : null;
if (!string.IsNullOrWhiteSpace(inner))
{
setEmployeeCommand = "SetEmployee(" + inner + ")";
return true;
}
}
}
return false;
}
public static List<string> ParseEmployeeIdsFromGetEmployeeIdResponse(string getEmployeeIdResponse)
{
if (string.IsNullOrWhiteSpace(getEmployeeIdResponse))
{
return new List<string>();
}
// Typical responses contain many occurrences like: id="6286"
var matches = Regex.Matches(getEmployeeIdResponse, "\\bid=\"([^\"]+)\"");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in matches)
{
if (!m.Success || m.Groups.Count < 2)
{
continue;
}
var id = m.Groups[1].Value?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
ids.Add(id);
}
return ids.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
}
}

865
TemplateTransferRunner.cs Normal file
View File

@ -0,0 +1,865 @@
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
namespace HanvonF710XAttendanceService
{
internal enum TemplateTransferMode
{
DEVICE_TO_DB,
DB_TO_DEVICE,
DEVICE_TO_DEVICE
}
internal sealed class TemplateTransferRunner
{
private readonly AttendanceMachineDAO _machineDao;
private readonly AttendanceMachineFaceTemplateDAO _templateDao;
public TemplateTransferRunner(AttendanceMachineDAO machineDao, AttendanceMachineFaceTemplateDAO templateDao)
{
_machineDao = machineDao ?? throw new ArgumentNullException(nameof(machineDao));
_templateDao = templateDao ?? throw new ArgumentNullException(nameof(templateDao));
}
public void RunJobs(DeviceSettingsConfig settings, TemplateTransferMode mode, MySqlConnection connection, List<string> logs)
{
if (mode == TemplateTransferMode.DEVICE_TO_DB)
{
// TEMPLATE SAVE (DEVICE_TO_DB) now follows the same scope model
// as attendance and machine user sync (CENTRAL vs SITE).
var machinesInScope = Program.GetMachinesForAttendanceAndUsers(_machineDao, connection, logs);
var scope = (ConfigurationManager.AppSettings["ATTENDANCE_SCOPE"] ?? "CENTRAL").ToUpperInvariant();
logs.Add($"Template DEVICE_TO_DB scope={scope}. machines_in_scope={machinesInScope.Count}");
bool autoDistributeAfterSave = GetBoolAppSetting("TEMPLATE_AUTO_DISTRIBUTE_AFTER_DEVICE_TO_DB", true);
if (machinesInScope.Count == 0)
{
logs.Add("Template DEVICE_TO_DB: no machines found in scope.");
return;
}
foreach (var m in machinesInScope)
{
if (!_machineDao.TryGetAttendanceMachineByIp(connection, m.MachineIp, out var sourceMachine, out var sourceType))
{
logs.Add($"[TemplateSave] source machine not found in DB. machine_id={m.MachineId} ip={m.MachineIp}");
continue;
}
if (!MachineScope.IsHanvonMachineType(sourceType))
{
logs.Add($"[TemplateSave] source machine_type={sourceType} not supported for Hanvon F710X service. machine_id={sourceMachine.MachineId} ip={sourceMachine.MachineIp}. Skipping.");
continue;
}
logs.Add($"[TemplateSave] DEVICE_TO_DB start. machine_id={sourceMachine.MachineId} ip={sourceMachine.MachineIp}");
bool saveOk = SaveTemplateDbFromDevice(sourceMachine, connection, logs, $"TemplateSave:{sourceMachine.MachineIp}");
if (saveOk && autoDistributeAfterSave)
{
DistributeMissingTemplatesAfterDeviceToDb(sourceMachine, machinesInScope, logs);
}
else if (!saveOk)
{
LogDistributionLine($"[TemplateDistribution] skipped for source_machine={sourceMachine.MachineId} ip={sourceMachine.MachineIp} (DEVICE_TO_DB did not complete; device unreachable or no employee list).", logs);
}
}
return;
}
if (settings?.TemplateTransfer == null || !settings.TemplateTransfer.Enabled)
{
logs.Add("TemplateTransfer is disabled in DeviceSettings.");
return;
}
var enabledJobs = settings.TemplateTransfer.Jobs.Where(j => j.Enabled).ToList();
if (enabledJobs.Count == 0)
{
logs.Add("No enabled TemplateTransfer jobs found.");
return;
}
foreach (var job in enabledJobs)
{
logs.Add($"[TemplateJob {job.Id}] Mode={mode} SourceIp={job.SourceIp} Targets={job.TargetIps.Count}");
if (!_machineDao.TryGetAttendanceMachineByIp(connection, job.SourceIp, out var sourceMachine, out var sourceType))
{
logs.Add($"[TemplateJob {job.Id}] Source machine not found in DB. ip={job.SourceIp}");
continue;
}
if (!MachineScope.IsHanvonMachineType(sourceType))
{
logs.Add($"[TemplateJob {job.Id}] Source machine_type={sourceType} is not a Hanvon F710X device. Skipping.");
continue;
}
var targetMachines = new List<(AttendanceMachine Machine, string Type)>();
foreach (var targetIp in job.TargetIps.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (!_machineDao.TryGetAttendanceMachineByIp(connection, targetIp, out var targetMachine, out var targetType))
{
logs.Add($"[TemplateJob {job.Id}] Target machine not found in DB. ip={targetIp}");
continue;
}
if (!MachineScope.IsHanvonMachineType(targetType))
{
logs.Add($"[TemplateJob {job.Id}] Target ip={targetIp} machine_type={targetType} is not a Hanvon F710X device. Skipping this target.");
continue;
}
targetMachines.Add((targetMachine, targetType));
}
if (targetMachines.Count == 0)
{
logs.Add($"[TemplateJob {job.Id}] No valid targets to transfer to.");
continue;
}
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var employeeIds, logs, job.Id))
{
continue;
}
if (employeeIds.Count == 0)
{
logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs.");
continue;
}
if (mode == TemplateTransferMode.DB_TO_DEVICE)
{
TransferDbToDevice(job, employeeIds, targetMachines, connection, logs);
}
else if (mode == TemplateTransferMode.DEVICE_TO_DEVICE)
{
TransferDeviceToDevice(job.Id, sourceMachine, employeeIds, targetMachines, logs);
}
}
}
private bool TryGetEmployeeIdsFromDevice(AttendanceMachine machine, out List<string> employeeIds, List<string> logs, string jobId)
{
employeeIds = new List<string>();
if (DeviceProtocol.UseHttp())
{
var client = HanvonHttpApiClient.ForMachine(machine);
if (!client.TryLogin(out string loginErr))
{
logs.Add($"[TemplateJob {jobId}] HTTP login failed. source={machine.MachineIp} err={loginErr}");
Program.RecordUnreachableMachine(machine, "TemplateSave HTTP: " + loginErr);
return false;
}
var users = client.GetUserList(out string listErr);
if (!string.IsNullOrEmpty(listErr) && (users == null || users.Count == 0))
{
logs.Add($"[TemplateJob {jobId}] getuserlist failed. source={machine.MachineIp} err={listErr}");
Program.RecordUnreachableMachine(machine, "TemplateSave HTTP getuserlist: " + listErr);
return false;
}
foreach (var u in users)
{
// Prefer device enroll id for getuserinfo; also keep serial for DB key.
if (!string.IsNullOrWhiteSpace(u.DeviceUserId))
employeeIds.Add(u.DeviceUserId);
else if (!string.IsNullOrWhiteSpace(u.SerialNumber))
employeeIds.Add(u.SerialNumber);
}
logs.Add($"[TemplateJob {jobId}] HTTP getuserlist ok. parsed={employeeIds.Count}");
return true;
}
var callback = new CallBack(Program.BeCalled);
string devInfo = machine.GetDeviceInfo();
string cmd = "GetEmployeeID()";
string response = "";
uint recvLen = 0;
if (Program.test(devInfo, devInfo.Length, cmd, cmd.Length, ref response, ref recvLen, callback) != 0)
{
logs.Add($"[TemplateJob {jobId}] GetEmployeeID() failed. source={machine.MachineIp}");
Program.RecordUnreachableMachine(machine, "TemplateSave: device not connected");
return false;
}
employeeIds = TemplateParser.ParseEmployeeIdsFromGetEmployeeIdResponse(response);
try
{
logs.Add($"[TemplateJob {jobId}] GetEmployeeID() ok. total={Program.GetLoopCnt(response)} parsed={employeeIds.Count}");
}
catch
{
logs.Add($"[TemplateJob {jobId}] GetEmployeeID() ok. parsed={employeeIds.Count}");
}
return true;
}
private bool SaveTemplateDbFromDevice(AttendanceMachine sourceMachine, MySqlConnection connection, List<string> logs, string jobId)
{
var swTotal = Stopwatch.StartNew();
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var employeeIds, logs, jobId))
{
return false;
}
int inserted = 0;
int skippedExisting = 0;
int failed = 0;
bool capturedRaw = false;
int totalIds = employeeIds.Count;
int batchSize = ChooseBatchSize(totalIds);
var batches = SplitIntoBatches(employeeIds, batchSize).ToList();
logs.Add($"[TemplateSave] machine_id={sourceMachine.MachineId} ip={sourceMachine.MachineIp} total_ids={totalIds} batch_size={batchSize} total_batches={batches.Count}");
for (int bi = 0; bi < batches.Count; bi++)
{
var swBatch = Stopwatch.StartNew();
int attempted = 0;
int bInserted = 0;
int bExisting = 0;
int bFailed = 0;
foreach (var empId in batches[bi])
{
attempted++;
try
{
if (_templateDao.ExistsBySerialNo(connection, empId))
{
skippedExisting++;
bExisting++;
// Simple log line for SaveFaceTemplate logs (keep deep logs in InternalLogs as-is).
logs.Add($"Serial number-- {empId} already exists in the database");
continue;
}
if (DeviceProtocol.UseHttp())
{
if (!int.TryParse(empId, out int enrollId))
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] invalid enroll id for HTTP. emp={empId}");
continue;
}
var httpClient = HanvonHttpApiClient.ForMachine(sourceMachine);
if (!httpClient.TryGetFaceRecord(enrollId, out string recordB64, out string empName, out string faceErr))
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] HTTP getuserinfo failed. emp={empId} err={faceErr}");
continue;
}
// Store under employee name/serial when available.
string serialKey = !string.IsNullOrWhiteSpace(empName) ? empName.Trim() : empId;
if (_templateDao.ExistsBySerialNo(connection, serialKey))
{
skippedExisting++;
bExisting++;
logs.Add($"Serial number-- {serialKey} already exists in the database");
continue;
}
var faceBytes = Encoding.UTF8.GetBytes(recordB64);
int faceRows = _templateDao.InsertTemplate(connection, serialKey, faceBytes, DateTime.Now);
if (faceRows > 0) { inserted++; bInserted++; }
else { failed++; bFailed++; }
continue;
}
var callback = new CallBack(Program.BeCalled);
string devInfo = sourceMachine.GetDeviceInfo();
string cmd = "GetEmployee(id=\"" + empId + "\")";
string response = "";
uint recvLen = 0;
if (Program.test(devInfo, devInfo.Length, cmd, cmd.Length, ref response, ref recvLen, callback) != 0)
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] GetEmployee failed. emp={empId} source={sourceMachine.MachineIp}");
continue;
}
if (!capturedRaw && response != null && response.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0)
{
capturedRaw = true;
WriteRawTemplateLog($"[TemplateJob {jobId}] GetEmployee raw response emp={empId}", response);
}
if (response == null || response.IndexOf("success", StringComparison.OrdinalIgnoreCase) < 0)
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] GetEmployee returned non-success. emp={empId} source={sourceMachine.MachineIp}");
continue;
}
if (!TemplateParser.TryExtractTemplatePayloadFromGetEmployeeResponse(response, out var payload))
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] Failed to parse template payload. emp={empId}");
continue;
}
var bytes = Encoding.UTF8.GetBytes(payload);
int rows = _templateDao.InsertTemplate(connection, empId, bytes, DateTime.Now);
if (rows > 0)
{
inserted++;
bInserted++;
}
else
{
failed++;
bFailed++;
}
}
catch (Exception ex)
{
failed++;
bFailed++;
logs.Add($"[TemplateJob {jobId}] SaveTemplateDB error. emp={empId} err={ex.Message}");
}
}
swBatch.Stop();
logs.Add($"[TemplateSaveBatch] machine_id={sourceMachine.MachineId} ip={sourceMachine.MachineIp} batch={bi + 1}/{batches.Count} attempted={attempted} inserted={bInserted} existing={bExisting} failed={bFailed} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}");
}
swTotal.Stop();
logs.Add($"[TemplateSave] machine_id={sourceMachine.MachineId} ip={sourceMachine.MachineIp} completed inserted={inserted} existing={skippedExisting} failed={failed} duration_sec={Math.Round(swTotal.Elapsed.TotalSeconds, 1)}");
logs.Add($"[TemplateJob {jobId}] SaveTemplateDB done. inserted={inserted} existing={skippedExisting} failed={failed}");
return true;
}
/// <summary>
/// After DEVICE_TO_DB, distribute templates to peer Nedo machines using registration-based target selection:
/// only push an employee template to machines where that employee is registered (attendance_machine_user, is_deleted=0).
/// Central truth is still DB templates; device lists are only used to skip already-present templates.
/// Uses a dedicated DB connection for query + template fetch (no shared connection with attendance job).
/// </summary>
private void DistributeMissingTemplatesAfterDeviceToDb(AttendanceMachine sourceMachine, List<AttendanceMachine> machinesInScope, List<string> logs)
{
if (sourceMachine == null || machinesInScope == null || machinesInScope.Count == 0)
{
return;
}
var peers = machinesInScope.Where(m => !IsSameScopedMachine(m, sourceMachine)).ToList();
if (peers.Count == 0)
{
LogDistributionLine($"[TemplateDistributionPlan] source_machine={sourceMachine.MachineId} ip={sourceMachine.MachineIp} no peer Hanvon machines in scope; skipping.", logs);
return;
}
try
{
using (var distConn = new MySqlConnection(Program.GetConnectionString()))
{
distConn.Open();
// Targets to consider are already filtered by ATTENDANCE_SCOPE + DeviceSettings in machinesInScope.
// Now apply registration-based selection inside DB queries.
var targets = new List<AttendanceMachine>();
foreach (var t in peers)
{
if (t == null) continue;
if (string.IsNullOrWhiteSpace(t.MachineId) || string.IsNullOrWhiteSpace(t.MachineIp))
{
continue;
}
targets.Add(t);
}
if (targets.Count == 0)
{
LogDistributionLine("[TemplateDistribution] no valid peer Hanvon targets in scope; skipping.", logs);
return;
}
var allowedMachineIds = targets
.Select(t => t.MachineId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// 1) For each target machine, get already-present employee ids from the device,
// then get DB templates only for employees registered on that specific target.
// 2) missing = registeredTemplates - onDeviceIds
var toSendByTarget = new Dictionary<string, Dictionary<string, byte[]>>(StringComparer.OrdinalIgnoreCase);
var missingSerialsUnion = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var targetMachine in targets)
{
if (!TryGetEmployeeIdsFromDevice(targetMachine, out var onDeviceIds, logs, "TemplateDist:" + targetMachine.MachineIp))
{
continue; // unreachable or device communication error already logged
}
var onDevice = new HashSet<string>(onDeviceIds, StringComparer.OrdinalIgnoreCase);
// Registration-based: only templates whose employee is registered to THIS target machine.
var registeredTemplates = _templateDao.GetActiveTemplatesForRegisteredMachine(distConn, targetMachine.MachineId);
var missing = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in registeredTemplates)
{
if (!onDevice.Contains(kvp.Key))
{
missing[kvp.Key] = kvp.Value;
missingSerialsUnion.Add(kvp.Key);
}
}
toSendByTarget[targetMachine.MachineId] = missing;
}
if (missingSerialsUnion.Count == 0)
{
LogDistributionLine("[TemplateDistribution] nothing missing on peer Hanvon machines; registration-based distribution skipped.", logs);
return;
}
// Build serial -> list of registered target machine ids (restricted to current scope targets).
var serialToRegisteredTargets = _templateDao.GetRegisteredTargetMachineIdsForSerialNos(
distConn,
missingSerialsUnion.ToList(),
allowedMachineIds);
// Pre-compute pushed_to / skipped_existing per serial (based on which targets have missing templates).
var pushedTargetsBySerial = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var serial in missingSerialsUnion)
{
pushedTargetsBySerial[serial] = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
foreach (var kvp in toSendByTarget)
{
var targetMachineId = kvp.Key;
var missingTemplates = kvp.Value;
if (missingTemplates == null || missingTemplates.Count == 0) continue;
foreach (var serial in missingTemplates.Keys)
{
if (pushedTargetsBySerial.TryGetValue(serial, out var set))
{
set.Add(targetMachineId);
}
}
}
// Log registration targets + per-serial distribution summary.
foreach (var serial in missingSerialsUnion)
{
int registeredTargets = 0;
if (serialToRegisteredTargets.TryGetValue(serial, out var targetList) && targetList != null)
{
registeredTargets = targetList.Count;
}
int pushedTo = 0;
if (pushedTargetsBySerial.TryGetValue(serial, out var pushedSet) && pushedSet != null)
{
pushedTo = pushedSet.Count;
}
int skippedExisting = Math.Max(0, registeredTargets - pushedTo);
LogDistributionLine($"[TemplateRegistrationTargets] serial_no={serial} registered_targets={registeredTargets}", logs);
LogDistributionLine($"[TemplateDistributionSummary] serial_no={serial} total_targets={registeredTargets} pushed_to={pushedTo} skipped_existing={skippedExisting}", logs);
}
// 3) Push missing templates in batches per target machine.
foreach (var targetMachine in targets)
{
if (!toSendByTarget.TryGetValue(targetMachine.MachineId, out var missingTemplates) ||
missingTemplates == null ||
missingTemplates.Count == 0)
{
continue;
}
int batchSize = ChooseBatchSize(missingTemplates.Count);
int totalBatches = (missingTemplates.Count + batchSize - 1) / batchSize;
LogDistributionLine(
$"[TemplateDistributionPlan] target_machine={targetMachine.MachineId} missing_templates={missingTemplates.Count} batch_size={batchSize} total_batches={totalBatches}",
logs);
foreach (var serial in missingTemplates.Keys)
{
LogDistributionLine(
$"[TemplateDistributionPlan] serial_no={serial} target_machine={targetMachine.MachineId} already_present=false will_push=true",
logs);
}
PushDistributionBatches(targetMachine, missingTemplates, batchSize, logs, out _);
}
}
}
catch (Exception ex)
{
LogDistributionLine("[TemplateDistribution] failed: " + ex.ToString(), logs);
}
}
private static void LogDistributionLine(string message, List<string> logs)
{
logs?.Add(message);
}
private static bool IsSameScopedMachine(AttendanceMachine a, AttendanceMachine b)
{
if (a == null || b == null) return false;
if (!string.IsNullOrWhiteSpace(a.MachineId) && !string.IsNullOrWhiteSpace(b.MachineId))
{
return string.Equals(a.MachineId.Trim(), b.MachineId.Trim(), StringComparison.OrdinalIgnoreCase);
}
return string.Equals(a.MachineIp?.Trim(), b.MachineIp?.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Send only missing templates; does not issue SetEmployee for serials already on device.</summary>
private int PushDistributionBatches(AttendanceMachine target, Dictionary<string, byte[]> templates, int batchSize, List<string> logs, out int failedTotal)
{
failedTotal = 0;
if (templates == null || templates.Count == 0)
{
return 0;
}
bool verifyAfterPush = GetBoolAppSetting("TEMPLATE_VERIFY_AFTER_PUSH", true);
int delayMs = GetIntAppSetting("TEMPLATE_DEVICE_DELAY_MS", 0);
string targetDevInfo = target.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
var pairs = templates.ToList();
var batches = SplitIntoBatches(pairs, batchSize).ToList();
int sent = 0;
for (int bi = 0; bi < batches.Count; bi++)
{
var swBatch = Stopwatch.StartNew();
int attempted = 0;
int success = 0;
int failed = 0;
foreach (var kvp in batches[bi])
{
attempted++;
string empId = kvp.Key;
string templateStr = Encoding.UTF8.GetString(kvp.Value ?? Array.Empty<byte>());
if (!TemplateParser.TryBuildSetEmployeeCommand(templateStr, out var setCmd))
{
failed++;
failedTotal++;
LogDistributionLine($"[TemplateDistribution] target={target.MachineIp} emp={empId} send=FAIL reason=BuildSetEmployee", logs);
continue;
}
string response = "";
uint recvLen = 0;
int rc = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref response, ref recvLen, callback);
if (rc != 0 || (response != null && response.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0))
{
failed++;
failedTotal++;
LogDistributionLine($"[TemplateDistribution] target={target.MachineIp} emp={empId} send=FAIL rc={rc}", logs);
continue;
}
success++;
sent++;
if (verifyAfterPush)
{
string verifyCmd = "GetEmployee(id=\"" + empId + "\")";
string verifyResp = "";
uint verifyRecvLen = 0;
int vrc = Program.test(targetDevInfo, targetDevInfo.Length, verifyCmd, verifyCmd.Length, ref verifyResp, ref verifyRecvLen, callback);
bool ok = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0;
LogDistributionLine(ok
? $"[TemplateDistribution] target={target.MachineIp} emp={empId} verify=OK"
: $"[TemplateDistribution] target={target.MachineIp} emp={empId} verify=FAIL rc={vrc}", logs);
}
}
if (delayMs > 0)
{
Thread.Sleep(delayMs);
}
swBatch.Stop();
LogDistributionLine(
$"[TemplateDistributionBatch] target_machine={target.MachineId} ip={target.MachineIp} batch={bi + 1}/{batches.Count} attempted={attempted} success={success} failed={failed} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}",
logs);
}
return sent;
}
private static int ChooseBatchSize(int total)
{
if (total <= 0) return 1;
if (total <= 20) return 5;
if (total <= 100) return 10;
if (total <= 300) return 25;
if (total <= 1000) return 50;
return 100;
}
private static IEnumerable<List<T>> SplitIntoBatches<T>(IReadOnlyList<T> items, int batchSize)
{
if (items == null || items.Count == 0) yield break;
if (batchSize <= 0) batchSize = 1;
for (int i = 0; i < items.Count; i += batchSize)
{
int count = Math.Min(batchSize, items.Count - i);
var batch = new List<T>(count);
for (int j = 0; j < count; j++)
{
batch.Add(items[i + j]);
}
yield return batch;
}
}
private void TransferDbToDevice(TemplateTransferJob job, List<string> employeeIds, List<(AttendanceMachine Machine, string Type)> targets, MySqlConnection connection, List<string> logs)
{
bool verifyAfterPush = GetBoolAppSetting("TEMPLATE_VERIFY_AFTER_PUSH", true);
int delayMs = GetIntAppSetting("TEMPLATE_DEVICE_DELAY_MS", 0);
var effectiveEmployeeIds = employeeIds ?? new List<string>();
// Optional filtering by EmpIds configured on the job (for safe, small-scope tests)
if (job.EmpIds != null && job.EmpIds.Count > 0)
{
var filter = new HashSet<string>(job.EmpIds, StringComparer.OrdinalIgnoreCase);
effectiveEmployeeIds = effectiveEmployeeIds.Where(id => filter.Contains(id)).ToList();
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} employees_filtered={effectiveEmployeeIds.Count}/{employeeIds.Count}");
}
var templates = _templateDao.GetActiveTemplatesBySerialNos(connection, effectiveEmployeeIds);
logs.Add($"[TemplateJob {job.Id}] DB templates fetched. count={templates.Count}");
// Log whether each requested employee has a template in DB
foreach (var empId in effectiveEmployeeIds)
{
if (templates.ContainsKey(empId))
{
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} emp={empId} template=FOUND");
}
else
{
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} emp={empId} template=NOT_FOUND");
}
}
// Batch per target machine.
var templatePairs = templates.ToList();
int totalTemplates = templatePairs.Count;
int batchSize = ChooseBatchSize(totalTemplates);
var batches = SplitIntoBatches(templatePairs, batchSize).ToList();
foreach (var target in targets)
{
string targetDevInfo = target.Machine.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
int ok = 0;
int fail = 0;
int verifyOk = 0;
int verifyFail = 0;
logs.Add($"[TemplateTransfer] job={job.Id} target_ip={target.Machine.MachineIp} total_templates={totalTemplates} batch_size={batchSize} total_batches={batches.Count}");
for (int bi = 0; bi < batches.Count; bi++)
{
var swBatch = Stopwatch.StartNew();
int bAttempted = 0;
int bOk = 0;
int bFail = 0;
int bVerifyOk = 0;
int bVerifyFail = 0;
foreach (var kvp in batches[bi])
{
bAttempted++;
string empId = kvp.Key;
string templateStr = Encoding.UTF8.GetString(kvp.Value ?? Array.Empty<byte>());
if (!TemplateParser.TryBuildSetEmployeeCommand(templateStr, out var setCmd))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] Build SetEmployee failed. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=BuildSetEmployee");
continue;
}
string response = "";
uint recvLen = 0;
int rc = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref response, ref recvLen, callback);
if (rc != 0 || (response != null && response.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] SetEmployee failed. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL rc={rc}");
}
else
{
ok++;
bOk++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK");
if (verifyAfterPush)
{
string verifyCmd = "GetEmployee(id=\"" + empId + "\")";
string verifyResp = "";
uint verifyRecvLen = 0;
int vrc = Program.test(targetDevInfo, targetDevInfo.Length, verifyCmd, verifyCmd.Length, ref verifyResp, ref verifyRecvLen, callback);
bool success = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0;
if (success)
{
verifyOk++;
bVerifyOk++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=OK");
}
else
{
verifyFail++;
bVerifyFail++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL rc={vrc}");
}
}
}
}
if (delayMs > 0)
{
System.Threading.Thread.Sleep(delayMs);
}
swBatch.Stop();
logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} ok={bOk} fail={bFail} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}");
}
if (verifyAfterPush)
{
logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE target={target.Machine.MachineIp} ok={ok} fail={fail} verify_ok={verifyOk} verify_fail={verifyFail}");
}
else
{
logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE target={target.Machine.MachineIp} ok={ok} fail={fail}");
}
}
}
private static bool GetBoolAppSetting(string key, bool defaultValue)
{
try
{
var val = ConfigurationManager.AppSettings[key];
if (string.IsNullOrWhiteSpace(val)) return defaultValue;
return bool.TryParse(val.Trim(), out var parsed) ? parsed : defaultValue;
}
catch
{
return defaultValue;
}
}
private static int GetIntAppSetting(string key, int defaultValue)
{
try
{
var val = ConfigurationManager.AppSettings[key];
if (string.IsNullOrWhiteSpace(val)) return defaultValue;
return int.TryParse(val.Trim(), out var parsed) ? parsed : defaultValue;
}
catch
{
return defaultValue;
}
}
private void TransferDeviceToDevice(string jobId, AttendanceMachine source, List<string> employeeIds, List<(AttendanceMachine Machine, string Type)> targets, List<string> logs)
{
string sourceDevInfo = source.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
foreach (var target in targets)
{
int ok = 0;
int fail = 0;
string targetDevInfo = target.Machine.GetDeviceInfo();
foreach (var empId in employeeIds)
{
string getCmd = "GetEmployee(id=\"" + empId + "\")";
string getResp = "";
uint recvLen = 0;
int rc = Program.test(sourceDevInfo, sourceDevInfo.Length, getCmd, getCmd.Length, ref getResp, ref recvLen, callback);
if (rc != 0 || getResp == null || getResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) < 0)
{
fail++;
continue;
}
if (!TemplateParser.TryBuildSetEmployeeCommand(getResp, out var setCmd))
{
fail++;
continue;
}
string setResp = "";
uint setRecvLen = 0;
int rc2 = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref setResp, ref setRecvLen, callback);
if (rc2 != 0 || (setResp != null && setResp.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0))
{
fail++;
}
else
{
ok++;
}
}
logs.Add($"[TemplateJob {jobId}] DEVICE_TO_DEVICE target={target.Machine.MachineIp} ok={ok} fail={fail}");
}
}
private static void WriteRawTemplateLog(string header, string raw)
{
try
{
string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\TemplateRawLogs\\TemplateRaw_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt";
LogService.EnqueueLine(filepath, "----- " + DateTime.Now + " -----");
LogService.EnqueueLine(filepath, header);
LogService.EnqueueLine(filepath, raw ?? "");
LogService.EnqueueLine(filepath, "");
}
catch
{
// ignore logging failures
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log File Browser</title>
<script src="resources/plugins/jquery.1.12.4.min.js"></script>
<script src="resources/plugins/bootstrap-3.3.0/js/bootstrap.min.js"></script>
<script src="resources/plugins/waves-0.7.5/waves.min.js"></script>
<script src="resources/plugins/jquery.cookie.js"></script>
<script src="resources/js/notify.js"></script>
<script src="resources/js/common.js"></script>
<script src="resources/js/lang.js"></script>
<style>
* {
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
margin: 0 auto;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e6ed;
}
h1 {
margin: 0;
font-size: 24px;
color: #2c3e50;
flex-grow: 1;
}
.btn {
padding: 8px 16px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #2980b9;
}
.btn.back {
background-color: #7f8c8d;
}
.btn.back:hover {
background-color: #34495e;
}
#file-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
overflow: hidden;
}
.list-header {
padding: 12px 20px;
background-color: #3498db;
color: white;
font-weight: bold;
display: grid;
grid-template-columns: 1fr 100px;
}
.file-item {
padding: 12px 20px;
border-bottom: 1px solid #eee;
display: grid;
grid-template-columns: 1fr 100px;
cursor: pointer;
transition: background-color 0.2s;
}
.file-item:hover {
background-color: #f8f9fa;
}
.file-name {
font-weight: 500;
color: #2c3e50;
word-break: break-all;
}
.file-size {
color: #7f8c8d;
text-align: right;
font-size: 0.9em;
}
#file-content {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
padding: 20px;
margin-top: 20px;
display: none;
}
.content-header {
display: flex;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
#content-title {
margin: 0;
font-size: 20px;
color: #2c3e50;
flex-grow: 1;
word-break: break-all;
}
#file-text {
white-space: pre-wrap;
font-family: monospace;
font-size: 14px;
line-height: 1.5;
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
max-height: 60vh;
overflow: auto;
}
.empty-message {
text-align: center;
padding: 40px 20px;
color: #7f8c8d;
}
.loading {
padding: 20px;
text-align: center;
color: #7f8c8d;
}
.content-header {
display: flex;
gap: 16px; /* 控制按钮间距,按需调整 */
}
/* 新增网格布局样式 */
#list-container {
display: grid;
grid-template-columns: repeat(5, 1fr); /* 一行4列 */
gap: 16px; /* 项目间距 */
padding: 16px;
}
.file-item {
background: white;
border: 1px solid #e0e6ed;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden; /* 防止内容溢出 */
text-overflow: ellipsis;
}
.file-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transform: translateY(-2px);
border-color: #3498db;
}
.file-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
/* 响应式适配 */
@media (max-width: 1200px) {
#list-container {
grid-template-columns: repeat(3, 1fr); /* 中等屏幕显示3列 */
}
}
@media (max-width: 768px) {
#list-container {
grid-template-columns: repeat(2, 1fr); /* 小屏幕显示2列 */
}
}
@media (max-width: 480px) {
#list-container {
grid-template-columns: 1fr; /* 超小屏幕显示1列 */
}
}
</style>
</head>
<body>
<header>
<h1>Log File Browser</h1>
<button id="refresh-btn" class="btn">Refresh list</button>
</header>
<div id="file-list">
<div class="list-header">
<span>file name</span>
</div>
<div id="list-container">
<div class="loading">Loading file list...</div>
</div>
</div>
<div id="file-content">
<div class="content-header">
<h2 id="content-title"></h2>
<button id="copy-btn" class="btn">Copy</button>
<button id="back-btn" class="btn back">Return to list</button>
</div>
<pre id="file-text"></pre>
</div>
<script>
check_permission();
const listContainer = document.getElementById('list-container');
const fileContent = document.getElementById('file-content');
const contentTitle = document.getElementById('content-title');
const fileText = document.getElementById('file-text');
const refreshBtn = document.getElementById('refresh-btn');
const backBtn = document.getElementById('back-btn');
// 获取日志文件列表适配新版后端API
async function loglist_get() {
const payload = {
password: $.cookie("pwd"),
cmd: 'getdir',
path: '/data/log'
};
try {
const response = await $.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(payload)
});
// 转换后端数据结构为前端需要的格式
return {
status: response.result ? 'success' : 'error',
files: response.files.map(filename => ({ name: filename }))
};
} catch (error) {
console.error("API request failed:", error);
return {
status: 'error',
message: 'Failed to obtain file list'
};
}
}
async function renderFileList() {
listContainer.innerHTML = '<div class="loading">Loading file list...</div>';
const result = await loglist_get();
if (result.status === 'success' && result.files && result.files.length > 0) {
// 按文件名中的时间戳倒序排序(新文件在前)
result.files.sort((a, b) => {
// 提取文件名中的时间戳格式如20250731-15-36-55.log
const timestampA = a.name.split('.')[0]; // 去掉后缀
const timestampB = b.name.split('.')[0];
// 按字符串倒序排列(新文件在前)
return timestampB.localeCompare(timestampA);
});
let listHTML = '';
result.files.forEach(file => {
listHTML += `
<div class="file-item" data-filename="${file.name}">
<div class="file-name">${file.name}</div>
</div>
`;
});
listContainer.innerHTML = listHTML;
// 添加点击事件处理(保持不变)
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', async () => {
const filename = item.dataset.filename;
await showFileContent(filename);
});
});
} else {
const errorMsg = result.message || 'Log file not found';
listContainer.innerHTML = `<div class="empty-message">${errorMsg}</div>`;
}
}
// 获取文件内容连接真实API
async function fetchFileContent(filename) {
const payload = {
password: $.cookie("pwd"),
cmd: 'getfile',
path: '/data/log/'+filename
};
try {
const response = await $.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(payload)
});
return {
status: response.result ? 'success' : 'error',
file: response.base64
};
} catch (error) {
console.error("API request failed:", error);
return {
status: 'error',
message: 'Failed to getfile'
};
}
}
async function showFileContent(filename) {
fileContent.style.display = 'block';
contentTitle.textContent = filename;
fileText.textContent = 'loading...';
const result=await fetchFileContent(filename);
if (result.status === 'success')
{
const base64Data = result.file;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const decoder = new TextDecoder('utf-8');
const utf8Text = decoder.decode(bytes);
fileText.textContent = utf8Text;
}
else
fileText.textContent = `error: ${result.message || 'Unknown error'}`;
fileContent.scrollIntoView({ behavior: 'smooth' });
}
// 返回文件列表
function backToList() {
fileContent.style.display = 'none';
document.querySelector('header').scrollIntoView({ behavior: 'smooth' });
}
// 事件监听
refreshBtn.addEventListener('click', renderFileList);
backBtn.addEventListener('click', backToList);
// 初始化
document.addEventListener('DOMContentLoaded', renderFileList);
// 在JS中添加以下代码
// 获取复制按钮DOM
const copyBtn = document.getElementById('copy-btn');
// 复制功能实现
async function copyFileContent() {
const content = fileText.innerText;
try {
// 现代浏览器方案Clipboard API
if (navigator.clipboard) {
await navigator.clipboard.writeText(content);
notify('Copied to clipboard!');
return;
}
// 兼容旧浏览器的回退方案
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
// 选择并复制
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
notify('Copied!');
} catch (err) {
console.error('Copy failed:', err);
notify('Copy failed! Please copy manually');
}
}
// 事件绑定在DOMContentLoaded或初始化时添加
copyBtn.addEventListener('click', copyFileContent);
</script>
</body>
</html>

View File

@ -0,0 +1,783 @@
<!DOCTYPE HTML>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>log view</title>
<link href="resources/plugins/bootstrap-3.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<link href="resources/plugins/material-design-iconic-font-2.2.0/css/material-design-iconic-font.min.css" rel="stylesheet"/>
<link href="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.css" rel="stylesheet"/>
<link href="resources/plugins/waves-0.7.5/waves.min.css" rel="stylesheet"/>
<link href="resources/plugins/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/>
<link href="resources/css/common.css" rel="stylesheet"/>
<style>
.img-max-height-lg {
max-height: 240px !important;
}
.img-max-height-sm {
max-height: 320px !important;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-block;
color: #000; /* 默认颜色为黑色 */
transition: color 0.3s; /* 平滑过渡 */
white-space: nowrap; /* 防止文字换行 */
font-size: 14px; /* 可以根据需要调整字体大小 */
z-index: 1; /* 确保文字在进度条之上 */
}
</style>
</head>
<body>
<div id="main">
<div id="toolbar">
<a style="color: red;font-size: 1.2em;" class="waves-effect waves-button" href="javascript:;" onclick="clealogAction()"><i class="zmdi zmdi-delete"></i><span class="lang" key="UISTR_ALLLOG_DELETE">clean all logs</span></a>
<a style="font-size: 1.2em;" id="savelogs" class="waves-effect waves-button" href="javascript:;" onclick="generateAndDownloadCsv()"><i class="zmdi zmdi-download"></i><span class="lang" key="UISTR_SAVE_LOGS">save logs as csv</span></a>
<a style="font-size: 1.2em;" id="uploadlogs" class="waves-effect waves-button" href="javascript:;" onclick="uploadlogs()"><i class="zmdi zmdi-upload"></i><span class="lang" key="UISTR_UPLOAD_LOGS">upload log from CSV</span></a>
<a style="font-size: 1.2em;" id="saveasafd" class="waves-effect waves-button" href="javascript:;" onclick="generateAndDownloadAFD()"><i class="zmdi zmdi-download"></i><span>Save logs as AFD</span></a>
<!-- 添加说明 -->
<a style="color: red;font-size: 1.2em; display: block; margin-top: 0px;" class="waves-effect waves-button"><i class="zmdi zmdi-info-outline"></i><span class="lang" key="UISTR_LOGS_NOTE">logs note</span></a>
</div>
<table id="table"></table>
</div>
<!-- 选择时间栏 -->
<div id="createDialog" class="crudDialog" hidden>
<form>
<div class="form-group">
<label for="inputuserid" class="lang" key="UISTR_ENROLL_ID">user id</label>
<input id="inputuserid" class="form-control" type="text" oninput="validateEnrollid(this)">
</div>
<div class="form-group">
<label for="inputstartdate" class="lang" key="UISTR_START">start</label>
<input id="inputstartdate" type="date" class="form-control">
</div>
<div class="form-group">
<label for="inputenddate"class="lang" key="UISTR_END">end</label>
<input id="inputenddate" type="date" class="form-control">
</div>
</form>
</div>
<!-- 添加模态窗口 -->
<div class="modal fade" id="uploadModal" tabindex="-1" role="dialog" aria-labelledby="uploadModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadModalLabel">Upload Logs</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><span id="uploaded" class="lang" key="UISTR_UPLOADED">Uploaded</span></p>
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
<span class="progress-text">0%</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="lang btn btn-secondary" data-dismiss="modal" key="UISTR_CLOSE">Close</button>
</div>
</div>
</div>
</div>
<div id="resetDialog" class="crudDialog" hidden>
<form>
</form>
</div>
<script src="resources/plugins/jquery.1.12.4.min.js"></script>
<script src="resources/plugins/bootstrap-3.3.0/js/bootstrap.min.js"></script>
<script src="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.js"></script>
<script src="resources/plugins/waves-0.7.5/waves.min.js"></script>
<script src="resources/plugins/jquery-confirm/jquery-confirm.min.js"></script>
<script src="resources/plugins/jquery.cookie.js"></script>
<script src="resources/js/notify.js"></script>
<script src="resources/js/lang.js"></script>
<script src="resources/js/common.js"></script>
<script src="resources/js/admin.js"></script>
<script>
let deviceid = $.cookie("deviceid") || "1";
let DI_USERID_FORMAT = parseInt($.cookie("USERID_FORMAT")) || 0;
let fromDate = null;
let toDate = null;
let enrollIdstr = null;
let poithide = $.cookie("manufacturer") !== undefined && $.cookie("manufacturer")==="Piot Robotics";
check_permission();
lang_load().then(() => {
getfromtodate();
changetitle('time',lang.UISTR_TIME_SETTING);
changetitle('id',lang.UISTR_ENROLL_ID);
changetitle('name',lang.UISTR_UserName);
changetitle('mode',lang.UISTR_VERIFY_mode);
changetitle('event',lang.UISTR_EVENT);
changetitle('note',lang.UISTR_NOTE||'note');
changetitle('inout',lang.UISTR_INOUT||'inout');
changetitle('photo',lang.UISTR_Photo||'Photo');
changetitle('photouri',lang.UISTR_Photo_Url||'Photo');
changeSearchPlaceholder();
if (!$.cookie("CNPJ")) {
const element = document.getElementById("saveasafd");
if (element) {
element.remove();
}
}
}).catch(error => {
});
function convertEnrollId(strid) {
if (typeof strid === 'number') {
return strid > 0xFFFFFFFF ? strid.toString() : strid;
}
if (typeof strid === 'string' && /^\d+$/.test(strid) && DI_USERID_FORMAT != 2) {
const num = Number(strid);
return Number.isSafeInteger(num) ? num : strid;
}
return strid;
}
function formatNoteContent(note) {
if (note === null || note === undefined || note === "") return "";
if (typeof note === 'object') {
return Object.entries(note)
.map(([key, value]) => `${key}:${value}`)
.join(',');
}
if (typeof note === 'string') {
try {
const parsed = JSON.parse(note);
return Object.entries(parsed)
.map(([key, value]) => `${key}:${value}`)
.join(',');
} catch (e) {
return note;
}
}
return String(note);
}
function get_log(index = 0,fromDate, toDate, enrollIdstr) {
let send = new Object();
send["password"] = $.cookie("pwd");
send["cmd"] = 'getlog';
send["index"]=index;
if (enrollIdstr?.trim()) {
send["enrollid"] = convertEnrollId(enrollIdstr);
}
if (fromDate) {
send["from"] = fromDate;
}
if (toDate) {
send["to"] = toDate;
}
console.log(send);
$.ajax({
url: debugserver+'/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json) {
console.log("done", json);
let records = json.record;
let transformedData = [];
for (let record of records) {
let transformedRecord = {
id: record.enrollid,
name: record.name || "",
time: record.time || "",
mode: record.mode || "",
inout: record.inout || "",
event: record.event || "",
note: formatNoteContent(record.note),
photo: record.photourl ? 1 : 0,
photouri:record.photourl || ""
};
transformedData.push(transformedRecord);
}
$table.bootstrapTable('append', transformedData);
if (json.count > json.to) {
var nindex=json.to+1;
get_log(nindex);
}
},
error: function(error) {
console.log("error::::", error);
}
});
}
function getfromtodate() {
$.confirm({
type: 'blue',
animationSpeed: 300,
title: lang.UISTR_VIEWLOG,
content: $('#createDialog').html(),
buttons: {
confirm: {
text:lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
fromDate = this.$content.find('#inputstartdate').val();
toDate = this.$content.find('#inputenddate').val();
enrollIdstr = this.$content.find('#inputuserid').val();
get_log(0,fromDate, toDate, enrollIdstr);
}
}
}
});
}
var $table = $('#table');
$(function() {
$(document).on('focus', 'input[type="text"]', function() {
$(this).parent().find('label').addClass('active');
}).on('blur', 'input[type="text"]', function() {
if ($(this).val() == '') {
$(this).parent().find('label').removeClass('active');
}
});
// bootstrap table初始化
// http://bootstrap-table.wenzhixin.net.cn/zh-cn/documentation/
$table.bootstrapTable({
data:[],
height: getHeight(),
striped: true,
search: true,
searchOnEnterKey: true,
showRefresh: true,
//showToggle: true,
showColumns: true,
minimumCountColumns: 2,
//showPaginationSwitch: true,
clickToSelect: true,
detailView: true,
detailFormatter: 'detailFormatter',
pagination: true,
pageSize: 15,
pageList: [10, 15, 30, 50, 100],
paginationLoop: false,
classes: 'table table-hover table-no-bordered',
//sidePagination: 'server',
//silentSort: false,
smartDisplay: false,
idField: 'time',
sortName: 'time',
sortOrder: 'desc',
escape: true,
searchOnEnterKey: true,
maintainSelected: true,
toolbar: '#toolbar',
columns: [
{field: 'time', title: 'date time', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'id', title: 'user id', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'name', title: 'name', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'mode', title: 'verify mode', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'inout', title: 'inout', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'event', title: 'event', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'note', title: 'note', sortable: true, halign: 'center', width: '20%',align: 'center'},
{field: 'photo', title: 'photo', sortable: true, halign: 'center', width: '10%',align: 'center', formatter: 'actionfaceFormatter'},
{field: 'photouri', title: 'image', visible: false}
]
}).on('all.bs.table', function (e, name, args) {
$('[data-toggle="tooltip"]').tooltip();
$('[data-toggle="popover"]').popover();
});
});
$table.on('refresh.bs.table', function () {
$table.bootstrapTable('removeAll');
get_log(0,fromDate, toDate, enrollIdstr);
});
function validateEnrollid(inputElement) {
let maxValue, maxLength;
switch(DI_USERID_FORMAT) {
case 0:
inputElement.value = inputElement.value.replace(/\D/g, '');
maxValue = 99999999;
maxLength = 8;
break;
case 1:
inputElement.value = inputElement.value.replace(/[^0-9]/g, '');
maxValue = BigInt('9223372036854775807');
maxLength = 19;
break;
case 2:
maxLength = 32;
break;
}
if (inputElement.value.length > maxLength) {
inputElement.value = inputElement.value.slice(0, maxLength);
}
if (DI_USERID_FORMAT !== 2) {
const numericValue = DI_USERID_FORMAT === 1 ?
BigInt(inputElement.value || 0) :
parseInt(inputElement.value || 0, 10);
if (numericValue > maxValue) {
inputElement.value = maxValue.toString();
}
}
}
function generateAndDownloadCsv() {
const data = $table.bootstrapTable('getData');
const header = "No\tTMNo\tEnNo\tName\tEvent\tMode\tDateTime\r\n";
const csvContent = "data:text/csv;charset=utf-8,"
+ header
+ data.map((row, index) => `${index + 1}\t${deviceid}\t${row.id}\t${row.name}\t${row.inout}\t${row.mode}\t${row.time}`).join("\r\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
let paddedDeviceId = String(deviceid).padStart(3, '0');
link.setAttribute("href", encodedUri);
link.setAttribute("download", `alog_${paddedDeviceId}.CSV`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function updateProgressBar(total, uploaded,isdownload=false) {
const progressBar = $('.progress-bar');
var percentage = (uploaded / total) * 100;
progressBar.css('width', `${percentage}%`).attr('aria-valuenow', percentage);
if(isdownload)
$('#uploaded').text(lang.UISTR_DOWNLOADED+': '+uploaded+' / '+total);
else
$('#uploaded').text(lang.UISTR_UPLOADED+': '+uploaded+' / '+total);
// 更新进度条中的文字
const progressText = $('.progress-text');
progressText.text(`${Math.round(percentage)}%`);
// 根据进度调整文字颜色
if (percentage > 52) {
progressText.css('color', 'white');
} else {
progressText.css('color', 'black');
}
}
function uploadlogs() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = ".TXT, .CSV";
fileInput.onchange = function(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const csvContent = e.target.result;
processDataFromCSV(csvContent);
};
reader.readAsText(file);
}
};
fileInput.click();
}
function processDataFromCSV(csvContent) {
var allRows = [];
var lines = csvContent.split('\n');
for (var i = 0; i < lines.length; i++) {
if (i === 0) continue;
var columns = lines[i].split('\t');
if (columns.length >= 6) {
var row = {
id: convertEnrollId(columns[2]),
event: Number(columns[4]),
mode: Number(columns[5]),
time: columns[6].replace(/\r/g, '')
};
allRows.push(row);
}
}
const totalRows = allRows.length;
let currentIndex = 0;
function batchProcessRecords(startIndex, endIndex) {
const batch = allRows.slice(startIndex, endIndex);
set_logs(batch).then(() => {
currentIndex = endIndex;
updateProgressBar(totalRows, currentIndex);
if (currentIndex < totalRows) {
// 计算下次发送的数据量
const nextBatchSize = Math.min(500, Math.ceil(totalRows / 50));
const nextEndIndex = Math.min(currentIndex + nextBatchSize, totalRows);
batchProcessRecords(currentIndex, nextEndIndex);
} else {
$('#uploadModal').modal('hide');
onUploadSuccess();
}
}).catch(error => {
console.error('Error setting user info:', error);
alert('An error occurred while processing the records.');
$('#uploadModal').modal('hide');
});
}
// 初始化模态框
$('#uploadModalLabel').text(lang.UISTR_UPLOAD_LOGS);
$('#uploadModal').modal('show');
updateProgressBar(totalRows, currentIndex);
// 初始化处理
const firstBatchSize = Math.min(500, Math.ceil(totalRows / 50));
const firstEndIndex = Math.min(firstBatchSize, totalRows);
batchProcessRecords(0, firstEndIndex);
}
function set_logs(recordsArray) {
return new Promise((resolve, reject) => {
let send = new Object();
send["password"] = $.cookie("pwd");
send["cmd"] = 'setlogs';
send["count"] = recordsArray.length;
send["records"] = recordsArray;
console.log(send);
$.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function () { },
success: function (json) {
if (json.ret === "setlogs" && json.result === true) {
resolve();
} else {
reject(json);
}
},
error: function (error) {
reject(error);
}
});
});
}
function changetitle(field, newTitle) {
var columns = $table.bootstrapTable('getOptions').columns[0];
for (var i = 0; i < columns.length; i++) {
if (columns[i].field === field) {
columns[i].title = newTitle;
break;
}
}
$table.bootstrapTable('refreshOptions', {columns: columns});
}
function changeSearchPlaceholder() {
const options = $table.bootstrapTable('getOptions');
options.formatSearch = () => lang.UISTR_USER_FIND || 'Search...';
options.formatRefresh = () => lang.UISTR_Refresh || 'Refresh';
options.formatRefreshButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Refresh || 'Refresh'}`;
options.formatColumns = () => lang.UISTR_Columns || 'Columns'; // [4](@ref)
options.formatColumnsButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Columns || 'Columns'}`;
options.formatToggle = () => lang.UISTR_Toggle || 'Toggle'; // 悬浮提示Tooltip
options.formatToggleButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Toggle || 'Toggle'}`; // 按钮文本
options.formatNoMatches = () => lang.UISTR_NoMatches || 'No matching records found';
options.formatShowingRows = (from, to, total) => {
const template = lang.UISTR_ShowingRows
|| 'Showing #1 to #2 of #3 rows';
return template
.replace(/#1/g, from)
.replace(/#2/g, to)
.replace(/#3/g, total);
};
options.formatRecordsPerPage = (pageSize) => {
const template = lang.UISTR_RecordsPerPage
|| '#3 rows per page';
return template.replace(/#3/g, pageSize);
};
// 强制刷新配置
$table.bootstrapTable('refreshOptions', options);
}
function actionfaceFormatter(value, row, index) {
return value ? `<i class="glyphicon glyphicon-picture"></i>` : '';
}
function detailFormatter(index, row) {
var html = [];
var detailsHtml = [];
// Manually add the information you want to display
detailsHtml.push('<p><b>'+lang.UISTR_ENROLL_ID+'</b>' + row.id + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_UserName+'</b>' + row.name + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_TIME_SETTING+'</b>' + row.time + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_VERIFY_mode+'</b>' + row.mode + '</p>');
if(!poithide)
{
detailsHtml.push('<p><b>inout</b>' + row.inout + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_EVENT+'</b>' + row.event + '</p>');
detailsHtml.push('<p><b>note</b>' + row.note + '</p>');
}
var imageUrl = '';
if(row.photouri)
imageUrl = debugserver+row.photouri;
// Use Flexbox for responsive alignment
html.push('<div class="row d-flex flex-column flex-sm-row">'); // Use flex-column for stacking on small screens, and flex-row for larger screens
html.push(' <div class="col-12 col-sm-8">'); // Adjusted width for the first column
html.push(' ' + detailsHtml.join(''));
html.push(' </div>');
if (imageUrl && imageUrl.trim()) {
// Ensure the image is always on the left on small screens
html.push(' <div class="col-12 col-sm-4 order-first order-sm-0">'); // Use order-first for small screens, and remove it for larger screens
html.push(' <a href="' + imageUrl+'?t=' + new Date().getTime() + '" target="_blank">'); // Added link to open image in new tab
html.push(' <img src="' + imageUrl+'?t=' + new Date().getTime() + '" alt="User Image" class="img-max-height-lg img-max-height-sm">');
html.push(' </a>');
html.push(' </div>');
}
html.push('</div>');
return html.join('');
}
function clealogAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_ALLLOG_DELETE,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleanlog'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
$.alert(lang.UISTR_SUCCESSFUL);
$table.bootstrapTable('removeAll');
get_log(0,fromDate, toDate, enrollIdstr);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS);
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
$(document).ready(function() {
$('#uploadModal').on('hidden.bs.modal', function () {
$table.bootstrapTable('removeAll');
get_log(0,fromDate, toDate, enrollIdstr);
});
if(poithide)
{
const hideColumns = ['inout', 'event', 'note', 'photo', 'photouri'];
hideColumns.forEach(col => {
$table.bootstrapTable('hideColumn', col);
});
}
});
// 严格兼容C++的CRC16算法
function crc16_modbus(data) {
let crc = 0xFFFF;
for (let i = 0; i < data.length; i++) {
crc ^= data.charCodeAt(i);
for (let j = 0; j < 8; j++) {
const lsb = crc & 0x0001;
crc = (crc >> 1) & 0x7FFF;
if (lsb) crc ^= 0xA001;
}
}
return crc.toString(16).toUpperCase().padStart(4, '0');
}
// 严格字段格式化(支持对齐方式)
function formatField(value, length, { numeric = false, alignLeft = false } = {}) {
const str = String(value);
const padded = numeric ?
str.padStart(length, '0') :
(alignLeft ? str.padEnd(length, ' ') : str.padStart(length, ' '));
return padded.slice(0, length);
}
// 设备序列号生成严格遵循C++格式)
function generateEquipmentSerial(serial) {
return [
'00049', // 厂商编号5位
'00907', // 型号编号5位
'0', // 版本号1位
serial.padStart(6, '0') // 序列号6位
].join('').padEnd(17, ' ');
}
function formatAFDDateTime(input) {
// Step 1: 统一转换为标准时间字符串
let timeStr;
if (typeof input === 'number') {
// 时间戳处理兼容Date.now()
const d = new Date(input);
timeStr = [
d.getFullYear(),
(d.getMonth() + 1).toString().padStart(2, '0'),
d.getDate().toString().padStart(2, '0')
].join('-') + ' ' + [
d.getHours().toString().padStart(2, '0'),
d.getMinutes().toString().padStart(2, '0'),
d.getSeconds().toString().padStart(2, '0')
].join(':');
} else if (typeof input === 'string') {
// 字符串直接使用
timeStr = input;
} else {
throw new Error('输入类型错误应为时间戳或YYYY-MM-DD HH:mm:ss字符串');
}
// Step 2: 格式校验(更严格的正则表达式)
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(timeStr)) {
throw new Error(`时间格式错误应为YYYY-MM-DD HH:mm:ss当前输入${timeStr}`);
}
// Step 3: 构建AFD格式
const [datePart, timePart] = timeStr.split(' ');
const [year, month, day] = datePart.split('-');
const [hour, minute, second] = timePart.split(':');
// 生成带分隔符的日期和时间[6,7](@ref)
const formattedDate = `${year}-${month}-${day}`;
const formattedTime = `${hour}:${minute}:${second}`;
// 拼接并填充至24位[9,10](@ref)
return `${formattedDate}T${formattedTime}-0300`.padEnd(24, ' ');
}
function buildTrailer(counts) {
return [
'999999999', // 固定头
formatField(counts[2] || 0, 9, { numeric: true }), // 类型2计数
formatField(counts[3] || 0, 9, { numeric: true }), // 类型3计数
formatField(0, 9, { numeric: true }), // 类型4
formatField(0, 9, { numeric: true }), // 类型5
formatField(0, 9, { numeric: true }), // 类型6
formatField(0, 9, { numeric: true }), // 类型7
'9' // 结束标识
].join('');
}
function generateAndDownloadAFD() {
const data = $table.bootstrapTable('getData');
const config = {
serialNumber: $.cookie("devicesn") || "000001",
cnpj: $.cookie("CNPJ")||"",
companyName: $.cookie("companyname") || "",
manufacturerCNPJ: "44880091000172"
};
// 构建文件名(严格长度控制)
const fileName = `AFD${
formatField(config.serialNumber,17)
}${
formatField(config.cnpj, 14, {numeric: true})
}REP.txt`;
// 生成Header记录字段位置严格对齐
const headerFields = [
'000000000', // 001-009
'1', // 010
'1', // 011
formatField(config.cnpj, 14, {numeric: true}), // 012-025
formatField(' ', 14), // 026-039
formatField(config.companyName, 150, {alignLeft: true}), //040-189
formatField(config.serialNumber, 17),
formatField(data[data.length-1].time.split('T')[0], 10), //217-226
formatField(data[0].time.split('T')[0], 10), //207-216
formatAFDDateTime(Date.now()), //227-250
'003', //251-253
'1', //254
config.manufacturerCNPJ, //255-268
formatField('E8', 30, {alignLeft: true}) //269-298
].join('');
const headerLine = headerFields +crc16_modbus(headerFields);
// 处理数据记录(严格类型判断)
let counts = {2:0, 3:0};
const records = data
.slice() // 创建副本避免修改原数组
.sort((a, b) => new Date(a.time) - new Date(b.time)) // 按时间升序排序(最旧在前)
.map((row, index) => {
const isType2 = row.id === '999999999999' && row.mode ===255;
let baseStr;
if (isType2) {
// Type2记录9位序号 + "2" + 24位时间 + 固定CPF + "1" + 备注
baseStr = [
formatField(index + 1, 9, {numeric: true}), // 9位序号
'2',
formatAFDDateTime(row.time).slice(0, 24), // 严格24位
'26571383063 ', // 固定14位
'1',
formatField(row.note, 150, {alignLeft: true})// 左对齐备注
].join('');
counts[2]++;
} else {
// Type3记录9位序号 + "3" + 24位时间 + 12位CPF
baseStr = [
formatField(index + 1, 9, {numeric: true}), // 9位序号
'3',
formatAFDDateTime(row.time).slice(0, 24), // 24位时间
formatField(row.id, 11, {alignLeft: true})
].join('');
counts[3]++;
}
// 计算CRC并拼接记录
const crc = crc16_modbus(baseStr);
return `${baseStr} ${crc}`;
});
const trailerLine = buildTrailer(counts);
// 构建完整文件内容
const fullContent = [headerLine, ...records, trailerLine].join('\r\n');
const encoder = new TextEncoder();
const encoded = encoder.encode(fullContent);
// 文件下载
const blob = new Blob([encoded], {type: 'text/plain;charset=iso-8859-1'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function onUploadSuccess() {
const homeChannel = new BroadcastChannel('home_refresh_channel');
homeChannel.postMessage({type: 'refresh_home'});
homeChannel.close();
}
</script>
</body>
</html>

957
device-api-dump/report.html Normal file
View File

@ -0,0 +1,957 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title class="lang" key="device_setting">edit shift</title>
<link href="resources/plugins/bootstrap-3.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<link href="resources/plugins/material-design-iconic-font-2.2.0/css/material-design-iconic-font.min.css" rel="stylesheet"/>
<link href="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.css" rel="stylesheet"/>
<link href="resources/plugins/waves-0.7.5/waves.min.css" rel="stylesheet"/>
<link href="resources/plugins/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/>
<link href="resources/css/common.css" rel="stylesheet"/>
<link href="resources/css/diy.css" rel="stylesheet"/>
<style>
.tab {
display: none;
justify-content: center;
}
.tab.active {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
/*height: 90vh;*/
}
.tab-container {
display: none;
}
.tab-container.active {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.tab-buttons {
display: flex;
}
.tab-buttons button {
background-color: #f1f1f1;
border: none;
outline: none;
cursor: pointer;
padding: 10px 20px;
transition: background-color 0.3s ease;
font-size: 16px;
font-weight: bold;
border-radius: 5px 5px 0 0;
margin-right: 10px;
}
.tab-buttons button.active {
background-color: #ccc;
}
.tab table {
width: 100%;
border-collapse: collapse;
font-family: Arial, sans-serif;
}
.tab th, .tab td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
#Schedule.tab table {
margin-bottom: 20px;
}
/* sc */
.xl655987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:9.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
border-top:.5pt solid black;
border-right:.5pt solid black;
border-bottom:.5pt solid black;
border-left:.5pt solid windowtext;
background:#7fcafa;
mso-pattern:black none;
white-space:nowrap;}
.xl665987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:windowtext;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:red;
mso-pattern:black none;
white-space:nowrap;}
.xl675987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:windowtext;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:"\@";
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:#c5e5f9;
mso-pattern:black none;
white-space:nowrap;}
.xl685987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:windowtext;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:"\@";
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:#f7f9fb;
mso-pattern:black none;
white-space:nowrap;}
.xl695987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:windowtext;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:#CCFFCC;
mso-pattern:black none;
white-space:nowrap;}
.xl705987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:24.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
background:#0d9bf7;
mso-pattern:black none;
white-space:nowrap;}
.xl715987
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:9.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:left;
vertical-align:middle;
border-top:.5pt solid black;
border-right:.5pt solid black;
border-bottom:.5pt solid black;
border-left:.5pt solid windowtext;
background:#7fcafa;
mso-pattern:black none;
white-space:nowrap;}
/* 考勤汇总表 */
.xl1518308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:11.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:Arial, sans-serif;
mso-font-charset:0;
mso-number-format:General;
text-align:general;
vertical-align:bottom;
background:#7fcafa;
mso-pattern:auto;
white-space:nowrap;}
.xl6518308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:9.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
border-top:.5pt solid black;
border-right:.5pt solid black;
border-bottom:.5pt solid black;
border-left:.5pt solid windowtext;
background:#7fcafa;
mso-pattern:black none;
white-space:nowrap;}
.xl6618308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:"\@";
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:#c5e5f9;
mso-pattern:black none;
white-space:nowrap;}
.xl6718308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:8.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:"\@";
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:1.0pt solid green;
border-left:.5pt solid green;
background:#f7f9fb;
mso-pattern:black none;
white-space:nowrap;}
.xl6818308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:24.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
background:#6ec2f8;
mso-pattern:black none;
white-space:nowrap;}
.xl6918308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:6.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:"\@";
text-align:left;
vertical-align:middle;
background:#6ec2f8;
mso-pattern:black none;
white-space:normal;}
.xl7018308
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:8.0pt;
font-weight:700;
font-style:normal;
text-decoration:none;
font-family:"Meiryo UI", serif;
mso-font-charset:0;
mso-number-format:General;
text-align:center;
vertical-align:middle;
border-top:1.0pt solid green;
border-right:.5pt solid green;
border-bottom:none;
border-left:.5pt solid green;
background:#99CCFF;
mso-pattern:black none;
white-space:normal;}
</style>
</head>
<body>
<div id="createDialog" class="crudDialog" hidden>
<form>
<div class="form-group">
<label for="inputuserid" class="lang" key="UISTR_ENROLL_ID">user id</label>
<input id="inputuserid" class="form-control" type="text" oninput="validateEnrollid(this)">
</div>
<div class="form-group">
<label for="inputstartdate" class="lang" key="UISTR_START">start</label>
<input id="inputstartdate" type="date" class="form-control">
</div>
<div class="form-group">
<label for="inputenddate"class="lang" key="UISTR_END">end</label>
<input id="inputenddate" type="date" class="form-control">
</div>
<div class="form-group">
<div id="progress-bar" class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
0%
</div>
</div>
</div>
</form>
</div>
<div class="tab-container">
<div class="tab-buttons">
<button class="lang" key="UISTR_Report_DETAILS">Schedule</button>
<button class="lang" key="UISTR_MonthReport">Summary</button>
</div>
<div class="button-container">
<button class="optbtn lang" key="UISTR_PRINT" onclick="printActiveDiv()">Print</button>
</div>
</div>
<div id="Schedule" class="tab">
</div>
<div id="Summary" class="tab">
<table id="Summary_table" style='width:800pt'>
<col width=47 span=11 style='width:35pt'>
<col width=94 span=2 style='width:71pt'>
<tr height=43 style='height:32.25pt'>
<td colspan=13 class=xl6818308><span class="lang" key="UISTR_MonthReport">Summary</span></td>
</tr>
<tr height=24 style='height:18.2pt'>
<td colspan=13 class=xl6918308></td>
</tr>
<tr height=20 style='height:15.0pt'>
<td rowspan=2 class=xl6518308 ><span class="lang" key="UISTR_ENROLL_ID">ID</span></td>
<td rowspan=2 class=xl6518308><span class="lang" key="UISTR_UserName">username</span></td>
<td rowspan=2 class=xl6518308><span class="lang" key="UISTR_Department">dept.</span></td>
<td colspan=2 class=xl6518308 ><span class="lang" key="UISTR_Work_hours">work hours</span></td>
<td colspan=2 class=xl6518308 ><span class="lang" key="UISTR_Late_minutes">late minutes</span></td>
<td colspan=2 class=xl6518308 ><span class="lang" key="UISTR_Leave_minutes">leave minutes</span></td>
<td colspan=2 class=xl6518308 ><span class="lang" key="UISTR_OT_hours">ot hours</span></td>
<td rowspan=2 class=xl6518308 ><span class="lang" key="UISTR_Work_Dates">work dates</span></td>
<td rowspan=2 class=xl6518308 ><span class="lang" key="UISTR_Truancy">truancy</span></td>
</tr>
<tr height=20 style='height:15.0pt'>
<td class=xl6518308 ><span class="lang" key="UISTR_ATTNormal">normal</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_Actual">actual</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_TIMES">times</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_Minute">minute</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_TIMES">times</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_Minute">minute</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_ATTNormal">normal</span></td>
<td class=xl6518308 ><span class="lang" key="UISTR_Actual">actual</span></td>
</tr>
</table>
</div>
<script src="resources/plugins/jquery.1.12.4.min.js"></script>
<script src="resources/plugins/bootstrap-3.3.0/js/bootstrap.min.js"></script>
<script src="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.js"></script>
<script src="resources/plugins/waves-0.7.5/waves.min.js"></script>
<script src="resources/plugins/jquery-confirm/jquery-confirm.min.js"></script>
<script src="resources/plugins/jquery.cookie.js"></script>
<link href="resources/plugins/jquery-toastmessage/resources/css/jquery.toastmessage.css" rel="stylesheet"/>
<script src="resources/plugins/jquery-toastmessage/javascript/jquery.toastmessage.js"></script>
<script src="resources/js/notify.js"></script>
<script src="resources/js/lang.js"></script>
<script src="resources/js/common.js"></script>
<script src="resources/js/xlsx.full.min.js"></script>
<script>
let DI_USERID_FORMAT = parseInt($.cookie("USERID_FORMAT")) || 0;
const tabButtons = document.querySelectorAll('.tab-buttons button');
const tabs = document.querySelectorAll('.tab');
check_permission();
lang_load().then(() => {
getfromtodate();
}).catch(error => {
});
function convertEnrollId(strid) {
if (typeof strid === 'number') {
return strid > 0xFFFFFFFF ? strid.toString() : strid;
}
if (typeof strid === 'string' && /^\d+$/.test(strid) && DI_USERID_FORMAT != 2) {
const num = Number(strid);
return Number.isSafeInteger(num) ? num : strid;
}
return strid;
}
function set_report_input(data) {
// 创建表格元素
let table = document.createElement('table');
table.style.width = '800pt';
// 添加表格列宽
let colWidths = [47, 38, 47, 47, 47, 47, 42, 42, 66, 66, 66, 72];
for (let i = 0; i < colWidths.length; i++) {
let col = document.createElement('col');
col.style.width = colWidths[i] + 'pt';
table.appendChild(col);
}
// 添加表头
let thead = document.createElement('thead');
let tr = document.createElement('tr');
tr.height = 40;
let th = document.createElement('th');
th.colSpan = 13;
th.height = 40;
th.className = 'xl705987';
th.textContent = lang.UISTR_Report_DETAILS;
tr.appendChild(th);
thead.appendChild(tr);
tr = document.createElement('tr');
tr.height = 20;
th = document.createElement('th');
th.colSpan = 3;
th.height = 20;
th.className = 'xl715987';
th.textContent = lang.UISTR_ENROLL_ID+':'+data.enrollid;
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 10;
th.className = 'xl715987';
th.textContent =lang.UISTR_UserName+':'+data.name;
tr.appendChild(th);
thead.appendChild(tr);
tr = document.createElement('tr');
tr.height = 20;
th = document.createElement('th');
th.colSpan = 3;
th.height = 20;
th.className = 'xl655987';
th.textContent =lang.UISTR_Department+':'+data.department;
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 6;
th.className = 'xl655987';
th.textContent = lang.UISTR_UserShift+':'+data.shift;
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 4;
th.className = 'xl655987';
th.textContent =lang.UISTR_Holidaysdate+':'+data.date;
tr.appendChild(th);
thead.appendChild(tr);
tr = document.createElement('tr');
tr.height = 20;
th = document.createElement('th');
th.rowSpan = 2;
th.height = 40;
th.className = 'xl655987';
th.textContent = lang.UISTR_Holidaysdate;
tr.appendChild(th);
th = document.createElement('th');
th.rowSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_WEEK;
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_Section+'1';
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_Section+'2';
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 2;
th.className = 'xl655987';
th.textContent =lang.UISTR_Section+'3';
tr.appendChild(th);
th = document.createElement('th');
th.colSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_Work_hours;
tr.appendChild(th);
th = document.createElement('th');
th.rowSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_OT_hours;
tr.appendChild(th);
th = document.createElement('th');
th.rowSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_Late_minutes;
tr.appendChild(th);
th = document.createElement('th');
th.rowSpan = 2;
th.className = 'xl655987';
th.textContent = lang.UISTR_Leave_minutes;
tr.appendChild(th);
thead.appendChild(tr);
tr = document.createElement('tr');
tr.height = 20;
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_ON_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_OFF_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_ON_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_OFF_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_ON_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_OFF_DUTY;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_ATTNormal;
tr.appendChild(th);
th = document.createElement('th');
th.className = 'xl655987';
th.textContent = lang.UISTR_Actual;
tr.appendChild(th);
thead.appendChild(tr);
table.appendChild(thead);
// 添加表格数据
let tbody = document.createElement('tbody');
for (let i = 0; i < data.days.length; i++) {
let day = data.days[i];
let tr = document.createElement('tr');
tr.height = 20;
var vclassname=i%2==0?'xl685987':'xl675987'
let td = document.createElement('td');
td.height = 20;
td.className = vclassname;
td.textContent = day.day;
tr.appendChild(td);
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.week;
tr.appendChild(td);
for (let j = 0; j < day.time.length; j++) {
td = document.createElement('td');
var vcolor=vclassname;
if(day.time[j].length !== 0){
if (day.flag[j] == 0 ){
vcolor='xl665987';
}
else if(day.flag[j]==2){
vcolor='xl695987';
}
}
td.className = vcolor;
td.textContent = day.time[j];
tr.appendChild(td);
}
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.fatten;
tr.appendChild(td);
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.fact_atten;
tr.appendChild(td);
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.dayot;
tr.appendChild(td);
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.daylate;
tr.appendChild(td);
td = document.createElement('td');
td.className = vclassname;
td.textContent = day.dayLeaveearly;
tr.appendChild(td);
tbody.appendChild(tr);
}
// 添加总计行
tr = document.createElement('tr');
tr.height = 20;
td = document.createElement('td');
td.colSpan = 8;
td.height = 20;
td.className = 'xl675987';
td.textContent = lang.UISTR_Summary+':';
tr.appendChild(td);
td = document.createElement('td');
td.className = 'xl675987';
td.textContent = data.work_hour;
tr.appendChild(td);
td = document.createElement('td');
td.className = 'xl675987';
td.textContent = data.atten_hour;
tr.appendChild(td);
td = document.createElement('td');
td.className = 'xl675987';
td.textContent = data.ot_hour;
tr.appendChild(td);
td = document.createElement('td');
td.className = 'xl675987';
td.textContent = data.late_minute;
tr.appendChild(td);
td = document.createElement('td');
td.className = 'xl675987';
td.textContent = data.leave_minute;
tr.appendChild(td);
tbody.appendChild(tr);
table.appendChild(tbody);
// 将表格添加到 DOM
let reportDiv = document.getElementById('Schedule');
reportDiv.appendChild(table);
}
let isDateUpdated = false;
function validateEnrollid(inputElement) {
let maxValue, maxLength;
switch(DI_USERID_FORMAT) {
case 0:
inputElement.value = inputElement.value.replace(/\D/g, '');
maxValue = 99999999;
maxLength = 8;
break;
case 1:
inputElement.value = inputElement.value.replace(/[^0-9]/g, '');
maxValue = BigInt('9223372036854775807');
maxLength = 19;
break;
case 2:
maxLength = 32;
break;
}
if (inputElement.value.length > maxLength) {
inputElement.value = inputElement.value.slice(0, maxLength);
}
if (DI_USERID_FORMAT !== 2) {
const numericValue = DI_USERID_FORMAT === 1 ?
BigInt(inputElement.value || 0) :
parseInt(inputElement.value || 0, 10);
if (numericValue > maxValue) {
inputElement.value = maxValue.toString();
}
}
}
function set_summary_input(data) {
const summarytable = document.getElementById('Summary_table');
const tableBody = summarytable.querySelector('tbody');
const rows = tableBody.rows;
const rowindex = rows.length;
const row = document.createElement('tr');
row.setAttribute('height', '15.0px');
const cssClass = rowindex % 2 === 0 ? 'xl6718308' : 'xl6618308';
row.innerHTML = `
<td class='${cssClass}' >${data.enrollid}</td>
<td class='${cssClass}' >${data.name}</td>
<td class='${cssClass}' >${data.department}</td>
<td class='${cssClass}' >${data.work_hour}</td>
<td class='${cssClass}' >${data.atten_hour}</td>
<td class='${cssClass}' >${data.late_times}</td>
<td class='${cssClass}' >${data.late_minute}</td>
<td class='${cssClass}' >${data.leave_times}</td>
<td class='${cssClass}' >${data.leave_minute}</td>
<td class='${cssClass}' >${data.std_ot_hour}</td>
<td class='${cssClass}' >${data.ot_hour}</td>
<td class='${cssClass}' >${data.work_days}</td>
<td class='${cssClass}' >${data.absent_days}</td>
`;
tableBody.appendChild(row);
// 只在第一次调用时更新日期部分
if (!isDateUpdated) {
const dateCell = summarytable.querySelector('tr:nth-child(2) td');
dateCell.textContent =lang.UISTR_Holidaysdate+':'+data.date;
isDateUpdated = true;
}
}
function get_userids(fromDate, toDate) {
let send = new Object();
send["password"] = $.cookie("pwd");
send["cmd"] = 'getuserids';
$.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
// 显示进度条
$('#progress-bar .progress-bar').css('width', '0%').attr('aria-valuenow', 0);
},
success: function(json) {
console.log("done", json);
let records = json.record;
let total = records.length;
let completed = 0;
for (let i = 0; i < records.length; i++){
let record = records[i];
report_get(fromDate, toDate, record, function() {
completed++;
let progress = (completed / total) * 100;
$('#progress-bar .progress-bar').css('width', progress + '%').attr('aria-valuenow', progress).text(Math.round(progress) + '%');
});
}
},
error: function(error) {
console.log("error::::", error);
}
});
}
function report_get(fromDate, toDate, enrollIdstr) {
let send = new Object()
send["password"] = $.cookie("pwd");
send["cmd"] = 'getreport';
if (enrollIdstr) {
send["enrollid"] = convertEnrollId(enrollIdstr);
}
if (fromDate) {
send["from"] = fromDate;
}
if (toDate) {
send["to"] = toDate;
}
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log("done", json);
set_report_input(json);
set_summary_input(json);
const tabContainer = document.querySelector('.tab-container');
tabContainer.classList.add('active');
tabButtons[0].classList.add('active');
tabs[0].classList.add('active');
},
error: function(error){
console.log("error::::",error);
}
});
}
function getfromtodate() {
$.confirm({
type: 'blue',
animationSpeed: 300,
title: lang.UISTR_Report,
content: $('#createDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let fromDate = this.$content.find('#inputstartdate').val();
let toDate = this.$content.find('#inputenddate').val();
let enrollIdstr = this.$content.find('#inputuserid').val();
if (enrollIdstr === '') {
get_userids(fromDate, toDate);
} else {
report_get(fromDate, toDate, enrollIdstr);
}
}
}
}
});
}
tabButtons.forEach((button, index) => {
button.addEventListener('click', () => {
// Remove active class from all buttons and tabs
tabButtons.forEach(btn => btn.classList.remove('active'));
tabs.forEach(tab => tab.classList.remove('active'));
// Add active class to the clicked button and its corresponding tab
button.classList.add('active');
tabs[index].classList.add('active');
});
});
function printActiveDiv() {
// 获取当前激活的标签页
const activeTabs = document.querySelectorAll('.tab.active');
// 检查是否有激活的标签页
if (activeTabs.length > 0) {
// 获取激活标签页的内容
const activeTabContent = activeTabs[0].innerHTML;
// 获取当前激活的 button 文本
const activeButton = document.querySelector('.tab-buttons .active');
const activeButtonText = activeButton ? activeButton.textContent : 'Print Report';
// 创建新窗口用于打印
const printWindow = window.open('', 'Print Window', 'width=800,height=600');
printWindow.document.write(`
<html>
<head>
<title>${activeButtonText}</title>
<style>
/* 添加必要的样式 */
@media print {
* {
-webkit-print-color-adjust: exact;
color-adjust: exact;
}
body {
color: initial;
background-color: initial;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 20px;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
background-color: inherit; // 重置单元格背景色为继承
}
th {
background-color: #f2f2f2;
}
td {
background-color: inherit; // 确保数据单元格的背景色正确打印
}
}
</style>
</head>
<body>
${activeTabContent}
</body>
</html>
`);
// 打印窗口
printWindow.document.close();
printWindow.print();
// 关闭打印窗口
printWindow.close();
} else {
console.log('没有找到激活的标签页。');
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,3 @@
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=f.navigator&&/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
//# sourceMappingURL=FileSaver.min.js.map

View File

@ -0,0 +1,259 @@
var click = device.mobile() ? 'touchstart' : 'click';
$(function() {
// 侧边栏操作按钮
$(document).on(click, '#guide', function() {
$(this).toggleClass('toggled');
$('#sidebar').toggleClass('toggled');
});
// 侧边栏二级菜单
$(document).on('click', '.sub-menu a', function() {
$(this).next().slideToggle(200);
$(this).parent().toggleClass('toggled');
});
// 个人资料
$(document).on('click', '.s-profile a', function() {
$(this).next().slideToggle(200);
$(this).parent().toggleClass('toggled');
});
// Waves初始化
Waves.displayEffect();
// 滚动条初始化
$('#sidebar').mCustomScrollbar({
theme: 'minimal-dark',
scrollInertia: 100,
axis: 'yx',
mouseWheel: {
enable: true,
axis: 'y',
preventDefault: true
}
});
// 切换系统
$('.switch-systems').click(function () {
var systemid = $(this).attr('systemid');
var systemname = $(this).attr('systemname');
var systemtitle = $(this).attr('systemtitle');
$('.system_menus').hide(0, function () {
$('.system_' + systemid).show();
});
$('body').attr("id", systemname);
$('#system_title').text(systemtitle);
$.cookie('zheng-upms-systemid', systemid);
$.cookie('zheng-upms-systemname', systemname);
$.cookie('zheng-upms-systemtitle', systemtitle);
});
// 显示cookie菜单
var systemid = $.cookie('zheng-upms-systemid') || 1;
var systemname = $.cookie('zheng-upms-systemname') || 'zheng-upms-server';
var systemtitle = $.cookie('zheng-upms-systemtitle') || 'AI Biometric Device Management System';
$('.system_menus').hide(0, function () {
$('.system_' + systemid).show();
});
$('body').attr('id', systemname);
$('#system_title').text(systemtitle);
});
// iframe高度自适应
function changeFrameHeight(ifm) {
ifm.height = document.documentElement.clientHeight - 118;
}
function resizeFrameHeight() {
$('.tab_iframe').css('height', document.documentElement.clientHeight - 118);
$('md-tab-content').css('left', '0');
}
window.onresize = function() {
resizeFrameHeight();
initScrollShow();
initScrollState();
}
// ========== 选项卡操作 ==========
$(function() {
// 选项卡点击
$(document).on('click', '.content_tab li', function() {
// 切换选项卡
$('.content_tab li').removeClass('cur');
$(this).addClass('cur');
// 切换iframe
$('.iframe').removeClass('cur');
$('#iframe_' + $(this).data('index')).addClass('cur');
var marginLeft = ($('#tabs').css('marginLeft').replace('px', ''));
// 滚动到可视区域:在左侧
if ($(this).position().left < marginLeft) {
var left = $('.content_tab>ul').scrollLeft() + $(this).position().left - marginLeft;
$('.content_tab>ul').animate({scrollLeft: left}, 200, function() {
initScrollState();
});
}
// 滚动到可视区域:在右侧
if(($(this).position().left + $(this).width() - marginLeft) > document.getElementById('tabs').clientWidth) {
var left = $('.content_tab>ul').scrollLeft() + (($(this).position().left + $(this).width() - marginLeft) - document.getElementById('tabs').clientWidth);
$('.content_tab>ul').animate({scrollLeft: left}, 200, function() {
initScrollState();
});
}
});
// 控制选项卡滚动位置
$(document).on('click', '.tab_left>a', function() {
$('.content_tab>ul').animate({scrollLeft: $('.content_tab>ul').scrollLeft() - 300}, 200, function() {
initScrollState();
});
});
// 向右箭头
$(document).on('click', '.tab_right>a', function() {
$('.content_tab>ul').animate({scrollLeft: $('.content_tab>ul').scrollLeft() + 300}, 200, function() {
initScrollState();
});
});
// 初始化箭头状态
// 选项卡右键菜单
var menu = new BootstrapMenu('.tabs li', {
fetchElementData: function(item) {
return item;
},
actionsGroups: [
['close', 'refresh'],
['closeOther', 'closeAll'],
['closeRight', 'closeLeft']
],
actions: {
close: {
name: lang.UISTR_CLOSE,
iconClass: 'zmdi zmdi-close',
onClick: function(item) {
Tab.closeTab($(item));
}
},
closeOther: {
name: lang.UISTR_CLOSE_OTHERS,
iconClass: 'zmdi zmdi-arrow-split',
onClick: function(item) {
var index = $(item).data('index');
$('.content_tab li').each(function() {
if ($(this).data('index') != index) {
Tab.closeTab($(this));
}
});
}
},
closeAll: {
name: lang.UISTR_CLOSE_ALL,
iconClass: 'zmdi zmdi-swap',
onClick: function() {
$('.content_tab li').each(function() {
Tab.closeTab($(this));
});
}
},
closeRight: {
name: lang.UISTR_CLOSE_RIGHT,
iconClass: 'zmdi zmdi-arrow-right',
onClick: function(item) {
var index = $(item).data('index');
$($('.content_tab li').toArray().reverse()).each(function() {
if ($(this).data('index') != index) {
Tab.closeTab($(this));
} else {
return false;
}
});
}
},
closeLeft: {
name: lang.UISTR_CLOSE_LEFT,
iconClass: 'zmdi zmdi-arrow-left',
onClick: function(item) {
var index = $(item).data('index');
$('.content_tab li').each(function() {
if ($(this).data('index') != index) {
Tab.closeTab($(this));
} else {
return false;
}
});
}
},
refresh: {
name: lang.UISTR_Refresh,
iconClass: 'zmdi zmdi-refresh',
onClick: function(item) {
var index = $(item).data('index');
var $iframe = $('#iframe_' + index).find('iframe');
$iframe.attr('src', $iframe.attr('src'));
}
}
}
});
});
// 选项卡对象
var Tab = {
addTab: function(title, url, key) {
var index = url.replace(/\./g, '_').replace(/\//g, '_').replace(/:/g, '_').replace(/\?/g, '_').replace(/,/g, '_').replace(/=/g, '_').replace(/&/g, '_');
// 如果存在选项卡,则激活,否则创建新选项卡
if ($('#tab_' + index).length == 0) {
// 添加选项卡
$('.content_tab li').removeClass('cur');
var tab = '<li id="tab_' + index +'" data-index="' + index + '" class="cur"><span class="waves-effect waves-light lang"' + 'key="' + key + '">' + title + '</span><i class="waves-effect waves-light zmdi zmdi-close" onclick="Tab.closeTab($(\'#tab_' + index +'\'));"></i></li>';
// console.log(tab)
$('.content_tab>ul').append(tab);
// 添加iframe
$('.iframe').removeClass('cur');
var iframe = '<div id="iframe_' + index + '" class="iframe cur"><iframe class="tab_iframe" src="' + url + '" width="100%" frameborder="0" scrolling="auto" onload="changeFrameHeight(this)"></iframe></div>';
$('.content_main').append(iframe);
initScrollShow();
$('.content_tab>ul').animate({scrollLeft: document.getElementById('tabs').scrollWidth - document.getElementById('tabs').clientWidth}, 200, function() {
initScrollState();
});
} else {
$('#tab_' + index).trigger('click');
}
// 关闭侧边栏
$('#guide').trigger(click);
},
closeTab: function($item) {
var closeable = $item.data('closeable');
if (closeable != false) {
// 如果当前时激活状态则关闭后激活左边选项卡
if($item.hasClass('cur')) {
$item.prev().trigger('click');
}
// 关闭当前选项卡
var index = $item.data('index');
$('#iframe_' + index).remove();
$item.remove();
}
initScrollShow();
}
}
function initScrollShow() {
if (document.getElementById('tabs').scrollWidth > document.getElementById('tabs').clientWidth) {
$('.content_tab').addClass('scroll');
} else {
$('.content_tab').removeClass('scroll');
}
}
function initScrollState() {
if ($('.content_tab>ul').scrollLeft() == 0) {
$('.tab_left>a').removeClass('active');
} else {
$('.tab_left>a').addClass('active');
}
if (($('.content_tab>ul').scrollLeft() + document.getElementById('tabs').clientWidth) >= document.getElementById('tabs').scrollWidth) {
$('.tab_right>a').removeClass('active');
} else {
$('.tab_right>a').addClass('active');
}
}
function fullPage() {
if ($.util.supportsFullScreen) {
if ($.util.isFullScreen()) {
$.util.cancelFullScreen();
} else {
$.util.requestFullScreen();
}
} else {
alert("The current browser does not support the full-screen API. Please switch to the latest Chrome/Firefox/Safari browser or use the F11 shortcut key to operate.");
}
}

View File

@ -0,0 +1,41 @@
$(function() {
// Waves初始化
Waves.displayEffect();
initMaterialInput();
// 数据表格动态高度
$(window).resize(function () {
$('#table').bootstrapTable('resetView', {
height: getHeight()
});
});
// 设置input特效
$(document).on('focus', 'input[type="text"],input[type="date"]', function() {
$(this).parent().find('label').addClass('active');
}).on('blur', 'input[type="text"], input[type="date"]', function() {
if ($(this).val() == '') {
$(this).parent().find('label').removeClass('active');
}
});
});
// 动态高度
function getHeight() {
return $(window).height() - 20;
}
// 数据表格展开内容
function detailFormatter(index, row) {
var html = [];
$.each(row, function (key, value) {
html.push('<p><b>' + key + ':</b> ' + value + '</p>');
});
return html.join('');
}
// 初始化input特效
function initMaterialInput() {
$('form input[type="text"], form input[type="date"]').each(function () {
if ($(this).val() != '') {
$(this).parent().find('label').addClass('active');
}
});
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,91 @@
let current_lang = ''
//let debugserver = 'http://192.168.0.22';
let debugserver = '';
function lang_load() {
return lang_change();
}
function get_lang() {
return new Promise((resolve, reject) => {
let send = { cmd: 'getlang' };
console.log(send);
$.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(send),
success: function(json) {
console.log("done", json);
if (json.result) {
resolve(json.lang);
} else {
reject(new Error('Request failed with result false.'));
}
},
error: function(error) {
console.log("error::::", error);
reject(error);
}
});
});
}
function getTitle() {
return fetch('title.txt')
.then(response => {
if (!response.ok) {
throw new Error("HTTP error " + response.status);
}
return response.text();
})
.then(data => {
return data.trim();
})
.catch(error => {
return null;
});
}
async function lang_change() {
if (current_lang === '') {
try {
current_lang = await get_lang();
} catch (error) {
current_lang = 'en';
console.error('Error getting language:', error);
}
}
let langJs = current_lang;
$.cookie('lang', current_lang);
console.log("language:", langJs);
let title = await getTitle();
return new Promise((resolve, reject) => {
$.ajax({
url: "resources/lang/" + langJs + ".js",
dataType: "script",
async: false,
success: function() {
window.lang = lang;
if (title !== null&&title!='') {
window.lang.UISTR_AI_BIO_ID_SYSTEM = title;
}
$('.lang').each(function(idx, ele) {
let key = ele.getAttribute("key");
let value = lang[key];
if (typeof(value) !== "undefined") {
$(this).text(value);
}
});
console.log("reload to refresh lang");
resolve();
},
error: function(error) {
console.log("error lang", error);
reject(error);
}
});
});
}

View File

@ -0,0 +1,14 @@
function notify(text, sticky) {
$().toastmessage('showToast',{
text: text,
sticky : sticky || false,
position : 'top-right',
closeText: ''
});
}
function check_permission() {
if (typeof ($.cookie("pwd")) == "undefined"){
location.href = "index.html"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
/*! device.js 0.2.7 */
(function(){var a,b,c,d,e,f,g,h,i,j;b=window.device,a={},window.device=a,d=window.document.documentElement,j=window.navigator.userAgent.toLowerCase(),a.ios=function(){return a.iphone()||a.ipod()||a.ipad()},a.iphone=function(){return!a.windows()&&e("iphone")},a.ipod=function(){return e("ipod")},a.ipad=function(){return e("ipad")},a.android=function(){return!a.windows()&&e("android")},a.androidPhone=function(){return a.android()&&e("mobile")},a.androidTablet=function(){return a.android()&&!e("mobile")},a.blackberry=function(){return e("blackberry")||e("bb10")||e("rim")},a.blackberryPhone=function(){return a.blackberry()&&!e("tablet")},a.blackberryTablet=function(){return a.blackberry()&&e("tablet")},a.windows=function(){return e("windows")},a.windowsPhone=function(){return a.windows()&&e("phone")},a.windowsTablet=function(){return a.windows()&&e("touch")&&!a.windowsPhone()},a.fxos=function(){return(e("(mobile;")||e("(tablet;"))&&e("; rv:")},a.fxosPhone=function(){return a.fxos()&&e("mobile")},a.fxosTablet=function(){return a.fxos()&&e("tablet")},a.meego=function(){return e("meego")},a.cordova=function(){return window.cordova&&"file:"===location.protocol},a.nodeWebkit=function(){return"object"==typeof window.process},a.mobile=function(){return a.androidPhone()||a.iphone()||a.ipod()||a.windowsPhone()||a.blackberryPhone()||a.fxosPhone()||a.meego()},a.tablet=function(){return a.ipad()||a.androidTablet()||a.blackberryTablet()||a.windowsTablet()||a.fxosTablet()},a.desktop=function(){return!a.tablet()&&!a.mobile()},a.television=function(){var a;for(television=["googletv","viera","smarttv","internet.tv","netcast","nettv","appletv","boxee","kylo","roku","dlnadoc","roku","pov_tv","hbbtv","ce-html"],a=0;a<television.length;){if(e(television[a]))return!0;a++}return!1},a.portrait=function(){return window.innerHeight/window.innerWidth>1},a.landscape=function(){return window.innerHeight/window.innerWidth<1},a.noConflict=function(){return window.device=b,this},e=function(a){return-1!==j.indexOf(a)},g=function(a){var b;return b=new RegExp(a,"i"),d.className.match(b)},c=function(a){var b=null;g(a)||(b=d.className.replace(/^\s+|\s+$/g,""),d.className=b+" "+a)},i=function(a){g(a)&&(d.className=d.className.replace(" "+a,""))},a.ios()?a.ipad()?c("ios ipad tablet"):a.iphone()?c("ios iphone mobile"):a.ipod()&&c("ios ipod mobile"):a.android()?c(a.androidTablet()?"android tablet":"android mobile"):a.blackberry()?c(a.blackberryTablet()?"blackberry tablet":"blackberry mobile"):a.windows()?c(a.windowsTablet()?"windows tablet":a.windowsPhone()?"windows mobile":"desktop"):a.fxos()?c(a.fxosTablet()?"fxos tablet":"fxos mobile"):a.meego()?c("meego mobile"):a.nodeWebkit()?c("node-webkit"):a.television()?c("television"):a.desktop()&&c("desktop"),a.cordova()&&c("cordova"),f=function(){a.landscape()?(i("portrait"),c("landscape")):(i("landscape"),c("portrait"))},h=Object.prototype.hasOwnProperty.call(window,"onorientationchange")?"orientationchange":"resize",window.addEventListener?window.addEventListener(h,f,!1):window.attachEvent?window.attachEvent(h,f):window[h]=f,f(),"function"==typeof define&&"object"==typeof define.amd&&define.amd?define(function(){return a}):"undefined"!=typeof module&&module.exports?module.exports=a:window.device=a}).call(this);

View File

@ -0,0 +1,301 @@
<!DOCTYPE HTML>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>log view</title>
<link href="resources/plugins/bootstrap-3.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<link href="resources/plugins/material-design-iconic-font-2.2.0/css/material-design-iconic-font.min.css" rel="stylesheet"/>
<link href="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.css" rel="stylesheet"/>
<link href="resources/plugins/waves-0.7.5/waves.min.css" rel="stylesheet"/>
<link href="resources/plugins/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/>
<link href="resources/css/common.css" rel="stylesheet"/>
<style>
.img-max-height-lg {
max-height: 240px !important;
}
.img-max-height-sm {
max-height: 320px !important;
}
</style>
</head>
<body>
<div id="main">
<div id="toolbar">
</div>
<table id="table"></table>
</div>
<script src="resources/plugins/jquery.1.12.4.min.js"></script>
<script src="resources/plugins/bootstrap-3.3.0/js/bootstrap.min.js"></script>
<script src="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.js"></script>
<script src="resources/plugins/waves-0.7.5/waves.min.js"></script>
<script src="resources/plugins/jquery-confirm/jquery-confirm.min.js"></script>
<script src="resources/plugins/jquery.cookie.js"></script>
<script src="resources/js/notify.js"></script>
<script src="resources/js/lang.js"></script>
<script src="resources/js/common.js"></script>
<script src="resources/js/admin.js"></script>
<script>
let poithide = $.cookie("manufacturer") !== undefined && $.cookie("manufacturer")==="Piot Robotics";
check_permission();
lang_load().then(() => {
fetchLogs();
changetitle('time', lang.UISTR_TIME_SETTING);
changetitle('id', lang.UISTR_ENROLL_ID);
changetitle('name', lang.UISTR_UserName);
changetitle('mode', lang.UISTR_VERIFY_mode);
changetitle('event', lang.UISTR_EVENT);
changetitle('note',lang.UISTR_NOTE||'note');
changetitle('inout',lang.UISTR_INOUT||'inout');
changetitle('photo',lang.UISTR_Photo||'Photo');
changetitle('photouri',lang.UISTR_Photo_Url||'Photo');
changeSearchPlaceholder();
}).catch(error => {
console.error("Language loading failed:", error);
});
function formatNoteContent(note) {
if (note === null || note === undefined || note === "") return "";
if (typeof note === 'object') {
return Object.entries(note)
.map(([key, value]) => `${key}:${value}`)
.join(',');
}
if (typeof note === 'string') {
try {
const parsed = JSON.parse(note);
return Object.entries(parsed)
.map(([key, value]) => `${key}:${value}`)
.join(',');
} catch (e) {
return note;
}
}
return String(note);
}
function get_rtlog(index = 0) {
let send = new Object();
send["password"] = $.cookie("pwd");
send["cmd"] = 'getrtlog';
send["index"] = index;
console.log(send);
return new Promise((resolve, reject) => {
$.ajax({
url: debugserver + '/api',
type: 'POST',
contentType: "application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json) {
console.log("done", json);
if(json.count&&json.count>0)
{
let records = json.record;
let transformedData = [];
for (let record of records) {
let transformedRecord = {
id: record.enrollid,
name: record.name || "",
time: record.time || "",
mode: record.mode || "",
inout: record.inout || "",
event: record.event || "",
note: formatNoteContent(record.note),
photo: record.photourl ? 1 : 0,
photouri: record.photourl || ""
};
transformedData.push(transformedRecord);
}
$table.bootstrapTable('append', transformedData);
}
if (json.count > json.to) {
var nindex = json.to + 1;
get_rtlog(nindex).then(resolve); // 继续处理下一个分包
} else {
resolve(); // 所有分包处理完毕
}
},
error: function(error) {
console.log("error::::", error);
reject(error);
}
});
});
}
let isFetching = false;
function fetchLogs() {
if (!isFetching) {
isFetching = true;
get_rtlog().then(() => {
isFetching = false;
setTimeout(fetchLogs, 3000); // 每3秒更新一次
}).catch(error => {
console.error("Failed to fetch logs:", error);
isFetching = false;
setTimeout(fetchLogs, 3000); // 如果请求失败3秒后重试
});
}
}
var $table = $('#table');
$(function() {
$(document).on('focus', 'input[type="text"]', function() {
$(this).parent().find('label').addClass('active');
}).on('blur', 'input[type="text"]', function() {
if ($(this).val() == '') {
$(this).parent().find('label').removeClass('active');
}
});
// bootstrap table初始化
// http://bootstrap-table.wenzhixin.net.cn/zh-cn/documentation/
$table.bootstrapTable({
data:[],
height: getHeight(),
striped: true,
search: true,
searchOnEnterKey: true,
showRefresh: false,
//showToggle: true,
showColumns: true,
minimumCountColumns: 2,
//showPaginationSwitch: true,
clickToSelect: true,
detailView: true,
detailFormatter: 'detailFormatter',
pagination: true,
pageSize: 15,
pageList: [10, 15, 30, 50, 100],
paginationLoop: false,
classes: 'table table-hover table-no-bordered',
//sidePagination: 'server',
//silentSort: false,
smartDisplay: false,
idField: 'time',
sortName: 'time',
sortOrder: 'desc',
escape: true,
searchOnEnterKey: true,
maintainSelected: true,
toolbar: '#toolbar',
columns: [
{field: 'time', title: 'date time', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'id', title: 'user id', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'name', title: 'name', sortable: true, halign: 'center', width: '15%',align: 'center'},
{field: 'mode', title: 'verify mode', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'inout', title: 'inout', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'event', title: 'event', sortable: true, halign: 'center', width: '10%',align: 'center'},
{field: 'note', title: 'not', sortable: true, halign: 'center', width: '20%',align: 'center'},
{field: 'photo', title: 'photo', sortable: true, halign: 'center', width: '10%',align: 'center', formatter: 'actionfaceFormatter'},
{field: 'photouri', title: 'image', visible: false}
]
}).on('all.bs.table', function (e, name, args) {
$('[data-toggle="tooltip"]').tooltip();
$('[data-toggle="popover"]').popover();
});
});
$table.on('refresh.bs.table', function () {
$table.bootstrapTable('removeAll');
get_rtlog();
});
function changetitle(field, newTitle) {
var columns = $table.bootstrapTable('getOptions').columns[0];
for (var i = 0; i < columns.length; i++) {
if (columns[i].field === field) {
columns[i].title = newTitle;
break;
}
}
$table.bootstrapTable('refreshOptions', {columns: columns});
}
function changeSearchPlaceholder() {
const options = $table.bootstrapTable('getOptions');
options.formatSearch = () => lang.UISTR_USER_FIND || 'Search...';
options.formatRefresh = () => lang.UISTR_Refresh || 'Refresh';
options.formatRefreshButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Refresh || 'Refresh'}`;
options.formatColumns = () => lang.UISTR_Columns || 'Columns'; // [4](@ref)
options.formatColumnsButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Columns || 'Columns'}`;
options.formatToggle = () => lang.UISTR_Toggle || 'Toggle'; // 悬浮提示Tooltip
options.formatToggleButton = (icon, text) =>
`<i class="${icon}"></i> ${text || lang.UISTR_Toggle || 'Toggle'}`; // 按钮文本
options.formatNoMatches = () => lang.UISTR_NoMatches || 'No matching records found';
options.formatShowingRows = (from, to, total) => {
const template = lang.UISTR_ShowingRows
|| 'Showing #1 to #2 of #3 rows';
return template
.replace(/#1/g, from)
.replace(/#2/g, to)
.replace(/#3/g, total);
};
options.formatRecordsPerPage = (pageSize) => {
const template = lang.UISTR_RecordsPerPage
|| '#3 rows per page';
return template.replace(/#3/g, pageSize);
};
// 强制刷新配置
$table.bootstrapTable('refreshOptions', options);
}
function actionfaceFormatter(value, row, index) {
return value ? `<i class="glyphicon glyphicon-picture"></i>` : '';
}
function detailFormatter(index, row) {
var html = [];
var detailsHtml = [];
// Manually add the information you want to display
detailsHtml.push('<p><b>'+lang.UISTR_ENROLL_ID+'</b>' + row.id + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_UserName+'</b>' + row.name + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_TIME_SETTING+'</b>' + row.time + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_VERIFY_mode+'</b>' + row.mode + '</p>');
if(!poithide)
{
detailsHtml.push('<p><b>inout</b>' + row.inout + '</p>');
detailsHtml.push('<p><b>'+lang.UISTR_EVENT+'</b>' + row.event + '</p>');
detailsHtml.push('<p><b>note</b>' + row.note + '</p>');
}
var imageUrl = '';
if(row.photouri)
imageUrl = debugserver+row.photouri;
// Use Flexbox for responsive alignment
html.push('<div class="row d-flex flex-column flex-sm-row">'); // Use flex-column for stacking on small screens, and flex-row for larger screens
html.push(' <div class="col-12 col-sm-8">'); // Adjusted width for the first column
html.push(' ' + detailsHtml.join(''));
html.push(' </div>');
if (imageUrl && imageUrl.trim()) {
// Ensure the image is always on the left on small screens
html.push(' <div class="col-12 col-sm-4 order-first order-sm-0">'); // Use order-first for small screens, and remove it for larger screens
html.push(' <a href="' + imageUrl+'?t=' + new Date().getTime() + '" target="_blank">'); // Added link to open image in new tab
html.push(' <img src="' + imageUrl+'?t=' + new Date().getTime() + '" alt="User Image" class="img-max-height-lg img-max-height-sm">');
html.push(' </a>');
html.push(' </div>');
}
html.push('</div>');
return html.join('');
}
$(document).ready(function() {
if(poithide)
{
const hideColumns = ['inout', 'event', 'note', 'photo', 'photouri'];
hideColumns.forEach(col => {
$table.bootstrapTable('hideColumn', col);
});
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,581 @@
<!DOCTYPE HTML>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title class="lang" key="device_setting">sys init</title>
<link href="resources/plugins/bootstrap-3.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<link href="resources/plugins/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/>
<script src="resources/plugins/jquery.1.12.4.min.js"></script>
<script src="resources/plugins/bootstrap-3.3.0/js/bootstrap.min.js"></script>
<script src="resources/plugins/bootstrap-table-1.17.0/bootstrap-table.min.js"></script>
<script src="resources/plugins/waves-0.7.5/waves.min.js"></script>
<script src="resources/plugins/jquery-confirm/jquery-confirm.min.js"></script>
<script src="resources/plugins/jquery.cookie.js"></script>
<link href="resources/plugins/jquery-toastmessage/resources/css/jquery.toastmessage.css" rel="stylesheet"/>
<script src="resources/plugins/jquery-toastmessage/javascript/jquery.toastmessage.js"></script>
<script src="resources/js/notify.js"></script>
<script src="resources/js/common.js"></script>
<script src="resources/js/lang.js"></script>
<script src="resources/js/crypto-js.min.js"></script>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.AdduserDialog {
display: flex;
margin-left:25%;
flex-direction: column;
flex-grow: 1;
max-width: 50%;
width: 50%;
padding: 2rem;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.input-group {
flex: 1;
text-align: right;
}
.input-group, .btn {
margin: 0 10px;
}
.button-spacing {
margin-top: 10px;
margin-bottom: 10px;
width: 200px;
}
@media (max-width: 768px) {
.AdduserDialog {
display: flex;
margin-left:25%;
flex-direction: column;
flex-grow: 1;
width: 700px;
padding: 2rem;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
}
</style>
</head>
<body>
<div class="container">
<div class="AdduserDialog d-flex flex-column align-items-center">
<div class="panel text-center">
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_all_user" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_ENROLL_DELETEALL"
href="javascript:;" onclick="cleanuserAction()">cleanuser</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_all_log" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_ALLLOG_DELETE"
href="javascript:;" onclick="clealogAction()">clealog</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_database" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_CLEAR_DATABASE"
href="javascript:;" onclick="cleandatabaseAction()">cleandatabase</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="init_menu" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_InitSystem"
href="javascript:;" onclick="initmenuAction()">initmenu</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_admin" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_BenumbAllManager"
href="javascript:;" onclick="cleaadminAction()">cleaadmin</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_inactive_user" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_CLEAR_INACTIVE_USERS"
href="javascript:;" onclick="cleaninactiveuserAction()">cleaninactiveuser</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="clean_all_snop" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_PHOTO_DELETE"
href="javascript:;" onclick="cleanlogphotoAction()">cleanlogphoto</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="factory_default" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_QUESTION_SETDEFAULT"
href="javascript:;" onclick="resetAction()">Reset to default setting</button>
</div>
</div>
<div class="row">
<div class="col-md-12 d-flex justify-content-center">
<button id="factory_default" type="" class="btn btn-danger waves-effect waves-float lang button-spacing"
key="UISTR_REBOOT"
href="javascript:;" onclick="rebootAction()">Reboot</button>
</div>
</div>
</div>
</div>
<div id="resetDialog" class="crudDialog" hidden>
<form>
</form>
</div>
</div>
<script>
check_permission();
lang_load();
function resetAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_QUESTION_SETDEFAULT,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'initsys'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function cleanuserAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_ENROLL_DELETEALL,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleanuser'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function clealogAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_ALLLOG_DELETE,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleanlog'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function cleaadminAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_BenumbAllManager,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleanadmin'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function cleaninactiveuserAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_CLEAR_INACTIVE_USERS,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleaninactiveuser'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function cleanlogphotoAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_PHOTO_DELETE,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleanlogphoto'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function initmenuAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_InitSystem,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'initmenu'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
notify(lang.UISTR_SUCCESSFUL);
onUploadSuccess();
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function rebootAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_REBOOT,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = {
password: $.cookie("pwd"),
cmd: 'reboot'
};
console.log(send);
// 发送请求到服务器
$.ajax({
url: debugserver + '/api',
type: 'POST',
async: true, // 使用默认的异步请求
contentType: "application/json",
data: JSON.stringify(send),
beforeSend: function () {
},
always: function () {
notify(lang.UISTR_SUCCESSFUL);
}
});
}
},
cancel: {
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
// 取消操作
}
}
}
});
}
function cleandatabaseAction() {
$.confirm({
type: 'dark',
animationSpeed: 300,
title: window.lang.UISTR_CLEAR_DATABASE,
content: $('#resetDialog').html(),
buttons: {
confirm: {
text: lang.UISTR_YES_OK,
btnClass: 'waves-effect waves-button',
action: function () {
let send = new Object()
send["password"] = $.cookie("pwd")
send["cmd"] = 'cleandatebase'
console.log(send)
$.ajax({
url: debugserver+'/api',
type: 'POST',
async: false,
contentType:"application/json",
dataType: "json",
data: JSON.stringify(send),
beforeSend: function() {
},
success: function(json){
console.log(json)
$.alert(lang.UISTR_SUCCESSFUL);
},
error: function(error){
console.log("reset error::::",error);
notify(lang.UISTR_ERRNONESUCCESS)
},
always: function () {
}
})
}
},
cancel:{
text: lang.UISTR_NO_ESC,
btnClass: 'waves-effect waves-button',
action: function () {
}
}
}
});
}
function onUploadSuccess() {
const homeChannel = new BroadcastChannel('home_refresh_channel');
homeChannel.postMessage({type: 'refresh_home'});
homeChannel.close();
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

12
install-service.bat Normal file
View File

@ -0,0 +1,12 @@
@echo off
setlocal
set EXE=%~dp0bin\Debug\HanvonF710XAttendanceService.exe
if not exist "%EXE%" set EXE=%~dp0bin\Release\HanvonF710XAttendanceService.exe
if not exist "%EXE%" (
echo Build the project first. EXE not found.
exit /b 1
)
set INSTALLUTIL=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\InstallUtil.exe
echo Installing Hanvon F710X Attendance Service from %EXE%
"%INSTALLUTIL%" "%EXE%"
endlocal

14
packages.config Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="BouncyCastle" version="1.8.5" targetFramework="net472" />
<package id="Google.Protobuf" version="3.11.4" targetFramework="net472" />
<package id="K4os.Compression.LZ4" version="1.1.11" targetFramework="net472" />
<package id="K4os.Compression.LZ4.Streams" version="1.1.11" targetFramework="net472" />
<package id="K4os.Hash.xxHash" version="1.0.6" targetFramework="net472" />
<package id="MySql.Data" version="8.0.23" targetFramework="net472" />
<package id="SSH.NET" version="2020.0.0" targetFramework="net472" />
<package id="System.Buffers" version="4.5.1" targetFramework="net472" />
<package id="System.Memory" version="4.5.3" targetFramework="net472" />
<package id="System.Numerics.Vectors" version="4.4.0" targetFramework="net472" />
<package id="System.Runtime.CompilerServices.Unsafe" version="4.5.2" targetFramework="net472" />
</packages>