From 5d6f0e03f688a9970f7f71b835d0883f2df13b6b Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 27 May 2026 04:49:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(whale-monitor):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E9=80=89=E6=8B=A9=E9=A1=B5=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E7=B1=BB=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 体育联赛通过 series_id 展示单场比赛,长期市场分块可折叠 - 移除全部分类,支持搜索与分类切换缓存、首屏自动加载 - 单组列表扁平展示,列表行不展示图片(联赛下拉保留 logo) - 补充大单监听功能说明与 Code Review 清单文档 Co-authored-by: Cursor --- .../agent-code-review-checklist.md | 97 +++++++++ docs/zh/market-whale-monitor/spec.md | 193 +++++++++++++++++ .../WhaleMonitorMarketGroupedList.tsx | 200 +++++++++++++----- .../components/WhaleMonitorMarketListItem.tsx | 7 +- frontend/src/locales/en/common.json | 2 +- frontend/src/locales/zh-CN/common.json | 2 +- frontend/src/locales/zh-TW/common.json | 2 +- .../src/pages/WhaleMonitorMarketSelect.tsx | 176 ++++++++++++--- 8 files changed, 594 insertions(+), 85 deletions(-) create mode 100644 docs/zh/market-whale-monitor/agent-code-review-checklist.md create mode 100644 docs/zh/market-whale-monitor/spec.md diff --git a/docs/zh/market-whale-monitor/agent-code-review-checklist.md b/docs/zh/market-whale-monitor/agent-code-review-checklist.md new file mode 100644 index 0000000..6b93d9f --- /dev/null +++ b/docs/zh/market-whale-monitor/agent-code-review-checklist.md @@ -0,0 +1,97 @@ +# Agent 代码产出 Review Checklist + +用于:每次 Agent 完成一段功能开发/修复后,你在合并/上线前做快速、系统性的代码审查。 + +## 0. 变更范围(先看清改了什么) +- **git diff 是否可控**:只包含本需求相关文件,避免“顺手重构”带来风险 +- **是否引入新依赖**:新增依赖是否必要、是否有替代、是否影响体积/启动速度 +- **配置变更**:是否修改了 `application.properties`(避免无意义的默认值/硬编码) +- **敏感信息**:确认没有提交 `.env`、私钥、API Key、Token、RPC URL 等 + +## 1. 需求一致性(功能层验收) +- **需求点逐条对照**:每个需求都有对应实现与测试路径 +- **边界条件**:空数据、异常响应、重复触发、网络抖动、重连等是否考虑 +- **失败行为明确**:API/下单失败时是否记录失败原因并可追踪(禁止“失败返回默认值”掩盖问题) +- **幂等性**:重复请求/重复消息/并发触发不会导致重复下单或重复落库 + +## 2. 后端(Kotlin / Spring)检查 + +### 2.1 项目规范硬约束 +- **禁止**出现 `TODO/FIXME/XXX` 注释 +- **Controller** + - 统一 `@PostMapping` + - **不能**使用 `suspend` + - 调用 suspend 方法时使用 `runBlocking`,且范围最小化 +- **数值与时间** + - 金额/数量/价格计算:**必须 BigDecimal**(避免 Double) + - DTO/Entity 金额字段:优先 **String 存储**(计算时再转 BigDecimal) + - 时间字段:**Long 毫秒时间戳** +- **JSON** + - 优先使用扩展函数 `fromJson()` / `toJson()`,不要直接 new `Gson` +- **API 响应** + - 统一 `ApiResponse { code,data,msg }` + - 错误信息通过 `ErrorCode + MessageSource`,不要硬编码中英文 + - 响应里不要直接返回 `Map` + +### 2.2 事务与并发 +- **@Transactional 边界合理**:避免把网络请求(外部 API/WS)包进大事务 +- **并发安全**:涉及下单/触发等必须考虑并发(数据库唯一键、Mutex、幂等表等) +- **重试策略**:重试次数、间隔、是否每次重试重新签名/更新 salt(如适用) + +### 2.3 可观测性与排错 +- **日志可用**:失败路径有 `logger.error`(包含关键上下文:strategyId/market/tokenId/orderId) +- **告警/通知**:关键失败是否能被用户看到(UI/Telegram/推送) +- **性能**:WS 高吞吐场景是否有“快速过滤”避免全量 JSON 解析 + +## 3. 数据库与实体(如有) +- **Entity 规范** + - `id: Long? = null` + - 时间:Long 毫秒 + - 数值字段:DB 用 Decimal,代码用 String/BigDecimal(按项目既定规则) +- **索引/唯一约束** + - 幂等表是否有唯一键(例如 `strategyId + windowStart + tokenId`) +- **字段默认值** + - data class 字段必须提供默认值(字符串空串、集合 emptyList 等) +- **迁移脚本** + - 是否存在(如果项目采用迁移工具),是否可回滚/可重复执行 + +## 4. 前端(React + TypeScript)检查 + +### 4.1 项目规范硬约束 +- **禁止 any**:没有 `any`、没有 `@ts-ignore`(除非明确理由) +- **禁止硬编码文案**:所有 UI 文案都走 `react-i18next` 的 `t('...')` +- **移动端适配**:关键页面在 <768px 下可用(按钮 ≥ 44x44) +- **金额展示** + - 统一使用现有格式化函数(如 `formatUSDC` 或同类函数),不要手写 `toFixed` + +### 4.2 类型与接口 +- API 请求/响应类型是否完整、字段命名与后端一致 +- 表单校验是否与后端一致(例如价格 0~1、最多两位小数) +- 错误处理是否统一(message/notification、loading 状态) + +## 5. 交易/风控相关(若涉及下单) +- **价格区间/精度校验**:前后端双重校验一致 +- **深度/滑点校验**:下单前是否检查订单簿深度足够 +- **限额** + - 单次最大金额 + - 每日最大金额/最大订单数 + - cooldown/防连点(策略级) +- **订单类型**:是否符合预期(例如 FAK 快速成交,避免挂单风险) +- **失败不兜底**:下单失败不应该假装成功;必须可追踪与可重试 + +## 6. 安全与合规 +- 私钥/API Secret 解密使用是否符合现有封装(避免重复实现) +- 日志里是否泄露私钥、完整 API Key/Secret、签名原文等 +- 外部请求是否走统一的 Retrofit/拦截器(避免绕开认证/代理配置) + +## 7. 本地验证(建议最小化操作清单) +- `git status` 干净(除了预期新增/修改文件) +- 后端:能编译通过(至少 `./gradlew test` 或 `./gradlew bootJar`,按你们习惯) +- 前端:能构建通过(`pnpm build`/`npm run build`,按你们项目) +- 关键路径手测:创建策略 → 触发 → 过滤/下单 → 记录/通知 + +## 8. 合并前最后一眼 +- 变更是否可回滚(开关、配置、禁用策略等) +- 默认值是否安全(默认禁用自动下单/默认阈值合理/冷却存在) +- 文档是否更新(字段、接口、流程) + diff --git a/docs/zh/market-whale-monitor/spec.md b/docs/zh/market-whale-monitor/spec.md new file mode 100644 index 0000000..7e90ef7 --- /dev/null +++ b/docs/zh/market-whale-monitor/spec.md @@ -0,0 +1,193 @@ +# 市场大单监听策略(10 秒聚合) + +## 一、需求概述 + +目标:对用户配置的**自选市场列表**做实时成交监听。当某个市场的某个 outcome(`tokenId`)在短时间窗口内出现显著的买入成交额(可由多个成交组成),认为存在“聪明钱/趋势”,系统按策略配置自动下单。 + +本策略的关键特性: +- **监听数据源**:Polymarket Activity WebSocket(`topic=activity,type=trades`) +- **聚合粒度**:按 **`tokenId + side`** 聚合(每个 outcome 单独统计) +- **触发方向**:默认只做 **BUY**(可扩展) +- **触发窗口**:默认 10 秒(可配置) +- **触发阈值**:窗口内累计成交额(金额)达到阈值 +- **执行方式**:触发后**自动下单**,并且 **跟随触发成交的 `tokenId`** +- **价格区间**:只在 **下单价** 落入配置区间时才下单(0~1,最多两位小数) + +## 二、数据与名词 + +### 2.1 Activity Trade 关键字段 + +从 Activity WS trade 消息中我们关注: +- `payload.conditionId`:市场 ID(conditionId) +- `payload.asset`:outcome 的 **tokenId**(下单必须用) +- `payload.side`:`BUY` / `SELL` +- `payload.price`:成交价(0~1 的概率价) +- `payload.size`:成交数量(shares) +- `payload.timestamp`:成交时间 +- `payload.transactionHash`:去重用交易哈希(同一笔可能在不同类型推送里出现) + +### 2.2 成交额(金额)定义 + +每条成交的金额计算: + +\[ +notional = price \times size +\] + +其中: +- `price` 取 BigDecimal(字符串化后再解析) +- `size` 取 BigDecimal +- `notional` 作为本策略的累计指标与阈值比较对象 + +说明: +- 前端展示可以使用 `$` 符号,但后端计算统一使用 `String + BigDecimal`,不要在代码里绑定具体稳定币名称。 + +## 三、产品流程 + +### 3.1 策略创建/编辑(前端) + +用户创建一条“大单监听策略”时需要配置: + +- **基础信息** + - 策略名称 + - 绑定账户(下单账户) + - 是否启用 + +- **监听范围** + - 自选市场列表(按 `conditionId`,可多选) + +- **触发条件** + - 窗口秒数 `windowSeconds`(默认 10) + - 触发阈值 `thresholdAmount`(金额) + - 冷却时间 `cooldownSeconds`(默认例如 60) + +- **下单参数** + - 固定下单金额 `orderAmount`(金额) + - `priceTolerance`(可选,用于提高成交概率的容忍度) + +- **价格区间过滤(按下单价)** + - `minPrice` / `maxPrice` + - 取值范围:0~1 + - 精度:最多两位小数 + - 含义:仅当“最终下单价(orderPrice)”满足 `minPrice <= orderPrice <= maxPrice` 才下单;否则视为被过滤(记录过滤原因)。 + +### 3.2 运行时触发与执行 + +简化流程: + +``` +收到 trade 消息 + ↓ +按 tokenId+BUY 入 10 秒滑动窗口,累计 notional + ↓ +累计 notional ≥ thresholdAmount ? + ├─ 否:继续 + └─ 是: + ↓ + cooldown 内已触发过 ? + ├─ 是:记录/忽略,不下单 + └─ 否: + ↓ + 读取订单簿 bestAsk 计算最终下单价 orderPrice + ↓ + orderPrice 是否在 [minPrice,maxPrice] ? + ├─ 否:记录过滤原因,不下单 + └─ 是: + ↓ + 用固定金额 orderAmount 计算 sizeShares + ↓ + 深度/滑点风控通过? + ↓ + FAK 下单(tokenId=触发tokenId) + ↓ + 记录触发与订单结果、推送通知 +``` + +## 四、技术方案(后端) + +### 4.1 监听与过滤 + +推荐新增独立服务(与跟单监听解耦),但复用现有基础设施: +- WebSocket 客户端:`backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt` +- Activity WS 协议:`docs/zh/polymarket-activity-websocket-api.md` + +性能要点: +- 订阅全局 trades 可能消息频率较高,必须先做**快速字符串过滤**(按 `conditionId` 集合)再 JSON 解析。 + +### 4.2 10 秒滑动窗口聚合(按 tokenId+BUY) + +聚合 key: +- `AggKey = (conditionId, tokenId, side)`,本需求默认只统计 `side=BUY` + +每个 key 维护: +- `ArrayDeque` +- `runningSum`:当前窗口累计 notional + +每条新事件: +- 入队并 `runningSum += notional` +- 清理过期事件并 `runningSum -= expiredNotional` +- 若 `runningSum >= thresholdAmount` 且不在 cooldown 内,则触发执行 + +去重: +- 使用 `txHash` TTL 去重,避免重复触发/重复入窗 + +### 4.3 冷却与幂等 + +冷却: +- 对同一 `strategyId + tokenId + side` 记录最近触发时间,`cooldownSeconds` 内不重复触发下单。 + +幂等落库: +- 触发记录表建议包含:`strategyId, conditionId, tokenId, side, windowStartTs, windowEndTs, windowSumNotional, status, failReason, createdAt` +- 唯一键建议:`strategyId + tokenId + side + windowStartTs`(窗口起点按 `floor(ts/windowMs)*windowMs`) + +### 4.4 下单执行(FAK,跟随 tokenId) + +关键步骤: +- 读取订单簿 `bestAsk` +- 生成最终下单价 `orderPrice` + - 若使用 `priceTolerance`:可按 `orderPrice = bestAsk * (1 + priceTolerance)`,并根据业务限制做截位 +- **价格区间校验**(必须) + - `minPrice/maxPrice` 均为字符串配置,解析为 BigDecimal + - 必须满足:0~1 范围、最多两位小数(配置校验与下单前双重校验) + - 最终判断:`minPrice <= orderPrice <= maxPrice` +- 计算下单数量: + - `sizeShares = orderAmount / orderPrice` + - 使用你们统一的 BigDecimal 扩展函数做除法精度与四舍五入策略 +- 风控建议: + - 订单簿深度能否覆盖 `orderAmount`(必要) + - 单次最大金额 / 日累计金额(建议) + - 最大允许价差/滑点(建议) +- 提交订单: + - 复用 `OrderSigningService.createAndSignOrder` + - `orderType = FAK`(允许部分成交,未成交部分取消) + +## 五、配置校验规则 + +### 5.1 价格区间(minPrice/maxPrice) + +规则: +- 范围:`0 <= price <= 1` +- 精度:最多两位小数 + +建议校验点: +- 策略创建/更新时校验(后端) +- 下单前再次校验(避免异常数据导致下单) + +### 5.2 价格与金额数据类型 + +- 后端 DTO/Entity 使用 `String` 存储金额与价格(保持统一,与现有跟单/策略一致) +- 计算时转换为 BigDecimal +- 禁止使用 `Double` 做金额/价格运算 + +## 六、可观测性与通知 + +建议输出与推送: +- 触发事件:包含市场、tokenId、窗口累计金额、下单价、是否通过区间、是否下单 +- 下单结果:订单 ID、成交数量/均价(如可获取)、失败原因 + +## 七、后续扩展(可选) + +- 支持 SELL 方向(做反向策略或止损策略) +- 触发条件增加“成交笔数”“大额单笔阈值”等组合条件 +- 支持“市场整体聚合”(按 conditionId 聚合,而不是 tokenId)作为另一种策略类型 + diff --git a/frontend/src/components/WhaleMonitorMarketGroupedList.tsx b/frontend/src/components/WhaleMonitorMarketGroupedList.tsx index 993f3c8..d02020e 100644 --- a/frontend/src/components/WhaleMonitorMarketGroupedList.tsx +++ b/frontend/src/components/WhaleMonitorMarketGroupedList.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor' import type { WhaleMonitorMarketItem } from '../types' import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem' -import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail' const { Text, Title } = Typography @@ -16,31 +15,44 @@ interface WhaleMonitorMarketGroupedListProps { onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void } +const renderFlatMarketList = ( + markets: WhaleMonitorMarketItem[], + selectedMap: Map, + isMobile: boolean, + onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void, + compact = false +) => ( +
+ {markets.map(market => ( + onToggleMarket(market, checked)} + /> + ))} +
+) + const renderMarketGroups = ( groups: WhaleMonitorMarketGroup[], selectedMap: Map, isMobile: boolean, onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void, onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void, - t: (key: string, options?: Record) => string + t: (key: string, options?: Record) => string, + defaultActiveKeys?: string[] ) => { - const showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:')) - - if (!showAsGroups) { - const flatMarkets = groups.flatMap(g => g.markets) - return ( -
- {flatMarkets.map(market => ( - onToggleMarket(market, checked)} - /> - ))} -
+ if (groups.length <= 1) { + return renderFlatMarketList( + groups.flatMap(g => g.markets), + selectedMap, + isMobile, + onToggleMarket, + groups.length === 1 && groups[0].key.startsWith('event:') ) } @@ -62,16 +74,13 @@ const renderMarketGroups = ( paddingRight: 8 }} > -
- -
- - {group.title} - - - {t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })} - -
+
+ + {group.title} + + + {t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })} +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> g.key)} + defaultActiveKey={defaultActiveKeys ?? groups.map(g => g.key)} items={collapseItems} style={{ background: 'transparent' }} /> ) } +const renderSectionHeader = (sectionTitle: string, marketCount: number) => ( +
+ + {sectionTitle} + + + ({marketCount}) + +
+) + +const renderSectionBody = ( + section: { key: 'game' | 'season'; groups: WhaleMonitorMarketGroup[] }, + selectedMap: Map, + isMobile: boolean, + onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void, + onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void, + t: (key: string, options?: Record) => string +) => { + const isSeason = section.key === 'season' + return renderMarketGroups( + section.groups, + selectedMap, + isMobile, + onToggleMarket, + onToggleGroup, + t, + isSeason ? [] : section.groups.map(g => g.key) + ) +} + const WhaleMonitorMarketGroupedList: React.FC = ({ markets, selectedMap, @@ -131,37 +171,95 @@ const WhaleMonitorMarketGroupedList: React.FC sum + s.groups.length, 0) + const allMarkets = sections.flatMap(s => s.groups.flatMap(g => g.markets)) + + if (totalGroups <= 1) { + return renderFlatMarketList(allMarkets, selectedMap, isMobile, onToggleMarket) + } + if (sections.length === 1) { const section = sections[0] - const showSectionTitle = - markets.some(m => m.marketType === 'game' || m.marketType === 'season') || - section.groups.some(g => g.key.startsWith('event:')) + const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0) + + if (section.key === 'season' && section.groups.length > 1) { + return ( + + ) + } + return ( <> - {showSectionTitle && ( - - {section.title} - - )} - {renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} + + {section.title} + <Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}> + ({marketCount}) + </Text> + + {renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} ) } return (
- {sections.map((section, index) => ( -
- {index > 0 && } - - {section.title} - <Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}> - ({section.groups.reduce((sum, g) => sum + g.markets.length, 0)}) - </Text> - - {renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} -
- ))} + {sections.map((section, index) => { + const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0) + return ( +
+ {index > 0 && } + {section.key === 'season' && section.groups.length > 1 ? ( + + ) : ( + <> + + {section.title} + <Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}> + ({marketCount}) + </Text> + + {renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} + + )} +
+ ) + })}
) } diff --git a/frontend/src/components/WhaleMonitorMarketListItem.tsx b/frontend/src/components/WhaleMonitorMarketListItem.tsx index c0bbbda..7336cc8 100644 --- a/frontend/src/components/WhaleMonitorMarketListItem.tsx +++ b/frontend/src/components/WhaleMonitorMarketListItem.tsx @@ -1,10 +1,9 @@ import { Checkbox, Tag, Typography } from 'antd' import { LinkOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor' +import { parseMarketOutcomes } from '../constants/whaleMonitor' import type { WhaleMonitorMarketItem } from '../types' import { formatUSDC } from '../utils' -import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail' const { Text } = Typography @@ -33,9 +32,6 @@ const WhaleMonitorMarketListItem: React.FC = ({ const volumeDisplay = market.volume && parseFloat(market.volume) > 0 ? formatUSDC(market.volume) : null const polymarketUrl = market.slug ? `https://polymarket.com/event/${market.slug}` : null - const imageUrl = pickMarketImageUrl(market) - const thumbSize = compact ? 36 : isMobile ? 40 : 44 - const handleOpenLink = (e: React.MouseEvent) => { e.stopPropagation() if (polymarketUrl) { @@ -72,7 +68,6 @@ const WhaleMonitorMarketListItem: React.FC = ({ onChange={e => onToggle(e.target.checked)} style={{ marginTop: 2 }} /> -
{market.eventTitle && !hideEventTitle && ( diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index c6a3609..9a348a5 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1907,7 +1907,7 @@ "subtitle": "Filter by category or search by name. Multiple selection supported.", "back": "Back", "searchPlaceholder": "Search market name (min. 2 characters)", - "searchHint": "Enter keywords or pick a category above", + "searchHint": "Pick a category above, or enter at least 2 characters to search", "sportsGamesHint": "Sports leagues show individual game markets; search by team name", "sportsGamesEmpty": "No active game markets for this league. Try searching a team name or check back later.", "empty": "No markets found", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index b0ef18d..dfad0dc 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1907,7 +1907,7 @@ "subtitle": "按分类筛选或搜索市场名称,可多选", "back": "返回", "searchPlaceholder": "搜索市场名称(至少 2 个字符)", - "searchHint": "请输入关键词搜索,或选择上方分类筛选", + "searchHint": "请选择上方分类,或输入至少 2 个字符搜索", "sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索", "sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试", "empty": "未找到匹配的市场", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 5719d10..402f941 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1907,7 +1907,7 @@ "subtitle": "按分類篩選或搜索市場名稱,可多選", "back": "返回", "searchPlaceholder": "搜索市場名稱(至少 2 個字符)", - "searchHint": "請輸入關鍵詞搜索,或選擇上方分類篩選", + "searchHint": "請選擇上方分類,或輸入至少 2 個字符搜索", "sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索", "sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試", "empty": "未找到匹配的市場", diff --git a/frontend/src/pages/WhaleMonitorMarketSelect.tsx b/frontend/src/pages/WhaleMonitorMarketSelect.tsx index 7b00569..1509ec2 100644 --- a/frontend/src/pages/WhaleMonitorMarketSelect.tsx +++ b/frontend/src/pages/WhaleMonitorMarketSelect.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { Button, @@ -23,13 +23,27 @@ import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } fr const { Title, Text } = Typography const TAG_OPTIONS = [ - { key: 'all', value: '' }, { key: 'sports', value: '1' }, { key: 'politics', value: '2' }, { key: 'crypto', value: '21' }, { key: 'popCulture', value: '100639' } ] as const +const MIN_SEARCH_LENGTH = 2 + +interface TagViewCache { + sportSubSeriesId?: string + keyword: string + debouncedKeyword: string + markets: WhaleMonitorMarketItem[] +} + +const buildListCacheKey = ( + tag: string, + seriesId: string | undefined, + searchKeyword: string +): string => `${tag}::${seriesId ?? ''}::${searchKeyword}` + const WhaleMonitorMarketSelect: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() @@ -41,7 +55,7 @@ const WhaleMonitorMarketSelect: React.FC = () => { const [keyword, setKeyword] = useState('') const [debouncedKeyword, setDebouncedKeyword] = useState('') - const [tagId, setTagId] = useState('') + const [tagId, setTagId] = useState(undefined) const [sportSubSeriesId, setSportSubSeriesId] = useState(undefined) const [sportSubCategories, setSportSubCategories] = useState< Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }> @@ -54,6 +68,12 @@ const WhaleMonitorMarketSelect: React.FC = () => { return map }) + const tagViewCacheRef = useRef>(new Map()) + const listCacheRef = useRef>(new Map()) + const skipNextFetchRef = useRef(false) + const initialTagSetRef = useRef(false) + const initialSportLeagueSetRef = useRef(false) + const tagSegmentOptions = useMemo( () => TAG_OPTIONS.map(opt => ({ @@ -68,17 +88,7 @@ const WhaleMonitorMarketSelect: React.FC = () => { return () => clearTimeout(timer) }, [keyword]) - useEffect(() => { - if (tagId === '1') { - fetchSportSubCategories() - setSportSubSeriesId(undefined) - } else { - setSportSubCategories([]) - setSportSubSeriesId(undefined) - } - }, [tagId]) - - const fetchSportSubCategories = async () => { + const fetchSportSubCategories = useCallback(async () => { try { const res = await apiService.markets.sportsCategories() if (res.data.code === 0 && res.data.data) { @@ -91,35 +101,137 @@ const WhaleMonitorMarketSelect: React.FC = () => { } catch { setSportSubCategories([]) } + }, []) + + useEffect(() => { + if (initialTagSetRef.current) return + initialTagSetRef.current = true + setTagId(TAG_OPTIONS[0].value) + }, []) + + useEffect(() => { + if (tagId === '1') { + if (sportSubCategories.length === 0) { + fetchSportSubCategories() + } + } else { + setSportSubCategories([]) + } + }, [tagId, sportSubCategories.length, fetchSportSubCategories]) + + useEffect(() => { + if (tagId !== '1' || sportSubCategories.length === 0) return + if (sportSubSeriesId) return + const tagCached = tagViewCacheRef.current.get('1') + if (tagCached?.sportSubSeriesId) { + setSportSubSeriesId(tagCached.sportSubSeriesId) + return + } + if (!initialSportLeagueSetRef.current) { + initialSportLeagueSetRef.current = true + setSportSubSeriesId(sportSubCategories[0].seriesId) + } + }, [tagId, sportSubCategories, sportSubSeriesId]) + + const saveCurrentTagView = useCallback(() => { + if (!tagId) return + tagViewCacheRef.current.set(tagId, { + sportSubSeriesId, + keyword, + debouncedKeyword, + markets: marketList + }) + listCacheRef.current.set( + buildListCacheKey(tagId, sportSubSeriesId, debouncedKeyword), + marketList + ) + }, [tagId, sportSubSeriesId, keyword, debouncedKeyword, marketList]) + + const handleTagChange = (newTagId: string) => { + if (newTagId === tagId) return + saveCurrentTagView() + + const cached = tagViewCacheRef.current.get(newTagId) + skipNextFetchRef.current = !!cached + + setTagId(newTagId) + + if (cached) { + setSportSubSeriesId(cached.sportSubSeriesId) + setKeyword(cached.keyword) + setDebouncedKeyword(cached.debouncedKeyword) + setMarketList(cached.markets) + if (newTagId === '1' && sportSubCategories.length === 0) { + fetchSportSubCategories() + } + return + } + + setKeyword('') + setDebouncedKeyword('') + setMarketList([]) + if (newTagId === '1') { + setSportSubSeriesId(undefined) + if (sportSubCategories.length === 0) { + fetchSportSubCategories() + } + } else { + setSportSubSeriesId(undefined) + setSportSubCategories([]) + } + } + + const flushSearch = () => { + setDebouncedKeyword(keyword.trim()) } const fetchMarkets = useCallback(async () => { + if (!tagId) return + const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId) const seriesId = sportSubSeriesId const sportSlug = selectedSport?.slug - const effectiveTagId = tagId && tagId !== '1' ? tagId : undefined const searchKeyword = debouncedKeyword + const hasKeyword = searchKeyword.length >= MIN_SEARCH_LENGTH + const categoryTagId = tagId !== '1' ? tagId : undefined + const sportsTagId = tagId === '1' && !seriesId && hasKeyword ? '1' : undefined + const effectiveTagId = categoryTagId || sportsTagId + const listCacheKey = buildListCacheKey(tagId, sportSubSeriesId, searchKeyword) - if (tagId === '1' && !sportSubSeriesId && searchKeyword.length < 2) { + if (tagId === '1' && !sportSubSeriesId && !hasKeyword) { setMarketList([]) return } - if (!seriesId && !effectiveTagId && searchKeyword.length < 2) { + if (!seriesId && !effectiveTagId && !hasKeyword) { setMarketList([]) return } + const listCached = listCacheRef.current.get(listCacheKey) + if (listCached) { + setMarketList(listCached) + return + } + setLoading(true) try { const res = await apiService.markets.search({ - keyword: searchKeyword.length >= 2 ? searchKeyword : '', + keyword: hasKeyword ? searchKeyword : '', seriesId: seriesId || undefined, sportSlug: seriesId ? sportSlug : undefined, tagId: seriesId ? undefined : effectiveTagId, limit: 200 }) if (res.data.code === 0 && res.data.data) { - setMarketList(res.data.data.map(m => toWhaleMonitorMarketItem(m))) + const items = res.data.data.map(m => toWhaleMonitorMarketItem(m)) + listCacheRef.current.set(listCacheKey, items) + setMarketList(items) + tagViewCacheRef.current.set(tagId, { + sportSubSeriesId, + keyword, + debouncedKeyword: searchKeyword, + markets: items + }) } else { setMarketList([]) } @@ -128,12 +240,21 @@ const WhaleMonitorMarketSelect: React.FC = () => { } finally { setLoading(false) } - }, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories]) + }, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories, keyword]) useEffect(() => { + if (skipNextFetchRef.current) { + skipNextFetchRef.current = false + return + } fetchMarkets() }, [fetchMarkets]) + const handleSportLeagueChange = (seriesId: string | undefined) => { + saveCurrentTagView() + setSportSubSeriesId(seriesId) + } + const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap]) const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => { @@ -172,10 +293,14 @@ const WhaleMonitorMarketSelect: React.FC = () => { navigate('/whale-monitor-strategy') } - const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2 + const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < MIN_SEARCH_LENGTH const showSportsGamesEmpty = - tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2 - const showSearchHint = !tagId && debouncedKeyword.length < 2 + tagId === '1' && + !!sportSubSeriesId && + marketList.length === 0 && + !loading && + debouncedKeyword.length < MIN_SEARCH_LENGTH + const showSearchHint = !tagId && debouncedKeyword.length < MIN_SEARCH_LENGTH return (
@@ -202,6 +327,7 @@ const WhaleMonitorMarketSelect: React.FC = () => { placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')} value={keyword} onChange={e => setKeyword(e.target.value)} + onPressEnter={flushSearch} size={isMobile ? 'large' : 'middle'} style={{ marginBottom: 12 }} /> @@ -209,7 +335,7 @@ const WhaleMonitorMarketSelect: React.FC = () => { block={isMobile} options={tagSegmentOptions} value={tagId} - onChange={val => setTagId(val as string)} + onChange={val => handleTagChange(val)} style={{ marginBottom: tagId === '1' ? 12 : 0 }} /> {tagId === '1' && sportSubCategories.length > 0 && ( @@ -220,7 +346,7 @@ const WhaleMonitorMarketSelect: React.FC = () => { placeholder={t('whaleMonitorStrategy.form.sportLeague')} style={{ width: '100%', marginTop: 12 }} value={sportSubSeriesId} - onChange={setSportSubSeriesId} + onChange={handleSportLeagueChange} optionFilterProp="label" size={isMobile ? 'large' : 'middle'} options={sportSubCategories.map(s => ({