add:增加文档和上位机

This commit is contained in:
2026-05-21 12:56:29 +08:00
parent 8ee0849831
commit a1d1f19585
95 changed files with 8594 additions and 43 deletions

View File

@@ -0,0 +1,96 @@
<Page x:Class="YKC.ChargerPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="充电桩管理" Background="#F0F2F5">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24">
<TextBlock Text="充电桩管理" FontSize="16" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,20"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 写入桩ID -->
<Border Grid.Column="0" Background="White" CornerRadius="6"
Margin="0,0,8,0" Padding="24">
<StackPanel>
<TextBlock Text="写入桩 ID" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,16"/>
<TextBlock Text="桩编号" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtSetPileIdx" Text="1" Width="80"
Style="{StaticResource FormInput}" Margin="0,0,0,16"/>
<TextBlock Text="序列号 (HEX, 14位)" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtSetPileId" Width="240" MaxLength="14"
Style="{StaticResource FormInput}" FontFamily="Consolas"
Margin="0,0,0,16"/>
<WrapPanel>
<Button Content="写 入" Style="{StaticResource BtnPrimary}" Width="90"
Click="SetPileId_Click"/>
<TextBlock x:Name="txtSetResult" Foreground="#10B981"
FontSize="13" VerticalAlignment="Center" Margin="12,0,0,0"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- 查询充电桩 -->
<Border Grid.Column="1" Background="White" CornerRadius="6"
Margin="8,0,0,0" Padding="24">
<StackPanel>
<TextBlock Text="查询充电桩信息" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,16"/>
<TextBlock Text="桩编号" Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,0,0,14">
<TextBox x:Name="txtInfoPileIdx" Text="1" Width="80"
Style="{StaticResource FormInput}"/>
<Button Content="查 询" Style="{StaticResource BtnOutline}" Width="80"
Margin="10,0,0,0" Click="GetPileInfo_Click"/>
<TextBlock x:Name="txtInfoResult" VerticalAlignment="Center"
FontSize="13" Margin="10,0,0,0"/>
</WrapPanel>
<!-- 查询结果 -->
<Border x:Name="panelInfo" Background="#F8FAFC"
CornerRadius="4" Padding="14" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="序列号" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoSerial" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="类型" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoType" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="枪数量" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoGuns" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="协议版本" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoProto" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="软件版本" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoSw" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="SIM 卡" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtInfoSim" Text="--" Style="{StaticResource InfoValue}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
</Grid>
<!-- 设备操作 -->
<Border Background="White" CornerRadius="6" Padding="24" Margin="0,14,0,0">
<StackPanel>
<TextBlock Text="设备操作" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,14"/>
<WrapPanel>
<Button Content="重启设备" Style="{StaticResource BtnDanger}" Width="110"
Click="Reboot_Click"/>
<TextBlock x:Name="txtRebootResult" Foreground="#10B981"
FontSize="13" VerticalAlignment="Center" Margin="12,0,0,0"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Page>

View File

@@ -0,0 +1,118 @@
using System;
using System.Windows;
using System.Windows.Controls;
using Newtonsoft.Json.Linq;
namespace YKC
{
public partial class ChargerPage : Page
{
public ChargerPage()
{
InitializeComponent();
}
private async void SetPileId_Click(object sender, RoutedEventArgs e)
{
string idx = txtSetPileIdx.Text.Trim();
string id = txtSetPileId.Text.Trim();
if (string.IsNullOrEmpty(id) || id.Length != 14)
{
ShowResult(txtSetResult, "序列号需14位HEX", false);
return;
}
ShowResult(txtSetResult, "发送中...", true);
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["SET_PILE_ID"],
["pile_index"] = int.Parse(idx),
["pile_id"] = id
});
Dispatcher.BeginInvoke(new Action(() =>
{
if (result.Value<bool>("success"))
ShowResult(txtSetResult, "写入成功", true);
else
ShowResult(txtSetResult, result.Value<string>("error") ?? "失败", false);
}));
});
}
private async void GetPileInfo_Click(object sender, RoutedEventArgs e)
{
string idx = txtInfoPileIdx.Text.Trim();
panelInfo.Visibility = Visibility.Visible;
txtInfoResult.Text = "查询中...";
txtInfoResult.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#64748B"));
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["GET_PILE_INFO"],
["pile_index"] = int.Parse(idx)
});
Dispatcher.BeginInvoke(new Action(() =>
{
if (result.Value<bool>("success") && result["serial"] != null)
{
txtInfoSerial.Text = result.Value<string>("serial") ?? "--";
txtInfoType.Text = result.Value<string>("type") ?? "--";
txtInfoGuns.Text = result.Value<string>("gun_num") ?? "--";
txtInfoProto.Text = result.Value<string>("protocol_ver") ?? "--";
txtInfoSw.Text = result.Value<string>("software_ver") ?? "--";
txtInfoSim.Text = result.Value<string>("sim") ?? "--";
txtInfoResult.Text = "";
}
else
{
txtInfoSerial.Text = "--"; txtInfoType.Text = "--";
txtInfoGuns.Text = "--"; txtInfoProto.Text = "--";
txtInfoSw.Text = "--"; txtInfoSim.Text = "--";
ShowResult(txtInfoResult, result.Value<string>("error") ?? "查询失败", false);
}
}));
});
}
private async void Reboot_Click(object sender, RoutedEventArgs e)
{
var dialogResult = MessageBox.Show("确认重启设备?", "警告", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (dialogResult != MessageBoxResult.Yes) return;
ShowResult(txtRebootResult, "发送中...", true);
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["REBOOT"]
});
Dispatcher.BeginInvoke(new Action(() =>
{
if (result.Value<bool>("success"))
ShowResult(txtRebootResult, "重启指令已发送", true);
else
ShowResult(txtRebootResult, result.Value<string>("error") ?? "失败", false);
}));
});
}
private void ShowResult(TextBlock el, string msg, bool ok)
{
el.Text = msg;
el.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(
ok ? "#10B981" : "#EF4444"));
var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(4) };
timer.Tick += (s, _) => { el.Text = ""; timer.Stop(); };
timer.Start();
}
}
}

