Skip to content

Commit 58c277d

Browse files
pythonHuangclaude
andcommitted
fix: 报表导出文件后缀错误-下载Blob重包装丢失MIME导致浏览器补.txt后缀,改为直接用响应Blob保留原始类型;PDF导出升级为QuestPDF真实PDF(中文字体嵌入/样式边框背景/数据扩展聚合与HTML一致),文件名改为.pdf
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent ab1be0b commit 58c277d

4 files changed

Lines changed: 194 additions & 8 deletions

File tree

‎Juggle.Api/Controllers/Api/ReportController.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public async Task<IActionResult> ExportPdf([FromBody] ReportPreviewRequest req)
8686
var rpt = await _db.Set<ReportEntity>().FindAsync(req.Id);
8787
if (rpt == null) return NotFound();
8888
var data = await _reportExec.ExportPdfAsync(rpt.LayoutJson!, req.Params);
89-
return File(data, "text/html", $"{rpt.Name}.html");
89+
return File(data, "application/pdf", $"{rpt.Name}.pdf");
9090
}
9191

9292
[HttpPost("export-excel")]

‎Juggle.Application/Juggle.Application.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<ItemGroup>
99
<PackageReference Include="ClosedXML" Version="0.104.2" />
10+
<PackageReference Include="QuestPDF" Version="2024.10.0" />
1011
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
1112
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
1213
</ItemGroup>

‎Juggle.Application/Services/Impl/ReportExecutionService.cs‎

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
using System.Text;
33
using System.Text.Json;
44
using System.Text.RegularExpressions;
5+
using QuestPDF.Fluent;
6+
using QuestPDF.Infrastructure;
7+
using QuestPDF.Helpers;
58
using ClosedXML.Excel;
69
using Juggle.Domain.Engine;
710
using Juggle.Infrastructure.Persistence;
@@ -600,11 +603,187 @@ private static byte[] ExportExcelCore(JsonElement root, Dictionary<string, DataT
600603
return ms.ToArray();
601604
}
602605

