import { useMemo, useState, type ComponentType } from 'react' import { useQuery } from '@tanstack/react-query' import { BarChart3, CalendarDays, Database, Layers3, Search, Tags, TrendingUp, Users, } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api' import { QK } from '@/lib/queryKeys' interface DimensionAnalysisProps { menuId?: string title?: string subtitle?: string kindLabel?: string emptyHint?: string accentClass?: string keywords?: string[] fallbackFieldNames?: string[] } interface DimensionGroup { key: string count: number rows: Record[] metrics: Record } const PAGE_LIMIT = 5000 const SEPARATORS = /[、,,;;|/\s]+/ function normalizeText(v: unknown): string { if (v == null) return '' return String(v).trim() } function fieldLabel(fields: ExtDataField[], name: string): string { return fields.find(f => f.name === name)?.label || name } function isNumericField(f: ExtDataField) { return f.dtype === 'int' || f.dtype === 'float' } function scoreConfig(config: ExtDataConfig, keywords: string[]) { const haystack = [ config.id, config.label, config.description ?? '', ...config.fields.flatMap(f => [f.name, f.label]), ].join(' ').toLowerCase() return keywords.reduce((n, k) => n + (haystack.includes(k.toLowerCase()) ? 1 : 0), 0) } function pickDefaultConfig(configs: ExtDataConfig[], keywords: string[]) { const ranked = [...configs].sort((a, b) => scoreConfig(b, keywords) - scoreConfig(a, keywords)) return ranked[0]?.id ?? '' } function pickDefaultDimensionField(config: ExtDataConfig | undefined, fallbackFieldNames: string[]) { if (!config) return '' const fields = config.fields.filter(f => f.name !== 'symbol' && f.name !== 'code') for (const name of fallbackFieldNames) { const matched = fields.find(f => f.name.toLowerCase().includes(name) || f.label.toLowerCase().includes(name)) if (matched) return matched.name } return fields.find(f => !isNumericField(f))?.name ?? fields[0]?.name ?? '' } function formatNumber(v: unknown, digits = 2) { if (typeof v !== 'number' || !Number.isFinite(v)) return '—' return v.toLocaleString('zh-CN', { maximumFractionDigits: digits }) } function formatValue(v: unknown, col?: AnalysisColumn) { if (v == null || v === '') return '—' if (typeof v === 'boolean') return v ? '是' : '否' if (typeof v !== 'number') return String(v) const digits = col?.precision ?? (col?.type === 'number' || col?.type === 'amount' || col?.type === 'percent' ? 2 : 2) if (col?.type === 'percent') return `${formatNumber(v, digits)}%` if (col?.type === 'amount' || col?.format === 'amount') return formatNumber(v, digits) return formatNumber(v, digits) } function splitDimensionValues(value: unknown) { const text = normalizeText(value) if (!text) return [] return text.split(SEPARATORS).map(s => s.trim()).filter(Boolean) } function StatCard({ label, value, hint, icon: Icon }: { label: string value: string | number hint: string icon: ComponentType<{ className?: string }> }) { return (
{label}
{value}
{hint}
) } function columnsFromFields(fields: ExtDataField[], dimensionField: string, rows: Record[]) { const numericFields = fields.filter(f => isNumericField(f) && rows.some(r => typeof r[f.name] === 'number')).slice(0, 4) return [ ...fields.filter(f => ['symbol', 'code', 'name', '股票简称', '股票代码'].includes(f.name)), ...fields.filter(f => f.name === dimensionField), ...numericFields, ] .filter((f, i, arr) => arr.findIndex(x => x.name === f.name) === i) .map(f => ({ field: f.name, label: f.label || f.name, type: isNumericField(f) ? 'number' : 'string', precision: f.dtype === 'float' ? 2 : null, sortable: isNumericField(f), visible: true, })) } function aggregate(rows: Record[], col: AnalysisColumn) { if (col.field === '__count') return rows.length const values = rows.map(r => r[col.field]).filter((v): v is number => typeof v === 'number' && Number.isFinite(v)) if (!values.length) return null switch (col.aggregate) { case 'sum': return values.reduce((a, b) => a + b, 0) case 'min': return Math.min(...values) case 'max': return Math.max(...values) case 'avg': default: return values.reduce((a, b) => a + b, 0) / values.length } } export function ExtDimensionAnalysis({ menuId, title, subtitle, kindLabel = '维度', emptyHint = '还没有可用于分析的扩展数据。', accentClass = 'bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.16),transparent_36%)]', keywords = [], fallbackFieldNames = [], }: DimensionAnalysisProps) { const configs = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const menuQuery = useQuery({ queryKey: QK.analysisMenu(menuId ?? ''), queryFn: () => api.analysisMenu(menuId!), enabled: !!menuId, }) const menu = menuQuery.data const [selectedConfigId, setSelectedConfigId] = useState('') const [dimensionField, setDimensionField] = useState('') const [search, setSearch] = useState('') const [selectedGroup, setSelectedGroup] = useState(null) const availableConfigs = configs.data?.items ?? [] const activeConfigId = menu?.data_source || selectedConfigId || pickDefaultConfig(availableConfigs, keywords) const activeConfig = availableConfigs.find(c => c.id === activeConfigId) const activeDimensionField = menu?.dimension_field || dimensionField || pickDefaultDimensionField(activeConfig, fallbackFieldNames) const activeTitle = menu?.label || title || '扩展分析' const activeKindLabel = menu?.template === 'table' ? '数据' : kindLabel const configuredColumns = useMemo(() => { const cols = menu?.detail_columns?.filter(c => c.visible !== false) ?? [] return cols.length > 0 ? cols : null }, [menu?.detail_columns]) const requestedColumns = useMemo(() => { const cols = [activeDimensionField, ...(configuredColumns?.map(c => c.field) ?? [])].filter(Boolean) return Array.from(new Set(cols)) }, [activeDimensionField, configuredColumns]) const columnsKey = requestedColumns.join(',') const rowsQuery = useQuery({ queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT, columnsKey), queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT, columns: requestedColumns }), enabled: !!activeConfigId, }) const rows = rowsQuery.data?.rows ?? [] const baseFields = activeConfig?.fields ?? rowsQuery.data?.fields ?? [] const fields = rows.some(r => r.name != null) && !baseFields.some(f => f.name === 'name') ? [...baseFields, { name: 'name', dtype: 'string', label: '名称' }] : baseFields const dimensionOptions = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') const displayColumns = configuredColumns ?? columnsFromFields(fields, activeDimensionField, rows) const groupColumns = (menu?.group_columns?.length ? menu.group_columns : [ { field: '__dimension', label: activeKindLabel }, { field: '__count', label: '股票数', type: 'number' as const, sortable: true }, ]).filter(c => c.visible !== false) const sortedRows = useMemo(() => { if (!menu?.default_sort?.field) return rows const factor = menu.default_sort.order === 'asc' ? 1 : -1 return [...rows].sort((a, b) => { const av = a[menu.default_sort!.field] const bv = b[menu.default_sort!.field] if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor return String(av ?? '').localeCompare(String(bv ?? '')) * factor }) }, [rows, menu?.default_sort]) const groups = useMemo(() => { if (!activeDimensionField || menu?.template === 'table' || menu?.template === 'ranking') return [] const map = new Map[]>() for (const row of sortedRows) { const values = splitDimensionValues(row[activeDimensionField]) for (const value of values) { const item = map.get(value) ?? [] item.push(row) map.set(value, item) } } return [...map.entries()] .map(([key, itemRows]) => ({ key, count: itemRows.length, rows: itemRows, metrics: Object.fromEntries(groupColumns.map(col => [col.field, aggregate(itemRows, col)])), })) .sort((a, b) => b.count - a.count) }, [sortedRows, activeDimensionField, groupColumns, menu?.template]) const filteredGroups = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return groups return groups.filter(g => g.key.toLowerCase().includes(q)) }, [groups, search]) const currentGroup = filteredGroups.find(g => g.key === selectedGroup) ?? filteredGroups[0] const tableRows = menu?.template === 'table' || menu?.template === 'ranking' ? sortedRows : (currentGroup?.rows ?? []) const coveredSymbols = useMemo(() => { const set = new Set() rows.forEach(r => { if (r.symbol) set.add(String(r.symbol)) }) return set.size }, [rows]) const missingConfiguredColumns = displayColumns.filter(c => rows.length > 0 && !Object.prototype.hasOwnProperty.call(rows[0], c.field)) return ( <> } />
扩展数据驱动 · 菜单可配置 · 列动态渲染

