feat: 初始功能

This commit is contained in:
Shiqvlizi Name
2026-05-23 18:16:50 +08:00
parent a5bfd8792e
commit 9a51259e9e
63 changed files with 5377 additions and 1 deletions

429
.gitignore vendored Normal file
View File

@@ -0,0 +1,429 @@
## 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/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
[Dd]ebug/x64/
[Dd]ebugPublic/x64/
[Rr]elease/x64/
[Rr]eleases/x64/
bin/x64/
obj/x64/
[Dd]ebug/x86/
[Dd]ebugPublic/x86/
[Rr]elease/x86/
[Rr]eleases/x86/
bin/x86/
obj/x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# 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.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# 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/
.artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.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_*
.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 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# 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
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# 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
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp

86
App.xaml Normal file
View File

@@ -0,0 +1,86 @@
<Application x:Class="CloudPrint.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryDarkBrush" Color="#1E40AF" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="MutedBrush" Color="#64748B" />
<SolidColorBrush x:Key="PageBrush" Color="#F4F7FB" />
<SolidColorBrush x:Key="BorderBrush" Color="#E2E8F0" />
<SolidColorBrush x:Key="SuccessBrush" Color="#16A34A" />
<SolidColorBrush x:Key="WarningBrush" Color="#D97706" />
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626" />
<Style TargetType="Window">
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="Background" Value="{StaticResource PageBrush}" />
</Style>
<Style TargetType="Button">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="14,6" />
<Setter Property="Margin" Value="0,0,8,0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource PrimaryDarkBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TextBox">
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderBrush" Value="#CBD5E1" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style TargetType="PasswordBox">
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderBrush" Value="#CBD5E1" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style TargetType="DataGrid">
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="CanUserAddRows" Value="False" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="RowHeight" Value="36" />
<Setter Property="BorderBrush" Value="#E2E8F0" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style x:Key="SecondaryButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="#E2E8F0" />
<Setter Property="Foreground" Value="#0F172A" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#CBD5E1" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style>
</Application.Resources>
</Application>

38
App.xaml.cs Normal file
View File

@@ -0,0 +1,38 @@
using System.Windows.Threading;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services;
namespace CloudPrint.Client;
public partial class App : System.Windows.Application
{
private readonly FileAppLogger _logger = new();
protected override void OnStartup(System.Windows.StartupEventArgs e)
{
base.OnStartup(e);
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
_logger.Write(AppLogEntry.FromException("UI 未处理异常", e.Exception));
e.Handled = true;
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is Exception exception)
{
_logger.Write(AppLogEntry.FromException("应用未处理异常", exception));
}
}
private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
_logger.Write(AppLogEntry.FromException("后台任务未观察异常", e.Exception));
e.SetObserved();
}
}

28
CloudPrint.Client.csproj Normal file
View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CloudPrint.Client</AssemblyName>
<RootNamespace>CloudPrint.Client</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.27.3" />
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Grpc.Tools" Version="2.65.0" PrivateAssets="All" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="SharpCompress" Version="0.48.1" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="..\backend\cloudprint-backend\proto\cloudprint.proto" GrpcServices="Client" Link="Protos\cloudprint.proto" />
</ItemGroup>
</Project>

227
Helpers/PdfProcessor.cs Normal file
View File

@@ -0,0 +1,227 @@
using CloudPrint.Client.Models;
using PdfSharp.Drawing;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;
using System.IO;
namespace CloudPrint.Client.Helpers;
/// <summary>
/// 基于 PDFsharp 的 PDF 处理工具,负责打印前的页码范围裁剪、水印和合并。
/// </summary>
public sealed class PdfProcessor
{
public Task<string> AddWatermarkAsync(string pdfPath, string text, CancellationToken cancellationToken = default)
{
ValidatePdfPath(pdfPath);
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult(pdfPath);
}
cancellationToken.ThrowIfCancellationRequested();
var outputPath = CreateSiblingOutputPath(pdfPath, "watermarked");
using var document = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Modify);
foreach (var page in document.Pages)
{
cancellationToken.ThrowIfCancellationRequested();
using var graphics = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
var font = new XFont("Arial", 36, XFontStyleEx.Bold);
var size = graphics.MeasureString(text, font);
var centerX = page.Width.Point / 2;
var centerY = page.Height.Point / 2;
graphics.TranslateTransform(centerX, centerY);
graphics.RotateTransform(-35);
graphics.DrawString(
text,
font,
new XSolidBrush(XColor.FromArgb(80, 120, 120, 120)),
new XRect(-size.Width / 2, -size.Height / 2, size.Width, size.Height),
XStringFormats.Center);
graphics.RotateTransform(35);
graphics.TranslateTransform(-centerX, -centerY);
}
document.Save(outputPath);
return Task.FromResult(outputPath);
}
public Task<string> ExtractPageRangeAsync(string pdfPath, string pageRange, string outputDirectory, CancellationToken cancellationToken = default)
{
ValidatePdfPath(pdfPath);
if (string.IsNullOrWhiteSpace(pageRange) || pageRange.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(pdfPath);
}
cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(outputDirectory);
using var input = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Import);
var pageIndexes = ParsePageRange(pageRange, input.PageCount);
if (pageIndexes.Count == input.PageCount && pageIndexes.SequenceEqual(Enumerable.Range(0, input.PageCount)))
{
return Task.FromResult(pdfPath);
}
using var output = new PdfDocument();
output.Info.Title = input.Info.Title;
output.Info.Author = input.Info.Author;
output.Info.Subject = input.Info.Subject;
output.Info.Keywords = input.Info.Keywords;
foreach (var index in pageIndexes)
{
cancellationToken.ThrowIfCancellationRequested();
output.AddPage(input.Pages[index]);
}
var outputPath = CreateUniquePath(Path.Combine(outputDirectory, $"{Path.GetFileNameWithoutExtension(pdfPath)}.pages-{NormalizePageRangeForFileName(pageRange)}.pdf"));
output.Save(outputPath);
return Task.FromResult(outputPath);
}
public Task<string> MergeAsync(IEnumerable<CloudPrintDocument> documents, string outputPath, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(documents);
if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("输出路径不能为空", nameof(outputPath));
}
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? Directory.GetCurrentDirectory());
using var output = new PdfDocument();
foreach (var document in documents)
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsPdf(document.FilePath))
{
continue;
}
using var input = PdfReader.Open(document.FilePath, PdfDocumentOpenMode.Import);
foreach (var page in input.Pages)
{
output.AddPage(page);
}
}
if (output.PageCount == 0)
{
throw new InvalidOperationException("没有可合并的 PDF 文件");
}
var uniqueOutputPath = CreateUniquePath(outputPath);
output.Save(uniqueOutputPath);
return Task.FromResult(uniqueOutputPath);
}
private static IReadOnlyList<int> ParsePageRange(string pageRange, int pageCount)
{
if (pageCount <= 0)
{
throw new InvalidOperationException("PDF 没有可打印页面");
}
var pages = new SortedSet<int>();
foreach (var rawPart in pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var part = rawPart.Replace('', '-').Replace('—', '-');
if (part.Contains('-', StringComparison.Ordinal))
{
var bounds = part.Split('-', 2, StringSplitOptions.TrimEntries);
var start = ParseOneBasedPageNumber(bounds[0], pageCount);
var end = ParseOneBasedPageNumber(bounds[1], pageCount);
if (start > end)
{
throw new ArgumentException($"页码范围无效:{rawPart}", nameof(pageRange));
}
for (var page = start; page <= end; page++)
{
pages.Add(page - 1);
}
}
else
{
pages.Add(ParseOneBasedPageNumber(part, pageCount) - 1);
}
}
if (pages.Count == 0)
{
throw new ArgumentException("页码范围不能为空", nameof(pageRange));
}
return pages.ToArray();
}
private static int ParseOneBasedPageNumber(string value, int pageCount)
{
if (!int.TryParse(value, out var page) || page < 1 || page > pageCount)
{
throw new ArgumentOutOfRangeException(nameof(value), $"页码 {value} 超出有效范围 1-{pageCount}");
}
return page;
}
private static void ValidatePdfPath(string pdfPath)
{
if (string.IsNullOrWhiteSpace(pdfPath))
{
throw new ArgumentException("PDF 路径不能为空", nameof(pdfPath));
}
if (!File.Exists(pdfPath))
{
throw new FileNotFoundException("PDF 文件不存在", pdfPath);
}
if (!IsPdf(pdfPath))
{
throw new ArgumentException("文件不是 PDF", nameof(pdfPath));
}
}
private static bool IsPdf(string path)
{
return Path.GetExtension(path).Equals(".pdf", StringComparison.OrdinalIgnoreCase);
}
private static string CreateSiblingOutputPath(string sourcePath, string suffix)
{
var directory = Path.GetDirectoryName(sourcePath) ?? Directory.GetCurrentDirectory();
var outputPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(sourcePath)}.{suffix}.pdf");
return CreateUniquePath(outputPath);
}
private static string CreateUniquePath(string path)
{
if (!File.Exists(path))
{
return path;
}
var directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory();
var fileName = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
for (var index = 1; index < 10_000; index++)
{
var candidate = Path.Combine(directory, $"{fileName}-{index}{extension}");
if (!File.Exists(candidate))
{
return candidate;
}
}
throw new IOException($"无法创建唯一文件路径:{path}");
}
private static string NormalizePageRangeForFileName(string pageRange)
{
return string.Concat(pageRange.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-');
}
}

117
Helpers/WindowsPrintApi.cs Normal file
View File

