新增打印模板管理功能,包含免密接口和实时通知机制,支持桌面端打印模板的查询和列表展示。更新相关控制器、服务和视图,优化用户体验并增强系统的实时数据同步能力。

This commit is contained in:
geht
2026-05-12 18:29:03 +08:00
parent f5ba828eff
commit fcedc66f7a
32 changed files with 2788 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using YY.Admin.Core.Services;
namespace YY.Admin.Services.Service.Print;
/// <summary>
/// PrintDot 本地桥接器 WebSocket 客户端。
/// URL 从 appsettings.json 的 PrintDot:Url 读取,可在运行时通过 <see cref="PrintDotSettings"/> 覆盖。
/// </summary>
public class PrintDotService : IPrintDotService
{
private readonly IConfiguration _config;
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public PrintDotService(IConfiguration config)
{
_config = config;
}
private string ResolveWsUrl()
{
var url = PrintDotSettings.Current?.WsUrl
?? _config.GetValue<string>("PrintDot:Url")
?? "ws://127.0.0.1:1122/ws";
return url.TrimEnd('/');
}
public async Task<IReadOnlyList<PrintDotPrinter>> GetPrintersAsync(CancellationToken ct = default)
{
var wsUrl = ResolveWsUrl();
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(wsUrl), ct);
// 连接后服务端立即推送 printer_list
var json = await ReceiveTextAsync(ws, ct);
var doc = JsonNode.Parse(json);
if (doc?["type"]?.GetValue<string>() == "printer_list")
{
var arr = doc["data"]?.AsArray();
if (arr != null)
{
return arr
.Where(n => n != null)
.Select(n => new PrintDotPrinter(
Name: n!["name"]?.GetValue<string>()?.Trim() ?? string.Empty,
IsDefault: n["isDefault"]?.GetValue<bool>() ?? false))
.Where(p => !string.IsNullOrWhiteSpace(p.Name))
.ToList();
}
}
return [];
}
public async Task PrintAsync(string printerName, string pdfBase64, string jobName = "QH-MES", int copies = 1, CancellationToken ct = default)
{
// 去掉 data: 前缀
var content = pdfBase64.Trim();
var comma = content.IndexOf(',');
if (content.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
content = content[(comma + 1)..];
var payload = new
{
printer = printerName,
content,
job = new { name = jobName, copies = Math.Max(1, copies) }
};
var wsUrl = ResolveWsUrl();
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(wsUrl), ct);
// 先等服务端推送 printer_list 再发任务
await ReceiveTextAsync(ws, ct);
var msg = JsonSerializer.Serialize(payload, JsonOpts);
await ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, ct);
// 等待打印结果(最多 3 分钟)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromMinutes(3));
while (ws.State == WebSocketState.Open)
{
var response = await ReceiveTextAsync(ws, cts.Token);
var resDoc = JsonNode.Parse(response);
var status = resDoc?["status"]?.GetValue<string>();
if (status == null) continue;
if (status == "success") return;
throw new InvalidOperationException(resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败");
}
}
private static async Task<string> ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct)
{
var buffer = new ArraySegment<byte>(new byte[64 * 1024]);
using var ms = new System.IO.MemoryStream();
WebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(buffer, ct);
ms.Write(buffer.Array!, buffer.Offset, result.Count);
} while (!result.EndOfMessage);
return Encoding.UTF8.GetString(ms.ToArray());
}
}

View File

