import { useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { BarChart3, ChevronDown, ChevronUp, Plus, Save, Trash2 } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api' import { QK } from '@/lib/queryKeys' function dtypeToColumnType(dtype: string): AnalysisColumn['type'] { return dtype === 'int' || dtype === 'float' ? 'number' : 'string' } function buildColumn(field: ExtDataField): AnalysisColumn { return { field: field.name, label: field.label || field.name, type: dtypeToColumnType(field.dtype), precision: field.dtype === 'float' ? 2 : null, sortable: field.dtype === 'int' || field.dtype === 'float', visible: true, } } function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) { if (!config) return '' for (const keyword of keywords) { const lower = keyword.toLowerCase() const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower)) if (matched) return matched.name } return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? '' } export function Analysis() { const qc = useQueryClient() const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus }) const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const configs = extData.data?.items ?? [] const [showCreate, setShowCreate] = useState(false) const [id, setId] = useState('') const [label, setLabel] = useState('') const [dataSource, setDataSource] = useState('') const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank') const [dimensionField, setDimensionField] = useState('') const [rankField, setRankField] = useState('') const [selectedColumns, setSelectedColumns] = useState([]) const [error, setError] = useState('') const menuItems = menus.data?.items ?? [] const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0] const fields = activeConfig?.fields ?? [] const resetForm = () => { const cfg = configs[0] setId('') setLabel('') setDataSource(cfg?.id ?? '') setTemplate('dimension_rank') setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector'])) setRankField('') setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? []) setError('') } const save = useMutation({ mutationFn: () => { const cfg = activeConfig if (!cfg) throw new Error('请选择扩展数据源') if (!id.trim()) throw new Error('请输入菜单标识') if (!label.trim()) throw new Error('请输入菜单名称') if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段') if (template === 'ranking' && !rankField) throw new Error('请选择排名字段') const detailColumns = selectedColumns .map(name => cfg.fields.find(f => f.name === name)) .filter(Boolean) .map(f => buildColumn(f as ExtDataField)) const groupColumns: AnalysisColumn[] = template === 'dimension_rank' ? [ { field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true }, { field: '__count', label: '股票数', type: 'number', sortable: true, visible: true }, ...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })), ] : [] return api.analysisMenuSave(id.trim(), { label: label.trim(), icon: template === 'dimension_rank' ? 'tags' : 'chart', data_source: cfg.id, template, dimension_field: template === 'dimension_rank' ? dimensionField : null, rank_field: template === 'ranking' ? rankField : null, group_columns: groupColumns, detail_columns: detailColumns, default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null, visible: true, order: menuItems.length + 100, }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: QK.analysisMenus }) setShowCreate(false) resetForm() }, onError: (err) => setError(String((err as any)?.message ?? err)), }) const del = useMutation({ mutationFn: api.analysisMenuDelete, onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }), }) const reorder = useMutation({ mutationFn: api.analysisMenuReorder, onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }), }) const moveMenu = (idx: number, dir: -1 | 1) => { const next = [...menuItems] const target = idx + dir if (target < 0 || target >= next.length) return const [item] = next.splice(idx, 1) next.splice(target, 0, item) reorder.mutate(next.map(m => m.id)) } const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields]) return ( <> { resetForm(); setShowCreate(v => !v) }} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors" > 新建菜单 } />
扩展数据 → 分析菜单 → 动态页面

把任意扩展字段配置成一个菜单

菜单配置决定使用哪个扩展数据源、哪个字段分组、列表展示哪些列。侧边栏会自动显示可见菜单,列表列严格按配置渲染。

{showCreate && (
列表列配置
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => { const active = selectedColumns.includes(f.name) return ( ) })}
{error &&
{error}
}
)}
{menuItems.map((menu, idx) => (

{menu.label}

{menu.id}

{menu.builtin ? ( 默认 ) : ( )}
数据源:{menu.data_source}
模板:{menu.template}
{menu.dimension_field &&
分组字段:{menu.dimension_field}
}
列表列:{menu.detail_columns.length} 个
打开分析页
))} {menuItems.length === 0 && (
暂无分析菜单,点击右上角新建。
)}
) }