diff --git a/backend/main.py b/backend/main.py index 5d72787..f2ce74e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from database import init_db -from routes import stock, collections, shares, sectors, themes, core_stocks +from routes import stock, collections, shares, themes, core_stocks from services.daily_collector import collector_loop, cache_cleanup_loop load_dotenv() @@ -43,7 +43,6 @@ app.add_middleware( app.include_router(stock.router, prefix="/api/stock") app.include_router(collections.router, prefix="/api/collections") app.include_router(shares.router, prefix="/api/share") -app.include_router(sectors.router, prefix="/api/sectors") app.include_router(themes.router, prefix="/api/themes") app.include_router(core_stocks.router, prefix="/api/core-stocks") diff --git a/backend/routes/sectors.py b/backend/routes/sectors.py deleted file mode 100644 index 5e10e76..0000000 --- a/backend/routes/sectors.py +++ /dev/null @@ -1,39 +0,0 @@ -"""板块数据路由:行业板块、概念板块""" - -from fastapi import APIRouter, Query, HTTPException -from fastapi.responses import JSONResponse -from services import eastmoney, mootdx - -router = APIRouter() - -# 行业/概念通过 query 参数区分,但上游反代/CDN 可能按 path 缓存而忽略 query, -# 导致两个 tab 返回相同数据。显式禁止缓存,保证按 query 区分。 -_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"} - - -@router.get("", summary="板块列表") -async def sector_list( - type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"), -): - if type not in ("industry", "concept"): - raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept") - - data = await eastmoney.fetch_sector_list(type) - if data: - return JSONResponse( - {"data": data, "count": len(data), "type": type}, - headers=_NO_CACHE_HEADERS, - ) - - # 降级:通达信 mootdx(不含实时资金流数据) - md_data = await mootdx.fetch_sector_list(type) - if md_data: - return JSONResponse( - {"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"}, - headers=_NO_CACHE_HEADERS, - ) - - return JSONResponse( - {"data": [], "count": 0, "type": type}, - headers=_NO_CACHE_HEADERS, - ) diff --git a/backend/services/eastmoney.py b/backend/services/eastmoney.py index 8fdcbd4..a3fc300 100644 --- a/backend/services/eastmoney.py +++ b/backend/services/eastmoney.py @@ -6,13 +6,11 @@ import httpx import json import os import re -from datetime import datetime, time as dtime, timedelta, timezone +from datetime import datetime from typing import Optional, List from services.cache import get_cache, set_cache -from services.cache import get_cache, set_cache - # ---- API Key 轮询(MX 备选源用)---- @@ -283,328 +281,6 @@ def get_eastmoney_market(code: str) -> str: return "1" return "0" - -# ---- 板块数据 ---- - -# 从东方财富 bkzj/list.js 逆向的字段映射 -# f62=主力净流入, f184=主力净流入占比 -# f66=超大单净流入, f69=超大单净流入占比 -# f72=大单净流入, f75=大单净流入占比 -# f78=中单净流入, f81=中单净流入占比 -# f84=小单净流入, f87=小单净流入占比 -# f70=成交额 -SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70" - -# 东方财富板块类型映射 -SECTOR_MEDIA_MAP = { - "industry": "m:90+s:4", - "concept": "m:90+t:3", -} - -# 东方财富 UT 令牌管理 -_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15" -_em_ut_lock = asyncio.Lock() - - -async def _refresh_em_ut() -> str: - """ - 从东方财富前端 JS 中提取最新的 ut 令牌。 - 按优先级尝试: - 1. bkzj/list.js(板块页专用) - 2. common/emdataview.js(通用数据组件) - """ - urls = [ - "https://data.eastmoney.com/newstatic/js/bkzj/list.js", - "https://data.eastmoney.com/newstatic/js/common/emdataview.js", - ] - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Referer": "https://data.eastmoney.com/bkzj/hy.html", - } - - async with httpx.AsyncClient() as client: - for url in urls: - try: - resp = await client.get(url, headers=headers, timeout=10) - if resp.status_code != 200: - continue - # 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx" - m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text) - if m: - token = m.group(1) - print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...") - return token - except Exception as e: - print(f"[eastmoney] 获取 UT 失败({url}): {e}") - return _em_ut # 保底返回当前值 - - -async def get_em_ut(force_refresh: bool = False) -> str: - """获取当前 UT,必要时刷新""" - global _em_ut - if force_refresh: - async with _em_ut_lock: - _em_ut = await _refresh_em_ut() - return _em_ut - - -# ---- 板块数据(市场时间感知缓存)---- - -_CST = timezone(timedelta(hours=8)) # 北京时间 -_TRADING_MORNING = (dtime(9, 30), dtime(11, 30)) -_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0)) - - -def _cst_now() -> datetime: - return datetime.now(_CST) - - -def _is_trading_time() -> bool: - """判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)""" - now = _cst_now() - if now.weekday() >= 5: - return False - t = now.time() - return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1] - or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1]) - - -def _sector_ttl_hours() -> int: - """根据是否在交易时段返回缓存 TTL - - 交易时段: 2 分钟(数据持续变化) - - 非交易时段: 18 小时(覆盖到下一个交易日) - """ - return 0 if _is_trading_time() else 18 - - -# curl_cffi 模拟 Chrome TLS 指纹 -from curl_cffi.requests import AsyncSession - -_sector_session: Optional[AsyncSession] = None - - -def _get_sector_session() -> AsyncSession: - global _sector_session - if _sector_session is None: - _sector_session = AsyncSession( - impersonate="chrome131", - headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Referer": "https://data.eastmoney.com/bkzj/hy.html", - "Accept": "*/*", - "Accept-Language": "zh-CN,zh;q=0.9", - }, - timeout=5, - ) - return _sector_session - - -# 内存缓存(加速交易时段频繁请求) -_sector_cache: dict[str, tuple[list[dict], float]] = {} -_SECTOR_MEM_TTL = 60 - - -async def fetch_sector_list(sector_type: str) -> list[dict]: - # 1. 内存缓存 - now = time.time() - if sector_type in _sector_cache: - data, ts = _sector_cache[sector_type] - if now - ts < _SECTOR_MEM_TTL: - return data - - cache_key = f"sector_list:{sector_type}" - - # 2. 非交易时段:走磁盘持久缓存 - if not _is_trading_time(): - cached = get_cache(cache_key) - if cached is not None: - data = json.loads(cached) - _sector_cache[sector_type] = (data, now) - return data - - # 3. 并发请求 push2 和 akshare,优先使用 push2 - push2_task = asyncio.create_task(_fetch_push2(sector_type)) - akshare_task = asyncio.create_task(_fetch_akshare(sector_type)) - - push2_data = await push2_task - if push2_data: - akshare_task.cancel() - try: - await akshare_task - except asyncio.CancelledError: - pass - _sector_cache[sector_type] = (push2_data, now) - ttl = _sector_ttl_hours() - if ttl > 0: - set_cache(cache_key, json.dumps(push2_data, ensure_ascii=False), ttl_hours=ttl) - return push2_data - - # push2 失败,用 akshare(不写磁盘缓存) - akshare_data = await akshare_task - if akshare_data: - _sector_cache[sector_type] = (akshare_data, now) - return akshare_data - - -async def _fetch_push2(sector_type: str) -> list[dict]: - """东方财富 push2 API(curl_cffi 模拟浏览器 TLS 指纹)""" - fs = SECTOR_MEDIA_MAP.get(sector_type) - if not fs: - return [] - - session = _get_sector_session() - ut = await get_em_ut() - - for attempt in range(2): - url = ( - f"https://push2.eastmoney.com/api/qt/clist/get" - f"?fs={fs}&fields={SECTOR_FIELDS}" - f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2" - f"&invt=2&ut={ut}" - ) - try: - resp = await session.get(url) - if resp.status_code != 200: - if attempt == 0: - await asyncio.sleep(1) - continue - return [] - result = resp.json() - if result.get("rc") != 0: - return [] - diff = result.get("data", {}).get("diff", []) - items = [] - for item in diff: - items.append({ - "code": item.get("f12", ""), - "name": item.get("f14", ""), - "level": item.get("f2"), - "changePercent": item.get("f3"), - "changeAmount": None, - "mainNetInflow": item.get("f62", 0) or 0, - "mainNetInflowPercent": item.get("f184", 0), - "superLargeInflow": item.get("f66", 0) or 0, - "superLargeInflowPercent": item.get("f69", 0), - "largeInflow": item.get("f72", 0) or 0, - "largeInflowPercent": item.get("f75", 0), - "mediumInflow": item.get("f78", 0) or 0, - "mediumInflowPercent": item.get("f81", 0), - "smallInflow": item.get("f84", 0) or 0, - "smallInflowPercent": item.get("f87", 0), - "turnover": item.get("f70", 0) or 0, - }) - return items - except Exception as e: - err = str(e) - print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}") - # UT 可能过期,尝试刷新 - if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1: - await get_em_ut(force_refresh=True) - ut = _em_ut - # 先尝试更新现有会话的 headers - try: - session.headers.update({"Referer": "https://data.eastmoney.com/bkzj/hy.html"}) - except Exception: - pass - # 重建会话(TLS 指纹可能会被缓存) - global _sector_session - _sector_session = None - session = _get_sector_session() - if attempt == 0: - await asyncio.sleep(1) - return [] - - -import akshare as ak - - -async def _fetch_akshare(sector_type: str) -> list[dict]: - """akshare 降级方案(东方财富数据源)""" - loop = asyncio.get_event_loop() - code_map_key = f"board_codes:{sector_type}" - - def _build_code_map(): - """获取板块代码映射(HTTP 较慢,结果单独缓存 24h)""" - code_map = {} - try: - if sector_type == "industry": - code_df = ak.stock_board_industry_name_em() - else: - code_df = ak.stock_board_concept_name_em() - if code_df is not None and not code_df.empty: - for _, r in code_df.iterrows(): - code_map[str(r.get("f14", ""))] = str(r.get("f12", "")) - except Exception: - try: - if sector_type == "industry": - code_df = ak.stock_board_industry_name_ths() - else: - code_df = ak.stock_board_concept_name_ths() - if code_df is not None and not code_df.empty: - for _, r in code_df.iterrows(): - code_map[str(r.get("name", ""))] = str(r.get("code", "")) - except Exception: - pass - return code_map - - def _get_fund_flow(): - if sector_type == "industry": - return ak.stock_fund_flow_industry() - else: - return ak.stock_fund_flow_concept() - - # 1. 尝试从缓存读取 code_map - code_map = {} - cached_map = get_cache(code_map_key) - if cached_map is not None: - code_map = json.loads(cached_map) - - try: - if code_map: - # 已有缓存,只需获取资金流 - df = await loop.run_in_executor(None, _get_fund_flow) - else: - # 首次:code_map + 资金流并发获取 - map_data, df = await asyncio.gather( - loop.run_in_executor(None, _build_code_map), - loop.run_in_executor(None, _get_fund_flow), - ) - if map_data: - code_map = map_data - set_cache(code_map_key, json.dumps(code_map, ensure_ascii=False), ttl_hours=24) - - if df is None or df.empty: - return [] - df = df.sort_values("净额", ascending=False) - items = [] - for _, row in df.iterrows(): - name = str(row.get("行业", "")).strip() - inflow = float(row.get("流入资金", 0) or 0) * 100000000 - outflow = float(row.get("流出资金", 0) or 0) * 100000000 - items.append({ - "code": code_map.get(name, ""), - "name": name, - "level": float(row.get("行业指数") or 0), - "changePercent": float(row.get("行业-涨跌幅") or 0), - "changeAmount": None, - "mainNetInflow": float(row.get("净额", 0) or 0) * 100000000, - "mainNetInflowPercent": None, - "superLargeInflow": None, - "superLargeInflowPercent": None, - "largeInflow": None, - "largeInflowPercent": None, - "mediumInflow": None, - "mediumInflowPercent": None, - "smallInflow": None, - "smallInflowPercent": None, - "turnover": inflow + outflow, - }) - return items - except Exception as e: - print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}") - return [] - - # ---- 公司概况 ---- _F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"} diff --git a/backend/services/mootdx.py b/backend/services/mootdx.py index 0fc0020..25ca0b4 100644 --- a/backend/services/mootdx.py +++ b/backend/services/mootdx.py @@ -3,7 +3,6 @@ 通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。 主要用途: - K线数据:主数据源(稳定可靠) -- 板块数据:东方财富 push2 的降级方案 """ import asyncio @@ -69,60 +68,3 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]] except Exception as e: print(f"[mootdx] fetch_kline error: {e}") return None - - -# ---- 板块数据(东方财富降级方案)---- - - -def _sync_fetch_sectors(sector_type: str) -> Optional[list]: - from mootdx.consts import MARKET_SH, MARKET_SZ - - client = _create_client() - - # block() 返回 DataFrame,列:code, name 等 - # 按板块类型过滤 - block_df = client.block() - if block_df is None or block_df.empty: - return None - - items = [] - for _, row in block_df.iterrows(): - name = str(row.get("name", "") or row.get("blockname", "")) - code = str(row.get("code", "") or row.get("blockcode", "")) - if not code or not name: - continue - - items.append( - { - "code": code, - "name": name, - "level": None, - "changePercent": None, - "changeAmount": None, - "mainNetInflow": 0, - "mainNetInflowPercent": None, - "superLargeInflow": None, - "superLargeInflowPercent": None, - "largeInflow": None, - "largeInflowPercent": None, - "mediumInflow": None, - "mediumInflowPercent": None, - "smallInflow": None, - "smallInflowPercent": None, - "turnover": 0, - } - ) - - return items if items else None - - -async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]: - """获取板块列表(东方财富的降级方案,仅含代码和名称)""" - try: - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type) - except ImportError: - return None - except Exception as e: - print(f"[mootdx] fetch_sectors error: {e}") - return None diff --git a/src/lib/stock-api.ts b/src/lib/stock-api.ts index a334065..930d064 100755 --- a/src/lib/stock-api.ts +++ b/src/lib/stock-api.ts @@ -430,50 +430,3 @@ export async function fetchFinancialData(code: string, years: number = 5): Promi } } -// ---- 板块数据 ---- - -export interface SectorItem { - code: string; - name: string; - level: number | null; - changePercent: number | null; - changeAmount: number | null; - mainNetInflow: number; - mainNetInflowPercent: number | null; - superLargeInflow: number | null; - superLargeInflowPercent: number | null; - largeInflow: number | null; - largeInflowPercent: number | null; - mediumInflow: number | null; - mediumInflowPercent: number | null; - smallInflow: number | null; - smallInflowPercent: number | null; - turnover: number; -} - -export type SectorType = "industry" | "concept"; - -export interface SectorResponse { - data: SectorItem[]; - count: number; - type: SectorType; -} - -/** - * 获取东方财富板块列表(按主力净流入排序) - * @param type industry=行业板块, concept=概念板块 - */ -export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise { - const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/api/sectors?type=${type}`; - - try { - const resp = await fetch(url, { method: "GET", signal, cache: "no-store" }); - if (!resp.ok) return []; - const result: SectorResponse = await resp.json(); - return result.data || []; - } catch (err) { - console.error("[stock-api] 获取板块数据失败:", err); - return []; - } -} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c6718cb..ef2a2ec 100755 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,7 +10,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ThemesRouteImport } from './routes/themes' -import { Route as SectorsRouteImport } from './routes/sectors' import { Route as HotMapRouteImport } from './routes/hot-map' import { Route as CoreStocksRouteImport } from './routes/core-stocks' import { Route as IndexRouteImport } from './routes/index' @@ -23,11 +22,6 @@ const ThemesRoute = ThemesRouteImport.update({ path: '/themes', getParentRoute: () => rootRouteImport, } as any) -const SectorsRoute = SectorsRouteImport.update({ - id: '/sectors', - path: '/sectors', - getParentRoute: () => rootRouteImport, -} as any) const HotMapRoute = HotMapRouteImport.update({ id: '/hot-map', path: '/hot-map', @@ -63,7 +57,6 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute - '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -73,7 +66,6 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute - '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -84,7 +76,6 @@ export interface FileRoutesById { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute - '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute '/share/$code': typeof ShareCodeRoute '/stock/$code': typeof StockCodeRoute @@ -96,7 +87,6 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' - | '/sectors' | '/themes' | '/share/$code' | '/stock/$code' @@ -106,7 +96,6 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' - | '/sectors' | '/themes' | '/share/$code' | '/stock/$code' @@ -116,7 +105,6 @@ export interface FileRouteTypes { | '/' | '/core-stocks' | '/hot-map' - | '/sectors' | '/themes' | '/share/$code' | '/stock/$code' @@ -127,7 +115,6 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute CoreStocksRoute: typeof CoreStocksRoute HotMapRoute: typeof HotMapRoute - SectorsRoute: typeof SectorsRoute ThemesRoute: typeof ThemesRoute ShareCodeRoute: typeof ShareCodeRoute StockCodeRoute: typeof StockCodeRoute @@ -143,13 +130,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ThemesRouteImport parentRoute: typeof rootRouteImport } - '/sectors': { - id: '/sectors' - path: '/sectors' - fullPath: '/sectors' - preLoaderRoute: typeof SectorsRouteImport - parentRoute: typeof rootRouteImport - } '/hot-map': { id: '/hot-map' path: '/hot-map' @@ -199,7 +179,6 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CoreStocksRoute: CoreStocksRoute, HotMapRoute: HotMapRoute, - SectorsRoute: SectorsRoute, ThemesRoute: ThemesRoute, ShareCodeRoute: ShareCodeRoute, StockCodeRoute: StockCodeRoute, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8d9072e..5488668 100755 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -212,12 +212,6 @@ function Index() {

创建股票集合,分享历史走势

- - - -
- - - {/* ── Tab 切换 ── */} -
-
- {TABS.map((t) => ( - - ))} -
-
- - {/* ── 排序切换 + 统计 ── */} -
-

- 共 {cachedData.length} 个板块 - {isFetching && ( - - · 刷新中… - - )} -

-
- {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ( - - ))} -
-
- - {/* ── 内容区 ── */} -
- {/* 加载骨架 */} - {isLoading ? ( -
- {Array.from({ length: 20 }).map((_, i) => ( -
- ))} -
- ) : isError ? ( - /* 请求失败 */ -
-

数据加载失败

- -
- ) : cachedData.length === 0 ? ( - /* 数据为空 */ -
- 暂无{tab === "industry" ? "行业" : "概念"}板块数据 -
- ) : ( - /* 板块卡片网格 */ -
- {displayData.map((item) => ( - - ))} -
- )} -
-
- ); -} - -/* ============================================================ - 数值格式化 - ============================================================ */ -function fmt(val: number | null | undefined, digits = 2): string { - if (val == null) return "--"; - return val.toFixed(digits); -} - -/* ============================================================ - 板块卡片 - ============================================================ */ -function SectorCard({ item }: { item: SectorItem }) { - const change = item.changePercent; - const inflow = item.mainNetInflow; - const inflowIsPos = inflow >= 0; - const inflowPct = item.mainNetInflowPercent; - - return ( - - - {/* 板块名称 + 代码 */} -
-

- {item.name} -

- {item.code && ( - - {item.code.replace("BK", "")} - - )} -
- - {/* 涨跌幅 + 成交额 */} -
- {change != null ? ( - = 0 ? "text-red-500" : "text-green-500" - }`} - > - {change >= 0 ? ( - - ) : ( - - )} - {change >= 0 ? "+" : ""} - {fmt(change)}% - - ) : ( - -- - )} - - {formatMoney(item.turnover)} - -
- - {/* 分割线 */} -
- - {/* 主力净流入金额 + 占比 */} -
- 主力净流入 -
- - {inflow >= 0 ? "+" : ""} - {formatMoney(inflow)} - - {inflowPct != null && ( - - {inflow >= 0 ? "+" : ""} - {fmt(inflowPct)}% - - )} -
-
- - {/* 资金流向明细条 */} - -
-
- ); -} - -/* ============================================================ - 资金流向明细 — 超大单 / 大单 / 中单 / 小单 - ============================================================ */ -const FLOW_LABELS = [ - { key: "superLargeInflow" as const, label: "超大单" }, - { key: "largeInflow" as const, label: "大单" }, - { key: "mediumInflow" as const, label: "中单" }, - { key: "smallInflow" as const, label: "小单" }, -]; - -function FundFlowBreakdown({ item }: { item: SectorItem }) { - // 取所有流量的最大绝对值做归一化 - const maxAbs = Math.max( - Math.abs(item.mainNetInflow), - Math.abs(item.superLargeInflow ?? 0), - Math.abs(item.largeInflow ?? 0), - Math.abs(item.mediumInflow ?? 0), - Math.abs(item.smallInflow ?? 0), - 1, - ); - - return ( -
- {FLOW_LABELS.map((f) => { - const val = item[f.key]; - if (val == null) return null; - const pct = maxAbs > 0 ? (Math.abs(val) / maxAbs) * 100 : 0; - const isPos = val >= 0; - return ( -
- - {f.label} - -
-
-
- - {formatMoney(val)} - -
- ); - })} -
- ); -}