@josephyan/qingflow-app-user-mcp 0.2.0-beta.1003 → 0.2.0-beta.1005
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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/commands/task.py +492 -11
- package/src/qingflow_mcp/cli/formatters.py +8 -0
- package/src/qingflow_mcp/cli/interaction.py +22 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1005
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1005 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,16 +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 ..interaction import cancelled_result, resolve_interactive_selection
|
|
8
|
+
from ..interaction import cancelled_result, resolve_interactive_selection, resolve_interactive_text_input
|
|
7
9
|
from ..terminal_ui import SelectionOption
|
|
8
10
|
from .common import load_object_arg, raise_config_error
|
|
9
11
|
|
|
10
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
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
11
32
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
12
33
|
parser = subparsers.add_parser("task", help="待办与流程上下文")
|
|
13
|
-
task_subparsers = parser.add_subparsers(dest="task_command", required=
|
|
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)
|
|
14
36
|
|
|
15
37
|
list_parser = task_subparsers.add_parser("list", help="列出待办")
|
|
16
38
|
list_parser.add_argument("--task-box", default="todo")
|
|
@@ -39,7 +61,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
39
61
|
action.add_argument("--app-key")
|
|
40
62
|
action.add_argument("--record-id")
|
|
41
63
|
action.add_argument("--workflow-node-id", type=int)
|
|
42
|
-
action.add_argument("--action",
|
|
64
|
+
action.add_argument("--action", help="不传时在交互终端中选择当前待办可执行动作")
|
|
43
65
|
action.add_argument("--payload-file")
|
|
44
66
|
action.add_argument("--fields-file")
|
|
45
67
|
action.set_defaults(
|
|
@@ -79,6 +101,36 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
79
101
|
)
|
|
80
102
|
|
|
81
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
|
+
|
|
82
134
|
def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
83
135
|
selection_result = _resolve_task_locator_or_select(args, context, tool_name="task get")
|
|
84
136
|
if isinstance(selection_result, dict):
|
|
@@ -95,10 +147,21 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
95
147
|
|
|
96
148
|
|
|
97
149
|
def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
102
165
|
return context.task.task_action_execute(
|
|
103
166
|
profile=args.profile,
|
|
104
167
|
task_id=args.task_id,
|
|
@@ -106,8 +169,8 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
106
169
|
record_id=args.record_id or "",
|
|
107
170
|
workflow_node_id=int(args.workflow_node_id or 0),
|
|
108
171
|
action=args.action,
|
|
109
|
-
payload=
|
|
110
|
-
fields=
|
|
172
|
+
payload=payload,
|
|
173
|
+
fields=fields,
|
|
111
174
|
)
|
|
112
175
|
|
|
113
176
|
|
|
@@ -143,6 +206,50 @@ def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
143
206
|
)
|
|
144
207
|
|
|
145
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
|
+
if str(detail_selection.value or "") == "exit":
|
|
216
|
+
return "result", cancelled_result("已退出")
|
|
217
|
+
if str(detail_selection.value or "") != "action":
|
|
218
|
+
return "back", ""
|
|
219
|
+
|
|
220
|
+
action_selection = _choose_task_action_interactively(
|
|
221
|
+
args,
|
|
222
|
+
task_context,
|
|
223
|
+
fields={},
|
|
224
|
+
title=_build_task_action_title(task_context),
|
|
225
|
+
)
|
|
226
|
+
if action_selection.status == "cancelled":
|
|
227
|
+
continue
|
|
228
|
+
if action_selection.status == "empty":
|
|
229
|
+
continue
|
|
230
|
+
if action_selection.status != "selected":
|
|
231
|
+
return "result", cancelled_result("已退出")
|
|
232
|
+
|
|
233
|
+
args.action = str(action_selection.value or "").strip()
|
|
234
|
+
payload_selection = _resolve_action_payload_or_select(args, task_context, payload={})
|
|
235
|
+
if _is_cancelled_result(payload_selection):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
result = context.task.task_action_execute(
|
|
239
|
+
profile=args.profile,
|
|
240
|
+
task_id=args.task_id,
|
|
241
|
+
app_key=args.app_key or "",
|
|
242
|
+
record_id=args.record_id or "",
|
|
243
|
+
workflow_node_id=int(args.workflow_node_id or 0),
|
|
244
|
+
action=args.action,
|
|
245
|
+
payload=payload_selection,
|
|
246
|
+
fields={},
|
|
247
|
+
)
|
|
248
|
+
if _is_success_result(result):
|
|
249
|
+
return "refresh", _task_action_success_label(str(args.action or "").strip().lower())
|
|
250
|
+
return "result", result
|
|
251
|
+
|
|
252
|
+
|
|
146
253
|
def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContext, *, tool_name: str) -> dict | None:
|
|
147
254
|
if (args.task_id or "").strip():
|
|
148
255
|
return None
|
|
@@ -178,7 +285,13 @@ def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContex
|
|
|
178
285
|
return None
|
|
179
286
|
|
|
180
287
|
|
|
181
|
-
def _choose_todo_task_interactively(
|
|
288
|
+
def _choose_todo_task_interactively(
|
|
289
|
+
args: argparse.Namespace,
|
|
290
|
+
context: CliContext,
|
|
291
|
+
*,
|
|
292
|
+
tool_name: str,
|
|
293
|
+
title: str = "选择待办",
|
|
294
|
+
):
|
|
182
295
|
def load_options() -> list[SelectionOption[str]]:
|
|
183
296
|
result = context.task.task_list(
|
|
184
297
|
profile=args.profile,
|
|
@@ -222,7 +335,7 @@ def _choose_todo_task_interactively(args: argparse.Namespace, context: CliContex
|
|
|
222
335
|
|
|
223
336
|
return resolve_interactive_selection(
|
|
224
337
|
args,
|
|
225
|
-
title=
|
|
338
|
+
title=title,
|
|
226
339
|
unavailable_message=(
|
|
227
340
|
f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id"
|
|
228
341
|
),
|
|
@@ -253,6 +366,374 @@ def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext)
|
|
|
253
366
|
return None
|
|
254
367
|
|
|
255
368
|
|
|
369
|
+
def _load_task_action_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
|
|
370
|
+
return context.task.task_get(
|
|
371
|
+
profile=args.profile,
|
|
372
|
+
task_id=args.task_id,
|
|
373
|
+
app_key=args.app_key or "",
|
|
374
|
+
record_id=args.record_id or "",
|
|
375
|
+
workflow_node_id=int(args.workflow_node_id or 0),
|
|
376
|
+
include_candidates=True,
|
|
377
|
+
include_associated_reports=False,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _load_task_workbench_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
|
|
382
|
+
return context.task.task_get(
|
|
383
|
+
profile=args.profile,
|
|
384
|
+
task_id=args.task_id,
|
|
385
|
+
app_key=args.app_key or "",
|
|
386
|
+
record_id=args.record_id or "",
|
|
387
|
+
workflow_node_id=int(args.workflow_node_id or 0),
|
|
388
|
+
include_candidates=True,
|
|
389
|
+
include_associated_reports=True,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _resolve_task_action_or_select(
|
|
394
|
+
args: argparse.Namespace,
|
|
395
|
+
task_context: dict[str, Any],
|
|
396
|
+
*,
|
|
397
|
+
fields: dict[str, Any],
|
|
398
|
+
) -> dict | None:
|
|
399
|
+
selection = _choose_task_action_interactively(args, task_context, fields=fields)
|
|
400
|
+
if selection.status == "unavailable":
|
|
401
|
+
raise_config_error(
|
|
402
|
+
"task action requires --action, or an interactive terminal to choose the current task action",
|
|
403
|
+
fix_hint="Pass `--action ACTION`, or run `qingflow task action` in an interactive terminal and choose from available actions.",
|
|
404
|
+
)
|
|
405
|
+
if selection.status == "empty":
|
|
406
|
+
raise_config_error(
|
|
407
|
+
selection.message or "task action could not open an action selector because no interactive actions are available.",
|
|
408
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect available_actions, or pass a supported `--action` explicitly.",
|
|
409
|
+
)
|
|
410
|
+
if selection.status == "cancelled":
|
|
411
|
+
return cancelled_result(selection.message or "已取消")
|
|
412
|
+
|
|
413
|
+
args.action = str(selection.value or "").strip()
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _choose_task_action_interactively(
|
|
418
|
+
args: argparse.Namespace,
|
|
419
|
+
task_context: dict[str, Any],
|
|
420
|
+
*,
|
|
421
|
+
fields: dict[str, Any],
|
|
422
|
+
title: str = "选择操作",
|
|
423
|
+
):
|
|
424
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
425
|
+
available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
|
|
426
|
+
extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
427
|
+
action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
|
|
428
|
+
feedback_required = {
|
|
429
|
+
str(item).strip()
|
|
430
|
+
for item in (action_metadata.get("feedback_required_for") or [])
|
|
431
|
+
if str(item).strip()
|
|
432
|
+
}
|
|
433
|
+
rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
|
|
434
|
+
transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
|
|
435
|
+
|
|
436
|
+
def load_options() -> list[SelectionOption[str]]:
|
|
437
|
+
options: list[SelectionOption[str]] = []
|
|
438
|
+
for action in available_actions:
|
|
439
|
+
if action == "save_only" and not fields:
|
|
440
|
+
continue
|
|
441
|
+
label = TASK_ACTION_LABELS.get(action, action)
|
|
442
|
+
hint_parts = [action]
|
|
443
|
+
if action in feedback_required:
|
|
444
|
+
hint_parts.append("需要理由")
|
|
445
|
+
if action == "rollback":
|
|
446
|
+
hint_parts.append(f"可退回节点 {rollback_count}")
|
|
447
|
+
if action == "transfer":
|
|
448
|
+
hint_parts.append(f"可转交成员 {transfer_count}")
|
|
449
|
+
if action == "save_only":
|
|
450
|
+
hint_parts.append("仅保存字段,不推进流程")
|
|
451
|
+
options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
|
|
452
|
+
return options
|
|
453
|
+
|
|
454
|
+
return resolve_interactive_selection(
|
|
455
|
+
args,
|
|
456
|
+
title=title,
|
|
457
|
+
unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
|
|
458
|
+
empty_message="task action could not open an action selector because no interactive actions are available.",
|
|
459
|
+
load_options=load_options,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _show_task_workbench_detail(args: argparse.Namespace, task_context: dict[str, Any]):
|
|
464
|
+
has_actions = _task_has_interactive_actions(task_context)
|
|
465
|
+
|
|
466
|
+
def load_options() -> list[SelectionOption[str]]:
|
|
467
|
+
if has_actions:
|
|
468
|
+
return [
|
|
469
|
+
SelectionOption(value="action", label="执行操作", hint="查看当前节点可执行动作"),
|
|
470
|
+
SelectionOption(value="back", label="返回列表", hint="回到待办列表"),
|
|
471
|
+
]
|
|
472
|
+
return [
|
|
473
|
+
SelectionOption(value="back", label="返回列表", hint="当前节点没有可执行动作"),
|
|
474
|
+
SelectionOption(value="exit", label="退出工作台", hint="结束当前待办工作台"),
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
return resolve_interactive_selection(
|
|
478
|
+
args,
|
|
479
|
+
title=_build_task_detail_title(task_context),
|
|
480
|
+
unavailable_message="task workbench requires an interactive terminal",
|
|
481
|
+
empty_message="task workbench could not open the task detail view.",
|
|
482
|
+
load_options=load_options,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _resolve_action_payload_or_select(
|
|
487
|
+
args: argparse.Namespace,
|
|
488
|
+
task_context: dict[str, Any],
|
|
489
|
+
*,
|
|
490
|
+
payload: dict[str, Any],
|
|
491
|
+
) -> dict[str, Any] | dict[str, str]:
|
|
492
|
+
action = str(args.action or "").strip().lower()
|
|
493
|
+
if action == "reject" and not _extract_audit_feedback(payload):
|
|
494
|
+
feedback = resolve_interactive_text_input(
|
|
495
|
+
args,
|
|
496
|
+
prompt="请输入驳回原因并回车,留空取消: ",
|
|
497
|
+
unavailable_message="task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
|
|
498
|
+
)
|
|
499
|
+
if feedback.status == "unavailable":
|
|
500
|
+
raise_config_error(
|
|
501
|
+
"task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
|
|
502
|
+
fix_hint="Pass `--payload-file` with `{\"audit_feedback\": \"...\"}`, or retry in an interactive terminal.",
|
|
503
|
+
)
|
|
504
|
+
if feedback.status == "cancelled":
|
|
505
|
+
return cancelled_result(feedback.message or "已取消")
|
|
506
|
+
payload["audit_feedback"] = str(feedback.value or "").strip()
|
|
507
|
+
return payload
|
|
508
|
+
|
|
509
|
+
if action == "rollback" and _extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId")) <= 0:
|
|
510
|
+
selection = _choose_rollback_candidate_interactively(args, task_context)
|
|
511
|
+
if selection.status == "unavailable":
|
|
512
|
+
raise_config_error(
|
|
513
|
+
"task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
|
|
514
|
+
fix_hint="Pass `--payload-file` with `{\"target_workflow_node_id\": NODE_ID}`, or retry in an interactive terminal.",
|
|
515
|
+
)
|
|
516
|
+
if selection.status == "empty":
|
|
517
|
+
raise_config_error(
|
|
518
|
+
selection.message or "task rollback could not open a selector because no rollback candidates are visible.",
|
|
519
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect rollback candidates, or choose another action.",
|
|
520
|
+
)
|
|
521
|
+
if selection.status == "cancelled":
|
|
522
|
+
return cancelled_result(selection.message or "已取消")
|
|
523
|
+
payload["target_workflow_node_id"] = int(selection.value or 0)
|
|
524
|
+
return payload
|
|
525
|
+
|
|
526
|
+
if action == "transfer" and _extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId")) <= 0:
|
|
527
|
+
selection = _choose_transfer_candidate_interactively(args, task_context)
|
|
528
|
+
if selection.status == "unavailable":
|
|
529
|
+
raise_config_error(
|
|
530
|
+
"task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
|
|
531
|
+
fix_hint="Pass `--payload-file` with `{\"target_member_id\": UID}`, or retry in an interactive terminal.",
|
|
532
|
+
)
|
|
533
|
+
if selection.status == "empty":
|
|
534
|
+
raise_config_error(
|
|
535
|
+
selection.message or "task transfer could not open a selector because no transfer candidates are visible.",
|
|
536
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect transfer candidates, or choose another action.",
|
|
537
|
+
)
|
|
538
|
+
if selection.status == "cancelled":
|
|
539
|
+
return cancelled_result(selection.message or "已取消")
|
|
540
|
+
payload["target_member_id"] = int(selection.value or 0)
|
|
541
|
+
return payload
|
|
542
|
+
|
|
543
|
+
return payload
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _choose_rollback_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
|
|
547
|
+
extras = _task_context_extras(task_context)
|
|
548
|
+
|
|
549
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
550
|
+
rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
|
|
551
|
+
items = rollback_candidates.get("items") if isinstance(rollback_candidates.get("items"), list) else []
|
|
552
|
+
options: list[SelectionOption[int]] = []
|
|
553
|
+
for item in items:
|
|
554
|
+
if not isinstance(item, dict):
|
|
555
|
+
continue
|
|
556
|
+
workflow_node_id = int(item.get("workflow_node_id") or 0)
|
|
557
|
+
if workflow_node_id <= 0:
|
|
558
|
+
continue
|
|
559
|
+
workflow_node_name = str(item.get("workflow_node_name") or f"节点 {workflow_node_id}").strip() or f"节点 {workflow_node_id}"
|
|
560
|
+
options.append(
|
|
561
|
+
SelectionOption(
|
|
562
|
+
value=workflow_node_id,
|
|
563
|
+
label=workflow_node_name,
|
|
564
|
+
hint=f"workflow_node_id={workflow_node_id}",
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
return options
|
|
568
|
+
|
|
569
|
+
return resolve_interactive_selection(
|
|
570
|
+
args,
|
|
571
|
+
title="选择退回节点",
|
|
572
|
+
unavailable_message="task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
|
|
573
|
+
empty_message="task rollback could not open a selector because no rollback candidates are visible.",
|
|
574
|
+
load_options=load_options,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _choose_transfer_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
|
|
579
|
+
extras = _task_context_extras(task_context)
|
|
580
|
+
|
|
581
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
582
|
+
transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
|
|
583
|
+
items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
|
|
584
|
+
options: list[SelectionOption[int]] = []
|
|
585
|
+
for item in items:
|
|
586
|
+
if not isinstance(item, dict):
|
|
587
|
+
continue
|
|
588
|
+
uid = int(item.get("uid") or 0)
|
|
589
|
+
if uid <= 0:
|
|
590
|
+
continue
|
|
591
|
+
name = str(item.get("name") or f"成员 {uid}").strip() or f"成员 {uid}"
|
|
592
|
+
hint_parts = [f"uid={uid}"]
|
|
593
|
+
department_name = str(item.get("department_name") or "").strip()
|
|
594
|
+
email = str(item.get("email") or "").strip()
|
|
595
|
+
if department_name:
|
|
596
|
+
hint_parts.append(department_name)
|
|
597
|
+
if email:
|
|
598
|
+
hint_parts.append(email)
|
|
599
|
+
options.append(SelectionOption(value=uid, label=name, hint=" · ".join(hint_parts)))
|
|
600
|
+
return options
|
|
601
|
+
|
|
602
|
+
return resolve_interactive_selection(
|
|
603
|
+
args,
|
|
604
|
+
title="选择转交成员",
|
|
605
|
+
unavailable_message="task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
|
|
606
|
+
empty_message="task transfer could not open a selector because no transfer candidates are visible.",
|
|
607
|
+
load_options=load_options,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _task_context_extras(task_context: dict[str, Any]) -> dict[str, Any]:
|
|
612
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
613
|
+
return data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _count_candidate_items(value: Any) -> int:
|
|
617
|
+
if not isinstance(value, dict):
|
|
618
|
+
return 0
|
|
619
|
+
items = value.get("items")
|
|
620
|
+
return len(items) if isinstance(items, list) else 0
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _extract_audit_feedback(payload: dict[str, Any]) -> str | None:
|
|
624
|
+
for key in ("audit_feedback", "auditFeedback"):
|
|
625
|
+
value = payload.get(key)
|
|
626
|
+
if isinstance(value, str) and value.strip():
|
|
627
|
+
return value.strip()
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _extract_positive_int(payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
|
|
632
|
+
for candidate in (key, *aliases):
|
|
633
|
+
value = payload.get(candidate)
|
|
634
|
+
if isinstance(value, int) and value > 0:
|
|
635
|
+
return value
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _is_cancelled_result(value: Any) -> bool:
|
|
640
|
+
return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _is_success_result(result: dict[str, Any]) -> bool:
|
|
644
|
+
if not isinstance(result, dict):
|
|
645
|
+
return False
|
|
646
|
+
if result.get("ok") is False:
|
|
647
|
+
return False
|
|
648
|
+
return str(result.get("status") or "").strip().lower() == "success"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _task_action_success_label(action: str) -> str:
|
|
652
|
+
return TASK_ACTION_SUCCESS_LABELS.get(action, "已完成")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _build_task_workbench_list_title(banner: str | None) -> str:
|
|
656
|
+
lines = ["待办工作台", "选择一条待办后查看详情并执行操作"]
|
|
657
|
+
if banner:
|
|
658
|
+
lines.extend(["", f"上一条结果:{banner}"])
|
|
659
|
+
return "\n".join(lines)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _build_task_detail_title(task_context: dict[str, Any]) -> str:
|
|
663
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
664
|
+
task = data.get("task") if isinstance(data.get("task"), dict) else {}
|
|
665
|
+
record_summary = data.get("record_summary") if isinstance(data.get("record_summary"), dict) else {}
|
|
666
|
+
available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
|
|
667
|
+
editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
|
|
668
|
+
extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
669
|
+
initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
|
|
670
|
+
core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
|
|
671
|
+
action_labels = ", ".join(TASK_ACTION_LABELS.get(str(item), str(item)) for item in available_actions if str(item).strip()) or "-"
|
|
672
|
+
lines = [
|
|
673
|
+
"待办详情",
|
|
674
|
+
f"Task ID: {task.get('task_id') or '-'}",
|
|
675
|
+
f"应用: {task.get('app_name') or '-'}",
|
|
676
|
+
f"节点: {task.get('workflow_node_name') or '-'}",
|
|
677
|
+
f"发起人: {_task_initiator_label(initiator)}",
|
|
678
|
+
f"状态: {record_summary.get('apply_status') or '-'}",
|
|
679
|
+
f"可执行动作: {action_labels}",
|
|
680
|
+
f"可编辑字段: {len(editable_fields)}",
|
|
681
|
+
(
|
|
682
|
+
"附加信息: "
|
|
683
|
+
f"报表 {_count_candidate_items(extras.get('associated_reports'))} / "
|
|
684
|
+
f"退回 {_count_candidate_items(extras.get('rollback_candidates'))} / "
|
|
685
|
+
f"转交 {_count_candidate_items(extras.get('transfer_candidates'))}"
|
|
686
|
+
),
|
|
687
|
+
]
|
|
688
|
+
if core_fields:
|
|
689
|
+
lines.append("")
|
|
690
|
+
lines.append("摘要字段:")
|
|
691
|
+
for key, value in list(core_fields.items())[:6]:
|
|
692
|
+
lines.append(f"- {key}: {_short_display(value)}")
|
|
693
|
+
return "\n".join(lines)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _build_task_action_title(task_context: dict[str, Any]) -> str:
|
|
697
|
+
return _build_task_detail_title(task_context) + "\n\n选择操作"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _task_initiator_label(initiator: dict[str, Any]) -> str:
|
|
701
|
+
for key in ("display_name", "displayName", "name", "email", "uid"):
|
|
702
|
+
value = initiator.get(key)
|
|
703
|
+
if value not in (None, ""):
|
|
704
|
+
return str(value)
|
|
705
|
+
return "-"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _task_has_interactive_actions(task_context: dict[str, Any]) -> bool:
|
|
709
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
710
|
+
available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
|
|
711
|
+
return any(action != "save_only" for action in available_actions)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _short_display(value: Any, *, limit: int = 60) -> str:
|
|
715
|
+
text = str(value if value not in (None, "") else "-")
|
|
716
|
+
if len(text) <= limit:
|
|
717
|
+
return text
|
|
718
|
+
return text[: limit - 1] + "…"
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _has_interactive_terminal(args: argparse.Namespace) -> bool:
|
|
722
|
+
input_stream = getattr(args, "_stdin", None)
|
|
723
|
+
return bool(getattr(input_stream, "isatty", lambda: False)())
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _raise_task_subcommand_required(args: argparse.Namespace) -> None:
|
|
727
|
+
stream = getattr(args, "_stderr_stream", None) or sys.stderr
|
|
728
|
+
task_parser = getattr(args, "_task_parser", None)
|
|
729
|
+
if isinstance(task_parser, argparse.ArgumentParser):
|
|
730
|
+
task_parser.print_usage(stream)
|
|
731
|
+
stream.write(f"{task_parser.prog}: error: the following arguments are required: task_command\n")
|
|
732
|
+
else:
|
|
733
|
+
stream.write("qingflow task: error: the following arguments are required: task_command\n")
|
|
734
|
+
raise SystemExit(2)
|
|
735
|
+
|
|
736
|
+
|
|
256
737
|
def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
|
|
257
738
|
def load_options() -> list[SelectionOption[int]]:
|
|
258
739
|
result = context.task.task_get(
|
|
@@ -296,6 +296,13 @@ def _format_task_action(result: dict[str, Any]) -> str:
|
|
|
296
296
|
return _task_action_success_label(action) + "\n"
|
|
297
297
|
|
|
298
298
|
|
|
299
|
+
def _format_task_workbench(result: dict[str, Any]) -> str:
|
|
300
|
+
message = str(result.get("message") or "").strip()
|
|
301
|
+
if message:
|
|
302
|
+
return message + "\n"
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
299
306
|
def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
|
|
300
307
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
301
308
|
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
@@ -588,6 +595,7 @@ _FORMATTERS = {
|
|
|
588
595
|
"app_get": _format_app_get,
|
|
589
596
|
"record_list": _format_record_list,
|
|
590
597
|
"task_list": _format_task_list,
|
|
598
|
+
"task_workbench": _format_task_workbench,
|
|
591
599
|
"task_get": _format_task_get,
|
|
592
600
|
"task_action_execute": _format_task_action,
|
|
593
601
|
"task_associated_report_detail_get": _format_task_associated_report_detail,
|
|
@@ -48,3 +48,25 @@ def resolve_interactive_selection(
|
|
|
48
48
|
if value is None:
|
|
49
49
|
return InteractiveSelectionResult(status="cancelled", message="已取消")
|
|
50
50
|
return InteractiveSelectionResult(status="selected", value=value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_interactive_text_input(
|
|
54
|
+
args: object,
|
|
55
|
+
*,
|
|
56
|
+
prompt: str,
|
|
57
|
+
unavailable_message: str,
|
|
58
|
+
) -> InteractiveSelectionResult[str]:
|
|
59
|
+
input_stream = getattr(args, "_stdin", None)
|
|
60
|
+
output_stream = getattr(args, "_stderr_stream", None)
|
|
61
|
+
if input_stream is None or output_stream is None:
|
|
62
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
63
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()):
|
|
64
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
65
|
+
|
|
66
|
+
output_stream.write(prompt)
|
|
67
|
+
output_stream.flush()
|
|
68
|
+
line = input_stream.readline()
|
|
69
|
+
text = str(line or "").strip()
|
|
70
|
+
if not text:
|
|
71
|
+
return InteractiveSelectionResult(status="cancelled", message="已取消")
|
|
72
|
+
return InteractiveSelectionResult(status="selected", value=text)
|
|
@@ -56,6 +56,8 @@ def run(
|
|
|
56
56
|
if not bool(args.json):
|
|
57
57
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
58
58
|
result = handler(args, context)
|
|
59
|
+
except SystemExit as exc:
|
|
60
|
+
return int(exc.code or 0)
|
|
59
61
|
except RuntimeError as exc:
|
|
60
62
|
payload = trim_error_response(_parse_error_payload(exc))
|
|
61
63
|
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|