View File

@@ -0,0 +1,92 @@
<Page x:Class="YKC.DashboardPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="总览" Background="#F0F2F5">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24">
<!-- 统计卡片行 -->
<WrapPanel Margin="0,0,0,20">
<Border Background="White" CornerRadius="6" Width="240"
Padding="20,18" Margin="0,0,16,16">
<StackPanel>
<TextBlock Text="设备连接" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtConnStatus" Text="--"
FontSize="20" FontWeight="SemiBold" Foreground="#1E293B"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="240"
Padding="20,18" Margin="0,0,16,16">
<StackPanel>
<TextBlock Text="在线充电桩" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtOnlineCount" Text="--"
FontSize="28" FontWeight="SemiBold" Foreground="#10B981"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="240"
Padding="20,18" Margin="0,0,16,16">
<StackPanel>
<TextBlock Text="充电中" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtChargingCount" Text="--"
FontSize="28" FontWeight="SemiBold" Foreground="#3B82F6"/>
</StackPanel>
</Border>
</WrapPanel>
<!-- 标题 + 按钮 -->
<Grid Margin="0,0,0,12">
<TextBlock Text="充电桩列表" FontSize="15" FontWeight="SemiBold"
Foreground="#1E293B" VerticalAlignment="Center"/>
<Button Content="刷新" Style="{StaticResource BtnPrimary}"
Width="90" HorizontalAlignment="Right"
Click="Refresh_Click"/>
</Grid>
<!-- 表格 -->
<Border Background="White" CornerRadius="6" Padding="0">
<DataGrid x:Name="dgPiles" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" CanUserAddRows="False"
CanUserDeleteRows="False" RowHeight="40"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#F1F5F9"
BorderThickness="0" Background="White">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#F8FAFC"/>
<Setter Property="Foreground" Value="#64748B"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="BorderBrush" Value="#E2E8F0"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="{TemplateBinding Background}">
<ContentPresenter Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</DataGrid.CellStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="#" Width="50" Binding="{Binding Index}"/>
<DataGridTextColumn Header="序列号" Width="*" Binding="{Binding Serial}"/>
<DataGridTextColumn Header="桩状态" Width="80" Binding="{Binding StatusText}"/>
<DataGridTextColumn Header="枪1" Width="80" Binding="{Binding Gun1Text}"/>
<DataGridTextColumn Header="枪2" Width="80" Binding="{Binding Gun2Text}"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</StackPanel>
</ScrollViewer>
</Page>

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using Newtonsoft.Json.Linq;
using OxyPlot;
namespace YKC
{
public partial class DashboardPage : Page
{
private Timer _timer;
public class PileRow
{
public int Index { get; set; }
public string Serial { get; set; } = "--";
public string StatusText { get; set; } = "--";
public string Gun1Text { get; set; } = "--";
public string Gun2Text { get; set; } = "--";
}
public ObservableCollection<PileRow> Rows { get; } = new ObservableCollection<PileRow>();
public DashboardPage()
{
InitializeComponent();
dgPiles.ItemsSource = Rows;
Loaded += (s, e) =>
{
Dispatcher.BeginInvoke(new Action(() => RefreshData()),
System.Windows.Threading.DispatcherPriority.Background);
_timer = new Timer((_) => Dispatcher.BeginInvoke(new Action(RefreshData)), null, 5000, 5000);
};
Unloaded += (s, e) => _timer?.Dispose();
}
private void Refresh_Click(object sender, RoutedEventArgs e) => RefreshData();
private void RefreshData()
{
try
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["GET_STATUS"]
});
if (result.Value<bool>("success") == false && result["piles"] == null)
{
Dispatcher.BeginInvoke(new Action(() => txtConnStatus.Text = "无响应"));
return;
}
Dispatcher.BeginInvoke(new Action(() => txtConnStatus.Text = "在线"));
var piles = result["piles"] as JArray ?? new JArray();
int online = 0, charging = 0;
var rows = new ObservableCollection<PileRow>();
foreach (var p in piles)
{
bool isOnline = p.Value<bool>("is_online");
if (isOnline) online++;
var guns = p["guns"] as JArray ?? new JArray();
foreach (var g in guns)
if (g.Value<int?>("status") == 3) charging++;
rows.Add(new PileRow
{
Index = rows.Count + 1,
Serial = p.Value<string>("serial") ?? "--",
StatusText = isOnline ? "在线" : "离线",
Gun1Text = guns.Count > 0 ? GunStatusText(guns[0].Value<int>("status")) : "--",
Gun2Text = guns.Count > 1 ? GunStatusText(guns[1].Value<int>("status")) : "--",
});
}
Dispatcher.BeginInvoke(new Action(() =>
{
txtOnlineCount.Text = online.ToString();
txtChargingCount.Text = charging.ToString();
Rows.Clear();
foreach (var r in rows) Rows.Add(r);
}));
}
catch { }
}
private string GunStatusText(int status)
{
switch (status)
{
case 0: return "离线";
case 1: return "故障";
case 2: return "空闲";
case 3: return "充电中";
default: return status.ToString();
}
}
}
}

