Skip to content

Commit 47f724a

Browse files
pythonHuangclaude
andcommitted
feat: 报表智能助手-根据需求生成报表(数据集优先复用已有数据视图/数据源/流程/接口,自动设计查询参数,排版含公式与聚合占位符),生成后展示数据集/参数清单与报表HTML预览,确认后才创建报表,菜单模型助手新增报表智能助手
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 2ba7c48 commit 47f724a

6 files changed

Lines changed: 444 additions & 1 deletion

File tree

‎Juggle.Api/Controllers/Api/AiController.cs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,21 @@ public async Task<ApiResult> RunAssistant([FromBody] AiAssistantRunRequest req)
276276
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
277277
}
278278

279+
// ==================== 报表智能生成 ====================
280+
281+
/// <summary>根据需求生成报表(数据集/查询参数/排版),供预览确认后入库</summary>
282+
[HttpPost("generate-report")]
283+
public async Task<ApiResult> GenerateReport([FromBody] AiGenerateReportRequest req)
284+
{
285+
try
286+
{
287+
var result = await _aiService.GenerateReportAsync(req.Requirement, req.ReportName,
288+
req.DataViews, req.DataSources, req.Flows, req.Apis, req.ProviderId, req.Model);
289+
return ApiResult.Success(result);
290+
}
291+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
292+
}
293+
279294
// ==================== 多轮对话 ====================
280295

281296
/// <summary>开启新对话(快照助手配置与输入参数)</summary>
@@ -466,3 +481,15 @@ public class AiConversationEndRequest
466481
public long ProviderId { get; set; }
467482
public string? Model { get; set; }
468483
}
484+
485+
public class AiGenerateReportRequest
486+
{
487+
public string Requirement { get; set; } = "";
488+
public string? ReportName { get; set; }
489+
public long ProviderId { get; set; }
490+
public string? Model { get; set; }
491+
public List<Dictionary<string, object?>>? DataViews { get; set; }
492+
public List<Dictionary<string, object?>>? DataSources { get; set; }
493+
public List<Dictionary<string, object?>>? Flows { get; set; }
494+
public List<Dictionary<string, object?>>? Apis { get; set; }
495+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ public async Task<ApiResult> Preview([FromBody] ReportPreviewRequest req)
8080
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
8181
}
8282

