feat: 归档L03与L09审批发布能力
- 新增统一审批中心与审批管理页面,支持流程配置、审批详情与角色/用户审批对象 - 接入聊天助手、知识库、工作流的发布与删除审批,并补齐发布态校验与快照展示
This commit is contained in:
@@ -139,6 +139,18 @@ export class Tinyflow {
|
||||
return true;
|
||||
}
|
||||
|
||||
async fitView(options?: { duration?: number; padding?: number }) {
|
||||
const flow = this._getFlowInstance();
|
||||
if (!flow) {
|
||||
return false;
|
||||
}
|
||||
await flow.fitView({
|
||||
duration: options?.duration ?? 220,
|
||||
padding: options?.padding ?? 0.2,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
setTheme(theme: TinyflowTheme) {
|
||||
this.options.theme = theme;
|
||||
if (this.tinyflowEl) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
} = $props();
|
||||
|
||||
let { data } = options;
|
||||
let initialViewport = null;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
@@ -28,7 +29,12 @@
|
||||
console.error('Invalid JSON data:', data);
|
||||
}
|
||||
}
|
||||
store.init((data as TinyflowData)?.nodes || [], (data as TinyflowData)?.edges || []);
|
||||
initialViewport = (data as TinyflowData)?.viewport || null;
|
||||
store.init(
|
||||
(data as TinyflowData)?.nodes || [],
|
||||
(data as TinyflowData)?.edges || [],
|
||||
initialViewport,
|
||||
);
|
||||
setContext('tinyflow_options', options);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -57,9 +57,11 @@
|
||||
let flowRootEl = $state<HTMLDivElement | null>(null);
|
||||
let inlineNodePickerEl = $state<HTMLDivElement | null>(null);
|
||||
let connectStartPoint = $state<{ x: number; y: number } | null>(null);
|
||||
let canvasLocked = $state(false);
|
||||
const asString = (value: unknown) => (value == null ? '' : String(value));
|
||||
const options = getOptions();
|
||||
const readonly = options.readonly === true;
|
||||
let canvasLocked = $state(readonly);
|
||||
const hideBottomDock = options.hideBottomDock === true;
|
||||
const availableNodes = getAvailableNodes(options);
|
||||
const onRunTest = options.onRunTest;
|
||||
|
||||
@@ -567,6 +569,9 @@
|
||||
};
|
||||
|
||||
function handleGlobalPointerDown(event: PointerEvent) {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
if (!nodePickerVisible || !inlineNodePickerEl) {
|
||||
return;
|
||||
}
|
||||
@@ -579,15 +584,19 @@
|
||||
|
||||
onMount(() => {
|
||||
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('paste', handleGlobalPaste);
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
||||
if (!readonly) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('paste', handleGlobalPaste);
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('paste', handleGlobalPaste);
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
||||
if (!readonly) {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('paste', handleGlobalPaste);
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
||||
}
|
||||
});
|
||||
|
||||
const customNodeTypes = {
|
||||
@@ -630,24 +639,30 @@
|
||||
nodesDraggable={!canvasLocked}
|
||||
nodesConnectable={!canvasLocked}
|
||||
elementsSelectable={!canvasLocked}
|
||||
panOnDrag={!canvasLocked}
|
||||
zoomOnScroll={!canvasLocked}
|
||||
zoomOnDoubleClick={!canvasLocked}
|
||||
ondrop={onDrop}
|
||||
ondragover={onDragOver}
|
||||
panOnDrag={readonly ? true : !canvasLocked}
|
||||
zoomOnScroll={readonly ? true : !canvasLocked}
|
||||
zoomOnDoubleClick={readonly ? true : !canvasLocked}
|
||||
ondrop={readonly ? undefined : onDrop}
|
||||
ondragover={readonly ? undefined : onDragOver}
|
||||
isValidConnection={isValidConnection}
|
||||
onconnectend={onconnectend}
|
||||
onconnectstart={onconnectstart}
|
||||
onconnect={onconnect}
|
||||
onconnectend={readonly ? undefined : onconnectend}
|
||||
onconnectstart={readonly ? undefined : onconnectstart}
|
||||
onconnect={readonly ? undefined : onconnect}
|
||||
connectionRadius={50}
|
||||
connectionLineComponent={FlowConnectionLine}
|
||||
onedgeclick={(e) => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
showEdgePanel = true;
|
||||
currentEdge = e.edge;
|
||||
}}
|
||||
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
|
||||
ondelete={onDelete}
|
||||
ondelete={readonly ? undefined : onDelete}
|
||||
onclick={(e) => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
const el = e.target as HTMLElement;
|
||||
if (el.classList.contains("svelte-flow__edge-interaction")
|
||||
|| el.classList.contains('panel-content')
|
||||
@@ -750,6 +765,7 @@
|
||||
<NodePicker nodes={availableNodes} onSelect={handlePickerSelectNode} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if !hideBottomDock}
|
||||
<div class="tf-bottom-dock">
|
||||
<div class="tf-unified-bar">
|
||||
<!-- 缩放百分比选择器 -->
|
||||
@@ -818,6 +834,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -2,27 +2,14 @@ import { store } from '#store/stores.svelte';
|
||||
import { genShortId } from '#components/utils/IdGen';
|
||||
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
|
||||
|
||||
import { sanitizeEdge, sanitizeNode } from '#utils/sanitize';
|
||||
|
||||
interface ClipboardData {
|
||||
tinyflowNodes: Node[];
|
||||
tinyflowEdges?: Edge[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理节点中不可序列化的字段,确保可安全 JSON.stringify
|
||||
*/
|
||||
function sanitizeNode(node: Node): Node {
|
||||
const { id, type, position, data, parentId } = node;
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x: position.x, y: position.y },
|
||||
parentId: parentId ? parentId : undefined,
|
||||
data: data ? JSON.parse(JSON.stringify(data)) : {},
|
||||
...(parentId !== undefined && { parentId }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 nodes 数组排序,确保每个父节点出现在其所有子节点之前。
|
||||
* 使用 Kahn 算法(拓扑排序)处理任意嵌套层级。
|
||||
@@ -83,22 +70,6 @@ export function sortNodesForSvelteFlow(nodes: Node[]): Node[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理边中不可序列化的字段
|
||||
*/
|
||||
function sanitizeEdge(edge: Edge): Edge {
|
||||
const { id, source, target, sourceHandle, targetHandle, type, data } = edge;
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
...(sourceHandle !== undefined && { sourceHandle }),
|
||||
...(targetHandle !== undefined && { targetHandle }),
|
||||
...(type !== undefined && { type }),
|
||||
data: data ? JSON.parse(JSON.stringify(data)) : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历对象,仅当遇到 { refType: 'ref', ref: string } 时重写 ref 的 id
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './Tinyflow';
|
||||
export * from './components/TinyflowComponent.svelte';
|
||||
export * from './utils/sanitize';
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||
|
||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||
|
||||
const createStore = () => {
|
||||
let nodesInternal = $state.raw([] as Node[]);
|
||||
let edgesInternal = $state.raw([] as Edge[]);
|
||||
let viewport = $state.raw({ x: 250, y: 100, zoom: 1 } as Viewport);
|
||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||
|
||||
return {
|
||||
// nodes: nodesInternal,
|
||||
// edges: edgesInternal,
|
||||
// viewport,
|
||||
init: (nodes: Node[], edges: Edge[]) => {
|
||||
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
||||
nodesInternal = nodes;
|
||||
edgesInternal = edges;
|
||||
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
||||
},
|
||||
|
||||
getNodes: () => nodesInternal,
|
||||
|
||||
@@ -87,6 +87,8 @@ export type TinyflowOptions = {
|
||||
element: string | Element;
|
||||
theme?: TinyflowTheme;
|
||||
data?: TinyflowData | string;
|
||||
readonly?: boolean;
|
||||
hideBottomDock?: boolean;
|
||||
provider?: {
|
||||
llm?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
knowledge?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
|
||||
89
easyflow-ui-admin/packages/tinyflow-ui/src/utils/sanitize.ts
Normal file
89
easyflow-ui-admin/packages/tinyflow-ui/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { type Edge, type Node } from '@xyflow/svelte';
|
||||
|
||||
import type { TinyflowData } from '#types';
|
||||
|
||||
/**
|
||||
* 清理节点中不适合序列化或预览的运行时字段。
|
||||
*/
|
||||
export function sanitizeNode(node: Node): Node {
|
||||
const { id, type, position, data, parentId } = node;
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: {
|
||||
x: position?.x ?? 0,
|
||||
y: position?.y ?? 0,
|
||||
},
|
||||
data: data ? JSON.parse(JSON.stringify(data)) : {},
|
||||
...(parentId !== undefined && { parentId }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理边中不适合序列化或预览的运行时字段。
|
||||
*/
|
||||
export function sanitizeEdge(edge: Edge): Edge {
|
||||
const { id, source, target, sourceHandle, targetHandle, type, data } = edge;
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
...(sourceHandle !== undefined && { sourceHandle }),
|
||||
...(targetHandle !== undefined && { targetHandle }),
|
||||
...(type !== undefined && { type }),
|
||||
data: data ? JSON.parse(JSON.stringify(data)) : {},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeReadonlyNode(node: Node): Node {
|
||||
const sanitized = sanitizeNode(node);
|
||||
const nextData =
|
||||
sanitized.data && typeof sanitized.data === 'object'
|
||||
? {
|
||||
...sanitized.data,
|
||||
expand: false,
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
...sanitized,
|
||||
data: nextData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为只读预览重建 tinyflow 数据,移除 viewport 和节点运行时几何状态。
|
||||
*/
|
||||
export function sanitizeTinyflowDataForReadonlyPreview(
|
||||
input: TinyflowData | string,
|
||||
): null | TinyflowData | undefined {
|
||||
let parsed: TinyflowData | undefined;
|
||||
if (typeof input === 'string') {
|
||||
const raw = input.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
parsed = input;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(parsed.nodes)
|
||||
? parsed.nodes.map((node) => sanitizeReadonlyNode(node as Node))
|
||||
: [];
|
||||
const edges = Array.isArray(parsed.edges)
|
||||
? parsed.edges.map((edge) => sanitizeEdge(edge as Edge))
|
||||
: [];
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user