新增质量模块(工艺执行书 / 样茧试缫)与报表中心

质量模块:
- 11 张工艺执行书表:封面、试产、选剥茧(含混茧明细)、试缫与大样、煮茧(含分车间配温、副煮车间)、自动缫(含大样)、后缫
- 10 张样茧试缫表:原料小样/综合样主表 + 选茧、解舒、茧幅、切剖、清洁洁净、茧丝百回纤度
- 服务与接口、主控指针回写(列表 √ 标记)、封面编号自增、报表数据包
- 前端 8 张报表打印版式、7 类工艺单据与 6 类调查录入弹窗、字段驱动表单/表格组件
- 菜单种子新增「质量」目录,样茧子表增加 YjssType 判别列,避免原料小样与综合样自增 Id 重号串数据

报表中心:
- 报表模板管理、锐浪 grhtml5 加载与预览、全屏设计面板、数据源 Provider(落丝记录、职工花名册)

其他:同步工作进度 1-11 文档、开发数据库快照、.gitignore 忽略运行日志目录
This commit is contained in:
yanpeng200212
2026-10-22 10:30:37 +08:00
parent 8c4c464362
commit a8890c81e9
92 changed files with 14785 additions and 70 deletions
@@ -0,0 +1,41 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos.SYS.Reports;
using F9WebAiTest.Domain.Entities.SYS.Reports;
namespace F9WebAiTest.Application.Services.Reports;
public interface IReportService
{
Task<PageResult<ReportListDto>> GetPagedAsync(string like, string category, bool? isActive, int pageIndex, int pageSize);
Task<ReportDetailDto> GetByIdAsync(long id);
Task<long> CreateAsync(ReportCreateDto dto);
Task UpdateAsync(long id, ReportUpdateDto dto);
Task DeleteAsync(long id);
/// <summary>上传 .grf 模板文件(f9designer:// 本机设计器协议回传用);常规保存走 Create/Update 的 templateJson</summary>
Task UploadTemplateAsync(long id, byte[] data, string fileName);
/// <summary>直接保存模板文本(前端在线编辑模板源码用,免下载 / 上传),返回保存后的字符数</summary>
Task<int> SaveTemplateContentAsync(long id, string content);
/// <summary>读取报表模板内容(ReportFile.DesignContent,.grf 的 JSON 文本);无模板返回 null</summary>
Task<string?> GetTemplateContentAsync(Report report);
/// <summary>用户可见报表(按权限码过滤,admin 返回全部启用报表)</summary>
Task<List<ReportViewDto>> GetVisibleReportsAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions);
/// <summary>校验用户是否拥有某报表的查看权限(admin 视为通过)</summary>
Task EnsureReportAccessAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions, long reportId);
/// <summary>执行报表数据查询 SQL,返回 Grid++Report 约定的 JSON 形态</summary>
Task<Dictionary<string, object?>> ExecuteDataAsync(Report report, IReadOnlyDictionary<string, object?> parameters);
/// <summary>列出所有启用的报表(角色管理-报表权限分配时使用)</summary>
Task<List<ReportListDto>> GetAllActiveAsync();
/// <summary>数据源清单(IReportDataProvider 注册表 + 各自声明的查询参数),新增报表自动配置用</summary>
Task<List<ReportDataSourceInfoDto>> GetDataSourcesAsync();
/// <summary>探测数据源结构:按 DsName / DataSql 执行一次查询,识别明细列与参数</summary>
Task<ReportProbeResultDto> ProbeAsync(ReportProbeRequestDto request);
}
@@ -0,0 +1,47 @@
using F9WebAiTest.Application.CQRS.SYS.Reports.Queries;
using F9WebAiTest.Application.Dtos.SYS.Reports;
using F9WebAiTest.Application.Interfaces.Reports;
using MediatR;
namespace F9WebAiTest.Application.Services.Reports.Providers;
/// <summary>
/// 落丝记录报表数据提供者(生产-前缫)。
/// DsName = "RepDt_Prod_Lsjl"
/// 参数:q_date_start / q_date_end(日期范围)、q_dep(车间,含子车间)、q_jizu(机组)、
/// q_zk(庄口 Id,选择页取值)、q_like(关键字:庄口 / 规格 / 颜色)。
/// 参数与列完全由查询结果/报表配置决定,Provider 只负责取数。
/// </summary>
public class RepDt_Prod_Lsjl : IReportDataProvider
{
public string ReportName => "RepDt_Prod_Lsjl";
public async Task<ReportDto_DataSource> GetData(Dictionary<string, object> pairs, IMediator mediator)
{
DateTime? dateStart = null;
if (pairs.TryGetValue("q_date_start", out var ds) && ds != null && ds.ToString() is { Length: > 0 } s1
&& DateTime.TryParse(s1, out var d1))
dateStart = d1;
DateTime? dateEnd = null;
if (pairs.TryGetValue("q_date_end", out var de) && de != null && de.ToString() is { Length: > 0 } s2
&& DateTime.TryParse(s2, out var d2))
dateEnd = d2;
long depId = 0;
if (pairs.TryGetValue("q_dep", out var dep) && dep != null && long.TryParse(dep.ToString(), out var d))
depId = d;
int jizu = 0;
if (pairs.TryGetValue("q_jizu", out var jz) && jz != null && int.TryParse(jz.ToString(), out var j))
jizu = j;
long gyzkId = 0;
if (pairs.TryGetValue("q_zk", out var zk) && zk != null && long.TryParse(zk.ToString(), out var z))
gyzkId = z;
var like = pairs.TryGetValue("q_like", out var lk) && lk != null ? lk.ToString() ?? "" : "";
return await mediator.Send(new GetLsjlRepQuery(dateStart, dateEnd, depId, jizu, gyzkId, like));
}
}
@@ -0,0 +1,31 @@
using F9WebAiTest.Application.CQRS.SYS.Reports.Queries;
using F9WebAiTest.Application.Dtos.SYS.Reports;
using F9WebAiTest.Application.Interfaces.Reports;
using MediatR;
namespace F9WebAiTest.Application.Services.Reports.Providers;
/// <summary>
/// 职工花名册数据提供者(对应 F9 的 IReportService 实现):
/// 解析查询参数 → 通过 MediatR 发送 CQRS 查询 → 返回含记录集与报表参数的数据源。
/// DsName = "RepDt_Sys_Users"
/// </summary>
public class RepDt_Sys_Users : IReportDataProvider
{
public string ReportName => "RepDt_Sys_Users";
public async Task<ReportDto_DataSource> GetData(Dictionary<string, object> pairs, IMediator mediator)
{
// 1. 解析输入参数(部门 q_dep、关键字 q_like)
long depId = 0;
if (pairs.TryGetValue("q_dep", out var depObj) && depObj != null && long.TryParse(depObj.ToString(), out var d))
depId = d;
string like = "";
if (pairs.TryGetValue("q_like", out var likeObj) && likeObj != null)
like = likeObj.ToString() ?? "";
// 2. 发送 CQRS 查询获取报表数据
return await mediator.Send(new GetEmployeeDirectoryRepQuery(depId, like));
}
}
@@ -0,0 +1,652 @@
using System.Collections.Concurrent;
using System.Data;
using System.Data.Common;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using FreeSql;
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Common.Exceptions;
using F9WebAiTest.Application.Dtos.SYS.Reports;
using F9WebAiTest.Application.Interfaces.Reports;
using F9WebAiTest.Application.Interfaces.SnowflakeID;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities.SYS.RBAC;
using F9WebAiTest.Domain.Entities.SYS.Reports;
using F9WebAiTest.Domain.Enums.SYS.RBAC;
using MediatR;
using System.Data.SQLite;
namespace F9WebAiTest.Application.Services.Reports;
public class ReportService : IReportService
{
private readonly IRepository<Report> _reportRepo;
private readonly IRepository<ReportFile> _reportFileRepo;
private readonly IRepository<Permission> _permissionRepo;
private readonly IRepository<RolePermission> _rolePermissionRepo;
private readonly IFreeSql _fsql;
private readonly ISnowflakeIdGenerator _snowflake;
private readonly IMediator _mediator;
private readonly Dictionary<string, IReportDataProvider> _dataProviders;
public ReportService(
IRepository<Report> reportRepo,
IRepository<ReportFile> reportFileRepo,
IRepository<Permission> permissionRepo,
IRepository<RolePermission> rolePermissionRepo,
IFreeSql fsql,
ISnowflakeIdGenerator snowflake,
IMediator mediator,
IEnumerable<IReportDataProvider> dataProviders)
{
_reportRepo = reportRepo;
_reportFileRepo = reportFileRepo;
_permissionRepo = permissionRepo;
_rolePermissionRepo = rolePermissionRepo;
_fsql = fsql;
_snowflake = snowflake;
_mediator = mediator;
_dataProviders = dataProviders.ToDictionary(p => p.ReportName, StringComparer.OrdinalIgnoreCase);
}
// ===== 报表管理 =====
public async Task<PageResult<ReportListDto>> GetPagedAsync(string like, string category, bool? isActive, int pageIndex, int pageSize)
{
var query = _fsql.Select<Report>().Where(r => !r.IsDeleted);
if (!string.IsNullOrWhiteSpace(like))
query = query.Where(r => r.Name.Contains(like) || r.Code.Contains(like));
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(r => r.Category == category);
if (isActive.HasValue)
query = query.Where(r => r.IsActive == isActive.Value);
var total = await query.CountAsync();
var rows = await query
.OrderBy(r => r.SortOrder)
.OrderByDescending(r => r.CreatedAt)
.Page(pageIndex, pageSize)
.ToListAsync();
var items = rows.Select(r => new ReportListDto(
r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, r.IsActive,
r.ReportFileId, HasTemplate(r), r.CreatedAt, r.UpdatedAt)).ToList();
return new PageResult<ReportListDto> { Items = items, Total = total, PageIndex = pageIndex, PageSize = pageSize };
}
public async Task<ReportDetailDto> GetByIdAsync(long id)
{
var r = await _reportRepo.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted)
?? throw new F9NotFoundException("报表不存在");
return ToDetail(r);
}
public async Task<long> CreateAsync(ReportCreateDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Code)) throw new F9OperationException("报表编码不能为空");
if (string.IsNullOrWhiteSpace(dto.Name)) throw new F9OperationException("报表名称不能为空");
ValidateCode(dto.Code);
ValidateConfig(dto.Config);
// 有 DsName 时允许不填 SQL;否则必须为合法 SELECT
if (GetConfigDsName(dto.Config) == null) ValidateSql(dto.DataSql);
if (await _reportRepo.AnyAsync(r => r.Code == dto.Code && !r.IsDeleted))
throw new F9OperationException($"报表编码已存在:{dto.Code}");
var entity = new Report(_snowflake.CreateId(), dto.Code.Trim(), dto.Name.Trim(),
dto.Category ?? "通用", dto.Description ?? "", dto.DataSql ?? "",
dto.DataParams ?? "[]", dto.SortOrder, dto.Config ?? "");
// 模板与配置一起保存(模板实例写入 ReportFile,Report 持有 ReportFileId)
if (!string.IsNullOrWhiteSpace(dto.TemplateJson))
await SaveTemplateAsync(entity, dto.TemplateJson);
await _reportRepo.AddAsync(entity);
await _reportRepo.SaveChangesAsync();
// 同步创建权限单元:report:{code}:visble
var perm = new Permission(_snowflake.CreateId(), Permission_Type_Enum.Visble,
Resource_Type_Enum.报表, entity.Code, entity.Id);
perm.SetCode($"report:{entity.Code}:visble");
await _permissionRepo.AddAsync(perm);
await _permissionRepo.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(long id, ReportUpdateDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Code)) throw new F9OperationException("报表编码不能为空");
if (string.IsNullOrWhiteSpace(dto.Name)) throw new F9OperationException("报表名称不能为空");
ValidateCode(dto.Code);
ValidateConfig(dto.Config);
if (GetConfigDsName(dto.Config) == null) ValidateSql(dto.DataSql);
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
?? throw new F9NotFoundException("报表不存在");
if (await _reportRepo.AnyAsync(r => r.Code == dto.Code && r.Id != id && !r.IsDeleted))
throw new F9OperationException($"报表编码已存在:{dto.Code}");
var oldCode = entity.Code;
entity.UpdateBasic(dto.Code.Trim(), dto.Name.Trim(), dto.Category ?? "通用",
dto.Description ?? "", dto.DataSql ?? "", dto.DataParams ?? "[]", dto.SortOrder, dto.Config ?? "");
entity.Activate();
// 模板与配置一起保存;不传模板时保留原模板
if (!string.IsNullOrWhiteSpace(dto.TemplateJson))
await SaveTemplateAsync(entity, dto.TemplateJson);
await _reportRepo.UpdateAsync(entity);
await _reportRepo.SaveChangesAsync();
// 同步更新权限 Code(编码变更时)
if (!string.Equals(oldCode, entity.Code, StringComparison.Ordinal))
{
var perm = await _permissionRepo.FirstOrDefaultAsync(p =>
p.ResourceType == Resource_Type_Enum.报表 && p.ResourceId == entity.Id && !p.IsDeleted);
if (perm != null)
{
perm.SetCode($"report:{entity.Code}:visble");
await _permissionRepo.UpdateAsync(perm);
await _permissionRepo.SaveChangesAsync();
}
}
}
public async Task DeleteAsync(long id)
{
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
?? throw new F9NotFoundException("报表不存在");
await _reportRepo.DeleteAsync(entity);
await _reportRepo.SaveChangesAsync();
// 级联删除模板文件实例
var files = await _reportFileRepo.FindAsync(f => f.ReportId == id && !f.IsDeleted);
if (files.Count > 0)
{
await _reportFileRepo.DeleteRangeAsync(files);
await _reportFileRepo.SaveChangesAsync();
}
// 同步删除报表关联的权限单元与角色授权
var perms = await _permissionRepo.FindAsync(p => p.ResourceType == Resource_Type_Enum.报表 && p.ResourceId == id && !p.IsDeleted);
if (perms.Count > 0)
{
var permIds = perms.Select(p => p.Id).ToList();
await _rolePermissionRepo.HardDeleteRangeAsync(
(await _rolePermissionRepo.FindAsync(rp => permIds.Contains(rp.PermissionId) && !rp.IsDeleted)));
await _permissionRepo.HardDeleteRangeAsync(perms);
await _permissionRepo.SaveChangesAsync();
}
}
public async Task UploadTemplateAsync(long id, byte[] data, string fileName)
{
if (data == null || data.Length == 0) throw new F9OperationException("模板内容为空");
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
?? throw new F9NotFoundException("报表不存在");
// .grf 可能是 GR6 时代的 UTF-8 JSON,也可能是 GR7 的 "_WR" 压缩文本,按 BOM 自动识别编码
var text = DecodeTemplateBytes(data);
await SaveTemplateAsync(entity, text);
await _reportRepo.UpdateAsync(entity);
await _reportRepo.SaveChangesAsync();
}
/// <summary>
/// 直接保存模板文本(前端「模板源码」在线编辑后保存)。
/// JSON 格式模板做一次语法校验,避免保存后在设计器 / 渲染端打不开;
/// "_WR" 压缩模板无法在服务端校验,原样保存。
/// </summary>
public async Task<int> SaveTemplateContentAsync(long id, string content)
{
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
?? throw new F9NotFoundException("报表不存在");
var text = NormalizeTemplateContent(content);
var head = text.TrimStart();
if (head.StartsWith('{') || head.StartsWith('['))
{
try { using var _ = JsonDocument.Parse(text); }
catch (JsonException ex) { throw new F9OperationException($"模板 JSON 不合法:{ex.Message}"); }
}
await SaveTemplateAsync(entity, text);
await _reportRepo.UpdateAsync(entity);
await _reportRepo.SaveChangesAsync();
return text.Length;
}
public async Task<string?> GetTemplateContentAsync(Report report)
{
var file = await FindTemplateFileAsync(report);
return file?.DesignContent;
}
/// <summary>查找报表的模板文件实例(优先 ReportFileId,兼容按 ReportId 查找)</summary>
private async Task<ReportFile?> FindTemplateFileAsync(Report report)
{
if (report.ReportFileId != 0)
{
var byId = await _reportFileRepo.FirstOrDefaultAsync(f => f.Id == report.ReportFileId && !f.IsDeleted);
if (byId != null) return byId;
}
return await _reportFileRepo.FirstOrDefaultAsync(f => f.ReportId == report.Id && !f.IsDeleted);
}
/// <summary>
/// 保存模板:新建或更新 ReportFile.DesignContent,并把 Report.ReportFileId 绑定到该实例。
/// 模板与报表配置一起保存,前端无需再单独上传 .grf 文件。
/// 注意:只修改内存中的 report,不落库(由调用方统一保存)。
/// </summary>
private async Task SaveTemplateAsync(Report report, string templateContent)
{
var content = NormalizeTemplateContent(templateContent);
var file = await FindTemplateFileAsync(report);
if (file != null)
{
file.UpdateContent(content);
await _reportFileRepo.UpdateAsync(file);
await _reportFileRepo.SaveChangesAsync();
}
else
{
file = new ReportFile(_snowflake.CreateId(), report.Id, content);
await _reportFileRepo.AddAsync(file);
await _reportFileRepo.SaveChangesAsync();
}
if (report.ReportFileId != file.Id) report.BindReportFile(file.Id);
}
/// <summary>
/// 规范化模板内容:仅去 BOM / 去掉首尾空白,不做格式校验。
/// .grf 有两种格式:GR6 的 UTF-8 JSON 文本,以及 GR7 的 "_WR" 前缀压缩文本;
/// 两者都由 Grid++Report 的 HTML5 渲染库自行识别(内部 decodeWR),服务端原样保存即可。
/// </summary>
private static string NormalizeTemplateContent(string? content)
{
if (string.IsNullOrWhiteSpace(content)) throw new F9OperationException("报表模板内容为空");
var text = content.Trim();
if (text.Length > 0 && text[0] == '\uFEFF') text = text.Substring(1);
if (string.IsNullOrWhiteSpace(text)) throw new F9OperationException("报表模板内容为空");
return text;
}
/// <summary>按 BOM / 内容特征解码 .grf 字节:UTF-8(BOM) / UTF-16LE(BOM) / UTF-16BE(BOM) / 无 BOM 按 UTF-8</summary>
private static string DecodeTemplateBytes(byte[] data)
{
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
return Encoding.UTF8.GetString(data, 3, data.Length - 3);
if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0xFE)
return Encoding.Unicode.GetString(data, 2, data.Length - 2);
if (data.Length >= 2 && data[0] == 0xFE && data[1] == 0xFF)
return Encoding.BigEndianUnicode.GetString(data, 2, data.Length - 2);
// 无 BOM:先按 UTF-8 严格解码,失败则按 UTF-16LE 尝试
try
{
var strict = new UTF8Encoding(false, true);
return strict.GetString(data);
}
catch (DecoderFallbackException)
{
try { return Encoding.Unicode.GetString(data); }
catch { return Encoding.UTF8.GetString(data); }
}
}
// ===== 用户可见报表 =====
public async Task<List<ReportViewDto>> GetVisibleReportsAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions)
{
var all = await _reportRepo.FindAsync(r => r.IsActive && !r.IsDeleted);
IEnumerable<Report> visible;
if (isAdmin)
{
visible = all;
}
else
{
visible = all.Where(r => permissions.Contains($"report:{r.Code}:visble"));
}
return visible
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
.Select(r => new ReportViewDto(r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, HasTemplate(r)))
.ToList();
}
public async Task EnsureReportAccessAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions, long reportId)
{
if (isAdmin) return;
var report = await _reportRepo.FirstOrDefaultAsync(r => r.Id == reportId && !r.IsDeleted)
?? throw new F9NotFoundException("报表不存在", 404);
if (!report.IsActive) throw new F9NotFoundException("报表已停用", 404);
if (!permissions.Contains($"report:{report.Code}:visble"))
throw new F9OperationException("无权访问该报表");
}
// ===== 数据查询 =====
public async Task<Dictionary<string, object?>> ExecuteDataAsync(Report report, IReadOnlyDictionary<string, object?> parameters)
{
// 1. 优先按 Config.DsName 解析到数据提供者(F9 IReportService 模式)
var dsName = GetConfigDsName(report.Config);
if (!string.IsNullOrWhiteSpace(dsName))
{
if (!_dataProviders.TryGetValue(dsName, out var provider))
throw new F9OperationException($"未找到数据源查询实现:{dsName}");
var pairs = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var kv in parameters) pairs[kv.Key] = kv.Value!;
var dataSource = await provider.GetData(pairs, _mediator);
return ToGridReportJson(dataSource);
}
// 2. 回退:DataSql + @参数名(仅绑定 SQL 中实际出现的参数,防注入)
if (string.IsNullOrWhiteSpace(report.DataSql))
throw new F9OperationException("报表未配置数据源(Config.DsName 或 DataSql)");
try
{
var sqlTokens = Regex.Matches(report.DataSql, @"@([A-Za-z_][A-Za-z0-9_]*)")
.Select(m => m.Groups[1].Value).Distinct().ToHashSet(StringComparer.Ordinal);
var dbParams = new List<DbParameter>();
foreach (var kv in parameters)
{
if (kv.Key.Length > 0 && sqlTokens.Contains(kv.Key) && kv.Value != null)
dbParams.Add(new SQLiteParameter("@" + kv.Key, kv.Value));
}
var dt = dbParams.Count > 0
? _fsql.Ado.ExecuteDataTable(report.DataSql, dbParams.ToArray())
: _fsql.Ado.ExecuteDataTable(report.DataSql);
var recordset = new List<Dictionary<string, object?>>();
foreach (DataRow row in dt.Rows)
{
var d = new Dictionary<string, object?>(dt.Columns.Count, StringComparer.Ordinal);
foreach (DataColumn col in dt.Columns)
{
var v = row[col];
d[col.ColumnName] = v == DBNull.Value ? null : v;
}
recordset.Add(d);
}
return new Dictionary<string, object?> { ["recordset"] = recordset };
}
catch (F9OperationException) { throw; }
catch (Exception ex)
{
throw new F9OperationException("报表数据查询失败:" + ex.Message);
}
}
/// <summary>
/// 将 F9 的 ReportDto_DataSource 转换为 Grid++Report 约定的 JSON 数据:
/// 单数据集 → { "recordset": [ {...} ] }(兼容无名记录集的模板)
/// 多数据集 → { "main": [ {...} ], "detail": [ {...} ] }(顶层键为数据集名)
/// 报表参数 → 附加 { "parameter": [ { 账期: "...", 车间: "..." } ] }(模板以 Parameter 方式引用)
/// </summary>
private static Dictionary<string, object?> ToGridReportJson(ReportDto_DataSource dataSource)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
var datasets = dataSource.Report_Data
.Where(kv => (kv.Value.Field_Data?.Count ?? 0) > 0)
.ToList();
if (datasets.Count <= 1)
{
// 0 行时也必须输出空 recordset:否则渲染库找不到主记录集,
// 报表参数(Parameter)会被错位渲染到明细区(实测 0 行报表参数值错乱)
result["recordset"] = datasets.Count == 0
? new List<object>()
: datasets[0].Value.Field_Data.Select(ToNameValueDict).Cast<object>().ToList();
}
else
{
foreach (var kv in datasets)
result[kv.Key] = kv.Value.Field_Data.Select(ToNameValueDict).Cast<object>().ToList();
}
// 报表参数:以 Parameter 数据集输出(键名须与 .grf 模板的 Parameter 节点一致,HTML5 渲染器取数组第一项)
var paramData = dataSource.Report_Data.Values
.FirstOrDefault(d => d.Parameter_Data != null)?.Parameter_Data;
if (paramData != null)
result["Parameter"] = new List<object> { ToNameValueDict(paramData) };
return result;
}
/// <summary>
/// 对象 → 字段名/值的字典。
/// 用字典承载是为了保证属性名原样输出(不受 ASP.NET Core 默认 camelCase 序列化策略影响),
/// 从而与 .grf 模板 DetailGrid.Recordset.Field 定义的字段名严格一致。
/// </summary>
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _rowPropsCache = new();
private static Dictionary<string, object?> ToNameValueDict(object row)
{
if (row is IDictionary<string, object> exists) return exists.ToDictionary(k => k.Key, v => (object?)v.Value);
var props = _rowPropsCache.GetOrAdd(row.GetType(), t => t.GetProperties());
var dict = new Dictionary<string, object?>(props.Length, StringComparer.Ordinal);
foreach (var p in props)
{
if (!p.CanRead) continue;
dict[p.Name] = p.GetValue(row);
}
return dict!;
}
private static string? GetConfigDsName(string configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return null;
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.TryGetProperty("DsName", out var n) && n.ValueKind == JsonValueKind.String)
return n.GetString();
}
catch { /* 配置 JSON 非法时忽略 */ }
return null;
}
public async Task<List<ReportListDto>> GetAllActiveAsync()
{
var list = await _reportRepo.FindAsync(r => r.IsActive && !r.IsDeleted);
return list
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
.Select(r => new ReportListDto(
r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, r.IsActive,
r.ReportFileId, HasTemplate(r), r.CreatedAt, r.UpdatedAt))
.ToList();
}
// ===== 数据源清单 / 结构探测(新增报表时按查询结果自动识别明细列) =====
public Task<List<ReportDataSourceInfoDto>> GetDataSourcesAsync()
{
var list = _dataProviders.Values
.OrderBy(p => p.ReportName, StringComparer.OrdinalIgnoreCase)
.Select(p => new ReportDataSourceInfoDto { DsName = p.ReportName })
.ToList();
return Task.FromResult(list);
}
public async Task<ReportProbeResultDto> ProbeAsync(ReportProbeRequestDto request)
{
var dsName = (request.DsName ?? "").Trim();
var result = new ReportProbeResultDto { DsName = dsName };
_dataProviders.TryGetValue(dsName, out var provider);
// 探测参数只用前端带入的取值(参数本身由用户在报表配置里维护,不在这里生成控件)
var pairs = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var kv in request.Params)
if (kv.Value != null) pairs[kv.Key] = kv.Value;
try
{
if (provider != null)
await FillFromProviderAsync(result, provider, pairs);
else if (!string.IsNullOrWhiteSpace(request.DataSql))
FillFromSql(result, request.DataSql!, pairs);
else if (!string.IsNullOrWhiteSpace(dsName))
result.Error = $"未找到数据源实现:{dsName}";
else
result.Error = "请先选择数据源(DsName)或填写数据 SQL";
}
catch (F9OperationException ex) { result.Error = ex.Message; }
catch (Exception ex) { result.Error = "数据源探测失败:" + ex.Message; }
return result;
}
/// <summary>通过 IReportDataProvider 执行一次查询,取返回记录集的字段名作为明细列</summary>
private async Task FillFromProviderAsync(ReportProbeResultDto result, IReportDataProvider provider, Dictionary<string, object> pairs)
{
var dataSource = await provider.GetData(pairs, _mediator);
// 报表参数:来自 Provider 返回的 Parameter_Data(不是查询条件,也不是硬编码声明)
var paramDatum = dataSource.Report_Data.FirstOrDefault(kv => kv.Value?.Parameter_Data != null);
if (paramDatum.Value?.Parameter_Data != null)
{
result.Parameters = ToNameValueDict(paramDatum.Value.Parameter_Data)
.Select(kv => new ReportParamValueDto
{
Name = kv.Key,
Value = kv.Value?.ToString() ?? "",
})
.ToList();
}
var datums = dataSource.Report_Data
.Where(kv => kv.Value?.Field_Data is { Count: > 0 })
.ToList();
result.Total = datums.Sum(kv => kv.Value.Field_Data.Count);
if (datums.Count == 0)
{
result.Error = "查询未返回数据,无法识别明细列(可手工添加列)";
return;
}
var rows = datums[0].Value.Field_Data.Take(3).Select(ToNameValueDict).ToList();
result.Rows = rows;
result.Fields = rows[0].Select(kv => new ReportFieldDefDto
{
Name = kv.Key,
Label = kv.Key,
DataType = InferDataType(kv.Value),
}).ToList();
}
/// <summary>直接用 DataSql 执行一次查询(带 @参数),取 DataTable 列作为明细列</summary>
private void FillFromSql(ReportProbeResultDto result, string sql, Dictionary<string, object> pairs)
{
ValidateSql(sql);
var sqlTokens = Regex.Matches(sql, @"@([A-Za-z_][A-Za-z0-9_]*)")
.Select(m => m.Groups[1].Value).Distinct().ToHashSet(StringComparer.Ordinal);
var dbParams = new List<DbParameter>();
foreach (var kv in pairs)
if (sqlTokens.Contains(kv.Key) && kv.Value != null)
dbParams.Add(new SQLiteParameter("@" + kv.Key, kv.Value));
var dt = dbParams.Count > 0
? _fsql.Ado.ExecuteDataTable(sql, dbParams.ToArray())
: _fsql.Ado.ExecuteDataTable(sql);
result.Fields = dt.Columns.Cast<DataColumn>().Select(c => new ReportFieldDefDto
{
Name = c.ColumnName,
Label = c.ColumnName,
DataType = c.DataType.Name,
}).ToList();
result.Total = dt.Rows.Count;
result.Rows = dt.Rows.Cast<DataRow>().Take(3).Select(row =>
{
var d = new Dictionary<string, object?>(dt.Columns.Count, StringComparer.Ordinal);
foreach (DataColumn col in dt.Columns)
{
var v = row[col];
d[col.ColumnName] = v == DBNull.Value ? null : v;
}
return d;
}).ToList();
}
private static string InferDataType(object? value)
{
if (value == null) return "String";
if (value is JsonElement je)
{
return je.ValueKind switch
{
JsonValueKind.Number => "Number",
JsonValueKind.True or JsonValueKind.False => "Boolean",
_ => "String",
};
}
return value switch
{
int or long or short or byte or uint or ulong or ushort or sbyte => "Integer",
decimal or double or float => "Number",
DateTime or DateTimeOffset => "DateTime",
bool => "Boolean",
_ => "String",
};
}
// ===== 工具 =====
private static bool HasTemplate(Report r) => r.ReportFileId != 0;
private static ReportDetailDto ToDetail(Report r) => new(
r.Id, r.Code, r.Name, r.Category, r.Description, r.DataSql, r.DataParams, r.Config,
r.SortOrder, r.IsActive, r.ReportFileId, HasTemplate(r),
r.CreatedAt, r.UpdatedAt);
private static void ValidateCode(string code)
{
if (!Regex.IsMatch(code ?? "", "^[a-zA-Z0-9_]{2,40}$"))
throw new F9OperationException("报表编码仅允许字母/数字/下划线,长度 2-40");
}
private static void ValidateSql(string sql)
{
if (string.IsNullOrWhiteSpace(sql)) throw new F9OperationException("数据集 SQL 不能为空");
var trimmed = sql.Trim();
if (!trimmed.StartsWith("select", StringComparison.OrdinalIgnoreCase))
throw new F9OperationException("数据集 SQL 必须以 SELECT 开头(仅允许查询语句)");
// 禁止分号后跟其他语句
if (trimmed.IndexOf(';') >= 0 && trimmed.IndexOf(';') < trimmed.Length - 1)
throw new F9OperationException("数据集 SQL 不允许包含多条语句");
}
private static void ValidateConfig(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return;
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
throw new F9OperationException("查询配置(Config)必须是 JSON 对象");
}
catch (JsonException)
{
throw new F9OperationException("查询配置(Config)不是合法 JSON");
}
catch (F9OperationException) { throw; }
}
}