Skip to content

Commit cd17207

Browse files
pythonHuangclaude
andcommitted
feat: 流程设计器新增文件解析/Excel读取/文件写入节点-文件解析(data URL/base64/原文,JSON对象/CSV行数组/文本),Excel读取(xlsx首行为表头返回行字典数组),文件写入(对象自动JSON序列化生成data URL),各节点附完整帮助弹窗
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 2c4e9c7 commit cd17207

5 files changed

Lines changed: 459 additions & 2 deletions

File tree

‎Juggle.Domain/Engine/FlowEngine.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ public async Task<FlowResult> ExecuteAsync(
154154
"TRANSFORM" => new TransformNodeExecutor(),
155155
"AI" => new AiNodeExecutor(
156156
_aiChatFunc ?? throw new InvalidOperationException("流程引擎未接入大模型,无法执行 AI 节点")),
157+
"FILE_PARSE" => new FileParseNodeExecutor(),
158+
"EXCEL_READ" => new ExcelReadNodeExecutor(),
159+
"FILE_WRITE" => new FileWriteNodeExecutor(),
157160
_ => throw new InvalidOperationException($"未知节点类型: {currentNode.ElementType}")
158161
};
159162

‎Juggle.Domain/Engine/FlowModels.cs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ public class FlowNode
3838
[JsonPropertyName("aiConfig")]
3939
public AiConfig? AiConfig { get; set; }
4040

41+
[JsonPropertyName("fileParseConfig")]
42+
public FileParseConfig? FileParseConfig { get; set; }
43+
44+
[JsonPropertyName("excelReadConfig")]
45+
public ExcelReadConfig? ExcelReadConfig { get; set; }
46+
47+
[JsonPropertyName("fileWriteConfig")]
48+
public FileWriteConfig? FileWriteConfig { get; set; }
49+
4150
[JsonPropertyName("subFlowConfig")]
4251
public SubFlowConfig? SubFlowConfig { get; set; }
4352

@@ -265,6 +274,58 @@ public class AiConfig
265274
public string Output { get; set; } = "";
266275
}
267276

