From 82cbd0f1c71449e4cb8d1257e15a2a5525417900 Mon Sep 17 00:00:00 2001 From: wuchengji <17601056863@163.com> Date: Fri, 27 Feb 2026 18:22:58 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=85=B3=E7=B3=BB=E5=9B=BE?= =?UTF-8?q?=E8=B0=B1=E7=82=B9=E5=87=BB=E8=8A=82=E7=82=B9=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Relationships.tsx | 242 +++++++++++++++++++++++++-- 1 file changed, 228 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/Relationships.tsx b/frontend/src/pages/Relationships.tsx index 0ec374f..9ea0627 100644 --- a/frontend/src/pages/Relationships.tsx +++ b/frontend/src/pages/Relationships.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs, AutoComplete } from 'antd'; +import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs, AutoComplete, Descriptions, Divider } from 'antd'; import { PlusOutlined, ApartmentOutlined, UserOutlined, EditOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import axios from 'axios'; @@ -64,6 +64,27 @@ interface GraphData { links: GraphLink[]; } +interface CharacterDetail { + project_id: string; + name: string; + age: string; + gender: string; + is_organization: boolean; + role_type: string; + personality: string; + background: string; + appearance: string; + organization_type: string; + organization_purpose: string; + organization_members: string; + traits: string; + avatar_url: string; + power_level: number; + location: string; + motto: string; + color: string; +} + export default function Relationships() { const { projectId } = useParams<{ projectId: string }>(); const { currentProject } = useStore(); @@ -81,6 +102,9 @@ export default function Relationships() { const [currentPage, setCurrentPage] = useState(1); const [graphData, setGraphData] = useState(null); const [graphLoading, setGraphLoading] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [nodeDetail, setNodeDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -129,19 +153,40 @@ export default function Relationships() { const data = res.data as GraphData; // 转换为 React Flow 的节点和边 - const flowNodes: Node[] = data.nodes.map((node, index) => ({ - id: node.id, - type: 'default', - position: { - x: 100 + (index % 4) * 200, - y: 100 + Math.floor(index / 4) * 150, - }, - data: { - label: node.name, - type: node.type, - role_type: node.role_type, - }, - })); + const getNodeColors = (type: string, roleType: string) => { + // 人物颜色 + if (type === 'character') { + if (roleType === 'protagonist') return { border: '#f5222d', bg: '#f5222d' }; + if (roleType === 'antagonist') return { border: '#cf1322', bg: '#cf1322' }; + return { border: '#1890ff', bg: '#1890ff' }; + } + // 组织颜色 + return { border: '#52c41a', bg: '#52c41a' }; + }; + + const flowNodes: Node[] = data.nodes.map((node, index) => { + const colors = getNodeColors(node.type, node.role_type); + return { + id: node.id, + type: 'default', + position: { + x: 100 + (index % 4) * 200, + y: 100 + Math.floor(index / 4) * 150, + }, + data: { + label: node.name, + type: node.type, + role_type: node.role_type, + }, + style: { + border: `2px solid ${colors.border}`, + borderRadius: 8, + backgroundColor: `${colors.bg}33`, // 20% 透明度 + padding: '10px 15px', + minWidth: 100, + }, + }; + }); const flowEdges: Edge[] = data.links.map(link => ({ id: `${link.source}-${link.target}`, @@ -182,6 +227,44 @@ export default function Relationships() { } }; + const loadNodeDetail = async (nodeId: string) => { + if (!projectId) return; + setDetailLoading(true); + try { + const res = await axios.get(`/api/characters?project_id=${projectId}`); + const characters = res.data.items || []; + // 通过 id 查找角色 + const character = characters.find((c: { id: string }) => c.id === nodeId); + if (character) { + setNodeDetail(character); + } else { + // 如果没找到,尝试通过 name 查找 + const nodeLabel = nodes.find(n => n.id === nodeId)?.data.label; + const characterByName = characters.find((c: { name: string }) => c.name === nodeLabel); + if (characterByName) { + setNodeDetail(characterByName); + } else { + message.error('未找到该角色详细信息'); + } + } + } catch (error) { + message.error('加载角色详情失败'); + console.error(error); + } finally { + setDetailLoading(false); + } + }; + + const handleNodeClick = (_: unknown, node: { id: string }) => { + setSelectedNodeId(node.id); + loadNodeDetail(node.id); + }; + + const handleCloseDetail = () => { + setSelectedNodeId(null); + setNodeDetail(null); + }; + const handleCreateRelationship = async (values: { character_from_id: string; character_to_id: string; @@ -542,6 +625,7 @@ export default function Relationships() { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onNodeClick={handleNodeClick} fitView fitViewOptions={{ padding: 0.2 }} attributionPosition="bottom-left" @@ -691,6 +775,136 @@ export default function Relationships() { + + {/* 节点详情 Modal */} + + 关闭 + + ]} + width={isMobile ? '100%' : 700} + loading={detailLoading} + > + {nodeDetail && ( + <> +
+ {nodeDetail.avatar_url ? ( + {nodeDetail.name} + ) : ( +
+ {nodeDetail.is_organization ? '🏛️' : '👤'} +
+ )} +

{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} + ))} +
+
+ )} + + )} + + )} +
);