feat: 初始功能
This commit is contained in:
429
.gitignore
vendored
Normal file
429
.gitignore
vendored
Normal 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
86
App.xaml
Normal 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
38
App.xaml.cs
Normal 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
28
CloudPrint.Client.csproj
Normal 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
227
Helpers/PdfProcessor.cs
Normal 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
117
Helpers/WindowsPrintApi.cs
Normal 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);
|
||||
}
|
||||
55
Infrastructure/AsyncRelayCommand.cs
Normal file
55
Infrastructure/AsyncRelayCommand.cs
Normal 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);
|
||||
}
|
||||
6
Infrastructure/IUiDispatcher.cs
Normal file
6
Infrastructure/IUiDispatcher.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace CloudPrint.Client.Infrastructure;
|
||||
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Invoke(Action action);
|
||||
}
|
||||
26
Infrastructure/ObservableObject.cs
Normal file
26
Infrastructure/ObservableObject.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
28
Infrastructure/RelayCommand.cs
Normal file
28
Infrastructure/RelayCommand.cs
Normal 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);
|
||||
}
|
||||
18
Infrastructure/WpfUiDispatcher.cs
Normal file
18
Infrastructure/WpfUiDispatcher.cs
Normal 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
14
Models/AdminSession.cs
Normal 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
39
Models/AppLogEntry.cs
Normal 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
77
Models/ClientSettings.cs
Normal 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
11
Models/ConnectionState.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace CloudPrint.Client.Models;
|
||||
|
||||
public enum ConnectionState
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Subscribing,
|
||||
Subscribed,
|
||||
Error
|
||||
}
|
||||
71
Models/Document.cs
Normal file
71
Models/Document.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Models/FilePreparationResult.cs
Normal file
9
Models/FilePreparationResult.cs
Normal 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;
|
||||
}
|
||||
17
Models/FileValidationResult.cs
Normal file
17
Models/FileValidationResult.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
9
Models/LibraryCategory.cs
Normal file
9
Models/LibraryCategory.cs
Normal 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
110
Models/PrintJob.cs
Normal 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
82
Models/Printer.cs
Normal 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
213
README.md
@@ -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 云印享 版权所有
|
||||
|
||||
9
Services/Abstractions/IAppLogger.cs
Normal file
9
Services/Abstractions/IAppLogger.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IAppLogger
|
||||
{
|
||||
string LogDirectory { get; }
|
||||
void Write(AppLogEntry entry);
|
||||
}
|
||||
16
Services/Abstractions/IFileService.cs
Normal file
16
Services/Abstractions/IFileService.cs
Normal 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);
|
||||
}
|
||||
27
Services/Abstractions/IPrintBackendClient.cs
Normal file
27
Services/Abstractions/IPrintBackendClient.cs
Normal 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();
|
||||
}
|
||||
13
Services/Abstractions/IPrintService.cs
Normal file
13
Services/Abstractions/IPrintService.cs
Normal 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);
|
||||
}
|
||||
7
Services/Abstractions/ISecretProtector.cs
Normal file
7
Services/Abstractions/ISecretProtector.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface ISecretProtector
|
||||
{
|
||||
string Protect(string plainText);
|
||||
string Unprotect(string protectedText);
|
||||
}
|
||||
9
Services/Abstractions/ISettingsService.cs
Normal file
9
Services/Abstractions/ISettingsService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface ISettingsService
|
||||
{
|
||||
ClientSettings Load();
|
||||
void Save(ClientSettings settings);
|
||||
}
|
||||
14
Services/Abstractions/IWebSocketService.cs
Normal file
14
Services/Abstractions/IWebSocketService.cs
Normal 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);
|
||||
}
|
||||
34
Services/DpapiSecretProtector.cs
Normal file
34
Services/DpapiSecretProtector.cs
Normal 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
38
Services/FileAppLogger.cs
Normal 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
378
Services/FileService.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
55
Services/Grpc/CloudPrintGrpcMapper.cs
Normal file
55
Services/Grpc/CloudPrintGrpcMapper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
30
Services/Grpc/GrpcMetadataFactory.cs
Normal file
30
Services/Grpc/GrpcMetadataFactory.cs
Normal 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
362
Services/GrpcService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
138
Services/JsonSettingsService.cs
Normal file
138
Services/JsonSettingsService.cs
Normal 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
35
Services/PathHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
67
Services/PrintJobCoordinator.cs
Normal file
67
Services/PrintJobCoordinator.cs
Normal 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
170
Services/PrintService.cs
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
9
Services/Printing/IPrintJobExecutor.cs
Normal file
9
Services/Printing/IPrintJobExecutor.cs
Normal 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);
|
||||
}
|
||||
6
Services/Printing/IPrinterStatusReader.cs
Normal file
6
Services/Printing/IPrinterStatusReader.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public interface IPrinterStatusReader
|
||||
{
|
||||
PrinterStatusSnapshot Read(string printerName);
|
||||
}
|
||||
7
Services/Printing/IWindowsPrintQueueService.cs
Normal file
7
Services/Printing/IWindowsPrintQueueService.cs
Normal 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);
|
||||
}
|
||||
88
Services/Printing/ImagePrintJobExecutor.cs
Normal file
88
Services/Printing/ImagePrintJobExecutor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
100
Services/Printing/PdfPrintJobExecutor.cs
Normal file
100
Services/Printing/PdfPrintJobExecutor.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
22
Services/Printing/PrintFileGuards.cs
Normal file
22
Services/Printing/PrintFileGuards.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Services/Printing/PrinterStatusSnapshot.cs
Normal file
10
Services/Printing/PrinterStatusSnapshot.cs
Normal 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;
|
||||
}
|
||||
94
Services/Printing/RawPrintJobExecutor.cs
Normal file
94
Services/Printing/RawPrintJobExecutor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
Services/Printing/WindowsPrintQueueJob.cs
Normal file
12
Services/Printing/WindowsPrintQueueJob.cs
Normal 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; }
|
||||
}
|
||||
164
Services/Printing/WindowsPrintQueueService.cs
Normal file
164
Services/Printing/WindowsPrintQueueService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
Services/Printing/WindowsPrinterStatusReader.cs
Normal file
119
Services/Printing/WindowsPrinterStatusReader.cs
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
182
Services/WebSocketService.cs
Normal file
182
Services/WebSocketService.cs
Normal 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] + "...";
|
||||
}
|
||||
}
|
||||
154
ViewModels/LibraryViewModel.cs
Normal file
154
ViewModels/LibraryViewModel.cs
Normal 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
767
ViewModels/MainViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
ViewModels/PrintQueueViewModel.cs
Normal file
9
ViewModels/PrintQueueViewModel.cs
Normal 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();
|
||||
}
|
||||
155
ViewModels/SettingsViewModel.cs
Normal file
155
ViewModels/SettingsViewModel.cs
Normal 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
65
Views/LibraryView.xaml
Normal 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>
|
||||
9
Views/LibraryView.xaml.cs
Normal file
9
Views/LibraryView.xaml.cs
Normal 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
89
Views/MainWindow.xaml
Normal 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
13
Views/MainWindow.xaml.cs
Normal 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
89
Views/PrintQueueView.xaml
Normal 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>
|
||||
9
Views/PrintQueueView.xaml.cs
Normal file
9
Views/PrintQueueView.xaml.cs
Normal 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
77
Views/SettingsView.xaml
Normal 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>
|
||||
17
Views/SettingsView.xaml.cs
Normal file
17
Views/SettingsView.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user