@josephyan/qingflow-mcp 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +517 -0
  2. package/docs/local-agent-install.md +213 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +63 -0
  9. package/qingflow-mcp +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +336 -0
  13. package/src/qingflow_mcp/config.py +166 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/server.py +70 -0
  17. package/src/qingflow_mcp/session_store.py +235 -0
  18. package/src/qingflow_mcp/solution/__init__.py +6 -0
  19. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  20. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  21. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  22. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  23. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  24. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  25. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  26. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  27. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  28. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  29. package/src/qingflow_mcp/solution/design_session.py +222 -0
  30. package/src/qingflow_mcp/solution/design_store.py +100 -0
  31. package/src/qingflow_mcp/solution/executor.py +2064 -0
  32. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  33. package/src/qingflow_mcp/solution/run_store.py +221 -0
  34. package/src/qingflow_mcp/solution/spec_models.py +755 -0
  35. package/src/qingflow_mcp/tools/__init__.py +1 -0
  36. package/src/qingflow_mcp/tools/app_tools.py +239 -0
  37. package/src/qingflow_mcp/tools/approval_tools.py +481 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +496 -0
  39. package/src/qingflow_mcp/tools/base.py +81 -0
  40. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  41. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  42. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  43. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  44. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  45. package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
  46. package/src/qingflow_mcp/tools/record_tools.py +4305 -0
  47. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  48. package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
  49. package/src/qingflow_mcp/tools/task_tools.py +677 -0
  50. package/src/qingflow_mcp/tools/view_tools.py +324 -0
  51. package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
  52. package/src/qingflow_mcp/tools/workspace_tools.py +143 -0
