diff --git a/category_tree_openapi.json b/category_tree_openapi.json index 47dd9f66..e94d89fb 100644 --- a/category_tree_openapi.json +++ b/category_tree_openapi.json @@ -19,12 +19,170 @@ } ], "tags": [ + { + "name": "搜索", + "description": "指标搜索相关接口" + }, { "name": "分类树", "description": "分类树状结构相关接口" + }, + { + "name": "数据查询", + "description": "指标时间序列数据查询接口" } ], "paths": { + "/api/search": { + "get": { + "tags": [ + "搜索" + ], + "summary": "搜索化工商品指标", + "description": "基于Elasticsearch的多关键词模糊搜索,支持智能分词和相关度排序。\n\n## 功能特点\n- **多关键词搜索**: 支持空格分隔多个关键词,自动AND逻辑组合\n- **模糊匹配**: 自动容错1-2个字符的拼写错误\n- **多字段匹配**: 同时搜索指标名称、分类路径等多个字段\n- **相关度排序**: 自动按匹配度评分排序,最相关的结果排在前面\n- **灵活过滤**: 支持按数据源(SMM/Mysteel)和频率(日/周/月)过滤\n\n## 搜索字段权重\n- 指标名称(metric_name): 权重最高 (3x)\n- 分类层级(category_levels): 权重中等 (2x)\n- 分类路径(category_path): 权重中等 (2x)\n\n## 使用场景\n- 用户输入关键词快速查找指标\n- 自动补全和搜索建议\n- 按类别和数据源筛选指标\n\n## 搜索示例\n- 搜索\"电解液 产量\": 查找包含\"电解液\"和\"产量\"的指标\n- 搜索\"硫酸钴\": 查找所有硫酸钴相关指标\n- 搜索\"焦炭 价格 日\": 查找焦炭日度价格数据\n", + "operationId": "searchMetrics", + "parameters": [ + { + "name": "keywords", + "in": "query", + "description": "搜索关键词,支持空格分隔多个词。\n\n示例:\n- \"电解液 产量\" - 查找同时包含这两个词的指标\n- \"硫酸钴\" - 查找硫酸钴相关指标\n- \"焦炭 价格\" - 查找焦炭价格数据\n", + "required": true, + "schema": { + "type": "string" + }, + "example": "电解液 产量" + }, + { + "name": "source", + "in": "query", + "description": "数据源过滤(可选)。\n\n- SMM: 上海有色网数据\n- Mysteel: 我的钢铁网数据\n- 不指定: 搜索所有数据源\n", + "required": false, + "schema": { + "type": "string", + "enum": [ + "SMM", + "Mysteel" + ] + }, + "example": "SMM" + }, + { + "name": "frequency", + "in": "query", + "description": "数据频率过滤(可选)。\n\n- 日: 日度数据\n- 周: 周度数据\n- 月: 月度数据\n- 不指定: 搜索所有频率\n", + "required": false, + "schema": { + "type": "string", + "enum": [ + "日", + "周", + "月" + ] + }, + "example": "日" + }, + { + "name": "size", + "in": "query", + "description": "返回结果数量限制", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + }, + "example": 10 + } + ], + "responses": { + "200": { + "description": "成功返回搜索结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + }, + "examples": { + "基础搜索示例": { + "value": { + "total": 50, + "query": "电解液 产量", + "results": [ + { + "source": "SMM", + "metric_id": "12345", + "metric_name": "SMM中国电解液月度产量", + "unit": "吨", + "frequency": "月", + "category_path": "新能源|电解液|产量|SMM中国电解液月度产量", + "description": "", + "score": 15.8 + }, + { + "source": "SMM", + "metric_id": "12346", + "metric_name": "SMM中国电解液周度产量", + "unit": "吨", + "frequency": "周", + "category_path": "新能源|电解液|产量|SMM中国电解液周度产量", + "description": "", + "score": 14.2 + } + ] + } + }, + "过滤搜索示例": { + "value": { + "total": 15, + "query": "硫酸钴", + "results": [ + { + "source": "SMM", + "metric_id": "23456", + "metric_name": "SMM中国硫酸钴月度产量", + "unit": "吨", + "frequency": "月", + "category_path": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量", + "description": "", + "score": 18.5 + } + ] + } + } + } + } + } + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "detail": "keywords参数不能为空" + } + } + } + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "detail": "搜索服务暂时不可用" + } + } + } + } + } + } + }, "/api/category-tree": { "get": { "tags": [ @@ -239,10 +397,254 @@ } } } + }, + "/api/metric-data": { + "get": { + "tags": [ + "数据查询" + ], + "summary": "获取指标时间序列数据", + "description": "根据指标ID查询历史时间序列数据,自动识别数据源(SMM或Mysteel)。\n\n## 功能特点\n- **自动识别数据源**: 无需指定source参数,系统自动查找\n- **灵活的日期范围**: 支持可选的开始/结束日期过滤\n- **数据限制**: 支持limit参数控制返回数据量\n\n## 日期格式支持\n- YYYY-MM-DD (推荐): \"2024-01-01\"\n- YYYYMMDD: \"20240101\"\n- YYYYMMDDHHmmss: \"20240101000000\"(只取日期部分)\n\n## 使用场景\n- 用户点击树节点查看指标数据\n- 图表展示时间序列数据\n- 数据导出和分析\n", + "operationId": "getMetricData", + "parameters": [ + { + "name": "metric_id", + "in": "query", + "description": "指标唯一ID", + "required": true, + "schema": { + "type": "string" + }, + "example": "12345" + }, + { + "name": "start_date", + "in": "query", + "description": "开始日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD", + "required": false, + "schema": { + "type": "string" + }, + "example": "2024-01-01" + }, + { + "name": "end_date", + "in": "query", + "description": "结束日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD", + "required": false, + "schema": { + "type": "string" + }, + "example": "2024-12-31" + }, + { + "name": "limit", + "in": "query", + "description": "返回数据条数限制(1-10000)", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 100 + }, + "example": 100 + } + ], + "responses": { + "200": { + "description": "成功返回指标数据", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricDataResponse" + }, + "examples": { + "SMM数据示例": { + "value": { + "metric_id": "12345", + "metric_name": "SMM中国硫酸钴月度产量", + "source": "SMM", + "frequency": "月", + "unit": "吨", + "data": [ + { + "date": "2024-12-01", + "value": 12500.5 + }, + { + "date": "2024-11-01", + "value": 12300.0 + }, + { + "date": "2024-10-01", + "value": 12100.8 + } + ], + "total_count": 120 + } + }, + "Mysteel数据示例": { + "value": { + "metric_id": "A0101010", + "metric_name": "唐山焦炭价格", + "source": "MYSTEEL", + "frequency": "日", + "unit": "元/吨", + "data": [ + { + "date": "2024-12-20", + "value": 2350.0 + }, + { + "date": "2024-12-19", + "value": 2340.0 + } + ], + "total_count": 365 + } + } + } + } + } + }, + "404": { + "description": "未找到指定指标", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "detail": "未找到指标: metric_id=99999" + } + } + } + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "detail": "limit参数必须在1-10000之间" + } + } + } + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "detail": "查询数据失败: [具体错误信息]" + } + } + } + } + } + } } }, "components": { "schemas": { + "SearchResponse": { + "type": "object", + "description": "搜索结果响应对象", + "required": [ + "total", + "query", + "results" + ], + "properties": { + "total": { + "type": "integer", + "description": "搜索结果总数", + "example": 50 + }, + "query": { + "type": "string", + "description": "查询关键词", + "example": "电解液 产量" + }, + "results": { + "type": "array", + "description": "指标列表(按相关度评分降序)", + "items": { + "$ref": "#/components/schemas/MetricInfo" + } + } + } + }, + "MetricInfo": { + "type": "object", + "description": "指标信息对象", + "required": [ + "source", + "metric_id", + "metric_name", + "unit", + "frequency", + "category_path" + ], + "properties": { + "source": { + "type": "string", + "description": "数据源", + "enum": [ + "SMM", + "Mysteel" + ], + "example": "SMM" + }, + "metric_id": { + "type": "string", + "description": "指标唯一ID", + "example": "12345" + }, + "metric_name": { + "type": "string", + "description": "指标名称", + "example": "SMM中国硫酸钴月度产量" + }, + "unit": { + "type": "string", + "description": "数据单位", + "example": "吨" + }, + "frequency": { + "type": "string", + "description": "数据频率", + "enum": [ + "日", + "周", + "月" + ], + "example": "月" + }, + "category_path": { + "type": "string", + "description": "完整分类路径(用|分隔)", + "example": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量" + }, + "description": { + "type": "string", + "description": "指标描述备注", + "example": "" + }, + "score": { + "type": "number", + "description": "搜索相关度评分(仅搜索结果返回)", + "nullable": true, + "example": 15.8 + } + } + }, "CategoryTreeResponse": { "type": "object", "description": "分类树响应对象", @@ -374,6 +776,88 @@ } } }, + "MetricDataResponse": { + "type": "object", + "description": "指标数据查询响应对象", + "required": [ + "metric_id", + "metric_name", + "source", + "frequency", + "unit", + "data", + "total_count" + ], + "properties": { + "metric_id": { + "type": "string", + "description": "指标唯一ID", + "example": "12345" + }, + "metric_name": { + "type": "string", + "description": "指标名称", + "example": "SMM中国硫酸钴月度产量" + }, + "source": { + "type": "string", + "description": "数据源", + "enum": [ + "SMM", + "MYSTEEL" + ], + "example": "SMM" + }, + "frequency": { + "type": "string", + "description": "数据频率", + "enum": [ + "日", + "周", + "月" + ], + "example": "月" + }, + "unit": { + "type": "string", + "description": "数据单位", + "example": "吨" + }, + "data": { + "type": "array", + "description": "时间序列数据点列表(按日期倒序)", + "items": { + "$ref": "#/components/schemas/DataPoint" + } + }, + "total_count": { + "type": "integer", + "description": "符合条件的数据总条数", + "example": 120 + } + } + }, + "DataPoint": { + "type": "object", + "description": "单个数据点", + "required": [ + "date", + "value" + ], + "properties": { + "date": { + "type": "string", + "description": "日期,格式 YYYY-MM-DD", + "example": "2024-01-01" + }, + "value": { + "type": "number", + "description": "数值(可能为null)", + "nullable": true, + "example": 1234.56 + } + } + }, "ErrorResponse": { "type": "object", "description": "错误响应对象", diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index c4c5596d..e146dee8 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -118,34 +118,59 @@ export const fetchCategoryNode = async ( } }; +export interface MetricSearchResult { + source: string; + metric_id: string; + metric_name: string; + unit: string; + frequency: string; + category_path: string; + description?: string; + score?: number; +} + +export interface SearchResponse { + total: number; + results: MetricSearchResult[]; + query: string; +} + /** * 搜索指标 - * @param query 搜索关键词 - * @param source 数据源类型 ('SMM' | 'Mysteel') - * @returns 匹配的指标列表 + * @param keywords 搜索关键词(支持空格分隔多个词) + * @param source 数据源过滤(可选) + * @param frequency 频率过滤(可选) + * @param size 返回结果数量(默认100) + * @returns 搜索结果 */ export const searchMetrics = async ( - query: string, - source: 'SMM' | 'Mysteel' -): Promise => { + keywords: string, + source?: 'SMM' | 'Mysteel', + frequency?: string, + size: number = 100 +): Promise => { try { - // 注意:这个接口可能需要后端额外实现 - // 如果后端没有提供搜索接口,可以在前端基于完整树进行过滤 - const response = await fetch( - `/category-api/api/metrics/search?query=${encodeURIComponent(query)}&source=${source}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ); + const params = new URLSearchParams({ + keywords, + size: size.toString(), + }); + + if (source) params.append('source', source); + if (frequency) params.append('frequency', frequency); + + const response = await fetch(`/category-api/api/search?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const errorData: ErrorResponse = await response.json(); + throw new Error(errorData.detail || `HTTP ${response.status}`); } - const data: TreeMetric[] = await response.json(); + const data: SearchResponse = await response.json(); return data; } catch (error) { console.error('searchMetrics error:', error); diff --git a/src/views/DataBrowser/index.tsx b/src/views/DataBrowser/index.tsx index e432f547..76de6b46 100644 --- a/src/views/DataBrowser/index.tsx +++ b/src/views/DataBrowser/index.tsx @@ -34,7 +34,16 @@ import { FaEye, } from 'react-icons/fa'; import { motion } from 'framer-motion'; -import { fetchCategoryTree, fetchCategoryNode, TreeNode, TreeMetric, CategoryTreeResponse } from '@services/categoryService'; +import { + fetchCategoryTree, + fetchCategoryNode, + searchMetrics, + TreeNode, + TreeMetric, + CategoryTreeResponse, + MetricSearchResult, + SearchResponse +} from '@services/categoryService'; import MetricDataModal from './MetricDataModal'; // 黑金主题配色 @@ -253,6 +262,8 @@ const DataBrowser: React.FC = () => { const [treeData, setTreeData] = useState(null); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); const [currentNode, setCurrentNode] = useState(null); const [breadcrumbs, setBreadcrumbs] = useState([]); const [expandedNodes, setExpandedNodes] = useState>(new Set()); @@ -275,6 +286,7 @@ const DataBrowser: React.FC = () => { setCurrentNode(null); setBreadcrumbs([]); setExpandedNodes(new Set()); + setSearchResults(null); // 清空搜索结果 } catch (error) { toast({ title: '加载失败', @@ -288,6 +300,43 @@ const DataBrowser: React.FC = () => { } }; + // 执行搜索 + const handleSearch = async () => { + if (!searchQuery.trim()) { + setSearchResults(null); + return; + } + + setSearching(true); + try { + const results = await searchMetrics(searchQuery, selectedSource, undefined, 100); + setSearchResults(results); + } catch (error) { + toast({ + title: '搜索失败', + description: '无法搜索指标数据', + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setSearching(false); + } + }; + + // 当搜索关键词变化时,自动搜索 + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery.trim()) { + handleSearch(); + } else { + setSearchResults(null); + } + }, 500); // 防抖 500ms + + return () => clearTimeout(timer); + }, [searchQuery, selectedSource]); + // 切换节点展开状态(懒加载子节点) const toggleNodeExpand = async (node: TreeNode) => { const isCurrentlyExpanded = expandedNodes.has(node.path); @@ -401,28 +450,12 @@ const DataBrowser: React.FC = () => { onOpen(); }; - // 过滤树节点(根据搜索关键词) - 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); + // 显示的树节点(搜索时不显示树) + const displayTree = useMemo(() => { + if (searchQuery.trim()) { + return []; // 搜索时不显示树 + } + return treeData?.tree || []; }, [treeData, searchQuery]); return ( @@ -518,40 +551,64 @@ const DataBrowser: React.FC = () => { mb={6} > - - 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}`, - }} - /> - - {searchQuery && ( + + + 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}`, + }} + /> + {searchQuery && ( + + )} + + + {/* 搜索结果提示 */} + {searchResults && ( + + + 找到 {searchResults.total} 个相关指标 + + + 关键词: "{searchResults.query}" + + )} - + {searching && ( + + + + 搜索中... + + + )} + @@ -628,9 +685,77 @@ const DataBrowser: React.FC = () => { - ) : ( + ) : searchQuery.trim() ? ( + // 搜索模式:显示搜索结果列表 - {filteredTree.map((node) => ( + {searchResults && searchResults.results.length > 0 ? ( + searchResults.results.map((result) => ( + { + // 转换搜索结果为 TreeMetric 格式 + const metric: TreeMetric = { + metric_id: result.metric_id, + metric_name: result.metric_name, + source: result.source, + frequency: result.frequency, + unit: result.unit, + description: result.description, + }; + handleMetricClick(metric); + }} + > + + + + {result.metric_name} + + + {result.source} + + + + 路径: {result.category_path} + 频率: {result.frequency} + 单位: {result.unit || '-'} + + {result.score && ( + + 相关度: {(result.score * 100).toFixed(0)}% + + )} + + + )) + ) : searchResults ? ( + + + + 未找到匹配的指标 + + 尝试使用不同的关键词 + + + + ) : null} + + ) : ( + // 正常模式:显示分类树 + + {displayTree.map((node) => ( { onNodeClick={handleNodeClick} expandedNodes={expandedNodes} onToggleExpand={toggleNodeExpand} - searchQuery={searchQuery} + searchQuery="" loadingNodes={loadingNodes} /> ))}