View File

@@ -0,0 +1,145 @@
<Page x:Class="YKC.GatewayPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="网关管理" Background="#F0F2F5">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24">
<TextBlock Text="网关管理" FontSize="16" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,20"/>
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 网关基本信息 -->
<Border Grid.Column="0" Background="White" CornerRadius="6"
Margin="0,0,8,0" Padding="24">
<StackPanel>
<TextBlock Text="网关基本信息" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,14"/>
<TextBlock Text="网关 ID" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtGwId" Text="--" Style="{StaticResource InfoValue}"
FontFamily="Consolas"/>
<TextBlock Text="软件版本" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtGwSw" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="硬件版本" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtGwHw" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="运行时间" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtGwUptime" Text="--" Style="{StaticResource InfoValue}"/>
<Grid>
<Button Content="刷新" Style="{StaticResource BtnPrimary}" Width="80"
Click="RefreshGwInfo_Click" Margin="0,4,0,0"/>
<TextBlock x:Name="txtGwStatus" FontSize="12"
VerticalAlignment="Center" Margin="92,4,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- 4G 状态 -->
<Border Grid.Column="1" Background="White" CornerRadius="6"
Margin="8,0,0,0" Padding="24">
<StackPanel>
<TextBlock Text="4G 状态" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,14"/>
<TextBlock Text="SIM 卡号" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtSim" Text="--" Style="{StaticResource InfoValue}"
FontFamily="Consolas"/>
<TextBlock Text="网络状态" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtNetStatus" Text="--" Style="{StaticResource InfoValue}"
Foreground="#10B981"/>
<TextBlock Text="信号强度" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtSignal" Text="--" Style="{StaticResource InfoValue}"/>
<TextBlock Text="运营商" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtIsp" Text="--" Style="{StaticResource InfoValue}"/>
<Grid>
<Button Content="刷新" Style="{StaticResource BtnPrimary}" Width="80"
Click="Refresh4G_Click" Margin="0,4,0,0"/>
<TextBlock x:Name="txt4GStatus" FontSize="12"
VerticalAlignment="Center" Margin="92,4,0,0"/>
</Grid>
</StackPanel>
</Border>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 云服务器配置 -->
<Border Grid.Column="0" Background="White" CornerRadius="6"
Margin="0,0,8,0" Padding="24">
<StackPanel>
<TextBlock Text="云服务器配置" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,14"/>
<TextBlock Text="服务器地址 (IP 或域名)" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtCloudHost" Width="220"
Style="{StaticResource FormInput}" Margin="0,0,0,12"/>
<TextBlock Text="端口号" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtCloudPort" Width="120"
Style="{StaticResource FormInput}" Margin="0,0,0,16"/>
<WrapPanel>
<Button Content="查 询" Style="{StaticResource BtnOutline}" Width="80"
Click="LoadCloudConfig_Click"/>
<Button Content="保 存" Style="{StaticResource BtnPrimary}" Width="80"
Click="SaveCloudConfig_Click" Margin="8,0,0,0"/>
<TextBlock x:Name="txtCloudResult" Foreground="#10B981"
FontSize="13" VerticalAlignment="Center" Margin="12,0,0,0"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- 网络配置 -->
<Border Grid.Column="1" Background="White" CornerRadius="6"
Margin="8,0,0,0" Padding="24">
<StackPanel>
<TextBlock Text="有线网络配置" FontSize="14" FontWeight="SemiBold"
Foreground="#1E293B" Margin="0,0,0,14"/>
<TextBlock Text="IP 地址" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtNetIp" Width="200"
Style="{StaticResource FormInput}" Margin="0,0,0,10"/>
<TextBlock Text="子网掩码" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtNetMask" Width="200"
Style="{StaticResource FormInput}" Margin="0,0,0,10"/>
<TextBlock Text="默认网关" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtNetGw" Width="200"
Style="{StaticResource FormInput}" Margin="0,0,0,10"/>
<TextBlock Text="DNS 服务器" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="txtNetDns" Width="200"
Style="{StaticResource FormInput}" Margin="0,0,0,16"/>
<WrapPanel>
<Button Content="查 询" Style="{StaticResource BtnOutline}" Width="80"
Click="LoadNetConfig_Click"/>
<Button Content="保 存" Style="{StaticResource BtnPrimary}" Width="80"
Click="SaveNetConfig_Click" Margin="8,0,0,0"/>
<TextBlock x:Name="txtNetResult" Foreground="#10B981"
FontSize="13" VerticalAlignment="Center" Margin="12,0,0,0"/>
</WrapPanel>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</Page>