277+
/// <summary>文件解析节点配置(FILE_PARSE 节点)</summary>
278+
public class FileParseConfig
279+
{
280+
/// <summary>输入变量(文件内容:data URL / base64 / 原文)</summary>
281+
[JsonPropertyName("input")]
282+
public string Input { get; set; } = "";
283+
284+
/// <summary>文件类型:auto / text / json / xml / csv</summary>
285+
[JsonPropertyName("fileType")]
286+
public string FileType { get; set; } = "auto";
287+
288+
/// <summary>输出变量(解析结果:JSON 为对象/数组、CSV 为行数组、文本为字符串)</summary>
289+
[JsonPropertyName("output")]
290+
public string Output { get; set; } = "";
291+
}
292+
293+
/// <summary>Excel 读取节点配置(EXCEL_READ 节点)</summary>
294+
public class ExcelReadConfig
295+
{
296+
/// <summary>输入变量(Excel 文件内容:base64 / data URL)</summary>
297+
[JsonPropertyName("input")]
298+
public string Input { get; set; } = "";
299+
300+
/// <summary>工作表名(空=第一个工作表)</summary>
301+
[JsonPropertyName("sheetName")]
302+
public string SheetName { get; set; } = "";
303+
304+
/// <summary>输出变量(行字典数组,首行为表头)</summary>
305+
[JsonPropertyName("output")]
306+
public string Output { get; set; } = "";
307+
}
308+
309+
/// <summary>文件写入节点配置(FILE_WRITE 节点)</summary>
310+
public class FileWriteConfig
311+
{
312+
/// <summary>内容变量(对象自动序列化为 JSON)</summary>
313+
[JsonPropertyName("content")]
314+
public string Content { get; set; } = "";
315+
316+
/// <summary>文件名(可选,同样写入 data URL)</summary>
317+
[JsonPropertyName("fileName")]
318+
public string FileName { get; set; } = "";
319+
320+
/// <summary>文件类型:text / json / csv</summary>
321+
[JsonPropertyName("fileType")]
322+
public string FileType { get; set; } = "text";
323+
324+
/// <summary>输出变量(data URL,可下载或供后续节点使用)</summary>
325+
[JsonPropertyName("output")]
326+
public string Output { get; set; } = "";
327+
}
328+
268329
/// <summary>MySQL 节点配置(MYSQL 节点)</summary>
269330
public class MysqlConfig
270331
{
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using ClosedXML.Excel;
4+
5+
namespace Juggle.Domain.Engine.NodeExecutors;
6+
7+
/// <summary>文件解析节点:解析文本/JSON/XML/CSV 文件内容(支持 data URL 或 base64 输入)。</summary>
8+
public class FileParseNodeExecutor : INodeExecutor
9+
{
10+
public Task<string?> ExecuteAsync(FlowNode node, FlowContext context)
11+
{
12+
var cfg = node.FileParseConfig
13+
?? throw new InvalidOperationException($"文件解析节点 [{node.Key}] 未配置 fileParseConfig。");
14+
15+
var raw = string.IsNullOrWhiteSpace(cfg.Input) ? "" : context.GetVariable(cfg.Input)?.ToString() ?? "";
16+
var content = DecodeContent(raw);
17+
var fileType = (cfg.FileType ?? "auto").ToLower();
18+
19+
object? result = fileType switch
20+
{
21+
"json" => ParseJson(content),
22+
"csv" => ParseCsv(content),
23+
"text" => content,
24+
"xml" => content,
25+
_ => TryParseJson(content, out var obj) ? obj : content
26+
};
27+
28+
if (!string.IsNullOrWhiteSpace(cfg.Output))
29+
context.SetVariable(cfg.Output, result);
30+
31+
return Task.FromResult(node.Outgoings.FirstOrDefault());
32+
}
33+
34+
/// <summary>解析 JSON 为对象/数组(失败返回原文)。</summary>
35+
internal static object? ParseJson(string content)
36+
=> TryParseJson(content, out var obj) ? obj : content;
37+
38+
internal static bool TryParseJson(string content, out object? obj)
39+
{
40+
obj = null;
41+
try
42+
{
43+
using var doc = JsonDocument.Parse(content);
44+
obj = CloneToNative(doc.RootElement);
45+
return true;
46+
}
47+
catch { return false; }
48+
}
49+
50+
/// <summary>解析 CSV:首行为表头,返回行字典数组。</summary>
51+
internal static object ParseCsv(string content)
52+
{
53+
var rows = new List<Dictionary<string, object?>>();
54+
var lines = content.Replace("\r\n", "\n").Split('\n');
55+
var headers = new List<string>();
56+
var first = true;
57+
foreach (var rawLine in lines)
58+
{
59+
if (string.IsNullOrWhiteSpace(rawLine)) continue;
60+
var cells = SplitCsvLine(rawLine);
61+
if (first)
62+
{
63+
headers = cells;
64+
first = false;
65+
continue;
66+
}
67+
var row = new Dictionary<string, object?>();
68+
for (int i = 0; i < cells.Count; i++)
69+
row[headers.Count > i ? headers[i] : $"column{i + 1}"] = cells[i];
70+
rows.Add(row);
71+
}
72+
return rows;
73+
}
74+
75+
private static List<string> SplitCsvLine(string line)
76+
{
77+
var cells = new List<string>();
78+
var sb = new StringBuilder();
79+
var inQuote = false;
80+
foreach (var ch in line)
81+
{
82+
if (ch == '"') { inQuote = !inQuote; continue; }
83+
if (ch == ',' && !inQuote) { cells.Add(sb.ToString().Trim()); sb.Clear(); continue; }
84+
sb.Append(ch);
85+
}
86+
cells.Add(sb.ToString().Trim());
87+
return cells;
88+
}
89+
90+
/// <summary>解码输入:data URL → 提取 base64 解码;纯 base64 → 解码;否则原文本。</summary>
91+
internal static string DecodeContent(string raw)
92+
{
93+
try
94+
{
95+
var text = raw.Trim();
96+
if (text.StartsWith("data:"))
97+
{
98+
var comma = text.IndexOf(',');
99+
if (comma > 0)
100+
{
101+
var payload = text[(comma + 1)..];
102+
return Encoding.UTF8.GetString(Convert.FromBase64String(payload));
103+
}
104+
}
105+
// 尝试作为纯 base64 解码(可解码且无非法 JSON/XML 时)
106+
if (text.Length > 8 && !text.StartsWith("{") && !text.StartsWith("[") && !text.StartsWith("<") && !text.Contains(' ') && !text.Contains('\n'))
107+
{
108+
var bytes = Convert.FromBase64String(text);
109+
var decoded = Encoding.UTF8.GetString(bytes);
110+
// 解码后是文本内容才采用
111+
if (decoded.Any(ch => ch == '\n' || ch == '{' || ch == '[' || ch == '<' || ch == ',' || ch == '一'))
112+
return decoded;
113+
}
114+
}
115+
catch { /* 解码失败按原文处理 */ }
116+
return raw;
117+
}
118+
119+
internal static object? CloneToNative(JsonElement el)
120+
{
121+
return el.ValueKind switch
122+
{
123+
JsonValueKind.String => el.GetString(),
124+
JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),
125+
JsonValueKind.True => true,
126+
JsonValueKind.False => false,
127+
JsonValueKind.Null => null,
128+
JsonValueKind.Array => el.EnumerateArray().Select(CloneToNative).ToList(),
129+
JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => CloneToNative(p.Value)),
130+
_ => el.ToString()
131+
};
132+
}
133+
}
134+
135+
/// <summary>Excel 读取节点:读取 Excel(base64 / data URL)第一个工作表,首行为表头,返回行字典数组。</summary>
136+
public class ExcelReadNodeExecutor : INodeExecutor
137+
{
138+
public Task<string?> ExecuteAsync(FlowNode node, FlowContext context)
139+
{
140+
var cfg = node.ExcelReadConfig
141+
?? throw new InvalidOperationException($"Excel 读取节点 [{node.Key}] 未配置 excelReadConfig。");
142+
143+
var raw = string.IsNullOrWhiteSpace(cfg.Input) ? "" : context.GetVariable(cfg.Input)?.ToString() ?? "";
144+
var text = raw.Trim();
145+
byte[] bytes;
146+
try
147+
{
148+
if (text.StartsWith("data:"))
149+
{
150+
var comma = text.IndexOf(',');
151+
bytes = Convert.FromBase64String(text[(comma + 1)..]);
152+
}
153+
else
154+
{
155+
bytes = Convert.FromBase64String(text);
156+
}
157+
}
158+
catch
159+
{
160+
throw new InvalidOperationException("Excel 内容不是有效的 base64 / data URL,请确认输入变量为 Excel 文件内容");
161+
}
162+
163+
using var ms = new MemoryStream(bytes);
164+
using var wb = new XLWorkbook(ms);
165+
var ws = string.IsNullOrWhiteSpace(cfg.SheetName)
166+
? wb.Worksheets.First()
167+
: wb.Worksheets.FirstOrDefault(w => w.Name == cfg.SheetName)
168+
?? throw new InvalidOperationException($"工作表 [{cfg.SheetName}] 不存在");
169+
170+
var rows = new List<Dictionary<string, object?>>();
171+
var headerRow = ws.FirstRowUsed();
172+
if (headerRow == null) { context.SetVariable(cfg.Output, rows); return Task.FromResult(node.Outgoings.FirstOrDefault()); }
173+
174+
var headers = new List<string>();
175+
foreach (var cell in headerRow.CellsUsed())
176+
headers.Add(cell.GetString());
177+
178+
foreach (var row in ws.RowsUsed().Skip(1))
179+
{
180+
if (row.RowNumber() == headerRow.RowNumber()) continue;
181+
var dict = new Dictionary<string, object?>();
182+
var ci = 0;
183+
foreach (var cell in row.CellsUsed())
184+
{
185+
var key = headers.Count > ci ? headers[ci] : $"column{ci + 1}";
186+
dict[key] = cell.Value.IsBlank ? null : (object?)cell.GetString();
187+
ci++;
188+
}
189+
if (dict.Count > 0) rows.Add(dict);
190+
}
191+
192+
if (!string.IsNullOrWhiteSpace(cfg.Output))
193+
context.SetVariable(cfg.Output, rows);
194+
195+
return Task.FromResult(node.Outgoings.FirstOrDefault());
196+
}
197+
}
198+
199+
/// <summary>文件写入节点:把变量内容(对象序列化为 JSON)生成 data URL 写入输出变量,供下载或后续节点使用。</summary>
200+
public class FileWriteNodeExecutor : INodeExecutor
201+
{
202+
public Task<string?> ExecuteAsync(FlowNode node, FlowContext context)
203+
{
204+
var cfg = node.FileWriteConfig
205+
?? throw new InvalidOperationException($"文件写入节点 [{node.Key}] 未配置 fileWriteConfig。");
206+
207+
var val = string.IsNullOrWhiteSpace(cfg.Content) ? null : context.GetVariable(cfg.Content);
208+
var fileType = (cfg.FileType ?? "text").ToLower();
209+
var content = val switch
210+
{
211+
null => "",
212+
string s => s,
213+
_ => JsonSerializer.Serialize(val)
214+
};
215+
216+
var mime = fileType switch
217+
{
218+
"json" => "application/json",
219+
"csv" => "text/csv",
220+
_ => "text/plain"
221+
};
222+
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(content));
223+
var dataUrl = $"data:{mime};base64,{base64}";
224+
225+
if (!string.IsNullOrWhiteSpace(cfg.Output))
226+
context.SetVariable(cfg.Output, dataUrl);
227+
if (!string.IsNullOrWhiteSpace(cfg.FileName))
228+
context.SetVariable(cfg.FileName, dataUrl);
229+
230+
return Task.FromResult(node.Outgoings.FirstOrDefault());
231+
}
232+
}

‎Juggle.Domain/Juggle.Domain.csproj‎

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

99
<ItemGroup>
10+
<PackageReference Include="ClosedXML" Version="0.104.2" />
1011
<PackageReference Include="DM.DmProvider" Version="8.3.1.47463" />
1112
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
1213
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" />

0 commit comments

Comments
 (0)