@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6

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 (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/record.py +20 -0
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +139 -4
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +2 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
  21. package/src/qingflow_mcp/errors.py +2 -2
  22. package/src/qingflow_mcp/export_store.py +14 -0
  23. package/src/qingflow_mcp/public_surface.py +6 -0
  24. package/src/qingflow_mcp/response_trim.py +40 -1
  25. package/src/qingflow_mcp/server.py +22 -0
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +104 -8
  28. package/src/qingflow_mcp/session_store.py +57 -6
  29. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  30. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  31. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  32. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  33. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  34. package/src/qingflow_mcp/tools/record_tools.py +515 -45
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
@@ -1,14 +1,38 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import sys
5
+ from typing import Any
4
6
 
5
7
  from ..context import CliContext
6
- from .common import load_object_arg
8
+ from ..interaction import cancelled_result, resolve_interactive_selection, resolve_interactive_text_input
9
+ from ..terminal_ui import SelectionOption
10
+ from .common import load_object_arg, raise_config_error
11
+
12
+
13
+ TASK_ACTION_LABELS = {
14
+ "approve": "通过",
15
+ "reject": "驳回",
16
+ "rollback": "退回",
17
+ "transfer": "转交",
18
+ "save_only": "仅保存",
19
+ "urge": "催办",
20
+ }
21
+
22
+ TASK_ACTION_SUCCESS_LABELS = {
23
+ "approve": "已通过",
24
+ "reject": "已驳回",
25
+ "rollback": "已退回",
26
+ "transfer": "已转交",
27
+ "save_only": "已保存",
28
+ "urge": "已催办",
29
+ }
7
30
 
8
31
 
9
32
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
10
33
  parser = subparsers.add_parser("task", help="待办与流程上下文")
11
- task_subparsers = parser.add_subparsers(dest="task_command", required=True)
34
+ task_subparsers = parser.add_subparsers(dest="task_command", required=False)
35
+ parser.set_defaults(handler=_handle_task_workbench, format_hint="task_workbench", _task_parser=parser)
12
36
 
13
37
  list_parser = task_subparsers.add_parser("list", help="列出待办")
14
38
  list_parser.add_argument("--task-box", default="todo")
@@ -37,7 +61,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
37
61
  action.add_argument("--app-key")
38
62
  action.add_argument("--record-id")
39
63
  action.add_argument("--workflow-node-id", type=int)
40
- action.add_argument("--action", required=True)
64
+ action.add_argument("--action", help="不传时在交互终端中选择当前待办可执行动作")
41
65
  action.add_argument("--payload-file")
42
66
  action.add_argument("--fields-file")
43
67
  action.set_defaults(
@@ -51,7 +75,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
51
75
  report.add_argument("--app-key")
52
76
  report.add_argument("--record-id")
53
77
  report.add_argument("--workflow-node-id", type=int)
54
- report.add_argument("--report-id", required=True, type=int)
78
+ report.add_argument("--report-id", type=int, help="不传时在交互终端中选择关联报表")
55
79
  report.add_argument("--page", type=int, default=1)
56
80
  report.add_argument("--page-size", type=int, default=20)
57
81
  report.set_defaults(handler=_handle_report, format_hint="task_associated_report_detail_get")
@@ -77,11 +101,40 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
77
101
  )
78
102
 
79
103
 
104
+ def _handle_task_workbench(args: argparse.Namespace, context: CliContext) -> dict:
105
+ if bool(args.json) or not _has_interactive_terminal(args):
106
+ _raise_task_subcommand_required(args)
107
+
108
+ banner: str | None = None
109
+ while True:
110
+ title = _build_task_workbench_list_title(banner)
111
+ selection = _choose_todo_task_interactively(args, context, tool_name="task", title=title)
112
+ banner = None
113
+ if selection.status == "empty":
114
+ return {"status": "success", "message": "当前没有待办"}
115
+ if selection.status == "cancelled":
116
+ return cancelled_result("已退出")
117
+ if selection.status != "selected":
118
+ _raise_task_subcommand_required(args)
119
+
120
+ args.task_id = str(selection.value or "")
121
+ args.app_key = ""
122
+ args.record_id = ""
123
+ args.workflow_node_id = 0
124
+
125
+ outcome, payload = _run_task_workbench_task_loop(args, context)
126
+ if outcome == "refresh":
127
+ banner = str(payload or "").strip() or None
128
+ continue
129
+ if outcome == "back":
130
+ continue
131
+ return payload if isinstance(payload, dict) else cancelled_result("已退出")
132
+
133
+
80
134
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
81
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
82
- raise RuntimeError(
83
- '{"category":"config","message":"task get requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
84
- )
135
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task get")
136
+ if isinstance(selection_result, dict):
137
+ return selection_result
85
138
  return context.task.task_get(
86
139
  profile=args.profile,
87
140
  task_id=args.task_id,
@@ -94,10 +147,21 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
94
147
 
95
148
 
96
149
  def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
97
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
98
- raise RuntimeError(
99
- '{"category":"config","message":"task action requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
100
- )
150
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task action")
151
+ if isinstance(selection_result, dict):
152
+ return selection_result
153
+ payload = load_object_arg(args.payload_file, option_name="--payload-file") or {}
154
+ fields = load_object_arg(args.fields_file, option_name="--fields-file") or {}
155
+ task_context: dict[str, Any] | None = None
156
+ if not str(args.action or "").strip():
157
+ task_context = _load_task_action_context(args, context)
158
+ action_selection = _resolve_task_action_or_select(args, task_context, fields=fields)
159
+ if isinstance(action_selection, dict):
160
+ return action_selection
161
+ payload_selection = _resolve_action_payload_or_select(args, task_context, payload=payload)
162
+ if _is_cancelled_result(payload_selection):
163
+ return payload_selection
164
+ payload = payload_selection
101
165
  return context.task.task_action_execute(
102
166
  profile=args.profile,
103
167
  task_id=args.task_id,
@@ -105,16 +169,15 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
105
169
  record_id=args.record_id or "",
106
170
  workflow_node_id=int(args.workflow_node_id or 0),
107
171
  action=args.action,
108
- payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
109
- fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
172
+ payload=payload,
173
+ fields=fields,
110
174
  )
111
175
 
112
176
 
113
177
  def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
114
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
115
- raise RuntimeError(
116
- '{"category":"config","message":"task log requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
117
- )
178
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task log")
179
+ if isinstance(selection_result, dict):
180
+ return selection_result
118
181
  return context.task.task_workflow_log_get(
119
182
  profile=args.profile,
120
183
  task_id=args.task_id,
@@ -125,10 +188,12 @@ def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
125
188
 
126
189
 
127
190
  def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
128
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
129
- raise RuntimeError(
130
- '{"category":"config","message":"task report requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
131
- )
191
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task report")
192
+ if isinstance(selection_result, dict):
193
+ return selection_result
194
+ report_selection = _resolve_report_id_or_select(args, context)
195
+ if isinstance(report_selection, dict):
196
+ return report_selection
132
197
  return context.task.task_associated_report_detail_get(
133
198
  profile=args.profile,
134
199
  task_id=args.task_id,
@@ -139,3 +204,560 @@ def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
139
204
  page=int(args.page),
140
205
  page_size=int(args.page_size),
141
206
  )
207
+
208
+
209
+ def _run_task_workbench_task_loop(args: argparse.Namespace, context: CliContext) -> tuple[str, dict | str]:
210
+ while True:
211
+ task_context = _load_task_workbench_context(args, context)
212
+ detail_selection = _show_task_workbench_detail(args, task_context)
213
+ if detail_selection.status == "cancelled":
214
+ return "back", ""
215
+ selected_value = str(detail_selection.value or "").strip()
216
+ if selected_value == "exit":
217
+ return "result", cancelled_result("已退出")
218
+ if selected_value == "back":
219
+ return "back", ""
220
+ if not selected_value:
221
+ continue
222
+
223
+ args.action = selected_value
224
+ payload_selection = _resolve_action_payload_or_select(args, task_context, payload={})
225
+ if _is_cancelled_result(payload_selection):
226
+ continue
227
+
228
+ result = context.task.task_action_execute(
229
+ profile=args.profile,
230
+ task_id=args.task_id,
231
+ app_key=args.app_key or "",
232
+ record_id=args.record_id or "",
233
+ workflow_node_id=int(args.workflow_node_id or 0),
234
+ action=args.action,
235
+ payload=payload_selection,
236
+ fields={},
237
+ )
238
+ if _is_success_result(result):
239
+ return "refresh", _task_action_success_label(str(args.action or "").strip().lower())
240
+ return "result", result
241
+
242
+
243
+ def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContext, *, tool_name: str) -> dict | None:
244
+ if (args.task_id or "").strip():
245
+ return None
246
+ has_app_key = bool((args.app_key or "").strip())
247
+ has_record_id = bool((args.record_id or "").strip())
248
+ has_workflow_node_id = int(args.workflow_node_id or 0) > 0
249
+ if has_app_key and has_record_id and has_workflow_node_id:
250
+ return None
251
+ if has_app_key or has_record_id or has_workflow_node_id:
252
+ raise_config_error(
253
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
254
+ fix_hint="Either pass `--task-id TASK_ID`, or provide the full locator triple `--app-key --record-id --workflow-node-id`.",
255
+ )
256
+
257
+ selection = _choose_todo_task_interactively(args, context, tool_name=tool_name)
258
+ if selection.status == "unavailable":
259
+ raise_config_error(
260
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
261
+ fix_hint=(
262
+ "Retry in an interactive terminal to choose from current todo tasks, "
263
+ "or pass `--task-id TASK_ID` explicitly."
264
+ ),
265
+ )
266
+ if selection.status == "empty":
267
+ raise_config_error(
268
+ selection.message or f"{tool_name} could not open a selector because no current todo tasks are available.",
269
+ fix_hint="Run `task list` to confirm current todo tasks, or retry later with `--task-id TASK_ID`.",
270
+ )
271
+ if selection.status == "cancelled":
272
+ return cancelled_result(selection.message or "已取消")
273
+
274
+ args.task_id = str(selection.value or "")
275
+ return None
276
+
277
+
278
+ def _choose_todo_task_interactively(
279
+ args: argparse.Namespace,
280
+ context: CliContext,
281
+ *,
282
+ tool_name: str,
283
+ title: str = "选择待办",
284
+ ):
285
+ def load_options() -> list[SelectionOption[str]]:
286
+ result = context.task.task_list(
287
+ profile=args.profile,
288
+ task_box="todo",
289
+ flow_status="all",
290
+ app_key=None,
291
+ workflow_node_id=None,
292
+ query=None,
293
+ page=1,
294
+ page_size=100,
295
+ )
296
+ data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
297
+ items = data.get("items") if isinstance(data.get("items"), list) else []
298
+ options: list[SelectionOption[str]] = []
299
+ for item in items:
300
+ if not isinstance(item, dict):
301
+ continue
302
+ task_id = str(item.get("task_id") or "").strip()
303
+ if not task_id:
304
+ continue
305
+ node_name = str(item.get("workflow_node_name") or "未命名节点").strip() or "未命名节点"
306
+ app_name = str(item.get("app_name") or item.get("app_key") or "未知应用").strip() or "未知应用"
307
+ label = f"{node_name} / {app_name}"
308
+ hint_parts = [f"task_id={task_id}"]
309
+ apply_time = str(item.get("apply_time") or "").strip()
310
+ if apply_time:
311
+ hint_parts.append(apply_time)
312
+ summary_fields = item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else []
313
+ preview_parts: list[str] = []
314
+ for summary in summary_fields[:2]:
315
+ if not isinstance(summary, dict):
316
+ continue
317
+ title = str(summary.get("title") or "").strip()
318
+ answer = str(summary.get("answer") or "").strip()
319
+ if title and answer:
320
+ preview_parts.append(f"{title}: {answer}")
321
+ if preview_parts:
322
+ hint_parts.append("; ".join(preview_parts))
323
+ options.append(SelectionOption(value=task_id, label=label, hint=" · ".join(hint_parts)))
324
+ return options
325
+
326
+ return resolve_interactive_selection(
327
+ args,
328
+ title=title,
329
+ unavailable_message=(
330
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id"
331
+ ),
332
+ empty_message=f"{tool_name} could not open a selector because no current todo tasks are available.",
333
+ load_options=load_options,
334
+ )
335
+
336
+
337
+ def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext) -> dict | None:
338
+ if int(args.report_id or 0) > 0:
339
+ return None
340
+
341
+ selection = _choose_associated_report_interactively(args, context)
342
+ if selection.status == "unavailable":
343
+ raise_config_error(
344
+ "task report requires --report-id, or an interactive terminal to choose a visible associated report",
345
+ fix_hint="Pass `--report-id REPORT_ID`, or run in an interactive terminal and choose from visible associated reports.",
346
+ )
347
+ if selection.status == "empty":
348
+ raise_config_error(
349
+ selection.message or "task report could not open a selector because the selected task has no visible associated reports.",
350
+ fix_hint="Run `task get --task-id TASK_ID` to inspect `extras.associated_reports`, or choose another task.",
351
+ )
352
+ if selection.status == "cancelled":
353
+ return cancelled_result(selection.message or "已取消")
354
+
355
+ args.report_id = int(selection.value or 0)
356
+ return None
357
+
358
+
359
+ def _load_task_action_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
360
+ return context.task.task_get(
361
+ profile=args.profile,
362
+ task_id=args.task_id,
363
+ app_key=args.app_key or "",
364
+ record_id=args.record_id or "",
365
+ workflow_node_id=int(args.workflow_node_id or 0),
366
+ include_candidates=True,
367
+ include_associated_reports=False,
368
+ )
369
+
370
+
371
+ def _load_task_workbench_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
372
+ return context.task.task_get(
373
+ profile=args.profile,
374
+ task_id=args.task_id,
375
+ app_key=args.app_key or "",
376
+ record_id=args.record_id or "",
377
+ workflow_node_id=int(args.workflow_node_id or 0),
378
+ include_candidates=True,
379
+ include_associated_reports=True,
380
+ )
381
+
382
+
383
+ def _resolve_task_action_or_select(
384
+ args: argparse.Namespace,
385
+ task_context: dict[str, Any],
386
+ *,
387
+ fields: dict[str, Any],
388
+ ) -> dict | None:
389
+ selection = _choose_task_action_interactively(args, task_context, fields=fields)
390
+ if selection.status == "unavailable":
391
+ raise_config_error(
392
+ "task action requires --action, or an interactive terminal to choose the current task action",
393
+ fix_hint="Pass `--action ACTION`, or run `qingflow task action` in an interactive terminal and choose from available actions.",
394
+ )
395
+ if selection.status == "empty":
396
+ raise_config_error(
397
+ selection.message or "task action could not open an action selector because no interactive actions are available.",
398
+ fix_hint="Run `task get --task-id TASK_ID` to inspect available_actions, or pass a supported `--action` explicitly.",
399
+ )
400
+ if selection.status == "cancelled":
401
+ return cancelled_result(selection.message or "已取消")
402
+
403
+ args.action = str(selection.value or "").strip()
404
+ return None
405
+
406
+
407
+ def _choose_task_action_interactively(
408
+ args: argparse.Namespace,
409
+ task_context: dict[str, Any],
410
+ *,
411
+ fields: dict[str, Any],
412
+ title: str = "选择操作",
413
+ ):
414
+ def load_options() -> list[SelectionOption[str]]:
415
+ return _task_interactive_action_options(task_context, fields=fields)
416
+
417
+ return resolve_interactive_selection(
418
+ args,
419
+ title=title,
420
+ unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
421
+ empty_message="task action could not open an action selector because no interactive actions are available.",
422
+ load_options=load_options,
423
+ )
424
+
425
+
426
+ def _show_task_workbench_detail(args: argparse.Namespace, task_context: dict[str, Any]):
427
+ def load_options() -> list[SelectionOption[str]]:
428
+ options = _task_interactive_action_options(task_context, fields={})
429
+ if options:
430
+ options.append(SelectionOption(value="back", label="返回列表", hint="回到待办列表"))
431
+ return options
432
+ return [
433
+ SelectionOption(value="back", label="返回列表", hint="当前节点没有可执行动作"),
434
+ SelectionOption(value="exit", label="退出工作台", hint="结束当前待办工作台"),
435
+ ]
436
+
437
+ return resolve_interactive_selection(
438
+ args,
439
+ title=_build_task_detail_title(task_context),
440
+ unavailable_message="task workbench requires an interactive terminal",
441
+ empty_message="task workbench could not open the task detail view.",
442
+ load_options=load_options,
443
+ )
444
+
445
+
446
+ def _resolve_action_payload_or_select(
447
+ args: argparse.Namespace,
448
+ task_context: dict[str, Any],
449
+ *,
450
+ payload: dict[str, Any],
451
+ ) -> dict[str, Any] | dict[str, str]:
452
+ action = str(args.action or "").strip().lower()
453
+ if action == "reject" and not _extract_audit_feedback(payload):
454
+ feedback = resolve_interactive_text_input(
455
+ args,
456
+ prompt="请输入驳回原因并回车,留空取消: ",
457
+ unavailable_message="task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
458
+ )
459
+ if feedback.status == "unavailable":
460
+ raise_config_error(
461
+ "task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
462
+ fix_hint="Pass `--payload-file` with `{\"audit_feedback\": \"...\"}`, or retry in an interactive terminal.",
463
+ )
464
+ if feedback.status == "cancelled":
465
+ return cancelled_result(feedback.message or "已取消")
466
+ payload["audit_feedback"] = str(feedback.value or "").strip()
467
+ return payload
468
+
469
+ if action == "rollback" and _extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId")) <= 0:
470
+ selection = _choose_rollback_candidate_interactively(args, task_context)
471
+ if selection.status == "unavailable":
472
+ raise_config_error(
473
+ "task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
474
+ fix_hint="Pass `--payload-file` with `{\"target_workflow_node_id\": NODE_ID}`, or retry in an interactive terminal.",
475
+ )
476
+ if selection.status == "empty":
477
+ raise_config_error(
478
+ selection.message or "task rollback could not open a selector because no rollback candidates are visible.",
479
+ fix_hint="Run `task get --task-id TASK_ID` to inspect rollback candidates, or choose another action.",
480
+ )
481
+ if selection.status == "cancelled":
482
+ return cancelled_result(selection.message or "已取消")
483
+ payload["target_workflow_node_id"] = int(selection.value or 0)
484
+ return payload
485
+
486
+ if action == "transfer" and _extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId")) <= 0:
487
+ selection = _choose_transfer_candidate_interactively(args, task_context)
488
+ if selection.status == "unavailable":
489
+ raise_config_error(
490
+ "task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
491
+ fix_hint="Pass `--payload-file` with `{\"target_member_id\": UID}`, or retry in an interactive terminal.",
492
+ )
493
+ if selection.status == "empty":
494
+ raise_config_error(
495
+ selection.message or "task transfer could not open a selector because no transfer candidates are visible.",
496
+ fix_hint="Run `task get --task-id TASK_ID` to inspect transfer candidates, or choose another action.",
497
+ )
498
+ if selection.status == "cancelled":
499
+ return cancelled_result(selection.message or "已取消")
500
+ payload["target_member_id"] = int(selection.value or 0)
501
+ return payload
502
+
503
+ return payload
504
+
505
+
506
+ def _choose_rollback_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
507
+ extras = _task_context_extras(task_context)
508
+
509
+ def load_options() -> list[SelectionOption[int]]:
510
+ rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
511
+ items = rollback_candidates.get("items") if isinstance(rollback_candidates.get("items"), list) else []
512
+ options: list[SelectionOption[int]] = []
513
+ for item in items:
514
+ if not isinstance(item, dict):
515
+ continue
516
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
517
+ if workflow_node_id <= 0:
518
+ continue
519
+ workflow_node_name = str(item.get("workflow_node_name") or f"节点 {workflow_node_id}").strip() or f"节点 {workflow_node_id}"
520
+ options.append(
521
+ SelectionOption(
522
+ value=workflow_node_id,
523
+ label=workflow_node_name,
524
+ hint=f"workflow_node_id={workflow_node_id}",
525
+ )
526
+ )
527
+ return options
528
+
529
+ return resolve_interactive_selection(
530
+ args,
531
+ title="选择退回节点",
532
+ unavailable_message="task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
533
+ empty_message="task rollback could not open a selector because no rollback candidates are visible.",
534
+ load_options=load_options,
535
+ )
536
+
537
+
538
+ def _choose_transfer_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
539
+ extras = _task_context_extras(task_context)
540
+
541
+ def load_options() -> list[SelectionOption[int]]:
542
+ transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
543
+ items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
544
+ options: list[SelectionOption[int]] = []
545
+ for item in items:
546
+ if not isinstance(item, dict):
547
+ continue
548
+ uid = int(item.get("uid") or 0)
549
+ if uid <= 0:
550
+ continue
551
+ name = str(item.get("name") or f"成员 {uid}").strip() or f"成员 {uid}"
552
+ hint_parts = [f"uid={uid}"]
553
+ department_name = str(item.get("department_name") or "").strip()
554
+ email = str(item.get("email") or "").strip()
555
+ if department_name:
556
+ hint_parts.append(department_name)
557
+ if email:
558
+ hint_parts.append(email)
559
+ options.append(SelectionOption(value=uid, label=name, hint=" · ".join(hint_parts)))
560
+ return options
561
+
562
+ return resolve_interactive_selection(
563
+ args,
564
+ title="选择转交成员",
565
+ unavailable_message="task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
566
+ empty_message="task transfer could not open a selector because no transfer candidates are visible.",
567
+ load_options=load_options,
568
+ )
569
+
570
+
571
+ def _task_context_extras(task_context: dict[str, Any]) -> dict[str, Any]:
572
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
573
+ return data.get("extras") if isinstance(data.get("extras"), dict) else {}
574
+
575
+
576
+ def _count_candidate_items(value: Any) -> int:
577
+ if not isinstance(value, dict):
578
+ return 0
579
+ items = value.get("items")
580
+ return len(items) if isinstance(items, list) else 0
581
+
582
+
583
+ def _extract_audit_feedback(payload: dict[str, Any]) -> str | None:
584
+ for key in ("audit_feedback", "auditFeedback"):
585
+ value = payload.get(key)
586
+ if isinstance(value, str) and value.strip():
587
+ return value.strip()
588
+ return None
589
+
590
+
591
+ def _extract_positive_int(payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
592
+ for candidate in (key, *aliases):
593
+ value = payload.get(candidate)
594
+ if isinstance(value, int) and value > 0:
595
+ return value
596
+ return 0
597
+
598
+
599
+ def _is_cancelled_result(value: Any) -> bool:
600
+ return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
601
+
602
+
603
+ def _is_success_result(result: dict[str, Any]) -> bool:
604
+ if not isinstance(result, dict):
605
+ return False
606
+ if result.get("ok") is False:
607
+ return False
608
+ return str(result.get("status") or "").strip().lower() == "success"
609
+
610
+
611
+ def _task_action_success_label(action: str) -> str:
612
+ return TASK_ACTION_SUCCESS_LABELS.get(action, "已完成")
613
+
614
+
615
+ def _build_task_workbench_list_title(banner: str | None) -> str:
616
+ lines = ["待办工作台", "选择一条待办后查看详情并执行操作"]
617
+ if banner:
618
+ lines.extend(["", f"上一条结果:{banner}"])
619
+ return "\n".join(lines)
620
+
621
+
622
+ def _build_task_detail_title(task_context: dict[str, Any]) -> str:
623
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
624
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
625
+ record_summary = data.get("record_summary") if isinstance(data.get("record_summary"), dict) else {}
626
+ available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
627
+ editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
628
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
629
+ initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
630
+ all_fields = record_summary.get("all_fields") if isinstance(record_summary.get("all_fields"), dict) else {}
631
+ core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
632
+ field_map = all_fields or core_fields
633
+ action_labels = ", ".join(TASK_ACTION_LABELS.get(str(item), str(item)) for item in available_actions if str(item).strip()) or "-"
634
+ lines = [
635
+ "待办详情",
636
+ f"Task ID: {task.get('task_id') or '-'}",
637
+ f"应用: {task.get('app_name') or '-'}",
638
+ f"节点: {task.get('workflow_node_name') or '-'}",
639
+ f"发起人: {_task_initiator_label(initiator)}",
640
+ f"状态: {record_summary.get('apply_status') or '-'}",
641
+ f"申请编号: {record_summary.get('custom_apply_num') or record_summary.get('apply_num') or '-'}",
642
+ f"提交时间: {record_summary.get('apply_time') or '-'}",
643
+ f"可执行动作: {action_labels}",
644
+ f"可编辑字段: {len(editable_fields)}",
645
+ (
646
+ "附加信息: "
647
+ f"报表 {_count_candidate_items(extras.get('associated_reports'))} / "
648
+ f"退回 {_count_candidate_items(extras.get('rollback_candidates'))} / "
649
+ f"转交 {_count_candidate_items(extras.get('transfer_candidates'))}"
650
+ ),
651
+ ]
652
+ if field_map:
653
+ lines.append("")
654
+ lines.append(f"字段值({len(field_map)}):")
655
+ for key, value in field_map.items():
656
+ lines.append(f"- {key}: {_full_display(value)}")
657
+ return "\n".join(lines)
658
+
659
+
660
+ def _task_initiator_label(initiator: dict[str, Any]) -> str:
661
+ for key in ("display_name", "displayName", "name", "email", "uid"):
662
+ value = initiator.get(key)
663
+ if value not in (None, ""):
664
+ return str(value)
665
+ return "-"
666
+
667
+
668
+ def _task_interactive_action_options(task_context: dict[str, Any], *, fields: dict[str, Any]) -> list[SelectionOption[str]]:
669
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
670
+ available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
671
+ action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
672
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
673
+ feedback_required = {
674
+ str(item).strip()
675
+ for item in (action_metadata.get("feedback_required_for") or [])
676
+ if str(item).strip()
677
+ }
678
+ rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
679
+ transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
680
+
681
+ options: list[SelectionOption[str]] = []
682
+ for action in available_actions:
683
+ if action == "save_only" and not fields:
684
+ continue
685
+ label = TASK_ACTION_LABELS.get(action, action)
686
+ hint_parts = [action]
687
+ if action in feedback_required:
688
+ hint_parts.append("需要理由")
689
+ if action == "rollback":
690
+ hint_parts.append(f"可退回节点 {rollback_count}")
691
+ if action == "transfer":
692
+ hint_parts.append(f"可转交成员 {transfer_count}")
693
+ if action == "save_only":
694
+ hint_parts.append("仅保存字段,不推进流程")
695
+ options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
696
+ return options
697
+
698
+
699
+ def _full_display(value: Any) -> str:
700
+ if value in (None, ""):
701
+ return "-"
702
+ if isinstance(value, list):
703
+ normalized = [str(item) for item in value if item not in (None, "")]
704
+ return " / ".join(normalized) if normalized else "-"
705
+ return str(value)
706
+
707
+
708
+ def _has_interactive_terminal(args: argparse.Namespace) -> bool:
709
+ input_stream = getattr(args, "_stdin", None)
710
+ return bool(getattr(input_stream, "isatty", lambda: False)())
711
+
712
+
713
+ def _raise_task_subcommand_required(args: argparse.Namespace) -> None:
714
+ stream = getattr(args, "_stderr_stream", None) or sys.stderr
715
+ task_parser = getattr(args, "_task_parser", None)
716
+ if isinstance(task_parser, argparse.ArgumentParser):
717
+ task_parser.print_usage(stream)
718
+ stream.write(f"{task_parser.prog}: error: the following arguments are required: task_command\n")
719
+ else:
720
+ stream.write("qingflow task: error: the following arguments are required: task_command\n")
721
+ raise SystemExit(2)
722
+
723
+
724
+ def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
725
+ def load_options() -> list[SelectionOption[int]]:
726
+ result = context.task.task_get(
727
+ profile=args.profile,
728
+ task_id=args.task_id,
729
+ app_key=args.app_key or "",
730
+ record_id=args.record_id or "",
731
+ workflow_node_id=int(args.workflow_node_id or 0),
732
+ include_candidates=False,
733
+ include_associated_reports=True,
734
+ )
735
+ data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
736
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
737
+ associated_reports = extras.get("associated_reports") if isinstance(extras.get("associated_reports"), dict) else {}
738
+ items = associated_reports.get("items") if isinstance(associated_reports.get("items"), list) else []
739
+ options: list[SelectionOption[int]] = []
740
+ for item in items:
741
+ if not isinstance(item, dict):
742
+ continue
743
+ report_id = int(item.get("report_id") or 0)
744
+ if report_id <= 0:
745
+ continue
746
+ chart_name = str(item.get("chart_name") or f"报表 {report_id}").strip() or f"报表 {report_id}"
747
+ graph_type = str(item.get("graph_type") or "chart").strip() or "chart"
748
+ options.append(
749
+ SelectionOption(
750
+ value=report_id,
751
+ label=chart_name,
752
+ hint=f"report_id={report_id} · {graph_type}",
753
+ )
754
+ )
755
+ return options
756
+
757
+ return resolve_interactive_selection(
758
+ args,
759
+ title="选择关联报表",
760
+ unavailable_message="task report requires --report-id, or an interactive terminal to choose a visible associated report",
761
+ empty_message="task report could not open a selector because the selected task has no visible associated reports.",
762
+ load_options=load_options,
763
+ )