View File

@@ -0,0 +1,227 @@
using System;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using Newtonsoft.Json.Linq;
namespace YKC
{
public partial class GatewayPage : Page
{
private static readonly Regex IpRegex = new Regex(
@"^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$");
private static readonly Regex DomainRegex = new Regex(
@"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z]{2,})+$");
public GatewayPage()
{
InitializeComponent();
Loaded += (s, e) =>
{
Dispatcher.BeginInvoke(new Action(() =>
{
if (txtGwId != null) RefreshAll();
}), System.Windows.Threading.DispatcherPriority.Background);
};
}
private bool IsValidIp(string s) => IpRegex.IsMatch(s);
private bool IsValidDomain(string s) => DomainRegex.IsMatch(s);
private bool IsValidHost(string s) => IsValidIp(s) || IsValidDomain(s);
private void ShowResult(TextBlock el, string msg, bool ok)
{
el.Text = msg;
el.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(
ok ? "#10B981" : "#EF4444"));
var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
timer.Tick += (s, _) => { el.Text = ""; timer.Stop(); };
timer.Start();
}
private string FormatUptime(int sec)
{
if (sec <= 0) return "--";
int d = sec / 86400;
int h = (sec % 86400) / 3600;
int m = (sec % 3600) / 60;
string result = "";
if (d > 0) result += d + "天";
if (h > 0) result += h + "时";
if (m > 0) result += m + "分";
return result.Length > 0 ? result : sec + "秒";
}
private void RefreshAll()
{
RefreshGwInfo();
Refresh4G();
}
private async void RefreshGwInfo()
{
txtGwStatus.Text = "加载中...";
txtGwStatus.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#64748B"));
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject { ["cmd"] = Config.Cmd["GET_GW_INFO"] });
Dispatcher.BeginInvoke(new Action(() =>
{
if (result.Value<bool>("success") && result["device_id"] != null)
{
txtGwId.Text = result.Value<string>("device_id") ?? "--";
txtGwSw.Text = result.Value<string>("software_ver") ?? result.Value<string>("sw_ver") ?? "--";
txtGwHw.Text = result.Value<string>("hardware_ver") ?? result.Value<string>("hw_ver") ?? "--";
txtGwUptime.Text = FormatUptime(result.Value<int?>("uptime") ?? 0);
txtGwStatus.Text = "";
}
else
{
txtGwId.Text = "--"; txtGwSw.Text = "--"; txtGwHw.Text = "--"; txtGwUptime.Text = "--";
ShowResult(txtGwStatus, result.Value<string>("error") ?? "查询超时", false);
}
}));
});
}
private async void Refresh4G()
{
txt4GStatus.Text = "加载中...";
txt4GStatus.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#64748B"));
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject { ["cmd"] = Config.Cmd["GET_4G_STATUS"] });
Dispatcher.BeginInvoke(new Action(() =>
{
if (result.Value<bool>("success") && result["sim"] != null)
{
txtSim.Text = result.Value<string>("sim") ?? result.Value<string>("iccid") ?? "--";
txtNetStatus.Text = result.Value<string>("net_status") ?? result.Value<string>("status_text") ?? "--";
txtSignal.Text = result.Value<string>("signal") != null ? result.Value<int>("signal") + " dBm" : "--";
txtIsp.Text = result.Value<string>("isp") ?? result.Value<string>("operator") ?? "--";
txt4GStatus.Text = "";
}
else
{
txtSim.Text = "--"; txtNetStatus.Text = "--"; txtSignal.Text = "--"; txtIsp.Text = "--";
ShowResult(txt4GStatus, result.Value<string>("error") ?? "查询超时", false);
}
}));
});
}
private void RefreshGwInfo_Click(object sender, RoutedEventArgs e) => RefreshGwInfo();
private void Refresh4G_Click(object sender, RoutedEventArgs e) => Refresh4G();
private async void LoadCloudConfig_Click(object sender, RoutedEventArgs e)
{
txtCloudResult.Text = "加载中...";
txtCloudResult.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#64748B"));
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject { ["cmd"] = Config.Cmd["GET_CLOUD_CONFIG"] });
Dispatcher.BeginInvoke(new Action(() =>
{
txtCloudResult.Text = "";
if (result.Value<bool>("success") && result["host"] != null)
{
txtCloudHost.Text = result.Value<string>("host") ?? "";
txtCloudPort.Text = result.Value<string>("port") ?? result.Value<int?>("port")?.ToString() ?? "";
}
else
{
ShowResult(txtCloudResult, result.Value<string>("error") ?? "查询超时", false);
}
}));
});
}
private async void SaveCloudConfig_Click(object sender, RoutedEventArgs e)
{
string host = txtCloudHost.Text.Trim();
string port = txtCloudPort.Text.Trim();
if (string.IsNullOrEmpty(host)) { ShowResult(txtCloudResult, "请输入服务器地址", false); return; }
if (!IsValidHost(host)) { ShowResult(txtCloudResult, "地址格式错误", false); return; }
if (string.IsNullOrEmpty(port) || !int.TryParse(port, out int p) || p < 1 || p > 65535)
{ ShowResult(txtCloudResult, "端口范围 1-65535", false); return; }
ShowResult(txtCloudResult, "保存中...", true);
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["SET_CLOUD_CONFIG"],
["host"] = host,
["port"] = p
});
Dispatcher.BeginInvoke(new Action(() =>
{
ShowResult(txtCloudResult, result.Value<bool>("success") ? "保存成功" : (result.Value<string>("error") ?? "失败"), result.Value<bool>("success"));
}));
});
}
private async void LoadNetConfig_Click(object sender, RoutedEventArgs e)
{
txtNetResult.Text = "加载中...";
txtNetResult.Foreground = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#64748B"));
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject { ["cmd"] = Config.Cmd["GET_NET_CONFIG"] });
Dispatcher.BeginInvoke(new Action(() =>
{
txtNetResult.Text = "";
if (result.Value<bool>("success") && result["ip"] != null)
{
txtNetIp.Text = result.Value<string>("ip") ?? "";
txtNetMask.Text = result.Value<string>("mask") ?? result.Value<string>("netmask") ?? "";
txtNetGw.Text = result.Value<string>("gateway") ?? result.Value<string>("gw") ?? "";
txtNetDns.Text = result.Value<string>("dns") ?? "";
}
else
{
ShowResult(txtNetResult, result.Value<string>("error") ?? "查询超时", false);
}
}));
});
}
private async void SaveNetConfig_Click(object sender, RoutedEventArgs e)
{
string ip = txtNetIp.Text.Trim();
string mask = txtNetMask.Text.Trim();
string gw = txtNetGw.Text.Trim();
string dns = txtNetDns.Text.Trim();
if (string.IsNullOrEmpty(ip)) { ShowResult(txtNetResult, "请输入IP地址", false); return; }
if (!IsValidIp(ip)) { ShowResult(txtNetResult, "IP格式错误", false); return; }
if (string.IsNullOrEmpty(mask)) { ShowResult(txtNetResult, "请输入子网掩码", false); return; }
if (!IsValidIp(mask)) { ShowResult(txtNetResult, "掩码格式错误", false); return; }
if (!string.IsNullOrEmpty(gw) && !IsValidIp(gw)) { ShowResult(txtNetResult, "网关格式错误", false); return; }
if (!string.IsNullOrEmpty(dns) && !IsValidIp(dns)) { ShowResult(txtNetResult, "DNS格式错误", false); return; }
ShowResult(txtNetResult, "保存中...", true);
await System.Threading.Tasks.Task.Run(() =>
{
var result = UdpClientHolder.Instance.SendSync(new JObject
{
["cmd"] = Config.Cmd["SET_NET_CONFIG"],
["ip"] = ip,
["mask"] = mask,
["gateway"] = gw,
["dns"] = dns
});
Dispatcher.BeginInvoke(new Action(() =>
{
ShowResult(txtNetResult, result.Value<bool>("success") ? "保存成功,重启生效" : (result.Value<string>("error") ?? "失败"), result.Value<bool>("success"));
}));
});
}
}
}

