A szállítólevél-grid fájlneve megnyitja a csatolt PDF-et vagy képet

- GridShippingDocument: a PdfFileName oszlop link, ha a soron van csatolmány;
  kattintásra a fájl a beépített megjelenítőben nyílik, DxWindow-ban.
- pdfViewer.renderBytes: memóriából érkező fájl rajzolása — PDF-nél pdf.js,
  képnél objektum-URL. A renderPdfs URL-es útja változatlan.
- A tartalom szerződése a dróton: SignalRTags 69, IFruitBankDataControllerCommon
  és a kliens wrappere.
This commit is contained in:
2026-08-24 12:39:45 +02:00
parent 05477d93ba
commit 48ab0b7eea
5 changed files with 187 additions and 23 deletions
@@ -151,6 +151,11 @@ public interface IFruitBankDataControllerCommon
public Task<List<ShippingDocument>?> GetShippingDocumentsByPartnerId(int partnerId);
public Task<ShippingDocument?> AddShippingDocument(ShippingDocument shippingDocument);
public Task<ShippingDocument?> UpdateShippingDocument(ShippingDocument shippingDocument);
/// <summary>A csatolt fájl nyers tartalma <c>Files.Id</c> alapján; null, ha a blob nem található.</summary>
/// <remarks>A hívó a kiterjesztést a soron már megkapott <c>Files</c> rekordból tudja — a bájtok mellé
/// nem megy külön típusinformáció.</remarks>
public Task<byte[]?> GetShippingDocumentFileContent(int filesId);
#endregion ShippingDocument
#region Customer
+1
View File
@@ -53,6 +53,7 @@ public class SignalRTags : AcSignalRTags
public const int UpdateShippingDocument = 66;
public const int CanDeleteShippingDocument = 67;
public const int DeleteShippingDocument = 68;
public const int GetShippingDocumentFileContent = 69;
public const int GetMeasuringUsers = 70;
public const int GetCustomerDtoById = 71;
@@ -21,6 +21,7 @@
@inject IEnumerable<IAcLogWriterClientBase> LogWriters
@inject FruitBankSignalRClient FruitBankSignalRClient
@inject GuardedDeleteService GuardedDelete
@inject IJSRuntime JS
<MgGridWithInfoPanel ShowInfoPanel="@IsMasterGrid">
<GridContent>
@@ -96,7 +97,28 @@
<DxGridDataColumn FieldName="TotalPallets" />
<DxGridDataColumn FieldName="IsAllMeasured" ReadOnly="true" />
<DxGridDataColumn FieldName="Comment" />
<DxGridDataColumn FieldName="PdfFileName" />
@* A cella SZÖVEGE a dokumentumé (PdfFileName), a link CÉLJA viszont a csatolt Files rekord:
hash-egyezéskor a fájl egy korábbi feltöltésé, más néven — a tényleges tárolt név a tooltipben.
Csatolmány nélküli soron marad a sima szöveg. *@
<DxGridDataColumn FieldName="PdfFileName">
<CellDisplayTemplate>
@{
var fileDocument = (ShippingDocument)context.DataItem;
var attachedFile = AttachedFileOf(fileDocument);
}
@if (attachedFile == null)
{
@context.DisplayText
}
else
{
<DxButton RenderStyle="ButtonRenderStyle.Link" CssClass="p-0"
Text="@context.DisplayText"
Title="@($"Megnyitás: {attachedFile.FileName}{attachedFile.FileExtension}")"
Click="() => OpenAttachedFileAsync(fileDocument, attachedFile)" />
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Created" ReadOnly="true" />
<DxGridDataColumn FieldName="Modified" ReadOnly="true" />
@* DeleteButtonVisible=false: a beépített törlés a keret RemoveMessageTag-jére mentene, ami
@@ -200,6 +222,30 @@
<AiProcessFormTemplate />
</BodyContentTemplate>
</DxWindow>
@* A csatolt szállítólevél megjelenítője. A fájl a SignalR-en jön (nincs URL, amit a böngésző letölthetne),
ezért ugyanez a kód fut a web hostban és a MAUI WebView-ban is. *@
<DxWindow AllowResize="true"
ShowCloseButton="true"
CloseOnEscape="true"
ShowHeader="true"
HeaderText="@_pdfWindowTitle"
ShowFooter="false"
SizeMode="SizeMode.Large"
Width="90vw"
Height="90vh"
@bind-Visible="_pdfWindowVisible">
<BodyContentTemplate Context="ctxPdfBody">
@if (_pdfError != null)
{
<div class="text-danger p-3">@_pdfError</div>
}
else
{
<div id="@_pdfContainerId" style="width: 100%; height: 100%; overflow-y: auto;"></div>
}
</BodyContentTemplate>
</DxWindow>
@code {
[Inject] public required DatabaseClient Database { get; set; }
[Inject] public required LoggedInModel LoggedInModel { get; set; }
@@ -240,6 +286,73 @@
}
}
private bool _pdfWindowVisible;
private string _pdfWindowTitle = string.Empty;
private string? _pdfError;
// Példányonkénti konténer-azonosító: ez a rács master-ként és detail-ként is a lapon lehet, és a
// megjelenítő getElementById-vel keresi a helyét — két azonos id közül a másikat találná meg.
private readonly string _pdfContainerId = $"shippingDocumentPdf_{Guid.NewGuid():N}";
/// <summary>A dokumentumhoz csatolt fájl rekordja, vagy null, ha nincs csatolmány.</summary>
/// <remarks>Ma egy csatolmány van dokumentumonként, ezért a legrégebbi a link célja. A több csatolmányt
/// külön fájlkezelő felület fogja kezelni.</remarks>
private static Files? AttachedFileOf(ShippingDocument? shippingDocument)
=> shippingDocument?.ShippingDocumentToFiles?
.OrderBy(mapping => mapping.Id)
.Select(mapping => mapping.ShippingDocumentFile)
.FirstOrDefault(file => file != null);
/// <summary>
/// A csatolt szállítólevél megnyitása a beépített megjelenítőben.
/// </summary>
/// <remarks>
/// A sorrend szándékos: előbb az ablak (a rajzoló csak létező konténerbe tud dolgozni), utána a letöltés.
/// A letöltés await-je adja azt a render-kört, amiben a konténer megjelenik; a <c>renderBytes</c> ettől
/// függetlenül is megvárja.
/// </remarks>
private async Task OpenAttachedFileAsync(ShippingDocument shippingDocument, Files file)
{
_pdfError = null;
_pdfWindowTitle = $"{shippingDocument.DocumentIdNumber} — {file.FileName}{file.FileExtension}";
_pdfWindowVisible = true;
try
{
var content = await FruitBankSignalRClient.GetShippingDocumentFileContent(file.Id);
if (content == null || content.Length == 0)
{
_pdfError = "A csatolt fájl nem található a tárolóban.";
_logger.Warning($"ShippingDocument {shippingDocument.Id}: a(z) {file.Id} Files rekord blobja nem található.");
return;
}
// Az ablak bezárásakor a konténer eltűnik a DOM-ból, a megjelenítő viszont a kulcs alapján
// kihagyná az újrarajzolást — ezért minden megnyitás előtt ejtjük a rajzolás-cache-t. A kibontott
// dokumentum a saját cache-ében marad, tehát az újranyitás így is olcsó.
await JS.InvokeVoidAsync("pdfViewer.clearRenderCache");
await JS.InvokeVoidAsync("pdfViewer.renderBytes", _pdfContainerId, $"files-{file.Id}", content, MimeTypeOf(file.FileExtension));
}
catch (Exception ex)
{
_pdfError = "A csatolt fájl megnyitása nem sikerült.";
_logger.Error($"ShippingDocument {shippingDocument.Id}: a(z) {file.Id} Files rekord megnyitása elszállt.", ex);
}
}
/// <summary>A megjelenítő ebből dönti el, hogy PDF-et rajzol vagy képet mutat.</summary>
private static string MimeTypeOf(string? fileExtension) => (fileExtension ?? string.Empty).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
_ => "application/octet-stream"
};
public bool IsMasterGrid => ParentDataItem == null;
public bool ParentDataItemIsShipping => (ParentDataItem is Shipping);
public bool ParentDataItemIsPartner => (ParentDataItem is Partner);
@@ -372,6 +372,9 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
public Task<ShippingDocument?> UpdateShippingDocument(ShippingDocument shippingDocument)
=> PostDataAsync(SignalRTags.UpdateShippingDocument, shippingDocument);
public Task<byte[]?> GetShippingDocumentFileContent(int filesId)
=> GetByIdAsync<byte[]?>(SignalRTags.GetShippingDocumentFileContent, filesId);
#endregion ShippingDocument
#region Customer
+64 -22
View File
@@ -1,12 +1,26 @@
window.pdfViewer = {
_pdfCache: new Map(),
_objectUrlCache: new Map(),
_resizeObserver: null,
_currentContainerId: null,
_currentPdfUrls: null,
_currentSources: null,
_renderTimeout: null,
_lastRenderedUrls: null, // Track what was last rendered
_lastRenderedKey: null, // Track what was last rendered
// A böngésző tölti le a PDF-eket a megadott URL-ekről.
renderPdfs: async function (containerId, pdfUrls) {
const sources = (pdfUrls || []).map(url => ({ key: url, src: url, kind: 'pdf' }));
await this._render(containerId, sources);
},
// Egyetlen fájl a memóriából: a bájtok a SignalR-en jönnek, nincs URL, amit a böngésző letölthetne.
// A cacheKey azonosítja a fájlt (pl. "files-123") — bájtokra magukra nem lehet cache-kulcsot képezni.
renderBytes: async function (containerId, cacheKey, bytes, mimeType) {
const kind = (mimeType || '').startsWith('image/') ? 'image' : 'pdf';
await this._render(containerId, [{ key: cacheKey, src: bytes, kind: kind, mimeType: mimeType }]);
},
_render: async function (containerId, sources) {
// Wait for container to be available
let container = null;
for (let i = 0; i < 50; i++) {
@@ -20,25 +34,26 @@ window.pdfViewer = {
return;
}
// Check if URLs changed - if same, skip render (use cache)
const urlsKey = JSON.stringify(pdfUrls);
if (this._lastRenderedUrls === urlsKey && this._currentContainerId === containerId) {
console.log('[PDF] Same URLs, skipping render (cached)');
// Check if the source set changed - if same, skip render (use cache).
// A kulcsokra képezzük, nem a forrásokra: a bájttömb JSON-ba írása megölné a lapot.
const sourcesKey = JSON.stringify(sources.map(s => s.key));
if (this._lastRenderedKey === sourcesKey && this._currentContainerId === containerId) {
console.log('[PDF] Same sources, skipping render (cached)');
return;
}
console.log('[PDF] New URLs detected, rendering:', pdfUrls);
console.log('[PDF] New sources detected, rendering:', sources.map(s => s.key));
// Store for resize handling
this._currentContainerId = containerId;
this._currentPdfUrls = pdfUrls;
this._lastRenderedUrls = urlsKey;
this._currentSources = sources;
this._lastRenderedKey = sourcesKey;
// Setup resize observer
this._setupResizeObserver(container);
// Render
await this._doRender(container, pdfUrls);
await this._doRender(container, sources);
},
_setupResizeObserver: function(container) {
@@ -59,7 +74,7 @@ window.pdfViewer = {
clearTimeout(this._renderTimeout);
}
this._renderTimeout = setTimeout(() => {
this._doRender(container, this._currentPdfUrls);
this._doRender(container, this._currentSources);
}, 150);
}
});
@@ -67,10 +82,11 @@ window.pdfViewer = {
this._resizeObserver.observe(container);
},
_doRender: async function(container, pdfUrls) {
_doRender: async function(container, sources) {
container.innerHTML = '';
if (typeof pdfjsLib === 'undefined') {
// A kép-ág nem használja a pdf.js-t, ezért csak akkor bukunk el rajta, ha tényleg PDF-et kérnek.
if (typeof pdfjsLib === 'undefined' && sources.some(s => s.kind !== 'image')) {
console.error('PDF.js not loaded');
container.innerHTML = '<p style="color:red;">PDF.js nincs betöltve</p>';
return;
@@ -85,19 +101,33 @@ window.pdfViewer = {
return;
}
console.log('[PDF] Rendering at width:', containerWidth, 'URLs:', pdfUrls);
console.log('[PDF] Rendering at width:', containerWidth, 'sources:', sources.map(s => s.key));
for (const url of pdfUrls) {
for (const source of sources) {
const url = source.key;
try {
if (source.kind === 'image') {
const img = document.createElement('img');
img.src = this._toObjectUrl(source);
img.style.width = '100%';
img.style.display = 'block';
img.style.marginBottom = '8px';
container.appendChild(img);
continue;
}
// Use cached PDF document if available (PDF.js document cache)
let pdf = this._pdfCache.get(url);
let pdf = this._pdfCache.get(source.key);
if (!pdf) {
console.log('[PDF] Loading new PDF:', url);
const loadingTask = pdfjsLib.getDocument(url);
console.log('[PDF] Loading new PDF:', source.key);
// A bájttömböt MÁSOLVA adjuk át: a pdf.js a workernek átadva leválasztja (detach)
// az ArrayBuffert, és a hívó példánya használhatatlanná válna egy újrarajzoláshoz.
const loadingTask = pdfjsLib.getDocument(
typeof source.src === 'string' ? source.src : { data: new Uint8Array(source.src) });
pdf = await loadingTask.promise;
this._pdfCache.set(url, pdf);
this._pdfCache.set(source.key, pdf);
} else {
console.log('[PDF] Using cached PDF:', url);
console.log('[PDF] Using cached PDF:', source.key);
}
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
@@ -134,6 +164,16 @@ window.pdfViewer = {
}
},
// Kulcsonként EGY objektum-URL: az átméretezés újrarajzol, és minden körben új URL-t gyártani szivárgás.
_toObjectUrl: function(source) {
let objectUrl = this._objectUrlCache.get(source.key);
if (!objectUrl) {
objectUrl = URL.createObjectURL(new Blob([source.src], { type: source.mimeType || 'application/octet-stream' }));
this._objectUrlCache.set(source.key, objectUrl);
}
return objectUrl;
},
dispose: function() {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
@@ -144,11 +184,13 @@ window.pdfViewer = {
}
// Keep PDF cache for performance, only clear on full dispose
this._pdfCache.clear();
this._lastRenderedUrls = null;
this._objectUrlCache.forEach(url => URL.revokeObjectURL(url));
this._objectUrlCache.clear();
this._lastRenderedKey = null;
},
// Clear only the render cache (not PDF documents)
clearRenderCache: function() {
this._lastRenderedUrls = null;
this._lastRenderedKey = null;
}
};