Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor (8 node types, drag-drop canvas, execution engine)

This commit is contained in:
qyyfw committed 2026-07-06 09:27:30 +08:00
1 parent fc36777a5c
commit b675e9fd20
5 files changed
+694 -1

No files matched your search

+667
View File
@@ -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",
};
}