update pay function

This commit is contained in:
2025-11-20 12:55:28 +08:00
parent 082e644534
commit 80676dd622
11 changed files with 1395 additions and 2166 deletions

View File

@@ -102,6 +102,17 @@ export const homeRoutes = [
}
},
// 数据浏览器 - /home/data-browser
{
path: 'data-browser',
component: lazyComponents.DataBrowser,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '数据浏览器',
description: '化工商品数据分类树浏览器'
}
},
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',

View File

@@ -42,6 +42,9 @@ export const lazyComponents = {
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
// 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
};
/**
@@ -69,4 +72,5 @@ export const {
AgentChat,
ValueForum,
ForumPostDetail,
DataBrowser,
} = lazyComponents;

View File

@@ -0,0 +1,192 @@
/**
* 商品分类树数据服务
* 对接化工商品数据分类树API
* API文档: category_tree_openapi.json
*/
import { getApiBase } from '@utils/apiConfig';
// 类型定义
export interface TreeMetric {
metric_id: string;
metric_name: string;
source: 'SMM' | 'Mysteel';
frequency: string;
unit: string;
description?: string;
}
export interface TreeNode {
name: string;
path: string;
level: number;
children?: TreeNode[];
metrics?: TreeMetric[];
}
export interface CategoryTreeResponse {
source: 'SMM' | 'Mysteel';
total_metrics: number;
tree: TreeNode[];
}
export interface ErrorResponse {
detail: string;
}
/**
* 获取完整分类树
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @returns 完整的分类树数据
*/
export const fetchCategoryTree = async (
source: 'SMM' | 'Mysteel'
): Promise<CategoryTreeResponse> => {
try {
const response = await fetch(`/category-api/api/category-tree?source=${source}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: CategoryTreeResponse = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryTree error:', error);
throw error;
}
};
/**
* 获取特定节点及其子树
* @param path 节点完整路径(用 | 分隔)
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @returns 节点数据及其子树
*/
export const fetchCategoryNode = async (
path: string,
source: 'SMM' | 'Mysteel'
): Promise<TreeNode> => {
try {
const encodedPath = encodeURIComponent(path);
const response = await fetch(
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: TreeNode = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryNode error:', error);
throw error;
}
};
/**
* 搜索指标
* @param query 搜索关键词
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @returns 匹配的指标列表
*/
export const searchMetrics = async (
query: string,
source: 'SMM' | 'Mysteel'
): Promise<TreeMetric[]> => {
try {
// 注意:这个接口可能需要后端额外实现
// 如果后端没有提供搜索接口,可以在前端基于完整树进行过滤
const response = await fetch(
`/category-api/api/metrics/search?query=${encodeURIComponent(query)}&source=${source}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data: TreeMetric[] = await response.json();
return data;
} catch (error) {
console.error('searchMetrics error:', error);
throw error;
}
};
/**
* 从树中提取所有指标(用于前端搜索)
* @param nodes 树节点数组
* @returns 所有指标的扁平化数组
*/
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
const metrics: TreeMetric[] = [];
const traverse = (node: TreeNode) => {
if (node.metrics && node.metrics.length > 0) {
metrics.push(...node.metrics);
}
if (node.children && node.children.length > 0) {
node.children.forEach(traverse);
}
};
nodes.forEach(traverse);
return metrics;
};
/**
* 在树中查找节点
* @param nodes 树节点数组
* @param path 节点路径
* @returns 找到的节点或 null
*/
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) {
return node;
}
if (node.children) {
const found = findNodeByPath(node.children, path);
if (found) {
return found;
}
}
}
return null;
};
/**
* 获取节点的所有父节点路径
* @param path 节点路径(用 | 分隔)
* @returns 父节点路径数组
*/
export const getParentPaths = (path: string): string[] => {
const parts = path.split('|');
const parentPaths: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentPaths.push(parts.slice(0, i).join('|'));
}
return parentPaths;
};

View File