@@ -0,0 +1,117 @@
using System.Runtime.InteropServices;
namespace CloudPrint.Client.Helpers;
/// <summary>
/// Windows Print Spooler API P/Invoke 声明,用于 RAW 打印、队列枚举和任务取消。
/// </summary>
public static class WindowsPrintApi
{
public const int JobControlCancel = 3;
public const int JobControlDelete = 5;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public sealed class DocInfo
{
[MarshalAs(UnmanagedType.LPWStr)]
public string DocumentName = string.Empty;
[MarshalAs(UnmanagedType.LPWStr)]
public string? OutputFile;
[MarshalAs(UnmanagedType.LPWStr)]
public string DataType = "RAW";
}
[StructLayout(LayoutKind.Sequential)]
public struct SystemTime
{
public short Year;
public short Month;
public short DayOfWeek;
public short Day;
public short Hour;
public short Minute;
public short Second;
public short Milliseconds;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct JobInfo1
{
public int JobId;
[MarshalAs(UnmanagedType.LPWStr)]
public string? PrinterName;
[MarshalAs(UnmanagedType.LPWStr)]
public string? MachineName;
[MarshalAs(UnmanagedType.LPWStr)]
public string? UserName;
[MarshalAs(UnmanagedType.LPWStr)]
public string? DocumentName;
[MarshalAs(UnmanagedType.LPWStr)]
public string? DataType;
[MarshalAs(UnmanagedType.LPWStr)]
public string? StatusText;
public int Status;
public int Priority;
public int Position;
public int TotalPages;
public int PagesPrinted;
public SystemTime Submitted;
}
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenPrinter(string printerName, out IntPtr printerHandle, IntPtr printerDefaults);
[DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool ClosePrinter(IntPtr printerHandle);
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern int StartDocPrinter(IntPtr printerHandle, int level, [In] DocInfo documentInfo);
[DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EndDocPrinter(IntPtr printerHandle);
[DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool StartPagePrinter(IntPtr printerHandle);
[DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EndPagePrinter(IntPtr printerHandle);
[DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WritePrinter(IntPtr printerHandle, byte[] data, int count, out int written);
[DllImport("winspool.drv", EntryPoint = "EnumJobsW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumJobs(
IntPtr printerHandle,
int firstJob,
int numberOfJobs,
int level,
IntPtr jobBuffer,
int bufferSize,
out int bytesNeeded,
out int jobsReturned);
[DllImport("winspool.drv", EntryPoint = "SetJobW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetJob(
IntPtr printerHandle,
int jobId,
int level,
IntPtr jobInfo,
int command);
}

View File

@@ -0,0 +1,55 @@
using System.Windows.Input;
namespace CloudPrint.Client.Infrastructure;
public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool IsExecuting
{
get => _isExecuting;
private set
{
if (_isExecuting == value)
{
return;
}
_isExecuting = value;
RaiseCanExecuteChanged();
}
}
public bool CanExecute(object? parameter) => !IsExecuting && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
{
return;
}
try
{
IsExecuting = true;
await _execute().ConfigureAwait(true);
}
finally
{
IsExecuting = false;
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,6 @@
namespace CloudPrint.Client.Infrastructure;
public interface IUiDispatcher
{
void Invoke(Action action);
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CloudPrint.Client.Infrastructure;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,28 @@
using System.Windows.Input;
namespace CloudPrint.Client.Infrastructure;
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute is null ? null : _ => canExecute())
{
}
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,18 @@
namespace CloudPrint.Client.Infrastructure;
public sealed class WpfUiDispatcher : IUiDispatcher
{
public void Invoke(Action action)
{
ArgumentNullException.ThrowIfNull(action);
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null || dispatcher.CheckAccess())
{
action();
return;
}
dispatcher.Invoke(action);
}
}

14
Models/AdminSession.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace CloudPrint.Client.Models;
public sealed class AdminSession
{
public int AdminId { get; init; }
public string Username { get; init; } = string.Empty;
public string Nickname { get; init; } = string.Empty;
public int Role { get; init; }
public string AccessToken { get; init; } = string.Empty;
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(AccessToken);
public string DisplayName => string.IsNullOrWhiteSpace(Nickname) ? Username : Nickname;
}

39
Models/AppLogEntry.cs Normal file
View File

@@ -0,0 +1,39 @@
namespace CloudPrint.Client.Models;
public enum AppLogLevel
{
Info,
Success,
Warning,
Error
}
public sealed class AppLogEntry
{
public DateTime Timestamp { get; init; } = DateTime.Now;
public AppLogLevel Level { get; init; } = AppLogLevel.Info;
public string Message { get; init; } = string.Empty;
public string ExceptionText { get; init; } = string.Empty;
public string LevelText => Level switch
{
AppLogLevel.Success => "成功",
AppLogLevel.Warning => "警告",
AppLogLevel.Error => "错误",
_ => "信息"
};
public string DisplayText => string.IsNullOrWhiteSpace(ExceptionText)
? $"[{Timestamp:HH:mm:ss}] [{LevelText}] {Message}"
: $"[{Timestamp:HH:mm:ss}] [{LevelText}] {Message}(详见本地日志)";
public static AppLogEntry FromException(string message, Exception exception)
{
return new AppLogEntry
{
Level = AppLogLevel.Error,
Message = message,
ExceptionText = exception.ToString()
};
}
}

77
Models/ClientSettings.cs Normal file
View File

@@ -0,0 +1,77 @@
using CloudPrint.Client.Infrastructure;
namespace CloudPrint.Client.Models;
public sealed class ClientSettings : ObservableObject
{
private string _serverAddress = "http://localhost:8080";
private string _apiKey = string.Empty;
private string _adminUsername = string.Empty;
private string _adminAccessToken = string.Empty;
private string _storeName = "默认打印店";
private bool _allowInsecureGrpc = true;
private string _libraryRoot = string.Empty;
private string _printCacheRoot = string.Empty;
private int _maxUploadSizeMb = 50;
private int _heartbeatIntervalSeconds = 15;
public string ServerAddress
{
get => _serverAddress;
set => SetProperty(ref _serverAddress, value);
}
public string ApiKey
{
get => _apiKey;
set => SetProperty(ref _apiKey, value);
}
public string AdminUsername
{
get => _adminUsername;
set => SetProperty(ref _adminUsername, value);
}
public string AdminAccessToken
{
get => _adminAccessToken;
set => SetProperty(ref _adminAccessToken, value);
}
public string StoreName
{
get => _storeName;
set => SetProperty(ref _storeName, value);
}
public bool AllowInsecureGrpc
{
get => _allowInsecureGrpc;
set => SetProperty(ref _allowInsecureGrpc, value);
}
public string LibraryRoot
{
get => _libraryRoot;
set => SetProperty(ref _libraryRoot, value);
}
public string PrintCacheRoot
{
get => _printCacheRoot;
set => SetProperty(ref _printCacheRoot, value);
}
public int MaxUploadSizeMb
{
get => _maxUploadSizeMb;
set => SetProperty(ref _maxUploadSizeMb, Math.Clamp(value, 1, 1024));
}
public int HeartbeatIntervalSeconds
{
get => _heartbeatIntervalSeconds;
set => SetProperty(ref _heartbeatIntervalSeconds, Math.Clamp(value, 5, 300));
}
}

11
Models/ConnectionState.cs Normal file
View File

@@ -0,0 +1,11 @@
namespace CloudPrint.Client.Models;
public enum ConnectionState
{
Disconnected,
Connecting,
Connected,
Subscribing,
Subscribed,
Error
}

71
Models/Document.cs Normal file
View File

@@ -0,0 +1,71 @@
using CloudPrint.Client.Infrastructure;
namespace CloudPrint.Client.Models;
public sealed class CloudPrintDocument : ObservableObject
{
private string _title = string.Empty;
private string _description = string.Empty;
private string _categoryName = "未分类";
private bool _isUploaded;
private string _syncStatus = "本地";
public int Id { get; init; }
public string FileName { get; init; } = string.Empty;
public string FilePath { get; init; } = string.Empty;
public string FileType { get; init; } = string.Empty;
public long FileSize { get; init; }
public string UploaderType { get; init; } = "admin";
public int UploaderId { get; init; }
public int CategoryId { get; init; }
public int PageCount { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.Now;
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public string Description
{
get => _description;
set => SetProperty(ref _description, value);
}
public string CategoryName
{
get => _categoryName;
set => SetProperty(ref _categoryName, value);
}
public bool IsUploaded
{
get => _isUploaded;
set => SetProperty(ref _isUploaded, value);
}
public string SyncStatus
{
get => _syncStatus;
set => SetProperty(ref _syncStatus, value);
}
public string FileSizeText
{
get
{
if (FileSize >= 1024 * 1024)
{
return $"{FileSize / 1024d / 1024d:F2} MB";
}
if (FileSize >= 1024)
{
return $"{FileSize / 1024d:F2} KB";
}
return $"{FileSize} B";
}
}
}

View File

@@ -0,0 +1,9 @@
namespace CloudPrint.Client.Models;
public sealed class FilePreparationResult
{
public string SourcePath { get; init; } = string.Empty;
public string PreparedPath { get; init; } = string.Empty;
public bool Converted { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace CloudPrint.Client.Models;
public sealed class FileValidationResult
{
public bool IsValid { get; init; }
public string Message { get; init; } = string.Empty;
public static FileValidationResult Success(string message = "文件可用")
{
return new FileValidationResult { IsValid = true, Message = message };
}
public static FileValidationResult Failure(string message)
{
return new FileValidationResult { IsValid = false, Message = message };
}
}

View File

@@ -0,0 +1,9 @@
namespace CloudPrint.Client.Models;
public sealed class LibraryCategory
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Icon { get; init; } = string.Empty;
public int SortOrder { get; init; }
}

110
Models/PrintJob.cs Normal file
View File

@@ -0,0 +1,110 @@
using CloudPrint.Client.Infrastructure;
namespace CloudPrint.Client.Models;
public enum PrintJobStatus
{
Pending,
Printing,
Completed,
Failed,
Cancelled
}
public sealed class PrintJob : ObservableObject
{
private PrintJobStatus _status = PrintJobStatus.Pending;
private int _progress;
private string _errorMessage = string.Empty;
private int? _windowsJobId;
private string _windowsJobStatus = string.Empty;
public int Id { get; init; }
public string JobNo { get; init; } = string.Empty;
public int PrinterId { get; set; }
public string PrinterName { get; set; } = string.Empty;
public int OrderItemId { get; init; }
public int FileId { get; init; }
public string FileName { get; init; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public int Copies { get; init; } = 1;
public bool Color { get; init; }
public bool Duplex { get; init; }
public string PageRange { get; init; } = "all";
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int? WindowsJobId
{
get => _windowsJobId;
set => SetProperty(ref _windowsJobId, value);
}
public string WindowsJobStatus
{
get => _windowsJobStatus;
set => SetProperty(ref _windowsJobStatus, value);
}
public PrintJobStatus Status
{
get => _status;
set
{
if (SetProperty(ref _status, value))
{
OnPropertyChanged(nameof(StatusText));
}
}
}
public int Progress
{
get => _progress;
set => SetProperty(ref _progress, Math.Clamp(value, 0, 100));
}
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
public string StatusText => Status switch
{
PrintJobStatus.Pending => "待打印",
PrintJobStatus.Printing => "打印中",
PrintJobStatus.Completed => "已完成",
PrintJobStatus.Failed => "失败",
PrintJobStatus.Cancelled => "已取消",
_ => "未知"
};
public void MarkPrinting()
{
StartedAt ??= DateTime.Now;
Status = PrintJobStatus.Printing;
Progress = Math.Max(Progress, 1);
}
public void MarkCompleted()
{
Status = PrintJobStatus.Completed;
Progress = 100;
CompletedAt = DateTime.Now;
ErrorMessage = string.Empty;
}
public void MarkFailed(string message)
{
Status = PrintJobStatus.Failed;
ErrorMessage = message;
CompletedAt = DateTime.Now;
}
public void MarkCancelled()
{
Status = PrintJobStatus.Cancelled;
CompletedAt = DateTime.Now;
}
}

82
Models/Printer.cs Normal file
View File

@@ -0,0 +1,82 @@
using CloudPrint.Client.Infrastructure;
namespace CloudPrint.Client.Models;
public enum PrinterStatus
{
Idle = 0,
Printing = 1,
Offline = 2,
Error = 3,
Unknown = 99
}
public sealed class PrinterInfo : ObservableObject
{
private PrinterStatus _status;
private int _queueCount;
private string _statusMessage = string.Empty;
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public bool IsActive { get; init; } = true;
public string ApiKey { get; init; } = string.Empty;
public DateTime? LastHeartbeat { get; set; }
public int QueueCount
{
get => _queueCount;
set
{
if (SetProperty(ref _queueCount, Math.Max(0, value)))
{
OnPropertyChanged(nameof(DisplayText));
}
}
}
public string StatusMessage
{
get => _statusMessage;
set
{
if (SetProperty(ref _statusMessage, value))
{
OnPropertyChanged(nameof(DisplayText));
}
}
}
public PrinterStatus Status
{
get => _status;
set
{
if (SetProperty(ref _status, value))
{
OnPropertyChanged(nameof(StatusText));
OnPropertyChanged(nameof(DisplayText));
}
}
}
public string StatusText => Status switch
{
PrinterStatus.Idle => "空闲",
PrinterStatus.Printing => "打印中",
PrinterStatus.Offline => "离线",
PrinterStatus.Error => "异常",
_ => "未知"
};
public string DisplayText
{
get
{
var queueText = QueueCount > 0 ? $",队列 {QueueCount}" : string.Empty;
var messageText = string.IsNullOrWhiteSpace(StatusMessage) ? string.Empty : $"{StatusMessage}";
return $"{DisplayName}{StatusText}{queueText}{messageText}";
}
}
}

213
README.md
View File

@@ -1,2 +1,213 @@
# Client
# 云印享 - Windows 门店打印客户端
云印享 Windows 门店打印客户端是部署在打印店本地 Windows 电脑上的 WPF 程序,负责连接真实 Go gRPC 后端、注册本机打印机、订阅打印任务、预处理文件并调用 Windows 打印系统执行打印。
本客户端只承担 **门店本地管理与打印执行** 职责不替代微信小程序、React 管理后台或 Go 后端服务。
## 技术栈
| 技术 | 说明 |
|------|------|
| 框架 | .NET 8 + WPF |
| 语言 | C# |
| 架构 | MVVM |
| 通信 | gRPC + WebSocket |
| 协议 | Protobuf |
| 打印 | Windows Print Spooler API / PrintDocument |
| 文件处理 | PDFsharp / SharpCompress / LibreOffice Headless |
| 配置安全 | Windows DPAPI |
## 功能模块
- 🖨️ **打印机管理** - 枚举本机实体/网络打印机,过滤 PDF、XPS、OneNote、Fax 等虚拟输出设备
- 📋 **打印任务队列** - 订阅后端任务流,自动顺序处理,支持暂停、恢复、取消任务
- 📄 **文件预处理** - 支持 PDF、图片、Office/TXT、ZIP、RAR 等文件处理
- 📌 **PDF 处理** - 支持订单页码范围裁剪
- 📁 **文库管理** - 管理员上传本地资料到后端文库服务
- 🔐 **认证与配置** - API Key 注册打印机,管理员登录,敏感配置 DPAPI 加密保存
- 🔄 **连接保活** - 心跳、订阅恢复、指数退避重连、任务去重
- 📝 **日志记录** - UI 日志和异常堆栈落盘到本地日志目录
## 项目结构
```text
client/
├── App.xaml # 应用入口
├── CloudPrint.Client.csproj # WPF 项目文件
├── Helpers/ # Windows API 与 PDF 工具
│ ├── PdfProcessor.cs
│ └── WindowsPrintApi.cs
├── Infrastructure/ # MVVM 基础设施
│ ├── AsyncRelayCommand.cs
│ ├── ObservableObject.cs
│ ├── RelayCommand.cs
│ └── WpfUiDispatcher.cs
├── Models/ # 领域模型
│ ├── ClientSettings.cs
│ ├── Document.cs
│ ├── Printer.cs
│ └── PrintJob.cs
├── Services/ # 业务服务
│ ├── Abstractions/ # 服务接口
│ ├── Grpc/ # gRPC metadata 与模型映射
│ ├── Printing/ # 打印执行器与队列服务
│ ├── FileService.cs # 文件校验、解压、转换
│ ├── GrpcService.cs # 后端 gRPC 通信
│ ├── JsonSettingsService.cs # 本地配置持久化
│ ├── PrintJobCoordinator.cs # 打印任务协调流程
│ ├── PrintService.cs # 打印服务门面
│ └── WebSocketService.cs # WebSocket 备用通道
├── ViewModels/ # 页面 ViewModel
│ ├── MainViewModel.cs
│ ├── PrintQueueViewModel.cs
│ ├── LibraryViewModel.cs
│ └── SettingsViewModel.cs
├── Views/ # WPF 页面
│ ├── MainWindow.xaml
│ ├── PrintQueueView.xaml
│ ├── LibraryView.xaml
│ └── SettingsView.xaml
```
## 环境要求
- Windows 10/11
- .NET 8 SDK 或 .NET 8 Desktop Runtime
- 可用的 Go gRPC 后端服务
- 真实有效的打印客户端 API Key
- 至少一台实体或网络 Windows 打印机
- PDF 打印建议安装 SumatraPDF
- Office/TXT 转 PDF 需要安装 LibreOffice 或配置 `SOFFICE_PATH`
## 快速开始
### 1. 还原依赖
```powershell
dotnet restore .\CloudPrint.Client.csproj
```
### 2. 启动客户端
```powershell
dotnet run --project .\CloudPrint.Client.csproj
```
### 3. 配置客户端
在“系统设置”页配置:
- 服务器地址Go gRPC 后端地址,例如 `http://localhost:8080`
- API Key后端为门店打印客户端分配的认证 Key
- 门店名称:当前打印店名称
- 本地文库目录:门店本地资料目录
- 允许 HTTP gRPC本地开发或内网明文 HTTP/2 时启用,生产环境建议关闭
- 最大上传大小、心跳间隔等运行参数
### 4. 注册并订阅任务
1. 在主界面选择真实打印机
2. 点击连接后端
3. 点击订阅打印任务
4. 保持客户端在线,后端推送任务后自动进入打印队列
## 开发命令
```powershell
# 构建客户端
dotnet build .\CloudPrint.Client.csproj
# 检查依赖漏洞
dotnet list .\CloudPrint.Client.csproj package --vulnerable
```
## 对接服务
客户端基于 `backend/cloudprint-backend/proto/cloudprint.proto` 生成 C# gRPC Stub主要对接以下服务
| 服务 | 用途 |
|------|------|
| PrintService.RegisterPrinter | 注册本机打印机 |
| PrintService.PrinterHeartbeat | 上报打印机心跳和状态 |
| PrintService.SubscribeJobs | 订阅后端打印任务流 |
| PrintService.ReportJobStatus | 上报打印任务进度和结果 |
| PrintService.CancelPrintJob | 取消后端打印任务 |
| FileService.GetFile | 下载待打印文件 |
| LibraryService.UploadFile | 上传门店文库文件 |
| AdminService.Login | 管理员登录 |
## 文件处理能力
| 类型 | 格式 | 处理方式 |
|------|------|----------|
| PDF | `.pdf` | 支持页码范围裁剪,直接提交 PDF 打印 |
| 图片 | `.jpg`, `.jpeg`, `.png`, `.bmp`, `.gif` | 使用 `PrintDocument` 等比缩放打印 |
| Office | `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` | 通过 LibreOffice Headless 转 PDF 后打印 |
| 文本 | `.txt` | 通过 LibreOffice Headless 转 PDF 后打印 |
| 压缩包 | `.zip`, `.rar` | 使用 SharpCompress 解压首个可打印文件后继续处理 |
压缩包处理会限制条目数量、嵌套层级和单文件大小,并清理压缩包内路径,避免路径穿越。
## 打印行为
- 客户端只展示实体/网络打印机,默认过滤虚拟输出设备
- PDF 打印优先使用 SumatraPDF未安装时回退系统默认 PDF 打印程序的 `printto` 动作
- 图片打印使用 `PrintDocument`
- 其他格式在预处理后无法匹配专用执行器时回退 RAW 打印
- Windows 队列 JobId 优先通过 WinSpool `EnumJobs` 捕获WMI 兜底
- 取消任务时优先通过 WinSpool `SetJob` 删除/取消真实系统队列任务
## 配置与安全
本地设置保存到:
```text
%APPDATA%\CloudPrint.Client\settings.json
```
安全策略:
- API Key 使用 Windows DPAPI 按当前用户加密保存
- 管理员 access token 使用 Windows DPAPI 按当前用户加密保存
- 设置文件复制到其他 Windows 用户后无法解密敏感字段
- 生产环境建议使用 HTTPS/TLS gRPC并关闭 HTTP gRPC
## 日志
日志目录:
```text
%APPDATA%\CloudPrint.Client\logs
```
记录内容包括:
- 连接和注册结果
- 打印任务状态
- 文件下载和预处理结果
- 打印失败异常
- 全局未处理异常
## 注意事项
- 本客户端不提供模拟后端或 DryRun运行时必须连接真实后端和真实打印机
- Windows 自带的 `Microsoft Print to PDF``XPS Document Writer``OneNote``Fax` 等不会作为门店打印机展示
- 若门店电脑只安装虚拟打印设备,客户端打印机列表会为空
- Office/TXT 文件转换依赖 LibreOffice请提前安装或配置 `SOFFICE_PATH`
- PDF 打印建议安装 SumatraPDF以获得更稳定的命令行打印能力
## 后续优化
- 根据后端最终 WebSocket 消息结构,将备用通道消息映射为打印任务或控制事件
- 按后端任务字段接入 PDF 水印和合并业务流程
- 增强 LibreOffice/SumatraPDF 路径检测和设置页提示
- 增加托盘常驻、开机自启动、单实例运行和安装包
- 继续提升同名文档并发场景下的 Windows JobId 匹配准确性
## 许可证
MIT License
---
© 2026 云印享 版权所有

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IAppLogger
{
string LogDirectory { get; }
void Write(AppLogEntry entry);
}

View File

@@ -0,0 +1,16 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IFileService
{
long MaxFileSizeBytes { get; }
IReadOnlyCollection<string> SupportedExtensions { get; }
bool IsSupported(string path);
FileValidationResult ValidateFile(string path, long? maxFileSizeBytes = null);
CloudPrintDocument CreateLocalDocument(string path);
CloudPrintDocument CopyToLibrary(string sourcePath, string libraryRoot, string categoryName, int categoryId = 0);
string PrepareForPrint(PrintJob job);
Task<FilePreparationResult> PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,27 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IPrintBackendClient : IAsyncDisposable
{
event EventHandler<PrintJobReceivedEventArgs>? PrintJobReceived;
event EventHandler<AppLogEntry>? LogReceived;
bool IsConnected { get; }
int PrinterId { get; }
string Token { get; }
AdminSession AdminSession { get; }
Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default);
Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default);
void SetAdminToken(string username, string accessToken);
void LogoutAdmin();
Task RegisterPrinterAsync(PrinterInfo printer, string apiKey, CancellationToken cancellationToken = default);
Task SubscribePrintJobsAsync(PrinterInfo printer, CancellationToken cancellationToken = default);
Task<string> DownloadPrintFileAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default);
Task UploadLibraryDocumentAsync(CloudPrintDocument document, CancellationToken cancellationToken = default);
Task CancelPrintJobAsync(PrintJob job, CancellationToken cancellationToken = default);
Task SendStatusUpdateAsync(PrintJob job, CancellationToken cancellationToken = default);
Task SendHeartbeatAsync(PrinterInfo printer, CancellationToken cancellationToken = default);
void Disconnect();
}

View File

@@ -0,0 +1,13 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IPrintService
{
IReadOnlyList<PrinterInfo> GetPrinters();
PrinterStatus GetPrinterStatus(string printerName);
Task<string> SubmitPrintJobAsync(PrintJob job, IProgress<int>? progress = null, CancellationToken cancellationToken = default);
bool CancelPrintJob(string jobId);
bool CancelPrintJob(PrintJob job);
PrintJobStatus GetJobStatus(string jobId);
}

View File

@@ -0,0 +1,7 @@
namespace CloudPrint.Client.Services.Abstractions;
public interface ISecretProtector
{
string Protect(string plainText);
string Unprotect(string protectedText);
}

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface ISettingsService
{
ClientSettings Load();
void Save(ClientSettings settings);
}

View File

@@ -0,0 +1,14 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IWebSocketService
{
event EventHandler<AppLogEntry>? LogReceived;
bool IsConnected { get; }
Uri? Endpoint { get; }
Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default);
Task DisconnectAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,34 @@
using System.Security.Cryptography;
using System.Text;
using CloudPrint.Client.Services.Abstractions;
namespace CloudPrint.Client.Services;
public sealed class DpapiSecretProtector : ISecretProtector
{
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("CloudPrint.Client.Settings.v1");
public string Protect(string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
return string.Empty;
}
var bytes = Encoding.UTF8.GetBytes(plainText);
var protectedBytes = ProtectedData.Protect(bytes, Entropy, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public string Unprotect(string protectedText)
{
if (string.IsNullOrEmpty(protectedText))
{
return string.Empty;
}
var protectedBytes = Convert.FromBase64String(protectedText);
var bytes = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(bytes);
}
}

38
Services/FileAppLogger.cs Normal file
View File

@@ -0,0 +1,38 @@
using System.IO;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
namespace CloudPrint.Client.Services;
public sealed class FileAppLogger : IAppLogger
{
private readonly object _syncRoot = new();
public FileAppLogger()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
LogDirectory = Path.Combine(appData, "CloudPrint.Client", "logs");
}
public string LogDirectory { get; }
public void Write(AppLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
Directory.CreateDirectory(LogDirectory);
var logPath = Path.Combine(LogDirectory, $"cloudprint-{DateTime.Now:yyyyMMdd}.log");
lock (_syncRoot)
{
File.AppendAllText(logPath, Format(entry) + Environment.NewLine);
}
}
private static string Format(AppLogEntry entry)
{
var exceptionText = string.IsNullOrWhiteSpace(entry.ExceptionText)
? string.Empty
: $" | Exception={entry.ExceptionText}";
return $"{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{entry.LevelText}] {entry.Message}{exceptionText}";
}
}

378
Services/FileService.cs Normal file
View File

@@ -0,0 +1,378 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using CloudPrint.Client.Helpers;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
using SharpCompress.Common;
using SharpCompress.Readers;
namespace CloudPrint.Client.Services;
/// <summary>
/// 文件处理服务:校验白名单、管理本地文库副本、解压压缩包、转换 Office/TXT 并处理 PDF 页码范围。
/// </summary>
public sealed class FileService : IFileService
{
private const int MaxArchiveEntries = 512;
private const int MaxArchiveNestingDepth = 3;
private const long MaxExtractedFileSizeBytes = 200L * 1024 * 1024;
private static readonly HashSet<string> SupportedExtensionSet = new(StringComparer.OrdinalIgnoreCase)
{
".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", ".ppt", ".pptx",
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".zip", ".rar"
};
private readonly PdfProcessor _pdfProcessor;
public FileService()
: this(new PdfProcessor())
{
}
public FileService(PdfProcessor pdfProcessor)
{
_pdfProcessor = pdfProcessor;
}
public long MaxFileSizeBytes { get; } = 50L * 1024 * 1024;
public IReadOnlyCollection<string> SupportedExtensions => SupportedExtensionSet;
public bool IsSupported(string path)
{
return SupportedExtensionSet.Contains(Path.GetExtension(path));
}
public FileValidationResult ValidateFile(string path, long? maxFileSizeBytes = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return FileValidationResult.Failure("文件路径不能为空");
}
if (!File.Exists(path))
{
return FileValidationResult.Failure("文件不存在");
}
if (!IsSupported(path))
{
return FileValidationResult.Failure($"不支持的文件类型:{Path.GetExtension(path)}");
}
var fileInfo = new FileInfo(path);
var limit = maxFileSizeBytes ?? MaxFileSizeBytes;
if (fileInfo.Length > limit)
{
return FileValidationResult.Failure($"文件超过大小限制:{FormatSize(limit)}");
}
return FileValidationResult.Success();
}
public CloudPrintDocument CreateLocalDocument(string path)
{
var validation = ValidateFile(path);
if (!validation.IsValid)
{
throw new InvalidOperationException(validation.Message);
}
var fileInfo = new FileInfo(path);
return new CloudPrintDocument
{
Id = Environment.TickCount,
FileName = fileInfo.Name,
FilePath = fileInfo.FullName,
FileType = fileInfo.Extension.TrimStart('.').ToLowerInvariant(),
FileSize = fileInfo.Length,
Title = Path.GetFileNameWithoutExtension(fileInfo.Name),
Description = "本地文库待上传文件",
CategoryName = "未分类",
CreatedAt = DateTime.Now
};
}
public CloudPrintDocument CopyToLibrary(string sourcePath, string libraryRoot, string categoryName, int categoryId = 0)
{
var document = CreateLocalDocument(sourcePath);
var safeCategoryName = PathHelper.SanitizePathSegment(string.IsNullOrWhiteSpace(categoryName) ? "未分类" : categoryName);
var root = string.IsNullOrWhiteSpace(libraryRoot)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "CloudPrintLibrary")
: libraryRoot;
var targetDirectory = Path.Combine(root, safeCategoryName);
Directory.CreateDirectory(targetDirectory);
var targetPath = PathHelper.CreateUniqueFilePath(targetDirectory, document.FileName);
File.Copy(sourcePath, targetPath);
var copiedFileInfo = new FileInfo(targetPath);
return new CloudPrintDocument
{
Id = Environment.TickCount,
FileName = copiedFileInfo.Name,
FilePath = copiedFileInfo.FullName,
FileType = copiedFileInfo.Extension.TrimStart('.').ToLowerInvariant(),
FileSize = copiedFileInfo.Length,
Title = document.Title,
Description = document.Description,
CategoryName = categoryName,
CategoryId = categoryId,
SyncStatus = "本地",
CreatedAt = DateTime.Now
};
}
public string PrepareForPrint(PrintJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.FilePath))
{
return string.Empty;
}
var extension = Path.GetExtension(job.FilePath).ToLowerInvariant();
return extension switch
{
".pdf" => job.FilePath,
".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif" => job.FilePath,
".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt" => job.FilePath,
".zip" or ".rar" => job.FilePath,
_ => job.FilePath
};
}
public async Task<FilePreparationResult> PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.FilePath))
{
throw new InvalidOperationException("打印任务缺少文件路径");
}
if (!File.Exists(job.FilePath))
{
throw new FileNotFoundException("打印文件不存在", job.FilePath);
}
var root = string.IsNullOrWhiteSpace(cacheRoot)
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "prepared")
: cacheRoot;
Directory.CreateDirectory(root);
return await PreparePathAsync(job.FilePath, root, job.PageRange, 0, cancellationToken).ConfigureAwait(true);
}
private async Task<FilePreparationResult> PreparePathAsync(string sourcePath, string root, string pageRange, int archiveDepth, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(sourcePath).ToLowerInvariant();
if (IsPdf(extension))
{
return await PreparePdfAsync(sourcePath, root, pageRange, cancellationToken).ConfigureAwait(true);
}
if (IsImage(extension))
{
return new FilePreparationResult
{
SourcePath = sourcePath,
PreparedPath = sourcePath,
Converted = false,
Message = "文件无需转换"
};
}
if (extension is ".zip" or ".rar")
{
if (archiveDepth >= MaxArchiveNestingDepth)
{
throw new InvalidOperationException($"压缩包嵌套层级过深,最多允许 {MaxArchiveNestingDepth} 层");
}
var extractedPath = ExtractFirstPrintableFileFromArchive(sourcePath, root);
return await PreparePathAsync(extractedPath, root, pageRange, archiveDepth + 1, cancellationToken).ConfigureAwait(true);
}
if (IsOfficeOrText(extension))
{
var pdfPath = await ConvertToPdfWithLibreOfficeAsync(sourcePath, root, cancellationToken).ConfigureAwait(true);
var preparedPdf = await PreparePdfAsync(pdfPath, root, pageRange, cancellationToken).ConfigureAwait(true);
return new FilePreparationResult
{
SourcePath = sourcePath,
PreparedPath = preparedPdf.PreparedPath,
Converted = true,
Message = preparedPdf.PreparedPath == pdfPath ? "已转换为 PDF" : "已转换为 PDF 并裁剪页码范围"
};
}
throw new NotSupportedException($"不支持的打印文件类型:{extension}");
}
private async Task<FilePreparationResult> PreparePdfAsync(string pdfPath, string root, string pageRange, CancellationToken cancellationToken)
{
var outputDirectory = Path.Combine(root, "pdf-pages", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
var preparedPath = await _pdfProcessor.ExtractPageRangeAsync(pdfPath, pageRange, outputDirectory, cancellationToken).ConfigureAwait(true);
return new FilePreparationResult
{
SourcePath = pdfPath,
PreparedPath = preparedPath,
Converted = preparedPath != pdfPath,
Message = preparedPath == pdfPath ? "PDF 文件无需转换" : $"已按页码范围裁剪:{pageRange}"
};
}
private string ExtractFirstPrintableFileFromArchive(string archivePath, string root)
{
var extractDirectory = Path.Combine(root, PathHelper.SanitizePathSegment(Path.GetFileNameWithoutExtension(archivePath)), DateTime.Now.ToString("yyyyMMddHHmmssfff"));
Directory.CreateDirectory(extractDirectory);
using var stream = File.OpenRead(archivePath);
using var reader = ReaderFactory.OpenReader(stream);
var entryCount = 0;
while (reader.MoveToNextEntry())
{
if (reader.Entry.IsDirectory)
{
continue;
}
entryCount++;
if (entryCount > MaxArchiveEntries)
{
throw new InvalidOperationException($"压缩包条目过多,最多允许 {MaxArchiveEntries} 个文件");
}
if (!IsPrintableArchiveEntry(reader.Entry))
{
continue;
}
if (reader.Entry.Size > MaxExtractedFileSizeBytes)
{
throw new InvalidOperationException($"压缩包内文件超过解压大小限制:{FormatSize(MaxExtractedFileSizeBytes)}");
}
var targetPath = CreateSafeExtractPath(extractDirectory, reader.Entry.Key);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? extractDirectory);
using var input = reader.OpenEntryStream();
using var output = File.Create(targetPath);
input.CopyTo(output);
return targetPath;
}
throw new InvalidOperationException("压缩包中没有可打印文件");
}
private static string CreateSafeExtractPath(string extractDirectory, string? entryKey)
{
var normalizedKey = string.IsNullOrWhiteSpace(entryKey)
? Guid.NewGuid().ToString("N")
: entryKey.Replace('\\', '/');
var safeSegments = normalizedKey
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(segment => segment != "." && segment != "..")
.Select(PathHelper.SanitizePathSegment)
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
if (safeSegments.Length == 0)
{
safeSegments = new[] { Guid.NewGuid().ToString("N") };
}
var targetPath = Path.GetFullPath(Path.Combine(new[] { extractDirectory }.Concat(safeSegments).ToArray()));
var safeRoot = Path.GetFullPath(extractDirectory) + Path.DirectorySeparatorChar;
if (!targetPath.StartsWith(safeRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("压缩包包含不安全的文件路径");
}
return targetPath;
}
private static bool IsPrintableArchiveEntry(IEntry entry)
{
var extension = Path.GetExtension(entry.Key ?? string.Empty).ToLowerInvariant();
return SupportedExtensionSet.Contains(extension);
}
private static async Task<string> ConvertToPdfWithLibreOfficeAsync(string sourcePath, string outputRoot, CancellationToken cancellationToken)
{
var sofficePath = FindLibreOfficeExecutable();
if (string.IsNullOrWhiteSpace(sofficePath))
{
throw new FileNotFoundException("未找到 LibreOffice soffice.exe无法转换 Office/TXT 文件");
}
var outputDirectory = Path.Combine(outputRoot, "converted", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
Directory.CreateDirectory(outputDirectory);
var startInfo = new ProcessStartInfo
{
FileName = sofficePath,
Arguments = $"--headless --convert-to pdf --outdir \"{outputDirectory}\" \"{sourcePath}\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 LibreOffice 失败");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(true);
throw new InvalidOperationException($"LibreOffice 转换失败:{error}");
}
var expectedPdfPath = Path.Combine(outputDirectory, Path.ChangeExtension(Path.GetFileName(sourcePath), ".pdf"));
if (File.Exists(expectedPdfPath))
{
return expectedPdfPath;
}
var generatedPdf = Directory.EnumerateFiles(outputDirectory, "*.pdf", SearchOption.TopDirectoryOnly).FirstOrDefault();
return generatedPdf ?? throw new FileNotFoundException("LibreOffice 未生成 PDF 文件");
}
private static string? FindLibreOfficeExecutable()
{
var candidates = new[]
{
Environment.GetEnvironmentVariable("SOFFICE_PATH"),
@"C:\Program Files\LibreOffice\program\soffice.exe",
@"C:\Program Files (x86)\LibreOffice\program\soffice.exe"
};
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
}
private static bool IsPdf(string extension) => extension == ".pdf";
private static bool IsImage(string extension)
{
return extension is ".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif";
}
private static bool IsOfficeOrText(string extension)
{
return extension is ".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt";
}
private static string FormatSize(long bytes)
{
return bytes >= 1024 * 1024
? $"{bytes / 1024d / 1024d:F2} MB"
: $"{bytes / 1024d:F2} KB";
}
}

View File

@@ -0,0 +1,55 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Grpc;
public static class CloudPrintGrpcMapper
{
public static PrintJob ToPrintJob(Cloudprint.PrintJob backendJob)
{
var orderItem = backendJob.OrderItem;
var file = orderItem?.File;
return new PrintJob
{
Id = backendJob.Id,
JobNo = backendJob.JobNo,
PrinterId = backendJob.PrinterId,
OrderItemId = backendJob.OrderItemId,
FileName = file?.FileName ?? backendJob.JobNo,
FilePath = file?.FilePath ?? string.Empty,
FileId = orderItem?.FileId ?? 0,
Copies = Math.Max(1, orderItem?.Copies ?? 1),
Color = orderItem?.Color ?? false,
Duplex = orderItem?.Duplex ?? false,
PageRange = string.IsNullOrWhiteSpace(orderItem?.PageRange) ? "all" : orderItem.PageRange,
Status = FromBackendStatus(backendJob.Status),
Progress = backendJob.Progress,
ErrorMessage = backendJob.ErrorMsg
};
}
public static string ToBackendStatus(PrintJobStatus status)
{
return status switch
{
PrintJobStatus.Pending => "pending",
PrintJobStatus.Printing => "printing",
PrintJobStatus.Completed => "completed",
PrintJobStatus.Failed => "failed",
PrintJobStatus.Cancelled => "cancelled",
_ => "pending"
};
}
private static PrintJobStatus FromBackendStatus(string status)
{
return status?.ToLowerInvariant() switch
{
"printing" => PrintJobStatus.Printing,
"completed" => PrintJobStatus.Completed,
"failed" => PrintJobStatus.Failed,
"cancelled" => PrintJobStatus.Cancelled,
_ => PrintJobStatus.Pending
};
}
}

View File

@@ -0,0 +1,30 @@
using CloudPrint.Client.Models;
using Grpc.Core;
namespace CloudPrint.Client.Services.Grpc;
public static class GrpcMetadataFactory
{
public static Metadata Create(string apiKey, string printerToken, AdminSession adminSession)
{
var metadata = new Metadata();
AddIfNotEmpty(metadata, "x-api-key", apiKey);
AddIfNotEmpty(metadata, "x-printer-token", printerToken);
if (adminSession.IsAuthenticated)
{
metadata.Add("authorization", $"Bearer {adminSession.AccessToken}");
}
return metadata;
}
private static void AddIfNotEmpty(Metadata metadata, string key, string value)
{
if (!string.IsNullOrWhiteSpace(value))
{
metadata.Add(key, value);
}
}
}

362
Services/GrpcService.cs Normal file
View File

@@ -0,0 +1,362 @@
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Grpc;
using CloudPrint.Client.Services.Abstractions;
using Google.Protobuf;
using Grpc.Core;
using Grpc.Net.Client;
using System.IO;
namespace CloudPrint.Client.Services;
public sealed class PrintJobReceivedEventArgs : EventArgs
{
public PrintJobReceivedEventArgs(PrintJob job)
{
Job = job;
}
public PrintJob Job { get; }
}
/// <summary>
/// 真实 gRPC 通信服务,负责连接后端、注册打印机、订阅任务、下载文件和状态上报。
/// </summary>
public sealed class GrpcService : IPrintBackendClient
{
private CancellationTokenSource? _subscriptionCts;
private GrpcChannel? _channel;
private Cloudprint.PrintService.PrintServiceClient? _printClient;
private Cloudprint.LibraryService.LibraryServiceClient? _libraryClient;
private Cloudprint.FileService.FileServiceClient? _fileClient;
private Cloudprint.AdminService.AdminServiceClient? _adminClient;
private string _apiKey = string.Empty;
public event EventHandler<PrintJobReceivedEventArgs>? PrintJobReceived;
public event EventHandler<AppLogEntry>? LogReceived;
public bool IsConnected { get; private set; }
public int PrinterId { get; private set; }
public string Token { get; private set; } = string.Empty;
public AdminSession AdminSession { get; private set; } = new();
public bool AllowInsecureGrpc { get; set; } = true;
public async Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(address))
{
throw new ArgumentException("服务器地址不能为空", nameof(address));
}
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new ArgumentException("API Key 不能为空", nameof(apiKey));
}
_apiKey = apiKey;
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc)
{
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
}
_channel?.Dispose();
_channel = GrpcChannel.ForAddress(address);
_printClient = new Cloudprint.PrintService.PrintServiceClient(_channel);
_libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel);
_fileClient = new Cloudprint.FileService.FileServiceClient(_channel);
_adminClient = new Cloudprint.AdminService.AdminServiceClient(_channel);
IsConnected = true;
Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}");
}
public async Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(username))
{
throw new ArgumentException("管理员用户名不能为空", nameof(username));
}
if (string.IsNullOrWhiteSpace(password))
{
throw new ArgumentException("管理员密码不能为空", nameof(password));
}
var response = await EnsureAdminClient().LoginAsync(new Cloudprint.AdminLoginRequest
{
Username = username,
Password = password
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
AdminSession = new AdminSession
{
AdminId = response.Admin?.Id ?? 0,
Username = response.Admin?.Username ?? username,
Nickname = response.Admin?.Nickname ?? string.Empty,
Role = response.Admin?.Role ?? 0,
AccessToken = response.AccessToken
};
Log(AppLogLevel.Success, $"管理员登录成功:{AdminSession.DisplayName}");
return AdminSession;
}
public void SetAdminToken(string username, string accessToken)
{
AdminSession = new AdminSession
{
Username = username,
Nickname = username,
AccessToken = accessToken
};
}
public void LogoutAdmin()
{
AdminSession = new AdminSession();
Log(AppLogLevel.Warning, "管理员已退出登录");
}
public async Task RegisterPrinterAsync(PrinterInfo printer, string apiKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(printer);
var client = EnsurePrintClient();
var response = await client.RegisterPrinterAsync(new Cloudprint.RegisterPrinterRequest
{
Name = printer.Name,
DisplayName = printer.DisplayName,
ApiKey = apiKey
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
PrinterId = response.PrinterId;
Token = response.Token;
Log(AppLogLevel.Success, $"已注册打印机:{printer.DisplayName}PrinterId={PrinterId}");
}
public async Task SubscribePrintJobsAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
throw new InvalidOperationException("请先连接 gRPC 服务");
}
_subscriptionCts?.Cancel();
_subscriptionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var token = _subscriptionCts.Token;
Log(AppLogLevel.Info, $"开始订阅打印任务流:{printer.DisplayName}");
_ = Task.Run(async () => await ReadPrintJobStreamAsync(token).ConfigureAwait(false), CancellationToken.None);
}
public async Task<string> DownloadPrintFileAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
if (job.FileId <= 0)
{
throw new InvalidOperationException("打印任务缺少 file_id无法下载文件");
}
var root = string.IsNullOrWhiteSpace(cacheRoot)
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "print-cache")
: cacheRoot;
Directory.CreateDirectory(root);
var fileName = string.IsNullOrWhiteSpace(job.FileName) ? $"{job.JobNo}.bin" : job.FileName;
var targetPath = PathHelper.CreateUniqueFilePath(root, fileName);
using var call = EnsureFileClient().GetFile(new Cloudprint.GetFileRequest { FileId = job.FileId }, headers: CreateMetadata(), cancellationToken: cancellationToken);
await using var output = File.Create(targetPath);
await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
if (chunk.Data.Length > 0)
{
chunk.Data.WriteTo(output);
}
}
Log(AppLogLevel.Success, $"打印文件已下载:{targetPath}");
return targetPath;
}
public async Task UploadLibraryDocumentAsync(CloudPrintDocument document, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
if (!IsConnected)
{
throw new InvalidOperationException("请先连接 gRPC 服务");
}
await UploadLibraryDocumentByGrpcAsync(document, cancellationToken).ConfigureAwait(true);
}
public async Task CancelPrintJobAsync(PrintJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
if (!IsConnected)
{
job.MarkCancelled();
return;
}
await EnsurePrintClient().CancelPrintJobAsync(new Cloudprint.CancelPrintJobRequest
{
JobNo = job.JobNo
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
job.MarkCancelled();
Log(AppLogLevel.Warning, $"已取消打印任务:{job.JobNo}");
}
public async Task SendStatusUpdateAsync(PrintJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
if (IsConnected)
{
await EnsurePrintClient().ReportJobStatusAsync(new Cloudprint.ReportJobStatusRequest
{
JobNo = job.JobNo,
Status = CloudPrintGrpcMapper.ToBackendStatus(job.Status),
Progress = job.Progress,
ErrorMsg = job.ErrorMessage
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
}
Log(AppLogLevel.Info, $"上报任务状态:{job.JobNo} / {job.StatusText} / {job.Progress}%");
}
public async Task SendHeartbeatAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(printer);
if (IsConnected)
{
await EnsurePrintClient().PrinterHeartbeatAsync(new Cloudprint.PrinterHeartbeatRequest
{
PrinterId = PrinterId == 0 ? printer.Id : PrinterId,
Status = (int)printer.Status,
Token = Token
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
}
Log(AppLogLevel.Info, $"心跳:{printer.DisplayName} / {printer.StatusText}");
}
public void Disconnect()
{
_subscriptionCts?.Cancel();
IsConnected = false;
_channel?.Dispose();
_channel = null;
_printClient = null;
_libraryClient = null;
_fileClient = null;
_adminClient = null;
Log(AppLogLevel.Warning, "已断开 gRPC 连接");
}
public ValueTask DisposeAsync()
{
_subscriptionCts?.Cancel();
_subscriptionCts?.Dispose();
_channel?.Dispose();
return ValueTask.CompletedTask;
}
private async Task ReadPrintJobStreamAsync(CancellationToken cancellationToken)
{
try
{
using var call = EnsurePrintClient().SubscribeJobs(new Cloudprint.SubscribeJobsRequest
{
PrinterId = PrinterId,
Token = Token
}, headers: CreateMetadata(), cancellationToken: cancellationToken);
await foreach (var backendJob in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
var job = CloudPrintGrpcMapper.ToPrintJob(backendJob);
PrintJobReceived?.Invoke(this, new PrintJobReceivedEventArgs(job));
Log(AppLogLevel.Info, $"收到打印任务:{job.JobNo}");
}
}
catch (OperationCanceledException)
{
Log(AppLogLevel.Warning, "打印任务订阅已取消");
}
catch (Exception ex)
{
Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}");
IsConnected = false;
}
}
private async Task UploadLibraryDocumentByGrpcAsync(CloudPrintDocument document, CancellationToken cancellationToken)
{
if (!File.Exists(document.FilePath))
{
throw new FileNotFoundException("文库文件不存在", document.FilePath);
}
document.SyncStatus = "上传中";
using var call = EnsureLibraryClient().UploadFile(headers: CreateMetadata(), cancellationToken: cancellationToken);
const int bufferSize = 64 * 1024;
var buffer = new byte[bufferSize];
await using var stream = File.OpenRead(document.FilePath);
while (true)
{
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
if (read == 0)
{
break;
}
await call.RequestStream.WriteAsync(new Cloudprint.UploadLibraryFileRequest
{
CategoryId = document.CategoryId,
Title = document.Title,
Description = document.Description,
FileName = document.FileName,
FileType = document.FileType,
Data = ByteString.CopyFrom(buffer, 0, read)
}, cancellationToken).ConfigureAwait(true);
}
await call.RequestStream.CompleteAsync().ConfigureAwait(true);
_ = await call.ResponseAsync.ConfigureAwait(true);
document.IsUploaded = true;
document.SyncStatus = "已同步";
Log(AppLogLevel.Success, $"文库文件已上传:{document.Title}");
}
private Cloudprint.PrintService.PrintServiceClient EnsurePrintClient()
{
return _printClient ?? throw new InvalidOperationException("gRPC 打印服务未连接");
}
private Cloudprint.LibraryService.LibraryServiceClient EnsureLibraryClient()
{
return _libraryClient ?? throw new InvalidOperationException("gRPC 文库服务未连接");
}
private Cloudprint.FileService.FileServiceClient EnsureFileClient()
{
return _fileClient ?? throw new InvalidOperationException("gRPC 文件服务未连接");
}
private Cloudprint.AdminService.AdminServiceClient EnsureAdminClient()
{
return _adminClient ?? throw new InvalidOperationException("gRPC 管理员服务未连接");
}
private global::Grpc.Core.Metadata CreateMetadata()
{
return GrpcMetadataFactory.Create(_apiKey, Token, AdminSession);
}
private void Log(AppLogLevel level, string message)
{
LogReceived?.Invoke(this, new AppLogEntry
{
Level = level,
Message = message
});
}
}

View File

@@ -0,0 +1,138 @@
using System.IO;
using System.Text.Json;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
namespace CloudPrint.Client.Services;
public sealed class JsonSettingsService : ISettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private readonly string _settingsPath;
private readonly ISecretProtector _secretProtector;
public JsonSettingsService()
: this(new DpapiSecretProtector())
{
}
public JsonSettingsService(ISecretProtector secretProtector)
: this(secretProtector, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CloudPrint.Client", "settings.json"))
{
}
public JsonSettingsService(ISecretProtector secretProtector, string settingsPath)
{
if (string.IsNullOrWhiteSpace(settingsPath))
{
throw new ArgumentException("设置文件路径不能为空", nameof(settingsPath));
}
_secretProtector = secretProtector;
_settingsPath = settingsPath;
}
public ClientSettings Load()
{
if (!File.Exists(_settingsPath))
{
return new ClientSettings();
}
try
{
var json = File.ReadAllText(_settingsPath);
var dto = JsonSerializer.Deserialize<SettingsFileDto>(json, SerializerOptions);
return dto?.ToSettings(_secretProtector) ?? new ClientSettings();
}
catch
{
return new ClientSettings();
}
}
public void Save(ClientSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(SettingsFileDto.FromSettings(settings, _secretProtector), SerializerOptions);
File.WriteAllText(_settingsPath, json);
}
private sealed class SettingsFileDto
{
public string ServerAddress { get; set; } = "http://localhost:8080";
public string ApiKey { get; set; } = string.Empty; // Legacy plaintext field.
public string ProtectedApiKey { get; set; } = string.Empty;
public string AdminUsername { get; set; } = string.Empty;
public string AdminAccessToken { get; set; } = string.Empty; // Legacy plaintext field.
public string ProtectedAdminAccessToken { get; set; } = string.Empty;
public string StoreName { get; set; } = "默认打印店";
public bool AllowInsecureGrpc { get; set; } = true;
public string LibraryRoot { get; set; } = string.Empty;
public string PrintCacheRoot { get; set; } = string.Empty;
public int MaxUploadSizeMb { get; set; } = 50;
public int HeartbeatIntervalSeconds { get; set; } = 15;
public ClientSettings ToSettings(ISecretProtector protector)
{
return new ClientSettings
{
ServerAddress = ServerAddress,
ApiKey = ReadSecret(ProtectedApiKey, ApiKey, protector),
AdminUsername = AdminUsername,
AdminAccessToken = ReadSecret(ProtectedAdminAccessToken, AdminAccessToken, protector),
StoreName = StoreName,
AllowInsecureGrpc = AllowInsecureGrpc,
LibraryRoot = LibraryRoot,
PrintCacheRoot = PrintCacheRoot,
MaxUploadSizeMb = MaxUploadSizeMb,
HeartbeatIntervalSeconds = HeartbeatIntervalSeconds
};
}
public static SettingsFileDto FromSettings(ClientSettings settings, ISecretProtector protector)
{
return new SettingsFileDto
{
ServerAddress = settings.ServerAddress,
ProtectedApiKey = protector.Protect(settings.ApiKey),
AdminUsername = settings.AdminUsername,
ProtectedAdminAccessToken = protector.Protect(settings.AdminAccessToken),
StoreName = settings.StoreName,
AllowInsecureGrpc = settings.AllowInsecureGrpc,
LibraryRoot = settings.LibraryRoot,
PrintCacheRoot = settings.PrintCacheRoot,
MaxUploadSizeMb = settings.MaxUploadSizeMb,
HeartbeatIntervalSeconds = settings.HeartbeatIntervalSeconds
};
}
private static string ReadSecret(string protectedValue, string legacyPlainText, ISecretProtector protector)
{
if (string.IsNullOrWhiteSpace(protectedValue))
{
return legacyPlainText;
}
try
{
return protector.Unprotect(protectedValue);
}
catch
{
return legacyPlainText;
}
}
}
}

35
Services/PathHelper.cs Normal file
View File

@@ -0,0 +1,35 @@
using System.IO;
namespace CloudPrint.Client.Services;
public static class PathHelper
{
public static string CreateUniqueFilePath(string directory, string fileName)
{
Directory.CreateDirectory(directory);
var safeFileName = SanitizeFileName(string.IsNullOrWhiteSpace(fileName) ? "untitled" : fileName);
var name = Path.GetFileNameWithoutExtension(safeFileName);
var extension = Path.GetExtension(safeFileName);
var candidate = Path.Combine(directory, safeFileName);
var index = 1;
while (File.Exists(candidate))
{
candidate = Path.Combine(directory, $"{name}_{index++}{extension}");
}
return candidate;
}
public static string SanitizePathSegment(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
private static string SanitizeFileName(string fileName)
{
return SanitizePathSegment(fileName);
}
}

View File

@@ -0,0 +1,67 @@
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
using System.IO;
namespace CloudPrint.Client.Services;
public sealed class PrintJobCoordinator
{
private readonly IFileService _fileService;
private readonly IPrintService _printService;
private readonly IPrintBackendClient _backendClient;
public PrintJobCoordinator(IFileService fileService, IPrintService printService, IPrintBackendClient backendClient)
{
_fileService = fileService;
_printService = printService;
_backendClient = backendClient;
}
public async Task ProcessAsync(PrintJob job, ClientSettings settings, IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
ArgumentNullException.ThrowIfNull(settings);
try
{
job.MarkPrinting();
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
if ((string.IsNullOrWhiteSpace(job.FilePath) || !File.Exists(job.FilePath)) && job.FileId > 0)
{
job.FilePath = await _backendClient.DownloadPrintFileAsync(job, settings.PrintCacheRoot, cancellationToken).ConfigureAwait(true);
}
var preparationResult = await _fileService.PrepareForPrintAsync(job, settings.PrintCacheRoot, cancellationToken).ConfigureAwait(true);
job.FilePath = preparationResult.PreparedPath;
var forwardingProgress = new Progress<int>(async value =>
{
job.Progress = value;
if (value >= 100)
{
job.MarkCompleted();
}
progress?.Report(value);
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
});
await _printService.SubmitPrintJobAsync(job, forwardingProgress, cancellationToken).ConfigureAwait(true);
job.MarkCompleted();
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
job.MarkCancelled();
await _backendClient.SendStatusUpdateAsync(job, CancellationToken.None).ConfigureAwait(true);
throw;
}
catch (Exception ex)
{
job.MarkFailed(ex.Message);
await _backendClient.SendStatusUpdateAsync(job, CancellationToken.None).ConfigureAwait(true);
throw;
}
}
}

170
Services/PrintService.cs Normal file
View File

@@ -0,0 +1,170 @@
using System.Drawing.Printing;
using System.Management;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
using CloudPrint.Client.Services.Printing;
namespace CloudPrint.Client.Services;
/// <summary>
/// 真实打印服务:枚举 Windows 打印机,按文件类型提交到图片/PDF/RAW 打印执行器。
/// </summary>
public sealed class PrintService : IPrintService
{
private readonly IReadOnlyList<IPrintJobExecutor> _executors;
private readonly IPrinterStatusReader _statusReader;
private readonly IWindowsPrintQueueService _queueService;
public PrintService()
: this(CreateDefaultExecutors(), new WindowsPrinterStatusReader(), new WindowsPrintQueueService())
{
}
private static IReadOnlyList<IPrintJobExecutor> CreateDefaultExecutors()
{
var queueService = new WindowsPrintQueueService();
return new IPrintJobExecutor[]
{
new ImagePrintJobExecutor(queueService),
new PdfPrintJobExecutor(queueService),
new RawPrintJobExecutor()
};
}
public PrintService(IEnumerable<IPrintJobExecutor> executors, IPrinterStatusReader statusReader, IWindowsPrintQueueService queueService)
{
_executors = executors.ToList();
_statusReader = statusReader;
_queueService = queueService;
if (_executors.Count == 0)
{
throw new ArgumentException("至少需要一个打印执行器", nameof(executors));
}
}
public IReadOnlyList<PrinterInfo> GetPrinters()
{
var printers = new List<PrinterInfo>();
var index = 1;
foreach (string printerName in PrinterSettings.InstalledPrinters)
{
if (IsVirtualPrinter(printerName))
{
continue;
}
var snapshot = _statusReader.Read(printerName);
printers.Add(new PrinterInfo
{
Id = index++,
Name = printerName,
DisplayName = printerName,
IsActive = true,
Status = snapshot.Status,
QueueCount = snapshot.QueueCount,
StatusMessage = snapshot.Message,
LastHeartbeat = DateTime.Now
});
}
return printers;
}
public PrinterStatus GetPrinterStatus(string printerName)
{
if (string.IsNullOrWhiteSpace(printerName))
{
return PrinterStatus.Unknown;
}
return _statusReader.Read(printerName).Status;
}
public async Task<string> SubmitPrintJobAsync(PrintJob job, IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
job.StartedAt = DateTime.Now;
job.MarkPrinting();
progress?.Report(5);
var executor = _executors.FirstOrDefault(item => item.CanHandle(job))
?? throw new NotSupportedException($"没有可用的打印执行器处理文件:{job.FilePath}");
await executor.ExecuteAsync(job, progress, cancellationToken).ConfigureAwait(true);
job.MarkCompleted();
return job.JobNo;
}
public bool CancelPrintJob(string jobId)
{
return !string.IsNullOrWhiteSpace(jobId);
}
public bool CancelPrintJob(PrintJob job)
{
ArgumentNullException.ThrowIfNull(job);
return job.WindowsJobId is > 0 && _queueService.CancelJob(job.PrinterName, job.WindowsJobId.Value);
}
public PrintJobStatus GetJobStatus(string jobId)
{
return string.IsNullOrWhiteSpace(jobId) ? PrintJobStatus.Failed : PrintJobStatus.Pending;
}
private static bool IsVirtualPrinter(string printerName)
{
if (string.IsNullOrWhiteSpace(printerName))
{
return true;
}
try
{
using var searcher = new ManagementObjectSearcher("SELECT Name, DriverName, PortName FROM Win32_Printer");
foreach (ManagementObject printer in searcher.Get())
{
var name = Convert.ToString(printer["Name"]);
if (!string.Equals(name, printerName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var driverName = Convert.ToString(printer["DriverName"]) ?? string.Empty;
var portName = Convert.ToString(printer["PortName"]) ?? string.Empty;
return IsKnownVirtualPrinter(printerName, driverName, portName);
}
}
catch
{
// If WMI is unavailable, fall back to conservative name-based filtering.
}
return IsKnownVirtualPrinter(printerName, string.Empty, string.Empty);
}
private static bool IsKnownVirtualPrinter(string printerName, string driverName, string portName)
{
var value = $"{printerName}|{driverName}|{portName}";
return ContainsAny(value,
"Microsoft Print to PDF",
"Microsoft XPS Document Writer",
"OneNote",
"Fax",
"Foxit PDF",
"PDF Printer",
"Print to Evernote",
"Evernote",
"XPS",
"PORTPROMPT:",
"FILE:",
"NUL:");
}
private static bool ContainsAny(string value, params string[] candidates)
{
return candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Printing;
public interface IPrintJobExecutor
{
bool CanHandle(PrintJob job);
Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace CloudPrint.Client.Services.Printing;
public interface IPrinterStatusReader
{
PrinterStatusSnapshot Read(string printerName);
}

View File

@@ -0,0 +1,7 @@
namespace CloudPrint.Client.Services.Printing;
public interface IWindowsPrintQueueService
{
WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName);
bool CancelJob(string printerName, int jobId);
}

View File

@@ -0,0 +1,88 @@
using CloudPrint.Client.Models;
using System.Drawing;
using System.Drawing.Printing;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class ImagePrintJobExecutor : IPrintJobExecutor
{
private readonly IWindowsPrintQueueService _queueService;
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".bmp", ".gif"
};
public ImagePrintJobExecutor(IWindowsPrintQueueService queueService)
{
_queueService = queueService;
}
public bool CanHandle(PrintJob job)
{
return SupportedExtensions.Contains(Path.GetExtension(job.FilePath));
}
public Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var image = Image.FromFile(job.FilePath);
using var document = new PrintDocument
{
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
PrinterSettings =
{
PrinterName = job.PrinterName,
Copies = (short)Math.Clamp(job.Copies, 1, short.MaxValue)
}
};
if (document.PrinterSettings.CanDuplex)
{
document.PrinterSettings.Duplex = job.Duplex ? Duplex.Vertical : Duplex.Simplex;
}
document.DefaultPageSettings.Color = job.Color;
document.PrintPage += (_, args) => DrawImageToPage(image, args);
progress?.Report(20);
document.Print();
CaptureWindowsJob(job, document.DocumentName);
progress?.Report(100);
}, cancellationToken);
}
private void CaptureWindowsJob(PrintJob job, string documentName)
{
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
if (queueJob is null)
{
return;
}
job.WindowsJobId = queueJob.JobId;
job.WindowsJobStatus = queueJob.Status;
}
private static void DrawImageToPage(Image image, PrintPageEventArgs args)
{
if (args.Graphics is null)
{
throw new InvalidOperationException("打印图形上下文不可用");
}
var bounds = args.MarginBounds;
var ratio = Math.Min(bounds.Width / (double)image.Width, bounds.Height / (double)image.Height);
var width = (int)(image.Width * ratio);
var height = (int)(image.Height * ratio);
var x = bounds.Left + (bounds.Width - width) / 2;
var y = bounds.Top + (bounds.Height - height) / 2;
args.Graphics.DrawImage(image, x, y, width, height);
args.HasMorePages = false;
}
}

View File

@@ -0,0 +1,100 @@
using CloudPrint.Client.Models;
using System.Diagnostics;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class PdfPrintJobExecutor : IPrintJobExecutor
{
private readonly IWindowsPrintQueueService _queueService;
public PdfPrintJobExecutor(IWindowsPrintQueueService queueService)
{
_queueService = queueService;
}
public bool CanHandle(PrintJob job)
{
return string.Equals(Path.GetExtension(job.FilePath), ".pdf", StringComparison.OrdinalIgnoreCase);
}
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
progress?.Report(10);
var sumatraPath = FindSumatraPdfExecutable();
if (!string.IsNullOrWhiteSpace(sumatraPath))
{
await PrintWithSumatraAsync(sumatraPath, job, cancellationToken).ConfigureAwait(true);
}
else
{
await PrintWithShellAsync(job, cancellationToken).ConfigureAwait(true);
}
CaptureWindowsJob(job);
progress?.Report(100);
}
private void CaptureWindowsJob(PrintJob job)
{
var documentName = Path.GetFileName(job.FilePath);
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
if (queueJob is null)
{
return;
}
job.WindowsJobId = queueJob.JobId;
job.WindowsJobStatus = queueJob.Status;
}
private static async Task PrintWithSumatraAsync(string sumatraPath, PrintJob job, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = sumatraPath,
Arguments = $"-print-to \"{job.PrinterName}\" -silent -exit-when-done \"{job.FilePath}\"",
CreateNoWindow = true,
UseShellExecute = false
};
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 SumatraPDF 失败");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"SumatraPDF 打印失败,退出码:{process.ExitCode}");
}
}
private static async Task PrintWithShellAsync(PrintJob job, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = job.FilePath,
Verb = "printto",
Arguments = $"\"{job.PrinterName}\"",
UseShellExecute = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("无法调用系统默认 PDF 打印程序,请安装 SumatraPDF 或配置 PDF 默认打印程序");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
}
private static string? FindSumatraPdfExecutable()
{
var candidates = new[]
{
Environment.GetEnvironmentVariable("SUMATRA_PDF_PATH"),
@"C:\Program Files\SumatraPDF\SumatraPDF.exe",
@"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe"
};
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
}
}

View File

@@ -0,0 +1,22 @@
using CloudPrint.Client.Models;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
internal static class PrintFileGuards
{
public static void EnsurePrintableFile(PrintJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.PrinterName))
{
throw new InvalidOperationException("打印机名称不能为空");
}
if (string.IsNullOrWhiteSpace(job.FilePath) || !File.Exists(job.FilePath))
{
throw new FileNotFoundException("待打印文件不存在", job.FilePath);
}
}
}