@@ -0,0 +1,46 @@
namespace YY.Admin.Services.Service.Print;
/// <summary>
/// 运行时可修改的 PrintDot 连接设置由设置页写入PrintDotService 优先读取)。
/// </summary>
public class PrintDotSettings
{
private static readonly string SettingsPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YY.Admin", "print-dot-settings.json");
public string WsUrl { get; set; } = "ws://127.0.0.1:1122/ws";
public string SelectedPrinter { get; set; } = string.Empty;
private static PrintDotSettings? _current;
public static PrintDotSettings? Current => _current;
public static PrintDotSettings Load()
{
try
{
if (System.IO.File.Exists(SettingsPath))
{
var json = System.IO.File.ReadAllText(SettingsPath);
_current = System.Text.Json.JsonSerializer.Deserialize<PrintDotSettings>(json) ?? new PrintDotSettings();
return _current;
}
}
catch { /* 读取失败使用默认值 */ }
_current = new PrintDotSettings();
return _current;
}
public void Save()
{
try
{
var dir = System.IO.Path.GetDirectoryName(SettingsPath)!;
System.IO.Directory.CreateDirectory(dir);
var json = System.Text.Json.JsonSerializer.Serialize(this, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(SettingsPath, json);
_current = this;
}
catch { /* 忽略保存失败 */ }
}
}

View File

@@ -0,0 +1,170 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Services;
namespace YY.Admin.Services.Service.Print;
public class PrintTemplateService : IPrintTemplateService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly object _cacheLock = new();
private List<PrintTemplate> _localCache = new();
private readonly string _cacheFilePath;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new NullableDateTimeJsonConverter() }
};
private string BaseUrl =>
(_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
public PrintTemplateService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YY.Admin", "sync-cache");
Directory.CreateDirectory(appDataDir);
_cacheFilePath = Path.Combine(appDataDir, "print-template-cache.json");
LoadCacheFromDisk();
}
public IReadOnlyList<PrintTemplate> GetCached()
{
lock (_cacheLock)
{
return _localCache.AsReadOnly();
}
}
public async Task<PrintTemplate?> GetByCodeAsync(string templateCode, CancellationToken ct = default)
{
try
{
var client = _httpClientFactory.CreateClient("JeecgApi");
var url = $"{BaseUrl}/print/template/anon/queryByCode?code={Uri.EscapeDataString(templateCode)}";
var response = await client.GetAsync(url, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return GetCachedByCode(templateCode);
var result = await response.Content.ReadFromJsonAsync<JeecgResult<PrintTemplate>>(JsonOpts, ct).ConfigureAwait(false);
return result?.Success == true ? result.Result : GetCachedByCode(templateCode);
}
catch
{
return GetCachedByCode(templateCode);
}
}
public async Task<IReadOnlyList<PrintTemplate>> ListAsync(CancellationToken ct = default)
{
try
{
return await RefreshCacheAsync(ct).ConfigureAwait(false);
}
catch
{
return GetCached();
}
}
public async Task<IReadOnlyList<PrintTemplate>> RefreshCacheAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("JeecgApi");
var url = $"{BaseUrl}/print/template/anon/list?pageSize=200";
var response = await client.GetAsync(url, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JeecgResult<JeecgPage<PrintTemplate>>>(JsonOpts, ct).ConfigureAwait(false);
if (result?.Success != true)
throw new InvalidOperationException(result?.Message ?? "后端返回失败");
var records = result.Result?.Records ?? new List<PrintTemplate>();
lock (_cacheLock)
{
_localCache = records;
SaveCacheToDiskUnsafe();
}
return records.AsReadOnly();
}
private PrintTemplate? GetCachedByCode(string code)
{
lock (_cacheLock)
{
return _localCache.FirstOrDefault(t =>
string.Equals(t.TemplateCode, code, StringComparison.OrdinalIgnoreCase));
}
}
private void LoadCacheFromDisk()
{
try
{
if (!File.Exists(_cacheFilePath)) return;
var json = File.ReadAllText(_cacheFilePath);
var data = JsonSerializer.Deserialize<List<PrintTemplate>>(json, JsonOpts);
_localCache = data ?? new List<PrintTemplate>();
}
catch
{
_localCache = new List<PrintTemplate>();
}
}
private void SaveCacheToDiskUnsafe()
{
try
{
var json = JsonSerializer.Serialize(_localCache, JsonOpts);
File.WriteAllText(_cacheFilePath, json);
}
catch { }
}
private record JeecgResult<T>(bool Success, T? Result, string? Message);
private record JeecgPage<T>(List<T> Records, long Total);
private sealed class NullableDateTimeJsonConverter : JsonConverter<DateTime?>
{
private static readonly string[] Formats =
[
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm:ss.fff",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm:ss.fff",
"yyyy-MM-ddTHH:mm:ssZ",
"yyyy-MM-ddTHH:mm:ss.fffZ"
];
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null) return null;
if (reader.TokenType == JsonTokenType.String)
{
var raw = reader.GetString();
if (string.IsNullOrWhiteSpace(raw)) return null;
if (DateTime.TryParseExact(raw, Formats, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeLocal, out var exact))
return exact;
if (DateTime.TryParse(raw, out var fallback)) return fallback;
}
throw new JsonException($"无法将 JSON 值转换为 DateTime?token={reader.TokenType}");
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value.HasValue) writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss"));
else writer.WriteNullValue();
}
}
}

View File

@@ -0,0 +1,81 @@
using Prism.Events;
using System.Text.Json;
using YY.Admin.Core;
using YY.Admin.Core.Events;
using YY.Admin.Core.Services;
namespace YY.Admin.Services.Service.Print;
public class PrintTemplateSyncCoordinator : ISingletonDependency
{
private readonly IEventAggregator _eventAggregator;
private readonly IPrintTemplateService _printTemplateService;
private readonly ILoggerService _logger;
private SubscriptionToken? _remoteCommandToken;
private SubscriptionToken? _networkStatusToken;
public PrintTemplateSyncCoordinator(
IEventAggregator eventAggregator,
IPrintTemplateService printTemplateService,
SyncPollManager pollManager,
ILoggerService logger)
{
_eventAggregator = eventAggregator;
_printTemplateService = printTemplateService;
_logger = logger;
_remoteCommandToken = _eventAggregator
.GetEvent<RemoteCommandReceivedEvent>()
.Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
_networkStatusToken = _eventAggregator
.GetEvent<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("打印模板", () =>
{
_eventAggregator.GetEvent<PrintTemplateChangedEvent>()
.Publish(new PrintTemplateChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
_logger.Information("[打印模板推送] PrintTemplateSyncCoordinator 已启动");
}
private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload)
{
if (!payload.IsOnline) return;
_logger.Information("[打印模板推送] 网络恢复,触发补偿刷新");
_eventAggregator.GetEvent<PrintTemplateChangedEvent>()
.Publish(new PrintTemplateChangedPayload { Action = "reconnect" });
}
private void OnRemoteCommand(RemoteCommandPayload payload)
{
try
{
var json = payload.CommandJson ?? string.Empty;
if (string.IsNullOrWhiteSpace(json)) return;
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return;
var cmd = cmdEl.GetString() ?? string.Empty;
if (!cmd.Equals("PRINT_TEMPLATE_CHANGED", StringComparison.OrdinalIgnoreCase)) return;
doc.RootElement.TryGetProperty("action", out var actionEl);
doc.RootElement.TryGetProperty("templateId", out var idEl);
var changedPayload = new PrintTemplateChangedPayload
{
Action = actionEl.GetString() ?? string.Empty,
TemplateId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null
};
_logger.Information($"[打印模板推送] 收到变更信号 action={changedPayload.Action}, templateId={changedPayload.TemplateId}");
_eventAggregator.GetEvent<PrintTemplateChangedEvent>().Publish(changedPayload);
}
catch (Exception ex)
{
_logger.Warning($"[打印模板推送] 处理 STOMP 信号失败: {ex.Message}");
}
}
}