mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat: 个股日K信息条支持自定义指标(扩展数据/财务/单独显示)
- 信息条(StockInfoBar)行末加配置齿轮,复用自选列表抽屉自定义显示项 - 新增指标: 成交量/振幅/开高低 + 扩展数据(ext) + 财务(EPS/BPS/ROE/PE/PB等) - 后端 klineDaily 新增 ext_columns 参数,按 symbol JOIN 扩展数据 - 财务数据前端直拉 useFinancialMetrics, PE/PB 用现价现算 - 每列可设单独显示(standalone),独占一行渲染 - ext 标签支持 maxTags 截断 + 点击 +N 同页展开/收起 - 修复重开弹窗信息条消失(symbol 重置与子组件 onDataChange 竞态) - 修复 ext 勾选不显示(query key 未含 extColumns 导致缓存命中) - 配置按钮左侧加监控通知图标占位(BellRing) - ListColumnCustomizer 新增 showExtColumns/showStandaloneToggle prop
This commit is contained in:
@@ -80,11 +80,14 @@ def get_daily(
|
||||
days: int = Query(120, ge=10, le=2000),
|
||||
start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
|
||||
end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||||
):
|
||||
"""读取本地 enriched 表中某只股票的日 K。
|
||||
|
||||
- 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛
|
||||
- Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回
|
||||
- ext_columns: 可选,动态 LEFT JOIN 扩展数据表,结果平铺到 stock_info.ext 下
|
||||
(key 为 "{config_id}__{field_name}"),供日K信息条等场景展示自定义字段
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
@@ -112,14 +115,81 @@ def get_daily(
|
||||
rows = enriched.tail(days).to_dicts()
|
||||
# 即使 live 模式也尝试追加实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 追加/覆盖今日实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
|
||||
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||
|
||||
|
||||
def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> dict:
|
||||
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||||
|
||||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||||
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
|
||||
"""
|
||||
if not ext_columns or not ext_columns.strip():
|
||||
return resp
|
||||
|
||||
specs: list[tuple[str, str]] = []
|
||||
for part in ext_columns.split(","):
|
||||
part = part.strip()
|
||||
if "." not in part:
|
||||
continue
|
||||
config_id, field_name = part.split(".", 1)
|
||||
config_id, field_name = config_id.strip(), field_name.strip()
|
||||
if config_id and field_name:
|
||||
specs.append((config_id, field_name))
|
||||
if not specs:
|
||||
return resp
|
||||
|
||||
import polars as pl
|
||||
data_dir = repo.store.data_dir
|
||||
try:
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
from app.api.ext_data import _read_ext_dataframe
|
||||
ext_store = ExtConfigStore(data_dir)
|
||||
configs = {c.id: c for c in ext_store.load_all()}
|
||||
except Exception: # noqa: BLE001
|
||||
configs = {}
|
||||
|
||||
ext_values: dict = {}
|
||||
for config_id, field_name in specs:
|
||||
ext_col_name = f"{config_id}__{field_name}"
|
||||
value = None
|
||||
try:
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
else:
|
||||
ext_df = pl.from_arrow(
|
||||
repo.store.db.query(
|
||||
f'SELECT symbol, "{field_name}" FROM ext_{config_id}'
|
||||
).arrow()
|
||||
)
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
|
||||
# 时序表取最新分区,避免一个 symbol 多行
|
||||
row = (
|
||||
ext_df
|
||||
.select(["symbol", field_name])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.filter(pl.col("symbol") == symbol)
|
||||
)
|
||||
if not row.is_empty():
|
||||
value = row[field_name][0]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("kline ext join failed for %s.%s: %s", config_id, field_name, e)
|
||||
ext_values[ext_col_name] = value
|
||||
|
||||
stock_info = dict(resp.get("stock_info") or {})
|
||||
stock_info["ext"] = ext_values
|
||||
resp["stock_info"] = stock_info
|
||||
return resp
|
||||
|
||||
|
||||
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface StockInfo {
|
||||
name?: string
|
||||
total_shares?: number
|
||||
float_shares?: number
|
||||
/** 扩展数据(key: configId__fieldName),来自 klineDaily 的 ext_columns */
|
||||
ext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 子图定义 */
|
||||
|
||||
@@ -34,9 +34,13 @@ interface ListColumnCustomizerProps {
|
||||
builtinSectionLabel?: string
|
||||
extColumnAlign?: 'left' | 'center' | 'right'
|
||||
extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean
|
||||
/** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */
|
||||
showExtColumns?: boolean
|
||||
/** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */
|
||||
showStandaloneToggle?: boolean
|
||||
}
|
||||
|
||||
function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig }: {
|
||||
function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: {
|
||||
col: ColumnConfig
|
||||
onRemove: (id: string) => void
|
||||
onConfig: (id: string | null) => void
|
||||
@@ -45,6 +49,8 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel,
|
||||
extConfig: React.ReactNode
|
||||
candleConfig: React.ReactNode
|
||||
strategiesConfig: React.ReactNode
|
||||
showStandaloneToggle?: boolean
|
||||
onToggleStandalone?: (id: string) => void
|
||||
}) {
|
||||
const {
|
||||
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||
@@ -80,6 +86,19 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel,
|
||||
? `${col.label}(${extTableLabel})`
|
||||
: col.label}
|
||||
</span>
|
||||
{showStandaloneToggle && (
|
||||
<button
|
||||
onClick={() => onToggleStandalone?.(col.id)}
|
||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
|
||||
col.standalone
|
||||
? 'text-accent bg-accent/10'
|
||||
: 'text-muted hover:text-secondary opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={col.standalone ? '取消单独显示' : '单独一行显示'}
|
||||
>
|
||||
{col.standalone ? '单独' : '单行'}
|
||||
</button>
|
||||
)}
|
||||
{hasConfig && (
|
||||
<button
|
||||
onClick={() => onConfig(configOpen ? null : col.id)}
|
||||
@@ -112,11 +131,13 @@ export function ListColumnCustomizer({
|
||||
builtinSectionLabel = '内置列',
|
||||
extColumnAlign = 'center',
|
||||
extFieldFilter,
|
||||
showExtColumns = true,
|
||||
showStandaloneToggle = false,
|
||||
}: ListColumnCustomizerProps) {
|
||||
const extSchema = useQuery({
|
||||
queryKey: QK.extDataSchemaAll,
|
||||
queryFn: api.extDataSchemaAll,
|
||||
enabled: open,
|
||||
enabled: open && showExtColumns,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
@@ -142,6 +163,12 @@ export function ListColumnCustomizer({
|
||||
))
|
||||
}, [columns, onChange])
|
||||
|
||||
const toggleStandalone = useCallback((colId: string) => {
|
||||
onChange(columns.map(c =>
|
||||
c.id === colId ? { ...c, standalone: !c.standalone } : c
|
||||
))
|
||||
}, [columns, onChange])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -513,14 +540,28 @@ export function ListColumnCustomizer({
|
||||
)
|
||||
|
||||
const renderBuiltinRow = (col: ColumnConfig) => (
|
||||
<button
|
||||
<div
|
||||
key={col.id}
|
||||
onClick={() => toggleVisible(col.id)}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
|
||||
>
|
||||
<button onClick={() => toggleVisible(col.id)} className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{renderCheckbox(col.visible)}
|
||||
<span className={`flex-1 text-xs truncate ${col.visible ? 'text-foreground' : 'text-muted'}`}>{col.label}</span>
|
||||
</button>
|
||||
{showStandaloneToggle && col.visible && (
|
||||
<button
|
||||
onClick={() => toggleStandalone(col.id)}
|
||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
|
||||
col.standalone
|
||||
? 'text-accent bg-accent/10'
|
||||
: 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
title={col.standalone ? '取消单独显示' : '单独一行显示'}
|
||||
>
|
||||
{col.standalone ? '单独' : '单行'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => {
|
||||
@@ -596,6 +637,8 @@ export function ListColumnCustomizer({
|
||||
extConfig={renderExtConfig(col)}
|
||||
candleConfig={renderCandleConfig(col)}
|
||||
strategiesConfig={renderStrategiesConfig(col)}
|
||||
showStandaloneToggle={showStandaloneToggle}
|
||||
onToggleStandalone={toggleStandalone}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -650,7 +693,7 @@ export function ListColumnCustomizer({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{extTables.length > 0 && (
|
||||
{showExtColumns && extTables.length > 0 && (
|
||||
<div className="pt-1 border-t border-border mt-1">
|
||||
<div className="flex items-center gap-1.5 px-1 py-1.5">
|
||||
<Database className="h-3 w-3 text-accent/70" />
|
||||
@@ -700,7 +743,7 @@ export function ListColumnCustomizer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extTables.length === 0 && extSchema.isSuccess && (
|
||||
{showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
|
||||
<div className="text-xs text-muted text-center py-4">
|
||||
暂无扩展数据表,可在「数据」页面创建
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,8 @@ interface Props {
|
||||
linkedPrice?: number | null
|
||||
onDateClick?: (date: string) => void
|
||||
onDataChange?: (result: StockDailyKChartResult) => void
|
||||
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
|
||||
extColumns?: string
|
||||
}
|
||||
|
||||
function isValidRow(r: any): boolean {
|
||||
@@ -121,15 +123,17 @@ export function StockDailyKChart({
|
||||
linkedPrice,
|
||||
onDateClick,
|
||||
onDataChange,
|
||||
extColumns,
|
||||
}: Props) {
|
||||
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
|
||||
const [showMarkers, setShowMarkers] = useState(true)
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
const days = useMemo(() => rangeDays(dateRange), [dateRange])
|
||||
|
||||
// extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
|
||||
const kline = useQuery({
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end),
|
||||
queryFn: () => api.klineDaily(symbol, days, dateRange),
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
|
||||
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
|
||||
enabled: !!symbol,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { KlineRow } from '@/lib/api'
|
||||
import { fmtPrice, fmtBigNum } from '@/lib/format'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Settings2, BellRing } from 'lucide-react'
|
||||
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
|
||||
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
|
||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
|
||||
|
||||
const BULL = '#C74040'
|
||||
const BEAR = '#2D9B65'
|
||||
@@ -7,11 +11,96 @@ const BEAR = '#2D9B65'
|
||||
interface Props {
|
||||
symbol: string
|
||||
name?: string
|
||||
stockInfo?: { name?: string; total_shares?: number; float_shares?: number }
|
||||
stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
|
||||
rows: KlineRow[]
|
||||
/** 信息条字段配置(由 StockPanel 提升,受控) */
|
||||
fields: ColumnConfig[]
|
||||
onFieldsChange: (fields: ColumnConfig[]) => void
|
||||
/** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */
|
||||
financialMetrics?: FinancialMetricRecord
|
||||
}
|
||||
|
||||
export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) {
|
||||
/**
|
||||
* 精简渲染扩展数据值(信息条专用)。
|
||||
* 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。
|
||||
* 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。
|
||||
*/
|
||||
function renderExtInline(
|
||||
val: unknown,
|
||||
col: ColumnConfig,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
): ReactNode {
|
||||
if (val == null || (typeof val === 'number' && Number.isNaN(val))) {
|
||||
return <span className="text-muted">—</span>
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
|
||||
return <span className="tabular-nums">{displayVal}</span>
|
||||
}
|
||||
if (typeof val === 'boolean') {
|
||||
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
|
||||
}
|
||||
const str = String(val)
|
||||
// 纯文本模式
|
||||
if (col.extDisplay?.displayMode === 'text') {
|
||||
return <span>{str}</span>
|
||||
}
|
||||
// 标签模式(默认):按分隔符拆成小标签
|
||||
const sep = col.extDisplay?.separator?.trim() || null
|
||||
const tags = sep
|
||||
? str.split(sep).map(s => s.trim()).filter(Boolean)
|
||||
: str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean)
|
||||
if (tags.length === 0) return <span className="text-muted">—</span>
|
||||
// maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起
|
||||
const maxTags = col.extDisplay?.maxTags ?? 0
|
||||
const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined
|
||||
const showAll = maxTags <= 0 || expanded
|
||||
const sliced = showAll ? tags : tags.slice(0, maxTags)
|
||||
const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced
|
||||
const overflow = tags.length - shown.length
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-0.5">
|
||||
{shown.map((tag, i) => (
|
||||
<span key={i} className="inline-block px-1 rounded text-[10px] leading-tight text-yellow-500 bg-yellow-500/10">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{!showAll && overflow > 0 && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="inline-block px-1 rounded text-[10px] leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
+{overflow}
|
||||
</button>
|
||||
)}
|
||||
{showAll && maxTags > 0 && tags.length > maxTags && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="inline-block px-1 rounded text-[10px] leading-tight text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics }: Props) {
|
||||
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
|
||||
const [customizerOpen, setCustomizerOpen] = useState(false)
|
||||
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
|
||||
const [expandedExt, setExpandedExt] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleExtExpand = (key: string) => {
|
||||
setExpandedExt(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const latest = rows[rows.length - 1]
|
||||
@@ -31,6 +120,79 @@ export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) {
|
||||
: null
|
||||
|
||||
const displayName = stockInfo?.name ?? name ?? ''
|
||||
const extData = stockInfo?.ext ?? {}
|
||||
|
||||
// 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。
|
||||
// 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。
|
||||
const computeBuiltinValue = (key: string): string | null => {
|
||||
switch (key) {
|
||||
case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null
|
||||
case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null
|
||||
case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null
|
||||
case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null
|
||||
case 'amplitude': {
|
||||
const prevClose = prev ? Number(prev.close) : null
|
||||
if (prevClose == null || prevClose === 0) return null
|
||||
const hi = Number(latest.high)
|
||||
const lo = Number(latest.low)
|
||||
return `${((hi - lo) / prevClose * 100).toFixed(2)}%`
|
||||
}
|
||||
case 'open': return fmtPrice(Number(latest.open))
|
||||
case 'high': return fmtPrice(Number(latest.high))
|
||||
case 'low': return fmtPrice(Number(latest.low))
|
||||
// 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + %
|
||||
case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null
|
||||
case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null
|
||||
case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null
|
||||
case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null
|
||||
case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null
|
||||
case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null
|
||||
case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null
|
||||
case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null
|
||||
// PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM)
|
||||
case 'pe_ttm': {
|
||||
const eps = financialMetrics?.eps_basic
|
||||
return eps && eps !== 0 ? fmtPrice(close / eps) : null
|
||||
}
|
||||
case 'pb': {
|
||||
const bps = financialMetrics?.bps
|
||||
return bps && bps !== 0 ? fmtPrice(close / bps) : null
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const visibleFields = fields.filter(f => f.visible)
|
||||
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
|
||||
const inlineFields = visibleFields.filter(f => !f.standalone)
|
||||
const standaloneFields = visibleFields.filter(f => f.standalone)
|
||||
|
||||
// 渲染单个字段(builtin / ext 通用)
|
||||
const renderField = (f: ColumnConfig): ReactNode => {
|
||||
if (f.source.type === 'ext') {
|
||||
const { configId, fieldName } = f.source
|
||||
const val = extData[`${configId}__${fieldName}`]
|
||||
// 无值的 ext 字段整体跳过(与 builtin 无数据行为一致)
|
||||
if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null
|
||||
const cellKey = `${symbol}::${f.id}`
|
||||
return (
|
||||
<span key={f.id} className="inline-flex items-center gap-1">
|
||||
<span>{f.label}</span>
|
||||
<span className="text-secondary">
|
||||
{renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// builtin
|
||||
const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '')
|
||||
if (value == null) return null
|
||||
return (
|
||||
<span key={f.id}>
|
||||
{f.label} <span className="text-secondary">{value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
|
||||
@@ -47,20 +209,53 @@ export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) {
|
||||
<span style={{ color: clr }} className="tabular-nums">
|
||||
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
|
||||
</span>
|
||||
{/* 右侧操作按钮:监控通知 + 信息条配置 */}
|
||||
<div className="ml-auto self-center flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title="监控通知(开发中)"
|
||||
>
|
||||
<BellRing className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomizerOpen(true)}
|
||||
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title="自定义信息条"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: market cap, float market cap, turnover rate */}
|
||||
<div className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
|
||||
{marketCap != null && (
|
||||
<span>市值 <span className="text-secondary">{fmtBigNum(marketCap)}</span></span>
|
||||
)}
|
||||
{floatMarketCap != null && (
|
||||
<span>流通值 <span className="text-secondary">{fmtBigNum(floatMarketCap)}</span></span>
|
||||
)}
|
||||
{turnoverRate != null && (
|
||||
<span>换手 <span className="text-secondary">{turnoverRate.toFixed(2)}%</span></span>
|
||||
)}
|
||||
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap) */}
|
||||
{inlineFields.length > 0 && (
|
||||
<div className="flex items-center gap-x-4 gap-y-1 text-[11px] flex-wrap text-muted">
|
||||
{inlineFields.map(renderField)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 单独显示的指标:各占一行 */}
|
||||
{standaloneFields.map(f => {
|
||||
const node = renderField(f)
|
||||
if (node == null) return null
|
||||
return (
|
||||
<div key={f.id} className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<ListColumnCustomizer
|
||||
columns={fields}
|
||||
groups={INFO_GROUPS}
|
||||
onChange={onFieldsChange}
|
||||
open={customizerOpen}
|
||||
onClose={() => setCustomizerOpen(false)}
|
||||
title="信息条指标"
|
||||
builtinSectionLabel="可选指标"
|
||||
extColumnAlign="left"
|
||||
showStandaloneToggle
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { type KlineRow } from '@/lib/api'
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
|
||||
import { StockInfoBar } from '@/components/StockInfoBar'
|
||||
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
|
||||
import { StockIntradayChart } from '@/components/StockIntradayChart'
|
||||
import { useFinancialMetrics } from '@/lib/useFinancials'
|
||||
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
||||
import {
|
||||
loadInfoFields,
|
||||
saveInfoFields,
|
||||
buildInfoExtColumnsParam,
|
||||
type ColumnConfig,
|
||||
} from '@/lib/stock-info-fields'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
@@ -39,6 +46,22 @@ export function StockPanel({
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
|
||||
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
|
||||
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
|
||||
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
|
||||
|
||||
const handleFieldsChange = useCallback((next: ColumnConfig[]) => {
|
||||
setFields(next)
|
||||
saveInfoFields(next)
|
||||
}, [])
|
||||
|
||||
// 财务指标:仅当信息条配置含可见的财务字段时才请求(避免无谓请求 + 受 Cap.FINANCIAL 门控)
|
||||
const hasFinanceField = useMemo(
|
||||
() => fields.some(f => f.visible && f.source.type === 'builtin'
|
||||
&& ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)),
|
||||
[fields],
|
||||
)
|
||||
const financials = useFinancialMetrics(hasFinanceField ? symbol : undefined)
|
||||
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
|
||||
@@ -51,8 +74,14 @@ export function StockPanel({
|
||||
const stockInfo = dailyResult?.stockInfo
|
||||
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
|
||||
|
||||
// symbol 变化时重置分时相关状态,避免切股后残留旧日期
|
||||
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
|
||||
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
|
||||
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
|
||||
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
|
||||
const prevSymbol = useRef<string | null>(symbol)
|
||||
useEffect(() => {
|
||||
if (prevSymbol.current === symbol) return
|
||||
prevSymbol.current = symbol
|
||||
setSelectedDate(null)
|
||||
setLinkedPrice(null)
|
||||
setDailyResult(null)
|
||||
@@ -73,6 +102,9 @@ export function StockPanel({
|
||||
: undefined
|
||||
if (!symbol) return null
|
||||
|
||||
// 财务指标最新一期(metrics 按 period_end 排序,取首项)
|
||||
const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<StockInfoBar
|
||||
@@ -80,6 +112,9 @@ export function StockPanel({
|
||||
name={dailyResult?.name}
|
||||
stockInfo={stockInfo}
|
||||
rows={rawRows}
|
||||
fields={fields}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
financialMetrics={financialMetrics}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
@@ -97,6 +132,7 @@ export function StockPanel({
|
||||
onDateClick={handleDateClick}
|
||||
onDataChange={setDailyResult}
|
||||
visibleBars={showIntraday ? 40 : 60}
|
||||
extColumns={extColumns}
|
||||
/>
|
||||
|
||||
{showIntraday && selectedDate && (
|
||||
|
||||
@@ -697,17 +697,18 @@ export const api = {
|
||||
redetectCapabilities: () =>
|
||||
request<CapabilitiesResponse>('/api/capabilities/redetect', { method: 'POST' }),
|
||||
|
||||
klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }) =>
|
||||
klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }, extColumns?: string) =>
|
||||
request<{
|
||||
symbol: string
|
||||
name?: string
|
||||
stock_info?: { name?: string; total_shares?: number; float_shares?: number }
|
||||
stock_info?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
|
||||
rows: KlineRow[]
|
||||
source?: string
|
||||
}>(
|
||||
dateRange
|
||||
(dateRange
|
||||
? `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&start_date=${dateRange.start}&end_date=${dateRange.end}`
|
||||
: `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`,
|
||||
: `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`)
|
||||
+ (extColumns ? `&ext_columns=${encodeURIComponent(extColumns)}` : ''),
|
||||
),
|
||||
klineDailyBatch: (symbols: string[], days = 12) =>
|
||||
request<{ data: Record<string, KlineRow[]> }>('/api/kline/daily-batch', {
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface ColumnConfig {
|
||||
extDisplay?: ExtColumnDisplayConfig
|
||||
/** 日k列渲染配置(仅 builtin: candle 列生效) */
|
||||
candleConfig?: CandleColumnConfig
|
||||
/** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
export interface ColumnGroup {
|
||||
@@ -133,12 +135,13 @@ export function mergeColumns(
|
||||
const def = defaultMap.get(col.id)
|
||||
if (def) {
|
||||
// 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置;
|
||||
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay)需保留,否则刷新后丢失
|
||||
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失
|
||||
result.push({
|
||||
...def,
|
||||
visible: col.visible,
|
||||
...(col.candleConfig ? { candleConfig: col.candleConfig } : {}),
|
||||
...(col.extDisplay ? { extDisplay: col.extDisplay } : {}),
|
||||
...(col.standalone ? { standalone: col.standalone } : {}),
|
||||
})
|
||||
} else if (col.source?.type === 'ext') {
|
||||
// ext 列: 保留用户配置,清理旧 label 中的括号后缀
|
||||
|
||||
@@ -48,8 +48,8 @@ export const QK = {
|
||||
analysisMenu: (id: string) => ['analysis-menu', id] as const,
|
||||
|
||||
// Kline
|
||||
kline: (symbol: string, start: string, end: string) =>
|
||||
['kline', symbol, start, end] as const,
|
||||
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
|
||||
['kline', symbol, start, end, extColumns ?? ''] as const,
|
||||
klineMinute: (symbol: string, date: string) =>
|
||||
['kline-minute', symbol, date] as const,
|
||||
indexDaily: (symbol: string, start: string, end: string) =>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。
|
||||
*
|
||||
* 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座,
|
||||
* 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线
|
||||
* Modal 共用同一份配置。
|
||||
*/
|
||||
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
||||
mergeColumns as mergeColumnsBase,
|
||||
serializeColumns as serializeColumnsBase,
|
||||
type ColumnConfig,
|
||||
type ColumnGroup,
|
||||
} from '@/lib/list-columns'
|
||||
|
||||
export type { ColumnConfig, ColumnGroup }
|
||||
|
||||
// ===== 内置指标注册表 =====
|
||||
|
||||
export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [
|
||||
// 规模
|
||||
{ id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' },
|
||||
{ id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' },
|
||||
// 成交
|
||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' },
|
||||
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' },
|
||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' },
|
||||
// 行情
|
||||
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' },
|
||||
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' },
|
||||
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' },
|
||||
// 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算)
|
||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' },
|
||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' },
|
||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' },
|
||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' },
|
||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' },
|
||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' },
|
||||
]
|
||||
|
||||
export const INFO_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] },
|
||||
{ id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] },
|
||||
]
|
||||
|
||||
// ===== localStorage 持久化 =====
|
||||
|
||||
/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */
|
||||
export function loadInfoFields(): ColumnConfig[] {
|
||||
const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[]
|
||||
if (saved.length === 0) return [...BUILTIN_INFO_FIELDS]
|
||||
return mergeFields(saved, BUILTIN_INFO_FIELDS)
|
||||
}
|
||||
|
||||
/** 保存信息条指标配置到 localStorage。 */
|
||||
export function saveInfoFields(columns: ColumnConfig[]): void {
|
||||
storage.stockInfoBarFields.set(serializeFields(columns))
|
||||
}
|
||||
|
||||
/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */
|
||||
function serializeFields(columns: ColumnConfig[]): ColumnConfig[] {
|
||||
return serializeColumnsBase(columns)
|
||||
}
|
||||
|
||||
/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */
|
||||
export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string {
|
||||
return buildExtColumnsParamBase(columns)
|
||||
}
|
||||
|
||||
/** 合并用户保存的配置与默认配置。 */
|
||||
function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
||||
// 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑
|
||||
return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] })
|
||||
}
|
||||
@@ -30,6 +30,9 @@ export const storage = {
|
||||
/** 自选列表列配置 */
|
||||
watchlistColumns: kv<unknown[]>('watchlist_columns'),
|
||||
|
||||
/** 个股日K信息条指标配置 */
|
||||
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
||||
|
||||
/** 策略结果列表列配置 */
|
||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user