feat: 丰富架构图资源组件
This commit is contained in:
1 parent
7e419b6464
commit
ce72f71a9d
7 files changed
+585
-39
No files matched your search
@@ -99,8 +99,8 @@ describe("diagram editor canvas surface", () => {
|
||||
test("provides architecture components, boundaries, semantic edges, and editable labels", () => {
|
||||
expect(source).toContain('document.kind === "architecture"');
|
||||
expect(source).toContain('addNode("service")');
|
||||
expect(source).toContain('addNode("database")');
|
||||
expect(source).toContain('addNode("boundary")');
|
||||
expect(source).toContain('{ shape: "database", icon: Database');
|
||||
expect(source).toContain('{ shape: "boundary", icon: Box');
|
||||
expect(source).toContain('t("diagram.architectureConnectHint")');
|
||||
expect(source).toContain("fitArchitectureBoundaries(graph)");
|
||||
expect(source).toContain("parent.addChild(node)");
|
||||
@@ -112,6 +112,33 @@ describe("diagram editor canvas surface", () => {
|
||||
expect(globalStyles).toContain('[data-diagram-kind="architecture"] .x6-port-body');
|
||||
});
|
||||
|
||||
test("organizes architecture resources in a searchable category library", () => {
|
||||
expect(source).toContain("ARCHITECTURE_LIBRARY_CATEGORIES");
|
||||
expect(source).toContain('t("diagram.componentLibrary")');
|
||||
expect(source).toContain('t("diagram.componentSearch")');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryExperience"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryServices"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryDatabases"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryStorage"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryMiddleware"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryNetwork"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategorySecurity"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryObservability"');
|
||||
expect(source).toContain('labelKey: "diagram.componentCategoryExternal"');
|
||||
expect(source).toContain("<Collapsible key={category.id} defaultOpen>");
|
||||
expect(source).toContain("category.items.filter");
|
||||
expect(source).toContain('className="grid grid-cols-7 gap-1 px-1 pb-2"');
|
||||
expect(source).toContain('<TooltipContent side="top">');
|
||||
expect(source).toContain('aria-label={label}');
|
||||
expect(source).toContain('options.label ??');
|
||||
expect(source).toContain('resourceIcon: architectureResourceIcon(item)');
|
||||
expect(source).toContain('...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {})');
|
||||
expect(source).toContain('architectureNodeVisuals(node.shape, size, appearance, node.resourceIcon)');
|
||||
expect(source).toContain('inferArchitectureResourceIcon(node.label, t)');
|
||||
expect(source).toContain('.render({}, null).props.iconNode');
|
||||
expect(source).not.toContain('className="line-clamp-2"');
|
||||
});
|
||||
|
||||
test("shows connection handles only on selected flowchart nodes", () => {
|
||||
expect(globalStyles).toContain('.x6-node.edgeever-flow-node-active .edgeever-flow-port-dot');
|
||||
expect(globalStyles).toContain('.x6-port-body:hover .edgeever-flow-port-dot');
|
||||
|
||||
@@ -1,40 +1,74 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
|
||||
import {
|
||||
Activity,
|
||||
AppWindow,
|
||||
Blocks,
|
||||
Box,
|
||||
Boxes,
|
||||
BrickWall,
|
||||
Cable,
|
||||
Check,
|
||||
ChartNoAxesCombined,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
CircleAlert,
|
||||
Cloud,
|
||||
CloudCog,
|
||||
CloudUpload,
|
||||
Code2,
|
||||
Container,
|
||||
Copy,
|
||||
Cpu,
|
||||
Diamond,
|
||||
Database,
|
||||
DatabaseZap,
|
||||
Download,
|
||||
EthernetPort,
|
||||
FileCode2,
|
||||
FileClock,
|
||||
FileImage,
|
||||
FileStack,
|
||||
FolderArchive,
|
||||
Gauge,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
History as HistoryIcon,
|
||||
LayoutDashboard,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
ListTree,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
LoaderCircle,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreHorizontal,
|
||||
MonitorSmartphone,
|
||||
Network,
|
||||
Pencil,
|
||||
RadioTower,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Router,
|
||||
Search,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
ShieldEllipsis,
|
||||
Smartphone,
|
||||
SquareFunction,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Webhook,
|
||||
Workflow,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -44,6 +78,7 @@ import {
|
||||
markdownToDoc,
|
||||
parseDiagramDocument,
|
||||
serializeDiagramDocument,
|
||||
type ArchitectureResourceIcon,
|
||||
type DiagramDocument,
|
||||
type DiagramEdgeKind,
|
||||
type DiagramNodeShape,
|
||||
@@ -57,6 +92,7 @@ import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
|
||||
import { RevisionHistoryDialog } from "@/components/dialogs/RevisionHistoryDialog";
|
||||
import { ShareMemoDialog } from "@/components/dialogs/ShareMemoDialog";
|
||||
import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -90,7 +126,7 @@ type DiagramEditorPaneProps = {
|
||||
onToggleDesktopFocusMode: () => void;
|
||||
};
|
||||
|
||||
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string };
|
||||
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string; resourceIcon?: ArchitectureResourceIcon };
|
||||
type EdgeData = { kind?: DiagramEdgeKind; bidirectional?: boolean };
|
||||
type MindMapInsertRelation = "child" | "sibling";
|
||||
type FlowPort = "top" | "right" | "bottom" | "left";
|
||||
@@ -125,6 +161,231 @@ type NodeEditorState = {
|
||||
borderColor: string;
|
||||
};
|
||||
|
||||
type ArchitectureLibraryItem = {
|
||||
icon: LucideIcon;
|
||||
labelKey: `diagram.architectureResources.${ArchitectureResourceIcon}`;
|
||||
shape: DiagramNodeShape;
|
||||
};
|
||||
|
||||
const ARCHITECTURE_LIBRARY_CATEGORIES: Array<{
|
||||
id: string;
|
||||
labelKey: string;
|
||||
tone: string;
|
||||
items: ArchitectureLibraryItem[];
|
||||
}> = [
|
||||
{
|
||||
id: "applications",
|
||||
labelKey: "diagram.componentCategoryExperience",
|
||||
tone: "text-cyan-600",
|
||||
items: [
|
||||
{ shape: "client", icon: MonitorSmartphone, labelKey: "diagram.architectureResources.client" },
|
||||
{ shape: "frontend", icon: AppWindow, labelKey: "diagram.architectureResources.webApp" },
|
||||
{ shape: "client", icon: Smartphone, labelKey: "diagram.architectureResources.mobileApp" },
|
||||
{ shape: "frontend", icon: Globe2, labelKey: "diagram.architectureResources.website" },
|
||||
{ shape: "client", icon: Code2, labelKey: "diagram.architectureResources.apiClient" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "compute",
|
||||
labelKey: "diagram.componentCategoryServices",
|
||||
tone: "text-emerald-600",
|
||||
items: [
|
||||
{ shape: "service", icon: Server, labelKey: "diagram.architectureResources.service" },
|
||||
{ shape: "service", icon: Cpu, labelKey: "diagram.architectureResources.virtualMachine" },
|
||||
{ shape: "service", icon: Container, labelKey: "diagram.architectureResources.container" },
|
||||
{ shape: "service", icon: Blocks, labelKey: "diagram.architectureResources.kubernetes" },
|
||||
{ shape: "service", icon: SquareFunction, labelKey: "diagram.architectureResources.serverless" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
labelKey: "diagram.componentCategoryDatabases",
|
||||
tone: "text-violet-600",
|
||||
items: [
|
||||
{ shape: "database", icon: Database, labelKey: "diagram.architectureResources.relationalDatabase" },
|
||||
{ shape: "database", icon: DatabaseZap, labelKey: "diagram.architectureResources.noSqlDatabase" },
|
||||
{ shape: "database", icon: Layers3, labelKey: "diagram.architectureResources.cache" },
|
||||
{ shape: "database", icon: ChartNoAxesCombined, labelKey: "diagram.architectureResources.dataWarehouse" },
|
||||
{ shape: "database", icon: Search, labelKey: "diagram.architectureResources.searchEngine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
labelKey: "diagram.componentCategoryStorage",
|
||||
tone: "text-lime-600",
|
||||
items: [
|
||||
{ shape: "storage", icon: Cloud, labelKey: "diagram.architectureResources.objectStorage" },
|
||||
{ shape: "storage", icon: FileStack, labelKey: "diagram.architectureResources.fileStorage" },
|
||||
{ shape: "storage", icon: HardDrive, labelKey: "diagram.architectureResources.blockStorage" },
|
||||
{ shape: "storage", icon: FolderArchive, labelKey: "diagram.architectureResources.backup" },
|
||||
{ shape: "storage", icon: CloudUpload, labelKey: "diagram.architectureResources.cdn" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "middleware",
|
||||
labelKey: "diagram.componentCategoryMiddleware",
|
||||
tone: "text-orange-600",
|
||||
items: [
|
||||
{ shape: "queue", icon: GitBranch, labelKey: "diagram.architectureResources.messageQueue" },
|
||||
{ shape: "queue", icon: Workflow, labelKey: "diagram.architectureResources.eventBus" },
|
||||
{ shape: "queue", icon: RadioTower, labelKey: "diagram.architectureResources.streamProcessing" },
|
||||
{ shape: "service", icon: Webhook, labelKey: "diagram.architectureResources.webhook" },
|
||||
{ shape: "service", icon: ListTree, labelKey: "diagram.architectureResources.serviceMesh" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
labelKey: "diagram.componentCategoryNetwork",
|
||||
tone: "text-blue-600",
|
||||
items: [
|
||||
{ shape: "service", icon: Router, labelKey: "diagram.architectureResources.apiGateway" },
|
||||
{ shape: "service", icon: Activity, labelKey: "diagram.architectureResources.loadBalancer" },
|
||||
{ shape: "external", icon: Globe2, labelKey: "diagram.architectureResources.dns" },
|
||||
{ shape: "boundary", icon: Network, labelKey: "diagram.architectureResources.vpc" },
|
||||
{ shape: "boundary", icon: Cable, labelKey: "diagram.architectureResources.subnet" },
|
||||
{ shape: "security", icon: EthernetPort, labelKey: "diagram.architectureResources.vpn" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
labelKey: "diagram.componentCategorySecurity",
|
||||
tone: "text-rose-600",
|
||||
items: [
|
||||
{ shape: "security", icon: KeyRound, labelKey: "diagram.architectureResources.identity" },
|
||||
{ shape: "security", icon: BrickWall, labelKey: "diagram.architectureResources.firewall" },
|
||||
{ shape: "security", icon: ShieldCheck, labelKey: "diagram.architectureResources.waf" },
|
||||
{ shape: "security", icon: LockKeyhole, labelKey: "diagram.architectureResources.secretManager" },
|
||||
{ shape: "security", icon: ShieldEllipsis, labelKey: "diagram.architectureResources.certificate" },
|
||||
{ shape: "boundary", icon: Box, labelKey: "diagram.architectureResources.systemBoundary" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "observability",
|
||||
labelKey: "diagram.componentCategoryObservability",
|
||||
tone: "text-teal-600",
|
||||
items: [
|
||||
{ shape: "service", icon: Gauge, labelKey: "diagram.architectureResources.monitoring" },
|
||||
{ shape: "service", icon: FileClock, labelKey: "diagram.architectureResources.logging" },
|
||||
{ shape: "service", icon: ChartNoAxesCombined, labelKey: "diagram.architectureResources.metrics" },
|
||||
{ shape: "service", icon: Activity, labelKey: "diagram.architectureResources.tracing" },
|
||||
{ shape: "service", icon: CircleAlert, labelKey: "diagram.architectureResources.alerting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "external",
|
||||
labelKey: "diagram.componentCategoryExternal",
|
||||
tone: "text-slate-600",
|
||||
items: [
|
||||
{ shape: "external", icon: CloudCog, labelKey: "diagram.architectureResources.saas" },
|
||||
{ shape: "external", icon: Webhook, labelKey: "diagram.architectureResources.externalApi" },
|
||||
{ shape: "external", icon: Zap, labelKey: "diagram.architectureResources.thirdPartyService" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const architectureResourceIcon = (item: ArchitectureLibraryItem) =>
|
||||
item.labelKey.slice("diagram.architectureResources.".length) as ArchitectureResourceIcon;
|
||||
|
||||
const ARCHITECTURE_RESOURCE_ICON_COMPONENTS = Object.fromEntries(
|
||||
ARCHITECTURE_LIBRARY_CATEGORIES.flatMap((category) => category.items)
|
||||
.map((item) => [architectureResourceIcon(item), item.icon]),
|
||||
) as Record<ArchitectureResourceIcon, LucideIcon>;
|
||||
|
||||
const ARCHITECTURE_LIBRARY_ITEMS = ARCHITECTURE_LIBRARY_CATEGORIES.flatMap((category) => category.items);
|
||||
|
||||
const inferArchitectureResourceIcon = (
|
||||
label: string,
|
||||
t: (key: string) => string,
|
||||
) => {
|
||||
const item = ARCHITECTURE_LIBRARY_ITEMS.find((candidate) => t(candidate.labelKey) === label);
|
||||
return item ? architectureResourceIcon(item) : undefined;
|
||||
};
|
||||
|
||||
const ArchitectureComponentLibrary = ({
|
||||
onAdd,
|
||||
t,
|
||||
}: {
|
||||
onAdd: (item: ArchitectureLibraryItem) => void;
|
||||
t: (key: string) => string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const categories = ARCHITECTURE_LIBRARY_CATEGORIES.map((category) => ({
|
||||
...category,
|
||||
items: category.items.filter((item) => t(item.labelKey).toLocaleLowerCase().includes(normalizedQuery)),
|
||||
})).filter((category) => category.items.length > 0);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery("");
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline"><Boxes className="h-4 w-4" />{t("diagram.componentLibrary")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[min(36rem,calc(100vh-8rem))] w-[min(30rem,calc(100vw-2rem))] overflow-y-auto p-0">
|
||||
<div className="sticky top-0 z-10 border-b border-slate-200 bg-white p-2.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-9 pl-9"
|
||||
value={query}
|
||||
placeholder={t("diagram.componentSearch")}
|
||||
aria-label={t("diagram.componentSearch")}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
{categories.length > 0 ? categories.map((category) => (
|
||||
<Collapsible key={category.id} defaultOpen>
|
||||
<DropdownMenuItem asChild onSelect={(event) => event.preventDefault()}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm font-semibold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-green)]">
|
||||
<ChevronDown className="h-4 w-4 text-slate-400 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{t(category.labelKey)}
|
||||
<span className="ml-auto text-xs font-normal text-slate-400">{category.items.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
</DropdownMenuItem>
|
||||
<CollapsibleContent>
|
||||
<div className="grid grid-cols-7 gap-1 px-1 pb-2">
|
||||
{category.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const label = t(item.labelKey);
|
||||
return (
|
||||
<Tooltip key={item.labelKey}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
aria-label={label}
|
||||
className={cn("flex h-10 w-10 cursor-pointer justify-center rounded-lg p-0 hover:bg-current/10 focus:bg-current/10", category.tone)}
|
||||
onSelect={() => {
|
||||
onAdd(item);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)) : (
|
||||
<div className="px-3 py-8 text-center text-sm text-slate-500">{t("diagram.noMatchingComponents")}</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const FLOW_ACTIVE_NODE_CLASS = "edgeever-flow-node-active";
|
||||
const FLOW_PORT_HIT_RADIUS = 14;
|
||||
const FLOW_PORT_DOT_RADIUS = 7;
|
||||
@@ -317,14 +578,53 @@ const architectureNodeVisuals = (
|
||||
shape: DiagramNodeShape,
|
||||
size: { width: number; height: number },
|
||||
appearance: DiagramAppearance,
|
||||
resourceIcon?: ArchitectureResourceIcon,
|
||||
) => {
|
||||
const accent = ARCHITECTURE_NODE_ACCENTS[shape] ?? "#64748B";
|
||||
const iconY = Math.round((size.height - 34) / 2);
|
||||
const iconComponent = resourceIcon ? ARCHITECTURE_RESOURCE_ICON_COMPONENTS[resourceIcon] : undefined;
|
||||
const iconNodes = iconComponent
|
||||
? (iconComponent as unknown as {
|
||||
render: (props: Record<string, never>, ref: null) => {
|
||||
props: { iconNode: Array<[string, Record<string, string>]> };
|
||||
};
|
||||
}).render({}, null).props.iconNode
|
||||
: null;
|
||||
const iconMarkup = iconNodes?.map(([tagName], index) => ({
|
||||
tagName,
|
||||
selector: `architectureIcon${index}`,
|
||||
})) ?? [{ tagName: "path", selector: "architectureIcon" }];
|
||||
const iconAttrs = iconNodes
|
||||
? Object.fromEntries(iconNodes.map(([, sourceAttrs], index) => {
|
||||
const { key: _key, ...geometry } = sourceAttrs;
|
||||
return [`architectureIcon${index}`, {
|
||||
...geometry,
|
||||
transform: `translate(15 ${iconY + 5})`,
|
||||
fill: "none",
|
||||
stroke: accent,
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
pointerEvents: "none",
|
||||
}];
|
||||
}))
|
||||
: {
|
||||
architectureIcon: {
|
||||
d: ARCHITECTURE_NODE_ICONS[shape],
|
||||
transform: `translate(15 ${iconY + 5})`,
|
||||
fill: "none",
|
||||
stroke: accent,
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
};
|
||||
return {
|
||||
markup: [
|
||||
{ tagName: "rect", selector: "body" },
|
||||
{ tagName: "rect", selector: "iconFrame" },
|
||||
{ tagName: "path", selector: "architectureIcon" },
|
||||
...iconMarkup,
|
||||
{ tagName: "text", selector: "label" },
|
||||
],
|
||||
attrs: {
|
||||
@@ -339,16 +639,7 @@ const architectureNodeVisuals = (
|
||||
stroke: "none",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
architectureIcon: {
|
||||
d: ARCHITECTURE_NODE_ICONS[shape],
|
||||
transform: `translate(15 ${iconY + 5})`,
|
||||
fill: "none",
|
||||
stroke: accent,
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
...iconAttrs,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -402,7 +693,7 @@ const nodeMetadata = (
|
||||
const size = diagramNodeSize(node, kind);
|
||||
const hasPorts = isConnectableDiagram(kind) && node.shape !== "boundary";
|
||||
const architectureVisuals = kind === "architecture" && node.shape !== "boundary"
|
||||
? architectureNodeVisuals(node.shape, size, appearance)
|
||||
? architectureNodeVisuals(node.shape, size, appearance, node.resourceIcon)
|
||||
: null;
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -412,7 +703,12 @@ const nodeMetadata = (
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
zIndex: node.shape === "boundary" ? 0 : 2,
|
||||
data: { label: node.label, shape: node.shape, ...(node.parentId ? { parentId: node.parentId } : {}) } satisfies NodeData,
|
||||
data: {
|
||||
label: node.label,
|
||||
shape: node.shape,
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
|
||||
} satisfies NodeData,
|
||||
...(architectureVisuals ? { markup: architectureVisuals.markup } : {}),
|
||||
attrs: {
|
||||
body: visualAttrs.body,
|
||||
@@ -488,6 +784,7 @@ const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: Dia
|
||||
height: Math.round(size.height),
|
||||
shape: data?.shape ?? "process",
|
||||
...(data?.parentId && graph.getCellById(data.parentId)?.isNode() ? { parentId: data.parentId } : {}),
|
||||
...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {}),
|
||||
};
|
||||
}),
|
||||
edges: graph.getEdges().flatMap((edge) => {
|
||||
@@ -593,9 +890,10 @@ const applyGraphPalette = (
|
||||
node.attr("body", attrs.body);
|
||||
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
|
||||
if (kind === "architecture" && shape !== "boundary") {
|
||||
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance);
|
||||
node.attr("iconFrame", architectureVisuals.attrs.iconFrame);
|
||||
node.attr("architectureIcon", architectureVisuals.attrs.architectureIcon);
|
||||
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance, data?.resourceIcon);
|
||||
for (const [selector, selectorAttrs] of Object.entries(architectureVisuals.attrs)) {
|
||||
node.attr(selector, selectorAttrs);
|
||||
}
|
||||
}
|
||||
for (const port of node.getPorts()) {
|
||||
if (!port.id) continue;
|
||||
@@ -843,7 +1141,15 @@ export const DiagramEditorPane = ({
|
||||
},
|
||||
}));
|
||||
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: !readOnly, showNodeSelectionBox: true, showEdgeSelectionBox: true }));
|
||||
graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme, document.kind, appearance)));
|
||||
graph.addNodes(document.nodes.map((node) => {
|
||||
const inferredResourceIcon = document.kind === "architecture" && !node.resourceIcon
|
||||
? inferArchitectureResourceIcon(node.label, t)
|
||||
: undefined;
|
||||
return nodeMetadata({
|
||||
...node,
|
||||
...(inferredResourceIcon ? { resourceIcon: inferredResourceIcon } : {}),
|
||||
}, documentTheme, document.kind, appearance);
|
||||
}));
|
||||
if (document.kind === "architecture") {
|
||||
for (const node of graph.getNodes()) {
|
||||
const parentId = node.getData<NodeData>()?.parentId;
|
||||
@@ -1117,6 +1423,7 @@ export const DiagramEditorPane = ({
|
||||
height: node.getSize().height,
|
||||
shape: data?.shape ?? "process",
|
||||
...(data?.parentId ? { parentId: data.parentId } : {}),
|
||||
...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {}),
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
const parent = data?.parentId ? graph.getCellById(data.parentId) : null;
|
||||
if (parent?.isNode()) parent.addChild(duplicate);
|
||||
@@ -1226,7 +1533,13 @@ export const DiagramEditorPane = ({
|
||||
|
||||
const addNode = useCallback((
|
||||
shape: DiagramNodeShape = "process",
|
||||
options: { relation?: MindMapInsertRelation; baseNodeId?: string; beginEditing?: boolean } = {},
|
||||
options: {
|
||||
relation?: MindMapInsertRelation;
|
||||
baseNodeId?: string;
|
||||
beginEditing?: boolean;
|
||||
label?: string;
|
||||
resourceIcon?: ArchitectureResourceIcon;
|
||||
} = {},
|
||||
) => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || !document || readOnly) return;
|
||||
@@ -1272,7 +1585,7 @@ export const DiagramEditorPane = ({
|
||||
graph.startBatch("add");
|
||||
const node = graph.addNode(nodeMetadata({
|
||||
id,
|
||||
label: isMindMap ? t("diagram.newTopic") : isArchitecture ? architectureNodeLabel(shape, t) : t("diagram.newStep"),
|
||||
label: options.label ?? (isMindMap ? t("diagram.newTopic") : isArchitecture ? architectureNodeLabel(shape, t) : t("diagram.newStep")),
|
||||
x: nextPosition.x,
|
||||
y: nextPosition.y,
|
||||
width: authoredSize.width,
|
||||
@@ -1280,6 +1593,7 @@ export const DiagramEditorPane = ({
|
||||
shape: isMindMap ? "topic" : shape,
|
||||
...(isMindMap && parent?.isNode() ? { parentId: parent.id } : {}),
|
||||
...(architectureParentId ? { parentId: architectureParentId } : {}),
|
||||
...(isArchitecture && options.resourceIcon ? { resourceIcon: options.resourceIcon } : {}),
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
if (architectureParentId) {
|
||||
const architectureParent = graph.getCellById(architectureParentId);
|
||||
@@ -1775,22 +2089,13 @@ export const DiagramEditorPane = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("diagram.architectureShortcuts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline"><Boxes className="h-4 w-4" />{t("diagram.components")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onSelect={() => addNode("client")}><Network className="h-4 w-4" />{t("diagram.addClient")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("frontend")}><Globe2 className="h-4 w-4" />{t("diagram.addFrontend")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("database")}><Database className="h-4 w-4" />{t("diagram.addDatabase")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("storage")}><HardDrive className="h-4 w-4" />{t("diagram.addStorage")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("queue")}><GitBranch className="h-4 w-4" />{t("diagram.addQueue")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("security")}><ShieldCheck className="h-4 w-4" />{t("diagram.addSecurity")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("external")}><Globe2 className="h-4 w-4" />{t("diagram.addExternal")}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => addNode("boundary")}><Box className="h-4 w-4" />{t("diagram.addBoundary")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ArchitectureComponentLibrary
|
||||
onAdd={(item) => addNode(item.shape, {
|
||||
label: t(item.labelKey),
|
||||
resourceIcon: architectureResourceIcon(item),
|
||||
})}
|
||||
t={t}
|
||||
/>
|
||||
<span className="hidden items-center gap-1.5 px-2 text-xs text-slate-500 xl:flex"><Link2 className="h-3.5 w-3.5" />{t("diagram.architectureConnectHint")}</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram";
|
||||
import type { ArchitectureResourceIcon, DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram";
|
||||
|
||||
export type DiagramAppearance = "light" | "dark";
|
||||
|
||||
@@ -47,6 +47,32 @@ const architectureAccent: Partial<Record<DiagramNodeShape, string>> = {
|
||||
external: "#64748B",
|
||||
};
|
||||
|
||||
// Native X6 viewers do not bundle React icon components. These compact,
|
||||
// monochrome glyphs preserve each resource's visual identity in that portable
|
||||
// projection while the Web editor renders the matching Lucide pictogram.
|
||||
const architectureResourceGlyphs: Record<ArchitectureResourceIcon, string> = {
|
||||
client: "▣", webApp: "▤", mobileApp: "▯", website: "◎", apiClient: "</>",
|
||||
service: "▤", virtualMachine: "⚙", container: "⬡", kubernetes: "⌘", serverless: "ƒ",
|
||||
relationalDatabase: "◉", noSqlDatabase: "ϟ", cache: "▱", dataWarehouse: "▥", searchEngine: "⌕",
|
||||
objectStorage: "☁", fileStorage: "▧", blockStorage: "▰", backup: "↶", cdn: "⇧",
|
||||
messageQueue: "≡", eventBus: "⑂", streamProcessing: "≋", webhook: "⌁", serviceMesh: "⋮",
|
||||
apiGateway: "⇄", loadBalancer: "↔", dns: "◎", vpc: "◇", subnet: "⊞", vpn: "⌁",
|
||||
identity: "◆", firewall: "▦", waf: "✓", secretManager: "⌑", certificate: "◈", systemBoundary: "□",
|
||||
monitoring: "◴", logging: "▤", metrics: "↗", tracing: "∿", alerting: "!",
|
||||
saas: "☁", externalApi: "⌁", thirdPartyService: "ϟ",
|
||||
};
|
||||
|
||||
const architectureShapeGlyphs: Partial<Record<DiagramNodeShape, string>> = {
|
||||
client: "▣",
|
||||
frontend: "▤",
|
||||
service: "▥",
|
||||
database: "◉",
|
||||
storage: "▰",
|
||||
queue: "≡",
|
||||
security: "✓",
|
||||
external: "☁",
|
||||
};
|
||||
|
||||
/** Plain X6 metadata shared by native WebView viewers. */
|
||||
export const diagramDocumentToX6Cells = (
|
||||
document: DiagramDocument,
|
||||
@@ -65,6 +91,10 @@ export const diagramDocumentToX6Cells = (
|
||||
? (appearance === "dark" ? palette.nodeFill : `${accent}12`)
|
||||
: emphasized ? palette.topicFill : palette.nodeFill;
|
||||
const stroke = isBoundary ? palette.nodeStroke : accent ?? (emphasized ? palette.topicStroke : palette.nodeStroke);
|
||||
const usesArchitectureIcon = document.kind === "architecture" && !isBoundary;
|
||||
const iconGlyph = node.resourceIcon
|
||||
? architectureResourceGlyphs[node.resourceIcon]
|
||||
: architectureShapeGlyphs[node.shape];
|
||||
return {
|
||||
id: node.id,
|
||||
shape: node.shape === "decision" ? "polygon" : "rect",
|
||||
@@ -73,6 +103,12 @@ export const diagramDocumentToX6Cells = (
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
zIndex: isBoundary ? 0 : 2,
|
||||
...(usesArchitectureIcon ? { markup: [
|
||||
{ tagName: "rect", selector: "body" },
|
||||
{ tagName: "rect", selector: "iconFrame" },
|
||||
{ tagName: "text", selector: "resourceIcon" },
|
||||
{ tagName: "text", selector: "label" },
|
||||
] } : {}),
|
||||
attrs: {
|
||||
body: {
|
||||
fill,
|
||||
@@ -89,7 +125,30 @@ export const diagramDocumentToX6Cells = (
|
||||
fontSize: node.shape === "topic" ? 14 : isBoundary ? 12 : 13,
|
||||
fontWeight: emphasized || isBoundary || accent ? 650 : 500,
|
||||
...(isBoundary ? { refX: 18, refY: 22, textAnchor: "start", textVerticalAnchor: "middle" } : {}),
|
||||
...(usesArchitectureIcon ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}),
|
||||
},
|
||||
...(usesArchitectureIcon ? {
|
||||
iconFrame: {
|
||||
x: 10,
|
||||
y: Math.round((node.height - 34) / 2),
|
||||
width: 34,
|
||||
height: 34,
|
||||
rx: node.shape === "database" ? 17 : node.shape === "security" ? 12 : 8,
|
||||
ry: node.shape === "database" ? 17 : node.shape === "security" ? 12 : 8,
|
||||
fill: appearance === "dark" ? `${accent}30` : `${accent}18`,
|
||||
stroke: "none",
|
||||
},
|
||||
resourceIcon: {
|
||||
text: iconGlyph,
|
||||
x: 27,
|
||||
y: node.height / 2,
|
||||
fill: accent,
|
||||
fontSize: iconGlyph === "</>" ? 10 : 17,
|
||||
fontWeight: 700,
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("diagram document", () => {
|
||||
|
||||
test("round-trips architecture components, boundaries, and semantic connections", () => {
|
||||
const document = createDefaultDiagramDocument("architecture");
|
||||
document.nodes.find((node) => node.id === "api").resourceIcon = "container";
|
||||
const parsed = parseDiagramDocument(serializeDiagramDocument(document));
|
||||
expect(parsed).toEqual(document);
|
||||
expect(parsed.schemaVersion).toBe(2);
|
||||
@@ -97,6 +98,23 @@ describe("diagram document", () => {
|
||||
expect(fallback).toContain("classDef archDatabase");
|
||||
});
|
||||
|
||||
test("keeps legacy architecture nodes valid and projects resource-specific icons", () => {
|
||||
const legacy = createDefaultDiagramDocument("architecture");
|
||||
expect(legacy.nodes.every((node) => node.resourceIcon === undefined)).toBe(true);
|
||||
expect(parseDiagramDocument(serializeDiagramDocument(legacy))).toEqual(legacy);
|
||||
|
||||
const container = legacy.nodes.find((node) => node.id === "api");
|
||||
const database = legacy.nodes.find((node) => node.id === "database");
|
||||
container.resourceIcon = "container";
|
||||
database.resourceIcon = "noSqlDatabase";
|
||||
const projected = diagramDocumentToX6Cells(legacy, "light");
|
||||
const containerCell = projected.nodes.find((node) => node.id === "api");
|
||||
const databaseCell = projected.nodes.find((node) => node.id === "database");
|
||||
expect(containerCell.attrs.resourceIcon.text).toBe("⬡");
|
||||
expect(databaseCell.attrs.resourceIcon.text).toBe("ϟ");
|
||||
expect(containerCell.attrs.resourceIcon.text).not.toBe(databaseCell.attrs.resourceIcon.text);
|
||||
});
|
||||
|
||||
test("rejects malformed and dangling graph data", () => {
|
||||
expect(parseDiagramDocument("ordinary note")).toBeNull();
|
||||
const document = createDefaultDiagramDocument("flowchart");
|
||||
|
||||
@@ -21,6 +21,20 @@ export type DiagramNodeShape =
|
||||
export type DiagramEdgeKind = "dependency" | "request" | "async" | "data";
|
||||
export type DiagramTheme = "brand" | "ocean" | "ink";
|
||||
|
||||
export const ARCHITECTURE_RESOURCE_ICONS = [
|
||||
"client", "webApp", "mobileApp", "website", "apiClient",
|
||||
"service", "virtualMachine", "container", "kubernetes", "serverless",
|
||||
"relationalDatabase", "noSqlDatabase", "cache", "dataWarehouse", "searchEngine",
|
||||
"objectStorage", "fileStorage", "blockStorage", "backup", "cdn",
|
||||
"messageQueue", "eventBus", "streamProcessing", "webhook", "serviceMesh",
|
||||
"apiGateway", "loadBalancer", "dns", "vpc", "subnet", "vpn",
|
||||
"identity", "firewall", "waf", "secretManager", "certificate", "systemBoundary",
|
||||
"monitoring", "logging", "metrics", "tracing", "alerting",
|
||||
"saas", "externalApi", "thirdPartyService",
|
||||
] as const;
|
||||
|
||||
export type ArchitectureResourceIcon = typeof ARCHITECTURE_RESOURCE_ICONS[number];
|
||||
|
||||
export type DiagramNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -30,6 +44,7 @@ export type DiagramNode = {
|
||||
height: number;
|
||||
shape: DiagramNodeShape;
|
||||
parentId?: string;
|
||||
resourceIcon?: ArchitectureResourceIcon;
|
||||
};
|
||||
|
||||
export type DiagramEdge = {
|
||||
@@ -80,6 +95,9 @@ const DIAGRAM_NODE_SHAPES: DiagramNodeShape[] = [
|
||||
];
|
||||
const DIAGRAM_EDGE_KINDS: DiagramEdgeKind[] = ["dependency", "request", "async", "data"];
|
||||
|
||||
const isArchitectureResourceIcon = (value: unknown): value is ArchitectureResourceIcon =>
|
||||
typeof value === "string" && ARCHITECTURE_RESOURCE_ICONS.includes(value as ArchitectureResourceIcon);
|
||||
|
||||
const parseNode = (value: unknown): DiagramNode | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const node = value as Record<string, unknown>;
|
||||
@@ -98,6 +116,7 @@ const parseNode = (value: unknown): DiagramNode | null => {
|
||||
height: node.height,
|
||||
shape: node.shape as DiagramNodeShape,
|
||||
...(typeof node.parentId === "string" && node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(isArchitectureResourceIcon(node.resourceIcon) ? { resourceIcon: node.resourceIcon } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -266,6 +266,65 @@ export const enUS = {
|
||||
addExternal: "Add external system",
|
||||
addBoundary: "Add system boundary",
|
||||
components: "Components",
|
||||
componentLibrary: "Shape library",
|
||||
componentSearch: "Search architecture components",
|
||||
componentCategoryExperience: "Apps & clients",
|
||||
componentCategoryServices: "Compute & services",
|
||||
componentCategoryDatabases: "Databases",
|
||||
componentCategoryStorage: "Storage & CDN",
|
||||
componentCategoryMiddleware: "Internet middleware",
|
||||
componentCategoryNetwork: "Network",
|
||||
componentCategorySecurity: "Security & boundaries",
|
||||
componentCategoryObservability: "Management & monitoring",
|
||||
componentCategoryExternal: "External services",
|
||||
noMatchingComponents: "No matching architecture components",
|
||||
architectureResources: {
|
||||
client: "Client",
|
||||
webApp: "Web app",
|
||||
mobileApp: "Mobile app",
|
||||
website: "Website",
|
||||
apiClient: "API client",
|
||||
service: "Application service",
|
||||
virtualMachine: "Virtual machine",
|
||||
container: "Container",
|
||||
kubernetes: "Kubernetes",
|
||||
serverless: "Serverless function",
|
||||
relationalDatabase: "Relational database",
|
||||
noSqlDatabase: "NoSQL database",
|
||||
cache: "Cache",
|
||||
dataWarehouse: "Data warehouse",
|
||||
searchEngine: "Search engine",
|
||||
objectStorage: "Object storage",
|
||||
fileStorage: "File storage",
|
||||
blockStorage: "Block storage",
|
||||
backup: "Backup",
|
||||
cdn: "CDN",
|
||||
messageQueue: "Message queue",
|
||||
eventBus: "Event bus",
|
||||
streamProcessing: "Stream processing",
|
||||
webhook: "Webhook",
|
||||
serviceMesh: "Service mesh",
|
||||
apiGateway: "API gateway",
|
||||
loadBalancer: "Load balancer",
|
||||
dns: "DNS",
|
||||
vpc: "Virtual private cloud",
|
||||
subnet: "Subnet",
|
||||
vpn: "VPN",
|
||||
identity: "Identity and access",
|
||||
firewall: "Firewall",
|
||||
waf: "Web application firewall",
|
||||
secretManager: "Secret manager",
|
||||
certificate: "Certificate",
|
||||
systemBoundary: "System boundary",
|
||||
monitoring: "Monitoring",
|
||||
logging: "Logging",
|
||||
metrics: "Metrics",
|
||||
tracing: "Distributed tracing",
|
||||
alerting: "Alerting",
|
||||
saas: "SaaS service",
|
||||
externalApi: "External API",
|
||||
thirdPartyService: "Third-party service",
|
||||
},
|
||||
architectureConnectHint: "Drag a handle to connect architecture components",
|
||||
connectHint: "Drag a handle to connect, or drop on canvas to create the next node",
|
||||
newTopic: "New topic",
|
||||
|
||||
@@ -266,6 +266,65 @@ export const zhCN = {
|
||||
addExternal: "添加外部系统",
|
||||
addBoundary: "添加系统边界",
|
||||
components: "组件",
|
||||
componentLibrary: "图形库",
|
||||
componentSearch: "搜索架构组件",
|
||||
componentCategoryExperience: "应用与客户端",
|
||||
componentCategoryServices: "计算与服务",
|
||||
componentCategoryDatabases: "数据库",
|
||||
componentCategoryStorage: "存储与 CDN",
|
||||
componentCategoryMiddleware: "互联网中间件",
|
||||
componentCategoryNetwork: "网络",
|
||||
componentCategorySecurity: "安全与边界",
|
||||
componentCategoryObservability: "管理与监控",
|
||||
componentCategoryExternal: "外部服务",
|
||||
noMatchingComponents: "没有匹配的架构组件",
|
||||
architectureResources: {
|
||||
client: "客户端",
|
||||
webApp: "Web 应用",
|
||||
mobileApp: "移动应用",
|
||||
website: "网站",
|
||||
apiClient: "API 客户端",
|
||||
service: "应用服务",
|
||||
virtualMachine: "虚拟机",
|
||||
container: "容器",
|
||||
kubernetes: "Kubernetes",
|
||||
serverless: "无服务器函数",
|
||||
relationalDatabase: "关系型数据库",
|
||||
noSqlDatabase: "NoSQL 数据库",
|
||||
cache: "缓存",
|
||||
dataWarehouse: "数据仓库",
|
||||
searchEngine: "搜索引擎",
|
||||
objectStorage: "对象存储",
|
||||
fileStorage: "文件存储",
|
||||
blockStorage: "块存储",
|
||||
backup: "备份",
|
||||
cdn: "CDN",
|
||||
messageQueue: "消息队列",
|
||||
eventBus: "事件总线",
|
||||
streamProcessing: "流处理",
|
||||
webhook: "Webhook",
|
||||
serviceMesh: "服务网格",
|
||||
apiGateway: "API 网关",
|
||||
loadBalancer: "负载均衡",
|
||||
dns: "DNS",
|
||||
vpc: "虚拟私有云",
|
||||
subnet: "子网",
|
||||
vpn: "VPN",
|
||||
identity: "身份与访问管理",
|
||||
firewall: "防火墙",
|
||||
waf: "Web 应用防火墙",
|
||||
secretManager: "密钥管理",
|
||||
certificate: "证书",
|
||||
systemBoundary: "系统边界",
|
||||
monitoring: "监控",
|
||||
logging: "日志",
|
||||
metrics: "指标",
|
||||
tracing: "链路追踪",
|
||||
alerting: "告警",
|
||||
saas: "SaaS 服务",
|
||||
externalApi: "外部 API",
|
||||
thirdPartyService: "第三方服务",
|
||||
},
|
||||
architectureConnectHint: "拖动端点连接架构组件",
|
||||
connectHint: "拖动端点连线;松开在空白处可创建后续节点",
|
||||
newTopic: "新主题",
|
||||
|
||||
Reference in new issue
Block a user