新增终端大洲分组实施计划 — 9个任务,从continent-grouping逻辑到响应式UI

This commit is contained in:
2569718930@qq.com
2026-05-25 01:39:34 +08:00
parent 6672f02989
commit 8fbbe54c46
@@ -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<typeof getSignalState>, 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<GapColor, string> = {
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<string, ScanOpportunityRow[]>();
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<string> {
const expanded = new Set<string>();
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 (
<button
type="button"
onClick={onToggle}
className="group flex w-full items-center gap-2 border-b border-slate-200 bg-[#eef2f6] px-3 py-2 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-slate-400 group-hover:text-slate-600">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="text-xs font-black uppercase tracking-wide text-slate-600">
{label}
</span>
<span className="text-[11px] text-slate-400">
{parts.join(" · ")}
</span>
</button>
);
}
```
- [ ] **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 (
<button
type="button"
onClick={() => onClick(row)}
className="w-full rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm hover:bg-blue-50/70 transition-colors"
>
{/* Row 1: City + Signal */}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-slate-900">
{rowName(row)}
</span>
<span className="shrink-0 text-xs font-black">
<span className={signal === "active" ? "text-emerald-600" : signal === "watch" ? "text-amber-600" : signal === "closed" ? "text-slate-400" : "text-red-500"}>
{getSignalLabel(signal, isEn)}
</span>
</span>
</div>
{/* Row 2: Obs · Gap · Market */}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-mono font-bold">
Obs {tempVal(row.current_temp, row.temp_symbol)}
</span>
<span className={gapColor}>
Gap {tempVal(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
</span>
<span className="text-slate-600">
{formatPrice(row.midpoint, row.ask, row.bid)}
</span>
</div>
{/* Row 3: High · DEB */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span>
High {tempVal(row.current_max_so_far, row.temp_symbol)}
</span>
<span>
DEB {tempVal(row.deb_prediction, row.temp_symbol)}
</span>
</div>
</button>
);
}
```
- [ ] **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 (
<div className="flex overflow-x-auto border-b border-slate-200 bg-white px-2 no-scrollbar">
{groups.map((g) => {
const label = isEn ? g.labelEn : g.labelZh;
const isActive = activeTab === g.key;
return (
<button
key={g.key}
type="button"
onClick={() => onSelectTab(g.key)}
className={clsx(
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
isActive
? "border-blue-600 text-blue-700"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
>
{label}
<span className="ml-1 text-[10px] text-slate-400">
{g.activeCount > 0 ? `${g.activeCount}A` : g.rows.length}
</span>
</button>
);
})}
</div>
);
}
```
- [ ] **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<Set<string>>(() => {
const c = new Set<string>();
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 (
<div className="overflow-auto">
<table className="w-full min-w-[800px] border-collapse text-[13px]">
<thead>
<tr className="border-b border-slate-200 bg-[#f5f7fa] text-left text-[11px] uppercase text-slate-500">
<th className="px-3 py-2 font-black">City</th>
<th className="px-2 py-2 text-right font-black">Obs</th>
<th className="px-2 py-2 text-right font-black">High</th>
<th className="px-2 py-2 text-right font-black">DEB</th>
<th className="px-2 py-2 text-right font-black">Gap</th>
<th className="px-2 py-2 text-right font-black">Market</th>
<th className="px-2 py-2 text-right font-black">Edge</th>
<th className="px-2 py-2 text-right font-black">Spr/Liq</th>
<th className="px-3 py-2 font-black">Signal</th>
</tr>
</thead>
<tbody>
{groups.map((group) => {
const isExpanded = !collapsed.has(group.key);
const label = isEn ? group.labelEn : group.labelZh;
return (
<Fragment key={group.key}>
{/* Group header row */}
<tr className="border-b border-slate-200 bg-[#eef2f6]">
<td colSpan={9} className="p-0">
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-4 w-4 place-items-center text-slate-400">
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<span className="text-[11px] font-black uppercase tracking-wide text-slate-600">
{label}
</span>
<span className="text-[10px] text-slate-400">
{group.rows.length} · {isEn ? "Active" : "活跃"} {group.activeCount} · {isEn ? "Watch" : "观察"} {group.watchCount}
{group.localTimeRange ? ` · LT ${group.localTimeRange}` : ""}
{group.hotCity ? ` · Hot: ${group.hotCity}` : ""}
</span>
</button>
</td>
</tr>
{/* Data rows */}
{isExpanded &&
group.rows.map((row) => {
const signal = getSignalState(row);
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
return (
<tr
key={row.id}
className={clsx(
"cursor-pointer border-b border-slate-100 hover:bg-blue-50/70",
selectedId === row.id && "bg-blue-50"
)}
onClick={() => onSelect(row)}
>
<td className="px-3 py-2">
<div className="font-bold text-slate-900">{rowName(row)}</div>
<div className="truncate text-[11px] text-slate-500">
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
</div>
</td>
<td className="px-2 py-2 text-right font-mono font-bold">
{temp(row.current_temp, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{temp(row.current_max_so_far, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{temp(row.deb_prediction, row.temp_symbol)}
</td>
<td className={clsx("px-2 py-2 text-right font-mono font-bold", gapColor)}>
{temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{formatPrice(row.midpoint, row.ask, row.bid)}
</td>
<td className={clsx("px-2 py-2 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
{pct(row.edge_percent)}
</td>
<td className="px-2 py-2 text-right font-mono text-[11px]">
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
</td>
<td className="px-3 py-2">
<span className={clsx(
"text-[11px] font-black",
signal === "active" ? "text-emerald-600" :
signal === "watch" ? "text-amber-600" :
signal === "closed" ? "text-slate-400" : "text-red-500"
)}>
{getSignalLabel(signal, isEn)}
</span>
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
```
需要在本文件顶部新增 `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 行)**
找到左列 `<Panel title="Weather Contracts">` 内的 `<MarketTable>`,替换为:
```tsx
<Panel title={isEn ? "Weather Contracts" : "天气合约"}>
<div className="grid grid-cols-3 border-b border-slate-200 text-center">
<div className="p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
{isEn ? "Rows" : "行数"}
</div>
<div className="font-mono text-xl font-black">{rows.length}</div>
</div>
<div className="border-x border-slate-200 p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
Avg Edge
</div>
<div className={clsx("font-mono text-xl font-black", edgeClass(avgEdge))}>
{pct(avgEdge)}
</div>
</div>
<div className="p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
Liquidity
</div>
<div className="font-mono text-xl font-black">
{money(totalLiquidity)}
</div>
</div>
</div>
<GroupedMarketTable
groups={continentGroups}
isEn={isEn}
selectedId={selectedRow?.id}
onSelect={setSelectedRow}
/>
</Panel>
```
- [ ] **Step 2: 替换中列 MarketTable (第 600-606 行)**
```tsx
<Panel title={isEn ? "All Contracts" : "全部合约"}>
<GroupedMarketTable
groups={continentGroups}
isEn={isEn}
selectedId={selectedRow?.id}
onSelect={setSelectedRow}
/>
</Panel>
```
- [ ] **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 区域)
`<main>` 内,用 `useMediaQuery` 或 Tailwind 响应式类区分桌面/移动端布局。移动端显示 `MobileRegionTabs` + `MobileCityCard` 列表。
- [ ] **Step 1: 在 KoyfinWeatherTerminal 中添加移动端状态和渲染**
在组件顶部添加:
```tsx
const [mobileTab, setMobileTab] = useState<string>("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 行)**
`<main>` 中包裹条件渲染:
```tsx
<main className="min-h-0 flex-1 overflow-auto p-2">
{/* Mobile layout */}
<div className="flex flex-col gap-2 lg:hidden">
<MobileRegionTabs
activeTab={mobileTab}
groups={continentGroups}
isEn={isEn}
onSelectTab={setMobileTab}
/>
<div className="space-y-2 px-1">
{mobileActiveGroup?.rows.map((row) => (
<MobileCityCard
key={row.id}
row={row}
isEn={isEn}
onClick={setSelectedRow}
/>
))}
</div>
</div>
{/* Desktop layout (existing 3-column grid) */}
<div className="hidden min-h-full grid-cols-1 gap-2 lg:grid xl:grid-cols-[1.12fr_1.6fr_1.1fr]">
{/* ... existing 3 columns unchanged ... */}
</div>
</main>
```
现有的三列布局代码放入 `{/* 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 successfully66 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 "终端大洲分组功能验证通过"
```