Files
stock/refer/frontend/src/pages/Analysis.tsx
T

292 lines
14 KiB
TypeScript

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<string[]>([])
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 (
<>
<PageHeader
title="扩展分析"
subtitle="自定义分析菜单 · 动态字段 · 动态列"
right={
<button
onClick={() => { 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"
>
<Plus className="h-3.5 w-3.5" />
新建菜单
</button>
}
/>
<div className="px-8 py-6 max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(139,92,246,0.14),transparent_38%)]">
<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">
<BarChart3 className="h-3.5 w-3.5" />
扩展数据 分析菜单 动态页面
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">把任意扩展字段配置成一个菜单</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
菜单配置决定使用哪个扩展数据源、哪个字段分组、列表展示哪些列。侧边栏会自动显示可见菜单,列表列严格按配置渲染。
</p>
</section>
{showCreate && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">菜单标识</span>
<input value={id} onChange={e => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} placeholder="如 concept_hot" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">菜单名称</span>
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">扩展数据源</span>
<select
value={dataSource || activeConfig?.id || ''}
onChange={e => {
const cfg = configs.find(c => c.id === e.target.value)
setDataSource(e.target.value)
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
}}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">模板</span>
<select value={template} onChange={e => setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="dimension_rank">维度热度榜</option>
<option value="ranking">指标排名榜</option>
<option value="table">明细表</option>
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">分组字段</span>
<select value={dimensionField} onChange={e => setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value="">请选择</option>
{fields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">排名字段</span>
<select value={rankField} onChange={e => setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value="">请选择</option>
{numericFields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
</div>
<div>
<div className="text-[11px] text-muted mb-2">列表列配置</div>
<div className="flex flex-wrap gap-2">
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
const active = selectedColumns.includes(f.name)
return (
<button
key={f.name}
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
>
{f.label || f.name}
</button>
)
})}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs">取消</button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />保存
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{menuItems.map((menu, idx) => (
<div key={menu.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{menu.label}</h3>
<p className="mt-1 text-[11px] text-muted font-mono">{menu.id}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => moveMenu(idx, -1)}
disabled={idx === 0 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="上移"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveMenu(idx, 1)}
disabled={idx === menuItems.length - 1 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="下移"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{menu.builtin ? (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">默认</span>
) : (
<button onClick={() => del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1 text-[11px] text-secondary">
<div>数据源:<span className="font-mono text-muted">{menu.data_source}</span></div>
<div>模板:{menu.template}</div>
{menu.dimension_field && <div>分组字段:{menu.dimension_field}</div>}
<div>列表列:{menu.detail_columns.length} </div>
</div>
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
打开分析页
</Link>
</div>
))}
{menuItems.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2 xl:col-span-3">暂无分析菜单,点击右上角新建。</div>
)}
</section>
</div>
</>
)
}