feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。

- 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动
- 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃
- FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字
- 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改)
- 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外)
- 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」
- 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
This commit is contained in:
AskaEth committed 2026-10-05 19:51:45 +08:00
1 parent f4adf0b526
commit 14e76b6480
9 files changed
+964 -108

No files matched your search

+55 -38
View File
@@ -6,34 +6,53 @@ import {
STATUS_ORDER,
statusOf,
} from "./flow/tone";
import { layoutFlow, NODE_W, NODE_H, type PlacedBand, type PlacedEdge, type PlacedNode } from "./flow/layout";
import { layoutFlow, NODE_W, NODE_H, type FlowOrientation, type PlacedBand } from "./flow/layout";
import { edgeShapes, type EdgeShape } from "./flow/edges";
/** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps).
* Left-to-right layered layout: a node's column is its longest-path depth from
* a root, and the rows of one column stack top-down. Sub-agent branches are
* drawn in their own framed lanes below the trunk (design/19 §2), so parallel
* branches read as separate swimlanes instead of interleaving. Edges are cubic
* beziers with an arrowhead; node stroke/fill is coded by status and a running
* node pulses with the panel's existing `.mu-blink` (no new animation dep). */
* A node's step is its longest-path depth from a root; the steps advance along the
* **time axis** and the newest one always sits at the canvas origin (top or left, see
* `FlowOrientation` in flow/layout.ts). Sub-agent branches are drawn in their own framed
* lanes beside the trunk (design/19 §2), so parallel branches read as separate swimlanes
* instead of interleaving. Edges are cubic beziers with an arrowhead anchored on the
* border the direction dictates (flow/edges.ts); node stroke/fill is coded by status and
* a running node pulses with the panel's existing `.mu-blink` (no new animation dep).
*
* 容器**不限高**:图多高就多高(主人要求「容器高度自动拓高」,不在内部裁切);横向仅在
* 泳道把画布撑宽时滚动(`newest-top` 下就是这种情形),纵向永远随图生长。 */
/** 方向提示(画在图例里):让「哪端是最新」写在脸上,不靠用户自己猜。 */
const DIRECTION_HINT: Record<FlowOrientation, string> = {
"newest-top": "时序自下而上 ↑ · 最新在最上",
"newest-left": "时序自右向左 ← · 最新在最左",
};
export function FlowGraphView({
graph,
orientation,
onNodeClick,
selectedId,
}: {
graph: FlowGraph;
/** 渲染方向(flow/layout.ts):`newest-top` = 时序自下而上(整页 Flow);
* `newest-left` = 时序自右向左(嵌在页面里的图块,容器默认露出最左边)。 */
orientation: FlowOrientation;
onNodeClick?: (node: FlowNode) => void;
selectedId?: string | null;
}): ReactNode {
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
const laneMarkerId = `${markerId}L`;
const { nodes, edges, bands, lanes, width, height } = layoutFlow(graph);
const placedOf = new Map(nodes.map((p) => [p.node.id, p]));
const layout = layoutFlow(graph, orientation);
const { nodes, bands, lanes, width, height } = layout;
const shapes = edgeShapes(layout);
const laneRoots = new Set(lanes.map((l) => l.id));
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
<div
data-flow-canvas="1"
className="overflow-x-auto rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3"
>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block">
<defs>
<marker
@@ -64,12 +83,10 @@ export function FlowGraphView({
<LaneFrame key={band.id} band={band} />
))}
{edges.map((e, i) => (
{shapes.map((shape, i) => (
<EdgePath
key={`${e.edge.from}-${e.edge.to}-${i}`}
edge={e}
from={placedOf.get(e.edge.from)}
to={placedOf.get(e.edge.to)}
key={`${shape.fromId}-${shape.toId}-${i}`}
shape={shape}
markerId={markerId}
laneMarkerId={laneMarkerId}
/>
@@ -88,42 +105,32 @@ export function FlowGraphView({
))}
</svg>
</div>
<Legend hasLanes={lanes.length > 0} />
<Legend hasLanes={lanes.length > 0} orientation={orientation} />
</div>
);
}
/** EdgePath draws one cubic bezier between two placed nodes; edges that run into
* a lane are dashed and accent-colored so the trunk stays readable. */
/** EdgePath draws one cubic bezier between the two placed nodes; edges that run into
* a lane are dashed and accent-colored so the trunk stays readable. The endpoints come
* from `edgeShapes` — the component never recomputes (nor guesses) them. */
function EdgePath({
edge,
from,
to,
shape,
markerId,
laneMarkerId,
}: {
edge: PlacedEdge;
from?: PlacedNode;
to?: PlacedNode;
shape: EdgeShape;
markerId: string;
laneMarkerId: string;
}): ReactNode {
if (!from || !to) return null;
const sx = from.x + NODE_W;
const sy = from.y + NODE_H / 2;
const tx = to.x;
const ty = to.y + NODE_H / 2;
const dx = Math.max(24, Math.abs(tx - sx) / 2);
const inLane = edge.lane !== undefined;
return (
<path
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
d={shape.d}
fill="none"
stroke={inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
strokeOpacity={inLane ? 0.7 : 1}
stroke={shape.inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
strokeOpacity={shape.inLane ? 0.7 : 1}
strokeWidth="1.5"
strokeDasharray={inLane ? "5 3" : undefined}
markerEnd={`url(#${inLane ? laneMarkerId : markerId})`}
strokeDasharray={shape.inLane ? "5 3" : undefined}
markerEnd={`url(#${shape.inLane ? laneMarkerId : markerId})`}
/>
);
}
@@ -245,8 +252,15 @@ function NodeGroup({
);
}
/** Legend explains the status colors and the lane edge style. */
function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
/** Legend explains the status colors, the lane edge style, and which end of the time
* axis is the newest one (the direction is reversed for the whole-page view). */
function Legend({
hasLanes,
orientation,
}: {
hasLanes: boolean;
orientation: FlowOrientation;
}): ReactNode {
return (
<div className="flex flex-wrap items-center gap-3 px-1">
{STATUS_ORDER.map((s) => (
@@ -274,6 +288,9 @@ function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
泳道内步骤
</span>
) : null}
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
{DIRECTION_HINT[orientation]}
</span>
</div>
);
}