View File

@@ -0,0 +1,10 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Printing;
public sealed class PrinterStatusSnapshot
{
public PrinterStatus Status { get; init; } = PrinterStatus.Unknown;
public int QueueCount { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,94 @@
using CloudPrint.Client.Helpers;
using CloudPrint.Client.Models;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class RawPrintJobExecutor : IPrintJobExecutor
{
public bool CanHandle(PrintJob job) => true;
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
if (!WindowsPrintApi.OpenPrinter(job.PrinterName, out var printerHandle, IntPtr.Zero))
{
throw new InvalidOperationException($"打开打印机失败:{job.PrinterName}");
}
try
{
var docInfo = new WindowsPrintApi.DocInfo
{
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
DataType = "RAW"
};
var windowsJobId = WindowsPrintApi.StartDocPrinter(printerHandle, 1, docInfo);
if (windowsJobId == 0)
{
throw new InvalidOperationException("启动打印文档失败");
}
job.WindowsJobId = windowsJobId;
job.WindowsJobStatus = "已提交到 Windows 打印队列";
try
{
if (!WindowsPrintApi.StartPagePrinter(printerHandle))
{
throw new InvalidOperationException("启动打印页面失败");
}
try
{
await WriteFileToPrinterAsync(printerHandle, job.FilePath, progress, cancellationToken).ConfigureAwait(true);
}
finally
{
WindowsPrintApi.EndPagePrinter(printerHandle);
}
}
finally
{
WindowsPrintApi.EndDocPrinter(printerHandle);
}
}
finally
{
WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static async Task WriteFileToPrinterAsync(IntPtr printerHandle, string filePath, IProgress<int>? progress, CancellationToken cancellationToken)
{
const int bufferSize = 64 * 1024;
var buffer = new byte[bufferSize];
var fileInfo = new FileInfo(filePath);
long totalWritten = 0;
await using var stream = File.OpenRead(filePath);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
if (read == 0)
{
break;
}
var chunk = read == buffer.Length ? buffer : buffer[..read];
if (!WindowsPrintApi.WritePrinter(printerHandle, chunk, read, out var written) || written != read)
{
throw new InvalidOperationException("写入打印机失败");
}
totalWritten += written;
var percent = fileInfo.Length == 0 ? 100 : (int)Math.Min(99, totalWritten * 100 / fileInfo.Length);
progress?.Report(percent);
}
progress?.Report(100);
}
}

View File

@@ -0,0 +1,12 @@
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrintQueueJob
{
public int JobId { get; init; }
public string PrinterName { get; init; } = string.Empty;
public string DocumentName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public int Position { get; init; }
public int TotalPages { get; init; }
public int PagesPrinted { get; init; }
}

View File

@@ -0,0 +1,164 @@
using System.Management;
using System.Runtime.InteropServices;
using CloudPrint.Client.Helpers;
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrintQueueService : IWindowsPrintQueueService
{
public WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName)
{
if (string.IsNullOrWhiteSpace(printerName) || string.IsNullOrWhiteSpace(documentName))
{
return null;
}
try
{
return EnumerateJobs(printerName)
.Where(job => job.DocumentName.Contains(documentName, StringComparison.OrdinalIgnoreCase)
|| documentName.Contains(job.DocumentName, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(job => job.JobId)
.FirstOrDefault();
}
catch
{
return null;
}
}
public bool CancelJob(string printerName, int jobId)
{
if (string.IsNullOrWhiteSpace(printerName) || jobId <= 0)
{
return false;
}
if (CancelJobWithWinSpool(printerName, jobId))
{
return true;
}
foreach (var job in QueryJobObjects(printerName))
{
var currentJobId = Convert.ToInt32(job["JobId"] ?? 0);
if (currentJobId != jobId)
{
continue;
}
job.InvokeMethod("Delete", null);
return true;
}
return false;
}
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobs(string printerName)
{
var spoolerJobs = EnumerateJobsWithWinSpool(printerName);
if (spoolerJobs.Count > 0)
{
return spoolerJobs;
}
return EnumerateJobsWithWmi(printerName);
}
private static IReadOnlyList<WindowsPrintQueueJob> EnumerateJobsWithWinSpool(string printerName)
{
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
{
return Array.Empty<WindowsPrintQueueJob>();
}
try
{
const int maxJobs = 256;
_ = WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, IntPtr.Zero, 0, out var bytesNeeded, out _);
if (bytesNeeded <= 0)
{
return Array.Empty<WindowsPrintQueueJob>();
}
var buffer = Marshal.AllocHGlobal(bytesNeeded);
try
{
if (!WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, buffer, bytesNeeded, out _, out var jobsReturned))
{
return Array.Empty<WindowsPrintQueueJob>();
}
var jobs = new List<WindowsPrintQueueJob>(jobsReturned);
var itemSize = Marshal.SizeOf<WindowsPrintApi.JobInfo1>();
for (var index = 0; index < jobsReturned; index++)
{
var itemPointer = IntPtr.Add(buffer, index * itemSize);
var jobInfo = Marshal.PtrToStructure<WindowsPrintApi.JobInfo1>(itemPointer);
jobs.Add(new WindowsPrintQueueJob
{
JobId = jobInfo.JobId,
PrinterName = printerName,
DocumentName = jobInfo.DocumentName ?? string.Empty,
Status = string.IsNullOrWhiteSpace(jobInfo.StatusText) ? $"0x{jobInfo.Status:X}" : jobInfo.StatusText,
Position = jobInfo.Position,
TotalPages = jobInfo.TotalPages,
PagesPrinted = jobInfo.PagesPrinted
});
}
return jobs;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
finally
{
_ = WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static bool CancelJobWithWinSpool(string printerName, int jobId)
{
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
{
return false;
}
try
{
return WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlDelete)
|| WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlCancel);
}
finally
{
_ = WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobsWithWmi(string printerName)
{
foreach (var job in QueryJobObjects(printerName))
{
yield return new WindowsPrintQueueJob
{
JobId = Convert.ToInt32(job["JobId"] ?? 0),
PrinterName = printerName,
DocumentName = Convert.ToString(job["Document"]) ?? string.Empty,
Status = Convert.ToString(job["JobStatus"]) ?? string.Empty
};
}
}
private static IEnumerable<ManagementObject> QueryJobObjects(string printerName)
{
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
using var searcher = new ManagementObjectSearcher($"SELECT JobId, Document, JobStatus, Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
foreach (ManagementObject job in searcher.Get())
{
yield return job;
}
}
}

View File

@@ -0,0 +1,119 @@
using CloudPrint.Client.Models;
using System.Management;
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrinterStatusReader : IPrinterStatusReader
{
public PrinterStatusSnapshot Read(string printerName)
{
if (string.IsNullOrWhiteSpace(printerName))
{
return new PrinterStatusSnapshot { Status = PrinterStatus.Unknown, Message = "打印机名称为空" };
}
try
{
return ReadCore(printerName);
}
catch (Exception ex)
{
return new PrinterStatusSnapshot
{
Status = PrinterStatus.Unknown,
Message = $"状态读取失败:{ex.Message}"
};
}
}
private static PrinterStatusSnapshot ReadCore(string printerName)
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, WorkOffline, PrinterStatus, DetectedErrorState FROM Win32_Printer");
foreach (ManagementObject printer in searcher.Get())
{
var name = Convert.ToString(printer["Name"]);
if (!string.Equals(name, printerName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var workOffline = Convert.ToBoolean(printer["WorkOffline"] ?? false);
var printerStatus = Convert.ToUInt32(printer["PrinterStatus"] ?? 2u);
var errorState = Convert.ToUInt32(printer["DetectedErrorState"] ?? 0u);
var queueCount = CountQueueJobs(printerName);
var status = MapStatus(workOffline, printerStatus, errorState, queueCount);
return new PrinterStatusSnapshot
{
Status = status,
QueueCount = queueCount,
Message = BuildMessage(workOffline, printerStatus, errorState)
};
}
return new PrinterStatusSnapshot
{
Status = PrinterStatus.Offline,
Message = "未找到打印机"
};
}
private static int CountQueueJobs(string printerName)
{
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
using var searcher = new ManagementObjectSearcher($"SELECT Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
return searcher.Get().Count;
}
private static PrinterStatus MapStatus(bool workOffline, uint printerStatus, uint errorState, int queueCount)
{
if (workOffline || printerStatus == 7)
{
return PrinterStatus.Offline;
}
if (errorState is >= 3 and <= 11 || printerStatus == 6)
{
return PrinterStatus.Error;
}
if (printerStatus == 4 || queueCount > 0)
{
return PrinterStatus.Printing;
}
return printerStatus == 3 ? PrinterStatus.Idle : PrinterStatus.Unknown;
}
private static string BuildMessage(bool workOffline, uint printerStatus, uint errorState)
{
if (workOffline)
{
return "脱机";
}
return errorState switch
{
3 => "缺纸",
4 => "卡纸",
5 => "碳粉不足",
6 => "门打开",
7 => "需要人工干预",
8 => "离线",
9 => "维修中",
10 => "输出纸盘已满",
11 => "缺纸",
_ => printerStatus switch
{
3 => "就绪",
4 => "打印中",
5 => "预热中",
6 => "已停止",
7 => "离线",
_ => string.Empty
}
};
}
}

View File

@@ -0,0 +1,182 @@
using CloudPrint.Client.Models;
using CloudPrint.Client.Services.Abstractions;
using System.IO;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
namespace CloudPrint.Client.Services;
/// <summary>
/// WebSocket 备用通信通道。
/// 当前后端主链路仍使用 gRPC 订阅;当后端提供 /ws/print 时,本服务可保持备用长连接并记录推送消息。
/// </summary>
public sealed class WebSocketService : IWebSocketService
{
private const int ReceiveBufferSize = 8 * 1024;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private ClientWebSocket? _socket;
private CancellationTokenSource? _receiveCts;
public event EventHandler<AppLogEntry>? LogReceived;
public bool IsConnected { get; private set; }
public Uri? Endpoint { get; private set; }
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
if (endpoint.Scheme is not "ws" and not "wss")
{
throw new ArgumentException("WebSocket 地址必须以 ws:// 或 wss:// 开头", nameof(endpoint));
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await DisconnectCoreAsync(CancellationToken.None).ConfigureAwait(false);
Endpoint = endpoint;
var socket = new ClientWebSocket();
_receiveCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is WebSocketException or HttpRequestException or IOException)
{
socket.Dispose();
_receiveCts.Dispose();
_receiveCts = null;
IsConnected = false;
Log(AppLogLevel.Warning, $"WebSocket 备用通道不可用:{ex.Message}");
return;
}
_socket = socket;
IsConnected = true;
Log(AppLogLevel.Success, $"WebSocket 备用通道已连接:{endpoint}");
_ = Task.Run(() => ReceiveLoopAsync(socket, _receiveCts.Token), CancellationToken.None);
}
finally
{
_connectionLock.Release();
}
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await DisconnectCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_connectionLock.Release();
}
}
private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[ReceiveBufferSize];
try
{
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
{
using var message = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await CloseSocketAsync(socket, cancellationToken).ConfigureAwait(false);
MarkDisconnected(socket, "WebSocket 备用通道被服务端关闭");
return;
}
message.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Text)
{
var payload = Encoding.UTF8.GetString(message.ToArray());
Log(AppLogLevel.Info, $"WebSocket 推送:{TrimPayload(payload)}");
}
else if (result.MessageType == WebSocketMessageType.Binary)
{
Log(AppLogLevel.Info, $"WebSocket 收到二进制消息:{message.Length} 字节");
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown path.
}
catch (Exception ex) when (ex is WebSocketException or IOException)
{
MarkDisconnected(socket, $"WebSocket 备用通道中断:{ex.Message}");
}
}
private async Task DisconnectCoreAsync(CancellationToken cancellationToken)
{
_receiveCts?.Cancel();
var socket = _socket;
_socket = null;
IsConnected = false;
if (socket is not null)
{
await CloseSocketAsync(socket, cancellationToken).ConfigureAwait(false);
socket.Dispose();
Log(AppLogLevel.Warning, "WebSocket 备用通道已断开");
}
_receiveCts?.Dispose();
_receiveCts = null;
}
private static async Task CloseSocketAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "client closing", cancellationToken).ConfigureAwait(false);
}
}
private void MarkDisconnected(ClientWebSocket socket, string message)
{
if (ReferenceEquals(_socket, socket))
{
_socket = null;
IsConnected = false;
_receiveCts?.Dispose();
_receiveCts = null;
}
socket.Dispose();
Log(AppLogLevel.Warning, message);
}
private void Log(AppLogLevel level, string message)
{
LogReceived?.Invoke(this, new AppLogEntry
{
Level = level,
Message = message
});
}
private static string TrimPayload(string payload)
{
const int maxLength = 500;
return payload.Length <= maxLength ? payload : payload[..maxLength] + "...";
}
}

View File

@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using Microsoft.Win32;
namespace CloudPrint.Client.ViewModels;
public sealed class LibraryViewModel : ObservableObject
{
private readonly IFileService _fileService;
private readonly IPrintBackendClient _backendClient;
private readonly ClientSettings _settings;
private LibraryCategory? _selectedCategory;
private CloudPrintDocument? _selectedDocument;
private string _statusMessage = "文库就绪";
public LibraryViewModel()
: this(new FileService(), new GrpcService(), new ClientSettings())
{
}
public LibraryViewModel(IFileService fileService, IPrintBackendClient backendClient, ClientSettings settings)
{
_fileService = fileService;
_backendClient = backendClient;
_settings = settings;
Categories.Add(new LibraryCategory { Id = 1, Name = "教材资料", Icon = "📘", SortOrder = 1 });
Categories.Add(new LibraryCategory { Id = 2, Name = "考试真题", Icon = "📝", SortOrder = 2 });
Categories.Add(new LibraryCategory { Id = 3, Name = "证件照片", Icon = "🖼️", SortOrder = 3 });
Categories.Add(new LibraryCategory { Id = 4, Name = "其他文件", Icon = "📁", SortOrder = 99 });
SelectedCategory = Categories.FirstOrDefault();
AddDocumentCommand = new RelayCommand(AddDocument);
RemoveDocumentCommand = new RelayCommand(RemoveSelectedDocument, () => SelectedDocument is not null);
SyncSelectedCommand = new AsyncRelayCommand(SyncSelectedAsync, () => SelectedDocument is not null && _backendClient.IsConnected);
SyncAllCommand = new AsyncRelayCommand(SyncAllAsync, () => Documents.Any(document => !document.IsUploaded) && _backendClient.IsConnected);
}
public ObservableCollection<LibraryCategory> Categories { get; } = new();
public ObservableCollection<CloudPrintDocument> Documents { get; } = new();
public LibraryCategory? SelectedCategory
{
get => _selectedCategory;
set => SetProperty(ref _selectedCategory, value);
}
public CloudPrintDocument? SelectedDocument
{
get => _selectedDocument;
set
{
if (SetProperty(ref _selectedDocument, value))
{
RemoveDocumentCommand.RaiseCanExecuteChanged();
SyncSelectedCommand.RaiseCanExecuteChanged();
}
}
}
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
public RelayCommand AddDocumentCommand { get; }
public RelayCommand RemoveDocumentCommand { get; }
public AsyncRelayCommand SyncSelectedCommand { get; }
public AsyncRelayCommand SyncAllCommand { get; }
public void RefreshCommandState()
{
SyncSelectedCommand.RaiseCanExecuteChanged();
SyncAllCommand.RaiseCanExecuteChanged();
}
private void AddDocument()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "选择要加入文库的文件",
Multiselect = true,
Filter = "支持的文件|*.pdf;*.doc;*.docx;*.txt;*.xls;*.xlsx;*.ppt;*.pptx;*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.zip;*.rar|所有文件|*.*"
};
if (dialog.ShowDialog() != true)
{
return;
}
var addedCount = 0;
var categoryName = SelectedCategory?.Name ?? "未分类";
var limit = Math.Max(1, _settings.MaxUploadSizeMb) * 1024L * 1024L;
foreach (var fileName in dialog.FileNames)
{
var validation = _fileService.ValidateFile(fileName, limit);
if (!validation.IsValid)
{
StatusMessage = $"跳过 {System.IO.Path.GetFileName(fileName)}{validation.Message}";
continue;
}
var document = _fileService.CopyToLibrary(fileName, _settings.LibraryRoot, categoryName, SelectedCategory?.Id ?? 0);
Documents.Insert(0, document);
addedCount++;
}
StatusMessage = addedCount > 0 ? $"已加入 {addedCount} 个文件到文库" : "没有文件被加入文库";
RefreshCommandState();
}
private void RemoveSelectedDocument()
{
if (SelectedDocument is null)
{
return;
}
var document = SelectedDocument;
Documents.Remove(document);
SelectedDocument = null;
StatusMessage = $"已从列表移除:{document.Title}";
RefreshCommandState();
}
private async Task SyncSelectedAsync()
{
if (SelectedDocument is null)
{
return;
}
await _backendClient.UploadLibraryDocumentAsync(SelectedDocument).ConfigureAwait(true);
StatusMessage = $"已同步:{SelectedDocument.Title}";
RefreshCommandState();
}
private async Task SyncAllAsync()
{
var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList();
foreach (var document in pendingDocuments)
{
await _backendClient.UploadLibraryDocumentAsync(document).ConfigureAwait(true);
}
StatusMessage = $"已同步 {pendingDocuments.Count} 个文库文件";
RefreshCommandState();
}
}

