From 705313f31a28a9318ace973427212ed830ffec4f Mon Sep 17 00:00:00 2001 From: wuchengji <17601056863@163.com> Date: Sat, 28 Feb 2026 11:28:14 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=89=93=E5=BC=80=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2=E5=B1=95=E7=A4=BA=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=EF=BC=8C=E5=B9=B6=E4=B8=94=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=92=8C=E8=BF=9E=E7=BA=BF=E9=85=8D=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 265 +++++++++++- frontend/package.json | 2 + frontend/src/App.tsx | 2 + frontend/src/pages/RelationshipGraph.tsx | 487 +++++++++++++++++++++++ frontend/src/pages/Relationships.tsx | 405 +------------------ 5 files changed, 772 insertions(+), 389 deletions(-) create mode 100644 frontend/src/pages/RelationshipGraph.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a793299..6a24d1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,16 +6,19 @@ "packages": { "": { "name": "frontend", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@ant-design/icons": "^5.6.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^9.0.0", "@dnd-kit/utilities": "^3.2.2", "@types/canvas-confetti": "^1.9.0", + "@types/dagre": "^0.7.54", + "@xyflow/react": "^12.10.1", "antd": "^5.27.6", "axios": "^1.12.2", "canvas-confetti": "^1.9.4", + "dagre": "^0.8.5", "dayjs": "^1.11.13", "react": "^18.3.1", "react-diff-viewer-continued": "^3.4.0", @@ -1860,6 +1863,61 @@ "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", "license": "MIT" }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2208,6 +2266,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2500,6 +2618,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -2613,6 +2737,121 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -3218,6 +3457,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3480,6 +3728,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4861,6 +5115,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0ac6cd5..d0f303a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,10 +15,12 @@ "@dnd-kit/sortable": "^9.0.0", "@dnd-kit/utilities": "^3.2.2", "@types/canvas-confetti": "^1.9.0", + "@types/dagre": "^0.7.54", "@xyflow/react": "^12.10.1", "antd": "^5.27.6", "axios": "^1.12.2", "canvas-confetti": "^1.9.4", + "dagre": "^0.8.5", "dayjs": "^1.11.13", "react": "^18.3.1", "react-diff-viewer-continued": "^3.4.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ed56a3..8a68ecf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Outline from './pages/Outline'; import Characters from './pages/Characters'; import Careers from './pages/Careers'; import Relationships from './pages/Relationships'; +import RelationshipGraph from './pages/RelationshipGraph'; import Organizations from './pages/Organizations'; import Chapters from './pages/Chapters'; import ChapterReader from './pages/ChapterReader'; @@ -61,6 +62,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/RelationshipGraph.tsx b/frontend/src/pages/RelationshipGraph.tsx new file mode 100644 index 0000000..7b52a48 --- /dev/null +++ b/frontend/src/pages/RelationshipGraph.tsx @@ -0,0 +1,487 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, Tag, Button, Space, message } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import axios from 'axios'; +import dagre from 'dagre'; +import { + ReactFlow, + Background, + Controls, + useNodesState, + useEdgesState, + BackgroundVariant, + MarkerType, +} from '@xyflow/react'; +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 }; +}; + +interface GraphNode { + id: string; + name: string; + type: string; + role_type: string; + avatar: string | null; +} + +interface GraphLink { + source: string; + target: string; + relationship: string; + intimacy: number; + status: string; +} + +interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; +} + +interface RelationshipType { + id: number; + name: string; + category: string; + reverse_name: string; + intimacy_range: string; + icon: string; + description: string; + created_at: string; +} + +interface CharacterDetail { + id: string; + 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 RelationshipGraph() { + const { projectId } = useParams<{ projectId: string }>(); + const [graphData, setGraphData] = useState(null); + const [loading, setLoading] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [nodeDetail, setNodeDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [relationshipTypes, setRelationshipTypes] = useState([]); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + useEffect(() => { + if (projectId) { + loadRelationshipTypes(); + } + }, [projectId]); + + // 当 relationshipTypes 加载完成后再加载图数据 + useEffect(() => { + if (projectId && relationshipTypes.length > 0) { + loadGraphData(); + } + }, [projectId, relationshipTypes]); + + const loadRelationshipTypes = async () => { + try { + const res = await axios.get('/api/relationships/types'); + setRelationshipTypes(res.data || []); + } catch (error) { + console.error('加载关系类型失败', error); + } + }; + + // 根据关系名称获取分类颜色 + const getCategoryColor = (relationshipName: string, isActive: boolean) => { + // 找到对应的关系类型 + 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 loadGraphData = async () => { + if (!projectId) return; + setLoading(true); + try { + const res = await axios.get(`/api/relationships/graph/${projectId}`); + const data = res.data as GraphData; + + 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 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, + 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, + }, + }; + }); + + 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, + }, + data: { + intimacy: link.intimacy, + status: link.status, + category: relationshipTypes.find(rt => rt.name === link.relationship)?.category || 'social', + }, + }; + }); + + // 使用 dagre 进行自动布局 + const layouted = getLayoutedElements(flowNodes, flowEdges); + setNodes(layouted.nodes); + setEdges(layouted.edges); + setGraphData(data); + } catch (error) { + message.error('加载关系图谱失败'); + console.error(error); + } finally { + setLoading(false); + } + }; + + 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 || []; + const character = characters.find((c: CharacterDetail) => c.id === nodeId); + if (character) { + setNodeDetail(character); + } 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 goBack = () => { + window.close(); + }; + + return ( +
+ + + 关系图谱 + + } + extra={ + +
+ {/* 节点颜色图例 */} +
+ + 主角 +
+
+ + 反派 +
+
+ + 配角 +
+
+ + 组织 +
+ | + {/* 连线颜色图例 */} +
+ + 家族 +
+
+ + 敌对 +
+
+ + 职业 +
+
+ + 社交 +
+
+
+ } + > +
+ + + + +
+
+ + {/* 节点详情 */} + {selectedNodeId && nodeDetail && ( +
+
+

{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.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} + ))} +
+
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Relationships.tsx b/frontend/src/pages/Relationships.tsx index 9ea0627..55739b0 100644 --- a/frontend/src/pages/Relationships.tsx +++ b/frontend/src/pages/Relationships.tsx @@ -1,20 +1,9 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; 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'; -import { - ReactFlow, - Background, - Controls, - MiniMap, - useNodesState, - useEdgesState, - BackgroundVariant, - MarkerType, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; const { TextArea } = Input; @@ -43,48 +32,6 @@ interface Character { is_organization: boolean; } -interface GraphNode { - id: string; - name: string; - type: string; - role_type: string; - avatar: string | null; -} - -interface GraphLink { - source: string; - target: string; - relationship: string; - intimacy: number; - status: string; -} - -interface GraphData { - nodes: GraphNode[]; - 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(); @@ -100,14 +47,6 @@ export default function Relationships() { const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const [pageSize, setPageSize] = useState(10); 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([]); useEffect(() => { const handleResize = () => { @@ -145,126 +84,6 @@ export default function Relationships() { } }; - const loadGraphData = async () => { - if (!projectId) return; - setGraphLoading(true); - try { - const res = await axios.get(`/api/relationships/graph/${projectId}`); - const data = res.data as GraphData; - - // 转换为 React Flow 的节点和边 - 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}`, - source: link.source, - target: link.target, - label: link.relationship, - type: 'smoothstep', - style: { - stroke: link.status === 'active' ? '#a3b1bf' : '#ffccc7', - strokeWidth: 2, - }, - labelStyle: { - fill: '#666', - fontSize: 10, - }, - labelBgStyle: { - fill: '#fff', - fillOpacity: 0.9, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: link.status === 'active' ? '#a3b1bf' : '#ffccc7', - }, - data: { - intimacy: link.intimacy, - status: link.status, - }, - })); - - setNodes(flowNodes); - setEdges(flowEdges); - setGraphData(data); - } catch (error) { - message.error('加载关系图谱失败'); - console.error(error); - } finally { - setGraphLoading(false); - } - }; - - 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; @@ -499,14 +318,22 @@ export default function Relationships() { } extra={ - + + + + } > ), }, - { - key: 'graph', - label: '关系图谱', - children: ( -
- {!graphData && !graphLoading && ( -
- -
- )} - {graphLoading && ( -
- 加载中... -
- )} - {graphData && ( - <> -
- 节点: {graphData.nodes.length} - 关系: {graphData.links.length} -
- 提示: 拖拽画布平移 | 滚轮缩放 | 拖拽节点移动 | 点击节点查看详情 -
-
-
- - - - { - const data = node.data as { type?: string; role_type?: string }; - if (data?.type === 'organization') return '#52c41a'; - if (data?.role_type === 'protagonist') return '#f5222d'; - return '#1890ff'; - }} - style={{ backgroundColor: '#f5f5f5' }} - /> - -
- - )} -
- ), - }, ]} /> @@ -775,136 +534,6 @@ 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} - ))} -
-
- )} - - )} - - )} -
);