diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce6f63b910df41ce683160211a7a1de2dc72265 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc *)" + ] + } +} diff --git a/.gitignore b/.gitignore index 30b5dd605dcf2060abcf32f50e002eb7e8aade71..7c362f4190720d238d39171a25ff800e297110dd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ test pnpm-lock.yaml -public/domain.json \ No newline at end of file +public/domain.json + +dist.tar +任务分工 diff --git a/package.json b/package.json index 68c5dade95bfacff78829c440eb3fc829f42e2ea..1f174c2440d4ee55f5b2d499658553c82268de09 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "license": "MIT", "scripts": { "dev": "vite --mode development", - "build": "node --max_old_space_size=20480 ./node_modules/.bin/vite build", + "build": "node --max_old_space_size=20480 ./node_modules/vite/bin/vite.js build", "build:beta": "vite build --mode beta", "build:release": "vite build --mode release", "build:legacy": "vite build --mode legacy ", @@ -162,4 +162,4 @@ "vite-plugin-style-import": "^2.0.0" }, "type": "commonjs" -} \ No newline at end of file +} diff --git a/public/img/mallTemplate/logo.png b/public/img/mallTemplate/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c2dd1817b2a06fddf81d8cd6f9f01c61e009121b Binary files /dev/null and b/public/img/mallTemplate/logo.png differ diff --git a/src/components/ChainGraph/ChainGraph.tsx b/src/components/ChainGraph/ChainGraph.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31507c4307a75d201c278904a3be8a8b04bb0806 --- /dev/null +++ b/src/components/ChainGraph/ChainGraph.tsx @@ -0,0 +1,260 @@ +import type { ChainGraph as ChainGraphData, ChainNode as ChainNodeData } from '@/ts/core/chain/chain'; +import { ChainRelationStyle, ChainRelationType } from '@/ts/core/chain/chain'; +import { mockChainAdapter } from '@/services'; +import { Graph } from '@antv/x6'; +import { Dnd } from '@antv/x6-plugin-dnd'; +import { Selection } from '@antv/x6-plugin-selection'; +import { register } from '@antv/x6-react-shape'; +import { message } from 'antd'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ChainNodeView } from './ChainNode'; +import ChainLegend from './ChainLegend'; +import styles from './index.module.less'; + +interface ChainGraphProps { + /** 图谱 ID */ + graphId?: string; + /** 中心节点 ID(以某节点为中心展示一跳关系) */ + centerNodeId?: string; + /** 数据加载后回调 */ + onDataLoaded?: (data: ChainGraphData) => void; + /** 单击选中节点 */ + onNodeClick?: (node: ChainNodeData) => void; + /** 双击节点 */ + onNodeDoubleClick?: (node: ChainNodeData) => void; +} + +let nodeRegistered = false; + +const ChainGraph: React.FC = ({ + graphId, + centerNodeId, + onDataLoaded, + onNodeClick, + onNodeDoubleClick, +}) => { + const containerRef = useRef(null); + const graphRef = useRef(null); + const [ready, setReady] = useState(false); + + // ---------- 初始化画布 ---------- + const initGraph = useCallback(() => { + if (!containerRef.current) return; + + const graph = new Graph({ + container: containerRef.current, + grid: { + size: 10, + visible: true, + args: { color: '#f0f0f0', thickness: 1 }, + }, + autoResize: true, + panning: { enabled: true, modifiers: [] }, + mousewheel: { + enabled: true, + modifiers: ['ctrl'], + factor: 1.1, + }, + background: { color: '#fafafa' }, + interacting: { + nodeMovable: true, + edgeLabelMovable: false, + }, + }); + + graph.use(new Dnd({ target: graph })); + graph.use( + new Selection({ + enabled: true, + multiple: false, + rubberband: false, + movable: false, + pointerEvents: 'none', + }), + ); + + // 单击节点 → 选中 + graph.on('node:click', ({ node }) => { + const data = node.getData(); + if (data && onNodeClick) { + onNodeClick(data); + } + }); + + // 双击节点 → 展开详情 + graph.on('node:dblclick', ({ node }) => { + const data = node.getData(); + if (data && onNodeDoubleClick) { + onNodeDoubleClick(data); + } + }); + + // 窗口自适应居中 + graph.on('resize', () => { + graph.centerContent(); + }); + + // 注册 React 节点(全局只注册一次) + if (!nodeRegistered) { + register({ + shape: 'chain-node', + width: 200, + height: 72, + component: ChainNodeView, + effect: ['data'], + }); + nodeRegistered = true; + } + + graphRef.current = graph; + setReady(true); + }, [onNodeClick, onNodeDoubleClick]); + + // ---------- 加载图谱数据 ---------- + const loadGraphData = useCallback(async () => { + const graph = graphRef.current; + if (!graph) return; + + try { + let res: { code: number; data: ChainGraphData | null }; + + if (centerNodeId) { + // 以节点为中心加载一跳关系 + res = await mockChainAdapter.getCenteredGraph(centerNodeId); + } else if (graphId) { + res = await mockChainAdapter.getChainGraph(graphId); + } else { + const myRes = await mockChainAdapter.getMyChainGraph(); + res = { code: myRes.code, data: myRes.data }; + } + + if (!res || res.code !== 200 || !res.data) { + message.error('加载图谱数据失败'); + return; + } + + const { nodes, edges } = res.data; + const highlightId = centerNodeId || res.data.centerNodeId; + + // 构建节点 cells + const nodeCells = nodes.map((n, idx) => { + const hasPos = typeof n.x === 'number' && typeof n.y === 'number'; + let x = hasPos ? n.x! : 0; + let y = hasPos ? n.y! : 0; + + if (!hasPos) { + const radius = Math.max(250, nodes.length * 30); + const angle = (2 * Math.PI * idx) / nodes.length - Math.PI / 2; + x = 500 + radius * Math.cos(angle); + y = 350 + radius * Math.sin(angle); + } + + return { + id: n.id, + shape: 'chain-node', + x, + y, + // isCenter 标记让 React 节点渲染高亮 + data: { ...n, isCenter: n.id === highlightId || n.targetId === highlightId }, + }; + }); + + // 构建边 cells + const edgeCells = edges.map((e) => { + const rStyle = + ChainRelationStyle[e.relationType] || + ChainRelationStyle[ChainRelationType.Horizontal]; + return { + id: e.id, + shape: 'edge', + source: e.source, + target: e.target, + data: e, + attrs: { + line: { + stroke: rStyle.color, + strokeWidth: 2, + strokeDasharray: + rStyle.lineType === 'dashed' + ? '8 4' + : rStyle.lineType === 'dotted' + ? '2 4' + : undefined, + targetMarker: rStyle.hasArrow + ? { name: 'block', width: 8, height: 6, fill: rStyle.color } + : undefined, + }, + }, + labels: e.description + ? [ + { + position: { distance: 0.5 }, + attrs: { + text: { text: e.description, fontSize: 11, fill: '#666' }, + rect: { + fill: '#fff', + stroke: '#e8e8e8', + rx: 3, + strokeWidth: 1, + refWidth: '120%', + refHeight: '120%', + refX: -4, + refY: -3, + }, + }, + }, + ] + : undefined, + zIndex: -1, + }; + }); + + graph.clearCells(); + nodeCells.forEach((cell) => graph.addNode(cell)); + edgeCells.forEach((cell) => graph.addEdge(cell)); + + // 中心节点居中 + if (highlightId) { + const centerCell = graph.getCellById(highlightId); + if (centerCell) { + graph.centerCell(centerCell); + } + } else { + graph.centerContent(); + } + + // 通知父组件数据已加载 + if (onDataLoaded) { + onDataLoaded(res.data); + } + } catch { + message.error('加载图谱数据失败'); + } + }, [graphId, centerNodeId, onDataLoaded]); + + // ---------- 生命周期 ---------- + useEffect(() => { + initGraph(); + return () => { + graphRef.current?.dispose(); + graphRef.current = null; + }; + }, []); + + useEffect(() => { + if (ready && graphRef.current) { + loadGraphData(); + } + }, [ready, loadGraphData]); + + return ( +
+
+ +
+ ); +}; + +ChainGraph.displayName = 'ChainGraph'; + +export default ChainGraph; diff --git a/src/components/ChainGraph/ChainLegend.tsx b/src/components/ChainGraph/ChainLegend.tsx new file mode 100644 index 0000000000000000000000000000000000000000..332a988d0b10abf1d9ba100d32175dc0f61ba323 --- /dev/null +++ b/src/components/ChainGraph/ChainLegend.tsx @@ -0,0 +1,54 @@ +import { + ChainRelationLabel, + ChainRelationStyle, + ChainRelationType, +} from '@/ts/core/chain/chain'; +import React from 'react'; +import styles from './index.module.less'; + +const relationTypes = [ + ChainRelationType.Upstream, + ChainRelationType.Downstream, + ChainRelationType.Horizontal, + ChainRelationType.Resource, +]; + +const ChainLegend: React.FC = () => { + return ( +
+
图例
+ {relationTypes.map((type) => { + const style = ChainRelationStyle[type]; + const label = ChainRelationLabel[type]; + return ( +
+ + + {style.hasArrow && ( + + )} + + {label} +
+ ); + })} +
+ ); +}; + +ChainLegend.displayName = 'ChainLegend'; + +export default ChainLegend; diff --git a/src/components/ChainGraph/ChainNav.tsx b/src/components/ChainGraph/ChainNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4d45afd7807ed7d6154fe2a36d4756aefc306b1 --- /dev/null +++ b/src/components/ChainGraph/ChainNav.tsx @@ -0,0 +1,151 @@ +import { mockChainAdapter } from '@/services'; +import type { ChainCategory, ChainGraph } from '@/ts/core/chain/chain'; +import { AppstoreOutlined, ReloadOutlined, RightOutlined, NodeIndexOutlined } from '@ant-design/icons'; +import { Button, Spin, Tooltip, message } from 'antd'; +import React, { useCallback, useEffect, useState } from 'react'; +import styles from './index.module.less'; + +interface ChainNavProps { + /** 当前加载的图谱 ID */ + activeGraphId?: string; + /** 选择产业链回调 */ + onSelectChain: (graph: ChainGraph) => void; + /** 加载"我的产业链"回调 */ + onMyChain: () => void; +} + +const ChainNav: React.FC = ({ activeGraphId, onSelectChain, onMyChain }) => { + const [collapsed, setCollapsed] = useState(false); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedCats, setExpandedCats] = useState>({}); + + const loadCategories = useCallback(async () => { + setLoading(true); + try { + const res = await mockChainAdapter.getChainCategories(); + if (res.code === 200 && res.data) { + setCategories(res.data); + // 默认展开所有分类 + const expanded: Record = {}; + res.data.forEach((c) => { expanded[c.id] = true; }); + setExpandedCats(expanded); + } + } catch { + message.error('加载产业链分类失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadCategories(); + }, [loadCategories]); + + const toggleCategory = (catId: string) => { + setExpandedCats((prev) => ({ ...prev, [catId]: !prev[catId] })); + }; + + const toggleCollapse = () => setCollapsed(!collapsed); + + const formatDate = (dateStr: string) => { + if (!dateStr) return ''; + return dateStr.slice(0, 10); + }; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + 产业链导航 + +
+ +
+
+ + {/* 我的产业链快捷入口 */} +
+ + 我的产业链 +
+ + {/* 分类列表 */} +
+ + {categories.map((cat) => ( +
+
toggleCategory(cat.id)}> + + {cat.name} + {cat.chains.length} +
+ {expandedCats[cat.id] && + cat.chains.map((chain) => ( +
onSelectChain(chain)} + > +
{chain.name}
+
+ {chain.nodeCount} 组织 + {chain.edgeCount} 关系 + {formatDate(chain.updateTime)} +
+
+ ))} +
+ ))} + {!loading && categories.length === 0 && ( +
暂无产业链数据
+ )} +
+
+
+ ); +}; + +ChainNav.displayName = 'ChainNav'; + +export default ChainNav; diff --git a/src/components/ChainGraph/ChainNode.tsx b/src/components/ChainGraph/ChainNode.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0166837b6e47941cb0e224b8449e7c725c425fee --- /dev/null +++ b/src/components/ChainGraph/ChainNode.tsx @@ -0,0 +1,44 @@ +import { ChainNode as ChainNodeData, ChainNodeStyle } from '@/ts/core/chain/chain'; +import { Node } from '@antv/x6'; +import { Graph } from '@antv/x6'; +import React from 'react'; +import styles from './index.module.less'; + +interface ChainNodeProps { + node: Node; + graph: Graph; +} + +const ChainNodeView: React.FC = ({ node }) => { + const rawData = node.getData(); + const data = rawData; + const style = ChainNodeStyle[data.nodeType] || { color: '#666', label: data.nodeType }; + const isCenter = data.isCenter; + + return ( +
+
+ {style.label} +
+
+
+ {data.logo ? ( + {data.name} + ) : ( + {data.name.charAt(0)} + )} +
+
+
{data.name}
+
+ {data.industry || ''}{data.scale ? ` | ${data.scale}` : ''} +
+
+
+
+ ); +}; + +ChainNodeView.displayName = 'ChainNode'; + +export { ChainNodeView }; diff --git a/src/components/ChainGraph/RelationManager.tsx b/src/components/ChainGraph/RelationManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33bbee0d0022df11efbf69f4087d96b7a1926417 --- /dev/null +++ b/src/components/ChainGraph/RelationManager.tsx @@ -0,0 +1,355 @@ +import { + ChainRelationLabel, + ChainRelationStyle, + ChainRelationType, +} from '@/ts/core/chain/chain'; +import type { ChainEdge, ChainNode, RelationFormData } from '@/ts/core/chain/chain'; +import { mockChainAdapter } from '@/services'; +import { + DeleteOutlined, + EditOutlined, + LinkOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { + Button, + Drawer, + Form, + Input, + message, + Popconfirm, + Select, + Space, + Tag, + Typography, +} from 'antd'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styles from './index.module.less'; + +const { TextArea } = Input; +const { Text } = Typography; + +interface RelationManagerProps { + visible: boolean; + selectedNode: ChainNode | null; + currentEdges: ChainEdge[]; + onClose: () => void; + onRelationsChanged: () => void; +} + +const RelationManager: React.FC = ({ + visible, + selectedNode, + currentEdges, + onClose, + onRelationsChanged, +}) => { + const [form] = Form.useForm(); + const [adding, setAdding] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [searchOptions, setSearchOptions] = useState([]); + const [searching, setSearching] = useState(false); + const searchTimer = useRef>(); + + // 与当前选中节点相关的连边 + const relatedEdges = selectedNode + ? currentEdges.filter( + (e) => e.source === selectedNode.id || e.target === selectedNode.id, + ) + : []; + + // 获取连边的另一侧节点名称 + const getPeerName = (edge: ChainEdge) => { + if (!selectedNode) return ''; + const peerId = edge.source === selectedNode.id ? edge.target : edge.source; + return peerId; + }; + + // 防抖搜索 + const handleSearch = useCallback((keyword: string) => { + if (!keyword || keyword.length < 1) { + setSearchOptions([]); + return; + } + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(async () => { + setSearching(true); + try { + const res = await mockChainAdapter.searchNodes({ keyword }); + if (res.code === 200 && res.data) { + // 过滤掉当前节点自身 + setSearchOptions(res.data.filter((n) => n.id !== selectedNode?.id)); + } + } catch { + message.error('搜索组织失败'); + } finally { + setSearching(false); + } + }, 350); + }, [selectedNode]); + + // 清理定时器 + useEffect(() => { + return () => { + if (searchTimer.current) clearTimeout(searchTimer.current); + }; + }, []); + + // 关闭时重置 + useEffect(() => { + if (!visible) { + setAdding(false); + setEditingId(null); + form.resetFields(); + } + }, [visible, form]); + + // 提交添加关系 + const handleAdd = async () => { + if (!selectedNode) return; + try { + const values = await form.validateFields(); + setSubmitting(true); + const data: RelationFormData = { + sourceTargetId: selectedNode.targetId, + targetTargetId: values.targetNodeId, + relationType: values.relationType, + description: values.description, + }; + const res = await mockChainAdapter.addRelation(data); + if (res.code === 200) { + message.success('关系添加成功'); + setAdding(false); + form.resetFields(); + onRelationsChanged(); + } + } catch { + // 表单校验失败 + } finally { + setSubmitting(false); + } + }; + + // 提交编辑 + const handleEdit = async () => { + if (!editingId) return; + try { + const values = await form.validateFields(); + setSubmitting(true); + const res = await mockChainAdapter.updateRelation(editingId, { + relationType: values.relationType, + description: values.description, + }); + if (res.code === 200) { + message.success('关系修改成功'); + setEditingId(null); + form.resetFields(); + onRelationsChanged(); + } + } catch { + // 表单校验失败 + } finally { + setSubmitting(false); + } + }; + + // 删除关系 + const handleDelete = async (edgeId: string) => { + const res = await mockChainAdapter.deleteRelation(edgeId); + if (res.code === 200) { + message.success('关系已删除'); + onRelationsChanged(); + } + }; + + // 进入编辑模式 + const startEdit = (edge: ChainEdge) => { + setEditingId(edge.id); + setAdding(false); + form.setFieldsValue({ + relationType: edge.relationType, + description: edge.description || '', + }); + }; + + // 取消编辑/添加 + const cancelEdit = () => { + setEditingId(null); + setAdding(false); + form.resetFields(); + }; + + if (!selectedNode) return null; + + return ( + + + 关系管理 —— {selectedNode.name} + + } + placement="right" + width={420} + open={visible} + onClose={onClose} + destroyOnClose + > + {/* 已有关系列表 */} +
+
+ 当前关系 ({relatedEdges.length}) + {!adding && !editingId && ( + + )} +
+ + {relatedEdges.length === 0 && !adding && ( +
暂无关系数据,点击"添加关系"新建
+ )} + + {relatedEdges.map((edge) => ( +
+ {editingId === edge.id ? ( + /* ---- 编辑模式 ---- */ +
+ + + + +