@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.5
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/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +20 -0
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +139 -4
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +6 -0
- package/src/qingflow_mcp/response_trim.py +40 -1
- package/src/qingflow_mcp/server.py +22 -0
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +104 -8
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +551 -16
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- 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
|
|
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=
|
|
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",
|
|
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",
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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=
|
|
109
|
-
fields=
|
|
172
|
+
payload=payload,
|
|
173
|
+
fields=fields,
|
|
110
174
|
)
|
|
111
175
|
|
|
112
176
|
|
|
113
177
|
def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
)
|