refactor(ValueChainCard): 重构产业链分析卡片布局

- 新增 ProcessNavigation 流程导航组件(上游→核心→下游+副标题)
- 新增 ValueChainFilterBar 筛选栏组件(类型/重要度/视图Tab切换)
- 重构布局为左右分栏:左侧流程导航,右侧筛选+视图切换
- 移除 DisclaimerBox 免责声明
- ValueChainNodeCard 适配黑金主题
- 移除卡片内部左右边距

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-12 14:04:04 +08:00
parent 7c7c70c4d9
commit 23112db115
6 changed files with 550 additions and 157 deletions

View File

@@ -0,0 +1,170 @@
/**
* 产业链流程式导航组件
*
* 显示上游 → 核心 → 下游的流程式导航
* 带图标箭头连接符
*/
import React, { memo } from 'react';
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
import { FaArrowRight } from 'react-icons/fa';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textSecondary: 'gray.400',
upstream: {
active: 'orange.500',
activeBg: 'orange.900',
inactive: 'gray.600',
inactiveBg: 'gray.700',
},
core: {
active: 'blue.500',
activeBg: 'blue.900',
inactive: 'gray.600',
inactiveBg: 'gray.700',
},
downstream: {
active: 'green.500',
activeBg: 'green.900',
inactive: 'gray.600',
inactiveBg: 'gray.700',
},
};
export type TabType = 'upstream' | 'core' | 'downstream';
interface ProcessNavigationProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
upstreamCount: number;
coreCount: number;
downstreamCount: number;
}
interface NavItemProps {
label: string;
subtitle: string;
count: number;
isActive: boolean;
colorKey: 'upstream' | 'core' | 'downstream';
onClick: () => void;
}
const NavItem: React.FC<NavItemProps> = memo(({
label,
subtitle,
count,
isActive,
colorKey,
onClick,
}) => {
const colors = THEME[colorKey];
return (
<Box
px={4}
py={2}
borderRadius="lg"
cursor="pointer"
bg={isActive ? colors.activeBg : colors.inactiveBg}
borderWidth={2}
borderColor={isActive ? colors.active : 'transparent'}
onClick={onClick}
transition="all 0.2s"
_hover={{
borderColor: colors.active,
transform: 'translateY(-2px)',
}}
>
<VStack spacing={1} align="center">
<HStack spacing={2}>
<Text
fontWeight={isActive ? 'bold' : 'medium'}
color={isActive ? colors.active : colors.inactive}
fontSize="sm"
>
{label}
</Text>
<Badge
bg={isActive ? colors.active : colors.inactive}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{count}
</Badge>
</HStack>
<Text
fontSize="xs"
color={THEME.textSecondary}
>
{subtitle}
</Text>
</VStack>
</Box>
);
});
NavItem.displayName = 'NavItem';
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
activeTab,
onTabChange,
upstreamCount,
coreCount,
downstreamCount,
}) => {
return (
<HStack
spacing={2}
flexWrap="wrap"
gap={2}
>
<NavItem
label="上游供应链"
subtitle="原材料与供应商"
count={upstreamCount}
isActive={activeTab === 'upstream'}
colorKey="upstream"
onClick={() => onTabChange('upstream')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="核心企业"
subtitle="公司主体与产品"
count={coreCount}
isActive={activeTab === 'core'}
colorKey="core"
onClick={() => onTabChange('core')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="下游客户"
subtitle="客户与终端市场"
count={downstreamCount}
isActive={activeTab === 'downstream'}
colorKey="downstream"
onClick={() => onTabChange('downstream')}
/>
</HStack>
);
});
ProcessNavigation.displayName = 'ProcessNavigation';
export default ProcessNavigation;

View File

@@ -0,0 +1,151 @@
/**
* 产业链筛选栏组件
*
* 提供类型筛选、重要度筛选和视图切换功能
*/
import React, { memo } from 'react';
import {
HStack,
Select,
Tabs,
TabList,
Tab,
} from '@chakra-ui/react';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
inputBg: 'gray.700',
inputBorder: 'gray.600',
};
export type ViewMode = 'hierarchy' | 'flow';
// 节点类型选项
const TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'company', label: '公司' },
{ value: 'supplier', label: '供应商' },
{ value: 'customer', label: '客户' },
{ value: 'regulator', label: '监管机构' },
{ value: 'product', label: '产品' },
{ value: 'service', label: '服务' },
{ value: 'channel', label: '渠道' },
{ value: 'raw_material', label: '原材料' },
{ value: 'end_user', label: '终端用户' },
];
// 重要度选项
const IMPORTANCE_OPTIONS = [
{ value: 'all', label: '全部重要度' },
{ value: 'high', label: '高 (≥80)' },
{ value: 'medium', label: '中 (50-79)' },
{ value: 'low', label: '低 (<50)' },
];
interface ValueChainFilterBarProps {
typeFilter: string;
onTypeChange: (value: string) => void;
importanceFilter: string;
onImportanceChange: (value: string) => void;
viewMode: ViewMode;
onViewModeChange: (value: ViewMode) => void;
}
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
typeFilter,
onTypeChange,
importanceFilter,
onImportanceChange,
viewMode,
onViewModeChange,
}) => {
return (
<HStack
spacing={3}
flexWrap="wrap"
gap={3}
>
{/* 左侧筛选区 */}
<HStack spacing={3}>
<Select
value={typeFilter}
onChange={(e) => onTypeChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
<Select
value={importanceFilter}
onChange={(e) => onImportanceChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{IMPORTANCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
</HStack>
{/* 右侧视图切换 */}
<Tabs
index={viewMode === 'hierarchy' ? 0 : 1}
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
variant="soft-rounded"
size="sm"
>
<TabList>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
</TabList>
</Tabs>
</HStack>
);
});
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
export default ValueChainFilterBar;

View File

@@ -8,3 +8,7 @@ export { default as DisclaimerBox } from './DisclaimerBox';
export { default as ScoreBar } from './ScoreBar';
export { default as BusinessTreeItem } from './BusinessTreeItem';
export { default as KeyFactorCard } from './KeyFactorCard';
export { default as ProcessNavigation } from './ProcessNavigation';
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
export type { TabType } from './ProcessNavigation';
export type { ViewMode } from './ValueChainFilterBar';

View File

@@ -2,184 +2,218 @@
* 产业链分析卡片
*
* 显示产业链层级视图和流向关系
* 黑金主题风格 + 流程式导航
*/
import React from 'react';
import React, { useState, useMemo, memo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
SimpleGrid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Center,
Box,
Flex,
} from '@chakra-ui/react';
import { FaNetworkWired } from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { DisclaimerBox } from '../atoms';
import {
ProcessNavigation,
ValueChainFilterBar,
} from '../atoms';
import type { TabType, ViewMode } from '../atoms';
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
import { getSankeyChartOption } from '../utils/chartOptions';
import type { ValueChainData } from '../types';
import type { ValueChainData, ValueChainNode } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
};
interface ValueChainCardProps {
valueChainData: ValueChainData;
companyName?: string;
cardBg?: string;
}
const ValueChainCard: React.FC<ValueChainCardProps> = ({
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
valueChainData,
cardBg,
companyName = '目标公司',
}) => {
const sankeyOption = getSankeyChartOption(valueChainData);
// 状态管理
const [activeTab, setActiveTab] = useState<TabType>('upstream');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [importanceFilter, setImportanceFilter] = useState<string>('all');
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
// 解析节点数据
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
// 获取上游节点
const upstreamNodes = [
const upstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_-2'] || []),
...(nodesByLevel?.['level_-1'] || []),
];
], [nodesByLevel]);
// 获取核心节点
const coreNodes = nodesByLevel?.['level_0'] || [];
const coreNodes = useMemo(() =>
nodesByLevel?.['level_0'] || [],
[nodesByLevel]);
// 获取下游节点
const downstreamNodes = [
const downstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_1'] || []),
...(nodesByLevel?.['level_2'] || []),
];
], [nodesByLevel]);
// 计算总节点数
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
// 根据 activeTab 获取当前节点
const currentNodes = useMemo(() => {
switch (activeTab) {
case 'upstream':
return upstreamNodes;
case 'core':
return coreNodes;
case 'downstream':
return downstreamNodes;
default:
return [];
}
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
// 筛选节点
const filteredNodes = useMemo(() => {
let nodes = [...currentNodes];
// 类型筛选
if (typeFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
}
// 重要度筛选
if (importanceFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => {
const score = n.importance_score || 0;
switch (importanceFilter) {
case 'high':
return score >= 80;
case 'medium':
return score >= 50 && score < 80;
case 'low':
return score < 50;
default:
return true;
}
});
}
return nodes;
}, [currentNodes, typeFilter, importanceFilter]);
// Sankey 图配置
const sankeyOption = useMemo(() =>
getSankeyChartOption(valueChainData),
[valueChainData]);
return (
<Card bg={cardBg} shadow="md">
<Card bg={THEME.cardBg} shadow="md">
{/* 头部区域 */}
<CardHeader>
<HStack>
<Icon as={FaNetworkWired} color="teal.500" />
<Heading size="sm"></Heading>
<HStack spacing={2}>
<Badge colorScheme="orange">
{valueChainData.analysis_summary?.upstream_nodes || 0}
</Badge>
<Badge colorScheme="blue">
{valueChainData.analysis_summary?.company_nodes || 0}
</Badge>
<Badge colorScheme="green">
{valueChainData.analysis_summary?.downstream_nodes || 0}
</Badge>
</HStack>
<HStack flexWrap="wrap" gap={2}>
<Icon as={FaNetworkWired} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}>
</Heading>
<Text color={THEME.textSecondary} fontSize="sm">
| {companyName}
</Text>
<Badge bg={THEME.gold} color="gray.900">
{totalNodes}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<Tabs variant="soft-rounded" colorScheme="teal">
<TabList>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
{/* 层级视图 */}
<TabPanel>
<VStack spacing={8} align="stretch">
{/* 上游供应链 */}
{upstreamNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{upstreamNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
level={node.node_level}
/>
))}
</SimpleGrid>
</Box>
)}
<CardBody px={0}>
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
<Flex
px={0}
py={3}
borderBottom="1px solid"
borderColor="gray.700"
justify="space-between"
align="center"
flexWrap="wrap"
gap={3}
>
{/* 左侧:流程式导航 */}
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
{/* 核心企业 */}
{coreNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{coreNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={0}
/>
))}
</SimpleGrid>
</Box>
)}
{/* 右侧:筛选与视图切换 */}
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Flex>
{/* 下游客户 */}
{downstreamNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{downstreamNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
level={node.node_level}
/>
))}
</SimpleGrid>
</Box>
)}
</VStack>
</TabPanel>
{/* 流向关系 */}
<TabPanel>
{sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="light"
/>
) : (
<Center h="200px">
<Text color="gray.500"></Text>
</Center>
)}
</TabPanel>
</TabPanels>
</Tabs>
{/* 内容区域 */}
<Box px={0} pt={4}>
{viewMode === 'hierarchy' ? (
filteredNodes.length > 0 ? (
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{filteredNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={node.node_level}
/>
))}
</SimpleGrid>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)
) : sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="dark"
/>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)}
</Box>
</CardBody>
</Card>
);
};
});
ValueChainCard.displayName = 'ValueChainCard';
export default ValueChainCard;

