From cfbc32505e5e4ed38d3446438c8bcfb03f36341f Mon Sep 17 00:00:00 2001 From: xiamuceer-j Date: Wed, 4 Mar 2026 16:25:37 +0800 Subject: [PATCH] =?UTF-8?q?feature=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E5=9B=BE=E8=B0=B1=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9F=A5=E7=9C=8B=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E3=80=81=E8=A7=92=E8=89=B2=E3=80=81=E8=81=8C=E4=B8=9A=E7=9A=84?= =?UTF-8?q?=E6=89=80=E6=9C=89=E5=85=B3=E7=B3=BB=E8=BF=9E=E6=8E=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/characters.py | 24 +- backend/app/api/relationships.py | 34 +- frontend/src/pages/RelationshipGraph.tsx | 1491 ++++++++++++++++++---- 3 files changed, 1256 insertions(+), 293 deletions(-) diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 55b40b5..b45781a 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -81,7 +81,7 @@ async def _build_relationships_summary(character_id: str, project_id: str, db: A async def _build_org_members_summary(character_id: str, db: AsyncSession) -> str: - """从 organization_members 表构建组织成员摘要文本""" + """从 organization_members 表构建组织成员JSON字符串(与schema契约保持一致)""" # 先查找该角色对应的 Organization 记录 org_result = await db.execute( select(Organization).where(Organization.character_id == character_id) @@ -89,30 +89,32 @@ async def _build_org_members_summary(character_id: str, db: AsyncSession) -> str org = org_result.scalar_one_or_none() if not org: return "" - - # 查询该组织的所有成员 + + # 查询该组织的所有成员(按职级倒序,保证展示顺序稳定) members_result = await db.execute( - select(OrganizationMember).where(OrganizationMember.organization_id == org.id) + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id) + .order_by(OrganizationMember.rank.desc(), OrganizationMember.created_at) ) members = members_result.scalars().all() if not members: return "" - + # 批量查询成员角色名称 member_char_ids = [m.character_id for m in members] chars_result = await db.execute( select(Character.id, Character.name).where(Character.id.in_(member_char_ids)) ) name_map = {row.id: row.name for row in chars_result} - - # 构建摘要 - parts = [] + + # 返回 JSON 字符串数组,避免前端 JSON.parse 报错 + member_items = [] for m in members: name = name_map.get(m.character_id, "未知") position = m.position or "成员" - parts.append(f"{name}({position})") - - return "、".join(parts) + member_items.append(f"{name}({position})") + + return json.dumps(member_items, ensure_ascii=False) @router.get("", response_model=CharacterListResponse, summary="获取角色列表") diff --git a/backend/app/api/relationships.py b/backend/app/api/relationships.py index 0e45fae..de119cd 100644 --- a/backend/app/api/relationships.py +++ b/backend/app/api/relationships.py @@ -108,14 +108,14 @@ async def get_relationship_graph( for c in characters ] - # 获取所有关系(边) + # 获取所有角色关系(边) rels_result = await db.execute( select(CharacterRelationship).where( CharacterRelationship.project_id == project_id ) ) relationships = rels_result.scalars().all() - + links = [ RelationshipGraphLink( source=r.character_from_id, @@ -126,8 +126,34 @@ async def get_relationship_graph( ) for r in relationships ] - - logger.info(f"获取项目 {project_id} 的关系图谱:{len(nodes)} 个节点,{len(links)} 条关系") + + # 获取组织成员关系(组织 -> 成员)并追加到图谱边 + # source 使用组织对应的角色ID(Organization.character_id),确保与节点ID一致 + members_result = await db.execute( + select(OrganizationMember, Organization).join( + Organization, + OrganizationMember.organization_id == Organization.id + ).where(Organization.project_id == project_id) + ) + org_members = members_result.all() + + member_links = [ + RelationshipGraphLink( + source=org.character_id, + target=member.character_id, + relationship=f"组织成员·{member.position}", + intimacy=member.loyalty, + status=member.status + ) + for member, org in org_members + ] + + links.extend(member_links) + + logger.info( + f"获取项目 {project_id} 的关系图谱:{len(nodes)} 个节点," + f"{len(relationships)} 条角色关系,{len(member_links)} 条组织成员关系" + ) return RelationshipGraphData(nodes=nodes, links=links) diff --git a/frontend/src/pages/RelationshipGraph.tsx b/frontend/src/pages/RelationshipGraph.tsx index 2360e5a..d6fb85c 100644 --- a/frontend/src/pages/RelationshipGraph.tsx +++ b/frontend/src/pages/RelationshipGraph.tsx @@ -1,7 +1,13 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo, type CSSProperties } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { Card, Tag, Button, Space, message } from 'antd'; -import { ArrowLeftOutlined } from '@ant-design/icons'; +import { Card, Tag, Button, Space, message, Typography } from 'antd'; +import { + ArrowLeftOutlined, + ApartmentOutlined, + UserOutlined, + TeamOutlined, + TrophyOutlined, +} from '@ant-design/icons'; import axios from 'axios'; import dagre from 'dagre'; import { @@ -16,35 +22,7 @@ import { import type { Node, Edge } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -// 使用 dagre 进行自动布局 -const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { - const dagreGraph = new dagre.graphlib.Graph(); - dagreGraph.setDefaultEdgeLabel(() => ({})); - dagreGraph.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 }); - - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 140, height: 60 }); - }); - - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target); - }); - - dagre.layout(dagreGraph); - - const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id); - return { - ...node, - position: { - x: nodeWithPosition.x - 70, - y: nodeWithPosition.y - 30, - }, - }; - }); - - return { nodes: layoutedNodes, edges }; -}; +const { Text } = Typography; interface GraphNode { id: string; @@ -98,24 +76,529 @@ interface CharacterDetail { location: string; motto: string; color: string; + main_career_id?: string; + main_career_stage?: number; + sub_careers?: Array<{ career_id: string; stage?: number }> | string | null; } +interface CareerItem { + id: string; + name: string; + type: 'main' | 'sub'; + max_stage: number; +} + +interface CareerListResponse { + main_careers?: CareerItem[]; + sub_careers?: CareerItem[]; +} + +interface CharacterListResponse { + items?: CharacterDetail[]; +} + +const GROUP_MAIN_CAREER_NODE_ID = '__career_group_main__'; +const GROUP_SUB_CAREER_NODE_ID = '__career_group_sub__'; + +const EDGE_CATEGORY_META: Record = { + organization: { label: '组织成员', color: '#722ed1', order: 1 }, + career_main: { label: '主职业关联', color: '#faad14', order: 2 }, + career_sub: { label: '副职业关联', color: '#13c2c2', order: 3 }, + career_group: { label: '职业分类', color: '#8c8c8c', order: 4 }, + family: { label: '亲属关系', color: '#f39c12', order: 5 }, + hostile: { label: '敌对关系', color: '#e74c3c', order: 6 }, + professional: { label: '职业关系', color: '#3498db', order: 7 }, + social: { label: '社交关系', color: '#27ae60', order: 8 }, + default: { label: '其他关系', color: '#95a5a6', order: 99 }, +}; + +const getEdgeCategory = (edge: Edge) => + typeof edge.data?.category === 'string' ? edge.data.category : 'default'; + +const getEdgeCategoryMeta = (category: string) => + EDGE_CATEGORY_META[category] || { + label: `${category}关系`, + color: '#95a5a6', + order: 999, + }; + +const clampTextStyle = (rows: number): CSSProperties => ({ + margin: '4px 0 0', + color: '#555', + fontSize: 14, + lineHeight: '22px', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: rows, + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', +}); + +const getNodeSize = (node: Node) => { + const width = + typeof node.style?.width === 'number' + ? node.style.width + : Number(node.style?.width ?? 140) || 140; + const height = + typeof node.style?.height === 'number' + ? node.style.height + : Number(node.style?.height ?? 60) || 60; + + return { width, height }; +}; + +const MAIN_GRAPH_FIXED_X_GAP = 220; +const MAIN_GRAPH_FIXED_Y_GAP = 180; +const MAIN_GRAPH_MAX_PER_ROW = 6; +const MAIN_GRAPH_GROUP_Y_GAP = 140; + +const layoutNodesInWrappedRows = ( + rowNodes: Node[], + startX: number, + startY: number, + maxPerRow: number, + columnGap: number, + rowGap: number, +): Node[] => { + if (rowNodes.length === 0) { + return []; + } + + const sorted = [...rowNodes].sort((a, b) => a.position.x - b.position.x); + + return sorted.map((node, index) => { + const col = index % maxPerRow; + const row = Math.floor(index / maxPerRow); + return { + ...node, + position: { + ...node.position, + x: startX + col * columnGap, + y: startY + row * rowGap, + }, + }; + }); +}; + +// 使用 dagre 进行自动布局,并支持分组排版策略 +const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + // 增大节点间距,使用更合理的排版 + dagreGraph.setGraph({ rankdir: 'TB', nodesep: 160, ranksep: 180, edgesep: 80, marginx: 40, marginy: 40 }); + + // 1. 拆分职业节点和其他节点 + const careerNodeIds = new Set( + nodes.filter((n) => n.id.startsWith('career-') || n.id.startsWith('__career_group')).map((n) => n.id) + ); + + const layoutNodes = nodes.filter((n) => !careerNodeIds.has(n.id)); + const careerNodes = nodes.filter((n) => careerNodeIds.has(n.id)); + + // 2. 配置主图谱 (组织 + 角色) + layoutNodes.forEach((node) => { + const { width, height } = getNodeSize(node); + dagreGraph.setNode(node.id, { width, height }); + }); + + // 使用虚拟根节点强制分层:第一排组织,第二排角色 + dagreGraph.setNode('__dummy_root', { width: 1, height: 1 }); + layoutNodes.forEach((node) => { + if (node.data?.type === 'organization') { + dagreGraph.setEdge('__dummy_root', node.id, { weight: 100, minlen: 1 }); + } else { + // 角色统一放在第二排(minlen=2) + dagreGraph.setEdge('__dummy_root', node.id, { weight: 1, minlen: 2 }); + } + }); + + // 添加常规连线(排除职业相关的连线参与 Dagre 布局,避免干扰主图谱结构) + edges.forEach((edge) => { + if (!careerNodeIds.has(edge.source) && !careerNodeIds.has(edge.target)) { + dagreGraph.setEdge(edge.source, edge.target, { + weight: edge.data?.layoutWeight ?? 1, + minlen: 1 + }); + } + }); + + dagre.layout(dagreGraph); + + // 3. 应用 Dagre 布局结果,并执行“首元素对齐 + 每排最多6个自动换行” + const layoutedNodes = layoutNodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + const { width, height } = getNodeSize(node); + + return { + ...node, + position: { + x: nodeWithPosition.x - width / 2, + y: nodeWithPosition.y - height / 2, + }, + }; + }); + + const organizationNodes = layoutedNodes.filter((node) => node.data?.type === 'organization'); + const characterNodes = layoutedNodes.filter((node) => node.data?.type !== 'organization'); + + const baseStartX = layoutedNodes.reduce( + (min, node) => (node.position.x < min ? node.position.x : min), + Infinity, + ); + const alignedStartX = Number.isFinite(baseStartX) ? baseStartX : 0; + + const orgStartYRaw = organizationNodes.reduce( + (min, node) => (node.position.y < min ? node.position.y : min), + Infinity, + ); + const orgStartY = Number.isFinite(orgStartYRaw) ? orgStartYRaw : 0; + + const orgRows = Math.ceil(organizationNodes.length / MAIN_GRAPH_MAX_PER_ROW); + const orgMaxHeight = organizationNodes.reduce( + (max, node) => Math.max(max, getNodeSize(node).height), + 0, + ); + const organizationBottomY = + orgStartY + + Math.max(orgRows - 1, 0) * MAIN_GRAPH_FIXED_Y_GAP + + orgMaxHeight; + + const characterStartYRaw = characterNodes.reduce( + (min, node) => (node.position.y < min ? node.position.y : min), + Infinity, + ); + const characterStartYBase = Number.isFinite(characterStartYRaw) + ? characterStartYRaw + : organizationBottomY + MAIN_GRAPH_GROUP_Y_GAP; + const characterStartY = Math.max(characterStartYBase, organizationBottomY + MAIN_GRAPH_GROUP_Y_GAP); + + const wrappedOrganizations = layoutNodesInWrappedRows( + organizationNodes, + alignedStartX, + orgStartY, + MAIN_GRAPH_MAX_PER_ROW, + MAIN_GRAPH_FIXED_X_GAP, + MAIN_GRAPH_FIXED_Y_GAP, + ); + + const wrappedCharacters = layoutNodesInWrappedRows( + characterNodes, + alignedStartX, + characterStartY, + MAIN_GRAPH_MAX_PER_ROW, + MAIN_GRAPH_FIXED_X_GAP, + MAIN_GRAPH_FIXED_Y_GAP, + ); + + const normalizedMap = new Map( + [...wrappedOrganizations, ...wrappedCharacters].map((node) => [node.id, node]), + ); + + const normalizedLayoutedNodes = layoutedNodes.map((node) => normalizedMap.get(node.id) || node); + + const { minX, minY } = normalizedLayoutedNodes.reduce( + (acc, node) => ({ + minX: Math.min(acc.minX, node.position.x), + minY: Math.min(acc.minY, node.position.y), + }), + { minX: Infinity, minY: Infinity }, + ); + + const safeMinX = Number.isFinite(minX) ? minX : 0; + const safeMinY = Number.isFinite(minY) ? minY : 0; + + // 4. 在左侧独立排版职业节点 + const careerStartX = safeMinX - 460; // 在主图左侧留出足够空间 + let currentY = safeMinY; + + const placedCareerNodes: Node[] = []; + const placeNode = (nodeId: string, xOffset = 0) => { + const node = careerNodes.find((n) => n.id === nodeId); + if (node) { + placedCareerNodes.push({ + ...node, + position: { x: careerStartX + xOffset, y: currentY }, + }); + const { height } = getNodeSize(node); + currentY += height + 30; // 节点垂直间距 + } + }; + + // 依次排列:主职业分组 -> 主职业列表 -> 副职业分组 -> 副职业列表 + placeNode(GROUP_MAIN_CAREER_NODE_ID, -180); + careerNodes.filter((n) => n.data?.type === 'career_main').forEach((n) => placeNode(n.id)); + + currentY += 20; // 主副职业之间的额外间距 + + placeNode(GROUP_SUB_CAREER_NODE_ID, -180); + careerNodes.filter((n) => n.data?.type === 'career_sub').forEach((n) => placeNode(n.id)); + + return { nodes: [...normalizedLayoutedNodes, ...placedCareerNodes], edges }; +}; + +const safeParseStringArray = (raw: unknown): string[] => { + if (!raw) return []; + + if (Array.isArray(raw)) { + return raw.map((item) => String(item)).filter(Boolean); + } + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed.map((item) => String(item)).filter(Boolean); + } + } catch { + return raw + .split(/[,,、]/) + .map((item) => item.trim()) + .filter(Boolean); + } + } + + return []; +}; + +const safeParseSubCareers = (raw: CharacterDetail['sub_careers']) => { + if (!raw) return [] as Array<{ career_id: string; stage?: number }>; + + if (Array.isArray(raw)) { + return raw.filter((item) => item?.career_id); + } + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Array<{ career_id: string; stage?: number }>; + if (Array.isArray(parsed)) { + return parsed.filter((item) => item?.career_id); + } + } catch { + return []; + } + } + + return []; +}; + +const getCategoryColor = ( + relationshipName: string, + isActive: boolean, + relationshipTypes: RelationshipType[], +) => { + if (relationshipName.startsWith('组织成员·')) { + return isActive ? '#722ed1' : '#cdb7f6'; + } + + if (relationshipName.startsWith('主职业·')) { + return isActive ? '#faad14' : '#ffe7ba'; + } + + if (relationshipName.startsWith('副职业·')) { + return isActive ? '#13c2c2' : '#b5f5ec'; + } + + if (relationshipName.startsWith('职业分类·')) { + return isActive ? '#8c8c8c' : '#d9d9d9'; + } + + const relType = relationshipTypes.find((rt) => rt.name === relationshipName); + const category = relType?.category || 'default'; + + const categoryColors: Record = { + family: { active: '#f39c12', inactive: '#fcd59e' }, + hostile: { active: '#e74c3c', inactive: '#f5a49a' }, + professional: { active: '#3498db', inactive: '#a9d4ed' }, + social: { active: '#27ae60', inactive: '#a3d9b5' }, + default: { active: '#95a5a6', inactive: '#c8d0d2' }, + }; + + const colors = categoryColors[category] || categoryColors.default; + return isActive ? colors.active : colors.inactive; +}; + +const getCharacterNodeStyle = (roleType: string): CSSProperties => { + const roleColorMap: Record = { + protagonist: '#e74c3c', + antagonist: '#9b59b6', + supporting: '#3498db', + }; + + const baseColor = roleColorMap[roleType] || '#3498db'; + + return { + width: 130, + height: 130, + border: `2px solid ${baseColor}`, + borderRadius: '50%', + background: `linear-gradient(135deg, #ffffff, ${baseColor}15)`, + boxShadow: `0 4px 16px ${baseColor}25`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + transition: 'all 0.3s ease', + }; +}; + +const getOrganizationNodeStyle = (): CSSProperties => ({ + width: 160, + height: 90, + border: '2px solid #27ae60', + borderRadius: 12, + background: 'linear-gradient(135deg, #ffffff, #27ae6015)', + boxShadow: '0 4px 16px rgba(39, 174, 96, 0.15)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + transition: 'all 0.3s ease', +}); + +const getCareerNodeStyle = (type: 'main' | 'sub'): CSSProperties => { + const color = type === 'main' ? '#faad14' : '#13c2c2'; + + return { + width: 150, + height: 72, + border: `2px solid ${color}`, + borderRadius: 12, + background: `linear-gradient(135deg, #ffffff, ${color}15)`, + boxShadow: `0 4px 12px ${color}20`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + transition: 'all 0.3s ease', + }; +}; + +const getCareerGroupStyle = (type: 'main' | 'sub'): CSSProperties => { + const color = type === 'main' ? '#d48806' : '#08979c'; + + return { + width: 130, + height: 52, + border: `2px dashed ${color}`, + borderRadius: 26, + backgroundColor: '#ffffff', + boxShadow: `0 2px 8px ${color}15`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 600, + fontSize: 13, + color, + padding: 0, + }; +}; + +const InfoField = ({ + label, + value, + rows = 2, +}: { + label: string; + value?: string | null; + rows?: number; +}) => { + if (!value) return null; + + return ( +
+ + {label} + +
{value}
+
+ ); +}; + export default function RelationshipGraph() { const { projectId } = useParams<{ projectId: string }>(); const navigate = useNavigate(); - const [, setGraphData] = useState(null); + + const [graphData, setGraphData] = useState(null); const [, setLoading] = useState(false); const [selectedNodeId, setSelectedNodeId] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); const [, setDetailLoading] = useState(false); const [relationshipTypes, setRelationshipTypes] = useState([]); + const [characterDetailMap, setCharacterDetailMap] = useState>({}); + const [mainCareers, setMainCareers] = useState([]); + const [subCareers, setSubCareers] = useState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edgeVisibilityMap, setEdgeVisibilityMap] = useState>({}); + + const careerNameMap = useMemo(() => { + const map: Record = {}; + [...mainCareers, ...subCareers].forEach((career) => { + map[career.id] = career; + }); + return map; + }, [mainCareers, subCareers]); + + const edgeCategoryOptions = useMemo(() => { + const counter = new Map(); + + edges.forEach((edge) => { + const category = getEdgeCategory(edge); + counter.set(category, (counter.get(category) || 0) + 1); + }); + + return Array.from(counter.entries()) + .map(([category, count]) => { + const meta = getEdgeCategoryMeta(category); + return { + category, + count, + ...meta, + }; + }) + .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, 'zh-CN')); + }, [edges]); + + useEffect(() => { + if (edgeCategoryOptions.length === 0) { + return; + } + + setEdgeVisibilityMap((prev) => { + const next: Record = {}; + edgeCategoryOptions.forEach((option) => { + next[option.category] = prev[option.category] ?? true; + }); + return next; + }); + }, [edgeCategoryOptions]); + + const visibleEdges = useMemo( + () => edges.filter((edge) => edgeVisibilityMap[getEdgeCategory(edge)] !== false), + [edges, edgeVisibilityMap], + ); + + const toggleEdgeCategoryVisibility = (category: string) => { + setEdgeVisibilityMap((prev) => ({ + ...prev, + [category]: !(prev[category] ?? true), + })); + }; useEffect(() => { if (projectId) { - loadRelationshipTypes(); + void loadRelationshipTypes(); } }, [projectId]); @@ -128,99 +611,310 @@ export default function RelationshipGraph() { } }; - // 根据关系名称获取分类颜色 - const getCategoryColor = useCallback((relationshipName: string, isActive: boolean) => { - // 找到对应的关系类型 - const relType = relationshipTypes.find(rt => rt.name === relationshipName); - const category = relType?.category || 'default'; + const buildFlowEdge = useCallback( + ( + edgeId: string, + source: string, + target: string, + relationship: string, + status: string, + intimacy: number, + opts?: { + dashed?: boolean; + animated?: boolean; + layoutWeight?: number; + }, + ): Edge => { + const edgeColor = getCategoryColor(relationship, status === 'active', relationshipTypes); + const isOrgMemberLink = relationship.startsWith('组织成员·'); + const isCareerMainLink = relationship.startsWith('主职业·'); + const isCareerSubLink = relationship.startsWith('副职业·'); + const isCareerClassLink = relationship.startsWith('职业分类·'); - // 分类颜色映射 - 重新设计更符合语义 - const categoryColors: Record = { - family: { active: '#f39c12', inactive: '#fcd59e' }, // 家族关系 - 橙黄色(温馨) - hostile: { active: '#e74c3c', inactive: '#f5a49a' }, // 敌对关系 - 红色 - professional: { active: '#3498db', inactive: '#a9d4ed' }, // 职业关系 - 蓝色(专业) - social: { active: '#27ae60', inactive: '#a3d9b5' }, // 社交关系 - 绿色(友好) - default: { active: '#95a5a6', inactive: '#c8d0d2' }, // 默认 - 灰色 - }; - - const colors = categoryColors[category] || categoryColors.default; - return isActive ? colors.active : colors.inactive; - }, [relationshipTypes]); + return { + id: edgeId, + source, + target, + label: relationship, + type: 'smoothstep', + animated: opts?.animated, + style: { + stroke: edgeColor, + strokeWidth: isCareerClassLink ? 1.5 : 2, + strokeDasharray: opts?.dashed || isOrgMemberLink || isCareerSubLink ? '6 3' : undefined, + opacity: isCareerClassLink ? 0.5 : (isCareerMainLink || isCareerSubLink ? 0.6 : 1), + }, + labelStyle: { + fill: '#666', + fontSize: 10, + fontWeight: isCareerMainLink || isCareerSubLink ? 600 : 500, + }, + labelBgStyle: { + fill: '#fff', + fillOpacity: 0.9, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: edgeColor, + }, + data: { + intimacy, + status, + layoutWeight: opts?.layoutWeight ?? 1, + category: isOrgMemberLink + ? 'organization' + : isCareerMainLink + ? 'career_main' + : isCareerSubLink + ? 'career_sub' + : isCareerClassLink + ? 'career_group' + : relationshipTypes.find((rt) => rt.name === relationship)?.category || 'social', + }, + }; + }, + [relationshipTypes], + ); const loadGraphData = useCallback(async () => { if (!projectId || relationshipTypes.length === 0) return; + setLoading(true); try { - const res = await axios.get(`/api/relationships/graph/${projectId}`); - const data = res.data as GraphData; + const [graphRes, charactersRes, careersRes] = await Promise.all([ + axios.get(`/api/relationships/graph/${projectId}`), + axios.get('/api/characters', { params: { project_id: projectId } }), + axios.get('/api/careers', { params: { project_id: projectId } }), + ]); - const getNodeColors = (type: string, roleType: string) => { - // 节点颜色 - 重新设计更清晰 - if (type === 'character') { - if (roleType === 'protagonist') return { border: '#e74c3c', bg: '#e74c3c' }; // 主角 - 红色 - if (roleType === 'antagonist') return { border: '#9b59b6', bg: '#9b59b6' }; // 反派 - 紫色 - return { border: '#3498db', bg: '#3498db' }; // 配角 - 蓝色 - } - return { border: '#27ae60', bg: '#27ae60' }; // 组织 - 绿色 - }; + const data = graphRes.data as GraphData; + const characters = (charactersRes.data as CharacterListResponse)?.items || []; + const careersData = (careersRes.data as CareerListResponse) || {}; + + setMainCareers(careersData.main_careers || []); + setSubCareers(careersData.sub_careers || []); + + const detailMap: Record = {}; + characters.forEach((item) => { + detailMap[item.id] = item; + }); + setCharacterDetailMap(detailMap); + + const baseNodes: Node[] = data.nodes.map((node) => { + const style = node.type === 'organization' ? getOrganizationNodeStyle() : getCharacterNodeStyle(node.role_type); + const detail = detailMap[node.id]; + + const roleColorMap: Record = { + protagonist: '#e74c3c', + antagonist: '#9b59b6', + supporting: '#3498db', + }; + const baseColor = roleColorMap[node.role_type] || '#3498db'; + + const labelContent = node.type === 'organization' ? ( +
+ +
{node.name}
+
{detail?.organization_type || '组织'}
+
+ ) : ( +
+ {detail?.avatar_url ? ( + {node.name} + ) : ( +
+ +
+ )} +
{node.name}
+
+ {node.role_type === 'protagonist' ? '主角' : node.role_type === 'antagonist' ? '反派' : '配角'} +
+
+ ); - const flowNodes: Node[] = data.nodes.map((node) => { - const colors = getNodeColors(node.type, node.role_type); return { id: node.id, type: 'default', position: { x: 0, y: 0 }, data: { - label: node.name, + label: labelContent, type: node.type, role_type: node.role_type, }, - style: { - border: `2px solid ${colors.border}`, - borderRadius: 8, - backgroundColor: `${colors.bg}33`, - padding: '10px 15px', - minWidth: 100, - }, + style, }; }); - const flowEdges: Edge[] = data.links.map(link => { - const edgeColor = getCategoryColor(link.relationship, link.status === 'active'); - return { - id: `${link.source}-${link.target}`, - source: link.source, - target: link.target, - label: link.relationship, - type: 'smoothstep', - style: { - stroke: edgeColor, - strokeWidth: 2, - }, - labelStyle: { - fill: '#666', - fontSize: 10, - }, - labelBgStyle: { - fill: '#fff', - fillOpacity: 0.9, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: edgeColor, - }, + const mainCareerNodes: Node[] = (careersData.main_careers || []).map((career) => ({ + id: `career-main-${career.id}`, + type: 'default', + position: { x: 0, y: 0 }, + data: { + label: ( +
+
主职业
+
{career.name}
+
+ ), + type: 'career_main', + }, + style: getCareerNodeStyle('main'), + })); + + const subCareerNodes: Node[] = (careersData.sub_careers || []).map((career) => ({ + id: `career-sub-${career.id}`, + type: 'default', + position: { x: 0, y: 0 }, + data: { + label: ( +
+
副职业
+
{career.name}
+
+ ), + type: 'career_sub', + }, + style: getCareerNodeStyle('sub'), + })); + + const careerGroupNodes: Node[] = []; + if (mainCareerNodes.length > 0) { + careerGroupNodes.push({ + id: GROUP_MAIN_CAREER_NODE_ID, + type: 'default', + position: { x: 0, y: 0 }, data: { - intimacy: link.intimacy, - status: link.status, - category: relationshipTypes.find(rt => rt.name === link.relationship)?.category || 'social', + label: '主职业分组', + type: 'career_group', }, - }; + style: getCareerGroupStyle('main'), + }); + } + if (subCareerNodes.length > 0) { + careerGroupNodes.push({ + id: GROUP_SUB_CAREER_NODE_ID, + type: 'default', + position: { x: 0, y: 0 }, + data: { + label: '副职业分组', + type: 'career_group', + }, + style: getCareerGroupStyle('sub'), + }); + } + + const allNodes: Node[] = [...baseNodes, ...careerGroupNodes, ...mainCareerNodes, ...subCareerNodes]; + + const orgMemberLinks = data.links.filter((link) => link.relationship.startsWith('组织成员·')); + const memberRelationLinks = data.links.filter((link) => !link.relationship.startsWith('组织成员·')); + + // 先建立组织-成员边(用于先稳定层级结构) + const orgMemberEdges: Edge[] = orgMemberLinks.map((link) => + buildFlowEdge( + `${link.source}-${link.target}-${link.relationship}`, + link.source, + link.target, + link.relationship, + link.status, + link.intimacy, + { layoutWeight: 8 }, + ), + ); + + // 再构建职业块 -> 职业 -> 角色边 + const careerGroupEdges: Edge[] = [ + ...mainCareerNodes.map((node) => + buildFlowEdge( + `${GROUP_MAIN_CAREER_NODE_ID}-${node.id}`, + GROUP_MAIN_CAREER_NODE_ID, + node.id, + '职业分类·主职业', + 'active', + 0, + { dashed: true, layoutWeight: 4 }, + ), + ), + ...subCareerNodes.map((node) => + buildFlowEdge( + `${GROUP_SUB_CAREER_NODE_ID}-${node.id}`, + GROUP_SUB_CAREER_NODE_ID, + node.id, + '职业分类·副职业', + 'active', + 0, + { dashed: true, layoutWeight: 4 }, + ), + ), + ]; + + const careerToCharacterEdges: Edge[] = []; + const localCareerNameMap: Record = {}; + [...(careersData.main_careers || []), ...(careersData.sub_careers || [])].forEach((career) => { + localCareerNameMap[career.id] = career.name; }); - // 使用 dagre 进行自动布局 - const layouted = getLayoutedElements(flowNodes, flowEdges); + characters + .filter((character) => !character.is_organization) + .forEach((character) => { + if (character.main_career_id) { + const careerNodeId = `career-main-${character.main_career_id}`; + if (mainCareerNodes.some((node) => node.id === careerNodeId)) { + const careerName = localCareerNameMap[character.main_career_id] || '未知职业'; + careerToCharacterEdges.push( + buildFlowEdge( + `${careerNodeId}-${character.id}-main`, + careerNodeId, + character.id, + `主职业·${careerName}`, + 'active', + 100, + { layoutWeight: 3 }, + ), + ); + } + } + + const subCareerData = safeParseSubCareers(character.sub_careers); + subCareerData.forEach((sub) => { + const careerNodeId = `career-sub-${sub.career_id}`; + if (subCareerNodes.some((node) => node.id === careerNodeId)) { + const careerName = localCareerNameMap[sub.career_id] || '未知副职业'; + careerToCharacterEdges.push( + buildFlowEdge( + `${careerNodeId}-${character.id}-sub-${sub.stage || 1}`, + careerNodeId, + character.id, + `副职业·${careerName}`, + 'active', + 80, + { dashed: true, layoutWeight: 2 }, + ), + ); + } + }); + }); + + // 最后才连接成员之间的人际关系 + const memberRelationEdges: Edge[] = memberRelationLinks.map((link) => + buildFlowEdge( + `${link.source}-${link.target}-${link.relationship}`, + link.source, + link.target, + link.relationship, + link.status, + link.intimacy, + { layoutWeight: 1 }, + ), + ); + + const layoutEdges = [...orgMemberEdges, ...careerGroupEdges, ...careerToCharacterEdges]; + const fallbackLayoutEdges = layoutEdges.length > 0 ? layoutEdges : memberRelationEdges; + + const layouted = getLayoutedElements(allNodes, fallbackLayoutEdges); + setNodes(layouted.nodes); - setEdges(layouted.edges); + setEdges([...orgMemberEdges, ...careerGroupEdges, ...careerToCharacterEdges, ...memberRelationEdges]); setGraphData(data); } catch (error) { message.error('加载关系图谱失败'); @@ -228,7 +922,7 @@ export default function RelationshipGraph() { } finally { setLoading(false); } - }, [projectId, relationshipTypes, getCategoryColor, setNodes, setEdges]); + }, [projectId, relationshipTypes, buildFlowEdge, setNodes, setEdges]); // 当 relationshipTypes 加载完成后再加载图数据 useEffect(() => { @@ -237,18 +931,29 @@ export default function RelationshipGraph() { const loadNodeDetail = async (nodeId: string) => { if (!projectId) return; + + // 职业分组节点不展示详情 + if (nodeId === GROUP_MAIN_CAREER_NODE_ID || nodeId === GROUP_SUB_CAREER_NODE_ID) { + return; + } + + // 职业节点不展示详情 + if (nodeId.startsWith('career-main-') || nodeId.startsWith('career-sub-')) { + return; + } + + const cached = characterDetailMap[nodeId]; + if (cached) { + setNodeDetail(cached); + return; + } + setDetailLoading(true); try { - const res = await axios.get(`/api/characters?project_id=${projectId}`); - const characters = res.data.items || []; - const character = characters.find((c: CharacterDetail) => c.id === nodeId); - if (character) { - setNodeDetail(character); - } else { - message.error('未找到该角色详细信息'); - } + const res = await axios.get(`/api/characters/${nodeId}`); + setNodeDetail(res.data as CharacterDetail); } catch (error) { - message.error('加载角色详情失败'); + message.error('加载详情失败'); console.error(error); } finally { setDetailLoading(false); @@ -257,7 +962,18 @@ export default function RelationshipGraph() { const handleNodeClick = (_: unknown, node: { id: string }) => { setSelectedNodeId(node.id); - loadNodeDetail(node.id); + + const shouldShowDetail = + node.id !== GROUP_MAIN_CAREER_NODE_ID && + node.id !== GROUP_SUB_CAREER_NODE_ID && + !node.id.startsWith('career-main-') && + !node.id.startsWith('career-sub-'); + + setNodeDetail(null); + + if (shouldShowDetail) { + void loadNodeDetail(node.id); + } }; const handleCloseDetail = () => { @@ -273,68 +989,175 @@ export default function RelationshipGraph() { navigate('/projects'); }; + const renderCareerTags = () => { + if (!nodeDetail || nodeDetail.is_organization) return null; + + const subCareerData = safeParseSubCareers(nodeDetail.sub_careers); + + return ( +
+ + 职业体系 + +
+ {nodeDetail.main_career_id ? ( +
+ 主职业 + + {careerNameMap[nodeDetail.main_career_id]?.name || nodeDetail.main_career_id} + {nodeDetail.main_career_stage ? 第{nodeDetail.main_career_stage}阶 : ''} + +
+ ) : ( +
+ 主职业 + 未设置 +
+ )} + + {subCareerData.length > 0 ? ( +
+ 副职业 +
+ {subCareerData.map((sub, index) => ( + + {careerNameMap[sub.career_id]?.name || sub.career_id} + {sub.stage ? 阶{sub.stage} : ''} + + ))} +
+
+ ) : ( +
+ 副职业 + 未设置 +
+ )} +
+
+ ); + }; + + const traitList = safeParseStringArray(nodeDetail?.traits); + const orgMembers = safeParseStringArray(nodeDetail?.organization_members); + return ( -
+
- 关系图谱 + + {graphData?.nodes?.length || 0} 节点 / {graphData?.links?.length || 0} 关系 + } extra={ - -
- {/* 节点颜色图例 */} -
- - 主角 -
-
- - 反派 -
+ +
+ {/* 节点图例 */}
- 配角 + 角色(圆形)
- - 组织 + + 组织(方形)
+
+ + 主职业 +
+
+ + 副职业 +
+ | - {/* 连线颜色图例 */} + + {/* 连线图例 */}
- - 家族 + - - + 组织成员
- - 敌对 + + 主职业关联
- - 职业 -
-
- - 社交 + - - + 副职业关联
+ + {edgeCategoryOptions.length > 0 && ( +
+ + 连线显示: + + {edgeCategoryOptions.map((option) => { + const isVisible = edgeVisibilityMap[option.category] !== false; + return ( + + ); + })} +
+ )}
} > -
+
{/* 节点详情 */} - {selectedNodeId && nodeDetail && ( -
-
-

{nodeDetail.is_organization ? '组织详情' : '角色详情'}

- -
+{selectedNodeId && nodeDetail && ( +
+ + {nodeDetail.is_organization ? : } + {nodeDetail.is_organization ? '组织详情' : '角色详情'} + + } + extra={ + + } + > +
+
+
+ {nodeDetail.avatar_url ? ( + {nodeDetail.name} + ) : ( +
+ {nodeDetail.is_organization ? : } +
+ )} +
+ {nodeDetail.is_organization ? : } +
+
-
- {nodeDetail.avatar_url ? ( - {nodeDetail.name} - ) : ( -
- {nodeDetail.is_organization ? '🏛️' : '👤'} +
{nodeDetail.name}
+ + {!nodeDetail.is_organization && ( + + {nodeDetail.role_type === 'protagonist' + ? '主角' + : nodeDetail.role_type === 'antagonist' + ? '反派' + : '配角'} + + )} + {nodeDetail.gender && !nodeDetail.is_organization && {nodeDetail.gender}} + {nodeDetail.age && !nodeDetail.is_organization && {nodeDetail.age}岁} +
- )} -

{nodeDetail.name}

- - - {nodeDetail.is_organization ? '组织' : '角色'} - - - {nodeDetail.role_type === 'protagonist' ? '主角' : - nodeDetail.role_type === 'antagonist' ? '反派' : '配角'} - - {nodeDetail.gender && {nodeDetail.gender}} - {nodeDetail.age && {nodeDetail.age}岁} - -
- {!nodeDetail.is_organization ? ( - <> - {nodeDetail.appearance && ( -
- 外貌特征: -

{nodeDetail.appearance}

-
- )} - {nodeDetail.personality && ( -
- 性格特点: -

{nodeDetail.personality}

-
- )} - {nodeDetail.background && ( -
- 背景故事: -

{nodeDetail.background}

-
- )} - {nodeDetail.traits && ( -
- 特征标签: -
- {JSON.parse(nodeDetail.traits).map((trait: string, index: number) => ( - {trait} - ))} -
-
- )} - - ) : ( - <> - {nodeDetail.organization_type && ( -
- 组织类型: -

{nodeDetail.organization_type}

-
- )} - {nodeDetail.organization_purpose && ( -
- 组织目的: -

{nodeDetail.organization_purpose}

-
- )} - {nodeDetail.location && ( -
- 所在地: -

{nodeDetail.location}

-
- )} - {nodeDetail.motto && ( -
- 组织格言: -

{nodeDetail.motto}

-
- )} - {nodeDetail.power_level !== undefined && ( -
- 势力等级: -

{nodeDetail.power_level}/100

-
- )} - {nodeDetail.organization_members && ( -
- 组织成员: -
- {JSON.parse(nodeDetail.organization_members).map((member: string, index: number) => ( - {member} - ))} -
-
- )} - - )} +
+ {!nodeDetail.is_organization ? ( + <> + {renderCareerTags()} + + + + + {traitList.length > 0 && ( +
+ + 特征标签 + + + {traitList.slice(0, 12).map((trait, index) => ( + + {trait} + + ))} + +
+ )} + + ) : ( + <> + + + + + + {nodeDetail.power_level !== undefined && nodeDetail.power_level !== null && ( +
+ + 势力等级 + +
+ {nodeDetail.power_level}/100 +
+
+ )} + + {orgMembers.length > 0 && ( +
+ + 组织成员 + + + {orgMembers.slice(0, 16).map((member, index) => ( + + {member} + + ))} + +
+ )} + + )} +
+
+ +
+ )} + + {/* 职业节点点击提示(不展示详情卡时) */} + {selectedNodeId && !nodeDetail && (selectedNodeId.startsWith('career-main-') || selectedNodeId.startsWith('career-sub-')) && ( +
+ + + +
+ 职业节点 +

+ 职业节点用于展示主/副职业分组及其与角色的关联关系,不显示角色详情卡。 +

+
+
+
)}