767
ViewModels/MainViewModel.cs Normal file
View File

@@ -0,0 +1,767 @@
using System.Collections.ObjectModel;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using Grpc.Core;
namespace CloudPrint.Client.ViewModels;
public sealed class MainViewModel : ObservableObject
{
private const int MaxReconnectAttempts = 5;
private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan InitialReconnectDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30);
private readonly IPrintService _printService;
private readonly IPrintBackendClient _backendClient;
private readonly IWebSocketService _webSocketService;
private readonly ISettingsService _settingsService;
private readonly IUiDispatcher _uiDispatcher;
private readonly IAppLogger _appLogger;
private readonly PrintJobCoordinator _jobCoordinator;
private readonly HashSet<string> _knownJobKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _queueProcessingGate = new(1, 1);
private CancellationTokenSource? _heartbeatCts;
private CancellationTokenSource? _subscriptionWatchCts;
private CancellationTokenSource? _queueCts;
private CancellationTokenSource? _processingCts;
private Task? _queueWorker;
private PrintJob? _selectedJob;
private PrintJob? _processingJob;
private PrinterInfo? _selectedPrinter;
private ConnectionState _connectionState = ConnectionState.Disconnected;
private string _lastMessage = "已就绪";
private bool _isReconnecting;
private bool _isManualDisconnect = true;
private bool _autoProcessQueue = true;
private bool _isQueuePaused;
private bool _isQueueProcessing;
public MainViewModel()
: this(new PrintService(), new FileService(), new GrpcService(), new WebSocketService(), new JsonSettingsService(), new WpfUiDispatcher(), new FileAppLogger())
{
}
public MainViewModel(
IPrintService printService,
IFileService fileService,
IPrintBackendClient backendClient,
IWebSocketService webSocketService,
ISettingsService settingsService,
IUiDispatcher uiDispatcher,
IAppLogger appLogger)
{
_printService = printService;
_backendClient = backendClient;
_webSocketService = webSocketService;
_settingsService = settingsService;
_uiDispatcher = uiDispatcher;
_appLogger = appLogger;
_jobCoordinator = new PrintJobCoordinator(fileService, printService, backendClient);
Settings = _settingsService.Load();
ConfigureBackendMode();
Library = new LibraryViewModel(fileService, backendClient, Settings);
SettingsPanel = new SettingsViewModel(Settings, settingsService, backendClient, appLogger);
_backendClient.PrintJobReceived += OnPrintJobReceived;
_backendClient.LogReceived += (_, entry) => AddLog(entry);
_webSocketService.LogReceived += (_, entry) => AddLog(entry);
RefreshPrintersCommand = new RelayCommand(RefreshPrinters);
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedPrinter is not null);
SubscribeCommand = new AsyncRelayCommand(SubscribeAsync, () => CanSubscribe);
ProcessSelectedJobCommand = new AsyncRelayCommand(ProcessSelectedJobAsync, () => Jobs.Any(job => job.Status == PrintJobStatus.Pending) && !IsQueueProcessing);
CancelSelectedJobCommand = new AsyncRelayCommand(CancelSelectedJobAsync, () => CanCancelSelectedJob);
PauseQueueCommand = new RelayCommand(PauseQueue, () => AutoProcessQueue && !IsQueuePaused);
ResumeQueueCommand = new RelayCommand(ResumeQueue, () => AutoProcessQueue && IsQueuePaused);
DisconnectCommand = new RelayCommand(Disconnect, () => _backendClient.IsConnected || _webSocketService.IsConnected);
SaveSettingsCommand = new RelayCommand(SaveSettings);
ClearLogsCommand = new RelayCommand(ClearLogs);
RefreshPrinters();
}
public ClientSettings Settings { get; }
public ObservableCollection<PrinterInfo> Printers { get; } = new();
public ObservableCollection<PrintJob> Jobs { get; } = new();
public ObservableCollection<AppLogEntry> Logs { get; } = new();
public LibraryViewModel Library { get; }
public SettingsViewModel SettingsPanel { get; }
public PrintJob? SelectedJob
{
get => _selectedJob;
set
{
if (SetProperty(ref _selectedJob, value))
{
CancelSelectedJobCommand.RaiseCanExecuteChanged();
}
}
}
public PrinterInfo? SelectedPrinter
{
get => _selectedPrinter;
set
{
if (SetProperty(ref _selectedPrinter, value))
{
(ConnectCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(SubscribeCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
}
}
public ConnectionState ConnectionState
{
get => _connectionState;
private set
{
if (SetProperty(ref _connectionState, value))
{
OnPropertyChanged(nameof(ConnectionStatus));
OnPropertyChanged(nameof(CanSubscribe));
SubscribeCommand.RaiseCanExecuteChanged();
DisconnectCommand.RaiseCanExecuteChanged();
Library.RefreshCommandState();
}
}
}
public string ConnectionStatus => ConnectionState switch
{
ConnectionState.Connecting => "连接中",
ConnectionState.Connected => "已连接",
ConnectionState.Subscribing => "订阅中",
ConnectionState.Subscribed => "已订阅",
ConnectionState.Error => "连接异常",
_ => "未连接"
};
public bool CanSubscribe => SelectedPrinter is not null && _backendClient.IsConnected;
public bool CanCancelSelectedJob => SelectedJob is { Status: PrintJobStatus.Pending or PrintJobStatus.Printing };
public bool AutoProcessQueue
{
get => _autoProcessQueue;
set
{
if (SetProperty(ref _autoProcessQueue, value))
{
OnPropertyChanged(nameof(QueueStatusText));
RaiseQueueCommandStates();
if (value)
{
StartQueueProcessingIfNeeded();
}
}
}
}
public bool IsQueuePaused
{
get => _isQueuePaused;
private set
{
if (SetProperty(ref _isQueuePaused, value))
{
OnPropertyChanged(nameof(QueueStatusText));
RaiseQueueCommandStates();
}
}
}
public bool IsQueueProcessing
{
get => _isQueueProcessing;
private set
{
if (SetProperty(ref _isQueueProcessing, value))
{
OnPropertyChanged(nameof(QueueStatusText));
RaiseQueueCommandStates();
}
}
}
public string QueueStatusText => AutoProcessQueue switch
{
false => "自动队列已关闭,可手动处理下一任务",
_ when IsQueuePaused => "队列已暂停,当前任务完成后不再继续处理",
_ when IsQueueProcessing => "队列处理中,任务将按顺序自动打印",
_ => "自动队列已开启,等待新任务"
};
public string LastMessage
{
get => _lastMessage;
private set => SetProperty(ref _lastMessage, value);
}
public RelayCommand RefreshPrintersCommand { get; }
public AsyncRelayCommand ConnectCommand { get; }
public AsyncRelayCommand SubscribeCommand { get; }
public AsyncRelayCommand ProcessSelectedJobCommand { get; }
public AsyncRelayCommand CancelSelectedJobCommand { get; }
public RelayCommand PauseQueueCommand { get; }
public RelayCommand ResumeQueueCommand { get; }
public RelayCommand DisconnectCommand { get; }
public RelayCommand SaveSettingsCommand { get; }
public RelayCommand ClearLogsCommand { get; }
private void RefreshPrinters()
{
Printers.Clear();
foreach (var printer in _printService.GetPrinters())
{
Printers.Add(printer);
}
SelectedPrinter = Printers.FirstOrDefault();
AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机");
}
private async Task ConnectAsync()
{
if (SelectedPrinter is null)
{
AddLog(AppLogLevel.Warning, "请先选择打印机");
return;
}
try
{
_isManualDisconnect = false;
ConfigureBackendMode();
ConnectionState = ConnectionState.Connecting;
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true);
await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey).ConfigureAwait(true);
await _backendClient.SendHeartbeatAsync(SelectedPrinter).ConfigureAwait(true);
await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress))).ConfigureAwait(true);
_settingsService.Save(Settings);
StartHeartbeatLoop();
ConnectionState = ConnectionState.Connected;
DisconnectCommand.RaiseCanExecuteChanged();
Library.RefreshCommandState();
}
catch (Exception ex)
{
ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex));
}
}
private async Task SubscribeAsync()
{
if (SelectedPrinter is null)
{
AddLog(AppLogLevel.Warning, "请先选择打印机");
return;
}
try
{
ConnectionState = ConnectionState.Subscribing;
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true);
ConnectionState = ConnectionState.Subscribed;
StartSubscriptionWatchLoop();
ProcessSelectedJobCommand.RaiseCanExecuteChanged();
Library.RefreshCommandState();
StartQueueProcessingIfNeeded();
}
catch (Exception ex)
{
ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"订阅失败:{ex.Message}", ex));
}
}
private async Task ProcessSelectedJobAsync()
{
var job = GetNextPendingJob(preferSelected: true);
if (job is null)
{
AddLog(AppLogLevel.Warning, "没有待打印任务");
return;
}
if (!await _queueProcessingGate.WaitAsync(0).ConfigureAwait(true))
{
AddLog(AppLogLevel.Warning, "队列正在处理任务,请稍后再试");
return;
}
try
{
IsQueueProcessing = true;
await ProcessJobAsync(job, CancellationToken.None).ConfigureAwait(true);
}
finally
{
IsQueueProcessing = false;
_queueProcessingGate.Release();
RaiseQueueCommandStates();
}
}
private async Task CancelSelectedJobAsync()
{
if (SelectedJob is null)
{
AddLog(AppLogLevel.Warning, "请先选择要取消的任务");
return;
}
try
{
if (ReferenceEquals(SelectedJob, _processingJob))
{
_processingCts?.Cancel();
}
_printService.CancelPrintJob(SelectedJob);
await _backendClient.CancelPrintJobAsync(SelectedJob).ConfigureAwait(true);
await _backendClient.SendStatusUpdateAsync(SelectedJob).ConfigureAwait(true);
AddLog(AppLogLevel.Warning, $"已取消任务:{SelectedJob.JobNo}");
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"取消任务失败:{ex.Message}", ex));
}
finally
{
ProcessSelectedJobCommand.RaiseCanExecuteChanged();
CancelSelectedJobCommand.RaiseCanExecuteChanged();
}
}
private void OnPrintJobReceived(object? sender, PrintJobReceivedEventArgs e)
{
var isDuplicate = false;
_uiDispatcher.Invoke(() =>
{
if (!TryRememberJob(e.Job))
{
isDuplicate = true;
return;
}
Jobs.Insert(0, e.Job);
ProcessSelectedJobCommand.RaiseCanExecuteChanged();
});
if (isDuplicate)
{
AddLog(AppLogLevel.Warning, $"已忽略重复打印任务:{GetJobDisplayName(e.Job)}");
return;
}
StartQueueProcessingIfNeeded();
}
private void SaveSettings()
{
ConfigureBackendMode();
_settingsService.Save(Settings);
AddLog(AppLogLevel.Success, "设置已保存");
}
private void ConfigureBackendMode()
{
if (_backendClient is GrpcService grpcService)
{
grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc;
}
}
private void Disconnect()
{
_isManualDisconnect = true;
_heartbeatCts?.Cancel();
_subscriptionWatchCts?.Cancel();
_queueCts?.Cancel();
_processingCts?.Cancel();
_backendClient.Disconnect();
_ = _webSocketService.DisconnectAsync();
ConnectionState = ConnectionState.Disconnected;
AddLog(AppLogLevel.Warning, "客户端已断开连接");
Library.RefreshCommandState();
}
private void PauseQueue()
{
IsQueuePaused = true;
AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务");
}
private void ResumeQueue()
{
IsQueuePaused = false;
AddLog(AppLogLevel.Info, "自动队列已恢复");
StartQueueProcessingIfNeeded();
}
private void StartQueueProcessingIfNeeded()
{
if (!AutoProcessQueue || IsQueuePaused || _isManualDisconnect)
{
RaiseQueueCommandStates();
return;
}
if (_queueWorker is { IsCompleted: false })
{
return;
}
_queueCts?.Cancel();
_queueCts?.Dispose();
_queueCts = new CancellationTokenSource();
var token = _queueCts.Token;
_queueWorker = Task.Run(() => RunQueueProcessingLoopAsync(token), CancellationToken.None);
}
private async Task RunQueueProcessingLoopAsync(CancellationToken cancellationToken)
{
if (!await _queueProcessingGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
_uiDispatcher.Invoke(() => IsQueueProcessing = true);
while (!cancellationToken.IsCancellationRequested && AutoProcessQueue && !IsQueuePaused)
{
var job = GetNextPendingJob(preferSelected: false);
if (job is null)
{
break;
}
await ProcessJobAsync(job, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown or user cancellation path.
}
finally
{
_uiDispatcher.Invoke(() => IsQueueProcessing = false);
_queueProcessingGate.Release();
RaiseQueueCommandStates();
_queueWorker = null;
if (HasPendingJobs() && AutoProcessQueue && !IsQueuePaused && !_isManualDisconnect && !cancellationToken.IsCancellationRequested)
{
StartQueueProcessingIfNeeded();
}
}
}
private async Task ProcessJobAsync(PrintJob job, CancellationToken cancellationToken)
{
try
{
_processingJob = job;
_processingCts?.Cancel();
_processingCts?.Dispose();
_processingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_uiDispatcher.Invoke(() => SelectedJob = job);
await _jobCoordinator.ProcessAsync(job, Settings, cancellationToken: _processingCts.Token).ConfigureAwait(false);
AddLog(AppLogLevel.Success, $"任务处理完成:{job.JobNo}");
}
catch (OperationCanceledException)
{
AddLog(AppLogLevel.Warning, $"任务已取消:{job.JobNo}");
if (cancellationToken.IsCancellationRequested)
{
throw;
}
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"任务处理失败:{ex.Message}", ex));
}
finally
{
_processingJob = null;
RaiseQueueCommandStates();
}
}
private PrintJob? GetNextPendingJob(bool preferSelected)
{
PrintJob? job = null;
_uiDispatcher.Invoke(() =>
{
job = preferSelected && SelectedJob?.Status == PrintJobStatus.Pending
? SelectedJob
: Jobs.LastOrDefault(item => item.Status == PrintJobStatus.Pending);
});
return job;
}
private bool HasPendingJobs()
{
var hasPending = false;
_uiDispatcher.Invoke(() => hasPending = Jobs.Any(item => item.Status == PrintJobStatus.Pending));
return hasPending;
}
private void RaiseQueueCommandStates()
{
_uiDispatcher.Invoke(() =>
{
ProcessSelectedJobCommand.RaiseCanExecuteChanged();
CancelSelectedJobCommand.RaiseCanExecuteChanged();
PauseQueueCommand.RaiseCanExecuteChanged();
ResumeQueueCommand.RaiseCanExecuteChanged();
});
}
private void StartHeartbeatLoop()
{
_heartbeatCts?.Cancel();
_heartbeatCts?.Dispose();
_heartbeatCts = new CancellationTokenSource();
var token = _heartbeatCts.Token;
_ = Task.Run(() => RunHeartbeatLoopAsync(token), CancellationToken.None);
}
private void StartSubscriptionWatchLoop()
{
_subscriptionWatchCts?.Cancel();
_subscriptionWatchCts?.Dispose();
_subscriptionWatchCts = new CancellationTokenSource();
var token = _subscriptionWatchCts.Token;
_ = Task.Run(() => RunSubscriptionWatchLoopAsync(token), CancellationToken.None);
}
private async Task RunSubscriptionWatchLoopAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(SubscriptionWatchInterval);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
if (_isManualDisconnect || SelectedPrinter is null)
{
continue;
}
if (!_backendClient.IsConnected)
{
AddLog(AppLogLevel.Warning, "检测到任务订阅流断开,准备自动恢复...");
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown path.
}
}
private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(5, Settings.HeartbeatIntervalSeconds)));
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await SendHeartbeatOnceAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown path.
}
}
private async Task SendHeartbeatOnceAsync(CancellationToken cancellationToken)
{
if (SelectedPrinter is null || !_backendClient.IsConnected)
{
if (!_isManualDisconnect && SelectedPrinter is not null)
{
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
return;
}
try
{
await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"心跳失败:{ex.Message}", ex));
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task TryReconnectAsync(CancellationToken cancellationToken)
{
if (SelectedPrinter is null || _isManualDisconnect || _isReconnecting)
{
return;
}
_isReconnecting = true;
try
{
for (var attempt = 1; attempt <= MaxReconnectAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var delay = GetReconnectDelay(attempt);
AddLog(AppLogLevel.Warning, $"正在尝试自动重连(第 {attempt}/{MaxReconnectAttempts} 次,等待 {delay.TotalSeconds:0} 秒)...");
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
try
{
ConfigureBackendMode();
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey, cancellationToken).ConfigureAwait(false);
await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey, cancellationToken).ConfigureAwait(false);
await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress)), cancellationToken).ConfigureAwait(false);
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Subscribed);
AddLog(AppLogLevel.Success, "自动重连成功,任务订阅已恢复");
return;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (IsFatalReconnectError(ex))
{
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
AddLog(AppLogEntry.FromException($"自动重连停止:{ex.Message}", ex));
return;
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"自动重连失败:{ex.Message}", ex));
}
}
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
AddLog(AppLogLevel.Error, "自动重连已达到最大次数,请检查网络、后端服务或 API Key 后手动重试");
}
finally
{
_isReconnecting = false;
}
}
private void ClearLogs()
{
Logs.Clear();
LastMessage = "日志已清空";
}
private void AddLog(AppLogLevel level, string message)
{
AddLog(new AppLogEntry { Level = level, Message = message });
}
private void AddLog(AppLogEntry entry)
{
_appLogger.Write(entry);
_uiDispatcher.Invoke(() =>
{
LastMessage = entry.Message;
Logs.Insert(0, entry);
while (Logs.Count > 200)
{
Logs.RemoveAt(Logs.Count - 1);
}
});
}
private static string ToWebSocketAddress(string address)
{
if (address.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "wss://" + address[8..].TrimEnd('/') + "/ws/print";
}
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
return "ws://" + address[7..].TrimEnd('/') + "/ws/print";
}
return "ws://" + address.TrimEnd('/') + "/ws/print";
}
private bool TryRememberJob(PrintJob job)
{
var key = GetJobKey(job);
if (!_knownJobKeys.Add(key))
{
return false;
}
return !Jobs.Any(existing => GetJobKey(existing).Equals(key, StringComparison.OrdinalIgnoreCase));
}
private static string GetJobKey(PrintJob job)
{
if (!string.IsNullOrWhiteSpace(job.JobNo))
{
return $"job-no:{job.JobNo}";
}
if (job.Id > 0)
{
return $"id:{job.Id}";
}
return $"file:{job.FileId}:{job.FileName}:{job.OrderItemId}";
}
private static string GetJobDisplayName(PrintJob job)
{
return string.IsNullOrWhiteSpace(job.JobNo) ? job.FileName : job.JobNo;
}
private static TimeSpan GetReconnectDelay(int attempt)
{
var seconds = InitialReconnectDelay.TotalSeconds * Math.Pow(2, Math.Max(0, attempt - 1));
return TimeSpan.FromSeconds(Math.Min(seconds, MaxReconnectDelay.TotalSeconds));
}
private static bool IsFatalReconnectError(Exception exception)
{
if (exception is ArgumentException or InvalidOperationException)
{
return true;
}
if (exception is RpcException rpcException)
{
return rpcException.StatusCode is StatusCode.Unauthenticated or StatusCode.PermissionDenied or StatusCode.InvalidArgument;
}
return false;
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.ObjectModel;
using CloudPrint.Client.Models;
namespace CloudPrint.Client.ViewModels;
public sealed class PrintQueueViewModel
{
public ObservableCollection<PrintJob> Jobs { get; } = new();
}

View File

@@ -0,0 +1,155 @@
using CloudPrint.Client.Models;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using System.IO;
namespace CloudPrint.Client.ViewModels;
public sealed class SettingsViewModel : ObservableObject
{
private readonly ISettingsService _settingsService;
private readonly IPrintBackendClient _backendClient;
private readonly IAppLogger _appLogger;
private string _statusMessage = "设置就绪";
private string _adminPassword = string.Empty;
public SettingsViewModel()
: this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger())
{
}
public SettingsViewModel(ClientSettings settings, ISettingsService settingsService, IPrintBackendClient backendClient, IAppLogger appLogger)
{
Settings = settings;
_settingsService = settingsService;
_backendClient = backendClient;
_appLogger = appLogger;
SaveCommand = new RelayCommand(Save);
SelectLibraryRootCommand = new RelayCommand(SelectLibraryRoot);
LoginAdminCommand = new AsyncRelayCommand(LoginAdminAsync);
LogoutAdminCommand = new RelayCommand(LogoutAdmin, () => IsAdminLoggedIn);
OpenLogDirectoryCommand = new RelayCommand(OpenLogDirectory);
if (!string.IsNullOrWhiteSpace(Settings.AdminAccessToken))
{
_backendClient.SetAdminToken(Settings.AdminUsername, Settings.AdminAccessToken);
IsAdminLoggedIn = true;
}
}
public ClientSettings Settings { get; }
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
public RelayCommand SaveCommand { get; }
public RelayCommand SelectLibraryRootCommand { get; }
public AsyncRelayCommand LoginAdminCommand { get; }
public RelayCommand LogoutAdminCommand { get; }
public RelayCommand OpenLogDirectoryCommand { get; }
public string LogDirectory => _appLogger.LogDirectory;
private bool _isAdminLoggedIn;
public bool IsAdminLoggedIn
{
get => _isAdminLoggedIn;
private set
{
if (SetProperty(ref _isAdminLoggedIn, value))
{
OnPropertyChanged(nameof(AdminLoginStatus));
LogoutAdminCommand.RaiseCanExecuteChanged();
}
}
}
public string AdminLoginStatus => IsAdminLoggedIn ? $"已登录:{Settings.AdminUsername}" : "未登录";
public string AdminPassword
{
get => _adminPassword;
set => SetProperty(ref _adminPassword, value);
}
private void Save()
{
_settingsService.Save(Settings);
StatusMessage = "设置已保存";
}
private async Task LoginAdminAsync()
{
try
{
ConfigureBackendMode();
if (!_backendClient.IsConnected)
{
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true);
}
var session = await _backendClient.LoginAdminAsync(Settings.AdminUsername, AdminPassword).ConfigureAwait(true);
Settings.AdminUsername = session.Username;
Settings.AdminAccessToken = session.AccessToken;
AdminPassword = string.Empty;
IsAdminLoggedIn = true;
_settingsService.Save(Settings);
StatusMessage = $"管理员登录成功:{session.DisplayName}";
}
catch (Exception ex)
{
IsAdminLoggedIn = false;
StatusMessage = $"管理员登录失败:{ex.Message}";
}
}
private void LogoutAdmin()
{
_backendClient.LogoutAdmin();
Settings.AdminAccessToken = string.Empty;
AdminPassword = string.Empty;
IsAdminLoggedIn = false;
_settingsService.Save(Settings);
StatusMessage = "管理员已退出登录";
}
private void ConfigureBackendMode()
{
if (_backendClient is GrpcService grpcService)
{
grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc;
}
}
private void SelectLibraryRoot()
{
using var dialog = new System.Windows.Forms.FolderBrowserDialog
{
Description = "选择本地文库目录",
UseDescriptionForTitle = true,
SelectedPath = string.IsNullOrWhiteSpace(Settings.LibraryRoot)
? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
: Settings.LibraryRoot
};
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
Settings.LibraryRoot = dialog.SelectedPath;
StatusMessage = "已选择文库目录";
}
}
private void OpenLogDirectory()
{
Directory.CreateDirectory(_appLogger.LogDirectory);
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = _appLogger.LogDirectory,
UseShellExecute = true
};
System.Diagnostics.Process.Start(startInfo);
}
}