{activeTitle}

页面按分析菜单配置读取扩展数据源,使用分组字段生成榜单,并严格按照 detail_columns 渲染明细列。

{configs.isLoading || menuQuery.isLoading ? (
加载配置中…
) : menuQuery.isError ? (
分析菜单不存在或已删除。
) : !activeConfig ? (
{emptyHint}
请先在“数据”页面新增扩展数据,并在“扩展分析”中创建分析菜单。
) : ( <>
{missingConfiguredColumns.length > 0 && (
菜单中有字段在当前数据中不存在:{missingConfiguredColumns.map(c => c.field).join('、')}
)}
{menu?.template !== 'table' && menu?.template !== 'ranking' && (

分组榜单

按覆盖标的数量排序

Top {Math.min(filteredGroups.length, 100)}
{ setSearch(e.target.value); setSelectedGroup(null) }} placeholder={`搜索${activeKindLabel}`} className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50" />
{filteredGroups.length === 0 ? (
没有可展示的分组
) : filteredGroups.slice(0, 100).map((group, i) => { const active = currentGroup?.key === group.key return ( ) })}
)}

{menu?.template === 'table' || menu?.template === 'ranking' ? '明细列表' : currentGroup?.key ?? `选择${activeKindLabel}`}

{tableRows.length} 条

列来自菜单 detail_columns:{displayColumns.map(f => f.label || f.field).join(' / ') || '暂无字段'}

{rowsQuery.data?.date ?? '当前快照'}
{displayColumns.map(col => ( ))} {rowsQuery.isLoading ? ( ) : tableRows.length === 0 ? ( ) : tableRows.slice(0, 300).map((row, i) => ( {displayColumns.map(col => ( ))} ))}
{col.label || col.field}
加载数据中…
暂无明细数据
{formatValue(row[col.field], col)}
{tableRows.length > 300 && (
仅展示前 300 条明细,共 {tableRows.length} 条
)}
)}
) }