代码提交
This commit is contained in:
1 parent
179a3f2b2f
commit
ce78cbf936
4 files changed
+188
-26
No files matched your search
@@ -166,14 +166,18 @@ export function derivePurposeLevels(tree: PurposeTreeNode[]): PurposeLevel[] {
|
|||||||
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
|
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
|
||||||
*
|
*
|
||||||
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
|
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
|
||||||
* - 父→子建一条边(from=父 id);多个根目的各自成一棵子树(画布支持多根);
|
* - 父→子建一条边(from=父 id);
|
||||||
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
|
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
|
||||||
* 推荐理由并进 detail(点节点弹窗里看)。
|
* 推荐理由并进 detail(点节点弹窗里看)。
|
||||||
|
* - 传入 ``rootLabel`` 时,在最顶层加一个「任务」根节点,所有「目的」挂到它下面
|
||||||
|
* (与 B 系统给的「任务 → 目的 → 行为体 → …」流程一致,单一根节点);不传则保持
|
||||||
|
* 旧的「多个目的各自成根」森林(向后兼容)。
|
||||||
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
|
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
|
||||||
*/
|
*/
|
||||||
export function purposeTreeToFlowGraph(
|
export function purposeTreeToFlowGraph(
|
||||||
tree: PurposeTreeNode[],
|
tree: PurposeTreeNode[],
|
||||||
taskId?: string,
|
taskId?: string,
|
||||||
|
rootLabel?: string,
|
||||||
): FlowGraphData {
|
): FlowGraphData {
|
||||||
const nodes: FlowGraphNode[] = [];
|
const nodes: FlowGraphNode[] = [];
|
||||||
const edges: FlowGraphEdge[] = [];
|
const edges: FlowGraphEdge[] = [];
|
||||||
@@ -189,7 +193,12 @@ export function purposeTreeToFlowGraph(
|
|||||||
if (parentId) edges.push({ from: parentId, to: id });
|
if (parentId) edges.push({ from: parentId, to: id });
|
||||||
node.children?.forEach((child) => walk(child, 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 {
|
return {
|
||||||
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
|
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
|
||||||
nodes,
|
nodes,
|
||||||
|
|||||||
@@ -3627,7 +3627,7 @@ function PurposeFlowPreview({
|
|||||||
>
|
>
|
||||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
|
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
|
||||||
<Layers className="h-3.5 w-3.5 text-primary" />
|
<Layers className="h-3.5 w-3.5 text-primary" />
|
||||||
策划流程
|
原任务已有结果
|
||||||
</span>
|
</span>
|
||||||
{levels.map((lv, i) => {
|
{levels.map((lv, i) => {
|
||||||
const m = meta[lv.type];
|
const m = meta[lv.type];
|
||||||
|
|||||||
@@ -9,10 +9,19 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { readTaskContext } from "@/core/auth/task-purpose";
|
import { readTaskContext } from "@/core/auth/task-purpose";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TASK_DEEPLINK_CONFIG,
|
DEFAULT_TASK_DEEPLINK_CONFIG,
|
||||||
@@ -23,6 +32,7 @@ import {
|
|||||||
buttonsForBusiness,
|
buttonsForBusiness,
|
||||||
resolveTaskButtons,
|
resolveTaskButtons,
|
||||||
runTaskButton,
|
runTaskButton,
|
||||||
|
type TaskButton,
|
||||||
type TaskBusiness,
|
type TaskBusiness,
|
||||||
} from "@/core/auth/task-buttons";
|
} from "@/core/auth/task-buttons";
|
||||||
import { useTaskButtons } from "@/strategy-components/api/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 (
|
return (
|
||||||
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
|
<>
|
||||||
{jumpButtons.map((btn) => (
|
{menuButtons.length > 0 && <TaskSideJumpMenu buttons={menuButtons} onRun={handleRun} />}
|
||||||
<Button
|
{nextButtons.length > 0 && (
|
||||||
key={btn.id}
|
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
|
||||||
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
|
{nextButtons.map((btn) => (
|
||||||
onClick={() => handleRun(btn)}
|
<TaskJumpButton key={btn.id} button={btn} onRun={handleRun} />
|
||||||
title={desensitize(btn.label)}
|
))}
|
||||||
>
|
</div>
|
||||||
<span className="truncate">{desensitize(btn.label)}</span>
|
)}
|
||||||
{btn.linkType === "url" ? (
|
|
||||||
<ExternalLink className="h-5 w-5 shrink-0" />
|
|
||||||
) : btn.linkType === "purpose" ? (
|
|
||||||
<Maximize2 className="h-5 w-5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ArrowRight className="h-5 w-5 shrink-0" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<TaskPurposeDialog
|
<TaskPurposeDialog
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
open={purposeOpen}
|
open={purposeOpen}
|
||||||
onOpenChange={setPurposeOpen}
|
onOpenChange={setPurposeOpen}
|
||||||
resolveFromActionId={purposeActionId}
|
resolveFromActionId={purposeActionId}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNextTaskButton(btn: TaskButton) {
|
||||||
|
return btn.id.toLowerCase().includes("next") || btn.label.trim() === "下一步";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskJumpButton({
|
||||||
|
button,
|
||||||
|
onRun,
|
||||||
|
}: {
|
||||||
|
button: TaskButton;
|
||||||
|
onRun: (button: TaskButton) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
|
||||||
|
onClick={() => onRun(button)}
|
||||||
|
title={desensitize(button.label)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{desensitize(button.label)}</span>
|
||||||
|
{renderTaskJumpIcon(button, "h-5 w-5 shrink-0")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskSideJumpMenu({
|
||||||
|
buttons,
|
||||||
|
onRun,
|
||||||
|
}: {
|
||||||
|
buttons: TaskButton[];
|
||||||
|
onRun: (button: TaskButton) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuWidth = 168;
|
||||||
|
const collapsedWidth = 64;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed right-0 top-1/2 z-50 -translate-y-1/2 overflow-visible"
|
||||||
|
style={{ width: collapsedWidth }}
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-1/2 overflow-hidden rounded-l-2xl rounded-r-none bg-white/95 py-1.5 shadow-[0_18px_45px_rgba(15,23,42,0.16)] backdrop-blur-2xl dark:bg-[#061b35]/90 dark:shadow-[0_18px_45px_rgba(0,0,0,0.35)]"
|
||||||
|
style={{
|
||||||
|
width: menuWidth,
|
||||||
|
transform: `translateY(-50%) translateX(${open ? 0 : menuWidth - collapsedWidth}px)`,
|
||||||
|
transition:
|
||||||
|
"transform 950ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 950ms cubic-bezier(0.22, 1, 0.36, 1), background-color 950ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 px-2">
|
||||||
|
{buttons.map((btn) => {
|
||||||
|
const tone = getRoundtableMenuTone(btn);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={btn.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"grid h-11 w-full grid-cols-[2rem_1fr] items-center gap-2.5 overflow-hidden rounded-l-xl rounded-r-none pl-2 pr-3 text-left text-sm font-semibold transition-[background-color,color,transform] duration-300 ease-out",
|
||||||
|
"text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300/70 dark:text-slate-100 dark:hover:bg-white/10 dark:focus-visible:ring-sky-300/60",
|
||||||
|
tone.item,
|
||||||
|
)}
|
||||||
|
onClick={() => onRun(btn)}
|
||||||
|
title={desensitize(btn.label)}
|
||||||
|
aria-label={desensitize(btn.label)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-lg transition-transform duration-300",
|
||||||
|
tone.icon,
|
||||||
|
)}
|
||||||
|
style={{ transform: open ? "scale(1.05)" : "scale(1)" }}
|
||||||
|
>
|
||||||
|
{renderRoundtableMenuIcon(btn)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="block min-w-0 truncate tracking-normal"
|
||||||
|
style={{
|
||||||
|
opacity: open ? 1 : 0,
|
||||||
|
transform: `translateX(${open ? 0 : 14}px)`,
|
||||||
|
transition:
|
||||||
|
"opacity 650ms ease 140ms, transform 650ms cubic-bezier(0.22, 1, 0.36, 1) 140ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desensitize(btn.label)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTaskJumpIcon(btn: TaskButton, className: string) {
|
||||||
|
if (btn.linkType === "url") return <ExternalLink className={className} />;
|
||||||
|
if (btn.linkType === "purpose") return <Maximize2 className={className} />;
|
||||||
|
return <ArrowRight className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoundtableMenuIcon(btn: TaskButton) {
|
||||||
|
const className = "h-4 w-4";
|
||||||
|
if (btn.linkType === "purpose") return <Maximize2 className={className} />;
|
||||||
|
if (btn.linkType === "url") return <FileSearch className={className} />;
|
||||||
|
if (btn.target === "3qfx") return <Network className={className} />;
|
||||||
|
if (btn.target === "xdfx") return <Workflow className={className} />;
|
||||||
|
if (btn.target === "rwfx") return <GitBranch className={className} />;
|
||||||
|
return <ArrowRight className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
+7
-4
@@ -19,6 +19,8 @@ import {
|
|||||||
purposeTreeToFlowGraph,
|
purposeTreeToFlowGraph,
|
||||||
type TaskPurposeResult,
|
type TaskPurposeResult,
|
||||||
} from "@/core/auth/task-purpose";
|
} from "@/core/auth/task-purpose";
|
||||||
|
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
||||||
|
|
||||||
export function TaskPurposeDialog({
|
export function TaskPurposeDialog({
|
||||||
@@ -52,10 +54,11 @@ export function TaskPurposeDialog({
|
|||||||
};
|
};
|
||||||
}, [open, taskId]);
|
}, [open, taskId]);
|
||||||
|
|
||||||
const flowData = useMemo(
|
// 任务为流程图的单一根节点:根节点文案用登录深链暂存的任务名称(已脱敏),取不到回退「任务」。
|
||||||
() => purposeTreeToFlowGraph(result?.tree ?? [], taskId),
|
const flowData = useMemo(() => {
|
||||||
[result, taskId],
|
const taskName = desensitize((readCopTaskDetail()?.taskName ?? "").trim()) || "任务";
|
||||||
);
|
return purposeTreeToFlowGraph(result?.tree ?? [], taskId, taskName);
|
||||||
|
}, [result, taskId]);
|
||||||
|
|
||||||
const hasNodes = flowData.nodes.length > 0;
|
const hasNodes = flowData.nodes.length > 0;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user