# 终端城市表格按大洲分组 — 实施计划 > **For agentic workers:** 使用 superpowers:subagent-driven-development 或 superpowers:executing-plans 按任务逐步实施。 **目标:** 将扫描终端的平铺城市表格重构为按时区分组 + Active Signals 虚拟分组的金融终端风格,桌面端 9 列可折叠分组表格,移动端 Tab 切换 + 卡片流。 **架构:** 抽取分组逻辑到 `continent-grouping.ts`,新增 `ContinentGroupHeader`、`MobileCityCard`、`MobileRegionTabs` 三个子组件,重构 `ScanTerminalDashboard.tsx` 中的 `KoyfinWeatherTerminal` 和 `MarketTable`。数据字段全部就绪,后端零改动。 **技术栈:** React 19 + TypeScript + Tailwind CSS 3 + CSS Modules --- ### Task 1: 创建 continent-grouping.ts 分组逻辑 **文件:** - 创建: `frontend/components/dashboard/scan-terminal/continent-grouping.ts` 这个文件负责所有分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理、Gap 颜色映射。 - [ ] **Step 1: 写入 continent-grouping.ts** ```typescript import type { ScanOpportunityRow } from "@/lib/dashboard-types"; // 7 trading regions from backend scan_terminal_filters.py export const TRADING_REGIONS = [ { key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 }, { key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 }, { key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 }, { key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 }, { key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 }, { key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 }, { key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 }, ] as const; export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"]; export interface ContinentGroup { key: string; // "active_signals" | TradingRegionKey labelEn: string; labelZh: string; sort: number; rows: ScanOpportunityRow[]; activeCount: number; watchCount: number; hotCity: string | null; localTimeRange: string | null; } export function isActiveSignal(row: ScanOpportunityRow): boolean { const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase(); if (decision.includes("approve")) return true; if (row.tradable && row.active) return true; return false; } export function isWatchSignal(row: ScanOpportunityRow): boolean { const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase(); if (decision.includes("watch")) return true; if (decision.includes("monitor")) return true; if (!row.tradable && row.active) return true; return false; } export function isDeadSignal(row: ScanOpportunityRow): boolean { if (row.closed) return true; const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase(); if (decision.includes("veto")) return true; return false; } export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" { if (isDeadSignal(row)) return "closed"; if (isActiveSignal(row)) return "active"; if (isWatchSignal(row)) return "watch"; return "data"; } export function getSignalLabel(state: ReturnType, isEn: boolean): string { switch (state) { case "active": return isEn ? "◆ Active" : "◆ 活跃"; case "watch": return isEn ? "● Watch" : "● 观察"; case "closed": return isEn ? "○ Closed" : "○ 关闭"; case "data": return isEn ? "! Data" : "! 数据"; } } export type GapColor = "green" | "orange" | "slate" | "gray" | "red"; export function getGapColor(row: ScanOpportunityRow): GapColor { const gap = Number(row.signed_gap ?? row.gap_to_target); const edge = Number(row.edge_percent || 0); const spread = Number(row.spread || 0); const liq = Number(row.book_liquidity || row.market_liquidity || 0); if (!Number.isFinite(gap)) return "gray"; if (liq <= 0 || spread > 20) return "red"; if (gap >= 2) return "green"; if (gap >= 0 && edge > 5) return "orange"; if (gap >= 0) return "slate"; if (gap < -5 || edge < -10) return "gray"; return "slate"; } export const GAP_COLOR_MAP: Record = { green: "text-emerald-600", orange: "text-amber-600", slate: "text-slate-500", gray: "text-slate-400", red: "text-red-500", }; export function formatPrice(midpoint?: number | null, ask?: number | null, bid?: number | null): string { const m = Number(midpoint); if (Number.isFinite(m) && m > 0) { const cents = Math.round(m * 100); return `Y ${cents}¢`; } const a = Number(ask); if (Number.isFinite(a) && a > 0) { const cents = Math.round(a * 100); return `Y ${cents}¢`; } return "--"; } export function formatSpreadLiquidity(spread?: number | null, liquidity?: number | null): string { const sp = Number(spread); const liq = Number(liquidity); const spStr = Number.isFinite(sp) ? `${Math.round(sp)}¢` : "--"; const liqStr = Number.isFinite(liq) ? liq >= 1000 ? `$${(liq / 1000).toFixed(1)}K` : `$${Math.round(liq)}` : "--"; return `${spStr} / ${liqStr}`; } export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] { const regionMap = new Map(); for (const row of rows) { const region = String(row.trading_region || "unknown").toLowerCase(); if (!regionMap.has(region)) regionMap.set(region, []); regionMap.get(region)!.push(row); } const groups: ContinentGroup[] = []; // Active Signals virtual group const activeRows = rows.filter((r) => isActiveSignal(r)); if (activeRows.length > 0) { const hotRow = activeRows.reduce((best, r) => Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best ); groups.push({ key: "active_signals", labelEn: "Active Signals", labelZh: "活跃信号", sort: 0, rows: activeRows, activeCount: activeRows.filter((r) => isActiveSignal(r)).length, watchCount: activeRows.filter((r) => isWatchSignal(r)).length, hotCity: hotRow?.city_display_name || hotRow?.city || null, localTimeRange: null, }); } for (const region of TRADING_REGIONS) { const regionRows = regionMap.get(region.key) || []; if (regionRows.length === 0) continue; const activeCount = regionRows.filter((r) => isActiveSignal(r)).length; const watchCount = regionRows.filter((r) => isWatchSignal(r)).length; const sorted = [...regionRows].sort((a, b) => Number(b.final_score || 0) - Number(a.final_score || 0) ); const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null; // Compute local time range for this region const times = regionRows.map((r) => String(r.local_time || "").trim()).filter(Boolean); const ltRange = times.length >= 2 ? `${times[0]}-${times[times.length - 1]}` : times[0] || null; groups.push({ key: region.key, labelEn: region.labelEn, labelZh: region.labelZh, sort: region.sort, rows: regionRows, activeCount, watchCount, hotCity, localTimeRange: ltRange, }); } // Sort: active_signals first, then by trading_region_sort groups.sort((a, b) => a.sort - b.sort); return groups; } export function getDefaultExpanded(groups: ContinentGroup[]): Set { const expanded = new Set(); for (const g of groups) { if (g.key === "active_signals") { expanded.add(g.key); } else if (g.activeCount > 0 || g.watchCount > 0) { expanded.add(g.key); } } return expanded; } ``` - [ ] **Step 2: 验证 TypeScript 类型** ```bash cd frontend && npx tsc --noEmit src/components/dashboard/scan-terminal/continent-grouping.ts 2>&1 || true ``` 预期:类型通过(可能有模块路径的错误,这些在组件集成后解决)。 - [ ] **Step 3: 提交** ```bash git add frontend/components/dashboard/scan-terminal/continent-grouping.ts git commit -m "新增终端大洲分组逻辑模块" ``` --- ### Task 2: 创建 ContinentGroupHeader.tsx 分组标题行组件 **文件:** - 创建: `frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx` - [ ] **Step 1: 写入组件** ```tsx "use client"; import { ChevronDown, ChevronRight } from "lucide-react"; import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping"; export function ContinentGroupHeader({ group, isExpanded, isEn, onToggle, }: { group: ContinentGroup; isExpanded: boolean; isEn: boolean; onToggle: () => void; }) { const label = isEn ? group.labelEn : group.labelZh; const parts: string[] = [`${group.rows.length}`]; if (group.activeCount > 0) { parts.push(isEn ? `Active ${group.activeCount}` : `活跃 ${group.activeCount}`); } if (group.watchCount > 0) { parts.push(isEn ? `Watch ${group.watchCount}` : `观察 ${group.watchCount}`); } if (group.localTimeRange) { parts.push(`LT ${group.localTimeRange}`); } if (group.hotCity) { parts.push(isEn ? `Hot: ${group.hotCity}` : `热门: ${group.hotCity}`); } return ( ); } ``` - [ ] **Step 2: 提交** ```bash git add frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx git commit -m "新增终端大洲分组标题行组件" ``` --- ### Task 3: 创建 MobileCityCard.tsx 移动端卡片组件 **文件:** - 创建: `frontend/components/dashboard/scan-terminal/MobileCityCard.tsx` - [ ] **Step 1: 写入组件** ```tsx "use client"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { formatPrice, formatSpreadLiquidity, GAP_COLOR_MAP, getGapColor, getSignalLabel, getSignalState, } from "@/components/dashboard/scan-terminal/continent-grouping"; function rowName(row: ScanOpportunityRow) { return row.city_display_name || row.display_name || row.city || "--"; } function tempVal(value?: number | null, symbol?: string | null) { const n = Number(value); if (!Number.isFinite(n)) return "--"; return `${n.toFixed(1)}${symbol || "°"}`; } export function MobileCityCard({ isEn, onClick, row, }: { isEn: boolean; onClick: (row: ScanOpportunityRow) => void; row: ScanOpportunityRow; }) { const signal = getSignalState(row); const gapColor = GAP_COLOR_MAP[getGapColor(row)]; return ( ); } ``` - [ ] **Step 2: 提交** ```bash git add frontend/components/dashboard/scan-terminal/MobileCityCard.tsx git commit -m "新增终端移动端城市卡片组件" ``` --- ### Task 4: 创建 MobileRegionTabs.tsx 移动端 Tab 栏 **文件:** - 创建: `frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx` - [ ] **Step 1: 写入组件** ```tsx "use client"; import clsx from "clsx"; import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping"; export function MobileRegionTabs({ activeTab, groups, isEn, onSelectTab, }: { activeTab: string; groups: ContinentGroup[]; isEn: boolean; onSelectTab: (key: string) => void; }) { return (
{groups.map((g) => { const label = isEn ? g.labelEn : g.labelZh; const isActive = activeTab === g.key; return ( ); })}
); } ``` - [ ] **Step 2: 提交** ```bash git add frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx git commit -m "新增终端移动端大洲 Tab 栏组件" ``` --- ### Task 5: 重构 ScanTerminalDashboard.tsx — 重写 MarketTable 为分组表格 **文件:** - 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (替换 `MarketTable` 函数) 这个步骤将现有的 `MarketTable` 替换为支持分组标题行的新版本 `GroupedMarketTable`。 - [ ] **Step 1: 替换 MarketTable 组件 (第 245-323 行)** 删除旧 `MarketTable`,替换为: ```tsx import { ChevronDown, ChevronRight } from "lucide-react"; import { ContinentGroup, buildContinentGroups, formatPrice, formatSpreadLiquidity, GAP_COLOR_MAP, getDefaultExpanded, getGapColor, getSignalLabel, getSignalState, } from "@/components/dashboard/scan-terminal/continent-grouping"; function GroupedMarketTable({ groups, isEn, onSelect, selectedId, }: { groups: ContinentGroup[]; isEn: boolean; onSelect: (row: ScanOpportunityRow) => void; selectedId?: string | null; }) { const [collapsed, setCollapsed] = useState>(() => { const c = new Set(); const defaultExpanded = getDefaultExpanded(groups); for (const g of groups) { if (!defaultExpanded.has(g.key)) c.add(g.key); } return c; }); const toggleGroup = (key: string) => { setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; return (
{groups.map((group) => { const isExpanded = !collapsed.has(group.key); const label = isEn ? group.labelEn : group.labelZh; return ( {/* Group header row */} {/* Data rows */} {isExpanded && group.rows.map((row) => { const signal = getSignalState(row); const gapColor = GAP_COLOR_MAP[getGapColor(row)]; return ( onSelect(row)} > ); })} ); })}
City Obs High DEB Gap Market Edge Spr/Liq Signal
{rowName(row)}
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
{temp(row.current_temp, row.temp_symbol)} {temp(row.current_max_so_far, row.temp_symbol)} {temp(row.deb_prediction, row.temp_symbol)} {temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)} {formatPrice(row.midpoint, row.ask, row.bid)} {pct(row.edge_percent)} {formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)} {getSignalLabel(signal, isEn)}
); } ``` 需要在本文件顶部新增 `import { Fragment, ... }` from react。 - [ ] **Step 2: 更新 import 语句 (文件第 1 行)** 将 `import { useEffect, useMemo, useState } from "react";` 改为: ```tsx import { Fragment, useEffect, useMemo, useState } from "react"; ``` - [ ] **Step 3: 验证 TypeScript** ```bash cd frontend && npx tsc --noEmit 2>&1 | head -30 ``` - [ ] **Step 4: 提交** ```bash git add frontend/components/dashboard/ScanTerminalDashboard.tsx git commit -m "终端表格重构为9列时区分组布局:新增 City/Obs/High/DEB/Gap/Market/Edge/SprLiq/Signal 列" ``` --- ### Task 6: 重构 KoyfinWeatherTerminal — 接入分组逻辑 **文件:** - 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal`) 在 `KoyfinWeatherTerminal` 中引入 `buildContinentGroups`,将左列 `MarketTable` 替换为 `GroupedMarketTable`,中列 `MarketTable` 替换为 `GroupedMarketTable`,右列 Watchlist 保留列表展示。 - [ ] **Step 1: 修改 KoyfinWeatherTerminal 中的左列表格部分 (第 460-491 行)** 找到左列 `` 内的 ``,替换为: ```tsx
{isEn ? "Rows" : "行数"}
{rows.length}
Avg Edge
{pct(avgEdge)}
Liquidity
{money(totalLiquidity)}
``` - [ ] **Step 2: 替换中列 MarketTable (第 600-606 行)** ```tsx ``` - [ ] **Step 3: 在 KoyfinWeatherTerminal 中计算 continentGroups** 在组件内部,紧接 `const selectedLabel = ...` 之后添加: ```tsx const continentGroups = useMemo( () => buildContinentGroups(rows, isEn), [rows, isEn] ); ``` - [ ] **Step 4: 提交** ```bash git add frontend/components/dashboard/ScanTerminalDashboard.tsx git commit -m "终端三列布局接入大洲分组数据" ``` --- ### Task 7: 实现移动端响应式布局 — Tab + 卡片流 **文件:** - 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal` main 区域) 在 `
` 内,用 `useMediaQuery` 或 Tailwind 响应式类区分桌面/移动端布局。移动端显示 `MobileRegionTabs` + `MobileCityCard` 列表。 - [ ] **Step 1: 在 KoyfinWeatherTerminal 中添加移动端状态和渲染** 在组件顶部添加: ```tsx const [mobileTab, setMobileTab] = useState("active_signals"); ``` 在 `continentGroups` 计算后添加: ```tsx const mobileActiveGroup = useMemo( () => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0], [continentGroups, mobileTab] ); useEffect(() => { if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) { setMobileTab(continentGroups[0].key); } }, [continentGroups, mobileTab]); ``` - [ ] **Step 2: 修改 main 区域为响应式双布局 (第 458-673 行)** 在 `
` 中包裹条件渲染: ```tsx
{/* Mobile layout */}
{mobileActiveGroup?.rows.map((row) => ( ))}
{/* Desktop layout (existing 3-column grid) */}
{/* ... existing 3 columns unchanged ... */}
``` 现有的三列布局代码放入 `{/* Desktop layout */}` 块中,用 `hidden lg:grid` 控制显示。 - [ ] **Step 3: 验证 TypeScript** ```bash cd frontend && npx tsc --noEmit 2>&1 | head -40 ``` - [ ] **Step 4: 提交** ```bash git add frontend/components/dashboard/ScanTerminalDashboard.tsx git commit -m "终端新增移动端 Tab+卡片流响应式布局" ``` --- ### Task 8: 添加 CSS 样式 — 分组行和移动端卡片 **文件:** - 创建: `frontend/components/dashboard/ScanTerminalContinent.module.css` - 修改: `frontend/components/dashboard/scan-root-styles.ts` - [ ] **Step 1: 写入 CSS Module** ```css /* ScanTerminalContinent.module.css */ .root { --gap-green: #16a34a; --gap-orange: #ea580c; --gap-slate: #475569; --gap-gray: #94a3b8; --gap-red: #dc2626; } /* Group header */ .groupHeader { background: #eef2f6; border-bottom: 1px solid #cbd5e1; } .groupHeader:hover { background: #e2e8f0; } /* Mobile: hide scrollbar on tab bar */ .mobileTabs { scrollbar-width: none; -ms-overflow-style: none; } .mobileTabs::-webkit-scrollbar { display: none; } /* Mobile card */ .mobileCard { border: 1px solid #e2e8f0; background: #ffffff; border-radius: 8px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .mobileCard:hover { background: #f0f7ff; } /* Signal badge */ .signalActive { color: #059669; } .signalWatch { color: #d97706; } .signalClosed { color: #94a3b8; } .signalData { color: #dc2626; } /* Light theme */ :global(html.light) .groupHeader { background: #f8fafc; border-bottom-color: #e2e8f0; } :global(html.light) .groupHeader:hover { background: #f1f5f9; } :global(html.light) .mobileCard { background: #ffffff; border-color: #e2e8f0; } ``` - [ ] **Step 2: 注册到 scan-root-styles.ts barrel** 读取 `frontend/components/dashboard/scan-root-styles.ts`,追加: ```typescript import sContinent from "@/components/dashboard/ScanTerminalContinent.module.css"; // ... add sContinent.root to the barrel export ``` - [ ] **Step 3: 提交** ```bash git add frontend/components/dashboard/ScanTerminalContinent.module.css frontend/components/dashboard/scan-root-styles.ts git commit -m "新增终端大洲分组与移动端卡片样式" ``` --- ### Task 9: 最终验证与构建 - [ ] **Step 1: TypeScript 类型检查** ```bash cd frontend && npx tsc --noEmit ``` 预期:0 errors。 - [ ] **Step 2: 生产构建** ```bash cd frontend && npm run build ``` 预期:✓ Compiled successfully,66 pages generated。 - [ ] **Step 3: 启动 dev server 验证 UI** ```bash cd frontend && npm run dev ``` 打开 http://localhost:3000/terminal 验证: - 桌面端:Active Signals 展开显示,时区分组折叠/展开,9 列表格 - 移动端:Tab 切换流畅,卡片流显示正常 - 双主题切换无样式错误 - [ ] **Step 4: 提交** ```bash git add -A git commit -m "终端大洲分组功能验证通过" ```