Update WBBR frontend template collection
This commit is contained in:
1
app/stargraph/StarGraph.module.css
Normal file
1
app/stargraph/StarGraph.module.css
Normal file
File diff suppressed because one or more lines are too long
10
app/stargraph/StarGraphHover.module.css
Normal file
10
app/stargraph/StarGraphHover.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.hoverCard{position:absolute;z-index:7;width:224px;padding:11px 12px;border:1px solid rgba(145,136,225,.32);border-radius:8px;background:rgba(12,15,34,.96);box-shadow:0 16px 38px rgba(0,0,0,.38),0 0 22px rgba(98,83,196,.12);color:#dfe4fa;pointer-events:none;backdrop-filter:blur(12px)}
|
||||
.hoverHeader{display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid rgba(145,156,202,.14)}
|
||||
.hoverHeader>i{width:9px;height:22px;flex:0 0 auto;border-radius:2px}
|
||||
.hoverHeader>span{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}
|
||||
.hoverHeader small{color:#7d87aa;font-size:8px}
|
||||
.hoverHeader strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}
|
||||
.hoverHeader em{padding:3px 5px;border-radius:4px;background:rgba(129,112,228,.13);color:#9b92db;font-size:7px;font-style:normal;white-space:nowrap}
|
||||
.hoverCard p{margin:8px 0 0;color:#929bbd;font-size:9px;line-height:1.65}
|
||||
.hoverTags{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
||||
.hoverTags span{padding:3px 6px;border-radius:4px;background:rgba(115,103,192,.12);color:#888fb4;font-size:7px}
|
||||
728
app/stargraph/page.tsx
Normal file
728
app/stargraph/page.tsx
Normal file
@@ -0,0 +1,728 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChangeEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import demoData from "../data/star-graph.json";
|
||||
import styles from "./StarGraph.module.css";
|
||||
import hoverStyles from "./StarGraphHover.module.css";
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
size: number;
|
||||
description: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type GraphEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type GraphData = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
nodeTypes: { id: string; name: string; color: string }[];
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
};
|
||||
|
||||
type Interaction = {
|
||||
mode: "node" | "pan";
|
||||
nodeId?: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "orbit-star-graph-v1";
|
||||
const INITIAL_GRAPH = demoData as GraphData;
|
||||
const RELATION_TYPES = ["角色关系", "组织关系", "地点关系", "剧情关系", "事件关系", "协作关系", "自定义"];
|
||||
|
||||
export default function StarGraphPage() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
const interactionRef = useRef<Interaction | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const [graph, setGraph] = useState<GraphData>(() => cloneGraph(INITIAL_GRAPH));
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 650, dpr: 1 });
|
||||
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>("project-starsea");
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [connectMode, setConnectMode] = useState(false);
|
||||
const [connectFrom, setConnectFrom] = useState<string | null>(null);
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const [hoverPoint, setHoverPoint] = useState({ x: 0, y: 0 });
|
||||
const [showAddNode, setShowAddNode] = useState(false);
|
||||
const [showAddEdge, setShowAddEdge] = useState(false);
|
||||
const [addPoint, setAddPoint] = useState({ x: 0, y: 0 });
|
||||
const [pendingEdge, setPendingEdge] = useState({ source: "", target: "" });
|
||||
const [newNode, setNewNode] = useState({ label: "", type: "character", description: "", tags: "" });
|
||||
const [newEdge, setNewEdge] = useState({ label: "关联", type: "自定义" });
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
const stars = useMemo(() => makeStars(180), []);
|
||||
const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||||
const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId) ?? null;
|
||||
const fromNode = graph.nodes.find((node) => node.id === connectFrom) ?? null;
|
||||
const hoveredNode = graph.nodes.find((node) => node.id === hoveredNodeId) ?? null;
|
||||
const visibleNodes = graph.nodes.filter((node) => {
|
||||
const text = `${node.label} ${node.type} ${node.tags.join(" ")}`.toLowerCase();
|
||||
return (typeFilter === "all" || node.type === typeFilter) && text.includes(query.trim().toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (isGraphData(parsed)) setGraph(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Keep the bundled demo if local data cannot be read.
|
||||
}
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(graph));
|
||||
}, [graph, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
setCanvasSize({ width: rect.width, height: rect.height, dpr });
|
||||
};
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
let frame = 0;
|
||||
const draw = (time: number) => {
|
||||
drawGraph({
|
||||
context,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
dpr: canvasSize.dpr,
|
||||
time,
|
||||
stars,
|
||||
graph,
|
||||
viewport,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
connectFrom,
|
||||
query,
|
||||
typeFilter,
|
||||
});
|
||||
frame = window.requestAnimationFrame(draw);
|
||||
};
|
||||
frame = window.requestAnimationFrame(draw);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [canvasSize, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter]);
|
||||
|
||||
const notify = (message: string) => {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 1800);
|
||||
};
|
||||
|
||||
const updateNode = (id: string, patch: Partial<GraphNode>) => {
|
||||
setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } : node) }));
|
||||
};
|
||||
|
||||
const updateEdge = (id: string, patch: Partial<GraphEdge>) => {
|
||||
setGraph((current) => ({ ...current, edges: current.edges.map((edge) => edge.id === id ? { ...edge, ...patch } : edge) }));
|
||||
};
|
||||
|
||||
const pointInCanvas = (event: { clientX: number; clientY: number }) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
|
||||
};
|
||||
|
||||
const screenPoint = (node: GraphNode) => ({
|
||||
x: canvasSize.width / 2 + viewport.x + node.x * viewport.scale,
|
||||
y: canvasSize.height / 2 + viewport.y + node.y * viewport.scale,
|
||||
});
|
||||
|
||||
const hitNode = (point: { x: number; y: number }) => {
|
||||
for (let index = graph.nodes.length - 1; index >= 0; index -= 1) {
|
||||
const node = graph.nodes[index];
|
||||
const screen = screenPoint(node);
|
||||
const card = nodeCardSize(node, viewport.scale);
|
||||
if (Math.abs(point.x - screen.x) <= card.width / 2 + 5 && Math.abs(point.y - screen.y) <= card.height / 2 + 5) return node;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hitEdge = (point: { x: number; y: number }) => graph.edges.find((edge) => {
|
||||
const source = graph.nodes.find((node) => node.id === edge.source);
|
||||
const target = graph.nodes.find((node) => node.id === edge.target);
|
||||
if (!source || !target) return false;
|
||||
return distanceToSegment(point, screenPoint(source), screenPoint(target)) < 9;
|
||||
}) ?? null;
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const point = pointInCanvas(event);
|
||||
const node = hitNode(point);
|
||||
if (connectMode && node) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
interactionRef.current = node
|
||||
? { mode: "node", nodeId: node.id, startX: point.x, startY: point.y, baseX: node.x, baseY: node.y, moved: false }
|
||||
: { mode: "pan", startX: point.x, startY: point.y, baseX: viewport.x, baseY: viewport.y, moved: false };
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const interaction = interactionRef.current;
|
||||
const point = pointInCanvas(event);
|
||||
if (!interaction) {
|
||||
const node = hitNode(point);
|
||||
setHoveredNodeId(node?.id ?? null);
|
||||
setHoverPoint({
|
||||
x: clamp(point.x + 15, 12, Math.max(12, canvasSize.width - 238)),
|
||||
y: clamp(point.y + 15, 58, Math.max(58, canvasSize.height - 150)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setHoveredNodeId(null);
|
||||
const dx = point.x - interaction.startX;
|
||||
const dy = point.y - interaction.startY;
|
||||
if (Math.hypot(dx, dy) > 3) interaction.moved = true;
|
||||
if (interaction.mode === "node" && interaction.nodeId) {
|
||||
updateNode(interaction.nodeId, { x: interaction.baseX + dx / viewport.scale, y: interaction.baseY + dy / viewport.scale });
|
||||
} else {
|
||||
setViewport((current) => ({ ...current, x: interaction.baseX + dx, y: interaction.baseY + dy }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (interactionRef.current?.moved) suppressClickRef.current = true;
|
||||
interactionRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
const point = pointInCanvas(event);
|
||||
const node = hitNode(point);
|
||||
if (node) {
|
||||
if (connectMode) {
|
||||
if (!connectFrom) {
|
||||
setConnectFrom(node.id);
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
notify(`已选择起点:${node.label}`);
|
||||
} else if (connectFrom === node.id) {
|
||||
notify("请选择另一个节点");
|
||||
} else {
|
||||
setPendingEdge({ source: connectFrom, target: node.id });
|
||||
setNewEdge({ label: "关联", type: "自定义" });
|
||||
setShowAddEdge(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
return;
|
||||
}
|
||||
if (connectMode) return;
|
||||
const edge = hitEdge(point);
|
||||
setSelectedEdgeId(edge?.id ?? null);
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const point = pointInCanvas(event);
|
||||
if (hitNode(point)) return;
|
||||
setAddPoint({
|
||||
x: (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale,
|
||||
y: (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale,
|
||||
});
|
||||
setShowAddNode(true);
|
||||
};
|
||||
|
||||
const handleWheel = (event: ReactWheelEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault();
|
||||
const point = pointInCanvas(event);
|
||||
const nextScale = clamp(viewport.scale * (event.deltaY > 0 ? 0.9 : 1.1), 0.45, 2.2);
|
||||
const worldX = (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale;
|
||||
const worldY = (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale;
|
||||
setViewport({
|
||||
scale: nextScale,
|
||||
x: point.x - canvasSize.width / 2 - worldX * nextScale,
|
||||
y: point.y - canvasSize.height / 2 - worldY * nextScale,
|
||||
});
|
||||
};
|
||||
|
||||
const openAddNode = () => {
|
||||
setAddPoint({ x: -viewport.x / viewport.scale, y: -viewport.y / viewport.scale });
|
||||
setNewNode({ label: "", type: "character", description: "", tags: "" });
|
||||
setShowAddNode(true);
|
||||
};
|
||||
|
||||
const createNode = () => {
|
||||
const label = newNode.label.trim();
|
||||
if (!label) return notify("请输入节点名称");
|
||||
const nodeType = graph.nodeTypes.find((type) => type.id === newNode.type) ?? graph.nodeTypes[0];
|
||||
const node: GraphNode = {
|
||||
id: `node-${Date.now()}`,
|
||||
label,
|
||||
type: nodeType.id,
|
||||
x: addPoint.x,
|
||||
y: addPoint.y,
|
||||
color: nodeType.color,
|
||||
size: 21,
|
||||
description: newNode.description.trim() || "暂无说明",
|
||||
tags: newNode.tags.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean),
|
||||
};
|
||||
setGraph((current) => ({ ...current, nodes: [...current.nodes, node] }));
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
setShowAddNode(false);
|
||||
notify("节点已添加");
|
||||
};
|
||||
|
||||
const createEdge = () => {
|
||||
if (!pendingEdge.source || !pendingEdge.target) return;
|
||||
const duplicate = graph.edges.some((edge) =>
|
||||
(edge.source === pendingEdge.source && edge.target === pendingEdge.target)
|
||||
|| (edge.source === pendingEdge.target && edge.target === pendingEdge.source));
|
||||
if (duplicate) {
|
||||
setShowAddEdge(false);
|
||||
setConnectFrom(null);
|
||||
return notify("这两个节点已经存在关联");
|
||||
}
|
||||
const edge: GraphEdge = {
|
||||
id: `edge-${Date.now()}`,
|
||||
source: pendingEdge.source,
|
||||
target: pendingEdge.target,
|
||||
label: newEdge.label.trim() || "关联",
|
||||
type: newEdge.type,
|
||||
};
|
||||
setGraph((current) => ({ ...current, edges: [...current.edges, edge] }));
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedNodeId(null);
|
||||
setShowAddEdge(false);
|
||||
setConnectFrom(null);
|
||||
notify("直线关联已建立");
|
||||
};
|
||||
|
||||
const deleteNode = (id: string) => {
|
||||
setGraph((current) => ({
|
||||
...current,
|
||||
nodes: current.nodes.filter((node) => node.id !== id),
|
||||
edges: current.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||
}));
|
||||
setSelectedNodeId(null);
|
||||
if (connectFrom === id) setConnectFrom(null);
|
||||
notify("节点及其关联已删除");
|
||||
};
|
||||
|
||||
const deleteEdge = (id: string) => {
|
||||
setGraph((current) => ({ ...current, edges: current.edges.filter((edge) => edge.id !== id) }));
|
||||
setSelectedEdgeId(null);
|
||||
notify("关联已删除");
|
||||
};
|
||||
|
||||
const resetGraph = () => {
|
||||
setGraph(cloneGraph(INITIAL_GRAPH));
|
||||
setViewport({ x: 0, y: 0, scale: 1 });
|
||||
setSelectedNodeId("project-starsea");
|
||||
setSelectedEdgeId(null);
|
||||
setConnectFrom(null);
|
||||
notify("已恢复演示数据");
|
||||
};
|
||||
|
||||
const exportGraph = () => {
|
||||
const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "star-graph.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
notify("JSON 已导出");
|
||||
};
|
||||
|
||||
const importGraph = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text());
|
||||
if (!isGraphData(parsed)) throw new Error("invalid");
|
||||
setGraph(parsed);
|
||||
setSelectedNodeId(parsed.nodes[0]?.id ?? null);
|
||||
setSelectedEdgeId(null);
|
||||
notify("JSON 已导入");
|
||||
} catch {
|
||||
notify("JSON 格式不正确");
|
||||
}
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const focusNode = (node: GraphNode) => {
|
||||
setViewport((current) => ({ ...current, x: -node.x * current.scale, y: -node.y * current.scale }));
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<Link className={styles.brand} href="/"><span>✦</span><div><strong>StarGraph</strong><small>星空关系网络</small></div></Link>
|
||||
<nav><Link href="/worklist">工作列表</Link><Link href="/approval">审批流程</Link><Link className={styles.activeNav} href="/stargraph">星图网络</Link></nav>
|
||||
<div className={styles.headerStats}><span><b>{graph.nodes.length}</b> 节点</span><span><b>{graph.edges.length}</b> 关联</span><em>{hydrated ? "● 已自动保存" : "正在读取…"}</em></div>
|
||||
</header>
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideHeading}><span>GRAPH EXPLORER</span><h1>{graph.title}</h1><p>{graph.subtitle}</p></div>
|
||||
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点或标签" />{query && <button onClick={() => setQuery("")}>×</button>}</label>
|
||||
<div className={styles.filters}>
|
||||
<button className={typeFilter === "all" ? styles.filterActive : ""} onClick={() => setTypeFilter("all")}><i style={{ background: "#a9b1d6" }} />全部<b>{graph.nodes.length}</b></button>
|
||||
{graph.nodeTypes.map((type) => <button key={type.id} className={typeFilter === type.id ? styles.filterActive : ""} onClick={() => setTypeFilter(type.id)}><i style={{ background: type.color }} />{type.name}<b>{graph.nodes.filter((node) => node.type === type.id).length}</b></button>)}
|
||||
</div>
|
||||
<div className={styles.nodeListHeader}><span>节点列表</span><b>{visibleNodes.length}</b></div>
|
||||
<div className={styles.nodeList}>
|
||||
{visibleNodes.map((node) => <button key={node.id} className={selectedNodeId === node.id ? styles.nodeActive : ""} onClick={() => focusNode(node)}><i style={{ background: node.color, boxShadow: `0 0 12px ${node.color}` }} /><span><strong>{node.label}</strong><small>{typeName(graph, node.type)} · {graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length} 条关联</small></span><em>›</em></button>)}
|
||||
{visibleNodes.length === 0 && <p className={styles.emptyList}>没有匹配的节点</p>}
|
||||
</div>
|
||||
<div className={styles.dataActions}><button onClick={exportGraph}>⇩ 导出 JSON</button><button onClick={() => importRef.current?.click()}>⇧ 导入</button><button onClick={resetGraph}>↺ 重置</button><input ref={importRef} type="file" accept="application/json,.json" onChange={importGraph} /></div>
|
||||
</aside>
|
||||
|
||||
<main className={`${styles.stage} ${connectMode ? styles.connecting : ""}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
role="img"
|
||||
aria-label={`星空关系图,共 ${graph.nodes.length} 个节点和 ${graph.edges.length} 条直线关联。可拖动节点或画布,滚轮缩放。`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerLeave={() => setHoveredNodeId(null)}
|
||||
onClick={handleCanvasClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
{hoveredNode && <aside className={hoverStyles.hoverCard} style={{ left: hoverPoint.x, top: hoverPoint.y }} aria-live="polite">
|
||||
<div className={hoverStyles.hoverHeader}><i style={{ background: hoveredNode.color, boxShadow: `0 0 12px ${hoveredNode.color}` }} /><span><small>{typeName(graph, hoveredNode.type)}</small><strong>{hoveredNode.label}</strong></span><em>{graph.edges.filter((edge) => edge.source === hoveredNode.id || edge.target === hoveredNode.id).length} 关联</em></div>
|
||||
<p>{hoveredNode.description}</p>
|
||||
{hoveredNode.tags.length > 0 && <div className={hoverStyles.hoverTags}>{hoveredNode.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>}
|
||||
</aside>}
|
||||
<div className={styles.canvasToolbar}>
|
||||
<button className={styles.primaryButton} onClick={openAddNode}><span>+</span>添加节点</button>
|
||||
<button className={connectMode ? styles.connectButtonActive : ""} onClick={() => { setConnectMode((value) => !value); setConnectFrom(null); }}><span>⌁</span>{connectMode ? "退出关联" : "关联节点"}</button>
|
||||
<i />
|
||||
<button title="放大" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale + 0.15, 0.45, 2.2) }))}>+</button>
|
||||
<button title="缩小" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale - 0.15, 0.45, 2.2) }))}>−</button>
|
||||
<button title="居中" onClick={() => setViewport({ x: 0, y: 0, scale: 1 })}>⌾</button>
|
||||
<span className={styles.zoomValue}>{Math.round(viewport.scale * 100)}%</span>
|
||||
</div>
|
||||
{connectMode && <div className={styles.connectHint}><span>{connectFrom ? "2" : "1"}</span><p><strong>{fromNode ? `起点:${fromNode.label}` : "请选择第一个节点"}</strong><small>{fromNode ? "再点击另一个表单节点,即可建立直线关联" : "点击任意方形表单节点作为关联起点"}</small></p>{connectFrom && <button onClick={() => setConnectFrom(null)}>重选</button>}</div>}
|
||||
<div className={styles.canvasHelp}><span>拖动画布移动</span><span>滚轮缩放</span><span>拖动节点调整位置</span><span>双击空白处添加</span></div>
|
||||
<div className={styles.minimapLabel}><span>LIVE MAP</span><b>直线关系模式</b></div>
|
||||
<p className={styles.srOnly}>节点包括:{graph.nodes.map((node) => node.label).join("、")}。关联包括:{graph.edges.map((edge) => `${nodeLabel(graph, edge.source)}与${nodeLabel(graph, edge.target)}:${edge.label}`).join(";")}。</p>
|
||||
</main>
|
||||
|
||||
<aside className={styles.inspector}>
|
||||
<div className={styles.inspectorHeading}><div><span>INSPECTOR</span><strong>{selectedNode ? "节点属性" : selectedEdge ? "关联属性" : "属性面板"}</strong></div><small>{selectedNode ? "NODE" : selectedEdge ? "EDGE" : "EMPTY"}</small></div>
|
||||
{selectedNode ? <>
|
||||
<div className={styles.nodePreview}><i style={{ background: selectedNode.color, boxShadow: `0 0 28px ${selectedNode.color}`, borderRadius: "7px" }} /><div><strong>{selectedNode.label}</strong><span>{typeName(graph, selectedNode.type)}</span></div><em>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</em></div>
|
||||
<div className={styles.fieldGroup}>
|
||||
<label><span>节点名称</span><input value={selectedNode.label} onChange={(event) => updateNode(selectedNode.id, { label: event.target.value })} /></label>
|
||||
<label><span>节点类型</span><select value={selectedNode.type} onChange={(event) => { const type = graph.nodeTypes.find((item) => item.id === event.target.value); updateNode(selectedNode.id, { type: event.target.value, color: type?.color ?? selectedNode.color }); }}>{graph.nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
|
||||
<label><span>说明</span><textarea rows={4} value={selectedNode.description} onChange={(event) => updateNode(selectedNode.id, { description: event.target.value })} /></label>
|
||||
<label><span>标签(逗号分隔)</span><input value={selectedNode.tags.join(",")} onChange={(event) => updateNode(selectedNode.id, { tags: event.target.value.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean) })} /></label>
|
||||
<label><span>发光颜色</span><div className={styles.colorInput}><input type="color" value={selectedNode.color} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} /><code>{selectedNode.color.toUpperCase()}</code></div></label>
|
||||
<label><span>节点大小</span><div className={styles.rangeInput}><input type="range" min="14" max="36" value={selectedNode.size} onChange={(event) => updateNode(selectedNode.id, { size: Number(event.target.value) })} /><b>{selectedNode.size}</b></div></label>
|
||||
</div>
|
||||
<div className={styles.relationBlock}><div><strong>已连接节点</strong><span>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</span></div>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).map((edge) => { const otherId = edge.source === selectedNode.id ? edge.target : edge.source; const other = graph.nodes.find((node) => node.id === otherId); return <button key={edge.id} onClick={() => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }}><i style={{ background: other?.color }} /><span><strong>{other?.label}</strong><small>{edge.label}</small></span><em>›</em></button>; })}</div>
|
||||
<button className={styles.deleteButton} onClick={() => deleteNode(selectedNode.id)}>删除此节点</button>
|
||||
</> : selectedEdge ? <>
|
||||
<div className={styles.edgePreview}><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.source)?.color, borderRadius: "3px" }} /><i /><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.target)?.color, borderRadius: "3px" }} /></div>
|
||||
<div className={styles.edgeEndpoints}><strong>{nodeLabel(graph, selectedEdge.source)}</strong><small>直线连接至</small><strong>{nodeLabel(graph, selectedEdge.target)}</strong></div>
|
||||
<div className={styles.fieldGroup}>
|
||||
<label><span>关联名称</span><input value={selectedEdge.label} onChange={(event) => updateEdge(selectedEdge.id, { label: event.target.value })} /></label>
|
||||
<label><span>关联类型</span><select value={selectedEdge.type} onChange={(event) => updateEdge(selectedEdge.id, { type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label>
|
||||
</div>
|
||||
<div className={styles.edgeNote}><span>╱</span><p><strong>直线关联</strong><small>节点移动后,连接线会自动跟随更新。</small></p></div>
|
||||
<button className={styles.deleteButton} onClick={() => deleteEdge(selectedEdge.id)}>删除此关联</button>
|
||||
</> : <div className={styles.emptyInspector}><span>✦</span><h2>选择一个节点</h2><p>点击画布中的节点或连接线,可在这里查看和修改属性。</p><button onClick={openAddNode}>+ 添加第一个节点</button></div>}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{showAddNode && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddNode(false); }}><section className={styles.modal} role="dialog" aria-modal="true" aria-labelledby="add-node-title"><div className={styles.modalHeader}><div><span>NEW STAR</span><h2 id="add-node-title">添加新节点</h2></div><button onClick={() => setShowAddNode(false)}>×</button></div><div className={styles.modalBody}><label><span>节点名称 *</span><input autoFocus value={newNode.label} onChange={(event) => setNewNode({ ...newNode, label: event.target.value })} placeholder="例如:新角色、线索或地点" onKeyDown={(event) => { if (event.key === "Enter") createNode(); }} /></label><label><span>节点类型</span><div className={styles.typeCards}>{graph.nodeTypes.map((type) => <button key={type.id} className={newNode.type === type.id ? styles.typeCardActive : ""} onClick={() => setNewNode({ ...newNode, type: type.id })}><i style={{ background: type.color, boxShadow: `0 0 12px ${type.color}` }} />{type.name}</button>)}</div></label><label><span>说明</span><textarea rows={3} value={newNode.description} onChange={(event) => setNewNode({ ...newNode, description: event.target.value })} placeholder="简要描述这个节点" /></label><label><span>标签</span><input value={newNode.tags} onChange={(event) => setNewNode({ ...newNode, tags: event.target.value })} placeholder="使用逗号分隔多个标签" /></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddNode(false)}>取消</button><button className={styles.modalPrimary} onClick={createNode}>添加到星图</button></div></section></div>}
|
||||
|
||||
{showAddEdge && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddEdge(false); }}><section className={`${styles.modal} ${styles.edgeModal}`} role="dialog" aria-modal="true" aria-labelledby="add-edge-title"><div className={styles.modalHeader}><div><span>NEW CONNECTION</span><h2 id="add-edge-title">建立直线关联</h2></div><button onClick={() => setShowAddEdge(false)}>×</button></div><div className={styles.connectionPreview}><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.source)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.source)}</strong></div><span><b>────────</b><small>直线</small></span><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.target)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.target)}</strong></div></div><div className={styles.modalBody}><label><span>关联名称</span><input autoFocus value={newEdge.label} onChange={(event) => setNewEdge({ ...newEdge, label: event.target.value })} placeholder="例如:好友、所属、依赖" onKeyDown={(event) => { if (event.key === "Enter") createEdge(); }} /></label><label><span>关联类型</span><select value={newEdge.type} onChange={(event) => setNewEdge({ ...newEdge, type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddEdge(false)}>取消</button><button className={styles.modalPrimary} onClick={createEdge}>确认关联</button></div></section></div>}
|
||||
|
||||
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function drawGraph(options: {
|
||||
context: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
dpr: number;
|
||||
time: number;
|
||||
stars: { x: number; y: number; size: number; alpha: number; phase: number }[];
|
||||
graph: GraphData;
|
||||
viewport: { x: number; y: number; scale: number };
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
connectFrom: string | null;
|
||||
query: string;
|
||||
typeFilter: string;
|
||||
}) {
|
||||
const { context: ctx, width, height, dpr, time, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter } = options;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const background = ctx.createLinearGradient(0, 0, width, height);
|
||||
background.addColorStop(0, "#070a19");
|
||||
background.addColorStop(0.48, "#10112b");
|
||||
background.addColorStop(1, "#070b1b");
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawNebula(ctx, width * 0.2, height * 0.28, Math.max(width, height) * 0.42, "111,89,255");
|
||||
drawNebula(ctx, width * 0.82, height * 0.68, Math.max(width, height) * 0.38, "39,199,217");
|
||||
stars.forEach((star) => {
|
||||
const pulse = 0.68 + Math.sin(time * 0.0014 + star.phase) * 0.32;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(218,231,255,${star.alpha * pulse})`;
|
||||
ctx.arc(star.x * width, star.y * height, star.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
const toScreen = (node: GraphNode) => ({ x: width / 2 + viewport.x + node.x * viewport.scale, y: height / 2 + viewport.y + node.y * viewport.scale });
|
||||
const matches = (node: GraphNode) => {
|
||||
const search = query.trim().toLowerCase();
|
||||
return (typeFilter === "all" || node.type === typeFilter) && (!search || `${node.label} ${node.tags.join(" ")}`.toLowerCase().includes(search));
|
||||
};
|
||||
|
||||
graph.edges.forEach((edge) => {
|
||||
const source = graph.nodes.find((node) => node.id === edge.source);
|
||||
const target = graph.nodes.find((node) => node.id === edge.target);
|
||||
if (!source || !target) return;
|
||||
const sourceCenter = toScreen(source);
|
||||
const targetCenter = toScreen(target);
|
||||
const a = cardEdgePoint(sourceCenter, targetCenter, nodeCardSize(source, viewport.scale));
|
||||
const b = cardEdgePoint(targetCenter, sourceCenter, nodeCardSize(target, viewport.scale));
|
||||
const active = edge.id === selectedEdgeId;
|
||||
const faded = !matches(source) || !matches(target);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = faded ? 0.09 : active ? 1 : 0.48;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.lineWidth = active ? 2.4 : 1.15;
|
||||
ctx.strokeStyle = active ? "#e8e5ff" : "#7e86bc";
|
||||
ctx.shadowBlur = active ? 12 : 4;
|
||||
ctx.shadowColor = active ? "#9b8aff" : "#4d60a3";
|
||||
ctx.stroke();
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const my = (a.y + b.y) / 2;
|
||||
ctx.font = '500 10px "Microsoft YaHei", "PingFang SC", sans-serif';
|
||||
const labelWidth = ctx.measureText(edge.label).width + 14;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = active ? "rgba(111,92,214,.92)" : "rgba(13,17,43,.76)";
|
||||
ctx.fillRect(mx - labelWidth / 2, my - 9, labelWidth, 18);
|
||||
ctx.fillStyle = active ? "#ffffff" : "#aeb6dd";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(edge.label, mx, my);
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const point = toScreen(node);
|
||||
const card = nodeCardSize(node, viewport.scale);
|
||||
const left = point.x - card.width / 2;
|
||||
const top = point.y - card.height / 2;
|
||||
const active = node.id === selectedNodeId;
|
||||
const linking = node.id === connectFrom;
|
||||
const faded = !matches(node);
|
||||
const relationCount = graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length;
|
||||
const pulse = 0.78 + Math.sin(time * 0.002 + node.x * 0.01) * 0.18;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = faded ? 0.16 : 1;
|
||||
roundedRectPath(ctx, left, top, card.width, card.height, 5);
|
||||
ctx.fillStyle = "rgba(11,15,34,.96)";
|
||||
ctx.shadowBlur = active || linking ? 22 : 9 * pulse;
|
||||
ctx.shadowColor = node.color;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineWidth = active || linking ? 1.8 : 1;
|
||||
ctx.setLineDash(linking ? [5, 3] : []);
|
||||
ctx.strokeStyle = linking ? "#ffffff" : active ? node.color : "rgba(135,145,190,.35)";
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fillRect(left, top, 3, card.height);
|
||||
ctx.fillStyle = hexToRgba(node.color, active ? 0.18 : 0.1);
|
||||
ctx.fillRect(left + 3, top, card.width - 3, card.height);
|
||||
|
||||
const padding = 7;
|
||||
const markerSize = clamp(card.height - 13, 9, 13);
|
||||
ctx.fillStyle = node.color;
|
||||
roundedRectPath(ctx, left + padding, point.y - markerSize / 2, markerSize, markerSize, 2);
|
||||
ctx.fill();
|
||||
ctx.font = `700 ${clamp(markerSize - 5, 7, 9)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(typeGlyph(node.type), left + padding + markerSize / 2, point.y + .5);
|
||||
ctx.font = `${active ? 700 : 600} ${clamp(card.height * .34, 9, 11)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||
ctx.fillStyle = active ? "#ffffff" : "#e2e7fb";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(fitCanvasText(ctx, node.label, card.width - markerSize - 34), left + padding + markerSize + 6, point.y + .5);
|
||||
ctx.font = '600 8px "Microsoft YaHei", "PingFang SC", sans-serif';
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#7f89aa";
|
||||
ctx.fillText(String(relationCount), left + card.width - 7, point.y + .5);
|
||||
if (active || linking) {
|
||||
const portSize = 4;
|
||||
ctx.fillStyle = linking ? "#ffffff" : node.color;
|
||||
[[point.x, top], [left + card.width, point.y], [point.x, top + card.height], [left, point.y]].forEach(([x, y]) => ctx.fillRect(x - portSize / 2, y - portSize / 2, portSize, portSize));
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function nodeCardSize(node: GraphNode, scale: number) {
|
||||
const emphasis = clamp((node.size - 14) / 22, 0, 1);
|
||||
return {
|
||||
width: clamp((92 + emphasis * 14) * scale, 70, 118),
|
||||
height: clamp((25 + emphasis * 5) * scale, 21, 32),
|
||||
};
|
||||
}
|
||||
|
||||
function cardEdgePoint(center: { x: number; y: number }, toward: { x: number; y: number }, card: { width: number; height: number }) {
|
||||
const dx = toward.x - center.x;
|
||||
const dy = toward.y - center.y;
|
||||
if (dx === 0 && dy === 0) return center;
|
||||
const xScale = dx === 0 ? Number.POSITIVE_INFINITY : card.width / 2 / Math.abs(dx);
|
||||
const yScale = dy === 0 ? Number.POSITIVE_INFINITY : card.height / 2 / Math.abs(dy);
|
||||
const scale = Math.min(xScale, yScale);
|
||||
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
||||
}
|
||||
|
||||
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
||||
const r = Math.min(radius, width / 2, height / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + width - r, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
||||
ctx.lineTo(x + width, y + height - r);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
||||
ctx.lineTo(x + r, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let value = text;
|
||||
while (value.length > 1 && ctx.measureText(`${value}…`).width > maxWidth) value = value.slice(0, -1);
|
||||
return `${value}…`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number) {
|
||||
const clean = hex.replace("#", "");
|
||||
const value = Number.parseInt(clean.length === 3 ? clean.split("").map((char) => `${char}${char}`).join("") : clean, 16);
|
||||
const red = (value >> 16) & 255;
|
||||
const green = (value >> 8) & 255;
|
||||
const blue = value & 255;
|
||||
return `rgba(${red},${green},${blue},${alpha})`;
|
||||
}
|
||||
|
||||
function drawNebula(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, rgb: string) {
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, `rgba(${rgb},.12)`);
|
||||
gradient.addColorStop(0.35, `rgba(${rgb},.055)`);
|
||||
gradient.addColorStop(1, `rgba(${rgb},0)`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
}
|
||||
|
||||
function makeStars(count: number) {
|
||||
let seed = 12873;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed - 1) / 2147483646;
|
||||
};
|
||||
return Array.from({ length: count }, () => ({ x: random(), y: random(), size: 0.3 + random() * 1.25, alpha: 0.25 + random() * 0.65, phase: random() * Math.PI * 2 }));
|
||||
}
|
||||
|
||||
function distanceToSegment(point: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
if (dx === 0 && dy === 0) return Math.hypot(point.x - a.x, point.y - a.y);
|
||||
const t = clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy), 0, 1);
|
||||
return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function cloneGraph(graph: GraphData): GraphData {
|
||||
return JSON.parse(JSON.stringify(graph));
|
||||
}
|
||||
|
||||
function isGraphData(value: unknown): value is GraphData {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<GraphData>;
|
||||
return Array.isArray(candidate.nodes) && Array.isArray(candidate.edges) && Array.isArray(candidate.nodeTypes);
|
||||
}
|
||||
|
||||
function nodeLabel(graph: GraphData, id: string) {
|
||||
return graph.nodes.find((node) => node.id === id)?.label ?? "未知节点";
|
||||
}
|
||||
|
||||
function typeName(graph: GraphData, id: string) {
|
||||
return graph.nodeTypes.find((type) => type.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function typeGlyph(type: string) {
|
||||
return ({ project: "星", character: "人", world: "界", organization: "组", event: "事" } as Record<string, string>)[type] ?? "点";
|
||||
}
|
||||
Reference in New Issue
Block a user