Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor (8 node types, drag-drop canvas, execution engine)
This commit is contained in:
1 parent
fc36777a5c
commit
b675e9fd20
5 files changed
+694
-1
No files matched your search
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||||
|
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin
|
||||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -419,6 +419,7 @@ app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["
|
|||||||
|
|
||||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||||
|
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
||||||
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||||
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
||||||
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
|||||||
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
||||||
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
||||||
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
||||||
|
const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow'));
|
||||||
|
|
||||||
// Loading fallback
|
// Loading fallback
|
||||||
const PageLoading: React.FC = () => (
|
const PageLoading: React.FC = () => (
|
||||||
@@ -113,6 +114,7 @@ const App: React.FC = () => {
|
|||||||
{/* AI能力 */}
|
{/* AI能力 */}
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="agents" element={<AdminAgents />} />
|
<Route path="agents" element={<AdminAgents />} />
|
||||||
|
<Route path="workflow" element={<AdminWorkflow />} />
|
||||||
<Route path="skills" element={<AdminSkills />} />
|
<Route path="skills" element={<AdminSkills />} />
|
||||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||||
<Route path="mcp" element={<AdminMcp />} />
|
<Route path="mcp" element={<AdminMcp />} />
|
||||||
|
|||||||
@@ -686,4 +686,25 @@ export const evolutionAPI = {
|
|||||||
},
|
},
|
||||||
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
|
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
|
||||||
|
|
||||||
|
|
||||||
|
// Workflow (Phase 3.6)
|
||||||
|
getWorkflows: (params?: Record<string, any>) =>
|
||||||
|
get<any>('/admin/workflows/?' + new URLSearchParams(params || {}).toString()),
|
||||||
|
getWorkflow: (id: string) =>
|
||||||
|
get<any>(`/admin/workflows/${id}`),
|
||||||
|
getWorkflowStats: () =>
|
||||||
|
get<any>('/admin/workflows/stats'),
|
||||||
|
getWorkflowTemplates: () =>
|
||||||
|
get<any>('/admin/workflows/templates'),
|
||||||
|
createWorkflow: (data: any) =>
|
||||||
|
post<any>('/admin/workflows/', data),
|
||||||
|
updateWorkflow: (id: string, data: any) =>
|
||||||
|
put<any>(`/admin/workflows/${id}`, data),
|
||||||
|
deleteWorkflow: (id: string) =>
|
||||||
|
del<any>(`/admin/workflows/${id}`),
|
||||||
|
toggleWorkflow: (id: string, isActive: boolean) =>
|
||||||
|
post<any>(`/admin/workflows/${id}/toggle?is_active=${isActive}`),
|
||||||
|
executeWorkflow: (id: string, inputText: string) =>
|
||||||
|
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -34,6 +34,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/admin/models', icon: 'models', label: '模型控制台' },
|
{ path: '/admin/models', icon: 'models', label: '模型控制台' },
|
||||||
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
||||||
|
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
|
||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||||
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
||||||
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' },
|
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' },
|
||||||
@@ -73,6 +74,7 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
|||||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
|
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
|
||||||
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>,
|
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>,
|
||||||
agents: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.4 8 12 8 12s8-6.6 8-12a8 8 0 0 0-8-8z"/></svg>,
|
agents: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.4 8 12 8 12s8-6.6 8-12a8 8 0 0 0-8-8z"/></svg>,
|
||||||
|
workflow: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></svg>,
|
||||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||||
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>,
|
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>,
|
||||||
// New icons for orphan pages
|
// New icons for orphan pages
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface WorkflowNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
data: { label: string; config: Record<string, any> };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceHandle?: string | null;
|
||||||
|
targetHandle?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workflow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
agent_id: string | null;
|
||||||
|
nodes: WorkflowNode[];
|
||||||
|
edges: WorkflowEdge[];
|
||||||
|
variables: Record<string, any>;
|
||||||
|
status: string;
|
||||||
|
version: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeTemplate {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, string> = {
|
||||||
|
start: "#3B6D11",
|
||||||
|
llm: "#534AB7",
|
||||||
|
knowledge: "#185FA5",
|
||||||
|
skill: "#0F6E56",
|
||||||
|
condition: "#BA7517",
|
||||||
|
http: "#D4537E",
|
||||||
|
code: "#5F5E5A",
|
||||||
|
end: "#A32D2D",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_LABELS: Record<string, string> = {
|
||||||
|
start: "开始",
|
||||||
|
llm: "LLM 调用",
|
||||||
|
knowledge: "知识库检索",
|
||||||
|
skill: "Skill 调用",
|
||||||
|
condition: "条件分支",
|
||||||
|
http: "HTTP 请求",
|
||||||
|
code: "代码执行",
|
||||||
|
end: "结束",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Workflow() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [view, setView] = useState<"list" | "editor">("list");
|
||||||
|
const [currentWf, setCurrentWf] = useState<Workflow | null>(null);
|
||||||
|
const [templates, setTemplates] = useState<NodeTemplate[]>([]);
|
||||||
|
const [options, setOptions] = useState<Record<string, any[]>>({});
|
||||||
|
const [selectedNode, setSelectedNode] = useState<WorkflowNode | null>(null);
|
||||||
|
const [draggingNode, setDraggingNode] = useState<string | null>(null);
|
||||||
|
const [connectingFrom, setConnectingFrom] = useState<string | null>(null);
|
||||||
|
const [executeResult, setExecuteResult] = useState<any>(null);
|
||||||
|
const [executeInput, setExecuteInput] = useState("");
|
||||||
|
const [showExecute, setShowExecute] = useState(false);
|
||||||
|
const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 });
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragOffset = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const api = (window as any).api;
|
||||||
|
|
||||||
|
// Load workflows
|
||||||
|
const loadWorkflows = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get("/admin/workflows/");
|
||||||
|
if (res.code === 0) setWorkflows(res.data.items || []);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
try {
|
||||||
|
const s = await api.get("/admin/workflows/stats");
|
||||||
|
if (s.code === 0) setStats(s.data);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load templates
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get("/admin/workflows/templates");
|
||||||
|
if (res.code === 0) {
|
||||||
|
setTemplates(Object.values(res.data.node_templates || {}));
|
||||||
|
setOptions(res.data.options || {});
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadWorkflows(); loadTemplates(); }, []);
|
||||||
|
|
||||||
|
// Create new workflow
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = prompt("工作流名称:", "新工作流");
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const res = await api.post("/admin/workflows/", {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
nodes: [
|
||||||
|
{ id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } },
|
||||||
|
{ id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
loadWorkflows();
|
||||||
|
handleOpenEditor(res.data);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open editor
|
||||||
|
const handleOpenEditor = (wf: Workflow) => {
|
||||||
|
setCurrentWf(wf);
|
||||||
|
setSelectedNode(null);
|
||||||
|
setView("editor");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save workflow
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentWf) return;
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/admin/workflows/${currentWf.id}`, {
|
||||||
|
name: currentWf.name,
|
||||||
|
description: currentWf.description,
|
||||||
|
nodes: currentWf.nodes,
|
||||||
|
edges: currentWf.edges,
|
||||||
|
variables: currentWf.variables,
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
setCurrentWf(res.data);
|
||||||
|
alert("保存成功");
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete workflow
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确认删除此工作流?")) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/admin/workflows/${id}`);
|
||||||
|
loadWorkflows();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle publish
|
||||||
|
const handleToggle = async (wf: Workflow) => {
|
||||||
|
const isActive = wf.status !== "published";
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`);
|
||||||
|
loadWorkflows();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add node to canvas
|
||||||
|
const handleAddNode = (template: NodeTemplate) => {
|
||||||
|
if (!currentWf) return;
|
||||||
|
const id = `${template.type}-${Date.now()}`;
|
||||||
|
const newNode: WorkflowNode = {
|
||||||
|
id,
|
||||||
|
type: template.type,
|
||||||
|
position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 },
|
||||||
|
data: {
|
||||||
|
label: template.label,
|
||||||
|
config: Object.fromEntries(
|
||||||
|
Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Node drag
|
||||||
|
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const node = currentWf!.nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node || !canvasRef.current) return;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
dragOffset.current = {
|
||||||
|
x: e.clientX - rect.left - node.position.x,
|
||||||
|
y: e.clientY - rect.top - node.position.y,
|
||||||
|
};
|
||||||
|
setDraggingNode(nodeId);
|
||||||
|
setSelectedNode(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!draggingNode || !currentWf || !canvasRef.current) return;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left - dragOffset.current.x;
|
||||||
|
const y = e.clientY - rect.top - dragOffset.current.y;
|
||||||
|
setCurrentWf({
|
||||||
|
...currentWf,
|
||||||
|
nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseUp = () => { setDraggingNode(null); };
|
||||||
|
|
||||||
|
// Canvas click (deselect / cancel connecting)
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setSelectedNode(null);
|
||||||
|
setConnectingFrom(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start connecting from a node
|
||||||
|
const handleStartConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConnectingFrom(nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Complete connection to a node
|
||||||
|
const handleEndConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!connectingFrom || !currentWf) return;
|
||||||
|
if (connectingFrom === nodeId) { setConnectingFrom(null); return; }
|
||||||
|
// Check if edge already exists
|
||||||
|
const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId);
|
||||||
|
if (exists) { setConnectingFrom(null); return; }
|
||||||
|
const newEdge: WorkflowEdge = {
|
||||||
|
id: `e-${Date.now()}`,
|
||||||
|
source: connectingFrom,
|
||||||
|
target: nodeId,
|
||||||
|
};
|
||||||
|
setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] });
|
||||||
|
setConnectingFrom(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete edge
|
||||||
|
const handleDeleteEdge = (edgeId: string) => {
|
||||||
|
if (!currentWf) return;
|
||||||
|
setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete selected node
|
||||||
|
const handleDeleteNode = () => {
|
||||||
|
if (!selectedNode || !currentWf) return;
|
||||||
|
const nodeId = selectedNode.id;
|
||||||
|
setCurrentWf({
|
||||||
|
...currentWf,
|
||||||
|
nodes: currentWf.nodes.filter(n => n.id !== nodeId),
|
||||||
|
edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
|
||||||
|
});
|
||||||
|
setSelectedNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update node config
|
||||||
|
const handleConfigChange = (key: string, value: any) => {
|
||||||
|
if (!selectedNode || !currentWf) return;
|
||||||
|
const updatedNodes = currentWf.nodes.map(n =>
|
||||||
|
n.id === selectedNode.id
|
||||||
|
? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } }
|
||||||
|
: n
|
||||||
|
);
|
||||||
|
setCurrentWf({ ...currentWf, nodes: updatedNodes });
|
||||||
|
setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute workflow
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (!currentWf) return;
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
||||||
|
input_text: executeInput,
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
setExecuteResult(res.data);
|
||||||
|
} else {
|
||||||
|
alert("执行失败: " + (res.message || "未知错误"));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); alert("执行失败"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG edge path (bezier curve)
|
||||||
|
const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => {
|
||||||
|
const x1 = src.position.x + 90;
|
||||||
|
const y1 = src.position.y + 25;
|
||||||
|
const x2 = tgt.position.x;
|
||||||
|
const y2 = tgt.position.y + 25;
|
||||||
|
const dx = Math.abs(x2 - x1) * 0.5;
|
||||||
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LIST VIEW
|
||||||
|
// ============================================================
|
||||||
|
if (view === "list") {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px", color: "#e0e0e0", minHeight: "100vh" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", color: "#fff" }}>Agent 可视化编排</h2>
|
||||||
|
<p style={{ margin: "4px 0 0", color: "#888", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · 支持LLM/知识库/Skill/条件分支/HTTP/代码节点</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCreate} style={btnPrimary}>+ 新建工作流</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "16px", marginBottom: "24px" }}>
|
||||||
|
{[
|
||||||
|
{ label: "总工作流", value: stats.total, color: "#534AB7" },
|
||||||
|
{ label: "草稿", value: stats.draft, color: "#BA7517" },
|
||||||
|
{ label: "已发布", value: stats.published, color: "#3B6D11" },
|
||||||
|
{ label: "已归档", value: stats.archived, color: "#5F5E5A" },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ ...statCard, borderLeft: `3px solid ${s.color}` }}>
|
||||||
|
<div style={{ fontSize: "24px", fontWeight: 600, color: "#fff" }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#888" }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow list */}
|
||||||
|
<div style={{ background: "#1a1a2e", borderRadius: "12px", overflow: "hidden" }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid #333" }}>
|
||||||
|
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
|
||||||
|
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#888", fontWeight: 500 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>加载中...</td></tr>
|
||||||
|
) : workflows.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>暂无工作流,点击"新建工作流"开始</td></tr>
|
||||||
|
) : workflows.map(wf => (
|
||||||
|
<tr key={wf.id} style={{ borderBottom: "1px solid #222" }}>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<div style={{ color: "#fff", fontWeight: 500 }}>{wf.name}</div>
|
||||||
|
<div style={{ color: "#666", fontSize: "12px" }}>{wf.description || "—"}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<span style={statusBadge(wf.status)}>{wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px", color: "#aaa" }}>{wf.nodes?.length || 0}</td>
|
||||||
|
<td style={{ padding: "12px 16px", color: "#aaa" }}>v{wf.version}</td>
|
||||||
|
<td style={{ padding: "12px 16px", color: "#888", fontSize: "12px" }}>{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"}</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<button onClick={() => handleOpenEditor(wf)} style={btnSmall}>编辑</button>
|
||||||
|
<button onClick={() => handleToggle(wf)} style={{ ...btnSmall, marginLeft: "4px" }}>{wf.status === "published" ? "归档" : "发布"}</button>
|
||||||
|
<button onClick={() => handleDelete(wf.id)} style={{ ...btnSmall, marginLeft: "4px", color: "#F09595" }}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EDITOR VIEW
|
||||||
|
// ============================================================
|
||||||
|
if (!currentWf) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#0d0d1a", color: "#e0e0e0" }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#16162a", borderBottom: "1px solid #2a2a4a", gap: "8px" }}>
|
||||||
|
<button onClick={() => { setView("list"); loadWorkflows(); }} style={btnSmall}>← 返回</button>
|
||||||
|
<input
|
||||||
|
value={currentWf.name}
|
||||||
|
onChange={e => setCurrentWf({ ...currentWf, name: e.target.value })}
|
||||||
|
style={{ background: "transparent", border: "1px solid #333", borderRadius: "6px", padding: "4px 12px", color: "#fff", fontSize: "14px", width: "200px" }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#666", fontSize: "12px" }}>v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button onClick={handleSave} style={btnPrimary}>保存</button>
|
||||||
|
<button onClick={() => setShowExecute(!showExecute)} style={{ ...btnSmall, borderColor: "#3B6D11" }}>执行</button>
|
||||||
|
{selectedNode && <button onClick={handleDeleteNode} style={{ ...btnSmall, color: "#F09595" }}>删除节点</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execute panel */}
|
||||||
|
{showExecute && (
|
||||||
|
<div style={{ padding: "12px 16px", background: "#1a1a2e", borderBottom: "1px solid #2a2a4a" }}>
|
||||||
|
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: "12px", color: "#888" }}>输入文本</label>
|
||||||
|
<textarea
|
||||||
|
value={executeInput}
|
||||||
|
onChange={e => setExecuteInput(e.target.value)}
|
||||||
|
placeholder="输入测试文本..."
|
||||||
|
style={{ width: "100%", background: "#0d0d1a", border: "1px solid #333", borderRadius: "6px", padding: "8px", color: "#fff", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleExecute} style={btnPrimary}>执行工作流</button>
|
||||||
|
</div>
|
||||||
|
{executeResult && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>执行日志:</div>
|
||||||
|
{executeResult.log?.map((l: any, i: number) => (
|
||||||
|
<div key={i} style={{ fontSize: "12px", padding: "4px 8px", background: l.success ? "rgba(59,109,17,0.15)" : "rgba(163,45,45,0.15)", borderRadius: "4px", marginBottom: "2px" }}>
|
||||||
|
<span style={{ color: l.success ? "#97C459" : "#F09595" }}>[{l.node_type}]</span>{" "}
|
||||||
|
<span style={{ color: "#aaa" }}>{l.node_label}</span>
|
||||||
|
{l.output_preview && <span style={{ color: "#666" }}> → {l.output_preview.slice(0, 100)}</span>}
|
||||||
|
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ marginTop: "8px", padding: "8px", background: "#0d0d1a", borderRadius: "6px", border: "1px solid #333" }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>最终输出:</div>
|
||||||
|
<pre style={{ margin: 0, color: "#97C459", fontSize: "13px", whiteSpace: "pre-wrap" }}>{executeResult.output}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main area: palette | canvas | config */}
|
||||||
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
|
{/* Node palette */}
|
||||||
|
<div style={{ width: "180px", background: "#16162a", borderRight: "1px solid #2a2a4a", padding: "12px", overflowY: "auto" }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px", textTransform: "uppercase" }}>节点类型</div>
|
||||||
|
{Object.entries(NODE_LABELS).map(([type, label]) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
onClick={() => {
|
||||||
|
const tmpl = templates.find(t => t.type === type);
|
||||||
|
if (tmpl) handleAddNode(tmpl);
|
||||||
|
else handleAddNode({ type, label, icon: "", color: NODE_COLORS[type], description: "", config_schema: {} });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
marginBottom: "4px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#1a1a2e",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderLeft: `3px solid ${NODE_COLORS[type]}`,
|
||||||
|
transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "#222244")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "#1a1a2e")}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#fff", fontSize: "13px" }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onMouseMove={handleCanvasMouseMove}
|
||||||
|
onMouseUp={handleCanvasMouseUp}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundImage: "radial-gradient(circle, #1a1a2e 1px, transparent 1px)",
|
||||||
|
backgroundSize: "20px 20px",
|
||||||
|
background: "#0d0d1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* SVG for edges */}
|
||||||
|
<svg style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||||
|
{currentWf.edges.map(edge => {
|
||||||
|
const src = currentWf.nodes.find(n => n.id === edge.source);
|
||||||
|
const tgt = currentWf.nodes.find(n => n.id === edge.target);
|
||||||
|
if (!src || !tgt) return null;
|
||||||
|
return (
|
||||||
|
<g key={edge.id} style={{ pointerEvents: "auto" }}>
|
||||||
|
<path d={getEdgePath(src, tgt)} fill="none" stroke="#534AB7" strokeWidth={2} markerEnd="url(#arrowhead)" />
|
||||||
|
<circle cx={(src.position.x + tgt.position.x + 90) / 2} cy={(src.position.y + tgt.position.y + 25) / 2} r={8} fill="#1a1a2e" stroke="#534AB7" strokeWidth={1} style={{ cursor: "pointer" }} onClick={() => handleDeleteEdge(edge.id)} />
|
||||||
|
<text x={(src.position.x + tgt.position.x + 90) / 2} y={(src.position.y + tgt.position.y + 25) / 2 + 3} textAnchor="middle" fill="#F09595" fontSize="10" style={{ pointerEvents: "none" }}>x</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||||
|
<polygon points="0 0, 8 3, 0 6" fill="#534AB7" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{currentWf.nodes.map(node => {
|
||||||
|
const isSelected = selectedNode?.id === node.id;
|
||||||
|
const color = NODE_COLORS[node.type] || "#5F5E5A";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
onMouseDown={e => handleNodeMouseDown(e, node.id)}
|
||||||
|
onClick={e => { e.stopPropagation(); setSelectedNode(node); }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: node.position.x,
|
||||||
|
top: node.position.y,
|
||||||
|
width: "90px",
|
||||||
|
minHeight: "50px",
|
||||||
|
background: "#1a1a2e",
|
||||||
|
border: `2px solid ${isSelected ? color : "#333"}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor: draggingNode === node.id ? "grabbing" : "grab",
|
||||||
|
padding: "8px",
|
||||||
|
textAlign: "center",
|
||||||
|
boxShadow: isSelected ? `0 0 12px ${color}40` : "none",
|
||||||
|
zIndex: isSelected ? 10 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "11px", color, fontWeight: 600, marginBottom: "2px" }}>{NODE_LABELS[node.type] || node.type}</div>
|
||||||
|
<div style={{ fontSize: "11px", color: "#aaa" }}>{node.data.label}</div>
|
||||||
|
{/* Connection handles */}
|
||||||
|
<div
|
||||||
|
onClick={e => connectingFrom ? handleEndConnect(e, node.id) : handleStartConnect(e, node.id)}
|
||||||
|
style={{
|
||||||
|
position: "absolute", right: -6, top: "50%", transform: "translateY(-50%)",
|
||||||
|
width: 12, height: 12, borderRadius: "50%",
|
||||||
|
background: connectingFrom === node.id ? "#97C459" : "#534AB7",
|
||||||
|
cursor: "pointer", border: "2px solid #1a1a2e",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={e => connectingFrom && handleEndConnect(e, node.id)}
|
||||||
|
style={{
|
||||||
|
position: "absolute", left: -6, top: "50%", transform: "translateY(-50%)",
|
||||||
|
width: 12, height: 12, borderRadius: "50%",
|
||||||
|
background: "#444", cursor: "pointer", border: "2px solid #1a1a2e",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{connectingFrom && (
|
||||||
|
<div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", background: "#534AB7", color: "#fff", padding: "4px 12px", borderRadius: "6px", fontSize: "12px" }}>
|
||||||
|
点击目标节点的右端口完成连线
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config panel */}
|
||||||
|
<div style={{ width: "280px", background: "#16162a", borderLeft: "1px solid #2a2a4a", padding: "16px", overflowY: "auto" }}>
|
||||||
|
{selectedNode ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: "14px", fontWeight: 600, color: "#fff", marginBottom: "4px" }}>
|
||||||
|
{NODE_LABELS[selectedNode.type] || selectedNode.type}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#666", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "12px" }}>
|
||||||
|
<label style={{ fontSize: "12px", color: "#888" }}>显示名称</label>
|
||||||
|
<input
|
||||||
|
value={selectedNode.data.label}
|
||||||
|
onChange={e => {
|
||||||
|
const updated = currentWf.nodes.map(n => n.id === selectedNode.id ? { ...n, data: { ...n.data, label: e.target.value } } : n);
|
||||||
|
setCurrentWf({ ...currentWf, nodes: updated });
|
||||||
|
setSelectedNode({ ...selectedNode, data: { ...selectedNode.data, label: e.target.value } });
|
||||||
|
}}
|
||||||
|
style={configInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config fields */}
|
||||||
|
{Object.entries(templates.find(t => t.type === selectedNode.type)?.config_schema || {}).map(([key, schema]: [string, any]) => (
|
||||||
|
<div key={key} style={{ marginBottom: "12px" }}>
|
||||||
|
<label style={{ fontSize: "12px", color: "#888" }}>{schema.label || key}</label>
|
||||||
|
{schema.help && <div style={{ fontSize: "11px", color: "#666", marginBottom: "2px" }}>{schema.help}</div>}
|
||||||
|
{schema.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
value={selectedNode.data.config[key] ?? ""}
|
||||||
|
onChange={e => handleConfigChange(key, e.target.value)}
|
||||||
|
style={{ ...configInput, minHeight: "60px", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
) : schema.type === "select" && schema.options_from ? (
|
||||||
|
<select
|
||||||
|
value={selectedNode.data.config[key] ?? ""}
|
||||||
|
onChange={e => handleConfigChange(key, e.target.value)}
|
||||||
|
style={configInput}
|
||||||
|
>
|
||||||
|
<option value="">-- 选择 --</option>
|
||||||
|
{(options[schema.options_from] || []).map((opt: any) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : schema.type === "select" && schema.options ? (
|
||||||
|
<select
|
||||||
|
value={selectedNode.data.config[key] ?? ""}
|
||||||
|
onChange={e => handleConfigChange(key, e.target.value)}
|
||||||
|
style={configInput}
|
||||||
|
>
|
||||||
|
<option value="">-- 选择 --</option>
|
||||||
|
{schema.options.map((opt: string) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={schema.type === "number" ? "number" : "text"}
|
||||||
|
value={selectedNode.data.config[key] ?? ""}
|
||||||
|
onChange={e => handleConfigChange(key, schema.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value)}
|
||||||
|
style={configInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#666", fontSize: "13px", textAlign: "center", marginTop: "40px" }}>
|
||||||
|
选择一个节点查看配置
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Styles
|
||||||
|
// ============================================================
|
||||||
|
const btnPrimary: React.CSSProperties = {
|
||||||
|
background: "#534AB7", color: "#fff", border: "none", borderRadius: "6px",
|
||||||
|
padding: "6px 16px", cursor: "pointer", fontSize: "13px", fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnSmall: React.CSSProperties = {
|
||||||
|
background: "transparent", color: "#aaa", border: "1px solid #333", borderRadius: "4px",
|
||||||
|
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCard: React.CSSProperties = {
|
||||||
|
background: "#1a1a2e", borderRadius: "8px", padding: "16px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const configInput: React.CSSProperties = {
|
||||||
|
width: "100%", background: "#0d0d1a", border: "1px solid #333",
|
||||||
|
borderRadius: "4px", padding: "6px 8px", color: "#fff", fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusBadge(status: string): React.CSSProperties {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
published: "#3B6D11",
|
||||||
|
draft: "#BA7517",
|
||||||
|
archived: "#5F5E5A",
|
||||||
|
};
|
||||||
|
const bg = colors[status] || "#5F5E5A";
|
||||||
|
return {
|
||||||
|
background: `${bg}20`, color: bg, padding: "2px 8px",
|
||||||
|
borderRadius: "4px", fontSize: "12px",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user