diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts
index 90685a0b..5fe3c5f2 100644
--- a/frontend-web/src/core/auth/task-purpose.ts
+++ b/frontend-web/src/core/auth/task-purpose.ts
@@ -166,14 +166,18 @@ export function derivePurposeLevels(tree: PurposeTreeNode[]): PurposeLevel[] {
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
*
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
- * - 父→子建一条边(from=父 id);多个根目的各自成一棵子树(画布支持多根);
+ * - 父→子建一条边(from=父 id);
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
* 推荐理由并进 detail(点节点弹窗里看)。
+ * - 传入 ``rootLabel`` 时,在最顶层加一个「任务」根节点,所有「目的」挂到它下面
+ * (与 B 系统给的「任务 → 目的 → 行为体 → …」流程一致,单一根节点);不传则保持
+ * 旧的「多个目的各自成根」森林(向后兼容)。
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
*/
export function purposeTreeToFlowGraph(
tree: PurposeTreeNode[],
taskId?: string,
+ rootLabel?: string,
): FlowGraphData {
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
@@ -189,7 +193,12 @@ export function purposeTreeToFlowGraph(
if (parentId) edges.push({ from: parentId, to: id });
node.children?.forEach((child) => walk(child, id));
};
- tree.forEach((root) => walk(root, null));
+ // 任务为单一根节点(仅在有目的数据且传入 rootLabel 时):每个「目的」根挂到它下面。
+ const rootId = rootLabel && rootLabel.trim() && tree.length > 0 ? "task-root" : null;
+ if (rootId) {
+ nodes.push({ id: rootId, label: rootLabel!.trim(), type: "start", group: "任务" });
+ }
+ tree.forEach((root) => walk(root, rootId));
return {
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
nodes,
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index 85e9bfc2..23fc50fa 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -3627,7 +3627,7 @@ function PurposeFlowPreview({
>
- 策划流程
+ 原任务已有结果
{levels.map((lv, i) => {
const m = meta[lv.type];
diff --git a/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx b/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx
index fa6d86b5..13b5050f 100644
--- a/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx
+++ b/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx
@@ -9,10 +9,19 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { ArrowRight, ExternalLink, Maximize2 } from "lucide-react";
+import {
+ ArrowRight,
+ ExternalLink,
+ FileSearch,
+ GitBranch,
+ Maximize2,
+ Network,
+ Workflow,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
import { readTaskContext } from "@/core/auth/task-purpose";
import {
DEFAULT_TASK_DEEPLINK_CONFIG,
@@ -23,6 +32,7 @@ import {
buttonsForBusiness,
resolveTaskButtons,
runTaskButton,
+ type TaskButton,
type TaskBusiness,
} from "@/core/auth/task-buttons";
import { useTaskButtons } from "@/strategy-components/api/task-buttons";
@@ -81,31 +91,171 @@ export function TaskJumpBar({
});
};
+ const nextButtons = jumpButtons.filter(isNextTaskButton);
+ const menuButtons = jumpButtons.filter((btn) => !isNextTaskButton(btn));
+
return (
-
- {jumpButtons.map((btn) => (
-
- ))}
+ <>
+ {menuButtons.length > 0 &&
}
+ {nextButtons.length > 0 && (
+
+ {nextButtons.map((btn) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+function isNextTaskButton(btn: TaskButton) {
+ return btn.id.toLowerCase().includes("next") || btn.label.trim() === "下一步";
+}
+
+function TaskJumpButton({
+ button,
+ onRun,
+}: {
+ button: TaskButton;
+ onRun: (button: TaskButton) => void;
+}) {
+ return (
+
+ );
+}
+
+function TaskSideJumpMenu({
+ buttons,
+ onRun,
+}: {
+ buttons: TaskButton[];
+ onRun: (button: TaskButton) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const menuWidth = 168;
+ const collapsedWidth = 64;
+
+ return (
+
setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
+ }}
+ >
+
+
+ {buttons.map((btn) => {
+ const tone = getRoundtableMenuTone(btn);
+ return (
+
+ );
+ })}
+
+
);
}
+
+function renderTaskJumpIcon(btn: TaskButton, className: string) {
+ if (btn.linkType === "url") return
;
+ if (btn.linkType === "purpose") return
;
+ return
;
+}
+
+function renderRoundtableMenuIcon(btn: TaskButton) {
+ const className = "h-4 w-4";
+ if (btn.linkType === "purpose") return
;
+ if (btn.linkType === "url") return
;
+ if (btn.target === "3qfx") return
;
+ if (btn.target === "xdfx") return
;
+ if (btn.target === "rwfx") return
;
+ return
;
+}
+
+function getRoundtableMenuTone(btn: TaskButton) {
+ if (btn.linkType === "url") {
+ return {
+ item: "hover:text-rose-700 dark:hover:text-rose-100",
+ icon: "bg-rose-50 text-rose-600 dark:bg-rose-400/10 dark:text-rose-200",
+ };
+ }
+ if (btn.target === "3qfx") {
+ return {
+ item: "hover:text-sky-700 dark:hover:text-sky-100",
+ icon: "bg-sky-50 text-sky-600 dark:bg-sky-400/10 dark:text-sky-200",
+ };
+ }
+ if (btn.target === "xdfx") {
+ return {
+ item: "hover:text-emerald-700 dark:hover:text-emerald-100",
+ icon: "bg-emerald-50 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-200",
+ };
+ }
+ if (btn.target === "rwfx") {
+ return {
+ item: "hover:text-amber-700 dark:hover:text-amber-100",
+ icon: "bg-amber-50 text-amber-600 dark:bg-amber-400/10 dark:text-amber-200",
+ };
+ }
+ return {
+ item: "hover:text-slate-900 dark:hover:text-white",
+ icon: "bg-slate-50 text-slate-600 dark:bg-white/10 dark:text-slate-100",
+ };
+}
diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposeDialog.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposeDialog.tsx
index a6b91c7f..732488e3 100644
--- a/frontend-web/src/strategy-components/components/task-components/TaskPurposeDialog.tsx
+++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposeDialog.tsx
@@ -19,6 +19,8 @@ import {
purposeTreeToFlowGraph,
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
+import { readCopTaskDetail } from "@/core/auth/task-deeplink";
+import { desensitize } from "@/lib/desensitize";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
export function TaskPurposeDialog({
@@ -52,10 +54,11 @@ export function TaskPurposeDialog({
};
}, [open, taskId]);
- const flowData = useMemo(
- () => purposeTreeToFlowGraph(result?.tree ?? [], taskId),
- [result, taskId],
- );
+ // 任务为流程图的单一根节点:根节点文案用登录深链暂存的任务名称(已脱敏),取不到回退「任务」。
+ const flowData = useMemo(() => {
+ const taskName = desensitize((readCopTaskDetail()?.taskName ?? "").trim()) || "任务";
+ return purposeTreeToFlowGraph(result?.tree ?? [], taskId, taskName);
+ }, [result, taskId]);
const hasNodes = flowData.nodes.length > 0;