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

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

其他:同步工作进度 1-11 文档、开发数据库快照、.gitignore 忽略运行日志目录
2026-10-22 10:30:37 +08:00

352 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Claims;
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Common.Exceptions;
using F9WebAiTest.Application.Dtos.SYS.Reports;
using F9WebAiTest.Application.Services.Auth;
using F9WebAiTest.Application.Services.Reports;
using F9WebAiTest.Domain.Entities.SYS.RBAC;
using F9WebAiTest.Domain.Enums.SYS.RBAC;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace F9WebAiTest.Api.Controllers;
/// <summary>报表中心:报表定义维护 + 模板下载 + 数据查询(Grid++Report 7 Web 嵌入)</summary>
[ApiController]
[Route("api/sys/report")]
[Authorize]
public class SysReportController : ControllerBase
{
private readonly IReportService _reportService;
private readonly IFreeSql _fsql;
private readonly IJwtService _jwt;
private readonly IConfiguration _config;
public SysReportController(IReportService reportService, IFreeSql fsql, IJwtService jwt, IConfiguration config)
{
_reportService = reportService;
_fsql = fsql;
_jwt = jwt;
_config = config;
}
// ==================== 报表管理 ====================
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] string like = "",
[FromQuery] string category = "",
[FromQuery] bool? isActive = null,
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50)
{
var data = await _reportService.GetPagedAsync(like ?? "", category ?? "", isActive, pageIndex, pageSize);
return Ok(ApiResult<object>.Ok(data));
}
[HttpGet("all")]
public async Task<IActionResult> GetAllActive()
=> Ok(ApiResult<object>.Ok(await _reportService.GetAllActiveAsync()));
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
try { return Ok(ApiResult<object>.Ok(await _reportService.GetByIdAsync(id))); }
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] ReportCreateDto dto)
{
try { return Ok(ApiResult<object>.Ok(new { id = await _reportService.CreateAsync(dto) }, "新增成功")); }
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(long id, [FromBody] ReportUpdateDto dto)
{
try { await _reportService.UpdateAsync(id, dto); return Ok(ApiResult<object>.Ok(new { id }, "修改成功")); }
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
try { await _reportService.DeleteAsync(id); return Ok(ApiResult<object>.Ok(new { id }, "删除成功")); }
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
}
/// <summary>上传 .grf 模板文件。
/// 常规编辑流程已改为「模板随报表一起保存」(POST/PUT 携带 templateJson),
/// 本接口保留给 f9designer:// 本机设计器协议回传使用。</summary>
[HttpPost("{id}/template")]
[RequestSizeLimit(50 * 1024 * 1024)]
public async Task<IActionResult> UploadTemplate(long id, IFormFile file)
{
if (file == null || file.Length == 0) return BadRequest(ApiResult<object>.Fail("请选择要上传的 .grf 模板文件", 400));
if (!file.FileName.EndsWith(".grf", StringComparison.OrdinalIgnoreCase))
return BadRequest(ApiResult<object>.Fail("仅支持上传 .grf 模板文件", 400));
try
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
await _reportService.UploadTemplateAsync(id, ms.ToArray(), file.FileName);
return Ok(ApiResult<object>.Ok(new { size = file.Length }, "上传成功"));
}
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
/// <summary>
/// 直接保存模板文本(前端在线编辑「模板源码」后保存,免下载 / 上传 .grf)。
/// 与上传接口的区别:这里提交的是文本(JSON 或 "_WR" 压缩文本),不是文件流。
/// </summary>
[HttpPut("{id}/template/content")]
[RequestSizeLimit(50 * 1024 * 1024)]
public async Task<IActionResult> SaveTemplateContent(long id, [FromBody] ReportTemplateContentDto dto)
{
try
{
var size = await _reportService.SaveTemplateContentAsync(id, dto?.Content ?? "");
return Ok(ApiResult<object>.Ok(new { id, size, hasTemplate = true }, "模板已保存"));
}
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
/// <summary>下载 .grf 模板文件。保留给 f9designer:// 本机设计器协议(下载 → 编辑 → 自动回传)使用。</summary>
[HttpGet("{id}/template/download")]
public async Task<IActionResult> DownloadTemplate(long id)
{
var r = await _fsql.Select<Domain.Entities.SYS.Reports.Report>().Where(x => x.Id == id && !x.IsDeleted).FirstAsync();
var content = r == null ? null : await _reportService.GetTemplateContentAsync(r);
if (string.IsNullOrEmpty(content))
return NotFound(ApiResult<object>.Fail("模板尚未上传", 404));
return File(ToGrfBytes(content), "application/octet-stream", $"{r!.Code}.grf");
}
// ==================== 新增报表辅助:数据源 + 结构探测 ====================
/// <summary>数据源清单(IReportDataProvider 注册表 + 各数据源声明的查询参数)</summary>
[HttpGet("datasources")]
public async Task<IActionResult> GetDataSources()
=> Ok(ApiResult<object>.Ok(await _reportService.GetDataSourcesAsync()));
/// <summary>
/// 探测数据源结构:按 DsName(或 DataSql)执行一次查询,
/// 返回可自动配置的查询参数 + 明细字段(列)+ 少量样本行。
/// </summary>
[HttpPost("scaffold")]
public async Task<IActionResult> Scaffold([FromBody] ReportProbeRequestDto dto)
{
var result = await _reportService.ProbeAsync(dto ?? new ReportProbeRequestDto());
return Ok(ApiResult<object>.Ok(result));
}
// ==================== 模板与数据公开端点(供 HTML5 渲染 / 客户端调用) ====================
// grhtml5.insertReportViewer 的模板参数是普通 GET,浏览器无法附加 Authorization header,
// 故模板/数据接口接受 ?access_token=xxx 方式鉴权(仅内部网络使用,与 /hubs 相同的处理思路)。
/// <summary>下载报表 .grf 模板(带权限校验,支持 query token 鉴权)。
/// 额外暴露 template.json / template.grf 别名:Grid++ 的 JS 库与客户端对 URL 中不含
/// ".grf/.txt/.xml/.json" 的请求会用 POST(取模板会误入上传接口)。</summary>
[HttpGet("{id}/template")]
[HttpGet("{id}/template.json")]
[HttpGet("{id}/template.grf")]
[AllowAnonymous]
public async Task<IActionResult> GetTemplate(long id, [FromQuery(Name = "access_token")] string? accessToken = null)
{
var (userId, isAdmin) = await ResolveCallerAsync(accessToken);
if (userId <= 0) return Unauthorized(new { code = 401, message = "未授权" });
var r = await _fsql.Select<Domain.Entities.SYS.Reports.Report>().Where(x => x.Id == id && !x.IsDeleted).FirstAsync();
var content = r == null ? null : await _reportService.GetTemplateContentAsync(r);
if (string.IsNullOrEmpty(content))
return NotFound(new { code = 404, message = "模板尚未上传" });
if (!await CanAccessReportAsync(userId, isAdmin, r!)) return ForbidReport();
// 不输出 BOM:HTML5 渲染库按 { 或 _WR 前缀判断模板格式,BOM 会破坏该判断
return File(System.Text.Encoding.UTF8.GetBytes(content!), "application/json; charset=utf-8", $"{r!.Code}.grf");
}
/// <summary>执行报表数据查询(GET/POST 均可)。data.json 为 GET 别名(客户端 URL 方法判定约定)</summary>
[HttpGet("{id}/data")]
[HttpGet("{id}/data.json")]
[AllowAnonymous]
public async Task<IActionResult> GetData(long id, [FromQuery] string? access_token = null, [FromQuery] string? params_ = null)
=> await RunDataAsync(id, access_token, params_);
[HttpPost("{id}/data")]
[AllowAnonymous]
public async Task<IActionResult> PostData(long id, [FromQuery(Name = "access_token")] string? accessToken = null)
{
string? paramsJson = null;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(body)) paramsJson = body;
}
catch { /* 忽略读取错误 */ }
return await RunDataAsync(id, accessToken, paramsJson);
}
private async Task<IActionResult> RunDataAsync(long id, string? accessToken, string? paramsJson)
{
var (userId, isAdmin) = await ResolveCallerAsync(accessToken);
if (userId <= 0) return Unauthorized(new { code = 401, message = "未授权" });
var r = await _fsql.Select<Domain.Entities.SYS.Reports.Report>().Where(x => x.Id == id && !x.IsDeleted).FirstAsync();
if (r == null) return NotFound(new { code = 404, message = "报表不存在" });
if (!await CanAccessReportAsync(userId, isAdmin, r)) return ForbidReport();
var p = ParseParams(paramsJson);
try
{
var data = await _reportService.ExecuteDataAsync(r, p);
return Ok(data);
}
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
private static System.Collections.Generic.Dictionary<string, object?> ParseParams(string? json)
{
var result = new System.Collections.Generic.Dictionary<string, object?>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(json)) return result;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
result[prop.Name] = System.Text.Json.JsonSerializer.Deserialize<object?>(prop.Value.GetRawText());
}
else if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
// 支持 [{name,value}] 形式(与 Grid++ WEB 客户端参数一致)
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.TryGetProperty("name", out var n) && el.TryGetProperty("value", out var v))
result[n.GetString() ?? ""] = System.Text.Json.JsonSerializer.Deserialize<object?>(v.GetRawText());
}
}
}
catch { /* 忽略解析错误,返回空 */ }
return result;
}
// ==================== 报表中心(用户视图) ====================
/// <summary>当前用户可见的报表(按权限过滤)</summary>
[HttpGet("visible")]
public async Task<IActionResult> GetVisible()
{
var userId = GetUserId();
var isAdmin = await IsAdminAsync(userId);
var perms = await GetPermissionsAsync(userId, isAdmin);
return Ok(ApiResult<object>.Ok(await _reportService.GetVisibleReportsAsync(userId, isAdmin, perms)));
}
/// <summary>当前用户预览某报表(带数据)</summary>
[HttpGet("{id}/preview")]
public async Task<IActionResult> Preview(long id, [FromQuery] string? params_ = null)
{
var userId = GetUserId();
var isAdmin = await IsAdminAsync(userId);
var perms = await GetPermissionsAsync(userId, isAdmin);
try
{
await _reportService.EnsureReportAccessAsync(userId, isAdmin, perms, id);
var r = await _fsql.Select<Domain.Entities.SYS.Reports.Report>().Where(x => x.Id == id && !x.IsDeleted).FirstAsync();
if (r == null) return NotFound(ApiResult<object>.Fail("报表不存在", 404));
if (r.ReportFileId == 0)
return BadRequest(ApiResult<object>.Fail("该报表尚未配置模板", 400));
var p = ParseParams(params_);
var data = await _reportService.ExecuteDataAsync(r, p);
return Ok(ApiResult<object>.Ok(new
{
report = new
{
r.Id,
r.Code,
r.Name,
r.Category,
r.Description,
config = r.Config,
hasTemplate = true
},
templateUrl = $"/api/sys/report/{r.Id}/template.json",
data
}));
}
catch (F9NotFoundException ex) { return NotFound(ApiResult<object>.Fail(ex.Message, 404)); }
catch (F9OperationException ex) { return BadRequest(ApiResult<object>.Fail(ex.Message, 400)); }
}
// ==================== 内部辅助 ====================
private long GetUserId() => long.Parse(User.FindFirst("id")!.Value);
private async Task<bool> IsAdminAsync(long userId)
{
var roleIds = await _fsql.Select<UserRole>().Where(ur => ur.UserId == userId).ToListAsync(x => x.RoleId);
if (roleIds.Count == 0) return false;
return await _fsql.Select<Role>().Where(r => roleIds.Contains(r.Id) && r.Code == "admin").AnyAsync();
}
private async Task<System.Collections.Generic.HashSet<string>> GetPermissionsAsync(long userId, bool isAdmin)
{
if (isAdmin)
return new System.Collections.Generic.HashSet<string>(
await _fsql.Select<Permission>().Where(p => !p.IsDeleted && !string.IsNullOrEmpty(p.Code)).ToListAsync(p => p.Code));
var roleIds = await _fsql.Select<UserRole>().Where(ur => ur.UserId == userId).ToListAsync(x => x.RoleId);
if (roleIds.Count == 0) return new();
var permIds = await _fsql.Select<RolePermission>().Where(rp => roleIds.Contains(rp.RoleId) && !rp.IsDeleted).ToListAsync(x => x.PermissionId);
if (permIds.Count == 0) return new();
var codes = await _fsql.Select<Permission>().Where(p => permIds.Contains(p.Id) && !p.IsDeleted && !string.IsNullOrEmpty(p.Code)).ToListAsync(p => p.Code);
return new System.Collections.Generic.HashSet<string>(codes);
}
/// <summary>用 query 中的 access_token 解析用户;优先尝试从 Authorization 头解析;解析失败返回 0。</summary>
private async Task<(long UserId, bool IsAdmin)> ResolveCallerAsync(string? accessToken)
{
// 优先用当前 HTTP 请求上下文(JWT 中间件已解析)
if (User?.Identity?.IsAuthenticated == true)
return (GetUserId(), await IsAdminAsync(GetUserId()));
if (string.IsNullOrEmpty(accessToken)) return (0, false);
try
{
var (uid, _) = _jwt.ValidateAccessToken(accessToken);
return (uid, await IsAdminAsync(uid));
}
catch { return (0, false); }
}
private async Task<bool> CanAccessReportAsync(long userId, bool isAdmin, Domain.Entities.SYS.Reports.Report r)
{
if (isAdmin) return true;
if (!r.IsActive) return false;
var perms = await GetPermissionsAsync(userId, isAdmin);
return perms.Contains($"report:{r.Code}:visble");
}
private IActionResult ForbidReport() => StatusCode(403, new { code = 403, message = "无权访问该报表" });
/// <summary>模板文本 → .grf 字节(带 UTF-8 BOM,与锐浪设计器保存格式一致)</summary>
private static byte[] ToGrfBytes(string content)
{
var text = content.Length > 0 && content[0] == '\uFEFF' ? content.Substring(1) : content;
var enc = new System.Text.UTF8Encoding(true);
var pre = enc.GetPreamble();
var body = enc.GetBytes(text);
var data = new byte[pre.Length + body.Length];
Buffer.BlockCopy(pre, 0, data, 0, pre.Length);
Buffer.BlockCopy(body, 0, data, pre.Length, body.Length);
return data;
}
}