603-
/// <summary>导出 PDF — 基于 HTML 渲染 + QuestPDF 嵌入(降级为 HTML 输出供浏览器打印)</summary>
606+
/// <summary>导出 PDF — QuestPDF 生成真实 PDF(与 HTML/Excel 同一套扩展/聚合/样式逻辑)</summary>
604607
public async Task<byte[]> ExportPdfAsync(string layoutJson, Dictionary<string, object?>? queryParams = null)
605608
{
606-
var html = await RenderToHtml(layoutJson, queryParams);
607-
return Encoding.UTF8.GetBytes(html);
609+
using var doc = JsonDocument.Parse(layoutJson);
610+
var root = doc.RootElement;
611+
var datasets = await ResolveDatasetsAsync(root, queryParams);
612+
try
613+
{
614+
return ExportPdfCore(root, datasets);
615+
}
616+
catch (Exception ex)
617+
{
618+
// PDF 生成失败(如缺少中文字体)时降级为 HTML 输出
619+
_lastPdfError = ex.ToString();
620+
return Encoding.UTF8.GetBytes(RenderHtmlCore(root, datasets));
621+
}
622+
}
623+
624+
private static bool _pdfFontReady;
625+
private static string? _lastPdfError;
626+
/// <summary>最近一次 PDF 生成失败的原因(排查用)</summary>
627+
public static string? LastPdfError => _lastPdfError;
628+
private static readonly List<FileStream> _pdfFontStreams = new(); // 保持字体文件流存活
629+
630+
/// <summary>注册支持中文的字体(Windows 常见字体路径)。</summary>
631+
private static void EnsurePdfFont()
632+
{
633+
if (_pdfFontReady) return;
634+
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
635+
string[] candidates =
636+
{
637+
@"C:\Windows\Fonts\msyh.ttc", // 微软雅黑
638+
@"C:\Windows\Fonts\msyh.ttf",
639+
@"C:\Windows\Fonts\simsun.ttc", // 宋体
640+
@"C:\Windows\Fonts\Deng.ttf", // 等线
641+
@"C:\Windows\Fonts\simhei.ttf" // 黑体
642+
};
643+
foreach (var path in candidates)
644+
{
645+
if (!File.Exists(path)) continue;
646+
try
647+
{
648+
var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
649+
QuestPDF.Drawing.FontManager.RegisterFontWithCustomName("RptFont", fs);
650+
_pdfFontStreams.Add(fs);
651+
_pdfFontReady = true;
652+
return;
653+
}
654+
catch { /* 尝试下一个字体 */ }
655+
}
656+
}
657+
658+
private static byte[] ExportPdfCore(JsonElement root, Dictionary<string, DataTable> datasets)
659+
{
660+
EnsurePdfFont();
661+
var cellMap = BuildCellMap(root);
662+
var maxCols = GetColCount(root);
663+
var materialized = MaterializeRows(root, datasets);
664+
var colWidths = GetColWidths(root, maxCols);
665+
var templateUsed = new HashSet<(int r, int c)>();
666+
667+
var document = QuestPDF.Fluent.Document.Create(container =>
668+
{
669+
container.Page(page =>
670+
{
671+
page.Size(QuestPDF.Helpers.PageSizes.A4);
672+
page.Margin(15);
673+
page.Content().Table(table =>
674+
{
675+
table.ColumnsDefinition(cols =>
676+
{
677+
foreach (var w in colWidths)
678+
cols.RelativeColumn(Math.Max(1, w));
679+
});
680+
681+
foreach (var rr in materialized)
682+
{
683+
var used = new HashSet<int>();
684+
for (int ci = 0; ci < maxCols; ci++)
685+
{
686+
if (used.Contains(ci)) continue;
687+
if (rr.DataIndex == null && templateUsed.Contains((rr.TemplateRow, ci))) continue;
688+
689+
var hasCell = cellMap.TryGetValue((rr.TemplateRow, ci), out var cell);
690+
var value = hasCell ? ResolveCellValue(cell, rr, datasets) : "";
691+
var colspan = hasCell ? Math.Max(1, GetInt(cell, "colspan", 1)) : 1;
692+
var rowspan = hasCell ? Math.Max(1, GetInt(cell, "rowspan", 1)) : 1;
693+
if (rr.DataIndex != null) rowspan = 1;
694+
695+
var cellBase = table.Cell();
696+
IContainer cellEl = cellBase;
697+
if (colspan > 1 && rowspan > 1) { cellBase.ColumnSpan((uint)colspan); cellEl = cellBase.RowSpan((uint)rowspan); }
698+
else if (colspan > 1) cellEl = cellBase.ColumnSpan((uint)colspan);
699+
else if (rowspan > 1) cellEl = cellBase.RowSpan((uint)rowspan);
700+
var height = GetRowHeight(root, rr.TemplateRow);
701+
if (height > 0) cellEl = cellEl.MinHeight(height * 0.75f);
702+
703+
cellEl = ApplyPdfCellAppearance(cellEl, cell, hasCell, rr);
704+
705+
var align = hasCell && cell.TryGetProperty("style", out var alS)
706+
&& alS.TryGetProperty("align", out var al) && al.ValueKind == JsonValueKind.String
707+
? al.GetString() : "";
708+
cellEl.Padding(3).Text(text =>
709+
{
710+
if (align == "center") text.AlignCenter();
711+
else if (align == "right") text.AlignRight();
712+
else text.AlignLeft();
713+
text.DefaultTextStyle(st =>
714+
{
715+
if (_pdfFontReady) st.FontFamily("RptFont");
716+
if (hasCell && cell.TryGetProperty("style", out var sEl))
717+
{
718+
if (sEl.TryGetProperty("bold", out var b) && b.ValueKind == JsonValueKind.True) st.Bold();
719+
if (sEl.TryGetProperty("italic", out var it) && it.ValueKind == JsonValueKind.True) st.Italic();
720+
if (sEl.TryGetProperty("underline", out var un) && un.ValueKind == JsonValueKind.True) st.Underline();
721+
if (sEl.TryGetProperty("fontSize", out var fs) && fs.ValueKind == JsonValueKind.Number) st.FontSize(fs.GetSingle());
722+
if (sEl.TryGetProperty("color", out var cl) && cl.ValueKind == JsonValueKind.String)
723+
st.FontColor(Color.FromHex(cl.GetString()!));
724+
}
725+
else st.FontSize(10);
726+
return st;
727+
});
728+
text.Span(value.Replace("\n", " "));
729+
});
730+
731+
for (int dc = 1; dc < colspan; dc++) used.Add(ci + dc);
732+
if (rr.DataIndex == null)
733+
for (int dr = 0; dr < rowspan; dr++)
734+
for (int dc = 0; dc < colspan; dc++)
735+
if (dr > 0 || dc > 0)
736+
templateUsed.Add((rr.TemplateRow + dr, ci + dc));
737+
}
738+
}
739+
});
740+
});
741+
});
742+
return document.GeneratePdf();
743+
}
744+
745+
/// <summary>PDF 单元格外观(单一链式构建,返回链上容器):边框(分边/粗细/颜色)、背景色、斑马纹。</summary>
746+
private static IContainer ApplyPdfCellAppearance(IContainer cellEl, JsonElement cell, bool hasCell, MaterializedRow rr)
747+
{
748+
// 背景
749+
if (hasCell && HasBgColor(cell))
750+
cellEl = cellEl.Background(Color.FromHex(cell.GetProperty("style").GetProperty("bgColor").GetString()!));
751+
else if (rr.Zebra && rr.RowNumber % 2 == 0)
752+
cellEl = cellEl.Background(Color.FromHex("#F5F7FA"));
753+
754+
// 边框
755+
if (hasCell && cell.TryGetProperty("style", out var sEl) && sEl.TryGetProperty("border", out var bdEl))
756+
{
757+
if (bdEl.ValueKind == JsonValueKind.False) return cellEl; // 无边框
758+
if (bdEl.ValueKind != JsonValueKind.Object)
759+
{
760+
return cellEl.Border(0.6f).BorderColor(Color.FromHex("#CCCCCC"));
761+
}
762+
var bw = Math.Max(0.4f, GetInt(bdEl, "width", 1) * 0.6f);
763+
var bc = Color.FromHex(
764+
bdEl.TryGetProperty("color", out var bce) && bce.ValueKind == JsonValueKind.String ? bce.GetString()! : "#333333");
765+
if (bdEl.TryGetProperty("top", out var bte) && bte.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderTop(bw);
766+
if (bdEl.TryGetProperty("bottom", out var bbe) && bbe.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderBottom(bw);
767+
if (bdEl.TryGetProperty("left", out var ble) && ble.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderLeft(bw);
768+
if (bdEl.TryGetProperty("right", out var bre) && bre.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderRight(bw);
769+
return cellEl.BorderColor(bc); // QuestPDF 单元格边框颜色统一应用
770+
}
771+
return cellEl.Border(0.6f).BorderColor(Color.FromHex("#CCCCCC"));
772+
}
773+
774+
private static List<float> GetColWidths(JsonElement root, int maxCols)
775+
{
776+
var widths = new List<float>();
777+
if (root.TryGetProperty("cols", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array)
778+
{
779+
foreach (var col in colsEl.EnumerateArray())
780+
{
781+
var w = col.TryGetProperty("width", out var we) && we.ValueKind == JsonValueKind.Number ? we.GetInt32() : 100;
782+
widths.Add(Math.Max(10, w) * 0.75f); // px → pt
783+
}
784+
}
785+
while (widths.Count < maxCols) widths.Add(75f);
786+
return widths;
608787
}
609788

610789
// ===== Data Source Helpers =====

‎JuggleNet6.Frontend/src/views/report/ReportList.vue‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async function doPreview() {
113113
114114
async function doExportPdf(row: any) {
115115
const res: any = await request.post('/report/export-pdf', { id: row.id, params: {} }, { responseType: 'blob' })
116-
downloadBlob(res.data, `${row.name}.html`)
116+
downloadBlob(res.data, `${row.name}.pdf`)
117117
}
118118
119119
async function doExportExcel(row: any) {
@@ -135,9 +135,15 @@ async function doPrint(row: any) {
135135
} catch { /* 拦截器已提示 */ }
136136
}
137137
138-
function downloadBlob(data: any, filename: string) {
139-
const url = URL.createObjectURL(new Blob([data]))
140-
const a = document.createElement('a'); a.href = url; a.download = filename; a.click()
138+
function downloadBlob(data: Blob, filename: string) {
139+
// 直接使用响应 Blob(保留原始 MIME 类型,避免浏览器按未知类型补 .txt 后缀)
140+
const url = URL.createObjectURL(data)
141+
const a = document.createElement('a')
142+
a.href = url
143+
a.download = filename
144+
document.body.appendChild(a)
145+
a.click()
146+
document.body.removeChild(a)
141147
URL.revokeObjectURL(url)
142148
}
143149

0 commit comments

Comments
 (0)