Files
tick-stock-panel/frontend/src/components/ExtDimensionAnalysis.tsx
T
shy3130 e8f7026adb fix(ui): 修复 text-base 与色板 token base 撞名
色板里定义了颜色 token base, Tailwind 的 text-base 因此生成 color 规则
(文字被涂成 --base 背景色) 而非 16px 字号 —— 标题字号丢失, accent 底按钮
文字与底色同色不可见。全量替换: 字号意图改写 text-[16px] leading-6,
accent 底按钮文字改写 text-white; tailwind.config.ts 注释禁用 text-base。
2026-09-25 14:45:58 +08:00

437 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, any>[]
metrics: Record<string, number | null>
}
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 (
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{label}</span>
<Icon className="h-4 w-4 text-secondary" />
</div>
<div className="mt-3 text-2xl font-semibold tracking-tight text-foreground">{value}</div>
<div className="mt-1 text-[11px] text-muted">{hint}</div>
</div>
)
}
function columnsFromFields(fields: ExtDataField[], dimensionField: string, rows: Record<string, any>[]) {
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<AnalysisColumn>(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<string, any>[], 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<string | null>(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<DimensionGroup[]>(() => {
if (!activeDimensionField || menu?.template === 'table' || menu?.template === 'ranking') return []
const map = new Map<string, Record<string, any>[]>()
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<string>()
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 (
<>
<PageHeader
title={activeTitle}
subtitle={menu ? `${menu.template} · ${menu.data_source}` : subtitle}
right={
<div className="flex items-center gap-2">
<select
value={activeConfigId}
onChange={(e) => { setSelectedConfigId(e.target.value); setDimensionField(''); setSelectedGroup(null) }}
className="h-8 min-w-40 rounded-btn border border-border bg-surface px-2 text-xs text-foreground focus:outline-none focus:border-accent/50"
>
{availableConfigs.length === 0 ? (
<option value="">暂无扩展数据</option>
) : availableConfigs.map(config => (
<option key={config.id} value={config.id}>{config.label}</option>
))}
</select>
<select
value={activeDimensionField}
onChange={(e) => { setDimensionField(e.target.value); setSelectedGroup(null) }}
disabled={!activeConfig}
className="h-8 min-w-36 rounded-btn border border-border bg-surface px-2 text-xs text-foreground disabled:opacity-50 focus:outline-none focus:border-accent/50"
>
{dimensionOptions.length === 0 ? (
<option value="">暂无字段</option>
) : dimensionOptions.map(field => (
<option key={field.name} value={field.name}>{field.label || field.name}</option>
))}
</select>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
<section className={`relative overflow-hidden rounded-2xl border border-border bg-surface p-6 ${accentClass}`}>
<div className="relative z-10 max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] text-secondary">
<Layers3 className="h-3.5 w-3.5" />
扩展数据驱动 · 菜单可配置 · 列动态渲染
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">{activeTitle}</h2>
<p className="mt-2 text-sm leading-6 text-secondary">
页面按分析菜单配置读取扩展数据源,使用分组字段生成榜单,并严格按照 detail_columns 渲染明细列。
</p>
</div>
<div className="absolute right-8 top-6 hidden h-28 w-28 rounded-full border border-foreground/10 bg-foreground/[0.03] lg:flex items-center justify-center">
<BarChart3 className="h-12 w-12 text-foreground/20" />
</div>
</section>
{configs.isLoading || menuQuery.isLoading ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted">加载配置中…</div>
) : menuQuery.isError ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted">分析菜单不存在或已删除。</div>
) : !activeConfig ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary">{emptyHint}</div>
<div className="mt-1 text-xs text-muted">请先在“数据”页面新增扩展数据,并在“扩展分析”中创建分析菜单。</div>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard label="数据源" value={activeConfig.label} hint={`${activeConfig.mode === 'snapshot' ? '快照' : '时序'} · ${activeConfig.id}`} icon={Database} />
<StatCard label="分组数量" value={groups.length || '—'} hint={activeDimensionField ? `按 ${fieldLabel(fields, activeDimensionField)} 聚合` : '未配置分组字段'} icon={Tags} />
<StatCard label="覆盖标的" value={coveredSymbols || rows.length} hint={rowsQuery.data?.date ? `数据日期 ${rowsQuery.data.date}` : '当前快照'} icon={Users} />
<StatCard label="列表列数" value={displayColumns.length} hint={`配置列 ${displayColumns.length} 个`} icon={TrendingUp} />
</div>
{missingConfiguredColumns.length > 0 && (
<div className="rounded-card border border-warning/40 bg-warning/5 px-4 py-3 text-xs text-warning">
菜单中有字段在当前数据中不存在:{missingConfiguredColumns.map(c => c.field).join('、')}
</div>
)}
<div className={menu?.template === 'table' || menu?.template === 'ranking' ? 'grid grid-cols-1' : 'grid grid-cols-1 xl:grid-cols-[24rem_1fr] gap-5'}>
{menu?.template !== 'table' && menu?.template !== 'ranking' && (
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">分组榜单</h3>
<p className="mt-0.5 text-[11px] text-muted">按覆盖标的数量排序</p>
</div>
<span className="text-[10px] text-muted">Top {Math.min(filteredGroups.length, 100)}</span>
</div>
<div className="p-3 border-b border-border/60">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => { 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"
/>
</div>
</div>
<div className="max-h-[560px] overflow-auto p-2 space-y-1">
{filteredGroups.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted">没有可展示的分组</div>
) : filteredGroups.slice(0, 100).map((group, i) => {
const active = currentGroup?.key === group.key
return (
<button
key={group.key}
onClick={() => setSelectedGroup(group.key)}
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${active ? 'bg-accent/10 border border-accent/25' : 'border border-transparent hover:bg-elevated/60'}`}
>
<div className="flex items-center gap-2">
<span className="w-5 text-[10px] font-mono text-muted">#{i + 1}</span>
<span className="flex-1 truncate text-xs font-medium text-foreground">{group.key}</span>
<span className="font-mono text-xs text-secondary">{group.count}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-accent/70"
style={{ width: `${Math.max(6, (group.count / (filteredGroups[0]?.count || 1)) * 100)}%` }}
/>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted">
{groupColumns.filter(c => !['__dimension', '__count'].includes(c.field)).slice(0, 3).map(col => (
<span key={col.field}>{col.label || col.field}: {formatValue(group.metrics[col.field], col)}</span>
))}
</div>
</button>
)
})}
</div>
</section>
)}
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-[16px] leading-6 font-semibold text-foreground">{menu?.template === 'table' || menu?.template === 'ranking' ? '明细列表' : currentGroup?.key ?? `选择${activeKindLabel}`}</h3>
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] text-accent">{tableRows.length} 条</span>
</div>
<p className="mt-1 text-xs text-muted">列来自菜单 detail_columns:{displayColumns.map(f => f.label || f.field).join(' / ') || '暂无字段'}</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted">
<CalendarDays className="h-3.5 w-3.5" />
{rowsQuery.data?.date ?? '当前快照'}
</div>
</div>
<div className="overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/50 text-[11px] text-muted">
<tr>
{displayColumns.map(col => (
<th key={col.field} className="whitespace-nowrap px-4 py-2 font-medium" style={col.width ? { width: col.width } : undefined}>{col.label || col.field}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{rowsQuery.isLoading ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}>加载数据中…</td></tr>
) : tableRows.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}>暂无明细数据</td></tr>
) : tableRows.slice(0, 300).map((row, i) => (
<tr key={`${row.symbol ?? row.code ?? i}-${i}`} className="hover:bg-elevated/30">
{displayColumns.map(col => (
<td key={col.field} className={`whitespace-nowrap px-4 py-2 ${col.field === activeDimensionField ? 'max-w-[18rem] truncate text-secondary' : col.field === 'symbol' || col.field === 'code' || col.field === '股票代码' ? 'font-mono text-secondary' : 'text-foreground'}`}>
{formatValue(row[col.field], col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{tableRows.length > 300 && (
<div className="border-t border-border px-4 py-2 text-center text-[11px] text-muted">仅展示前 300 条明细,共 {tableRows.length} 条</div>
)}
</section>
</div>
</>
)}
</div>
</>
)
}