@@ -0,0 +1,456 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from ..spec_models import EntitySpec, FieldType, FormLayoutRowSpec, FormLayoutSectionSpec
7
+ from .icon_utils import encode_workspace_icon
8
+
9
+
10
+ QUESTION_TYPE_MAP = {
11
+ FieldType.text: 2,
12
+ FieldType.long_text: 3,
13
+ FieldType.number: 8,
14
+ FieldType.amount: 8,
15
+ FieldType.date: 4,
16
+ FieldType.datetime: 4,
17
+ FieldType.member: 5,
18
+ FieldType.department: 22,
19
+ FieldType.single_select: 11,
20
+ FieldType.multi_select: 12,
21
+ FieldType.phone: 7,
22
+ FieldType.email: 6,
23
+ FieldType.address: 21,
24
+ FieldType.attachment: 13,
25
+ FieldType.boolean: 10,
26
+ FieldType.relation: 25,
27
+ FieldType.subtable: 18,
28
+ }
29
+
30
+ OPTION_THEME_PALETTE = [
31
+ {
32
+ "hoverBorderColor": "#D16243",
33
+ "optionColor": "#FCE5DE",
34
+ "textColor": "#571C0C",
35
+ "themeId": 6,
36
+ "tickColor": "#D16243",
37
+ },
38
+ {
39
+ "hoverBorderColor": "#63AD0E",
40
+ "optionColor": "#E6F7D2",
41
+ "textColor": "#244201",
42
+ "themeId": 8,
43
+ "tickColor": "#63AD0E",
44
+ },
45
+ {
46
+ "hoverBorderColor": "#26ACD1",
47
+ "optionColor": "#DCF5FC",
48
+ "textColor": "#054557",
49
+ "themeId": 10,
50
+ "tickColor": "#26ACD1",
51
+ },
52
+ ]
53
+
54
+
55
+ def compile_entity_form(
56
+ entity: EntitySpec,
57
+ *,
58
+ include_package: bool,
59
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None, dict[str, dict[str, Any]], dict[str, str]]:
60
+ field_specs = {field.field_id: field.model_dump(mode="json") for field in entity.fields}
61
+ field_labels = {field.field_id: field.label for field in entity.fields}
62
+ app_create_payload = {
63
+ "appName": entity.display_name,
64
+ "appIcon": encode_workspace_icon(
65
+ icon=entity.icon,
66
+ color=entity.color,
67
+ title=entity.display_name,
68
+ fallback_icon_name="template",
69
+ ),
70
+ "auth": default_member_auth(),
71
+ # Reserve the first slot inside a package for the package portal/homepage.
72
+ "ordinal": (entity.ordinal or 1) + (1 if include_package else 0),
73
+ }
74
+ if include_package:
75
+ app_create_payload["tagIds"] = ["__PACKAGE_TAG_ID__"]
76
+
77
+ questions_by_field_id: dict[str, dict[str, Any]] = {}
78
+ temp_id = -10000
79
+ for field in entity.fields:
80
+ question, next_temp_id = build_question(field.model_dump(mode="json"), temp_id)
81
+ questions_by_field_id[field.field_id] = question
82
+ temp_id = next_temp_id
83
+
84
+ base_questions = build_form_questions(entity, questions_by_field_id, include_reference=False)
85
+ base_payload = default_form_payload(entity.display_name, base_questions)
86
+
87
+ reference_payload = None
88
+ if any(field.type == FieldType.relation for field in entity.fields):
89
+ reference_questions = build_form_questions(entity, questions_by_field_id, include_reference=True)
90
+ reference_payload = default_form_payload(entity.display_name, reference_questions)
91
+ return app_create_payload, base_payload, reference_payload, field_specs, field_labels
92
+
93
+
94
+ def default_member_auth() -> dict[str, Any]:
95
+ return {
96
+ "type": "WORKSPACE",
97
+ "contactAuth": {
98
+ "type": "WORKSPACE_ALL",
99
+ "authMembers": {
100
+ "member": [],
101
+ "depart": [],
102
+ "role": [],
103
+ "dynamic": [],
104
+ "includeSubDeparts": None,
105
+ },
106
+ },
107
+ "externalMemberAuth": {
108
+ "type": "NOT",
109
+ "authMembers": {
110
+ "member": [],
111
+ "depart": [],
112
+ "role": [],
113
+ "dynamic": [],
114
+ "includeSubDeparts": None,
115
+ },
116
+ },
117
+ }
118
+
119
+
120
+ def default_form_payload(form_title: str, form_questions: list[list[dict[str, Any]]]) -> dict[str, Any]:
121
+ return {
122
+ "formTitle": form_title,
123
+ "serialNumType": 1,
124
+ "formTheme": 0,
125
+ "editVersionNo": 1,
126
+ "questionRelations": [],
127
+ "formQues": form_questions,
128
+ }
129
+
130
+
131
+ def build_form_questions(
132
+ entity: EntitySpec,
133
+ questions_by_field_id: dict[str, dict[str, Any]],
134
+ *,
135
+ include_reference: bool,
136
+ ) -> list[list[dict[str, Any]]]:
137
+ used_field_ids: set[str] = set()
138
+ lines: list[list[dict[str, Any]]] = []
139
+ layout = entity.form_layout
140
+ if layout is not None:
141
+ for row in layout.rows:
142
+ row_questions = _row_questions(row, questions_by_field_id, include_reference=include_reference, used_field_ids=used_field_ids)
143
+ if row_questions:
144
+ lines.append(row_questions)
145
+ for section in layout.sections:
146
+ section_question = _section_question(
147
+ section,
148
+ questions_by_field_id,
149
+ include_reference=include_reference,
150
+ used_field_ids=used_field_ids,
151
+ )
152
+ if section_question is not None:
153
+ lines.append([section_question])
154
+ else:
155
+ for field in entity.fields:
156
+ if field.type == FieldType.relation and not include_reference:
157
+ continue
158
+ lines.append([deepcopy(questions_by_field_id[field.field_id])])
159
+ used_field_ids.add(field.field_id)
160
+
161
+ for field in entity.fields:
162
+ if field.field_id in used_field_ids:
163
+ continue
164
+ if field.type == FieldType.relation and not include_reference:
165
+ continue
166
+ lines.append([deepcopy(questions_by_field_id[field.field_id])])
167
+ return lines
168
+
169
+
170
+ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any], int]:
171
+ que_type = QUESTION_TYPE_MAP[field["type"]]
172
+ config = field.get("config") or {}
173
+ question = {
174
+ "queId": 0,
175
+ "queTempId": temp_id,
176
+ "queType": que_type,
177
+ "queTitle": field["label"],
178
+ "queWidth": int(config.get("que_width") or config.get("width") or 100),
179
+ "scanType": 1,
180
+ "status": 1,
181
+ "required": field["required"],
182
+ "queHint": field.get("description") or "",
183
+ "linkedQuestions": {},
184
+ "logicalShow": True,
185
+ "queDefaultValue": None,
186
+ "queDefaultType": 1,
187
+ "subQueWidth": 2,
188
+ "innerQuestions": [],
189
+ "beingHide": False,
190
+ "beingDesensitized": False,
191
+ }
192
+ next_temp_id = temp_id - 1
193
+ field_type = FieldType(field["type"])
194
+ if field_type in (FieldType.number, FieldType.amount, FieldType.text, FieldType.long_text, FieldType.phone, FieldType.email):
195
+ question.update(
196
+ {
197
+ "minWordCount": -1,
198
+ "maxWordCount": -1,
199
+ "canDecimal": field_type == FieldType.amount,
200
+ "numberFormat": 1,
201
+ "currencyType": 1,
202
+ }
203
+ )
204
+ if field_type == FieldType.date:
205
+ question["dateType"] = 0
206
+ if field_type == FieldType.datetime:
207
+ question["dateType"] = 1
208
+ if field_type == FieldType.member:
209
+ question.update({"memberDftValue": None, "memberSelectScopeType": 1, "memberSelectScope": {"member": []}})
210
+ if field_type == FieldType.department:
211
+ question.update({"deptSelectScopeType": 1, "deptSelectScope": {"depart": []}})
212
+ if field_type in (FieldType.single_select, FieldType.multi_select, FieldType.boolean):
213
+ options = field.get("options") or (["是", "否"] if field_type == FieldType.boolean else ["未命名1", "未命名2", "未命名3"])
214
+ question.update(
215
+ {
216
+ "options": build_options(options, start_temp_id=next_temp_id - len(options) + 1),
217
+ "unnamedOptions": options,
218
+ "optDirection": 0,
219
+ "beingShowColor": True,
220
+ }
221
+ )
222
+ if field_type == FieldType.multi_select:
223
+ question["optSelectMode"] = 1
224
+ next_temp_id -= len(options)
225
+ if field_type == FieldType.attachment:
226
+ question.update(
227
+ {
228
+ "fileBeingCaptureOnly": 0,
229
+ "fileSize": 20,
230
+ "fileType": [],
231
+ "pluginStatus": False,
232
+ "imageCompress": False,
233
+ "queDefaultValues": {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": []},
234
+ }
235
+ )
236
+ if field_type == FieldType.address:
237
+ question.update({"addressPrecision": 1, "queDefaultValues": {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}})
238
+ if field_type == FieldType.relation:
239
+ question.update(
240
+ {
241
+ "referenceConfig": build_reference_config(field, temp_id),
242
+ "queOriginType": 25,
243
+ "queDefaultType": 2,
244
+ }
245
+ )
246
+ if field_type == FieldType.subtable:
247
+ sub_questions: list[dict[str, Any]] = []
248
+ for subfield in field.get("subfields", []):
249
+ sub_question, next_temp_id = build_question(subfield, next_temp_id)
250
+ sub_questions.append(sub_question)
251
+ question["subQuestions"] = sub_questions
252
+ question["innerQuestions"] = deepcopy(sub_questions)
253
+ question["queDefaultValues"] = {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}
254
+ return question, next_temp_id
255
+
256
+
257
+ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any]:
258
+ config = field.get("config") or {}
259
+ display_field_id = field.get("target_field_id") or "title"
260
+ display_field_label = config.get("target_field_label") or "__TARGET_FIELD_LABEL__"
261
+ refer_field_ids = list(config.get("refer_field_ids") or [display_field_id])
262
+ refer_field_labels = config.get("refer_field_labels") or [display_field_label]
263
+ refer_field_types = config.get("refer_field_types")
264
+ refer_questions = []
265
+ for ordinal, field_id in enumerate(refer_field_ids, start=1):
266
+ label = refer_field_labels[ordinal - 1] if ordinal - 1 < len(refer_field_labels) else field_id
267
+ raw_type = _reference_field_type_value(
268
+ refer_field_types,
269
+ field_id=field_id,
270
+ ordinal=ordinal,
271
+ default=config.get("target_field_type") if field_id == display_field_id else None,
272
+ )
273
+ refer_questions.append(
274
+ {
275
+ "queId": 0,
276
+ "queTitle": label,
277
+ "queType": _normalize_reference_que_type(raw_type) or "2",
278
+ "queAuth": 1,
279
+ "ordinal": ordinal,
280
+ "quoteId": temp_id,
281
+ "_field_id": field_id,
282
+ }
283
+ )
284
+ auth_field_ids = list(config.get("auth_field_ids") or refer_field_ids)
285
+ return {
286
+ "referAppKey": "__TARGET_APP_KEY__",
287
+ "referQueId": 0,
288
+ "customButtonText": config.get("custom_button_text") or "选择数据",
289
+ "beingTableSource": False,
290
+ "referQuestions": refer_questions,
291
+ "referMatchRules": deepcopy(config.get("refer_match_rules") or []),
292
+ "referFillRules": deepcopy(config.get("refer_fill_rules") or []),
293
+ "referAuthQues": deepcopy(config.get("refer_auth_ques") or [{"queId": 0, "queAuth": 1, "_field_id": field_id} for field_id in auth_field_ids]),
294
+ "canAddData": bool(config.get("can_add_data", False)),
295
+ "dataAdditionButtonText": config.get("data_addition_button_text") or "新增数据",
296
+ "canViewProcessLog": bool(config.get("can_view_process_log", True)),
297
+ "optionalDataNum": int(config.get("optional_data_num", 1)),
298
+ "beingDataLogVisible": bool(config.get("being_data_log_visible", True)),
299
+ "beingDefaultFormulaAutoFillEnabled": bool(config.get("being_default_formula_auto_fill_enabled", False)),
300
+ "defaultValueMatchRules": deepcopy(config.get("default_value_match_rules") or []),
301
+ "configShowForm": config.get("config_show_form") or "TABLE",
302
+ "configSortFieldId": None,
303
+ "configAsc": config.get("config_asc", True),
304
+ "dataShowForm": config.get("data_show_form") or "CARD",
305
+ "defaultRow": config.get("default_row"),
306
+ "fieldNameShow": config.get("field_name_show", True),
307
+ "dataSortFieldId": None,
308
+ "dataSortAsc": config.get("data_sort_asc"),
309
+ "_targetFieldId": display_field_id,
310
+ "_targetEntityId": field.get("target_entity_id"),
311
+ }
312
+
313
+
314
+ def build_options(options: list[str], *, start_temp_id: int) -> list[dict[str, Any]]:
315
+ items: list[dict[str, Any]] = []
316
+ current = start_temp_id
317
+ for index, option in enumerate(options):
318
+ items.append(
319
+ {
320
+ "otherValue": None,
321
+ "beingDefault": False,
322
+ "optId": 0,
323
+ "optValue": option,
324
+ "optUrl": "",
325
+ "beingOtherOpt": False,
326
+ "beingRequired": False,
327
+ "applyLimit": -1,
328
+ "currentApply": 0,
329
+ "linkQueIds": [],
330
+ "emptyText": "",
331
+ "tempId": current,
332
+ "optionThemeColor": deepcopy(OPTION_THEME_PALETTE[index % len(OPTION_THEME_PALETTE)]),
333
+ }
334
+ )
335
+ current += 1
336
+ return items
337
+
338
+
339
+ def _row_questions(
340
+ row: FormLayoutRowSpec,
341
+ questions_by_field_id: dict[str, dict[str, Any]],
342
+ *,
343
+ include_reference: bool,
344
+ used_field_ids: set[str],
345
+ ) -> list[dict[str, Any]]:
346
+ questions: list[dict[str, Any]] = []
347
+ for field_id in row.field_ids:
348
+ question = deepcopy(questions_by_field_id[field_id])
349
+ if question.get("queType") == 25 and not include_reference:
350
+ continue
351
+ used_field_ids.add(field_id)
352
+ questions.append(question)
353
+ widths = _resolve_row_widths(row, questions)
354
+ for question, width in zip(questions, widths):
355
+ question["queWidth"] = width
356
+ return questions
357
+
358
+
359
+ def _section_question(
360
+ section: FormLayoutSectionSpec,
361
+ questions_by_field_id: dict[str, dict[str, Any]],
362
+ *,
363
+ include_reference: bool,
364
+ used_field_ids: set[str],
365
+ ) -> dict[str, Any] | None:
366
+ inner_rows: list[list[dict[str, Any]]] = []
367
+ for row in section.rows:
368
+ row_questions = _row_questions(row, questions_by_field_id, include_reference=include_reference, used_field_ids=used_field_ids)
369
+ if row_questions:
370
+ inner_rows.append(row_questions)
371
+ if not inner_rows:
372
+ return None
373
+ config = section.config or {}
374
+ return {
375
+ "queId": 0,
376
+ "queTempId": -(20000 + sum(ord(ch) for ch in section.section_id)),
377
+ "queType": 24,
378
+ "queTitle": section.title,
379
+ "queWidth": int(config.get("que_width") or 100),
380
+ "scanType": 1,
381
+ "status": 1,
382
+ "required": False,
383
+ "queHint": config.get("description") or "",
384
+ "linkedQuestions": {},
385
+ "logicalShow": True,
386
+ "queDefaultValue": None,
387
+ "queDefaultType": 1,
388
+ "subQueWidth": 2,
389
+ "innerQuestions": inner_rows,
390
+ "beingHide": False,
391
+ "beingDesensitized": False,
392
+ "sectionId": config.get("section_id"),
393
+ }
394
+
395
+
396
+ def _resolve_row_widths(row: FormLayoutRowSpec, questions: list[dict[str, Any]]) -> list[int]:
397
+ if not questions:
398
+ return []
399
+ configured_widths = row.config.get("field_widths") if isinstance(row.config.get("field_widths"), dict) else {}
400
+ widths: list[int] = []
401
+ remaining_slots = 0
402
+ assigned_total = 0
403
+ for field_id, question in zip(row.field_ids, questions):
404
+ width = configured_widths.get(field_id)
405
+ if width is None:
406
+ widths.append(-1)
407
+ remaining_slots += 1
408
+ continue
409
+ resolved_width = int(width)
410
+ widths.append(resolved_width)
411
+ assigned_total += resolved_width
412
+ if remaining_slots <= 0:
413
+ return widths
414
+ remaining_total = max(0, 100 - assigned_total)
415
+ auto_widths = _auto_row_widths(remaining_slots, total=remaining_total)
416
+ auto_index = 0
417
+ for index, width in enumerate(widths):
418
+ if width >= 0:
419
+ continue
420
+ widths[index] = auto_widths[auto_index]
421
+ auto_index += 1
422
+ return widths
423
+
424
+
425
+ def _auto_row_widths(count: int, *, total: int = 100) -> list[int]:
426
+ if count <= 0:
427
+ return []
428
+ base = total // count
429
+ remainder = total % count
430
+ return [base + (1 if index < remainder else 0) for index in range(count)]
431
+
432
+
433
+ def _reference_field_type_value(raw_types: Any, *, field_id: str, ordinal: int, default: Any) -> Any:
434
+ if isinstance(raw_types, dict):
435
+ return raw_types.get(field_id, default)
436
+ if isinstance(raw_types, list):
437
+ index = ordinal - 1
438
+ if 0 <= index < len(raw_types):
439
+ return raw_types[index]
440
+ return default
441
+
442
+
443
+ def _normalize_reference_que_type(raw_type: Any) -> str | None:
444
+ if raw_type is None:
445
+ return None
446
+ if isinstance(raw_type, int):
447
+ return str(raw_type)
448
+ value = str(raw_type).strip()
449
+ if not value:
450
+ return None
451
+ if value.isdigit():
452
+ return value
453
+ try:
454
+ return str(QUESTION_TYPE_MAP[FieldType(value)])
455
+ except ValueError:
456
+ return None
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+
6
+ DEFAULT_ICON_COLOR = "qing-orange"
7
+ SUPPORTED_EX_ICONS = {
8
+ "ex-alert-outlined",
9
+ "ex-clock-outlined",
10
+ "ex-folder-outlined",
11
+ "ex-form-outlined",
12
+ "ex-home-outlined",
13
+ "ex-user-outlined",
14
+ }
15
+
16
+ ICON_ALIAS_MAP = {
17
+ "archive-box": "ex-form-outlined",
18
+ "beaker": "ex-form-outlined",
19
+ "briefcase": "ex-folder-outlined",
20
+ "building-office": "ex-form-outlined",
21
+ "calendar": "ex-clock-outlined",
22
+ "chat": "ex-clock-outlined",
23
+ "chat-bubble-left-right": "ex-clock-outlined",
24
+ "chart-bar": "ex-form-outlined",
25
+ "chart-square-bar": "ex-form-outlined",
26
+ "check-badge": "ex-alert-outlined",
27
+ "check-circle": "ex-alert-outlined",
28
+ "clipboard-check": "ex-form-outlined",
29
+ "clock": "ex-clock-outlined",
30
+ "cube": "ex-form-outlined",
31
+ "document-text": "ex-form-outlined",
32
+ "folder": "ex-folder-outlined",
33
+ "home": "ex-home-outlined",
34
+ "office-building": "ex-form-outlined",
35
+ "shield-check": "ex-alert-outlined",
36
+ "template": "ex-form-outlined",
37
+ "user": "ex-user-outlined",
38
+ "user-group": "ex-user-outlined",
39
+ "users": "ex-user-outlined",
40
+ "view-grid": "ex-home-outlined",
41
+ "warning": "ex-alert-outlined",
42
+ }
43
+
44
+
45
+ def encode_workspace_icon(
46
+ *,
47
+ icon: str | None,
48
+ color: str | None,
49
+ title: str,
50
+ fallback_icon_name: str | None = None,
51
+ ) -> str:
52
+ if icon and _looks_like_icon_json(icon):
53
+ return icon
54
+ normalized_icon_name = normalize_workspace_icon_name(icon or fallback_icon_name)
55
+ if normalized_icon_name and normalized_icon_name.startswith("ex-") and not color:
56
+ return normalized_icon_name
57
+ icon_name = normalized_icon_name
58
+ icon_color = color or DEFAULT_ICON_COLOR
59
+ if icon_name:
60
+ return json.dumps(
61
+ {
62
+ "icon": None,
63
+ "iconColor": icon_color,
64
+ "iconName": icon_name,
65
+ "iconText": None,
66
+ },
67
+ ensure_ascii=False,
68
+ separators=(",", ":"),
69
+ )
70
+ icon_text = (title or "未")[:1]
71
+ return json.dumps(
72
+ {
73
+ "iconColor": icon_color,
74
+ "iconText": icon_text,
75
+ },
76
+ ensure_ascii=False,
77
+ separators=(",", ":"),
78
+ )
79
+
80
+
81
+ def normalize_workspace_icon_name(icon: str | None) -> str | None:
82
+ if not icon:
83
+ return None
84
+ if _looks_like_icon_json(icon):
85
+ try:
86
+ payload = json.loads(icon)
87
+ except Exception:
88
+ return "ex-form-outlined"
89
+ return normalize_workspace_icon_name(payload.get("iconName")) or "ex-form-outlined"
90
+ if icon in SUPPORTED_EX_ICONS:
91
+ return icon
92
+ normalized = icon.strip().lower()
93
+ if normalized in ICON_ALIAS_MAP:
94
+ return ICON_ALIAS_MAP[normalized]
95
+ for token in normalized.replace("_", "-").split("-"):
96
+ if token in ICON_ALIAS_MAP:
97
+ return ICON_ALIAS_MAP[token]
98
+ if "folder" in normalized or "package" in normalized:
99
+ return "ex-folder-outlined"
100
+ if "home" in normalized or "portal" in normalized or "dashboard" in normalized:
101
+ return "ex-home-outlined"
102
+ if "user" in normalized or "member" in normalized or "contact" in normalized:
103
+ return "ex-user-outlined"
104
+ if "clock" in normalized or "time" in normalized or "schedule" in normalized or "log" in normalized:
105
+ return "ex-clock-outlined"
106
+ if "risk" in normalized or "alert" in normalized or "warn" in normalized or "safety" in normalized:
107
+ return "ex-alert-outlined"
108
+ return "ex-form-outlined"
109
+
110
+
111
+ def _looks_like_icon_json(value: str) -> bool:
112
+ stripped = value.strip()
113
+ return stripped.startswith("{") and stripped.endswith("}")
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..spec_models import NavigationItemSpec, NavigationTargetType, SolutionSpec
6
+ from .icon_utils import normalize_workspace_icon_name
7
+
8
+
9
+ def compile_navigation(spec: SolutionSpec) -> list[dict[str, Any]]:
10
+ if not spec.preferences.create_navigation or not spec.navigation.enabled:
11
+ return []
12
+ compiled_items = [_compile_item(item) for item in spec.navigation.items]
13
+ if not compiled_items:
14
+ return []
15
+ package_item = next(
16
+ (
17
+ item
18
+ for item in compiled_items
19
+ if item["payload"].get("targetType") == "TAG"
20
+ ),
21
+ None,
22
+ )
23
+ if package_item is None:
24
+ return compiled_items[:1]
25
+ package_item["children"] = []
26
+ return [package_item]
27
+
28
+
29
+ def _compile_item(item: NavigationItemSpec) -> dict[str, Any]:
30
+ default_icon = _default_navigation_icon(item.target_type)
31
+ payload: dict[str, Any] = {
32
+ "item_id": item.item_id,
33
+ "payload": {
34
+ "name": item.title,
35
+ "navigationIcon": normalize_workspace_icon_name(item.icon or default_icon) or default_icon,
36
+ "auth": {"type": "WORKSPACE"},
37
+ **item.config,
38
+ },
39
+ "children": [_compile_item(child) for child in item.children],
40
+ }
41
+ if item.target_type == NavigationTargetType.package:
42
+ payload["payload"].update({"targetType": "TAG", "tagId": "__PACKAGE_TAG_ID__"})
43
+ elif item.target_type == NavigationTargetType.app:
44
+ payload["payload"].update({"targetType": "APP", "appRef": {"entity_id": item.entity_id}})
45
+ elif item.target_type == NavigationTargetType.portal:
46
+ payload["payload"].update({"targetType": "DASH", "dashKey": "__PORTAL_DASH_KEY__"})
47
+ else:
48
+ raise ValueError("navigation target_type currently supports only: package, app, portal")
49
+ return payload
50
+
51
+
52
+ def _default_navigation_icon(target_type: NavigationTargetType) -> str:
53
+ if target_type == NavigationTargetType.package:
54
+ return "ex-folder-outlined"
55
+ if target_type == NavigationTargetType.portal:
56
+ return "ex-home-outlined"
57
+ return "ex-form-outlined"
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from ..spec_models import SolutionSpec
4
+ from .icon_utils import encode_workspace_icon
5
+
6
+
7
+ def compile_package(spec: SolutionSpec) -> dict | None:
8
+ if not spec.preferences.create_package or not spec.package.enabled:
9
+ return None
10
+ return {
11
+ "tagName": spec.package.name or spec.solution_name,
12
+ "tagIcon": encode_workspace_icon(
13
+ icon=spec.package.icon,
14
+ color=spec.package.color,
15
+ title=spec.package.name or spec.solution_name,
16
+ fallback_icon_name="files-folder",
17
+ ),
18
+ "ordinal": spec.package.ordinal,
19
+ }