@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,495 @@
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.q_linker: 20,
27
+ FieldType.code_block: 26,
28
+ FieldType.relation: 25,
29
+ FieldType.subtable: 18,
30
+ }
31
+
32
+ OPTION_THEME_PALETTE = [
33
+ {
34
+ "hoverBorderColor": "#D16243",
35
+ "optionColor": "#FCE5DE",
36
+ "textColor": "#571C0C",
37
+ "themeId": 6,
38
+ "tickColor": "#D16243",
39
+ },
40
+ {
41
+ "hoverBorderColor": "#63AD0E",
42
+ "optionColor": "#E6F7D2",
43
+ "textColor": "#244201",
44
+ "themeId": 8,
45
+ "tickColor": "#63AD0E",
46
+ },
47
+ {
48
+ "hoverBorderColor": "#26ACD1",
49
+ "optionColor": "#DCF5FC",
50
+ "textColor": "#054557",
51
+ "themeId": 10,
52
+ "tickColor": "#26ACD1",
53
+ },
54
+ ]
55
+
56
+
57
+ def compile_entity_form(
58
+ entity: EntitySpec,
59
+ *,
60
+ include_package: bool,
61
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None, dict[str, dict[str, Any]], dict[str, str]]:
62
+ field_specs = {field.field_id: field.model_dump(mode="json") for field in entity.fields}
63
+ field_labels = {field.field_id: field.label for field in entity.fields}
64
+ app_create_payload = {
65
+ "appName": entity.display_name,
66
+ "appIcon": encode_workspace_icon(
67
+ icon=entity.icon,
68
+ color=entity.color,
69
+ title=entity.display_name,
70
+ fallback_icon_name="template",
71
+ ),
72
+ "auth": default_member_auth(),
73
+ # Reserve the first slot inside a package for the package portal/homepage.
74
+ "ordinal": (entity.ordinal or 1) + (1 if include_package else 0),
75
+ }
76
+ if include_package:
77
+ app_create_payload["tagIds"] = ["__PACKAGE_TAG_ID__"]
78
+
79
+ questions_by_field_id: dict[str, dict[str, Any]] = {}
80
+ temp_id = -10000
81
+ for field in entity.fields:
82
+ question, next_temp_id = build_question(field.model_dump(mode="json"), temp_id)
83
+ questions_by_field_id[field.field_id] = question
84
+ temp_id = next_temp_id
85
+
86
+ base_questions = build_form_questions(entity, questions_by_field_id, include_reference=False)
87
+ base_payload = default_form_payload(entity.display_name, base_questions)
88
+
89
+ reference_payload = None
90
+ if any(field.type == FieldType.relation for field in entity.fields):
91
+ reference_questions = build_form_questions(entity, questions_by_field_id, include_reference=True)
92
+ reference_payload = default_form_payload(entity.display_name, reference_questions)
93
+ return app_create_payload, base_payload, reference_payload, field_specs, field_labels
94
+
95
+
96
+ def default_member_auth() -> dict[str, Any]:
97
+ return {
98
+ "type": "WORKSPACE",
99
+ "contactAuth": {
100
+ "type": "WORKSPACE_ALL",
101
+ "authMembers": {
102
+ "member": [],
103
+ "depart": [],
104
+ "role": [],
105
+ "dynamic": [],
106
+ "includeSubDeparts": None,
107
+ },
108
+ },
109
+ "externalMemberAuth": {
110
+ "type": "NOT",
111
+ "authMembers": {
112
+ "member": [],
113
+ "depart": [],
114
+ "role": [],
115
+ "dynamic": [],
116
+ "includeSubDeparts": None,
117
+ },
118
+ },
119
+ }
120
+
121
+
122
+ def default_form_payload(form_title: str, form_questions: list[list[dict[str, Any]]]) -> dict[str, Any]:
123
+ return {
124
+ "formTitle": form_title,
125
+ "serialNumType": 1,
126
+ "formTheme": 0,
127
+ "editVersionNo": 1,
128
+ "questionRelations": [],
129
+ "formQues": form_questions,
130
+ }
131
+
132
+
133
+ def build_form_questions(
134
+ entity: EntitySpec,
135
+ questions_by_field_id: dict[str, dict[str, Any]],
136
+ *,
137
+ include_reference: bool,
138
+ ) -> list[list[dict[str, Any]]]:
139
+ used_field_ids: set[str] = set()
140
+ lines: list[list[dict[str, Any]]] = []
141
+ layout = entity.form_layout
142
+ if layout is not None:
143
+ for row in layout.rows:
144
+ row_questions = _row_questions(row, questions_by_field_id, include_reference=include_reference, used_field_ids=used_field_ids)
145
+ if row_questions:
146
+ lines.append(row_questions)
147
+ for section in layout.sections:
148
+ section_question = _section_question(
149
+ section,
150
+ questions_by_field_id,
151
+ include_reference=include_reference,
152
+ used_field_ids=used_field_ids,
153
+ )
154
+ if section_question is not None:
155
+ lines.append([section_question])
156
+ else:
157
+ for field in entity.fields:
158
+ if field.type == FieldType.relation and not include_reference:
159
+ continue
160
+ lines.append([deepcopy(questions_by_field_id[field.field_id])])
161
+ used_field_ids.add(field.field_id)
162
+
163
+ for field in entity.fields:
164
+ if field.field_id in used_field_ids:
165
+ continue
166
+ if field.type == FieldType.relation and not include_reference:
167
+ continue
168
+ lines.append([deepcopy(questions_by_field_id[field.field_id])])
169
+ return lines
170
+
171
+
172
+ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any], int]:
173
+ que_type = QUESTION_TYPE_MAP[field["type"]]
174
+ config = field.get("config") or {}
175
+ question = {
176
+ "queId": 0,
177
+ "queTempId": temp_id,
178
+ "queType": que_type,
179
+ "queTitle": field["label"],
180
+ "queWidth": int(config.get("que_width") or config.get("width") or 100),
181
+ "scanType": 1,
182
+ "status": 1,
183
+ "required": field["required"],
184
+ "queHint": field.get("description") or "",
185
+ "linkedQuestions": {},
186
+ "logicalShow": True,
187
+ "queDefaultValue": None,
188
+ "queDefaultType": 1,
189
+ "subQueWidth": 2,
190
+ "innerQuestions": [],
191
+ "beingHide": False,
192
+ "beingDesensitized": False,
193
+ }
194
+ next_temp_id = temp_id - 1
195
+ field_type = FieldType(field["type"])
196
+ if field_type in (FieldType.number, FieldType.amount, FieldType.text, FieldType.long_text, FieldType.phone, FieldType.email):
197
+ question.update(
198
+ {
199
+ "minWordCount": -1,
200
+ "maxWordCount": -1,
201
+ "canDecimal": field_type == FieldType.amount,
202
+ "numberFormat": 1,
203
+ "currencyType": 1,
204
+ }
205
+ )
206
+ if field_type == FieldType.date:
207
+ question["dateType"] = 0
208
+ if field_type == FieldType.datetime:
209
+ question["dateType"] = 1
210
+ if field_type == FieldType.member:
211
+ question.update({"memberDftValue": None, "memberSelectScopeType": 1, "memberSelectScope": {"member": []}})
212
+ if field_type == FieldType.department:
213
+ question.update({"deptSelectScopeType": 1, "deptSelectScope": {"depart": []}})
214
+ if field_type in (FieldType.single_select, FieldType.multi_select, FieldType.boolean):
215
+ options = field.get("options") or (["是", "否"] if field_type == FieldType.boolean else ["未命名1", "未命名2", "未命名3"])
216
+ question.update(
217
+ {
218
+ "options": build_options(options, start_temp_id=next_temp_id - len(options) + 1),
219
+ "unnamedOptions": options,
220
+ "optDirection": 0,
221
+ "beingShowColor": True,
222
+ }
223
+ )
224
+ if field_type == FieldType.multi_select:
225
+ question["optSelectMode"] = 1
226
+ next_temp_id -= len(options)
227
+ if field_type == FieldType.attachment:
228
+ question.update(
229
+ {
230
+ "fileBeingCaptureOnly": 0,
231
+ "fileSize": 20,
232
+ "fileType": [],
233
+ "pluginStatus": False,
234
+ "imageCompress": False,
235
+ "queDefaultValues": {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": []},
236
+ }
237
+ )
238
+ if field_type == FieldType.address:
239
+ question.update({"addressPrecision": 1, "queDefaultValues": {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}})
240
+ if field_type == FieldType.relation:
241
+ question.update(
242
+ {
243
+ "referenceConfig": build_reference_config(field, temp_id),
244
+ "queOriginType": 25,
245
+ "queDefaultType": 2,
246
+ }
247
+ )
248
+ if field_type == FieldType.code_block:
249
+ question.update(
250
+ {
251
+ "minOpts": -1,
252
+ "maxOpts": -1,
253
+ "codeBlockConfig": {
254
+ "configMode": int(config.get("config_mode") or 1),
255
+ "codeContent": str(config.get("code_content") or ""),
256
+ "resultAliasPath": deepcopy(config.get("result_alias_path") or []),
257
+ "beingHideOnForm": bool(config.get("being_hide_on_form", False)),
258
+ },
259
+ "autoTrigger": bool(config.get("auto_trigger", False)),
260
+ "customBtnTextStatus": bool(config.get("custom_button_text_enabled", False)),
261
+ "customBtnText": str(config.get("custom_button_text") or ""),
262
+ }
263
+ )
264
+ if field_type == FieldType.q_linker:
265
+ question.update(
266
+ {
267
+ "minOpts": -1,
268
+ "maxOpts": -1,
269
+ "remoteLookupConfig": deepcopy(config.get("remote_lookup_config") or config),
270
+ "autoTrigger": bool(config.get("auto_trigger", False)),
271
+ "customBtnTextStatus": bool(config.get("custom_button_text_enabled", False)),
272
+ "customBtnText": str(config.get("custom_button_text") or ""),
273
+ }
274
+ )
275
+ if field_type == FieldType.subtable:
276
+ sub_questions: list[dict[str, Any]] = []
277
+ for subfield in field.get("subfields", []):
278
+ sub_question, next_temp_id = build_question(subfield, next_temp_id)
279
+ sub_questions.append(sub_question)
280
+ question["subQuestions"] = sub_questions
281
+ question["innerQuestions"] = [deepcopy(sub_questions)]
282
+ question["queDefaultValues"] = {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}
283
+ return question, next_temp_id
284
+
285
+
286
+ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any]:
287
+ config = field.get("config") or {}
288
+ display_field_id = field.get("target_field_id") or "title"
289
+ display_field_que_id = field.get("target_field_que_id") or config.get("target_field_que_id") or 0
290
+ display_field_label = config.get("target_field_label") or "__TARGET_FIELD_LABEL__"
291
+ refer_field_ids = list(config.get("refer_field_ids") or [display_field_id])
292
+ refer_field_que_ids = list(config.get("refer_field_que_ids") or [])
293
+ refer_field_labels = config.get("refer_field_labels") or [display_field_label]
294
+ refer_field_types = config.get("refer_field_types")
295
+ refer_questions = []
296
+ for ordinal, field_id in enumerate(refer_field_ids, start=1):
297
+ label = refer_field_labels[ordinal - 1] if ordinal - 1 < len(refer_field_labels) else field_id
298
+ que_id = refer_field_que_ids[ordinal - 1] if ordinal - 1 < len(refer_field_que_ids) else 0
299
+ raw_type = _reference_field_type_value(
300
+ refer_field_types,
301
+ field_id=field_id,
302
+ ordinal=ordinal,
303
+ default=config.get("target_field_type") if field_id == display_field_id else None,
304
+ )
305
+ refer_questions.append(
306
+ {
307
+ "queId": que_id,
308
+ "queTitle": label,
309
+ "queType": _normalize_reference_que_type(raw_type) or "2",
310
+ "queAuth": 3,
311
+ "ordinal": ordinal,
312
+ "quoteId": temp_id,
313
+ "_field_id": field_id,
314
+ }
315
+ )
316
+ auth_field_ids = list(config.get("auth_field_ids") or refer_field_ids)
317
+ auth_field_que_ids = list(config.get("auth_field_que_ids") or [])
318
+ auth_ques = deepcopy(config.get("refer_auth_ques") or [])
319
+ if not auth_ques:
320
+ auth_ques = []
321
+ for ordinal, field_id in enumerate(auth_field_ids, start=1):
322
+ que_id = auth_field_que_ids[ordinal - 1] if ordinal - 1 < len(auth_field_que_ids) else 0
323
+ auth_ques.append({"queId": que_id, "queAuth": 3, "_field_id": field_id})
324
+ return {
325
+ "referAppKey": "__TARGET_APP_KEY__",
326
+ "referQueId": display_field_que_id,
327
+ "customButtonText": config.get("custom_button_text") or "选择数据",
328
+ "beingTableSource": False,
329
+ "referQuestions": refer_questions,
330
+ "referMatchRules": deepcopy(config.get("refer_match_rules") or []),
331
+ "referFillRules": deepcopy(config.get("refer_fill_rules") or []),
332
+ "referAuthQues": auth_ques,
333
+ "canAddData": bool(config.get("can_add_data", False)),
334
+ "dataAdditionButtonText": config.get("data_addition_button_text") or "新增数据",
335
+ "canViewProcessLog": bool(config.get("can_view_process_log", True)),
336
+ "optionalDataNum": int(config.get("optional_data_num", 1)),
337
+ "beingDataLogVisible": bool(config.get("being_data_log_visible", True)),
338
+ "beingDefaultFormulaAutoFillEnabled": bool(config.get("being_default_formula_auto_fill_enabled", False)),
339
+ "defaultValueMatchRules": deepcopy(config.get("default_value_match_rules") or []),
340
+ "configShowForm": config.get("config_show_form") or "TABLE",
341
+ "configSortFieldId": None,
342
+ "configAsc": config.get("config_asc", True),
343
+ "dataShowForm": config.get("data_show_form") or "CARD",
344
+ "defaultRow": config.get("default_row"),
345
+ "fieldNameShow": config.get("field_name_show", True),
346
+ "dataSortFieldId": None,
347
+ "dataSortAsc": config.get("data_sort_asc"),
348
+ "_targetFieldId": display_field_id,
349
+ "_targetEntityId": field.get("target_entity_id"),
350
+ }
351
+
352
+
353
+ def build_options(options: list[str], *, start_temp_id: int) -> list[dict[str, Any]]:
354
+ items: list[dict[str, Any]] = []
355
+ current = start_temp_id
356
+ for index, option in enumerate(options):
357
+ items.append(
358
+ {
359
+ "otherValue": None,
360
+ "beingDefault": False,
361
+ "optId": 0,
362
+ "optValue": option,
363
+ "optUrl": "",
364
+ "beingOtherOpt": False,
365
+ "beingRequired": False,
366
+ "applyLimit": -1,
367
+ "currentApply": 0,
368
+ "linkQueIds": [],
369
+ "emptyText": "",
370
+ "tempId": current,
371
+ "optionThemeColor": deepcopy(OPTION_THEME_PALETTE[index % len(OPTION_THEME_PALETTE)]),
372
+ }
373
+ )
374
+ current += 1
375
+ return items
376
+
377
+
378
+ def _row_questions(
379
+ row: FormLayoutRowSpec,
380
+ questions_by_field_id: dict[str, dict[str, Any]],
381
+ *,
382
+ include_reference: bool,
383
+ used_field_ids: set[str],
384
+ ) -> list[dict[str, Any]]:
385
+ questions: list[dict[str, Any]] = []
386
+ for field_id in row.field_ids:
387
+ question = deepcopy(questions_by_field_id[field_id])
388
+ if question.get("queType") == 25 and not include_reference:
389
+ continue
390
+ used_field_ids.add(field_id)
391
+ questions.append(question)
392
+ widths = _resolve_row_widths(row, questions)
393
+ for question, width in zip(questions, widths):
394
+ question["queWidth"] = width
395
+ return questions
396
+
397
+
398
+ def _section_question(
399
+ section: FormLayoutSectionSpec,
400
+ questions_by_field_id: dict[str, dict[str, Any]],
401
+ *,
402
+ include_reference: bool,
403
+ used_field_ids: set[str],
404
+ ) -> dict[str, Any] | None:
405
+ inner_rows: list[list[dict[str, Any]]] = []
406
+ for row in section.rows:
407
+ row_questions = _row_questions(row, questions_by_field_id, include_reference=include_reference, used_field_ids=used_field_ids)
408
+ if row_questions:
409
+ inner_rows.append(row_questions)
410
+ if not inner_rows:
411
+ return None
412
+ config = section.config or {}
413
+ return {
414
+ "queId": 0,
415
+ "queTempId": -(20000 + sum(ord(ch) for ch in section.section_id)),
416
+ "queType": 24,
417
+ "queTitle": section.title,
418
+ "queWidth": int(config.get("que_width") or 100),
419
+ "scanType": 1,
420
+ "status": 1,
421
+ "required": False,
422
+ "queHint": config.get("description") or "",
423
+ "linkedQuestions": {},
424
+ "logicalShow": True,
425
+ "queDefaultValue": None,
426
+ "queDefaultType": 1,
427
+ "subQueWidth": 2,
428
+ "innerQuestions": inner_rows,
429
+ "beingHide": False,
430
+ "beingDesensitized": False,
431
+ "sectionId": config.get("section_id"),
432
+ }
433
+
434
+
435
+ def _resolve_row_widths(row: FormLayoutRowSpec, questions: list[dict[str, Any]]) -> list[int]:
436
+ if not questions:
437
+ return []
438
+ configured_widths = row.config.get("field_widths") if isinstance(row.config.get("field_widths"), dict) else {}
439
+ widths: list[int] = []
440
+ remaining_slots = 0
441
+ assigned_total = 0
442
+ for field_id, question in zip(row.field_ids, questions):
443
+ width = configured_widths.get(field_id)
444
+ if width is None:
445
+ widths.append(-1)
446
+ remaining_slots += 1
447
+ continue
448
+ resolved_width = int(width)
449
+ widths.append(resolved_width)
450
+ assigned_total += resolved_width
451
+ if remaining_slots <= 0:
452
+ return widths
453
+ remaining_total = max(0, 100 - assigned_total)
454
+ auto_widths = _auto_row_widths(remaining_slots, total=remaining_total)
455
+ auto_index = 0
456
+ for index, width in enumerate(widths):
457
+ if width >= 0:
458
+ continue
459
+ widths[index] = auto_widths[auto_index]
460
+ auto_index += 1
461
+ return widths
462
+
463
+
464
+ def _auto_row_widths(count: int, *, total: int = 100) -> list[int]:
465
+ if count <= 0:
466
+ return []
467
+ base = total // count
468
+ remainder = total % count
469
+ return [base + (1 if index < remainder else 0) for index in range(count)]
470
+
471
+
472
+ def _reference_field_type_value(raw_types: Any, *, field_id: str, ordinal: int, default: Any) -> Any:
473
+ if isinstance(raw_types, dict):
474
+ return raw_types.get(field_id, default)
475
+ if isinstance(raw_types, list):
476
+ index = ordinal - 1
477
+ if 0 <= index < len(raw_types):
478
+ return raw_types[index]
479
+ return default
480
+
481
+
482
+ def _normalize_reference_que_type(raw_type: Any) -> str | None:
483
+ if raw_type is None:
484
+ return None
485
+ if isinstance(raw_type, int):
486
+ return str(raw_type)
487
+ value = str(raw_type).strip()
488
+ if not value:
489
+ return None
490
+ if value.isdigit():
491
+ return value
492
+ try:
493
+ return str(QUESTION_TYPE_MAP[FieldType(value)])
494
+ except ValueError:
495
+ return None
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+
6
+
7
+ DEFAULT_ICON_COLOR = "qing-orange"
8
+ DEFAULT_ICON_STYLE_POOL: tuple[tuple[str, str], ...] = (
9
+ ("briefcase", "qing-orange"),
10
+ ("calendar", "emerald"),
11
+ ("files-folder", "azure"),
12
+ ("shield-check", "indigo"),
13
+ ("user-group", "qing-purple"),
14
+ ("chart-square-bar", "blue"),
15
+ ("clock", "pink"),
16
+ ("document-text", "green"),
17
+ ("home", "orange"),
18
+ ("globe", "red"),
19
+ )
20
+ LEGACY_EX_ICON_MAP = {
21
+ "ex-alert-outlined": "exclamation-circle",
22
+ "ex-clock-outlined": "clock",
23
+ "ex-folder-outlined": "folder",
24
+ "ex-form-outlined": "template",
25
+ "ex-home-outlined": "home",
26
+ "ex-user-outlined": "user",
27
+ }
28
+
29
+ ICON_ALIAS_MAP = {
30
+ "archive-box": "template",
31
+ "beaker": "beaker",
32
+ "briefcase": "briefcase",
33
+ "building-office": "office-building",
34
+ "calendar": "calendar",
35
+ "chat": "chat",
36
+ "chat-bubble-left-right": "chat",
37
+ "chart-bar": "chart-bar",
38
+ "chart-square-bar": "chart-square-bar",
39
+ "check-badge": "badge-check",
40
+ "check-circle": "check-circle",
41
+ "clipboard-check": "clipboard-check",
42
+ "clock": "clock",
43
+ "cube": "cube-transparent",
44
+ "document-text": "document-text",
45
+ "folder": "files-folder",
46
+ "home": "home",
47
+ "office-building": "office-building",
48
+ "shield-check": "shield-check",
49
+ "template": "template",
50
+ "user": "user",
51
+ "user-group": "user-group",
52
+ "users": "user-group",
53
+ "view-grid": "view-grid",
54
+ "warning": "exclamation-circle",
55
+ }
56
+
57
+
58
+ def encode_workspace_icon(
59
+ *,
60
+ icon: str | None,
61
+ color: str | None,
62
+ title: str,
63
+ fallback_icon_name: str | None = None,
64
+ ) -> str:
65
+ if icon and _looks_like_icon_json(icon):
66
+ return icon
67
+ normalized_icon_name = normalize_workspace_icon_name(icon or fallback_icon_name)
68
+ icon_name = normalized_icon_name
69
+ icon_color = color or DEFAULT_ICON_COLOR
70
+ if icon_name:
71
+ return json.dumps(
72
+ {
73
+ "icon": None,
74
+ "iconColor": icon_color,
75
+ "iconName": icon_name,
76
+ "iconText": None,
77
+ },
78
+ ensure_ascii=False,
79
+ separators=(",", ":"),
80
+ )
81
+ icon_text = (title or "未")[:1]
82
+ return json.dumps(
83
+ {
84
+ "iconColor": icon_color,
85
+ "iconText": icon_text,
86
+ },
87
+ ensure_ascii=False,
88
+ separators=(",", ":"),
89
+ )
90
+
91
+
92
+ def choose_default_workspace_icon_style(*, title: str) -> tuple[str, str]:
93
+ seed_text = (title or "未命名").strip() or "未命名"
94
+ digest = 0
95
+ for index, char in enumerate(seed_text):
96
+ digest = (digest * 131 + ord(char) + index) % 1_000_003
97
+ return DEFAULT_ICON_STYLE_POOL[digest % len(DEFAULT_ICON_STYLE_POOL)]
98
+
99
+
100
+ def parse_workspace_icon(value: str | None) -> tuple[str | None, str | None, str | None]:
101
+ if not value:
102
+ return None, None, None
103
+ stripped = str(value).strip()
104
+ if not stripped:
105
+ return None, None, None
106
+ if _looks_like_icon_json(stripped):
107
+ try:
108
+ payload = json.loads(stripped)
109
+ except Exception:
110
+ return normalize_workspace_icon_name(stripped), None, None
111
+ icon_name = normalize_workspace_icon_name(payload.get("iconName"))
112
+ icon_color = str(payload.get("iconColor") or "").strip() or None
113
+ icon_text = str(payload.get("iconText") or "").strip() or None
114
+ return icon_name, icon_color, icon_text
115
+ return normalize_workspace_icon_name(stripped), None, None
116
+
117
+
118
+ def encode_workspace_icon_with_defaults(
119
+ *,
120
+ icon: str | None,
121
+ color: str | None,
122
+ title: str,
123
+ fallback_icon_name: str | None = None,
124
+ existing_icon: str | None = None,
125
+ ) -> str:
126
+ if icon and _looks_like_icon_json(icon):
127
+ return icon
128
+ if not icon and not color and existing_icon:
129
+ existing_payload = str(existing_icon).strip()
130
+ if existing_payload:
131
+ return existing_payload
132
+ existing_icon_name, existing_icon_color, _ = parse_workspace_icon(existing_icon)
133
+ default_icon, default_color = choose_default_workspace_icon_style(title=title)
134
+ resolved_icon = icon or existing_icon_name
135
+ resolved_color = color or existing_icon_color
136
+ if not resolved_icon:
137
+ if icon is None and color is None:
138
+ resolved_icon = default_icon
139
+ else:
140
+ resolved_icon = fallback_icon_name or default_icon
141
+ if not resolved_color:
142
+ resolved_color = default_color
143
+ return encode_workspace_icon(
144
+ icon=resolved_icon,
145
+ color=resolved_color,
146
+ title=title,
147
+ fallback_icon_name=fallback_icon_name,
148
+ )
149
+
150
+
151
+ def normalize_workspace_icon_name(icon: str | None) -> str | None:
152
+ if not icon:
153
+ return None
154
+ if _looks_like_icon_json(icon):
155
+ try:
156
+ payload = json.loads(icon)
157
+ except Exception:
158
+ return "template"
159
+ return normalize_workspace_icon_name(payload.get("iconName")) or "template"
160
+ normalized = icon.strip().lower()
161
+ if normalized in LEGACY_EX_ICON_MAP:
162
+ return LEGACY_EX_ICON_MAP[normalized]
163
+ if normalized in ICON_ALIAS_MAP:
164
+ return ICON_ALIAS_MAP[normalized]
165
+ for token in normalized.replace("_", "-").split("-"):
166
+ if token in ICON_ALIAS_MAP:
167
+ return ICON_ALIAS_MAP[token]
168
+ if "folder" in normalized or "package" in normalized:
169
+ return "files-folder"
170
+ if "home" in normalized:
171
+ return "home"
172
+ if "portal" in normalized or "dashboard" in normalized:
173
+ return "view-grid"
174
+ if "user" in normalized or "member" in normalized or "contact" in normalized:
175
+ return "user"
176
+ if "clock" in normalized or "time" in normalized or "schedule" in normalized or "log" in normalized:
177
+ return "clock"
178
+ if "risk" in normalized or "alert" in normalized or "warn" in normalized or "safety" in normalized:
179
+ return "exclamation-circle"
180
+ if re.fullmatch(r"[a-z0-9-]+", normalized):
181
+ return normalized
182
+ return "template"
183
+
184
+
185
+ def _looks_like_icon_json(value: str) -> bool:
186
+ stripped = value.strip()
187
+ return stripped.startswith("{") and stripped.endswith("}")