83+
/// <summary>按 layoutJson 直接渲染预览(AI 报表助手生成预览用,无需先保存)</summary>
84+
[HttpPost("preview-layout")]
85+
public async Task<ApiResult> PreviewLayout([FromBody] ReportPreviewLayoutRequest req)
86+
{
87+
try
88+
{
89+
var html = await _reportExec.RenderToHtml(req.LayoutJson ?? "{}", req.Params);
90+
return ApiResult.Success(new { html });
91+
}
92+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
93+
}
94+
8395
/// <summary>报表详情(正式访问页用:名称/参数配置等)</summary>
8496
[HttpGet("info/{id}")]
8597
public async Task<ApiResult> Info(long id)
@@ -146,3 +158,9 @@ public class ReportDatasetOptionsRequest
146158
public long Id { get; set; }
147159
public string? DatasetId { get; set; }
148160
}
161+
162+
public class ReportPreviewLayoutRequest
163+
{
164+
public string? LayoutJson { get; set; }
165+
public Dictionary<string, object?>? Params { get; set; }
166+
}

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,195 @@ private async Task<int> SaveParamsAsync(long ownerId, string ownerCode, int para
450450
private static string GetStr(JsonElement el, string prop)
451451
=> el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
452452

453+
// ==================== 报表智能生成 ====================
454+
455+
/// <summary>
456+
/// 根据需求生成报表(数据集 + 查询参数 + 排版),返回与报表设计器一致的 layoutJson 结构。
457+
/// </summary>
458+
public async Task<object> GenerateReportAsync(string requirement, string? reportName,
459+
List<Dictionary<string, object?>>? dataViews, List<Dictionary<string, object?>>? dataSources,
460+
List<Dictionary<string, object?>>? flows, List<Dictionary<string, object?>>? apis,
461+
long providerId = 0, string? model = null)
462+
{
463+
if (string.IsNullOrWhiteSpace(requirement))
464+
throw new Exception("请先描述报表需求");
465+
466+
var systemPrompt = """
467+
你是报表设计助手。根据用户需求生成报表定义 JSON(与报表设计器 layoutJson 结构一致)。
468+
只输出 JSON(不要 markdown 围栏、不要解释),结构如下:
469+
{
470+
"page": { "size": "A4", "orientation": "portrait", "margin": {}, "header": "", "footer": "", "bgImage": "" },
471+
"params": [ // 查询参数(用户可筛选,与数据集 SQL 中 @参数名 对应)
472+
{ "name": "beginDate", "label": "开始日期", "type": "date", "default": "" },
473+
{ "name": "status", "label": "状态", "type": "select", "options": "paid,pending", "default": "" }
474+
],
475+
"datasets": [ // 数据集(尽量复用已有数据视图)
476+
{
477+
"id": "ds1",
478+
"name": "订单数据",
479+
"sourceType": "dataview", // dataview=复用数据视图 / sql=自定义SQL / flow=流程 / api=接口
480+
"sourceRef": "12", // dataview=视图id;flow=flowKey;api=methodCode
481+
"customSql": "", // sql 类型时填 SQL(用 @参数名 引用查询参数,参数为空时不过滤)
482+
"dataSourceId": 3, // sql 类型时填数据源 id(必须来自可用数据源清单)
483+
"fields": []
484+
}
485+
],
486+
"rows": [
487+
{ "height": 35, "type": "title", "dataset": "", "expand": "" },
488+
{ "height": 28, "type": "header", "dataset": "", "expand": "" },
489+
{ "height": 25, "type": "data", "dataset": "ds1", "expand": "auto", "zebra": true },
490+
{ "height": 25, "type": "footer", "dataset": "ds1", "expand": "" }
491+
],
492+
"cols": [ { "width": 60 }, { "width": 150 }, { "width": 100 } ],
493+
"cells": [
494+
{ "r": 0, "c": 0, "value": "销售报表", "colspan": 3, "style": { "bold": true, "align": "center", "fontSize": 16 } },
495+
{ "r": 1, "c": 0, "value": "编号", "style": { "bold": true } },
496+
{ "r": 1, "c": 1, "value": "名称" },
497+
{ "r": 1, "c": 2, "value": "金额" },
498+
{ "r": 2, "c": 0, "value": "${rowIndex}" },
499+
{ "r": 2, "c": 1, "value": "${ds1.name}" }, // 数据行占位符:${字段} 或 ${数据集.字段}
500+
{ "r": 2, "c": 2, "value": "${amount}" },
501+
{ "r": 3, "c": 0, "value": "合计", "colspan": 2 },
502+
{ "r": 3, "c": 2, "value": "${amount:SUM}", "style": { "bold": true } }
503+
]
504+
}
505+
506+
规则:
507+
1. 数据集优先复用「可用数据视图」:sourceType=dataview、sourceRef=视图 id,不得编造;视图不满足时用 sourceType=sql + 可用数据源 id 写自定义 SQL(注意数据源方言:sqlite/mysql/postgresql/sqlserver);需要流程/接口数据时用 flow/api 类型并复用清单中的 flowKey/methodCode。
508+
2. 数据集 SQL 中需要筛选的字段用 @参数名 引用 params 中的查询参数(如 WHERE status = @status),参数类型取 text/number/date/select(select 配 options 逗号分隔)。
509+
3. 行结构固定:第 1 行 title(合并全部列)、第 2 行 header(列名)、数据行 type=data + dataset + expand=auto + zebra=true(可用多行)、最后 footer 行。
510+
4. 数据行单元格用 ${字段名} 或 ${数据集.字段} 占位符;序号列用 ${rowIndex}。
511+
5. 汇总/合计用聚合占位符:${金额:SUM}、${字段:AVG}、${字段:COUNT}、MIN/MAX;需要条件计算时用公式 =IF(条件, 真, 假)(如 =IF(${amount} > 100, '大额', '普通'))。
512+
6. cols 宽度按内容合理分配(序号 60、文本 150、数字 100 左右)。
513+
7. 标题行居中加粗 16 号,表头加粗;列名用中文。
514+
8. 只输出 JSON 本体。
515+
""";
516+
517+
var userPrompt = $"""
518+
## 报表需求
519+
{requirement.Trim()}
520+
521+
## 可用数据视图(dataview)
522+
{BuildSimpleList(dataViews, "id", "name", "groupName", "sql")}
523+
524+
## 可用数据源(自定义SQL用)
525+
{BuildSimpleList(dataSources, "id", "dataSourceName", "dataSourceType", null)}
526+
527+
## 已发布流程(flow)
528+
{BuildSimpleList(flows, "flowKey", "flowName", null, null)}
529+
530+
## 可用接口(api)
531+
{BuildSimpleList(apis, "methodCode", "methodName", "suiteCode", "url")}
532+
533+
请生成报表 JSON。
534+
""";
535+
536+
var content = await ChatAsync(systemPrompt, userPrompt, providerId, modelOverride: model);
537+
return ExtractAndValidateReport(content, reportName);
538+
}
539+
540+
/// <summary>提取报表查询参数(name/label/type/options/default)。</summary>
541+
private static List<Dictionary<string, object?>> ExtractReportParams(JsonElement root)
542+
{
543+
var list = new List<Dictionary<string, object?>>();
544+
if (!root.TryGetProperty("params", out var arr) || arr.ValueKind != JsonValueKind.Array) return list;
545+
foreach (var p in arr.EnumerateArray())
546+
{
547+
if (p.ValueKind != JsonValueKind.Object) continue;
548+
var name = GetStr(p, "name");
549+
if (string.IsNullOrEmpty(name)) continue;
550+
list.Add(new Dictionary<string, object?>
551+
{
552+
["name"] = name,
553+
["label"] = GetStr(p, "label") is { Length: > 0 } l ? l : name,
554+
["type"] = GetStr(p, "type") is { Length: > 0 } t ? t : "text",
555+
["options"] = GetStr(p, "options"),
556+
["default"] = GetStr(p, "default")
557+
});
558+
}
559+
return list;
560+
}
561+
562+
private static string BuildSimpleList(List<Dictionary<string, object?>>? items, string code, string name, string? extra, string? extra2)
563+
{
564+
if (items == null || items.Count == 0) return "(无)";
565+
var sb = new StringBuilder();
566+
foreach (var it in items)
567+
{
568+
sb.Append($"- {code}={it.GetValueOrDefault(code)} {name}={it.GetValueOrDefault(name)}");
569+
if (extra != null) sb.Append($" {extra}={it.GetValueOrDefault(extra)}");
570+
if (extra2 != null)
571+
{
572+
var v = it.GetValueOrDefault(extra2)?.ToString();
573+
if (!string.IsNullOrEmpty(v) && v.Length > 60) v = v[..60] + "...";
574+
sb.Append($" {extra2}={v}");
575+
}
576+
sb.AppendLine();
577+
}
578+
return sb.ToString();
579+
}
580+
581+
/// <summary>报表 JSON 校验归一化:结构完整 + 数据行展开标记补全。</summary>
582+
private static object ExtractAndValidateReport(string content, string? reportName)
583+
{
584+
var json = ExtractJson(content);
585+
using var doc = JsonDocument.Parse(json);
586+
var root = doc.RootElement;
587+
var layout = new Dictionary<string, object?>();
588+
589+
// page
590+
if (root.TryGetProperty("page", out var pg) && pg.ValueKind == JsonValueKind.Object)
591+
layout["page"] = CloneToObject(pg);
592+
else
593+
layout["page"] = new Dictionary<string, object?> { ["size"] = "A4", ["orientation"] = "portrait" };
594+
595+
// params(报表查询参数:name/label/type/options/default)
596+
layout["params"] = ExtractReportParams(root);
597+
var datasets = new List<object?>();
598+
if (root.TryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
599+
datasets.AddRange(dsArr.EnumerateArray().Select(CloneToObject));
600+
layout["datasets"] = datasets;
601+
602+
// rows:数据行绑定数据集时补 expand=auto
603+
var rows = new List<object?>();
604+
if (root.TryGetProperty("rows", out var rowsArr) && rowsArr.ValueKind == JsonValueKind.Array)
605+
{
606+
foreach (var r in rowsArr.EnumerateArray())
607+
{
608+
if (r.ValueKind != JsonValueKind.Object) continue;
609+
var row = r.EnumerateObject().ToDictionary(p => p.Name, p => CloneToObject(p.Value));
610+
var type = row.GetValueOrDefault("type")?.ToString() ?? "";
611+
if (type == "data" && !string.IsNullOrEmpty(row.GetValueOrDefault("dataset")?.ToString())
612+
&& string.IsNullOrEmpty(row.GetValueOrDefault("expand")?.ToString()))
613+
row["expand"] = "auto";
614+
rows.Add(row);
615+
}
616+
}
617+
layout["rows"] = rows;
618+
619+
// cols / cells
620+
layout["cols"] = root.TryGetProperty("cols", out var colsArr) && colsArr.ValueKind == JsonValueKind.Array
621+
? colsArr.EnumerateArray().Select(CloneToObject).ToList()
622+
: new List<object?>();
623+
layout["cells"] = root.TryGetProperty("cells", out var cellsArr) && cellsArr.ValueKind == JsonValueKind.Array
624+
? cellsArr.EnumerateArray().Select(CloneToObject).ToList()
625+
: new List<object?>();
626+
627+
if (rows.Count == 0 || ((List<object?>)layout["cols"]!).Count == 0)
628+
throw new Exception("AI 返回的报表缺少行/列定义,请调整需求重试");
629+
630+
var layoutJson = JsonSerializer.Serialize(layout);
631+
return new
632+
{
633+
reportName = reportName ?? "",
634+
layoutJson,
635+
paramsList = layout["params"],
636+
datasets = datasets,
637+
rows = rows.Count,
638+
cols = ((List<object?>)layout["cols"]!).Count
639+
};
640+
}
641+
453642
// ==================== 自定义助手 ====================
454643

455644
/// <summary>

‎JuggleNet6.Frontend/src/router/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ const routes = [
4444
{ path: 'ai/flow-assistant', component: () => import('../views/ai/FlowAssistant.vue') },
4545
{ path: 'ai/api-assistant', component: () => import('../views/ai/ApiAssistant.vue') },
4646
{ path: 'ai/assistants', component: () => import('../views/ai/AiAssistantList.vue') },
47-
{ path: 'ai/assistant/:id', component: () => import('../views/ai/AssistantRun.vue') }
47+
{ path: 'ai/assistant/:id', component: () => import('../views/ai/AssistantRun.vue') },
48+
{ path: 'ai/report-assistant', component: () => import('../views/ai/ReportAssistant.vue') }
4849
]
4950
},
5051
{

‎JuggleNet6.Frontend/src/views/Layout.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<el-menu-item index="/ai/assistants">模型助手管理</el-menu-item>
6363
<el-menu-item index="/ai/flow-assistant">流程智能编排助手</el-menu-item>
6464
<el-menu-item index="/ai/api-assistant">接口智能接入助手</el-menu-item>
65+
<el-menu-item index="/ai/report-assistant">报表智能助手</el-menu-item>
6566
<el-menu-item v-for="a in assistantMenuList" :key="a.id" :index="`/ai/assistant/${a.id}`">{{ a.assistantName }}</el-menu-item>
6667
</el-sub-menu>
6768
<el-sub-menu index="system" v-if="hasMenu('/system/token')">

0 commit comments

Comments
 (0)