@@ -0,0 +1,670 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Container,
Flex,
Text,
Input,
Button,
VStack,
HStack,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Icon,
Spinner,
useToast,
Card,
CardBody,
Divider,
SimpleGrid,
Collapse,
} from '@chakra-ui/react';
import {
FaDatabase,
FaFolder,
FaFolderOpen,
FaFile,
FaSearch,
FaHome,
FaChevronRight,
FaChevronDown,
FaChevronUp,
FaFilter,
FaTimes,
} from 'react-icons/fa';
import { motion } from 'framer-motion';
import { fetchCategoryTree, fetchCategoryNode } from '@services/categoryService';
// 黑金主题配色
const themeColors = {
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
goldDark: '#B8941F',
},
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
cardHover: '#252525',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
};
const MotionBox = motion(Box);
const MotionCard = motion(Card);
interface TreeNode {
name: string;
path: string;
level: number;
children?: TreeNode[];
metrics?: TreeMetric[];
}
interface TreeMetric {
metric_id: string;
metric_name: string;
source: string;
frequency: string;
unit: string;
description?: string;
}
interface CategoryTreeResponse {
source: string;
total_metrics: number;
tree: TreeNode[];
}
// 树节点组件
const TreeNodeComponent: React.FC<{
node: TreeNode;
onNodeClick: (node: TreeNode) => void;
expandedNodes: Set<string>;
onToggleExpand: (path: string) => void;
searchQuery: string;
}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => {
const isExpanded = expandedNodes.has(node.path);
const hasChildren = node.children && node.children.length > 0;
const hasMetrics = node.metrics && node.metrics.length > 0;
// 高亮搜索关键词
const highlightText = (text: string) => {
if (!searchQuery) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<Text as="span" key={index} color={themeColors.primary.gold} fontWeight="bold">
{part}
</Text>
) : (
part
)
);
};
return (
<Box>
<Flex
align="center"
p={2}
pl={node.level * 4}
cursor="pointer"
bg={isExpanded ? themeColors.bg.cardHover : 'transparent'}
_hover={{ bg: themeColors.bg.cardHover }}
borderRadius="md"
transition="all 0.2s"
onClick={() => {
if (hasChildren) {
onToggleExpand(node.path);
}
onNodeClick(node);
}}
>
{hasChildren ? (
<Icon
as={isExpanded ? FaChevronDown : FaChevronRight}
color={themeColors.text.muted}
mr={2}
fontSize="xs"
/>
) : (
<Box w="16px" mr={2} />
)}
<Icon
as={hasChildren ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
color={hasChildren ? themeColors.primary.gold : themeColors.text.secondary}
mr={2}
/>
<Text color={themeColors.text.primary} fontSize="sm">
{highlightText(node.name)}
</Text>
{hasMetrics && (
<Badge
ml={2}
bg={themeColors.border.gold}
color={themeColors.primary.gold}
fontSize="xs"
>
{node.metrics.length}
</Badge>
)}
</Flex>
{isExpanded && hasChildren && (
<Box>
{node.children!.map((child) => (
<TreeNodeComponent
key={child.path}
node={child}
onNodeClick={onNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={onToggleExpand}
searchQuery={searchQuery}
/>
))}
</Box>
)}
</Box>
);
};
// 指标卡片组件
const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
return (
<MotionCard
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.default}
borderRadius="lg"
overflow="hidden"
whileHover={{
borderColor: themeColors.border.goldGlow,
scale: 1.02,
}}
transition={{ duration: 0.2 }}
>
<CardBody>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm">
{metric.metric_name}
</Text>
<Badge
bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'}
color="white"
fontSize="xs"
>
{metric.source}
</Badge>
</HStack>
<Divider borderColor={themeColors.border.default} />
<SimpleGrid columns={2} spacing={2}>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.frequency}
</Text>
</Box>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.unit || '-'}
</Text>
</Box>
</SimpleGrid>
{metric.description && (
<Text color={themeColors.text.muted} fontSize="xs" noOfLines={2}>
{metric.description}
</Text>
)}
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
ID: {metric.metric_id}
</Text>
</VStack>
</CardBody>
</MotionCard>
);
};
const DataBrowser: React.FC = () => {
const [selectedSource, setSelectedSource] = useState<'SMM' | 'Mysteel'>('SMM');
const [treeData, setTreeData] = useState<CategoryTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showFilters, setShowFilters] = useState(false);
const toast = useToast();
// 加载分类树
useEffect(() => {
loadCategoryTree();
}, [selectedSource]);
const loadCategoryTree = async () => {
setLoading(true);
try {
const data = await fetchCategoryTree(selectedSource);
setTreeData(data);
setCurrentNode(null);
setBreadcrumbs([]);
} catch (error) {
toast({
title: '加载失败',
description: '无法加载分类树数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
};
// 切换节点展开状态
const toggleNodeExpand = (path: string) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return newSet;
});
};
// 处理节点点击
const handleNodeClick = (node: TreeNode) => {
setCurrentNode(node);
const pathParts = node.path.split('|');
setBreadcrumbs(pathParts);
};
// 处理面包屑导航
const handleBreadcrumbClick = (index: number) => {
if (index === -1) {
setCurrentNode(null);
setBreadcrumbs([]);
return;
}
const targetPath = breadcrumbs.slice(0, index + 1).join('|');
// 在树中查找对应节点
const findNode = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) return node;
if (node.children) {
const found = findNode(node.children, path);
if (found) return found;
}
}
return null;
};
if (treeData) {
const node = findNode(treeData.tree, targetPath);
if (node) {
handleNodeClick(node);
}
}
};
// 过滤树节点(根据搜索关键词)
const filteredTree = useMemo(() => {
if (!treeData || !searchQuery) return treeData?.tree || [];
const filterNodes = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.map((node) => {
const matchesName = node.name.toLowerCase().includes(searchQuery.toLowerCase());
const filteredChildren = node.children ? filterNodes(node.children) : [];
if (matchesName || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
return null;
})
.filter(Boolean) as TreeNode[];
};
return filterNodes(treeData.tree);
}, [treeData, searchQuery]);
return (
<Box
minH="100vh"
bg={themeColors.bg.primary}
bgGradient={themeColors.bgGradient}
position="relative"
pt={{ base: '120px', md: '75px' }}
>
{/* 金色光晕背景 */}
<Box
position="absolute"
top="0"
left="50%"
transform="translateX(-50%)"
width="100%"
height="400px"
bgGradient={themeColors.bgRadialGold}
opacity={0.3}
pointerEvents="none"
/>
<Container maxW="container.xl" position="relative" zIndex={1}>
{/* 标题区域 */}
<MotionBox
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<VStack spacing={4} align="stretch" mb={8}>
<HStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={8} />
<VStack align="start" spacing={0}>
<Text
fontSize="3xl"
fontWeight="bold"
color={themeColors.text.primary}
textShadow={`0 0 20px ${themeColors.primary.gold}40`}
>
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
-
</Text>
</VStack>
</HStack>
{/* 数据源切换 */}
<HStack spacing={4}>
<Button
size="sm"
bg={selectedSource === 'SMM' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'SMM' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('SMM')}
>
SMM {treeData && selectedSource === 'SMM' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
<Button
size="sm"
bg={selectedSource === 'Mysteel' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'Mysteel' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('Mysteel')}
>
Mysteel {treeData && selectedSource === 'Mysteel' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
</HStack>
</VStack>
</MotionBox>
{/* 搜索和过滤 */}
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
mb={6}
>
<CardBody>
<HStack spacing={4}>
<Input
placeholder="搜索分类或指标名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg={themeColors.bg.secondary}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_placeholder={{ color: themeColors.text.muted }}
_focus={{
borderColor: themeColors.primary.gold,
boxShadow: `0 0 0 1px ${themeColors.primary.gold}`,
}}
/>
<Button
leftIcon={<FaSearch />}
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
_hover={{ bg: themeColors.primary.goldLight }}
>
</Button>
{searchQuery && (
<Button
leftIcon={<FaTimes />}
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.text.primary }}
onClick={() => setSearchQuery('')}
>
</Button>
)}
</HStack>
</CardBody>
</Card>
</MotionBox>
{/* 面包屑导航 */}
{breadcrumbs.length > 0 && (
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
mb={4}
>
<Card bg={themeColors.bg.card} borderWidth="1px" borderColor={themeColors.border.default}>
<CardBody py={2}>
<Breadcrumb
spacing={2}
separator={<Icon as={FaChevronRight} color={themeColors.text.muted} />}
>
<BreadcrumbItem>
<BreadcrumbLink
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(-1)}
>
<Icon as={FaHome} />
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.length - 1}>
<BreadcrumbLink
color={index === breadcrumbs.length - 1 ? themeColors.primary.gold : themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(index)}
>
{crumb}
</BreadcrumbLink>
</BreadcrumbItem>
))}
</Breadcrumb>
</CardBody>
</Card>
</MotionBox>
)}
{/* 主内容区域 */}
<Flex gap={6} direction={{ base: 'column', lg: 'row' }}>
{/* 左侧:分类树 */}
<MotionBox
flex={{ base: '1', lg: '0 0 400px' }}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
maxH="calc(100vh - 400px)"
overflowY="auto"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: themeColors.bg.secondary,
},
'&::-webkit-scrollbar-thumb': {
background: themeColors.primary.gold,
borderRadius: '4px',
},
}}
>
<CardBody>
{loading ? (
<Flex justify="center" align="center" py={10}>
<Spinner color={themeColors.primary.gold} size="xl" />
</Flex>
) : (
<VStack align="stretch" spacing={1}>
{filteredTree.map((node) => (
<TreeNodeComponent
key={node.path}
node={node}
onNodeClick={handleNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={toggleNodeExpand}
searchQuery={searchQuery}
/>
))}
</VStack>
)}
</CardBody>
</Card>
</MotionBox>
{/* 右侧:指标详情 */}
<MotionBox
flex="1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
minH="400px"
>
<CardBody>
{currentNode ? (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text color={themeColors.text.primary} fontSize="2xl" fontWeight="bold">
{currentNode.name}
</Text>
<Text color={themeColors.text.muted} fontSize="sm">
{currentNode.level} | : {currentNode.path}
</Text>
</VStack>
{currentNode.metrics && currentNode.metrics.length > 0 && (
<Badge
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
fontSize="md"
px={3}
py={1}
>
{currentNode.metrics.length}
</Badge>
)}
</HStack>
<Divider borderColor={themeColors.border.gold} />
{currentNode.metrics && currentNode.metrics.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
{currentNode.metrics.map((metric) => (
<MetricCard key={metric.metric_id} metric={metric} />
))}
</SimpleGrid>
) : (
<Flex justify="center" align="center" py={10}>
<VStack spacing={3}>
<Icon as={FaFolder} color={themeColors.text.muted} boxSize={12} />
<Text color={themeColors.text.muted}>
{currentNode.children && currentNode.children.length > 0
? '该节点包含子分类,请展开查看'
: '该节点暂无指标数据'}
</Text>
</VStack>
</Flex>
)}
</VStack>
) : (
<Flex justify="center" align="center" py={20}>
<VStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={16} />
<Text color={themeColors.text.secondary} fontSize="lg" textAlign="center">
</Text>
{treeData && (
<Text color={themeColors.text.muted} fontSize="sm">
{treeData.total_metrics.toLocaleString()}
</Text>
)}
</VStack>
</Flex>
)}
</CardBody>
</Card>
</MotionBox>
</Flex>
</Container>
</Box>
);
};
export default DataBrowser;