"use client"; /** * 股票代码 ↔ 名称 的统一层。 * * 需求(本轮):**任何出现股票代码的地方都必须同时显示股票名称,且可点击** * 跳到个股页(基本信息 + 股价走势图)。 * * 实现选择:不做 N+1 请求,而是启动时一次性拉全市场 `symbol → name` 映射 * (`GET /api/stocks/names`,约 6 千条),并用全局 Provider 缓存; * 页面渲染只做 Map 查询。这样每个表格单元格都是纯查表,零额外网络开销。 * * 降级策略(AGENT §24:不许假装):接口不可用时静默留空名称,但**不隐藏代码**, * 也不阻塞页面;名称缺失时渲染「—」并在 title 里说明「名称未取到」, * 避免出现「看起来有名字但其实是代码」的假象。 */ import Link from "next/link"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { apiGet } from "@/lib/api"; import type { Stock } from "@/lib/types"; type NameMap = Record; interface Ctx { names: NameMap; ready: boolean; failed: boolean; reload: () => void; } const SymbolCtx = createContext({ names: {}, ready: false, failed: false, reload: () => {} }); /** 模块级缓存:Provider 重挂载(如整页刷新)时不必重新拉取 */ let cache: NameMap | null = null; export function SymbolNamesProvider({ children }: { children: React.ReactNode }) { const [names, setNames] = useState(cache ?? {}); const [ready, setReady] = useState(cache !== null); const [failed, setFailed] = useState(false); const [nonce, setNonce] = useState(0); useEffect(() => { let alive = true; const run = async () => { try { const m = await apiGet("/stocks/names"); if (!alive) return; cache = m; setNames(m); setReady(true); setFailed(false); } catch { // 兜底:老接口(返回股票列表)。注意 `/stocks` 单页上限 500, // 若只取首页会**静默漏掉 500 名之后的股票**(出现大量「—」而不报错), // 因此这里翻页取全量,翻页失败就如实置 failed 而不是给出残缺映射。 try { const m: NameMap = {}; for (let offset = 0; offset < 20_000; offset += 500) { const rows = await apiGet(`/stocks?limit=500&offset=${offset}`); if (!alive) return; for (const r of rows) if (r.name) m[r.symbol] = r.name; if (rows.length < 500) break; } if (!Object.keys(m).length) throw new Error("股票列表为空"); cache = m; setNames(m); setReady(true); setFailed(false); } catch { if (alive) { setReady(true); setFailed(true); } } } }; void run(); return () => { alive = false; }; }, [nonce]); const reload = useCallback(() => { cache = null; setNonce((n) => n + 1); }, []); const value = useMemo(() => ({ names, ready, failed, reload }), [names, ready, failed, reload]); return {children}; } export function useSymbolNames(): Ctx { return useContext(SymbolCtx); } /** 取名称;未就绪或查不到都返回 null(调用方据此渲染占位) */ export function useSymbolName(symbol?: string | null): string | null { const { names } = useContext(SymbolCtx); if (!symbol) return null; return names[symbol] ?? null; } export interface SymbolLinkProps { symbol: string; /** 优先使用调用方已有的名称(后端 payload 里带的更权威) */ name?: string | null; /** 名称在前(个股页标题风格)还是代码在前(表格列风格) */ order?: "code-first" | "name-first"; /** 只显示名称(表格里已有独立代码列时用) */ nameOnly?: boolean; /** 只显示代码(极少用:明确不需要名称时请说明理由) */ codeOnly?: boolean; className?: string; /** 不跳转(例如已经在个股页里) */ plain?: boolean; } /** * 股票代码 + 名称,点击进入个股页。 * * 用 `` 而非 ``:保留 Next 客户端路由(不整页刷新)。 */ export function SymbolLink({ symbol, name, order = "code-first", nameOnly = false, codeOnly = false, className, plain = false, }: SymbolLinkProps) { const resolved = useSymbolName(symbol); const real = name ?? resolved; const label = real || null; const body = ( <> {!nameOnly && {symbol}} {!codeOnly && (label ? ( {label} ) : ( — ))} ); const cls = ["symlink", className].filter(Boolean).join(" "); const title = label ? `${symbol} ${label} · 查看基本信息与股价走势图` : `${symbol} · 查看基本信息与股价走势图`; if (plain) { return ( {body} ); } return ( {body} ); } /** 纯文本「代码 名称」(不可点击场景,如打印/图例) */ export function SymbolText({ symbol, name, order = "code-first", }: { symbol: string; name?: string | null; order?: "code-first" | "name-first"; }) { const resolved = useSymbolName(symbol); const label = name ?? resolved; const code = {symbol}; const nm = label ? {label} : null; return ( {order === "name-first" ? ( <> {nm} {code} ) : ( <> {code} {nm} )} ); }