View File

@@ -2,9 +2,10 @@
* 产业链节点卡片组件
*
* 显示产业链中的单个节点,点击可展开查看相关公司
* 黑金主题风格
*/
import React, { useState } from 'react';
import React, { useState, memo } from 'react';
import {
Card,
CardBody,
@@ -37,6 +38,36 @@ import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
const API_BASE_URL = getApiBase();
// 黑金主题配置
const THEME = {
cardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: 'white',
textSecondary: 'gray.400',
// 上游颜色
upstream: {
bg: 'rgba(237, 137, 54, 0.1)',
border: 'orange.600',
badge: 'orange',
icon: 'orange.400',
},
// 核心企业颜色
core: {
bg: 'rgba(66, 153, 225, 0.15)',
border: 'blue.500',
badge: 'blue',
icon: 'blue.400',
},
// 下游颜色
downstream: {
bg: 'rgba(72, 187, 120, 0.1)',
border: 'green.600',
badge: 'green',
icon: 'green.400',
},
};
/**
* 获取节点类型对应的图标
*/
@@ -49,6 +80,8 @@ const getNodeTypeIcon = (type: string) => {
service: FaCog,
channel: FaNetworkWired,
raw_material: FaFlask,
regulator: FaBuilding,
end_user: FaUserTie,
};
return icons[type] || FaBuilding;
};
@@ -64,7 +97,7 @@ const getImportanceColor = (score?: number): string => {
return 'green';
};
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
node,
isCompany = false,
level = 0,
@@ -74,17 +107,14 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
const [loadingRelated, setLoadingRelated] = useState(false);
const toast = useToast();
// 根据层级和是否为核心企业确定颜色方案
const getColorScheme = (): string => {
if (isCompany) return 'blue';
if (level < 0) return 'orange';
if (level > 0) return 'green';
return 'gray';
// 根据层级确定颜色方案
const getColorConfig = () => {
if (isCompany || level === 0) return THEME.core;
if (level < 0) return THEME.upstream;
return THEME.downstream;
};
const colorScheme = getColorScheme();
const bgColor = `${colorScheme}.50`;
const borderColor = `${colorScheme}.200`;
const colorConfig = getColorConfig();
// 获取相关公司数据
const fetchRelatedCompanies = async () => {
@@ -135,16 +165,16 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
<>
<ScaleFade in={true} initialScale={0.9}>
<Card
bg={bgColor}
borderColor={borderColor}
borderWidth={isCompany ? 3 : 1}
bg={colorConfig.bg}
borderColor={colorConfig.border}
borderWidth={isCompany ? 2 : 1}
shadow={isCompany ? 'lg' : 'sm'}
cursor="pointer"
onClick={handleCardClick}
_hover={{
shadow: 'xl',
transform: 'translateY(-4px)',
borderColor: `${colorScheme}.400`,
borderColor: THEME.gold,
}}
transition="all 0.3s ease"
minH="140px"
@@ -155,11 +185,11 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
<HStack spacing={2}>
<Icon
as={getNodeTypeIcon(node.node_type)}
color={`${colorScheme}.500`}
color={colorConfig.icon}
boxSize={5}
/>
{isCompany && (
<Badge colorScheme="blue" variant="solid">
<Badge colorScheme={colorConfig.badge} variant="solid">
</Badge>
)}
@@ -168,28 +198,28 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
node.importance_score >= 70 && (
<Tooltip label="重要节点">
<span>
<Icon as={FaStar} color="orange.400" boxSize={4} />
<Icon as={FaStar} color={THEME.gold} boxSize={4} />
</span>
</Tooltip>
)}
</HStack>
<Text fontWeight="bold" fontSize="sm" noOfLines={2}>
<Text fontWeight="bold" fontSize="sm" color={THEME.textPrimary} noOfLines={2}>
{node.node_name}
</Text>
{node.node_description && (
<Text fontSize="xs" color="gray.600" noOfLines={2}>
<Text fontSize="xs" color={THEME.textSecondary} noOfLines={2}>
{node.node_description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
<Badge variant="subtle" size="sm" colorScheme={colorScheme}>
<Badge variant="subtle" size="sm" colorScheme={colorConfig.badge}>
{node.node_type}
</Badge>
{node.market_share !== undefined && (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" color={THEME.goldLight}>
{node.market_share}%
</Badge>
)}
@@ -198,10 +228,10 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
{node.importance_score !== undefined && (
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
<Text fontSize="xs" fontWeight="bold">
<Text fontSize="xs" fontWeight="bold" color={THEME.goldLight}>
{node.importance_score}
</Text>
</HStack>
@@ -210,6 +240,7 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
size="xs"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
bg="gray.600"
/>
</Box>
)}
@@ -223,12 +254,14 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
onClose={onClose}
node={node}
isCompany={isCompany}
colorScheme={colorScheme}
colorScheme={colorConfig.badge}
relatedCompanies={relatedCompanies}
loadingRelated={loadingRelated}
/>
</>
);
};
});
ValueChainNodeCard.displayName = 'ValueChainNodeCard';
export default ValueChainNodeCard;

View File

@@ -174,6 +174,7 @@ export interface AnalysisSummary {
upstream_nodes?: number;
company_nodes?: number;
downstream_nodes?: number;
total_nodes?: number;
}
export interface ValueChainData {