65
Views/LibraryView.xaml Normal file
View File

@@ -0,0 +1,65 @@
<UserControl x:Class="CloudPrint.Client.Views.LibraryView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="White" CornerRadius="14" Padding="18" Margin="0,0,16,0">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<TextBlock Text="文库分类" FontSize="18" FontWeight="Bold" />
<TextBlock Text="本地文库会按分类复制保存,连接后可同步上传到云端。" TextWrapping="Wrap" Foreground="#64748B" Margin="0,6,0,0" />
</StackPanel>
<ListBox ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="{Binding Icon}" Margin="0,0,8,0" />
<TextBlock Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<Border Grid.Column="1" Background="White" CornerRadius="14" Padding="18">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="文库文件" FontSize="18" FontWeight="Bold" />
<TextBlock Text="{Binding StatusMessage}" Foreground="#64748B" Margin="0,4,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="加入文件" Command="{Binding AddDocumentCommand}" />
<Button Content="同步选中" Command="{Binding SyncSelectedCommand}" />
<Button Content="同步全部" Command="{Binding SyncAllCommand}" />
<Button Content="移除" Command="{Binding RemoveDocumentCommand}" Style="{StaticResource SecondaryButtonStyle}" />
</StackPanel>
</Grid>
<DataGrid ItemsSource="{Binding Documents}" SelectedItem="{Binding SelectedDocument}">
<DataGrid.Columns>
<DataGridTextColumn Header="标题" Binding="{Binding Title, UpdateSourceTrigger=PropertyChanged}" Width="1.4*" />
<DataGridTextColumn Header="分类" Binding="{Binding CategoryName}" Width="110" />
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="1.4*" />
<DataGridTextColumn Header="类型" Binding="{Binding FileType}" Width="70" />
<DataGridTextColumn Header="大小" Binding="{Binding FileSizeText}" Width="90" />
<DataGridTextColumn Header="描述" Binding="{Binding Description, UpdateSourceTrigger=PropertyChanged}" Width="1.5*" />
<DataGridTextColumn Header="同步状态" Binding="{Binding SyncStatus}" Width="90" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,9 @@
namespace CloudPrint.Client.Views;
public partial class LibraryView : System.Windows.Controls.UserControl
{
public LibraryView()
{
InitializeComponent();
}
}