View File

@@ -0,0 +1,170 @@
<Page x:Class="YKC.RealtimePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:oxy="http://oxyplot.org/wpf"
Title="实时监控" Background="#F0F2F5">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="20">
<!-- 标题 + 选择器 -->
<Grid Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="实时监控" FontSize="16" FontWeight="SemiBold"
Foreground="#1E293B"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="桩" Foreground="#64748B" FontSize="13"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ComboBox x:Name="cmbPile" Style="{StaticResource StyledCombo}" Width="80"
SelectedIndex="0" SelectionChanged="PileGun_Changed">
<ComboBoxItem>桩 01</ComboBoxItem><ComboBoxItem>桩 02</ComboBoxItem>
<ComboBoxItem>桩 03</ComboBoxItem><ComboBoxItem>桩 04</ComboBoxItem>
<ComboBoxItem>桩 05</ComboBoxItem><ComboBoxItem>桩 06</ComboBoxItem>
</ComboBox>
<TextBlock Text="枪" Foreground="#64748B" FontSize="13"
VerticalAlignment="Center" Margin="12,0,6,0"/>
<ComboBox x:Name="cmbGun" Style="{StaticResource StyledCombo}" Width="70"
SelectedIndex="0" SelectionChanged="PileGun_Changed">
<ComboBoxItem>枪 1</ComboBoxItem><ComboBoxItem>枪 2</ComboBoxItem>
</ComboBox>
</StackPanel>
</Grid>
<!-- 数值卡片行1 -->
<WrapPanel Margin="0,0,0,6">
<Border Background="White" CornerRadius="6" Width="170"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="电流 (A)" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtCurrent" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#10B981"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="170"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="电压 (V)" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtVoltage" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#1E293B"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="170"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="功率 (kW)" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtPower" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#1E293B"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="170"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="度数 (kWh)" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtEnergy" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#1E293B"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="170"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="总金额 (元)" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtTotalAmount" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#FF5722"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="150"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="状态" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtStatus" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#3B82F6"/>
</StackPanel>
</Border>
</WrapPanel>
<!-- 订单信息行 -->
<WrapPanel Margin="0,0,0,10">
<Border Background="White" CornerRadius="6" Width="280"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="订单编号" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtOrderNo" Text="--" FontSize="13"
FontWeight="SemiBold" Foreground="#1E293B"
FontFamily="Consolas" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="140"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="目标 SOC" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtSoc" Text="--" FontSize="22"
FontWeight="SemiBold" Foreground="#1E293B"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="180"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="累计充电时间" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtCumTime" Text="--" FontSize="18"
FontWeight="SemiBold" Foreground="#1E293B"
FontFamily="Consolas"/>
</StackPanel>
</Border>
<Border Background="White" CornerRadius="6" Width="180"
Padding="14,12" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="剩余时间" Style="{StaticResource InfoLabel}"/>
<TextBlock x:Name="txtRemTime" Text="--" FontSize="18"
FontWeight="SemiBold" Foreground="#1E293B"
FontFamily="Consolas"/>
</StackPanel>
</Border>
</WrapPanel>
<!-- 图表 2x2 -->
<Grid Height="580">
<Grid.RowDefinitions>
<RowDefinition Height="290"/>
<RowDefinition Height="290"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Row="0" Grid.Column="0" Background="White" CornerRadius="6"
Margin="0,0,7,7">
<Grid>
<TextBlock Text="电流曲线 (A)" Foreground="#94A3B8" FontSize="12"
Margin="14,10,0,0" VerticalAlignment="Top"/>
<oxy:PlotView x:Name="plotCurrent" Margin="8,32,8,8"/>
</Grid>
</Border>
<Border Grid.Row="0" Grid.Column="1" Background="White" CornerRadius="6"
Margin="7,0,0,7">
<Grid>
<TextBlock Text="电压曲线 (V)" Foreground="#94A3B8" FontSize="12"
Margin="14,10,0,0" VerticalAlignment="Top"/>
<oxy:PlotView x:Name="plotVoltage" Margin="8,32,8,8"/>
</Grid>
</Border>
<Border Grid.Row="1" Grid.Column="0" Background="White" CornerRadius="6"
Margin="0,7,7,0">
<Grid>
<TextBlock Text="功率曲线 (kW)" Foreground="#94A3B8" FontSize="12"
Margin="14,10,0,0" VerticalAlignment="Top"/>
<oxy:PlotView x:Name="plotPower" Margin="8,32,8,8"/>
</Grid>
</Border>
<Border Grid.Row="1" Grid.Column="1" Background="White" CornerRadius="6"
Margin="7,7,0,0">
<Grid>
<TextBlock Text="费用明细 (元)" Foreground="#94A3B8" FontSize="12"
Margin="14,10,0,0" VerticalAlignment="Top"/>
<oxy:PlotView x:Name="plotAmount" Margin="8,32,8,8"/>
</Grid>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</Page>

