diff --git a/docs/superpowers/plans/2026-05-25-terminal-continent-grouping-plan.md b/docs/superpowers/plans/2026-05-25-terminal-continent-grouping-plan.md new file mode 100644 index 00000000..93910e67 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-terminal-continent-grouping-plan.md @@ -0,0 +1,929 @@ +# 终端城市表格按大洲分组 — 实施计划 + +> **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)} + > + + + + + + + + + + + ); + })} + + ); + })} + +
CityObsHighDEBGapMarketEdgeSpr/LiqSignal
+ +
+
{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 "终端大洲分组功能验证通过" +```