89
Views/MainWindow.xaml Normal file
View File

@@ -0,0 +1,89 @@
<Window x:Class="CloudPrint.Client.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:CloudPrint.Client.Views"
Title="云印享打印客户端"
Width="1200"
Height="760"
MinWidth="980"
MinHeight="620"
WindowStartupLocation="CenterScreen">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#2563EB" CornerRadius="18" Padding="24" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="云印享 Windows 打印客户端" FontSize="26" FontWeight="Bold" Foreground="White" />
<TextBlock Text="gRPC 订阅打印任务 · Windows 打印机管理 · 文件预处理(最小运行版)"
Margin="0,8,0,0" Foreground="#DBEAFE" FontSize="14" />
</StackPanel>
<Border Grid.Column="1" Background="#1D4ED8" CornerRadius="12" Padding="14,8" VerticalAlignment="Center">
<TextBlock Text="{Binding ConnectionStatus}" Foreground="White" FontWeight="SemiBold" />
</Border>
</Grid>
</Border>
<Border Grid.Row="1" Background="White" CornerRadius="14" Padding="18" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.15*" />
<ColumnDefinition Width="1.05*" />
<ColumnDefinition Width="1.05*" />
<ColumnDefinition Width="1.25*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,14,0">
<TextBlock Text="gRPC 服务器地址" Foreground="#475569" Margin="0,0,0,6" />
<TextBox Text="{Binding Settings.ServerAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="0,0,14,0">
<TextBlock Text="打印客户端 API Key" Foreground="#475569" Margin="0,0,0,6" />
<TextBox Text="{Binding Settings.ApiKey, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="0,0,14,0">
<TextBlock Text="门店名称" Foreground="#475569" Margin="0,0,0,6" />
<TextBox Text="{Binding Settings.StoreName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="3" Margin="0,0,14,0">
<TextBlock Text="本机打印机" Foreground="#475569" Margin="0,0,0,6" />
<ComboBox ItemsSource="{Binding Printers}"
SelectedItem="{Binding SelectedPrinter}"
DisplayMemberPath="DisplayText"
MinHeight="32" />
</StackPanel>
<StackPanel Grid.Column="4" VerticalAlignment="Bottom" Orientation="Horizontal">
<Button Content="保存设置" Command="{Binding SaveSettingsCommand}" Style="{StaticResource SecondaryButtonStyle}" />
<Button Content="刷新打印机" Command="{Binding RefreshPrintersCommand}" Style="{StaticResource SecondaryButtonStyle}" />
<Button Content="连接/注册" Command="{Binding ConnectCommand}" />
<Button Content="订阅任务" Command="{Binding SubscribeCommand}" />
<Button Content="断开" Command="{Binding DisconnectCommand}" Style="{StaticResource SecondaryButtonStyle}" />
</StackPanel>
</Grid>
</Border>
<TabControl Grid.Row="2" Background="Transparent" BorderThickness="0">
<TabItem Header="打印队列">
<views:PrintQueueView DataContext="{Binding}" Margin="0,12,0,0" />
</TabItem>
<TabItem Header="文库管理">
<views:LibraryView DataContext="{Binding Library}" Margin="0,12,0,0" />
</TabItem>
<TabItem Header="系统设置">
<views:SettingsView DataContext="{Binding SettingsPanel}" Margin="0,12,0,0" />
</TabItem>
</TabControl>
<TextBlock Grid.Row="3" Text="当前仅修改 Client 目录;未触碰 adminpage/backend/frontend。"
Foreground="#64748B" Margin="0,12,0,0" HorizontalAlignment="Right" />
</Grid>
</Window>