View File

@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Newtonsoft.Json.Linq;
using OxyPlot;
using OxyPlot.Axes;
using OxyPlot.Series;
namespace YKC
{
public partial class RealtimePage : Page
{
private Timer _pollTimer;
private int _currentPile = 1;
private int _currentGun = 1;
public RealtimePage()
{
InitializeComponent();
InitLinePlot(plotCurrent, OxyColor.Parse("#3B82F6"));
InitLinePlot(plotVoltage, OxyColor.Parse("#8B5CF6"));
InitLinePlot(plotPower, OxyColor.Parse("#F59E0B"));
InitBarPlot(plotAmount);
Loaded += (s, e) =>
{
Dispatcher.BeginInvoke(new Action(RefreshData),
System.Windows.Threading.DispatcherPriority.Background);
_pollTimer = new Timer((_) =>
Dispatcher.BeginInvoke(new Action(RefreshData)), null, 2000, 2000);
};
Unloaded += (s, e) => _pollTimer?.Dispose();
}
private void InitLinePlot(OxyPlot.Wpf.PlotView pv, OxyColor color)
{
var model = new PlotModel { PlotAreaBorderColor = OxyColors.Transparent };
model.Axes.Add(new DateTimeAxis
{
Position = AxisPosition.Bottom,
TextColor = OxyColor.Parse("#94A3B8"),
AxislineColor = OxyColor.Parse("#E2E8F0"),
TicklineColor = OxyColor.Parse("#E2E8F0"),
MajorGridlineColor = OxyColor.Parse("#F1F5F9"),
StringFormat = "HH:mm:ss",
FontSize = 10
});
model.Axes.Add(new LinearAxis
{
Position = AxisPosition.Left,
TextColor = OxyColor.Parse("#94A3B8"),
AxislineColor = OxyColors.Transparent,
TicklineColor = OxyColors.Transparent,
MajorGridlineStyle = LineStyle.Dash,
MajorGridlineColor = OxyColor.Parse("#F1F5F9"),
FontSize = 10,
MinimumPadding = 0.1,
MaximumPadding = 0.1
});
model.Series.Add(new LineSeries
{
Color = color,
StrokeThickness = 2,
MarkerType = MarkerType.None,
CanTrackerInterpolatePoints = false
});
pv.Model = model;
}
private void InitBarPlot(OxyPlot.Wpf.PlotView pv)
{
var model = new PlotModel { PlotAreaBorderColor = OxyColors.Transparent };
var catAxis = new CategoryAxis
{
Position = AxisPosition.Bottom,
TextColor = OxyColor.Parse("#64748B"),
FontSize = 12,
AxislineColor = OxyColor.Parse("#E2E8F0"),
TicklineColor = OxyColor.Parse("#E2E8F0")
};
catAxis.Labels.Add("服务费");
catAxis.Labels.Add("电费");
catAxis.Labels.Add("总金额");
model.Axes.Add(catAxis);
model.Axes.Add(new LinearAxis
{
Position = AxisPosition.Left,
Title = "元",
TextColor = OxyColor.Parse("#94A3B8"),
FontSize = 10,
AxislineColor = OxyColors.Transparent,
TicklineColor = OxyColors.Transparent,
MajorGridlineStyle = LineStyle.Dash,
MajorGridlineColor = OxyColor.Parse("#F1F5F9"),
MinimumPadding = 0,
MaximumPadding = 0.15
});
model.Series.Add(new RectangleBarSeries { FillColor = OxyColor.Parse("#1E9FFF") });
model.Series.Add(new RectangleBarSeries { FillColor = OxyColor.Parse("#10B981") });
model.Series.Add(new RectangleBarSeries { FillColor = OxyColor.Parse("#6366F1") });
pv.Model = model;
}
private void PileGun_Changed(object sender, SelectionChangedEventArgs e)
{
try
{
if (cmbPile == null || cmbGun == null) return;
_currentPile = cmbPile.SelectedIndex + 1;
_currentGun = cmbGun.SelectedIndex + 1;
RefreshData();
}
catch { }
}
private void RefreshData()
{
try
{
if (txtCurrent == null || plotCurrent == null || plotCurrent.Model == null) return;
var latest = PilesStoreHolder.Instance.GetLatest(_currentPile, _currentGun);
if (latest == null) return;
txtCurrent.Text = FormatValue(latest.Value<double?>("current")) + " A";
txtVoltage.Text = FormatValue(latest.Value<double?>("voltage")) + " V";
txtPower.Text = FormatValue(latest.Value<double?>("power")) + " kW";
txtEnergy.Text = FormatValue(latest.Value<double?>("energy")) + " kWh";
txtTotalAmount.Text = "¥ " + FormatValue(latest.Value<double?>("total_amount"));
int? st = latest.Value<int?>("status");
if (st == 0) { txtStatus.Text = "离线"; txtStatus.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#909399")); }
else if (st == 1) { txtStatus.Text = "故障"; txtStatus.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#EF4444")); }
else if (st == 2) { txtStatus.Text = "空闲"; txtStatus.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#10B981")); }
else if (st == 3) { txtStatus.Text = "充电中"; txtStatus.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3B82F6")); }
else { txtStatus.Text = "--"; }
txtOrderNo.Text = latest.Value<string>("order_no") ?? "--";
txtSoc.Text = latest.Value<double?>("soc") != null ? latest.Value<double>("soc").ToString("F1") + "%" : "--";
txtCumTime.Text = FormatMin(latest.Value<double?>("cumulative_time"));
txtRemTime.Text = FormatMin(latest.Value<double?>("remaining_time"));
UpdateCharts();
}
catch { }
}
private void UpdateCharts()
{
try
{
if (plotCurrent == null || plotCurrent.Model == null || plotCurrent.Model.Series.Count == 0) return;
if (plotAmount == null || plotAmount.Model == null || plotAmount.Model.Series.Count == 0) return;
var chartData = PilesStoreHolder.Instance.GetChartData(_currentPile, _currentGun);
if (chartData == null || chartData.Count == 0) return;
var curPoints = new List<DataPoint>();
var volPoints = new List<DataPoint>();
var powPoints = new List<DataPoint>();
foreach (var item in chartData)
{
long unixTs = item.Value<long>("t");
var dt = DateTimeOffset.FromUnixTimeSeconds(unixTs).DateTime;
double oxyTime = DateTimeAxis.ToDouble(dt);
curPoints.Add(new DataPoint(oxyTime, item.Value<double?>("current") ?? 0));
volPoints.Add(new DataPoint(oxyTime, item.Value<double?>("voltage") ?? 0));
powPoints.Add(new DataPoint(oxyTime, item.Value<double?>("power") ?? 0));
}
((LineSeries)plotCurrent.Model.Series[0]).Points.Clear();
((LineSeries)plotCurrent.Model.Series[0]).Points.AddRange(curPoints);
((LineSeries)plotVoltage.Model.Series[0]).Points.Clear();
((LineSeries)plotVoltage.Model.Series[0]).Points.AddRange(volPoints);
((LineSeries)plotPower.Model.Series[0]).Points.Clear();
((LineSeries)plotPower.Model.Series[0]).Points.AddRange(powPoints);
plotCurrent.InvalidatePlot(true);
plotVoltage.InvalidatePlot(true);
plotPower.InvalidatePlot(true);
var latest = PilesStoreHolder.Instance.GetLatest(_currentPile, _currentGun);
if (latest != null)
{
double sf = latest.Value<double?>("service_fee") ?? 0;
double ef = latest.Value<double?>("electricity_fee") ?? 0;
double ta = latest.Value<double?>("total_amount") ?? 0;
((RectangleBarSeries)plotAmount.Model.Series[0]).Items.Clear();
((RectangleBarSeries)plotAmount.Model.Series[0]).Items.Add(new RectangleBarItem(-0.25, 0, 0.25, sf));
((RectangleBarSeries)plotAmount.Model.Series[1]).Items.Clear();
((RectangleBarSeries)plotAmount.Model.Series[1]).Items.Add(new RectangleBarItem(0.75, 0, 1.25, ef));
((RectangleBarSeries)plotAmount.Model.Series[2]).Items.Clear();
((RectangleBarSeries)plotAmount.Model.Series[2]).Items.Add(new RectangleBarItem(1.75, 0, 2.25, ta));
plotAmount.InvalidatePlot(true);
}
}
catch { }
}
private string FormatValue(double? v)
{
return v.HasValue ? v.Value.ToString("F2") : "--";
}
private string FormatMin(double? v)
{
return v.HasValue ? Math.Round(v.Value).ToString() + " min" : "--";
}
}
}