13
Views/MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,13 @@
using System.Windows;
using CloudPrint.Client.ViewModels;
namespace CloudPrint.Client.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}

89
Views/PrintQueueView.xaml Normal file
View File

@@ -0,0 +1,89 @@
<UserControl x:Class="CloudPrint.Client.Views.PrintQueueView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="White" CornerRadius="14" Padding="18" Margin="0,0,16,0">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="打印任务队列" FontSize="18" FontWeight="Bold" />
<TextBlock Text="后端 SubscribeJobs 流进入队列,客户端预处理后提交打印并上报 ReportJobStatus。"
Foreground="#64748B" Margin="0,4,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox Content="自动处理" IsChecked="{Binding AutoProcessQueue}" VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Content="暂停队列" Command="{Binding PauseQueueCommand}" Style="{StaticResource SecondaryButtonStyle}" />
<Button Content="恢复队列" Command="{Binding ResumeQueueCommand}" Style="{StaticResource SecondaryButtonStyle}" />
<Button Content="处理下一任务" Command="{Binding ProcessSelectedJobCommand}" />
<Button Content="取消选中" Command="{Binding CancelSelectedJobCommand}" Style="{StaticResource SecondaryButtonStyle}" />
</StackPanel>
</Grid>
<TextBlock DockPanel.Dock="Top"
Text="{Binding QueueStatusText}"
Foreground="#2563EB"
Margin="0,0,0,10" />
<DataGrid ItemsSource="{Binding Jobs}" SelectedItem="{Binding SelectedJob}">
<DataGrid.Columns>
<DataGridTextColumn Header="任务号" Binding="{Binding JobNo}" Width="155" />
<DataGridTextColumn Header="文件" Binding="{Binding FileName}" Width="*" />
<DataGridTextColumn Header="打印机" Binding="{Binding PrinterName}" Width="160" />
<DataGridTextColumn Header="份数" Binding="{Binding Copies}" Width="65" />
<DataGridCheckBoxColumn Header="彩色" Binding="{Binding Color}" Width="65" />
<DataGridCheckBoxColumn Header="双面" Binding="{Binding Duplex}" Width="65" />
<DataGridTextColumn Header="页码" Binding="{Binding PageRange}" Width="70" />
<DataGridTextColumn Header="系统Job" Binding="{Binding WindowsJobId}" Width="80" />
<DataGridTextColumn Header="系统状态" Binding="{Binding WindowsJobStatus}" Width="110" />
<DataGridTextColumn Header="状态" Binding="{Binding StatusText}" Width="90" />
<DataGridTextColumn Header="进度" Binding="{Binding Progress, StringFormat={}{0}%}" Width="75" />
<DataGridTextColumn Header="错误" Binding="{Binding ErrorMessage}" Width="160" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="White" CornerRadius="14" Padding="18" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="运行设置" FontSize="18" FontWeight="Bold" />
<TextBlock Text="真实打印链路:注册打印机 → 订阅任务 → 下载/处理文件 → 调用 Windows 打印服务 → 上报状态。"
TextWrapping="Wrap" Foreground="#64748B" Margin="0,12,0,0" />
</StackPanel>
</Border>
<Border Grid.Row="1" Background="White" CornerRadius="14" Padding="18">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<DockPanel>
<TextBlock Text="运行日志" FontSize="18" FontWeight="Bold" DockPanel.Dock="Left" />
<Button Content="清空" Command="{Binding ClearLogsCommand}" Style="{StaticResource SecondaryButtonStyle}" DockPanel.Dock="Right" />
</DockPanel>
<TextBlock Text="{Binding LastMessage}" Foreground="#64748B" TextWrapping="Wrap" Margin="0,4,0,0" />
</StackPanel>
<ListBox ItemsSource="{Binding Logs}" BorderThickness="0" FontFamily="Consolas" FontSize="12">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayText}" TextWrapping="Wrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,9 @@
namespace CloudPrint.Client.Views;
public partial class PrintQueueView : System.Windows.Controls.UserControl
{
public PrintQueueView()
{
InitializeComponent();
}
}

77
Views/SettingsView.xaml Normal file
View File

@@ -0,0 +1,77 @@
<UserControl x:Class="CloudPrint.Client.Views.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Background="White" CornerRadius="14" Padding="22">
<Grid MaxWidth="820" HorizontalAlignment="Left">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.ColumnSpan="3" Margin="0,0,0,20">
<TextBlock Text="系统设置" FontSize="20" FontWeight="Bold" />
<TextBlock Text="配置真实后端地址、打印客户端 API Key、本地文库目录API Key 和 token 会用 Windows DPAPI 加密保存。"
Foreground="#64748B" Margin="0,6,0,0" />
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" Text="服务器地址" VerticalAlignment="Center" Margin="0,0,12,12" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Settings.ServerAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="API Key" VerticalAlignment="Center" Margin="0,0,12,12" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Settings.ApiKey, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="门店名称" VerticalAlignment="Center" Margin="0,0,12,12" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Settings.StoreName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="本地文库目录" VerticalAlignment="Center" Margin="0,0,12,12" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Settings.LibraryRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,8,12" />
<Button Grid.Row="4" Grid.Column="2" Content="选择" Command="{Binding SelectLibraryRootCommand}" Margin="0,0,0,12" />
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0">
<CheckBox Content="允许 HTTP gRPC" IsChecked="{Binding Settings.AllowInsecureGrpc}" VerticalAlignment="Center" Margin="0,0,24,0" />
<TextBlock Text="最大上传 MB" VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox Text="{Binding Settings.MaxUploadSizeMb, UpdateSourceTrigger=PropertyChanged}" Width="70" Margin="0,0,16,0" />
<TextBlock Text="心跳秒" VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox Text="{Binding Settings.HeartbeatIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" Width="55" Margin="0,0,16,0" />
<Button Content="保存设置" Command="{Binding SaveCommand}" />
<TextBlock Text="{Binding StatusMessage}" Foreground="#64748B" VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
<StackPanel Grid.Row="6" Grid.Column="1" Orientation="Horizontal" Margin="0,12,0,0">
<TextBlock Text="日志目录" VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBlock Text="{Binding LogDirectory}" Foreground="#64748B" VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Content="打开日志目录" Command="{Binding OpenLogDirectoryCommand}" Style="{StaticResource SecondaryButtonStyle}" />
</StackPanel>
<Separator Grid.Row="7" Grid.ColumnSpan="3" Margin="0,22,0,18" />
<StackPanel Grid.Row="8" Grid.ColumnSpan="3" Margin="0,0,0,14">
<TextBlock Text="管理员登录" FontSize="18" FontWeight="Bold" />
<TextBlock Text="登录后会保存 access token并在真实 gRPC 请求中附加 Authorization metadata。"
Foreground="#64748B" Margin="0,6,0,0" />
</StackPanel>
<TextBlock Grid.Row="9" Grid.Column="0" Text="管理员账号" VerticalAlignment="Center" Margin="0,0,12,0" />
<StackPanel Grid.Row="9" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal">
<TextBox Text="{Binding Settings.AdminUsername, UpdateSourceTrigger=PropertyChanged}" Width="180" Margin="0,0,10,0" />
<PasswordBox x:Name="PART_AdminPasswordBox" Width="180" PasswordChanged="AdminPasswordBox_OnPasswordChanged" Margin="0,0,10,0" />
<Button Content="登录" Command="{Binding LoginAdminCommand}" />
<Button Content="退出" Command="{Binding LogoutAdminCommand}" Style="{StaticResource SecondaryButtonStyle}" />
<TextBlock Text="{Binding AdminLoginStatus}" Foreground="#64748B" VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,17 @@
namespace CloudPrint.Client.Views;
public partial class SettingsView : System.Windows.Controls.UserControl
{
public SettingsView()
{
InitializeComponent();
}
private void AdminPasswordBox_OnPasswordChanged(object sender, System.Windows.RoutedEventArgs e)
{
if (DataContext is ViewModels.SettingsViewModel viewModel && sender is System.Windows.Controls.PasswordBox passwordBox)
{
viewModel.AdminPassword = passwordBox.Password;
}
}
}